Skip to content

fix(lldb): hide the Windows CRT, NT and winpthreads frames the stack filter never knew (#824) - #894

Merged
debugmcpdev merged 3 commits into
mainfrom
fix/824-lldb-windows-frames
Oct 10, 2026
Merged

debugmcpdev merged 3 commits into
mainfrom
fix/824-lldb-windows-frames

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Summary

Fixes #824.

On Windows the LLDB display filter hid nothing. Two causes, both in packages/shared/src/interfaces/lldb-policy-shared.ts:

  1. The source rule did not recognise CodeLLDB's @symbol label. A frame without debug info arrives as source: {name: '@NtWaitForSingleObject', sourceReference: N} with no path, and FrameAnchorResolver carries that label into file. isSystemOrMissingSource('@NtWaitForSingleObject') returned false (not empty, not <unknown_source>, no POSIX path), so even a name the filter knew would not have been hidden — adding Windows names alone would have changed nothing.
  2. No Windows names. The filter knew __libc_start_main, _start and the POSIX syscall wrappers, not __tmainCRTStartup, mainCRTStartup, BaseThreadInitThunk, RtlUserThreadStart, NtWaitForSingleObject, WaitForSingleObjectEx, DbgBreakPoint/DbgUiRemoteBreakin (the break-in thread a pause lands on) or winpthreads.

Change

  • isSystemOrMissingSource: an @-prefixed file is "no source"; \ is normalised to / before path matching; MSYS2's mingw64|mingw32|ucrt64|clang64|clangarm64/include/, the MSVC vctools tree and Windows Kits are system paths.
  • WINDOWS_RUNTIME_NAME_PATTERNS: the CRT start-up ([w]mainCRTStartup, [w]WinMainCRTStartup, __tmainCRTStartup, __scrt_common_main[_seh], invoke_main, __acrt_*), the kernel32/ntdll plumbing (BaseThreadInitThunk, RtlUserThreadStart, Dbg{BreakPoint,UiRemoteBreakin}, Nt*/Zw*, WaitFor{Single,Multiple}Object[s][Ex], Sleep[Ex], SleepConditionVariable{CS,SRW}, WaitOnAddress) and _?pthread_* — all under the existing name + no-user-source rule, so a user function named Sleep with a workspace path is kept.
  • Nothing changes in FrameAnchorResolver: the [QUESTION] Go stack filtering can return an empty stack — should it keep the first frame like JS? #346 guarantee (never an empty stack) and the js: when the paused frame is inside node_modules, get_stack_trace hides it and get_local_variables/evaluate_expression silently anchor on an async ancestor frame #672 paused-frame disclosure apply as before.
  • Docs: docs/stack-trace-filtering.md (the Windows rule, the @symbol shape, COBOL composes the filter too), docs/cpp/README.md step 6; changelog.d/824.fixed.md (it states the Linux side effect: pthread_* now also hides glibc's nptl frames, whose ./nptl/ path the filter already classified as system).

Measured on this box (Windows 11, CodeLLDB 1.11.8, MSYS2 g++ 15.2.0, examples/cpp/pause_test.cpp paused in its sleep loop, through the dev server)

Program thread, before → after:

frames shown hiddenFrames
before 12 (NtWaitForSingleObject … main … RtlUserThreadStart), none hidden —
after 2: std::this_thread::sleep_for<…> (MinGW this_thread_sleep.h), main (pause_test.cpp:20) 10

includeInternals: true shows the full 12 with the 9 @symbol frames flagged unresolvedSource: true and the #816 note, as before.

The break-in thread the pause itself lands on (DbgBreakPoint → RtlUserThreadStart, 4 frames) now reads "All 4 frames are internal/runtime frames; showing the top internal frame …" with hiddenFrames: 3 — the #346 shape. That a launched program's pause_execution anchors get_stack_trace on this injected thread rather than the program's thread is a separate finding, filed on its own.

Tests

  • adapter-policy-cpp.test.ts: 17 new rows in the real Windows shape (name: 'NtWaitForSingleObject', file: '@NtWaitForSingleObject' …), including the keep cases (user Sleep with a workspace path, main, sleep_for from the MinGW C++ header, a stripped user frame whose @label matches no name), plus filterStackFrames reduces the measured Windows pause stack to the program over the issue's 12-frame stack → [sleep_for, main].
  • adapter-policy-rust.test.ts: 11 new rows (Windows names, MSVC invoke_main under vctools, glibc nptl pthread_cond_wait under ./nptl/ → hidden, user pthread_helper → kept, a /rustc/ std frame with a non-wrapper name → kept).
  • adapter-policy-cobol.test.ts: three Windows rows (COBOL wraps the shared filter).
  • session-manager-dap.test.ts (get_stack_trace gives the js-debug sourceMaps hint for native frames on a C/C++ (CodeLLDB) session #816 fixture): the default view now hides the @symbol frames (hiddenFrameCount ≥ 2, pausedFrame names NtWaitForSingleObject) and includeInternals: true still carries the unresolvedSource flags.
  • tests/e2e/mcp-server-smoke-cpp-attach.test.ts: on win32 asserts hiddenFrames > 0 and a hidden note instead of the conditional unresolvedSource check that would otherwise silently stop running. Run locally: 2/2 pass.

Verification: npm run typecheck:all (ratchet 0/0), npm run lint, node scripts/check-docs.mjs, pnpm changelog:check; targeted unit run 379/379; pre-push hook (unit + integration) passed.

🤖 Generated with Claude Code

…filter never knew (#824)

On Windows the LLDB display filter hid nothing: it knew glibc's start-up
names and the POSIX syscall wrappers, but not `__tmainCRTStartup`,
`mainCRTStartup`, `BaseThreadInitThunk`, `RtlUserThreadStart`,
`NtWaitForSingleObject`, `WaitForSingleObjectEx` or winpthreads — and the
source rule did not recognise CodeLLDB's path-less `@symbol` label, which
the anchor resolver carries into `file`, so even a known name would not
have been hidden. The filter now treats an `@symbol` file as "no source",
knows the Windows CRT start-up, the kernel32/ntdll thread, wait and
break-in plumbing and `pthread_*`, and recognises MSYS2's include dirs
and the MSVC vctools tree as system paths — all under the existing
name + no-user-source rule, so a user `Sleep` with a workspace path stays.

Measured on this box (pause_test.cpp paused in its sleep loop, CodeLLDB
1.11.8, MSYS2 g++ 15.2.0): the program thread's default stack went from
12 frames, none hidden, to `sleep_for` + `main` with hiddenFrames: 10.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…es and the MinGW-w64 sysroot, and say where inspection anchors (#824)

Review of #894: the docs promised a pausedFrame disclosure on every
Windows pause and an anchor on "the first program frame"; the disclosure
fires only when the stop's thread is the anchored thread, and the anchor
for a program asleep in std::this_thread::sleep_for is that header frame.
The cpp guide, the changelog and the filtering overview now say so. The
bare '/lib/' path pattern, extended to Windows by the backslash
normalisation, would have hidden a user poll() under a project's lib\
folder — the roots are anchored now; the Windows patterns match
case-insensitively and include the MinGW-w64 sysroot layout and the MSVC
STL; /^_*pthread_/ reaches glibc's __pthread_ and ___pthread_ names; and
the #816 fixture pins hiddenFrameCount 3 and kept: false.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@debugmcpdev
debugmcpdev merged commit d943392 into main Oct 10, 2026
14 checks passed
@debugmcpdev
debugmcpdev deleted the fix/824-lldb-windows-frames branch October 10, 2026 13:53
debugmcpdev added a commit that referenced this pull request Oct 12, 2026
…the break-in thread shows the program thread (#904, #898) (#912)

* feat(session): selectStopAnchor — one bounded thread-rescue verdict (#904)

A debugger names the thread it stopped on, but that thread is not always
the program's: CodeLLDB reports a Windows attach or pause on the injected
break-in thread (#761/#817/#898), a .NET stop can land on a frameless
thread (#496), and js-debug's idle pause lands on a sourceless frame
(#900). The attach controller and the frame-anchor resolver each carried
their own copy of the rescue, with different predicates (path-based vs
policy-based) and different bounds (16 threads / 3 s / 10 s vs none).

selectStopAnchor is the one verdict both will share: candidates ordered
stopped thread, then threads named main, then listed order; a thread
reaches the program when a frame both names a source (not a '@symbol' or
'<placeholder>' label) and is one the policy calls the program's; bounds
injected by the caller. It decides and does not act — the callers move
the anchor and word the disclosure. policyUserFrames reads the per-frame
classifier before the filter because Java's filter hands the whole stack
back when nothing survives. toStackFrame is the resolver's mapping,
extracted so the scan classifies the frames the display sees.

threadStacksHandler is the test double both proxy fakes can take.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKWRJTTqYdYrhDn2z9Et5P

* refactor(attach): anchor through selectStopAnchor (#904)

The attach controller's own thread rescue (reachesSource,
threadReachingSource, the three scan bounds) becomes a call to the shared
verdict under the same bounds: the reported stop thread first, then a
thread named main, then listed order; 16 unwinds, each with its own 3 s
timeout, all within 10 s or the verify window. The fallback chain, the
rule strings and the log lines are unchanged, and a target with no
observed stop is still never unwound.

One deliberate change in the predicate: a frame whose source has a name
but no path (a Java class without debug info; a source the adapter could
not map) now counts as reaching the program when the policy does not
call it internal. '@symbol' and '<placeholder>' labels still never do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKWRJTTqYdYrhDn2z9Et5P

* fix(session): a pause reported on an internal-only thread adopts the program thread and says so (#898)

On Windows, CodeLLDB reports a pause on the thread the OS injects for the
break-in: an ntdll-only stack, every frame internal after #894, while the
program's own thread sits in main one list_threads away. The resolver's
sibling scan only ever ran for an EMPTY stack; an all-internal one took
the #346 path (show the top internal frame) and stopped there.

The resolver now routes both scans through selectStopAnchor. A
program-neutral stop (pause after normalisation) reported on a thread
with no program frame — judged by the per-frame classifier, so it holds
for Java, whose filter hands the whole stack back — adopts the first
sibling whose stack reaches the program, moves the anchor, and discloses
the switch in note. A breakpoint, step, exception or entry stop belongs
to its thread and never moves; an explicit threadId never moves; a stop
that lands during the scan drops the stale verdict (on the frameless
path too). The verdict is kept per stop so later locals and evaluate
reads do not scan again, and the readiness scan gains the per-unwind
timeout it never had. Any implicit read anchored off the reported thread
says so in one sentence.

StackTraceResult gains noProgramFrame for the case where no sibling
reaches the program either, which PR B words in the pause answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKWRJTTqYdYrhDn2z9Et5P

* docs: the stop-anchor verdict and the internal-only pause adoption (#904, #898)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKWRJTTqYdYrhDn2z9Et5P

* fix(session): the resolver's plain stack read stays a two-argument request

The per-unwind timeout is passed only when asked for; an explicit undefined
third argument broke the tests that pin the plain read's call shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKWRJTTqYdYrhDn2z9Et5P

* fix(session): key the internal-only adoption on the readiness option, not on a missing threadId

The server facade names the current thread when it reads the default
stack, and marks the read implicit with ensureStackReady alone; a
caller's explicit threadId is read without the option. The adoption
gate and the divergence note now follow that contract — measured live
on a Windows pause of examples/cpp/pause_test.cpp, where the first cut
never fired through get_stack_trace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKWRJTTqYdYrhDn2z9Et5P

* fix(session): review follow-ups for the stop-anchor verdict (#904)

- get_stack_trace forwards noProgramFrame, as the docs and fragment said
  it did; handler test.
- The #600 resolver test names its program thread something other than
  main, so the user-over-runtime preference, not name ordering, decides it.
- The readiness scan's budget is floored at one poll interval, so a
  sibling that answers in a few ms is not "could not be unwound" with a
  few ms left in the window.
- A verdict with no stopped thread says 'none'; the memo keeps negative
  verdicts by design and says so; ensureStackReady is documented where it
  is declared and where the facade reads it.
- The fragment discloses both directions of the attach predicate change
  and the Go pause consequence; CLAUDE.md loses a stray list marker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKWRJTTqYdYrhDn2z9Et5P

---------

Co-authored-by: JF <john.franklin@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants