UNPKG

@debugmcp/mcp-debugger

Version:

Step-through debugging MCP server for LLMs

673 lines 30.9 kB
/** * Simplified MinimalDapClient using proper buffer management * Extracts just the message parsing logic from vscode's implementation */ import net from 'net'; import { EventEmitter } from 'events'; import { createLogger } from '../utils/logger.js'; import fs from 'fs'; import path from 'path'; import { DefaultAdapterPolicy } from '@debugmcp/shared'; import { ChildSessionManager } from './child-session-manager.js'; import { getErrorMessage } from '../errors/debug-errors.js'; const logger = createLogger('minimal-dap-simple'); const TWO_CRLF = '\r\n\r\n'; export class MinimalDapClient extends EventEmitter { socket = null; rawData = Buffer.alloc(0); contentLength = -1; pendingRequests = new Map(); nextSeq = 1; isDisconnectingOrDisconnected = false; host; port; traceFile = process.env.DAP_TRACE_FILE; adoptedTargets = new Set(); childSessions = new Map(); activeChild = null; storedBreakpoints = new Map(); initializedSeen = false; // Adapter policy and DAP behavior configuration policy; dapBehavior; childSessionManager; // When true, we defer parent's configurationDone (policy-driven, e.g. js-debug) deferParentConfigDoneActive = false; // Defers parent's configurationDone to keep process paused until child is configured parentConfigDoneDeferred = null; // When set, the very next configurationDone send will not be deferred suppressNextConfigDoneDeferral = false; // Mirror of active children tracked for policy context // Child lifecycle is managed by ChildSessionManager; we only retain a view for policy consumers. childClientFactory; timers; constructor(host, port, policy, options) { super(); this.host = host; this.port = port; this.policy = policy || DefaultAdapterPolicy; this.dapBehavior = this.policy.getDapClientBehavior(); this.timers = options?.timers ?? { setTimeout, clearTimeout }; this.childClientFactory = options?.childClientFactory ?? ((childHost, childPort, policyForChild) => new MinimalDapClient(childHost, childPort, policyForChild, { timers: this.timers })); // Initialize ChildSessionManager for policies that support child sessions if (this.policy.supportsReverseStartDebugging) { const createChildSessionManager = options?.childSessionManagerFactory ?? ((opts) => new ChildSessionManager(opts)); this.childSessionManager = createChildSessionManager({ policy: this.policy, parentClient: this, host, port }); // Wire up events from ChildSessionManager this.childSessionManager.on('childCreated', (pendingId, child) => { logger.info(`[MinimalDapClient] childCreated event: Setting activeChild for ${pendingId}`); this.childSessions.set(pendingId, child); this.activeChild = child; }); this.childSessionManager.on('childEvent', (evt) => { // Forward child events this.emit(evt.event, evt.body); this.emit('event', evt); }); this.childSessionManager.on('childError', (_pendingId, error) => { logger.error('[MinimalDapClient] Child session error:', error); }); this.childSessionManager.on('childClosed', () => { logger.info(`[MinimalDapClient] childClosed event: Clearing activeChild`); this.childSessions.clear(); this.activeChild = null; }); } } /** * Handle raw data using the same algorithm as vscode's ProtocolServer * This ensures compatibility and proper message boundaries */ handleData(data) { this.rawData = Buffer.concat([this.rawData, data]); while (true) { if (this.contentLength >= 0) { // We have a content length, check if we have the full message if (this.rawData.length >= this.contentLength) { const message = this.rawData.toString('utf8', 0, this.contentLength); this.rawData = this.rawData.slice(this.contentLength); this.contentLength = -1; // Parse and handle the message if (message.length > 0) { try { const msg = JSON.parse(message); void this.handleProtocolMessage(msg); } catch (e) { logger.error('[MinimalDapClient] Error parsing message:', e); } } continue; } } // Look for the header const idx = this.rawData.indexOf(TWO_CRLF); if (idx === -1) { // No complete header yet break; } const header = this.rawData.toString('utf8', 0, idx); const lines = header.split('\r\n'); let parsedLength = null; for (const line of lines) { if (line.toLowerCase().startsWith('content-length')) { const value = line.split(':')[1]?.trim(); const candidate = Number.parseInt(value ?? '', 10); if (!Number.isNaN(candidate)) { parsedLength = candidate; } break; } } // Remove header from buffer this.rawData = this.rawData.slice(idx + TWO_CRLF.length); if (parsedLength === null || parsedLength <= 0 || !Number.isFinite(parsedLength)) { logger.warn('[MinimalDapClient] Invalid Content-Length header encountered; discarding payload'); this.contentLength = -1; this.rawData = Buffer.alloc(0); continue; } this.contentLength = parsedLength; } } async handleProtocolMessage(message) { this.appendTrace('in', message); const debugInfo = { type: message.type, seq: message.seq }; // Add command if it's a request or response if (message.type === 'request' || message.type === 'response') { debugInfo.command = message.command; } // Add event if it's an event if (message.type === 'event') { debugInfo.event = message.event; } // DIAGNOSTIC: Enhanced logging for ALL messages logger.info(`[MinimalDapClient] DAP message: ${message.type}`, debugInfo); if (message.type === 'request') { const req = message; logger.info(`[MinimalDapClient] Reverse request: ${req.command}`, { command: req.command, seq: req.seq, arguments: req.arguments }); } else if (message.type === 'event') { const evt = message; logger.info(`[MinimalDapClient] Event: ${evt.event}`, { event: evt.event, body: evt.body }); } logger.debug(`[MinimalDapClient] Received message:`, debugInfo); if (message.type === 'response') { const response = message; const pending = this.pendingRequests.get(response.request_seq); if (pending) { this.timers.clearTimeout(pending.timer); this.pendingRequests.delete(response.request_seq); if (response.success) { pending.resolve(response); } else { pending.reject(new Error(response.message || 'Request failed')); } } else { if (this.isDisconnectingOrDisconnected) { logger.debug(`[MinimalDapClient] Received response for unknown request ${response.request_seq} during shutdown`); } else { logger.warn(`[MinimalDapClient] Received response for unknown request ${response.request_seq}`); } } } else if (message.type === 'event') { const event = message; logger.info(`[MinimalDapClient] Received event: ${event.event}`); if (event.event === 'initialized') { this.initializedSeen = true; // Do not auto-send configurationDone here; defer to higher-level sequencing/policy // This avoids premature resume and double-config in multi-session adapters like js-debug. } // Emit both the specific event and the generic event for backward compatibility this.emit(event.event, event.body); this.emit('event', event); } else if (message.type === 'request') { const request = message; logger.info(`[MinimalDapClient] Received adapter request: ${request.command}`); // Try to handle through policy's reverse request handler if (this.dapBehavior.handleReverseRequest) { try { const context = { sendResponse: (req, body, success, errorMessage) => { this.sendResponse(req, body, success ?? true, errorMessage); }, createChildSession: async (config) => { if (this.childSessionManager) { await this.childSessionManager.createChildSession(config); // Update active child reference from manager this.activeChild = this.childSessionManager.getActiveChild(); } }, activeChildren: this.childSessions, adoptedTargets: this.adoptedTargets }; const result = await this.dapBehavior.handleReverseRequest(request, context); if (result.handled) { // Policy handled the request if (result.createChildSession && this.childSessionManager && result.childConfig) { // Create child session through the manager logger.info(`[MinimalDapClient] Creating child session via ChildSessionManager`); try { await this.childSessionManager.createChildSession(result.childConfig); // Set up deferred config if needed if (this.dapBehavior.deferParentConfigDone) { this.deferParentConfigDoneActive = true; } // Update active child reference from manager this.activeChild = this.childSessionManager.getActiveChild(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.error(`[MinimalDapClient] Failed to create child session: ${msg}`); } } return; } } catch (e) { const err = getErrorMessage(e); logger.error(`[MinimalDapClient] Error in policy reverse request handler: ${err}`); } } // Default handling for unhandled reverse requests try { switch (request.command) { case 'runInTerminal': // Acknowledge without spawning a terminal (internalConsole launch path). this.sendResponse(request, {}); break; default: // For unrecognized adapter requests, reply success with empty body to avoid deadlocks. this.sendResponse(request, {}); break; } } catch (e) { const err = getErrorMessage(e); logger.error(`[MinimalDapClient] Error handling adapter request '${request.command}': ${err}`); this.sendResponse(request, {}); } } } appendTrace(direction, payload) { if (!this.traceFile) return; try { fs.appendFileSync(this.traceFile, JSON.stringify({ ts: new Date().toISOString(), direction, payload }) + '\n', 'utf8'); } catch { // ignore trace errors } } sleep(ms) { return new Promise((resolve) => { this.timers.setTimeout(resolve, ms); }); } async waitInitialized(timeoutMs = 5000) { if (this.initializedSeen) return; await new Promise((resolve) => { let done = false; const onInit = () => { if (done) return; done = true; this.removeListener('initialized', onInit); resolve(); }; const timer = this.timers.setTimeout(() => { if (done) return; done = true; this.removeListener('initialized', onInit); resolve(); }, timeoutMs); this.on('initialized', () => { this.timers.clearTimeout(timer); onInit(); }); }); } flushDeferredParentConfigDone() { if (this.parentConfigDoneDeferred) { const pending = this.parentConfigDoneDeferred; this.parentConfigDoneDeferred = null; // Ensure we do not defer the parent's configDone again this.suppressNextConfigDoneDeferral = true; // Send now and wire through the original promise handlers void this.sendRequest('configurationDone', pending.args) .then(pending.resolve) .catch(pending.reject); } } connect() { return new Promise((resolve, reject) => { logger.info(`[MinimalDapClient] Connecting to ${this.host}:${this.port}`); let connected = false; let connectionRejected = false; // Use net.createConnection for test compatibility this.socket = net.createConnection({ host: this.host, port: this.port }, () => { logger.info(`[MinimalDapClient] Connected to ${this.host}:${this.port}`); connected = true; resolve(); }); // Set up all handlers immediately this.socket.on('data', (data) => { this.handleData(data); }); this.socket.on('error', (err) => { logger.error('[MinimalDapClient] Socket error:', err); // Only emit error events after successful connection // During connection, just reject the promise if (connected) { this.emit('error', err); } else if (!connectionRejected) { connectionRejected = true; reject(err); } }); this.socket.on('close', () => { logger.info('[MinimalDapClient] Socket closed'); this.emit('close'); this.cleanup(); // If we never connected and haven't rejected yet, reject now if (!connected && !connectionRejected) { connectionRejected = true; reject(new Error('Socket closed before connection established')); } }); }); } async sendRequest(command, args, timeoutMs = 30000) { if (!this.socket || this.socket.destroyed) { throw new Error('Socket not connected or destroyed'); } if (this.isDisconnectingOrDisconnected) { throw new Error('Client is disconnecting or disconnected'); } // Defer parent's configurationDone briefly to allow child session to configure, // avoiding immediate resume of the target before adoption completes. if (command === 'configurationDone' && this.deferParentConfigDoneActive) { if (this.suppressNextConfigDoneDeferral) { // Consume the suppression for a single pass-through this.suppressNextConfigDoneDeferral = false; } else { // Create a promise we will resolve once we actually send the deferred configDone return new Promise((resolve, reject) => { // Clear any prior deferral if (this.parentConfigDoneDeferred) { this.timers.clearTimeout(this.parentConfigDoneDeferred.timer); this.parentConfigDoneDeferred = null; } const timer = this.timers.setTimeout(() => { // Time-bound deferral: if no child completed in time, send now this.suppressNextConfigDoneDeferral = true; void this.sendRequest('configurationDone', args) // eslint-disable-next-line @typescript-eslint/no-explicit-any .then(resolve) .catch(reject); this.parentConfigDoneDeferred = null; }, 1500); this.parentConfigDoneDeferred = { // eslint-disable-next-line @typescript-eslint/no-explicit-any resolve: resolve, reject, args, timer }; }); } } // Route debuggee-scoped requests to active child session when present using policy const manager = this.childSessionManager; const shouldRouteToChild = manager?.shouldRouteToChild(command) ?? false; if (shouldRouteToChild) { const hasActiveChild = manager?.hasActiveChildren?.() ?? false; const adoptionInProgress = typeof manager?.isAdoptionInProgress === 'function' ? manager.isAdoptionInProgress() : false; logger.info(`[MinimalDapClient] Routing '${command}' to child session (hasActiveChild=${hasActiveChild}, adoptionInProgress=${adoptionInProgress})`); // Special handling for stackTrace when child isn't ready yet. // Policies can opt-in to waiting for a child session instead of falling back immediately. const stackTraceRequiresChild = this.dapBehavior.stackTraceRequiresChild === true; if (command === 'stackTrace' && !this.activeChild) { const expectChild = stackTraceRequiresChild || adoptionInProgress || hasActiveChild; if (expectChild) { logger.info(`[MinimalDapClient] stackTrace requested while child not ready - waiting for child session (policy=${this.policy?.name}, requiresChild=${stackTraceRequiresChild}, adoptionInProgress=${adoptionInProgress}, hasActiveChild=${hasActiveChild})`); const maxWaitMs = this.dapBehavior.childInitTimeout ?? 12000; const pollIntervalMs = 50; const maxIterations = maxWaitMs / pollIntervalMs; for (let i = 0; i < maxIterations && !this.activeChild; i++) { await this.sleep(pollIntervalMs); this.activeChild = manager?.getActiveChild() || null; if (i % 10 === 0 && i > 0) { const elapsedMs = i * pollIntervalMs; const stillAdopting = manager?.isAdoptionInProgress() ?? false; logger.info(`[MinimalDapClient] stackTrace wait ${elapsedMs}ms: activeChild=${!!this.activeChild}, adoptionInProgress=${stillAdopting}`); } } if (!this.activeChild) { logger.warn(`[MinimalDapClient] stackTrace: Child still not ready after ${maxWaitMs}ms wait`); if (stackTraceRequiresChild) { const syntheticFailure = { type: 'response', seq: this.nextSeq++, request_seq: -1, command, success: false, message: `Child session not ready for '${command}' after waiting ${maxWaitMs}ms` }; return syntheticFailure; } } else { logger.info('[MinimalDapClient] Child session now ready for stackTrace'); } } } else if (!this.activeChild && manager?.hasActiveChildren()) { // For other commands, wait longer if needed logger.info(`[MinimalDapClient] Waiting for active child before routing '${command}'`); for (let i = 0; i < 120 && !this.activeChild; i++) { await this.sleep(100); // up to ~12s this.activeChild = manager.getActiveChild(); } } if (this.activeChild) { try { logger.info(`[MinimalDapClient] Dispatching '${command}' to child session`); return await this.activeChild.sendRequest(command, args, timeoutMs); } catch (err) { const message = err instanceof Error ? err.message : String(err); const treatAsGracefulCompletion = command === 'continue' || command === 'disconnect' || command === 'terminate'; if (message.includes('DAP client disconnected') || message.includes('Socket not connected') || message.includes('write after end')) { logger.warn(`[MinimalDapClient] Child session unavailable for '${command}' (${message}); falling back to parent session.`); if (treatAsGracefulCompletion) { const syntheticResponse = { type: 'response', seq: this.nextSeq++, request_seq: -1, command, success: true }; return syntheticResponse; } } else { throw err; } } } else if (command === 'stackTrace') { logger.warn(`[MinimalDapClient] No active child for stackTrace - attempting parent session (may return empty)`); // Fall through to send to parent session } else { logger.warn(`[MinimalDapClient] No active child available for routed command '${command}'. Forwarding to parent session (may return empty/unsupported).`); } } else { const hasActiveChild = this.childSessionManager?.hasActiveChildren?.() ?? false; const adoptionInProgress = typeof this.childSessionManager?.isAdoptionInProgress === 'function' ? this.childSessionManager.isAdoptionInProgress() : false; logger.info(`[MinimalDapClient] Keeping '${command}' on parent session (hasActiveChild=${hasActiveChild}, adoptionInProgress=${adoptionInProgress})`); } // Track and mirror setBreakpoints to child if/when present using ChildSessionManager if (command === 'setBreakpoints' && this.childSessionManager) { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const a = args ?? {}; const sp = a?.source?.path; const bps = a?.breakpoints; if (typeof sp === 'string' && Array.isArray(bps)) { const absolutePath = path.isAbsolute(sp) ? sp : path.resolve(sp); // Store breakpoints in ChildSessionManager for mirroring this.childSessionManager.storeBreakpoints(absolutePath, bps); // Also keep local copy for legacy code compatibility this.storedBreakpoints.set(absolutePath, bps); } } catch { // ignore tracking errors } } const requestSeq = this.nextSeq++; // Normalize initialize args using policy if (command === 'initialize' && this.dapBehavior.normalizeAdapterId) { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const a = args && typeof args === 'object' ? { ...args } : {}; if (typeof a.adapterID === 'string') { const normalized = this.dapBehavior.normalizeAdapterId(a.adapterID); if (normalized !== a.adapterID) { a.adapterID = normalized; args = a; logger.info(`[MinimalDapClient] Normalized initialize.adapterID -> ${normalized}`); } } } catch { // ignore normalization errors } } const request = { seq: requestSeq, type: 'request', command: command, arguments: args }; logger.info(`[MinimalDapClient] Sending request:`, { command, seq: requestSeq, args: args || {} }); return new Promise((resolve, reject) => { // Set up timeout const timer = this.timers.setTimeout(() => { if (this.pendingRequests.has(requestSeq)) { this.pendingRequests.delete(requestSeq); reject(new Error(`DAP request '${command}' (seq ${requestSeq}) timed out`)); } }, timeoutMs); // Store pending request this.pendingRequests.set(requestSeq, { resolve: resolve, reject, timer }); // Send the request this.appendTrace('out', request); const json = JSON.stringify(request); const contentLength = Buffer.byteLength(json, 'utf8'); const message = `Content-Length: ${contentLength}${TWO_CRLF}${json}`; // Socket was already checked above, but TypeScript needs reassurance if (!this.socket) { this.timers.clearTimeout(timer); this.pendingRequests.delete(requestSeq); reject(new Error('Socket unexpectedly null')); return; } this.socket.write(message, (err) => { if (err) { this.timers.clearTimeout(timer); this.pendingRequests.delete(requestSeq); reject(err); } }); }); } writeMessage(message) { const json = JSON.stringify(message); const contentLength = Buffer.byteLength(json, 'utf8'); const payload = `Content-Length: ${contentLength}${TWO_CRLF}${json}`; this.appendTrace('out', message); if (this.socket && !this.socket.destroyed) { this.socket.write(payload); } else { logger.error('[MinimalDapClient] Cannot write message, socket not connected/destroyed'); } } sendResponse(request, body = {}, success = true, errorMessage) { const response = { type: 'response', seq: this.nextSeq++, request_seq: request.seq, command: request.command, success, ...(success ? { body } : { message: errorMessage || 'Request failed' }) }; this.writeMessage(response); } disconnect() { this.shutdown('Client disconnect requested'); } shutdown(reason = 'shutdown') { if (this.isDisconnectingOrDisconnected) { logger.debug('[MinimalDapClient] Already disconnecting or disconnected'); return; } this.isDisconnectingOrDisconnected = true; logger.info(`[MinimalDapClient] Shutting down: ${reason}`); // Shutdown any child sessions try { for (const child of this.childSessions.values()) { try { child.shutdown('parent shutdown'); } catch (e) { const emsg = getErrorMessage(e); logger.warn('[MinimalDapClient] Error shutting down child sessions:', emsg); } } } finally { this.childSessions.clear(); this.activeChild = null; } // Use immediate cleanup when explicitly shutting down this.cleanup(true); // Close socket if (this.socket && !this.socket.destroyed) { this.socket.end(); this.socket.destroy(); } } cleanup(immediate = false) { // Clear all pending requests this.pendingRequests.forEach((pending) => { this.timers.clearTimeout(pending.timer); pending.reject(new Error('DAP client disconnected')); }); this.pendingRequests.clear(); // Clear buffer to free memory this.rawData = Buffer.alloc(0); this.contentLength = -1; // Remove all listeners to prevent memory leaks if (immediate) { this.removeAllListeners(); } else { // Use setImmediate to allow any pending emit operations to complete setImmediate(() => { this.removeAllListeners(); }); } } } //# sourceMappingURL=minimal-dap.js.map