fix(server/debug): robust debugpy attach with bounded wait and tests update
This commit is contained in:
+30
-11
@@ -6,7 +6,7 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import runpy
|
import runpy
|
||||||
import sys
|
import sys
|
||||||
import time
|
import threading
|
||||||
|
|
||||||
|
|
||||||
def update_sys_path(path_to_add: str) -> None:
|
def update_sys_path(path_to_add: str) -> None:
|
||||||
@@ -27,21 +27,35 @@ def _debug_endpoint() -> tuple[str, int]:
|
|||||||
return host, port
|
return host, port
|
||||||
|
|
||||||
|
|
||||||
def _connect_debugger(debugpy, host: str, port: int, timeout: float = 15.0) -> None:
|
def _connect_debugger(debugpy, host: str, port: int, timeout: float = 30.0) -> None:
|
||||||
deadline = time.monotonic() + timeout
|
errors: list[BaseException] = []
|
||||||
last_error: OSError | None = None
|
|
||||||
while time.monotonic() < deadline:
|
def attach() -> None:
|
||||||
|
# debugpy.connect() cannot be retried: after a refused connection a second
|
||||||
|
# call terminates the process silently. Connect exactly once.
|
||||||
try:
|
try:
|
||||||
debugpy.connect((host, port))
|
debugpy.connect((host, port))
|
||||||
debugpy.wait_for_client()
|
debugpy.wait_for_client()
|
||||||
return
|
except BaseException as error: # pylint: disable=broad-exception-caught
|
||||||
except (ConnectionRefusedError, OSError) as error:
|
errors.append(error)
|
||||||
last_error = error
|
|
||||||
time.sleep(0.25)
|
|
||||||
|
|
||||||
|
# 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(
|
raise RuntimeError(
|
||||||
f"Could not connect debugpy to {host}:{port} within {timeout:.0f} seconds"
|
f"Connected to {host}:{port}, but no VS Code debug session attached within "
|
||||||
) from last_error
|
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:
|
def main() -> None:
|
||||||
@@ -61,7 +75,12 @@ def main() -> None:
|
|||||||
|
|
||||||
host, port = _debug_endpoint()
|
host, port = _debug_endpoint()
|
||||||
print(f"debugpy: waiting for VS Code at {host}:{port}", file=sys.stderr)
|
print(f"debugpy: waiting for VS Code at {host}:{port}", file=sys.stderr)
|
||||||
|
try:
|
||||||
_connect_debugger(debugpy, host, port)
|
_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)
|
print("debugpy: VS Code attached; starting language server", file=sys.stderr)
|
||||||
|
|
||||||
server_path = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
|
server_path = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -27,27 +28,46 @@ def test_debug_endpoint_rejects_invalid_port(monkeypatch, port):
|
|||||||
_debug_server._debug_endpoint()
|
_debug_server._debug_endpoint()
|
||||||
|
|
||||||
|
|
||||||
def test_connect_debugger_retries_until_adapter_is_ready(monkeypatch):
|
class FakeDebugpy:
|
||||||
class FakeDebugpy:
|
def __init__(self, refuse=False, block_wait=False):
|
||||||
def __init__(self):
|
self.refuse = refuse
|
||||||
|
self.block_wait = block_wait
|
||||||
self.connect_calls = 0
|
self.connect_calls = 0
|
||||||
self.wait_calls = 0
|
self.wait_calls = 0
|
||||||
|
|
||||||
def connect(self, endpoint):
|
def connect(self, endpoint):
|
||||||
assert endpoint == ("127.0.0.1", 5678)
|
assert endpoint == ("127.0.0.1", 5678)
|
||||||
self.connect_calls += 1
|
self.connect_calls += 1
|
||||||
if self.connect_calls < 3:
|
if self.refuse:
|
||||||
raise ConnectionRefusedError("listener is starting")
|
raise ConnectionRefusedError("no listener")
|
||||||
|
|
||||||
def wait_for_client(self):
|
def wait_for_client(self):
|
||||||
self.wait_calls += 1
|
self.wait_calls += 1
|
||||||
|
if self.block_wait:
|
||||||
|
threading.Event().wait()
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_debugger_connects_once_and_waits_for_client():
|
||||||
fake_debugpy = FakeDebugpy()
|
fake_debugpy = FakeDebugpy()
|
||||||
monkeypatch.setattr(_debug_server.time, "sleep", lambda _seconds: None)
|
|
||||||
|
|
||||||
_debug_server._connect_debugger(
|
_debug_server._connect_debugger(fake_debugpy, "127.0.0.1", 5678, timeout=1.0)
|
||||||
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
|
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