From ce3a67b42c99b235ca3ef81dc855f3c52e7a5763 Mon Sep 17 00:00:00 2001 From: Andrew Newell Date: Mon, 4 May 2026 10:40:38 -0400 Subject: [PATCH] fix: search subdirectories for mix.exs to prevent Expert deadlock in monorepos (#1444) --- CHANGELOG.md | 1 + .../elixir_tools/elixir_tools.py | 37 ++++++- test/solidlsp/elixir/test_elixir_startup.py | 104 ------------------ 3 files changed, 35 insertions(+), 107 deletions(-) delete mode 100644 test/solidlsp/elixir/test_elixir_startup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index abdc5f62..74890b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Status of the `main` branch. Changes prior to the next official version change w - `get_diagnostics_for_symbol`: Retrieves diagnostics pertaining to a specific symbol * Language Servers: + - Elixir (`elixir-tools/next-ls`): Fix deadlock in monorepo projects where `mix.exs` lives in a subdirectory. The server now searches immediate subdirectories when no `mix.exs` is found at the repository root. #1444 - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 # v1.2.0 (2026-04-27) diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 5ca86256..11e754a1 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -225,6 +225,36 @@ class ElixirTools(SolidLanguageServer): def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: return symbol["name"].removeprefix("defp ").removeprefix("def ").split("(", 1)[0].strip() + def _find_mix_exs(self) -> str | None: + """ + Find mix.exs in the repository. + + Checks {repository_root_path}/mix.exs first (standard layout), then scans + immediate subdirectories for mix.exs (monorepo layout, e.g. server/mix.exs). + Returns the absolute path to the first match found, or None if not found. + + Note: subdirectory iteration order depends on the filesystem (os.listdir). + In practice this is fine — monorepos typically have one Elixir app. + """ + # Check root first + root_mix_exs = os.path.join(self.repository_root_path, "mix.exs") + if os.path.exists(root_mix_exs): + return root_mix_exs + + # Search immediate subdirectories + try: + for entry in os.listdir(self.repository_root_path): + subdir_path = os.path.join(self.repository_root_path, entry) + if os.path.isdir(subdir_path): + subdir_mix_exs = os.path.join(subdir_path, "mix.exs") + if os.path.exists(subdir_mix_exs): + log.info(f"Found mix.exs in subdirectory: {entry}/mix.exs") + return subdir_mix_exs + except OSError as e: + log.warning(f"Error scanning for mix.exs in subdirectories: {e}") + + return None + @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ @@ -364,10 +394,11 @@ class ElixirTools(SolidLanguageServer): # 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 + mix_exs_path = self._find_mix_exs() + mix_exs_uri: str | None = None - if mix_exs_uri is not None: + if mix_exs_path is not None: + mix_exs_uri = pathlib.Path(mix_exs_path).as_uri() with open(mix_exs_path, encoding="utf-8") as f: mix_exs_content = f.read() self.server.notify.did_open_text_document( diff --git a/test/solidlsp/elixir/test_elixir_startup.py b/test/solidlsp/elixir/test_elixir_startup.py deleted file mode 100644 index 49a84be7..00000000 --- a/test/solidlsp/elixir/test_elixir_startup.py +++ /dev/null @@ -1,104 +0,0 @@ -"""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()