From e8edd418b468c6dfb89986889d703d8c8cc6ed01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Carvalho?= Date: Sat, 5 Sep 2026 07:54:37 +0100 Subject: [PATCH 1/3] feat: exception events as values, and the crash stack without .ecxr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf --- CHANGELOG.md | 30 ++ CLAUDE.md | 1 + examples/stored_event_probe.rs | 245 ++++++++++++++ src/dbgeng.rs | 567 +++++++++++++++++++++++++++++++-- 4 files changed, 824 insertions(+), 19 deletions(-) create mode 100644 examples/stored_event_probe.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba127d..dad03c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ All notable changes to this project are documented here. The format follows ### Added +- **Exception events are readable as values.** `DebugEngine::last_event` returns a `DebugEvent` — + kind, engine process and thread, and, when the event carried one, an `ExceptionRecord` with the + code, flags, faulting address and parameters. That is `.exr -1` typed, and it is the user-mode + counterpart to `bug_check`: on a target stopped by a fault it is the record that stopped it. + `ExceptionRecord::parameters` arrives already cut to the record's own `NumberParameters` (and + clamped to the fifteen slots there are), because the count is the field that tells the two shapes + of a `0xc0000409` apart — one parameter is the CRT's `abort`, three is WIL's, whose second is the + `HRESULT` — and a leftover read as a parameter would answer the question wrongly rather than + cosmetically. +- `DebugEngine::stored_event` returns 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: + it still answers after a caller has stepped, gone, or changed threads. `Ok(None)` where there is + no stored event — every live target, and every dump not written for a fault, including kernel + crash dumps, whose bug check `ReadBugCheckData` reads instead. That is read off the engine's own + refusal (`E_UNEXPECTED`, measured on both) rather than probed for, so a genuine failure still + reaches the caller as one. +- `DebugEngine::stack_frames_from` walks the stack a recorded context was in, which is what + `.ecxr; k` produces without `.ecxr`'s effect on the session: the caller's selected thread and + frame are left exactly where they were, so a triage built on it is still a read. **What makes it + differ from `stack_frames` is the selected thread and only that** — measured on a two-thread + fail-fast dump, after `~1s` the other walk returns the parked thread's six frames while this one + 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. +- `examples/stored_event_probe.rs`, the measurements behind the three. It also caught the one that + would otherwise have shipped: `GetStoredEventInformation` does **not** refuse a context buffer + that is too small the way `GetScope` does. It truncates — offered 716 bytes for an x64 dump it + writes 716, reports 716 and returns success, and the damage surfaces three calls later when + `GetContextStackTrace` rejects the truncated context with `E_INVALIDARG`. So the context ladder + here starts *above* every real `CONTEXT` rather than below it, and grows only on the one signal + the call gives that there was more to write. - Breakpoints can be **set**, not only listed. `DebugEngine::set_breakpoint` and `set_breakpoint_bounded` take a `BreakpointSpec` — a location (`BreakpointAt::Address` or `::Expression`), an optional command, match thread, pass count, one-shot flag, and a `DataWatch` diff --git a/CLAUDE.md b/CLAUDE.md index d854e06..3822952 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,5 +103,6 @@ There is no build script and no assembler step: both left with the exploitation - `examples/session_fuzz.rs` — randomised command sequences against a live session, checking after every step that the engine either still holds a target and answers or says it holds none. Seeded, so a failing sequence replays. Run it after touching anything in the wait/settle/guard seam: at seed 1 it finds the pre-fix half-dead session in 4 rounds of 8, and 150 rounds of 14 steps are clean on the fix - `examples/interrupt_provenance.rs` — whether a post-wait `GetInterrupt` tells a request that *ended* a wait from one that did not. It does: a request the wait consumed reads `[false; 5]` afterwards, one nothing consumed reads `[true, false, …]`, two back to back are one flag rather than two, and one lodged after the wait it was too late for is readable and belongs to the **next** operation. That makes it a forward signal and not a backward one, which is what #136 stage 3 would need to attribute a break to the operation it landed on rather than the one it was aimed at. Nothing depends on it yet — stage 2's scoping needs no engine state — and #136 asked for the measurement before anything did. Undocumented by Microsoft, one engine, one host: re-run it before building on it, and mind the DLL note below - `examples/breakpoint_probe.rs` — the measurements behind the breakpoint API: eager resolution and its cost, that the bound is reachable, that `bp` deduplicates where the engine does not, what a duplicate costs, and that a command string survives unescaped. Run it after touching that seam. **Copy the engine DLLs into `target/debug/examples/`, not `target/debug/`** — an example loads from its own directory, so otherwise it gets System32's `dbgeng.dll`, which without `symsrv.dll`/`msdia140.dll` resolves exported symbols from the export table and defers everything else. Nothing errors; the timings simply collapse to milliseconds and the run measures the wrong engine +- `examples/stored_event_probe.rs` — the measurements behind `last_event`, `stored_event` and `stack_frames_from`: how the engine says a target has no stored event (`E_UNEXPECTED`, on a live process and a kernel crash dump alike), that a kernel crash dump has none at all so `bug_check` and this do not overlap, and that what makes the two stack walks differ is the **selected thread** and nothing else — `.frame`, `.cxr` and `.ecxr` move neither. Run it after touching the event or stack-walk seam. Its main finding is a trap worth knowing before writing any `GetStoredEventInformation` call: unlike `GetScope` it does **not** refuse a context buffer that is too small, it truncates and returns success, and the damage only surfaces when `GetContextStackTrace` rejects the result. Needs a user-mode fault dump, and a **two-thread** one for the last question — a single-threaded dump agrees with itself in every state it can be put in, which nearly recorded "no difference" as the answer - `README.md` — user-facing overview, the `!dbgscope.poolmap` extension, and usage sketches - `.cursor/rules/*.mdc` — Cursor editor rules; they defer to this file for build commands and the module map diff --git a/examples/stored_event_probe.rs b/examples/stored_event_probe.rs new file mode 100644 index 0000000..5453cc5 --- /dev/null +++ b/examples/stored_event_probe.rs @@ -0,0 +1,245 @@ +//! Scratch experiment (not part of the public API), and the record behind +//! [`DebugEngine::last_event`], [`DebugEngine::stored_event`] and +//! [`DebugEngine::stack_frames_from`]. +//! +//! Five questions no unit test can answer, because each is a question about what a real +//! `dbgeng.dll` does. Measured on dbgeng 10.0.29547.1002, x64, Windows 11 26200, 2026-09-05. +//! +//! 1. **How does the engine say "this target has no stored event"?** It refuses, and the refusal +//! has to be told apart from a failure. Measured: `E_UNEXPECTED` (`0x8000ffff`), on a live +//! process and on a kernel crash dump alike — which is what `no_stored_event` is pinned to. +//! 2. **Does a kernel crash dump have one?** No. A bug check is not an exception event, and +//! `ReadBugCheckData` is what reads it — so the two calls do not overlap, and neither is a +//! fallback for the other. Its *last* event is a second-chance, noncontinuable +//! `STATUS_BREAKPOINT` with **zero** parameters, in `nt!KeBugCheck2`. +//! 3. **What is the last event on a freshly launched process?** `DEBUG_EVENT_EXCEPTION` (`0x2`) +//! carrying `STATUS_BREAKPOINT` (`0x80000003`), first-chance, one parameter. Not +//! `DEBUG_EVENT_BREAKPOINT` — the initial break arrives as an exception, which is the same +//! finding `last_event_process`'s doc comment records from the other direction. +//! 4. **Does `GetStoredEventInformation` refuse a context buffer that is too small?** **No, and +//! that is the trap this file caught.** It truncates: offered 716 bytes for an x64 dump it +//! writes 716, reports 716 and returns success, and the damage surfaces three calls later when +//! `GetContextStackTrace` rejects the truncated context with `E_INVALIDARG`. `GetScope` +//! *does* refuse, which is why `SCOPE_CONTEXT_SIZES` climbs from the smallest size — and why +//! borrowing that ladder here was wrong. `STORED_CONTEXT_SIZES` starts above every real +//! `CONTEXT` instead; offered 4,096 the same dump reports 1,232, which is x64's. +//! 5. **What actually makes the two stack walks differ?** The **selected thread**, and only that. +//! On the two-thread dump, after `~1s`, `stack_frames` returns the parked thread's six frames +//! (`ntdll!NtDelayExecution` … `throwcrash2!parked`) while `stack_frames_from` still returns +//! the crash's twelve. `.frame 5`, `.cxr` and `.ecxr` move **neither** — they change the symbol +//! scope, and `GetStackTrace` walks from the thread's registers. A single-threaded dump cannot +//! show any of this: both walks agree in every state it can be put in, which is how the first +//! run of this file nearly recorded "no difference" as the answer. +//! +//! ```text +//! cargo run --example stored_event_probe -- live +//! cargo run --example stored_event_probe -- dump +//! ``` +//! +//! **The dump arm wants a user-mode fault dump, and a two-thread one to answer question 5.** The +//! ones measured above were made by compiling a program that throws an object nothing catches — +//! so the CRT calls `terminate` → `abort` → `__fastfail`, which is the explorer walkthrough's +//! first fault exactly — and letting WER's `LocalDumps` catch it at `DumpType=1`. That is a +//! 190 KB minidump, and the second thread is a `Sleep(INFINITE)` started before the throw. +//! +//! **The engine has to be in `target/debug/examples`, not `target/debug`** — see +//! `breakpoint_probe.rs`, which explains what a wrong-engine run looks like. Here it would cost +//! the symbols on the frames and nothing else, since every question above is about event records +//! rather than about names. +//! +//! **How question 1 was measured, and how to measure it again.** A predicate that turns a refusal +//! into `Ok(None)` cannot be measured through itself: with `no_stored_event` in place, a live +//! target prints "no stored event" whatever the engine said. So it was flipped to `|_| false` for +//! one run, which surfaces every refusal with its `HRESULT` in the error text, and flipped back +//! for the next — and the second run is what shows the predicate catching the code the first one +//! printed. Do that again rather than trusting this comment if the engine version moves. + +use dbgscope::dbgeng::{DebugEngine, DebugEvent}; + +/// A target that lives long enough to be asked about, and exits on its own if this program dies +/// holding it. +const TARGET: &str = "cmd.exe /c ping -n 60 127.0.0.1"; + +/// How many frames to walk, from each of the two walks that are being compared. +const FRAMES: usize = 12; + +/// Long enough for the engine to read a dump off a cold disk and resolve what it needs to report +/// an event. Nothing here is timing-sensitive; this is a bound, not a measurement. +const WAIT_MS: u32 = 60_000; + +fn main() { + let mut args = std::env::args().skip(1); + let arm = args.next().unwrap_or_else(|| "live".into()); + match arm.as_str() { + "live" => live(), + "dump" => match args.next() { + Some(path) => dump(&path), + None => { + println!("dump needs a path: cargo run --example stored_event_probe -- dump ") + } + }, + other => { + println!("unknown arm {other}"); + println!("arms: live, dump "); + } + } +} + +/// A launched process, stopped at its loader break. +fn live() { + println!("======== live: {TARGET} ========"); + let engine = DebugEngine::new(); + if let Err(e) = engine.launch_process(TARGET) { + println!("could not launch {TARGET}: {e}"); + return; + } + report(&engine); +} + +/// A dump, opened by path. +fn dump(path: &str) { + println!("======== dump: {path} ========"); + let engine = DebugEngine::new(); + if let Err(e) = engine.open_dump(path) { + println!("could not open {path}: {e}"); + return; + } + // **The wait is not optional, and leaving it out is its own finding.** `open_dump` names the + // file; the engine reads it on the first wait. Before that `last_event` answers kind `0x0` + // with `DEBUG_ANY_ID` for both ids and no exception — "no event yet" rather than an error — + // and `stack_frames` fails outright with `E_UNEXPECTED`. Anything asking either question of a + // dump has to have waited first. + if let Err(e) = engine.wait_for_event(WAIT_MS) { + println!("could not wait for the dump's event: {e}"); + return; + } + // A bug check is the kernel's answer to the same question this is asking, and printing it + // beside the event is what shows the two do not overlap. + match engine.bug_check() { + Ok(Some(bug)) => println!( + " bug check: {:#x} ({:#x}, {:#x}, {:#x}, {:#x})", + bug.code, bug.parameters[0], bug.parameters[1], bug.parameters[2], bug.parameters[3] + ), + Ok(None) => println!(" bug check: none (code 0)"), + Err(e) => println!(" bug check: unreadable ({e})"), + } + report(&engine); +} + +/// Both event reads, and the two stack walks, on whatever target the caller opened. +fn report(engine: &DebugEngine) { + match engine.last_event() { + Ok(event) => print_event("last_event", &event), + Err(e) => println!(" last_event: {e}"), + } + let stored = match engine.stored_event() { + Ok(Some(event)) => { + print_event("stored_event", &event); + Some(event) + } + Ok(None) => { + println!(" stored_event: none — this target was not stored on an event"); + None + } + Err(e) => { + println!(" stored_event: {e}"); + None + } + }; + + // The comparison the new walk exists for: the same session, walked two ways. + match engine.stack_frames(FRAMES) { + Ok(frames) => print_frames("stack_frames (current context)", &frames), + Err(e) => println!(" stack_frames: {e}"), + } + let Some(context) = stored.as_ref().and_then(|event| event.context.as_ref()) else { + println!(" stack_frames_from: skipped — no stored context to walk from"); + return; + }; + println!(" stored context: {} bytes", context.len()); + match engine.stack_frames_from(context, FRAMES) { + Ok(frames) => print_frames("stack_frames_from (stored context)", &frames), + Err(e) => println!(" stack_frames_from: {e}"), + } + + // **The claim the second walk exists for**, which the two agreeing above does not establish: + // on a freshly opened dump the session is already looking at the crash, so both walks answer + // the same question. Navigate away and ask again. + // + // Three navigations rather than one because two of them are the *negative* result: `.frame` + // moves the symbol scope and moves neither walk, and `~1s` moves the selected thread and moves + // only the first. Running just the one that works would have left "a walk follows the session" + // looking like a broader claim than it is. + if let Ok(threads) = engine.execute_command("~") { + println!("\n threads in this target:\n{}", threads.trim_end()); + } + for navigation in [".frame 5", "~1s", "~0s"] { + match engine.execute_command(navigation) { + Ok(_) => println!("\n -------- after `{navigation}` --------"), + Err(e) => { + println!("\n `{navigation}` did not run: {e}"); + continue; + } + } + match engine.stack_frames(FRAMES) { + Ok(frames) => print_frames("stack_frames (current context)", &frames), + Err(e) => println!(" stack_frames: {e}"), + } + match engine.stack_frames_from(context, FRAMES) { + Ok(frames) => print_frames("stack_frames_from (stored context)", &frames), + Err(e) => println!(" stack_frames_from: {e}"), + } + } +} + +fn print_event(tag: &str, event: &DebugEvent) { + println!( + " {tag}: kind={:#x} process={} thread={} context={}", + event.kind, + event.process, + event.thread, + event + .context + .as_ref() + .map_or_else(|| "none".into(), |c| format!("{} bytes", c.len())), + ); + match &event.exception { + None => println!(" exception: none"), + Some(record) => { + println!( + " exception: code={:#010x} flags={:#x} address={:#x} first_chance={} \ + noncontinuable={} nested={:?}", + record.code, + record.flags, + record.address, + event.first_chance, + record.noncontinuable(), + record.nested.map(|at| format!("{at:#x}")), + ); + // The parameter *count* is the field the two shapes of a fail-fast are told apart by, + // so it is printed rather than left to be counted off the list. + println!( + " parameters ({}): {}", + record.parameters.len(), + record + .parameters + .iter() + .map(|p| format!("{p:#x}")) + .collect::>() + .join(", ") + ); + } + } +} + +fn print_frames(tag: &str, frames: &[dbgscope::dbgeng::StackFrame]) { + println!(" {tag}: {} frames", frames.len()); + for frame in frames { + println!( + " {:>2} {:#018x} {}", + frame.index, + frame.instruction_offset, + frame.symbol.as_deref().unwrap_or("(no symbol)") + ); + } +} diff --git a/src/dbgeng.rs b/src/dbgeng.rs index d43150b..f4e82ed 100644 --- a/src/dbgeng.rs +++ b/src/dbgeng.rs @@ -7,7 +7,9 @@ use std::thread; use std::time::{Duration, Instant}; use thiserror::Error; -use windows::Win32::Foundation::{E_INVALIDARG, E_NOINTERFACE, S_FALSE, S_OK}; +use windows::Win32::Foundation::{ + E_FAIL, E_INVALIDARG, E_NOINTERFACE, E_UNEXPECTED, S_FALSE, S_OK, +}; use windows::core::{HRESULT, IUnknown, Interface, PCSTR, PCWSTR, PWSTR}; // Import the necessary Windows Debug Engine interfaces @@ -16,24 +18,24 @@ use windows::Win32::System::Diagnostics::Debug::Extensions::{ DEBUG_BREAK_IO, DEBUG_BREAK_READ, DEBUG_BREAK_WRITE, DEBUG_BREAKPOINT_CODE, DEBUG_BREAKPOINT_DATA, DEBUG_BREAKPOINT_DEFERRED, DEBUG_BREAKPOINT_ENABLED, DEBUG_BREAKPOINT_ONE_SHOT, DEBUG_CLASS_KERNEL, DEBUG_ENGOPT_INITIAL_BREAK, - DEBUG_EVENT_BREAKPOINT, DEBUG_EXECUTE_ECHO, DEBUG_INTERRUPT_ACTIVE, DEBUG_KERNEL_SMALL_DUMP, - DEBUG_MODNAME_SYMBOL_FILE, DEBUG_MODULE_PARAMETERS, DEBUG_MODULE_USER_MODE, - DEBUG_OUTCTL_THIS_CLIENT, DEBUG_OUTPUT_NORMAL, DEBUG_REGISTER_DESCRIPTION, - DEBUG_REGISTER_SUB_REGISTER, DEBUG_STACK_FRAME, DEBUG_STATUS_GO, DEBUG_STATUS_GO_HANDLED, - DEBUG_STATUS_GO_NOT_HANDLED, DEBUG_STATUS_MASK, DEBUG_STATUS_NO_DEBUGGEE, - DEBUG_STATUS_REVERSE_GO, DEBUG_STATUS_REVERSE_STEP_BRANCH, DEBUG_STATUS_REVERSE_STEP_INTO, - DEBUG_STATUS_REVERSE_STEP_OVER, DEBUG_STATUS_STEP_BRANCH, DEBUG_STATUS_STEP_INTO, - DEBUG_STATUS_STEP_OVER, DEBUG_SYMINFO_IMAGEHLP_MODULEW64, DEBUG_SYMTYPE_CODEVIEW, - DEBUG_SYMTYPE_COFF, DEBUG_SYMTYPE_DEFERRED, DEBUG_SYMTYPE_DIA, DEBUG_SYMTYPE_EXPORT, - DEBUG_SYMTYPE_NONE, DEBUG_SYMTYPE_PDB, DEBUG_SYMTYPE_SYM, DEBUG_VALUE, DEBUG_VALUE_FLOAT32, - DEBUG_VALUE_FLOAT64, DEBUG_VALUE_FLOAT80, DEBUG_VALUE_FLOAT82, DEBUG_VALUE_FLOAT128, - DEBUG_VALUE_INT8, DEBUG_VALUE_INT16, DEBUG_VALUE_INT32, DEBUG_VALUE_INT64, - DEBUG_VALUE_VECTOR64, DEBUG_VALUE_VECTOR128, DebugConnectWide, IDebugAdvanced2, - IDebugBreakpoint2, IDebugClient6, IDebugControl4, IDebugDataSpaces4, + DEBUG_EVENT_BREAKPOINT, DEBUG_EVENT_EXCEPTION, DEBUG_EXECUTE_ECHO, DEBUG_INTERRUPT_ACTIVE, + DEBUG_KERNEL_SMALL_DUMP, DEBUG_LAST_EVENT_INFO_EXCEPTION, DEBUG_MODNAME_SYMBOL_FILE, + DEBUG_MODULE_PARAMETERS, DEBUG_MODULE_USER_MODE, DEBUG_OUTCTL_THIS_CLIENT, DEBUG_OUTPUT_NORMAL, + DEBUG_REGISTER_DESCRIPTION, DEBUG_REGISTER_SUB_REGISTER, DEBUG_STACK_FRAME, DEBUG_STATUS_GO, + DEBUG_STATUS_GO_HANDLED, DEBUG_STATUS_GO_NOT_HANDLED, DEBUG_STATUS_MASK, + DEBUG_STATUS_NO_DEBUGGEE, DEBUG_STATUS_REVERSE_GO, DEBUG_STATUS_REVERSE_STEP_BRANCH, + DEBUG_STATUS_REVERSE_STEP_INTO, DEBUG_STATUS_REVERSE_STEP_OVER, DEBUG_STATUS_STEP_BRANCH, + DEBUG_STATUS_STEP_INTO, DEBUG_STATUS_STEP_OVER, DEBUG_SYMINFO_IMAGEHLP_MODULEW64, + DEBUG_SYMTYPE_CODEVIEW, DEBUG_SYMTYPE_COFF, DEBUG_SYMTYPE_DEFERRED, DEBUG_SYMTYPE_DIA, + DEBUG_SYMTYPE_EXPORT, DEBUG_SYMTYPE_NONE, DEBUG_SYMTYPE_PDB, DEBUG_SYMTYPE_SYM, DEBUG_VALUE, + DEBUG_VALUE_FLOAT32, DEBUG_VALUE_FLOAT64, DEBUG_VALUE_FLOAT80, DEBUG_VALUE_FLOAT82, + DEBUG_VALUE_FLOAT128, DEBUG_VALUE_INT8, DEBUG_VALUE_INT16, DEBUG_VALUE_INT32, + DEBUG_VALUE_INT64, DEBUG_VALUE_VECTOR64, DEBUG_VALUE_VECTOR128, DebugConnectWide, + IDebugAdvanced2, IDebugBreakpoint2, IDebugClient6, IDebugControl4, IDebugDataSpaces4, IDebugEventContextCallbacks, IDebugOutputCallbacks, IDebugRegisters, IDebugSymbols3, IDebugSystemObjects, }; -use windows::Win32::System::Diagnostics::Debug::IMAGEHLP_MODULEW64; +use windows::Win32::System::Diagnostics::Debug::{EXCEPTION_RECORD64, IMAGEHLP_MODULEW64}; /// Callback type for breakpoint events that receives the breakpoint, context, and flags pub type BreakpointCallback = @@ -214,6 +216,23 @@ const KERNEL_ATTACH_WAIT_MS: u32 = 60_000; /// read a scope for at all. const SCOPE_CONTEXT_SIZES: &[u32] = &[716, 912, 1232, 2048, 4096, 8192, 16384, 32768, 65536]; +/// Buffer sizes offered to `GetStoredEventInformation` for a stored event's register context, +/// smallest first. +/// +/// **A separate ladder from [`SCOPE_CONTEXT_SIZES`], because the two calls fail differently and +/// borrowing the other one's rule produced a wrong answer that surfaced three calls later.** +/// `GetScope` *refuses* a buffer below the target's `CONTEXT` size, so climbing from the smallest +/// finds the exact size and the first rung that is accepted is the right one. This call does not +/// refuse: offered 716 bytes for an **x64** dump it writes 716, reports 716, and returns success +/// — a truncated context, which `GetContextStackTrace` then rejects with `E_INVALIDARG` from a +/// caller that did nothing wrong. Measured on the user-mode fail-fast dump in +/// `examples/stored_event_probe.rs`. +/// +/// So it starts *above* every real `CONTEXT` — x64's is 1,232 bytes, ARM64's 912, x86's 716 — +/// rather than below them, and grows only on the one signal this call gives that there was more to +/// write: the engine having filled the buffer exactly to the brim. +const STORED_CONTEXT_SIZES: &[u32] = &[4096, 8192, 16384, 32768, 65536]; + /// Ctrl+Breaks one engine from another thread. /// /// `SetInterrupt` is the one DbgEng call documented as safe from any thread — the rest of the @@ -1513,6 +1532,196 @@ pub struct BugCheck { pub parameters: [u64; 4], } +/// An exception the engine reported, as the fields of `EXCEPTION_RECORD64`. +/// +/// The kernel's counterpart to [`BugCheck`], and kept as sparse for the same reason: what a given +/// code's parameters *mean* is per-code lore — `0xc0000005`'s three say which access faulted where, +/// `0xe06d7363`'s four are the MSVC throw — and none of it is knowledge the engine has. This is the +/// record; deciding what it says is the caller's. +/// +/// **[`Self::parameters`] is already cut to `NumberParameters`.** The raw record carries fifteen +/// slots whatever it filled, and a caller reading past the count reads whatever the last exception +/// on that thread left there. Trimming here is the difference between a parameter and a leftover, +/// and it is exactly the count that tells the two shapes of a fail-fast apart: one parameter is the +/// CRT's `abort`, three is WIL's, whose second is the `HRESULT`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExceptionRecord { + /// The exception code — `0xc0000409` for a fail-fast, `0xe06d7363` for a C++ throw. + pub code: u32, + /// `EXCEPTION_NONCONTINUABLE` and friends; see [`Self::noncontinuable`]. + pub flags: u32, + /// The instruction that raised it. + pub address: u64, + /// `ExceptionInformation`, cut to the `NumberParameters` the record declares. + pub parameters: Vec, + /// The address of the record this one is nested inside, or `None` at the outermost. + /// + /// A pointer into the target rather than a record: chasing it needs a read, and a caller that + /// does not care should not pay for one. Nesting is not exotic — an exception raised while + /// another is being handled produces exactly this — and a triage that missed it would report + /// the outer code for an inner fault. + pub nested: Option, +} + +impl ExceptionRecord { + /// `EXCEPTION_NONCONTINUABLE`: resuming past this one raises a second exception rather than + /// continuing. True of every fail-fast, which is what makes them fatal. + pub fn noncontinuable(&self) -> bool { + self.flags & EXCEPTION_NONCONTINUABLE != 0 + } + + /// The record as the engine wrote it, with the two fields that need deciding decided. + /// + /// The parameter count is clamped to the array as well as taken from the record: fifteen is + /// how many slots there are, `NumberParameters` is how many the raiser filled, and a record + /// claiming more than it can hold is a corrupt record rather than an excuse to read off the + /// end of it. + fn from_raw(raw: &EXCEPTION_RECORD64) -> Self { + let filled = (raw.NumberParameters as usize).min(raw.ExceptionInformation.len()); + Self { + code: raw.ExceptionCode.0 as u32, + flags: raw.ExceptionFlags, + address: raw.ExceptionAddress, + parameters: raw.ExceptionInformation[..filled].to_vec(), + nested: (raw.ExceptionRecord != 0).then_some(raw.ExceptionRecord), + } + } +} + +/// Whether an engine refusal means "this target has no stored event" rather than a failure. +/// +/// A live target has none, and neither does a dump not written for a fault, so this is an ordinary +/// answer and not an error — but the engine has no way to say so except by refusing, and refusing +/// is also what it does when something is wrong. Pinned to the one code it uses for this, measured +/// by `examples/stored_event_probe.rs` against a live process and a kernel crash dump, so that a +/// genuine failure still reaches the caller as one. +fn no_stored_event(code: HRESULT) -> bool { + code == E_UNEXPECTED +} + +/// A buffer for the extra information `GetLastEventInformation` and `GetStoredEventInformation` +/// write beside an event. +/// +/// One buffer for every event kind rather than a union of the eight `DEBUG_LAST_EVENT_INFO_*` +/// shapes, because only one of them is ever read here and the rest exist only to be large enough +/// not to truncate. `DEBUG_LAST_EVENT_INFO_EXCEPTION` *is* the largest — an `EXCEPTION_RECORD64` +/// and a flag against the `u32`s and `u64`s the others hold — so sizing to it is sizing to all of +/// them, and the alignment is the record's. +#[repr(C, align(8))] +struct ExtraEventInfo([u8; size_of::()]); + +impl Default for ExtraEventInfo { + fn default() -> Self { + Self([0; size_of::()]) + } +} + +impl ExtraEventInfo { + const SIZE: u32 = size_of::() as u32; + + fn as_mut_ptr(&mut self) -> *mut std::ffi::c_void { + self.0.as_mut_ptr().cast() + } + + /// The exception record the engine wrote here, and whether it was first-chance. + /// + /// **Both guards are load-bearing.** The buffer is only an exception record when the event was + /// an exception — for any other kind these same bytes are an exit code or a module base, and + /// reading them as a record would invent one. And `used` is the engine's own count: a call + /// that wrote less than the struct's size did not write this struct, whatever the kind says. + fn exception(&self, kind: u32, used: u32) -> Option<(ExceptionRecord, bool)> { + if kind != DEBUG_EVENT_EXCEPTION || used < Self::SIZE { + return None; + } + // SAFETY: the engine reported writing `used` bytes here and `used >= SIZE`, which is this + // buffer's size; it is aligned for the struct, and the read is unaligned-safe regardless. + // `DEBUG_LAST_EVENT_INFO_EXCEPTION` is plain old data, so a copy out is a copy of bytes. + let info: DEBUG_LAST_EVENT_INFO_EXCEPTION = + unsafe { std::ptr::read_unaligned(self.0.as_ptr().cast()) }; + Some(( + ExceptionRecord::from_raw(&info.ExceptionRecord), + info.FirstChance != 0, + )) + } +} + +/// `EXCEPTION_NONCONTINUABLE`. +/// +/// Spelled out rather than imported: the `windows` crate has it, but under +/// `Win32_System_SystemServices`, and pulling a whole feature module in for one `= 1` is a worse +/// trade than a line. Not to be confused with that crate's `EXCEPTION_NONCONTINUABLE_EXCEPTION`, +/// which is `0xc0000025` — the status raised by trying to continue past one of these, not the flag +/// that says so. +const EXCEPTION_NONCONTINUABLE: u32 = 0x1; + +/// A target's `CONTEXT`, exactly as the engine handed it over. +/// +/// **Opaque on purpose.** `CONTEXT` is per-architecture, per-processor-feature and versioned by +/// its own `ContextFlags`; an x64 one is 1,232 bytes today and an `XSTATE`-extended one is not. +/// Nothing here parses it, so nothing here goes stale when it grows — the engine wrote the bytes +/// and the engine reads them back. +/// +/// It exists so that a stack can be walked from the context an *event* was recorded with rather +/// than from wherever the session's scope happens to point ([`DebugEngine::stack_frames_from`]). +/// That is what `.ecxr` does to a session, without doing it to the session: the caller's selected +/// frame and thread are untouched, so a triage that walks the crash stack is still a read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThreadContext { + bytes: Vec, +} + +impl ThreadContext { + /// The bytes, for the one caller that has to hand them back to the engine. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// How many bytes the engine reported, which is the `CONTEXT` size for this target — not the + /// size of the buffer it was offered. + pub fn len(&self) -> usize { + self.bytes.len() + } + + /// Whether the engine reported no context at all, which a stored event can legitimately do. + pub fn is_empty(&self) -> bool { + self.bytes.is_empty() + } +} + +/// The event a target is stopped on, as [`DebugEngine::last_event`] and +/// [`DebugEngine::stored_event`] report it. +/// +/// [`Self::kind`] is a raw `DEBUG_EVENT_*` value rather than an enum. Two reasons, and the second +/// is the one that decided it. The engine's list grows, and a closed enum would turn a new event +/// kind into a variant this crate has to ship before a caller can see it. And the distinction a +/// caller actually needs is already spelled: [`Self::exception`] is `Some` exactly when the event +/// carried a record, which is the question, and it is not answered by the kind — an initial break +/// arrives as `DEBUG_EVENT_EXCEPTION` (`0x2`) carrying `STATUS_BREAKPOINT`, not as +/// `DEBUG_EVENT_BREAKPOINT`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DebugEvent { + /// The raw `DEBUG_EVENT_*` value. + pub kind: u32, + /// The **engine** process id the event belongs to — the same index + /// [`DebugEngine::session_processes`] pairs, not a system pid. + pub process: u32, + /// The **engine** thread id the event belongs to. + pub thread: u32, + /// The exception, when the event carried one. `None` for every other kind of event. + pub exception: Option, + /// Whether [`Self::exception`] is first-chance — the debugger seeing it before the target's + /// own handlers do. Meaningless, and `false`, when there is no exception. + /// + /// A second-chance record is the interesting one on a crash: it means nothing in the target + /// handled it, which for a C++ exception is the whole finding. + pub first_chance: bool, + /// The register context the event was recorded with, where the engine kept one. + /// + /// Always `None` from [`DebugEngine::last_event`], which does not carry one; `Some` from + /// [`DebugEngine::stored_event`] on a dump written for a fault. + pub context: Option, +} + /// One frame of a stack walk, as [`DebugEngine::stack_frames`] reports it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StackFrame { @@ -4860,6 +5069,144 @@ impl DebugEngine { Ok(Some(BugCheck { code, parameters })) } + /// The event the engine last saw — what `.lastevent` prints, as values. + /// + /// The user-mode counterpart to [`Self::bug_check`], and the typed form of `.exr -1`: on a + /// target stopped by a fault, [`DebugEvent::exception`] is the record that stopped it. + /// + /// **This is the engine's last event, not the session's history.** It is whatever stopped the + /// engine most recently, so a caller who has stepped, gone, or hit a breakpoint since the fault + /// gets *that* event and not the fault. On a freshly opened dump it is the event the dump was + /// written for; [`Self::stored_event`] is the one that stays that way. + /// + /// Fails on an engine that has seen no event, which is every engine before its first wait. + /// That failure is "cannot say" rather than "nothing happened". + pub fn last_event(&self) -> Result { + let mut kind = 0u32; + let mut process = 0u32; + let mut thread = 0u32; + // Sized for the largest `DEBUG_LAST_EVENT_INFO_*` there is, which is the exception one by a + // wide margin, and `u64`-aligned because the engine writes an `EXCEPTION_RECORD64` into it. + // The engine reports how much it used, and nothing below reads past that. + let mut extra = ExtraEventInfo::default(); + let mut used = 0u32; + unsafe { + self.control.GetLastEventInformation( + &mut kind, + &mut process, + &mut thread, + Some(extra.as_mut_ptr()), + ExtraEventInfo::SIZE, + Some(&mut used), + // The description is text — "Break instruction exception - code 80000003 (first + // chance)" — and every fact in it is in the fields beside it. Asking for it would + // be a second buffer for a second rendering of what this already returns. + None, + None, + ) + } + .map_err(|source| DbgEngError::Context { + operation: "reading the engine's last event".into(), + source, + })?; + let exception = extra.exception(kind, used); + Ok(DebugEvent { + kind, + process, + thread, + first_chance: exception.as_ref().is_some_and(|(_, first)| *first), + exception: exception.map(|(record, _)| record), + // `GetLastEventInformation` carries no context. `.ecxr` works on a dump because the + // *stored* event has one, which is why the two calls are not one. + context: None, + }) + } + + /// The event a dump was written for, with the register context it was written with — what + /// `.ecxr` adopts, as values. + /// + /// **The difference from [`Self::last_event`] is that this one does not move.** It is the + /// event the target was *stored* on, so it still answers after a caller has stepped, gone, or + /// changed threads — which is exactly the state a triage runs in on any session somebody has + /// already been working in. [`DebugEvent::context`] is what makes that usable: hand it to + /// [`Self::stack_frames_from`] and the crash stack comes back without `.ecxr`'s side effect on + /// the session's selected scope. + /// + /// `Ok(None)` where the target has no stored event, which is every live target and every dump + /// not written for a fault. That is a fact about the target rather than a failure, and it is + /// read off the engine's own refusal rather than probed for — see [`no_stored_event`]. + pub fn stored_event(&self) -> Result, DbgEngError> { + let mut kind = 0u32; + let mut process = 0u32; + let mut thread = 0u32; + let mut refusal = None; + for (rung, &size) in STORED_CONTEXT_SIZES.iter().enumerate() { + let mut context = vec![0u8; size as usize]; + let mut context_used = 0u32; + let mut extra = ExtraEventInfo::default(); + let mut extra_used = 0u32; + match unsafe { + self.control.GetStoredEventInformation( + &mut kind, + &mut process, + &mut thread, + Some(context.as_mut_ptr().cast()), + size, + Some(&mut context_used), + Some(extra.as_mut_ptr()), + ExtraEventInfo::SIZE, + Some(&mut extra_used), + ) + } { + // **Filled to the brim means it may have had more to write**, which is the only + // signal this call gives that the buffer was too small — see + // [`STORED_CONTEXT_SIZES`]. Grow while there is a rung left; on the last one, take + // what there is rather than returning nothing. + Ok(()) + if context_used as usize >= context.len() + && rung + 1 < STORED_CONTEXT_SIZES.len() => + { + continue; + } + Ok(()) => { + // Clamped to the buffer as well as to the engine's own count, the same trust + // decision `stack_frames` makes about `filled`. + context.truncate((context_used as usize).min(context.len())); + let exception = extra.exception(kind, extra_used); + return Ok(Some(DebugEvent { + kind, + process, + thread, + first_chance: exception.as_ref().is_some_and(|(_, first)| *first), + exception: exception.map(|(record, _)| record), + context: (!context.is_empty()).then_some(ThreadContext { bytes: context }), + })); + } + // **Checked before the retry, not after it.** "There is no stored event" is an + // answer, and climbing the whole ladder to rediscover it four more times would + // turn one refusal into five engine calls. + Err(why) if no_stored_event(why.code()) => return Ok(None), + // Not what this engine does — it truncates rather than refusing, which is what the + // ladder above is shaped around — but a refusal for being too small is still worth + // one more rung rather than an error. + Err(why) if why.code() == E_INVALIDARG => refusal = Some(why), + Err(why) => { + refusal = Some(why); + break; + } + } + } + Err(DbgEngError::Context { + operation: "reading the event this target was stored on".into(), + // Unreachable with a non-empty ladder: the only arm that does not return records a + // refusal, and the one that continues cannot be the last rung. Spelled as a fallback + // rather than an `expect` because a panic here would be this crate breaking its own + // rule about library code — and as `E_FAIL` rather than `E_UNEXPECTED`, which is the + // code [`no_stored_event`] reads as "there is no stored event" and would be a lie. + source: refusal.unwrap_or_else(|| windows::core::Error::from(E_FAIL)), + }) + } + /// The current thread's stack, read through `IDebugControl` — what `k` renders, as data. /// /// Walked from the current context (`GetStackTrace` with zero offsets), so on a crash dump @@ -4894,8 +5241,73 @@ impl DebugEngine { // engine's own count, and trusting it past the allocation would be a trust decision this // does not need to make. raw.truncate((filled as usize).min(max_frames)); - Ok(raw - .iter() + Ok(self.resolve_frames(&raw)) + } + + /// The stack a recorded register context was in, rather than the one the session's **current + /// thread** is in — what `.ecxr` followed by `k` renders, without the `.ecxr`. + /// + /// Given [`DebugEvent::context`] from [`Self::stored_event`], this is the crash stack of a + /// dump, walked on any session whatever thread it has selected, and it leaves that selection + /// exactly where it found it — so a triage built on this is still a read of the target. + /// + /// **The difference from [`Self::stack_frames`] is the selected thread, and only that.** + /// Measured on the two-thread fail-fast dump in `examples/stored_event_probe.rs`: after `~1s` + /// the other walk returns the parked thread's six frames while this one still returns the + /// crash's twelve. What does *not* move it is `.frame`, `.cxr` or `.ecxr` — all three change + /// the symbol scope, and `GetStackTrace` walks from the thread's registers rather than from + /// the scope, so both walks agree across all three. That is worth knowing before reaching for + /// this one: on a freshly opened dump, whose current thread is the faulting thread, the two + /// answers are identical, and the reason to prefer this is that it stays identical. + /// + /// The context is passed through to `GetContextStackTrace` untouched — its size is the + /// engine's own [`ThreadContext::len`], not a size this code believes in — so a target whose + /// `CONTEXT` this crate has never seen walks the same as one it has. + /// + /// `max_frames` bounds the walk, and zero frames is a legitimate ask answered without touching + /// the engine, both exactly as [`Self::stack_frames`]. + pub fn stack_frames_from( + &self, + context: &ThreadContext, + max_frames: usize, + ) -> Result, DbgEngError> { + if max_frames == 0 { + return Ok(Vec::new()); + } + // An empty context would be read by the engine as "walk from the current one", which is + // the question `stack_frames` answers and the opposite of what this was called for. A + // caller holding one has a `stored_event` that reported no context; say so. + if context.is_empty() { + return Err(DbgEngError::Context { + operation: "walking the stack of a recorded context that has no registers".into(), + source: windows::core::Error::from(E_INVALIDARG), + }); + } + let mut raw = vec![DEBUG_STACK_FRAME::default(); max_frames]; + let mut filled = 0u32; + unsafe { + self.control.GetContextStackTrace( + Some(context.as_bytes().as_ptr().cast()), + context.len() as u32, + Some(&mut raw), + None, + 0, + 0, + Some(&mut filled), + ) + } + .map_err(|source| DbgEngError::Context { + operation: "walking the stack of a recorded register context".into(), + source, + })?; + raw.truncate((filled as usize).min(max_frames)); + Ok(self.resolve_frames(&raw)) + } + + /// The engine's raw frames with each one's symbol resolved — shared by the two stack walks, so + /// that a frame from either is the same record and joins the other. + fn resolve_frames(&self, raw: &[DEBUG_STACK_FRAME]) -> Vec { + raw.iter() .enumerate() .map(|(index, frame)| { let (symbol, displacement) = self.symbol_at(frame.InstructionOffset); @@ -4909,7 +5321,7 @@ impl DebugEngine { displacement, } }) - .collect()) + .collect() } /// Disassembles `count` instructions from `address` — what `u` renders, as data. @@ -10356,6 +10768,123 @@ mod tests { "run_to_address was not refused" ); } + + /// A record carries as many parameters as it says it filled, and no more. + /// + /// The 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 reading a + /// leftover as a parameter would not be a cosmetic error. It would answer the question. + #[test] + fn test_a_record_carries_only_the_parameters_it_filled() { + let mut raw = EXCEPTION_RECORD64 { + ExceptionCode: windows::Win32::Foundation::NTSTATUS(0xc000_0409_u32 as i32), + ExceptionFlags: EXCEPTION_NONCONTINUABLE, + ExceptionRecord: 0, + ExceptionAddress: 0x7ff6_3074_28f9, + NumberParameters: 3, + __unusedAlignment: 0, + ExceptionInformation: [0; 15], + }; + // WIL's shape, and a fourth slot holding whatever the last exception on this thread left. + raw.ExceptionInformation[0] = 0x7; + raw.ExceptionInformation[1] = 0xffff_ffff_8000_ffff; + raw.ExceptionInformation[2] = 0x28f; + raw.ExceptionInformation[3] = 0xdead_beef; + + let record = ExceptionRecord::from_raw(&raw); + assert_eq!(record.code, 0xc000_0409, "the code was not read as a u32"); + assert_eq!( + record.parameters, + vec![0x7, 0xffff_ffff_8000_ffff, 0x28f], + "the leftover in slot 3 was read as a parameter" + ); + assert!(record.noncontinuable(), "the flag was not decoded"); + assert_eq!( + record.nested, None, + "a zero nested pointer is no nested record, not a record at address zero" + ); + + // **A record claiming more than it can hold is corrupt, not an excuse to read past the + // array.** Fifteen is how many slots there are; sixteen is a fact about the dump. + raw.NumberParameters = 99; + assert_eq!( + ExceptionRecord::from_raw(&raw).parameters.len(), + 15, + "an over-large NumberParameters was not clamped to the array" + ); + + // A nested record is a pointer into the target, reported as one rather than chased. + raw.NumberParameters = 0; + raw.ExceptionRecord = 0x1234; + let nested = ExceptionRecord::from_raw(&raw); + assert_eq!(nested.nested, Some(0x1234)); + assert!( + nested.parameters.is_empty(), + "a zero-parameter record carried parameters" + ); + } + + /// The extra-information buffer is read as an exception only when it holds one. + /// + /// Both guards, because they fail in the same silent direction: for any other event kind these + /// same bytes are an exit code or a module base, and a short write is not this struct however + /// the kind reads. Either one missing invents a record out of whatever was in the buffer. + #[test] + fn test_extra_event_info_is_read_as_an_exception_only_when_it_holds_one() { + let mut extra = ExtraEventInfo::default(); + let info = DEBUG_LAST_EVENT_INFO_EXCEPTION { + ExceptionRecord: EXCEPTION_RECORD64 { + ExceptionCode: windows::Win32::Foundation::NTSTATUS(0x8000_0003_u32 as i32), + ExceptionFlags: 0, + ExceptionRecord: 0, + ExceptionAddress: 0x7ffc_9b83_d78d, + NumberParameters: 1, + __unusedAlignment: 0, + ExceptionInformation: [0; 15], + }, + FirstChance: 1, + }; + // SAFETY: writing the struct the engine would have written, into a buffer sized and + // aligned for exactly it. + unsafe { std::ptr::write(extra.as_mut_ptr().cast(), info) }; + + let (record, first_chance) = extra + .exception(DEBUG_EVENT_EXCEPTION, ExtraEventInfo::SIZE) + .expect("an exception event with a full write reported no exception"); + assert_eq!(record.code, 0x8000_0003); + assert_eq!(record.address, 0x7ffc_9b83_d78d); + assert_eq!(record.parameters, vec![0]); + assert!(first_chance, "the first-chance flag was dropped"); + + assert!( + extra + .exception(DEBUG_EVENT_BREAKPOINT, ExtraEventInfo::SIZE) + .is_none(), + "a breakpoint event's extra information was read as an exception record" + ); + assert!( + extra + .exception(DEBUG_EVENT_EXCEPTION, ExtraEventInfo::SIZE - 1) + .is_none(), + "a write shorter than the struct was read as the whole struct" + ); + } + + /// `E_UNEXPECTED` is "no stored event"; nothing else is. + /// + /// Pinned by `examples/stored_event_probe.rs` against a live process and a kernel crash dump, + /// and asserted here so that widening it to "any failure" -- which would turn every real error + /// into a silent `Ok(None)` -- fails a test rather than a user. + #[test] + fn test_only_e_unexpected_means_there_is_no_stored_event() { + assert!(no_stored_event(E_UNEXPECTED)); + for other in [E_INVALIDARG, E_NOINTERFACE, S_OK, S_FALSE] { + assert!( + !no_stored_event(other), + "{other:?} was taken for the absence of a stored event" + ); + } + } } #[windows::core::implement( From 201d5429e452ff3e51c9ca106d14ac1ffc8e6234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Carvalho?= Date: Sat, 5 Sep 2026 08:15:31 +0100 Subject: [PATCH 2/3] fix: no event is not an event kind, and a context belongs to one target 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 Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf --- CHANGELOG.md | 9 +++ examples/stored_event_probe.rs | 56 ++++++++++++- src/dbgeng.rs | 140 +++++++++++++++++++++++++++++++-- 3 files changed, 194 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dad03c3..e8f3104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ All notable changes to this project are documented here. The format follows kind, engine process and thread, and, when the event carried one, an `ExceptionRecord` with the code, flags, faulting address and parameters. That is `.exr -1` typed, and it is the user-mode counterpart to `bug_check`: on a target stopped by a fault it is the record that stopped it. + `Ok(None)` where the engine has seen no event, which is any engine before its first wait — + including a dump `open_dump` has *named* but nothing has pumped. That case is `None` rather than + an error because the engine does not fail it: it answers `S_OK` with kind `0` and `DEBUG_ANY_ID` + for both ids, and kind `0` is not a `DEBUG_EVENT_*` value. `ExceptionRecord::parameters` arrives already cut to the record's own `NumberParameters` (and clamped to the fifteen slots there are), because the count is the field that tells the two shapes of a `0xc0000409` apart — one parameter is the CRT's `abort`, three is WIL's, whose second is the @@ -31,6 +35,11 @@ All notable changes to this project are documented here. The format follows fail-fast dump, after `~1s` the other walk returns the parked thread's six frames while this one 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 `ThreadContext` carries the target it was read from and is refused + (`DbgEngError::ContextFromAnotherTarget`) by an engine that no longer holds it, exactly as + `set_scope` refuses a stale `Scope` — and for a sharper reason: a stale scope points the session + somewhere visibly wrong, while a stale context comes back as frames, which is an answer a caller + cannot tell from the right one. - `examples/stored_event_probe.rs`, the measurements behind the three. It also caught the one that would otherwise have shipped: `GetStoredEventInformation` does **not** refuse a context buffer that is too small the way `GetScope` does. It truncates — offered 716 bytes for an x64 dump it diff --git a/examples/stored_event_probe.rs b/examples/stored_event_probe.rs index 5453cc5..e1323fd 100644 --- a/examples/stored_event_probe.rs +++ b/examples/stored_event_probe.rs @@ -2,9 +2,16 @@ //! [`DebugEngine::last_event`], [`DebugEngine::stored_event`] and //! [`DebugEngine::stack_frames_from`]. //! -//! Five questions no unit test can answer, because each is a question about what a real +//! Six questions no unit test can answer, because each is a question about what a real //! `dbgeng.dll` does. Measured on dbgeng 10.0.29547.1002, x64, Windows 11 26200, 2026-09-05. //! +//! 0. **What does an engine with nothing to report say?** Not an error — `S_OK`, kind `0`, and +//! `DEBUG_ANY_ID` (`0xffffffff`) for both ids. True of an engine holding no target at all +//! (`empty`) *and* of a dump `open_dump` has named but nothing has pumped (`unwaited`), since +//! the engine reads the dump's event on the first wait rather than at the open. Kind `0` is not +//! a `DEBUG_EVENT_*` value, so `last_event` reports `None` for it rather than dressing it up as +//! an event; without that, "the target is not loaded yet" reads as an unrecognised event kind. +//! //! 1. **How does the engine say "this target has no stored event"?** It refuses, and the refusal //! has to be told apart from a failure. Measured: `E_UNEXPECTED` (`0x8000ffff`), on a live //! process and on a kernel crash dump alike — which is what `no_stored_event` is pinned to. @@ -33,7 +40,9 @@ //! //! ```text //! cargo run --example stored_event_probe -- live +//! cargo run --example stored_event_probe -- empty //! cargo run --example stored_event_probe -- dump +//! cargo run --example stored_event_probe -- unwaited //! ``` //! //! **The dump arm wants a user-mode fault dump, and a two-thread one to answer question 5.** The @@ -72,19 +81,59 @@ fn main() { let arm = args.next().unwrap_or_else(|| "live".into()); match arm.as_str() { "live" => live(), + "empty" => empty(), "dump" => match args.next() { Some(path) => dump(&path), None => { println!("dump needs a path: cargo run --example stored_event_probe -- dump ") } }, + "unwaited" => match args.next() { + Some(path) => unwaited(&path), + None => println!( + "unwaited needs a path: cargo run --example stored_event_probe -- unwaited " + ), + }, other => { println!("unknown arm {other}"); - println!("arms: live, dump "); + println!("arms: live, empty, dump , unwaited "); } } } +/// An engine holding nothing at all — the state every engine is in before its first open. +fn empty() { + println!("======== empty: an engine with no target ========"); + let engine = DebugEngine::new(); + print_reads(&engine); +} + +/// A dump named but never pumped, which is the state `open_dump` alone leaves the engine in. +fn unwaited(path: &str) { + println!("======== unwaited: {path} ========"); + let engine = DebugEngine::new(); + if let Err(e) = engine.open_dump(path) { + println!("could not open {path}: {e}"); + return; + } + print_reads(&engine); +} + +/// Just the two event reads, for the arms that are asking what an engine says before it has +/// anything to say. +fn print_reads(engine: &DebugEngine) { + match engine.last_event() { + Ok(Some(event)) => print_event("last_event", &event), + Ok(None) => println!(" last_event: none — this engine has seen no event"), + Err(e) => println!(" last_event: {e}"), + } + match engine.stored_event() { + Ok(Some(event)) => print_event("stored_event", &event), + Ok(None) => println!(" stored_event: none"), + Err(e) => println!(" stored_event: {e}"), + } +} + /// A launched process, stopped at its loader break. fn live() { println!("======== live: {TARGET} ========"); @@ -129,7 +178,8 @@ fn dump(path: &str) { /// Both event reads, and the two stack walks, on whatever target the caller opened. fn report(engine: &DebugEngine) { match engine.last_event() { - Ok(event) => print_event("last_event", &event), + Ok(Some(event)) => print_event("last_event", &event), + Ok(None) => println!(" last_event: none — this engine has seen no event"), Err(e) => println!(" last_event: {e}"), } let stored = match engine.stored_event() { diff --git a/src/dbgeng.rs b/src/dbgeng.rs index f4e82ed..f37cc80 100644 --- a/src/dbgeng.rs +++ b/src/dbgeng.rs @@ -140,6 +140,16 @@ pub enum DbgEngError { #[error("this scope was read from a target the engine no longer holds")] ScopeFromAnotherTarget, + + /// The sibling of [`Self::ScopeFromAnotherTarget`], for the same hazard on the other saved + /// register state this crate hands out. + /// + /// A [`crate::dbgeng::ThreadContext`] means something only on the target it was read from, and + /// walking a stack from one belonging to a *previous* target is the worse of the two: a stale + /// scope points the session somewhere visibly wrong, while a stale context comes back as + /// frames. The engine unwinds whatever those addresses reach now and answers. + #[error("this register context was read from a target the engine no longer holds")] + ContextFromAnotherTarget, } /// Fallback length of `_EPROCESS::ImageFileName` when the field's own size cannot be read. @@ -233,6 +243,15 @@ const SCOPE_CONTEXT_SIZES: &[u32] = &[716, 912, 1232, 2048, 4096, 8192, 16384, 3 /// write: the engine having filled the buffer exactly to the brim. const STORED_CONTEXT_SIZES: &[u32] = &[4096, 8192, 16384, 32768, 65536]; +/// The event kind an engine reports when it has no event to report. +/// +/// Not a `DEBUG_EVENT_*` constant, because it is not one: the engine's own list starts at +/// `DEBUG_EVENT_BREAKPOINT` (`0x1`) and every value in it is a single bit. Zero is what +/// `GetLastEventInformation` answers — with `S_OK`, and `DEBUG_ANY_ID` for both ids — on an engine +/// that has seen nothing, which is any engine before its first wait and a dump `open_dump` has +/// named but nothing has pumped. +const NO_EVENT_KIND: u32 = 0; + /// Ctrl+Breaks one engine from another thread. /// /// `SetInterrupt` is the one DbgEng call documented as safe from any thread — the rest of the @@ -1665,9 +1684,18 @@ const EXCEPTION_NONCONTINUABLE: u32 = 0x1; /// than from wherever the session's scope happens to point ([`DebugEngine::stack_frames_from`]). /// That is what `.ecxr` does to a session, without doing it to the session: the caller's selected /// frame and thread are untouched, so a triage that walks the crash stack is still a read. +/// +/// **It carries the target it was read from, exactly as [`Scope`] does**, and for a sharper +/// version of the same reason. These registers and the stack addresses in them mean something only +/// on that target, so handing one to an engine that has since released it and opened another walks +/// whatever those addresses reach now. Unlike a stale [`Scope`] — which points the session +/// somewhere visibly wrong — a stale context comes back as *frames*, which is an answer a caller +/// has no way to tell from the right one. [`DebugEngine::stack_frames_from`] refuses it instead. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThreadContext { bytes: Vec, + /// The [`DebugEngine::target_identity`] this was read from; see the type's own note. + target: u64, } impl ThreadContext { @@ -1676,6 +1704,14 @@ impl ThreadContext { &self.bytes } + /// The [`DebugEngine::target_identity`] of the target this was read from. + /// + /// Exposed because a caller holding one across a session boundary is the party that can say + /// whether that was deliberate, and comparing is cheaper than being refused. + pub fn target(&self) -> u64 { + self.target + } + /// How many bytes the engine reported, which is the `CONTEXT` size for this target — not the /// size of the buffer it was offered. pub fn len(&self) -> usize { @@ -3590,8 +3626,14 @@ impl DebugEngine { /// [`Self::session_processes`], which answers both — so callers join it to that pairing rather /// than to a pid. /// - /// Fails on an engine that has seen no event, which is every engine before its first wait, and - /// that failure is an answer of "cannot say" rather than "no". + /// **An engine that has seen no event does not fail this — it answers `DEBUG_ANY_ID`.** + /// Measured on both an engine holding nothing and a dump `open_dump` has named but nothing has + /// pumped (`examples/stored_event_probe.rs`, `empty` and `unwaited`): `S_OK`, kind `0`, and + /// `0xffffffff` for both ids. This used to say the call fails there, which is where + /// [`Self::last_event`] inherited the same wrong claim from (dbgscope#144). It costs the 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 read it as an engine id would be + /// joining on a sentinel. /// /// **The event *kind* is read and dropped, and that is not an oversight.** Review round 14 /// asked for the stop reason to be preserved and validated, so that an open completes on its @@ -5079,9 +5121,18 @@ impl DebugEngine { /// gets *that* event and not the fault. On a freshly opened dump it is the event the dump was /// written for; [`Self::stored_event`] is the one that stays that way. /// - /// Fails on an engine that has seen no event, which is every engine before its first wait. - /// That failure is "cannot say" rather than "nothing happened". - pub fn last_event(&self) -> Result { + /// `Ok(None)` where the engine has seen no event, which is every engine before its first wait + /// — including a dump that `open_dump` has *named* but nothing has pumped, since the engine + /// reads one on the first wait and not at the open. + /// + /// **That case is `None` rather than an error because the engine does not fail it.** It + /// answers `S_OK` with kind `0` and `DEBUG_ANY_ID` for both ids, and kind `0` is not a + /// `DEBUG_EVENT_*` value — there is no event, and the engine is saying so. Returning that + /// event as if it were one would hand a caller a `DebugEvent` whose `kind` means "nothing + /// happened yet" and let "the target is not loaded" read as an event kind this build does not + /// recognise (dbgscope#144). Measured by `examples/stored_event_probe.rs`'s `empty` and + /// `unwaited` arms. + pub fn last_event(&self) -> Result, DbgEngError> { let mut kind = 0u32; let mut process = 0u32; let mut thread = 0u32; @@ -5109,8 +5160,13 @@ impl DebugEngine { operation: "reading the engine's last event".into(), source, })?; + // See the doc comment: an engine with nothing to report succeeds and says so with a kind + // that is not an event kind, rather than failing. + if kind == NO_EVENT_KIND { + return Ok(None); + } let exception = extra.exception(kind, used); - Ok(DebugEvent { + Ok(Some(DebugEvent { kind, process, thread, @@ -5119,7 +5175,7 @@ impl DebugEngine { // `GetLastEventInformation` carries no context. `.ecxr` works on a dump because the // *stored* event has one, which is why the two calls are not one. context: None, - }) + })) } /// The event a dump was written for, with the register context it was written with — what @@ -5179,7 +5235,10 @@ impl DebugEngine { thread, first_chance: exception.as_ref().is_some_and(|(_, first)| *first), exception: exception.map(|(record, _)| record), - context: (!context.is_empty()).then_some(ThreadContext { bytes: context }), + context: (!context.is_empty()).then(|| ThreadContext { + bytes: context, + target: self.target_identity(), + }), })); } // **Checked before the retry, not after it.** "There is no stored event" is an @@ -5274,6 +5333,13 @@ impl DebugEngine { if max_frames == 0 { return Ok(Vec::new()); } + // **Refused before the engine sees it, exactly as `set_scope` refuses a stale scope.** + // 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() { + return Err(DbgEngError::ContextFromAnotherTarget); + } // An empty context would be read by the engine as "walk from the current one", which is // the question `stack_frames` answers and the opposite of what this was called for. A // caller holding one has a `stored_event` that reported no context; say so. @@ -10870,6 +10936,64 @@ mod tests { ); } + /// An engine that has seen no event reports no event, rather than an event kind of nothing. + /// + /// The engine does **not** fail this call before its first wait: it answers `S_OK` with kind + /// `0` and `DEBUG_ANY_ID` for both ids. Without the check that turns that into `None`, a + /// caller is handed a `DebugEvent` whose `kind` is not a `DEBUG_EVENT_*` value at all, and + /// "the target is not loaded yet" reads as an event kind this build does not recognise + /// (dbgscope#144). An engine holding nothing is the cheapest way to reach that state; the + /// other is a dump named but not pumped, which needs a file and is in the probe. + #[test] + fn test_an_engine_with_no_event_reports_none_rather_than_a_kind_of_zero() { + let e = DebugEngine::new(); + assert!( + matches!(e.last_event(), Ok(None)), + "an engine that has seen no event did not report none — which means either it failed \ + the call, or kind 0 was dressed up as an event" + ); + } + + /// A context is refused by an engine that no longer holds the target it was read from. + /// + /// The sibling of the `set_scope` rule, and the more important of the two: a stale scope points + /// the session somewhere visibly wrong, while a stale context comes back as *frames* — an + /// answer indistinguishable from the right one. Asserted against a real engine because + /// `target_identity` is engine state, and with **no target at all**, since the check has to + /// come before the engine sees the bytes rather than after it declines them. + #[test] + fn test_a_context_from_another_target_is_refused() { + let e = DebugEngine::new(); + // Read from an engine whose identity is whatever it is now, and then invalidated the only + // way this crate can: by construction, since `target_identity` is per-engine. + let stale = ThreadContext { + bytes: vec![0u8; 1232], + target: e.target_identity().wrapping_add(1), + }; + assert!( + matches!( + e.stack_frames_from(&stale, 8), + Err(DbgEngError::ContextFromAnotherTarget) + ), + "a context from another target was walked rather than refused" + ); + + // **And the refusal is about identity, not about there being no target.** A context whose + // identity matches gets past this check and fails on the engine instead, which is what + // shows the check is not passing everything through. + let current = ThreadContext { + bytes: vec![0u8; 1232], + target: e.target_identity(), + }; + assert!( + !matches!( + e.stack_frames_from(¤t, 8), + Err(DbgEngError::ContextFromAnotherTarget) + ), + "a context from this very engine was refused as another target's" + ); + } + /// `E_UNEXPECTED` is "no stored event"; nothing else is. /// /// Pinned by `examples/stored_event_probe.rs` against a live process and a kernel crash dump, From 9d68bd55ea34f111adf57889076ce3b526d91fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Carvalho?= Date: Sat, 5 Sep 2026 08:46:48 +0100 Subject: [PATCH 3/3] fix: a replaced session invalidates the contexts read from the old one 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 Claude-Session: https://claude.ai/code/session_01Ayv1beKpAVkmDJDLfYqoyf --- CHANGELOG.md | 10 +++++ src/dbgeng.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f3104..a6f6b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,16 @@ All notable changes to this project are documented here. The format follows `set_scope` refuses a stale `Scope` — and for a sharper reason: a stale scope points the session somewhere visibly wrong, while a stale context comes back as frames, which is an answer a caller cannot tell from the right one. + +### Fixed + +- **An opener that replaces the session now reissues the target identity.** Only `end_session` did, + 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 three openers that replace a session (`open_dump`, which `open_trace` delegates + to, and both kernel attaches) already funnel through `forget_the_previous_session`, so the + reissue lives there rather than in each of them: "the previous session is gone" now means all of + what it says, and the next opener gets it without remembering to. - `examples/stored_event_probe.rs`, the measurements behind the three. It also caught the one that would otherwise have shipped: `GetStoredEventInformation` does **not** refuse a context buffer that is too small the way `GetScope` does. It truncates — offered 716 bytes for an x64 dump it diff --git a/src/dbgeng.rs b/src/dbgeng.rs index f37cc80..d9eac77 100644 --- a/src/dbgeng.rs +++ b/src/dbgeng.rs @@ -6155,7 +6155,19 @@ impl DebugEngine { /// is a guard held *across* a session replacement, whose `(engine id, pid)` predicate would /// otherwise be evaluated against a session it never asked about. Forgetting the entry leaves /// [`Arrivals::presence`] answering [`Presence::Absent`] for it, which is the truth. + /// + /// **And it reissues the target identity, which is why that lives here rather than in the + /// openers.** [`Scope`] and [`ThreadContext`] both carry the identity they were read from and + /// are refused by an engine that no longer holds it — a check worth nothing if the identity + /// survives a replacement. It did: only [`Self::end_session`] reissued, so a caller who opened + /// dump A, saved a context, and opened dump B *through this engine* got the same identity back + /// and the stale registers were accepted (dbgscope#144). Doing it in the three openers would + /// have been a fourth thing for the next opener to remember; doing it here makes "the previous + /// session is gone" mean all of what it says, once. fn forget_the_previous_session(&self) { + // Ordered before the clears only so that a reader meets the invalidation first; nothing + // observes this function part-way through, since every caller holds `&self`. + reissue_identity(&self.client); self.state .attached_processes .lock() @@ -10945,7 +10957,14 @@ mod tests { /// (dbgscope#144). An engine holding nothing is the cheapest way to reach that state; the /// other is a dump named but not pumped, which needs a file and is in the probe. #[test] + #[cfg(not(miri))] fn test_an_engine_with_no_event_reports_none_rather_than_a_kind_of_zero() { + // **Even a test that opens no target needs this.** `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 -- which surfaces over there as a pump failing `E_UNEXPECTED`, + // with nothing pointing back here. Found exactly that way (dbgscope#144, round two): the + // suite was green single-threaded and red under the default harness. + let _debuggee = one_debuggee(); let e = DebugEngine::new(); assert!( matches!(e.last_event(), Ok(None)), @@ -10954,6 +10973,85 @@ mod tests { ); } + /// **An opener that replaces the session invalidates the contexts read from the old one.** + /// + /// The check that `stack_frames_from` and `set_scope` make is worth exactly nothing if the + /// identity survives a replacement, and it did: only `end_session` reissued, so a caller who + /// opened dump A, saved a context and opened dump B *through this engine* got the same + /// identity back and the stale registers were accepted (dbgscope#144, round two). + /// + /// **The dump is written by the engine rather than checked in**, which is what makes this a + /// test rather than a probe: `.dump /m` on a launched target gives a real dump to reopen, so + /// nothing here depends on a fixture or on this host having one. + #[test] + #[cfg(not(miri))] + fn test_an_opener_that_replaces_the_session_invalidates_the_old_contexts() { + let _guard = one_debuggee(); + let dump = std::env::temp_dir().join(format!( + "dbgscope-replace-{}-{}.dmp", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + let _ = std::fs::remove_file(&dump); + + // A target to dump. Any stopped process will do; this one exits on its own if the test + // dies holding it. + let e = DebugEngine::new(); + if e.launch_process("cmd.exe /c ping -n 30 127.0.0.1").is_err() { + // No engine on this host is a reason to skip, not to fail -- same standing rule as + // the other tests that need a real `dbgeng.dll`. + println!("SKIPPED: could not launch a target to dump"); + return; + } + let written = e.execute_command(&format!(".dump /m \"{}\"", dump.display())); + let _ = e.end_session(); + drop(e); + if written.is_err() || !dump.exists() { + println!("SKIPPED: this engine would not write a dump ({written:?})"); + return; + } + + let e = DebugEngine::new(); + let path = dump.display().to_string(); + assert!( + e.open_dump(&path).is_ok(), + "could not open the dump written" + ); + let _ = e.wait_for_event(60_000); + let first = e.target_identity(); + let stale = ThreadContext { + bytes: vec![0u8; 1232], + target: first, + }; + + // The same engine, opening a target again. This is the replacement path: no + // `end_session` between them, which is exactly the case that was accepted before. + assert!( + e.open_dump(&path).is_ok(), + "could not reopen the dump through the same engine" + ); + let _ = e.wait_for_event(60_000); + assert_ne!( + e.target_identity(), + first, + "an opener replaced the session without reissuing the identity, so every context and \ + scope saved from the previous target is still accepted against this one" + ); + assert!( + matches!( + e.stack_frames_from(&stale, 8), + Err(DbgEngError::ContextFromAnotherTarget) + ), + "a context from the replaced target was walked against the new one" + ); + + let _ = e.end_session(); + drop(e); + let _ = std::fs::remove_file(&dump); + } + /// A context is refused by an engine that no longer holds the target it was read from. /// /// The sibling of the `set_scope` rule, and the more important of the two: a stale scope points @@ -10962,7 +11060,14 @@ mod tests { /// `target_identity` is engine state, and with **no target at all**, since the check has to /// come before the engine sees the bytes rather than after it declines them. #[test] + #[cfg(not(miri))] fn test_a_context_from_another_target_is_refused() { + // **Even a test that opens no target needs this.** `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 -- which surfaces over there as a pump failing `E_UNEXPECTED`, + // with nothing pointing back here. Found exactly that way (dbgscope#144, round two): the + // suite was green single-threaded and red under the default harness. + let _debuggee = one_debuggee(); let e = DebugEngine::new(); // Read from an engine whose identity is whatever it is now, and then invalidated the only // way this crate can: by construction, since `target_identity` is per-engine.