diff --git a/src/phpDebug.ts b/src/phpDebug.ts index 82786a05..6917180c 100644 --- a/src/phpDebug.ts +++ b/src/phpDebug.ts @@ -238,7 +238,7 @@ class PhpDebugSession extends vscode.DebugSession { this.sendEvent(new vscode.TerminatedEvent()) }) script.on('error', (error: Error) => { - this.sendEvent(new vscode.OutputEvent(error.message + '\n')) + this.sendEvent(new vscode.OutputEvent(util.inspect(error) + '\n')) }) this._phpProcess = script } @@ -279,6 +279,10 @@ class PhpDebugSession extends vscode.DebugSession { }) connection.on('error', disposeConnection) connection.on('close', disposeConnection) + connection.on('before-execute-command', () => { + // It is about to start executing PHP code + this.sendEvent(new vscode.ContinuedEvent(connection.id)) + }) await connection.waitForInitPacket() // override features from launch.json @@ -307,7 +311,7 @@ class PhpDebugSession extends vscode.DebugSession { } }) server.on('error', (error: Error) => { - this.sendEvent(new vscode.OutputEvent('ERROR: ' + error.message + '\n', 'stderr')) + this.sendEvent(new vscode.OutputEvent(util.inspect(error) + '\n')) this.sendErrorResponse(response, error) }) server.listen(args.port || 9000, (error: NodeJS.ErrnoException) => (error ? reject(error) : resolve())) @@ -458,41 +462,43 @@ class PhpDebugSession extends vscode.DebugSession { // for all connections await Promise.all( connections.map(async (connection, connectionIndex) => { - // clear breakpoints for this file - // in the future when VS Code returns the breakpoint IDs it would be better to calculate the diff - const { breakpoints } = await connection.sendBreakpointListCommand() - await Promise.all( - breakpoints - // filter to only include line breakpoints for this file - .filter( - breakpoint => - breakpoint instanceof xdebug.LineBreakpoint && - isSameUri(fileUri, breakpoint.fileUri) - ) - // remove them - .map(breakpoint => breakpoint.remove()) - ) - // set new breakpoints - await Promise.all( - xdebugBreakpoints.map(async (breakpoint, index) => { - try { - await connection.sendBreakpointSetCommand(breakpoint) - // only capture each breakpoint once - if (connectionIndex === 0) { + const promise = (async () => { + const { breakpoints } = await connection.sendBreakpointListCommand() + // clear breakpoints for this file + // in the future when VS Code returns the breakpoint IDs it would be better to calculate the diff + await Promise.all( + breakpoints + .filter( + breakpoint => + breakpoint instanceof xdebug.LineBreakpoint && + isSameUri(fileUri, breakpoint.fileUri) + ) + .map(breakpoint => breakpoint.remove()) + ) + // set new breakpoints + await Promise.all( + xdebugBreakpoints.map(async (breakpoint, index) => { + try { + await connection.sendBreakpointSetCommand(breakpoint) vscodeBreakpoints[index] = { verified: true, line: breakpoint.line } - } - } catch (error) { - // only capture each breakpoint once - if (connectionIndex === 0) { + } catch (error) { vscodeBreakpoints[index] = { verified: false, line: breakpoint.line, message: (error).message, } } - } - }) - ) + }) + ) + })() + + if (connection.isPendingExecuteCommand) { + // There is a pending execute command which could lock the connection up, so do not + // wait on the response before continuing or it can get into a deadlock + promise.catch(err => this.sendEvent(new vscode.OutputEvent(util.inspect(err) + '\n'))) + } else { + await promise + } }) ) } @@ -513,20 +519,26 @@ class PhpDebugSession extends vscode.DebugSession { const connections = Array.from(this._connections.values()) await Promise.all( connections.map(async connection => { - // get all breakpoints - const { breakpoints } = await connection.sendBreakpointListCommand() - // remove all exception breakpoints - await Promise.all( - breakpoints - .filter(breakpoint => breakpoint.type === 'exception') - .map(breakpoint => breakpoint.remove()) - ) - // set new exception breakpoints - await Promise.all( - args.filters.map(filter => - connection.sendBreakpointSetCommand(new xdebug.ExceptionBreakpoint(filter)) + const promise = (async () => { + const { breakpoints } = await connection.sendBreakpointListCommand() + await Promise.all( + breakpoints + .filter(breakpoint => breakpoint.type === 'exception') + .map(breakpoint => breakpoint.remove()) ) - ) + await Promise.all( + args.filters.map(filter => + connection.sendBreakpointSetCommand(new xdebug.ExceptionBreakpoint(filter)) + ) + ) + })() + if (connection.isPendingExecuteCommand) { + // There is a pending execute command which could lock the connection up, so do not + // wait on the response before continuing or it can get into a deadlock + promise.catch(err => this.sendEvent(new vscode.OutputEvent(util.inspect(err) + '\n'))) + } else { + await promise + } }) ) } catch (error) { @@ -552,35 +564,40 @@ class PhpDebugSession extends vscode.DebugSession { // for all connections await Promise.all( connections.map(async (connection, connectionIndex) => { - // clear breakpoints for this file - const { breakpoints } = await connection.sendBreakpointListCommand() - await Promise.all( - breakpoints - .filter(breakpoint => breakpoint.type === 'call') - .map(breakpoint => breakpoint.remove()) - ) - // set new breakpoints - await Promise.all( - args.breakpoints.map(async (functionBreakpoint, index) => { - try { - await connection.sendBreakpointSetCommand( - new xdebug.CallBreakpoint(functionBreakpoint.name, functionBreakpoint.condition) - ) - // only capture each breakpoint once - if (connectionIndex === 0) { + const promise = (async () => { + const { breakpoints } = await connection.sendBreakpointListCommand() + await Promise.all( + breakpoints + .filter(breakpoint => breakpoint.type === 'call') + .map(breakpoint => breakpoint.remove()) + ) + await Promise.all( + args.breakpoints.map(async (functionBreakpoint, index) => { + try { + await connection.sendBreakpointSetCommand( + new xdebug.CallBreakpoint( + functionBreakpoint.name, + functionBreakpoint.condition + ) + ) vscodeBreakpoints[index] = { verified: true } - } - } catch (error) { - // only capture each breakpoint once - if (connectionIndex === 0) { + } catch (error) { vscodeBreakpoints[index] = { verified: false, message: error instanceof Error ? error.message : error, } } - } - }) - ) + }) + ) + })() + + if (connection.isPendingExecuteCommand) { + // There is a pending execute command which could lock the connection up, so do not + // wait on the response before continuing or it can get into a deadlock + promise.catch(err => this.sendEvent(new vscode.OutputEvent(util.inspect(err) + '\n'))) + } else { + await promise + } }) ) } diff --git a/src/xdebugConnection.ts b/src/xdebugConnection.ts index 5902331e..4dbec618 100644 --- a/src/xdebugConnection.ts +++ b/src/xdebugConnection.ts @@ -545,6 +545,8 @@ interface Command { resolveFn: (response: XMLDocument) => any /** callback that gets called if an error happened while parsing the response */ rejectFn: (error?: Error) => any + /** whether command results in PHP code being executed or not */ + isExecuteCommand: boolean } /** @@ -585,6 +587,15 @@ export class Connection extends DbgpConnection { */ private _commandQueue: Command[] = [] + private _pendingExecuteCommand = false + /** + * Whether a command was started that executes PHP, which means the connection will be blocked from + * running any additional commands until the execution gets to the next stopping point or exits. + */ + public get isPendingExecuteCommand(): boolean { + return this._pendingExecuteCommand + } + /** Constructs a new connection that uses the given socket to communicate with XDebug. */ constructor(socket: net.Socket) { super(socket) @@ -602,6 +613,7 @@ export class Connection extends DbgpConnection { if (this._pendingCommands.has(transactionId)) { const command = this._pendingCommands.get(transactionId)! this._pendingCommands.delete(transactionId) + this._pendingExecuteCommand = false command.resolveFn(response) } if (this._commandQueue.length > 0) { @@ -624,15 +636,31 @@ export class Connection extends DbgpConnection { */ private _enqueueCommand(name: string, args?: string, data?: string): Promise { return new Promise((resolveFn, rejectFn) => { - const command = { name, args, data, resolveFn, rejectFn } - if (this._commandQueue.length === 0 && this._pendingCommands.size === 0) { - this._executeCommand(command) - } else { - this._commandQueue.push(command) - } + this._enqueue({ name, args, data, resolveFn, rejectFn, isExecuteCommand: false }) }) } + /** + * Pushes a new execute command (one that results in executing PHP code) to the queue that will be executed after all the previous + * commands have finished and we received a response. + * If the queue is empty AND there are no pending transactions (meaning we already received a response and XDebug is waiting for + * commands) the command will be executed immediately. + */ + private _enqueueExecuteCommand(name: string, args?: string, data?: string): Promise { + return new Promise((resolveFn, rejectFn) => { + this._enqueue({ name, args, data, resolveFn, rejectFn, isExecuteCommand: true }) + }) + } + + /** Adds the given command to the queue, or executes immediately if no commands are currently being processed. */ + private _enqueue(command: Command): void { + if (this._commandQueue.length === 0 && this._pendingCommands.size === 0) { + this._executeCommand(command) + } else { + this._commandQueue.push(command) + } + } + /** * Sends a command to XDebug with a new transaction ID and calls the callback on the command. This can * only be called when XDebug can actually accept commands, which is after we received a response for the @@ -649,8 +677,14 @@ export class Connection extends DbgpConnection { } commandString += '\0' const data = iconv.encode(commandString, ENCODING) - await this.write(data) this._pendingCommands.set(transactionId, command) + this._pendingExecuteCommand = command.isExecuteCommand + if (this._pendingExecuteCommand) { + // Since PHP execution commands block anything on the connection until it is + // done executing, emit that the connection is about to go into such a locked state + this.emit('before-execute-command') + } + await this.write(data) } public close() { @@ -752,22 +786,22 @@ export class Connection extends DbgpConnection { /** sends a run command */ public async sendRunCommand(): Promise { - return new StatusResponse(await this._enqueueCommand('run'), this) + return new StatusResponse(await this._enqueueExecuteCommand('run'), this) } /** sends a step_into command */ public async sendStepIntoCommand(): Promise { - return new StatusResponse(await this._enqueueCommand('step_into'), this) + return new StatusResponse(await this._enqueueExecuteCommand('step_into'), this) } /** sends a step_over command */ public async sendStepOverCommand(): Promise { - return new StatusResponse(await this._enqueueCommand('step_over'), this) + return new StatusResponse(await this._enqueueExecuteCommand('step_over'), this) } /** sends a step_out command */ public async sendStepOutCommand(): Promise { - return new StatusResponse(await this._enqueueCommand('step_out'), this) + return new StatusResponse(await this._enqueueExecuteCommand('step_out'), this) } /** sends a stop command */