Phase 9: Interrupt transfers¶
Parent plan: PLAN-usb-redir.md
Goal¶
Handle usb_redir_start_interrupt_receiving,
usb_redir_stop_interrupt_receiving, and
usb_redir_interrupt_packet messages so that HID and
other interrupt-based USB devices work through passthrough.
After this phase, USB keyboards, mice, security keys,
and other interrupt-endpoint devices can be redirected.
The virtual mass storage device does not use interrupt transfers, so this phase only affects real device passthrough.
Background¶
Interrupt transfer model in usbredir¶
Interrupt transfers differ from bulk/control in that they are polled — the host periodically reads the device at a fixed interval. In the usbredir protocol this is modelled as:
-
Server sends
start_interrupt_receiving(endpoint)— the client should begin periodically reading from the interrupt IN endpoint and forwarding any data. -
Client sends
interrupt_packetdata as it arrives from the device (unsolicited — the server doesn't send a request per packet like it does for bulk). -
Server sends
stop_interrupt_receiving(endpoint)— the client stops polling. -
Server sends
interrupt_packetfor OUT — the client writes data to an interrupt OUT endpoint (rare, but used by some HID devices for LED control).
This is fundamentally different from the request/response model of bulk and control transfers. The client must maintain a background polling loop per active interrupt endpoint.
nusb interrupt endpoint API¶
nusb handles interrupt endpoints identically to bulk —
Interface::endpoint::<Interrupt, In>(addr) returns an
Endpoint that supports submit()/next_complete().
The key difference is that interrupt transfers are
naturally small and periodic.
For polling, we submit a transfer, await completion,
forward the data, and immediately submit another. This
continues until stop_interrupt_receiving arrives.
Architecture¶
Each active interrupt endpoint runs as a spawned tokio task that loops:
loop {
submit(Buffer::new(max_packet_size))
completion = next_complete().await
if cancelled: break
send interrupt_packet to server via channel
}
The channel handler stores JoinHandles for these tasks
and aborts them when stop_interrupt_receiving arrives
or the device disconnects.
Communication from the polling tasks back to the channel
handler uses an mpsc channel — the tasks can't directly
call send_usbredir() since it requires &mut self.
Scope¶
This phase adds interrupt transfer support for real devices only. The virtual mass storage device returns STALL for interrupt endpoints (unchanged from phase 6). Future virtual device types (e.g. virtual HID) would implement interrupt endpoints in their own backend.
Detailed steps¶
Step 1: Add interrupt data channel¶
Add an internal mpsc channel for interrupt polling tasks to send data back to the channel handler:
/// Data from an interrupt polling task.
struct InterruptData {
endpoint: u8,
data: Vec<u8>,
status: Status,
}
In UsbredirChannel, add:
interrupt_tx: mpsc::Sender<InterruptData>,
interrupt_rx: mpsc::Receiver<InterruptData>,
interrupt_handles: HashMap<u8, tokio::task::JoinHandle<()>>,
Add interrupt_rx to the tokio::select! loop alongside
network reads and USB commands.
Step 2: Handle start_interrupt_receiving¶
When UsbredirPayload::StartInterruptReceiving(sir)
arrives:
- Check that a device is connected.
- Check the endpoint is an interrupt IN type
(using
ep_info). - If already polling this endpoint, ignore (idempotent).
- Spawn a polling task for the endpoint.
- Send
InterruptReceivingStatuswithSuccess.
The polling task needs access to the nusb Interface and
the interrupt_tx sender. Since RealDevice owns the
interfaces, add a method to RealDevice:
pub fn spawn_interrupt_poll(
&self,
endpoint: u8,
max_packet_size: usize,
tx: mpsc::Sender<InterruptData>,
) -> Result<JoinHandle<()>>
This opens the interrupt endpoint, spawns a tokio task
that loops submit/next_complete, and sends
InterruptData on each completion.
For the trait, add to UsbDeviceBackend:
fn spawn_interrupt_poll(
&self,
endpoint: u8,
max_packet_size: usize,
tx: mpsc::Sender<InterruptData>,
) -> Result<JoinHandle<()>>;
With a default implementation that returns an error ("not supported") for virtual devices.
Step 3: Handle stop_interrupt_receiving¶
When UsbredirPayload::StopInterruptReceiving(sir) arrives:
- Look up the
JoinHandlefor this endpoint. - Abort the task.
- Remove it from
interrupt_handles. - Send
InterruptReceivingStatuswithSuccess.
Step 4: Forward interrupt data to server¶
In the tokio::select! loop, when interrupt_rx yields
InterruptData:
Some(idata) = self.interrupt_rx.recv() => {
let header = InterruptPacketHeader {
endpoint: idata.endpoint,
status: idata.status as u8,
length: idata.data.len() as u16,
};
let mut buf = Vec::new();
header.write(&mut buf)?;
buf.extend_from_slice(&idata.data);
self.send_usbredir(
msg_type::INTERRUPT_PACKET, 0, &buf
).await?;
}
The id field is 0 for unsolicited interrupt packets
(the server didn't request them with a specific ID).
Step 5: Handle interrupt OUT packets¶
When UsbredirPayload::InterruptPacket { header, data }
arrives and the endpoint is OUT (< 16):
- Forward to backend via a new
interrupt_out()method on the trait (or reuse the existing one if defined). - Respond with
interrupt_packetcontaining the status.
For IN direction packets from the server — this shouldn't happen (the client sends unsolicited data). Log and ignore.
Step 6: Clean up on disconnect¶
In disconnect_device(), abort all active interrupt
polling tasks:
for (ep, handle) in self.interrupt_handles.drain() {
handle.abort();
debug!("usbredir: stopped interrupt poll ep={}", ep);
}
Step 7: DeviceBackend enum extension¶
Add the spawn_interrupt_poll method delegation in the
DeviceBackend enum impl. The Virtual variant returns
an error (MSC doesn't use interrupts).
Files changed¶
| File | Change |
|---|---|
src/channels/usbredir.rs |
Add interrupt_tx/interrupt_rx/interrupt_handles fields; handle StartInterruptReceiving, StopInterruptReceiving; forward interrupt data in select loop; clean up on disconnect; replace STALL stub for interrupt packets with real IN/OUT handling |
src/usb/mod.rs |
Add InterruptData struct; add spawn_interrupt_poll to trait with default; extend DeviceBackend enum |
src/usb/real.rs |
Implement spawn_interrupt_poll — open interrupt endpoint, spawn polling task |
What is NOT in scope¶
- Interrupt transfers for virtual devices (the MSC device has no interrupt endpoints; future virtual HID would add this).
- Interrupt OUT for real devices (very rare — can add if a specific device needs it).
- Configurable polling interval (nusb handles this based
on the endpoint descriptor's
bInterval).
Testing¶
Build and lint¶
Manual testing¶
Requires a physical USB device with interrupt endpoints (e.g. a USB keyboard or mouse) and a host with USB access:
- Connect ryll with
--usb-device VID:PID(once phase 8 adds the flag) or via UI. - The guest OS should enumerate the HID device.
- Type on the keyboard / move the mouse.
- Verify input reaches the guest.
Unit tests¶
Limited scope — the polling loop requires a real USB
device. Test what we can:
- InterruptData struct construction.
- The start_interrupt_receiving → status response flow
(mock or error path).
- Cleanup on disconnect (verify handles are aborted).
Back brief¶
Before starting this phase, confirm understanding: we are
adding interrupt transfer support for real USB device
passthrough. The key architectural difference from bulk is
that interrupt IN is polled — the client maintains
background tokio tasks that continuously read from
interrupt endpoints and forward data unsolicited to the
server. These tasks communicate back via an internal mpsc
channel. The server controls the lifecycle with
start_interrupt_receiving / stop_interrupt_receiving
messages. The virtual MSC device is unaffected.