Skip to content

v0.15.0

Latest

Choose a tag to compare

@github-actions github-actions released this 04 Sep 18:14
· 41 commits to main since this release
Immutable release. Only release title and notes can be modified.

Fixed

  • A dump or trace that had not finished loading is no longer reported as an open that worked.
    open_dump and open_trace defer the real work to the next WaitForEvent, so
    wait_for_event(LOAD_WAIT_MS) is the load — and dbgscope's finite wait used to answer
    Result<(), _>, flattening S_FALSE (the 60-second bound passing with the load still going) into
    the same Ok a 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) and worker::load_completed reads it — only Stopped is a load that finished. The
    failure is reported post-commit, which is what the commit() already sitting above the wait
    was for: the caller is told the session holds the target, so end_session is the recovery and
    opening again would claim a second one. A host interrupting the load reports the same way. The
    Expired arm is unmeasured against a real engine — holding a dump load past sixty seconds is
    not something a test can arrange — so a_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_SESSIONS is 4, so a fifth open reclaims one) that is a machine nobody can
    work at, which is #273. The worker and the
    TTD.exe recorder are now spawned with CREATE_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_console asks
    GetConsoleProcessList rather than GetConsoleWindow, 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.rs checks 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 from session_status. A debuggee launched by launch gets 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.
    Command spawns and waits in one call through output() and status()
    as well as through spawn(), and service::icacls used the first of those — so
    every_process_spawn_in_this_crate_takes_the_spawn_lock reported 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, and spawn is only the spelling that
    says so — so it is now every_process_created_in_this_crate_takes_the_spawn_lock.

    Harmless where it stood — icacls runs 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. icacls now 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 in listen::gate and 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 execute retired can still end its own session. qd, q,
    .detach, .kill and .opendump release 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_session was not exempt, and two things did not line
    up. The execute that retires the handle appends "end_session releases it", and end_session
    with that handle was refused one call later — the server contradicting its own instruction. And
    the recovery the refusal named, omitting session_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_teardown of its own rather than a second caller of accepts_default,
    whose set is the same today but whose question is different. Both places a handle is checked had
    to widen together, the caller-side Sessions::resolve and the Gate at 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_session with the handle in it as the
    recovery that always works, and mentions omitting session_id second 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-handled end_session reaches 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 its CreateProcessWide, so that process still arrived and was claimed by
    whichever launch asked next — whose wait() 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 one PendingTarget and 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 Send and unsafe impl Sync for DebugEngine. Both asserted the opposite of what that
    crate says about itself — SetInterrupt is 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. InterruptHandle is now its only Send + Sync
    type: one SetInterrupt, 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 in worker::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 and EngineOp has 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 its SetInterrupt was
    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_in raise 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 delivers SetInterrupt, and there is
    no clear anywhere. Nothing about this server's tool surface changes —
    worker::interrupt_running now 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_breakpoint no longer runs bp as 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 — so breakpoint carries the id, the address, whether it is deferred and the command
    it runs, all read off the engine rather than inferred. What that replaces is an added list
    recovered by diffing bl either side of the bp, since a successful bp prints nothing at
    all, plus the two fields (listed, listing_error) whose whole job was to say the diff might be
    unavailable and an empty added therefore unknown rather than empty. None of that can arise
    now. replaced is new: setting a breakpoint where one already is removes it — what bp has
    always done, previously visible only as a breakpoint N redefined line in debugger text — and the
    ids it took are now a value a caller can act on.

  • ioctl_trace returns a structured result, having previously answered with whatever its bp
    printed, which on success was nothing at all (FOLLOWUPS.md item 57). It installs its logging
    breakpoint through the same typed op and reports the same BreakpointSet, with an outputSchema
    to match — a structured-aware client replaces the text block with structuredContent, 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_trace built
    bp <dispatch> ".printf \"IOCTL %08x …\", …; gc" as one string, so every quote was \\\" and the
    newline \\\\n inside a Rust format string, and the dispatch operand 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_breakers stays on set_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_breakpoint takes one_shot, which removes the breakpoint the first time it is hit.
    This was reachable before by putting /1 in expression, and only because the expression was
    interpolated into bp {expression}: /1 is not a location, so it could not survive the move to a
    typed setter and has a parameter of its own.

  • set_breakpoint also takes pass_count, bp's trailing Passes argument, reachable
    through expression before for the same reason. Its remaining options have no typed equivalent
    and are not added: /p, /c and /C have no setter on the engine's breakpoint interface at
    all, and /t takes an ETHREAD pointer where the engine's thread filter takes its own thread id —
    a different thing rather than a spelling of it. A raw bp still 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_stack and
    ioctl_trace moved from EngineOp::Command to EngineOp::BoundedCommand, so a command that
    runs away — !drvobj against a live kernel whose symbols are being fetched one frame at a time,
    a !tt seek 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 a done flag on a 200ms sleep, so the join waited out the rest of the nap and
    arming one rounded a command up to ceil(d / 200ms) * 200ms — a 30ms k became a 200ms k, 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: the Watchdog in the pinned revision parks on a Condvar, 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 bounded lm medians
    3.0ms and 3.3ms against the unbounded modules beside it at 4.1ms and 4.2ms, and a ~170ms .for
    loop costs ~171ms and ~185ms rather than 200ms. The old second mode — where lm raced 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 finite WaitForEvent look
    attractive, so fixing that defect retired this trade-off as a side effect and nothing here was
    revisited when it landed.

    index_trace stays out, and is now the only op that is. !ttdext.index -force deletes an
    unloadable .idx before 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 renamed EngineOp::UnboundedCommand to say so at the call site, and
    server::tests::only_index_trace_runs_a_command_unbounded holds 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_breakpoint runs its bp on the caller's clock too. EngineOp::SetBreakpoint carries a
    patience_ms and the command goes through execute_command_bounded. The address is the caller's
    text and bp makes the MASM evaluator resolve it, so bp nt!Foo+0x10 against a deferred module
    with a srv* 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. The bl reads either side of it stay unbounded, being direct
    engine calls with no Execute to 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 for Execute calls rather than for enum variants, and
    enumerates the five functions that legitimately run one unbounded — the two openers' fixed
    strings, the resume pump's own Execute, and the two that are deferred with items against them.
    Verified by backing the fix out, which names set_breakpoint.

    Enumerating rather than reasoning about it turned up a second instance the review did not:
    worker::resolve's ? <expr>, also caller text, filed as FOLLOWUPS.md item 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 an Ok run, so a
    bp that never finished looked from the caller's side exactly like one that ran and matched
    nothing — the same empty added, 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
    added settles 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 a bp at 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!NtCreateFile three times leaves one breakpoint, and
    ntdll!NtClose+0x2 twice leaves one, because a resolved breakpoint is keyed by address — while
    bp nosuchmod!Sym twice 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_out pair: the interrupt
    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 interrupted bp as 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, and added answers 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 plain cargo 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,
    an execute of lm against an execute of a ~170ms .for loop, 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.rs brought up to
    this server's surface. That example drives randomised command sequences straight at a
    DebugEngine and 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_async leaves 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 → Gonestale_session and then an answer is the half-dead session, while an
    answer and then stale_session is a program that finished a millisecond ago. The seed is fixed,
    so CI walks one short deterministic sequence on all three of its dbgeng.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 left Holding would pass without asking the question.
    docs/smoke-test.md has 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.md item 55: a
    handle that a raw execute has retired cannot release its own session, while the execute
    that retires it appends "end_session releases 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 by qd.

Documentation

  • FOLLOWUPS.md holds only what is still open; what has landed moved to DONE.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.yml and build.rs all cite them as
    "FOLLOWUPS.md item N", and those are prose references that renumbering would break without
    failing anything — so FOLLOWUPS.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 if DONE.md's index has fallen out of step with its
    entries. Some twenty files carry that string — doc comments in eleven modules and in tests/,
    DECISIONS.md, every docs/*.md, build.rs, ci.yml and 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.md item 14 moves to DONE.md. Two
    boundaries the entry now states explicitly, because both have been mistaken for the split before:
    the typed ops carry no patience_ms because there is no command for a watchdog to break, and
    reachable_from_dispatch is a job-level deadline and still item 13.

Full Changelog: v0.14.0...v0.15.0