Skip to content

Commit 8751be1

Browse files
committed
Broaden the simulated LSP session and register the Path watch types
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.
1 parent 5eb4d01 commit 8751be1

4 files changed

Lines changed: 187 additions & 16 deletions

File tree

adr/20260804-graalvm-native-image-build.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Add a GraalVM 21 `native-image` build producing `nextflow-lsp` for linux-amd64 a
7070

7171
### Extracting reflection metadata
7272

73-
`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: Groovy indy call sites reaching `MethodHandleNatives`, JDK-internal resources with version-specific paths, and the JSSE provider graph loaded reflectively for the plugin-registry HTTPS call. `native-image` merges the two, static config first.
73+
`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.
7474

7575
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.
7676

@@ -88,6 +88,8 @@ The JAR is the only available oracle. Unit tests run on the JVM and say nothing
8888

8989
- **A macOS-specific regression would go unnoticed**, since CI builds Linux only.
9090

91+
- **The plugin registry is not covered.** Resolving `include { ... } from 'plugin/...'` fetches over HTTPS, which brings in `HttpClient` and 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.
92+
9193
## Links
9294

9395
- [GraalVM Native Image](https://www.graalvm.org/latest/reference-manual/native-image/)

native/native.gradle

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ def reflectionGroups = [
7575
'java.util.function.Consumer',
7676
'java.util.function.Function',
7777
'java.util.function.Predicate',
78+
// Groovy configures the java.nio.file.Path class node for the Path shim
79+
// type, which enumerates Path.register(WatchService, Kind[], Modifier...).
80+
// Nothing calls that method, so without these the watch types are absent
81+
// from the image and configuring Path fails with a NoClassDefFoundError
82+
// -- surfacing as an internal error on the first completion request that
83+
// resolves a Path.
84+
'java.nio.file.Path',
85+
'java.nio.file.WatchEvent',
86+
'java.nio.file.WatchEvent$Kind',
87+
'java.nio.file.WatchEvent$Modifier',
88+
'java.nio.file.WatchKey',
89+
'java.nio.file.WatchService',
7890
],
7991
flags: ['allDeclaredFields', 'queryAllDeclaredMethods', 'queryAllDeclaredConstructors'],
8092
],

native/simulate.sh

Lines changed: 171 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
#
3939
# Document Synchronization:
4040
# - textDocument/didOpen : Open a document for editing
41+
# - textDocument/didChange : Apply an incremental edit
4142
# - textDocument/didClose : Close a document
4243
#
4344
# Language Features:
@@ -52,18 +53,27 @@
5253
# - textDocument/documentLink : Get clickable links in document
5354
# - textDocument/rename : Rename a symbol
5455
# - textDocument/prepareCallHierarchy: Prepare call hierarchy
56+
# - callHierarchy/incomingCalls : Resolve callers
57+
# - callHierarchy/outgoingCalls : Resolve callees
5558
#
5659
# Workspace Features:
5760
# - workspace/symbol : Search for symbols across workspace
5861
# - workspace/didChangeConfiguration : Push client settings (this is what
5962
# initializes the language services)
60-
# - workspace/executeCommand : Invoke a server-side command
63+
# - workspace/executeCommand : All four server commands
6164
#
62-
# TEST DOCUMENT:
63-
# The script opens a sample Nextflow script containing:
64-
# - A process definition (FOO) with input/output declarations
65-
# - A workflow block that invokes the process
66-
# This exercises parsing, symbol resolution, and most LSP features.
65+
# Handlers deliberately left out because they only log: didSave,
66+
# didChangeWatchedFiles, didCreateFiles, didDeleteFiles, didRenameFiles,
67+
# $/setTrace. Also left out: didChangeWorkspaceFolders, and plugin includes
68+
# (`from 'plugin/...'`), which would make the session depend on reaching the
69+
# plugin registry over the network.
70+
#
71+
# TEST WORKSPACE:
72+
# - main.nf : a process (FOO) and an entry workflow that calls it
73+
# - nextflow.config : process and docker scopes
74+
# - usage.nf : includes module.nf and calls BAR
75+
# - module.nf : the included process (BAR)
76+
# - lib/Helper.groovy : compiled by the Groovy compiler, not the Nextflow one
6777
#
6878
# NOTE:
6979
# The sleep delays between messages ensure the server has time to process
@@ -142,9 +152,52 @@ docker {
142152
}
143153
EOF
144154

155+
# A second script plus the module it includes. This is the only thing that
156+
# exercises include resolution, cross-file definition, and the document link
157+
# provider -- links come from include statements, so a single-file workspace
158+
# leaves that provider returning nothing.
159+
cat > "$WORKSPACE/module.nf" <<'EOF'
160+
process BAR {
161+
input:
162+
val y
163+
164+
output:
165+
stdout
166+
167+
script:
168+
"""
169+
echo ${y}
170+
"""
171+
}
172+
EOF
173+
174+
cat > "$WORKSPACE/usage.nf" <<'EOF'
175+
include { BAR } from './module.nf'
176+
177+
workflow NAMED {
178+
ch = channel.of(1)
179+
BAR(ch)
180+
}
181+
EOF
182+
183+
# lib/*.groovy is compiled by GroovyLibCache through the Groovy compiler rather
184+
# than the Nextflow parser, which is a distinct code path -- and the one that
185+
# would reach Groovy's AST transformation machinery if anything does.
186+
mkdir -p "$WORKSPACE/lib"
187+
cat > "$WORKSPACE/lib/Helper.groovy" <<'EOF'
188+
class Helper {
189+
190+
static String greet(String name) {
191+
return "hello ${name}"
192+
}
193+
194+
}
195+
EOF
196+
145197
WORKSPACE_URI="file://$WORKSPACE"
146198
DOC_URI="$WORKSPACE_URI/main.nf"
147199
CONFIG_URI="$WORKSPACE_URI/nextflow.config"
200+
USAGE_URI="$WORKSPACE_URI/usage.nf"
148201

149202
# JSON-escape a document so didOpen carries the same text that is on disk
150203
json_text() {
@@ -153,6 +206,16 @@ json_text() {
153206

154207
DOC_TEXT=$(json_text "$WORKSPACE/main.nf")
155208
CONFIG_TEXT=$(json_text "$WORKSPACE/nextflow.config")
209+
USAGE_TEXT=$(json_text "$WORKSPACE/usage.nf")
210+
211+
# The call hierarchy item that prepareCallHierarchy returns for `process FOO`.
212+
# incomingCalls/outgoingCalls take an item back as input, so it has to match
213+
# what the server produced -- these are the ranges of the process definition.
214+
FOO_ITEM='{"name":"FOO","kind":6,"uri":"'"$DOC_URI"'","range":{"start":{"line":0,"character":0},"end":{"line":11,"character":1}},"selectionRange":{"start":{"line":0,"character":0},"end":{"line":11,"character":1}}}'
215+
216+
# The entry workflow, used for outgoingCalls: it calls FOO, whereas FOO itself
217+
# calls nothing, so asking for FOO's callees only ever returns an empty list.
218+
ENTRY_ITEM='{"name":"<entry>","kind":6,"uri":"'"$DOC_URI"'","range":{"start":{"line":13,"character":0},"end":{"line":17,"character":1}},"selectionRange":{"start":{"line":13,"character":0},"end":{"line":17,"character":1}}}'
156219

157220
# =============================================================================
158221
# STEP 1: Initialize Connection
@@ -209,11 +272,24 @@ send_message '{"jsonrpc":"2.0","id":2,"method":"textDocument/hover","params":{"t
209272
sleep 0.5
210273

211274
# =============================================================================
212-
# STEP 5: Completion Request
275+
# STEP 5: Completion Requests
213276
# =============================================================================
214-
# Request code completion suggestions after the `.` in `FOO.out.view()`.
277+
# On `Channel` in `ch = Channel.of(1, 2, 3)`, which resolves the channel type
278+
# and returns ~70 items -- the DSL and value-type class registrations are what
279+
# produce them, so this is the probe most likely to catch a metadata gap.
215280
#
216-
send_message '{"jsonrpc":"2.0","id":3,"method":"textDocument/completion","params":{"textDocument":{"uri":"'"$DOC_URI"'"},"position":{"line":16,"character":12},"context":{"triggerKind":2,"triggerCharacter":"."}}}'
281+
# Member completion on a complete expression (`FOO.out.view()`) returns nothing,
282+
# so do not probe there: it compares empty to empty and proves the two builds
283+
# agree about having no answer.
284+
#
285+
send_message '{"jsonrpc":"2.0","id":3,"method":"textDocument/completion","params":{"textDocument":{"uri":"'"$DOC_URI"'"},"position":{"line":14,"character":9},"context":{"triggerKind":1}}}'
286+
287+
sleep 0.5
288+
289+
# At the top level (blank line 12), which offers declaration keywords and
290+
# snippets instead -- a different branch of the completion provider.
291+
#
292+
send_message '{"jsonrpc":"2.0","id":17,"method":"textDocument/completion","params":{"textDocument":{"uri":"'"$DOC_URI"'"},"position":{"line":12,"character":0},"context":{"triggerKind":1}}}'
217293

218294
sleep 0.5
219295

@@ -317,6 +393,20 @@ send_message '{"jsonrpc":"2.0","id":13,"method":"textDocument/prepareCallHierarc
317393

318394
sleep 0.5
319395

396+
# =============================================================================
397+
# STEP 15b: Resolve the Call Hierarchy
398+
# =============================================================================
399+
# prepareCallHierarchy on its own only locates the item; these two requests are
400+
# what run ScriptCallHierarchyProvider and OutgoingCallsVisitor.
401+
#
402+
send_message '{"jsonrpc":"2.0","id":18,"method":"callHierarchy/incomingCalls","params":{"item":'"$FOO_ITEM"'}}'
403+
404+
sleep 0.5
405+
406+
send_message '{"jsonrpc":"2.0","id":19,"method":"callHierarchy/outgoingCalls","params":{"item":'"$ENTRY_ITEM"'}}'
407+
408+
sleep 0.5
409+
320410
# =============================================================================
321411
# STEP 16: Config File
322412
# =============================================================================
@@ -345,17 +435,84 @@ send_message '{"jsonrpc":"2.0","id":16,"method":"textDocument/completion","param
345435

346436
sleep 0.5
347437

348-
send_message '{"jsonrpc":"2.0","id":17,"method":"textDocument/documentSymbol","params":{"textDocument":{"uri":"'"$CONFIG_URI"'"}}}'
438+
# There is deliberately no documentSymbol probe for the config file: the config
439+
# service has no symbol provider, so it can only ever answer with an empty list.
440+
441+
# =============================================================================
442+
# STEP 17: Execute Commands
443+
# =============================================================================
444+
# All four server commands. The second previewDag argument is the workflow name,
445+
# and `null` means the entry workflow -- which is exactly what the code lens
446+
# returned at id 9. Passing a process name instead matches no workflow, so the
447+
# command returns null and neither DataflowVisitor nor MermaidRenderer runs.
448+
#
449+
send_message '{"jsonrpc":"2.0","id":14,"method":"workspace/executeCommand","params":{"command":"nextflow.server.previewDag","arguments":["'"$DOC_URI"'",null]}}'
450+
451+
sleep 0.5
452+
453+
# previewWorkspace and convertPipelineToTyped take the workspace folder name.
454+
#
455+
send_message '{"jsonrpc":"2.0","id":20,"method":"workspace/executeCommand","params":{"command":"nextflow.server.previewWorkspace","arguments":["main"]}}'
349456

350457
sleep 0.5
351458

459+
send_message '{"jsonrpc":"2.0","id":21,"method":"workspace/executeCommand","params":{"command":"nextflow.server.convertPipelineToTyped","arguments":["main"]}}'
460+
461+
sleep 1
462+
463+
# The convert commands build a WorkspaceEdit and hand it to client.applyEdit,
464+
# so this also drives an outbound server-to-client request.
465+
#
466+
send_message '{"jsonrpc":"2.0","id":22,"method":"workspace/executeCommand","params":{"command":"nextflow.server.convertScriptToTyped","arguments":["'"$DOC_URI"'"]}}'
467+
468+
sleep 1
469+
352470
# =============================================================================
353-
# STEP 17: Execute Command
471+
# STEP 17b: Cross-file Include
354472
# =============================================================================
355-
# Invoke a server-side command. Exercises ExecuteCommandParams, which is
356-
# deserialized on a code path no other message reaches.
473+
# usage.nf includes module.nf, so these two requests cover include resolution:
474+
# the document link points at the included file, and the definition of `BAR`
475+
# resolves into it.
357476
#
358-
send_message '{"jsonrpc":"2.0","id":14,"method":"workspace/executeCommand","params":{"command":"nextflow.server.previewDag","arguments":["'"$DOC_URI"'","FOO"]}}'
477+
send_message '{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"'"$USAGE_URI"'","languageId":"nextflow","version":1,"text":"'"$USAGE_TEXT"'"}}}'
478+
479+
sleep 3
480+
481+
send_message '{"jsonrpc":"2.0","id":23,"method":"textDocument/documentLink","params":{"textDocument":{"uri":"'"$USAGE_URI"'"}}}'
482+
483+
sleep 0.5
484+
485+
send_message '{"jsonrpc":"2.0","id":24,"method":"textDocument/definition","params":{"textDocument":{"uri":"'"$USAGE_URI"'"},"position":{"line":4,"character":5}}}'
486+
487+
sleep 0.5
488+
489+
# Member completion, after the dot in `channel.of(1)`. This is a different
490+
# branch from the two completion probes above, which complete an identifier.
491+
# It must be the lowercase `channel` namespace: the deprecated uppercase
492+
# `Channel`, and channel-typed values such as `ch.` or `FOO.out.`, both return
493+
# nothing. Results are filtered by the prefix under the cursor, so this answers
494+
# with `of` rather than the whole member list.
495+
#
496+
send_message '{"jsonrpc":"2.0","id":26,"method":"textDocument/completion","params":{"textDocument":{"uri":"'"$USAGE_URI"'"},"position":{"line":3,"character":17},"context":{"triggerKind":2,"triggerCharacter":"."}}}'
497+
498+
sleep 0.5
499+
500+
# =============================================================================
501+
# STEP 17c: Edit a Document
502+
# =============================================================================
503+
# Sync kind is Incremental, so this is the message a real editor sends on every
504+
# keystroke, and nothing else here ever mutates a document: it exercises the
505+
# ranged patch, the re-parse, and the re-published diagnostics. The edit stays
506+
# valid, and the documentSymbol below observes the result.
507+
#
508+
# It comes after every other main.nf probe on purpose -- their positions are
509+
# fixed, and this shifts the lines below the insertion point.
510+
#
511+
send_message '{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"'"$DOC_URI"'","version":2},"contentChanges":[{"range":{"start":{"line":16,"character":18},"end":{"line":16,"character":18}},"text":"\n ch.view()"}]}}'
512+
513+
sleep 3
514+
515+
send_message '{"jsonrpc":"2.0","id":25,"method":"textDocument/documentSymbol","params":{"textDocument":{"uri":"'"$DOC_URI"'"}}}'
359516

360517
sleep 0.5
361518

native/verify.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
SIMULATOR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'simulate.sh')
3030

3131
# ids sent by simulate.sh; a missing one means the server never answered
32-
EXPECTED_IDS = set(range(1, 18)) | {99}
32+
EXPECTED_IDS = set(range(1, 27)) | {99}
3333

3434

3535
def run(cmd, workspace):

0 commit comments

Comments
 (0)