fix(server/debug): robust debugpy attach with bounded wait and tests update
This commit is contained in:
+32
-13
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user