A target that ends is an ending, and an engine with none refuses text - #120
Conversation
… 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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 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. Comment |
…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>
|
Follow-up on the branch, after a question about whether A command can take the target away itself
And a fuzzer, because every one of these was found by hand
It reached the half-dead session through It would have found the bugs. At 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
|
|
@codex review |
There was a problem hiding this comment.
💡 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".
| /// 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() { |
There was a problem hiding this comment.
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>
|
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 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. |
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>
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>
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/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>
Closes the dbgscope half of windbg-mcp#242, and with it windbg-mcp's
FOLLOWUPS.mditem 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
WaitForEventanswersE_UNEXPECTEDonce the target is gone.execute_and_wait,settleandrun_to_addressall propagated it verbatim —Debug command failed: Catastrophic failure (0x8000FFFF)for a program exiting normally — and theErrdiscarded 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 exitunder a plaing:Each of the three now reports the ending as an outcome carrying its output:
CommandRun::target_gone, andRunToOutcome::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.0x7isDEBUG_STATUS_NO_DEBUGGEE(the issue reports it asGO_NOT_HANDLED, which is3; the status is reliable, not stale). Everything else the engine offers is unusable by then —GetNumberProcesses,GetCurrentProcessSystemIdandGetExitCodeall failE_UNEXPECTED, and.lasteventanswers<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
gthroughexecute_command_boundedexits withSTATUS_ACCESS_VIOLATION— a structured exception, so nocatch_unwindtraps it and the whole host goes down.execute_and_waitandrun_to_addresshad a guard; the two raw-command paths did not, which is the door windbg-mcp'sexecutetool 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.ifbranch anddx …ExecuteCommand("g")all reach execution without saying so, the same reasonsettleasks 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_breakrunssxe ibpbefore the target exists, so the guard refused everylaunch_processandattach_processon the machine. It now goes throughexecute_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 onwindows-latestandwindows-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:kanswering a bare0x80040205while.echostill worked. All four now answer that there is no debuggee, andend_sessionstill 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 --libon the ARM64 bench: 136 passed, 0 failed.🤖 Generated with Claude Code