@debugmcp/mcp-debugger
Version:
Run-time step-through debugging for LLM agents.
416 lines • 17.1 kB
JavaScript
/**
* ProxyManager - Handles spawning and communication with debug proxy processes
*/
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import { fileURLToPath } from 'url';
import { createInitialState, handleProxyMessage, isValidProxyMessage } from '../dap-core/index.js';
import { ErrorMessages } from '../utils/error-messages.js';
/**
* Concrete implementation of ProxyManager
*/
export class ProxyManager extends EventEmitter {
adapter;
proxyProcessLauncher;
fileSystem;
logger;
proxyProcess = null;
sessionId = null;
currentThreadId = null;
pendingDapRequests = new Map();
isInitialized = false;
isDryRun = false;
adapterConfigured = false;
dapState = null;
stderrBuffer = [];
constructor(adapter, // Optional adapter for language-agnostic support
proxyProcessLauncher, fileSystem, logger) {
super();
this.adapter = adapter;
this.proxyProcessLauncher = proxyProcessLauncher;
this.fileSystem = fileSystem;
this.logger = logger;
}
async start(config) {
if (this.proxyProcess) {
throw new Error('Proxy already running');
}
this.sessionId = config.sessionId;
this.isDryRun = config.dryRunSpawn === true;
// Initialize functional core state
this.dapState = createInitialState(config.sessionId);
// Use adapter to validate environment and resolve executable if available
let executablePath = config.executablePath;
if (this.adapter) {
// Validate environment first
const validation = await this.adapter.validateEnvironment();
if (!validation.valid) {
throw new Error(`Invalid environment for ${this.adapter.language}: ${validation.errors[0].message}`);
}
// Resolve executable path if not provided
if (!executablePath) {
executablePath = await this.adapter.resolveExecutablePath();
this.logger.info(`[ProxyManager] Adapter resolved executable path: ${executablePath}`);
}
}
else if (!executablePath) {
throw new Error('No executable path provided and no adapter available to resolve it');
}
// Find proxy bootstrap script
const proxyScriptPath = await this.findProxyScript();
// Use environment as-is without any path manipulation
// Filter out undefined values to satisfy TypeScript
const env = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
env[key] = value;
}
}
this.logger.info(`[ProxyManager] Spawning proxy for session ${config.sessionId}. Path: ${proxyScriptPath}`);
try {
this.proxyProcess = this.proxyProcessLauncher.launchProxy(proxyScriptPath, config.sessionId, env);
}
catch (error) {
this.logger.error(`[ProxyManager] Failed to spawn proxy:`, error);
throw error;
}
if (!this.proxyProcess || typeof this.proxyProcess.pid === 'undefined') {
throw new Error('Proxy process is invalid or PID is missing');
}
this.logger.info(`[ProxyManager] Proxy spawned with PID: ${this.proxyProcess.pid}`);
// Set up event handlers
this.setupEventHandlers();
// Send initialization command
const initCommand = {
cmd: 'init',
sessionId: config.sessionId,
executablePath: executablePath, // Using resolved executable path
adapterHost: config.adapterHost,
adapterPort: config.adapterPort,
logDir: config.logDir,
scriptPath: config.scriptPath,
scriptArgs: config.scriptArgs,
stopOnEntry: config.stopOnEntry,
justMyCode: config.justMyCode,
initialBreakpoints: config.initialBreakpoints,
dryRunSpawn: config.dryRunSpawn,
// Pass adapter command info for language-agnostic adapter spawning
adapterCommand: config.adapterCommand
};
// Debug log the command being sent
this.logger.info(`[ProxyManager] Sending init command with adapterCommand:`, {
hasAdapterCommand: !!config.adapterCommand,
adapterCommand: config.adapterCommand ? {
command: config.adapterCommand.command,
args: config.adapterCommand.args,
hasEnv: !!config.adapterCommand.env
} : null
});
this.sendCommand(initCommand);
// Wait for initialization or dry run completion
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(ErrorMessages.proxyInitTimeout(30)));
}, 30000);
const cleanup = () => {
clearTimeout(timeout);
this.removeListener('initialized', handleInitialized);
this.removeListener('dry-run-complete', handleDryRun);
this.removeListener('error', handleError);
this.removeListener('exit', handleExit);
};
const handleInitialized = () => {
this.isInitialized = true;
cleanup();
resolve();
};
const handleDryRun = () => {
cleanup();
resolve();
};
const handleError = (error) => {
cleanup();
reject(error);
};
const handleExit = (code, signal) => {
cleanup();
if (this.isDryRun && code === 0) {
// Normal exit for dry run
resolve();
}
else {
let errorMessage = `Proxy exited during initialization. Code: ${code}, Signal: ${signal}`;
if (this.stderrBuffer.length > 0) {
errorMessage += `\nStderr output:\n${this.stderrBuffer.join('\n')}`;
}
reject(new Error(errorMessage));
}
};
this.once('initialized', handleInitialized);
this.once('dry-run-complete', handleDryRun);
this.once('error', handleError);
this.once('exit', handleExit);
});
}
async stop() {
if (!this.proxyProcess) {
return;
}
this.logger.info(`[ProxyManager] Stopping proxy for session ${this.sessionId}`);
// Send terminate command
try {
this.sendCommand({ cmd: 'terminate' });
}
catch (error) {
this.logger.error(`[ProxyManager] Error sending terminate command:`, error);
}
// Wait for graceful exit or force kill after timeout
return new Promise((resolve) => {
const timeout = setTimeout(() => {
this.logger.warn(`[ProxyManager] Timeout waiting for proxy exit. Force killing.`);
this.proxyProcess?.kill('SIGKILL');
resolve();
}, 5000);
this.proxyProcess?.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
}
async sendDapRequest(command, args) {
if (!this.proxyProcess || !this.isInitialized) {
throw new Error('Proxy not initialized');
}
const requestId = uuidv4();
const commandToSend = {
cmd: 'dap',
sessionId: this.sessionId,
requestId,
dapCommand: command,
dapArgs: args
};
this.logger.info(`[ProxyManager] Sending DAP command: ${command}, requestId: ${requestId}`);
return new Promise((resolve, reject) => {
this.pendingDapRequests.set(requestId, {
resolve: resolve,
reject,
command
});
try {
this.sendCommand(commandToSend);
}
catch (error) {
this.pendingDapRequests.delete(requestId);
reject(error);
}
// Timeout handler
setTimeout(() => {
if (this.pendingDapRequests.has(requestId)) {
this.pendingDapRequests.delete(requestId);
reject(new Error(ErrorMessages.dapRequestTimeout(command, 35)));
}
}, 35000);
});
}
isRunning() {
return this.proxyProcess !== null && !this.proxyProcess.killed;
}
getCurrentThreadId() {
return this.currentThreadId;
}
async findProxyScript() {
// Check if we're running from a bundled environment
const isBundled = fileURLToPath(import.meta.url).includes('bundle.cjs');
let distPath;
if (isBundled) {
// In bundled environment (e.g., Docker container), proxy-bootstrap.js is in same dist directory
distPath = path.resolve(process.cwd(), 'dist/proxy/proxy-bootstrap.js');
}
else {
// In development/non-bundled environment, resolve relative to this module's location
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
distPath = path.resolve(moduleDir, '../../dist/proxy/proxy-bootstrap.js');
}
this.logger.info(`[ProxyManager] Checking for proxy script at: ${distPath} (bundled: ${isBundled})`);
if (!(await this.fileSystem.pathExists(distPath))) {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
throw new Error(`Bootstrap worker script not found at: ${distPath}\n` +
`Module directory: ${moduleDir}\n` +
`Current working directory: ${process.cwd()}\n` +
`Is bundled: ${isBundled}\n` +
`This usually means:\n` +
` 1. You need to run 'npm run build' first\n` +
` 2. The build failed to copy proxy files\n` +
` 3. The TypeScript compilation structure is unexpected`);
}
return distPath;
}
sendCommand(command) {
if (!this.proxyProcess || this.proxyProcess.killed) {
throw new Error('Proxy process not available');
}
this.proxyProcess.sendCommand(command);
}
setupEventHandlers() {
if (!this.proxyProcess)
return;
// Handle IPC messages
this.proxyProcess.on('message', (rawMessage) => {
this.handleProxyMessage(rawMessage);
});
// Handle stderr
this.proxyProcess.stderr?.on('data', (data) => {
const output = data.toString().trim();
this.logger.error(`[ProxyManager STDERR] ${output}`);
// Capture stderr for error reporting during initialization
if (!this.isInitialized) {
this.stderrBuffer.push(output);
}
});
// Handle exit
this.proxyProcess.on('exit', (code, signal) => {
this.logger.info(`[ProxyManager] Proxy exited. Code: ${code}, Signal: ${signal}`);
this.handleProxyExit(code, signal);
});
// Handle errors
this.proxyProcess.on('error', (err) => {
this.logger.error(`[ProxyManager] Proxy error:`, err);
this.emit('error', err);
this.cleanup();
});
}
handleProxyMessage(rawMessage) {
this.logger.debug(`[ProxyManager] Received message:`, rawMessage);
// Validate message format
if (!isValidProxyMessage(rawMessage)) {
this.logger.warn(`[ProxyManager] Invalid message format:`, rawMessage);
return;
}
const message = rawMessage;
// Use functional core if state is initialized
if (this.dapState) {
const result = handleProxyMessage(this.dapState, message);
// Execute commands from functional core
for (const command of result.commands) {
switch (command.type) {
case 'log':
this.logger[command.level](command.message, command.data);
break;
case 'emitEvent':
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Event args must support variable argument counts, any[] required for spread operator
this.emit(command.event, ...command.args);
break;
case 'killProcess':
this.proxyProcess?.kill();
break;
case 'sendToProxy':
this.sendCommand(command.command);
break;
// Note: sendToClient is not used in ProxyManager context
}
}
// Update state if changed
if (result.newState) {
this.dapState = result.newState;
// Sync local state with functional core state
this.isInitialized = result.newState.initialized;
this.adapterConfigured = result.newState.adapterConfigured;
this.currentThreadId = result.newState.currentThreadId ?? null;
}
// Handle pending DAP responses (still done imperatively for now)
if (message.type === 'dapResponse') {
this.handleDapResponse(message);
}
}
else {
// Fallback if state not initialized (shouldn't happen)
this.logger.error(`[ProxyManager] DAP state not initialized`);
}
}
handleDapResponse(message) {
const pending = this.pendingDapRequests.get(message.requestId);
if (!pending) {
this.logger.warn(`[ProxyManager] Received response for unknown request: ${message.requestId}`);
return;
}
this.pendingDapRequests.delete(message.requestId);
if (message.success) {
pending.resolve((message.response || message.body));
}
else {
pending.reject(new Error(message.error || `DAP request '${pending.command}' failed`));
}
}
handleDapEvent(message) {
this.logger.info(`[ProxyManager] DAP event: ${message.event}`, message.body);
switch (message.event) {
case 'stopped':
const stoppedBody = message.body;
const threadId = stoppedBody?.threadId || 0;
const reason = stoppedBody?.reason || 'unknown';
if (threadId) {
this.currentThreadId = threadId;
}
this.emit('stopped', threadId, reason, stoppedBody);
break;
case 'continued':
this.emit('continued');
break;
case 'terminated':
this.emit('terminated');
break;
case 'exited':
this.emit('exited');
break;
// Forward other events as generic DAP events
default:
this.emit('dap-event', message.event, message.body);
}
}
handleStatusMessage(message) {
switch (message.status) {
case 'proxy_minimal_ran_ipc_test':
this.logger.info(`[ProxyManager] IPC test message received`);
this.proxyProcess?.kill();
break;
case 'dry_run_complete':
this.logger.info(`[ProxyManager] Dry run complete`);
this.emit('dry-run-complete', message.command, message.script);
break;
case 'adapter_configured_and_launched':
this.logger.info(`[ProxyManager] Adapter configured and launched`);
this.adapterConfigured = true;
this.emit('adapter-configured');
if (!this.isInitialized) {
this.isInitialized = true;
this.emit('initialized');
}
break;
case 'adapter_exited':
case 'dap_connection_closed':
case 'terminated':
this.logger.info(`[ProxyManager] Status: ${message.status}`);
this.emit('exit', message.code || 1, message.signal || undefined);
break;
}
}
handleProxyExit(code, signal) {
// Clean up pending requests
this.pendingDapRequests.forEach(pending => {
pending.reject(new Error('Proxy exited'));
});
this.pendingDapRequests.clear();
// Emit exit event
this.emit('exit', code, signal || undefined);
// Clean up
this.cleanup();
}
cleanup() {
this.proxyProcess = null;
this.isInitialized = false;
this.adapterConfigured = false;
this.currentThreadId = null;
}
}
//# sourceMappingURL=proxy-manager.js.map