https://github.com/jmfrank63 updated https://github.com/llvm/llvm-project/pull/212989
>From a871421684a5bafbb14c6b24c648792d064ba752 Mon Sep 17 00:00:00 2001 From: Johannes Maria Frank <[email protected]> Date: Thu, 30 Jul 2026 17:25:01 +0700 Subject: [PATCH] Fix python environment detection --- lldb/tools/lldb-dap/README.md | 5 + lldb/tools/lldb-dap/extension/.gitignore | 4 + lldb/tools/lldb-dap/extension/package.json | 19 +- .../extension/src/compatibility-utils.ts | 68 +++ .../extension/src/debug-adapter-factory.ts | 98 +++- .../src/debug-configuration-provider.ts | 235 ++++++++- .../test/unit/compatibility-utils.test.ts | 42 ++ .../test/unit/debug-adapter-factory.test.ts | 70 +++ .../unit/debug-configuration-provider.test.ts | 460 ++++++++++++++++++ 9 files changed, 969 insertions(+), 32 deletions(-) create mode 100644 lldb/tools/lldb-dap/extension/src/compatibility-utils.ts create mode 100644 lldb/tools/lldb-dap/extension/test/unit/compatibility-utils.test.ts create mode 100644 lldb/tools/lldb-dap/extension/test/unit/debug-adapter-factory.test.ts create mode 100644 lldb/tools/lldb-dap/extension/test/unit/debug-configuration-provider.test.ts diff --git a/lldb/tools/lldb-dap/README.md b/lldb/tools/lldb-dap/README.md index fa971d11fbdc2..0d8badd652e2a 100644 --- a/lldb/tools/lldb-dap/README.md +++ b/lldb/tools/lldb-dap/README.md @@ -1,5 +1,10 @@ # LLDB DAP +> **Windows:** the extension does not auto-set `LLDB_PYTHON_LIBRARY` from `PYTHONHOME` +> or `PATH`. LLDB can still discover Python using its normal runtime search and the +> inherited process environment. Set `LLDB_PYTHON_LIBRARY` only when you need to +> force a specific Python DLL. + ## Procuring the `lldb-dap` binary The extension requires the `lldb-dap` (formerly `lldb-vscode`) binary. diff --git a/lldb/tools/lldb-dap/extension/.gitignore b/lldb/tools/lldb-dap/extension/.gitignore index 4c32abbcd26ea..b23028ff665e9 100644 --- a/lldb/tools/lldb-dap/extension/.gitignore +++ b/lldb/tools/lldb-dap/extension/.gitignore @@ -4,3 +4,7 @@ node_modules .vscode-test *.vsix !.vscode +# Generated by `npm run sync-readme` from the parent README.md so `vsce +# package`/`vsce publish` have a README.md to show in the Marketplace +# "Details" tab. Not tracked, to avoid two copies drifting out of sync. +/README.md diff --git a/lldb/tools/lldb-dap/extension/package.json b/lldb/tools/lldb-dap/extension/package.json index 4ddc98817203e..c5e13f9186c16 100644 --- a/lldb/tools/lldb-dap/extension/package.json +++ b/lldb/tools/lldb-dap/extension/package.json @@ -1,7 +1,7 @@ { "name": "lldb-dap", "displayName": "LLDB DAP", - "version": "0.6.1", + "version": "0.6.2-dev.9", "publisher": "llvm-vs-code-extensions", "homepage": "https://lldb.llvm.org", "description": "Debugging with LLDB in Visual Studio Code", @@ -57,12 +57,13 @@ "scripts": { "bundle-extension": "npx tsc -p ./ --noEmit && npx esbuild src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node --target=node22 --minify", "bundle-symbols-table-view": "npx tsc -p src/webview --noEmit && npx esbuild src/webview/symbols-table-view.ts --bundle --format=iife --outdir=./out/webview", - "bundle-tabulator": "cp node_modules/tabulator-tables/dist/js/tabulator.min.js ./out/webview/ && cp node_modules/tabulator-tables/dist/css/tabulator_midnight.min.css ./out/webview/ && cp node_modules/tabulator-tables/dist/css/tabulator_simple.min.css ./out/webview/", + "bundle-tabulator": "node -e \"const fs=require('fs'),path=require('path');for(const f of ['dist/js/tabulator.min.js','dist/css/tabulator_midnight.min.css','dist/css/tabulator_simple.min.css'])fs.copyFileSync('node_modules/tabulator-tables/'+f,'./out/webview/'+path.basename(f))\"", "bundle-webview": "npm run bundle-symbols-table-view && npm run bundle-tabulator", - "vscode:prepublish": "npm run bundle-webview && npm run bundle-extension", + "sync-readme": "node -e \"require('fs').copyFileSync('../README.md', './README.md')\"", + "vscode:prepublish": "npm run sync-readme && npm run bundle-webview && npm run bundle-extension", "watch": "npm run bundle-webview && tsc -watch -p ./", "format": "npx prettier . --write", - "package": "rm -rf ./out && vsce package --out ./out/lldb-dap.vsix", + "package": "node -e \"require('fs').rmSync('./out',{recursive:true,force:true})\" && vsce package --out ./out/lldb-dap.vsix", "compile-debug": "npm run bundle-webview && npx esbuild src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node --target=node22 --sourcemap", "compile": "tsc -p ./", "publish": "vsce publish", @@ -143,7 +144,7 @@ "scope": "resource", "type": "object", "default": {}, - "description": "The environment of the lldb-dap process.", + "markdownDescription": "The environment of the `lldb-dap` process.\n\nOn Windows, the extension does not auto-set `LLDB_PYTHON_LIBRARY` from `PYTHONHOME` or `PATH`. LLDB can still discover Python using its normal runtime search and the inherited process environment. Set `LLDB_PYTHON_LIBRARY` only when you need to force a specific Python DLL.", "additionalProperties": { "type": "string" } @@ -452,7 +453,7 @@ "anyOf": [ { "type": "object", - "markdownDescription": "Additional environment variables to set when launching the debug adapter executable. For example `{ \"FOO\": \"1\" }`", + "markdownDescription": "Additional environment variables to set when launching the debug adapter executable. For example `{ \"FOO\": \"1\" }`.\n\nOn Windows, the extension does not auto-set `LLDB_PYTHON_LIBRARY` from `PYTHONHOME` or `PATH`. LLDB can still discover Python using its normal runtime search and the inherited process environment. Set `LLDB_PYTHON_LIBRARY` only when you need to force a specific Python DLL.", "patternProperties": { ".*": { "type": "string" @@ -462,7 +463,7 @@ }, { "type": "array", - "markdownDescription": "Additional environment variables to set when launching the debug adapter executable. For example `[\"FOO=1\", \"BAR\"]`", + "markdownDescription": "Additional environment variables to set when launching the debug adapter executable. For example `[\"FOO=1\", \"BAR\"]`.\n\nOn Windows, the extension does not auto-set `LLDB_PYTHON_LIBRARY` from `PYTHONHOME` or `PATH`. LLDB can still discover Python using its normal runtime search and the inherited process environment. Set `LLDB_PYTHON_LIBRARY` only when you need to force a specific Python DLL.", "items": { "type": "string", "pattern": "^\\w+(=.*)?$" @@ -734,7 +735,7 @@ "anyOf": [ { "type": "object", - "markdownDescription": "Additional environment variables to set when launching the debug adapter executable. For example `{ \"FOO\": \"1\" }`", + "markdownDescription": "Additional environment variables to set when launching the debug adapter executable. For example `{ \"FOO\": \"1\" }`.\n\nOn Windows, the extension does not auto-set `LLDB_PYTHON_LIBRARY` from `PYTHONHOME` or `PATH`. LLDB can still discover Python using its normal runtime search and the inherited process environment. Set `LLDB_PYTHON_LIBRARY` only when you need to force a specific Python DLL.", "patternProperties": { ".*": { "type": "string" @@ -744,7 +745,7 @@ }, { "type": "array", - "markdownDescription": "Additional environment variables to set when launching the debug adapter executable. For example `[\"FOO=1\", \"BAR\"]`", + "markdownDescription": "Additional environment variables to set when launching the debug adapter executable. For example `[\"FOO=1\", \"BAR\"]`.\n\nOn Windows, the extension does not auto-set `LLDB_PYTHON_LIBRARY` from `PYTHONHOME` or `PATH`. LLDB can still discover Python using its normal runtime search and the inherited process environment. Set `LLDB_PYTHON_LIBRARY` only when you need to force a specific Python DLL.", "items": { "type": "string", "pattern": "^\\w+(=.*)?$" diff --git a/lldb/tools/lldb-dap/extension/src/compatibility-utils.ts b/lldb/tools/lldb-dap/extension/src/compatibility-utils.ts new file mode 100644 index 0000000000000..11d4496541664 --- /dev/null +++ b/lldb/tools/lldb-dap/extension/src/compatibility-utils.ts @@ -0,0 +1,68 @@ +export function supportsCliFlag(helpText: string, flag: string): boolean { + const escapedFlag = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|\\s)${escapedFlag}(\\s|$)`, "m").test(helpText); +} + +/** + * Finds the key in `env` matching `key` case-insensitively, if any. On + * Windows, environment variable names are case-insensitive, so a lookup for + * "PATH" must also match "Path" or "path". + */ +export function getEnvironmentKey( + env: { [key: string]: string }, + key: string, +): string | undefined { + const lowerKey = key.toLowerCase(); + return Object.keys(env).find( + (currentKey) => currentKey.toLowerCase() === lowerKey, + ); +} + +/** Returns the value of `key` in `env` using a case-insensitive lookup. */ +export function getEnvironmentValue( + env: { [key: string]: string } | undefined, + key: string, +): string | undefined { + if (!env) { + return undefined; + } + const matchedKey = getEnvironmentKey(env, key); + return matchedKey !== undefined ? env[matchedKey] : undefined; +} + +/** + * Sets `env[key] = value`, reusing the casing of any existing key that + * matches case-insensitively (e.g. writing "PATH" onto an existing "Path"). + */ +export function setEnvironmentValue( + env: { [key: string]: string }, + key: string, + value: string, +): void { + const existingKey = getEnvironmentKey(env, key); + if (existingKey) { + env[existingKey] = value; + } else { + env[key] = value; + } +} + +/** + * Copies `overrides` onto `target`. On Windows, environment variable names + * are case-insensitive, so each override is written through + * {@link setEnvironmentValue} to avoid creating a second, differently-cased + * key (e.g. both "Path" and "PATH") that would leave the override ignored. + */ +export function applyEnvironmentOverrides( + target: { [key: string]: string }, + overrides: { [key: string]: string }, + platform: NodeJS.Platform = process.platform, +): void { + for (const [key, value] of Object.entries(overrides)) { + if (platform === "win32") { + setEnvironmentValue(target, key, value); + } else { + target[key] = value; + } + } +} diff --git a/lldb/tools/lldb-dap/extension/src/debug-adapter-factory.ts b/lldb/tools/lldb-dap/extension/src/debug-adapter-factory.ts index e415097d1b8be..18fda3b91abdf 100644 --- a/lldb/tools/lldb-dap/extension/src/debug-adapter-factory.ts +++ b/lldb/tools/lldb-dap/extension/src/debug-adapter-factory.ts @@ -3,6 +3,11 @@ import * as fs from "node:fs/promises"; import * as path from "path"; import * as util from "util"; import * as vscode from "vscode"; +import { + applyEnvironmentOverrides, + getEnvironmentKey, + setEnvironmentValue, +} from "./compatibility-utils"; import { LogFilePathProvider, LogType } from "./logging"; import { ErrorWithNotification } from "./ui/error-with-notification"; import { ConfigureButton, OpenSettingsButton } from "./ui/show-error-message"; @@ -19,6 +24,61 @@ async function isExecutable(path: string): Promise<Boolean> { return true; } +export function resolveWindowsPythonRuntimeLibrary( + env: { [key: string]: string }, + platform: NodeJS.Platform = process.platform, +): string | undefined { + if (platform !== "win32") { + return undefined; + } + + const configuredKey = getEnvironmentKey(env, "LLDB_PYTHON_LIBRARY"); + if (!configuredKey) { + return undefined; + } + + const configured = env[configuredKey]; + return configured && configured.length > 0 ? configured : undefined; +} + +function configureWindowsPythonEnvironment( + env: { [key: string]: string }, + pythonRuntimeLibrary: string, +): void { + const pythonDirectory = path.dirname(pythonRuntimeLibrary); + setEnvironmentValue(env, "LLDB_PYTHON_LIBRARY", pythonRuntimeLibrary); + + const pathKey = getEnvironmentKey(env, "PATH") ?? "PATH"; + const pathValue = env[pathKey] ?? ""; + const pathEntries = pathValue.length > 0 ? pathValue.split(path.delimiter) : []; + const normalizedPythonDirectory = path.normalize(pythonDirectory).toLowerCase(); + const hasPythonDirectory = pathEntries.some( + (entry) => path.normalize(entry).toLowerCase() === normalizedPythonDirectory, + ); + if (!hasPythonDirectory) { + env[pathKey] = + pathEntries.length > 0 + ? `${pythonDirectory}${path.delimiter}${pathValue}` + : pythonDirectory; + } +} + +function sanitizeEnvironmentForLogging(env: { [key: string]: string }): { + [key: string]: string; +} { + const redactedEnvironment: { [key: string]: string } = {}; + const sensitiveKeyPattern = + /(token|secret|password|passphrase|api[_-]?key|auth|credential|pat)/i; + + for (const [key, value] of Object.entries(env)) { + redactedEnvironment[key] = sensitiveKeyPattern.test(key) + ? "<redacted>" + : value; + } + + return redactedEnvironment; +} + async function findWithXcrun(executable: string): Promise<string | undefined> { if (process.platform === "darwin") { try { @@ -274,19 +334,47 @@ export async function createDebugAdapterExecutable( ); const dapPath = await getDAPExecutable(workspaceFolder, configuration); + // Preserve the extension host environment (including PATH and Python- + // related variables) so lldb-dap can resolve its runtime dependencies. + const inheritedEnvironment: { [key: string]: string } = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + inheritedEnvironment[key] = value; + } + } + + // Apply overrides through applyEnvironmentOverrides rather than spreading: + // on Windows, environment variable names are case-insensitive, so a naive + // spread could leave both an inherited "Path" and a configured "PATH" in + // the resulting object, silently ignoring the configured override. + const mergedEnvironment: { [key: string]: string } = { + ...inheritedEnvironment, + }; + applyEnvironmentOverrides(mergedEnvironment, configEnvironment); + applyEnvironmentOverrides(mergedEnvironment, env); + const dbgOptions = { - env: { - ...configEnvironment, - ...env, - }, + env: mergedEnvironment, cwd: configuration.cwd ?? workspaceFolder?.uri.fsPath, }; + + const pythonRuntimeLibrary = resolveWindowsPythonRuntimeLibrary( + dbgOptions.env, + ); + if (pythonRuntimeLibrary) { + configureWindowsPythonEnvironment(dbgOptions.env, pythonRuntimeLibrary); + } + const dbgArgs = await getDAPArguments(workspaceFolder, configuration); logger.info(`lldb-dap path: ${dapPath}`); logger.info(`lldb-dap args: ${dbgArgs}`); logger.info(`cwd: ${dbgOptions.cwd}`); - logger.info(`env: ${JSON.stringify(dbgOptions.env)}`); + logger.info( + `configured env: ${JSON.stringify( + sanitizeEnvironmentForLogging(configEnvironment), + )}`, + ); return new vscode.DebugAdapterExecutable(dapPath, dbgArgs, dbgOptions); } diff --git a/lldb/tools/lldb-dap/extension/src/debug-configuration-provider.ts b/lldb/tools/lldb-dap/extension/src/debug-configuration-provider.ts index bba9d1e1b5faa..fd08e2d4d80c5 100644 --- a/lldb/tools/lldb-dap/extension/src/debug-configuration-provider.ts +++ b/lldb/tools/lldb-dap/extension/src/debug-configuration-provider.ts @@ -1,27 +1,134 @@ import * as child_process from "child_process"; import * as os from "os"; +import * as path from "path"; import * as util from "util"; import * as vscode from "vscode"; import { pickProcess } from "./commands/pick-process"; import { convertToInteger } from "./commands/pid-helpers"; import { createDebugAdapterExecutable } from "./debug-adapter-factory"; +import { + getEnvironmentValue, + supportsCliFlag, +} from "./compatibility-utils"; import { LLDBDapServer } from "./lldb-dap-server"; import { LogFilePathProvider } from "./logging"; import { ErrorWithNotification } from "./ui/error-with-notification"; import { ConfigureButton } from "./ui/show-error-message"; const exec = util.promisify(child_process.execFile); +const PROBE_TIMEOUT_MS = 2000; + +interface PythonProbeResult { + status: "ok" | "failed" | "timeout" | "error"; + detail?: string; +} + +function buildWindowsPythonRuntimeSearchHint( + env: { [key: string]: string } | undefined, +): string { + const lldbPythonLibrary = + getEnvironmentValue(env, "LLDB_PYTHON_LIBRARY") ?? "<unset>"; + const pythonHome = getEnvironmentValue(env, "PYTHONHOME") ?? "<unset>"; + const pathValue = getEnvironmentValue(env, "PATH") ?? ""; + const pathEntries = pathValue + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + const sampleEntries = pathEntries.slice(0, 3); + const remainingEntries = Math.max(0, pathEntries.length - sampleEntries.length); + + const pathSummary = + sampleEntries.length > 0 + ? `${sampleEntries.join(path.delimiter)}${ + remainingEntries > 0 ? `${path.delimiter}... (+${remainingEntries} more)` : "" + }` + : "<empty>"; + + return ( + "Windows Python runtime search hint: " + + `LLDB_PYTHON_LIBRARY=${lldbPythonLibrary}; ` + + `PYTHONHOME=${pythonHome}; ` + + `PATH(sample)=${pathSummary}` + ); +} /** - * Determines whether or not the given lldb-dap executable supports executing - * in server mode. + * Fetches the `--help` output of the given lldb-dap executable, used to + * detect which optional CLI flags (e.g. `--connection`, `--check-python`) + * this build supports. * * @param exe the path to the lldb-dap executable - * @returns a boolean indicating whether or not lldb-dap supports server mode + * @returns the help text, or undefined if the probe failed or timed out */ -async function isServerModeSupported(exe: string): Promise<boolean> { - const { stdout } = await exec(exe, ["--help"]); - return /--connection/.test(stdout); +async function getHelpOutput(exe: string): Promise<string | undefined> { + try { + const { stdout } = await exec(exe, ["--help"], { + timeout: PROBE_TIMEOUT_MS, + }); + return stdout; + } catch { + return undefined; + } +} + +async function runPythonRuntimeProbe( + executable: vscode.DebugAdapterExecutable, +): Promise<PythonProbeResult> { + return new Promise((resolve) => { + const child = child_process.spawn(executable.command, ["--check-python"], { + cwd: executable.options?.cwd, + env: executable.options?.env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let resolved = false; + let timedOut = false; + let stdout = ""; + let stderr = ""; + + const finish = (result: PythonProbeResult) => { + if (resolved) { + return; + } + resolved = true; + clearTimeout(timer); + resolve(result); + }; + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + finish({ status: "error", detail: error.message }); + }); + + child.on("close", (code) => { + if (timedOut) { + return; + } + const detail = stderr || stdout; + if (code === 0) { + finish({ status: "ok", detail }); + } else { + finish({ status: "failed", detail }); + } + }); + + const timer = setTimeout(() => { + timedOut = true; + try { + child.kill(); + } catch { + // Ignore kill errors and continue. + } + finish({ status: "timeout", detail: stderr || stdout }); + }, PROBE_TIMEOUT_MS); + }); } interface BoolConfig { @@ -82,6 +189,9 @@ export function getDefaultConfigKey( export class LLDBDapConfigurationProvider implements vscode.DebugConfigurationProvider { + /** Tracks Python pre-check warnings already shown, per adapter and reason. */ + private readonly shownPythonCheckWarnings = new Set<string>(); + constructor( private readonly server: LLDBDapServer, private readonly logger: vscode.LogOutputChannel, @@ -110,6 +220,50 @@ export class LLDBDapConfigurationProvider ); } + /** + * Informs the user that the Python runtime pre-check was skipped and why. + * + * The explanation is shown as a non-blocking warning notification at most + * once per adapter executable and reason, to avoid nagging on every + * launch. The full runtime search hint is always written to the logs. + * + * This is only used for the "error" and "timeout" reasons: a supported + * check that misbehaves is genuinely anomalous. A build that simply lacks + * `--check-python` (the "unsupported" reason) is a normal, healthy older + * adapter and is logged only, via {@link logPythonCheckUnsupported}, to + * avoid nagging every user of an older lldb-dap on every launch. + */ + private warnPythonCheckSkipped( + executablePath: string, + reason: "error" | "timeout", + explanation: string, + runtimeSearchHint: string, + ): void { + this.logger.warn(`${explanation}\n${runtimeSearchHint}`); + + const dedupeKey = `${executablePath}:${reason}`; + if (this.shownPythonCheckWarnings.has(dedupeKey)) { + return; + } + this.shownPythonCheckWarnings.add(dedupeKey); + // Fire-and-forget: the notification must not block the launch. + vscode.window + .showWarningMessage(explanation, "Show Logs") + .then((selection) => { + if (selection === "Show Logs") { + this.logger.show(); + } + }); + } + + /** Logs (without a visible notification) that --check-python is unsupported. */ + private logPythonCheckUnsupported( + explanation: string, + runtimeSearchHint: string, + ): void { + this.logger.info(`${explanation}\n${runtimeSearchHint}`); + } + async resolveDebugConfiguration( folder: vscode.WorkspaceFolder | undefined, debugConfiguration: vscode.DebugConfiguration, @@ -236,20 +390,63 @@ export class LLDBDapConfigurationProvider return undefined; } + // Probe --help at most once per resolution; the output determines + // which optional CLI flags this lldb-dap build supports. + let helpOutputPromise: Promise<string | undefined> | undefined; + const getCachedHelpOutput = () => + (helpOutputPromise ??= getHelpOutput(executable.command)); + if (os.platform() === "win32") { - const pythonCheckProcess = child_process.spawnSync( - executable.command, - ["--check-python"], + const runtimeSearchHint = buildWindowsPythonRuntimeSearchHint( + executable.options?.env, ); - if (pythonCheckProcess.status !== 0) { - await vscode.window.showErrorMessage( - "Python is not installed correctly. Please install it to use lldb-dap.", - { - modal: true, - detail: pythonCheckProcess.stderr?.toString() ?? "", - }, + const pythonCheckSupported = supportsCliFlag( + (await getCachedHelpOutput()) ?? "", + "--check-python", + ); + if (pythonCheckSupported) { + const result = await runPythonRuntimeProbe(executable); + if (result.status === "error") { + this.warnPythonCheckSkipped( + executable.command, + "error", + "Skipped the Python runtime check: running " + + `"lldb-dap --check-python" failed (${result.detail ?? "unknown error"}). ` + + "Debugging will continue, but if lldb-dap fails to start " + + "or Python scripting is unavailable, verify your Python " + + "installation.", + runtimeSearchHint, + ); + } else if (result.status === "timeout") { + this.warnPythonCheckSkipped( + executable.command, + "timeout", + "Skipped the Python runtime check: " + + `"lldb-dap --check-python" did not finish within ` + + `${PROBE_TIMEOUT_MS / 1000} seconds. Debugging will ` + + "continue without verifying the Python runtime.", + runtimeSearchHint, + ); + } else if (result.status === "failed") { + const failureDetail = (result.detail ?? "").trim(); + throw new ErrorWithNotification( + "LLDB-DAP reported an unusable Python runtime while running --check-python." + + (failureDetail.length > 0 + ? `\n\n${failureDetail}` + : "") + + `\n\n${runtimeSearchHint}`, + new ConfigureButton(), + ); + } + } else { + this.logPythonCheckUnsupported( + "Skipped the Python runtime check: this lldb-dap does not " + + 'support "--check-python". Debugging will continue, but the ' + + "extension cannot verify your Python runtime before launch. " + + "If the debugger fails to start, ensure the Python version " + + "LLDB was built against is installed and on PATH.", + runtimeSearchHint, ); - return undefined; } } @@ -257,9 +454,11 @@ export class LLDBDapConfigurationProvider // will show an unhelpful error if it returns undefined. We'd rather show a // nicer error message here and allow stopping the debug session gracefully. const config = vscode.workspace.getConfiguration("lldb-dap", folder); + // Match only the standalone --connection flag and avoid matching + // related options such as --connection-timeout. if ( config.get<boolean>("serverMode", false) && - (await isServerModeSupported(executable.command)) + supportsCliFlag((await getCachedHelpOutput()) ?? "", "--connection") ) { const connectionTimeoutSeconds = config.get<number | undefined>( "connectionTimeout", diff --git a/lldb/tools/lldb-dap/extension/test/unit/compatibility-utils.test.ts b/lldb/tools/lldb-dap/extension/test/unit/compatibility-utils.test.ts new file mode 100644 index 0000000000000..b2f5546a0141b --- /dev/null +++ b/lldb/tools/lldb-dap/extension/test/unit/compatibility-utils.test.ts @@ -0,0 +1,42 @@ +import * as assert from "assert"; + +import { + applyEnvironmentOverrides, + setEnvironmentValue, +} from "../../src/compatibility-utils"; + +suite("compatibility-utils environment helpers", function () { + test("setEnvironmentValue reuses the existing key's casing", function () { + const env = { Path: "C:\\Windows" }; + + setEnvironmentValue(env, "PATH", "C:\\NewDir"); + + assert.deepStrictEqual(env, { Path: "C:\\NewDir" }); + }); + + test("setEnvironmentValue creates the key as given when absent", function () { + const env: { [key: string]: string } = {}; + + setEnvironmentValue(env, "FOO", "bar"); + + assert.deepStrictEqual(env, { FOO: "bar" }); + }); + + test("applyEnvironmentOverrides merges case-insensitively on win32", function () { + const target = { Path: "C:\\Windows" }; + + applyEnvironmentOverrides(target, { PATH: "C:\\Override" }, "win32"); + + // Only one key should remain, since "PATH" and "Path" refer to the same + // environment variable on Windows. + assert.deepStrictEqual(target, { Path: "C:\\Override" }); + }); + + test("applyEnvironmentOverrides keeps distinct keys on non-Windows platforms", function () { + const target = { Path: "C:\\Windows" }; + + applyEnvironmentOverrides(target, { PATH: "/usr/bin" }, "linux"); + + assert.deepStrictEqual(target, { Path: "C:\\Windows", PATH: "/usr/bin" }); + }); +}); diff --git a/lldb/tools/lldb-dap/extension/test/unit/debug-adapter-factory.test.ts b/lldb/tools/lldb-dap/extension/test/unit/debug-adapter-factory.test.ts new file mode 100644 index 0000000000000..19909d2957946 --- /dev/null +++ b/lldb/tools/lldb-dap/extension/test/unit/debug-adapter-factory.test.ts @@ -0,0 +1,70 @@ +import * as assert from "assert"; +import Module = require("module"); + +function loadResolveWindowsPythonRuntimeLibrary(): typeof import("../../src/debug-adapter-factory").resolveWindowsPythonRuntimeLibrary { + const moduleCtor = Module as unknown as { + _load: ( + request: string, + parent: NodeModule | null, + isMain: boolean, + ) => unknown; + }; + const originalLoad = moduleCtor._load; + + moduleCtor._load = function (request, parent, isMain) { + if (request === "vscode") { + return {}; + } + return originalLoad.call(this, request, parent, isMain); + }; + + const modulePath = require.resolve("../../src/debug-adapter-factory"); + delete require.cache[modulePath]; + + try { + const debugAdapterFactoryModule = require("../../src/debug-adapter-factory") as typeof import("../../src/debug-adapter-factory"); + return debugAdapterFactoryModule.resolveWindowsPythonRuntimeLibrary; + } finally { + delete require.cache[modulePath]; + moduleCtor._load = originalLoad; + } +} + +const resolveWindowsPythonRuntimeLibrary = + loadResolveWindowsPythonRuntimeLibrary(); + +suite("debug-adapter-factory Windows Python runtime selection", function () { + test("prefers explicit LLDB_PYTHON_LIBRARY over discovered runtimes", function () { + const explicitRuntime = "C:\\python\\python310.dll"; + + const resolved = resolveWindowsPythonRuntimeLibrary( + { LLDB_PYTHON_LIBRARY: explicitRuntime }, + "win32", + ); + + assert.strictEqual(resolved, explicitRuntime); + }); + + test("accepts case-insensitive LLDB_PYTHON_LIBRARY key names", function () { + const explicitRuntime = "C:\\python\\python311.dll"; + + const resolved = resolveWindowsPythonRuntimeLibrary( + { lldb_python_library: explicitRuntime }, + "win32", + ); + + assert.strictEqual(resolved, explicitRuntime); + }); + + test("does not infer LLDB_PYTHON_LIBRARY from PYTHONHOME or PATH", function () { + const resolved = resolveWindowsPythonRuntimeLibrary( + { + PYTHONHOME: "C:\\Python313", + PATH: "C:\\Python313;C:\\Python313\\DLLs", + }, + "win32", + ); + + assert.strictEqual(resolved, undefined); + }); +}); diff --git a/lldb/tools/lldb-dap/extension/test/unit/debug-configuration-provider.test.ts b/lldb/tools/lldb-dap/extension/test/unit/debug-configuration-provider.test.ts new file mode 100644 index 0000000000000..12a8494da6564 --- /dev/null +++ b/lldb/tools/lldb-dap/extension/test/unit/debug-configuration-provider.test.ts @@ -0,0 +1,460 @@ +import * as assert from "assert"; +import Module = require("module"); +import { EventEmitter } from "events"; + +import { + getEnvironmentKey, + getEnvironmentValue, + supportsCliFlag, +} from "../../src/compatibility-utils"; + +interface ProviderLoadOptions { + platform: "linux" | "win32"; + serverModeEnabled: boolean; + helpText: string; + forceSupportedFlags?: string[]; + pythonProbeExitCode?: number; + pythonProbeStdout?: string; + pythonProbeStderr?: string; + pythonProbeEmitError?: string; +} + +interface ProviderLoadResult { + provider: { + resolveDebugConfigurationWithSubstitutedVariables( + folder: unknown, + debugConfiguration: Record<string, unknown>, + token?: unknown, + ): Promise<Record<string, unknown> | null | undefined>; + }; + calls: { + helpProbeCount: number; + serverStartCount: number; + spawnCount: number; + warningCount: number; + warningMessages: string[]; + }; + restore(): void; +} + +function loadProviderForTest(options: ProviderLoadOptions): ProviderLoadResult { + const calls = { + helpProbeCount: 0, + serverStartCount: 0, + spawnCount: 0, + warningCount: 0, + warningMessages: [] as string[], + }; + + const moduleCtor = Module as unknown as { + _load: ( + request: string, + parent: NodeModule | null, + isMain: boolean, + ) => unknown; + }; + const originalLoad = moduleCtor._load; + + moduleCtor._load = function (request, parent, isMain) { + if (request === "vscode") { + return { + commands: { + registerCommand: () => ({ dispose: () => undefined }), + }, + workspace: { + getConfiguration: () => ({ + get: <T>(key: string, defaultValue: T): T => { + if (key === "serverMode") { + return options.serverModeEnabled as T; + } + return defaultValue; + }, + }), + }, + window: { + showErrorMessage: async () => undefined, + showWarningMessage: async (message: string) => { + calls.warningCount += 1; + calls.warningMessages.push(message); + return undefined; + }, + }, + }; + } + + if ( + request === "./ui/show-error-message" || + request === "./show-error-message" + ) { + class ConfigureButton { + async callback() { + return undefined; + } + } + + return { + ConfigureButton, + showErrorMessage: async () => undefined, + }; + } + + if (request === "child_process") { + const util = originalLoad.call(this, "util", parent, isMain) as { + promisify: { custom: symbol }; + }; + + const execFile = ( + _file: string, + args: readonly string[] | undefined, + _opts: unknown, + cb: + | ((error: Error | null, stdout: string, stderr: string) => void) + | undefined, + ) => { + const callback = + typeof _opts === "function" + ? (_opts as (error: Error | null, stdout: string, stderr: string) => void) + : cb ?? (() => undefined); + if (args?.includes("--help")) { + calls.helpProbeCount += 1; + } + callback(null, options.helpText, ""); + return {}; + }; + (execFile as unknown as Record<symbol, unknown>)[util.promisify.custom] = + (_file: string, args: readonly string[] | undefined) => { + if (args?.includes("--help")) { + calls.helpProbeCount += 1; + } + return Promise.resolve({ stdout: options.helpText, stderr: "" }); + }; + + return { + execFile, + spawn: () => { + calls.spawnCount += 1; + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill(): void; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => undefined; + + const stdout = options.pythonProbeStdout ?? ""; + const stderr = options.pythonProbeStderr ?? ""; + const exitCode = options.pythonProbeExitCode ?? 0; + + process.nextTick(() => { + if (options.pythonProbeEmitError) { + child.emit("error", new Error(options.pythonProbeEmitError)); + return; + } + if (stdout.length > 0) { + child.stdout.emit("data", stdout); + } + if (stderr.length > 0) { + child.stderr.emit("data", stderr); + } + child.emit("close", exitCode); + }); + + return child; + }, + }; + } + + if (request === "os") { + return { + platform: () => options.platform, + }; + } + + if (request === "./compatibility-utils") { + const module = originalLoad.call(this, request, parent, isMain) as { + supportsCliFlag: (helpText: string, flag: string) => boolean; + }; + + return { + ...module, + supportsCliFlag: (helpText: string, flag: string) => { + if (options.forceSupportedFlags?.includes(flag)) { + return true; + } + return module.supportsCliFlag(helpText, flag); + }, + }; + } + + if (request === "./debug-adapter-factory") { + return { + createDebugAdapterExecutable: async () => ({ + command: "lldb-dap", + args: [], + options: {}, + }), + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + let providerPath: string | undefined; + let errorWithNotificationPath: string | undefined; + let showErrorMessagePath: string | undefined; + const restore = () => { + if (providerPath) { + delete require.cache[providerPath]; + } + if (errorWithNotificationPath) { + delete require.cache[errorWithNotificationPath]; + } + if (showErrorMessagePath) { + delete require.cache[showErrorMessagePath]; + } + moduleCtor._load = originalLoad; + }; + + try { + providerPath = require.resolve("../../src/debug-configuration-provider"); + errorWithNotificationPath = require.resolve( + "../../src/ui/error-with-notification", + ); + showErrorMessagePath = require.resolve("../../src/ui/show-error-message"); + delete require.cache[providerPath]; + delete require.cache[errorWithNotificationPath]; + delete require.cache[showErrorMessagePath]; + const providerModule = require("../../src/debug-configuration-provider") as { + LLDBDapConfigurationProvider: new ( + server: { start: (...args: unknown[]) => Promise<unknown> }, + logger: { + info: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + }, + logFilePath: unknown, + ) => ProviderLoadResult["provider"]; + }; + + const server = { + start: async () => { + calls.serverStartCount += 1; + return { host: "127.0.0.1", port: 12345 }; + }, + }; + const logger = { + info: () => undefined, + debug: () => undefined, + warn: () => undefined, + error: () => undefined, + }; + + const provider = new providerModule.LLDBDapConfigurationProvider( + server, + logger, + {}, + ); + + return { + provider, + calls, + restore, + }; + } catch (error) { + restore(); + throw error; + } +} + +suite("debug-configuration-provider helpers", function () { + test("supportsCliFlag matches exact standalone flag", function () { + const help = ` +Usage: lldb-dap [options] + --connection <connection> + --connection-timeout <timeout> +`; + + assert.strictEqual(supportsCliFlag(help, "--connection"), true); + }); + + test("supportsCliFlag does not match similarly prefixed flag", function () { + const help = ` +Usage: lldb-dap [options] + --connection-timeout <timeout> +`; + + assert.strictEqual(supportsCliFlag(help, "--connection"), false); + }); + + test("supportsCliFlag matches check-python exactly", function () { + const help = ` +Usage: lldb-dap [options] + --check-python +`; + + assert.strictEqual(supportsCliFlag(help, "--check-python"), true); + assert.strictEqual(supportsCliFlag(help, "--check-python-extra"), false); + }); + + test("getEnvironmentKey matches keys case-insensitively", function () { + const env = { Path: "C:\\Windows", FOO: "bar" }; + + assert.strictEqual(getEnvironmentKey(env, "PATH"), "Path"); + assert.strictEqual(getEnvironmentKey(env, "foo"), "FOO"); + assert.strictEqual(getEnvironmentKey(env, "MISSING"), undefined); + }); + + test("getEnvironmentValue returns values case-insensitively", function () { + const env = { Path: "C:\\Windows" }; + + assert.strictEqual(getEnvironmentValue(env, "PATH"), "C:\\Windows"); + assert.strictEqual(getEnvironmentValue(env, "MISSING"), undefined); + assert.strictEqual(getEnvironmentValue(undefined, "PATH"), undefined); + }); +}); + +suite("debug-configuration-provider integration behavior", function () { + test("does not probe server capabilities when server mode is disabled", async function () { + const harness = loadProviderForTest({ + platform: "linux", + serverModeEnabled: false, + helpText: "Usage: lldb-dap [options]\n --connection <connection>\n", + }); + + try { + const config: Record<string, unknown> = { + name: "test", + request: "launch", + type: "lldb-dap", + console: "integratedConsole", + }; + + const resolved = + await harness.provider.resolveDebugConfigurationWithSubstitutedVariables( + undefined, + config, + ); + + assert.strictEqual(harness.calls.helpProbeCount, 0); + assert.strictEqual(harness.calls.serverStartCount, 0); + assert.strictEqual(resolved, config); + } finally { + harness.restore(); + } + }); + + test("starts server mode when check-python is unsupported, without a visible warning", async function () { + const harness = loadProviderForTest({ + platform: "win32", + serverModeEnabled: true, + helpText: + "Usage: lldb-dap [options]\n --connection <connection>\n --connection-timeout <timeout>\n", + }); + + try { + const config: Record<string, unknown> = { + name: "test", + request: "launch", + type: "lldb-dap", + console: "integratedConsole", + }; + + const resolved = + await harness.provider.resolveDebugConfigurationWithSubstitutedVariables( + undefined, + config, + ); + + assert.strictEqual(config.console, "integratedConsole"); + assert.strictEqual((resolved as Record<string, unknown>).console, "integratedConsole"); + // Server mode only depends on --connection support, which this + // adapter has, so it must still start even without --check-python. + assert.strictEqual(harness.calls.serverStartCount, 1); + assert.strictEqual( + (resolved as Record<string, unknown>).debugAdapterHostname, + "127.0.0.1", + ); + assert.strictEqual( + (resolved as Record<string, unknown>).debugAdapterPort, + 12345, + ); + assert.ok(harness.calls.helpProbeCount >= 1); + // Missing --check-python support is common on older, working + // adapters, so it must not raise a visible warning notification. + assert.strictEqual(harness.calls.warningCount, 0); + } finally { + harness.restore(); + } + }); + + test("shows the skipped-check warning only once per adapter when the probe errors", async function () { + const harness = loadProviderForTest({ + platform: "win32", + serverModeEnabled: false, + helpText: "Usage: lldb-dap [options]\n --check-python\n", + pythonProbeEmitError: "spawn EACCES", + }); + + try { + const config: Record<string, unknown> = { + name: "test", + request: "launch", + type: "lldb-dap", + }; + + await harness.provider.resolveDebugConfigurationWithSubstitutedVariables( + undefined, + { ...config }, + ); + await harness.provider.resolveDebugConfigurationWithSubstitutedVariables( + undefined, + { ...config }, + ); + + assert.strictEqual(harness.calls.warningCount, 1); + assert.match( + harness.calls.warningMessages[0], + /Skipped the Python runtime check[\s\S]*--check-python/, + ); + } finally { + harness.restore(); + } + }); + + test("blocks launch when supported check-python probe fails", async function () { + const harness = loadProviderForTest({ + platform: "win32", + serverModeEnabled: true, + helpText: + "Usage: lldb-dap [options]\n --check-python\n --connection <connection>\n", + pythonProbeExitCode: 1, + pythonProbeStderr: "missing python runtime", + }); + + try { + const config: Record<string, unknown> = { + name: "test", + request: "launch", + type: "lldb-dap", + console: "integratedConsole", + }; + + const resolved = + await harness.provider.resolveDebugConfigurationWithSubstitutedVariables( + undefined, + config, + ); + + assert.strictEqual(resolved, undefined); + assert.strictEqual(harness.calls.serverStartCount, 0); + assert.strictEqual(harness.calls.spawnCount, 1); + // A genuine --check-python failure blocks with a modal error rather + // than the non-blocking skipped-check warning. + assert.strictEqual(harness.calls.warningCount, 0); + } finally { + harness.restore(); + } + }); +}); _______________________________________________ lldb-commits mailing list [email protected] https://lists.llvm.org/cgi-bin/mailman/listinfo/lldb-commits
