diff --git a/Extension/package.json b/Extension/package.json index 51d701cf8..77fffa8b8 100644 --- a/Extension/package.json +++ b/Extension/package.json @@ -4379,6 +4379,14 @@ }, "default": [] }, + "env": { + "type": "object", + "description": "%c_cpp.debuggers.env.description%", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, "envFile": { "type": "string", "description": "%c_cpp.debuggers.envFile.description%", @@ -6052,6 +6060,14 @@ }, "default": [] }, + "env": { + "type": "object", + "description": "%c_cpp.debuggers.env.description%", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, "envFile": { "type": "string", "description": "%c_cpp.debuggers.envFile.description%", diff --git a/Extension/package.nls.json b/Extension/package.nls.json index c42e03e45..82203507c 100644 --- a/Extension/package.nls.json +++ b/Extension/package.nls.json @@ -927,6 +927,12 @@ "{Locked=\"[ { \\\"name\\\": \\\"\"} {Locked=\"\\\", \\\"value\\\": \\\"\"} {Locked=\"\\\" } ]\"} {Locked=\"[ { \\\"\"} {Locked=\"\\\": \\\"\"} {Locked=\"\\\" } ]\"}" ] }, + "c_cpp.debuggers.env.description": { + "message": "Object of environment variables to add to the environment for the program. Example: { \"MY_VAR\": \"value\" }. Use `environment` for the array-of-objects form.", + "comment": [ + "{Locked=\"{ \\\"MY_VAR\\\": \\\"value\\\" }\"} {Locked=\"`environment`\"}" + ] + }, "c_cpp.debuggers.envFile.description": "Absolute path to a file containing environment variable definitions. This file has key value pairs separated by an equals sign per line. E.g. KEY=VALUE.", "c_cpp.debuggers.additionalSOLibSearchPath.description": "Semicolon separated list of directories to use to search for .so files. Example: \"c:\\dir1;c:\\dir2\".", "c_cpp.debuggers.MIMode.description": "Indicates the console debugger that the MIDebugEngine will connect to. Allowed values are \"gdb\" \"lldb\".", diff --git a/Extension/src/Debugger/ParsedEnvironmentFile.ts b/Extension/src/Debugger/ParsedEnvironmentFile.ts index 9018a540b..d371f46fb 100644 --- a/Extension/src/Debugger/ParsedEnvironmentFile.ts +++ b/Extension/src/Debugger/ParsedEnvironmentFile.ts @@ -11,7 +11,7 @@ const localize: nls.LocalizeFunc = nls.loadMessageBundle(); export interface Environment { name: string; - value: string; + value: string | null; } export class ParsedEnvironmentFile { diff --git a/Extension/src/Debugger/configurationProvider.ts b/Extension/src/Debugger/configurationProvider.ts index e675516f8..bc9a3cabc 100644 --- a/Extension/src/Debugger/configurationProvider.ts +++ b/Extension/src/Debugger/configurationProvider.ts @@ -270,6 +270,10 @@ export class DebugConfigurationProvider implements vscode.DebugConfigurationProv // Add environment variables from .env file this.resolveEnvFile(config, folder); + // Debug adapters consume the legacy `environment` array, not `env`. + // Convert here so both syntaxes work while preserving `env` precedence. + this.resolveEnvObject(config); + await this.expand(config, folder); this.resolveSourceFileMapVariables(config); @@ -700,6 +704,35 @@ export class DebugConfigurationProvider implements vscode.DebugConfigurationProv } } + private resolveEnvObject(config: CppDebugConfiguration): void { + if ((config.type !== DebuggerType.cppdbg && config.type !== DebuggerType.cppvsdbg) || config.request !== 'launch') { + return; + } + + const envObject = config.env; + if (!util.isObject(envObject)) { + return; + } + + const environment: Environment[] = util.isArray(config.environment) ? config.environment : []; + const mergedEnvironment = new Map(); + + for (const entry of environment) { + if (util.isString(entry?.name) && util.isString(entry?.value)) { + mergedEnvironment.set(entry.name, entry.value); + } + } + + for (const [name, value] of Object.entries(envObject)) { + if (util.isString(value)) { + mergedEnvironment.set(name, value); + } + } + + config.environment = Array.from(mergedEnvironment.entries()).map(([name, value]) => ({ name, value })); + delete config.env; + } + private resolveSourceFileMapVariables(config: CppDebugConfiguration): void { const messages: string[] = []; if (config.sourceFileMap) { diff --git a/Extension/src/Debugger/runWithoutDebuggingAdapter.ts b/Extension/src/Debugger/runWithoutDebuggingAdapter.ts index fc7c98a35..1d8158306 100644 --- a/Extension/src/Debugger/runWithoutDebuggingAdapter.ts +++ b/Extension/src/Debugger/runWithoutDebuggingAdapter.ts @@ -13,6 +13,30 @@ import { isWindows } from '../constants'; nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); const localize = nls.loadMessageBundle(); +type TerminalEnvironment = NonNullable; +const managedTerminals = new Map(); +const terminalEnvironments = new WeakMap(); + +vscode.window.onDidCloseTerminal(closedTerminal => { + for (const [terminalName, terminal] of managedTerminals) { + if (terminal === closedTerminal) { + managedTerminals.delete(terminalName); + return; + } + } +}); + +type LaunchEnvironmentEntry = { name: string; value: string | null; }; + +type LaunchConfiguration = { + program?: string; + args?: string[]; + cwd?: string; + environment?: LaunchEnvironmentEntry[]; + env?: Record; + console?: string; + externalConsole?: boolean; +}; /** * A minimal inline Debug Adapter that runs the target program directly without a debug adapter @@ -59,31 +83,30 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { } private async launch(request: { command: string; seq: number; arguments?: any; }): Promise { - const config = request.arguments as { - program?: string; - args?: string[]; - cwd?: string; - environment?: { name: string; value: string; }[]; - console?: string; - externalConsole?: boolean; - }; + const config = request.arguments as LaunchConfiguration; const program: string = config.program ?? ''; const args: string[] = config.args ?? []; const cwd: string | undefined = config.cwd; - const environment: { name: string; value: string; }[] = config.environment ?? []; + const environment: LaunchEnvironmentEntry[] = config.environment ?? []; + const envObject: Record = config.env ?? {}; const consoleMode: string = config.console ?? (config.externalConsole ? 'externalTerminal' : 'integratedTerminal'); - // Merge the launch config's environment variables on top of the inherited process environment. + // Merge environment values in this order: inherited process environment, legacy + // `environment` entries, then shorthand `env` values (higher precedence). const env: NodeJS.ProcessEnv = { ...process.env }; + const terminalEnv: TerminalEnvironment = {}; for (const e of environment) { - env[e.name] = e.value; + this.applyEnvironmentValue(env, terminalEnv, e.name, e.value); + } + for (const [key, value] of Object.entries(envObject)) { + this.applyEnvironmentValue(env, terminalEnv, key, value); } this.sendResponse(request, {}); if (consoleMode === 'integratedTerminal' || consoleMode === 'internalConsole') { - await this.launchIntegratedTerminal(program, args, cwd, env); + await this.launchIntegratedTerminal(program, args, cwd, terminalEnv); } else if (consoleMode === 'externalTerminal') { this.launchExternalTerminal(program, args, cwd, env); } @@ -93,14 +116,25 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { * Launch the program in a VS Code integrated terminal. * The terminal will remain open after the program exits and be reused for the next session, if applicable. */ - private async launchIntegratedTerminal(program: string, args: string[], cwd: string | undefined, env: NodeJS.ProcessEnv): Promise { + private async launchIntegratedTerminal(program: string, args: string[], cwd: string | undefined, env: TerminalEnvironment): Promise { const terminalName = path.normalize(program); - const existingTerminal = vscode.window.terminals.find(t => t.name === terminalName); + const managedTerminal = managedTerminals.get(terminalName); + let existingTerminal = managedTerminal && vscode.window.terminals.includes(managedTerminal) ? managedTerminal : undefined; + if (!existingTerminal) { + managedTerminals.delete(terminalName); + } + if (existingTerminal && !this.environmentsEqual(terminalEnvironments.get(existingTerminal), env)) { + existingTerminal.dispose(); + existingTerminal = undefined; + managedTerminals.delete(terminalName); + } this.terminal = existingTerminal ?? vscode.window.createTerminal({ name: terminalName, cwd, - env: env as Record + env }); + managedTerminals.set(terminalName, this.terminal); + terminalEnvironments.set(this.terminal, env); this.terminal.show(true); const shellIntegration: vscode.TerminalShellIntegration | undefined = @@ -212,6 +246,40 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { return arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } + private applyEnvironmentValue(processEnv: NodeJS.ProcessEnv, terminalEnv: TerminalEnvironment, name: string, value: string | null): void { + const matchingKeys = isWindows + ? new Set([...Object.keys(processEnv), ...Object.keys(terminalEnv)].filter(key => key.toLowerCase() === name.toLowerCase())) + : new Set([name]); + + for (const key of matchingKeys) { + delete processEnv[key]; + if (key !== name || value === null) { + terminalEnv[key] = null; + } + } + + if (value === null) { + terminalEnv[name] = null; + } else { + processEnv[name] = value; + terminalEnv[name] = value; + } + } + + private environmentsEqual(first: TerminalEnvironment | undefined, second: TerminalEnvironment): boolean { + if (!first) { + return false; + } + + const firstKeys = Object.keys(first); + const secondKeys = Object.keys(second); + if (firstKeys.length !== secondKeys.length) { + return false; + } + + return firstKeys.every(key => first[key] === second[key]); + } + private waitForShellIntegration(terminal: vscode.Terminal, timeoutMs: number): Promise { return new Promise(resolve => { let resolved: boolean = false; diff --git a/Extension/test/scenarios/RunWithoutDebugging/assets/envTest.cpp b/Extension/test/scenarios/RunWithoutDebugging/assets/envTest.cpp new file mode 100644 index 000000000..45df63b0b --- /dev/null +++ b/Extension/test/scenarios/RunWithoutDebugging/assets/envTest.cpp @@ -0,0 +1,21 @@ +#include +#include + +int main(int argc, char *argv[]) { + if (argc < 3) { + return 1; + } + + const char *value = std::getenv(argv[1]); + + std::ofstream resultFile(argv[2]); + if (!resultFile) { + return 2; + } + + if (value) { + resultFile << value; + } + + return 0; +} diff --git a/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts b/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts index 53542e36e..111206634 100644 --- a/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts +++ b/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts @@ -10,6 +10,8 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as util from '../../../../src/common'; import { isMacOS, isWindows } from '../../../../src/constants'; +import { ConfigurationAssetProviderFactory, DebugConfigurationProvider } from '../../../../src/Debugger/configurationProvider'; +import { DebuggerType } from '../../../../src/Debugger/configurations'; import { compileProgram } from './compileProgram'; interface TrackerState { @@ -146,15 +148,42 @@ async function waitForResultFileValue(filePath: string, timeoutMs: number): Prom assert.fail(`Timed out waiting for numeric result in ${filePath}. Last contents: ${lastContents}`); } +async function waitForResultFileText(filePath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastContents = ''; + + while (Date.now() < deadline) { + try { + lastContents = await util.readFileText(filePath, 'utf8'); + const trimmedContents = lastContents.trim(); + if (trimmedContents.length > 0) { + return trimmedContents; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + assert.fail(`Timed out waiting for output in ${filePath}. Last contents: ${lastContents}`); +} + suite('Run Without Debugging Test', function (): void { const expectedResultValue = 37; const workspaceFolder = vscode.workspace.workspaceFolders?.[0] ?? assert.fail('No workspace folder available'); const workspacePath = workspaceFolder.uri.fsPath; const sourceFile = path.join(workspacePath, 'debugTest.cpp'); + const envSourceFile = path.join(workspacePath, 'envTest.cpp'); const sourceUri = vscode.Uri.file(sourceFile); const resultFilePath = path.join(workspacePath, 'runWithoutDebuggingResult.txt'); + const envResultFilePath = path.join(workspacePath, 'runWithoutDebuggingEnvResult.txt'); const executableName = isWindows ? 'debugTestProgram.exe' : 'debugTestProgram'; const executablePath = path.join(workspacePath, executableName); + const envExecutableName = isWindows ? 'envTestProgram.exe' : 'envTestProgram'; + const envExecutablePath = path.join(workspacePath, envExecutableName); const sessionName = 'Run Without Debugging Result File'; const debugType = isWindows ? 'cppvsdbg' : 'cppdbg'; const miMode = isMacOS ? 'lldb' : 'gdb'; @@ -165,6 +194,7 @@ suite('Run Without Debugging Test', function (): void { await extension.activate(); } await compileProgram(workspacePath, sourceFile, executablePath); + await compileProgram(workspacePath, envSourceFile, envExecutablePath); }); suiteTeardown(async function (): Promise { @@ -175,6 +205,138 @@ suite('Run Without Debugging Test', function (): void { setup(async function (): Promise { await util.deleteFile(resultFilePath); + await util.deleteFile(envResultFilePath); + }); + + test('DebugConfigurationProvider should convert env object to environment array for cppdbg and preserve precedence', async () => { + const provider = new DebugConfigurationProvider(ConfigurationAssetProviderFactory.getConfigurationProvider(), DebuggerType.cppdbg); + const inputConfig: any = { + name: 'Test Cppdbg Env Resolution', + type: 'cppdbg', + request: 'launch', + program: envExecutablePath, + environment: [ + { name: 'TEST_VAR', value: 'from_environment' }, + { name: 'OTHER_VAR', value: 'from_environment_2' } + ], + env: { + TEST_VAR: 'from_env', + NEW_VAR: 'from_env_2' + } + }; + + const resolvedConfig = await provider.resolveDebugConfigurationWithSubstitutedVariables(workspaceFolder, inputConfig); + assert.ok(resolvedConfig, 'Resolved config should not be undefined or null.'); + assert.strictEqual(resolvedConfig.env, undefined, 'config.env should be removed after conversion.'); + assert.deepStrictEqual(resolvedConfig.environment, [ + { name: 'TEST_VAR', value: 'from_env' }, + { name: 'OTHER_VAR', value: 'from_environment_2' }, + { name: 'NEW_VAR', value: 'from_env_2' } + ], 'config.environment should merge environment entries with env precedence.'); + }); + + test('Run Without Debugging should apply env and prefer it over environment entries', async () => { + const testVarName = 'CPPTOOLS_NO_DEBUG_ENV_TEST'; + const expectedValue = 'value-from-env-object'; + const fallbackValue = 'value-from-environment-array'; + const envConfig: Record = { + [testVarName]: expectedValue + }; + const envSessionName = `${sessionName} Env`; + const debugSessionTerminated = createSessionTerminatedPromise(envSessionName); + + let launchedSession: vscode.DebugSession | undefined; + const startedSubscription = vscode.debug.onDidStartDebugSession((session) => { + if (session.name === envSessionName) { + launchedSession = session; + } + }); + + try { + const started = await vscode.debug.startDebugging( + workspaceFolder, + { + name: envSessionName, + type: debugType, + request: 'launch', + program: envExecutablePath, + args: [testVarName, envResultFilePath], + cwd: workspacePath, + environment: [{ name: testVarName, value: fallbackValue }], + env: envConfig, + externalConsole: debugType === 'cppdbg' ? false : undefined, + console: debugType === 'cppvsdbg' ? 'internalConsole' : undefined + }, + { noDebug: true }); + + assert.strictEqual(started, true, 'The noDebug launch with env did not start successfully.'); + const actualValue = await waitForResultFileText(envResultFilePath, 10000); + + assert.strictEqual(actualValue, expectedValue, 'Expected env object values to be applied and take precedence over environment entries.'); + await debugSessionTerminated; + } finally { + startedSubscription.dispose(); + const sessionToStop = launchedSession ?? (vscode.debug.activeDebugSession?.name === envSessionName ? vscode.debug.activeDebugSession : undefined); + if (sessionToStop) { + await vscode.debug.stopDebugging(sessionToStop); + } + await util.deleteFile(envResultFilePath); + } + }); + + test('Run Without Debugging should refresh a reused terminal when env changes', async () => { + const testVarName = 'CPPTOOLS_NO_DEBUG_REUSED_TERMINAL_ENV_TEST'; + const firstValue = 'first-launch-value'; + const secondValue = 'second-launch-value'; + const firstResultFilePath = path.join(workspacePath, 'runWithoutDebuggingFirstEnvResult.txt'); + const secondResultFilePath = path.join(workspacePath, 'runWithoutDebuggingSecondEnvResult.txt'); + + try { + const launch = async (resultFilePath: string, value: string, name: string): Promise => { + const debugSessionTerminated = createSessionTerminatedPromise(name); + let launchedSession: vscode.DebugSession | undefined; + const startedSubscription = vscode.debug.onDidStartDebugSession((session) => { + if (session.name === name) { + launchedSession = session; + } + }); + + try { + const started = await vscode.debug.startDebugging( + workspaceFolder, + { + name, + type: debugType, + request: 'launch', + program: envExecutablePath, + args: [testVarName, resultFilePath], + cwd: workspacePath, + env: { [testVarName]: value }, + externalConsole: debugType === 'cppdbg' ? false : undefined, + console: debugType === 'cppvsdbg' ? 'internalConsole' : undefined + }, + { noDebug: true }); + + assert.strictEqual(started, true, `The ${name} noDebug launch did not start successfully.`); + await debugSessionTerminated; + return waitForResultFileText(resultFilePath, 10000); + } finally { + startedSubscription.dispose(); + const sessionToStop = launchedSession ?? (vscode.debug.activeDebugSession?.name === name ? vscode.debug.activeDebugSession : undefined); + if (sessionToStop) { + await vscode.debug.stopDebugging(sessionToStop); + } + } + }; + + assert.strictEqual(await launch(firstResultFilePath, firstValue, `${sessionName} First Env`), firstValue); + assert.strictEqual(await launch(secondResultFilePath, secondValue, `${sessionName} Second Env`), secondValue); + } finally { + await util.deleteFile(firstResultFilePath); + await util.deleteFile(secondResultFilePath); + const terminalName = path.normalize(envExecutablePath); + vscode.window.terminals.filter(terminal => terminal.name === terminalName).forEach(terminal => terminal.dispose()); + } }); test('Run Without Debugging should not break on breakpoints and write the expected result file', async () => { @@ -211,6 +373,7 @@ suite('Run Without Debugging Test', function (): void { tracker.dispose(); vscode.debug.removeBreakpoints([breakpoint]); await util.deleteFile(resultFilePath); + await util.deleteFile(envResultFilePath); } }); @@ -261,6 +424,7 @@ suite('Run Without Debugging Test', function (): void { tracker.dispose(); vscode.debug.removeBreakpoints([breakpoint]); await util.deleteFile(resultFilePath); + await util.deleteFile(envResultFilePath); } }); diff --git a/Extension/tools/OptionsSchema.json b/Extension/tools/OptionsSchema.json index 644f28a32..f11233804 100644 --- a/Extension/tools/OptionsSchema.json +++ b/Extension/tools/OptionsSchema.json @@ -706,6 +706,14 @@ }, "default": [] }, + "env": { + "type": "object", + "description": "%c_cpp.debuggers.env.description%", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, "envFile": { "type": "string", "description": "%c_cpp.debuggers.envFile.description%", @@ -1018,6 +1026,14 @@ }, "default": [] }, + "env": { + "type": "object", + "description": "%c_cpp.debuggers.env.description%", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, "envFile": { "type": "string", "description": "%c_cpp.debuggers.envFile.description%",