diff --git a/server/src/_debug_server.py b/server/src/_debug_server.py index a583b81..140a51c 100644 --- a/server/src/_debug_server.py +++ b/server/src/_debug_server.py @@ -6,7 +6,7 @@ import os import pathlib import runpy import sys -import time +import threading def update_sys_path(path_to_add: str) -> None: @@ -27,21 +27,35 @@ def _debug_endpoint() -> tuple[str, int]: return host, port -def _connect_debugger(debugpy, host: str, port: int, timeout: float = 15.0) -> None: - deadline = time.monotonic() + timeout - last_error: OSError | None = None - while time.monotonic() < deadline: +def _connect_debugger(debugpy, host: str, port: int, timeout: float = 30.0) -> None: + errors: list[BaseException] = [] + + def attach() -> None: + # debugpy.connect() cannot be retried: after a refused connection a second + # call terminates the process silently. Connect exactly once. try: debugpy.connect((host, port)) debugpy.wait_for_client() - return - except (ConnectionRefusedError, OSError) as error: - last_error = error - time.sleep(0.25) + except BaseException as error: # pylint: disable=broad-exception-caught + errors.append(error) - raise RuntimeError( - f"Could not connect debugpy to {host}:{port} within {timeout:.0f} seconds" - ) from last_error + # A stale debugpy adapter from an earlier debug session can still own the port. + # It accepts the connection but never attaches, so bound the wait. + waiter = threading.Thread(target=attach, daemon=True) + waiter.start() + waiter.join(timeout) + if waiter.is_alive(): + raise RuntimeError( + f"Connected to {host}:{port}, but no VS Code debug session attached within " + f"{timeout:.0f} seconds. A stale debugpy adapter probably still owns the " + f"port; stop it (e.g. 'fuser -k {port}/tcp') and restart debugging." + ) + if errors: + raise RuntimeError( + f"No debugpy listener on {host}:{port}. Start the launch configuration " + "'Python debug server (hidden)' (e.g. via a 'Debug Extension and Python' " + "compound) before the language server." + ) from errors[0] def main() -> None: @@ -61,7 +75,12 @@ def main() -> None: host, port = _debug_endpoint() print(f"debugpy: waiting for VS Code at {host}:{port}", file=sys.stderr) - _connect_debugger(debugpy, host, port) + try: + _connect_debugger(debugpy, host, port) + except RuntimeError as error: + print(f"debugpy: {error}", file=sys.stderr, flush=True) + # debugpy's background threads can keep the interpreter alive; exit hard. + os._exit(1) print("debugpy: VS Code attached; starting language server", file=sys.stderr) server_path = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py") diff --git a/server/tests/python_tests/test_debug_server.py b/server/tests/python_tests/test_debug_server.py index 4bc167b..6ec1285 100644 --- a/server/tests/python_tests/test_debug_server.py +++ b/server/tests/python_tests/test_debug_server.py @@ -1,4 +1,5 @@ import sys +import threading from pathlib import Path import pytest @@ -27,27 +28,46 @@ def test_debug_endpoint_rejects_invalid_port(monkeypatch, port): _debug_server._debug_endpoint() -def test_connect_debugger_retries_until_adapter_is_ready(monkeypatch): - class FakeDebugpy: - def __init__(self): - self.connect_calls = 0 - self.wait_calls = 0 +class FakeDebugpy: + def __init__(self, refuse=False, block_wait=False): + self.refuse = refuse + self.block_wait = block_wait + self.connect_calls = 0 + self.wait_calls = 0 - def connect(self, endpoint): - assert endpoint == ("127.0.0.1", 5678) - self.connect_calls += 1 - if self.connect_calls < 3: - raise ConnectionRefusedError("listener is starting") + def connect(self, endpoint): + assert endpoint == ("127.0.0.1", 5678) + self.connect_calls += 1 + if self.refuse: + raise ConnectionRefusedError("no listener") - def wait_for_client(self): - self.wait_calls += 1 + def wait_for_client(self): + self.wait_calls += 1 + if self.block_wait: + threading.Event().wait() + +def test_connect_debugger_connects_once_and_waits_for_client(): fake_debugpy = FakeDebugpy() - monkeypatch.setattr(_debug_server.time, "sleep", lambda _seconds: None) - _debug_server._connect_debugger( - fake_debugpy, "127.0.0.1", 5678, timeout=1.0 - ) + _debug_server._connect_debugger(fake_debugpy, "127.0.0.1", 5678, timeout=1.0) - assert fake_debugpy.connect_calls == 3 + assert fake_debugpy.connect_calls == 1 assert fake_debugpy.wait_calls == 1 + + +def test_connect_debugger_does_not_retry_refused_connection(): + # debugpy.connect() cannot be called a second time after a refused connection. + fake_debugpy = FakeDebugpy(refuse=True) + + with pytest.raises(RuntimeError, match="No debugpy listener"): + _debug_server._connect_debugger(fake_debugpy, "127.0.0.1", 5678, timeout=1.0) + + assert fake_debugpy.connect_calls == 1 + + +def test_connect_debugger_times_out_on_stale_adapter(): + fake_debugpy = FakeDebugpy(block_wait=True) + + with pytest.raises(RuntimeError, match="stale debugpy adapter"): + _debug_server._connect_debugger(fake_debugpy, "127.0.0.1", 5678, timeout=0.1)