- Authors: Ben Sherman
- Status: proposed
- Date: 2026-08-04
Build the language server as a GraalVM Native Image, which reduces startup time from ~400 ms to ~10 ms and eliminates the JRE dependency.
A language server is launched once per editor session, and again after every crash or manual restart. The editor has no Nextflow features until it answers initialize. Measured locally on Linux x86_64, the JAR takes ~400 ms to answer initialize, while a native image takes ~10 ms. The JAR also requires a compatible JRE, which the user must provide.
Compiling this particular application ahead of time is not routine, because much of what it does is dynamic:
- Gson deserializes every LSP message type reflectively.
- Groovy's
Java8.configureClassNodereadsgetDeclaredFields/getDeclaredMethodson the Nextflow DSL and value-type classes to build theClassNodes that back completion, hover, and type checking. - Groovy's
invokedynamiccall sites reachMethodHandleNativesmethods thatnative-imagecannot compile at all.
So the decision is not only "should we ship a binary" but "can we produce a binary that behaves identically to the JAR, and how would we know". The second half is where the risk concentrates: a native image missing reflection metadata does not fail to build and does not crash. It answers initialize and then returns empty — or plausible but wrong — results.
- Startup latency. The user-visible cost of launching the server should be negligible.
- No JRE prerequisite for consumers of the binary.
- Behavioral equivalence with the JAR. A binary that differs from the JAR is worse than no binary, because the difference is invisible to whoever ships it.
- No silent failure. A metadata gap must fail the build or the test, not degrade the binary in production.
- Replacing the JAR. It remains the primary artifact; the native build is an optional alternative.
- Minimizing binary size. 78 MB is accepted.
- Runtime performance tuning.
native-imagerecommends G1GC, PGO and-march=native; none are applied, since startup was the objective and steady-state throughput is already adequate.
- Good, because it is one artifact for all platforms and needs no per-platform CI.
- Good, because there is one execution model, so the existing tests remain representative.
- Bad, because ~400 ms of startup is paid on every session and every restart.
- Bad, because it makes the client responsible for finding a JRE.
- Good, because it removes essentially all startup cost (~400 ms → ~10 ms) and the JRE dependency.
- Good, because a single self-contained file is the simplest thing for a client to launch.
- Bad, because correctness now depends on reflection metadata declared ahead of time, and the failure mode is silent.
- Bad, because it depends on
--report-unsupported-elements-at-runtime, which is deprecated upstream (see Residual risks). - Bad, because the binary is 78 MB against a 15 MB JAR, per platform.
- Good, because it keeps one execution model and raises no reflection metadata problem.
- Bad, because it reduces JVM startup rather than removing it — not the order-of-magnitude change we want. Not benchmarked here, so this is a judgment rather than a measurement.
- Bad, because CRaC needs a specific JDK and a checkpoint step, and still requires a runtime on the user's machine.
- Good, because it removes the JRE prerequisite, with behavior identical to the JAR by construction.
- Bad, because startup remains JVM startup.
- Bad, because it produces a per-platform bundle anyway, so the CI cost is comparable without the latency benefit.
Add a GraalVM 21 native-image build producing nextflow-lsp for linux-amd64 and linux-arm64. Derive the reflection metadata from the resolved classpath rather than a traced session. Verify the build with an equivalent check against the JAR baseline.
generateNativeImageMetadata scans the resolved runtime classpath and emits reflect-config.json, resource-config.json and proxy-config.json, registering ~630 classes. Registration flags are set per group rather than uniformly, since image size is driven by reachability; the per-group reasoning lives in comments in build.gradle. The tracing agent remains a secondary source, for the tail that resists enumeration: the reflective entries behind Groovy's indy call sites, and the META-INF/services files that the JDK and Groovy load through ServiceLoader. native-image merges the two, static config first.
A scan is exhaustive where a traced session is accidental. The reflected-over sets are closed — Gson reflects over every type in org.eclipse.lsp4j, and Groovy's configureClassNode over every class handed to ClassHelper.makeCached — and those are properties of the jars, so scanning enumerates them completely and picks up dependency upgrades automatically. Traced coverage is instead a function of the script: the session used here reaches 2 of the 35 nextflow.script.types.** classes, and whatever it misses is not reported at build time, because the --report-unsupported-elements-at-runtime that Groovy's indy call sites force turns what would be build errors into runtime no-ops.
native/verify.py runs one scripted LSP session (native/simulate.sh) against the JAR and the binary and diffs the responses order-insensitively; native/build.sh fails if they differ, in CI and locally.
The JAR is the only available oracle. Unit tests run on the JVM and say nothing about the binary, and no self-contained assertion separates a correct response from a plausible wrong one, which is the failure mode a metadata gap produces. Differential testing also needs no expected values, so it stays valid as the language evolves. The gate was validated in both directions: it passes on a correct binary and fails on a deliberately under-registered one, which is what makes "no silent failure" a property of the build rather than an aspiration.
-
--report-unsupported-elements-at-runtimeis load-bearing and deprecated. Removing it fails the build withDeletedElementExceptiononMethodHandleNatives.setCallSiteTargetNormal, reached from Groovy's indy call sites. A future GraalVM that drops the flag will need a different answer; this is the largest strategic risk to the approach. -
The registered package set is a judgment call. It derives from call sites we read, so a future feature reflecting over a new package will not be registered automatically. The mitigation is
native/verify.py: such a gap surfaces as a response divergence rather than as a user report. -
A macOS-specific regression would go unnoticed, since CI builds Linux only.
-
The plugin registry is not covered. Resolving
include { ... } from 'plugin/...'fetches over HTTPS, which brings inHttpClientand the JSSE provider graph — reflection-heavy territory, and no config directory registers anything for it today. The session deliberately stays offline, so this would first fail for a user rather than in CI.