Skip to content

A target that ends is an ending, and an engine with none refuses text - #120

Merged
glslang merged 3 commits into
mainfrom
fix/a-target-that-ends-is-not-a-failure
Aug 27, 2026
Merged

A target that ends is an ending, and an engine with none refuses text#120
glslang merged 3 commits into
mainfrom
fix/a-target-that-ends-is-not-a-failure

Conversation

@glslang

@glslang glslang commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Closes the dbgscope half of windbg-mcp#242, and with it windbg-mcp's FOLLOWUPS.md item 48, which had been holding the same question open since #226: is an exited target an error at all? It is not.

Everything below was measured on dbgeng 10.0.26100.1 (ARM64) before anything was written; two of the reporter's premises did not survive that, and both changed the fix.

A debuggee that ends during a wait

WaitForEvent answers E_UNEXPECTED once the target is gone. execute_and_wait, settle and run_to_address all propagated it verbatim — Debug command failed: Catastrophic failure (0x8000FFFF) for a program exiting normally — and the Err discarded the buffer the run had captured.

That buffer is the point. The command itself prints only its own echo; the module loads, the breakpoint banner and anything an embedded bp X "…; g" script printed all arrive during the wait, and on the run that ends the target there is no successor to print them again. Measured, cmd.exe /c exit under a plain g:

Execute        -> Ok(())
WaitForEvent   -> Err(HRESULT(0x8000FFFF), "Catastrophic failure")
status         -> 0x7
captured       -> "g\nModLoad: …kernel.appcore.dll\nModLoad: …msvcrt.dll\n"   <- discarded

Each of the three now reports the ending as an outcome carrying its output: CommandRun::target_gone, and RunToOutcome::TargetGone.

The ending is read off GetExecutionStatus, not off the wait's HRESULT — which names nothing and is also what a genuinely broken engine answers. 0x7 is DEBUG_STATUS_NO_DEBUGGEE (the issue reports it as GO_NOT_HANDLED, which is 3; the status is reliable, not stale). Everything else the engine offers is unusable by then — GetNumberProcesses, GetCurrentProcessSystemId and GetExitCode all fail E_UNEXPECTED, and .lastevent answers <no event> — so the status is the only one of them that says anything. Every caller refuses to start without a debuggee, so a missing one afterwards means the target left during that call. An unreadable status is read as "still there": this decides whether to suppress the wait's error, and suppressing one on a guess would report a broken engine as a program that finished.

And text reaching an engine with no debuggee faults the process

A raw g through execute_command_bounded exits with STATUS_ACCESS_VIOLATION — a structured exception, so no catch_unwind traps it and the whole host goes down. execute_and_wait and run_to_address had a guard; the two raw-command paths did not, which is the door windbg-mcp's execute tool opens.

It reproduces on a fresh engine as well as on one whose debuggee just left, which is what says the trigger is the missing debuggee rather than the departure. So the guard is keyed on that, shared (refuse_without_a_debuggee), and covers all four ways in. It cannot be narrowed to text that looks like execution control — an alias, a .if branch and dx …ExecuteCommand("g") all reach execution without saying so, the same reason settle asks the engine instead of reading the command. The breadth costs the few engine-level commands that do work without a target (version, .echo, .sympath), which are refused too.

One of this crate's own openers was in that group, which the tests caught and reading the code did not: enable_initial_break runs sxe ibp before the target exists, so the guard refused every launch_process and attach_process on the machine. It now goes through execute_fixed_command — the unguarded path for this crate's own literals, and for nothing a caller supplied.

Tests

Three, none of them #[ignore]d, so CI runs them on windows-latest and windows-11-arm:

  • a_target_that_exits_during_a_go_is_an_ending_rather_than_a_catastrophe — plus the chain that made the session read as wedged: k answering a bare 0x80040205 while .echo still worked. All four now answer that there is no debuggee, and end_session still works.
  • a_target_that_exits_during_the_settle_pump_reports_the_ending_with_its_output — the corner #226's fix left open.
  • execution_control_with_no_debuggee_is_refused_rather_than_faulting_the_process — the access violation. It asserts by returning at all: a structured exception is not a panic, so there is no #[should_panic] shape for it, and under nextest a regression takes that test's own process down and nothing else.

cargo test --lib on the ARM64 bench: 136 passed, 0 failed.

🤖 Generated with Claude Code

… text

Two defects behind glslang/windbg-mcp#242, measured on dbgeng 10.0.26100.1
(ARM64) before anything was written.

**A debuggee that runs to completion during a wait was reported as a
catastrophe, and its output was thrown away.** `WaitForEvent` answers
`E_UNEXPECTED` once the target is gone, and `execute_and_wait`, `settle` and
`run_to_address` all propagated that verbatim: `Debug command failed:
Catastrophic failure (0x8000FFFF)` for a program exiting normally, with the
`Err` discarding the buffer the run had captured. That buffer is not
incidental — the command itself prints only its own echo, and the module
loads, the breakpoint banner and anything an embedded script printed all
arrive during the wait. On the run that ends the target there is no successor
to print them again. Each of the three now reports the ending as an outcome
that carries its output: `CommandRun::target_gone`, and
`RunToOutcome::TargetGone`.

The ending is read off `GetExecutionStatus`, not off the wait's HRESULT, which
names nothing and is also what a genuinely broken engine answers. Measured:
after the exit the status is `DEBUG_STATUS_NO_DEBUGGEE` while
`GetNumberProcesses`, `GetCurrentProcessSystemId` and `GetExitCode` all fail
and `.lastevent` says `<no event>` — the status is the only one of them that
says anything. Every caller refuses to *start* without a debuggee, so a
missing one afterwards means the target left during that call. An unreadable
status is read as "still there": this decides whether to suppress the wait's
error, and suppressing one on a guess would report a broken engine as a
program that finished.

**And text reaching an engine with no debuggee faults the process.** A raw `g`
through `execute_command_bounded` exits with `STATUS_ACCESS_VIOLATION` — a
structured exception, so no `catch_unwind` traps it and the whole host goes
down. `execute_and_wait` and `run_to_address` had a guard; the two raw-command
paths did not, which is the door windbg-mcp's `execute` tool opens. Measured
on a **fresh** engine too, which is what says the trigger is the missing
debuggee rather than the departure — so the guard is keyed on that, is shared
(`refuse_without_a_debuggee`), and covers all four ways in. It cannot be
narrowed to text that looks like execution control: an alias, a `.if` branch
and `dx …ExecuteCommand("g")` all reach execution without saying so, which is
the same reason `settle` asks the engine rather than reading the command. The
breadth costs the handful of engine-level commands that do work without a
target (`version`, `.echo`, `.sympath`), which are refused too.

One of this crate's own openers was in that group, which the tests caught and
reading the code did not: `enable_initial_break` runs `sxe ibp` *before* the
target exists, so the guard refused every `launch_process` and
`attach_process`. It now goes through `execute_fixed_command`, which is the
unguarded path for this crate's own literals and for nothing a caller
supplied.

Three tests, none of them ignored, so CI runs them on both architectures. The
access-violation one asserts by *returning at all*: a structured exception is
not a panic, so there is no `#[should_panic]` shape for it, and under nextest
a regression takes that test's own process down and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 26, 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: Pro Plus

Run ID: 081f45a7-5961-4ee8-b4a6-acc087634ebb


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.

…by hand

`examples/session_fuzz.rs` drives a live session with randomised sequences and
checks, after every step, the one property a session has to keep: it either
still holds a target and answers, or it says it holds none — and the process is
still running.

Written because the three defects behind glslang/windbg-mcp#242 were each found
one sequence at a time, and none of them is about a *command*. They are about
the state the previous command left the engine in, and the ways to reach a
given state outnumber what anyone enumerates by hand: the fuzzer reached the
half-dead session through `.if (1) { g }` on its own, which is precisely the
route a list of command names cannot cover.

**It would have found them.** At `--seed 1` against the parent commit it
reports four violations in eight rounds; the same seed is clean here, and so
are 150 rounds of 14 steps (seed 7777) on the ARM64 bench. Its oracle reads the
raw `GetExecutionStatus` value rather than calling `has_target`, so it does not
share its subject's notion of the state — and so that it runs unchanged against
a build from before the fix, which is the only way to know it finds anything.

Two things it needs to stay useful. Every step is printed *before* it runs and
the stream is flushed: the failure it exists to catch is the process dying, and
there is no unwind, no panic and no summary then — the last line is the whole
report. And the seed is printed twice, because a sequence nobody can replay is
a bug report nobody can act on.

Beside it, `has_target` is now public: a caller holding a session needs the
question for the same reason this crate does, and the alternative is each of
them comparing against a raw status constant.

And a regression test for the asymmetry the fuzzing turned up, now that
`execute_command_bounded` answers the same question: `.detach` takes the target
away as it returns, while `.kill` leaves one that still reads a stack and goes
away on the *next* resume. A list of command names would have to get that
right, per engine version; asking the engine does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@glslang

glslang commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

Follow-up on the branch, after a question about whether p/t and the commands that end a target outright have the same problem. They do, and one of them is not where you would look.

A command can take the target away itself

.detach returns with the engine already holding nothing — so settle finds nothing to pump and, keyed only on the pumping paths, the ending would have been reported by nobody. execute_command_bounded now answers the same question after its command, so there is one field whichever way the target went.

.kill is the counter-example that makes the case for asking the engine rather than listing command names: measured, it leaves the target still there and readable (DEBUG_STATUS_BREAK, a stack in ntdll!LdrShutdownProcess) and it goes away on the next resume. A name list would have to carry .detach and q and leave .kill off, per engine version. a_command_that_takes_the_target_away_says_so_and_kill_is_not_one_of_them pins both halves.

p/t need nothing extra: they are the same string handed to the same execute_and_wait, and the classification happens after the wait without reading the command.

And a fuzzer, because every one of these was found by hand

examples/session_fuzz.rs drives a live session with randomised sequences and checks, after every step, that the engine either still holds a target and answers or says it holds none.

It reached the half-dead session through .if (1) { g } on its own — the exact route a list of command names cannot cover.

It would have found the bugs. At --seed 1 against main it reports four violations in eight rounds:

!! VIOLATION on round 8: the target is gone and `k 3` answered
   `Debug command failed: An unexpected exception was raised (0x80040205)` instead of saying so
!! sequence: [Raw("qd")]

The same seed is clean on this branch, as are 150 rounds of 14 steps (seed 7777) on the ARM64 bench.

Its oracle reads the raw GetExecutionStatus value rather than calling has_target, deliberately: a fuzzer sharing its subject's notion of the state cannot report a wrong one, and it is also what lets the binary run unchanged against a pre-fix build — the only way to know it finds anything.

has_target is public alongside, since a caller holding a session needs that question for the same reason this crate does.

@glslang

glslang commented Aug 26, 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: ac4b943f8a

ℹ️ 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
/// answering a bare `0x80040205` while `.echo` still worked.
#[test]
#[cfg(not(miri))]
fn a_target_that_exits_during_a_go_is_an_ending_rather_than_a_catastrophe() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prefix the new unit-test names with test_

Rename this test and the three other newly added unit tests at lines 5005, 5046, and 5116 so their names begin with test_; the repository explicitly requires that naming convention for module unit tests, and these additions currently violate it.

AGENTS.md reference: AGENTS.md:L27-L29

Useful? React with 👍 / 👎.

Codex's finding on #120, and the premise checked before acting on it: the
convention holds 111 tests to 7 outside `src/dbgeng.rs`, so it is real and
widely followed rather than a stale line in `AGENTS.md`. `dbgeng.rs` is where
it has drifted — 14 prefixed against 21 not — and these four had joined the
drift rather than the convention.

Renamed rather than arguing the doc, and the 21 left alone: fixing the drift is
a cleanup nobody asked for in a bug-fix PR, and it would bury the change under
a rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@glslang

glslang commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

Renamed, in 6123236 — with the premise checked first, because "the repository explicitly requires" is the kind of claim worth measuring rather than accepting.

It holds. Outside src/dbgeng.rs the convention is followed 111 tests to 7. Inside it, it has drifted: 14 prefixed against 21 not, the newer ones — the scope, settle and watchdog tests — being the ones that dropped it. So these four had joined the drift, not the convention, and the finding is right.

The other 21 are deliberately left alone. Fixing the drift is a cleanup nobody asked for in a bug-fix PR, and it would bury the change under a rename.

137 tests pass on the ARM64 bench after the rename.

glslang added a commit to glslang/windbg-mcp that referenced this pull request Aug 26, 2026
No behaviour change — glslang/dbgscope#120's test rename. The pin still points
at that branch and needs repointing to the merge commit before this lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@glslang
glslang merged commit c4043d0 into main Aug 27, 2026
8 checks passed
@glslang
glslang deleted the fix/a-target-that-ends-is-not-a-failure branch August 27, 2026 06:49
glslang added a commit to glslang/windbg-mcp that referenced this pull request Aug 27, 2026
Closes [#242](#242) and, with it,
`FOLLOWUPS.md` item 48 — which had held the question open since #226: is an
exited target an error at all? It is not.

The dbgscope half is glslang/dbgscope#120: `execute_and_wait`, `settle` and
`run_to_address` now report a target that went away as an outcome carrying the
output the run captured, rather than propagating DbgEng's `E_UNEXPECTED`
("Catastrophic failure") and discarding the buffer with it; and every road into
`Execute` refuses when the engine holds no debuggee, which is what stops a raw
`g` faulting the worker process.

This side turns that into something a caller can act on.

**The ending is reported on both halves of the result.** `StopReport` gains
`target_gone`, and the text gains a sentence saying the same thing — a
structured-aware client forwards `structuredContent` and drops the text, so a
fact on one half is a fact half the clients never see. `run_to_address` gains
`RunToVerdict::TargetGone`, which is deliberately not a timeout: the run ended
because the target did, so the address was never ruled out.

**And every tool answers the same thing afterwards.** dbgscope refuses raw
commands itself, so without a gate here the typed tools would be the
inconsistent half: `registers`, `modules` and `backtrace` go through the
engine's own interfaces, which answer `E_UNEXPECTED` on a session with no
target. `refuse_when_the_target_is_gone` runs once per op, exempting the
openers (which run before a target exists, and a worker is sent exactly one, as
its first op), `end_session` (the answer this refusal gives) and `interrupt`
(which never reaches the queue). The category is `stale_session`, because no
change to what is asked will help and the next move is that category's: release
the handle and open again.

**A command can also take the target away itself**, which the pump never sees:
`.detach`, `q` and `qd` return with the engine already holding nothing.
`raw_command` reports that from the run rather than from the command's name —
`.kill` is measured *not* to be in the group, leaving a target that still reads
a stack and goes away on the next resume, so a name list would be wrong per
engine version.

Two tests in the launch tier, one per road to the ending: the typed resume and
the raw hatch's pump, which is the minimal repro in the issue. Each asserts
both halves, because either alone is satisfiable by something wrong — reporting
the ending while leaving the half-dead chain in place fixes only a message, and
refusing everything afterwards without reporting it turns a program finishing
into a call that failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
glslang added a commit to glslang/windbg-mcp that referenced this pull request Aug 27, 2026
No behaviour change — glslang/dbgscope#120's test rename. The pin still points
at that branch and needs repointing to the merge commit before this lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
glslang added a commit to glslang/windbg-mcp that referenced this pull request Aug 27, 2026
glslang/dbgscope#120 was **rebase-merged**, so its three commits are on `main`
under new SHAs and the rev this pinned — `6123236`, the branch's head — is not
an ancestor of `main` at all. It still resolves today only because the branch
has not been deleted yet; the moment it is, a clean `cargo fetch` of this
repo's `main` cannot find the rev and the build stops.

Repointed to `c4043d0`, dbgscope's `main`. The trees are identical (`git diff`
between the two is empty), so this is a no-op in content and a fix to where the
content is reachable from.

Worth writing down because `CLAUDE.md` says to repoint to the merge commit
before the dependent PR merges, and both merged together instead — which is
the ordinary thing to do and leaves exactly this behind. A rebase merge makes
it worse than the note implies: there is no merge commit to point at, and the
branch head is not on `main` under any name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant