Parallax Propeller2 USB driver - the MashUp

Recovering a wedged drive — reset, re-bind, and proving it worked
Login

Recovering a wedged drive — reset, re-bind, and proving it worked

Status: validated live on a 4-port hub carrying three FAT32 thumb drives and one exFAT SSD. Every behaviour below was measured on hardware, not inferred.

USB mass-storage devices wedge. Cheap thumb drives wedge more than most, and they do it in firmware, where nothing the host sends can always fix it. This guide is about what the driver does when that happens, what it asks you to decide, and — the part most people get wrong — how to tell a recovered drive from one that merely looks recovered.

Read storage.md first for ordinary file work. This page is the unhappy path.


The one-minute version

ifnot usb.disk_read(lba, 1, @buf)
  if usb.disk_wedged()                       ' the driver has GIVEN UP
    handle_dead_drive()                      ' your policy decision
  else
    ' still retrying internally - a later call may well succeed

Those two are not interchangeable, and that is the whole point. Before the retry was bounded, one wedged stick turned a bounded workload into an unbounded one: a measured run spent 144 seconds on eight doomed operations. Now the same failure resolves in about 16 seconds.


Two kinds of wedge, and the only thing that tells them apart

When storage stops answering there are two genuinely different faults underneath, and the discriminator is the hub port's ENABLE bit — only that.

ps := usb.hub_port_status(port_number)
Port status Recovery detail What it is What fixes it
Class A $0002_0101not enabled rst=0 clrO=0 clrI=0 The hub disabled the port. Nothing crosses a disabled port, so all three recovery transfers fail. Self-healing. Ordinary poll() re-enables and re-enumerates in ~3 s.
Class B $0000_0103enabled rst=0 clrO=1 clrI=1 The device's mass-storage state machine is wedged; its port is fine. Both endpoint halts clear, so ep0 is alive — the device is refusing the class reset specifically. No host packet fixes it. Port power cycle, or a human.

Do not judge by the rst/clrO/clrI triple. It is tempting and it is wrong; reading it that way cost this project an afternoon. The triple describes what the device answered — but on a disabled port nothing can answer at all, so a disabled port and a dead device produce similar-looking triples for completely different reasons. Check the ENABLE bit first, then read the triple.

These are two outcomes, and quite possibly of one underlying mechanism. Class B is not "firmware we live with"; that conclusion was drawn once and withdrawn.


The recovery ladder

The driver escalates in stages, weakest first. Which rung works is the diagnosis — that is why they are separate calls rather than one "fix it" button.

Rung 0 — do nothing but poll (free, always try first)

repeat 300
  usb.poll()                                 ' rescan() lives in here
  waitms(100)

A hub-disabled port recovers here and nowhere else is needed. poll() notices the disabled port, drives a real port reset, and the device re-enumerates. No privileged call, nothing an ordinary application does not already do.

The device comes back at a NEW BUS ADDRESS. See Address is not a name — this trips up nearly everyone.

Rung 1 — port reset, no power change

usb.hub_port_reset(pn)

Exposed separately on purpose. A device that returns from a bare reset was never unpowered; one that needs the power drop tells you the reset is not reaching it. Collapse the two and "the port cycle fixed it" tells you nothing about why.

Rung 2 — port power cycle

ok := usb.hub_port_recycle(pn, 3_000)        ' 1 = power really dropped

For a device wedged so hard it no longer enumerates at all — there is no roster entry left to recover from. Check the return value. It is 0 when the hub gangs its power rail, and without checking it you cannot distinguish "the power cycle did not help" from "the power cycle never happened."

Note this call issues a port reset after restoring power. Power alone does not re-enable a port the hub disabled (USB 2.0 §11.5.1.4). Restoring power and waiting is exactly what failed at both 3 s and 15 s dwells on this bench, and it is why the fault once looked like dead media.

Rung 3 — recover the bound device by identity

ok := usb.drive_recover(3_000)

Cycles the bound device's hub port and re-binds the same physical media — identity is captured before the power drop, so a bystander drive can never be bound in its place. Selection policy is forced sticky through the outage and restored afterwards.

Returns 0 immediately, rather than pretending, when:

A mounted filesystem does not survive. The detach abandons it, and you must fs_mount() again — deliberately, because a volume whose device power-cycled mid-write needs you to decide what to trust.


Automatic escalation is opt-in, and should stay that way

prev := usb.disk_auto_recover(1, 3_000)      ' ON  - soak only
usb.disk_auto_recover(prev, 3_000)           ' restore

Off by default. When on, disk_read()/disk_write() may escalate a wedged device to drive_recover() instead of failing.

Production code should leave this off. A power cycle is a genuine detach: unflushed writes are lost, mounts die, the device returns at a new address. An application that did not ask for that happening inside an ordinary block read is not being helped.

Unattended soak should turn it on. Nobody is watching, the alternative is the run ending at the first wedge, and the recovery itself is a data point. A soak that stops at the first wedge collects one sample per session; one that recovers and continues collects many.

Escalation is attempted once per failed operation, only once the MSC layer has actually given up, never re-entrantly.

Clearing the wedged latch

disk_wedged() latches. It is cleared by re-binding the device — drive_select() / drive_recover() both do this as part of a fresh start. There is currently no disk_clear_wedge() on the usb_app facade; the MSC layer's clear_wedge() is reached only through a re-bind. If you want the latch cleared without a re-bind, that call needs exposing — it is a real gap, not an intentional restriction.


Address is not a name

A device that re-enumerates comes back at a NEW BUS ADDRESS. This is not a quirk; an address is only issued by SET_ADDRESS during enumeration, so a changed address is the proof that a real reset happened. Measured on this bench: address 3 → 6.

Never re-bind by address after a recovery. Match by serial number:

PRI find_serial(p_ser) : ix | k, n
  ix := -1
  if usb.dev_count() > 0                     ' the roster can be EMPTY
    repeat k from 0 to usb.dev_count() - 1
      bytefill(@sbuf, 0, 64)
      n := usb.dev_serial(k, @sbuf, 63)
      if (n > 0) and strcomp(@sbuf, p_ser)
        return k

Then pin it so no rescan can swap it out underneath you:

usb.drive_select_sticky(usb.dev_addr(ix))

Never let auto-binding pick your drive on a multi-device rig. First-found is whatever answers first — on this bench, the SSD. A FAT32 program that trusts auto-binding fails setup in a way that looks exactly like a driver bug.

One more trap: dev_serial() reads a string descriptor, which is a control transfer to the device. A rostered-but-mute device fails that lookup exactly like an absent one. Report the two differently — check hub_port_status() and whether the port still holds a rostered device before concluding "it's gone."


Releasing a mount: fs_abandon(), not fs_unmount()

usb.fs_abandon()                             ' ZERO device I/O

fs_unmount() is not read-only. It syncs handles and rewrites both FSInfo sectors (primary and the backup at VBR+7). If the device is unreachable right now — a disabled port, a wedged stick — every byte of that is a doomed transfer that buries the evidence under recovery noise, and on unidentified media it is a write you did not intend.

Use fs_abandon() whenever you are releasing a mount you are not sure about: after a wedge, during a probe, before a re-bind. Use fs_unmount() when the device is healthy and you want the volume flushed properly.

Ordering matters: drive_select() unmounts the previous filesystem before rebinding — also a write. Abandon first, then select.


Proving a drive actually recovered

This is the part that matters, and the part most easily faked.

A port that enumerates is not a port that works. A device on the roster is not a device carrying data. The only proof is a content round-trip:

' Multi-cluster ON PURPOSE - a single-cluster file cannot expose a broken chain.
repeat c from 0 to CHUNKS - 1
  bytefill(@buf, c & $FF, 512)               ' every chunk stamped with its index
  usb.fs_write(h, @buf, 512)
' ... reopen, read back, compare every byte against its expected index

VERIFY MULTI-CLUSTER FILES BY CONTENT, NEVER BY SIZE. The signature failure of a wedge session is a file whose directory entry is perfect and whose FAT chain is not — it reads back the right length of the wrong bytes. A size check passes it happily. Stamping each 512-byte chunk with its own index makes a stray or out-of-order cluster fail at the exact chunk where the chain diverges.

This is not hypothetical. A real session corrupted the FAT chains of four untouched multi-cluster files while every file ≤16 KB stayed perfect, and directory listings showed correct sizes throughout.


Wedge fallout reads exactly like filesystem bugs

When a device goes unreachable, everything above it fails in ways that look like defects in your code. One suite's five "failures" were all downstream of a device that had stopped answering.

So, in that order:

  1. Prove the device answers before scoring anything.
  2. Report whether a recovery happened. A test that passed after a recovery is not the same result as one that passed without.
  3. Only then trust a failure as a filesystem failure.

And the standing rule that costs the most when ignored: "it doesn't recover" is not evidence that "the hardware is at fault." If the workload was not deliberately abusive, a hang is ours.


The evidence ring — new, and off unless you ask for it

(src/usb_evt_ring.spin2, build gate USB_EVTRING. Everything else on this page predates it.)

When a wedge happens, the interesting question is what the bus was doing in the handful of transactions before it. Ordinary logs cannot answer that. The ring records the last 256 BOT transactions in a circular buffer and emits nothing until you ask — so an armed green run costs only the RAM.

pnut-ts -d -I ../src -D USB_MSC -D USB_FAT32 -D USB_EVTRING my_soak.spin2
usb.evt_arm(1)                               ' arm AND clear
usb.evt_mark(1, chunk_size, 0)               ' note which workload axis moved
...
ifnot usb.disk_read(lba, 1, @buf)
  usb.evt_dump(string("read failed"))        ' dump at the FIRST anomaly, then STOP

Each record carries opcode, LBA, transfer length, elapsed time, NAK/retry count, CSW status, SOF frame at start and end, and — importantly — the device address it was addressed to:

seq  ms      who(addr:epO/epI)  op  dir len    lba   us      naks fr0>fr1  hs st
4 5 ms  addr3:1/1  op=28 dir=80 len=512 lba=100 1_847 us  naks=1 fr 1_752>1_756 hs=4B st=0

The device field is there because a recovery run against one drive has been observed to break every drive bound after it, while the faulting drive itself recovered fine. A ring that records what happened but not who it happened to cannot see that at all.

Practical notes:


Quick reference

Call Use it for
disk_bot_ok() A transaction was abandoned; recovery in progress. Ride it out.
disk_wedged() Driver has given up. Branch on this.
disk_recover_detail() rst, clro, clri of the last attempt — read after the ENABLE bit.
hub_port_status(pn) The ENABLE bit. The discriminator.
hub_port_reset(pn) Reset only, no power change.
hub_port_recycle(pn, ms) Power cycle + reset a port. Check the return value.
drive_recover(ms) Recover the bound device by identity; re-mount afterwards.
disk_auto_recover(on, ms) Opt-in auto-escalation. Soak only.
drive_select_sticky(addr) Pin a drive so no rescan swaps it.
dev_serial(i, buf, n) Match media by identity, not address.
fs_abandon() Release a mount with zero device I/O.
evt_arm / evt_mark / evt_dump The evidence ring (USB_EVTRING).

See also


MIT License — Copyright (c) 2026 Shannon Mackey (Refaqtory, LLC). Part of the P2 USB full-speed host project.