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..d917960a7 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,37 @@ 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(); + const isCaseInsensitiveTarget = config.type === DebuggerType.cppvsdbg || (isWindows && !config.pipeTransport && !config.miDebuggerServerAddress && !config.useExtendedRemote); + const getEnvironmentKey = (name: string): string => isCaseInsensitiveTarget ? name.toLowerCase() : name; + + for (const entry of environment) { + if (util.isString(entry?.name) && util.isString(entry?.value)) { + mergedEnvironment.set(getEnvironmentKey(entry.name), { name: entry.name, value: entry.value }); + } + } + + for (const [name, value] of Object.entries(envObject)) { + if (util.isString(value)) { + mergedEnvironment.set(getEnvironmentKey(name), { name, value }); + } + } + + config.environment = Array.from(mergedEnvironment.values()); + 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..de935c447 100644 --- a/Extension/src/Debugger/runWithoutDebuggingAdapter.ts +++ b/Extension/src/Debugger/runWithoutDebuggingAdapter.ts @@ -13,6 +13,32 @@ 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(); +const activeTerminals = new WeakSet(); + +vscode.window.onDidCloseTerminal(closedTerminal => { + activeTerminals.delete(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 @@ -27,6 +53,7 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { private childProcess?: cp.ChildProcess; private terminal?: vscode.Terminal; private terminalExecution?: vscode.TerminalShellExecution; + private releaseTerminalOnTerminate: boolean = false; private hasTerminated: boolean = false; public handleMessage(message: vscode.DebugProtocolMessage): void { @@ -59,33 +86,32 @@ 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); + this.launchExternalTerminal(program, args, cwd, env, terminalEnv); } } @@ -93,14 +119,28 @@ 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 && activeTerminals.has(existingTerminal)) { + existingTerminal = undefined; + } else 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); + activeTerminals.add(this.terminal); this.terminal.show(true); const shellIntegration: vscode.TerminalShellIntegration | undefined = @@ -108,6 +148,7 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { // Not all terminals support shell integration. If it's not available, we'll just send the command as text though we won't be able to monitor its execution. if (shellIntegration) { + this.releaseTerminalOnTerminate = true; this.monitorIntegratedTerminal(this.terminal); let executable: string = program; let executableArgs: string[] = args; @@ -126,6 +167,9 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { // buildShellCommandLine quotes the path, and PowerShell evaluates a quoted path as a string // literal instead of running it, so the call operator is required to invoke it. this.terminal.sendText(this.isPowerShellTerminal() ? `& ${cmdLine}` : cmdLine); + if (managedTerminals.get(terminalName) === this.terminal) { + managedTerminals.delete(terminalName); + } // The terminal manages its own lifecycle; notify VS Code the "debug" session is done. this.sendEvent('terminated'); @@ -155,13 +199,14 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { /** * Launch the program in an external terminal. We do not keep track of this terminal or the spawned process. */ - private launchExternalTerminal(program: string, args: string[], cwd: string | undefined, env: NodeJS.ProcessEnv): void { + private launchExternalTerminal(program: string, args: string[], cwd: string | undefined, env: NodeJS.ProcessEnv, terminalEnv: TerminalEnvironment): void { const cmdLine: string = buildShellCommandLine('', program, args, true); const platform: string = os.platform(); if (platform === 'win32') { cp.spawn('cmd.exe', ['/c', 'start', 'cmd.exe', '/K', `"${cmdLine}"`], { cwd, env, windowsVerbatimArguments: true, detached: true, stdio: 'ignore' }).unref(); } else if (platform === 'darwin') { - cp.spawn('osascript', ['-e', `tell application "Terminal" to do script "${this.escapeQuotes(cmdLine)}"`], { cwd, env, detached: true, stdio: 'ignore' }).unref(); + const terminalCommand = this.buildMacOSExternalTerminalCommand(cmdLine, terminalEnv); + cp.spawn('osascript', ['-e', `tell application "Terminal" to do script "${this.escapeQuotes(terminalCommand)}"`], { cwd, env, detached: true, stdio: 'ignore' }).unref(); } else if (platform === 'linux' && sessionIsWsl()) { cp.spawn('/mnt/c/Windows/System32/cmd.exe', ['/c', 'start', 'bash', '-c', `${cmdLine};read -p 'Press enter to continue...'`], { env, detached: true, stdio: 'ignore' }).unref(); } else { // platform === 'linux' @@ -208,10 +253,60 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { vscode.window.showErrorMessage(message); } + private buildMacOSExternalTerminalCommand(cmdLine: string, env: TerminalEnvironment): string { + const envArgs = Object.entries(env).flatMap(([name, value]) => { + if (value === null) { + return ['-u', this.escapeShellArg(name)]; + } + + return value === undefined ? [] : [this.escapeShellArg(`${name}=${value}`)]; + }); + + return envArgs.length === 0 ? cmdLine : `/usr/bin/env ${envArgs.join(' ')} ${cmdLine}`; + } + + private escapeShellArg(arg: string): string { + return `'${arg.replace(/'/g, `'\\''`)}'`; + } + private escapeQuotes(arg: string): string { 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; @@ -289,6 +384,9 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { } this.hasTerminated = true; + if (this.releaseTerminalOnTerminate && this.terminal) { + activeTerminals.delete(this.terminal); + } this.disposeTerminalListeners(); } @@ -302,6 +400,9 @@ export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { public dispose(): void { this.terminateProcess(); + if (this.releaseTerminalOnTerminate && this.terminal) { + activeTerminals.delete(this.terminal); + } this.disposeTerminalListeners(); this.sendMessageEmitter.dispose(); } 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..94279f65f 100644 --- a/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts +++ b/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts @@ -10,6 +10,9 @@ 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 { RunWithoutDebuggingAdapter } from '../../../../src/Debugger/runWithoutDebuggingAdapter'; import { compileProgram } from './compileProgram'; interface TrackerState { @@ -146,15 +149,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 +195,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 +206,191 @@ 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 pathOverrideName = isWindows ? 'Path' : 'PATH'; + 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' }, + { name: 'PATH', value: 'from_environment_path' } + ], + env: { + TEST_VAR: 'from_env', + [pathOverrideName]: 'from_env_path', + 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: pathOverrideName, value: 'from_env_path' }, + { name: 'NEW_VAR', value: 'from_env_2' } + ], 'config.environment should merge environment entries with env precedence.'); + }); + + test('DebugConfigurationProvider should preserve case-distinct env names for remote cppdbg targets', async () => { + const provider = new DebugConfigurationProvider(ConfigurationAssetProviderFactory.getConfigurationProvider(), DebuggerType.cppdbg); + const inputConfig: any = { + name: 'Test Remote Cppdbg Env Resolution', + type: 'cppdbg', + request: 'launch', + program: envExecutablePath, + pipeTransport: { + pipeProgram: 'ssh', + pipeArgs: [], + pipeCwd: '', + debuggerPath: '/usr/bin/gdb' + }, + environment: [ + { name: 'PATH', value: 'from_environment_path' } + ], + env: { + Path: 'from_env_path' + } + }; + + const resolvedConfig = await provider.resolveDebugConfigurationWithSubstitutedVariables(workspaceFolder, inputConfig); + assert.ok(resolvedConfig, 'Resolved config should not be undefined or null.'); + assert.deepStrictEqual(resolvedConfig.environment, [ + { name: 'PATH', value: 'from_environment_path' }, + { name: 'Path', value: 'from_env_path' } + ], 'Remote cppdbg targets should preserve case-distinct environment names.'); + }); + + test('RunWithoutDebuggingAdapter should build escaped macOS external terminal env command', () => { + const adapter = new RunWithoutDebuggingAdapter() as unknown as { + buildMacOSExternalTerminalCommand(cmdLine: string, env: Record): string; + escapeQuotes(arg: string): string; + }; + const terminalCommand = adapter.buildMacOSExternalTerminalCommand('"/tmp/test app" "arg value"', { + QUOTE_VAR: `it's "fine"`, + REMOVE_ME: null, + SKIP_ME: undefined + }); + const appleScriptCommand = adapter.escapeQuotes(terminalCommand); + + assert.ok(terminalCommand.startsWith('/usr/bin/env '), 'Expected macOS external terminal command to use /usr/bin/env.'); + assert.ok(terminalCommand.includes(`'QUOTE_VAR=it'\\''s "fine"'`), 'Expected shell-escaped quoted environment value.'); + assert.ok(terminalCommand.includes(`-u 'REMOVE_ME'`), 'Expected null environment values to be unset.'); + assert.ok(!terminalCommand.includes('SKIP_ME'), 'Expected undefined environment values to be omitted.'); + assert.ok(appleScriptCommand.includes('\\\\'), 'Expected shell escape backslashes to be escaped for AppleScript.'); + assert.ok(appleScriptCommand.includes('\\"fine\\"'), 'Expected double quotes to be escaped for AppleScript.'); + }); + + 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 +427,7 @@ suite('Run Without Debugging Test', function (): void { tracker.dispose(); vscode.debug.removeBreakpoints([breakpoint]); await util.deleteFile(resultFilePath); + await util.deleteFile(envResultFilePath); } }); @@ -261,6 +478,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%",