The service¶
Every Python client operation is answered by sysml-grpc. With no service
address configured, opensysml starts a private child for the current
interpreter and stops it when the last connection closes or the interpreter
exits. The module-level functions share a lazy connection; explicit
Connection objects are caller-owned.
Binary resolution¶
When it needs to start a service, the client resolves an executable in this order:
$OPENSYSML_BINARY, if set. This explicit path must name an executable; otherwise resolution fails instead of silently using another binary.- The shared cache at
~/.opensysml/bin/sysml-grpc(sysml-grpc.exeon Windows). - A download of the requested release, or the release this client was built against when no version is configured.
- The first executable
sysml-grpcon$PATH.
OPENSYSML_GRPC_VERSION requests another release. An explicit version=
argument to ensure_binary does the same. The service binary is shared across
client languages; the cache's metadata records its release and repository so a
stale cache is not mistaken for the requested release. A hand-installed
executable cache without release metadata is preserved.
The digest pinned in the Python package is authoritative when one is
available; a signed checksum manifest that disagrees with that pin is an
integrity failure. A release of opensysml pins its own core release: the
release pipeline builds the five sysml-grpc-* binaries first, hashes them and
stamps their digests under the release tag into the release-digests.json the
wheel and sdist package, before building them (pin_release_checksums.py
--from-binaries; the job fails if the built wheel lacks any of the five). So
pip install opensysml==X.Y.Z followed by opensysml.connect() verifies the
service it downloads against a digest inside the wheel, with no environment
variable and no sigstore at run time. For another release, or one newer than
the package's digest table, the client verifies the release manifest's
signature with sigstore and uses its digest. If sigstore (or a package it
depends on) is not installed, the download is refused rather than taken
unverified, and the SigstoreUnavailableError names the missing package and
its install, python -m pip install 'sigstore>=4.5.0,<5'. A server-provided
.sha256 sidecar alone is not a trust anchor. If the release
or platform asset is unavailable, a working cache remains in use; otherwise
an executable on $PATH may be used with a warning. If none is available,
ConnectionError names the release tags tried and suggests building the
service, selecting another release, installing it on $PATH, or connecting
to a caller-managed service.
A checksum mismatch or invalid manifest signature is an integrity failure and
is never bypassed by the cache or $PATH fallback. Without a package pin or
a digest from a verifiable signed manifest, the download is refused by
default. OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=<owner/repo> (or =1 for any
repository) opts into trusting a same-origin checksum in that case, with a
warning.
The digest table is embedded in the Python package as
opensysml/release-digests.json, a synced copy of client/release-digests.json.
A pin in the checkout alone would not protect an installed wheel. Cache
metadata records the repository, tag and digest alongside the executable so a
same-tag binary from a different repository or release cannot be mistaken for
the requested one. The cache decision and replacement use a shared file lock;
the executable is started through a digest-named link created while that lock
is held, so another client cannot replace it between resolution and startup.
After a release, the same pinning script (--write) downloads the published
service assets, hashes them, checks that any release .sha256 sidecar agrees
and back-fills the committed table, so later releases pin that one too. The
signed checksum manifest is verified independently. --check re-hashes each
pinned release so a republished asset that differs from its pin is detected.
A binary supplied through $OPENSYSML_BINARY or $PATH is started as found:
it is not copied into the shared cache and is not checked against pinned
release digests. Naming it or installing it on $PATH is the operator's
trust decision. OPENSYSML_GITHUB_REPO changes the release repository from
the default Open-MBEE/OpenSysML.
Service ownership¶
A connection without a service address starts a private child. The child asks the kernel for a free port and reports its bound address on stdout; the client does not choose, probe or retry a port. Connections in one interpreter that need the same service release share one child and its model-parse cache.
Connecting to a service the client did not start is explicit: pass a host and
port, set $OPENSYSML_SERVICE=host:port, or pass auto_start=False to require
a caller-managed service. The client never stops such a service. A managed
service is checked with GetServerInfo against any requested release and
capabilities, but is neither replaced nor stopped to satisfy the check.
No orphans¶
The client holds the write end of the child's stdin pipe and never writes to
it. The child reads stdin and exits at end-of-file, so the operating system
closes the pipe when its owner exits, including on SIGKILL, os._exit, a
fatal interpreter error or a crash during shutdown. On orderly close, the
client closes stdin and signals only the child it started through its
Popen object.
On Linux and macOS, the child has its own process session, so a signal to the
client's process group does not reach it. On Windows, process exit closes the
same anonymous pipe. After fork(), an os.register_at_fork hook closes the
forked process's inherited pipe and disowns the parent's child; the forked
process starts its own service if needed.
Cost of a private child¶
Measured on Linux with client/python/scripts/measure_private_service.py
((n=20)):
| Operation | p50 | p95 |
|---|---|---|
| First connection: spawn, bind, report and handshake | 7.0 ms | 9.1 ms |
| Later connection joining the interpreter's child | 0.6 ms | 1.0 ms |
| Child per connection, rather than one shared | 29.6 ms | 54.6 ms |
| Parsing a model already in the shared child's cache | 0.3 ms | 1.2 ms |
| Same parse in a new child for that connection | 139.8 ms | 269.6 ms |
The shared child avoids repeated startup and parsing. Model instances also
carry their model hash, so calls on a loaded Model do not need to reload the
source merely to address that model.
Latency and real-time behavior¶
The repository benchmark uses an 8-core x86-64 Linux machine, loopback gRPC,
20 part definitions (808 bytes) and 200 iterations with a warm Connection:
| Operation | p50 | p95 | p99 |
|---|---|---|---|
load / load_from_content, cache miss |
35 ms | 56 ms | 60 ms |
load / load_from_content, cache hit |
0.5 ms | 1.0 ms | 1.0 ms |
eval("2 + 2") on a cached model |
0.4 ms | 1.0 ms | 1.2 ms |
convert SysML to SysML |
0.7 ms | 1.2 ms | 2.0 ms |
convert SysML to Turtle |
1.1 ms | 1.4 ms | 1.5 ms |
convert Turtle to SysML |
1.2 ms | 1.5 ms | 2.7 ms |
Reproduce the measurements with:
Parsing costs more than querying a parsed result because parsing loads the
standard library into a fresh symbol index and runs semantic passes. Reuse a
Connection, parse once, query through the returned model, and batch related
requests. The service cache holds 100 models and evicts the least-recently
used; converting reparses its input rather than using the load cache.
This is a request/response service, not a hard real-time engine. Runtime step and time budgets cap execution duration but do not promise a deadline. Latency tails depend on garbage collection, scheduling, TCP and model size. Treat the reported p99 as a soft budget, measure the workload's own model sizes and concurrency, and keep per-sample filtering or windowing in the calling process rather than making one RPC per sample.