Compare commits

...
2 Commits
Author SHA1 Message Date
enricobuehler 33538582e2 Merge pull request 'The usbip DualSense died because its calibration report was one byte too long' (#287) from worktree-usbip-dualsense-fix into main
ci / web (push) Successful in 1m37s
android / android (push) Canceled after 8m17s
ci / bun-nix (push) Successful in 1m59s
arch / build-publish (push) Canceled after 8m19s
ci / docs-site (push) Successful in 2m24s
deb / build-publish-gamescope (push) Successful in 40s
deb / build-publish-client-arm64 (push) Successful in 1m18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 2m50s
deb / build-publish-host (push) Successful in 4m42s
ci / rust-arm64 (push) Successful in 9m6s
windows-host / package (push) Successful in 12m53s
windows-host / winget-source (push) Skipped
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 2m53s
ci / rust (push) Successful in 15m38s
docker / builders-arm64cross (push) Successful in 14s
windows-host / canary-manifest (push) Successful in 37s
docker / deploy-docs (push) Successful in 40s
deb / build-publish (push) Successful in 13m14s
deb / smoke-install (push) Successful in 6m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m42s
Reviewed-on: #287
2026-08-17 15:01:52 +00:00
enricobuehler 8e8cc84d1a fix(pad): the usbip DualSense died because its calibration report was one byte too long
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.
2026-08-17 16:54:37 +02: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])