Skip to content

usbmode: unblock the ota task when a USB OTA transfer times out - #337

Open
wmelonw wants to merge 1 commit into
Blockstream:masterfrom
wmelonw:fix/usb-ota-hang
Open

usbmode: unblock the ota task when a USB OTA transfer times out#337
wmelonw wants to merge 1 commit into
Blockstream:masterfrom
wmelonw:fix/usb-ota-hang

Conversation

@wmelonw

@wmelonw wmelonw commented Aug 29, 2026

Copy link
Copy Markdown

Fixes the "USB firmware update stalls at 2% with no error" behaviour reported in #287 (and seen by me updating a Jade Plus noradio to 1.0.41 via USB storage).

What happens today

usbmode_ota_worker() (main/usbhmsc/usbmode.c) streams the firmware file to the ota task in 4 KiB ota_data messages and waits for each ack. Once past the first few chunks that wait is bounded at 10 s (wait_for_ota_replies(), usbmode.c:569). When it times out the worker does this:

if (!failed_wait) {
    post_ota_complete_message(SOURCE_INTERNAL);
    ...
}
// TODO: Notify the user in the failed_wait == true case.
usbstorage_stop();
serial_start();
vTaskDelete(NULL);

i.e. on timeout it sends nothing to the ota task and exits. Meanwhile ota_process() is parked in a blocking input read (main/process/ota.c:95):

while (joctx->remaining_compressed && joctx->ota_return_status == OTA_SUCCESS) {
    jade_process_get_in_message(joctx, &handle_in_bin_data, true);
}

Nothing will ever arrive, so the ota task never reaches ota_finalize() and its error screen. The device sits on the progress screen indefinitely with no error until it is power-cycled — exactly what #287 describes ("stalls permanently at 2% with no error information", "let it sit for 10 minutes").

Why it is always in the first couple of percent: the timeout can first fire at msgs_sent == 5, and the progress bar is integer-truncated uncompressed-bytes-written (ota.c:69, dialogs.c:742), so 3–4 chunks of a 1.18 MB image shows as 1–2%.

The change

Always send ota_complete before the worker exits, including on timeout. When it arrives early the ota task rejects it (handle_in_bin_data()OTA_ERR_BADDATA), which takes it through its existing error path in ota_finalize(): it replies to the worker, calls await_error(), and the user is back at a screen where they can retry.

One hunk, no new state, no change to the wait bounds. It also retires the "Notify the user in the failed_wait == true case" TODO, since the ota task now does that through the code path it already has.

What this deliberately does not change

  • The unbounded waits for the first chunks (wait_forever = msgs_sent <= 4). I initially assumed that bound was the bug. It is not: the on-device hash confirmation happens inside decompression of the first data chunk (uncompressed_stream_writer()ota_user_validate(), ota.c:26), so the ack for that chunk blocks for as long as the user takes to read and confirm the hash. Tightening the bound would make the update fail for anyone who takes more than 10 s to verify the hash — which is exactly the behaviour we want to encourage. Left as is.
  • read() on the MSC mount has no timeout (usbmode.c:623). A transfer that stalls at the USB layer still hangs the worker inside read(), and this patch cannot help there (the worker never reaches the exit path). Bounding that needs a cancellation path in the MSC driver; I did not want to fake it with a wrapper task. Left for a follow-up.
  • If the ota task has genuinely hard-locked (never processes another message), nothing changes — but in that state nothing short of a reboot helps anyway.

Verification

Not build-tested and not tested on hardware — I do not have the ESP-IDF toolchain set up on this machine. The change is small enough that I hope a maintainer can build it more easily than I can, but please treat it accordingly.

What I did do is port the worker / ota-task control flow (wait bounds, blocking receive, hash-confirmation point, BADDATA rejection, ota_finalize error path) to a host-side harness and drive it through the stall scenarios:

scenario shipped bound tightened to <= 2 (rejected) this PR
user takes 4× the data timeout to confirm the hash upgrade completes fails (regression) upgrade completes
ota task stalls transiently past the unbounded window frozen, no error frozen, no error error screen, retry possible
ota task hard-locks frozen frozen frozen (reboot needed either way)
read() stalls at the USB layer frozen frozen frozen (out of scope, see above)
no stall upgrade completes upgrade completes upgrade completes

The second row is the #287 failure mode; the first row is why I dropped the bound change.

One cosmetic point for a follow-up: the error the user sees on the timeout path is the generic ERR_BADDATA text. A dedicated ota_cancel message (the existing TODO on that line) would let it say something more useful like "Update timed out".

Unrelated observation from the same session

Before getting anywhere near 2%, the device refused to see the card at all, reporting it was not FAT32 through several reformats. The reader was a multi-slot USB-C card reader; usb_host_msc only attaches LUN 0 (msc_host_install_device(), usbhmsc.c:160), and the microSD slot enumerated as LUN 1, so Jade was mounting the empty full-size slot. Moving the card into the full-size SD slot fixed it immediately. Probably worth a line in the USB update docs recommending a single-slot reader; happy to send that separately if useful.

🤖 Generated with Claude Code

When usbmode_ota_worker() times out waiting for an ota_data ack it
breaks out of the transfer loop, skips post_ota_complete_message(),
stops USB storage and exits. The ota task is still parked in its
blocking input-message read and never wakes up, so the device is left
on the firmware progress screen indefinitely with no error (Blockstream#287).

Always send ota_complete before exiting. When it arrives early the ota
task rejects it as bad data and goes through its normal error path,
which replies to the worker, shows the user an error screen and lets
them retry, instead of freezing until the device is power-cycled.

This does not change the wait bounds: the unbounded waits for the first
few acks are needed because the on-device hash confirmation happens
while those chunks are being decompressed, and a user verifying the
hash may take longer than the 10 s data timeout.

Not addressed: read() on the MSC mount has no timeout, so a transfer
that stalls at the USB layer can still hang the worker. That needs a
cancellation path in the MSC driver and is left for a follow-up.
@wmelonw

wmelonw commented Aug 30, 2026

Copy link
Copy Markdown
Author

Follow-up: I've since been able to build-verify this branch — it compiles cleanly for the Jade Plus target (switch_to.sh jade_v2 --dev, ESP-IDF 5.5 via blockstream/jade_builder), no new warnings in usbmode.c. Still not hardware-tested.

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