ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m22s
ci / web (pull_request) Successful in 4m52s
android / android (pull_request) Successful in 4m57s
ci / rust-arm64 (pull_request) Successful in 6m55s
ci / rust (pull_request) Successful in 27m20s
`PUNKTFUNK_DUALSENSE_USBIP=1` enumerated the pad and then lost it ~400 ms later,
taking the controller with it (the usbip transport replaces uhid, so there was
nothing to fall back to). Three sessions blamed the ISO stream, the link speed and
`actual_length` in turn. It was none of them.
`DS_FEATURE_CALIBRATION` is 42 bytes. `hid-playstation` asks for 41
(`DS_FEATURE_REPORT_CALIBRATION_SIZE`), and on a USB backend an over-long reply is
not truncated, it is fatal to the transport:
size = urb->actual_length; /* 42, what we declared */
if (size > urb->transfer_buffer_length) /* 42 > 41 */
goto error; /* "probably malicious packet" */
error:
dev_err(&urb->dev->dev, "recv xbuf, %d\n", ret); /* ret still 0 */
usbip_event_add(ud, VDEV_EVENT_ERROR_TCP);
`VDEV_EVENT_ERROR_TCP` tears down the whole connection, not the one URB — hence
`recv xbuf, 0` (that 0 is the untouched initialiser, not a byte count), then
-EPROTO on the calibration read, `Failed to create dualsense`, and the disconnect.
The dmesg order made the teardown look like the cause; it was the consequence.
The blob had been wrong since it was written, and a FIXME said so. It stayed
invisible because every other backend truncates: hidraw for the uhid pad, hidclass
on Windows. USB/IP is the first transport that checks.
Three changes, because one of them alone would leave the same trap set:
- Trim the constant to 41 and pin all three feature-report sizes in a test.
- Clamp every reply to the requested length in the transport (`clamp_reply`), and
drop any payload a handler returns on an OUT transfer — the kernel never reads
one, so those bytes would misframe every PDU after them. A handler bug now costs
one wrong reply instead of the device.
- `DualSenseUsbip::open` waits for the kernel to actually bind a HID driver before
reporting success. A `vhci_hcd` attach succeeds immediately and enumerates
asynchronously, so bringup faults were being reported as working pads; now they
return Err and the caller's existing uhid fallback catches them.
Also adds `PUNKTFUNK_USBIP_TRACE` (both socket directions to disk) and
`scripts/usbip-trace-analyse.py`, which walks a capture and names the first frame
whose declared length disagrees with what the kernel will consume. The handler's
Err arm is no longer discarded either — it was the only signal distinguishing "we
dropped the connection" from "the kernel did", and both read identically in dmesg.
Verified on .21 (CachyOS, kernel 7.1.8): `Registered DualSense controller
hw_version=0x01000208 fw_version=0x01000036`, the device stays enumerated, and
snd-usb-audio mints a real ALSA card. Audio over the isochronous endpoint now runs
for the first time — a 300 Hz tone on the coil pair reads back channel-exact
(peak_coils=0.5000, peak_speaker=0.0000) for the whole run. A 4957-frame capture
analyses clean.
165 lines
7.0 KiB
Python
165 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Walk a `PUNKTFUNK_USBIP_TRACE` capture and find where the two sides stop agreeing.
|
|
|
|
A USB/IP connection is a framed byte stream whose frame lengths are declared inside the frames, so
|
|
one reply that writes a different number of bytes than its header promises shifts everything after
|
|
it. The peer then fails at whatever frame happens to land badly, which is never the frame that was
|
|
wrong. This walks both directions as PDUs and reports the first frame that does not decode, plus a
|
|
per-URB ledger of declared vs. written bytes.
|
|
|
|
Usage: usbip-trace-analyse.py /tmp/pad.virtual-DualSense-0
|
|
(reads <prefix>.rx, <prefix>.tx, <prefix>.idx)
|
|
"""
|
|
|
|
import struct
|
|
import sys
|
|
|
|
CMD_SUBMIT, CMD_UNLINK, RET_SUBMIT, RET_UNLINK = 1, 2, 3, 4
|
|
NAMES = {1: "CMD_SUBMIT", 2: "CMD_UNLINK", 3: "RET_SUBMIT", 4: "RET_UNLINK"}
|
|
|
|
|
|
def be32(b, o):
|
|
return struct.unpack_from(">I", b, o)[0]
|
|
|
|
|
|
USBIP_VERSION = 0x0111
|
|
|
|
|
|
def skip_handshake(buf, side):
|
|
"""Return the offset where URB framing begins.
|
|
|
|
A capture starts at `accept()`, so the first bytes are the op-level import handshake, which is
|
|
framed differently (a 2-byte version, not a 4-byte command). Walking it as a PDU decodes as
|
|
garbage and reports a desync at offset 0 — a false positive that would send the reader hunting
|
|
for a framing bug in the one place there is none.
|
|
"""
|
|
off = 0
|
|
while off + 4 <= len(buf) and struct.unpack_from(">H", buf, off)[0] == USBIP_VERSION:
|
|
code = struct.unpack_from(">H", buf, off + 2)[0]
|
|
if side == "rx":
|
|
# OP_REQ_IMPORT: status(4) + busid(32); OP_REQ_DEVLIST: status(4).
|
|
off += 40 if code == 0x8003 else 8
|
|
else:
|
|
# OP_REP_IMPORT: status(4) + a 312-byte device record when status == 0.
|
|
status = be32(buf, off + 4)
|
|
off += 8 + (312 if code == 0x0003 and status == 0 else 0)
|
|
return off
|
|
|
|
|
|
def walk(buf, side):
|
|
"""Yield decoded PDUs. `side` is 'rx' (kernel -> us) or 'tx' (us -> kernel)."""
|
|
off = skip_handshake(buf, side)
|
|
while off < len(buf):
|
|
if len(buf) - off < 48:
|
|
yield {"off": off, "error": f"truncated header: {len(buf) - off} bytes left"}
|
|
return
|
|
cmd = be32(buf, off)
|
|
pdu = {
|
|
"off": off,
|
|
"cmd": cmd,
|
|
"name": NAMES.get(cmd, f"?{cmd:#x}"),
|
|
"seq": be32(buf, off + 4),
|
|
"dir": be32(buf, off + 12), # 0 = OUT, 1 = IN
|
|
"ep": be32(buf, off + 16),
|
|
}
|
|
if cmd not in NAMES:
|
|
pdu["error"] = "unknown command — the stream is already desynced at or before here"
|
|
yield pdu
|
|
return
|
|
|
|
body = off + 48
|
|
if cmd == CMD_SUBMIT:
|
|
pdu["xfer_len"] = be32(buf, off + 24)
|
|
npkts = be32(buf, off + 32)
|
|
pdu["npkts"] = npkts
|
|
# OUT carries its payload; IN does not.
|
|
payload = pdu["xfer_len"] if pdu["dir"] == 0 else 0
|
|
table = 16 * npkts if npkts not in (0, 0xFFFFFFFF) else 0
|
|
pdu["payload"], pdu["table"] = payload, table
|
|
pdu["setup"] = buf[off + 40 : off + 48].hex()
|
|
off = body + payload + table
|
|
elif cmd == RET_SUBMIT:
|
|
pdu["status"] = be32(buf, off + 20)
|
|
pdu["actual"] = be32(buf, off + 24)
|
|
npkts = be32(buf, off + 32)
|
|
pdu["npkts"] = npkts
|
|
# This is the crux: the kernel reads a payload back only for an IN transfer
|
|
# (`usbip_recv_xbuff` returns early for `usb_pipeout`). Bytes written after an OUT
|
|
# reply's header are never consumed and desync the stream. That holds for isochronous
|
|
# OUT too, where `actual_length` counts bytes *accepted* and no buffer follows — so it
|
|
# must not be read as a payload length here.
|
|
payload = pdu["actual"] if pdu["dir"] == 1 else 0
|
|
table = 16 * npkts if npkts not in (0, 0xFFFFFFFF) else 0
|
|
pdu["payload"], pdu["table"] = payload, table
|
|
off = body + payload + table
|
|
else: # UNLINK either way: 48 bytes flat
|
|
pdu["payload"], pdu["table"] = 0, 0
|
|
off = body
|
|
pdu["end"] = off
|
|
yield pdu
|
|
|
|
|
|
def main(prefix):
|
|
rx = open(prefix + ".rx", "rb").read()
|
|
tx = open(prefix + ".tx", "rb").read()
|
|
print(f"rx (kernel -> us): {len(rx)} bytes")
|
|
print(f"tx (us -> kernel): {len(tx)} bytes\n")
|
|
|
|
submits = {}
|
|
for p in walk(rx, "rx"):
|
|
if "error" in p:
|
|
print(f"!! RX desync at offset {p['off']}: {p['error']}")
|
|
break
|
|
if p["cmd"] == CMD_SUBMIT:
|
|
submits[p["seq"]] = p
|
|
|
|
print(f"parsed {len(submits)} CMD_SUBMITs from the kernel")
|
|
|
|
bad, replies = [], 0
|
|
for p in walk(tx, "tx"):
|
|
if "error" in p:
|
|
bad.append((p, f"TX desync at offset {p['off']}: {p['error']}"))
|
|
break
|
|
replies += 1
|
|
if p["cmd"] != RET_SUBMIT:
|
|
continue
|
|
req = submits.get(p["seq"])
|
|
# The two rules vhci_hcd kills the whole connection over, checked against its own logic.
|
|
if p["dir"] == 0 and not p["npkts"] and p["actual"]:
|
|
bad.append((p, f"OUT reply declares actual_length={p['actual']}, but the kernel reads "
|
|
f"NO payload back on OUT — those bytes desync every frame after it"))
|
|
elif req and p["dir"] == 1 and p["actual"] > req["xfer_len"]:
|
|
bad.append((p, f"actual_length {p['actual']} > the {req['xfer_len']} requested "
|
|
f"(setup {req['setup']}) — usbip_recv_xbuff() calls this a malicious "
|
|
f"packet: 'recv xbuf, 0' then VDEV_EVENT_ERROR_TCP, which disconnects "
|
|
f"the device"))
|
|
elif req and req["dir"] != p["dir"]:
|
|
bad.append((p, "direction does not match its CMD_SUBMIT"))
|
|
|
|
print(f"parsed {replies} replies from us\n")
|
|
if bad:
|
|
print(f"{len(bad)} BAD frame(s). The first is the bug; the rest is fallout.\n")
|
|
for p, why in bad[:10]:
|
|
d = "IN" if p["dir"] == 1 else "OUT"
|
|
print(f" offset {p['off']} seq {p['seq']} {d} ep{p['ep']}: {why}")
|
|
else:
|
|
print("Every reply's declared length matches what the kernel will consume.")
|
|
print("If the connection still died, framing is not the cause — look below for a")
|
|
print("CMD_SUBMIT that never got a reply (a missing reply, not a mis-sized one).")
|
|
|
|
# An unanswered request is the other way this dies, and it looks identical from dmesg.
|
|
answered = {p["seq"] for p in walk(tx, "tx") if p.get("cmd") in (RET_SUBMIT, RET_UNLINK)}
|
|
missing = [s for s in submits if s not in answered]
|
|
if missing:
|
|
print(f"\n{len(missing)} CMD_SUBMIT(s) never answered: {sorted(missing)[:20]}")
|
|
for s in sorted(missing)[:5]:
|
|
p = submits[s]
|
|
d = "IN" if p["dir"] == 1 else "OUT"
|
|
print(f" seq {s}: {d} ep{p['ep']} len={p['xfer_len']} setup={p['setup']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
sys.exit(__doc__)
|
|
main(sys.argv[1])
|