UNPKG

@debugmcp/mcp-debugger

Version:

Step-through debugging MCP server for LLMs

425 lines 17.3 kB
/** * ChildSessionManager - Manages child debug sessions for multi-session adapters * * This abstraction handles the complexity of child session creation and management, * particularly for JavaScript debugging with js-debug/pwa-node which uses multiple * concurrent sessions. */ import { randomBytes } from 'crypto'; import { EventEmitter } from 'events'; import { createLogger } from '../utils/logger.js'; import path from 'path'; const logger = createLogger('child-session-manager'); function createInstanceId() { return randomBytes(4).toString('hex'); } function createChildSafePolicy(policy) { if (!policy.supportsReverseStartDebugging) { return policy; } return { ...policy, supportsReverseStartDebugging: false, childSessionStrategy: 'none', shouldDeferParentConfigDone: () => false, getDapClientBehavior: () => { const baseBehavior = policy.getDapClientBehavior(); const behavior = { ...baseBehavior, childRoutedCommands: new Set(), mirrorBreakpointsToChild: false, deferParentConfigDone: false, pauseAfterChildAttach: false, stackTraceRequiresChild: false, }; if (baseBehavior.handleReverseRequest) { behavior.handleReverseRequest = async (request, context) => { const result = await baseBehavior.handleReverseRequest(request, context); if (!result.handled) { return result; } // Do not spawn grandchildren; acknowledge and stop. return { handled: true }; }; } return behavior; }, }; } export class ChildSessionManager extends EventEmitter { policy; dapBehavior; parentClient; host; port; // Child session tracking adoptedTargets = new Set(); childSessions = new Map(); activeChild = null; // Breakpoint mirroring storedBreakpoints = new Map(); // State tracking adoptionInProgress = false; childConfigComplete = false; instanceId; constructor(options) { super(); this.policy = options.policy; this.dapBehavior = options.policy.getDapClientBehavior(); this.parentClient = options.parentClient; this.host = options.host; this.port = options.port; this.instanceId = createInstanceId(); logger.info(`[ChildSessionManager:${this.instanceId}] created`); } /** * Check if a pending target has already been adopted */ isAdopted(pendingId) { return this.adoptedTargets.has(pendingId); } /** * Check if adoption is currently in progress */ isAdoptionInProgress() { logger.info(`[ChildSessionManager:${this.instanceId}] isAdoptionInProgress() => ${this.adoptionInProgress}`); return this.adoptionInProgress; } /** * Check if there are any active child sessions */ hasActiveChildren() { const result = this.activeChild !== null || this.childSessions.size > 0; logger.info(`[ChildSessionManager:${this.instanceId}] hasActiveChildren() => ${result} (activeChild: ${!!this.activeChild}, sessions: ${this.childSessions.size})`); return result; } /** * Get the active child session */ getActiveChild() { logger.info(`[ChildSessionManager:${this.instanceId}] getActiveChild() => ${this.activeChild ? 'active' : 'null'}`); return this.activeChild; } /** * Route a command to the appropriate child session if needed */ shouldRouteToChild(command) { const routedCommands = this.dapBehavior.childRoutedCommands; if (!routedCommands) { logger.info(`[ChildSessionManager:${this.instanceId}] shouldRouteToChild(${command}): false (no routed command set configured)`); return false; } if (!routedCommands.has(command)) { logger.info(`[ChildSessionManager:${this.instanceId}] shouldRouteToChild(${command}): false (command not routed)`); return false; } const hasActive = this.hasActiveChildren(); const adoptionInProg = this.adoptionInProgress; if (hasActive) { logger.info(`[ChildSessionManager:${this.instanceId}] shouldRouteToChild(${command}): true (active child session available)`); } else if (adoptionInProg) { logger.info(`[ChildSessionManager:${this.instanceId}] shouldRouteToChild(${command}): true (child adoption in progress)`); } else { // Still return true so callers can queue/await until the child attaches. logger.info(`[ChildSessionManager:${this.instanceId}] shouldRouteToChild(${command}): true (child command with no active child yet)`); } return true; } /** * Store breakpoints for mirroring to child sessions */ storeBreakpoints(sourcePath, breakpoints) { if (!this.dapBehavior.mirrorBreakpointsToChild) { return; } const absolutePath = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(sourcePath); this.storedBreakpoints.set(absolutePath, breakpoints); // Mirror to active child if present if (this.activeChild) { void this.activeChild.sendRequest('setBreakpoints', { source: { path: absolutePath }, breakpoints }).catch(() => { // Ignore errors when mirroring }); } } /** * Create and configure a child session */ async createChildSession(config) { const { pendingId, parentConfig } = config; // Check if already adopted if (this.adoptedTargets.has(pendingId)) { logger.warn(`Pending target ${pendingId} already adopted`); return; } // Check if adoption is in progress or we already have a child if (this.adoptionInProgress || this.hasActiveChildren()) { logger.info(`[ChildSessionManager:${this.instanceId}] Ignoring child session request; adoption in progress or child active`, { adoptionInProgress: this.adoptionInProgress, hasActiveChild: !!this.activeChild, childSessionCount: this.childSessions.size }); return; } this.adoptionInProgress = true; logger.info(`[ChildSessionManager:${this.instanceId}] Setting adoptionInProgress = true for ${pendingId}`); this.adoptedTargets.add(pendingId); try { // Import MinimalDapClient dynamically to avoid circular dependency const { MinimalDapClient } = await import('./minimal-dap.js'); // Create child client with a policy that disables recursive reverse debugging const childPolicy = createChildSafePolicy(this.policy); const child = new MinimalDapClient(this.host, this.port, childPolicy); await child.connect(); // Wire up event forwarding this.wireChildEvents(child); // Store and activate child this.childSessions.set(pendingId, child); this.activeChild = child; logger.info(`[ChildSessionManager:${this.instanceId}] *** ACTIVE CHILD SET *** for ${pendingId} at timestamp ${Date.now()}`); // Initialize child session await this.initializeChild(child, pendingId, parentConfig); // Configure child session await this.configureChild(child, pendingId, parentConfig); // Attach to pending target await this.attachChild(child, pendingId, parentConfig); // Handle post-attach initialization if needed await this.handlePostAttachInit(child); // Ensure initial stop if policy requires it if (this.dapBehavior.pauseAfterChildAttach) { await this.ensureChildStopped(child); } this.adoptionInProgress = false; logger.info(`[ChildSessionManager:${this.instanceId}] Setting adoptionInProgress = false for ${pendingId} (success)`); this.childConfigComplete = true; logger.info(`[ChildSessionManager:${this.instanceId}] Child session created successfully for ${pendingId}`); this.emit('childCreated', pendingId, child); } catch (error) { this.adoptionInProgress = false; logger.info(`[ChildSessionManager:${this.instanceId}] Setting adoptionInProgress = false for ${pendingId} (error)`); const msg = error instanceof Error ? error.message : String(error); logger.error(`[ChildSessionManager:${this.instanceId}] Failed to create child session for ${pendingId}: ${msg}`); this.emit('childError', pendingId, error); throw error; } } /** * Initialize child session */ async initializeChild(child, pendingId, _parentConfig) { void _parentConfig; // Currently unused but may be needed for future policy implementations const initArgs = { clientID: `mcp-child-${pendingId}`, adapterID: this.policy.getDapAdapterConfiguration().type, pathFormat: 'path', linesStartAt1: true, columnsStartAt1: true }; logger.info(`[child:${pendingId}] initialize`); await child.sendRequest('initialize', initArgs); // Wait for initialized event await this.waitForEvent(child, 'initialized', this.dapBehavior.childInitTimeout || 12000); } /** * Configure child session (breakpoints, exception filters, etc.) */ async configureChild(child, pendingId, _parentConfig) { void _parentConfig; // Currently unused but may be needed for future policy implementations // Set exception breakpoints try { logger.info(`[child:${pendingId}] setExceptionBreakpoints`); await child.sendRequest('setExceptionBreakpoints', { filters: [] }); } catch { logger.warn(`[child:${pendingId}] setExceptionBreakpoints failed or not supported`); } // Mirror breakpoints if policy requires if (this.dapBehavior.mirrorBreakpointsToChild) { for (const [srcPath, bps] of this.storedBreakpoints) { logger.info(`[child:${pendingId}] setBreakpoints -> ${srcPath} (${bps.length})`); try { await child.sendRequest('setBreakpoints', { source: { path: srcPath }, breakpoints: bps }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.warn(`[child:${pendingId}] setBreakpoints failed: ${msg}`); } } } // Send configuration done unless suppressed if (!this.dapBehavior.suppressPostAttachConfigDone) { try { logger.info(`[child:${pendingId}] configurationDone`); await child.sendRequest('configurationDone', {}); } catch { logger.warn(`[child:${pendingId}] configurationDone failed or not required`); } } } /** * Attach child to pending target */ async attachChild(child, pendingId, parentConfig) { const attachArgs = this.policy.buildChildStartArgs(pendingId, parentConfig); // Retry logic for attachment const maxRetries = 20; let adopted = false; let lastError; for (let i = 0; i < maxRetries && !adopted; i++) { try { logger.info(`[child:${pendingId}] ${attachArgs.command} attempt ${i + 1}`); await child.sendRequest(attachArgs.command, attachArgs.args, 20000); adopted = true; break; } catch (e) { lastError = e; await this.sleep(200); } } if (!adopted) { const msg = lastError instanceof Error ? lastError.message : String(lastError); throw new Error(`Failed to attach child after ${maxRetries} attempts: ${msg}`); } } /** * Handle post-attach initialization (some adapters emit another 'initialized') */ async handlePostAttachInit(child) { // Wait briefly for a post-attach initialized event const sawPostInit = await this.waitForEvent(child, 'initialized', 3000, false); if (sawPostInit && this.dapBehavior.mirrorBreakpointsToChild) { // Re-send configuration after post-attach initialized try { await child.sendRequest('setExceptionBreakpoints', { filters: [] }); } catch { } for (const [srcPath, bps] of this.storedBreakpoints) { try { await child.sendRequest('setBreakpoints', { source: { path: srcPath }, breakpoints: bps }); } catch { } } } } /** * Ensure child is stopped (for adapters that require it) */ async ensureChildStopped(child) { // Wait for stopped event const stopped = await this.waitForEvent(child, 'stopped', 15000, false); if (!stopped) { // Try to pause the first thread try { const threadsResp = await child.sendRequest('threads', {}, 5000); const threads = threadsResp?.body?.threads; if (Array.isArray(threads) && threads.length > 0) { const threadId = threads[0].id; logger.info(`[child] Pausing thread ${threadId}`); try { await child.sendRequest('pause', { threadId }); } catch { // Ignore pause errors } // For js-debug quirk: also try threadId 1 if we got 0 if (threadId === 0) { try { await child.sendRequest('pause', { threadId: 1 }); } catch { } } } } catch { logger.warn('[child] Could not retrieve threads for pause'); } } } /** * Wire child events to forward to parent */ wireChildEvents(child) { child.on('event', (evt) => { // Forward child events through parent this.emit('childEvent', evt); }); child.on('error', (err) => { logger.error('[child] DAP client error:', err); this.emit('childError', null, err); }); child.on('close', () => { logger.info(`[ChildSessionManager:${this.instanceId}] [child] DAP client connection closed (current count=${this.childSessions.size})`); this.emit('childClosed'); this.childSessions.clear(); this.activeChild = null; logger.info(`[ChildSessionManager:${this.instanceId}] *** ACTIVE CHILD CLEARED *** (child closed) at timestamp ${Date.now()}`); }); } /** * Wait for a specific event with timeout */ waitForEvent(client, eventName, timeoutMs, required = true) { return new Promise((resolve) => { let done = false; const onEvent = (evt) => { if (done) return; if (evt && evt.event === eventName) { done = true; client.off('event', onEvent); clearTimeout(timer); resolve(true); } }; const timer = setTimeout(() => { if (done) return; done = true; client.off('event', onEvent); if (required) { logger.warn(`Timeout waiting for '${eventName}' event`); } resolve(false); }, timeoutMs); client.on('event', onEvent); }); } /** * Sleep helper */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Shutdown all child sessions */ async shutdown() { logger.info(`[ChildSessionManager:${this.instanceId}] Shutting down child sessions`); for (const [id, child] of this.childSessions) { try { child.shutdown('parent shutdown'); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.warn(`Error shutting down child ${id}: ${msg}`); } } this.childSessions.clear(); this.activeChild = null; this.adoptedTargets.clear(); } } //# sourceMappingURL=child-session-manager.js.map