An ISO OUT reply claimed zero bytes consumed, and the kernel dropped the whole device #286

Merged
enricobuehler merged 1 commits from worktree-usbip-iso-actual-length into main 2026-08-17 12:59:59 +00:00
Owner

Fixes the field failure from turning on PUNKTFUNK_DUALSENSE_USBIP=1 (#282): the DualSense vanished from the host entirely — unusable in game and absent from Steam's controller list. The usbip path replaces uhid, so a failure leaves no pad at all.

Enumeration was never the problem

The kernel bound both drivers to the virtual pad:

usb 11-1: 2:0: sticky mixer values (-15360/0/48 => 0), disabling      ← snd-usb-audio bound
playstation 0003:054C:0CE6.0011: hidraw6: USB HID v1.11 Gamepad
    [Sony Interactive Entertainment DualSense Wireless Controller]
    on usb-vhci_hcd.0-1/input3                                        ← hid-playstation bound

So the composite descriptor set, the UAC function and the 054C:0CE6 identity are all accepted — the load-bearing assumption of #282 holds. ~56 ms later, as soon as snd-usb-audio submitted the first isochronous OUT URB:

usb 11-1: recv xbuf, 0
vhci_hcd: sendmsg failed!, ret=-32 for 48          ← -32 = EPIPE
vhci_hcd vhci_hcd.0: stop threads / release socket / disconnect device

The bug

usbip_ret_submit_iso filled each packet descriptor's actual_length correctly (bytes accepted — the requested length on OUT), but set the URB-level actual_length from transfer_buffer.len(), and the transfer buffer is only populated inbound. An OUT reply therefore stated per-packet actuals summing to N × 392 against a total of 0.

The kernel's usbip_recv_iso() sums the packet table and compares it to the URB's actual_length; on a mismatch it raises ERROR_TCP and returns -EPIPE, which drops the connection, not the URB. Hence the pad disappearing outright, and the attach loop retrying forever.

The function's own doc comment already stated the required semantics — "On an OUT endpoint there is no payload to return and the value is the number of bytes we accepted… Reporting the empty reply's length there would tell the kernel the device swallowed nothing" — only the aggregate missed them.

The fix

  • actual_length is now the running total of the per-packet actuals: unchanged for IN (where it already equalled the buffer length) and correct for OUT.
  • to_bytes()'s debug_assert encoded the same wrong rule for ISO; it now checks the kernel's invariant directly against the descriptor table, rather than against the buffer.
  • The OUT unit test asserted actual_length == 0 — it had locked the bug in, conflating "sends no payload back" with "consumed nothing". It now pins the real invariant, sum(table actual_length) == actual_length.

Gate

usbip-sim: clippy --all-targets -D warnings 0, fmt clean, 6/6 tests (a debug build, so the corrected debug_assert is exercised by the tests rather than compiled out).

Not yet re-tested on hardware. The env var is currently commented out on the .181 test host.

Follow-up worth doing separately

The usbip transport replacing uhid with no fallback is what turned a protocol bug into "no controller at all", and the attach loop retries indefinitely rather than giving up. A bounded retry that degrades to uhid would keep a future transport bug from costing the user their pad. Not attempted here — it is a behavioural change in the pad-creation path and I would not want to write it untested on top of an unverified transport.

Fixes the field failure from turning on `PUNKTFUNK_DUALSENSE_USBIP=1` (#282): the DualSense vanished from the host entirely — unusable in game and absent from Steam's controller list. The usbip path **replaces** uhid, so a failure leaves no pad at all. ## Enumeration was never the problem The kernel bound **both** drivers to the virtual pad: ``` usb 11-1: 2:0: sticky mixer values (-15360/0/48 => 0), disabling ← snd-usb-audio bound playstation 0003:054C:0CE6.0011: hidraw6: USB HID v1.11 Gamepad [Sony Interactive Entertainment DualSense Wireless Controller] on usb-vhci_hcd.0-1/input3 ← hid-playstation bound ``` So the composite descriptor set, the UAC function and the `054C:0CE6` identity are all accepted — the load-bearing assumption of #282 holds. ~56 ms later, as soon as `snd-usb-audio` submitted the first isochronous OUT URB: ``` usb 11-1: recv xbuf, 0 vhci_hcd: sendmsg failed!, ret=-32 for 48 ← -32 = EPIPE vhci_hcd vhci_hcd.0: stop threads / release socket / disconnect device ``` ## The bug `usbip_ret_submit_iso` filled each **packet descriptor's** `actual_length` correctly (bytes accepted — the requested length on OUT), but set the **URB-level** `actual_length` from `transfer_buffer.len()`, and the transfer buffer is only populated `inbound`. An OUT reply therefore stated per-packet actuals summing to N × 392 against a total of **0**. The kernel's `usbip_recv_iso()` sums the packet table and compares it to the URB's `actual_length`; on a mismatch it raises `ERROR_TCP` and returns `-EPIPE`, which drops **the connection**, not the URB. Hence the pad disappearing outright, and the attach loop retrying forever. The function's own doc comment already stated the required semantics — *"On an OUT endpoint there is no payload to return and the value is the number of bytes we accepted… Reporting the empty reply's length there would tell the kernel the device swallowed nothing"* — only the aggregate missed them. ## The fix - `actual_length` is now the running total of the per-packet actuals: unchanged for IN (where it already equalled the buffer length) and correct for OUT. - `to_bytes()`'s `debug_assert` encoded the same wrong rule for ISO; it now checks the kernel's invariant directly against the descriptor table, rather than against the buffer. - **The OUT unit test asserted `actual_length == 0`** — it had locked the bug in, conflating "sends no payload back" with "consumed nothing". It now pins the real invariant, `sum(table actual_length) == actual_length`. ## Gate `usbip-sim`: clippy `--all-targets -D warnings` **0**, fmt clean, **6/6** tests (a debug build, so the corrected `debug_assert` is exercised by the tests rather than compiled out). ⚠ **Not yet re-tested on hardware.** The env var is currently commented out on the .181 test host. ## Follow-up worth doing separately The usbip transport replacing uhid with no fallback is what turned a protocol bug into "no controller at all", and the attach loop retries indefinitely rather than giving up. A bounded retry that degrades to uhid would keep a future transport bug from costing the user their pad. Not attempted here — it is a behavioural change in the pad-creation path and I would not want to write it untested on top of an unverified transport.
enricobuehler added 1 commit 2026-08-17 12:58:05 +00:00
fix(usbip): an ISO OUT reply claimed zero bytes consumed, and the kernel dropped the whole device
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 5m38s
android / android (pull_request) Successful in 5m40s
ci / rust (pull_request) Successful in 15m13s
1c9aec35fe
Turning on PUNKTFUNK_DUALSENSE_USBIP=1 made the DualSense vanish from the host entirely - unusable in game and absent from Steam's controller list. The usbip path replaces uhid, so there is no fallback.

Enumeration was never the problem: the kernel bound both drivers to the virtual pad ('playstation ... hidraw6: USB HID v1.11 Gamepad [Sony Interactive Entertainment DualSense Wireless Controller]' and snd-usb-audio processing the mixer). ~56 ms later, as soon as snd-usb-audio submitted the first isochronous OUT URB, the link tore down:

    usb 11-1: recv xbuf, 0
    vhci_hcd: sendmsg failed!, ret=-32 for 48
    vhci_hcd vhci_hcd.0: stop threads / release socket / disconnect device

usbip_ret_submit_iso filled each packet descriptor's actual_length correctly (the bytes accepted, = the requested length on OUT) but set the URB-level actual_length from transfer_buffer.len(), and the transfer buffer is only populated INBOUND. So an OUT reply stated per-packet actuals summing to N x 392 against a total of 0.

The kernel's usbip_recv_iso() sums the packet table and compares it to the URB's actual_length; on a mismatch it raises ERROR_TCP and returns -EPIPE, which drops the connection rather than the URB - hence the pad disappearing outright and the attach loop retrying forever. The function's own doc comment already stated the required semantics; only the aggregate missed them.

actual_length is now the running total of the per-packet actuals, which is unchanged for IN (where it equals the buffer) and correct for OUT. to_bytes()'s debug_assert encoded the same wrong rule for ISO and now checks the kernel's invariant directly, against the descriptor table.

The OUT unit test asserted actual_length == 0 - it had locked in the bug, conflating 'sends no payload back' with 'consumed nothing'. It now pins the real invariant, sum(table actual_length) == actual_length.

Not yet re-tested on hardware.
enricobuehler merged commit af1717e9f4 into main 2026-08-17 12:59:59 +00:00
enricobuehler deleted branch worktree-usbip-iso-actual-length 2026-08-17 13:00:03 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#286