UNPKG

@debugmcp/mcp-debugger

Version:

Run-time step-through debugging for LLM agents.

357 lines 13.5 kB
/** * Production implementations of process launcher interfaces * These delegate to the existing ProcessManager for actual process operations */ import { EventEmitter } from 'events'; /** * Adapter to wrap IChildProcess as IProcess * Provides a cleaner interface while delegating to the underlying child process */ class ProcessAdapter extends EventEmitter { childProcess; _exitCode = null; _signalCode = null; childProcessListeners = []; // eslint-disable-line @typescript-eslint/no-explicit-any constructor(childProcess) { super(); this.childProcess = childProcess; // Create event handlers const exitHandler = (code, signal) => { this._exitCode = code; this._signalCode = signal; this.emit('exit', code, signal); }; const closeHandler = (code, signal) => { this.emit('close', code, signal); }; const errorHandler = (error) => { this.emit('error', error); }; const spawnHandler = () => { this.emit('spawn'); }; const messageHandler = (message) => { this.emit('message', message); }; // Add listeners and track them childProcess.on('exit', exitHandler); childProcess.on('close', closeHandler); childProcess.on('error', errorHandler); childProcess.on('spawn', spawnHandler); childProcess.on('message', messageHandler); // Track all listeners for cleanup this.childProcessListeners.push({ event: 'exit', listener: exitHandler }, { event: 'close', listener: closeHandler }, { event: 'error', listener: errorHandler }, { event: 'spawn', listener: spawnHandler }, { event: 'message', listener: messageHandler }); // Add a default error handler to prevent unhandled errors // This ensures that errors don't throw if no other handlers are attached this.on('error', () => { // Default error handler - prevents Node.js from throwing // Actual error handling should be done by subclasses or external handlers }); } get pid() { return this.childProcess.pid; } get stdin() { return this.childProcess.stdin; } get stdout() { return this.childProcess.stdout; } get stderr() { return this.childProcess.stderr; } get killed() { return this.childProcess.killed; } get exitCode() { return this._exitCode; } get signalCode() { return this._signalCode; } send(message) { return this.childProcess.send(message); } kill(signal) { return this.childProcess.kill(signal); } } /** * Production implementation of IProcessLauncher */ export class ProcessLauncherImpl { processManager; constructor(processManager) { this.processManager = processManager; } launch(command, args, options) { const childProcess = this.processManager.spawn(command, args, options); return new ProcessAdapter(childProcess); } } /** * Production implementation of IDebugTargetLauncher */ export class DebugTargetLauncherImpl { processLauncher; networkManager; constructor(processLauncher, networkManager) { this.processLauncher = processLauncher; this.networkManager = networkManager; } async launchPythonDebugTarget(scriptPath, args, pythonPath = 'python', debugPort) { // Find a free port if not specified const port = debugPort || await this.networkManager.findFreePort(); // Launch Python with debugpy const debugArgs = [ '-m', 'debugpy', '--listen', `127.0.0.1:${port}`, '--wait-for-client', scriptPath, ...args ]; // No cwd manipulation - let the process inherit the current working directory const debugProcess = this.processLauncher.launch(pythonPath, debugArgs); return { process: debugProcess, debugPort: port, terminate: async () => { return new Promise((resolve) => { if (debugProcess.killed) { resolve(); return; } debugProcess.once('exit', () => resolve()); debugProcess.kill('SIGTERM'); // Force kill after timeout setTimeout(() => { if (!debugProcess.killed) { debugProcess.kill('SIGKILL'); } resolve(); }, 5000); }); } }; } } /** * Proxy process adapter that adds proxy-specific functionality */ class ProxyProcessAdapter extends ProcessAdapter { sessionId; initializationPromise; initializationResolve; initializationReject; initializationState = 'none'; initializationCleanup; disposed = false; constructor(childProcess, sessionId) { super(childProcess); this.sessionId = sessionId; // NO promise creation here - wait for waitForInitialization() // Set up early exit handler this.once('exit', this.handleEarlyExit.bind(this)); // Set up error handling immediately to prevent unhandled errors // This must be done in the constructor to catch any early errors this.setupErrorHandling(); } setupErrorHandling() { // Override error handling to support DAP spec // Note: We must handle errors to prevent Node.js from throwing unhandled errors this.on('error', () => { if (this.initializationState === 'waiting') { // Both reject promise AND emit event (DAP spec requirement) // The failInitialization will reject the promise, but the error event // will still be emitted for other listeners // Don't pass the error directly - this will be handled by exit event } // Error is handled - prevent default throw behavior }); } createInitializationPromise(timeout) { return new Promise((resolve, reject) => { this.initializationResolve = resolve; this.initializationReject = reject; // Set up message handler const messageHandler = (message) => { const msg = message; if (msg?.type === 'status' && (msg.status === 'adapter_configured_and_launched' || msg.status === 'dry_run_complete')) { this.completeInitialization(); } }; this.on('message', messageHandler); // Set up timeout const timeoutId = setTimeout(() => { if (this.initializationState === 'waiting') { this.failInitialization(new Error('Proxy initialization timeout')); } }, timeout); // Store cleanup info this.initializationCleanup = () => { this.removeListener('message', messageHandler); clearTimeout(timeoutId); }; }); } completeInitialization() { if (this.initializationState !== 'waiting') return; this.initializationState = 'completed'; if (this.initializationResolve) { this.initializationResolve(); this.initializationResolve = undefined; this.initializationReject = undefined; } this.cleanupInitialization(); } failInitialization(error) { if (this.initializationState !== 'waiting') return; this.initializationState = 'failed'; if (this.initializationReject) { this.initializationReject(error); this.initializationResolve = undefined; this.initializationReject = undefined; } this.cleanupInitialization(); } cleanupInitialization() { if (this.initializationCleanup) { this.initializationCleanup(); this.initializationCleanup = undefined; } } handleEarlyExit() { if (this.initializationState === 'waiting' && this.initializationReject) { // Only reject if someone is waiting for initialization this.failInitialization(new Error('Proxy process exited before initialization')); } else if (this.initializationState === 'none') { // Process exited without initialization being requested // Mark as failed to prevent future initialization attempts this.initializationState = 'failed'; } this.dispose(); } dispose() { if (this.disposed) return; this.disposed = true; // Clean up initialization resources this.cleanupInitialization(); // Remove all listeners from this adapter this.removeAllListeners(); // Remove listeners from the underlying childProcess for (const { event, listener } of this.childProcessListeners) { this.childProcess.removeListener(event, listener); } this.childProcessListeners = []; } sendCommand(command) { // Send object directly - Node.js IPC will handle serialization this.send(command); } async waitForInitialization(timeout = 30000) { // Handle completed states if (this.initializationState === 'completed') { return; // Already initialized } if (this.initializationState === 'failed') { throw new Error('Initialization already completed or failed'); } // Handle concurrent calls - return existing promise if in progress if (this.initializationState === 'waiting' && this.initializationPromise) { return this.initializationPromise; } // Create promise only when first requested if (!this.initializationPromise) { this.initializationState = 'waiting'; this.initializationPromise = this.createInitializationPromise(timeout); } return this.initializationPromise; } kill(signal) { if (this.killed || this.disposed) { return false; // Already killed or disposed } // If waiting for initialization, fail it if (this.initializationState === 'waiting') { this.failInitialization(new Error('Process killed during initialization')); } const result = super.kill(signal); // Ensure disposal happens after kill if (result) { this.once('exit', () => this.dispose()); } return result; } } /** * Production implementation of IProxyProcessLauncher */ export class ProxyProcessLauncherImpl { processLauncher; constructor(processLauncher) { this.processLauncher = processLauncher; } launchProxy(proxyScriptPath, sessionId, env) { const diagnosticFlags = ['--trace-uncaught', '--trace-exit']; const args = [...diagnosticFlags, proxyScriptPath]; // Convert process.env to ensure all values are strings // Filter out test-related environment variables to ensure proxy runs normally const processEnv = {}; if (env) { Object.assign(processEnv, env); } else { for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { // Skip test-related environment variables if (key === 'NODE_ENV' || key === 'VITEST' || key === 'JEST_WORKER_ID') { continue; } processEnv[key] = value; } } } // Ensure the proxy knows it's not in test mode delete processEnv.NODE_ENV; delete processEnv.VITEST; delete processEnv.JEST_WORKER_ID; const options = { stdio: ['pipe', 'pipe', 'pipe', 'ipc'], // eslint-disable-line @typescript-eslint/no-explicit-any -- Required for Node.js StdioOptions IPC compatibility env: processEnv, cwd: process.cwd() // Ensure proxy runs from the MCP server's working directory }; const launchedProcess = this.processLauncher.launch(process.execPath, args, options); // Cast to ProcessAdapter to access the underlying child process const processAdapter = launchedProcess; return new ProxyProcessAdapter(processAdapter['childProcess'], sessionId); } } /** * Production implementation of IProcessLauncherFactory */ export class ProcessLauncherFactoryImpl { processManager; networkManager; constructor(processManager, networkManager) { this.processManager = processManager; this.networkManager = networkManager; } createProcessLauncher() { return new ProcessLauncherImpl(this.processManager); } createDebugTargetLauncher() { const processLauncher = this.createProcessLauncher(); return new DebugTargetLauncherImpl(processLauncher, this.networkManager); } createProxyProcessLauncher() { const processLauncher = this.createProcessLauncher(); return new ProxyProcessLauncherImpl(processLauncher); } } //# sourceMappingURL=process-launcher-impl.js.map