diff --git a/.github/workflows/job-compile-and-test.yml b/.github/workflows/job-compile-and-test.yml index a9a1b8621..884c61c91 100644 --- a/.github/workflows/job-compile-and-test.yml +++ b/.github/workflows/job-compile-and-test.yml @@ -66,6 +66,11 @@ jobs: # run: yarn test --scenario=MultirootDeadlockTest # working-directory: Extension + # - name: Run E2E IntelliSense features tests + # if: ${{ inputs.platform == 'windows' }} + # run: yarn test --scenario=RunWithoutDebugging + # working-directory: Extension + # NOTE: For mac/linux run the tests with xvfb-action for UI support. # Another way to start xvfb https://github.com/microsoft/vscode-test/blob/master/sample/azure-pipelines.yml @@ -83,3 +88,10 @@ jobs: # run: yarn test --scenario=MultirootDeadlockTest # working-directory: Extension + # - name: Run E2E IntelliSense features tests (xvfb) + # if: ${{ inputs.platform == 'mac' || inputs.platform == 'linux' }} + # uses: coactions/setup-xvfb@v1 + # with: + # run: yarn test --scenario=RunWithoutDebugging + # working-directory: Extension + diff --git a/Extension/.vscode/launch.json b/Extension/.vscode/launch.json index 4323f133d..a110f3407 100644 --- a/Extension/.vscode/launch.json +++ b/Extension/.vscode/launch.json @@ -97,6 +97,10 @@ "label": "MultirootDeadlockTest ", "value": "${workspaceFolder}/test/scenarios/MultirootDeadlockTest/assets/test.code-workspace" }, + { + "label": "RunWithoutDebugging ", + "value": "${workspaceFolder}/test/scenarios/RunWithoutDebugging/assets/" + }, { "label": "SimpleCppProject ", "value": "${workspaceFolder}/test/scenarios/SimpleCppProject/assets/simpleCppProject.code-workspace" diff --git a/Extension/src/Debugger/configurationProvider.ts b/Extension/src/Debugger/configurationProvider.ts index 8f0ae0c7c..5bc427759 100644 --- a/Extension/src/Debugger/configurationProvider.ts +++ b/Extension/src/Debugger/configurationProvider.ts @@ -147,12 +147,14 @@ export class DebugConfigurationProvider implements vscode.DebugConfigurationProv Telemetry.logDebuggerEvent(DebuggerEvent.debugPanel, { "debugType": DebugType.debug, "configSource": folder ? ConfigSource.workspaceFolder : ConfigSource.singleFile, "configMode": ConfigMode.noLaunchConfig, "cancelled": "true", "succeeded": "true" }); return undefined; // aborts debugging silently } else { + const noDebug = config.noDebug ?? false; // Preserve the noDebug value from the config if it exists. // Currently, we expect only one debug config to be selected. console.assert(configs.length === 1, "More than one debug config is selected."); config = configs[0]; // Keep track of the entry point where the debug config has been selected, for telemetry purposes. config.debuggerEvent = DebuggerEvent.debugPanel; config.configSource = folder ? ConfigSource.workspaceFolder : ConfigSource.singleFile; + config.noDebug = noDebug; } } diff --git a/Extension/src/Debugger/configurations.ts b/Extension/src/Debugger/configurations.ts index 96895c6da..d3c0acaf6 100644 --- a/Extension/src/Debugger/configurations.ts +++ b/Extension/src/Debugger/configurations.ts @@ -97,7 +97,7 @@ function createLaunchString(name: string, type: string, executable: string): str "stopAtEntry": false, "cwd": "$\{fileDirname\}", "environment": [], -${ type === "cppdbg" ? `"externalConsole": false` : `"console": "externalTerminal"` } +${type === "cppdbg" ? `"externalConsole": false` : `"console": "internalConsole"`} `; } @@ -164,7 +164,7 @@ export class MIConfigurations extends Configuration { \t${indentJsonString(createLaunchString(name, this.miDebugger, this.executable))}, \t"MIMode": "${this.MIMode}"{0}{1} }`, [this.miDebugger === "cppdbg" && os.platform() === "win32" ? `,${os.EOL}\t"miDebuggerPath": "/path/to/gdb"` : "", - this.additionalProperties ? `,${os.EOL}\t${indentJsonString(this.additionalProperties)}` : ""]); + this.additionalProperties ? `,${os.EOL}\t${indentJsonString(this.additionalProperties)}` : ""]); return { "label": configPrefix + name, @@ -182,7 +182,7 @@ export class MIConfigurations extends Configuration { \t${indentJsonString(createAttachString(name, this.miDebugger, this.executable))} \t"MIMode": "${this.MIMode}"{0}{1} }`, [this.miDebugger === "cppdbg" && os.platform() === "win32" ? `,${os.EOL}\t"miDebuggerPath": "/path/to/gdb"` : "", - this.additionalProperties ? `,${os.EOL}\t${indentJsonString(this.additionalProperties)}` : ""]); + this.additionalProperties ? `,${os.EOL}\t${indentJsonString(this.additionalProperties)}` : ""]); return { "label": configPrefix + name, diff --git a/Extension/src/Debugger/debugAdapterDescriptorFactory.ts b/Extension/src/Debugger/debugAdapterDescriptorFactory.ts index d43d71bc3..90657bb90 100644 --- a/Extension/src/Debugger/debugAdapterDescriptorFactory.ts +++ b/Extension/src/Debugger/debugAdapterDescriptorFactory.ts @@ -7,11 +7,13 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from "vscode"; import * as nls from 'vscode-nls'; +import { getOutputChannel } from '../logger'; +import { RunWithoutDebuggingAdapter } from './runWithoutDebuggingAdapter'; nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); const localize: nls.LocalizeFunc = nls.loadMessageBundle(); -// Registers DebugAdapterDescriptorFactory for `cppdbg` and `cppvsdbg`. If it is not ready, it will prompt a wait for the download dialog. +// Registers DebugAdapterDescriptorFactory for `cppdbg` and `cppvsdbg`. // NOTE: This file is not automatically tested. abstract class AbstractDebugAdapterDescriptorFactory implements vscode.DebugAdapterDescriptorFactory { @@ -26,8 +28,15 @@ abstract class AbstractDebugAdapterDescriptorFactory implements vscode.DebugAdap } export class CppdbgDebugAdapterDescriptorFactory extends AbstractDebugAdapterDescriptorFactory { + async createDebugAdapterDescriptor(session: vscode.DebugSession, _executable?: vscode.DebugAdapterExecutable): Promise { + if (session.configuration.noDebug) { + if (noDebugSupported(session.configuration)) { + return new vscode.DebugAdapterInlineImplementation(new RunWithoutDebuggingAdapter()); + } + // If the configuration is not supported, gracefully fall back to a regular debug session and log a message to the user. + logReasonForNoDebugNotSupported(session.configuration); + } - async createDebugAdapterDescriptor(_session: vscode.DebugSession, _executable?: vscode.DebugAdapterExecutable): Promise { const adapter: string = "./debugAdapters/bin/OpenDebugAD7" + (os.platform() === 'win32' ? ".exe" : ""); const command: string = path.join(this.context.extensionPath, adapter); @@ -37,8 +46,15 @@ export class CppdbgDebugAdapterDescriptorFactory extends AbstractDebugAdapterDes } export class CppvsdbgDebugAdapterDescriptorFactory extends AbstractDebugAdapterDescriptorFactory { + async createDebugAdapterDescriptor(session: vscode.DebugSession, _executable?: vscode.DebugAdapterExecutable): Promise { + if (session.configuration.noDebug) { + if (noDebugSupported(session.configuration)) { + return new vscode.DebugAdapterInlineImplementation(new RunWithoutDebuggingAdapter()); + } + // If the configuration is not supported, gracefully fall back to a regular debug session and log a message to the user. + logReasonForNoDebugNotSupported(session.configuration); + } - async createDebugAdapterDescriptor(_session: vscode.DebugSession, _executable?: vscode.DebugAdapterExecutable): Promise { if (os.platform() !== 'win32') { void vscode.window.showErrorMessage(localize("debugger.not.available", "Debugger type '{0}' is not available for non-Windows machines.", "cppvsdbg")); return null; @@ -50,3 +66,28 @@ export class CppvsdbgDebugAdapterDescriptorFactory extends AbstractDebugAdapterD } } } + +function noDebugSupported(configuration: vscode.DebugConfiguration): boolean { + // Don't attempt to start a noDebug session if the configuration has any of these properties, which require a debug adapter to function. + return configuration.request === 'launch' && !configuration.pipeTransport && !configuration.debugServerPath && !configuration.miDebuggerServerAddress && !configuration.coreDumpPath; +} + +function logReasonForNoDebugNotSupported(configuration: vscode.DebugConfiguration): void { + const outputChannel = getOutputChannel(); + if (configuration.request !== 'launch') { + outputChannel.appendLine(localize("debugger.noDebug.requestType.not.supported", "Run Without Debugging is only supported for launch configurations.")); + } + if (configuration.pipeTransport) { + outputChannel.appendLine(localize("debugger.noDebug.pipeTransport.not.supported", "Run Without Debugging is not supported for configurations with 'pipeTransport' set.")); + } + if (configuration.debugServerPath) { + outputChannel.appendLine(localize("debugger.noDebug.debugServerPath.not.supported", "Run Without Debugging is not supported for configurations with 'debugServerPath' set.")); + } + if (configuration.miDebuggerServerAddress) { + outputChannel.appendLine(localize("debugger.noDebug.miDebuggerServerAddress.not.supported", "Run Without Debugging is not supported for configurations with 'miDebuggerServerAddress' set.")); + } + if (configuration.coreDumpPath) { + outputChannel.appendLine(localize("debugger.noDebug.coreDumpPath.not.supported", "Run Without Debugging is not supported for configurations with 'coreDumpPath' set.")); + } + outputChannel.show(true); +} diff --git a/Extension/src/Debugger/runWithoutDebuggingAdapter.ts b/Extension/src/Debugger/runWithoutDebuggingAdapter.ts new file mode 100644 index 000000000..1a8d26bd7 --- /dev/null +++ b/Extension/src/Debugger/runWithoutDebuggingAdapter.ts @@ -0,0 +1,282 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. + * See 'LICENSE' in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import * as cp from 'child_process'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import { buildShellCommandLine, sessionIsWsl } from '../common'; + +nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); +const localize = nls.loadMessageBundle(); + +/** + * A minimal inline Debug Adapter that runs the target program directly without a debug adapter + * when the user invokes "Run Without Debugging". + */ +export class RunWithoutDebuggingAdapter implements vscode.DebugAdapter { + private readonly sendMessageEmitter = new vscode.EventEmitter(); + public readonly onDidSendMessage: vscode.Event = this.sendMessageEmitter.event; + private readonly terminalListeners: vscode.Disposable[] = []; + + private seq: number = 1; + private childProcess?: cp.ChildProcess; + private terminal?: vscode.Terminal; + private terminalExecution?: vscode.TerminalShellExecution; + private hasTerminated: boolean = false; + + public handleMessage(message: vscode.DebugProtocolMessage): void { + const msg = message as { type: string; command: string; seq: number; arguments?: any; }; + if (msg.type === 'request') { + void this.handleRequest(msg); + } + } + + private async handleRequest(request: { command: string; seq: number; arguments?: any; }): Promise { + switch (request.command) { + case 'initialize': + this.sendResponse(request, {}); + this.sendEvent('initialized'); + break; + case 'launch': + await this.launch(request); + break; + case 'configurationDone': + this.sendResponse(request, {}); + break; + case 'disconnect': + case 'terminate': + this.sendResponse(request, {}); + break; + default: + this.sendResponse(request, {}); + break; + } + } + + 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 program: string = config.program ?? ''; + const args: string[] = config.args ?? []; + const cwd: string | undefined = config.cwd; + const environment: { name: string; value: string; }[] = config.environment ?? []; + const consoleMode: string = config.console ?? (config.externalConsole ? 'externalTerminal' : 'integratedTerminal'); + + // Merge the launch config's environment variables on top of the inherited process environment. + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const e of environment) { + env[e.name] = e.value; + } + + this.sendResponse(request, {}); + + if (consoleMode === 'integratedTerminal' || consoleMode === 'internalConsole') { + await this.launchIntegratedTerminal(program, args, cwd, env); + } else if (consoleMode === 'externalTerminal') { + this.launchExternalTerminal(program, args, cwd, env); + } + } + + /** + * 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 { + const terminalName = path.normalize(program); + const existingTerminal = vscode.window.terminals.find(t => t.name === terminalName); + this.terminal = existingTerminal ?? vscode.window.createTerminal({ + name: terminalName, + cwd, + env: env as Record + }); + this.terminal.show(true); + + const shellIntegration: vscode.TerminalShellIntegration | undefined = + this.terminal.shellIntegration ?? await this.waitForShellIntegration(this.terminal, 3000); + + // 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.monitorIntegratedTerminal(this.terminal); + if (program.includes(' ')) { + // VS Code does not automatically quote the program path if it has spaces. + program = `"${program}"`; + } + this.terminalExecution = shellIntegration.executeCommand(program, args); + } else { + const cmdLine: string = buildShellCommandLine('', program, args); + this.terminal.sendText(cmdLine); + + // The terminal manages its own lifecycle; notify VS Code the "debug" session is done. + this.sendEvent('terminated'); + } + } + + /** + * 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 { + const cmdLine: string = buildShellCommandLine('', program, args); + const platform: string = os.platform(); + if (platform === 'win32') { + cp.spawn('cmd.exe', ['/c', 'start', 'cmd.exe', '/K', cmdLine], { cwd, env, 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(); + } 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' + this.launchLinuxExternalTerminal(cmdLine, cwd, env); + } + this.sendEvent('terminated'); + } + + /** + * On Linux, find and launch an available terminal emulator to run the command. + */ + private launchLinuxExternalTerminal(cmdLine: string, cwd: string | undefined, env: NodeJS.ProcessEnv): void { + const bashCmd = `${cmdLine}; echo; read -p 'Press enter to continue...'`; + const bashArgs = ['bash', '-c', bashCmd]; + + // Terminal emulators in order of preference, with the correct flag style for each. + const candidates: { cmd: string; buildArgs(): string[] }[] = [ + { cmd: 'x-terminal-emulator', buildArgs: () => ['-e', ...bashArgs] }, + { cmd: 'gnome-terminal', buildArgs: () => ['-e', ...bashArgs] }, + { cmd: 'konsole', buildArgs: () => ['-e', ...bashArgs] }, + { cmd: 'xterm', buildArgs: () => ['-e', ...bashArgs] } + ]; + + // Honor the $TERMINAL environment variable if set. + const terminalEnv = process.env['TERMINAL']; + if (terminalEnv) { + candidates.unshift({ cmd: terminalEnv, buildArgs: () => ['-e', ...bashArgs] }); + } + + for (const candidate of candidates) { + try { + const result = cp.spawnSync('which', [candidate.cmd], { stdio: 'pipe' }); + if (result.status === 0) { + cp.spawn(candidate.cmd, candidate.buildArgs(), { cwd, env, detached: true, stdio: 'ignore' }).unref(); + return; + } + } catch { + continue; + } + } + + const message = localize('no.terminal.emulator', 'No terminal emulator found. Please set the $TERMINAL environment variable to your terminal emulator of choice, or install one of the following: x-terminal-emulator, gnome-terminal, konsole, xterm.'); + vscode.window.showErrorMessage(message); + } + + private escapeQuotes(arg: string): string { + return arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } + + private waitForShellIntegration(terminal: vscode.Terminal, timeoutMs: number): Promise { + return new Promise(resolve => { + let resolved: boolean = false; + const done = (shellIntegration: vscode.TerminalShellIntegration | undefined): void => { + if (resolved) { + return; + } + + resolved = true; + clearTimeout(timeout); + shellIntegrationChanged.dispose(); + terminalClosed.dispose(); + resolve(shellIntegration); + }; + + const timeout = setTimeout(() => done(undefined), timeoutMs); + const shellIntegrationChanged = vscode.window.onDidChangeTerminalShellIntegration(event => { + if (event.terminal === terminal) { + done(event.shellIntegration); + } + }); + const terminalClosed = vscode.window.onDidCloseTerminal(closedTerminal => { + if (closedTerminal === terminal) { + done(undefined); + } + }); + }); + } + + private monitorIntegratedTerminal(terminal: vscode.Terminal): void { + this.disposeTerminalListeners(); + this.terminalListeners.push( + vscode.window.onDidEndTerminalShellExecution(event => { + if (event.terminal !== terminal || event.execution !== this.terminalExecution || this.hasTerminated) { + return; + } + + if (event.exitCode !== undefined) { + this.sendEvent('exited', { exitCode: event.exitCode }); + } + + this.sendEvent('terminated'); + }), + vscode.window.onDidCloseTerminal(closedTerminal => { + if (closedTerminal !== terminal || this.hasTerminated) { + return; + } + + this.sendEvent('terminated'); + }) + ); + } + + private disposeTerminalListeners(): void { + while (this.terminalListeners.length > 0) { + this.terminalListeners.pop()?.dispose(); + } + } + + private sendResponse(request: { command: string; seq: number; }, body: object): void { + this.sendMessageEmitter.fire({ + type: 'response', + seq: this.seq++, + request_seq: request.seq, + success: true, + command: request.command, + body + } as vscode.DebugProtocolMessage); + } + + private sendEvent(event: string, body?: object): void { + if (event === 'terminated') { + if (this.hasTerminated) { + return; + } + + this.hasTerminated = true; + this.disposeTerminalListeners(); + } + + this.sendMessageEmitter.fire({ + type: 'event', + seq: this.seq++, + event, + body + } as vscode.DebugProtocolMessage); + } + + public dispose(): void { + this.terminateProcess(); + this.disposeTerminalListeners(); + this.sendMessageEmitter.dispose(); + } + + private terminateProcess(): void { + this.childProcess?.kill(); + this.childProcess = undefined; + } +} diff --git a/Extension/src/common.ts b/Extension/src/common.ts index 4fc413604..cf82a1b1b 100644 --- a/Extension/src/common.ts +++ b/Extension/src/common.ts @@ -1567,6 +1567,34 @@ export function hasMsvcEnvironment(): boolean { ); } +export function getMissingMsvcEnvironmentVariables(): string[] { + const msvcEnvVars: string[] = [ + 'DevEnvDir', + 'Framework40Version', + 'FrameworkDir', + 'FrameworkVersion', + 'INCLUDE', + 'LIB', + 'LIBPATH', + 'UCRTVersion', + 'UniversalCRTSdkDir', + 'VCIDEInstallDir', + 'VCINSTALLDIR', + 'VCToolsRedistDir', + 'VisualStudioVersion', + 'VSINSTALLDIR', + 'WindowsLibPath', + 'WindowsSdkBinPath', + 'WindowsSdkDir', + 'WindowsSDKLibVersion', + 'WindowsSDKVersion' + ]; + return msvcEnvVars.filter(envVarName => + (process.env[envVarName] === undefined || process.env[envVarName] === '') && + extensionContext?.environmentVariableCollection?.get(envVarName) === undefined + ); +} + function isIntegral(str: string): boolean { const regex = /^-?\d+$/; return regex.test(str); @@ -1849,3 +1877,14 @@ export function getVSCodeLanguageModel(): any | undefined { } return vscodelm; } + +export function sessionIsWsl(): boolean { + if (process.env.WSL_DISTRO_NAME) { + return true; + } + try { + return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); + } catch { + return false; + } +} diff --git a/Extension/test/scenarios/RunWithoutDebugging/assets/debugTest.cpp b/Extension/test/scenarios/RunWithoutDebugging/assets/debugTest.cpp new file mode 100644 index 000000000..77020e3e5 --- /dev/null +++ b/Extension/test/scenarios/RunWithoutDebugging/assets/debugTest.cpp @@ -0,0 +1,11 @@ +#include + +int main() { + std::ofstream resultFile("runWithoutDebuggingResult.txt"); + if (!resultFile) { + return 1; + } + + resultFile << 37; + return 0; +} diff --git a/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts b/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts new file mode 100644 index 000000000..52012a02f --- /dev/null +++ b/Extension/test/scenarios/RunWithoutDebugging/tests/runWithoutDebugging.integration.test.ts @@ -0,0 +1,339 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. + * See 'LICENSE' in the project root for license information. + * ------------------------------------------------------------------------------------------ */ +/* eslint-disable @typescript-eslint/triple-slash-reference */ +/// +import * as assert from 'assert'; +import * as cp from 'child_process'; +import { suite } from 'mocha'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as util from '../../../../src/common'; +import { isLinux, isMacOS, isWindows } from '../../../../src/constants'; +import { getEffectiveEnvironment } from '../../../../src/LanguageServer/devcmd'; + +interface ProcessResult { + code: number | null; + stdout: string; + stderr: string; +} + +interface TrackerState { + setBreakpointsRequestReceived: boolean; + stoppedEventReceived: boolean; + exitedEventReceived: boolean; + exitedBeforeStop: boolean; +} + +interface TrackerController { + state: TrackerState; + lastEvent: Promise<'stopped' | 'exited'>; + dispose(): void; +} + +function runProcess(command: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = cp.spawn(command, args, { cwd, env }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +async function setWindowsBuildEnvironment(): Promise { + const promise = vscode.commands.executeCommand('C_Cpp.SetVsDeveloperEnvironment', 'test'); + const timer = setInterval(() => { + void vscode.commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem'); + }, 1000); + await promise; + clearInterval(timer); + const missingVars = util.getMissingMsvcEnvironmentVariables(); + assert.strictEqual(missingVars.length, 0, `MSVC environment missing: ${missingVars.join(', ')}`); +} + +async function compileProgram(workspacePath: string, sourcePath: string, outputPath: string): Promise { + if (isWindows) { + await setWindowsBuildEnvironment(); + const env = getEffectiveEnvironment(); + const result = await runProcess('cl.exe', ['/nologo', '/EHsc', '/Zi', '/std:c++17', `/Fe:${outputPath}`, sourcePath], workspacePath, env); + assert.strictEqual(result.code, 0, `MSVC compilation failed. stdout: ${result.stdout}\nstderr: ${result.stderr}`); + return; + } + + if (isMacOS) { + const result = await runProcess('clang++', ['-std=c++17', '-g', sourcePath, '-o', outputPath], workspacePath); + assert.strictEqual(result.code, 0, `clang++ compilation failed. stdout: ${result.stdout}\nstderr: ${result.stderr}`); + return; + } + + if (isLinux) { + const result = await runProcess('g++', ['-std=c++17', '-g', sourcePath, '-o', outputPath], workspacePath); + assert.strictEqual(result.code, 0, `g++ compilation failed. stdout: ${result.stdout}\nstderr: ${result.stderr}`); + return; + } + + assert.fail(`Unsupported test platform: ${process.platform}`); +} + +async function createBreakpointAtResultWriteStatement(sourceUri: vscode.Uri): Promise { + const document = await vscode.workspace.openTextDocument(sourceUri); + const resultWriteLine = document.getText().split(/\r?\n/).findIndex((line) => line.includes('resultFile << 37;')); + assert.notStrictEqual(resultWriteLine, -1, 'Unable to find expected result-write statement for breakpoint placement.'); + const breakpoint = new vscode.SourceBreakpoint(new vscode.Location(sourceUri, new vscode.Position(resultWriteLine, 0)), true); + vscode.debug.addBreakpoints([breakpoint]); + return breakpoint; +} + +function createSessionTerminatedPromise(sessionName: string): Promise { + return new Promise((resolve) => { + const terminateSubscription = vscode.debug.onDidTerminateDebugSession((session) => { + if (session.name === sessionName) { + terminateSubscription.dispose(); + resolve(); + } + }); + }); +} + +function createTracker(debugType: string, sessionName: string, timeoutMs: number, timeoutMessage: string): TrackerController { + const state: TrackerState = { + setBreakpointsRequestReceived: false, + stoppedEventReceived: false, + exitedEventReceived: false, + exitedBeforeStop: false + }; + + let trackerRegistration: vscode.Disposable | undefined; + let timeoutHandle: NodeJS.Timeout | undefined; + + const lastEvent = new Promise<'stopped' | 'exited'>((resolve, reject) => { + timeoutHandle = setTimeout(() => { + trackerRegistration?.dispose(); + trackerRegistration = undefined; + reject(new Error(timeoutMessage)); + }, timeoutMs); + + trackerRegistration = vscode.debug.registerDebugAdapterTrackerFactory(debugType, { + createDebugAdapterTracker: (session: vscode.DebugSession): vscode.DebugAdapterTracker | undefined => { + if (session.name !== sessionName) { + return undefined; + } + + return { + onWillReceiveMessage: (message: any): void => { + if (message?.type === 'request' && message?.command === 'setBreakpoints') { + state.setBreakpointsRequestReceived = true; + } + }, + onDidSendMessage: (message: any): void => { + if (message?.type !== 'event') { + return; + } + + if (message.event === 'stopped') { + state.stoppedEventReceived = true; + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = undefined; + } + resolve('stopped'); + } + + if ((message.event === 'terminated' || message.event === 'exited') && !state.exitedEventReceived) { + state.exitedEventReceived = true; + if (!state.stoppedEventReceived) { + state.exitedBeforeStop = true; + } + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = undefined; + } + resolve('exited'); + } + } + }; + } + }); + }); + + return { + state, + lastEvent, + dispose(): void { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = undefined; + } + trackerRegistration?.dispose(); + trackerRegistration = undefined; + } + }; +} + +async function waitForResultFileValue(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) { + const value = Number.parseInt(trimmedContents, 10); + if (!Number.isNaN(value)) { + return value; + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + assert.fail(`Timed out waiting for numeric result in ${filePath}. Last contents: ${lastContents}`); +} + +suite('Run Without Debugging Integration Test', function (): void { + suiteSetup(async function (): Promise { + const extension: vscode.Extension = vscode.extensions.getExtension('ms-vscode.cpptools') || assert.fail('Extension not found'); + if (!extension.isActive) { + await extension.activate(); + } + }); + + suiteTeardown(async function (): Promise { + if (isWindows) { + await vscode.commands.executeCommand('C_Cpp.ClearVsDeveloperEnvironment'); + } + }); + + test('Run Without Debugging should not break on breakpoints and write the expected result file', async () => { + 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 sourceUri = vscode.Uri.file(sourceFile); + const resultFilePath = path.join(workspacePath, 'runWithoutDebuggingResult.txt'); + const executableName = isWindows ? 'debugTestProgram.exe' : 'debugTestProgram'; + const executablePath = path.join(workspacePath, executableName); + const sessionName = 'Run Without Debugging Result File'; + const debugType = isWindows ? 'cppvsdbg' : 'cppdbg'; + + await util.deleteFile(resultFilePath); + await compileProgram(workspacePath, sourceFile, executablePath); + + const breakpoint = await createBreakpointAtResultWriteStatement(sourceUri); + const tracker = createTracker(debugType, sessionName, 30000, 'Timed out waiting for debugger event.'); + const debugSessionTerminated = createSessionTerminatedPromise(sessionName); + + try { + const started = await vscode.debug.startDebugging( + workspaceFolder, + { + name: sessionName, + type: debugType, + request: 'launch', + program: executablePath, + args: [], + cwd: workspacePath, + externalConsole: debugType === 'cppdbg' ? false : undefined, + console: debugType === 'cppvsdbg' ? 'internalConsole' : undefined + }, + { noDebug: true }); + + assert.strictEqual(started, true, 'The noDebug launch did not start successfully.'); + + const lastEvent = await tracker.lastEvent; + await debugSessionTerminated; + const actualResultValue = await waitForResultFileValue(resultFilePath, 10000); + + assert.strictEqual(lastEvent, 'exited', 'No-debug launch should exit rather than stop on a breakpoint.'); + assert.strictEqual(tracker.state.setBreakpointsRequestReceived, false, 'a "no debug" session should not send setBreakpoints requests.'); + assert.strictEqual(tracker.state.stoppedEventReceived, false, 'a "no debug" session should not emit stopped events.'); + assert.strictEqual(actualResultValue, expectedResultValue, 'Unexpected result value from run without debugging launch.'); + } finally { + tracker.dispose(); + vscode.debug.removeBreakpoints([breakpoint]); + await util.deleteFile(resultFilePath); + } + }); + + test('Debug launch should bind and stop at the breakpoint', async () => { + 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 sourceUri = vscode.Uri.file(sourceFile); + const resultFilePath = path.join(workspacePath, 'runWithoutDebuggingResult.txt'); + const executableName = isWindows ? 'debugTestProgram.exe' : 'debugTestProgram'; + const executablePath = path.join(workspacePath, executableName); + const sessionName = 'Debug Launch Breakpoint Stop'; + const debugType = isWindows ? 'cppvsdbg' : 'cppdbg'; + const miMode = isMacOS ? 'lldb' : 'gdb'; + + await compileProgram(workspacePath, sourceFile, executablePath); + + const breakpoint = await createBreakpointAtResultWriteStatement(sourceUri); + + let launchedSession: vscode.DebugSession | undefined; + const tracker = createTracker(debugType, sessionName, 45000, 'Timed out waiting for debugger event in normal debug mode.'); + + const startedSubscription = vscode.debug.onDidStartDebugSession((session) => { + if (session.name === sessionName) { + launchedSession = session; + } + }); + + const debugSessionTerminated = createSessionTerminatedPromise(sessionName); + + try { + const started = await vscode.debug.startDebugging( + workspaceFolder, + { + name: sessionName, + type: debugType, + request: 'launch', + program: executablePath, + args: [], + cwd: workspacePath, + MIMode: debugType === 'cppdbg' ? miMode : undefined, + externalConsole: debugType === 'cppdbg' ? false : undefined, + console: debugType === 'cppvsdbg' ? 'internalConsole' : undefined + }, + { noDebug: false }); + + assert.strictEqual(started, true, 'The debug launch did not start successfully.'); + + const lastEvent = await tracker.lastEvent; + + assert.strictEqual(lastEvent, 'stopped', 'Debug launch should stop at the breakpoint before exit.'); + assert.strictEqual(tracker.state.setBreakpointsRequestReceived, true, 'Debug mode should send setBreakpoints requests.'); + assert.strictEqual(tracker.state.stoppedEventReceived, true, 'Debug mode should emit a stopped event at the breakpoint.'); + assert.strictEqual(tracker.state.exitedBeforeStop, false, 'Program exited before stopping at breakpoint in debug mode.'); + assert.strictEqual(vscode.debug.activeDebugSession?.name, sessionName, 'Debug session should still be active at breakpoint.'); + + const stoppedSession = launchedSession ?? vscode.debug.activeDebugSession; + assert.ok(stoppedSession, 'Unable to identify the running debug session for termination.'); + await vscode.debug.stopDebugging(stoppedSession); + await debugSessionTerminated; + } finally { + startedSubscription.dispose(); + tracker.dispose(); + vscode.debug.removeBreakpoints([breakpoint]); + await util.deleteFile(resultFilePath); + } + }); +});