Force the Nextflow server's deferred workspace scan before finding references (#1832)

The Nextflow language server does not scan the workspace when it says it does.
`LanguageService.initialize` -- which the "Initializing" progress notification we
wait for wraps -- only marks the workspace as unscanned and clears the AST cache.
The scan itself happens in `LanguageService.update0`, behind a 1s debounce, and
only on a round that finds no pending file change; a round that does find one
re-defers it via `updateLater`. The first `didOpen` of a session therefore pushes
the scan out by at least one further round, so a references request issued right
after it sees an AST cache holding nothing but the file it just opened and answers
with an empty list.

Force the scan rather than waiting for it. `completion` is one of the two requests
that call `updateNow` before consulting their provider, and
`DebouncingExecutor.executeNow` cancels the pending debounce and runs the update
synchronously on the request thread, so the response is not sent until that round
has finished. Two rounds suffice whatever the workspace size: the first drains the
pending file changes -- which is what re-defers the scan -- and the second finds
nothing pending, so it compiles the whole workspace before replying.

Polling the workspace symbol index instead would be unsound: `LanguageService.symbol`
neither awaits the update nor holds the monitor that `update` synchronises on. On a
generated 1050-file workspace, polling it made the server raise
`java.util.ConcurrentModificationException` from inside the scan, non-deterministically
(6, 3 and 0 times across three runs).

The existing cross-file reference test did not catch the empty results because the
`language_server` fixture is module-scoped: earlier tests in the class had already
opened main.nf and forced the server to compile it, so the assertion passed for a
reason that does not hold in a real session. Run alone, it failed. The added test
starts its own server and opens nothing but the defining file.

Verified on the 1050-file workspace, where the symbol has exactly 100 references:
without the fix 0 are found, with it all 100, in 2.5s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Evangelos KaratzasandClaude Opus 5 authored and GitHub committed 2026-08-09 13:24:43 +02:00
1 parent 430fc62e72
commit 8ebb247dae
2 files changed
+53 -2

No files matched your search

@@ -88,6 +88,10 @@ class NextflowLanguageServer(SolidLanguageServer):
self._active_progress_tokens: set[str] = set()
self._progress_lock = threading.Lock()
# Whether the server's deferred workspace scan has been observed to complete
# (see _flush_deferred_workspace_scan).
self._workspace_scan_flushed = False
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
@@ -297,6 +301,32 @@ class NextflowLanguageServer(SolidLanguageServer):
else:
log.warning("Nextflow LS did not signal scan completion within %.0fs; proceeding anyway", _WORKSPACE_SCAN_TIMEOUT)
def _flush_deferred_workspace_scan(self, relative_file_path: str) -> None:
"""
Forces the workspace scan, which the server defers past the first request of a session.
The scan runs in ``LanguageService.update0`` behind a 1s debounce, and only on a round with no
pending file change; the session's first ``didOpen`` re-defers it, leaving references to be
answered from an AST cache holding just that one file.
``completion`` calls ``updateNow``, which runs the update synchronously before replying, so two
such requests force the scan whatever the workspace size: the first drains the pending change,
the second finds none and scans. Their results are discarded.
"""
if self._workspace_scan_flushed:
return
params: lsp_types.CompletionParams = {
"textDocument": {"uri": self._resolve_file_uri(relative_file_path)},
"position": {"line": 0, "character": 0},
}
for _ in range(2):
try:
self.server.send.completion(params)
except Exception as e:
log.debug("Completion request used to flush the Nextflow workspace scan failed: %s", e)
self._workspace_scan_flushed = True
@override
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
"""
@@ -309,13 +339,14 @@ class NextflowLanguageServer(SolidLanguageServer):
come back empty. Sending a ``documentSymbol`` request for the same file first is a cheap way to
block until the update has been applied.
"""
self._flush_deferred_workspace_scan(relative_file_path)
self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}})
return super()._send_references_request(relative_file_path, line, column)
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
"""Small safety buffer; the actual synchronisation happens in _send_references_request."""
return 1.0
"""No blind wait is needed: _send_references_request synchronises with the server explicitly."""
return 0.0
@override
def _document_symbols_cache_fingerprint(self) -> Hashable:
@@ -7,6 +7,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_utils import SymbolUtils
from test.conftest import start_default_ls_context
pytestmark = pytest.mark.skipif(shutil.which("java") is None, reason="Nextflow language server requires a Java runtime")
@@ -74,6 +75,25 @@ class TestNextflowLanguageServer:
# main.nf, 0-indexed line 12: greetings = GREET(names)
assert ("main.nf", 12) in ref_lines, f"invocation of GREET in main.nf not found. Found: {ref_lines}"
def test_find_references_across_files_on_a_fresh_server(self) -> None:
"""Cross-file references must not depend on another request having opened the referencing file.
The module-scoped ``language_server`` fixture is shared, so by the time
``test_find_references_across_files`` runs, earlier tests have already opened main.nf and thereby
forced the server to compile it. This test starts its own server and touches nothing but the
defining file, which is what a real session's first request looks like.
"""
module_path = os.path.join("modules", "greet", "main.nf")
with start_default_ls_context(LanguageServerId.NEXTFLOW) as language_server:
symbol = self._get_symbol(language_server, module_path, "GREET")
start = symbol["selectionRange"]["start"]
refs = language_server.request_references(module_path, start["line"], start["character"])
ref_lines = self._reference_lines(refs)
# main.nf, 0-indexed line 12: greetings = GREET(names)
assert ("main.nf", 12) in ref_lines, f"invocation of GREET in main.nf not found. Found: {ref_lines}"
@staticmethod
def _reference_lines(references: list[dict]) -> set[tuple[str, int]]:
"""(relative path, 0-indexed start line) per reference; paths use the platform separator."""