6 Commits
Author SHA1 Message Date
Daniel Samson d4f8dc51b9 docs: the hot-plug matrix is complete — 124/124 2026-08-09 14:19:39 +01:00
Daniel Samson 36ca3f98b3 test: H4 + H5 — a moved device is a new device, and generations do not leak
The hot-plug matrix's last two cells. H4: unplug from hub port 1.1, replug
on 1.2 — per-port identity means the old child is removed and reaped while
the new port binds a fresh driver; nothing ties a driver to the old port.
H5: three unplug/replug cycles on one port — three reaps, a bind after the
last, proving slots, the bus open table, and the manager's driver entries
are all reusable across generations.
2026-08-09 14:11:05 +01:00
Daniel Samson c3604429d4 test: H3 — a nested hub tree yanked whole rebuilds whole (hot-plug matrix)
hub -> hub -> keyboard, one device_del of the outer hub: the H2 recursion
runs at depth two (the inner hub is torn down as a child, its keyboard
first), the keyboard's driver is reaped, and re-adding all three rebinds.
2026-08-09 14:09:19 +01:00
Daniel Samson 77e7001878 usb: a hub yanked from a root port takes its subtree with it (H2)
The hot-plug matrix's predicted bug, found on first contact: tearDownPort
never recursed into a departing hub's children — only tearDownHubDevice
(a hub leaving one level down) did. Yank a populated hub from a root port
and the downstream slots stayed live against vanished hardware, their class
drivers were never reaped, and the replugged hub found its port still
occupied, so nothing ever re-enumerated: the subtree was gone for the boot.

tearDownPort now recurses children-first, exactly like tearDownHubDevice.
The usb-hub-yank case is the discrimination: one device_del removes a hub
carrying a keyboard AND a mouse, both drivers must be reaped, and the
re-added hub must rebind both — it failed against the unfixed bus and
passes with the recursion.
2026-08-09 14:08:15 +01:00
Daniel Samson a6a3402d92 test: H1 — root-port unplug and replug (hot-plug matrix)
The tearDownPort path, exercised for the first time with a real removal and
return: QEMU raises no root-port change events, so the bus's 250 ms
reconcile tick notices the PORTSC change alone — and it does. Reap, re-add,
rebind, second generation binds on the same port. Discrimination: against
the pre-reap manager (4a2df58~1) the case fails at the dedupe wall.
2026-08-09 14:03:02 +01:00
Daniel Samson d565a6b845 docs: the hot-plug matrix plan — unplug anything, replug anywhere 2026-08-09 13:58:29 +01:00
3 changed files with 191 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# The hot-plug matrix: unplug anything, replug anywhere
*Status: COMPLETE, 2026-08-09, same day — H1..H5 all green, full suite 124/124.
The predicted cascade bug was real and found by H2: `tearDownPort` did not recurse
into a hub yanked from a root port, so its subtree stayed live against vanished
hardware and the replugged hub found its port occupied; fixed by mirroring
`tearDownHubDevice`'s children-first recursion (77e7001). Every other cell passed
against the establishment-track machinery unchanged.*
*2026-08-09. The requirement, verbatim: "i want to be able to unplug devices and
plug them back in, in any order and on any hub." The machinery exists end to end —
per-device driver processes, cascading teardown (`tearDownHubDevice` recurses a
departing hub's subtree, children first), reap-on-removal in the manager, idempotent
per-port registration identity, lineage rebinding — but only one cell of the matrix
is verified: a leaf keyboard behind a hub, unplugged and replugged on the same port
(`usb-hub-unplug`). This plan verifies the rest and fixes what verification flushes
out. The cascade path has never executed; expect it to carry at least one bug.*
**Out of scope, stated up front:** unplugging the BOOT stick (the drivers handle it;
fat holds a dead block channel until M21 remount gives it re-acquisition), and
real-hardware root-port timing (the Ryzen bench is the acceptance run for that, as
always — QEMU proves the logic, not the silicon).
## The discrimination lever
Every case's rebind tail (— reaping → delegated → second-generation `ok`) is
impossible without the manager's reap: stashing the reap out of
`onChildRemoved`/`pruneChildrenOf` makes every matrix case fail at the dedupe wall.
That is the standing discrimination check for the whole matrix — run it once per
case shape, not per commit.
## The matrix
| Case | Topology | Event sequence | What it proves |
|---|---|---|---|
| H1 `usb-root-replug` | keyboard on a 2nd controller's ROOT port | del @8s, add @14s (same port) | the `tearDownPort` path + reap + rebind; root-port changes arrive via the 250 ms reconcile tick (QEMU raises no root-port events) |
| H2 `usb-hub-yank` | hub on 2nd controller; keyboard AND mouse behind it | del the HUB @8s; re-add hub @14s, kbd @17s, mouse @18s | the recursive cascade: one event removes the subtree, EVERY bound driver is reaped, and the rebuilt hub re-binds both |
| H3 `usb-hub-nested-yank` | hub → hub → keyboard | del the OUTER hub @8s; re-add all three @14–18s | the cascade recursion depth ≥ 2, and a nested rebuild |
| H4 `usb-replug-moved` | keyboard behind hub port 1.1 | del @8s; add on port 1.2 @14s | a replug on a DIFFERENT port is simply a new device: new port identity, new id, fresh match — no stale state ties a driver to the old port |
| H5 `usb-replug-cycles` | keyboard behind hub | del/add three times (6 hooks) | no state leaks across generations: slots, the bus's open table, driver entries (reaped entries must be reusable) |
Ordered expects follow the `usb-hub-unplug` shape: the boot devices' `ok` lines all
precede the first unplug, so a tail of `removed → reaping → delegated → ok` can only
be satisfied by the generation the sequence created. H2/H3 require one `reaping` per
bound child; H5 requires three.
## Order and discipline
H1 → H2 → H3 → H4 → H5, one commit per case (plus its fix if it finds a bug — the
case and the fix land together, the case having failed first). The standing
discrimination run once for H1 and once for H2's shape. Full suite green at the end,
docs touched where behavior was corrected, memory updated. Fixes stay within the
settled design: teardown order (children first), reap-on-removal, identity per port
— anything design-shaped that surfaces stops the loop and asks.
@@ -490,6 +490,14 @@ fn deviceIsHub(usb_device: *const library.Device) bool {
fn tearDownPort(manager: ipc.Handle, engine: *library.Controller, port: u32) void {
const usb_device = engine.deviceOnPort(port) orelse return;
std.log.info("port {d} disconnected", .{port});
// A hub yanked from a root port takes its whole subtree with it — children
// first, recursively, exactly as tearDownHubDevice does for a hub leaving
// one level down. Without this, the downstream slots stayed live against
// vanished hardware, their class drivers were never reaped, and the
// replugged hub found its port still occupied — nothing re-enumerated.
if (usb_device.is_hub) {
while (engine.nextChildOf(usb_device.slot_id, 0)) |child| tearDownHubDevice(manager, engine, child);
}
for (usb_device.interfaces[0..usb_device.interface_count]) |*interface| {
if (interface.registered_device_id == 0) continue;
reportRemoved(manager, (@as(u64, port) << 8) | interface.number, .{ .port = port, .interface = interface.number });
+129
View File
@@ -984,6 +984,135 @@ CASES = [
r"[\s\S]*device-manager: delegated device \d+ to /system/drivers/usb-hid-keyboard"
r"[\s\S]*usb-hid-keyboard: ok)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Hot-plug matrix H1 (docs/hot-plug-matrix-plan.md): ROOT-port unplug and
# replug on the same port. Different teardown path than the hub case
# (tearDownPort, not tearDownHubDevice), and QEMU raises no port-change
# events for root ports — the bus's 250 ms reconcile tick must notice the
# PORTSC change on its own. The ordered tail (removed → reaping → delegated
# → ok) can only be the second generation: every boot device's ok precedes
# the unplug.
{"name": "usb-root-replug",
"build_case": "usb-hid",
"smp": 4,
"timeout": 150,
"qemu_extra": ["-device", "qemu-xhci,id=xhci2",
"-device", "usb-kbd,bus=xhci2.0,port=2,id=rkbd"],
"qmp_sequence": [
{"delay": 8, "command": "device_del", "arguments": {"id": "rkbd"}},
{"delay": 14, "command": "device_add",
"arguments": {"driver": "usb-kbd", "bus": "xhci2.0", "port": "2", "id": "rkbd2"}},
],
"expect": r"(?s)(?=.*usb-hid-keyboard: ok \(device (\d+)\b"
r"[\s\S]*port \d+ disconnected"
r"[\s\S]*device-manager: child removed"
r"[\s\S]*device-manager: reaping \S*usb-hid-keyboard"
r"[\s\S]*device-manager: delegated device \d+ to /system/drivers/usb-hid-keyboard"
r"[\s\S]*usb-hid-keyboard: ok)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Hot-plug matrix H2 (docs/hot-plug-matrix-plan.md): yank a POPULATED hub.
# One device_del removes the hub with a keyboard and a mouse behind it — the
# bus's recursive teardown (tearDownHubDevice, children first) must report
# every downstream interface removed, the manager must reap BOTH bound
# drivers, and re-adding the hub and both devices must rebuild the subtree.
# The reaping lines only exist post-yank, so `reaping X ... X: ok` chains
# are unambiguously second-generation.
{"name": "usb-hub-yank",
"build_case": "usb-hid",
"smp": 4,
"timeout": 150,
"qemu_extra": ["-device", "qemu-xhci,id=xhci2",
"-device", "usb-hub,bus=xhci2.0,port=1,id=yhub",
"-device", "usb-kbd,bus=xhci2.0,port=1.1,id=ykbd",
"-device", "usb-mouse,bus=xhci2.0,port=1.2,id=ymouse"],
"qmp_sequence": [
{"delay": 8, "command": "device_del", "arguments": {"id": "yhub"}},
{"delay": 14, "command": "device_add",
"arguments": {"driver": "usb-hub", "bus": "xhci2.0", "port": "1", "id": "yhub2"}},
{"delay": 17, "command": "device_add",
"arguments": {"driver": "usb-kbd", "bus": "xhci2.0", "port": "1.1", "id": "ykbd2"}},
{"delay": 18, "command": "device_add",
"arguments": {"driver": "usb-mouse", "bus": "xhci2.0", "port": "1.2", "id": "ymouse2"}},
],
"expect": r"(?s)(?=.*hub device slot \d+ disconnected)"
r"(?=.*device-manager: reaping \S*usb-hid-keyboard[\s\S]*usb-hid-keyboard: ok)"
r"(?=.*device-manager: reaping \S*usb-hid-mouse[\s\S]*usb-hid-mouse: ok)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Hot-plug matrix H3 (docs/hot-plug-matrix-plan.md): yank a NESTED hub tree.
# hub → hub → keyboard, one device_del of the outer hub — the recursion must
# go two levels (inner hub torn down as a child, ITS keyboard first), the
# keyboard's driver reaped, and re-adding all three rebuilds and rebinds.
{"name": "usb-hub-nested-yank",
"build_case": "usb-hid",
"smp": 4,
"timeout": 150,
"qemu_extra": ["-device", "qemu-xhci,id=xhci2",
"-device", "usb-hub,bus=xhci2.0,port=1,id=ohub",
"-device", "usb-hub,bus=xhci2.0,port=1.1,id=ihub",
"-device", "usb-kbd,bus=xhci2.0,port=1.1.1,id=nkbd"],
"qmp_sequence": [
{"delay": 8, "command": "device_del", "arguments": {"id": "ohub"}},
{"delay": 14, "command": "device_add",
"arguments": {"driver": "usb-hub", "bus": "xhci2.0", "port": "1", "id": "ohub2"}},
{"delay": 17, "command": "device_add",
"arguments": {"driver": "usb-hub", "bus": "xhci2.0", "port": "1.1", "id": "ihub2"}},
{"delay": 20, "command": "device_add",
"arguments": {"driver": "usb-kbd", "bus": "xhci2.0", "port": "1.1.1", "id": "nkbd2"}},
],
"expect": r"(?s)(?=.*hub device slot \d+ disconnected)"
r"(?=.*device-manager: reaping \S*usb-hid-keyboard[\s\S]*usb-hid-keyboard: ok)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Hot-plug matrix H4 (docs/hot-plug-matrix-plan.md): replug on a DIFFERENT
# port. Registration identity is per-port, so a moved device is simply a new
# device: the old child is removed and its driver reaped; the new port's
# child gets a fresh id and a fresh driver. Nothing may tie a driver to the
# old port. The tail (reaping → hub slot N port 2 device → delegated → ok)
# is unambiguous: port 2 of the hub had nothing before the move.
{"name": "usb-replug-moved",
"build_case": "usb-hid",
"smp": 4,
"timeout": 150,
"qemu_extra": ["-device", "qemu-xhci,id=xhci2",
"-device", "usb-hub,bus=xhci2.0,port=1,id=mhub",
"-device", "usb-kbd,bus=xhci2.0,port=1.1,id=mkbd"],
"qmp_sequence": [
{"delay": 8, "command": "device_del", "arguments": {"id": "mkbd"}},
{"delay": 14, "command": "device_add",
"arguments": {"driver": "usb-kbd", "bus": "xhci2.0", "port": "1.2", "id": "mkbd2"}},
],
"expect": r"(?s)(?=.*device-manager: reaping \S*usb-hid-keyboard"
r"[\s\S]*hub slot \d+ port 2 device:"
r"[\s\S]*device-manager: delegated device \d+ to /system/drivers/usb-hid-keyboard"
r"[\s\S]*usb-hid-keyboard: ok)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# Hot-plug matrix H5 (docs/hot-plug-matrix-plan.md): three unplug/replug
# cycles of the same keyboard on the same hub port. Each cycle must reap the
# old generation and bind a new one — three reaping lines, and a bind after
# the last — proving no state leaks across generations: xHCI slots, the
# bus's open table, and the manager's driver entries must all be reusable.
{"name": "usb-replug-cycles",
"build_case": "usb-hid",
"smp": 4,
"timeout": 150,
"qemu_extra": ["-device", "qemu-xhci,id=xhci2",
"-device", "usb-hub,bus=xhci2.0,port=1,id=chub",
"-device", "usb-kbd,bus=xhci2.0,port=1.1,id=ckbd0"],
"qmp_sequence": [
{"delay": 8, "command": "device_del", "arguments": {"id": "ckbd0"}},
{"delay": 12, "command": "device_add",
"arguments": {"driver": "usb-kbd", "bus": "xhci2.0", "port": "1.1", "id": "ckbd1"}},
{"delay": 18, "command": "device_del", "arguments": {"id": "ckbd1"}},
{"delay": 22, "command": "device_add",
"arguments": {"driver": "usb-kbd", "bus": "xhci2.0", "port": "1.1", "id": "ckbd2"}},
{"delay": 28, "command": "device_del", "arguments": {"id": "ckbd2"}},
{"delay": 32, "command": "device_add",
"arguments": {"driver": "usb-kbd", "bus": "xhci2.0", "port": "1.1", "id": "ckbd3"}},
],
"expect": r"(?s)(?=.*device-manager: reaping \S*usb-hid-keyboard"
r"[\s\S]*device-manager: reaping \S*usb-hid-keyboard"
r"[\s\S]*device-manager: reaping \S*usb-hid-keyboard"
r"[\s\S]*device-manager: delegated device \d+ to /system/drivers/usb-hid-keyboard"
r"[\s\S]*usb-hid-keyboard: ok)",
"fail": r"DANOS-TEST-RESULT: FAIL"},
# The kernel VFS root (M-F): the mount table serves the initrd at /system —
# path resolution, node status/read (an ELF magic), and directory listing,
# asserted kernel-side.