From 8ebb247daee92e694820d8a9dde8fc5f1ff7b01e Mon Sep 17 00:00:00 2001 From: Evangelos Karatzas <32259775+vagkaratzas@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:24:43 +0100 Subject: [PATCH] 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 --- .../nextflow_language_server.py | 35 +++++++++++++++++-- test/solidlsp/nextflow/test_nextflow_basic.py | 20 +++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/solidlsp/language_servers/nextflow_language_server.py b/src/solidlsp/language_servers/nextflow_language_server.py index d39cc834..0b3fcf17 100644 --- a/src/solidlsp/language_servers/nextflow_language_server.py +++ b/src/solidlsp/language_servers/nextflow_language_server.py @@ -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: diff --git a/test/solidlsp/nextflow/test_nextflow_basic.py b/test/solidlsp/nextflow/test_nextflow_basic.py index 4f7243d1..d60bd2cd 100644 --- a/test/solidlsp/nextflow/test_nextflow_basic.py +++ b/test/solidlsp/nextflow/test_nextflow_basic.py @@ -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."""