UNPKG

@debugmcp/mcp-debugger

Version:

Step-through debugging MCP server for LLMs

926 lines 53.5 kB
/** * Debug operations for session management including starting, stepping, * continuing, and breakpoint management. */ import { v4 as uuidv4 } from 'uuid'; import { SessionState, SessionLifecycleState } from '@debugmcp/shared'; import path from 'path'; import { ErrorMessages } from '../utils/error-messages.js'; import { SessionManagerData } from './session-manager-data.js'; import { SessionTerminatedError, ProxyNotRunningError, DebugSessionCreationError, PythonNotFoundError } from '../errors/debug-errors.js'; import { McpError } from '@modelcontextprotocol/sdk/types.js'; /** * Debug operations functionality for session management */ export class SessionManagerOperations extends SessionManagerData { async startProxyManager(session, scriptPath, scriptArgs, dapLaunchArgs, dryRunSpawn, adapterLaunchConfig) { const sessionId = session.id; // Log entrance for Windows CI debugging this.logger.info(`[SessionManager] Entering startProxyManager for session ${sessionId}, dryRunSpawn: ${dryRunSpawn}, scriptPath: ${scriptPath}`); if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[SessionManager] Windows CI Debug - startProxyManager entrance:`, { sessionId, dryRunSpawn, scriptPath, language: session.language, hasBreakpoints: session.breakpoints?.size > 0, platform: process.platform, cwd: process.cwd() }); } // Create session log directory const sessionLogDir = path.join(this.logDirBase, sessionId, `run-${Date.now()}`); this.logger.info(`[SessionManager] Ensuring session log directory: ${sessionLogDir}`); try { await this.fileSystem.ensureDir(sessionLogDir); const dirExists = await this.fileSystem.pathExists(sessionLogDir); if (!dirExists) { throw new Error(`Log directory ${sessionLogDir} could not be created`); } } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.error(`[SessionManager] Failed to create log directory:`, err); throw new Error(`Failed to create session log directory: ${message}`); } // Persist log directory on session for diagnostics this.sessionStore.update(sessionId, { logDir: sessionLogDir }); // Get free port for adapter const adapterPort = await this.findFreePort(); const initialBreakpoints = Array.from(session.breakpoints.values()).map((bp) => { // Breakpoint file path has been validated by server.ts before reaching here return { file: bp.file, // Use the validated path line: bp.line, condition: bp.condition, }; }); // Merge launch args const effectiveLaunchArgs = { ...this.defaultDapLaunchArgs, ...(dapLaunchArgs || {}), }; const genericLaunchConfig = { ...effectiveLaunchArgs, program: scriptPath }; if (Array.isArray(scriptArgs) && scriptArgs.length > 0) { genericLaunchConfig.args = scriptArgs; } if (typeof genericLaunchConfig.cwd !== 'string' || genericLaunchConfig.cwd.length === 0) { genericLaunchConfig.cwd = path.dirname(scriptPath); } if (adapterLaunchConfig && typeof adapterLaunchConfig === 'object') { Object.assign(genericLaunchConfig, adapterLaunchConfig); } let transformedLaunchConfig; // Create the adapter for this language first const adapterConfig = { sessionId, executablePath: '', // Will be resolved by adapter adapterHost: '127.0.0.1', adapterPort, logDir: sessionLogDir, scriptPath, scriptArgs, launchConfig: genericLaunchConfig, }; const adapter = await this.adapterRegistry.create(session.language, adapterConfig); try { transformedLaunchConfig = await adapter.transformLaunchConfig(genericLaunchConfig); } catch (error) { this.logger.warn(`[SessionManager] transformLaunchConfig failed for ${session.language}: ${error instanceof Error ? error.message : String(error)}`); transformedLaunchConfig = undefined; } const adapterWithToolchain = adapter; const toolchainValidation = typeof adapterWithToolchain.consumeLastToolchainValidation === 'function' ? adapterWithToolchain.consumeLastToolchainValidation() : undefined; if (toolchainValidation) { this.sessionStore.update(sessionId, { toolchainValidation }); if (!toolchainValidation.compatible && toolchainValidation.behavior !== 'continue') { const toolchainError = new Error('MSVC_TOOLCHAIN_DETECTED'); toolchainError.toolchainValidation = toolchainValidation; throw toolchainError; } } else { this.sessionStore.update(sessionId, { toolchainValidation: undefined }); } // Use the adapter to resolve the executable path let resolvedExecutablePath; try { resolvedExecutablePath = await adapter.resolveExecutablePath(session.executablePath); this.logger.info(`[SessionManager] Adapter resolved executable path: ${resolvedExecutablePath}`); } catch (error) { const msg = error instanceof Error ? error.message : String(error); this.logger.error(`[SessionManager] Failed to resolve executable for ${session.language}:`, msg); // Convert to appropriate error type based on language if (session.language === 'python' && msg.includes('not found')) { throw new PythonNotFoundError(session.executablePath || 'python'); } throw new DebugSessionCreationError(`Failed to resolve ${session.language} executable: ${msg}`, error instanceof Error ? error : undefined); } // Update adapter config with resolved executable path adapterConfig.executablePath = resolvedExecutablePath; // Build adapter command using the adapter const adapterCommand = adapter.buildAdapterCommand(adapterConfig); const launchConfigBase = transformedLaunchConfig ?? genericLaunchConfig; const launchConfigData = { ...launchConfigBase }; const languageId = typeof session.language === 'string' ? session.language.toLowerCase() : String(session.language).toLowerCase(); const isJavascriptSession = languageId === 'javascript'; const stopOnEntryProvided = typeof dapLaunchArgs?.stopOnEntry === 'boolean'; if (isJavascriptSession && !stopOnEntryProvided) { launchConfigData.stopOnEntry = false; if (Array.isArray(launchConfigData.runtimeArgs)) { launchConfigData.runtimeArgs = launchConfigData.runtimeArgs.filter(arg => !/^--inspect(?:-brk)?(?:=|$)/.test(arg)); } } this.logger.info(`[SessionManager] Launch config stopOnEntry adjustments for ${sessionId}: base=${String(launchConfigBase?.stopOnEntry)}, final=${String(launchConfigData.stopOnEntry)}, userProvided=${String(dapLaunchArgs?.stopOnEntry)}`); const stopOnEntryFlag = typeof launchConfigData?.stopOnEntry === 'boolean' ? launchConfigData.stopOnEntry : effectiveLaunchArgs.stopOnEntry; const justMyCodeFlag = typeof launchConfigData?.justMyCode === 'boolean' ? launchConfigData.justMyCode : effectiveLaunchArgs.justMyCode; // Create ProxyConfig const programFromLaunchConfig = typeof launchConfigData?.program === 'string' && launchConfigData.program.length > 0 ? launchConfigData.program : scriptPath; const argsFromLaunchConfig = Array.isArray(launchConfigData?.args) ? launchConfigData.args.filter((arg) => typeof arg === 'string') : Array.isArray(scriptArgs) ? [...scriptArgs] : []; const normalizedScriptArgs = argsFromLaunchConfig.length > 0 ? argsFromLaunchConfig : undefined; if (initialBreakpoints.length) { this.logger.info(`[SessionManager] Initial breakpoints for ${sessionId}:`, initialBreakpoints.map(bp => ({ file: bp.file, line: bp.line }))); } const proxyConfig = { sessionId, language: session.language, // Add language from session executablePath: resolvedExecutablePath, adapterHost: '127.0.0.1', adapterPort, logDir: sessionLogDir, scriptPath: programFromLaunchConfig, scriptArgs: normalizedScriptArgs, stopOnEntry: stopOnEntryFlag, justMyCode: justMyCodeFlag, initialBreakpoints, dryRunSpawn: dryRunSpawn === true, launchConfig: launchConfigData, adapterCommand, // Pass the adapter command }; // Create and start ProxyManager with the adapter const proxyManager = this.proxyManagerFactory.create(adapter); session.proxyManager = proxyManager; // Set up event handlers this.setupProxyEventHandlers(session, proxyManager, effectiveLaunchArgs); // Start the proxy await proxyManager.start(proxyConfig); return launchConfigData; } /** * Helper method to wait for dry run completion with timeout */ async waitForDryRunCompletion(session, timeoutMs) { if (session.proxyManager?.hasDryRunCompleted?.()) { this.logger.info(`[SessionManager] Dry run already marked complete for session ${session.id} before wait`); return true; } let handler = null; let timeoutId = null; try { return await Promise.race([ new Promise((resolve) => { handler = () => { this.logger.info(`[SessionManager] Dry run completion event received for session ${session.id}`); resolve(true); }; this.logger.info(`[SessionManager] Setting up dry-run-complete listener for session ${session.id}`); session.proxyManager?.once('dry-run-complete', handler); }), new Promise((resolve) => { timeoutId = setTimeout(() => { if (session.proxyManager?.hasDryRunCompleted?.()) { this.logger.info(`[SessionManager] Dry run marked complete during timeout window for session ${session.id}`); resolve(true); return; } this.logger.warn(`[SessionManager] Dry run timeout after ${timeoutMs}ms for session ${session.id}`); resolve(false); }, timeoutMs); }), ]); } finally { // Clean up immediately if (handler && session.proxyManager) { this.logger.info(`[SessionManager] Removing dry-run-complete listener for session ${session.id}`); session.proxyManager.removeListener('dry-run-complete', handler); } if (timeoutId) { clearTimeout(timeoutId); } } } async startDebugging(sessionId, scriptPath, scriptArgs, dapLaunchArgs, dryRunSpawn, adapterLaunchConfig) { const session = this._getSessionById(sessionId); this.logger.info(`Attempting to start debugging for session ${sessionId}, script: ${scriptPath}, dryRunSpawn: ${dryRunSpawn}, dapLaunchArgs:`, dapLaunchArgs); // CI Debug: Entry point if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] startDebugging entry - sessionId: ${sessionId}, dryRunSpawn: ${dryRunSpawn}, scriptPath: ${scriptPath}`); } if (session.proxyManager) { this.logger.warn(`[SessionManager] Session ${sessionId} already has an active proxy. Terminating before starting new.`); await this.closeSession(sessionId); } // Update to INITIALIZING state and set lifecycle to ACTIVE this._updateSessionState(session, SessionState.INITIALIZING); // Explicitly set lifecycle state to ACTIVE when starting debugging this.sessionStore.update(sessionId, { sessionLifecycle: SessionLifecycleState.ACTIVE, }); this.logger.info(`[SessionManager] Session ${sessionId} lifecycle state set to ACTIVE`); try { // For dry run, start the proxy and wait for completion if (dryRunSpawn) { // CI Debug: Entering dry run branch if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] Entering dry run branch for session ${sessionId}`); } // Mark that we're setting up a dry run handler const sessionWithSetup = session; sessionWithSetup._dryRunHandlerSetup = true; // CI Debug: Before startProxyManager if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] About to call startProxyManager for dry run`); } // Start the proxy manager await this.startProxyManager(session, scriptPath, scriptArgs, dapLaunchArgs, dryRunSpawn, adapterLaunchConfig); this.logger.info(`[SessionManager] ProxyManager started for session ${sessionId}`); // CI Debug: After startProxyManager if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] startProxyManager completed, checking state`); } // Check if already completed before waiting const refreshedSession = this._getSessionById(sessionId); this.logger.info(`[SessionManager] Checking state after start: ${refreshedSession.state}`); // CI Debug: State check if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] Session state after proxy start: ${refreshedSession.state}`); } const initialDryRunSnapshot = refreshedSession.proxyManager?.getDryRunSnapshot?.(); const dryRunAlreadyComplete = refreshedSession.state === SessionState.STOPPED || refreshedSession.proxyManager?.hasDryRunCompleted?.() === true; if (dryRunAlreadyComplete) { this.logger.info(`[SessionManager] Dry run already completed for session ${sessionId}`); delete sessionWithSetup._dryRunHandlerSetup; // CI Debug: Early completion if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] Dry run completed immediately (state=STOPPED)`); } return { success: true, state: SessionState.STOPPED, data: { dryRun: true, message: 'Dry run spawn command logged by proxy.', command: initialDryRunSnapshot?.command, script: initialDryRunSnapshot?.script, }, }; } // Wait for completion with timeout this.logger.info(`[SessionManager] Waiting for dry run completion with timeout ${this.dryRunTimeoutMs}ms`); // CI Debug: Before wait if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] Waiting for dry run completion, timeout: ${this.dryRunTimeoutMs}ms`); } const dryRunCompleted = await this.waitForDryRunCompletion(refreshedSession, this.dryRunTimeoutMs); delete sessionWithSetup._dryRunHandlerSetup; // CI Debug: After wait if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] waitForDryRunCompletion returned: ${dryRunCompleted}`); } const latestSessionState = this._getSessionById(sessionId); const latestSnapshot = latestSessionState.proxyManager?.getDryRunSnapshot?.() ?? initialDryRunSnapshot; const effectiveDryRunComplete = dryRunCompleted || latestSessionState.state === SessionState.STOPPED || latestSessionState.proxyManager?.hasDryRunCompleted?.() === true; if (effectiveDryRunComplete) { this.logger.info(`[SessionManager] Dry run completed for session ${sessionId}, final state: ${latestSessionState.state}`); // CI Debug: Success path if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] Dry run success path - returning success`); } return { success: true, state: SessionState.STOPPED, data: { dryRun: true, message: 'Dry run spawn command logged by proxy.', command: latestSnapshot?.command, script: latestSnapshot?.script, }, }; } else { // Timeout occurred const finalSession = latestSessionState; this.logger.error(`[SessionManager] Dry run timeout for session ${sessionId}. ` + `State: ${finalSession.state}, ProxyManager active: ${!!finalSession.proxyManager}`); // CI Debug: Timeout path if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error(`[CI Debug] Dry run timeout! State: ${finalSession.state}, ProxyManager: ${!!finalSession.proxyManager}`); } return { success: false, error: `Dry run timed out after ${this.dryRunTimeoutMs}ms. Current state: ${finalSession.state}`, state: finalSession.state, }; } } // Normal (non-dry-run) flow // Start the proxy manager const launchConfigData = await this.startProxyManager(session, scriptPath, scriptArgs, dapLaunchArgs, dryRunSpawn, adapterLaunchConfig); this.logger.info(`[SessionManager] ProxyManager started for session ${sessionId}`); // Perform language-specific handshake if required const policy = this.selectPolicy(session.language); if (policy.performHandshake) { try { await policy.performHandshake({ proxyManager: session.proxyManager, sessionId: session.id, dapLaunchArgs, scriptPath, scriptArgs, breakpoints: session.breakpoints, launchConfig: launchConfigData }); } catch (handshakeErr) { this.logger.warn(`[SessionManager] Language handshake returned with warning/error: ${handshakeErr instanceof Error ? handshakeErr.message : String(handshakeErr)}`); } } // Use policy-defined readiness criteria when available. const sessionStateAfterHandshake = this._getSessionById(sessionId).state; const alreadyReady = policy.isSessionReady ? policy.isSessionReady(sessionStateAfterHandshake, { stopOnEntry: dapLaunchArgs?.stopOnEntry }) : sessionStateAfterHandshake === SessionState.PAUSED; if (!alreadyReady) { // Wait for adapter to be configured or first stop event const waitForReady = new Promise((resolve) => { let resolved = false; const handleStopped = () => { if (!resolved) { resolved = true; this.logger.info(`[SessionManager] Session ${sessionId} stopped on entry`); resolve(); } }; const handleConfigured = () => { const readyOnRunning = policy.isSessionReady ? policy.isSessionReady(SessionState.RUNNING, { stopOnEntry: dapLaunchArgs?.stopOnEntry }) : !dapLaunchArgs?.stopOnEntry; if (!resolved && readyOnRunning) { resolved = true; this.logger.info(`[SessionManager] Session ${sessionId} running (stopOnEntry=${dapLaunchArgs?.stopOnEntry ?? false})`); resolve(); } }; session.proxyManager?.once('stopped', handleStopped); session.proxyManager?.once('adapter-configured', handleConfigured); // In case the adapter already reached the desired state before listeners were attached, // perform a synchronous state check to avoid waiting for an event that already fired. const currentState = this._getSessionById(sessionId).state; const readyNow = policy.isSessionReady ? policy.isSessionReady(currentState, { stopOnEntry: dapLaunchArgs?.stopOnEntry }) : currentState === SessionState.PAUSED; if (readyNow) { resolved = true; session.proxyManager?.removeListener('stopped', handleStopped); session.proxyManager?.removeListener('adapter-configured', handleConfigured); resolve(); return; } // Timeout after 30 seconds setTimeout(() => { if (!resolved) { resolved = true; session.proxyManager?.removeListener('stopped', handleStopped); session.proxyManager?.removeListener('adapter-configured', handleConfigured); this.logger.warn(ErrorMessages.adapterReadyTimeout(30)); resolve(); } }, 30000); }); await waitForReady; } else { this.logger.info(`[SessionManager] Session ${sessionId} already ${sessionStateAfterHandshake} after handshake - skipping adapter readiness wait`); } // Re-fetch session to get the most up-to-date state const finalSession = this._getSessionById(sessionId); const finalState = finalSession.state; this.logger.info(`[SessionManager] Debugging started for session ${sessionId}. State: ${finalState}`); return { success: true, state: finalState, data: { message: `Debugging started for ${scriptPath}. Current state: ${finalState}`, reason: finalState === SessionState.PAUSED ? dapLaunchArgs?.stopOnEntry ? 'entry' : 'breakpoint' : undefined, stopOnEntrySuccessful: !!dapLaunchArgs?.stopOnEntry && finalState === SessionState.PAUSED, }, }; } catch (error) { // Attempt to capture proxy log tail for debugging initialization failures let proxyLogTail; let proxyLogPath; try { const latestSession = this._getSessionById(sessionId); if (latestSession.logDir) { proxyLogPath = path.join(latestSession.logDir, `proxy-${sessionId}.log`); const logExists = await this.fileSystem.pathExists(proxyLogPath); if (logExists) { const logContent = await this.fileSystem.readFile(proxyLogPath, 'utf-8'); const logLines = logContent.split(/\r?\n/); const tailLineCount = 80; const startIndex = Math.max(0, logLines.length - tailLineCount); proxyLogTail = logLines.slice(startIndex).join('\n'); } } } catch (logReadError) { proxyLogTail = `<<Failed to read proxy log: ${logReadError instanceof Error ? logReadError.message : String(logReadError)}>>`; } // Comprehensive error capture for debugging Windows CI issues const errorDetails = { type: error?.constructor?.name || 'Unknown', message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : 'No stack available', code: error?.code, errno: error?.errno, syscall: error?.syscall, path: error?.path, toString: error?.toString ? error.toString() : 'No toString', proxyLogPath, proxyLogTail }; // Try to capture raw error object try { errorDetails.raw = JSON.stringify(error); } catch { errorDetails.raw = 'Error not JSON serializable'; } // Log comprehensive error details this.logger.error(`[SessionManager] Detailed error in startDebugging for session ${sessionId}:`, errorDetails); // Also log to console for CI visibility if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') { console.error('[SessionManager] Windows CI Debug - Full error details:', errorDetails); } const errorMessage = error instanceof Error ? error.message : String(error); const toolchainValidation = error?.toolchainValidation ?? session.toolchainValidation; const incompatibleToolchain = Boolean(toolchainValidation) && toolchainValidation?.compatible === false; if (incompatibleToolchain) { this._updateSessionState(session, SessionState.CREATED); this.sessionStore.update(sessionId, { sessionLifecycle: SessionLifecycleState.CREATED, }); } else { this._updateSessionState(session, SessionState.ERROR); } if (session.proxyManager) { await session.proxyManager.stop(); session.proxyManager = undefined; } // Normalize error identity for callers/tests let errorType; let errorCode; if (error instanceof McpError) { errorType = error.constructor.name || 'McpError'; errorCode = error.code; } else if (error instanceof Error) { errorType = error.constructor.name || 'Error'; } if (incompatibleToolchain && toolchainValidation) { const behavior = (toolchainValidation.behavior ?? 'warn').toLowerCase(); const canContinue = behavior !== 'error'; const updatedSession = this._getSessionById(sessionId); return { success: false, error: 'MSVC_TOOLCHAIN_DETECTED', state: updatedSession.state, data: { message: toolchainValidation.message ?? errorMessage, toolchainValidation, }, canContinue, errorType, errorCode, }; } return { success: false, error: errorMessage, state: session.state, errorType, errorCode }; } } async setBreakpoint(sessionId, file, line, condition) { const session = this._getSessionById(sessionId); // Check if session is terminated if (session.sessionLifecycle === SessionLifecycleState.TERMINATED) { throw new SessionTerminatedError(sessionId); } const bpId = uuidv4(); // The file path has been validated and translated by server.ts before reaching here this.logger.info(`[SessionManager setBreakpoint] Using validated file path "${file}" for session ${sessionId}`); const newBreakpoint = { id: bpId, file, line, condition, verified: false }; if (!session.breakpoints) session.breakpoints = new Map(); session.breakpoints.set(bpId, newBreakpoint); this.logger.info(`[SessionManager] Breakpoint ${bpId} queued for ${file}:${line} in session ${sessionId}.`); if (session.proxyManager && session.proxyManager.isRunning() && (session.state === SessionState.RUNNING || session.state === SessionState.PAUSED)) { try { this.logger.info(`[SessionManager] Active proxy for session ${sessionId}, sending breakpoint ${bpId}.`); const response = await session.proxyManager.sendDapRequest('setBreakpoints', { source: { path: newBreakpoint.file }, breakpoints: [{ line: newBreakpoint.line, condition: newBreakpoint.condition }], }); if (response && response.body && response.body.breakpoints && response.body.breakpoints.length > 0) { const bpInfo = response.body.breakpoints[0]; newBreakpoint.verified = bpInfo.verified; newBreakpoint.line = bpInfo.line || newBreakpoint.line; newBreakpoint.message = bpInfo.message; // Capture validation message this.logger.info(`[SessionManager] Breakpoint ${bpId} sent and response received. Verified: ${newBreakpoint.verified}${bpInfo.message ? `, Message: ${bpInfo.message}` : ''}`); // Log breakpoint verification with structured logging if (newBreakpoint.verified) { this.logger.info('debug:breakpoint', { event: 'verified', sessionId: sessionId, sessionName: session.name, breakpointId: bpId, file: newBreakpoint.file, line: newBreakpoint.line, verified: true, timestamp: Date.now(), }); } } } catch (error) { this.logger.error(`[SessionManager] Error sending setBreakpoint to proxy for session ${sessionId}:`, error); } } return newBreakpoint; } async stepOver(sessionId) { const session = this._getSessionById(sessionId); // Check if session is terminated if (session.sessionLifecycle === SessionLifecycleState.TERMINATED) { throw new SessionTerminatedError(sessionId); } const threadId = session.proxyManager?.getCurrentThreadId(); this.logger.info(`[SM stepOver ${sessionId}] Entered. Current state: ${session.state}, ThreadID: ${threadId}`); if (!session.proxyManager || !session.proxyManager.isRunning()) { throw new ProxyNotRunningError(sessionId, 'step over'); } if (session.state !== SessionState.PAUSED) { this.logger.warn(`[SM stepOver ${sessionId}] Not paused. State: ${session.state}`); return { success: false, error: 'Not paused', state: session.state }; } if (typeof threadId !== 'number') { this.logger.warn(`[SM stepOver ${sessionId}] No current thread ID.`); return { success: false, error: 'No current thread ID', state: session.state }; } this.logger.info(`[SM stepOver ${sessionId}] Sending DAP 'next' for threadId ${threadId}`); try { return await this._executeStepOperation(session, sessionId, { command: 'next', threadId, logTag: 'stepOver', successMessage: 'Step completed.', }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`[SM stepOver ${sessionId}] Error during step:`, error); this._updateSessionState(session, SessionState.ERROR); return { success: false, error: errorMessage, state: session.state }; } } async stepInto(sessionId) { const session = this._getSessionById(sessionId); // Check if session is terminated if (session.sessionLifecycle === SessionLifecycleState.TERMINATED) { throw new SessionTerminatedError(sessionId); } const threadId = session.proxyManager?.getCurrentThreadId(); this.logger.info(`[SM stepInto ${sessionId}] Entered. Current state: ${session.state}, ThreadID: ${threadId}`); if (!session.proxyManager || !session.proxyManager.isRunning()) { throw new ProxyNotRunningError(sessionId, 'step into'); } if (session.state !== SessionState.PAUSED) { this.logger.warn(`[SM stepInto ${sessionId}] Not paused. State: ${session.state}`); return { success: false, error: 'Not paused', state: session.state }; } if (typeof threadId !== 'number') { this.logger.warn(`[SM stepInto ${sessionId}] No current thread ID.`); return { success: false, error: 'No current thread ID', state: session.state }; } this.logger.info(`[SM stepInto ${sessionId}] Sending DAP 'stepIn' for threadId ${threadId}`); try { return await this._executeStepOperation(session, sessionId, { command: 'stepIn', threadId, logTag: 'stepInto', successMessage: 'Step into completed.', }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`[SM stepInto ${sessionId}] Error during step:`, error); this._updateSessionState(session, SessionState.ERROR); return { success: false, error: errorMessage, state: session.state }; } } async stepOut(sessionId) { const session = this._getSessionById(sessionId); // Check if session is terminated if (session.sessionLifecycle === SessionLifecycleState.TERMINATED) { throw new SessionTerminatedError(sessionId); } const threadId = session.proxyManager?.getCurrentThreadId(); this.logger.info(`[SM stepOut ${sessionId}] Entered. Current state: ${session.state}, ThreadID: ${threadId}`); if (!session.proxyManager || !session.proxyManager.isRunning()) { throw new ProxyNotRunningError(sessionId, 'step out'); } if (session.state !== SessionState.PAUSED) { this.logger.warn(`[SM stepOut ${sessionId}] Not paused. State: ${session.state}`); return { success: false, error: 'Not paused', state: session.state }; } if (typeof threadId !== 'number') { this.logger.warn(`[SM stepOut ${sessionId}] No current thread ID.`); return { success: false, error: 'No current thread ID', state: session.state }; } this.logger.info(`[SM stepOut ${sessionId}] Sending DAP 'stepOut' for threadId ${threadId}`); try { return await this._executeStepOperation(session, sessionId, { command: 'stepOut', threadId, logTag: 'stepOut', successMessage: 'Step out completed.', }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`[SM stepOut ${sessionId}] Error during step:`, error); this._updateSessionState(session, SessionState.ERROR); return { success: false, error: errorMessage, state: session.state }; } } _executeStepOperation(session, sessionId, options) { const proxyManager = session.proxyManager; if (!proxyManager) { return Promise.resolve({ success: false, error: 'Proxy manager unavailable', state: session.state, }); } const terminatedMessage = options.terminatedMessage ?? 'Step completed as session terminated.'; const exitedMessage = options.exitedMessage ?? 'Step completed as session exited.'; return new Promise((resolve) => { let settled = false; const cleanup = () => { proxyManager.off('stopped', onStopped); proxyManager.off('terminated', onTerminated); proxyManager.off('exited', onExited); proxyManager.off('exit', onExit); clearTimeout(timeout); }; const settle = (result) => { if (settled) { return; } settled = true; cleanup(); resolve(result); }; const success = (message, location) => { this.logger.info(`[SM ${options.logTag} ${sessionId}] ${message} Current state: ${session.state}`); const data = { message }; if (location) { data.location = location; } settle({ success: true, state: session.state, data, }); }; const onStopped = async () => { // Try to get current location from stack trace let location; try { // Wait a brief moment for state to settle after stopped event await new Promise(resolve => setTimeout(resolve, 10)); const stackFrames = await this.getStackTrace(sessionId); if (stackFrames && stackFrames.length > 0) { const topFrame = stackFrames[0]; location = { file: topFrame.file, line: topFrame.line, column: topFrame.column }; this.logger.debug(`[SM ${options.logTag} ${sessionId}] Captured location: ${location.file}:${location.line}`); } } catch (error) { // Log but don't fail the step operation if we can't get location this.logger.debug(`[SM ${options.logTag} ${sessionId}] Could not capture location:`, error); } success(options.successMessage, location); }; const onTerminated = () => success(terminatedMessage); const onExited = () => success(exitedMessage); const onExit = () => success(exitedMessage); const timeout = setTimeout(() => { this.logger.warn(`[SM ${options.logTag} ${sessionId}] Timeout waiting for stopped or termination event`); settle({ success: false, error: ErrorMessages.stepTimeout(5), state: session.state, }); }, 5000); proxyManager.on('stopped', onStopped); proxyManager.on('terminated', onTerminated); proxyManager.on('exited', onExited); proxyManager.on('exit', onExit); this._updateSessionState(session, SessionState.RUNNING); proxyManager .sendDapRequest(options.command, { threadId: options.threadId }) .catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`[SM ${options.logTag} ${sessionId}] Error during step request:`, error); this._updateSessionState(session, SessionState.ERROR); settle({ success: false, error: errorMessage, state: session.state }); }); }); } async continue(sessionId) { const session = this._getSessionById(sessionId); // Check if session is terminated if (session.sessionLifecycle === SessionLifecycleState.TERMINATED) { throw new SessionTerminatedError(sessionId); } const threadId = session.proxyManager?.getCurrentThreadId(); this.logger.info(`[SessionManager continue] Called for session ${sessionId}. Current state: ${session.state}, ThreadID: ${threadId}`); if (!session.proxyManager || !session.proxyManager.isRunning()) { throw new ProxyNotRunningError(sessionId, 'continue'); } if (session.state !== SessionState.PAUSED) { this.logger.warn(`[SessionManager continue] Session ${sessionId} not paused. State: ${session.state}.`); return { success: false, error: 'Not paused', state: session.state }; } if (typeof threadId !== 'number') { this.logger.warn(`[SessionManager continue] No current thread ID for session ${sessionId}.`); return { success: false, error: 'No current thread ID', state: session.state }; } try { this.logger.info(`[SessionManager continue] Sending DAP 'continue' for session ${sessionId}, threadId ${threadId}.`); await session.proxyManager.sendDapRequest('continue', { threadId }); if (session.state === SessionState.PAUSED || session.state === SessionState.STOPPED) { this.logger.debug(`[SessionManager continue] DAP 'continue' completed but session ${sessionId} is already ${session.state}; skipping RUNNING update.`); } else { this._updateSessionState(session, SessionState.RUNNING); this.logger.info(`[SessionManager continue] DAP 'continue' sent, session ${sessionId} state updated to RUNNING.`); } return { success: true, state: session.state }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`[SessionManager continue] Error sending 'continue' to proxy for session ${sessionId}: ${errorMessage}`); throw error; } } /** * Helper method to truncate long strings for logging */ truncateForLog(value, maxLength = 1000) { if (!value) return ''; return value.length > maxLength ? value.substring(0, maxLength) + '... (truncated)' : value; } /** * Evaluate an expression in the context of the current debug session. * The debugger must be paused for evaluation to work. * Expressions CAN and SHOULD be able to modify program state (this is a feature). * * @param sessionId - The session ID * @param expression - The expression to evaluate * @param frameId - Optional stack frame ID for context (defaults to current frame) * @param context - The context in which to evaluate ('repl' is default for maximum flexibility) * @returns Evaluation result with value, type, and optional variable reference */ async evaluateExpression(sessionId, expression, frameId, context = 'variables') { const session = this._getSessionById(sessionId); this.logger.info(`[SM evaluateExpression ${sessionId}] Entered. Expression: "${this.truncateForLog(expression, 100)}", frameId: ${frameId}, context: ${context}, state: ${session.state}`); // Basic sanity checks if (!expression || expression.trim().length === 0) { this.logger.warn(`[SM evaluateExpression ${sessionId}] Empty expression provided`); return { success: false, error: 'Expression cannot be empty' }; } // Validate session state if (!session.proxyManager || !session.proxyManager.isRunning()) { this.logger.warn(`[SM evaluateExpression ${sessionId}] No active proxy or proxy not running`); return { success: false, error: 'No active debug session' }; } if (session.state !== SessionState.PAUSED) { this.logger.warn(`[SM evaluateExpression ${sessionId}] Cannot evaluate: session not paused. State: ${session.state}`); return { success: false, error: 'Cannot evaluate: debugger not paused. Ensure the debugger is stopped at a breakpoint.', }; } // Handle frameId - get current frame from stack trace if not provided if (frameId === undefined) { try { const threadId = session.proxyManager.getCurrentThreadId(); if (typeof threadId !== 'number') { this.logger.warn(`[SM evaluateExpression ${sessionId}] No current thread ID to get stack trace`); return { success: false, error: 'Unable to find thread for evaluation. Ensure the debugger is paused at a breakpoint.', }; } this.logger.info(`[SM evaluateExpression ${sessionId}] No frameId provided, getting current frame from stack trace`); const stackResponse = await session.proxyManager.sendDapRequest('stackTrace', { threadId, startFrame: 0, levels: 1, // We only need the first frame }); if (stackResponse?.body?.stackFrames && stackResponse.body.stackFrames.length > 0) { frameId = stackResponse.body.stackFrames[0].id; this.logger.info(`[SM evaluateExpression ${sessionId}] Using current frame ID: ${frameId} from stack trace`); } else { this.logger.warn(`[SM evaluateExpression ${sessionId}] No stack frames available`); return { success: false, error: 'No active stack frame. Ensure the debugger is paused at a breakpoint.', }; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`[SM evaluateExpression ${sessionId}] Error getting stack trace for default frame:`, error); return { success: false, error: `Unable to determine current frame: ${errorMessage}` }; } } try { // Send DAP evaluate request this.logger.info(`[SM evaluateExpression ${sessionId}] Sending DAP 'evaluate' request. Expression: "${this.truncateForLog(expression, 100)}", frameId: ${frameId}, context: ${context}`); const response = await session.proxyManager.sendDapRequest('evaluate', { expression, frameId, context, }); // Log raw response in debug mode this.logger.debug(`[SM evaluateExpression ${sessionId}] DAP evaluate raw response:`, response); // Process response if (response && response.body) { const body = response.body; // Note: debugpy automatically truncates collections at 300 items for performance const result = { success: true, result: body.result || '', // Default to empty string if no result type: body.type, // Optional, can be undefined variablesReference: body.variablesReference || 0, // Default to 0 (no children) namedVariables: body.namedVariables, indexedVariables: body.indexedVariables, presentationHint: body.presentationHint, }; // Log the evaluation result with structured logging this.logger.info('debug:evaluate', { event: 'expression', sessionId, sessionName: session.name, expression: this.tr