hub: recover a stuck status-change interrupt poll (Fruit Jam CH334F USB host) - #3776
hub: recover a stuck status-change interrupt poll (Fruit Jam CH334F USB host)#3776mikeysklar wants to merge 1 commit into
Conversation
…SB host)
When a device is unplugged from a hub port, the hub reports it on the
status-change interrupt endpoint. On some hosts that interrupt transfer starts
failing (XFER_RESULT_FAILED) repeatedly instead of delivering the change, and
hub_xfer_cb just re-arms it, so the removal is never seen and tuh_umount_cb is
never called. The device stays mounted and every transfer to it fails.
After HUB_INT_FAIL_RECOVER consecutive failed polls, poll a port status directly
(one port per recovery so every port is covered) so the latched change is still
found and handled through the normal path. State is per hub.
Reproduced with CircuitPython on an Adafruit Fruit Jam (RP2350, PIO USB host
with an onboard CH334F hub). Run the loop below and unplug the device from a hub
port a few times. Before this change, after some unplugs it prints the USBError
line forever; after it, every unplug prints "no device".
import time, usb.core
while True:
d = list(usb.core.find(find_all=True))
if not d:
print("no device")
else:
try:
print("device:", d[0].product)
except usb.core.USBError:
print("USBError, device wedged, not removed")
time.sleep(0.5)
|
| target | .text | .rodata | .data | .bss | total | % diff |
|---|---|---|---|---|---|---|
| ch32v203c_r0_1v0/device_info | 19,144 → 19,236 (+92) | — | — | — | 19,572 → 19,664 (+92) | +0.5% |
| ch32v203c_r0_1v0/bare_api | 20,032 → 20,124 (+92) | — | — | — | 20,460 → 20,552 (+92) | +0.4% |
| ch32v203c_r0_1v0/midi_rx | 20,540 → 20,628 (+88) | — | — | — | 20,976 → 21,064 (+88) | +0.4% |
| ch32v203c_r0_1v0/midi2_host | 21,680 → 21,772 (+92) | — | — | — | 22,108 → 22,200 (+92) | +0.4% |
| ea4088_quickstart/device_info | 15,444 → 15,508 (+64) | — | — | — | 16,032 → 16,096 (+64) | +0.4% |
| lpcxpresso1769/device_info | 15,640 → 15,704 (+64) | — | — | — | 16,164 → 16,228 (+64) | +0.4% |
| metro_m4_express/device_info | 16,092 → 16,156 (+64) | — | — | — | 16,256 → 16,320 (+64) | +0.4% |
| ea4088_quickstart/bare_api | 16,112 → 16,176 (+64) | — | — | — | 16,696 → 16,760 (+64) | +0.4% |
| lpcxpresso1769/bare_api | 16,324 → 16,388 (+64) | — | — | — | 16,844 → 16,908 (+64) | +0.4% |
| ea4088_quickstart/midi_rx | 16,280 → 16,344 (+64) | — | — | — | 16,864 → 16,928 (+64) | +0.4% |
Hardware-in-the-loop (HIL) Test Reporthfp.json✅ 41 passed · ❌ 15 failed · ⚪ 0 skipped · blank not run
tinyusb-esp.json✅ 20 passed · ❌ 4 failed · ⚪ 0 skipped · blank not run
tinyusb.json✅ 227 passed · ❌ 120 failed · ⚪ 17 skipped · blank not run
|
|
Interesting finding, Does the port revive when a new device is connected ? Linux handles the failure more aggressively, after 10 errors it reset the hub and disconnect/re-enumerate every child. |
|
Good question. I tested this today on the Fruit Jam with a MacroPad. Running a poll loop that logs every state change.
With this PR:
On the Linux nuclear approach: I could add something similar (~30 lines of code to reset the hub. It is not necessary for this wedged scenario, but maybe there are more severe situations. Every device plugged into the Fruit Jam would unmount and re-enumerate since this is a root-port bus reset. |
|
@mikeysklar is the CircuitPython the tip of main, I will try to hook it with usb analyzer to see what is wrong. Mabe pio-usb has an off timing that cause the hub fail to response. |
|
@hathach - Yes. The stock repro was CircuitPython main at 30d7addb4e (tip of main on Jul 17) with the submodules exactly as main pins them: tinyusb at 5453e…). |
|
@mikeysklar superb! that is great to hear, rp2350 has annoying bug with errata19 gpio reading, I fixed it using walkaround in pio-usb, maybe there is still a race. I will work on this soon (still wrap up other long pending issue). Cynthion is nice, I wish I grab one from adafruit when it is first available, more tool is always a plus :). |
|
doesn't it support USB HS or the HS is clsoed source ? |
|
My rig is actually using TERMINUS FE2.1, don't know why I'm thinking about CH334 🤦 |
|
@hathach - It actually does HighSpeed (480Mbps), but I've only used up to Full (12Mbps) for Adafruit USB host controller work. The popular ones don't have the HS PHY yet, but it looks like that will be the norm going forward.
|
|
What I've learned about the Fruit Jam USB wedge.
Now doing automated Macropad resets on Fruit Jam USB 1 on different CircuitPython builds to determine which setting(s) are the issue. |
|
This took 20 CircuitPython build iterations to prove out and will sound crazy. Disabling Minimal repro for anyone with a Fruit Jam:
import time, usb.core
while True:
d = list(usb.core.find(find_all=True))
if not d:
print("no device")
else:
try:
print("device:", d[0].product)
except usb.core.USBError:
print("USBError, wedged")
time.sleep(0.5)First or second unplug wedges it: USBError forever, replug is never seen, only reboot recovers. Separate and worthy of its own issue/pr in CircuitPython repo is Still looking into if this is flash or cache as no vectorio code even executes in this scenario. CircuitPython Build Log to narrow down the wedge
|
|
Follow up on the "flash or cache" question. I instrumented core1's receive path with GPIO markers (no code changes that shift timing, single instruction pin writes) and captured the wedge on a logic analyzer.
Every failed poll shows the same signature: core1 detects end of packet, drains all 5 bytes from the PIO FIFO, but the bytes fail the CRC check so the ACK is never sent.
The wire side (Cynthion capture between the RP2350 and the CH334F) shows the hub's bytes are perfect. The hub answers every interrupt poll with a valid CRC16 DATA1 carrying 02, the port 1 status change bitmap. The host never sends the ACK, so the hub keeps the change pending and retransmits the identical byte forever, every 12 ms, for the entire capture. Replug is invisible because the status change interrupt can never be delivered. The corruption correlates with what core0 fetches, not where any USB code lives. I'll chase the last layer on the CircuitPython side and file issues there. For this PR: the failure class predates RP2350 (#2971 was the same wedge on RP2040, #2994 the previous hub.c recovery). The GetPortStatus fallback keeps hubs usable on affected builds since control transfers bypass the broken interrupt delivery. From my side this PR is ready to merge. Related hardening for a neighboring receive path bug found during this work: sekigon-gonnoc/Pico-PIO-USB#210 |
|
Wanted to close the loop here. The root cause was a one bit framing offset: core1 locks onto the incoming packet one bit late, every byte arrives shifted by one, and the CRC check fails on wire data that was valid all along. Expected 80 4b 02 c1 7e, received 01 97 04 82 fd, same shift every retry. The flash layout only matters because it changes when core1 arms the receiver relative to the reply. Fixes that came out of this:
Thanks @hathach and @HiFiPhile for the questions and sniffer suggestions along the way, they kept pointing this at the right layer. This PR still stands as written since control transfer recovery helps for any interrupt delivery failure. |
superb, @mikeysklar great work on the finding. I will jump to pio-usb issue/pr next week to see if there is anything I could help with the fix. Each time I go back to pio-usb, I need to relearn usb timing constraint 😄 |
|
@hathach - Thank you for offering assistance on the pio-usb side. I'd like to see these two issues ( sekigon-gonnoc/Pico-PIO-USB#210 and sekigon-gonnoc/Pico-PIO-USB#211 ) go through in some form, if not as is. This issue must feel familiar as you and tannewt have run up against it multiple times. Timeline in one place since the pieces were spread across two repos over three years:
|







When a device is unplugged from a hub port, the hub reports it on the status-change interrupt endpoint. On some hosts that interrupt transfer starts failing (
XFER_RESULT_FAILED) repeatedly instead of delivering the change, andhub_xfer_cbjust re-arms it, so the removal is never seen andtuh_umount_cbis never called. The device stays mounted and every transfer to it fails.After
HUB_INT_FAIL_RECOVERconsecutive failed polls, poll a port status directly (one port per recovery so every port is covered) so the latched change is still found and handled through the normal path. State is per hub.Reproduced with CircuitPython on an Adafruit Fruit Jam (RP2350, PIO USB host with an onboard CH334F hub). Run the loop below and unplug the device from a hub port a few times. Before this change, after some unplugs it prints the
USBErrorline forever; after it, every unplug printsno device. Used a macropad as USB client for unplug/replug tests.