Java client for OpenSysML: parse, inspect and evaluate SysML v2 models over the
sysml-grpc service, from inside a JVM host application it does not own — an
Eclipse-based tool, a Cameo plugin, a web service.
<dependency>
<groupId>org.openmbee</groupId>
<artifactId>opensysml-client</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>Nothing is published yet. Build and install it into the local repository from a checkout:
make build # bin/sysml-grpc, which the tests start
mvn -f clients/java/pom.xml install # sources and javadoc jars includedtry (Connection connection = Connection.open()) { // starts a private sysml-grpc
Model model = connection.load(Path.of("model.sysml"));
Value sum = model.eval("1 + 2 * 3"); // Value.IntegerValue[value=7]
Value mass = model.evalWithSubject("mass", "Demo::sedan");
Symbol vehicle = model.symbol("Demo::Vehicle"); // findSymbol returns Optional
Instantiation built = model.instantiate("Demo::Vehicle");
connection.capabilities().require(Capabilities.FEATURE_VALUES);
}Every value the API answers with is immutable: Value is a sealed interface over
records (IntegerValue, RealValue, ComplexValue, QuantityValue, ArrayValue,
VectorValue, VectorQuantityValue, MeasurementRefValue, FunctionValue, MetaobjectValue,
EnumerationValue, InstanceReference, Sequence, NullValue, UnsetValue, …), and Symbol,
Diagnostic, Instance and Instantiation are records with copied collections.
No generated protobuf message or builder appears in the public API. A Diagnostic
is (severity, message, code, span); code() is the identifier to branch on
("syntax", a validation code such as "unresolved", "choice-point",
"guard-unevaluable"; "" when the service assigned none), message() is for reading. A
service that populates code advertises Capabilities.DIAGNOSTIC_CODES; without it every
code is "".
Everything the client throws is unchecked and descends from OpenSysMLException.
A host application handling a model is not helped by checked exceptions on every
call, and AutoCloseable's close() here throws nothing.
| exception | what happened |
|---|---|
ServiceException |
the call was refused, with a StatusCode (NOT_FOUND, …) |
ModelException |
the call succeeded and the answer reports a model failure |
TransportException |
HTTP or IO failure; the service was not reached or answered. UNAVAILABLE, except DEADLINE_EXCEEDED for a call that outlived its requestTimeout |
CapabilityException |
the service does not advertise a capability the call needs |
ServiceStartException |
no binary, a digest mismatch, or a child that would not start |
ChecksumMismatchException |
a binary's bytes are not the digest required of them (the service binary) |
The ServiceException/ModelException split is the one the conformance suite
draws too: an expression that will not evaluate is a successful call carrying an
error, not a service problem.
Maven, because a consumer of this ecosystem expects a POM: Eclipse tooling, Cameo
plugin builds and mvn dependency:tree all read one, and a Gradle consumer reads
the published POM as well.
maven.compiler.release is 17, not the 21 this repository's environment has.
17 is the lowest baseline a realistic host can offer: Eclipse 2023-03 and later
require 17, so does IntelliJ 2023.2+, and Spring Boot 3 requires it. The client
uses records, sealed interfaces, switch patterns and text blocks — all 17 — and
nothing from 21, so 21 would exclude hosts for no gain.
The compile-scope dependency is protobuf-java. Nothing else, by default:
| jar | size | when |
|---|---|---|
protobuf-java 4.33.1 |
1.8 M | always: the generated messages need it |
protobuf-java-util 4.33.1 |
76 K | only Encoding.JSON, declared optional |
gson 2.11.0 |
291 K | only Encoding.JSON, behind the above |
There is no gRPC, no Netty and no tcnative. The transport is
java.net.http.HttpClient from the JDK, speaking the Connect protocol: unary
POST /sysml.SysMLService/<Method> with an application/proto body and
Connect-Protocol-Version: 1. That decision is about the host application, not
about elegance:
grpc-javabringsgrpc-netty(-shaded),guava,perfmarkand optionallynetty-tcnative-boringssl-static. Inside an OSGi/Eclipse runtime or a Spring application that already has its own Netty, that is the classic shading and classloader conflict, and a client is a bad reason to inflict it.connect-kotlinwould add the Kotlin stdlib and OkHttp for a Java consumer.- The service serves gRPC, gRPC-Web and Connect on one port, so choosing Connect costs no functionality: the same port, the same protobuf bodies.
Only the client's own transport is affected. A host that already uses grpc-java
for something else keeps it; nothing here conflicts with it.
Encoding.PROTOBUF is the default. docs/internals/design/transport-evaluation.md
measured a 468 KB Query answer at ~6.5 ms with protobuf against ~42 ms with
JSON — that is JSON parsing cost, not bytes on the wire. Encoding.JSON exists
so an answer can be compared against curl, and it needs the optional
protobuf-java-util; the conformance suite runs over both.
A connection uses a service of its own and never stops one it did not start.
Connection.open()starts a private child:sysml-grpc -port 0 -health-port 0 -report-address -exit-with-parent. The kernel assigns the port and the child prints the address it was given on its first stdout line, so no port is chosen, probed or retried.- One child per classloader. The registry holding it is static, so the copy of the client an Eclipse plugin loaded, the copy a web application loaded and a copy shaded inside a third library each own one child, while every connection made through one copy shares a child — and therefore its parse cache, which is what makes a second connection and a repeat parse cheap. Per-instance would spawn a service per connection and reparse every model; a JVM-wide singleton would put one tenant's models in another tenant's cache and would outlive an undeployed application.
- A host that must not share a cache across tenants passes
ConnectionOptions.builder().isolatedService(true), which starts a child for that connection alone and stops it when that connection closes. - A private child stops when the last connection holding it closes.
Connection.close()is idempotent, andConnection.stopSharedServices()stops what this classloader still owns — call it from a plugin'sstop()or aServletContextListener, since unloading a classloader does not by itself stop a child (below). - Reaching a service the client did not start is explicit:
service(host, port),$OPENSYSML_SERVICE=host:port, orautoStart(false)to require one. Closing such a connection leaves it running, always.
The client holds the write end of the child's stdin pipe and never writes to
it; the child exits at end of file. Nothing else holds that write end, so the
kernel closes it when the owning JVM goes away — which is what survives
SIGKILL, Runtime.halt, an OutOfMemoryError during shutdown and a JVM crash.
ProcessHandle.onExit() and shutdown hooks do not. On an orderly close the
client closes stdin itself and then destroys the process it started, so exit is
prompt rather than eventual, and it only ever signals the Process object of a
child it started — never a pid read from disk.
OrphanSafetyTest proves it: a child JVM opens a connection, prints the service
pid, and is killed with kill -9; the test then waits for that pid to be gone.
Per platform:
- Linux, macOS: as above. The JVM does not leak the write end into other
children, since
ProcessBuilderdoes not pass a parent's pipe endpoints on. - Windows: the same anonymous pipe is closed by the OS when the owning
process exits however it exits, so the guarantee is unchanged;
taskkill /Fis thekill -9of the test. The-exit-with-parentflag adds a job-object tie on the service side. - A classloader that is unloaded: the child is not tied to the classloader,
so a host that undeploys an application without closing its connections would
leave a service running until its JVM exits.
ClassLoaderTestloads three isolated copies of the client, checks each owns a different child, checks that closing the connections leaves none, and checks the copies are collectable. Nothing the client starts is a non-daemon thread, so an unloaded copy holds no thread either: the HTTP executor and the child's output pumps are daemon threads (opensysml-http-*,opensysml-service-*). Collection is not instant, though — on 17 the JDK's ownHttpClient-N-SelectorManagerthread outlives a closed client until the client is collected, and pins the loader meanwhile.
Connection and Model are safe for concurrent use by many threads: a
connection holds one HttpClient, the shared-service registry is guarded by a
single lock, and close() is a compare-and-set. LifecycleTest opens
connections from eight threads at once and asserts one child was started and the
reference count reaches zero. Value types are immutable and therefore shareable.
The client resolves an installed binary, in order:
ConnectionOptions.binaryPath(...);$OPENSYSML_GRPC_BINARY;~/.opensysml/bin/sysml-grpc(.exeon Windows) — the cache it shares with the Python client;PATH.
expectedBinarySha256("<hex>") verifies the file's digest before it is executed
and refuses it otherwise, whichever binary is resolved and whether it was
downloaded or installed. An external service needs no binary at all.
Ask for a release and the client downloads it into that shared cache:
ConnectionOptions.builder().downloadVersion("v0.3.0").build(); // or "latest"The version is the caller's, else $OPENSYSML_GRPC_VERSION, else nothing —
no version, no download: without one the client only resolves what is
already there, so it never fetches a binary a caller did not ask for. latest
is resolved through the GitHub releases API. The repository is
Open-MBEE/OpenSysML, overridable with ConnectionOptions.githubRepo(...) or
$OPENSYSML_GITHUB_REPO. Every request times out after 15 seconds, and a
response body that stops arriving for that long is abandoned too, so a release
of any size may be downloaded but a hung origin never hangs a connection.
The release asset for the running platform (sysml-grpc-linux-amd64,
-linux-arm64, -darwin-amd64, -darwin-arm64, -windows-amd64.exe; any
other pair fails naming itself) is downloaded to a temporary file, verified, and
only then moved over the cached path, chmod 0700 where the filesystem keeps
POSIX modes. A download that fails or does not verify leaves the cached binary
untouched and removes the temporary file. A response is read to a bound — 512
MiB for a binary, 8 MiB for a checksum, manifest, bundle or release listing —
so an origin cannot answer a download with an endless body. Deciding whether the
cache is the release asked for and replacing it when it is not is done holding
~/.opensysml/bin/sysml-grpc.lock, so concurrent connections — in this JVM, in
another, or in the Python or Rust clients, which take the same lock over the same
span — do not install over each other.
What a service is started from is not the shared path but a hard link to it
named for its digest, ~/.opensysml/bin/sysml-grpc-<first 16 hex>, made while
the lock is held. A release installed over the cache afterwards is therefore not
the one a connection already resolved runs; a filesystem without hard links gets
a copy, and one that gives neither falls back to the shared path with a warning.
The cache is read and written exactly as the Python client does, so the two
share one binary: ~/.opensysml/bin/sysml-grpc.json beside it records
{"version": ..., "sha256": ..., "repo": ...}. A cached binary of another
release, or one from another repository, is replaced with a warning rather than
silently used, and its digest is re-checked so a binary swapped in by hand is
not read as the release it displaced. A release that cannot be reached, or that
nothing vouches for, is no reason to lose a working binary: the installed one is
kept with a warning that it may be older than the release asked for. A download
that contradicts a pin or a signature is never answered from the cache — it
fails.
In order, and each step is a refusal rather than a fallback:
- A pinned digest.
release-digests.json— this jar's synced copy ofclients/release-digests.json, loaded from the classpath — pins a SHA-256 per (repository, release, asset). Where a pin exists it is what the bytes must hash to, and a served.sha256that disagrees with it is a release republished with another binary: the download is refused. - The signed checksum manifest. For a release nothing is pinned for, the
client downloads
SHA256SUMS.txtand its sigstore bundleSHA256SUMS.txt.bundleand verifies the bundle withdev.sigstore:sigstore-javaagainst the manifest's digest, requiring the release pipeline's identity — CircleCI OIDC issuerhttps://oidc.circleci.com/org/1169df8b-0b59-400f-82d2-c9d8e98bdb62and a pipeline definition of projecthttps://circleci.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d. The asset's digest then comes from that verified manifest. A bundle that does not verify, another signer, an expired certificate or a manifest changed after signing is refused — never a quiet fall back to the served checksum. - Nothing. With no pin and no signature, the download is refused, naming the version, because the only checksum left is the one served beside the binary, which a compromised release would serve too.
The last step is the one opt-out: $OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=1, or set
to a comma-separated list naming owner/repo, accepts that served .sha256
with a warning. That is same-origin trust — it detects a corrupted transfer,
and nothing at all about a compromised release origin.
Excluding the dev.sigstore:sigstore-java dependency does not weaken any of
this: without it nothing verifies, which is refused exactly as an unsigned
release is, so only pinned releases install.
- A release published after this client's
release-digests.jsonwas synced is installed on its signature, so a jar built withsigstore-javaexcluded needs a client whose table pins that release. latestis one unauthenticated call toapi.github.com, so a rate-limited host should name the version instead.- The cache is one path per user, so two applications asking for different
releases replace one another's cached binary — each still runs the release it
asked for, from the digest-named link, but the next start re-downloads. Name
the binary with
ConnectionOptions.binaryPath()where that matters. - The digest-named links are never collected: a cache that has held many
releases keeps a link per release until
~/.opensysml/binis cleared.
Connection.open calls GetServerInfo once and keeps what it reported.
connection.capabilities().require(Capabilities.EVALUATE_SUBJECT) throws
CapabilityException when a capability is absent. Negotiation is on the
advertised names, never on the version string. The service does refuse a
request that needs a capability it lacks — UNIMPLEMENTED, naming the
capability, which arrives here as a ServiceException — but a capability that
only describes how a response is populated omits its fields instead, so a call
that relied on failure alone would silently read an answer computed without
them. That is why Model.evalWithSubject checks before it calls: a
CapabilityException names the missing capability and the service that lacks
it, before a round trip.
Deliberately out of scope, rather than half-implemented:
- the edit API (
ApplyEdits) — authoring notation from Java; - RDF conversion (
Convert) — Turtle/RDF export; - verification helpers (
VerifyConstraint,VerifyRequirement,VerifySatisfaction), behaviour execution (ExecuteAction,ExecuteState),EvaluateCalc,RunAnalysisandQuery/OSLC; - generated model-ergonomics types — no code generation from a model into Java classes.
The service still serves all of them; reach them from another client, or from the
generated stubs in org.openmbee.opensysml.proto with curl, until a v2 wraps them.
org.openmbee.opensysml.proto is committed, generated by buf from a plugin entry in the
root buf.gen.yaml with the version pinned inline, and regenerated by
make proto — no Maven plugin calls protoc and nothing here is hand-written.
Only the message classes are generated: the Connect protocol needs no service
stubs, so grpc-java never enters the build.
The runner in opensysml-conformance reads conformance/scenarios/*.json and
conformance/fixtures/, makes each call through the public API and compares
what the client read out of the answer, by the rules in conformance/README.md.
It writes the report shape cmd/conformance writes:
make build
mvn -f clients/java/pom.xml install -DskipTests
mvn -f clients/java/pom.xml -pl opensysml-conformance -q \
dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt
java -cp "clients/java/opensysml-conformance/target/classes:$(cat /tmp/cp.txt)" \
org.openmbee.opensysml.conformance.Main -binary bin/sysml-grpc -allow-skips \
-protocols connect,connect-json -report bin/conformance-report-java.json-run <regexp> selects scenarios by id, -service host:port runs against a
service the runner did not start, -mutate is below, and without -allow-skips
a skipped scenario is an exit code, so a shrinking API surface cannot go
unnoticed.
Or as a test, which is what CI runs: mvn -f clients/java/pom.xml test.
Per protocol, of 59 scenarios:
| protocol | ran | passed | failed | skipped |
|---|---|---|---|---|
connect |
25 | 25 | 0 | 34 |
connect-json |
25 | 25 | 0 | 34 |
34 skipped, and they are the scenarios of the RPCs v1 does not cover:
ExecuteAction (3), ExecuteState (2), Convert (5), ApplyEdits (5),
VerifyConstraint (4), VerifyRequirement (2), VerifySatisfaction (2),
EvaluateCalc (2), Query (8) — 33 — plus
parse/naming_no_source_is_invalid, which asserts that a request naming no
source at all is refused: the public API always names one, so the client cannot
send that request. gRPC is not run at all: this client does not speak it.
The runner is not vacuous. -mutate corrupts every answer before it is compared,
and SuiteTest.aCorruptedAnswerIsCaught asserts each corruption is caught:
-mutate |
what it does to every answer | scenarios that fail |
|---|---|---|
perturb-reals |
moves each real by a millionth | 4 |
truncate-lists |
drops the last repeated element | 7 |
rewrite-strings |
replaces each string | 13 |
make build # bin/sysml-grpc; tests skip without it
mvn -f clients/java/pom.xml test # 119 client tests, 27 conformance tests
mvn -f clients/java/pom.xml test -Dopensysml.requireService=true # CI: absence failsNothing has been published. The build produces a correct, signable artifact
(sources and javadoc jars, complete POM metadata, a release profile that signs
with GPG and stages to Sonatype Central with autoPublish=false), and
mvn install works today. What a maintainer must obtain first — a verified
org.openmbee namespace, a published GPG key, and Central portal
tokens — is in
docs/project/releasing.md.