Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/600.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
When the stopped thread has no frames, implicit stack, locals, and evaluation
now prefer and adopt a sibling thread with policy-recognized user frames over
runtime-only threads. Every automatic switch is disclosed in `note` or
`anchorNote`; explicit thread and frame IDs remain authoritative.
5 changes: 4 additions & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ Gets the current call stack.
- Internal/runtime frames (e.g. Node.js internals, Go `/runtime/`, `System.*`) are filtered out by default; pass `includeInternals: true` to see them. When any frames were hidden, the response additionally carries `hiddenFrames` (count) and a `note` explaining how to reveal them.
- The filtered stack is never empty when the adapter reported frames: if *every* frame is internal (e.g. a goroutine paused inside the Go runtime), the top internal frame is kept so `get_scopes`/`evaluate_expression` still have a valid `frameId`, and the `note` says so.
- When an explicit thread reports no frames, the response remains anchored to that thread and its `note` suggests a frame-bearing alternative when one is available.
- When the implicit stopped thread is frameless, stack, locals, and default evaluation share one resolver. It scans siblings, prefers a thread whose frames the language policy recognizes as user code over runtime-only stacks, adopts it once, and discloses the switch in `note`/`anchorNote`.

---

Expand Down Expand Up @@ -708,6 +709,8 @@ Gets local variables by traversing all stack frames and their scopes, then using

**Size guards:** same caps and `truncation` advisory as [get_variables](#get_variables); additionally, the multi-frame scope fan-out stops issuing DAP requests once the per-call variable budget is spent (`truncation.scopesSkipped` reports scopes never fetched). Top-frame scopes are fetched first, so the locals that matter are unaffected. The `truncation` counts describe only the returned payload — values cut while fetching fan-out scopes the policy then discarded (e.g. Global/Closure) are not reported (issue #438).

If the originally stopped thread has no frames, this tool uses the same user-frame-preferring adopted anchor as `get_stack_trace`. The response's `anchorNote` always names an automatic thread or lower-frame switch.

**Example - Python:**
```json
// Request
Expand Down Expand Up @@ -805,7 +808,7 @@ Evaluates an expression in the context of the current debug session.
**Parameters:**
- `sessionId` (string, required): The ID of the debug session.
- `expression` (string, required): The expression to evaluate.
- `frameId` (number, optional): Stack frame ID for context. If not provided, automatically uses the current (top) frame.
- `frameId` (number, optional): Authoritative stack frame ID for context. If omitted, evaluation uses the same adopted top frame as `get_stack_trace` and `get_local_variables`; an automatic thread switch is returned as `anchorNote`.
- `timeout` (number, optional): Maximum time in milliseconds to wait for the evaluation to complete (default: 30000, max: 600000). On expiry the request fails but the expression may keep executing in the debuggee.

**Response:**
Expand Down
6 changes: 3 additions & 3 deletions src/server/tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,10 @@ export function buildToolDefinitions(options: BuildToolDefinitionsOptions): Tool
{ name: 'pause_execution', description: 'Pause a running program. Waits briefly for the stop; if the program cannot stop within ~5s (e.g. blocked in native code, or an idle server waiting for input), returns success with pending:true and the session reports "paused" the next time the program runs code. Fails with an actionable error if the session has no debuggable target to pause (e.g. a js attach whose target session was never adopted or has ended)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, threadId: { type: 'number', description: 'Thread ID to pause. If omitted or 0, pauses all threads.' } }, required: ['sessionId'] } },
{ name: 'list_threads', description: 'List all threads in the debugged process', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } },
{ name: 'get_variables', description: 'Get variables (scope is variablesReference: number). Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, scope: { type: 'number', description: "The variablesReference number from a StackFrame or Variable" }, names: namesProp }, required: getVariablesRequired } },
{ name: 'get_local_variables', description: 'Get local variables for the current stack frame. This is a convenience tool that returns just the local variables without needing to traverse stack->scopes->variables manually. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeSpecial: { type: 'boolean', description: 'Include special/internal variables like this, __proto__, __builtins__, etc. Default: false' }, names: namesProp }, required: getLocalVariablesRequired } },
{ name: 'get_stack_trace', description: 'Get stack trace. The response includes stopReason — why the session is paused (e.g. "breakpoint" vs "exception"). Internal/runtime frames are filtered by default; when any are hidden the response carries hiddenFrames and a note (the top frame is always kept even if internal, so frameId anchors keep working)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeInternals: { type: 'boolean', description: 'Include internal/framework frames (e.g., Node.js internals). Default: false for cleaner output.' }, threadId: { type: 'number', description: 'Inspect a specific thread (ids from list_threads). When that thread reports frames it becomes the anchor for follow-up scopes/locals/evaluate calls — the escape hatch when the session is anchored to a frameless thread' } }, required: ['sessionId'] } },
{ name: 'get_local_variables', description: 'Get local variables for the shared inspection anchor. If the stopped thread is frameless, a sibling with policy-recognized user frames is preferred, adopted, and disclosed in anchorNote. Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeSpecial: { type: 'boolean', description: 'Include special/internal variables like this, __proto__, __builtins__, etc. Default: false' }, names: namesProp }, required: getLocalVariablesRequired } },
{ name: 'get_stack_trace', description: 'Get stack trace. The response includes stopReason — why the session is paused. For an implicit frameless stopped thread, a sibling with policy-recognized user frames is preferred and adopted, with the switch disclosed in note. Internal/runtime frames are filtered by default; when any are hidden the response carries hiddenFrames and a note', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeInternals: { type: 'boolean', description: 'Include internal/framework frames (e.g., Node.js internals). Default: false for cleaner output.' }, threadId: { type: 'number', description: 'Inspect this exact thread (ids from list_threads). Explicit selection is authoritative and is never silently switched' } }, required: ['sessionId'] } },
{ name: 'get_scopes', description: 'Get scopes for a stack frame', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, frameId: { type: 'number', description: "The ID of the stack frame from a stackTrace response" } }, required: ['sessionId', 'frameId'] } },
{ name: 'evaluate_expression', description: 'Evaluate expression in the current debug context. Expressions can read and modify program state. Waits up to 30s for the result by default; pass timeout for long-running expressions', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, expression: { type: 'string' }, frameId: { type: 'number', description: 'Optional stack frame ID for evaluation context. Must be a frame ID from a get_stack_trace response. If not provided, uses the current (top) frame automatically' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the evaluation to complete (default: 30000, max: 600000). On expiry the request fails but the expression may keep executing in the debuggee. Note: your MCP client may enforce its own overall request timeout' } }, required: ['sessionId', 'expression'] } },
{ name: 'evaluate_expression', description: 'Evaluate expression in the current debug context. Without frameId, uses the same adopted top frame as stack and locals and discloses an automatic thread switch in anchorNote. Expressions can read and modify program state. Waits up to 30s by default', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, expression: { type: 'string' }, frameId: { type: 'number', description: 'Optional authoritative stack frame ID from get_stack_trace. When provided, no automatic frame/thread selection occurs' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the evaluation to complete (default: 30000, max: 600000). On expiry the request fails but the expression may keep executing in the debuggee. Note: your MCP client may enforce its own overall request timeout' } }, required: ['sessionId', 'expression'] } },
{ name: 'get_source_context', description: 'Get source context around a specific line in a file', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, file: { type: 'string', description: fileDescription }, line: { type: 'number', description: 'Line number to get context for' }, linesContext: { type: 'number', description: 'Number of lines before and after to include (default: 5)' } }, required: ['sessionId', 'file', 'line'] } },
{ name: 'get_output', description: 'Get debuggee output (stdout/stderr/console) captured for a session. Buffered per launch (last 1000 entries; adapter telemetry and known adapter-internal diagnostics — e.g. LLDB DWARF-parser noise — filtered out). Works while the program is running and after it finishes, until the session is closed. Pass since=nextSince from the previous response to fetch only new output', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, since: { type: 'number', description: 'Only return entries with seq greater than this cursor (use nextSince from the previous response). Default: 0 = from the start of the buffer' }, limit: { type: 'number', description: 'Maximum entries to return (default: 100, max: 1000). hasMore:true in the response means more entries are available' } }, required: ['sessionId'] } },
{ name: 'redefine_classes', description: 'Hot-swap changed Java classes into a running JVM. Scans a classes directory for .class files modified after sinceTimestamp, matches them against loaded classes in the target JVM, and redefines them using JDI. Returns which classes were redefined and the newest file timestamp (pass as sinceTimestamp on next call for incremental updates). Statement-anchored breakpoints are re-resolved against the new source after the swap (anchorResolution reports moved/stale). Only works with Java debug sessions.', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, classesDir: { type: 'string', description: 'Absolute path to compiled classes directory (e.g. build/classes/java/main/)' }, sinceTimestamp: { type: 'number', description: 'Unix timestamp (ms). Only redefine .class files modified after this time. 0 or omitted = all files.' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the redefinition to complete (default: 30000, max: 600000). Increase when hot-swapping many classes at once' } }, required: ['sessionId', 'classesDir'] } },
Expand Down
12 changes: 11 additions & 1 deletion src/session/inspection/expression-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface EvaluateResult {
error?: string;
/** Present when secret-shaped content was masked in `result` (issue #237) */
redaction?: { rules: string[]; notice: string };
/** Discloses when default evaluation adopted a different stopped thread. */
anchorNote?: string;
}

/**
Expand Down Expand Up @@ -124,12 +126,19 @@ export class ExpressionEvaluator {

// Resolve the same default anchor stack and locals use. An explicit
// frameId is authoritative and deliberately bypasses the resolver.
let anchorNote: string | undefined;
if (frameId === undefined) {
try {
this.ctx.logger.info(
`[SM evaluateExpression ${sessionId}] No frameId provided; resolving the shared inspection anchor`
);
const anchor = await this.frameAnchorResolver.resolve(sessionId);
const anchor = await this.frameAnchorResolver.resolve(
sessionId,
undefined,
false,
{ ensureStackReady: true }
);
anchorNote = anchor.note;
if (anchor.frames.length > 0) {
frameId = anchor.frames[0].id;
this.ctx.logger.info(
Expand Down Expand Up @@ -194,6 +203,7 @@ export class ExpressionEvaluator {
namedVariables: body.namedVariables,
indexedVariables: body.indexedVariables,
presentationHint: body.presentationHint,
...(anchorNote ? { anchorNote } : {})
};

// Redaction hook (issue #237), placed above the logs below so they
Expand Down
38 changes: 36 additions & 2 deletions src/session/inspection/frame-anchor-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,17 +257,51 @@ export class FrameAnchorResolver {
} | null> {
const candidates = threads ?? await this.listThreadsForScan(sessionId, proxyManager);
if (!candidates) return null;
const session = this.ctx.getSession(sessionId);
const policy = this.ctx.selectPolicy(session.language);
let runtimeFallback: {
threadId: number;
threadName?: string;
frames: DebugProtocol.StackFrame[];
} | null = null;
for (const thread of candidates) {
if (!thread || typeof thread.id !== 'number' || thread.id === excludeThreadId) continue;
try {
const frames = await this.requestRawStackFrames(sessionId, proxyManager, thread.id);
if (frames.length > 0) {
return { threadId: thread.id, threadName: thread.name, frames };
const candidate = { threadId: thread.id, threadName: thread.name, frames };
if (this.hasPolicyUserFrame(frames, policy)) {
return candidate;
}
runtimeFallback ??= candidate;
}
} catch {
// Runtime threads may reject stackTrace; keep probing siblings.
}
}
return null;
return runtimeFallback;
}

/** Whether a policy recognizes at least one frame as user-inspectable. */
private hasPolicyUserFrame(
frames: DebugProtocol.StackFrame[],
policy: AdapterPolicy
): boolean {
const mapped: StackFrame[] = frames.map((frame) => ({
id: frame.id,
name: frame.name,
// Empty is intentional for classification: policies commonly use a
// missing source to identify runtime frames.
file: frame.source?.path || frame.source?.name || '',
line: frame.line,
column: frame.column
}));
if (policy.isInternalFrame) {
return mapped.some((frame) => !policy.isInternalFrame!(frame));
}
if (policy.filterStackFrames) {
return policy.filterStackFrames(mapped, false).length > 0;
}
return mapped.length > 0;
}
}
56 changes: 42 additions & 14 deletions src/session/session-manager-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,13 @@ export abstract class SessionManagerData extends SessionManagerCore {

try {
// Step 1: Get stack trace
const stackFrames = await this.getStackTrace(sessionId);
const stackResult = await this.frameAnchorResolver.resolve(
sessionId,
undefined,
false,
{ ensureStackReady: true }
);
const stackFrames = stackResult.frames;
if (!stackFrames || stackFrames.length === 0) {
this.logger.warn(`[SM getLocalVariables ${sessionId}] No stack frames available.`);
return { variables: [], frame: null, scopeName: null };
Expand All @@ -292,19 +298,21 @@ export abstract class SessionManagerData extends SessionManagerCore {
}
}

// Step 3: Collect variables for all scopes — budget-aware (issue
// #356): a JS attach's internal pause frame can expose scopes walking
// into process/global, so stop issuing DAP requests once the per-call
// variable budget is spent. Frames iterate top-first, so the frames
// that matter (whose Local scope extractLocalVariables reads) are
// fetched before the budget can run out. The `names` filter is pushed
// down so an explicit request is never starved by the budget.
// Step 3: Collect variables frame-by-frame — budget-aware (issue
// #356) and anchor-aware (issues #468/#594). Stop as soon as a frame
// yields usable locals; walking every async/runtime frame after the
// answer is already known is both wasteful and unsafe (an unrelated
// lower-frame formatter can hang the entire inspection). The `names`
// filter remains authoritative for the top frame, so explicit-name
// requests never walk down to a caller.
const variablesMap: Record<number, Variable[]> = {};
const truncationByScope = new Map<number, VariableTruncationSummary | undefined>();
let fetchedCount = 0;
let scopeFetchesSkipped = 0;
const fetchBudget = maxVariablesPerCall();
for (const frame of stackFrames) {
const policy = this.selectPolicy(session.language);
for (let frameIndex = 0; frameIndex < stackFrames.length; frameIndex++) {
const frame = stackFrames[frameIndex];
const scopes = scopesMap[frame.id];
if (!scopes) continue;
for (const scope of scopes) {
Expand All @@ -320,17 +328,37 @@ export abstract class SessionManagerData extends SessionManagerCore {
variablesMap[scope.variablesReference] = detailed.variables;
}
}

const framesAtAnchor = stackFrames.slice(frameIndex);
const extraction = policy.extractLocalVariables
? policy.extractLocalVariables(
framesAtAnchor,
scopesMap,
variablesMap,
includeSpecial
)
: undefined;
const fallbackHasVariables = !policy.extractLocalVariables && scopes.some(
scope =>
!scope.name.toLowerCase().includes('global') &&
(variablesMap[scope.variablesReference]?.length ?? 0) > 0
);
if (
names !== undefined ||
(extraction?.variables.length ?? 0) > 0 ||
fallbackHasVariables ||
fetchedCount >= fetchBudget
) {
break;
}
}
if (scopeFetchesSkipped > 0) {
this.logger.info(
`[SM getLocalVariables ${sessionId}] Skipped ${scopeFetchesSkipped} scope fetch(es) after hitting the ${fetchBudget}-variable budget.`
);
}

// Step 4: Get the appropriate adapter policy
const policy = this.selectPolicy(session.language);

// Step 5: Extract local variables using the adapter policy. Policies
// Step 4: Extract local variables using the adapter policy. Policies
// anchor to the first frame of the list they receive, so extraction is
// parameterized by anchor: slicing the frame list re-anchors it.
const extractAt = (frames: StackFrame[]): {
Expand Down Expand Up @@ -472,7 +500,7 @@ export abstract class SessionManagerData extends SessionManagerCore {
: undefined
]);

const anchorNotes: string[] = [];
const anchorNotes: string[] = stackResult.note ? [stackResult.note] : [];
if (anchorIndex > 0) {
anchorNotes.push(
`Top frame '${topFrame.name}' has no local variables (runtime/stdlib frame); ` +
Expand Down
Loading
Loading