diff --git a/CHANGELOG.md b/CHANGELOG.md index 358994af..432a71c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Exceptions raised during `LanguageServerManager.start` did not stop the language server subprocess if it was already started (#1949) - Fix: Dart's `$/analyzerStatus` notifications were logged as unhandled-method warnings during analysis (#1855) + - Fix: `DartLanguageServer._start_server` discarded both `$/analyzerStatus` and + `experimental/serverStatus`, the two notifications the Dart analysis server sends to report + indexing progress, and returned as soon as `initialized` was sent instead of waiting for either + one; a request issued right after activation (`find_symbol`, `find_referencing_symbols`) could + return before the workspace scan finished. Serena now waits (bounded by 60s) for either signal to + report completion, matching the pattern already used for pyright, basedpyright and rust-analyzer - Fix: clojure-lsp was not told that Serena sends `workspace/didChangeWatchedFiles`, so changes made outside Serena's own edit tools (a git checkout, another editor, a build step) need not invalidate its analysis; symbol queries could then answer from a stale index, e.g. `find_symbol` returning a diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index 0cd4c58f..5e7e6824 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -1,5 +1,6 @@ import logging import os +import threading from collections.abc import Hashable from overrides import override @@ -53,6 +54,11 @@ class DartLanguageServer(SolidLanguageServer): (default: the bundled Serena version). """ + # Mirrors pyright_server.py / basedpyright_server.py: a bounded wait for the server's own + # readiness signal, not an indefinite one, so a Dart analysis server that never reports + # quiescence (or a protocol variant that stops sending these notifications) cannot hang startup. + _TIMEOUT_FOR_INITIAL_ANALYSIS = 60.0 + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: """ Creates a DartServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. @@ -61,6 +67,9 @@ class DartLanguageServer(SolidLanguageServer): super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), "dart", solidlsp_settings ) + # Set once the Dart analysis server reports it has finished its initial workspace scan, + # via either notification it sends for this (see _start_server). + self.analysis_complete = threading.Event() @override def _document_symbols_cache_fingerprint(self) -> Hashable: @@ -181,8 +190,17 @@ class DartLanguageServer(SolidLanguageServer): def do_nothing(params: dict) -> None: return + def check_analyzer_status(params: dict) -> None: + # Legacy signal: isAnalyzing flips to False once the initial workspace scan finishes + # (dart-lang/sdk lsp_analysis_server.dart AnalyzerStatusParams(isAnalyzing=...)). + if params.get("isAnalyzing") is False: + log.info("Received $/analyzerStatus with isAnalyzing=false") + self.analysis_complete.set() + def check_experimental_status(params: dict) -> None: - pass + if params.get("quiescent") is True: + log.info("Received experimental/serverStatus with quiescent=true") + self.analysis_complete.set() def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") @@ -192,7 +210,7 @@ class DartLanguageServer(SolidLanguageServer): self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) - self.server.on_notification("$/analyzerStatus", do_nothing) + self.server.on_notification("$/analyzerStatus", check_analyzer_status) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) @@ -205,3 +223,10 @@ class DartLanguageServer(SolidLanguageServer): log.info(f"Received initialize response from dart-language-server: {init_response}") self.server.notify.initialized({}) + + log.info(f"Waiting up to {self._TIMEOUT_FOR_INITIAL_ANALYSIS}s for dart-language-server to finish initial analysis...") + if self.analysis_complete.wait(timeout=self._TIMEOUT_FOR_INITIAL_ANALYSIS): + log.info("dart-language-server initial analysis complete, server ready") + else: + log.warning("Timeout waiting for dart-language-server analysis completion, proceeding anyway") + self.analysis_complete.set() diff --git a/test/solidlsp/dart/test_dart_startup.py b/test/solidlsp/dart/test_dart_startup.py index c3834c9f..6b513141 100644 --- a/test/solidlsp/dart/test_dart_startup.py +++ b/test/solidlsp/dart/test_dart_startup.py @@ -1,6 +1,7 @@ """Regression test for Dart analyzer-status notification handling.""" import logging +import threading from pathlib import Path from typing import Any from unittest.mock import patch @@ -61,6 +62,8 @@ def test_analyzer_status_notification_is_handled_after_startup( "_create_language_server_interface", return_value=server_interface, ), + # No readiness signal is ever sent here, so keep the fallback wait short. + patch.object(DartLanguageServer, "_TIMEOUT_FOR_INITIAL_ANALYSIS", 0.05), ): server = DartLanguageServer( LanguageServerConfig(ls_id=LanguageServerId.DART), @@ -73,3 +76,104 @@ def test_analyzer_status_notification_is_handled_after_startup( server_interface.receive_notification("$/analyzerStatus", {"isAnalyzing": True}) assert "Unhandled method '$/analyzerStatus'" not in caplog.messages + + +@pytest.mark.parametrize( + ("method", "params"), + [ + ("experimental/serverStatus", {"quiescent": True}), + ("$/analyzerStatus", {"isAnalyzing": False}), + ], +) +def test_start_waits_for_analysis_readiness_signal( + tmp_path: Path, + method: str, + params: dict[str, Any], +) -> None: + """`start()` must block until the Dart analysis server reports it has finished its + initial workspace scan, instead of returning as soon as `initialized` is sent. + """ + settings = SolidLSPSettings( + solidlsp_dir=str(tmp_path / "global"), + project_data_path=str(tmp_path / "project"), + ls_specific_settings={LanguageServerId.DART: {}}, + ) + server_interface = _FakeLanguageServerInterface() + + with ( + patch.object( + DartLanguageServer, + "_setup_runtime_dependencies", + return_value=str(tmp_path / "dart-sdk"), + ), + patch.object( + DartLanguageServer, + "_create_language_server_interface", + return_value=server_interface, + ), + patch.object(DartLanguageServer, "_TIMEOUT_FOR_INITIAL_ANALYSIS", 2.0, create=True), + ): + server = DartLanguageServer( + LanguageServerConfig(ls_id=LanguageServerId.DART), + str(tmp_path), + settings, + ) + + start_thread = threading.Thread(target=server.start) + start_thread.start() + try: + # No readiness signal has been sent yet: start() must still be blocked. + start_thread.join(timeout=0.3) + assert start_thread.is_alive(), "start() returned before any readiness signal was received" + + server_interface.receive_notification(method, params) + + start_thread.join(timeout=2.0) + assert not start_thread.is_alive(), f"start() did not resume after {method} {params}" + finally: + if start_thread.is_alive(): + server_interface.receive_notification("experimental/serverStatus", {"quiescent": True}) + start_thread.join(timeout=2.0) + + +def test_start_proceeds_after_readiness_timeout_if_no_signal_arrives( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """If neither readiness notification ever arrives, start() must still return once + `_TIMEOUT_FOR_INITIAL_ANALYSIS` elapses, rather than blocking forever. + """ + settings = SolidLSPSettings( + solidlsp_dir=str(tmp_path / "global"), + project_data_path=str(tmp_path / "project"), + ls_specific_settings={LanguageServerId.DART: {}}, + ) + server_interface = _FakeLanguageServerInterface() + + with ( + patch.object( + DartLanguageServer, + "_setup_runtime_dependencies", + return_value=str(tmp_path / "dart-sdk"), + ), + patch.object( + DartLanguageServer, + "_create_language_server_interface", + return_value=server_interface, + ), + patch.object(DartLanguageServer, "_TIMEOUT_FOR_INITIAL_ANALYSIS", 0.2, create=True), + ): + server = DartLanguageServer( + LanguageServerConfig(ls_id=LanguageServerId.DART), + str(tmp_path), + settings, + ) + + start_thread = threading.Thread(target=server.start) + with caplog.at_level(logging.WARNING): + start_thread.start() + start_thread.join(timeout=2.0) + + assert not start_thread.is_alive(), "start() never returned despite the readiness timeout elapsing" + assert any("Timeout waiting for dart-language-server analysis completion" in m for m in caplog.messages) + assert server.analysis_complete.is_set()