diff --git a/package-lock.json b/package-lock.json index a48ba90..7c4d1cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-python-web-wasm", - "version": "0.1.0", + "version": "0.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-python-web-wasm", - "version": "0.1.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@types/path-browserify": "^1.0.0", diff --git a/package.json b/package.json index 6436226..026bc07 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,10 @@ "engines": { "vscode": "^1.71.0" }, - "main": "./dist/desktop/extension.js", - "browser": "./dist/web/extension.js", + "main": "./out/desktop/extension.js", + "browser": "./out/web/extension.js", "activationEvents": [ + "onLanguage:python", "onCommand:vscode-python-web-wasm.debug.runEditorContents", "onCommand:vscode-python-web-wasm.repl.start" ], @@ -97,6 +98,58 @@ "description": "A URL for a GitHub that hosts the python.wasm file together with the default Python libraries." } } - } + }, + "debuggers": [ + { + "label": "Python PDB", + "languages": [ + "python" + ], + "type": "python-pdb-node", + "when": "!virtualWorkspace", + "configurationAttributes": { + "launch": { + "properties": { + "args": { + "default": [], + "description": "Command line arguments passed to the file.", + "items": { + "type": "string" + }, + "type": [ + "array", + "string" + ] + }, + "cwd": { + "default": "${workspaceFolder}", + "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).", + "type": "string" + }, + "file": { + "default": "${file}", + "description": "Absolute path to the python file.", + "type": "string" + }, + "module": { + "description": "Name of module to debug. If this entry exists, file is ignored", + "type": "string" + }, + "stopOnEntry": { + "description": "Whether or not to stop the debugger on the first line of the first file", + "default": true, + "type": "boolean" + }, + "python": { + "default": "${command:python.interpreterPath}", + "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.", + "type": "string" + } + } + } + } + } + ] + } } diff --git a/src/common/DEBUGGER.md b/src/common/DEBUGGER.md new file mode 100644 index 0000000..b649556 --- /dev/null +++ b/src/common/DEBUGGER.md @@ -0,0 +1,42 @@ +# PDB based debugger + +This debugger uses [pdb](https://docs.python.org/3/library/pdb.html) to start a python file. It then translates all [DAP](https://microsoft.github.io/debug-adapter-protocol/overview) messages into pdb commands in order to allow debugging in VS code. + +For example, this message: + +```json +{ + command: "setBreakpoints", + arguments: { + source: { + name: "test_longoutput.py", + path: "d:\\Source\\Testing\\Testing_Pyright\\test_longoutput.py", + }, + lines: [ + 3, + ], + breakpoints: [ + { + line: 3, + }, + ], + sourceModified: false, + }, + type: "request", + seq: 3, +} +``` + +Gets translated into the pdb `b(reak)` command: + +``` +b d:\\Source\\Testing\\Testing_Pyright\\test_longoutput.py:3 +``` + +# Left to do +- Test to write + - Multiple files involved + - recursion and stack changing + - taking input from user + - what happens with threads +- Exception handling (stop on caught) \ No newline at end of file diff --git a/src/common/debugAdapter.ts b/src/common/debugAdapter.ts index 3a0956c..07189c5 100644 --- a/src/common/debugAdapter.ts +++ b/src/common/debugAdapter.ts @@ -6,147 +6,856 @@ import * as vscode from 'vscode'; import { DebugProtocol } from '@vscode/debugprotocol'; - +import { ServicePseudoTerminal, TerminalMode } from '@vscode/sync-api-service'; +import { Spawnee, Spawner } from './spawner'; import RAL from './ral'; -import { Event, Response } from './debugMessages'; -import { Launcher } from './launcher'; -import { ServicePseudoTerminal } from '@vscode/sync-api-service'; -import { Terminals } from './terminals'; - -export class DebugAdapter implements vscode.DebugAdapter { - private readonly context: vscode.ExtensionContext; +const StackFrameRegex = /^[>,\s]+(.+)\((\d+)\)(.*)\(\)/; +const ScrapeDirOutputRegex = /\[(.*)\]/; +const BreakpointRegex = /Breakpoint (\d+) at (.+):(\d+)/; +const PossibleStepExceptionRegex = /^\w+:\s+.*\r*\n>/; +const PrintExceptionMessage = `debug_pdb_print_exc_message`; +const SetupExceptionMessage = `alias debug_pdb_print_exc_message !import sys; print(sys.exc_info()[1])`; +const PrintExceptionTraceback = `debug_pdb_print_exc_traceback`; +const SetupExceptionTraceback = `alias debug_pdb_print_exc_traceback !import traceback; import sys; traceback.print_exception(*sys.exc_info())`; +const PdbTerminator = `(Pdb) `; - private sequenceNumber: number; - private _sendMessage: vscode.EventEmitter; +export class DebugAdapter implements vscode.DebugAdapter { + private _launcher: () => void; + private _cwd: string | undefined; + private _sequence = 0; + private _spawner: Spawner; + private _debuggee: Spawnee | undefined; + private _disposables: vscode.Disposable[] = []; + private _outputChain: Promise | undefined; + private _outputEmitter = new vscode.EventEmitter(); + private _stopped = true; + private _stopOnEntry = false; + private _currentFrame = 1; + private _disposed = false; + private _uncaughtException = false; + private _workspaceFolder: vscode.WorkspaceFolder | undefined; + private _boundBreakpoints: DebugProtocol.Breakpoint[] = []; + private _didSendMessageEmitter: vscode.EventEmitter = + new vscode.EventEmitter(); - private launcher: Launcher | undefined; + private static _terminal: vscode.Terminal; + private static _debugTerminal: ServicePseudoTerminal; - constructor(_vscodeSession: vscode.DebugSession, context: vscode.ExtensionContext) { - this.context = context; - this.sequenceNumber = 1; - this._sendMessage = new vscode.EventEmitter(); - this.onDidSendMessage = this._sendMessage.event; + constructor( + readonly session: vscode.DebugSession, + readonly context: vscode.ExtensionContext, + private readonly _ral: RAL + ) { + this._spawner = _ral.spawner.create(); + this._stopOnEntry = session.configuration.stopOnEntry; + this._workspaceFolder = session.workspaceFolder || + (vscode.workspace.workspaceFolders ? vscode.workspace.workspaceFolders[0] : undefined); + this._cwd = session.configuration.cwd || this._workspaceFolder; + if (!DebugAdapter._debugTerminal) { + DebugAdapter._debugTerminal = ServicePseudoTerminal.create(TerminalMode.idle); + DebugAdapter._terminal = vscode.window.createTerminal({ name: 'Python PDB', pty: DebugAdapter._debugTerminal }); + } + this._launcher = this._computeLauncher(session.configuration); + } + get onDidSendMessage(): vscode.Event { + return this._didSendMessageEmitter.event; } - - onDidSendMessage: vscode.Event; - handleMessage(message: DebugProtocol.ProtocolMessage): void { if (message.type === 'request') { - this.handleRequest(message as DebugProtocol.Request).catch(console.error); + this._handleRequest(message as DebugProtocol.Request); + } + } + dispose() { + // Hack, readlinecallback needs to be reset. We likely have an outstanding promise + if (!this._disposed) { + this._disposed = true; + DebugAdapter._debugTerminal?.handleInput!('\r'); + (DebugAdapter._debugTerminal as any).lines.clear(); + DebugAdapter._debugTerminal?.setMode(TerminalMode.idle); } } - private async handleRequest(request: DebugProtocol.Request): Promise { - switch (request.command) { + private _computeLauncher(config: vscode.DebugConfiguration) { + if (!config.module) { + return () => { + void this._launchpdbforfile(config.file, this._cwd || config.cwd, config.args || []); + }; + } else { + return () => { + void this._launchpdbformodule(config.module, this._cwd || config.cwd, config.args || []); + }; + } + } + + private _handleRequest(message: DebugProtocol.Request) { + switch (message.command) { + case 'launch': + void this._handleLaunch(message as DebugProtocol.LaunchRequest); + break; + + case 'disconnect': + this._handleDisconnect(message as DebugProtocol.DisconnectRequest); + case 'initialize': - const result = await this.handleInitialize(request.arguments as DebugProtocol.InitializeRequestArguments); - const response: DebugProtocol.InitializeResponse = new Response(request); - response.body = result; - this._sendMessage.fire(response); + this._handleInitialize(message as DebugProtocol.InitializeRequest); break; - case 'launch': - await this.handleLaunch(request.arguments as DebugProtocol.LaunchRequestArguments); - this._sendMessage.fire(new Response(request)); + + case 'threads': + this._handleThreads(message as DebugProtocol.ThreadsRequest); + break; + + case 'stackTrace': + void this._handleStackTrace(message as DebugProtocol.StackTraceRequest); + break; + + case 'scopes': + void this._handleScopesRequest(message as DebugProtocol.ScopesRequest); + break; + + case 'variables': + void this._handleVariablesRequest(message as DebugProtocol.VariablesRequest); + break; + + case 'setBreakpoints': + void this._handleSetBreakpointsRequest(message as DebugProtocol.SetBreakpointsRequest); + break; + + case 'configurationDone': + this._handleConfigurationDone(message as DebugProtocol.ConfigurationDoneRequest); + break; + + case 'continue': + this._handleContinue(message as DebugProtocol.ContinueRequest); break; + case 'terminate': - await this.handleTerminate(request.arguments as DebugProtocol.TerminateArguments); - this._sendMessage.fire(new Response(request)); - this.sendTerminated(); + this._handleTerminate(message as DebugProtocol.TerminateRequest); break; - case 'restart': - await this.handleRestart(request.arguments as DebugProtocol.RestartArguments); - this._sendMessage.fire(new Response(request)); + + case 'next': + void this._handleNext(message as DebugProtocol.NextRequest); break; + + case 'stepIn': + void this._handleStepIn(message as DebugProtocol.StepInRequest); + break; + + case 'stepOut': + void this._handleStepOut(message as DebugProtocol.StepOutRequest); + break; + + case 'evaluate': + void this._handleEvaluate(message as DebugProtocol.EvaluateRequest); + break; + + case 'exceptionInfo': + void this._handleExceptionInfo(message as DebugProtocol.ExceptionInfoRequest); + break; + default: - this._sendMessage.fire(new Response(request, `Unhandled request ${request.command}`)); - break; - } - } - - private async handleInitialize(args: DebugProtocol.InitializeRequestArguments): Promise { - return { - supportsConfigurationDoneRequest: false, - supportsFunctionBreakpoints: false, - supportsConditionalBreakpoints: false, - supportsHitConditionalBreakpoints: false, - supportsEvaluateForHovers: false, - supportsStepBack: false, - supportsSetVariable: false, - supportsRestartFrame: false, - supportsGotoTargetsRequest: false, - supportsStepInTargetsRequest: false, - supportsCompletionsRequest: false, - supportsModulesRequest: false, - supportsRestartRequest: false, - supportsExceptionOptions: false, - supportsValueFormattingOptions: false, - supportsExceptionInfoRequest: false, - supportTerminateDebuggee: true, - supportSuspendDebuggee: false, - supportsDelayedStackTraceLoading: false, - supportsLoadedSourcesRequest: false, - supportsLogPoints: false, - supportsTerminateThreadsRequest: false, - supportsSetExpression: false, - supportsTerminateRequest: true, - supportsDataBreakpoints: false, - supportsReadMemoryRequest: false, - supportsWriteMemoryRequest: false, - supportsDisassembleRequest: false, - supportsCancelRequest: false, - supportsBreakpointLocationsRequest: false, - supportsClipboardContext: false, - supportsSteppingGranularity: false, - supportsInstructionBreakpoints: false, - supportsExceptionFilterOptions: false, - supportsSingleThreadExecutionRequests: false - }; - } - - private async handleLaunch(args: DebugProtocol.LaunchRequestArguments & { program?: string, ptyInfo?: { uuid: string } }): Promise { - let pty: ServicePseudoTerminal | undefined; - if (args.ptyInfo !== undefined) { - const uuid = args.ptyInfo.uuid; - pty = Terminals.getTerminalInUse(uuid); - } - return this.launch(args.program, pty); - } - - private async handleTerminate(args: DebugProtocol.TerminateArguments): Promise { - if (this.launcher === undefined) { - return; + console.log(`Unknown debugger command ${message.command}`); + break; } - await this.launcher.terminate(); - this.launcher = undefined; } - private async handleRestart(args: DebugProtocol.RestartArguments): Promise { - if (this.launcher === undefined) { - return; + private _handleStdout(data: string) { + this._outputEmitter.fire(data); + } + + private _handleStderr(data: string) { + // Both handled the same for now. + return this._handleStdout(data); + } + + private _sendResponse(response: T) { + this._sequence += 1; + this._didSendMessageEmitter.fire({...response, seq: this._sequence}); + } + + private _sendEvent(event: T) { + this._sequence += 1; + this._didSendMessageEmitter.fire({...event, seq: this._sequence}); + } + + private _sendStoppedEvent(reason: string, breakpointHit?: DebugProtocol.Breakpoint, text?: string) { + if (breakpointHit && breakpointHit.id) { + this._sendEvent({ + type: 'event', + seq: 1, + event: 'stopped', + body: { + reason: 'breakpoint', + threadId: 1, + allThreadsStopped: true, + hitBreakpointIds: [breakpointHit.id] + } + }); + } else { + this._sendEvent({ + type: 'event', + seq: 1, + event: 'stopped', + body: { + reason, + threadId: 1, + allThreadsStopped: true, + text + } + }); + } + } + + private async _handleLaunch(message: DebugProtocol.LaunchRequest) { + if (!this._debuggee) { + // Startup pdb for the main file + + // Wait for debuggee to emit first bit of output before continuing + await this._waitForPdbOutput('command', this._launcher); + + // Setup an alias for printing exc info + await this._executecommand(SetupExceptionMessage); + await this._executecommand(SetupExceptionTraceback); + + // Send a message to the debug console to indicate started debugging + this._sendToDebugConsole(`PDB debugger connected.\r\n`); + + // Setup listening to user input + void this._handleUserInput(); + + // PDB should have stopped at the entry point and printed out the first line + + // Send back the response + this._sendResponse({ + type: 'response', + request_seq: message.seq, + success: !this._debuggee!.killed, + command: message.command, + seq: 1 + }); + } + } + + private _terminate() { + if (this._debuggee) { + this._writetostdin('exit\n'); + this._debuggee.kill(); + this._debuggee = undefined; + } + } + + private _handleDisconnect(message: DebugProtocol.DisconnectRequest) { + this._terminate(); + this._sendResponse({ + type: 'response', + request_seq: message.seq, + success: true, + command: message.command, + seq: 1, + }); + } + + private _handleInitialize(message: DebugProtocol.InitializeRequest) { + // Send back the initialize response + this._sendResponse({ + type: 'response', + request_seq: message.seq, + success: true, + command: message.command, + seq: 1, + body: { + supportsConditionalBreakpoints: true, + supportsConfigurationDoneRequest: true, + supportsSteppingGranularity: true, + supportsTerminateRequest: true, + supportsExceptionInfoRequest: true + } + }); + + // Send back the initialized event to indicate ready to receive breakpoint requests + this._sendEvent({ + type: 'event', + event: 'initialized', + seq: 1 + }); + } + + private _handleThreads(message: DebugProtocol.ThreadsRequest) { + // PDB doesn't handle threads, (see https://github.com/python/cpython/issues/85743) + // Just respond with a single thread + this._sendResponse({ + type: 'response', + request_seq: message.seq, + success: true, + command: message.command, + seq: 1, + body: { + threads: [ + { + id: 1, + name: 'Main Thread' + } + ] + } + }); + } + + private _waitForPdbOutput(mode: 'run' | 'command', generator: () => void): Promise { + const current = this._outputChain ?? Promise.resolve(''); + this._outputChain = current.then(() => { + return new Promise((resolve, reject) => { + let output = ''; + const disposable = this._outputEmitter.event((str) => { + // In command mode, remove carriage returns. Makes handling simpler + str = mode === 'command' ? str.replace(/\r/g, '') : str; + + // We are finished when the output ends with `(Pdb) ` + if (str.endsWith(PdbTerminator)) { + disposable.dispose(); + output = `${output}${str.slice(0, str.length - PdbTerminator.length)}`; + this._stopped = true; + resolve(output); + } else if (mode === 'run') { + // In run mode, send to console + this._sendToUserConsole(str); + } else { + // In command mode, save + output = `${output}${str}`; + } + }); + this._stopped = false; + generator(); + }); + }); + return this._outputChain; + } + + private _parseStackFrames(frames: string): DebugProtocol.StackFrame[] { + let result: DebugProtocol.StackFrame[] = []; + + // Split frames into lines + const lines = frames.replace(/\r/g, '').split('\n'); + + // Go through each line and return frames that are user code + lines.forEach((line, index) => { + const frameParts = StackFrameRegex.exec(line); + if (frameParts) { + const sepIndex = frameParts[1].replace(/\\/g, '/').lastIndexOf('/'); + const name = sepIndex >= 0 ? frameParts[1].slice(sepIndex) : frameParts[1]; + // Insert at the front so last frame is on front of list + result.splice(0, 0, { + id: result.length+1, + source: { + name, + path: frameParts[1], + sourceReference: 0 // Don't retrieve source from pdb + }, + name: frameParts[3], + line: parseInt(frameParts[2]), + column: 0 + }); + } + }); + + // Reverse the ids + result = result.map((v, i) => { + return { + ...v, + id: i + 1, + }; + }); + return result; + } + + private async _handleStackTrace(message: DebugProtocol.StackTraceRequest) { + // Ask PDB for the current frame + const frames = await this._executecommand('where'); + + // Parse the frames + const stackFrames = this._parseStackFrames(frames) + .filter(f => f.source?.path && this._isMyCode(f.source?.path)); + + // Return the stack trace + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + totalFrames: stackFrames.length, + stackFrames + } + }); + } + + private async _handleScopesRequest(message: DebugProtocol.ScopesRequest) { + // When the scopes request comes in, it has the frame that is being requested + // If not the same as our current frame, move up or down + await this._switchCurrentFrame(message.arguments.frameId); + + // For now have just a single scope all the time. PDB doesn't + // really have a way other than asking for 'locals()' or 'globals()' + // but then we have to figure out the difference. + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + scopes: [ + { + name: 'locals', + variablesReference: 1, + expensive: false + } + ] + } + }); + } + + private async _handleVariablesRequest(message: DebugProtocol.VariablesRequest) { + // Use the dir() python command to get back the list of current variables + const dir = await this._executecommand('dir()'); + const scrapedDir = ScrapeDirOutputRegex.exec(dir); + + // Go backwards through this list until we get something that starts without + // a double underscore + const entries = scrapedDir + ? scrapedDir[1].split(',') + .map(s => s.trim()) + .map(s => s.slice(1, s.length-1)) + .filter(e => !e.startsWith('__') || e === '__file__') + : []; + + // For each entry we need to make a request to pdb to get its value. This might take a while + // TODO: Handle limits here + const variables = await Promise.all(entries.map(async (e) => { + const value = await this._executecommand(`p ${e}`); + const result: DebugProtocol.Variable = { + name: e, + value, + variablesReference: 0 + }; + return result; + })); + + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + variables + } + }); + } + + private _handleConfigurationDone(message: DebugProtocol.ConfigurationDoneRequest) { + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + }); + + if (this._stopOnEntry) { + // Send back the stopped location. This should cause + // VS code to ask for the stack frame + this._sendStoppedEvent('entry'); + } else if (this._stopped) { + // Not stopping, tell pdb to continue. We should have + // gotten any breakpoint requests already + void this._continue(); + } + } + + private _sendTerminated() { + this._terminate(); + this._sendEvent({ + type: 'event', + event: 'terminated', + seq: 1, + }); + } + + private _handleTerminate(message: DebugProtocol.TerminateRequest) { + this._sendTerminated(); + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + }); + } + + private async _handleSetBreakpointsRequest(message: DebugProtocol.SetBreakpointsRequest) { + const results: DebugProtocol.Breakpoint[] = []; + + // If there is a source file, clear all breakpoints in this source + if (message.arguments.source.path) { + const numbers = this._boundBreakpoints + .filter(b => b.source?.path === message.arguments.source.path) + .map(b => b.id); + if (numbers.length) { + await this._executecommand(`cl ${numbers.join(' ')}`); + this._boundBreakpoints = this._boundBreakpoints + .filter(b => b.source?.path !== message.arguments.source.path); + } + } + + // Use the 'b' command to create breakpoints + if (message.arguments.breakpoints) { + await Promise.all(message.arguments.breakpoints.map(async (b) => { + const result = await this._executecommand(`b ${message.arguments.source.path}:${b.line}`); + const parsed = BreakpointRegex.exec(result); + if (parsed) { + const breakpoint: DebugProtocol.Breakpoint = { + id: parseInt(parsed[1]), + line: parseInt(parsed[3]), + source: { + path: parsed[2] + }, + verified: true + }; + this._boundBreakpoints.push(breakpoint); + results.push(breakpoint); + } + })); + } + + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + breakpoints: results + } + }); + } + + private async _launchpdb(args: string[], cwd: string) { + this._debuggee = await this._spawner.spawn(args, cwd); + this._disposables.push(this._debuggee!.stdout(this._handleStdout.bind(this))); + this._disposables.push(this._debuggee!.stderr(this._handleStderr.bind(this))); + this._disposables.push(this._debuggee!.exit((_code) => { + this._sendStoppedEvent('exit'); + })); + } + + private async _launchpdbforfile(file: string, cwd: string, args: string[]) { + this._sendToUserConsole(`python ${file} ${args.join(' ')}\r\n`); + return this._launchpdb( ['-m' ,'pdb', file, ...args], cwd); + } + + private async _launchpdbformodule(module: string, cwd: string, args: string[]) { + this._sendToUserConsole(`python -m ${module} ${args.join(' ')}\r\n`); + return this._launchpdb( ['-m' ,'pdb', '-m', module, ...args], cwd); + } + + private _isMyCode(file: string): boolean { + // Determine if this file is in the current workspace or not + if (this._workspaceFolder) { + const root = this._workspaceFolder.uri.fsPath.toLowerCase(); + return file.toLowerCase().startsWith(root); + } else { + // Otherwise no workspace folder and just a loose file. Use the starting file + const root = this._cwd?.toLowerCase(); + return root ? file.toLowerCase().startsWith(root) : false; + } + } + + private _handleProgramFinished(output: string) { + const finishedIndex = output.indexOf('The program finished and will be restarted'); + if (finishedIndex >= 0) { + this._sendToUserConsole(output.slice(0, finishedIndex)); } - await this.launcher.terminate(); - this.launcher = undefined; - await this.launch(); + // Program finished. Disconnect + this._sendTerminated(); } - private sendTerminated(): void { - const terminated: DebugProtocol.TerminatedEvent = new Event('terminated', { restart: false }); - this._sendMessage.fire(terminated); + private async _handleUncaughtException(output: string) { + const uncaughtIndex = output.indexOf('Uncaught exception. Entering post mortem debugging'); + if (uncaughtIndex >= 0) { + this._sendToUserConsole(output.slice(0, uncaughtIndex)); + } + + // Uncaught exception. Don't let any run commands be executed + this._uncaughtException = true; + + // Combine the two + this._sendStoppedEvent('exception'); + } + + private _handleFunctionReturn(output: string) { + const returnIndex = output.indexOf('--Return--'); + if (returnIndex > 0) { + this._sendToUserConsole(output.slice(0, returnIndex)); + } + return this._executerun('s'); + } + + private _handleFunctionCall(output:string) { + const callIndex = output.indexOf('--Call--'); + if (callIndex > 0) { + this._sendToUserConsole(output.slice(0, callIndex)); + } + return this._executerun('s'); + } + + private async _handleStopped(lastCommand: string, output: string) { + // Check for the step case where the step printed out an exception + // We don't want the exception to print out. If we were + // trying to catch caught exceptions, then maybe, but for now it + // is inconsistent with the behavior of continue. + if (lastCommand !== 'c' && PossibleStepExceptionRegex.test(output)) { + return this._executerun('s'); + } + // Filter out non 'frame' output. Send it to the output as + // it should be output from the process. + let nonFrameIndex = output.indexOf('\n> '); + if (nonFrameIndex >= 0) { + this._sendToUserConsole(output.slice(0, nonFrameIndex+1)); + output = output.slice(nonFrameIndex); + } + + // Parse the output. It should have the frames in it + const frames = this._parseStackFrames(output); + + // The topmost frame needs to be 'my code' or we should step out of the current + // frame + if (frames.length > 0 && !this._isMyCode(frames[0].source!.path!)) { + return this._stepOutOf(); + } + + // Otherwise we stopped. See if this location matches one of + // our current breakpoints + const match = frames.length > 0 ? this._boundBreakpoints.find( + b => b.line === frames[0].line && b.source?.path === frames[0].source?.path) : undefined; + this._sendStoppedEvent('step', match); } - private async launch(program?: string, pty?: ServicePseudoTerminal): Promise { - if (this.launcher !== undefined) { + private async _switchCurrentFrame(newFrame: number) { + if (this._currentFrame !== newFrame) { + const count = newFrame - this._currentFrame; + const frameCommand = count > 0 ? 'u' : 'd'; + await this._executecommand(`${frameCommand} ${Math.abs(count)}`); + this._currentFrame = newFrame; + } + } + + private async _handleUserInput() { + if (!this._disposed) { + DebugAdapter._debugTerminal.setMode(TerminalMode.inUse); + const output = await DebugAdapter._debugTerminal.readline(); + if (!this._stopped) { + // User typed something in, send it to the program + this._writetostdin(output); + } + // Recurse + void this._handleUserInput(); + } + } + + private _executerun(runcommand: string) { + // If at an unhandled exception, just terminate (user hit go after the exception happened) + if (this._uncaughtException) { + this._sendTerminated(); return; } - this.launcher = RAL().launcher.create(); - this.launcher.onExit().then((_rval) => { - this.launcher = undefined; - this.sendTerminated(); - }).catch(console.error); - await this.launcher.run(this.context, program?.replace(/\\/g, '/'), pty); + + // To prevent a large recursive chain, execute the rest of this in a timeout + this._ral.timer.setTimeout(async () => { + // If the current frame isn't the topmost, force it to the topmost. + // This is how debugpy works. It always steps the topmost frame + await this._switchCurrentFrame(1); + + // Then execute our run command + const output = await this._waitForPdbOutput('run', () => this._writetostdin(`${runcommand}\n`)); + + // We should be stopped now. Depends upon why + if (output.includes('The program finished and will be restarted')) { + this._handleProgramFinished(output); + } else if (output.includes('Uncaught exception. Entering post mortem debugging')) { + await this._handleUncaughtException(output); + } else if (output.includes('--Return--')) { + await this._handleFunctionReturn(output); + } else if (output.includes('--Call--')) { + await this._handleFunctionCall(output); + } else { + await this._handleStopped(runcommand, output); + } + }, 1); + } + private async _continue() { + // see https://docs.python.org/3/library/pdb.html#pdbcommand-continue + // Send a continue command. Waiting for the first output. + return this._executerun('c'); } - dispose() { + private async _stepInto() { + // see https://docs.python.org/3/library/pdb.html#pdbcommand-step + return this._executerun('s'); + } + + private async _stepOver() { + // see https://docs.python.org/3/library/pdb.html#pdbcommand-next + return this._executerun('n'); + } + + private async _stepOutOf() { + // see https://docs.python.org/3/library/pdb.html#pdbcommand-return + return this._executerun('r'); + } + + private _handleContinue(message: DebugProtocol.ContinueRequest) { + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + allThreadsContinued: true + } + }); + void this._continue(); + } + + private _handleNext(message: DebugProtocol.NextRequest) { + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + allThreadsContinued: true + } + }); + void this._stepOver(); + } + + private _handleStepIn(message: DebugProtocol.StepInRequest) { + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + allThreadsContinued: true + } + }); + void this._stepInto(); + } + + private _handleStepOut(message: DebugProtocol.StepOutRequest) { + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + allThreadsContinued: true + } + }); + void this._stepOutOf(); } -} \ No newline at end of file + + private async _handleEvaluate(message: DebugProtocol.EvaluateRequest) { + // Might have to switch frames + const startingFrame = this._currentFrame; + if (message.arguments.frameId && message.arguments.frameId !== this._currentFrame) { + await this._switchCurrentFrame(message.arguments.frameId); + } + + // Special case `print(` to just call print. Otherwise we get + // the return value of 'None' in the output, and it's unlikely the user + // wanted that + const command = message.arguments.expression.startsWith(`print(`) + ? message.arguments.expression + : `p ${message.arguments.expression}`; + const output = await this._executecommand(command); + + // Switch back to the starting frame if necessary + if (this._currentFrame !== startingFrame) { + await this._switchCurrentFrame(startingFrame); + } + + // Send the response with our result + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + result: output, + variablesReference: 0 + } + }); + } + + private async _handleExceptionInfo(message: DebugProtocol.ExceptionInfoRequest) { + // Get the current exception traceback + const msg = await this._executecommand(PrintExceptionMessage); + const traceback = await this._executecommand(PrintExceptionTraceback); + + // Turn it into something VS code understands + this._sendResponse({ + success: true, + command: message.command, + type: 'response', + seq: 1, + request_seq: message.seq, + body: { + exceptionId: msg, + breakMode: this._uncaughtException ? 'unhandled' : 'userUnhandled', + details: { + stackTrace: traceback + } + } + }); + + } + + private _writetostdin(text: string) { + this._debuggee?.stdin(text); + } + + private async _executecommand(command: string): Promise { + if (!this._stopped && this._outputChain) { + // If we're not currently stopped, then we must be waiting for output + await this._outputChain; + } + + // Send a 'command' to pdb + return this._waitForPdbOutput('command', () => this._writetostdin(`${command}\n`)); + } + + private _sendToUserConsole(data: string) { + // Make sure terminal is shown before we write output + DebugAdapter._terminal.show(); + DebugAdapter._debugTerminal.write(data); + } + + private _sendToDebugConsole(data: string) { + this._sendEvent({ + type: 'event', + seq: 1, + event: 'output', + body: { + output: data + } + }); + } +} + diff --git a/src/common/extension.ts b/src/common/extension.ts index 872611c..1b1d58b 100644 --- a/src/common/extension.ts +++ b/src/common/extension.ts @@ -4,11 +4,8 @@ * ------------------------------------------------------------------------------------------ */ import { - CancellationToken, commands, debug, DebugAdapterDescriptor, DebugAdapterInlineImplementation, DebugConfiguration, - DebugSession, ExtensionContext, Uri, window, WorkspaceFolder, workspace -} from 'vscode'; + commands, ExtensionContext, Uri, window} from 'vscode'; -import { DebugAdapter } from './debugAdapter'; import PythonInstallation from './pythonInstallation'; import RAL from './ral'; import { Terminals } from './terminals'; @@ -21,49 +18,6 @@ function isCossOriginIsolated(): boolean { return false; } -export class DebugConfigurationProvider implements DebugConfigurationProvider { - - constructor(private readonly preloadPromise: Promise) { - } - - /** - * Massage a debug configuration just before a debug session is being launched, - * e.g. add all missing attributes to the debug configuration. - */ - async resolveDebugConfiguration(folder: WorkspaceFolder | undefined, config: DebugConfiguration, token?: CancellationToken): Promise { - if (!isCossOriginIsolated()) { - return undefined; - } - await this.preloadPromise; - if (!config.type && !config.request && !config.name) { - const editor = window.activeTextEditor; - if (editor && editor.document.languageId === 'python') { - config.type = 'python-web-wasm'; - config.name = 'Launch'; - config.request = 'launch'; - config.program = '${file}'; - config.stopOnEntry = false; - } - } - - if (!config.program) { - await window.showInformationMessage('Cannot find a Python file to debug'); - return undefined; - } - - return config; - } -} - -export class DebugAdapterDescriptorFactory implements DebugAdapterDescriptorFactory { - constructor(private readonly context: ExtensionContext, private readonly preloadPromise: Promise) { - } - async createDebugAdapterDescriptor(session: DebugSession): Promise { - await this.preloadPromise; - return new DebugAdapterInlineImplementation(new DebugAdapter(session, this.context)); - } -} - export function activate(context: ExtensionContext) { const preloadPromise = PythonInstallation.preload(); context.subscriptions.push( @@ -94,29 +48,6 @@ export function activate(context: ExtensionContext) { } return false; }), - commands.registerCommand('vscode-python-web-wasm.debug.debugEditorContents', async (resource: Uri) => { - if (!isCossOriginIsolated()) { - return false; - } - let targetResource = resource; - if (!targetResource && window.activeTextEditor) { - targetResource = window.activeTextEditor.document.uri; - } - if (targetResource) { - await preloadPromise; - const pty = Terminals.getExecutionTerminal(targetResource, true); - const data: Terminals.Data = pty.data; - return debug.startDebugging(undefined, { - type: 'python-web-wasm', - name: 'Debug Python in WASM', - request: 'launch', - program: targetResource.toString(true), - stopOnEntry: true, - ptyInfo: { uuid: data.uuid } - }); - } - return false; - }), commands.registerCommand('vscode-python-web-wasm.repl.start', async () => { if (!isCossOriginIsolated()) { return false; @@ -144,12 +75,7 @@ export function activate(context: ExtensionContext) { }); }) ); - - // const provider = new DebugConfigurationProvider(preloadPromise); - // context.subscriptions.push(debug.registerDebugConfigurationProvider('python-web-wasm', provider)); - - // const factory = new DebugAdapterDescriptorFactory(context, preloadPromise); - // context.subscriptions.push(debug.registerDebugAdapterDescriptorFactory('python-web-wasm', factory)); + return preloadPromise; } export function deactivate(): Promise { diff --git a/src/common/ral.ts b/src/common/ral.ts index 5841912..2dd1e75 100644 --- a/src/common/ral.ts +++ b/src/common/ral.ts @@ -5,13 +5,20 @@ import type { IPath } from './path'; import type { Launcher } from './launcher'; +import { Spawner } from './spawner'; interface RAL { readonly launcher: { create(): Launcher; } + readonly spawner: { + create(): Spawner; + } readonly path: IPath; readonly isCrossOriginIsolated: boolean; + readonly timer: { + setTimeout(callback: () => void, timeout: number): any; + } } let _ral: RAL | undefined; diff --git a/src/common/spawner.ts b/src/common/spawner.ts new file mode 100644 index 0000000..86af28d --- /dev/null +++ b/src/common/spawner.ts @@ -0,0 +1,50 @@ +import { Event, EventEmitter } from 'vscode'; + +export interface Spawnee { + stdout: Event; + stderr: Event; + stdin(data: string): void; + exit: Event; + killed: boolean; + kill(): void; +} + + +export interface Spawner { + /** + * Spawn a python `process` + * + * @param args: arguments to pass to the python + * @returns an object that can be listened to for stdout/stderr/stdin + */ + spawn(args: string[], cwd: string | undefined): Promise; +} + +export abstract class BaseSpawnee { + _stdoutEmitter = new EventEmitter(); + _stderrEmitter = new EventEmitter(); + _exitEmitter = new EventEmitter(); + + protected fireStdout(data: string) { + this._stdoutEmitter.fire(data); + } + + protected fireStderr(data: string) { + this._stderrEmitter.fire(data); + } + + protected fireExit(code: number) { + this._exitEmitter.fire(code); + } + + get stdout(): Event { + return this._stdoutEmitter.event; + } + get stderr(): Event { + return this._stderrEmitter.event; + } + get exit(): Event { + return this._exitEmitter.event; + } + +} \ No newline at end of file diff --git a/src/desktop/debugConfigurationProvider.ts b/src/desktop/debugConfigurationProvider.ts new file mode 100644 index 0000000..82bef7e --- /dev/null +++ b/src/desktop/debugConfigurationProvider.ts @@ -0,0 +1,44 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ +import { DebugConfigurationProvider, WorkspaceFolder, DebugConfiguration, CancellationToken, window } from 'vscode'; +import RAL from '../common/ral'; + +export class DesktopDebugConfigurationProvider implements DebugConfigurationProvider { + + constructor(private readonly preloadPromise: Promise) { + } + + /** + * Massage a debug configuration just before a debug session is being launched, + * e.g. add all missing attributes to the debug configuration. + */ + async resolveDebugConfiguration(folder: WorkspaceFolder | undefined, config: DebugConfiguration, token?: CancellationToken): Promise { + if (!RAL().isCrossOriginIsolated) { + return undefined; + } + await this.preloadPromise; + if (!config.type && !config.request && !config.name) { + const editor = window.activeTextEditor; + if (editor && editor.document.languageId === 'python') { + config.type = 'python-pdb-node'; + config.name = 'Launch'; + config.request = 'launch'; + config.program = '${file}'; + config.stopOnEntry = true; + } + } + + if (config.stopOnEntry === undefined) { + config.stopOnEntry = true; + } + + if (!config.file && !config.module) { + await window.showInformationMessage('Cannot find a Python file to debug'); + return undefined; + } + + return config; + } +} diff --git a/src/desktop/extension.ts b/src/desktop/extension.ts index de72dbc..6d3e2a2 100644 --- a/src/desktop/extension.ts +++ b/src/desktop/extension.ts @@ -5,7 +5,31 @@ import RIL from './ril'; RIL.install(); -import { activate as _activate, deactivate as _deactivate } from '../common/extension'; +import { activate as commonActivate, deactivate as _deactivate } from '../common/extension'; +import { debug, DebugAdapterDescriptor, DebugAdapterInlineImplementation, DebugSession, ExtensionContext } from 'vscode'; +import { DesktopDebugConfigurationProvider } from './debugConfigurationProvider'; +import { DebugAdapter } from '../common/debugAdapter'; +import RAL from '../common/ral'; -export const activate = _activate; +class DebugAdapterDescriptorFactory implements DebugAdapterDescriptorFactory { + constructor(private readonly context: ExtensionContext, private readonly preloadPromise: Promise) { + } + async createDebugAdapterDescriptor(session: DebugSession): Promise { + await this.preloadPromise; + return new DebugAdapterInlineImplementation(new DebugAdapter(session, this.context, RAL())); + } +} + + +export function activate(context: ExtensionContext) { + const preloadPromise = commonActivate(context); + + // Setup the node debugger on desktop + const provider = new DesktopDebugConfigurationProvider(preloadPromise); + context.subscriptions.push(debug.registerDebugConfigurationProvider('python-pdb-node', provider)); + + const factory = new DebugAdapterDescriptorFactory(context, preloadPromise); + context.subscriptions.push(debug.registerDebugAdapterDescriptorFactory('python-pdb-node', factory)); + +} export const deactivate = _deactivate; \ No newline at end of file diff --git a/src/desktop/ril.ts b/src/desktop/ril.ts index afe1f8f..eca44e4 100644 --- a/src/desktop/ril.ts +++ b/src/desktop/ril.ts @@ -7,6 +7,8 @@ import path from 'path'; import RAL from '../common/ral'; import { Launcher } from '../common/launcher'; import { DesktopLauncher } from './launcher'; +import { DesktopSpawner } from './spawner'; +import { Spawner } from '../common/spawner'; const _ril: RAL = Object.freeze({ launcher: Object.freeze({ @@ -14,6 +16,16 @@ const _ril: RAL = Object.freeze({ return new DesktopLauncher(); } }), + spawner: Object.freeze({ + create(): Spawner { + return new DesktopSpawner(); + } + }), + timer: Object.freeze({ + setTimeout(callback: () => void, timeoutMs: number): any { + return setTimeout(callback,timeoutMs); + } + }), path: path.posix, isCrossOriginIsolated: true }); diff --git a/src/desktop/spawner.ts b/src/desktop/spawner.ts new file mode 100644 index 0000000..276cbf2 --- /dev/null +++ b/src/desktop/spawner.ts @@ -0,0 +1,62 @@ +import { ChildProcess, spawn } from 'child_process'; +import { TextDecoder } from 'util'; +import { extensions } from 'vscode'; +import { BaseSpawnee, Spawnee, Spawner } from '../common/spawner'; + +class NodeDesktopSpawnee extends BaseSpawnee implements Spawnee { + _textDecoder = new TextDecoder(); + + constructor(private readonly process: ChildProcess) { + super(); + process.stdout?.on('data', this._decodeAndFire.bind(this, this.fireStdout.bind(this))); + process.stderr?.on('data', this._decodeAndFire.bind(this, this.fireStderr.bind(this))); + process.on('exit', this._exitEmitter.fire.bind(this._exitEmitter)); + } + stdin(data: string): void { + const result = this.process.stdin?.write(data); + if (!result) { + this.process.stdin?.once('drain', () => { + this.process.stdin?.write(data); + }); + } + } + get killed(): boolean { + return this.process.killed; + } + kill(): void { + this.process.kill(); + } + _decodeData(data: Buffer) { + return this._textDecoder.decode(data); + } + _decodeAndFire(fire: (s: string) => void, data: Buffer) { + fire(this._decodeData(data)); + } + +} + +export class DesktopSpawner implements Spawner { + async spawn(args: string[], cwd: string | undefined): Promise { + // Find python using the python extension if it's installed + const python = await this._computePythonPath(); + + // For now use node. Switch this to wasm later (or add both) + return new NodeDesktopSpawnee(spawn(python, args, {cwd})); + } + + async _computePythonPath() { + // Use the python extension's current python if available + const python = extensions.getExtension('ms-python.python'); + let pythonPath = `python`; + if (python) { + const api = await python.activate(); + if (api.settings?.getExecutionDetails) { + const details = api.settings.getExecutionDetails(); + pythonPath = details.execCommand[0]; + } + } + return pythonPath; + } + + +} \ No newline at end of file diff --git a/src/web/ril.ts b/src/web/ril.ts index 0fecb3c..1356f7e 100644 --- a/src/web/ril.ts +++ b/src/web/ril.ts @@ -8,6 +8,8 @@ import path from 'path-browserify'; import RAL from '../common/ral'; import { Launcher } from '../common/launcher'; import { WebLauncher } from './launcher'; +import { Spawner } from '../common/spawner'; +import { WebSpawner } from './spawner'; const _ril: RAL = Object.freeze({ launcher: Object.freeze({ @@ -15,6 +17,16 @@ const _ril: RAL = Object.freeze({ return new WebLauncher(); } }), + spawner: Object.freeze({ + create(): Spawner { + return new WebSpawner(); + } + }), + timer: Object.freeze({ + setTimeout(callback: () => void, timeoutMs: number): any { + return setTimeout(callback,timeoutMs); + } + }), path: path, isCrossOriginIsolated: crossOriginIsolated }); diff --git a/src/web/spawner.ts b/src/web/spawner.ts new file mode 100644 index 0000000..2fce556 --- /dev/null +++ b/src/web/spawner.ts @@ -0,0 +1,30 @@ +import { BaseSpawnee, Spawnee, Spawner } from '../common/spawner'; + +class WebSpawnee extends BaseSpawnee implements Spawnee { + _textDecoder = new TextDecoder(); + + constructor(private readonly worker: Worker) { + super(); + // TODO: Wire up writes to the stdout/stdin mocks for pdb to + // these events + } + stdin(data: string): void { + // TODO: Wire up writes to the stdout/stdin mocks for pdb to + // these events + } + get killed(): boolean { + return false; + } + kill(): void { + this.worker.terminate(); + } + +} + +export class WebSpawner implements Spawner { + async spawn(args: string[], cwd: string | undefined): Promise { + // TODO: Spawn a worker that loads pdb and the file. Args should + // be passable to a python.js + return new WebSpawnee(new Worker('')); + } +} \ No newline at end of file