From fc27a5204e702f7dd008fbb7e925b8ab4d20e800 Mon Sep 17 00:00:00 2001 From: Andrew Newell Date: Fri, 24 Apr 2026 11:16:37 -0400 Subject: [PATCH] Fix: 1397 Expert deadlock by sending didOpen(mix.exs) before readiness wait (#1405) * Fix: Expert deadlock by sending didOpen(mix.exs) before readiness wait Expert's build pipeline only starts after receiving a textDocument/didOpen notification. Without it, Serena waited 300 s for $/progress signals that Expert never emitted, causing a deadlock on every cold start. --- CHANGELOG.md | 1 + .../elixir_tools/elixir_tools.py | 26 +++++ test/solidlsp/elixir/test_elixir_startup.py | 104 ++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 test/solidlsp/elixir/test_elixir_startup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3f1f13..0265b3c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 + - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 688da5e8..5ca86256 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -360,6 +360,28 @@ class ElixirTools(SolidLanguageServer): self.server.notify.initialized({}) + # Expert's build pipeline is triggered by a textDocument/didOpen notification. + # Without it Expert sits idle and never emits the $/progress signals we wait for, + # causing a deadlock. Opening mix.exs is the minimal trigger; we close it again + # once the server is ready so it does not linger in the open-file set. + mix_exs_path = os.path.join(self.repository_root_path, "mix.exs") + mix_exs_uri = pathlib.Path(mix_exs_path).as_uri() if os.path.exists(mix_exs_path) else None + + if mix_exs_uri is not None: + with open(mix_exs_path, encoding="utf-8") as f: + mix_exs_content = f.read() + self.server.notify.did_open_text_document( + { + "textDocument": { + "uri": mix_exs_uri, + "languageId": "elixir", + "version": 1, + "text": mix_exs_content, + } + } + ) + log.debug("Opened mix.exs to trigger Expert's build pipeline") + # Expert needs time to compile the project and build indexes on first run. # This can take 2-3+ minutes for mid-sized codebases. # After the first run, subsequent startups are much faster. @@ -370,3 +392,7 @@ class ElixirTools(SolidLanguageServer): else: log.warning(f"Expert did not signal readiness within {ready_timeout}s. Proceeding with requests anyway.") self.server_ready.set() # Mark as ready anyway to allow requests + + if mix_exs_uri is not None: + self.server.notify.did_close_text_document({"textDocument": {"uri": mix_exs_uri}}) + log.debug("Closed mix.exs after Expert is ready") diff --git a/test/solidlsp/elixir/test_elixir_startup.py b/test/solidlsp/elixir/test_elixir_startup.py new file mode 100644 index 00000000..49a84be7 --- /dev/null +++ b/test/solidlsp/elixir/test_elixir_startup.py @@ -0,0 +1,104 @@ +"""Unit tests for Elixir Expert language server startup behavior.""" + +import threading +from unittest.mock import MagicMock + +from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools + + +def _make_elixir_tools(tmp_path): + language_server = object.__new__(ElixirTools) + language_server.repository_root_path = str(tmp_path) + language_server.server_ready = threading.Event() + language_server.request_id = 0 + return language_server + + +def _make_mock_server(): + server = MagicMock() + server.send.initialize.return_value = {"capabilities": {"textDocumentSync": {}}} + return server + + +class TestElixirToolsStartup: + """Tests for ElixirTools._start_server() startup behavior — the Expert deadlock fix.""" + + def test_sends_did_open_for_mix_exs_after_initialized(self, tmp_path): + """Expert blocks waiting for a didOpen notification to trigger its build pipeline.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + calls = server.notify.did_open_text_document.call_args_list + assert len(calls) == 1, f"Expected one didOpen notification for mix.exs, got {len(calls)}" + text_doc = calls[0][0][0]["textDocument"] + assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}" + assert text_doc["languageId"] == "elixir" + + def test_did_open_contains_mix_exs_content(self, tmp_path): + """The didOpen notification must include the file content so Expert can parse it.""" + content = "defmodule App.MixProject do\n use Mix.Project\nend\n" + (tmp_path / "mix.exs").write_text(content, encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + calls = server.notify.did_open_text_document.call_args_list + assert len(calls) == 1 + text_doc = calls[0][0][0]["textDocument"] + assert text_doc["text"] == content + + def test_closes_mix_exs_after_server_ready(self, tmp_path): + """mix.exs is closed after Expert signals readiness so it does not linger open.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + close_calls = server.notify.did_close_text_document.call_args_list + assert len(close_calls) == 1, f"Expected one didClose for mix.exs, got {len(close_calls)}" + text_doc = close_calls[0][0][0]["textDocument"] + assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}" + + def test_startup_event_order_initialized_then_didopen_then_didclose(self, tmp_path): + """Initialized → didOpen(mix.exs) → [ready] → didClose(mix.exs) is the required sequence.""" + (tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8") + + events: list[str] = [] + + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + server.notify.initialized.side_effect = lambda *_: events.append("initialized") + server.notify.did_open_text_document.side_effect = lambda *_: events.append("didOpen") + server.notify.did_close_text_document.side_effect = lambda *_: events.append("didClose") + + language_server._start_server() + + assert events == ["initialized", "didOpen", "didClose"], f"Unexpected startup event order: {events}" + + def test_no_crash_when_mix_exs_missing(self, tmp_path): + """_start_server must not crash if mix.exs is absent (non-standard project layout).""" + language_server = _make_elixir_tools(tmp_path) + server = _make_mock_server() + language_server.server = server + language_server.server_ready.set() + + language_server._start_server() + + server.notify.did_open_text_document.assert_not_called()