UNPKG

@debugmcp/mcp-debugger

Version:

Run-time step-through debugging for LLM agents.

501 lines (437 loc) 16.8 kB
/** * Core worker class for DAP Proxy functionality * Encapsulates all business logic in a testable form */ import { ChildProcess } from 'child_process'; import path from 'path'; import { DebugProtocol } from '@vscode/debugprotocol'; import { DapProxyDependencies, ParentCommand, ProxyInitPayload, DapCommandPayload, IDapClient, ILogger, ProxyState, StatusMessage, DapResponseMessage, DapEventMessage, ErrorMessage } from './dap-proxy-interfaces.js'; import { CallbackRequestTracker } from './dap-proxy-request-tracker.js'; import { GenericAdapterManager, DebugpyAdapterManager } from './dap-proxy-adapter-manager.js'; import { DapConnectionManager } from './dap-proxy-connection-manager.js'; import { validateProxyInitPayload, validateAdapterCommand, logAdapterCommandValidation } from '../utils/type-guards.js'; export class DapProxyWorker { private logger: ILogger | null = null; private dapClient: IDapClient | null = null; private adapterProcess: ChildProcess | null = null; private currentSessionId: string | null = null; private currentInitPayload: ProxyInitPayload | null = null; private state: ProxyState = ProxyState.UNINITIALIZED; private requestTracker: CallbackRequestTracker; private processManager: GenericAdapterManager | null = null; private connectionManager: DapConnectionManager | null = null; constructor(private dependencies: DapProxyDependencies) { this.requestTracker = new CallbackRequestTracker( (requestId, command) => this.handleRequestTimeout(requestId, command) ); } /** * Get current state for testing */ getState(): ProxyState { return this.state; } /** * Main command handler */ async handleCommand(command: ParentCommand): Promise<void> { this.currentSessionId = command.sessionId; try { switch (command.cmd) { case 'init': await this.handleInitCommand(command); break; case 'dap': await this.handleDapCommand(command); break; case 'terminate': await this.handleTerminate(); break; } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger?.error(`[Worker] Error handling command ${command.cmd}:`, error); this.sendError(`Error handling ${command.cmd}: ${message}`); } } /** * Handle initialization command */ async handleInitCommand(payload: ProxyInitPayload): Promise<void> { if (this.state !== ProxyState.UNINITIALIZED) { throw new Error(`Invalid state for init: ${this.state}`); } // Validate payload structure const validatedPayload = validateProxyInitPayload(payload); this.state = ProxyState.INITIALIZING; this.currentInitPayload = validatedPayload; try { // Create logger const logPath = path.join(payload.logDir, `proxy-${payload.sessionId}.log`); await this.dependencies.fileSystem.ensureDir(path.dirname(logPath)); this.logger = await this.dependencies.loggerFactory(payload.sessionId, payload.logDir); this.logger.info(`[Worker] DAP Proxy worker initialized for session ${payload.sessionId}`); // Create managers with logger // Use generic adapter manager if adapter command is provided, otherwise fall back to Python if (payload.adapterCommand) { this.processManager = new GenericAdapterManager( this.dependencies.processSpawner, this.logger, this.dependencies.fileSystem ); } else { // Backward compatibility - use Python adapter manager this.processManager = new DebugpyAdapterManager( this.dependencies.processSpawner, this.logger, this.dependencies.fileSystem ); } this.connectionManager = new DapConnectionManager( this.dependencies.dapClientFactory, this.logger ); // No path validation - let debugpy handle it this.logger.info(`[Worker] Script path to debug: ${payload.scriptPath}`); // Handle dry run if (payload.dryRunSpawn) { this.handleDryRun(payload); return; } // Start adapter and connect await this.startDebugpyAdapterAndConnect(payload); } catch (error) { this.state = ProxyState.UNINITIALIZED; const message = error instanceof Error ? error.message : String(error); this.logger?.error(`[Worker] Critical initialization error: ${message}`, error); // For any initialization error, ensure we shut down await this.shutdown(); // Exit the process to trigger the 'exit' event in ProxyManager process.exit(1); } } /** * Handle dry run mode */ private handleDryRun(payload: ProxyInitPayload): void { let command: string; let args: string[]; if (payload.adapterCommand) { // Use provided adapter command command = payload.adapterCommand.command; args = payload.adapterCommand.args; } else if (this.processManager instanceof DebugpyAdapterManager) { // Use Python-specific command building const spawnCommand = this.processManager.buildSpawnCommand( payload.executablePath, payload.adapterHost, payload.adapterPort, payload.logDir ); command = spawnCommand.command; args = spawnCommand.args; } else { throw new Error('Cannot determine adapter command for dry run'); } const fullCommand = `${command} ${args.join(' ')}`; this.logger!.warn(`[Worker DRY_RUN] Would execute: ${fullCommand}`); this.logger!.warn(`[Worker DRY_RUN] Script to debug: ${payload.scriptPath}`); this.sendStatus('dry_run_complete', { command: fullCommand, script: payload.scriptPath }); // Indicate that the process should terminate this.state = ProxyState.TERMINATED; this.logger!.info('[Worker DRY_RUN] Dry run complete. State set to TERMINATED.'); } /** * Start debugpy adapter and establish connection */ private async startDebugpyAdapterAndConnect(payload: ProxyInitPayload): Promise<void> { // Spawn adapter process let spawnResult; if (payload.adapterCommand) { // Validate adapter command with detailed logging try { const validatedCommand = validateAdapterCommand(payload.adapterCommand, 'proxy-worker-init'); logAdapterCommandValidation(validatedCommand, 'proxy-worker-init', true, { executablePath: payload.executablePath, scriptPath: payload.scriptPath }); this.logger!.info('[Worker] Adapter command validated successfully:', { command: validatedCommand.command, argsLength: validatedCommand.args.length, hasEnv: !!validatedCommand.env }); } catch (validationError) { logAdapterCommandValidation(payload.adapterCommand, 'proxy-worker-init', false, { error: validationError instanceof Error ? validationError.message : String(validationError), rawPayload: payload }); throw validationError; } // Use validated adapter command const validatedCommand = validateAdapterCommand(payload.adapterCommand, 'proxy-worker-spawn'); spawnResult = await this.processManager!.spawn({ command: validatedCommand.command, args: validatedCommand.args, host: payload.adapterHost, port: payload.adapterPort, logDir: payload.logDir, env: validatedCommand.env }); } else if (this.processManager instanceof DebugpyAdapterManager) { // Use Python-specific spawning spawnResult = await this.processManager.spawnDebugpy({ pythonPath: payload.executablePath, host: payload.adapterHost, port: payload.adapterPort, logDir: payload.logDir }); } else { throw new Error('Cannot determine how to spawn adapter'); } this.adapterProcess = spawnResult.process; this.logger!.info(`[Worker] Adapter spawned with PID: ${spawnResult.pid}`); // Monitor adapter process this.adapterProcess.on('error', (err) => { this.logger!.error('[Worker] Adapter process error:', err); this.sendError(`Adapter process error: ${err.message}`); }); this.adapterProcess.on('exit', (code, signal) => { this.logger!.info(`[Worker] Adapter process exited. Code: ${code}, Signal: ${signal}`); this.sendStatus('adapter_exited', { code, signal }); }); // Connect to adapter try { this.dapClient = await this.connectionManager!.connectWithRetry( payload.adapterHost, payload.adapterPort ); // Set up event handlers this.setupDapEventHandlers(); // Initialize DAP session await this.connectionManager!.initializeSession(this.dapClient, payload.sessionId); // Send launch request // DIAGNOSTIC: Log the scriptPath before sending to connection manager this.logger!.info('[Worker] DIAGNOSTIC: About to send launch request with scriptPath:', payload.scriptPath); this.logger!.info('[Worker] DIAGNOSTIC: scriptPath type:', typeof payload.scriptPath); this.logger!.info('[Worker] DIAGNOSTIC: scriptPath length:', payload.scriptPath.length); this.logger!.info('[Worker] DIAGNOSTIC: Full payload object:', JSON.stringify(payload, null, 2)); await this.connectionManager!.sendLaunchRequest( this.dapClient, payload.scriptPath, payload.scriptArgs, payload.stopOnEntry, payload.justMyCode ); this.logger!.info('[Worker] Waiting for "initialized" event from adapter.'); } catch (error) { await this.shutdown(); throw error; } } /** * Set up DAP event handlers */ private setupDapEventHandlers(): void { if (!this.dapClient || !this.connectionManager) return; this.connectionManager.setupEventHandlers(this.dapClient, { onInitialized: async () => { await this.handleInitializedEvent(); }, onOutput: (body) => { this.logger!.debug('[Worker] DAP event: output', body); this.sendDapEvent('output', body); }, onStopped: (body) => { this.logger!.info('[Worker] DAP event: stopped', body); this.sendDapEvent('stopped', body); }, onContinued: (body) => { this.logger!.info('[Worker] DAP event: continued', body); this.sendDapEvent('continued', body); }, onThread: (body) => { this.logger!.debug('[Worker] DAP event: thread', body); this.sendDapEvent('thread', body); }, onExited: (body) => { this.logger!.info('[Worker] DAP event: exited (debuggee)', body); this.sendDapEvent('exited', body); }, onTerminated: (body) => { this.logger!.info('[Worker] DAP event: terminated (session)', body); this.sendDapEvent('terminated', body); this.shutdown(); }, onError: (err) => { this.logger!.error('[Worker] DAP client error:', err); this.sendError(`DAP client error: ${err.message}`); }, onClose: () => { this.logger!.info('[Worker] DAP client connection closed.'); this.sendStatus('dap_connection_closed'); this.shutdown(); } }); } /** * Handle DAP initialized event */ private async handleInitializedEvent(): Promise<void> { this.logger!.info('[Worker] DAP "initialized" event received.'); if (!this.currentInitPayload || !this.dapClient || !this.connectionManager) { throw new Error('Missing required state in initialized handler'); } try { // Set initial breakpoints if provided if (this.currentInitPayload.initialBreakpoints?.length) { await this.connectionManager.setBreakpoints( this.dapClient, this.currentInitPayload.scriptPath, this.currentInitPayload.initialBreakpoints ); } // Send configuration done await this.connectionManager.sendConfigurationDone(this.dapClient); // Update state and notify parent this.state = ProxyState.CONNECTED; this.sendStatus('adapter_configured_and_launched'); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.logger!.error('[Worker] Error in initialized handler:', error); this.sendError(`Error in DAP sequence: ${message}`); await this.shutdown(); } } /** * Handle DAP command */ async handleDapCommand(payload: DapCommandPayload): Promise<void> { if (this.state !== ProxyState.CONNECTED || !this.dapClient) { this.sendDapResponse(payload.requestId, false, undefined, 'DAP client not connected'); return; } try { // Track request this.requestTracker.track(payload.requestId, payload.dapCommand); // Log setBreakpoints for debugging if (payload.dapCommand === 'setBreakpoints') { this.logger!.info(`[Worker] Sending 'setBreakpoints' command. Args:`, payload.dapArgs); } // Send request const response = await this.dapClient.sendRequest(payload.dapCommand, payload.dapArgs); // Complete tracking this.requestTracker.complete(payload.requestId); // Log setBreakpoints response if (payload.dapCommand === 'setBreakpoints') { this.logger!.info(`[Worker] Response from adapter for 'setBreakpoints':`, response); } // Send response this.sendDapResponse(payload.requestId, true, response); } catch (error) { this.requestTracker.complete(payload.requestId); const message = error instanceof Error ? error.message : String(error); this.logger!.error(`[Worker] DAP command ${payload.dapCommand} failed:`, { error: message }); this.sendDapResponse(payload.requestId, false, undefined, message); } } /** * Handle request timeout */ private handleRequestTimeout(requestId: string, command: string): void { this.logger!.error(`[Worker] DAP request '${command}' (id: ${requestId}) timed out`); this.sendDapResponse(requestId, false, undefined, `Request '${command}' timed out`); } /** * Handle terminate command */ async handleTerminate(): Promise<void> { this.logger!.info('[Worker] Received terminate command.'); await this.shutdown(); this.sendStatus('terminated'); } /** * Shutdown the worker */ async shutdown(): Promise<void> { if (this.state === ProxyState.SHUTTING_DOWN || this.state === ProxyState.TERMINATED) { this.logger?.info('[Worker] Shutdown already in progress.'); return; } this.state = ProxyState.SHUTTING_DOWN; this.logger?.info('[Worker] Initiating shutdown sequence...'); // Clear request tracking this.requestTracker.clear(); // Reject any in-flight DAP requests and clear timers immediately if (this.dapClient) { this.dapClient.shutdown('worker shutdown'); } // Disconnect DAP client if (this.connectionManager && this.dapClient) { await this.connectionManager.disconnect(this.dapClient); } this.dapClient = null; // Terminate adapter process if (this.processManager && this.adapterProcess) { await this.processManager.shutdown(this.adapterProcess); } this.adapterProcess = null; this.state = ProxyState.TERMINATED; this.logger?.info('[Worker] Shutdown sequence completed.'); } // Message sending helpers private sendStatus(status: string, extra: Record<string, unknown> = {}): void { const message: StatusMessage = { type: 'status', status, sessionId: this.currentSessionId || 'unknown', ...extra }; this.dependencies.messageSender.send(message); } private sendDapResponse(requestId: string, success: boolean, response?: unknown, error?: string): void { const message: DapResponseMessage = { type: 'dapResponse', requestId, success, sessionId: this.currentSessionId || 'unknown', ...(success && response ? { body: (response as DebugProtocol.Response).body, response: response as DebugProtocol.Response } : { error }) }; this.dependencies.messageSender.send(message); } private sendDapEvent(event: string, body: unknown): void { const message: DapEventMessage = { type: 'dapEvent', event, body, sessionId: this.currentSessionId || 'unknown' }; this.dependencies.messageSender.send(message); } private sendError(message: string): void { const errorMessage: ErrorMessage = { type: 'error', message, sessionId: this.currentSessionId || 'unknown' }; this.dependencies.messageSender.send(errorMessage); } }