UNPKG

@debugmcp/mcp-debugger

Version:

Step-through debugging MCP server for LLMs

645 lines 29.9 kB
/** * Core worker class for DAP Proxy functionality - REFACTORED VERSION * Uses the Adapter Policy pattern to eliminate language-specific hardcoding */ import path from 'path'; import { ProxyState } from './dap-proxy-interfaces.js'; import { CallbackRequestTracker } from './dap-proxy-request-tracker.js'; import { GenericAdapterManager } from './dap-proxy-adapter-manager.js'; import { DapConnectionManager } from './dap-proxy-connection-manager.js'; import { validateProxyInitPayload } from '../utils/type-guards.js'; import { DefaultAdapterPolicy, JsDebugAdapterPolicy, PythonAdapterPolicy, RustAdapterPolicy, MockAdapterPolicy } from '@debugmcp/shared'; export class DapProxyWorker { dependencies; logger = null; dapClient = null; adapterProcess = null; currentSessionId = null; currentInitPayload = null; state = ProxyState.UNINITIALIZED; requestTracker; processManager = null; connectionManager = null; // Policy-based state management adapterPolicy = DefaultAdapterPolicy; adapterState; commandQueue = []; preConnectQueue = []; exitHook; traceFileFactory; constructor(dependencies, hooks = {}) { this.dependencies = dependencies; this.requestTracker = new CallbackRequestTracker((requestId, command) => this.handleRequestTimeout(requestId, command)); this.adapterState = DefaultAdapterPolicy.createInitialState(); this.exitHook = hooks.exit ?? ((code) => { // Default to preserving existing behaviour in production. process.exit(code); }); this.traceFileFactory = hooks.createTraceFile ?? ((sessionId, logDir) => { const tracePath = path.join(logDir, `dap-trace-${sessionId}.ndjson`); process.env.DAP_TRACE_FILE = tracePath; return tracePath; }); } /** * Select the appropriate adapter policy based on the adapter command */ selectAdapterPolicy(adapterCommand) { if (!adapterCommand) { // Legacy Python mode return PythonAdapterPolicy; } // Check each policy's matcher if (JsDebugAdapterPolicy.matchesAdapter(adapterCommand)) { return JsDebugAdapterPolicy; } else if (PythonAdapterPolicy.matchesAdapter(adapterCommand)) { return PythonAdapterPolicy; } else if (RustAdapterPolicy.matchesAdapter(adapterCommand)) { return RustAdapterPolicy; } else if (MockAdapterPolicy.matchesAdapter(adapterCommand)) { return MockAdapterPolicy; } // Fallback to default return DefaultAdapterPolicy; } /** * Get current state for testing */ getState() { return this.state; } /** * Main command handler */ async handleCommand(command) { this.currentSessionId = command.sessionId || null; const sessionTag = this.currentSessionId ?? 'unknown'; const dapLabel = command.cmd === 'dap' && command.dapCommand ? command.dapCommand : undefined; this.logger?.info(`[Worker] handleCommand cmd=${command.cmd}${dapLabel ? `/${dapLabel}` : ''} session=${sessionTag}`); try { switch (command.cmd) { case 'init': await this.handleInitCommand(command); break; case 'dap': await this.handleDapCommand(command); break; case 'terminate': await this.handleTerminate(); break; } const completionLabel = command.cmd === 'dap' && 'dapCommand' in command ? `${command.cmd}/${command.dapCommand}` : command.cmd; this.logger?.info(`[Worker] Completed command ${completionLabel} session=${sessionTag} state=${this.state}`); } 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) { // If already initializing, just acknowledge and return (idempotent handling for retries) if (this.state === ProxyState.INITIALIZING) { this.sendStatus('init_received'); this.logger?.info('[Worker] Duplicate init command received while already initializing, acknowledging'); return; } // Only allow init from UNINITIALIZED state for first init if (this.state !== ProxyState.UNINITIALIZED) { throw new Error(`Invalid state for init: ${this.state}`); } // Immediately acknowledge receipt of init command this.sendStatus('init_received'); // Validate payload structure const validatedPayload = validateProxyInitPayload(payload); // Select adapter policy this.adapterPolicy = this.selectAdapterPolicy(validatedPayload.adapterCommand); this.adapterState = this.adapterPolicy.createInitialState(); this.logger?.info(`[Worker] Selected adapter policy: ${this.adapterPolicy.name}`); 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}`); this.logger.info(`[Worker] Using adapter policy: ${this.adapterPolicy.name}`); // Enable per-session DAP frame tracing for diagnostics try { const tracePath = this.traceFileFactory(payload.sessionId, payload.logDir); if (tracePath) { this.logger?.info(`[Worker] DAP trace enabled at: ${tracePath}`); } else { this.logger?.debug?.('[Worker] Trace file factory returned no path - tracing disabled'); } } catch (e) { this.logger.warn?.('[Worker] Failed to configure DAP trace file', e); } // Create generic adapter manager this.processManager = new GenericAdapterManager(this.dependencies.processSpawner, this.logger, this.dependencies.fileSystem); this.connectionManager = new DapConnectionManager(this.dependencies.dapClientFactory, this.logger); // Set the adapter policy for DAP client creation this.connectionManager.setAdapterPolicy(this.adapterPolicy); 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); await this.shutdown(); this.exitHook(1); } } /** * Handle dry run mode * Includes Windows IPC message flushing fixes */ handleDryRun(payload) { // Get adapter spawn config from policy const spawnConfig = this.adapterPolicy.getAdapterSpawnConfig?.({ executablePath: payload.executablePath, adapterHost: payload.adapterHost, adapterPort: payload.adapterPort, logDir: payload.logDir, scriptPath: payload.scriptPath, adapterCommand: payload.adapterCommand }); if (!spawnConfig) { throw new Error(`Cannot determine adapter command for dry run (policy: ${this.adapterPolicy.name})`); } const fullCommand = `${spawnConfig.command} ${spawnConfig.args.join(' ')}`; this.logger.warn(`[Worker DRY_RUN] Would execute: ${fullCommand}`); this.logger.warn(`[Worker DRY_RUN] Script to debug: ${payload.scriptPath}`); // Send dry run complete status this.sendStatus('dry_run_complete', { command: fullCommand, script: payload.scriptPath }); // For IPC, ensure the message is flushed before terminating // Use setImmediate to allow the event loop to process the IPC send // This is crucial on Windows where IPC messages can be lost if the process exits too quickly setImmediate(() => { this.state = ProxyState.TERMINATED; this.logger.info('[Worker DRY_RUN] Dry run complete. State set to TERMINATED after message flush.'); // Give a bit more time for IPC to flush on Windows // Use the exit hook to allow tests to override this behavior setTimeout(() => { this.exitHook(0); }, 100); }); } /** * Start adapter and establish connection */ async startDebugpyAdapterAndConnect(payload) { // Get adapter spawn config from policy const spawnConfig = this.adapterPolicy.getAdapterSpawnConfig?.({ executablePath: payload.executablePath, adapterHost: payload.adapterHost, adapterPort: payload.adapterPort, logDir: payload.logDir, scriptPath: payload.scriptPath, adapterCommand: payload.adapterCommand }); if (!spawnConfig) { throw new Error(`Adapter policy ${this.adapterPolicy.name} does not provide spawn configuration`); } // Spawn adapter process using the config from the policy const spawnResult = await this.processManager.spawn(spawnConfig); 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(); // Check if adapter requires command queueing if (this.adapterPolicy.requiresCommandQueueing()) { this.logger.info(`[Worker] ${this.adapterPolicy.name} adapter detected; command queueing enabled`); this.state = ProxyState.CONNECTED; this.sendStatus('adapter_connected'); await this.drainPreConnectQueue(); } else { // Initialize DAP session with correct adapterId await this.connectionManager.initializeSession(this.dapClient, payload.sessionId, this.adapterPolicy.getDapAdapterConfiguration().type); // Send automatic launch request for non-queueing adapters this.logger.info('[Worker] Sending launch request with scriptPath:', payload.scriptPath); await this.connectionManager.sendLaunchRequest(this.dapClient, payload.scriptPath, payload.scriptArgs, payload.stopOnEntry, payload.justMyCode, payload.launchConfig); } this.logger.info('[Worker] Waiting for "initialized" event from adapter.'); } catch (error) { await this.shutdown(); throw error; } } /** * Set up DAP event handlers */ setupDapEventHandlers() { if (!this.dapClient || !this.connectionManager) return; this.connectionManager.setupEventHandlers(this.dapClient, { onInitialized: async () => { // Update adapter state if (this.adapterPolicy.updateStateOnEvent) { this.adapterPolicy.updateStateOnEvent('initialized', {}, this.adapterState); } if (this.adapterPolicy.requiresCommandQueueing()) { this.logger.info(`[Worker] DAP "initialized" (${this.adapterPolicy.name}) received; forwarding event and draining queue.`); this.sendDapEvent('initialized', {}); await this.drainCommandQueue(); } else { 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 */ async handleInitializedEvent() { 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) { this.logger.info('[Worker] Initial breakpoints payload:', this.currentInitPayload.initialBreakpoints); const groupedBreakpoints = new Map(); for (const breakpoint of this.currentInitPayload.initialBreakpoints) { const filePath = path.resolve(breakpoint.file); if (!groupedBreakpoints.has(filePath)) { groupedBreakpoints.set(filePath, []); } groupedBreakpoints.get(filePath).push({ line: breakpoint.line, condition: breakpoint.condition }); } for (const [filePath, breakpoints] of groupedBreakpoints.entries()) { await this.connectionManager.setBreakpoints(this.dapClient, filePath, breakpoints); } } // 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 commands from the parent process */ async handleDapCommand(payload) { // Check if we're connected if (!this.dapClient) { if (this.state === ProxyState.INITIALIZING) { this.preConnectQueue.push(payload); this.logger?.info(`[Worker] Queued pre-connect DAP command: ${payload.dapCommand}`); return; } this.sendDapResponse(payload.requestId, false, undefined, 'DAP client not connected'); return; } try { // Check if command should be queued based on policy const handling = this.adapterPolicy.shouldQueueCommand(payload.dapCommand, this.adapterState); this.logger?.info(`[Worker] Queue decision for '${payload.dapCommand}': shouldQueue=${handling.shouldQueue} shouldDefer=${handling.shouldDefer} queueLength=${this.commandQueue.length}`); if (handling.shouldQueue) { this.logger.info(`[Worker] ${handling.reason || 'Queuing command'}`); // Check if we need to inject configurationDone const initBehavior = this.adapterPolicy.getInitializationBehavior(); if (handling.shouldDefer && initBehavior.deferConfigDone) { const hasQueuedConfigDone = this.commandQueue.some(p => p.dapCommand === 'configurationDone'); if (!hasQueuedConfigDone) { // Inject a silent configurationDone const silentCommand = { requestId: `__silent_configDone_${Date.now()}`, dapCommand: 'configurationDone', dapArgs: {}, sessionId: payload.sessionId, cmd: 'dap', // Mark as silent so we don't send response __silent: true }; this.commandQueue.push(silentCommand); } } this.commandQueue.push(payload); this.logger?.info(`[Worker] Command queued. queueLength=${this.commandQueue.length} (command='${payload.dapCommand}')`); await this.drainCommandQueue(); return; } // 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); } // Add runtimeExecutable from executablePath if needed let dapArgs = payload.dapArgs; const initBehavior = this.adapterPolicy.getInitializationBehavior(); if (initBehavior.addRuntimeExecutable && payload.dapCommand === 'launch' && this.currentInitPayload?.executablePath) { const launchArgs = dapArgs; if (!launchArgs.runtimeExecutable) { launchArgs.runtimeExecutable = this.currentInitPayload.executablePath; this.logger.info(`[Worker] Added runtimeExecutable to launch args: ${launchArgs.runtimeExecutable}`); dapArgs = launchArgs; } } // Send request this.logger?.info(`[Worker] Sending '${payload.dapCommand}' to adapter`); const response = await this.dapClient.sendRequest(payload.dapCommand, dapArgs); // Update adapter state if needed if (this.adapterPolicy.updateStateOnCommand) { this.adapterPolicy.updateStateOnCommand(payload.dapCommand, dapArgs, this.adapterState); } // Mark initialize response received if needed if (this.adapterPolicy.updateStateOnResponse) { this.adapterPolicy.updateStateOnResponse(payload.dapCommand, response, this.adapterState); } else if (initBehavior.trackInitializeResponse && payload.dapCommand === 'initialize') { // Fallback for policies that rely on worker-managed initialize tracking. this.adapterState.initializeResponded = true; } // Complete tracking this.requestTracker.complete(payload.requestId); // Send response this.sendDapResponse(payload.requestId, true, response); // Ensure initial stop after launch if needed if (initBehavior.requiresInitialStop && (payload.dapCommand === 'launch' || payload.dapCommand === 'attach')) { await this.drainCommandQueue(); this.ensureInitialStop().catch((err) => { this.logger?.debug?.(`[Worker] ensureInitialStop encountered error: ${err instanceof Error ? err.message : String(err)}`); }); } } 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); } } /** * Drain the command queue */ async drainCommandQueue() { if (!this.dapClient || this.commandQueue.length === 0) return; this.logger.info(`[Worker] Draining command queue. Count: ${this.commandQueue.length}`); // Process commands through policy if it has a processor let ordered = this.commandQueue; if (this.adapterPolicy.processQueuedCommands) { ordered = this.adapterPolicy.processQueuedCommands(this.commandQueue, this.adapterState); } // Clear queue after ordering this.commandQueue = []; let remaining = ordered.length; for (const payload of ordered) { remaining--; try { const silent = (payload.__silent === true); this.logger?.info(`[Worker] Processing queued command '${payload.dapCommand}' silent=${silent} queueRemaining=${remaining}`); if (silent) { await this.dapClient.sendRequest(payload.dapCommand, payload.dapArgs); if (this.adapterPolicy.updateStateOnCommand) { this.adapterPolicy.updateStateOnCommand(payload.dapCommand, payload.dapArgs || {}, this.adapterState); } continue; } this.requestTracker.track(payload.requestId, payload.dapCommand); const response = await this.dapClient.sendRequest(payload.dapCommand, payload.dapArgs); if (this.adapterPolicy.updateStateOnCommand) { this.adapterPolicy.updateStateOnCommand(payload.dapCommand, payload.dapArgs || {}, this.adapterState); } this.requestTracker.complete(payload.requestId); this.sendDapResponse(payload.requestId, true, response); const initBehavior = this.adapterPolicy.getInitializationBehavior(); if (initBehavior.requiresInitialStop && (payload.dapCommand === 'launch' || payload.dapCommand === 'attach')) { this.ensureInitialStop().catch((err) => { this.logger?.debug?.(`[Worker] ensureInitialStop (queued) encountered error: ${err instanceof Error ? err.message : String(err)}`); }); } } catch (error) { this.requestTracker.complete(payload.requestId); const message = error instanceof Error ? error.message : String(error); this.logger.error(`[Worker] Queued DAP command ${payload.dapCommand} failed:`, { error: message }); this.sendDapResponse(payload.requestId, false, undefined, message); } } } /** * Ensure initial stop for JavaScript debugging */ async ensureInitialStop(timeoutMs = 12000) { if (!this.dapClient) return; const start = Date.now(); try { while (Date.now() - start < timeoutMs) { try { const threadsResp = await this.dapClient.sendRequest('threads', {}); const first = threadsResp?.body?.threads?.[0]?.id; if (typeof first === 'number' && first > 0) { const pauseTid = first; this.logger?.info(`[Worker] ensureInitialStop: pausing threadId=${pauseTid}`); try { await this.dapClient.sendRequest('pause', { threadId: pauseTid }); } catch { // ignore pause errors } return; } } catch { // ignore threads errors } await new Promise((r) => setTimeout(r, 100)); } this.logger?.warn('[Worker] ensureInitialStop: no threads discovered within timeout'); } finally { // nothing to unsubscribe } } /** * Drain pre-connect queue */ async drainPreConnectQueue() { if (!this.dapClient || !this.preConnectQueue.length) return; this.logger.info('[Worker] Draining pre-connect DAP request queue. Count:', this.preConnectQueue.length); const queued = [...this.preConnectQueue]; this.preConnectQueue = []; for (const payload of queued) { await this.handleDapCommand(payload); } } /** * Handle request timeout */ handleRequestTimeout(requestId, command) { 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() { // Check if already shutting down or terminated for idempotent behavior if (this.state === ProxyState.SHUTTING_DOWN || this.state === ProxyState.TERMINATED) { this.logger?.info('[Worker] Already shutting down or terminated.'); return; } // Use optional chaining since logger might be null if not initialized this.logger?.info('[Worker] Received terminate command.'); await this.shutdown(); this.sendStatus('terminated'); } /** * Shutdown the worker */ async shutdown() { 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 sendStatus(status, extra = {}) { const message = { type: 'status', status, sessionId: this.currentSessionId || 'unknown', ...extra }; this.dependencies.messageSender.send(message); } sendDapResponse(requestId, success, response, error) { const message = { type: 'dapResponse', requestId, success, sessionId: this.currentSessionId || 'unknown', ...(success && response ? { body: response.body, response: response } : { error }) }; this.dependencies.messageSender.send(message); } sendDapEvent(event, body) { const message = { type: 'dapEvent', event, body, sessionId: this.currentSessionId || 'unknown' }; this.dependencies.messageSender.send(message); } sendError(message) { const errorMessage = { type: 'error', message, sessionId: this.currentSessionId || 'unknown' }; this.dependencies.messageSender.send(errorMessage); } } //# sourceMappingURL=dap-proxy-worker.js.map