Skip to content

Add GraalVM native image compilation support - #146

Open
pditommaso wants to merge 13 commits into
mainfrom
add-native-image-support
Open

Add GraalVM native image compilation support#146
pditommaso wants to merge 13 commits into
mainfrom
add-native-image-support

Conversation

@pditommaso

@pditommaso pditommaso commented Dec 24, 2025

Copy link
Copy Markdown
Member

Summary

Add GraalVM native image compilation to deliver pre-built nlsp binaries for:

  • linux-amd64 - Linux x86_64
  • linux-arm64 - Linux ARM64
  • macos-intel - macOS Intel
  • macos-silicon - macOS Apple Silicon

Build Process

The native image build uses a two-phase approach to handle Groovy's dynamic nature:

  1. Tracing Phase: The lsp-simulator.sh script simulates an LSP client session, sending JSON-RPC messages that exercise all major LSP operations (initialize, hover, completion, definition, references, formatting, etc.). This runs with the GraalVM tracing agent (-agentlib:native-image-agent) to capture reflection, resource, and serialization metadata.

  2. Compilation Phase: The captured metadata is fed to native-image along with manual configuration in conf/native-image/ to produce a self-contained binary.

Changes

  • build.gradle: GraalVM native image plugin with generateNativeImageConfig task that runs the tracing agent
  • lsp-simulator.sh: LSP client simulator covering lifecycle, document sync, and language features
  • build-native.sh: Local build script with requirements checking and testing
  • conf/native-image/: Manual reflection configuration for edge cases
  • .github/workflows/build-native.yml: CI workflow for multi-platform builds

Test plan

  • Verify native builds complete on all 4 platforms
  • Test nlsp binary responds to LSP initialize request
  • Validate artifacts are uploaded correctly

🤖 Generated with Claude Code

pditommaso and others added 11 commits August 5, 2026 11:08
Add a native-image build for the language server binary, covering
linux-amd64, linux-arm64, macos-intel and macos-silicon.

The build runs the tracing agent against a simulated LSP session to
capture reflection metadata, then feeds it to native-image along with
manual configuration in conf/native-image/.
- Register conf/native-image as a nativeCompile input, so editing the
  reflection config actually triggers a rebuild instead of reporting
  UP-TO-DATE and shipping the previous binary.

- Drop the graalvmNative agent block. It only takes effect with -Pagent,
  and generateNativeImageConfig does the tracing instead.

- Make the simulator send workspace/didChangeConfiguration. This is what
  initializes the language services, so without it every language feature
  returned an empty result and the tracing agent observed almost nothing
  but lsp4j plumbing. The resulting binary answered "initialize" but was
  dead for real use: it could not deserialize DidChangeConfigurationParams
  or ExecuteCommandParams, and reported false errors such as
  "Unrecognized process input qualifier val".

- Also send workspace/executeCommand, use a real temp workspace instead of
  hardcoded /Users paths, and wait for outstanding async requests before
  shutdown -- responses arrive out of order and "exit" was truncating them.

- Strengthen the binary test. Checking only for an "initialize" response
  passes on a binary whose language features are all broken; also check for
  missing-metadata errors and for a non-empty documentSymbol result.

- Fix the release job: it collected *.tar.gz while the build uploaded a
  bare binary, and read the version from refs/tags while the workflow
  ignored tags. Package the binary as a tarball and trigger the release on
  v* tags.

- Limit push builds to main and tags, so a PR no longer builds four native
  images twice. Add timeout-minutes, and use the free macos-latest runner
  for Apple Silicon instead of the billed macos-latest-xlarge.

- Rename the binary from nlsp to nextflow-lsp.
The reflection metadata was produced only by tracing a simulated LSP session,
which records what that one session happened to touch. Anything it missed
shipped a binary that failed at runtime, and failed silently -- the
--report-unsupported-elements-at-runtime flag Groovy needs turns what would be
build errors into runtime no-ops.

Register the class sets that are reflected over wholesale instead, generating
reflect/resource/proxy config from the resolved classpath in
generateNativeImageMetadata. Tracing is kept for the tail that genuinely cannot
be enumerated (Groovy indy call sites, JDK internals), but is no longer the only
source. 629 classes are now registered statically versus 270 observed by tracing.

Flags are per group rather than uniform, because cost here is reachability, not
entry count: lsp4j's 538 message types need fields and the no-arg constructor
that Gson calls, not their accessors, while the Nextflow DSL and value-type
classes do need allDeclaredMethods -- Groovy's Java8.configureAnnotation invokes
the annotation accessors to read @Description/@Constant/@ops members.

Newly covered, none of which the traced session reached:
- nextflow.script.types.** including the .shim value types and their
  package-private *Ops interfaces. These back completion and hover on
  String/List/Map/Path; tracing had registered 1 of 35.
- nextflow.config.spec.**, which is the package the code actually imports.
  The prefix list previously named nextflow.config.schema -- a near-identical
  decoy package that nothing references.
- nextflow.script.namespaces.** and nextflow.config.dsl.ConfigDsl.
- spec/definitions.json. ConfigSpecFactory is the only classpath-resource read
  in the server and it only runs for .config files, which no session opened.
- The LanguageClient/Endpoint proxy pair, previously supplied only by tracing.
  Dropping the traced config made the binary die on startup with
  MissingReflectionRegistrationError.

Add verify-native.py, which runs the same LSP session against the jar and the
binary and diffs the responses. No self-contained assertion can catch this class
of bug: with the DSL classes unregistered the server reported "Unrecognized
process input qualifier val" on a valid script while every response stayed
well-formed and non-empty. The JVM build is the only available oracle. Verified
in both directions -- it passes on a correct binary and fails on a deliberately
under-registered one. It replaces the ad-hoc grep checks in build-native.sh,
which it subsumes.

Extend the simulator to open a nextflow.config as well, so both the tracing
agent and the comparison exercise the config service and the definitions.json
resource. LSP_SIM_WORKSPACE lets the comparison pin the workspace, since
document URIs appear in the responses.

Declare the generated config directories as nativeCompile inputs. dependsOn
alone orders the tasks without invalidating the build, so a metadata change
reported UP-TO-DATE and silently reused the previous binary.

The config completion probe targets the docker scope, not process: the server's
overload resolution for process directives is nondeterministic across runs --
arch alternates between String and Map, as do accelerator, clusterOptions and
pod -- which would make the comparison flaky for reasons unrelated to the
native image.

Binary grows from 74MB to 78MB. Startup is unchanged at ~10ms versus ~400ms for
the JVM build.
Records the decision to distribute prebuilt nextflow-lsp binaries alongside
the JAR: the options considered, how the reflection metadata is obtained and
why it is derived from the classpath rather than traced, how the binary is
verified against the JAR, the platform matrix and its CI cost, release
packaging, and the risks accepted -- notably that the build depends on a
GraalVM flag that is deprecated upstream.

Also adds the ADR template the directory was created with.
Consolidate the three helper scripts as native/build.sh, native/simulate.sh
and native/verify.py.

Remove the release job. It could not have worked -- it collected *.tar.gz while
the build uploaded a bare binary, and read the version from refs/tags/ while the
workflow ignored tags -- and attaching native binaries to releases is a
distribution decision better taken on its own, once something consumes them. The
v* tag trigger went with it, since it existed only to fire that job. Keep the tar
step: upload-artifact does not preserve the executable bit, so an uploaded bare
binary is not runnable when downloaded.

Drop the macOS matrix entries. There is no free Intel macOS runner since
macos-13 was retired, so macos-intel could only run on the per-minute
macos-latest-large, and Apple Silicon adds a second build. Paying for two macOS
builds per commit is not justified while nothing consumes the binary.
native/build.sh still builds on macOS locally.
Signed-off-by: Ben Sherman <bentshermann@gmail.com>
Signed-off-by: Ben Sherman <bentshermann@gmail.com>
instructions

Signed-off-by: Ben Sherman <bentshermann@gmail.com>
Signed-off-by: Ben Sherman <bentshermann@gmail.com>
Signed-off-by: Ben Sherman <bentshermann@gmail.com>
The session that drives both the tracing agent and the JVM-vs-native
comparison left several code paths untouched, and four of its probes
compared an empty result against an empty result:

- completion on main.nf returned nothing, because member completion on a
  complete expression has no answer to give
- previewDag was passed a process name where the second argument is a
  workflow name, so it returned null and neither DataflowVisitor nor
  MermaidRenderer ever ran
- documentLink returned nothing, because links come from include
  statements and the workspace had a single file
- documentSymbol on the config file can only ever return nothing, as the
  config service has no symbol provider

So: point completion at positions that resolve (an identifier, the top
level, and a member of the lowercase channel namespace), pass null to
previewDag for the entry workflow, add a module that usage.nf includes,
and drop the config documentSymbol probe. Also cover the handlers that
were never exercised: didChange, incoming/outgoingCalls, and the three
remaining commands. The comparison goes from 27 messages to 42.

That immediately failed, which is the point of it:

  NoClassDefFoundError: Unable to configure java.nio.file.Path due to
  missing dependency java.nio.file.WatchEvent$Modifier

Groovy configures the Path class node for the Path shim type and
enumerates its methods, one of which is register(WatchService, Kind[],
Modifier...). Nothing calls it, so native-image left the watch types out
of the image entirely and the first completion request that resolved a
Path failed with an internal error. Registering that closure fixes it.

Also correct the ADR: tracing contributes the reflective entries behind
Groovy's indy call sites and five META-INF/services files, not the JSSE
provider graph or JDK icu resources -- no config directory registers
anything for those. Since nothing exercises the plugin registry, the
HTTPS path it needs is covered by neither source, which is now recorded
as a residual risk.
@bentsherman
bentsherman force-pushed the add-native-image-support branch from 8751be1 to f07b2ed Compare August 5, 2026 16:15
Signed-off-by: Ben Sherman <bentshermann@gmail.com>
Signed-off-by: Ben Sherman <bentshermann@gmail.com>
@bentsherman

Copy link
Copy Markdown
Member

I took some time to clean up the PR:

  • ADR for native build
  • isolate everything under native directory (including gradle build)
  • add verify.py to verify native build against JAR
  • expand tracing and test coverage

I went ahead and updated the vs code extension to use the native binary (nextflow-lsp) if it is available. Tried the native build with nf-core/rnaseq and everything works on a basic level

I still need to figure out the distribution mechanism:

  • publish native binary alongside JAR in every release
  • vs code extension should download and cache native binary the same way as JAR, maybe with an extension setting

I'm still thinking about the simulate/verify scripts and whether/how they can be improved. Will likely do a few more iterations on this before merging

I need to focus on other things now, but the native build is much closer to being mergeable

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GraalVM native image

2 participants