Artemis Milestone 2h: hot-detach -- 2h complete

blk_subsys_detach_device() (block_subsystem.c) walks the device chain,
refuses removal of anything but the current tail (a mid-chain removal
would corrupt every later slot's start_lbn -- this architecture's own doc
already argues USB stays last specifically to avoid that), unlinks,
shrinks total_user_lbn, closes and frees the slot. Discards rather than
flushes dirty state -- the device is physically gone by the time this
runs (PORTSC disconnect only). Trigger wiring mirrors the attach path:
bot_msc_attached (set only once attach actually succeeds) gates a new
bot_msc_detach_pending flag set at PORTSC disconnect (not Disable Slot
completion, which is conditionally skipped and would miss concurrent
connect/disconnect pairs), consumed in sk_repl_idle().

Advisor flagged the real hazard ahead of time: block_words.c's VM block
window (blk_vm_lbn[]/blk_vm_cbuf[]) can go stale across a detach then a
same-LBN re-attach, and suggested a pointer-identity re-check in
blk_vm_load() as a minimal fix. That fix was implemented, then directly
falsified by its own designed-for-this test: attach a blank device, read
a block (populating the cache), detach, re-attach a device with distinct
content at the identical LBN, read again -- served stale content from
the first device. Root cause, confirmed live: glibc's allocator hands
free(slot) straight back to the very next same-size calloc(), so the
"fresh" and stale pointers were bitwise identical despite being two
different devices. Fixed properly with a monotonic blk_subsys_epoch()
counter (bumped on every attach/detach) checked by a new
blk_vm_check_epoch() helper at the one choke point (blk_vm_find(), plus
blk_vm_flush_all() which reads the same arrays directly) that covers
every path touching the window cache -- unfooled by address reuse.

Verified live with a new disk/usb-thumbdrive-test2.img fixture (distinct
content from the existing blank test image): attach A, read (cache hit
populated), detach, re-attach B at the same LBN, read again -- correctly
ran a fresh device read and returned B's real content, not A's stale
cached zeros. The failing pointer-comparison attempt's own capture log
kept as evidence, not deleted. All three architectures re-verified clean.
FABRIC-2.md Section X 2h marked complete -- enumeration through
hot-detach all live and verified; only WRITE(10) (2g's own still-open
item) remains unimplemented in the driver, not blocking anything here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CXjAPTEKrgY2Mrk25KoLDn
This commit is contained in:
Robert Allan James
2026-08-25 14:10:05 -04:00
co-authored by Claude Sonnet 5
parent 3b085dd875
commit af267a52a6
22 changed files with 46979 additions and 13 deletions
+76 -3
View File
@@ -3850,9 +3850,82 @@ call site of its own — USB is inherently hotplug, so a boot with no device con
none of this new code, same as every earlier BOT increment's own probe-free acceptance runs):
`logs/20260825-124143/amd64/`, `logs/20260825-124323/aarch64/`, `logs/20260825-124909/riscv64/`.
Still ahead for 2h: only the hot-**detach** path remains — `chain_append()` only adds, there is
no removal function yet (Section V area A's identity-derived-offset work in Milestone 3 is a
separate, later concern, not blocking here).
**Hot-detach, done 2026-08-25 — 2h is complete.** `blk_subsys_detach_device()`
(`block_subsystem.c`) walks `g.head` matching `slot->dev == dev`, refuses (`BLK_EINVAL`) unless
the matched slot is the chain tail (this file's own architecture doc already argues USB/future
devices are always last precisely so a removal never renumbers another slot's `start_lbn` — a
mid-chain removal would corrupt everything after it, so this is refused outright rather than
attempted), then unlinks, `g.total_user_lbn -= slot->user_blocks`, `blkio_close()`,
`free(slot->bam)`, `free(slot)`. Deliberately discards rather than flushes any dirty cache/BAM/
vol_meta state — the device is physically gone by the time this runs (called only after a real
PORTSC disconnect), so a flush attempt cannot succeed; revisit if a future graceful-unmount path
(as opposed to today's only path, a surprise removal) wants a best-effort flush first.
Trigger wiring mirrors the attach path's own shape: a new `bot_msc_attached` flag (`xhci_dev_t`)
is set once `blk_subsys_attach_device()` actually succeeds (not by SET_CONFIGURATION itself —
attach can fail, e.g. a bad capacity query, in which case there's nothing to detach later); the
PORTSC disconnect handler sets `bot_msc_detach_pending` when `bot_msc_attached` is set, and
`sk_repl_idle()` consumes it by calling `blk_subsys_detach_device()`. Deliberately hooked at
disconnect itself, not Disable Slot completion — Disable Slot is only even issued when
`connect_state == XHCI_CONN_IDLE` (see the existing "command ring busy" skip path a few lines
above), so hooking there would silently miss a detach on a concurrent connect/disconnect pair.
Unlike attach, detach needs no device round-trip (pure `block_subsystem.c` bookkeeping), so it
doesn't strictly need `xhci_poll_events()`'s own call frame to have already returned — handled
in `sk_repl_idle()` anyway, for shape symmetry and to keep `xhci.c` decoupled from
`block_subsystem.c`.
**A second, more serious bug found live, past what advisor review alone caught.** Advisor
flagged the real hazard correctly ahead of time: `vm->blk_vm_lbn[]`/`blk_vm_cbuf[]`
(`block_words.c`'s VM block window, `BLOCK`/`BUFFER`/`UPDATE`'s cache) can go stale across a
detach-then-reattach-at-the-same-LBN, since `block_subsystem.c`'s chain always appends at the
current tail — and recommended a pointer-identity check in `blk_vm_load()`'s cache-hit path
(re-resolve `blk_get_buffer()`, compare against the stored `cbuf`, treat a mismatch as a miss) as
a minimal fix requiring no new API. That fix was implemented, then **directly falsified by its
own designed-for-this test**: attach device A (blank), detach, re-attach device B (distinct
content) at the identical LBN range, read that LBN — pointer comparison passed the check (i.e.
called it a hit) and served **stale content from device A** anyway. Root cause: glibc's
allocator hands `free(slot)` in `blk_subsys_detach_device()` straight back to the very next
same-size `calloc(1, sizeof(*slot))` in `blk_subsys_attach_device()`, with nothing else allocated
in between — confirmed live in this exact session, not inferred — so the "fresh" pointer and the
stale one were bitwise identical despite belonging to two different physical devices. A pointer
comparison cannot distinguish "still the same live device" from "a different device that
happened to land at the same address" when the allocator is this deterministic.
Fixed properly with a monotonic `blk_subsys_epoch()` counter (`block_subsystem.c`, `uint64_t
g.epoch`, bumped in `blk_subsys_attach_device()`, `blk_subsys_add_raw_device()`, and
`blk_subsys_detach_device()`) that cannot be fooled by address reuse the way a pointer comparison
was. `block_words.c` gained `blk_vm_check_epoch()`, called at the top of both `blk_vm_find()` and
`blk_vm_flush_all()` (the only two functions reading `vm->blk_vm_lbn[]`/`vm->blk_vm_cbuf[]`
directly rather than through `blk_vm_find()` first) — any epoch change since the VM's window was
last validated discards every cached slot outright, no flush attempt, matching
`blk_subsys_detach_device()`'s own "device is already gone" reasoning. `blk_vm_evict()`,
`blk_vm_load()`'s and `blk_vm_assign()`'s own miss paths, and `block_word_update()` all route
through `blk_vm_find()` first, so a single choke point covers every path that could otherwise
read or write through a stale slot. A new `blk_vm_epoch` field on `VM` (`vm.h`) tracks the last
validated epoch per VM; zero-initialised by `memset(vm, 0, sizeof(*vm))` at VM creation,
consistent with `g.epoch` itself starting at 0 — no false invalidation on first use.
Verified live: attach `disk/usb-thumbdrive-test.img` (blank) at LBN 26074, read block 26074
(populates the VM window cache with the blank content — critical, this is what actually exercises
the cache-*hit* path rather than a miss), detach, re-attach `disk/usb-thumbdrive-test2.img` (new
fixture, distinct repeating `HOTDETACH-REATTACH-FIXTURE-2026-08-25--` content, added this
increment — see `disk/README.md`) at the identical LBN range, read block 26074 again. The second
read correctly ran a full fresh TUR+READ10 cycle against the device (not a cached-hit shortcut)
and `26074 BLOCK 64 TYPE CR` printed `HOTDETACH-REATTACH-FIXTURE-2026-08-25--...` — the new
device's real content, not the stale zeros a working pointer-comparison fix would have kept
serving. `logs/20260825-134747/amd64/` is that capture (also contains the `blk: detaching disk`
line and the two `blk: disk ... LBN 26074..75184` attach lines, byte-identical between the two
different backing devices, confirming the LBN-reuse premise itself). `logs/20260825-133413/amd64/`
is the earlier run that caught the pointer-comparison bug in the first place — kept as evidence,
not deleted, same as this project's established convention for a capture that found a real bug
rather than just confirming success. All three architectures re-verified clean, nothing
hot-attached: `logs/20260825-135600/amd64/`, `logs/20260825-140038/aarch64/`,
`logs/20260825-140626/riscv64/`.
Milestone 2h is now complete: enumeration, BOT transport, TEST UNIT READY sequencing, READ
CAPACITY(10), the `blkio_usb.c` backend, connect-time attach, and hot-detach are all live and
verified on all three architectures. WRITE(10) remains unimplemented in the xHCI driver itself
(2g's own still-open item) — a future increment, not blocking anything here.
### Milestone 3 — Block subsystem extensions (Section U items 3-6, Section V area A)