@debugmcp/mcp-debugger
Version:
Run-time step-through debugging for LLM agents.
589 lines (500 loc) • 18.8 kB
text/typescript
/**
* ProxyManager - Handles spawning and communication with debug proxy processes
*/
import { EventEmitter } from 'events';
import { DebugProtocol } from '@vscode/debugprotocol';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import { fileURLToPath } from 'url';
import {
IFileSystem,
ILogger
} from '../interfaces/external-dependencies.js';
import { IProxyProcessLauncher, IProxyProcess } from '../interfaces/process-interfaces.js';
import {
createInitialState,
handleProxyMessage,
isValidProxyMessage,
DAPSessionState
} from '../dap-core/index.js';
import { ErrorMessages } from '../utils/error-messages.js';
import { ProxyConfig } from './proxy-config.js';
import { IDebugAdapter } from '../adapters/debug-adapter-interface.js';
/**
* Events emitted by ProxyManager
*/
export interface ProxyManagerEvents {
// DAP events
'stopped': (threadId: number, reason: string, data?: DebugProtocol.StoppedEvent['body']) => void;
'continued': () => void;
'terminated': () => void;
'exited': () => void;
// Proxy lifecycle events
'initialized': () => void;
'error': (error: Error) => void;
'exit': (code: number | null, signal?: string) => void;
// Status events
'dry-run-complete': (command: string, script: string) => void;
'adapter-configured': () => void;
'dap-event': (event: string, body: unknown) => void;
}
/**
* Interface for proxy managers
*/
export interface IProxyManager extends EventEmitter {
start(config: ProxyConfig): Promise<void>;
stop(): Promise<void>;
sendDapRequest<T extends DebugProtocol.Response>(
command: string,
args?: unknown
): Promise<T>;
isRunning(): boolean;
getCurrentThreadId(): number | null;
// Typed event emitter methods
on<K extends keyof ProxyManagerEvents>(
event: K,
listener: ProxyManagerEvents[K]
): this;
emit<K extends keyof ProxyManagerEvents>(
event: K,
...args: Parameters<ProxyManagerEvents[K]>
): boolean;
}
// Message types from proxy
type ProxyStatusMessage =
| { type: 'status'; sessionId: string; status: 'proxy_minimal_ran_ipc_test'; message?: string }
| { type: 'status'; sessionId: string; status: 'dry_run_complete'; command: string; script: string; data?: unknown }
| { type: 'status'; sessionId: string; status: 'adapter_configured_and_launched'; data?: unknown }
| { type: 'status'; sessionId: string; status: 'adapter_exited' | 'dap_connection_closed' | 'terminated'; code?: number | null; signal?: NodeJS.Signals | null; data?: unknown };
type ProxyDapEventMessage = {
type: 'dapEvent';
sessionId: string;
event: string;
body?: unknown;
data?: unknown
};
type ProxyDapResponseMessage = {
type: 'dapResponse';
sessionId: string;
requestId: string;
success: boolean;
response?: DebugProtocol.Response;
body?: unknown;
error?: string;
data?: unknown;
};
type ProxyErrorMessage = {
type: 'error';
sessionId: string;
message: string;
data?: unknown
};
type ProxyMessage = ProxyStatusMessage | ProxyDapEventMessage | ProxyDapResponseMessage | ProxyErrorMessage;
/**
* Concrete implementation of ProxyManager
*/
export class ProxyManager extends EventEmitter implements IProxyManager {
private proxyProcess: IProxyProcess | null = null;
private sessionId: string | null = null;
private currentThreadId: number | null = null;
private pendingDapRequests = new Map<string, {
resolve: (response: DebugProtocol.Response) => void;
reject: (error: Error) => void;
command: string;
}>();
private isInitialized = false;
private isDryRun = false;
private adapterConfigured = false;
private dapState: DAPSessionState | null = null;
private stderrBuffer: string[] = [];
constructor(
private adapter: IDebugAdapter | null, // Optional adapter for language-agnostic support
private proxyProcessLauncher: IProxyProcessLauncher,
private fileSystem: IFileSystem,
private logger: ILogger
) {
super();
}
async start(config: ProxyConfig): Promise<void> {
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: string | undefined = 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: Record<string, string> = {};
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: Error) => {
cleanup();
reject(error);
};
const handleExit = (code: number | null, signal?: string) => {
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(): Promise<void> {
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<T extends DebugProtocol.Response>(
command: string,
args?: unknown
): Promise<T> {
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<T>((resolve, reject) => {
this.pendingDapRequests.set(requestId, {
resolve: resolve as (value: DebugProtocol.Response) => void,
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(): boolean {
return this.proxyProcess !== null && !this.proxyProcess.killed;
}
getCurrentThreadId(): number | null {
return this.currentThreadId;
}
private async findProxyScript(): Promise<string> {
// Check if we're running from a bundled environment
const isBundled = fileURLToPath(import.meta.url).includes('bundle.cjs');
let distPath: string;
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;
}
private sendCommand(command: object): void {
if (!this.proxyProcess || this.proxyProcess.killed) {
throw new Error('Proxy process not available');
}
this.proxyProcess.sendCommand(command);
}
private setupEventHandlers(): void {
if (!this.proxyProcess) return;
// Handle IPC messages
this.proxyProcess.on('message', (rawMessage: unknown) => {
this.handleProxyMessage(rawMessage);
});
// Handle stderr
this.proxyProcess.stderr?.on('data', (data: Buffer | string) => {
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: number | null, signal: string | null) => {
this.logger.info(`[ProxyManager] Proxy exited. Code: ${code}, Signal: ${signal}`);
this.handleProxyExit(code, signal);
});
// Handle errors
this.proxyProcess.on('error', (err: Error) => {
this.logger.error(`[ProxyManager] Proxy error:`, err);
this.emit('error', err);
this.cleanup();
});
}
private handleProxyMessage(rawMessage: unknown): void {
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 as ProxyMessage;
// 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 as keyof ProxyManagerEvents, ...(command.args as [any, any]));
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 as ProxyDapResponseMessage);
}
} else {
// Fallback if state not initialized (shouldn't happen)
this.logger.error(`[ProxyManager] DAP state not initialized`);
}
}
private handleDapResponse(message: ProxyDapResponseMessage): void {
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) as DebugProtocol.Response);
} else {
pending.reject(new Error(message.error || `DAP request '${pending.command}' failed`));
}
}
private handleDapEvent(message: ProxyDapEventMessage): void {
this.logger.info(`[ProxyManager] DAP event: ${message.event}`, message.body);
switch (message.event) {
case 'stopped':
const stoppedBody = message.body as { threadId?: number; reason?: string } | undefined;
const threadId = stoppedBody?.threadId || 0;
const reason = stoppedBody?.reason || 'unknown';
if (threadId) {
this.currentThreadId = threadId;
}
this.emit('stopped', threadId, reason, stoppedBody as DebugProtocol.StoppedEvent['body']);
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);
}
}
private handleStatusMessage(message: ProxyStatusMessage): void {
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;
}
}
private handleProxyExit(code: number | null, signal: string | null): void {
// 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();
}
private cleanup(): void {
this.proxyProcess = null;
this.isInitialized = false;
this.adapterConfigured = false;
this.currentThreadId = null;
}
}