Add IsoPacketDescriptor, UrbRequest, and UrbResponse types to the protocol layer. Rewrite handle_urb_loop to a concurrent architecture with pipelining for improved throughput. Replace interfaces vec with InterfaceState to track alternate settings. Implement isochronous transfer support in the nusb host handler with structured ISO packet descriptor parsing and serialization. Switch to ISO-capable nusb fork. Add IsoLoopbackHandler test fixture and ISO transfer tests. Fix host device passthrough: detach kernel drivers before claiming interfaces, use real EP0 max packet size, forward SET_CONFIGURATION to device, map nusb Speed enum to Linux kernel values, and use extend_from_slice for OUT transfer buffers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
18 KiB
Isochronous Transfer Support
Overview
Add isochronous USB transfer support to usbip-rs, enabling devices like USB audio interfaces and webcams to work through USB/IP. This includes:
- Concurrent URB processing with seqnum-based pipelining
- ISO packet descriptor parsing, serialization, and data packing
- Alternate interface setting support (required for real isochronous devices)
- Host passthrough via nusb
- Simulated device support via updated handler trait
- Minimal test fixture for validation
Motivation
The current implementation handles control, bulk, and interrupt transfers but silently ignores isochronous transfers. Isochronous is required for USB audio (UAC), video (UVC), and other streaming device classes. The primary validation target is USB audio, but the design is general enough for any isochronous device.
Beyond isochronous support, the current handle_urb_loop is strictly serial (read → process → write → repeat), which causes dropouts for isochronous transfers that require continuous frame delivery. The Linux USB/IP implementation uses seqnum-based pipelining with concurrent URB submission, and we adopt the same model.
Design Decisions
- Concurrent URB loop (Approach A): Rework
handle_urb_loopto read CMD_SUBMITs in a loop and spawn each as a concurrent tokio task. Responses sent back via mpsc channel keyed by seqnum. Benefits all transfer types, not just isochronous. - UrbRequest/UrbResponse structs (Approach B from trait discussion): Wrap all URB parameters in structs rather than adding more positional parameters. Existing handlers ignore ISO fields.
- nusb only: Isochronous host passthrough implemented for nusb handler only. rusb handler returns an error for ISO transfers.
- Alternate settings in scope: Required for real isochronous devices to function (zero-bandwidth alt 0, active stream alt 1+).
- Minimal test fixture:
IsoLoopbackHandlerfor exercising ISO code paths. Full UAC example device deferred to follow-up work.
1. UrbRequest / UrbResponse Structs
Replace the current 5-parameter handle_urb signature with two structs.
New Types (in interface.rs)
/// Parsed ISO packet descriptor from the wire (16 bytes each)
#[derive(Clone, Debug, Default)]
pub struct IsoPacketDescriptor {
pub offset: u32,
pub length: u32,
pub actual_length: u32,
pub status: u32,
}
/// All information needed to process a URB
#[derive(Clone, Debug)]
pub struct UrbRequest {
pub ep: UsbEndpoint,
pub transfer_buffer_length: u32,
pub setup: SetupPacket,
pub data: Vec<u8>,
// ISO-specific fields (zero/empty for non-ISO)
pub number_of_packets: u32,
pub iso_packet_descriptors: Vec<IsoPacketDescriptor>,
pub start_frame: u32,
pub interval: u32,
}
/// Result of processing a URB
#[derive(Clone, Debug)]
pub struct UrbResponse {
pub data: Vec<u8>,
pub iso_packet_descriptors: Vec<IsoPacketDescriptor>,
pub status: i32, // 0 = success, negative = USB error
pub start_frame: u32,
pub error_count: u32,
}
Updated Trait
pub trait UsbInterfaceHandler: std::fmt::Debug {
fn get_class_specific_descriptor(&self) -> Vec<u8>;
fn handle_urb(
&mut self,
interface: &UsbInterface,
request: UrbRequest,
) -> Result<UrbResponse>;
fn as_any(&mut self) -> &mut dyn Any;
}
Non-ISO handlers ignore the ISO fields on UrbRequest and return empty ISO fields on UrbResponse.
UsbDeviceHandler Trait
The device-level handler trait (UsbDeviceHandler) also receives the UrbRequest/UrbResponse update. Its current signature:
fn handle_urb(&mut self, transfer_buffer_length: u32, setup: SetupPacket, req: &[u8]) -> Result<Vec<u8>>;
Becomes:
fn handle_urb(&mut self, request: UrbRequest) -> Result<UrbResponse>;
This trait handles device-level control transfers (ep0) before interface dispatch. Both RusbUsbHostDeviceHandler and NusbUsbHostDeviceHandler are updated accordingly. Since device-level handlers only process control transfers, the ISO fields are always empty — no behavioral change.
2. Alternate Interface Settings
Data Model
UsbDevice.interfaces is replaced with a Vec<InterfaceState>:
/// Per-interface state with alt setting support and synchronization
pub struct InterfaceState {
pub active: UsbInterface,
pub alt_settings: Vec<UsbInterface>,
pub current_alt: u8,
pub lock: tokio::sync::RwLock<()>,
}
Updated UsbDevice:
pub struct UsbDevice {
// ... existing fields ...
// Replaces the old `interfaces: Vec<UsbInterface>`
pub interface_states: Vec<InterfaceState>,
}
The old interfaces field is removed. All code that accessed interfaces[i] now accesses interface_states[i].active. The InterfaceState owns both the alt setting data and the per-interface RwLock used for synchronization during alt setting changes.
Lock Ordering
When processing a URB concurrently:
- Acquire
InterfaceState.lock(read) — gates alt setting changes - Acquire
UsbInterface.handler(Arc<Mutex<...>>) — serializes handler state mutations - Call
handler.handle_urb() - Release handler mutex, then release RwLock read guard
For SET_INTERFACE:
- Acquire
InterfaceState.lock(write) — waits for all in-flight URBs on this interface to finish - Swap
activeto the new alt setting - Release write lock
The handler mutex is never held while acquiring the RwLock, preventing deadlocks.
Behavior
interface_states[i].alt_settingsstores all alternate settings for interfacei.interface_states[i].activealways reflects the currently active alternate setting.SET_INTERFACE(intf=i, alt=n): acquire write lock, swapactive = alt_settings[n].clone(), updatecurrent_alt.GET_INTERFACE(intf=i): returnsinterface_states[i].current_alt.- Configuration descriptor generation iterates each
InterfaceState.alt_settingsto emit all alternates with correctbAlternateSettingvalues.
Synchronization on SET_INTERFACE
With the concurrent URB loop, in-flight URBs could be targeting endpoints that are about to change. Per-interface RwLock handles this:
- Normal URB processing: acquires read lock on the target interface. Multiple URBs process concurrently.
- SET_INTERFACE: acquires write lock. Blocks until all in-flight URBs on that interface complete, then exclusively performs the switch.
For host passthrough, the write lock section calls nusb::Interface::set_alternate_setting(alt) on the real device, then updates the active endpoint list.
For simple devices (HID, CDC), alt_settings has one entry per interface (just alt 0). Builder methods default to single alt setting. No behavioral change.
3. Concurrent URB Loop
The core architectural change. Replaces the serial read-process-write loop with concurrent processing.
Architecture
┌─────────────┐
│ Reader │
│ (loop) │
└──────┬──────┘
│ CMD_SUBMIT
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Task 1 │ │ Task 2 │ │ Task 3 │
│ seqnum=1│ │ seqnum=2│ │ seqnum=3│
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
▼ ▼ ▼
response_tx ──────────────────► response_rx
│
┌─────▼─────┐
│ Writer │
│ (loop) │
└────────────┘
Key Details
- Socket splitting: Read half stays in the main reader loop. Write half moves to the writer task. No mutex needed on the socket.
- Response channel:
mpsc::channel(64)— bounded for backpressure. Responses may arrive out of order; USB/IP client matches by seqnum. spawn_blockingfor URB processing: TheUsbInterfaceHandler::handle_urbtrait method remains synchronous. Handler implementations (especially nusb) perform blocking USB I/O (transfer_blocking,.wait()). Each URB is dispatched viatokio::task::spawn_blockingto avoid starving the async executor. The reader loop and writer task remain async. The closure passed tospawn_blockingacquires the interface RwLock read guard and handler mutex, callshandle_urb, then sends the response through the channel.- Per-URB tasks: Each CMD_SUBMIT dispatches a
spawn_blockingtask. For ISO, the client pipelines many URBs so several tasks run concurrently on the blocking thread pool. - Interface RwLock: Each task acquires a read lock on its target interface. SET_INTERFACE acquires a write lock, naturally draining in-flight work. The
RwLockistokio::sync::RwLock— acquiring it fromspawn_blockingrequires entering the tokio runtime context viaHandle::current(). - Handler Mutex: Handlers are behind
Arc<Mutex<...>>(std). Multiple URBs on the same interface serialize through the handler mutex. This is correct since handler state (e.g., HID key queue) isn't safe for concurrent mutation. Usingstd::sync::Mutexis appropriate insidespawn_blocking. - Graceful shutdown: When the reader hits EOF, drop
response_tx. The writer drains remaining responses, then exits.
CMD_UNLINK Support
HashMap<u32, CancellationToken>keyed by seqnum tracks in-flight URBs.- On CMD_UNLINK: look up the seqnum, signal cancellation. If the task already completed, send RET_UNLINK with status
-ENOENT. If still in-flight, cancel and send RET_UNLINK with status 0. - In-flight tasks run inside
spawn_blocking, wheretokio::select!is unavailable. Instead, tasks checkCancellationToken::is_cancelled()before and after the blocking USB transfer call. This makes unlink best-effort for in-flight blocking transfers — consistent with how real USB host controllers handle URB cancellation.
4. ISO Packet Descriptor Handling
Wire format and data packing must match the Linux USB/IP implementation exactly.
Wire Format (per descriptor, 16 bytes, big-endian)
| Field | Size | Description |
|---|---|---|
| offset | u32 | Byte offset into transfer buffer |
| length | u32 | Expected transfer length |
| actual_length | u32 | Actual bytes transferred |
| status | u32 | Per-packet status (0 = success) |
Parsing
The current code reads iso_packet_descriptor as raw Vec<u8>. Change to parse into Vec<IsoPacketDescriptor>:
let iso_packet_descriptors: Vec<IsoPacketDescriptor> = raw_iso_bytes
.chunks_exact(16)
.map(|chunk| IsoPacketDescriptor {
offset: u32::from_be_bytes(chunk[0..4].try_into().unwrap()),
length: u32::from_be_bytes(chunk[4..8].try_into().unwrap()),
actual_length: u32::from_be_bytes(chunk[8..12].try_into().unwrap()),
status: u32::from_be_bytes(chunk[12..16].try_into().unwrap()),
})
.collect();
Data Packing (matching Linux)
CMD_SUBMIT (client → server, OUT):
- Transfer buffer contains ISO data at offsets specified by each packet descriptor.
- ISO packet descriptors follow the transfer buffer.
RET_SUBMIT (server → client, IN):
- Transfer buffer contains ISO data without padding — only
actual_lengthbytes per packet, concatenated. Failed packets (non-zero status) contribute zero bytes to the buffer (actual_length = 0). - This means the header's
actual_length(sum of all per-packetactual_lengthvalues) always equals the concatenated buffer length. No special-casing needed in the serialization path. - ISO packet descriptors follow, with
offsetfields reflecting the original layout. - Client reconstructs proper offsets (Linux
usbip_pad_iso()logic). error_count= number of packets with non-zero status.
Validation
- ISO packet descriptor
offset + lengthmust not exceedtransfer_buffer_length. Reject and log if violated.
5. Host Passthrough (nusb)
NusbUsbHostInterfaceHandler
New Isochronous arm in handle_urb:
ISO IN (device → host):
- Open isochronous endpoint via
handle.endpoint::<Isochronous, In>(ep.address) - Build packet lengths from request descriptors
- Allocate buffer and submit isochronous transfer
- Build response: concatenate actual data per packet (no padding), build response
IsoPacketDescriptorwithactual_lengthandstatus, count errors
ISO OUT (host → device):
- Open isochronous endpoint via
handle.endpoint::<Isochronous, Out>(ep.address) - Scatter
request.datainto packets using descriptor offsets - Submit to device
- Build response with per-packet status
Alternate Setting Support
When SET_INTERFACE arrives (control transfer with request = 0x0B):
- Acquire write lock on the interface
- Call
nusb::Interface::set_alternate_setting(alt)on the real device - Update
InterfaceState.activewith new endpoints fromalt_settings[alt] - Update
current_altindex - Release write lock
RusbUsbHostInterfaceHandler
The Isochronous arm returns Err(io::Error::new(Unsupported, "isochronous not supported on rusb handler")). No changes to existing control/interrupt/bulk paths.
6. Test Fixture
IsoLoopbackHandler
Location: lib/src/util.rs, #[cfg(test)] gated.
/// Minimal isochronous handler for testing.
/// ISO IN: returns incrementing bytes, one packet per URB.
/// ISO OUT: accepts and discards data, reports success per packet.
#[derive(Debug)]
pub struct IsoLoopbackHandler {
counter: u8,
}
Behavior:
- ISO IN: For each packet descriptor, fills
lengthbytes with incrementing counter value. Returns per-packet descriptors withactual_length = length,status = 0. - ISO OUT: Accepts data, returns per-packet descriptors with
actual_lengthmatching received data,status = 0. - Control: Handles
GET_DESCRIPTORminimally. HandlesSET_INTERFACE.
Test Cases
Tests use an in-memory duplex stream (e.g., tokio::io::duplex) as a mock socket. The test harness writes raw CMD_SUBMIT bytes to one end and reads RET_SUBMIT bytes from the other, with an IsoLoopbackHandler-based UsbDevice running handle_urb_loop on the opposite end.
- Single ISO IN URB with 8 packets — write one CMD_SUBMIT, read one RET_SUBMIT. Verify response has correct per-packet actual_lengths and concatenated data without padding.
- Single ISO OUT URB with 4 packets — write one CMD_SUBMIT with ISO data at correct offsets. Verify handler receives correct data slices per packet offset.
- Pipelined ISO IN URBs — write 3 CMD_SUBMITs consecutively before reading any responses. Read 3 RET_SUBMITs. Verify all 3 complete with correct seqnums (order may vary).
- ISO + control interleaved — write a CMD_SUBMIT for control and a CMD_SUBMIT for ISO. Verify both complete correctly through the concurrent loop.
7. Error Handling and Security
Bounds Checking
number_of_packetscapped atMAX_NUMBER_OF_PACKETS(256) — already enforced.transfer_buffer_lengthcapped atMAX_TRANSFER_BUFFER_LENGTH(16 MiB) — already enforced.- NEW: Validate ISO packet descriptor
offset + lengthdoes not exceedtransfer_buffer_length. Log warning and reject URB on violation.
Per-Packet Error Reporting
- Individual packet failures do not fail the entire URB. Response includes per-packet status and aggregate
error_count. - If the handler itself returns
Err(...), the entire URB fails withusbip_ret_submit_fail.
Concurrent Task Limits
- Bounded response channel (64 entries) provides natural backpressure.
- No explicit task cap — channel bound + interface mutex limit effective concurrency.
- A client flooding CMD_SUBMITs is throttled by the channel.
CMD_UNLINK
CancellationTokenper in-flight URB, keyed by seqnum.- On CMD_UNLINK: look up seqnum, signal cancellation. Send RET_UNLINK with status 0 if cancelled,
-ENOENTif already completed. - In-flight tasks check
CancellationToken::is_cancelled()before/after blocking USB calls (best-effort, sincespawn_blockingprecludestokio::select!).
Logging
All protocol errors, bounds violations, unexpected states, unlink misses, and per-packet errors are logged via warn!() or error!() before returning errors or continuing. No silent failures.
Timeouts
- Existing 30-second read timeout on the reader loop remains.
- Individual USB transfers use existing 1-second timeout in nusb handler (may need tuning for ISO).
8. Changes by File
| File | Changes |
|---|---|
lib/src/interface.rs |
New IsoPacketDescriptor, UrbRequest, UrbResponse structs. Update UsbInterfaceHandler::handle_urb and UsbDeviceHandler::handle_urb signatures. |
lib/src/device.rs |
Replace interfaces: Vec<UsbInterface> with interface_states: Vec<InterfaceState>. Add Isochronous arm to handle_urb. Handle SET_INTERFACE / GET_INTERFACE. Update config descriptor to emit all alt settings. |
lib/src/usbip_protocol.rs |
Parse ISO descriptors into Vec<IsoPacketDescriptor>. Update UsbIpCmdSubmit to use parsed descriptors. Update response serialization for ISO data packing. Add offset/length validation. |
lib/src/lib.rs |
Rewrite handle_urb_loop to concurrent architecture. Add CancellationToken tracking for CMD_UNLINK. Wire UrbRequest/UrbResponse. |
lib/src/host.rs |
Add Isochronous arm to NusbUsbHostInterfaceHandler. Add set_alternate_setting support. Error return for rusb ISO. |
lib/src/hid.rs |
Update handle_urb signature to UrbRequest/UrbResponse. No behavioral change. |
lib/src/cdc.rs |
Update handle_urb signature to UrbRequest/UrbResponse. No behavioral change. |
lib/src/util.rs |
Add IsoLoopbackHandler test fixture (#[cfg(test)]). |
lib/src/endpoint.rs |
No changes. |
lib/src/consts.rs |
No changes. |
cli/ |
No changes. CLI benefits automatically via NusbUsbHostInterfaceHandler + handle_urb_loop. |
Out of Scope
- Full UAC example device (follow-up work)
- IPv6 transport
- rusb isochronous support
- Multi-device per connection