Fixed
-
A dump or trace that had not finished loading is no longer reported as an open that worked.
open_dumpandopen_tracedefer the real work to the nextWaitForEvent, so
wait_for_event(LOAD_WAIT_MS)is the load — and dbgscope's finite wait used to answer
Result<(), _>, flatteningS_FALSE(the 60-second bound passing with the load still going) into
the sameOka completed load gets. A dump too large, or a symbol path too cold, to finish inside
that bound therefore came back as a successful open, and whatever the caller did next failed for a
reason nothing connected to it. Nothing here could have caught it: the fact was discarded inside
the wait.dbgscope now returns a
WaitOutcome(dbgscope#136
stage 1) andworker::load_completedreads it — onlyStoppedis a load that finished. The
failure is reported post-commit, which is what thecommit()already sitting above the wait
was for: the caller is told the session holds the target, soend_sessionis the recovery and
opening again would claim a second one. A host interrupting the load reports the same way. The
Expiredarm is unmeasured against a real engine — holding a dump load past sixty seconds is
not something a test can arrange — soa_load_that_did_not_finish_is_not_an_open_that_worked
asserts the mapping, and nothing claims how often it fires. -
A session no longer opens a console window on the desktop. Windows gives a console-subsystem
child of a console-less parent a brand-new, visible console — and a GUI MCP client starts a
stdio server without one — so every engine worker this server spawned put a window on the desktop,
titled with the exe's path and taking the foreground as it appeared. At the rate a model opens and
ends sessions (MAX_SESSIONSis 4, so a fifth open reclaims one) that is a machine nobody can
work at, which is #273. The worker and the
TTD.exerecorder are now spawned withCREATE_NO_WINDOW.Only when this process has no console of its own, which is not a refinement but the whole of
it. The flag does not suppress a console — it suppresses the window, by giving the child a
console of its own — and a worker's stderr is inherited. A console handle passed to a process
attached to a different console is re-bound to that one: measured here, such a child's write
reports success, bytes written and no error, and the text lands in its own invisible console
rather than in the terminal. Applied unconditionally the flag would therefore delete every worker
log line from a terminal-run server, silently, and make the log ring's "they are still on the
server's stderr" untrue. So it goes on exactly where it changes something: with no console there
is nothing to inherit and nothing for stderr to lose (a pipe or a file is inherited unchanged),
and with one the worker shares it and opens no window anyway.attached_to_a_consoleasks
GetConsoleProcessListrather thanGetConsoleWindow, which answers "no console" for a ConPTY —
Windows Terminal, and this repo's own harness — and would apply the flag to a worker holding a
live console handle.Two assertions, and the unconditional version fails both:
engine.rschecks that a child spawned
with a worker's flags joins this process's console, and the debugger tier checks the same of a
real session's engine pid, read fromsession_status. A debuggee launched bylaunchgets its
window from DbgEng rather than from here; that is
dbgscope#129, fixed there. -
The rule that no process is created without the spawn lock was checked by a marker that could
not see half of them.Commandspawns and waits in one call throughoutput()andstatus()
as well as throughspawn(), andservice::icaclsused the first of those — so
every_process_spawn_in_this_crate_takes_the_spawn_lockreported no unguarded spawns over a
source tree that created a process it could not see. Its own name was half of why that stayed
invisible — the rule is about a process being created, andspawnis only the spelling that
says so — so it is nowevery_process_created_in_this_crate_takes_the_spawn_lock.Harmless where it stood —
icaclsruns from the install and client-editing commands, in a
process that serves no session and spawns no worker — but that is a property of today's call
sites rather than of the rule, and the lock exists because a handle is inheritable process-wide
from the moment it is marked: a child started inside a worker's spawn window inherits that
worker's protocol channel and keeps the pipe from ever reporting EOF, so the session never
settles.icaclsnow takes the guard, and the marker counts the two fused calls.They are matched only inside a function that also constructs a
Command, because
response.status()is an HTTP status inlisten::gateand an unanchored marker demands the
spawn lock there — verified by removing the anchor, which lights up both lines.spawn()stays
unanchored: it is specific enough alone, and anchoring it would open that same hole in the half
that is load-bearing today. Each half is counted and asserted separately, since a marker that
matches nothing passes. -
A session handle that a raw
executeretired can still end its own session.qd,q,
.detach,.killand.opendumprelease or replace the target, which retires the handle
naming that session: every later call supplying it is refused, while the worker stays live and
reachable by a call supplying none.end_sessionwas not exempt, and two things did not line
up. Theexecutethat retires the handle appends "end_sessionreleases it", andend_session
with that handle was refused one call later — the server contradicting its own instruction. And
the recovery the refusal named, omittingsession_id, routes to whichever session is current,
so with anything newer open it reached a different one. The retired session could then not be
released by its owner at all: it held one of the four sessions and a live engine process with a
live target until everything newer had gone, or a client disconnect, or a lease expiry.A teardown does not touch the target retirement is about — it releases the session, which
the handle still names exactly — so it is now admitted, through a
SessionState::accepts_teardownof its own rather than a second caller ofaccepts_default,
whose set is the same today but whose question is different. Both places a handle is checked had
to widen together, the caller-sideSessions::resolveand theGateat the front of the
session's queue; backing either half out alone was tried and fails the same way, because
widening one only moves the refusal to a place with no caller to explain it to.The refusal's own text changed with it: it names
end_sessionwith the handle in it as the
recovery that always works, and mentions omittingsession_idsecond and qualified — "only
while this is still your current session" — since unqualified it reads as a way back to this
target and is a way to act on another.Found by the session fuzz added below, on the second seed it ran under, and covered by
a_handle_a_raw_command_retired_can_still_end_its_own_session. The second launch in that test
is the test rather than scenery: with one session open the retired one is still current, so an
un-handledend_sessionreaches it and the defect is invisible — which is why no
single-session test had ever seen it.
Changed
-
An abandoned launch no longer leaves its process to the next one. dbgscope
#141: dropping a launch guard before anything
pumps does not un-queue itsCreateProcessWide, so that process still arrived and was claimed by
whichever launch asked next — whosewait()then returned for a target it never asked for. The
entry now stays until its own create is accounted for, and a launch whose wait timed out is
discarded rather than kept.Unreachable from here, and that is a property rather than luck: each opener in
worker.rs
creates onePendingTargetand waits on it inside the same closure, so this server never abandons
a launch guard and never has two launches pending at once. The bump is the pin alone. What is
still open upstream is identification — which of two simultaneous launches gets which process —
declined there for the same reason it cannot arise here. -
The debug engine no longer claims to cross threads. dbgscope
#136 stage 4, the last of that refactor, deletes
unsafe impl Sendandunsafe impl Sync for DebugEngine. Both asserted the opposite of what that
crate says about itself —SetInterruptis the one DbgEng call documented as safe from any thread
because the rest of the engine is single-thread-affine — and neither carried a safety comment,
because neither could have been given a true one.InterruptHandleis now its onlySend + Sync
type: oneSetInterrupt, from anywhere, and nothing else.A breaking change upstream that this server does not feel, which is worth separating from "no
behaviour changed": a worker builds its engine inworker::build_engine, on the engine thread
that then uses it for the whole of that worker's life, so it never needed either bound. That was
measured before the change rather than after — removing each and building leaves this crate
compiling unchanged. -
The engine's arrival bookkeeping is a delivery register rather than an engine-wide record.
dbgscope #136 stage 3: an open registers what it
is waiting for, a stop is routed to the first open that wants it and has nothing yet, and the
entry dies with the guard that made it. That deletes the three lifecycle rules the record it
replaces needed — pruned at both openers for pid reuse, cleared where a session is replaced and
cleared again where one is ended — because nothing outlives its reader any more. It also makes two
opens pending at once exact where they were ambiguous, which the type it replaces had documented
as an accepted cost.No behaviour of this server moves, which is worth stating rather than leaving to be inferred
from a green suite: a worker holds one target for its whole life andEngineOphas no second
opener, so every case the register newly tells apart is one this server cannot reach. What the
bump buys is the correctness of the layer underneath, and the shapes it makes safe to add. No
public API changed either, so this is the pin alone. -
A break this server asks for is now scoped to the engine operation it will stop. dbgscope's
interrupt was an engine-wide flag that each bounded operation cleared as it opened, so a request
lodged between that clear and the wait it was meant for was erased while itsSetInterruptwas
still on the way — and the synthetic Ctrl+Break that then arrived was reported as the target's
own stop. This server reaches that path:interrupt/break_inraise the break from the request
reader, off the engine thread, while a live open runs on it.Closed upstream by dbgscope#135 /
#136 stage 2: the request is filed against the
operation running at that instant, under the same lock that deliversSetInterrupt, and there is
no clear anywhere. Nothing about this server's tool surface changes —
worker::interrupt_runningnow logs which operation the break was filed against, and
deliberately does not turn that into a different answer for the caller:NothingRunning
means the engine had no bounded operation to file against (a typed getter, a plain
execute_command), not that the session is idle, and the break is delivered either way. -
set_breakpointno longer runsbpas text, and its result is a different shape as a result.
It now goes through dbgscope's typed breakpoint API
(dbgscope#126), which hands back the breakpoint
it created — sobreakpointcarries the id, the address, whether it isdeferredand the command
it runs, all read off the engine rather than inferred. What that replaces is anaddedlist
recovered by diffingbleither side of thebp, since a successfulbpprints nothing at
all, plus the two fields (listed,listing_error) whose whole job was to say the diff might be
unavailable and an emptyaddedtherefore unknown rather than empty. None of that can arise
now.replacedis new: setting a breakpoint where one already is removes it — whatbphas
always done, previously visible only as abreakpoint N redefinedline in debugger text — and the
ids it took are now a value a caller can act on. -
ioctl_tracereturns a structured result, having previously answered with whatever itsbp
printed, which on success was nothing at all (FOLLOWUPS.mditem 57). It installs its logging
breakpoint through the same typed op and reports the sameBreakpointSet, with anoutputSchema
to match — a structured-aware client replaces the text block withstructuredContent, so sending
one without declaring a schema would have handed those clients an undeclared shape and taken their
text away. -
Both tools' command strings stopped being escaped by hand.
ioctl_tracebuilt
bp <dispatch> ".printf \"IOCTL %08x …\", …; gc"as one string, so every quote was\\\"and the
newline\\\\ninside a Rust format string, and thedispatchoperand had to be screened for;
and"because either would have closed the quote and appended a command of the caller's
choosing. A command reaches the engine as a parameter now, where a;separates nothing and a"
opens nothing.reject_command_breakersstays onset_breakpoint's expression as defence in
depth rather than as the only defence. -
A breakpoint's watched region is reported where it has one —
watch: {access, size}for a
data breakpoint, which the read side could previously say only that a breakpoint was. -
set_breakpointtakesone_shot, which removes the breakpoint the first time it is hit.
This was reachable before by putting/1inexpression, and only because the expression was
interpolated intobp {expression}:/1is not a location, so it could not survive the move to a
typed setter and has a parameter of its own. -
set_breakpointalso takespass_count,bp's trailingPassesargument, reachable
throughexpressionbefore for the same reason. Its remaining options have no typed equivalent
and are not added:/p,/cand/Chave no setter on the engine's breakpoint interface at
all, and/ttakes an ETHREAD pointer where the engine's thread filter takes its own thread id —
a different thing rather than a spelling of it. A rawbpstill reaches those. Together the two
parameters cost 839 B of model-visible surface, which is all the model pays for this change. -
Every raw command this server runs is now bounded, except
index_trace(FOLLOWUPS.md
item 14).threads,goto_position,driver_object,device_object,irp_stackand
ioctl_tracemoved fromEngineOp::CommandtoEngineOp::BoundedCommand, so a command that
runs away —!drvobjagainst a live kernel whose symbols are being fetched one frame at a time,
a!ttseek into a trace with no index — now Ctrl+Breaks itself ahead of the caller's timeout
and answers with the output it had, instead of holding its session's engine until it finishes.The split those six were on the other side of was decided on cost, not on principle: dbgscope's
watchdog polled adoneflag on a 200ms sleep, so the join waited out the rest of the nap and
arming one rounded a command up toceil(d / 200ms) * 200ms— a 30mskbecame a 200msk, and
a session issues those by the dozen. That was worth a stated criterion and a list either side of
it. It is not worth anything now: theWatchdogin the pinned revision parks on aCondvar, so
the disarm is immediate and the bound costs nothing until it is reached. Re-measured through the
tool surface before deciding, twice (x64 bench, sample dump, 20 rounds): a boundedlmmedians
3.0ms and 3.3ms against the unboundedmodulesbeside it at 4.1ms and 4.2ms, and a ~170ms.for
loop costs ~171ms and ~185ms rather than 200ms. The old second mode — wherelmraced the
watchdog's first poll and landed on either ~0.3ms or ~200.7ms run to run — did not appear.It arrived through the #226 work rather than
through anything aimed at this entry: the sleep was what made a finiteWaitForEventlook
attractive, so fixing that defect retired this trade-off as a side effect and nothing here was
revisited when it landed.index_tracestays out, and is now the only op that is.!ttdext.index -forcedeletes an
unloadable.idxbefore rebuilding it, so a break part-way through can leave a trace with no
usable index at all — the one case where the abort is worse than the wedge, and one whose long
run is productive work that frees the session when it finishes. What was the general "raw
command" op is renamedEngineOp::UnboundedCommandto say so at the call site, and
server::tests::only_index_trace_runs_a_command_unboundedholds it to its single caller by
reading the source — because the way a collapsed split comes back is a tool added by copy-paste
taking the unbounded path with nobody deciding to, and that tool works perfectly until the day
its command runs away. -
set_breakpointruns itsbpon the caller's clock too.EngineOp::SetBreakpointcarries a
patience_msand the command goes throughexecute_command_bounded. The address is the caller's
text andbpmakes the MASM evaluator resolve it, sobp nt!Foo+0x10against a deferred module
with asrv*path is a symbol-server fetch with this session's engine held for all of it — the
wedge the bounded path exists to stop, reached through a typed op where nothing in the name
said there was a command inside. Theblreads either side of it stay unbounded, being direct
engine calls with noExecuteto break.It survived the first draft of the change above, whose rule was stated over ops and whose test
certified ops, so both missed it.worker::tests::every_unbounded_execute_in_this_worker_is_accounted_for
is the correction: it reads the source forExecutecalls rather than for enum variants, and
enumerates the five functions that legitimately run one unbounded — the two openers' fixed
strings, the resume pump's ownExecute, and the two that are deferred with items against them.
Verified by backing the fix out, which namesset_breakpoint.Enumerating rather than reasoning about it turned up a second instance the review did not:
worker::resolve's? <expr>, also caller text, filed asFOLLOWUPS.mditem 56 rather than
fixed here because its three callers sit on three different clocks and one of them is item 13.And bounding it added a third state the result had no way to say, which is
structured::BreakpointSet::cut_short. An interrupted command comes back as anOkrun, so a
bpthat never finished looked from the caller's side exactly like one that ran and matched
nothing — the same emptyadded, the same successful result, rendered as "(this call added
none)". The two have opposite next moves. The listing is a real engine read taken afterwards, so
addedsettles which happened — non-empty is a breakpoint that landed before the break and must
not be re-requested. Empty settles nothing on its own: without a listing, which of the
session's breakpoints is new is unknown, and with one it is still not evidence the expression
is unset, because abpat an address that already carries a breakpoint adds no id either. So
the result says what the diff says and sends the caller to the listing for the rest. Reported in
both channels — a structured-aware client drops the text — and it stays a success rather than an
error, because an error is the shape a caller retries.That last point turned up a fact this module states as a universal and which is only half true.
Measured on a live target:bp ntdll!NtCreateFilethree times leaves one breakpoint, and
ntdll!NtClose+0x2twice leaves one, because a resolved breakpoint is keyed by address — while
bp nosuchmod!Symtwice leaves two, since a deferred one has no address to key on.bp
duplicates exactly when its expression does not resolve, which is the same condition that makes
one slow enough to be cut short in the first place. The warnings stay; what changed is that they
are accurate about why, and no longer claim a retry is safe when the diff cannot know it.One field for both causes, unlike a stop's
interrupted/timed_outpair: theinterrupt
tool reaches this command as readily as the deadline does and leaves the session in the same
state, so reading only the deadline reported an interruptedbpas a completed one — and on the
branch where the listing had also failed, as a breakpoint positively "set". A stop keeps the two
apart because the next move differs there; here it does not, andaddedanswers what the cause
would only hint at.
Added
-
arming_the_watchdog_does_not_round_a_quick_command_up, in the debugger tier, guarding the
assumption the rule above now rests on. The measurement it was extracted from
(measure_what_the_bounded_path_costs_a_quick_command) is#[ignore]d, so it went on passing
across the very change it exists to catch — the quantization it describes had been gone for six
days. This one is in the debugger tier — it opens the sample dump, so a plaincargo test
stands it down — and is not#[ignore]d, which is the difference: CI runs it on all three
runners. Its oracle is a ratio between two bounded commands of very different natural cost,
anexecuteoflmagainst anexecuteof a ~170ms.forloop, failing if they come within
5x. That is what a fixed quantum destroys — rounding both up to a multiple of the nap makes them
equal, where without one they stay ~50x apart — and it scales with the host, unlike the first
version's bounded-against-unbounded margin, which a slow enough baseline grows into. The
measurement keeps the numbers and its comment now records them. -
A session fuzz in the debugger tier — dbgscope's
examples/session_fuzz.rsbrought up to
this server's surface. That example drives randomised command sequences straight at a
DebugEngineand checks, after every one of them, that the session either still holds a target
and answers or says it holds none; it exists because the three defects behind
#242 were each found by hand, one sequence at
a time, and none of them is about a command — they are about the state the previous command
left behind, and there are more ways to reach a given state than anyone enumerates.What the port adds is everything between that engine and a caller. A third state, since
continue_asyncleaves a target moving with nobody waiting and reads are then refused
target_running— the supervisor's state machine
(#83), which the example cannot reach. The
category a refusal carries rather than merely that it refused. A bystander session on the
same server, never named by a step and asked after every round, which is the process-per-session
claim no in-process test can make. And reclamation of whatever the sequence left.Its oracle is a scale rather than an agreement: a bounded run can stop between one road into
the session and the next, so what is forbidden is a road moving back down
Moving → Holding → Gone—stale_sessionand then an answer is the half-dead session, while an
answer and thenstale_sessionis a program that finished a millisecond ago. The seed is fixed,
so CI walks one short deterministic sequence on all three of itsdbgeng.dlls; the fuzz proper
is a soak of the same test, and the run prints the states it reached and asserts it reached the
terminal one, because a walk that never leftHoldingwould pass without asking the question.
docs/smoke-test.mdhas the soak command and what it does and does not assert.It found one thing on the second seed it ran under, left standing as
FOLLOWUPS.mditem 55: a
handle that a rawexecutehas retired cannot release its own session, while theexecute
that retires it appends "end_sessionreleases it" — and the recovery its refusal names, omitting
the handle, routes to the newest session instead. Measured on the release build with two
launches, the older retired byqd.
Documentation
-
FOLLOWUPS.mdholds only what is still open; what has landed moved toDONE.md. Thirty-three
of its fifty-five entries were finished work, so two thirds of a file read for "what is left" was
answering a different question. The entries move in full and under the numbers they were filed
with —CLAUDE.md,CHANGELOG.md,docs/*.md,ci.ymlandbuild.rsall cite them as
"FOLLOWUPS.mditem N", and those are prose references that renumbering would break without
failing anything — soFOLLOWUPS.md's numbering is now sparse and its header is what answers
which file, above every entry. Neither file is in the markdownlint globs, so neither is checked
by CI.Citations are deliberately not retargeted, and
every_followups_citation_names_an_item_that_exists
is what makes that safe: it reads every text file in the repository and fails if a cited number is
in neither file, if a number is in both, or ifDONE.md's index has fallen out of step with its
entries. Some twenty files carry that string — doc comments in eleven modules and intests/,
DECISIONS.md, everydocs/*.md,build.rs,ci.ymland the eval tooling — so a citation whose
file half followed the entry would make every close a sweep of source comments, unchecked, and one
that had to be repeated on the next close. The number is the name; which file holds it is the
landing page's answer. Proved by breaking it three ways: an entry renumbered, an index line
dropped, and an anchor corrupted.Two shapes deliberately stayed: an item measured and declined (27, 35), where nothing was
built and the reopening condition is the content, and one that half landed (50), whose entry
narrows to the half that is left rather than splitting across two files. -
DECISIONS.md's bounded-command entry (2026-08-02) is superseded by its own revisit trigger,
and says so above the criterion rather than only in its Status line. The criterion stays as the
record of what the tax bought while it stood;FOLLOWUPS.mditem 14 moves toDONE.md. Two
boundaries the entry now states explicitly, because both have been mistaken for the split before:
the typed ops carry nopatience_msbecause there is no command for a watchdog to break, and
reachable_from_dispatchis a job-level deadline and still item 13.
Full Changelog: v0.14.0...v0.15.0