@debugmcp/mcp-debugger
Version:
Step-through debugging MCP server for LLMs
425 lines • 17.3 kB
JavaScript
/**
* 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