Ticket 0185. The session layer treats the bus roster as a living
thing: devices attach late, disappear without warning, and get swapped.
This guide shows how to write an application that keeps working through all
of it. Validated live end-to-end by harness/top_hotplug.spin2 (attach
event, programmatic detach of a mounted volume, remount, file I/O,
binding-integrity check) and harness/top_rootplug.spin2 (root-device
death and recovery — see below).
Coverage is symmetric since 2026-08-12 (field report from a P2 Edge
bench): the scan handles the root device of each bus too — the hub
itself, or a direct-attached device. Pulling the hub tears down its whole
subtree (leaf detach events, then a port-0 root detach); re-inserting a
root device — hub or direct — is picked up by poll() alone, no
begin() needed, without disturbing the other bus. Previously root
re-attach was never detected and a re-inserted hub left its bus dead.
The model
Three pieces, all in usb_app:
- Detection — a change scan samples every hub port's status register. Connect is a level; connect-change is a latched bit the hub holds until the host acknowledges it, so even a fast swap that ends "still connected" is caught. The scan consumes the bits properly (act, then acknowledge), so an event fires exactly once.
- Reaction — the driver fixes itself before telling you: on removal
it drops that address's class bindings, abandons a mounted filesystem
with zero I/O to the dead device, and compacts the roster; on arrival
it enumerates and auto-binds with the same selection policy as
begin(). - Notification — your handler (or
poll()'s return count) tells you it happened, so you re-establish application state: reopen files, redo autobaud, refresh a UI.
Quickstart
OBJ
usb : "usb_app"
PUB main()
usb.begin(16)
usb.on_change(@reconfigured) ' optional but recommended
repeat
usb.poll() ' idle loop: costs nothing between samples
do_work()
PRI reconfigured(kind, port, addr)
if kind == usb.EV_DETACH
' bindings for that device are ALREADY gone; fix your app state
log_lost(port)
else ' usb.EV_ATTACH
' the device is ALREADY enumerated and bound; take it into service
log_found(port, addr)
poll() is internally rate-limited to the port-scan period (250 ms — see
the timing ledger), so calling it every loop iteration is free: between
samples it returns 0 immediately. When a scan is due it runs, handles every
change, fires your handler once per change, and returns the change count.
No handler? Just use the return value and the ready-getters.
The API
| Call | Meaning |
|---|---|
poll() : n |
Rate-limited change scan; the idle-loop call. |
rescan() : n |
Force one full change scan now. |
on_change(mp) |
Bind/unbind (0) the handler (kind, port, addr). |
EV_ATTACH / EV_DETACH |
kind values (constants on usb_app). |
await_drive/serial/kbd(ms) : ok |
Block for a class, scanning while waiting. |
drive_ready() serial_ready() kbd_ready() mouse_ready() |
Truth now — they go false on detach and true again after re-attach. |
dev_count(), dev_addr/port/vid/pid(i) |
The live roster. |
hub_ports(), hub_port_status(pn) |
Port introspection (status bit 0 = connect, bit 16 = pending connect-change). |
hub_port_power(pn, on) |
Per-port power — how the self-test produces a real detach without hands. Ganged-power hubs lawfully ignore it; verify via hub_port_status(). |
Handler rules (important)
Your handler runs on whatever cog called poll()/rescan()/await_*(),
synchronously, once per change, after the driver has already reacted.
- Keep it prompt. It runs inside the scan; long work delays further change processing. Set flags, record the event, update your model — do heavy lifting back in your main loop.
- Don't start blocking USB work inside it.
drive_ready()on a cold device can take seconds (it runs the SCSI bring-up);await_*inside the handler would recurse into scanning. Cheap introspection —dev_*()roster,serial_kind(),fs_kind()— is fine. - Expect bursts. One physical action can produce several events (a power glitch can bounce a neighboring port — observed on the bench hub; the scan handles each cleanly).
What detach actually does, per class
The instant a scan sees a device gone, before your handler runs:
- Storage:
disk_*bindings cleared, cached disk-open state invalidated, and a mounted filesystem abandoned — state dropped with no I/O. Flushing to a dead device would only burn the storage error budgets (up to tens of seconds of retries); data not yet flushed is lost — that is the physics of surprise removal — and the on-media VolumeDirty bit (set at first write) records the unclean release for the next mount or a PC's fsck. Outstanding file handles become invalid: subsequentfs_read/fs_write/fs_closeon them return errors (−87/−80). Don't cache handles across detach; reopen by path after re-attach. - Serial:
ser_*binding cleared;serial_ready()false. The expect engine on top will simply time out — checkserial_ready()in your error path to distinguish "device gone" from "device silent". - HID: keyboard/mouse endpoint bindings cleared; polls return not-present.
Audio: the ISO arm flags and the frame reservation are cleared — the PHY stops servicing the rings that same frame and bulk gets its budget back.
aud_present()goes false;aud_write/aud_readreturn 0 (both guard on presence).Gamepad/joystick: binding and parsed layout cleared;
joy_present()false. On re-attach the descriptor is re-fetched and re-parsed — but your application's mapping (the storedjoy_changed()codes) is yours: re-run the wizard, or persist codes keyed bydev_vid/pidand reuse them when the same model returns.MIDI: binding cleared, buffered events flushed (a stale burst must not replay into a re-attached controller's session);
midi_present()false,midi_read()returns 0. After re-attach, re-binding is automatic and there is no rate/state to re-assert — MIDI is stateless per event.
Nested hubs (ticket 1205): one level of hub-behind-hub is walked -
combo hub+reader units enumerate their whole subtree, and every rostered
device carries its parent hub, so port sampling asks the right hub. An
inner hub's death tears down its children first (each with its own
EV_DETACH), then the inner hub itself. A hub at depth 2 still gets the
loud not-enumerated diagnostic.
When the hub itself dies (unplugged, power lost): the hub is not a
roster leaf, so it has its own liveness sample — the same
answer-a-descriptor evidence and 2-miss debounce as direct devices. On
death, every leaf behind it goes through the per-class teardown above
(EV_DETACH each, with its port), then a final EV_DETACH with
port 0 reports the root itself and the dead hub's port walk stops.
When a port fails enumeration (field 0530): the attempt is not repeated on every scan. Each consecutive failure doubles that port's retry delay (1 s first retry, 8 s ceiling), a connect-change on the port — a real replug — clears the backoff immediately, and at most one enumeration attempt runs per scan pass. A dead device on a live port used to cost the polling cog ~1 s out of every 250 ms scan (measured: 17 s per console command); now it costs one bounded attempt every backoff period.
When a root device wedges (field 0530): a root that keeps its pull-up
(line reads J) but answers nothing to three consecutive paced reset+probes
gets a real VBUS drop and restore, scoped to its bus — the same final rung
begin()'s ladder has, because a wedged device recovers only on a real
power drop (field report 2026-08-11). It fires only while nothing is
rostered on that bus, spans several scans (never blocks one poll() for
the off-dwell), and paces itself with a doubling gate on hardware whose
empty bus floats J. set_vbus_rescue(0) disables it — do that when your
application gates bus power itself, so the driver does not fight you for
the EN pin. Validated live: harness/top_rootplug.spin2 P5 — power cut
behind the driver's back, subtree re-rostered 9.9 s later by poll()
alone.
After re-attach, class bindings are restored automatically (same policy
as begin(): serial prefers vendor bridges (FTDI/CP210x/PL2303), storage/HID first-found by port order),
but application state is yours to re-establish:
- Storage: first
fs_*call re-runs disk-open + mount transparently; just reopen your files by path. - Serial: rerun
ser_open()/exp.autobaud()— the device rebooted and may be at its default rate again. - HID: nothing to redo;
kbd_ready()going true is the whole story. - Audio: the device is re-bound (
aud_present()true again) but streams do not restart themselves — rerunaud_open()/aud_mic_open(); the device rebooted, so rate, volume and mute all need re-asserting.
A rearrangement (unplug A, plug B into the same port) is simply a detach and an attach falling out of the same scan — both events fire, in that order.
Root attach: how an empty bus comes back
When a bus has no root device (hub gone, or direct device gone), each scan
samples that bus's D−/D+ line state — free, one PHY command. SE0/SE1
means nothing attached: zero cost until something arrives. J/K idle
held for two consecutive scans (symmetric with the 2-miss detach debounce)
triggers a scoped attach ladder: reset that bus only, spec recovery,
one address-0 probe. The probe answer is the attach evidence; on it, the
normal bring-up tail re-rosters the root and (for a hub) all its leaves —
EV_ATTACH fires per device, port 0 first.
One hardware honesty note: on some benches (P2 Eval + serial-host add-on,
hardware-proven 2026-08-09) a dead unpowered bus floats J, so line state
alone cannot arbitrate attach there. On such hardware the empty-bus scan
pays one ~25 ms reset+probe every ~2 s (ROOT_RETRY_SCANS, see the timing
ledger) — harmless on an empty bus, and the paced probe is the detector.
On hardware whose detached bus reads SE1 (P2 Edge + #64006B, per the field
report), attach shows up as the SE1→J transition and the empty bus costs
nothing.
Validated live by harness/top_rootplug.spin2 (this rig's EN pin gates hub
power, making root removal programmatic): hub death → 3 leaf + 1 root
detach events, roster drained; 6 s floating-J soak with zero phantom
attaches; EN restore → port-0 EV_ATTACH + full subtree re-rostered via
poll() alone in 1.1 s; all addresses answering after (RESULT PASS).
Choosing your waiting style
- Event-driven (recommended for long-running apps):
on_change+poll()in the idle loop. Nothing blocks; your app reacts when reality changes. - Blocking (fine for bring-up phases):
await_drive(15_000)— "I need storage before I can do anything anyway." It scans while it waits, so late attachers (the bench SSD asserts connect 6–9 s after power) arrive during the wait. - Both compose:
begin()→await_*for the essentials → event loop for the lifetime.
Cogs and concurrency
The simple, recommended shape: run poll() on the same cog that uses the
affected classes (typically your main loop cog). Scans and transfers are
individually safe from any cog (everything serializes on the host lock),
but if one cog is inside a storage call when another cog's scan processes
that device's removal, the in-flight call ends with I/O errors while the
teardown happens under it — harmless in practice (errors were inevitable;
the device is gone) but noisier to reason about. Single-cog polling avoids
the interleaving entirely.
Two buses (ticket 1160)
Nothing changes for your handler. After begin2(), poll()/rescan() scan
both buses in one pass; events carry the device address, and addresses are
per-bus (bus 0: 1..7, bus 1: 8..15), so app.dev_bus() or a simple
addr >= 8 tells you which header the event came from. Port numbers repeat
across buses — always pair a port with its bus when logging.
Ganged-power hubs and direct attach
hub_port_power()requires per-port power switching. Many hubs gang their ports; the request then succeeds but changes nothing — checkhub_port_status()if you depend on it. (Real unplug/replug detection is unaffected either way.)- With a device attached directly (no hub) there is no port register; liveness is the device answering its address, and a detach is declared after two consecutive missed samples (debouncing one transient failure — see the timing ledger).
Troubleshooting
| Symptom | Likely cause |
|---|---|
No EV_DETACH when you cut port power in software |
Ganged-power hub — the port never lost power. Verify with hub_port_status() bit 8/0. |
| Periodic brief bus resets on an empty bus | Expected on hardware whose dead bus floats J (see above): the root-attach pass probes at the ROOT_RETRY_SCANS cadence because the line cannot prove absence. Harmless — nothing is attached to disturb. |
| Periodic VBUS drops on an empty bus | Same floating-J hardware: the silent probes look like a wedged root, so the rescue rung fires — at a doubling cadence that settles near ~48 s. Harmless (nothing attached), or set_vbus_rescue(0) to silence it. |
A drive drops off the roster and rescan() never brings it back |
Before 0530 this needed a reboot (begin()'s ladder has the VBUS rung; rescan() didn't). Now the rescue rung recovers it within ~10 s. If it still stays gone: the device reads SE0 (its pull-up is truly gone) — reseat the cable/power. |
| Attach/detach pairs repeating | Marginal cable/power: the device brown-outs on inrush, drops, retries. The scan handles it, but fix the hardware. |
Device present but no EV_ATTACH at startup |
It was attached before begin() — already in the roster, no event. Events report changes; read the roster for the initial state. |
fs_* returns −87 after a replug |
Stale handle from before the detach. Reopen by path. |
| Serial timeouts after a replug | The device rebooted to its default baud — rerun autobaud(). |
Worked example
harness/top_hotplug.spin2 exercises every path above in one run and is
the reference for the patterns: roster display, event handler, blocking and
event-driven waits, the port-power detach of a mounted volume, remount with
real file I/O, and a post-cycle check that every binding still points at a
live address.
When it isn't a hot-plug
A device that stops answering without a detach event has not been unplugged — it has wedged, and the recovery path is a different one. See getting-started/recovery.md: the hub-disabled port and the wedged device look alike in the logs and are told apart only by the port's ENABLE bit.