@debugmcp/mcp-debugger
Version:
Step-through debugging MCP server for LLMs
926 lines • 53.5 kB
JavaScript
/**
* 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