usbip-rs/docs/superpowers/specs/2026-03-19-isochronous-support-design.md
Davíð Steinn Geirsson f7236deba9 feat: add isochronous transfer support and fix host passthrough
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>
2026-03-22 10:42:08 +00:00

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_loop to 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: IsoLoopbackHandler for 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:

  1. Acquire InterfaceState.lock (read) — gates alt setting changes
  2. Acquire UsbInterface.handler (Arc<Mutex<...>>) — serializes handler state mutations
  3. Call handler.handle_urb()
  4. Release handler mutex, then release RwLock read guard

For SET_INTERFACE:

  1. Acquire InterfaceState.lock (write) — waits for all in-flight URBs on this interface to finish
  2. Swap active to the new alt setting
  3. Release write lock

The handler mutex is never held while acquiring the RwLock, preventing deadlocks.

Behavior

  • interface_states[i].alt_settings stores all alternate settings for interface i.
  • interface_states[i].active always reflects the currently active alternate setting.
  • SET_INTERFACE(intf=i, alt=n): acquire write lock, swap active = alt_settings[n].clone(), update current_alt.
  • GET_INTERFACE(intf=i): returns interface_states[i].current_alt.
  • Configuration descriptor generation iterates each InterfaceState.alt_settings to emit all alternates with correct bAlternateSetting values.

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_blocking for URB processing: The UsbInterfaceHandler::handle_urb trait method remains synchronous. Handler implementations (especially nusb) perform blocking USB I/O (transfer_blocking, .wait()). Each URB is dispatched via tokio::task::spawn_blocking to avoid starving the async executor. The reader loop and writer task remain async. The closure passed to spawn_blocking acquires the interface RwLock read guard and handler mutex, calls handle_urb, then sends the response through the channel.
  • Per-URB tasks: Each CMD_SUBMIT dispatches a spawn_blocking task. 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 RwLock is tokio::sync::RwLock — acquiring it from spawn_blocking requires entering the tokio runtime context via Handle::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. Using std::sync::Mutex is appropriate inside spawn_blocking.
  • Graceful shutdown: When the reader hits EOF, drop response_tx. The writer drains remaining responses, then exits.
  • 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, where tokio::select! is unavailable. Instead, tasks check CancellationToken::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_length bytes 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-packet actual_length values) always equals the concatenated buffer length. No special-casing needed in the serialization path.
  • ISO packet descriptors follow, with offset fields 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 + length must not exceed transfer_buffer_length. Reject and log if violated.

5. Host Passthrough (nusb)

NusbUsbHostInterfaceHandler

New Isochronous arm in handle_urb:

ISO IN (device → host):

  1. Open isochronous endpoint via handle.endpoint::<Isochronous, In>(ep.address)
  2. Build packet lengths from request descriptors
  3. Allocate buffer and submit isochronous transfer
  4. Build response: concatenate actual data per packet (no padding), build response IsoPacketDescriptor with actual_length and status, count errors

ISO OUT (host → device):

  1. Open isochronous endpoint via handle.endpoint::<Isochronous, Out>(ep.address)
  2. Scatter request.data into packets using descriptor offsets
  3. Submit to device
  4. Build response with per-packet status

Alternate Setting Support

When SET_INTERFACE arrives (control transfer with request = 0x0B):

  1. Acquire write lock on the interface
  2. Call nusb::Interface::set_alternate_setting(alt) on the real device
  3. Update InterfaceState.active with new endpoints from alt_settings[alt]
  4. Update current_alt index
  5. 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 length bytes with incrementing counter value. Returns per-packet descriptors with actual_length = length, status = 0.
  • ISO OUT: Accepts data, returns per-packet descriptors with actual_length matching received data, status = 0.
  • Control: Handles GET_DESCRIPTOR minimally. Handles SET_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.

  1. 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.
  2. 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.
  3. 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).
  4. 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_packets capped at MAX_NUMBER_OF_PACKETS (256) — already enforced.
  • transfer_buffer_length capped at MAX_TRANSFER_BUFFER_LENGTH (16 MiB) — already enforced.
  • NEW: Validate ISO packet descriptor offset + length does not exceed transfer_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 with usbip_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.
  • CancellationToken per in-flight URB, keyed by seqnum.
  • On CMD_UNLINK: look up seqnum, signal cancellation. Send RET_UNLINK with status 0 if cancelled, -ENOENT if already completed.
  • In-flight tasks check CancellationToken::is_cancelled() before/after blocking USB calls (best-effort, since spawn_blocking precludes tokio::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