fix(server/debug): robust debugpy attach with bounded wait and tests update

This commit is contained in:
2026-09-24 07:53:15 +02:00
parent 0666a87d15
commit 3167c18bea
2 changed files with 69 additions and 30 deletions
+32 -13
View File
@@ -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")
+37 -17
View File
@@ -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)