From 5cde51de499eb1f4a420d1b8501046d3649f3e91 Mon Sep 17 00:00:00 2001 From: Damjan Cvetko Date: Mon, 1 Jul 2024 00:53:31 +0200 Subject: [PATCH 1/3] Add support for runInTerminal request --- package.json | 7 +++++- src/phpDebug.ts | 53 ++++++++++++++++++++++++++++++++----------- src/terminal.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 107e0641..610ecf03 100644 --- a/package.json +++ b/package.json @@ -201,9 +201,14 @@ }, "externalConsole": { "type": "boolean", - "description": "Launch debug target in external console.", + "description": "DEPRECATED: Launch debug target in external console.", "default": false }, + "console": { + "enum": ["internalConsole", "integratedTerminal", "externalTerminal"], + "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal", + "default": "internalConsole" + }, "args": { "type": "array", "description": "Command line arguments passed to the program.", diff --git a/src/phpDebug.ts b/src/phpDebug.ts index a04eed72..60bc2fc0 100644 --- a/src/phpDebug.ts +++ b/src/phpDebug.ts @@ -8,7 +8,7 @@ import * as childProcess from 'child_process' import * as path from 'path' import * as util from 'util' import * as fs from 'fs' -import { Terminal } from './terminal' +import { Terminal, IProgram, ProgramPidWrapper } from './terminal' import { convertClientPathToDebugger, convertDebuggerPathToClient, isPositiveMatchInGlobs } from './paths' import minimatch from 'minimatch' import { BreakpointManager, BreakpointAdapter } from './breakpoints' @@ -115,11 +115,16 @@ export interface LaunchRequestArguments extends VSCodeDebugProtocol.LaunchReques env?: { [key: string]: string } /** Absolute path to a file containing environment variable definitions. */ envFile?: string - /** If true launch the target in an external console. */ + /** DEPRECATED: If true launch the target in an external console. */ externalConsole?: boolean + /** Where to launch the debug target: internal console, integrated terminal, or external terminal. */ + console?: 'internalConsole' | 'integratedTerminal' | 'externalTerminal' } class PhpDebugSession extends vscode.DebugSession { + /** The arguments that were given to initializeRequest */ + private _initializeArgs: VSCodeDebugProtocol.InitializeRequestArguments + /** The arguments that were given to launchRequest */ private _args: LaunchRequestArguments @@ -127,7 +132,7 @@ class PhpDebugSession extends vscode.DebugSession { private _server: net.Server /** The child process of the launched PHP script, if launched by the debug adapter */ - private _phpProcess?: childProcess.ChildProcess + private _phpProcess?: IProgram /** * A map from VS Code thread IDs to Xdebug Connections. @@ -211,6 +216,7 @@ class PhpDebugSession extends vscode.DebugSession { response: VSCodeDebugProtocol.InitializeResponse, args: VSCodeDebugProtocol.InitializeRequestArguments ): void { + this._initializeArgs = args response.body = { supportsConfigurationDoneRequest: true, supportsEvaluateForHovers: true, @@ -287,18 +293,36 @@ class PhpDebugSession extends vscode.DebugSession { const program = args.program ? [args.program] : [] const cwd = args.cwd || process.cwd() const env = Object.fromEntries( - Object.entries({ ...process.env, ...getConfiguredEnvironment(args) }).map(v => [ + Object.entries(getConfiguredEnvironment(args)).map(v => [ v[0], v[1]?.replace('${port}', port.toString()), ]) ) // launch in CLI mode - if (args.externalConsole) { - const script = await Terminal.launchInTerminal( - cwd, - [runtimeExecutable, ...runtimeArgs, ...program, ...programArgs], - env - ) + if (args.externalConsole || args.console == 'integratedTerminal' || args.console == 'externalTerminal') { + let script: IProgram | undefined + if (this._initializeArgs.supportsRunInTerminalRequest) { + const kind: 'integrated' | 'external' = + args.externalConsole || args.console === 'externalTerminal' ? 'external' : 'integrated' + const ritr = await new Promise((resolve, reject) => { + this.runInTerminalRequest( + { args: [runtimeExecutable, ...runtimeArgs, ...program, ...programArgs], env, cwd, kind }, + 5000, + resolve + ) + }) + script = + ritr.success && ritr.body.shellProcessId + ? new ProgramPidWrapper(ritr.body.shellProcessId) + : undefined + } else { + script = await Terminal.launchInTerminal( + cwd, + [runtimeExecutable, ...runtimeArgs, ...program, ...programArgs], + env + ) + } + if (script) { // we only do this for CLI mode. In normal listen mode, only a thread exited event is send. script.on('exit', (code: number | null) => { @@ -306,10 +330,11 @@ class PhpDebugSession extends vscode.DebugSession { this.sendEvent(new vscode.TerminatedEvent()) }) } + // this._phpProcess = script } else { const script = childProcess.spawn(runtimeExecutable, [...runtimeArgs, ...program, ...programArgs], { cwd, - env, + env: { ...process.env, ...env }, }) // redirect output to debug console script.stdout.on('data', (data: Buffer) => { @@ -1423,8 +1448,10 @@ class PhpDebugSession extends vscode.DebugSession { } } // If launched as CLI, kill process - if (this._phpProcess) { - this._phpProcess.kill() + if (this._phpProcess?.pid) { + Terminal.killTree(this._phpProcess.pid).catch(err => + this.sendEvent(new vscode.OutputEvent(`killTree: ${err as string}\n`)) + ) } } catch (error) { this.sendErrorResponse(response, error as Error) diff --git a/src/terminal.ts b/src/terminal.ts index 51cf7dbc..67c74062 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -6,6 +6,7 @@ import * as Path from 'path' import * as FS from 'fs' import * as CP from 'child_process' +import { EventEmitter } from 'stream' export class Terminal { private static _terminalService: ITerminalService @@ -45,6 +46,65 @@ export class Terminal { } } +export interface IProgram { + readonly pid?: number | undefined + kill(signal?: NodeJS.Signals | number): boolean + on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this +} + +export class ProgramPidWrapper extends EventEmitter implements IProgram { + /** + * How often to check and see if the process exited. + */ + private static readonly terminationPollInterval = 1000 + + /** + * How often to check and see if the process exited after we send a close signal. + */ + //private static readonly killConfirmInterval = 200; + + private loop?: { timer: NodeJS.Timeout; processId: number } + + constructor(readonly pid?: number) { + super() + + if (pid) { + this.startPollLoop(pid) + } + } + + kill(signal?: number | NodeJS.Signals | undefined): boolean { + return false + } + + private startPollLoop(processId: number, interval = ProgramPidWrapper.terminationPollInterval) { + if (this.loop) { + clearInterval(this.loop.timer) + } + + const loop = { + processId, + timer: setInterval(() => { + if (!isProcessAlive(processId)) { + clearInterval(loop.timer) + this.emit('exit') + } + }, interval), + } + + this.loop = loop + } +} +function isProcessAlive(processId: number) { + try { + // kill with signal=0 just test for whether the proc is alive. It throws if not. + process.kill(processId, 0) + return true + } catch { + return false + } +} + interface ITerminalService { launchInTerminal( dir: string, From 759bd68888c1a480eb8a63b6e640c140d9a83976 Mon Sep 17 00:00:00 2001 From: Damjan Cvetko Date: Mon, 1 Jul 2024 01:08:11 +0200 Subject: [PATCH 2/3] Changelog and Readme instructions. --- CHANGELOG.md | 4 ++++ README.md | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4da25f..d1b2ee5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [1.35.0] + +- Support for console option with internalConsole, integratedTerminal and externalTerminal options. + ## [1.34.0] - Partial support for virtual workspaces diff --git a/README.md b/README.md index 9805851e..fe5e0b75 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ Options specific to CLI debugging: - `cwd`: The current working directory to use when launching the script - `runtimeExecutable`: Path to the PHP binary used for launching the script. By default the one on the PATH. - `runtimeArgs`: Additional arguments to pass to the PHP binary -- `externalConsole`: Launches the script in an external console window instead of the debug console (default: `false`) +- `externalConsole`: _DEPRECATED_ Launches the script in an external console window instead of the debug console (default: `false`) +- `console`: What kind of console to use for running the script. Possible values are: `internalConsole` (default), `integratedTerminal` or `externalTerminal`. - `env`: Environment variables to pass to the script - `envFile`: Optional path to a file containing environment variable definitions From 400566350d3c19212d0281836201a1f3b1add7cb Mon Sep 17 00:00:00 2001 From: Damjan Cvetko Date: Sun, 14 Jul 2024 00:49:19 +0200 Subject: [PATCH 3/3] In case of cli, observe the process with the PID from the Init packet --- src/cloud.ts | 2 +- src/phpDebug.ts | 23 ++++++++++++++++++----- src/terminal.ts | 14 +++++++++----- src/xdebugConnection.ts | 3 +++ 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/cloud.ts b/src/cloud.ts index fa465da2..14db0a0e 100644 --- a/src/cloud.ts +++ b/src/cloud.ts @@ -4,7 +4,7 @@ import { Transport, DbgpConnection, ENCODING } from './dbgp' import * as tls from 'tls' import * as iconv from 'iconv-lite' import * as xdebug from './xdebugConnection' -import { EventEmitter } from 'stream' +import { EventEmitter } from 'events' export declare interface XdebugCloudConnection { on(event: 'error', listener: (error: Error) => void): this diff --git a/src/phpDebug.ts b/src/phpDebug.ts index 60bc2fc0..651d8056 100644 --- a/src/phpDebug.ts +++ b/src/phpDebug.ts @@ -8,7 +8,7 @@ import * as childProcess from 'child_process' import * as path from 'path' import * as util from 'util' import * as fs from 'fs' -import { Terminal, IProgram, ProgramPidWrapper } from './terminal' +import { Terminal, IProgram, ProgramPidWrapper, isProcessAlive } from './terminal' import { convertClientPathToDebugger, convertDebuggerPathToClient, isPositiveMatchInGlobs } from './paths' import minimatch from 'minimatch' import { BreakpointManager, BreakpointAdapter } from './breakpoints' @@ -514,6 +514,21 @@ class PhpDebugSession extends vscode.DebugSession { private async initializeConnection(connection: xdebug.Connection): Promise { const initPacket = await connection.waitForInitPacket() + // track the process, if we asked the IDE to spawn it + if ( + !this._phpProcess && + (this._args.program || this._args.runtimeArgs) && + initPacket.appid && + isProcessAlive(parseInt(initPacket.appid)) + ) { + this._phpProcess = new ProgramPidWrapper(parseInt(initPacket.appid)) + // we only do this for CLI mode. In normal listen mode, only a thread exited event is send. + this._phpProcess.on('exit', (code: number | null) => { + this.sendEvent(new vscode.ExitedEvent(code ?? 0)) + this.sendEvent(new vscode.TerminatedEvent()) + }) + } + // check if this connection should be skipped if ( this._args.skipEntryPaths && @@ -1448,10 +1463,8 @@ class PhpDebugSession extends vscode.DebugSession { } } // If launched as CLI, kill process - if (this._phpProcess?.pid) { - Terminal.killTree(this._phpProcess.pid).catch(err => - this.sendEvent(new vscode.OutputEvent(`killTree: ${err as string}\n`)) - ) + if (this._phpProcess) { + this._phpProcess.kill() } } catch (error) { this.sendErrorResponse(response, error as Error) diff --git a/src/terminal.ts b/src/terminal.ts index 67c74062..7fb263db 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -6,7 +6,7 @@ import * as Path from 'path' import * as FS from 'fs' import * as CP from 'child_process' -import { EventEmitter } from 'stream' +import { EventEmitter } from 'events' export class Terminal { private static _terminalService: ITerminalService @@ -61,11 +61,11 @@ export class ProgramPidWrapper extends EventEmitter implements IProgram { /** * How often to check and see if the process exited after we send a close signal. */ - //private static readonly killConfirmInterval = 200; + private static readonly killConfirmInterval = 200 private loop?: { timer: NodeJS.Timeout; processId: number } - constructor(readonly pid?: number) { + constructor(readonly pid: number) { super() if (pid) { @@ -74,7 +74,11 @@ export class ProgramPidWrapper extends EventEmitter implements IProgram { } kill(signal?: number | NodeJS.Signals | undefined): boolean { - return false + this.startPollLoop(this.pid, ProgramPidWrapper.killConfirmInterval) + Terminal.killTree(this.pid).catch(err => { + // ignore + }) + return true } private startPollLoop(processId: number, interval = ProgramPidWrapper.terminationPollInterval) { @@ -95,7 +99,7 @@ export class ProgramPidWrapper extends EventEmitter implements IProgram { this.loop = loop } } -function isProcessAlive(processId: number) { +export function isProcessAlive(processId: number) { try { // kill with signal=0 just test for whether the proc is alive. It throws if not. process.kill(processId, 0) diff --git a/src/xdebugConnection.ts b/src/xdebugConnection.ts index f1908a4b..e3ecc32d 100644 --- a/src/xdebugConnection.ts +++ b/src/xdebugConnection.ts @@ -18,6 +18,8 @@ export class InitPacket { engineVersion: string /** the name of the engine */ engineName: string + /** the internal PID */ + appid: string /** * @param {XMLDocument} document - An XML document to read from * @param {Connection} connection @@ -30,6 +32,7 @@ export class InitPacket { this.ideKey = documentElement.getAttribute('idekey')! this.engineVersion = documentElement.getElementsByTagName('engine').item(0)?.getAttribute('version') ?? '' this.engineName = documentElement.getElementsByTagName('engine').item(0)?.textContent ?? '' + this.appid = documentElement.getAttribute('appid') ?? '' this.connection = connection } }