The usbip DualSense died because its calibration report was one byte too long #287

Merged
enricobuehler merged 1 commits from worktree-usbip-dualsense-fix into main 2026-08-17 15:01:57 +00:00
7 changed files with 586 additions and 12 deletions
@@ -624,7 +624,20 @@ impl DualSenseUsbip {
&format!("virtual DualSense {index}"),
)?;
// Publish only once the device is actually attached, so a failed attach leaves no stale
// **A successful attach is not a working pad.** `vhci_hcd` accepts the socket immediately
// and enumerates asynchronously, so any protocol fault downstream of the attach — a reply
// the kernel rejects, a descriptor it will not parse — surfaces a few hundred milliseconds
// later as a device that appears and then vanishes. Returning `Ok` on the attach alone
// reported those as success, and because this transport *replaces* uhid the user was left
// with no pad at all rather than a degraded one. That has now happened twice, so the
// contract is: `open` returns `Ok` only once the kernel has actually bound a driver, and
// the caller's existing uhid fallback covers everything else.
if let Err(e) = wait_until_bound(index) {
drop(attach); // detach the port before the caller retries or degrades
return Err(e);
}
// Publish only once the device is attached *and* bound, so a failed bringup leaves no stale
// receiver for the streamer to drain forever.
publish_audio_rx(index, rx);
tracing::info!(
@@ -668,6 +681,94 @@ impl Drop for DualSenseUsbip {
}
}
/// How long to give the kernel to enumerate the pad and bind a HID driver to it.
///
/// Enumeration + `hid-playstation` bind measured ~330 ms on an idle box; the failure this guards
/// against tore the device down ~400 ms after attach. Three seconds is comfortably clear of both,
/// and the cost of waiting is paid once per pad arrival. `PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS`
/// overrides it; `0` skips the check entirely (useful when bisecting the transport itself).
const BIND_GRACE: std::time::Duration = std::time::Duration::from_millis(3000);
/// Block until the kernel has enumerated the virtual pad *and* bound a HID driver to its HID
/// interface, or the grace period expires.
///
/// Checking for the `usb_device` node alone is not enough: in the 2026-08-17 failure the node was
/// created and then removed ~400 ms later when the calibration reply tore the connection down, so a
/// single early poll saw a healthy device. Requiring a bound HID driver with an `input` child means
/// the thing the pad exists to provide actually came up.
fn wait_until_bound(index: u8) -> Result<()> {
let grace = std::env::var("PUNKTFUNK_DUALSENSE_USBIP_GRACE_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_millis)
.unwrap_or(BIND_GRACE);
if grace.is_zero() {
return Ok(());
}
let deadline = Instant::now() + grace;
let mut saw_device = false;
loop {
if let Some(t) = find_usb_topology() {
saw_device = true;
if hid_input_bound(&t.sysfs_path) {
tracing::debug!(
index,
sysfs = %t.sysfs_path.display(),
"usbip DualSense bound a HID driver"
);
return Ok(());
}
}
if Instant::now() >= deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
// Which of the two it is tells the operator where to look, so say it rather than "failed".
if saw_device {
anyhow::bail!(
"the virtual DualSense enumerated but no HID driver bound within {:?} — it is present \
in sysfs without an input device. Check `dmesg` for a `playstation`/`hid-generic` \
probe failure",
grace
)
}
anyhow::bail!(
"the virtual DualSense never enumerated within {grace:?} — `vhci_hcd` accepted the attach \
but no 054c:0ce6 device appeared (or it appeared and was torn down again). Check `dmesg`; \
a transport fault here reads as `recv xbuf` / `sendmsg failed` from vhci_hcd"
)
}
/// Whether the pad's HID interface under `sysfs` has a bound HID driver that produced an input
/// device. Either `hid-playstation` or `hid-generic` counts — both give a usable pad.
fn hid_input_bound(sysfs: &std::path::Path) -> bool {
let Ok(entries) = std::fs::read_dir(sysfs) else {
return false;
};
for e in entries.flatten() {
// The HID function is interface 3; its sysfs node is `<busid>:1.3`.
if !e.file_name().to_string_lossy().ends_with(":1.3") {
continue;
}
let Ok(children) = std::fs::read_dir(e.path()) else {
continue;
};
for c in children.flatten() {
// `0003:054C:0CE6.000N` — the bound HID device. `input/` appears only once a driver
// has claimed it and registered; a probe that fails leaves the directory absent.
if c.file_name().to_string_lossy().starts_with("0003:")
&& c.path().join("input").is_dir()
{
return true;
}
}
}
false
}
/// The sysfs path of an attached virtual DualSense's `usb_device` node, plus the udev properties
/// wine turns into a Windows ContainerId.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -225,10 +225,12 @@ struct ServerThread {
}
impl ServerThread {
/// Spawn the server on `listener`, serving exactly the one simulated `dev`.
fn spawn(listener: std::net::TcpListener, dev: UsbDevice) -> Result<ServerThread> {
/// Spawn the server on `listener`, serving exactly the one simulated `dev`. `label` names the
/// device in log lines and in the `PUNKTFUNK_USBIP_TRACE` file names.
fn spawn(listener: std::net::TcpListener, dev: UsbDevice, label: &str) -> Result<ServerThread> {
let stop = Arc::new(tokio::sync::Notify::new());
let stop_t = stop.clone();
let label = label.to_string();
let join = std::thread::Builder::new()
.name("pf-deck-usbip".into())
.spawn(move || {
@@ -246,6 +248,7 @@ impl ServerThread {
listener,
Arc::new(UsbIpServer::new_simulated(vec![dev])),
stop_t,
label,
));
})
.context("spawn usbip server thread")?;
@@ -270,6 +273,7 @@ async fn run_server(
listener: std::net::TcpListener,
server: Arc<UsbIpServer>,
stop: Arc<tokio::sync::Notify>,
label: String,
) {
let listener = match tokio::net::TcpListener::from_std(listener) {
Ok(l) => l,
@@ -289,8 +293,41 @@ async fn run_server(
// active hidraw against a 266 Hz source).
sock.set_nodelay(true).ok();
let server = server.clone();
let trace = super::usbip_trace::trace_prefix(&label);
let label = label.clone();
tokio::spawn(async move {
let _ = usbip_sim::handler(&mut sock, server).await;
// The handler's Err arm used to be discarded. It is the *only* signal that
// we tore the connection down rather than the kernel — and the kernel's
// side of that (`recv xbuf`, `sendmsg failed`) reads identically either
// way, so throwing it away cost days of mis-attributed diagnosis.
let sink = trace.and_then(|prefix| {
match super::usbip_trace::open_trace(&prefix) {
Ok(s) => {
tracing::info!(prefix, "usbip byte trace armed");
Some(s)
}
Err(e) => {
tracing::warn!(error = %e, "usbip trace files unopenable — running untraced");
None
}
}
});
let res = match sink {
Some(s) => {
let mut traced = super::usbip_trace::TracedIo::wrap(sock, s);
usbip_sim::handler(&mut traced, server).await
}
None => usbip_sim::handler(&mut sock, server).await,
};
match res {
Ok(()) => tracing::debug!(label, "usbip connection closed by the kernel"),
Err(e) => tracing::warn!(
label,
error = %e,
"usbip server dropped the connection — the kernel will report this as a \
transfer error on whatever URB was in flight"
),
}
});
}
Err(e) => {
@@ -361,7 +398,7 @@ fn attach_in_process(dev: UsbDevice, label: &str) -> Result<UsbipAttachment> {
listener
.set_nonblocking(true)
.context("usbip listener set_nonblocking")?;
let server = ServerThread::spawn(listener, dev)?;
let server = ServerThread::spawn(listener, dev, label)?;
// Connect to our own server and run the OP_REQ_IMPORT handshake.
let mut sock = connect_loopback(port).context("connect to usbip server")?;
@@ -395,7 +432,7 @@ fn attach_via_cli(dev: UsbDevice, label: &str) -> Result<UsbipAttachment> {
listener
.set_nonblocking(true)
.context("usbip listener set_nonblocking")?;
let server = ServerThread::spawn(listener, dev)?;
let server = ServerThread::spawn(listener, dev, label)?;
let before = vhci_used_ports();
usbip_attach_cli().context("usbip CLI attach")?;
@@ -0,0 +1,167 @@
//! Byte-level tracing for the USB/IP transport (`PUNKTFUNK_USBIP_TRACE`).
//!
//! # Why this exists
//!
//! A USB/IP connection is a *framed byte stream over one TCP socket*, and every frame's length is
//! declared inside the frame. So any reply that writes a different number of bytes than its header
//! declares does not corrupt that one URB — it shifts every byte after it, and the peer's next read
//! lands mid-frame. `vhci_hcd` reports the wreckage from wherever it happens to notice
//! (`recv xbuf`, `unknown pdu`, `cannot find a urb of seqnum`), which is never where the extra or
//! missing bytes were written. Reading the code cannot settle it, because the bug *is* a
//! disagreement between the code's arithmetic and the wire.
//!
//! This wraps the socket and writes both directions to disk verbatim, plus a record of where each
//! read/write call began and ended, so [`crate::usbip_trace`]'s companion analyser can walk the
//! streams as PDUs and name the first frame whose declared length and written length disagree.
//!
//! It is off unless `PUNKTFUNK_USBIP_TRACE` is set, and it is deliberately dumb: no parsing, no
//! filtering, no allocation beyond the write buffer. A tracer that interprets can be wrong in the
//! same way the code under test is wrong.
//!
//! # Using it
//!
//! ```text
//! PUNKTFUNK_USBIP_TRACE=/tmp/pad punktfunk-host pad-usbip-test --seconds 5
//! ```
//!
//! yields `/tmp/pad.<label>.rx` (kernel → us), `.tx` (us → kernel) and `.idx` (one
//! `us,dir,offset,len` record per call). Feed them to `scripts/usbip-trace-analyse.py`.
use std::fs::File;
use std::io::{BufWriter, Write};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::Instant;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
/// Where the trace goes, if tracing is on. One prefix per attached device.
pub fn trace_prefix(label: &str) -> Option<String> {
let base = std::env::var("PUNKTFUNK_USBIP_TRACE").ok()?;
if base.is_empty() || base == "0" {
return None;
}
// `label` is a human string ("virtual DualSense 0"); keep it filesystem-safe.
let safe: String = label
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
Some(format!("{base}.{safe}"))
}
/// The three files a trace is made of, shared by the read and write halves.
struct Sink {
rx: BufWriter<File>,
tx: BufWriter<File>,
idx: BufWriter<File>,
rx_off: u64,
tx_off: u64,
start: Instant,
}
impl Sink {
fn create(prefix: &str) -> std::io::Result<Self> {
// Append rather than `with_extension`, which would treat the label's own dots/dashes as an
// extension and collapse every pad's trace onto the same three files.
let f = |ext: &str| File::create(format!("{prefix}.{ext}"));
Ok(Sink {
rx: BufWriter::new(f("rx")?),
tx: BufWriter::new(f("tx")?),
idx: BufWriter::new(f("idx")?),
rx_off: 0,
tx_off: 0,
start: Instant::now(),
})
}
/// Record one completed call. `dir` is `r` (kernel → us) or `w` (us → kernel).
fn record(&mut self, dir: char, bytes: &[u8]) {
let (stream, off) = match dir {
'r' => (&mut self.rx, &mut self.rx_off),
_ => (&mut self.tx, &mut self.tx_off),
};
let at = *off;
let _ = stream.write_all(bytes);
*off += bytes.len() as u64;
let us = self.start.elapsed().as_micros();
let _ = writeln!(self.idx, "{us},{dir},{at},{}", bytes.len());
// Flushed per call on purpose: the failure under investigation ends with the process's
// socket dying, and a buffered tail is exactly the part that would be lost.
let _ = stream.flush();
let _ = self.idx.flush();
}
}
/// An opened set of trace files, ready to wrap a stream.
///
/// Opening is separate from wrapping so the caller can fall back to the untraced path without
/// having already surrendered its socket to a constructor that then failed.
pub struct TraceSink(Arc<Mutex<Sink>>);
/// Open `<prefix>.rx` / `.tx` / `.idx`, truncating any previous trace.
pub fn open_trace(prefix: &str) -> std::io::Result<TraceSink> {
Ok(TraceSink(Arc::new(Mutex::new(Sink::create(prefix)?))))
}
/// A socket wrapper that copies both directions to disk.
///
/// Wraps at the *call site* rather than inside the vendored server, so the vendored crate carries
/// no debug scaffolding and the traced and untraced paths run the identical handler.
pub struct TracedIo<T> {
inner: T,
sink: Arc<Mutex<Sink>>,
}
impl<T> TracedIo<T> {
/// Begin copying `inner`'s traffic into an already-opened [`TraceSink`].
pub fn wrap(inner: T, sink: TraceSink) -> Self {
TracedIo {
inner,
sink: sink.0,
}
}
}
impl<T: AsyncRead + Unpin> AsyncRead for TracedIo<T> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let before = buf.filled().len();
let r = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &r {
let got = buf.filled()[before..].to_vec();
// A zero-length ready read is EOF, and it is worth a record of its own: it is the
// moment the peer went away, and which side went first is the whole question.
if let Ok(mut s) = self.sink.lock() {
s.record('r', &got);
}
}
r
}
}
impl<T: AsyncWrite + Unpin> AsyncWrite for TracedIo<T> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
let r = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &r {
if let Ok(mut s) = self.sink.lock() {
s.record('w', &buf[..*n]);
}
}
r
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
@@ -20,15 +20,19 @@ use punktfunk_core::quic::{HidOutput, RichInput};
// inputtino (each array's first byte is the report id). The pairing report carries a fixed
// virtual MAC.
#[rustfmt::skip]
// FIXME(cal-len): the descriptor declares report 0x05 as a 40-byte feature (id + 40 = 41 total),
// but this blob is 42 bytes (one trailing pad byte too many). Linux `hid-playstation` tolerates it
// (the backend is live-validated), and `hidclass` truncates to the declared length, so it is not
// currently blocking; trim the trailing 0x00 to 41 once a physical DualSense is available to
// re-verify motion calibration on both backends.
// **41 bytes, and that is load-bearing** — the descriptor declares report 0x05 as a 40-byte feature
// and `hid-playstation` asks for id + 40 = 41 (`DS_FEATURE_REPORT_CALIBRATION_SIZE`). This blob
// carried one trailing pad byte too many until 2026-08-17. On uhid and on Windows that was
// invisible, because hidraw and `hidclass` both truncate a feature reply to the declared length —
// but a *USB* backend does not: `usbip_recv_xbuff()` compares the reply's `actual_length` against
// the URB's `transfer_buffer_length` and, on 42 > 41, treats it as a malicious packet, logs
// `recv xbuf, 0` and raises `VDEV_EVENT_ERROR_TCP` — which tears down the whole connection, not the
// one URB. That killed the usbip pad's `hid-playstation` probe with -EPROTO and took the controller
// with it. Keep this exactly 41 bytes. See [`crate::dualsense_usbip`].
pub const DS_FEATURE_CALIBRATION: &[u8] = &[ // report 0x05 (motion calibration)
0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0xF4, 0x01, 0xF4, 0x01, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[rustfmt::skip]
pub const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at bytes 1..7)
@@ -643,6 +647,33 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
mod tests {
use super::*;
/// **Every feature report must be exactly the size the driver asks for.**
///
/// `hid-playstation` requests these by fixed size (`DS_FEATURE_REPORT_*_SIZE`), and on a *USB*
/// backend a reply longer than the request is fatal to the whole transport, not to the URB:
/// `usbip_recv_xbuff()` sees `actual_length > transfer_buffer_length`, calls it a malicious
/// packet, and raises `VDEV_EVENT_ERROR_TCP`, which disconnects the device. Calibration was
/// 42 bytes against a 41-byte request until 2026-08-17 and killed the usbip pad outright —
/// invisibly on uhid and on Windows, because hidraw and `hidclass` both truncate.
///
/// Sizes are `hid-playstation`'s own constants: calibration 41, pairing 20, firmware 64.
#[test]
fn feature_reports_are_exactly_the_size_the_driver_requests() {
assert_eq!(
DS_FEATURE_CALIBRATION.len(),
41,
"calibration (report 0x05)"
);
assert_eq!(DS_FEATURE_PAIRING.len(), 20, "pairing (report 0x09)");
assert_eq!(DS_FEATURE_FIRMWARE.len(), 64, "firmware (report 0x20)");
assert_eq!(ds_pairing_reply(0).len(), 20, "pairing reply");
// The first byte of a feature report is its id; a wrong one is answered to the wrong query.
assert_eq!(DS_FEATURE_CALIBRATION[0], 0x05);
assert_eq!(DS_FEATURE_PAIRING[0], 0x09);
assert_eq!(DS_FEATURE_FIRMWARE[0], 0x20);
}
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
+6
View File
@@ -623,6 +623,12 @@ pub mod uhid_abi;
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/uhid_manager.rs"]
pub mod uhid_manager;
/// Linux: byte-level tracing of the USB/IP socket (`PUNKTFUNK_USBIP_TRACE`). A framing bug in that
/// stream is only ever visible as damage the kernel notices somewhere later, so the wire itself has
/// to be recoverable.
#[cfg(target_os = "linux")]
#[path = "inject/linux/usbip_trace.rs"]
pub mod usbip_trace;
/// Transport-independent Xbox HID codec — the report the `pf-gamepad` UMDF driver serves under
/// device-types 4, 5 and 6 (Xbox Wireless / One S / Elite Series 2, which share one descriptor and
/// differ only in VID/PID), giving an Xbox pad the HID footing `pf-xusb` never had
+68
View File
@@ -134,6 +134,34 @@ async fn handle_iso_submit(
}
}
/// Force a non-isochronous reply into the shape `vhci_hcd` will accept (punktfunk addition).
///
/// **A reply may be shorter than the host asked for, never longer.** A short IN transfer is
/// ordinary USB — the device had less to say — but an over-long one is a babble condition and the
/// kernel does not forgive it: `usbip_recv_xbuff()` compares the reply's `actual_length` against
/// the URB's `transfer_buffer_length` and, on `>`, treats it as a malicious packet, logs
/// `recv xbuf, 0` (that `0` is the untouched initialiser, not a byte count) and raises
/// `VDEV_EVENT_ERROR_TCP` — which tears down the **whole connection**, so the device disappears
/// rather than one URB failing. Real hardware truncates here, so we do too: a handler bug then
/// costs one wrong reply instead of the pad.
///
/// An OUT transfer returns nothing at all. `usbip_recv_xbuff()` returns early for `usb_pipeout`,
/// so any payload appended to an OUT reply is bytes the kernel never reads — and every byte after
/// it in the stream is then misframed.
///
/// Field-diagnosed 2026-08-17: a 42-byte DualSense calibration report answering a 41-byte request
/// killed `hid-playstation`'s probe with `-EPROTO` and took the controller with it. Every backend
/// that is not USB (uhid, Windows `hidclass`) truncates silently, which is why the same constant
/// had looked correct for months.
pub(crate) fn clamp_reply(mut resp: Vec<u8>, requested: u32, out: bool) -> Vec<u8> {
if out {
resp.clear();
} else if resp.len() > requested as usize {
resp.truncate(requested as usize);
}
resp
}
pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
mut socket: &mut T,
server: Arc<UsbIpServer>,
@@ -264,6 +292,15 @@ pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
match resp {
Ok(resp) => {
let over = resp.len() > transfer_buffer_length as usize;
let resp = clamp_reply(resp, transfer_buffer_length, out);
if over {
warn!(
"handler returned more than the {transfer_buffer_length}-byte \
request on ep {real_ep:02x?} — truncated; an over-long reply \
tears down the whole usbip connection"
);
}
if out {
trace!("<-Wrote {}", data.len());
} else {
@@ -323,3 +360,34 @@ pub async fn server(addr: SocketAddr, server: Arc<UsbIpServer>) {
}
// (Host-mode constructors and in-crate tests removed in the vendored copy — see NOTICE.)
/// Covers only the punktfunk reply-shaping addition; see [`clamp_reply`] for why the kernel treats
/// an over-long reply as fatal to the connection rather than to the URB.
#[cfg(test)]
mod clamp_tests {
use super::clamp_reply;
/// The exact 2026-08-17 field failure: a 42-byte calibration blob against `wLength` 41.
/// Un-truncated this is `actual_length = 42 > transfer_buffer_length = 41`, which makes
/// `usbip_recv_xbuff()` raise `VDEV_EVENT_ERROR_TCP` and disconnect the pad entirely.
#[test]
fn an_over_long_in_reply_is_truncated_to_the_request() {
let reply = vec![0xAB; 42];
assert_eq!(clamp_reply(reply, 41, false).len(), 41);
}
/// A device returning less than asked is a short packet — ordinary USB, and the host is told
/// the true count. Padding it out would fabricate data the device never sent.
#[test]
fn a_short_in_reply_is_left_alone() {
assert_eq!(clamp_reply(vec![1, 2, 3], 64, false), vec![1, 2, 3]);
}
/// An OUT reply carries no payload back whatever the handler returns: the kernel does not read
/// one, so those bytes would stay in the stream and misframe every PDU after them.
#[test]
fn an_out_reply_never_carries_a_payload() {
assert!(clamp_reply(vec![1, 2, 3, 4], 4, true).is_empty());
assert!(clamp_reply(vec![], 0, true).is_empty());
}
}
+164
View File
@@ -0,0 +1,164 @@
#!/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])