Skip to content

Nine defects in the kernel's high-risk areas, each closed with a measured control - #306

Merged
Japabu merged 15 commits into
mainfrom
wt/toyos-md2
Aug 27, 2026
Merged

Nine defects in the kernel's high-risk areas, each closed with a measured control#306
Japabu merged 15 commits into
mainfrom
wt/toyos-md2

Conversation

@Japabu

@Japabu Japabu commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Nine tracker entries closed, out of a batch of 32 read for value. Every one is
in an area the workflow calls high-risk — the syscall entry, the panic path,
interrupt delivery, three USB drivers, the display, the futex pair — so each
names its negative control and its independent oracle below, and every control
was measured on both arms in one session rather than asserted.

Three entries are filed rather than fixed, and one existing entry loses a
citation it no longer has.

What landed

arch/percpu: every gs: displacement is derived
design-debt/percpu-asm-contract-is-unbound. The five naked entry stubs were
the last GS accesses spelling a displacement as a number; each takes the
matching OFF_* as a const operand now, the 20 const _: () = assert! and
the 27 // offset N comments go with the last literal, and _pad200 — reserved
because "dropping these 8 bytes shifts all of them" — goes too. Its two riders
follow in the next commit: lapic_id had no readers, and alloc_percpu writes
the whole struct so a new field cannot default to whatever zero means for it.

  • control: the same field surgery on the base, with the asserts updated — the
    careful edit the old shape invites. Init dies in boot with scheduler entered while a lock is held: preempt depth 4294966880, ALONE: red again.
  • oracle: llvm-objdump. The 33 gs: instructions across the stubs are the
    same instructions with every displacement moved by exactly the 8 bytes the
    padding occupied — the machine code, not the source, saying the const
    operand is the immediate the literal was.

The panic path asks whether this task is in a syscall
panic-path/syscall-rip-never-cleared. syscall_rip was never cleared, so on
any CPU that had served one syscall the recovery predicate was permanently
true and every IRQ-context panic with a task current killed that task's
process. PerCpu::syscall_task records the pid and tid of the task the bracket
was entered for; in_syscall() holds only while that is the task running.

  • control: a panic! staged in timer_handler. On base the process is killed
    and the machine runs on, and the report carries Syscall: num=63 user_rip=… with a user backtrace into dlmalloc::malloc — a syscall that had
    already returned. With the fix, no Syscall: block and every CPU halted.
  • oracle: the recorded real failure the entry was reopened on (2026-08-20, a
    12-wide boot-storm capture), where the same stale block's user_backtrace
    faulted and took the rest of the report with it.

Vector 0xFF has a gate, and it asks before it acknowledges
kernel/the-lapic-spurious-vector-has-no-gate. enable_x2apic writes 0xFF into
the SVR on every CPU and the IDT left the slot P = 0. The handler counts the
delivery in the interrupt census — it may not log, for idt::nmi's reason — and
EOIs only what the in-service register says is in service.

  • controls, two: with the gate removed and the delivery staged, the guest
    never reaches ===READY=== and the console carried: nothing at all; with
    the gate kept and the acknowledgment removed, the self-test says vector 0xff is still in service, so nothing below priority 0xF can be delivered again.
  • oracle: Intel SDM Vol. 3A — §11.9 (a spurious interrupt sets no ISR bit and
    needs no EOI), §11.8.4 (an in-service bit blocks every lower priority),
    §6.14's contributory-fault table for the escalation a P = 0 gate produces.
  • lapic_spurious_vector is a new registered name, Fast, committed to
    tests/test-durations with the UNMEASURED marker.

A BOT command names the region its data phase lands in
filesystem/bot-length-assertion-binds-another-buffer. bot asserted
data_len <= MSC_DATA_LEN (32 KiB) while four of five call sites pointed at a
64-byte scratch. The pair is one Option<Dma<'static>>; the CBW's length is the
region's own size.

  • control: an INQUIRY staged to ask for 4,096 bytes into the 64-byte scratch.
    On base usb_storage_gate is green — the transfer is programmed and
    nothing says a word. With the fix: DMA: 4096 byte(s) at 0x0 run past a region of 0x40.
  • oracle: USB Mass Storage Bulk-Only Transport 1.0 §5.1 and §6.7.2 — the
    device is licensed to send dCBWDataTransferLength bytes, so a host that
    names a length its buffer does not have has authorised the overflow. It is
    also why the base arm is green: QEMU's stick answers with 36 bytes.

A refused device gives its slot back where it is refused
hardware/xhci-slot-never-given-back. Eleven paths kept a Device Slot for a
device still in its port. device::refuse disables it and leaves the port
attached, so nothing re-enumerates what it just refused; msc::bind and
bind_hid answer bool so a class driver's refusal is one too.

  • control: xhci_slot_exhaustion gains the assertion, and on base it reads
    0 slot(s) disabled for 5 refused device(s) — the test that describes the
    leak was its largest producer.
  • oracle: xHCI 1.2 §4.6.4 (Disable Slot is legal from any slot state, which is
    what lets a refusal issue it against a device still in its port) and §4.6.3.

A stalled control transfer leaves EP0 running
filesystem/control-stall-halts-ep0. Two paths reach a halted EP0: the
tolerated SET_PROTOCOL stall during enumeration, answered by two commands the
pure sequence now owes, and Bulk-Only Reset Recovery, where the CLEAR_FEATUREs
behind a stalled class request could no longer run. control_transfer recovers
EP0 before it reports a stall.

  • control: xhci_full_speed_device — the bus where QEMU's tablet really
    stalls — gains the assertion, and on base reads EP0 was left halted behind the stall. The sequence half is gated where it is decided: two host tests in
    toyos-xhci.
  • oracle: USB 2.0 §9.4.5 and §8.5.3.4 (no Halt feature on the default pipe;
    the device clears it on the next SETUP) with xHCI 1.2 §4.6.8 and §4.6.10
    (Reset Endpoint leaves Halted, Set TR Dequeue is why it must follow).

set_resolution replaces every description of the mode it changed
design-debt/set-resolution-frees-a-live-framebuffer. The device registry
answered the next framebuffer claim with the regions and geometry of the mode
before last, and the absolute pointer's per-axis scale — whose own comment says
it "has to follow a mode change" — followed nothing.

  • control: staged, both arms. Base: the resize answered Ok((800, 600)) and the registry says Some((2048, 2048)). Fixed: Some((800, 600)).
  • oracle: an in-tree differential — the resize's return value, which the
    compositor reads, against the registry every later claim reads. Two answers to
    one question, and the control is them disagreeing.
  • No committed gate, and it is the harness's shape rather than a choice: no
    profile attaches a virtio-gpu, so every guest takes the GOP path and cannot
    resize at all — measured, and filed.

SYS_FUTEX_WAIT answers the timeout its ABI documents
kernel/futex-wait-cannot-report-a-timeout and
kernel/futex-wait-never-returns-its-timeout-code, one defect seen twice. The
predicate runs once more after the wait: a word that still holds expected is a
wait nothing it was armed for ended.

  • control: the kernel half reverted under the extended gate —
    a futex wait nobody woke answered 0, wanted the timeout.
  • oracle: the ABI's own line, written before this and never met — "Returns 0
    on wake, 1 on timeout" — and POSIX's pthread_cond_timedwait, which cannot be
    built on a primitive that answers one number.
  • The gate is a third question inside futex_wake_counts, so no new name is
    registered
    : three assertions, because any one alone passes on a kernel
    answering a constant.

Two things this branch had to answer for

xhci_two_controllers reds on the slot fix, and the staging is what was
wrong.
MetalXhciBoth balanced the boot stick with a usb-hub — a device
that is walked past, so what it contributed was a leaked slot. Rebalanced with a
device that binds; measured on three arms (base green on slot 3, branch red on
3 and 2, rebalanced green on slot 3 again).

syscall_window_nmi reds at 1,505 s against a committed 6,825 ms in a
92-guest run sharing the host with another worktree's suite, and is green alone
in 5 s in the same session. --known-red said NOT ON THE LIST; it now carries a
DevHostLoaded row with no rate, sourced to a new entry that says what would
settle it. exit_wait_storm reds in the same phase and was already on the list.

Filed rather than fixed

  • kernel/an-unclaimed-vector-halts-the-machine-with-no-name — the other 235
    P = 0 slots, with the spurious gate's own control as its evidence.
  • diagnostics/no-guest-can-change-the-display-mode — no harness profile can
    reach a successful resize.
  • build/syscall-window-nmi-reds-under-a-shared-host — the red above.

Gates

cargo test --lib green (180). cargo test --workspace --exclude toyos-build
green: 1,081 passed, 0 failed. cargo test (Fast tier, 288 names) twice: the
first run at 92 guests beside another worktree's suite is 285 passed and the
three reds above — one this branch's and fixed, two contention. The second, on
the tip with the staging rebalanced and nothing else on the host, is 288
passed, 0 failed
in 108 s.

src/prose-ledger gains deliberate raises in the same commits as the prose that
earned them, and one new row for arch/idt/spurious.rs. The dated column is
untouched throughout.

Japabu added 13 commits August 27, 2026 10:41
…t literal

The five naked entry stubs were the last GS accesses in the kernel spelling a
displacement as a number: eight in `arch::syscall`'s entry (the stack switch,
its three diagnostic stores and the preempt bracket), four in the Ring 0
timer's re-arm and its need_resched/fire-count stores, and two each in
`device_irq`, `common_entry` and the shootdown entry. Each now takes the
matching `arch::percpu::OFF_*` as a `const` operand, which a naked stub accepts
exactly as an ordinary `asm!` does — `arch::idt::nmi` had been doing it since it
was written. `OFF_KERNEL_RSP` is new, for the one field no Rust access named.

With no literal left, the 20 `const _: () = assert!(OFF_x == N)` protect
nothing and are deleted with the 27 `// offset N` field comments — a third copy
of the same numbers. The hazard those existed for was never the reorder they
catch: it was a field edit made *with* the asserts updated, which left the 18
asm sites reading the wrong bytes with no diagnostic at all.

`_pad200` goes too. It was reserved because "dropping these 8 bytes shifts all
of them" — every field below it was reached by a literal — and now nothing is.
Its removal is what proves the unification rather than asserting it.

Negative control, measured: this whole change reverted onto its base, with
`_pad200` deleted and the 20 asserts updated to the shifted offsets — the
careful edit the old shape invites — reds. The syscall entry's `lock add
dword ptr gs:[240]` then lands on `last_seen_ring0_fires`, and init dies in
boot with `scheduler entered while a lock is held: preempt depth 4294966880,
baseline 0` at `syscall_entry+0x88`. `ALONE syscall_cost: red again, the same
failure both times`.

Independent oracle, the emitted machine code rather than the source: the 33
`gs:` instructions across `syscall_entry`, `common_entry`, `timer_entry`,
`tlb_flush_entry` and the six `device_irq` stubs, disassembled before and after
with llvm-objdump, are the same instructions in the same order with every
displacement moved by exactly the 8 bytes `_pad200` occupied — 0xf0 to 0xe8 for
the preempt count, 0xd8 to 0xd0 for `syscall_rip`, 0x10 and 0x18 unmoved
because they sit above the padding. No instruction changed shape, so the
`const` operand really is the immediate-displacement form the literal was.

Green: syscall_cost, irq_census_conservation, sched_stress, futex_wake_counts,
cargo test --lib.

Closes issues/design-debt/percpu-asm-contract-is-unbound.md's first half; the
two PerCpu riders that entry says must not precede this follow.
The two riders the entry made wait for the unification, now that a field edit
moves nothing but the field.

`lapic_id` had zero readers: written once and never read, while every other
`lapic_id` in the kernel is the *parameter* of `alloc_percpu`, `init_bsp` or
`alloc_ap`. The field goes, and with it the parameter of the two functions that
carried it only to store it — `init_bsp` keeps its own, which its identity log
line prints, and `smp`'s AP loop still names the lapic id it sent the
INIT-SIPI-SIPI to.

`alloc_percpu` set 8 of the struct's fields and left the other 14 to
`alloc_zeroed`. It is one `ptr::write(PerCpu { .. })` now, so a field added
tomorrow does not compile until somebody says what it starts as — which is the
question `current_tid`/`current_pid` answer with `u32::MAX` and a zeroed
allocation would have answered with thread 0 of process 0. `init_tss_descriptor`
stays after the write, because a TSS descriptor holds the address the block was
allocated at.

Green: sched_stress (an SMP boot, so every AP takes this path),
irq_census_conservation, syscall_cost, cargo test --lib.

Closes issues/design-debt/percpu-asm-contract-is-unbound.md. No citation of the
slug or the path exists anywhere in the tree, and `src/redlist.rs` has no row
sourced to it. What the entry was for now lives where it is enforced: the
`OFF_*` block's own header in `arch/percpu.rs`, which says every access spells
a constant and none spells a number.
The panic handler recovered — killed the current process and rejoined the
scheduler — where `syscall_rip() != 0 && current_tid().is_some()`. Nothing ever
cleared `syscall_rip`, so on any CPU that had served one syscall the first
clause was permanently true, and every panic in IRQ context with a task current
read as that task's syscall panic.

`PerCpu::syscall_task` is what the question actually needs: the pid and tid of
the task this CPU entered a syscall for, or a sentinel. `syscall_handler`
brackets the dispatch with it, and `in_syscall()` holds only while the recorded
identity is the one this CPU is running. Pid *and* tid, because a tid is
per-process and `Tid(0)` is the main thread of every process on the machine.

It is deliberately not a guard type: a kernel panic does not unwind, and the
panic handler must find the bracket still open. A thread that leaves through
`SYS_EXIT` and never returns leaves an identity no live task has, which reads
false the moment anything else runs.

The predicate errs one way and it is the safe one — a syscall that parked and
resumed on a CPU that finished somebody else's in between answers false, so a
panic there halts and reports rather than recovering. The `false` direction
costs a report; the `true` direction kills an innocent process. Making that
case answer true means saving and restoring the word across a context switch as
`preempt_count` is, which is `hw::KernelHw::switch`'s to do and not this.

The crash report's `Syscall:` block moves to the same predicate. That is the
second half of the entry: the block was printed off the stale word and then
`user_backtrace` walked an equally stale `syscall_rbp` through the *current*
address space.

Negative control, measured — a `panic!` staged in `timer_handler`, five Ring 3
timer fires in, on both arms of the same session:

  base:  the process is killed and the machine runs on — `FAIL
         rs::allocator_stress: exit code -1` — and the report carries
         `Syscall: num=63 user_rip=0x1000003a036` with a user backtrace into
         `dlmalloc::malloc+0x776`: a syscall that had already returned, named
         as the context of a timer interrupt.
  fixed: `PANIC ... md2 control` with no `Syscall:` block, and every CPU
         halted — "the guest went quiet because every CPU is halted".

Independent oracle: the recorded real failure this entry was reopened on
(2026-08-20, a 12-wide `bootable.img` boot-storm capture of a kernel death),
where the same stale block printed `Syscall: num=90 user_rip=0x1000003d458`
and the backtrace off `syscall_rbp` faulted — `FAULT rip=... cr2=0x0 ...
RECURSIVE` — losing the rest of the report. The control arm above reproduces
its first half from a staged panic rather than from a boot storm.

Green: syscall_cost, allocator_stress, handle_kill_policy,
cargo test --lib.

`src/prose-ledger` gains three rows deliberately: `arch/percpu.rs` 501 -> 535
(this field, its four accessors, and the whole-struct initialiser two commits
back, which is this pull request's), `arch/idt/exceptions.rs` 192 -> 195 and
`arch/syscall/gate.rs` 90 -> 95.

Closes issues/panic-path/syscall-rip-never-cleared.md. Its two citations went
with it: `arch/idt/exceptions.rs` and `sched/kthread.rs`, both of which now
state the predicate rather than the defect. No `src/redlist.rs` row is sourced
to it.
…efore it acknowledges

`apic::enable_x2apic` writes `0xFF` into the spurious-interrupt vector field on
the BSP and on every AP, and the IDT left slot 0xFF `IdtEntry::EMPTY` — `P = 0`.
A vector the CPU can deliver through a non-present gate is a contributory fault
and the CPU escalates to `#DF`, which halts the machine: the rule `9bd7a9e`
wrote above `idt_vectors!` for the range Intel names, on the one vector the
platform names instead.

`arch/idt/spurious.rs` is the gate. It is `ring0` — it reaches no task, touches
no preempt count and reschedules nothing — and it does not log, for
`arch/idt/nmi.rs`'s reason: it can arrive inside the log's own commit bracket.
What it does is the census's single `add` and then the difficult part, which is
the acknowledgment: a genuine spurious interrupt sets no ISR bit (SDM Vol. 3A
§11.9), so an unconditional `eoi()` would clear an unrelated interrupt's bit and
lose it, while the same vector reached by an IPI does go through the IRR and
does need one. The handler reads the in-service register and acknowledges only
what is in service.

`irq_census::Source::Spurious` is where the count lands, because the handler
cannot say anything itself. A non-zero column on a machine that staged nothing
is an interrupt-routing defect, and this is its only witness.

The gate is exercised on every run rather than shipped unentered. Nothing on
this host raises the vector by itself — the SDM's classic condition needs a
task-priority register this kernel never writes, and every device here is MSI or
MSI-X — so `lapic-spurious-selftest` raises it deliberately and reports three
things: that the delivery arrived and was counted, that the vector is no longer
in service afterwards, and that the CPU went on taking interrupts.

Two negative controls, each measured on its own arm of the same session:

  the gate removed, the delivery still staged: the guest never reaches
  `===READY===` and the harness reports `the console carried: nothing at all`
  — the halted machine with no name on it, which is exactly the defect.

  the gate kept, the acknowledgment removed: `LAPIC: spurious selftest FAILED —
  vector 0xff is still in service, so nothing below priority 0xF can be
  delivered on cpu0 again`, from the kernel's own arm.

Independent oracle: Intel SDM Vol. 3A — §11.9 for what a spurious interrupt is
and that it needs no EOI, §11.8.4 for an in-service bit blocking every lower
priority, and §6.14's contributory-fault table for the escalation a `P = 0`
gate produces. The tree's own recorded failure is the same mechanism on the
exception range: `9bd7a9e`'s `div` by zero took the whole guest down before
every Intel-named vector had a gate.

`lapic_spurious_vector` is a new registered name, `Sched::Parallel`,
`Tier::Fast`, committed to `tests/test-durations` with the `UNMEASURED` marker —
the one measured run it buys is by design, and the price verdict lands on the
run that measures it.

Green: lapic_spurious_vector, irq_census_conservation (whose host-side `SOURCES`
gains the same column), cargo test --lib.

`src/prose-ledger`: `arch/idt/spurious.rs` enters at 50; `arch/idt/mod.rs`
223 -> 235, `irq_census.rs` 117 -> 125, `main.rs` 328 -> 329, `actuator.rs`
436 -> 442, `arch/apic.rs` 251 -> 263, `tests/toyos.rs` 4777 -> 4794. The dated
column is untouched.

Closes issues/kernel/the-lapic-spurious-vector-has-no-gate.md. Its one citation,
in `a-double-fault-on-cpu-1-under-a-wide-suite.md`, is updated in this commit:
that reading is closed and was never the claim. The entry's last paragraph — the
other 235 empty slots — is not this vector's defect and is filed as
issues/kernel/an-unclaimed-vector-halts-the-machine-with-no-name.md, with the
control above as its evidence. No `src/redlist.rs` row is sourced to either.
…s and a hope

`bot` took `(data_phys: u64, data_len: u32)` and asserted `data_len <=
MSC_DATA_LEN` — 32 KiB — while four of its five call sites pointed at
`MSC_SCRATCH`, which is 64 bytes. The bound was in the right place with the
wrong operand: nothing related the length the device was told to move to the
buffer it was told to move it into.

The pair is one `DataPhase = Option<Dma<'static>>` now. The CBW's
`dCBWDataTransferLength` is the region's own `size()`, so a command cannot name
a length its destination does not have, and `None` is a command with no data
phase rather than a zero somebody has to read as one. `MSC_DATA_LEN` is still
asserted and now says the only thing it can: this driver rings at most 32 KiB
per transfer.

The two scratch users take a 64-byte region and narrow it — `Dma::subview` is
what refuses `read_scratch`'s `want` at the buffer, in the type that owns the
bound, rather than against a constant declared somewhere else.

Negative control, measured, both arms in one session: an INQUIRY staged to ask
for a 4,096-byte data phase into the 64-byte scratch.

  base:  `usb_storage_gate` **green**. The transfer was programmed, the stick
         bound, the volume mounted, and nothing anywhere said a word.
  fixed: `DMA: 4096 byte(s) at 0x0 run past a region of 0x40, in the region at
         0xffff800001612080` — refused at the buffer, before anything reached a
         ring.

Independent oracle, USB Mass Storage Bulk-Only Transport 1.0: §5.1 makes
`dCBWDataTransferLength` the number of bytes the *host* expects to transfer, and
§6.7.2 licenses the device to send exactly that many before the CSW. So a host
that names a length its buffer does not have has authorised the overflow; the
device declining to use it is the device's choice, not the driver's bound. That
is also why the base arm is green — QEMU's stick answers INQUIRY with 36 bytes
and short-packets — and why no test in this tree could have caught it.

Green: usb_storage_gate, usb_storage_shapes, usb_transport_break (all nightly
tier), usb_short_read, usb_storage_write_error, cargo test --lib.
`src/prose-ledger` raises `drivers/xhci/wait/msc.rs` 548 -> 561 deliberately.

Closes issues/filesystem/bot-length-assertion-binds-another-buffer.md — the USB
storage type-safety audit's F6. No citation of the slug or the path exists
elsewhere in the tree, and no `src/redlist.rs` row is sourced to it.
…t is pulled

A Device Slot is the controller's from the moment Enable Slot answers, and only
Disable Slot gives one back. The unplug half was closed: `teardown_port`
disables the slot a port carries. The half that stayed open is a device that is
refused and *stays plugged in* — a hub, a camera, a fingerprint reader, a disk
with no bulk pair, one the pool has no block for, one whose bring-up fails —
which kept its slot for the life of the boot, on eleven paths.

`device::refuse` is the one exit those paths take now: it marks the port
attached with no slot and submits Disable Slot with `AfterSlot::Refused`. Two
things it deliberately does not do. The port stays *attached*, which is
`let_go`'s answer one stage earlier — a port that read as unattached would
enumerate the same refused device again on every debounce. And the pool blocks
stay with the port until the unplug, because the port still has a device in it
and `teardown_port` is where they are released; only the slot is a resource the
controller is short of.

Where a class driver is the one refusing, it has to say so, so `msc::bind` and
`bind_hid` answer `bool`: SET_CONFIGURATION failing, Configure Endpoint failing
for a bulk pair or for a HID interrupt endpoint, a pointer past
`PointerSource::claim`'s table, and a stick that never becomes ready are
refusals that used to end in the same `finish` a bound device does.

Gate and negative control are the same test, `xhci_slot_exhaustion`, which
stages a controller clamped to one device block on a six-device bus. It gains
one assertion — that every refused device's slot is given back — and that
assertion is measured in both directions in one session:

  base:  `FAIL xhci_slot_exhaustion: 0 slot(s) disabled for 5 refused
         device(s)` — the test that describes the leak was its largest producer.
  fixed: green, 5 for 5.

Independent oracle, xHCI 1.2: §4.6.4 makes Disable Slot legal from any slot
state, which is what lets a refusal issue it against a device still in its port
and is why no path here has to reason about what state the device reached;
§4.5.1 and the Enable Slot semantics in §4.6.3 make the slot allocated at the
command's completion whatever becomes of the device. Every path between a
successful Enable Slot and a bound device was enumerated by the USB storage
type-safety audit (F12) rather than by this change, and the count is the same
eleven.

Green: xhci_slot_exhaustion, xhci_many_devices, xhci_full_speed_device,
xhci_hotplug, usb_storage_gate, usb_refused_disk_first, cargo test --lib.

`src/prose-ledger` raises four rows deliberately: `xhci/device.rs` 262 -> 279,
`xhci/mod.rs` 948 -> 956, `xhci/wait/msc.rs` 561 -> 562, `tests/toyos.rs`
4794 -> 4799.

Closes issues/hardware/xhci-slot-never-given-back.md. No citation of the slug or
the path exists elsewhere in the tree and no `src/redlist.rs` row is sourced to
it.
… stall one

There was no `restart_endpoint` for a control endpoint, so a stall halted EP0
for good. Two places reach that state and both are now answered.

**The enumeration.** QEMU's `usb-wacom-tablet` stalls SET_PROTOCOL on every boot
of the full-speed bus, and the driver binds it anyway — that tolerance is
deliberate and stays. What did not stay is going on with EP0 halted:
`toyos_xhci::enumerate` learns `Stalled` from the act and answers with two
commands of its own, Reset Endpoint and Set TR Dequeue Pointer on DCI 1. Both,
because Reset Endpoint alone leaves the controller's dequeue pointer on the TRB
that stalled and the next control transfer re-runs it. No packet goes out: the
device clears its own half on the next SETUP.

**Bulk-Only Reset Recovery.** If the class request itself stalls, EP0 is halted
and the two CLEAR_FEATUREs behind it are control transfers that can no longer
run — so a disk that broke once could never be recovered. `control_transfer` now
recovers EP0 before it reports a stall, which is the only place that knows one
happened, and every blocking caller inherits it. `run_recovery` is the loop
`quiesce_endpoint` had, lifted so EP0 reaches the same `Recovery` sequence as
the bulk and interrupt endpoints; `restart_control_endpoint` is the entry point
that drops the device half.

`EP0_DCI` is declared once. Every device's EP0 ring is at `DEV_EP0_RING` inside
its own device block, which is what lets a caller supply the block and nothing
else.

Gate and negative control are the same test, `xhci_full_speed_device`, which
boots the bus the stall really happens on. It gains two assertions — that
exactly one SET_PROTOCOL stalled, and that EP0 ran again after it — measured in
both directions in one session:

  base:  `FAIL xhci_full_speed_device: EP0 was left halted behind the stall`
  fixed: green, and the driver says `EP0 on port 6 runs again after the stall`.

The sequence half is gated where it is decided: two host tests in
`toyos-xhci/src/enumerate.rs` assert that a stalled SET_PROTOCOL produces
exactly `ResetEp0`, `SetEp0Dequeue`, `ConfigureEndpoint` in that order, and that
a device which answered pays for neither. 41 pass in that crate.

Independent oracle, two specifications rather than this tree's reasoning: USB
2.0 §9.4.5 does not define the Halt feature for the default control pipe and
§8.5.3.4 has the device clear the condition on the next SETUP — which is why
there is no CLEAR_FEATURE here and why sending one would be a request over the
endpoint that is halted. xHCI 1.2 §4.6.8 puts a stalled endpoint in Halted and
names Reset Endpoint as what leaves it, and §4.6.10 is why Set TR Dequeue
Pointer has to follow.

Green: xhci_full_speed_device, xhci_slot_exhaustion, xhci_hid_break,
usb_short_read, usb_transport_break, usb_storage_gate, `cargo test` in
toyos-xhci, cargo test --lib.

`src/prose-ledger` raises five rows deliberately: `xhci/device.rs` 279 -> 289,
`xhci/mod.rs` 956 -> 959, `xhci/wait/mod.rs` 219 -> 243, `tests/common/usb.rs`
948 -> 954 (its dated column is untouched at 4), `toyos-xhci/src/enumerate.rs`
106 -> 126.

Closes issues/filesystem/control-stall-halts-ep0.md. No citation of the slug or
the path exists elsewhere in the tree, and no `src/redlist.rs` row is sourced to
it.
The entry was filed about the panic console, whose cached framebuffer address a
driver frees under it, and that half had already moved into `gpu::set_resolution`
— it blinds the console across the window and rearms it if the driver refused.
What the entry says next is the half that was still open: "the pattern is simply
unguarded for anything that caches the address", and two things do.

`device::set_framebuffer_info` holds the description the *next* framebuffer
claim is answered with — its regions and its geometry — and it was written once,
at registration, by a driver that then changed the mode without it. Its own
comment says the second consumer out loud: the absolute pointer's per-axis scale
"is a function of the screen and has to follow a mode change", and nothing made
it. So after a successful resize the next claimant is handed the regions of the
buffer that is no longer scanned out, at the geometry before last, and a tablet
maps its coordinates onto a screen that is not there.

`gpu::set_resolution` calls it now, which is what owning the invalidation means:
a caller doing it for itself is a caller that has to know the driver freed
something. `gpu::screen` is the one constructor of that description, so
registration and a mode change cannot describe the same screen differently —
`register_gpu` was the second copy and is now three lines.

Negative control, measured, both arms in one session. No configuration the
harness boots can resize at all — measured, and it is why nothing here caught
this: `md2 probe: the resize answered Err(NotSupported)`, because every guest
takes the UEFI GOP path and GOP cannot change mode after boot services exit,
while virtio-gpu — the one driver that can — is on no profile in
`tests/common/qemu.rs`. So the control stages a GOP that accepts a mode change
and asks the registry what the next claimant would be told:

  base:  `the resize answered Ok((800, 600)) and the registry says
         Some((2048, 2048))`
  fixed: `the resize answered Ok((800, 600)) and the registry says
         Some((800, 600))`

Independent oracle, an in-tree differential rather than an argument: the two
paths that answer "what is on this screen" are the resize's own return value,
which the compositor reads (`userland/compositor/src/session.rs`), and the
device registry, which every later claim reads. They are answers to one question
and the control above is them disagreeing.

**No committed gate, and that is a property of the harness rather than a
choice**: a successful resize is unreachable from any guest it can boot, which
the first measurement above establishes. Filed as
issues/diagnostics/no-guest-can-change-the-display-mode.md so the gap is tracked
rather than implied.

Green: diskless_boot, screen_log_absent, metal_sim_input, cargo test --lib.
`src/prose-ledger` raises `kernel/src/gpu.rs` 19 -> 33 deliberately.

Closes issues/design-debt/set-resolution-frees-a-live-framebuffer.md. No
citation of the slug or the path exists elsewhere in the tree, and no
`src/redlist.rs` row is sourced to it.
`toyos-abi`'s `futex_wait` says "Returns 0 on wake, 1 on timeout" and the kernel
could produce only the first: both arms of `process::futex_wait` returned 0, and
under them `scheduler::futex_wait` returned a bare `true` for everything —
`completion::wait_until` answers `Ok(())` for a satisfied predicate and for an
expired deadline alike. So a caller could not tell a timeout from a wake, and it
broke silently, because the honest answer and the wrong one were the same
number.

The two are told apart by the word itself, which is what the wait was armed on:
after `wait_until` returns, the predicate runs once more. A word that no longer
holds `expected` is the wake this wait was for — or a writer that got there
first, or the frame going away — and a word that still holds it is a wait
nothing it was armed for has ended, which leaves the caller's own deadline.
`scheduler::futex_wait` answers a named `FutexEnd` rather than the `bool` it
had, which claimed "it parked": a question no caller asks, answered `true`
whether or not it had.

This is the entry's second option, and its own words about it are right — "the
word may have changed and changed back". That reads as a wake, which is also
what the ABI answers a caller who was woken and found the word back where it
was: a `futex_wait` return is never proof of anything but that the caller must
look again. It costs one re-evaluation of a predicate the wait has already run
at least once, against `completion::wait_until` growing an outcome and eleven
call sites having to say what they do with it.

The gate is the third question in `futex_wake_counts`, which already owns this
syscall pair, so no new name is registered. Three assertions, because any one
of them passes on a kernel answering a constant: a timed wait nobody wakes
answers 1, a wait whose word never matched answers 0, and a wait that is woken
answers 0. Only the first is timed; the woken arm waits forever and is woken by
the test itself, so no margin decides a verdict.

Negative control, measured, this kernel change reverted under the same test:
`FAIL rs::futex_wake_counts: a futex wait nobody woke answered 0, wanted the
timeout`.

Independent oracle: the ABI's own line, written before this and never met —
`toyos-abi/src/syscall.rs`, "Returns 0 on wake, 1 on timeout" — and behind it
POSIX's `pthread_cond_timedwait`, whose `ETIMEDOUT` is the caller this exists
for and which cannot be built on a primitive that answers one number.

Green: futex_wake_counts, abuse_kernel_addr, sched_stress, cargo test --lib.
`src/prose-ledger` raises three rows deliberately: `scheduler.rs` 642 -> 659,
`process.rs` 920 -> 922, `futex_wake_counts.rs` 152 -> 167.

Closes two entries, which are one defect seen twice:
issues/kernel/futex-wait-cannot-report-a-timeout.md and
issues/kernel/futex-wait-never-returns-its-timeout-code.md. The second says so
itself — it is the doc-comment half of the first. No citation of either slug or
path exists elsewhere in the tree, and no `src/redlist.rs` row is sourced to
either.
`MetalXhciBoth` exists to put both pointers on the same xHCI slot id of their
own controller, because a slot id was once used as a machine-wide name for a
device and is not one. It balanced the boot stick on the first controller with a
`usb-hub` on the second — and a hub is walked past, so what it contributed was
a *leaked* slot. The commit before last gives that slot back at the refusal, so
the second controller's devices moved down one and the premise went with them:

    FAIL xhci_two_controllers: the two pointers are on slots 3 and 2, so a
    slot-keyed merge would not have collided and this test proves nothing

That is the test declining to certify rather than a defect it found, and the
staging is what is wrong. A second keyboard on the second controller balances
the stick with a device that *binds*, which is the only kind whose slot survives
its enumeration. The hub stays, walked past as before — and now also exercising
the refusal path that gives a slot back on a bus where something else is
enumerated behind it.

Measured, alone, three arms in one session: on `16c05999` (this branch's base)
both pointers land on slot 3 and the test is green; on the branch with the
old device list they land on 3 and 2 and it reds; with the list rebalanced they
are on slot 3 again and merge as sources 1 and 2.

The machine-wide totals the test asserts move with the device list: 5 HID
devices and three keyboards.

`src/prose-ledger` raises `tests/common/qemu.rs` 1588 -> 1591 deliberately.
`syscall_window_nmi` reds in the wide phase of this branch's `cargo test` — at
1,505 s against a committed 6,825 ms, on a host carrying 92 guests and a second
worktree's suite — and its isolated re-run in the same session is green in 5 s
with the storm reported in full. `--known-red` answered NOT ON THE LIST, so the
next agent to meet it would have read it as theirs.

The row is `Instrument::DevHostLoaded`, `Finding::Seen`, no rate: one sighting
has no denominator, and this tree does not write a number it did not measure.
What the entry beside it says is what would settle it.

It is not this branch's: the syscall entry's displacements changed *spelling*
and are byte-identical machine code, and the two per-CPU stores the panic
path's bracket adds are not a 220x wall stretch. The same tip runs the test
green alone.
…reason

Placed by the orchestrator with the slot fix that revealed it: MetalXhciBoth balanced its buses with a leaked hub slot, so the fix moved both pointers and the test declined to certify. A staging device must own a resource that survives its own enumeration.
The UNMEASURED marker bought hosted run 33077119849 (guest partition green); it measured lapic_spurious_vector at 6829ms, under the Fast commitment, so the declared tier stands. The rest of the profile is unchanged.
@Japabu
Japabu marked this pull request as ready for review August 27, 2026 14:30
@Japabu
Japabu enabled auto-merge August 27, 2026 14:30
Japabu added 2 commits August 27, 2026 18:50
Both sides raised tests/common/qemu.rs and tests/toyos.rs; the merged file exceeds either arm, so the rows re-record the measured union (1607, 4848), verified by the gate below.
The merge of main brought the reshaped placement signature beside this branch's call and the union borrowed a generic argument the callee takes by value; CI clippy refused it, and this time the kernel clippy ran locally before the push.
@Japabu
Japabu added this pull request to the merge queue Aug 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 27, 2026
@Japabu
Japabu added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit 802ee90 Aug 27, 2026
26 checks passed
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