Skip to content

Commit 6816484

Browse files
authored
Merge pull request #170 from oraios/fix_deadlocks
Fix deadlocks
2 parents 9d51c0c + 3d48923 commit 6816484

15 files changed

Lines changed: 1711 additions & 542 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ dependencies = [
2424
"python-dotenv>=1.0.0, <2",
2525
"mcp>=1.5.0",
2626
"fastapi>=0.115.12",
27-
"fastmcp>=0.4.1",
2827
"sensai-utils>=1.4.0",
2928
"pydantic>=2.10.6",
3029
"types-pyyaml>=6.0.12.20241230",
@@ -107,7 +106,9 @@ exclude = "^build/|^docs/"
107106
PYDEVD_DISABLE_FILE_VALIDATION = "1"
108107

109108
[tool.poe.tasks]
110-
test = "pytest test --color=yes"
109+
# Uses PYTEST_MARKERS env var for default markers (defaults to "not java and not rust")
110+
# Set PYTEST_MARKERS="" to run all tests
111+
test = "pytest test --color=yes -m \"${PYTEST_MARKERS:-not java and not rust}\""
111112
_black_check = "black --check --exclude src/multilspy/ src scripts test"
112113
_ruff_check = "ruff check --exclude .venv/ --exclude src/multilspy/ src scripts test"
113114
_black_format = "black --exclude .venv/|src/multilspy/ src scripts test"
@@ -259,4 +260,5 @@ markers = [
259260
"typescript: language server running for TypeScript",
260261
"php: language server running for PHP",
261262
"snapshot: snapshot tests",
263+
"isolated_process: test runs with process isolated agent",
262264
]

src/multilspy/language_server.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = T
317317

318318
return False
319319

320-
async def _shutdown(self, timeout: float = 10.0):
320+
async def _shutdown(self, timeout: float = 5.0):
321321
"""
322322
A robust shutdown process designed to terminate cleanly on all platforms, including Windows,
323323
by explicitly closing all I/O pipes.
@@ -946,7 +946,7 @@ def visit_tree_nodes_and_build_tree_repr(node: GenericDocumentSymbol) -> List[mu
946946
self._document_symbols_cache[cache_key] = (file_data.content_hash, result)
947947
self._cache_has_changed = True
948948
return result
949-
949+
950950
async def request_full_symbol_tree(self, within_relative_path: str | None = None, include_body: bool = False) -> List[multilspy_types.UnifiedSymbolInformation]:
951951
"""
952952
Will go through all files in the project or within a relative path and build a tree of symbols.
@@ -1750,41 +1750,44 @@ def start_server(self) -> Iterator["SyncLanguageServer"]:
17501750
17511751
:return: None
17521752
"""
1753-
self.loop = asyncio.new_event_loop()
1754-
self.loop_thread = threading.Thread(target=self.loop.run_forever, daemon=True)
1755-
self.loop_thread.start()
1756-
ctx = self.language_server.start_server()
1757-
asyncio.run_coroutine_threadsafe(ctx.__aenter__(), loop=self.loop).result()
1753+
self.start()
17581754
yield self
1755+
self.language_server.logger.log("SyncLS Startup: exiting LS context", logging.DEBUG)
17591756
self.stop()
17601757

17611758
def request_definition(self, file_path: str, line: int, column: int) -> List[multilspy_types.Location]:
17621759
"""
17631760
Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server
17641761
for the symbol at the given line and column in the given file. Wait for the response and return the result.
17651762
1766-
:param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up
1767-
:param line: The line number of the symbol
1768-
:param column: The column number of the symbol
1763+
:param file_path: The path of the file relative to the repository root.
1764+
:param line: The line number (zero-indexed).
1765+
:param column: The column number (zero-indexed).
17691766
1770-
:return List[multilspy_types.Location]: A list of locations where the symbol is defined
1767+
:return: A list of Locations defining the symbol.
17711768
"""
1769+
if not self.is_running():
1770+
raise RuntimeError("Language server is not running. Cannot make requests to a stopped language server.")
1771+
17721772
result = asyncio.run_coroutine_threadsafe(
17731773
self.language_server.request_definition(file_path, line, column), self.loop
17741774
).result(timeout=self.timeout)
17751775
return result
17761776

17771777
def request_references(self, file_path: str, line: int, column: int) -> List[multilspy_types.Location]:
17781778
"""
1779-
Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server
1780-
to find references to the symbol at the given line and column in the given file. Wait for the response and return the result.
1779+
Raises a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server
1780+
for the symbol at the given line and column in the given file. Wait for the response and return the result.
17811781
1782-
:param relative_file_path: The relative path of the file that has the symbol for which references should be looked up
1783-
:param line: The line number of the symbol
1784-
:param column: The column number of the symbol
1782+
:param file_path: The path of the file relative to the repository root.
1783+
:param line: The line number (zero-indexed).
1784+
:param column: The column number (zero-indexed).
17851785
1786-
:return List[multilspy_types.Location]: A list of locations where the symbol is referenced
1786+
:return: A list of Locations referencing the symbol.
17871787
"""
1788+
if not self.is_running():
1789+
raise RuntimeError("Language server is not running. Cannot make requests to a stopped language server.")
1790+
17881791
try:
17891792
result = asyncio.run_coroutine_threadsafe(
17901793
self.language_server.request_references(file_path, line, column), self.loop
@@ -1870,6 +1873,9 @@ def request_full_symbol_tree(self, within_relative_path: str | None = None, incl
18701873
18711874
:return: A list of root symbols representing the top-level packages/modules in the project.
18721875
"""
1876+
if not self.is_running():
1877+
raise RuntimeError("Language server is not running. Cannot make requests to a stopped language server.")
1878+
18731879
result = asyncio.run_coroutine_threadsafe(
18741880
self.language_server.request_full_symbol_tree(within_relative_path, include_body), self.loop
18751881
).result(timeout=self.timeout)
@@ -1882,7 +1888,9 @@ def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[tuple[s
18821888
Maps relative paths of all contained files to info about top-level symbols in the file
18831889
(name, kind, line, column).
18841890
"""
1885-
assert self.loop
1891+
if not self.is_running():
1892+
raise RuntimeError("Language server is not running. Cannot make requests to a stopped language server.")
1893+
18861894
result = asyncio.run_coroutine_threadsafe(
18871895
self.language_server.request_dir_overview(relative_dir_path), self.loop
18881896
).result(timeout=self.timeout)
@@ -2255,8 +2263,6 @@ def stop(self, shutdown_timeout: float = 5.0) -> None:
22552263
shutdown_type = "Nuclear" if is_windows else "Graceful"
22562264
self.language_server.logger.log(f"{shutdown_type} shutdown complete - all references cleared", logging.INFO)
22572265

2258-
2259-
22602266
def save_cache(self):
22612267
"""
22622268
Save the cache to a file.

src/multilspy/language_servers/pyright_language_server/pyright_server.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python.
33
"""
44

5+
import asyncio
56
import json
67
import logging
78
import os
89
import pathlib
10+
import re
911
from contextlib import asynccontextmanager
1012
from typing import AsyncIterator, Tuple
1113

@@ -38,6 +40,10 @@ def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_
3840
"python",
3941
)
4042

43+
# Event to signal when initial workspace analysis is complete
44+
self.analysis_complete = asyncio.Event()
45+
self.found_source_files = False
46+
4147
@override
4248
def is_ignored_dirname(self, dirname: str) -> bool:
4349
return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"]
@@ -145,16 +151,19 @@ def _get_initialize_params(self, repository_absolute_path: str) -> InitializePar
145151
@asynccontextmanager
146152
async def start_server(self) -> AsyncIterator["PyrightServer"]:
147153
"""
148-
Starts the Pyright Language Server, waits for the server to be ready and yields the LanguageServer instance.
154+
Starts the Pyright Language Server and waits for initial workspace analysis to complete.
155+
156+
This prevents zombie processes by ensuring Pyright has finished its initial background
157+
tasks before we consider the server ready.
149158
150159
Usage:
151160
```
152161
async with lsp.start_server():
153-
# LanguageServer has been initialized and ready to serve requests
162+
# LanguageServer has been initialized and workspace analysis is complete
154163
await lsp.request_definition(...)
155164
await lsp.request_references(...)
156165
# Shutdown the LanguageServer on exit from scope
157-
# LanguageServer has been shutdown
166+
# LanguageServer has been shutdown cleanly
158167
```
159168
"""
160169

@@ -164,13 +173,33 @@ async def execute_client_command_handler(params):
164173
async def do_nothing(params):
165174
return
166175

167-
async def check_experimental_status(params):
168-
if params["quiescent"] == True:
169-
self.completions_available.set()
170-
171176
async def window_log_message(msg):
172-
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
177+
"""
178+
Monitor Pyright's log messages to detect when initial analysis is complete.
179+
Pyright logs "Found X source files" when it finishes scanning the workspace.
180+
"""
181+
message_text = msg.get("message", "")
182+
self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO)
183+
184+
# Look for "Found X source files" which indicates workspace scanning is complete
185+
# Unfortunately, pyright is unreliable and there seems to be no better way
186+
if re.search(r"Found \d+ source files?", message_text):
187+
self.logger.log("Pyright workspace scanning complete", logging.INFO)
188+
self.found_source_files = True
189+
self.analysis_complete.set()
190+
self.completions_available.set()
173191

192+
async def check_experimental_status(params):
193+
"""
194+
Also listen for experimental/serverStatus as a backup signal
195+
"""
196+
if params.get("quiescent") == True:
197+
self.logger.log("Received experimental/serverStatus with quiescent=true", logging.INFO)
198+
if not self.found_source_files:
199+
self.analysis_complete.set()
200+
self.completions_available.set()
201+
202+
# Set up notification handlers
174203
self.server.on_request("client/registerCapability", do_nothing)
175204
self.server.on_notification("language/status", do_nothing)
176205
self.server.on_notification("window/logMessage", window_log_message)
@@ -195,6 +224,23 @@ async def window_log_message(msg):
195224
self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO)
196225

197226
# Verify that the server supports our required features
227+
assert "textDocumentSync" in init_response["capabilities"]
228+
assert "completionProvider" in init_response["capabilities"]
229+
assert "definitionProvider" in init_response["capabilities"]
230+
231+
# Complete the initialization handshake
198232
self.server.notify.initialized({})
233+
234+
# Wait for Pyright to complete its initial workspace analysis
235+
# This prevents zombie processes by ensuring background tasks finish
236+
self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO)
237+
try:
238+
await asyncio.wait_for(self.analysis_complete.wait(), timeout=1.0)
239+
self.logger.log("Pyright initial analysis complete, server ready", logging.INFO)
240+
except asyncio.TimeoutError:
241+
self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING)
242+
# Fallback: assume analysis is complete after timeout
243+
self.analysis_complete.set()
244+
self.completions_available.set()
199245

200-
yield self
246+
yield self

src/multilspy/language_servers/typescript_language_server/typescript_language_server.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,22 @@ async def do_nothing(params):
186186

187187
async def window_log_message(msg):
188188
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
189+
190+
191+
async def check_experimental_status(params):
192+
"""
193+
Also listen for experimental/serverStatus as a backup signal
194+
"""
195+
if params.get("quiescent") == True:
196+
self.server_ready.set()
197+
self.completions_available.set()
189198

190199
self.server.on_request("client/registerCapability", register_capability_handler)
191200
self.server.on_notification("window/logMessage", window_log_message)
192201
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
193202
self.server.on_notification("$/progress", do_nothing)
194203
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
204+
self.server.on_notification("experimental/serverStatus", check_experimental_status)
195205

196206
async with super().start_server():
197207
self.logger.log("Starting TypeScript server process", logging.INFO)
@@ -213,11 +223,13 @@ async def window_log_message(msg):
213223
}
214224

215225
self.server.notify.initialized({})
216-
self.completions_available.set()
217-
218-
# TypeScript server is typically ready immediately after initialization
219-
self.server_ready.set()
220-
await self.server_ready.wait()
226+
try:
227+
await asyncio.wait_for(self.server_ready.wait(), timeout=1.0)
228+
except asyncio.TimeoutError:
229+
self.logger.log("Timeout waiting for TypeScript server to become ready, proceeding anyway", logging.INFO)
230+
# Fallback: assume server is ready after timeout
231+
self.server_ready.set()
232+
self.completions_available.set()
221233

222234
yield self
223235

src/multilspy/multilspy_logger.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def __init__(self, json_format: bool = False, log_level: int = logging.INFO) ->
3030
self.logger.setLevel(log_level)
3131
self.json_format = json_format
3232

33-
def log(self, debug_message: str, level: int, sanitized_error_message: str = "") -> None:
33+
def log(self, debug_message: str, level: int, sanitized_error_message: str = "", stacklevel: int = 2) -> None:
3434
"""
3535
Log the debug and santized messages using the logger
3636
"""
@@ -59,6 +59,7 @@ def log(self, debug_message: str, level: int, sanitized_error_message: str = "")
5959
self.logger.log(
6060
level=level,
6161
msg=debug_log_line.json(),
62+
stacklevel=stacklevel,
6263
)
6364
else:
64-
self.logger.log(level, debug_message)
65+
self.logger.log(level, debug_message, stacklevel=stacklevel)

0 commit comments

Comments
 (0)