Skip to content

feat: exception events as values, and the crash stack without .ecxr - #144

Merged
glslang merged 3 commits into
mainfrom
feat/exception-record
Sep 5, 2026
Merged

feat: exception events as values, and the crash stack without .ecxr#144
glslang merged 3 commits into
mainfrom
feat/exception-record

Conversation

@glslang

@glslang glslang commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Three typed reads that windbg-mcp's explorer walkthrough had to perform by hand through the execute text hatch, three times in one evening — .exr -1, .ecxr, and the stack walk that follows one. Its §9 makes the case for them; this is the primitive half.

What lands

  • DebugEngine::last_event() -> DebugEvent — kind, engine process and thread, and an ExceptionRecord (code, flags, faulting address, parameters, nested pointer) when the event carried one. The user-mode counterpart to bug_check.

    parameters arrives cut to the record's own NumberParameters, and clamped to the fifteen slots there are. That count is the field the two shapes of a 0xc0000409 are told apart by — one parameter is the CRT's abort, three is WIL's, whose second is the HRESULT — so a leftover read as a parameter would answer the question rather than merely look untidy.

  • DebugEngine::stored_event() -> Option<DebugEvent> — the event a dump was written for, with the register context it was written with, as an opaque ThreadContext. Unlike last_event it does not move, so it still answers on a session somebody has already been working in. Ok(None) where there is no stored event, read off the engine's own refusal (E_UNEXPECTED) rather than probed for, so a genuine failure still reaches the caller as one.

  • DebugEngine::stack_frames_from(&ThreadContext, max).ecxr; k without the .ecxr: the caller's selected thread and frame are left where they were, so a triage built on it is still a read of the target.

The measurement that changed the design

examples/stored_event_probe.rs caught the bug that would otherwise have shipped. GetScope refuses a context buffer below the target's CONTEXT size — which is why SCOPE_CONTEXT_SIZES climbs from the smallest. GetStoredEventInformation does not: offered 716 bytes for an x64 dump it writes 716, reports 716, and returns success. The damage surfaces three calls later, when GetContextStackTrace rejects the truncated context with E_INVALIDARG against a caller that did nothing wrong.

So this call gets a ladder of its own that starts above every real CONTEXT and grows only on the one signal the call gives that there was more to write. Offered 4,096 the same dump reports 1,232.

Two negative results, because both narrow the claim

  • A kernel crash dump has no stored event at all. Its last event is a second-chance, noncontinuable STATUS_BREAKPOINT with zero parameters, in nt!KeBugCheck2. So this and bug_check do not overlap and neither is a fallback for the other.
  • What makes the two stack walks differ is the selected thread, and only that. After ~1s on a two-thread fail-fast dump, stack_frames returns the parked thread's six frames while stack_frames_from still returns the crash's twelve. .frame, .cxr and .ecxr move neither — they change the symbol scope, and GetStackTrace walks from the thread's registers. A single-threaded dump agrees with itself in every state it can be put in, which nearly recorded "no difference" as the answer.

Verification

  • cargo test --lib: 192 passed, up from 189 — three new unit tests, over synthetic records rather than an engine, so they run under Miri.
  • Both new guards mutation-tested: dropping the used < SIZE check and dropping the parameter clamp each fail the test written for them.
  • cargo fmt --all -- --check clean; cargo clippy --all-targets unchanged at 10 warnings, none in this diff.
  • examples/stored_event_probe.rs run on all three target shapes: a live cmd.exe, a kernel crash dump, and user-mode fail-fast dumps (one thread and two). E_UNEXPECTED was pinned by flipping no_stored_event to |_| false for one run and back for the next.

Miri does not run on PRs here; nothing in this diff adds an unsafe shape that Miri would have something new to say about (one read_unaligned out of a #[repr(C, align(8))] byte buffer), but it is worth a workflow_dispatch before merge if you would rather.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf

Three typed reads that `windbg-mcp`'s explorer walkthrough had to do by hand
through the `execute` text hatch, three times in one evening: `.exr -1`,
`.ecxr`, and the stack walk that follows one.

`last_event` returns a `DebugEvent` — kind, engine process and thread, and an
`ExceptionRecord` when the event carried one. It is the user-mode counterpart to
`bug_check`. `parameters` is cut to the record's own `NumberParameters` and
clamped to the fifteen slots there are, because that count is the field the two
shapes of a `0xc0000409` are told apart by: one parameter is the CRT's `abort`,
three is WIL's, whose second is the HRESULT. A leftover read as a parameter
would answer the question rather than merely look untidy.

`stored_event` returns the event a dump was written for, with the register
context it was written with. Unlike `last_event` it does not move, so it still
answers on a session somebody has already been working in. `Ok(None)` where
there is no stored event, read off the engine's own refusal rather than probed
for.

`stack_frames_from` walks that context — `.ecxr; k` without the `.ecxr`, so the
caller's selected thread and frame are left where they were.

Measured in `examples/stored_event_probe.rs`, which caught the one that would
otherwise have shipped: unlike `GetScope`, `GetStoredEventInformation` does not
refuse a context buffer that is too small. It truncates and returns success —
offered 716 bytes for an x64 dump it writes 716 and reports 716 — and the damage
surfaces three calls later, when `GetContextStackTrace` rejects the result with
`E_INVALIDARG` against a caller that did nothing wrong. So this call gets a
ladder of its own that starts above every real `CONTEXT` instead of below it.

Two negative results are recorded there too, because both narrow the claim.
A kernel crash dump has no stored event at all, so this and `bug_check` do not
overlap and neither is a fallback for the other. And what makes the two stack
walks differ is the selected thread and only that: after `~1s` on a two-thread
fail-fast dump, `stack_frames` returns the parked thread's six frames while
`stack_frames_from` still returns the crash's twelve — while `.frame`, `.cxr`
and `.ecxr` move neither, since they change the symbol scope and `GetStackTrace`
walks from the thread's registers. A single-threaded dump agrees with itself in
every state it can be put in, which nearly recorded "no difference" as the
answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: e2fdc47d-a953-4afa-9371-621194a46d26


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8edd418b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs Outdated
Comment on lines +5113 to +5114
Ok(DebugEvent {
kind,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject the pre-wait last-event sentinel

When a dump has been passed to open_dump but has not yet been pumped with wait_for_event, GetLastEventInformation succeeds with kind 0 and DEBUG_ANY_ID identifiers, as the new stored_event_probe itself records. This unconditional construction therefore returns a seemingly valid DebugEvent, contrary to this method's guarantee that an engine before its first wait fails; callers can mistake “target not loaded yet” for an actual future/unknown event kind. Detect this sentinel and return an error instead.

Useful? React with 👍 / 👎.

Comment thread src/dbgeng.rs
Comment on lines +5290 to +5291
Some(context.as_bytes().as_ptr().cast()),
context.len() as u32,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject contexts captured from a previous target

If a caller retains this context, ends the session, opens another same-architecture target, and then calls stack_frames_from, the method passes the old register bytes directly to the new target. Those register and stack addresses are target-specific, so this can unwind or symbolize unrelated memory instead of returning the crash stack; unlike Scope, ThreadContext carries no target identity to detect the misuse. Store the originating target identity with the context and reject it when it no longer matches before calling GetContextStackTrace.

Useful? React with 👍 / 👎.

Both findings from Codex's review of dbgscope#144, and both were right about
the fact. The remedy differs from the one suggested in the first case.

**"No event" was claimed to be a failure and is not.** `last_event`'s doc said
the call fails on an engine that has seen no event. Measured -- on an engine
holding no target, and on a dump `open_dump` has named but nothing has pumped
-- it succeeds: `S_OK`, kind `0`, `DEBUG_ANY_ID` for both ids. So the call was
returning a `DebugEvent` whose `kind` is not a `DEBUG_EVENT_*` value at all, and
a caller could read "the target is not loaded yet" as an event kind this build
does not recognise.

Codex proposed detecting the sentinel and erroring. It is `Ok(None)` instead,
which is this crate's shape for exactly this -- `bug_check` and `stored_event`
both use it -- and which an error would lose: "nothing has happened yet" and
"the call failed" are different answers, and only one of them is a reason to
stop. Two new probe arms, `empty` and `unwaited`, are where that was measured.

The wrong sentence came from `last_event_process`, which has carried it since it
was written, so it is corrected there too. It costs that call's one caller
nothing -- `record_where_it_stopped` looks the id up in the session listing and
`DEBUG_ANY_ID` is in no listing -- but a caller that believed the doc would be
joining on a sentinel.

**A `ThreadContext` now carries the target it was read from**, and
`stack_frames_from` refuses one from a target the engine no longer holds, as
`set_scope` already refuses a stale `Scope`. That precedent is the argument: it
is the same saved register state with the same hazard, and this is the sharper
half of it. A stale scope points the session somewhere visibly wrong; a stale
context comes back as *frames*, which is an answer a caller cannot tell from the
right one.

Both new rules are mutation-verified: removing the kind-zero check fails the
test written for it, and so does removing the identity check. The first was
initially only visible through the probe, so it has a unit test now -- an engine
holding nothing reaches that state without a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
@glslang

glslang commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 201d5429e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/dbgeng.rs
// The check is cheap and the failure it prevents is not detectable afterwards: a context
// from a released target unwinds whatever its addresses reach in the new one and comes
// back as a plausible stack.
if context.target != self.target_identity() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reissue the identity when an opener replaces the session

Although the follow-up now protects the end_session path, this comparison still accepts a stale context when a caller loads dump A, saves its context, and then replaces it with dump B through open_dump (or either kernel-attach opener): those paths call forget_the_previous_session, which explicitly treats the old session as replaced, but never call reissue_identity. The fresh evidence beyond the earlier comment is this repository-controlled replacement path, where context.target therefore remains equal and the old registers can again be unwound against the new target; reissue the client identity as part of successful session replacement.

Useful? React with 👍 / 👎.

Round two of Codex's review on dbgscope#144, and it is right: the identity check
the previous commit added was worth nothing on the path that matters most.

Only `end_session` reissued the target identity. The three openers that replace
a session -- `open_dump`, which `open_trace` delegates to, and both kernel
attaches -- call `forget_the_previous_session`, which says in its own name and
doc that the old session is gone, and then left the identity alone. So a caller
who opened dump A, saved a `Scope` or a `ThreadContext`, and opened dump B
through the same engine got the same identity back, and the stale registers were
accepted against the new target.

The reissue goes in `forget_the_previous_session` rather than in the three
openers. Codex proposed the openers; that would be a fourth thing for the next
opener to remember, and the function that already means "this session is being
replaced" is where the invalidation belongs.

`test_an_opener_that_replaces_the_session_invalidates_the_old_contexts` pins it,
and **writes its own dump** with `.dump /m` on a launched target rather than
wanting a fixture -- which is what makes it a test rather than a probe. Backing
the reissue out fails it on the assertion written for it.

Two things that came out of adding it, both mine.

The two engine-building tests from the previous commit took no `one_debuggee()`
guard, because neither opens a target and the guard reads as being about
targets. It is not: `DebugEngine::drop` ends the process's debuggee session, so
an engine built and dropped beside another test's live target ends *that*
target's session. It surfaced three tests away as
`test_an_abandoned_launch_does_not_hand_its_process_to_the_next_one` failing a
pump with `E_UNEXPECTED`, with nothing pointing back here, and only under the
default harness -- single-threaded the suite was green, which is the wrong thing
to conclude from. Both take the guard now; three consecutive parallel runs are
clean.

And neither carried `#[cfg(not(miri))]`, which every other engine test here
does. Miri does not run on pull requests in this repo, so that would have been
green here and red on the merge to `main` -- the exact gap this repo's own notes
warn about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf
@glslang

glslang commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 9d68bd55ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@glslang
glslang merged commit bb26d44 into main Sep 5, 2026
7 checks passed
@glslang
glslang deleted the feat/exception-record branch September 5, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant