stdio-transport.ts•10.3 kB
import { EventEmitter } from 'events';
import { spawn } from 'child_process';
import { Logger } from '../server/logger';
import { MetricsCollector } from '../server/metrics';
import { ConfigManager } from '../server/config';
/**
* Debug Adapter Protocol (DAP) Stdio Transport
*
* Implements the DAP stdio transport with Content-Length framing
* as specified in the VS Code Debug Protocol.
*/
export class DapStdioTransport extends EventEmitter {
private logger: Logger;
private metrics: MetricsCollector;
private config: ConfigManager;
private process: any;
private messageBuffer: string = '';
private seq: number = 1;
private isConnected: boolean = false;
private isConnecting: boolean = false;
constructor() {
super();
this.logger = new Logger('DapStdioTransport');
this.metrics = MetricsCollector.getInstance();
this.config = ConfigManager.getInstance();
}
/**
* Connect to debug adapter via stdio
*/
async connect(adapterCommand: string[], adapterEnv?: Record<string, string>): Promise<void> {
if (this.isConnected || this.isConnecting) {
throw new Error('Already connected or connecting');
}
this.isConnecting = true;
this.logger.info(`Connecting to debug adapter: ${adapterCommand.join(' ')}`);
this.metrics.startTimer('dap.transport.connect');
try {
// Start the debug adapter process
this.process = spawn(adapterCommand[0] || '', adapterCommand.slice(1), {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
...adapterEnv,
},
});
this.setupProcessHandlers();
// Wait for adapter to initialize
await this.waitForAdapterInitialization();
this.isConnected = true;
this.isConnecting = false;
this.metrics.increment('dap.transport.connect.count');
this.metrics.stopTimer('dap.transport.connect');
this.logger.info('Debug adapter connected successfully');
this.emit('connected');
} catch (error) {
this.cleanup();
this.isConnecting = false;
this.logger.error('Failed to connect to debug adapter:', error);
throw error;
}
}
/**
* Disconnect from debug adapter
*/
async disconnect(): Promise<void> {
if (!this.isConnected) {
this.logger.warn('Not connected to debug adapter');
return;
}
this.logger.info('Disconnecting from debug adapter');
this.metrics.startTimer('dap.transport.disconnect');
try {
// Send terminate request
await this.sendRequest('terminate', {});
// Wait a bit for graceful shutdown
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
this.logger.warn('Error during graceful disconnect:', error);
} finally {
this.cleanup();
this.isConnected = false;
this.metrics.increment('dap.transport.disconnect.count');
this.metrics.stopTimer('dap.transport.disconnect');
this.logger.info('Debug adapter disconnected');
this.emit('disconnected');
}
}
/**
* Send DAP request to debug adapter
*/
async sendRequest(command: string, arguments_: any = {}, seq?: number): Promise<any> {
if (!this.isConnected) {
throw new Error('Not connected to debug adapter');
}
const requestSeq = seq || this.seq++;
const request = {
type: 'request',
seq: requestSeq,
command,
arguments: arguments_,
};
this.logger.debug(`Sending DAP request: ${command}`, { seq: requestSeq });
this.metrics.increment(`dap.${command.toLowerCase()}.count`);
const message = this.formatMessage(request);
this.process.stdin.write(message);
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => {
reject(new Error(`Timeout waiting for response to ${command}`));
},
this.config.get('debugging.defaultTimeout', 30000)
);
const handler = (response: any) => {
clearTimeout(timeout);
this.off('response', handler);
this.off('error', errorHandler);
if (response.success) {
resolve(response);
} else {
reject(new Error(response.message || 'DAP request failed'));
}
};
const errorHandler = (error: Error) => {
clearTimeout(timeout);
this.off('response', handler);
this.off('error', errorHandler);
reject(error);
};
this.once('response', handler);
this.once('error', errorHandler);
});
}
/**
* Send DAP event to debug adapter
*/
sendEvent(event: string, body: any = {}): void {
if (!this.isConnected) {
this.logger.warn('Not connected to debug adapter');
return;
}
const eventMessage = {
type: 'event',
seq: this.seq++,
event,
body,
};
this.logger.debug(`Sending DAP event: ${event}`);
const message = this.formatMessage(eventMessage);
this.process.stdin.write(message);
}
/**
* Check if connected
*/
get connected(): boolean {
return this.isConnected;
}
/**
* Setup process event handlers
*/
private setupProcessHandlers(): void {
if (!this.process) return;
// Handle stdout data
this.process.stdout.on('data', (data: Buffer) => {
this.handleStdoutData(data.toString());
});
// Handle stderr data
this.process.stderr.on('data', (data: Buffer) => {
const message = data.toString().trim();
this.logger.warn('Debug adapter stderr:', message);
this.emit('error', new Error(`Debug adapter error: ${message}`));
});
// Handle process exit
this.process.on('exit', (code: number) => {
this.logger.info(`Debug adapter process exited with code ${code}`);
this.cleanup();
this.isConnected = false;
this.emit('disconnected');
});
// Handle process error
this.process.on('error', (error: Error) => {
this.logger.error('Debug adapter process error:', error);
this.emit('error', error);
});
}
/**
* Handle incoming data from debug adapter stdout
*/
private handleStdoutData(data: string): void {
this.messageBuffer += data;
// Process complete messages
while (this.messageBuffer.length > 0) {
const contentLengthMatch = this.messageBuffer.match(/^Content-Length: (\d+)\r\n\r\n/);
if (!contentLengthMatch) {
// Wait for more data to complete the header
break;
}
const contentLength = parseInt(contentLengthMatch[1] || '0', 10);
const messageStart = this.messageBuffer.indexOf('\r\n\r\n') + 4;
if (this.messageBuffer.length < messageStart + contentLength) {
// Wait for more data to complete the message body
break;
}
// Extract complete message
const messageBody = this.messageBuffer.substring(messageStart, messageStart + contentLength);
const remainder = this.messageBuffer.substring(messageStart + contentLength);
// Update buffer
this.messageBuffer = remainder;
try {
const message = JSON.parse(messageBody);
this.handleMessage(message);
} catch (error) {
this.logger.error('Failed to parse DAP message:', error);
this.emit('error', new Error('Invalid DAP message format'));
}
}
}
/**
* Handle incoming DAP message
*/
private handleMessage(message: any): void {
this.logger.debug('Received DAP message:', message);
// Handle responses
if (message.type === 'response') {
this.emit('response', message);
return;
}
// Handle events
if (message.type === 'event') {
this.emit('event', message.event, message.body);
return;
}
// Handle requests (for bidirectional communication)
if (message.type === 'request') {
this.logger.warn('Received unexpected DAP request:', message);
this.emit('error', new Error('Unexpected DAP request received'));
return;
}
this.logger.warn('Unknown DAP message type:', message.type);
}
/**
* Format message with Content-Length framing
*/
private formatMessage(message: any): string {
const json = JSON.stringify(message);
const contentLength = Buffer.byteLength(json, 'utf8');
return `Content-Length: ${contentLength}\r\n\r\n${json}`;
}
/**
* Wait for adapter initialization
*/
private async waitForAdapterInitialization(): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Adapter initialization timeout'));
}, 10000);
const checkInitialized = () => {
if (this.process.exitCode !== undefined) {
clearTimeout(timeout);
reject(new Error('Adapter process exited during initialization'));
return;
}
// Send an initialize request to test the connection
this.sendRequest('initialize', {
clientID: 'watchtower',
clientName: 'Watchtower MCP Server',
adapterID: 'watchtower',
locale: 'en-us',
linesStartAt1: true,
columnsStartAt1: true,
pathFormat: 'path',
supportsVariableType: true,
supportsVariablePaging: true,
supportsRunInTerminalRequest: true,
supportsMemoryReferences: false,
supportsArgsCanBeInterpretedByShell: false,
supportsProgressReporting: false,
supportsInvalidatedEvent: true,
supportsMemoryEvent: false,
})
.then(() => {
clearTimeout(timeout);
resolve();
})
.catch(error => {
clearTimeout(timeout);
reject(new Error(`Adapter initialization failed: ${error.message}`));
});
};
// Try to initialize immediately
checkInitialized();
});
}
/**
* Cleanup resources
*/
private cleanup(): void {
if (this.process) {
this.process.kill('SIGTERM');
this.process = null;
}
this.messageBuffer = '';
this.seq = 1;
this.isConnected = false;
this.isConnecting = false;
this.removeAllListeners();
}
}
/**
* Create DAP stdio transport instance
*/
export function createDapStdioTransport(): DapStdioTransport {
return new DapStdioTransport();
}