Skip to content

get_local_variables anchors to stackFrames[0] — returns empty on a syscall pause after already fetching the user frame's locals #468

Description

@debugmcpdev

Summary

get_local_variables hard-anchors to stackFrames[0]. When a pause_execution lands inside a blocking syscall — the normal case for any long-running program — frame 0 is a stdlib/libc frame with no locals, so the tool returns an empty array even though the user frame is one frame down and already visible in the default (filtered) stack.

The sting: the implementation has already fetched the data it then discards. session-manager-data.ts loops over every frame collecting scopes (the fan-out from #438), and then narrows the result to stackFrames[0]'s Local scope.

// src/session/session-manager-data.ts:436
const topFrame = stackFrames[0];
...
// the loop directly below fetches scopes for ALL frames
for (const frame of stackFrames) { const scopes = await this.getScopes(sessionId, frame.id); ... }

This matters because get_local_variables is the tool an agent reaches for to inspect a paused long-running process, and its documented purpose is "returns just the local variables without needing to traverse stack->scopes->variables manually". In exactly that scenario it returns nothing, and the agent has no signal that the data is one hop away.

Reproduction (C/C++, verified on v0.24.2)

create_debug_session { language: "cpp" }
start_debugging { scriptPath: "examples/cpp/pause_test.cpp", dapLaunchArgs: { stopOnEntry: false } }
pause_execution { }
→ { stopReason: "pause", location: { file: ".../this_thread_sleep.h", line: 80 } }

get_local_variables { }
→ { "variables": [], "count": 0,
    "frame": { "name": "void std::this_thread::sleep_for<...>(...)",
               "file": "/usr/include/c++/13/bits/this_thread_sleep.h", "line": 80 },
    "message": "The Local scope is empty." }

get_stack_trace { }                       // DEFAULT call — already filtered
→ stackFrames[0] = std::this_thread::sleep_for   (this_thread_sleep.h:80)
  stackFrames[1] = main                          (pause_test.cpp:20)   ← VISIBLE, not hidden
  count: 5, hiddenFrames: 2

get_scopes { frameId: <main> }  → Local scope 1074
get_variables { scope: 1074 }   → [ { "name": "counter", "value": "348", "type": "long long" } ]

main is frame 1 of the default, already-filtered stack — not something includeInternals was hiding.

Cross-adapter inconsistency

Same fixture shape, same pause-in-sleep, opposite outcomes:

Adapter get_local_variables at a sleep pause
java counter: 78 from PauseTest.main (policy classifies Thread.sleep internals as hidden)
dotnet counter from PauseTest.Main
cpp / rust / ruby / javascript ❌ empty — "The Local scope is empty."

So the behavior is an artifact of how completely each adapter policy classifies internal frames, not a deliberate contract. Observed on 13 independent runs across 5 languages and all three build groups (local / npx / docker, both transports) in a full /testdebugger sweep.

Relationship to existing issues

Suggested fix

Make the frame choice adapter-agnostic instead of chasing per-adapter internal-frame classification: pick the first frame whose Local scope is non-empty (falling back to stackFrames[0] if none is), and name the chosen frame in the existing frame field so the caller can see the tool walked down. The scopes are already fetched, so this costs no extra DAP round-trips.

If the empty result is ever genuinely correct, the message should say the frame is a runtime/stdlib frame and point at the user frame that does have locals — the current "The Local scope is empty." is true but not actionable.

Relates to #369, #465.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions