Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33538582e2 | ||
|
|
8e8cc84d1a |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user