From 33cf282b0a282db48cb2e353c1ee845c1d0cc712 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 17 Aug 2026 11:02:22 +0200 Subject: [PATCH] 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 --- .vscode/launch.json | 47 ++++++++--- .vscode/prepare-debug.ps1 | 57 +++++++++++++ .vscode/tasks.json | 26 ++++++ README.md | 25 +++++- client/package-lock.json | 22 +++++ client/package.json | 1 + client/src/common/server.ts | 81 ++++++++++++------- client/src/extension.ts | 21 ++++- esbuild.js | 6 +- package.json | 1 + server/src/_debug_server.py | 71 ++++++++++------ server/src/lsp_server.py | 8 +- .../tests/python_tests/test_debug_server.py | 53 ++++++++++++ test/test.tcl | 3 + 14 files changed, 349 insertions(+), 73 deletions(-) create mode 100644 .vscode/prepare-debug.ps1 create mode 100644 .vscode/tasks.json create mode 100644 server/tests/python_tests/test_debug_server.py diff --git a/.vscode/launch.json b/.vscode/launch.json index ccaf648..fd0075e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,13 +10,21 @@ "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceFolder}"], - "outFiles": ["${workspaceFolder}/client/**/*.js"], + "args": [ + "--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug", + "${env:TEMP}/nx-post-support-vscode-debug", + "${env:TEMP}/nx-post-support-vscode-debug/test/test.tcl" + ], + "cwd": "${env:TEMP}/nx-post-support-vscode-debug", + "outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"], + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js", + "!**/node_modules/**" + ], + "skipFiles": ["/**"], "autoAttachChildProcesses": true, - "preLaunchTask": { - "type": "npm", - "script": "watch" - } + "preLaunchTask": "NX Post Support: Compile Debug" }, { "name": "Python Attach", @@ -34,10 +42,24 @@ "name": "Debug Extension (hidden)", "type": "extensionHost", "request": "launch", - "args": ["--extensionDevelopmentPath=${workspaceFolder}"], - "outFiles": ["${workspaceFolder}/client/**/*.js"], + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug", + "${env:TEMP}/nx-post-support-vscode-debug", + "${env:TEMP}/nx-post-support-vscode-debug/test/test.tcl" + ], + "cwd": "${env:TEMP}/nx-post-support-vscode-debug", + "outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"], + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js", + "!**/node_modules/**" + ], + "skipFiles": ["/**"], "env": { - "USE_DEBUGPY": "True" + "USE_DEBUGPY": "True", + "NXPS_DEBUG_HOST": "127.0.0.1", + "NXPS_DEBUG_PORT": "5678" }, "presentation": { "hidden": true, @@ -49,8 +71,9 @@ "name": "Python debug server (hidden)", "type": "debugpy", "request": "attach", - "listen": { "host": "localhost", "port": 5678 }, - "justMyCode": true, + "listen": { "host": "127.0.0.1", "port": 5678 }, + "justMyCode": false, + "logToFile": true, "presentation": { "hidden": true, "group": "", @@ -63,7 +86,7 @@ "name": "Debug Extension and Python", "configurations": ["Python debug server (hidden)", "Debug Extension (hidden)"], "stopAll": true, - "preLaunchTask": "npm: watch", + "preLaunchTask": "NX Post Support: Compile Debug", "presentation": { "hidden": false, "group": "", diff --git a/.vscode/prepare-debug.ps1 b/.vscode/prepare-debug.ps1 new file mode 100644 index 0000000..6fd93fe --- /dev/null +++ b/.vscode/prepare-debug.ps1 @@ -0,0 +1,57 @@ +param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot, + + [Parameter(Mandatory = $true)] + [string]$DebugRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function ConvertFrom-ExtendedWindowsPath { + param([string]$Path) + + if ($Path.StartsWith("\\?\UNC\", [System.StringComparison]::OrdinalIgnoreCase)) { + return "\\" + $Path.Substring(8) + } + if ($Path.StartsWith("\\?\", [System.StringComparison]::OrdinalIgnoreCase)) { + return $Path.Substring(4) + } + return $Path +} + +$workspacePath = ConvertFrom-ExtendedWindowsPath $WorkspaceRoot +$workspaceItem = Get-Item -LiteralPath $workspacePath +if (-not $workspaceItem.PSIsContainer) { + throw "Workspace root is not a directory: $workspacePath" +} +$workspacePath = $workspaceItem.FullName + +$debugPath = ConvertFrom-ExtendedWindowsPath $DebugRoot +$tempPath = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd("\") +$debugParent = [System.IO.Path]::GetFullPath((Split-Path -Parent $debugPath)).TrimEnd("\") +if (-not $debugParent.Equals($tempPath, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Debug alias must be located directly below the user temp directory: $debugPath" +} + +if (Test-Path -LiteralPath $debugPath) { + $debugItem = Get-Item -LiteralPath $debugPath -Force + if ($debugItem.LinkType -ne "Junction") { + throw "Debug alias exists but is not a junction: $debugPath" + } + + $currentTarget = (Get-Item -LiteralPath $debugItem.Target).FullName + if (-not $currentTarget.Equals($workspacePath, [System.StringComparison]::OrdinalIgnoreCase)) { + # Removing a junction removes only the link, never the target directory. + Remove-Item -LiteralPath $debugPath -Force + } +} + +if (-not (Test-Path -LiteralPath $debugPath)) { + New-Item -ItemType Junction -Path $debugPath -Target $workspacePath | Out-Null +} + +Write-Output "Debug extension path: $debugPath -> $workspacePath" +& npm.cmd --prefix $workspacePath run compile:debug +exit $LASTEXITCODE diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..9fc060b --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,26 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "NX Post Support: Compile Debug", + "type": "process", + "command": "powershell.exe", + "args": [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "& { param([string]$WorkspaceRoot, [string]$DebugRoot); $scriptRoot = $WorkspaceRoot; if ($scriptRoot.StartsWith('\\\\?\\')) { $scriptRoot = $scriptRoot.Substring(4) }; & (Join-Path $scriptRoot '.vscode\\prepare-debug.ps1') -WorkspaceRoot $WorkspaceRoot -DebugRoot $DebugRoot; exit $LASTEXITCODE }", + "${workspaceFolder}", + "${env:TEMP}\\nx-post-support-vscode-debug" + ], + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + } + } + ] +} diff --git a/README.md b/README.md index 068b5bb..a248473 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A comprehensive VS Code extension providing language support for NX CAM postproc ## Installation 1. Install from the VS Code Marketplace -2. Install Python 3.8 or higher +2. Install Python 3.11 or higher 3. Open any `.cdl`, `.tcl`, or `.def` file 4. The extension will automatically activate and provide language support @@ -43,6 +43,29 @@ Simply open any supported file type and enjoy: - Hover information - Signature help while entering procedure arguments +## Development and debugging + +Install the root and client dependencies before the first debug session: + +```powershell +npm install +npm install --prefix client +``` + +Use one of the checked-in VS Code launch configurations: + +- **Run Extension** debugs the TypeScript extension host. +- **Debug Extension and Python** debugs both the TypeScript extension and the + Python language server. This is the recommended configuration for LSP work. +- **Python Attach** attaches manually to an already running Python process. + +The launch configuration creates a fresh non-minified bundle with embedded +source maps and opens `test/test.tcl` so the extension activates immediately. +For combined debugging, the Python adapter listens on `127.0.0.1:5678`; the +language server waits for that adapter before initialization. The NX +Postprocessor Support output channel reports `Python debug mode: enabled` and +shows `_debug_server.py` in the server command when the debug path is active. + ## Contributing This extension is actively maintained. For issues or feature requests, please visit our [repository](https://git.cbsk-tech.de/Christoph/nx_post_support.git). diff --git a/client/package-lock.json b/client/package-lock.json index f2fa6f6..19a6908 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,6 +14,7 @@ "vscode-languageclient": "^9.0.1" }, "devDependencies": { + "@types/fs-extra": "^11.0.4", "@types/node": "^22.10.5", "@types/vscode": "^1.96.0" }, @@ -21,6 +22,27 @@ "vscode": "^1.96.0" } }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.10.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", diff --git a/client/package.json b/client/package.json index 4db362e..8c0f30b 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,7 @@ "vscode-languageclient": "^9.0.1" }, "devDependencies": { + "@types/fs-extra": "^11.0.4", "@types/node": "^22.10.5", "@types/vscode": "^1.96.0" } diff --git a/client/src/common/server.ts b/client/src/common/server.ts index 5460e07..b943873 100644 --- a/client/src/common/server.ts +++ b/client/src/common/server.ts @@ -39,13 +39,26 @@ async function createServer( initializationOptions: IInitOptions ): Promise { const command = settings.interpreter[0] + if (!command) { + throw new Error("No Python interpreter is configured for the language server.") + } const cwd = settings.cwd // Set debugger path needed for debugging python code. const newEnv = { ...process.env } - const debuggerPath = await getDebuggerPath() const isDebugScript = await fsapi.pathExists(DEBUG_SERVER_SCRIPT_PATH) - if (newEnv.USE_DEBUGPY && debuggerPath) { + const debugRequested = newEnv.USE_DEBUGPY?.toLowerCase() === "true" + if (debugRequested && !isDebugScript) { + throw new Error(`Python debug bootstrap not found: ${DEBUG_SERVER_SCRIPT_PATH}`) + } + + const debuggerPath = debugRequested ? await getDebuggerPath() : undefined + if (debugRequested && !debuggerPath) { + throw new Error( + "Python debugging was requested, but the Python Debugger extension did not provide debugpy." + ) + } + if (debugRequested && debuggerPath) { newEnv.DEBUGPY_PATH = debuggerPath } else { newEnv.USE_DEBUGPY = "False" @@ -57,10 +70,13 @@ async function createServer( // Set notification type newEnv.LS_SHOW_NOTIFICATION = settings.showNotifications - const args = - newEnv.USE_DEBUGPY === "False" || !isDebugScript - ? settings.interpreter.slice(1).concat([SERVER_SCRIPT_PATH]) - : settings.interpreter.slice(1).concat([DEBUG_SERVER_SCRIPT_PATH]) + const serverScript = debugRequested ? DEBUG_SERVER_SCRIPT_PATH : SERVER_SCRIPT_PATH + const interpreterArgs = settings.interpreter.slice(1) + if (debugRequested && !interpreterArgs.includes("-Xfrozen_modules=off")) { + interpreterArgs.push("-Xfrozen_modules=off") + } + const args = interpreterArgs.concat([serverScript]) + traceInfo(`Python debug mode: ${debugRequested ? "enabled" : "disabled"}`) traceInfo(`Server run command: ${[command, ...args].join(" ")}`) const serverOptions: ServerOptions = { @@ -108,35 +124,40 @@ export async function restartServer( const projectRoot = await getProjectRoot() const workspaceSetting = await getWorkspaceSettings(serverId, projectRoot, true) - const newLSClient = await createServer(workspaceSetting, serverId, serverName, outputChannel, { - settings: await getExtensionSettings(serverId, true), - globalSettings: await getGlobalSettings(serverId, false) - }) - traceInfo(`Server: Start requested.`) - _disposables.push( - newLSClient.onDidChangeState((e) => { - switch (e.newState) { - case State.Stopped: - traceVerbose(`Server State: Stopped`) - break - case State.Starting: - traceVerbose(`Server State: Starting`) - break - case State.Running: - traceVerbose(`Server State: Running`) - break - } - }) - ) try { + const newLSClient = await createServer( + workspaceSetting, + serverId, + serverName, + outputChannel, + { + settings: await getExtensionSettings(serverId, true), + globalSettings: await getGlobalSettings(serverId, false) + } + ) + traceInfo(`Server: Start requested.`) + _disposables.push( + newLSClient.onDidChangeState((e) => { + switch (e.newState) { + case State.Stopped: + traceVerbose(`Server State: Stopped`) + break + case State.Starting: + traceVerbose(`Server State: Starting`) + break + case State.Running: + traceVerbose(`Server State: Running`) + break + } + }) + ) await newLSClient.start() + const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel) + await newLSClient.setTrace(level) + return newLSClient } catch (ex) { traceError(`Server: Start failed: ${ex}`) disposeServerResources() return undefined } - - const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel) - await newLSClient.setTrace(level) - return newLSClient } diff --git a/client/src/extension.ts b/client/src/extension.ts index e939a0d..d7e3984 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -38,6 +38,7 @@ export async function activate(context: vscode.ExtensionContext) { const serverInfo = loadServerDefaults() const serverName = serverInfo.name const serverId = serverInfo.module + const pythonDebugMode = process.env.USE_DEBUGPY?.toLowerCase() === "true" // Setup logging const outputChannel = createOutputChannel(serverName) @@ -101,10 +102,15 @@ export async function activate(context: vscode.ExtensionContext) { return runServerQueue } + if (!pythonDebugMode) { + context.subscriptions.push( + onDidChangePythonInterpreter(async () => { + await runServer() + }) + ) + } + context.subscriptions.push( - onDidChangePythonInterpreter(async () => { - await runServer() - }), onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => { if (checkIfConfigurationChanged(e, serverId)) { await runServer() @@ -116,6 +122,15 @@ export async function activate(context: vscode.ExtensionContext) { ) setImmediate(async () => { + if (pythonDebugMode) { + // A debugpy listen session is attached to exactly one process. Do not + // subscribe to interpreter changes during startup, as the Python + // extension can emit a duplicate event and restart that process. + traceLog("Python debug mode: starting one stable server session") + await runServer() + return + } + const interpreter = getInterpreterFromSetting(serverId) if (interpreter === undefined || interpreter.length === 0) { traceLog(`Python extension loading`) diff --git a/esbuild.js b/esbuild.js index 5c96d50..0b878d8 100644 --- a/esbuild.js +++ b/esbuild.js @@ -1,19 +1,21 @@ const esbuild = require("esbuild") +const path = require("path") const production = process.argv.includes("--production") const watch = process.argv.includes("--watch") async function main() { const ctx = await esbuild.context({ + absWorkingDir: __dirname, entryPoints: ["client/src/extension.ts"], bundle: true, format: "cjs", minify: production, sourcemap: !production, - sourcesContent: false, + sourcesContent: !production, platform: "node", // outdir: "out", - outfile: "./dist/extension.js", + outfile: path.join(__dirname, "dist", "extension.js"), external: ["vscode"], logLevel: "silent", plugins: [ diff --git a/package.json b/package.json index 18ab0f4..7a111c2 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ }, "scripts": { "compile": "node esbuild.js --production", + "compile:debug": "node esbuild.js", "watch": "node esbuild.js --watch", "package": "node esbuild.js --production" }, diff --git a/server/src/_debug_server.py b/server/src/_debug_server.py index ade940e..a583b81 100644 --- a/server/src/_debug_server.py +++ b/server/src/_debug_server.py @@ -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() diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 2fcc103..189fb10 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -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 = { diff --git a/server/tests/python_tests/test_debug_server.py b/server/tests/python_tests/test_debug_server.py new file mode 100644 index 0000000..4bc167b --- /dev/null +++ b/server/tests/python_tests/test_debug_server.py @@ -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 diff --git a/test/test.tcl b/test/test.tcl index d7793b4..bfb0ed7 100644 --- a/test/test.tcl +++ b/test/test.tcl @@ -114,3 +114,6 @@ proc SERVICE_get_tool_data {} { LIB_GE_command_buffer_edit_replace MOM_end_of_program_LIB END_OF_PROGRAM @END_OF_PROG { MOM_do_template "end_of_program_rewind" } EndOfProgramRewind + + +SERVICE_remove_file "test" \ No newline at end of file