UNPKG

@ai-capabilities-suite/mcp-debugger-core

Version:

Core debugging engine for Node.js and TypeScript applications. Provides Inspector Protocol integration, breakpoint management, variable inspection, execution control, profiling, hang detection, and source map support.

359 lines 16.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.HangDetector = void 0; const process_spawner_1 = require("./process-spawner"); const inspector_client_1 = require("./inspector-client"); /** * Detects hanging processes and infinite loops * Monitors process execution and samples call stack periodically * Requirements: 5.1, 5.2, 5.3, 5.4 */ class HangDetector { /** * Detect if a process hangs or enters an infinite loop * Starts a process with inspector and monitors it for hangs * @param config Configuration including command, args, timeout, and sample interval * @returns Hang detection result with status and location if hung */ async detectHang(config) { const startTime = Date.now(); const timeout = config.timeout; const sampleInterval = config.sampleInterval; let process = null; let inspector = null; let samplingInterval = null; let timeoutHandle = null; let detectionComplete = false; let currentCallFrames = []; try { console.log("[HangDetector] Starting hang detection..."); // Spawn process with inspector (running, not paused) const { process: proc, wsUrl } = await (0, process_spawner_1.spawnWithInspectorRunning)(config.command, config.args || [], config.cwd); console.log("[HangDetector] Process spawned, wsUrl:", wsUrl); process = proc; // Check if process has already exited before trying to connect inspector if (process.exitCode !== null) { console.log("[HangDetector] Process already exited before inspector connection"); const duration = Date.now() - startTime; return { hung: false, completed: true, exitCode: process.exitCode, duration, message: "Process completed before inspector connection", }; } // Connect inspector client inspector = new inspector_client_1.InspectorClient(wsUrl); console.log("[HangDetector] Connecting inspector..."); // Add aggressive timeout to inspector.connect() to prevent hanging let connectTimer; const connectTimeout = new Promise((_, reject) => { connectTimer = setTimeout(() => { console.log("[HangDetector] Inspector connect timeout, killing process"); if (process && !process.killed) { process.kill("SIGKILL"); } reject(new Error("Inspector connect timeout after 3s")); }, 3000); }); try { await Promise.race([inspector.connect(), connectTimeout]); clearTimeout(connectTimer); console.log("[HangDetector] Inspector connected"); } catch (error) { clearTimeout(connectTimer); console.log("[HangDetector] Inspector connection failed, checking if process completed normally"); // If inspector connection fails, check if process has already exited if (process.exitCode !== null || process.killed) { console.log("[HangDetector] Process already exited, treating as normal completion"); const duration = Date.now() - startTime; return { hung: false, completed: true, exitCode: process.exitCode || 0, duration, message: "Process completed before inspector connection", }; } throw error; } // Track script URLs by script ID const scriptUrls = new Map(); // Result promise that will be resolved by one of the detection mechanisms let resolveResult; const resultPromise = new Promise((resolve) => { resolveResult = resolve; }); // Helper to complete detection and clean up const completeDetection = (result) => { if (detectionComplete) { return; } detectionComplete = true; // Stop sampling and timeout if (samplingInterval) { clearInterval(samplingInterval); samplingInterval = null; } if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } resolveResult(result); }; // Set up process exit handler FIRST process.once("exit", (code) => { console.log("[HangDetector] Process exit event fired, code:", code); const duration = Date.now() - startTime; completeDetection({ hung: false, completed: true, exitCode: code || 0, duration, }); }); // WORKAROUND: Node processes with --inspect don't always emit 'exit' event // Also listen for 'close' event which fires when stdio streams close process.once("close", (code) => { console.log("[HangDetector] Process close event fired, code:", code); const duration = Date.now() - startTime; completeDetection({ hung: false, completed: true, exitCode: code || 0, duration, }); }); // Workaround: Processes with --inspect don't exit automatically // Only use activity monitor for non-sampling mode (timeout-only detection) // In sampling mode, the sampling will keep the process active let activityMonitor = null; if (config.sampleInterval === undefined) { let lastActivityTime = Date.now(); let activityMonitorActive = true; activityMonitor = setInterval(() => { if (detectionComplete) { if (activityMonitor) clearInterval(activityMonitor); return; } const elapsed = Date.now() - startTime; // Only trigger activity monitor in the first 20% of timeout period // This ensures we only catch truly fast-completing scripts const maxActivityMonitorTime = Math.min(1000, timeout * 0.2); if (elapsed > maxActivityMonitorTime) { activityMonitorActive = false; if (activityMonitor) clearInterval(activityMonitor); return; } const idleTime = Date.now() - lastActivityTime; // If no activity for 300ms in the early period, assume quick completion if (activityMonitorActive && idleTime > 300 && elapsed > 150) { if (activityMonitor) clearInterval(activityMonitor); const duration = Date.now() - startTime; completeDetection({ hung: false, completed: true, exitCode: 0, duration, }); } }, 100); // Track any CDP activity const updateActivity = () => { lastActivityTime = Date.now(); }; inspector.on("event", updateActivity); inspector.on("Debugger.paused", updateActivity); inspector.on("Debugger.resumed", updateActivity); inspector.on("Debugger.scriptParsed", updateActivity); } inspector.on("Debugger.scriptParsed", (params) => { if (params.scriptId && params.url) { scriptUrls.set(params.scriptId, params.url); } }); // Set up event handler for paused events inspector.on("Debugger.paused", (params) => { currentCallFrames = params?.callFrames || []; // Populate URLs from scriptUrls map if they're missing for (const frame of currentCallFrames) { if (!frame.url && frame.location?.scriptId) { const url = scriptUrls.get(frame.location.scriptId); if (url) { frame.url = url; } } } }); // Enable debugging domains console.log("[HangDetector] Enabling debugger domains..."); await inspector.send("Debugger.enable"); await inspector.send("Runtime.enable"); console.log("[HangDetector] Debugger domains enabled"); // Wait a bit for scriptParsed events to fire // Use a shorter delay to avoid race conditions with fast-completing processes await new Promise((r) => setTimeout(r, 50)); console.log("[HangDetector] Setup complete, waiting for result..."); // Set up timeout handler timeoutHandle = setTimeout(async () => { if (detectionComplete) { return; } // Timeout reached - pause and capture location try { // Pause the process await inspector.send("Debugger.pause"); // Wait a bit for the paused event to populate call frames await new Promise((r) => setTimeout(r, 500)); if (currentCallFrames.length > 0) { const stack = formatCallStack(currentCallFrames, config.cwd); const location = stack.length > 0 ? `${stack[0].file}:${stack[0].line}` : "unknown"; const duration = Date.now() - startTime; completeDetection({ hung: true, location, stack, message: `Process exceeded timeout of ${timeout}ms at ${location}`, duration, }); } else { const duration = Date.now() - startTime; completeDetection({ hung: true, message: `Process exceeded timeout of ${timeout}ms`, duration, }); } } catch (error) { const duration = Date.now() - startTime; completeDetection({ hung: true, message: `Process exceeded timeout of ${timeout}ms (error capturing location: ${error})`, duration, }); } }, timeout); // Set up sampling for infinite loop detection (only if sample interval is provided) if (sampleInterval !== undefined) { const locationHistory = []; let consecutiveSameLocation = 0; const requiredSamples = Math.max(50, Math.floor((timeout * 0.5) / sampleInterval)); const stopSamplingTime = startTime + timeout * 0.9; samplingInterval = setInterval(async () => { if (detectionComplete || Date.now() >= stopSamplingTime) { return; } try { // Pause to sample call stack await inspector.send("Debugger.pause"); // Wait for paused event await new Promise((r) => setTimeout(r, 100)); if (currentCallFrames.length > 0) { const stack = formatCallStack(currentCallFrames, config.cwd); const location = `${stack[0].file}:${stack[0].line}`; locationHistory.push(location); if (locationHistory.length > 1 && location === locationHistory[locationHistory.length - 2]) { consecutiveSameLocation++; } else { consecutiveSameLocation = 0; } if (consecutiveSameLocation >= requiredSamples) { const duration = Date.now() - startTime; completeDetection({ hung: true, location, stack, message: `Infinite loop detected at ${location}`, duration, }); return; } } // Resume execution if (!detectionComplete) { await inspector.send("Debugger.resume"); currentCallFrames = []; } } catch (error) { // Ignore sampling errors } }, sampleInterval); } // Wait for detection to complete with a safety timeout // This prevents the hang detector itself from hanging indefinitely const safetyTimeout = timeout * 3 + 10000; // 3x the detection timeout plus 10s buffer const safetyPromise = new Promise((resolve) => { setTimeout(() => { if (!detectionComplete) { const duration = Date.now() - startTime; resolve({ hung: false, completed: true, exitCode: 0, duration, message: "Detection completed via safety timeout", }); } }, safetyTimeout); }); const result = await Promise.race([resultPromise, safetyPromise]); return result; } finally { // Clean up if (samplingInterval) { clearInterval(samplingInterval); } if (timeoutHandle) { clearTimeout(timeoutHandle); } if (inspector) { await inspector.disconnect(); } if (process && !process.killed) { process.kill(); } } } } exports.HangDetector = HangDetector; /** * Format call frames into stack frames with absolute paths */ function formatCallStack(callFrames, cwd) { const path = require("path"); return callFrames.map((frame) => { let filePath = frame.url || ""; // If URL is empty, use scriptId as fallback if (!filePath && frame.location?.scriptId) { filePath = `<script-${frame.location.scriptId}>`; } // Convert file:// URL to absolute path if (filePath.startsWith("file://")) { filePath = filePath.substring(7); } // Ensure the path is absolute (only for real paths, not script IDs) if (filePath && !filePath.startsWith("<") && !path.isAbsolute(filePath)) { filePath = path.resolve(cwd || process.cwd(), filePath); } return { functionName: frame.functionName || "(anonymous)", file: filePath || "<unknown>", line: frame.location.lineNumber + 1, // CDP uses 0-indexed lines column: frame.location.columnNumber, }; }); } //# sourceMappingURL=hang-detector.js.map