feat(debug): enable Python debug workflow and debug server integration

Adds an end-to-end Python debug workflow for the extension.
Includes a new prepare-debug.ps1 script and a VS Code task.
Extends the Python server and extension to coordinate a debug session and safe startup.

- Add prepare-debug.ps1 and a VS Code task to build the debug bundle
- Enable Python debug wiring in the server and tests
- Ensure a single stable Python debug session during startup
This commit is contained in:
Christoph Brandau
2026-08-17 11:02:22 +02:00
parent 35a4357551
commit 33cf282b0a
14 changed files with 349 additions and 73 deletions
+47 -24
View File
@@ -6,6 +6,7 @@ import os
import pathlib
import runpy
import sys
import time
def update_sys_path(path_to_add: str) -> None:
@@ -14,10 +15,43 @@ def update_sys_path(path_to_add: str) -> None:
sys.path.append(path_to_add)
# Ensure debugger is loaded before we load anything else, to debug initialization.
debugger_path = os.getenv("DEBUGPY_PATH", None)
if debugger_path:
if debugger_path.endswith("debugpy"):
def _debug_endpoint() -> tuple[str, int]:
host = os.getenv("NXPS_DEBUG_HOST", "127.0.0.1")
raw_port = os.getenv("NXPS_DEBUG_PORT", "5678")
try:
port = int(raw_port)
except ValueError as error:
raise RuntimeError(f"Invalid NXPS_DEBUG_PORT: {raw_port!r}") from error
if not 1 <= port <= 65535:
raise RuntimeError(f"NXPS_DEBUG_PORT is outside the valid range: {port}")
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:
try:
debugpy.connect((host, port))
debugpy.wait_for_client()
return
except (ConnectionRefusedError, OSError) as error:
last_error = error
time.sleep(0.25)
raise RuntimeError(
f"Could not connect debugpy to {host}:{port} within {timeout:.0f} seconds"
) from last_error
def main() -> None:
# Ensure debugger is loaded before we load anything else, so server
# initialization and module-level feature registration can be debugged.
debugger_path = os.getenv("DEBUGPY_PATH")
if not debugger_path:
raise RuntimeError("DEBUGPY_PATH is missing in Python debug mode")
if pathlib.Path(debugger_path).name.casefold() == "debugpy":
debugger_path = os.fspath(pathlib.Path(debugger_path).parent)
update_sys_path(debugger_path)
@@ -25,25 +59,14 @@ if debugger_path:
# pylint: disable=wrong-import-position,import-error
import debugpy
# 5678 is the default port, If you need to change it update it here
# and in launch.json.
# Connecting requires the "Python debug server" listener (launch.json) to be
# up first. If it isn't (e.g. wrong launch config was used), don't crash the
# whole language server - just continue running without the debugger attached.
try:
debugpy.connect(5678)
except (ConnectionRefusedError, OSError) as exc:
print(
f"debugpy: could not connect to debug adapter on port 5678 "
f"({exc}); continuing without debugging.",
file=sys.stderr,
)
host, port = _debug_endpoint()
print(f"debugpy: waiting for VS Code at {host}:{port}", file=sys.stderr)
_connect_debugger(debugpy, host, port)
print("debugpy: VS Code attached; starting language server", file=sys.stderr)
# This will ensure that execution is paused as soon as the debugger
# connects to VS Code. If you don't want to pause here comment this
# line and set breakpoints as appropriate.
# debugpy.breakpoint()
server_path = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
runpy.run_path(server_path, run_name="__main__")
SERVER_PATH = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
# NOTE: Set breakpoint in `lsp_server.py` before continuing.
runpy.run_path(SERVER_PATH, run_name="__main__")
if __name__ == "__main__":
main()
+7 -1
View File
@@ -704,7 +704,13 @@ def initialized(_params: lsp.InitializedParams):
def index_workspace():
try:
root = LSP_SERVER.workspace.root_path
try:
root = LSP_SERVER.workspace.root_path
except RuntimeError:
root = None
if not root:
log_to_output("Background indexing skipped: no workspace folder is open.")
return
log_to_output("Background indexing started...")
root_path = pathlib.Path(root)
skipped_directories = {
@@ -0,0 +1,53 @@
import sys
from pathlib import Path
import pytest
THIS_DIR = Path(__file__).parent
SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
import _debug_server
def test_debug_endpoint_defaults(monkeypatch):
monkeypatch.delenv("NXPS_DEBUG_HOST", raising=False)
monkeypatch.delenv("NXPS_DEBUG_PORT", raising=False)
assert _debug_server._debug_endpoint() == ("127.0.0.1", 5678)
@pytest.mark.parametrize("port", ["invalid", "0", "65536"])
def test_debug_endpoint_rejects_invalid_port(monkeypatch, port):
monkeypatch.setenv("NXPS_DEBUG_PORT", port)
with pytest.raises(RuntimeError):
_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
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 wait_for_client(self):
self.wait_calls += 1
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
)
assert fake_debugpy.connect_calls == 3
assert fake_debugpy.wait_calls == 1