GSoC 2026 final report

Aug 21, 2026

Overview

My project this summer is to implement USB redirection for qemu-rdp. qemu-rdp is an RDP server for the org.qemu.Display1 D-Bus interface that QEMU exposes with -display dbus. It runs as its own process, outside of QEMU, and turns a virtual machine into a remote desktop that speaks a protocol every platform already has a client for. It could already carry display, input, audio and clipboard, but not a USB device.

And my work aims to close that gap. USB redirection feels like plugging a local USB device into a remote QEMU machine over the network.

RDP carries everything beyond the core desktop over dynamic virtual channels, multiplexed by a static DRDYNVC channel (MS-RDPEDYC). MS-RDPEUSB defines one named URBDRC for USB. What it carries is deliberately low level: URBs, raw USB Request Blocks, so a device works without any code written for its class.

QEMU, meanwhile, already speaks usbredir, the protocol SPICE has used for USB pass-through for years. -device usb-redir gives the guest an emulated port and exposes the other end as a chardev, which QEMU offers over the same org.qemu.Display1 D-Bus interface. That chardev is qemu-rdp's way in.

So the project is a bridge. qemu-rdp plays the usbredir host, except it owns no device: each request from QEMU becomes a URB sent over URBDRC to the RDP client, and completes when the answer comes back.

[physical USB] → RDP client → URBDRC over DRDYNVC → qemu-rdp → usbredir → QEMU → [guest]

My Work

All my work can be found in these repos:

my info: @uchouT i@uchout.moe

  • qemu-rdp - the RDP server that exports a QEMU virtual machine. I wrote the USB redirection backend here, bridging URBDRC to usbredir.
  • IronRDP - the Rust RDP stack qemu-rdp is built on. The MS-RDPEUSB implementation lives here, along with the dynamic virtual channel work it needed.
  • FreeRDP - the widely used C implementation of RDP, and the client I tested against. A few urbdrc bug fixes.
  • QEMU - just a documentation fix for the D-Bus chardev.

I'll pick some highlights from my GSoC project below.

Refactor ironrdp-dvc

URBDRC asks more of the DVC layer than any channel before it. Channels have to be created and closed at runtime, and every instance shares one name — a control channel plus one channel per redirected device, told apart only by the ID DRDYNVC assigns them. ironrdp-dvc could do neither: channels were registered once at build time, and the registry was keyed by name, so a second URBDRC would have overwritten the first. It took several PRs to get there.

  • IronRDP#1142 - DvcChannelListener, so one name can produce many processors
  • IronRDP#1302 - closing a channel from either side
  • IronRDP#1368, IronRDP#1377 - typed accessors, so a caller recovers a processor together with its channel ID and the wrapper types stay private
  • IronRDP#1416 - reserve the ID before building the processor, so the processor can use it while initializing

The first of those came out of a design argument I am glad I made. IronRDP#1135 arrived with a plan already sketched, and the plan was to let the DVC layer do the work: a protocol processor would queue up the channels it wanted, and the layer beneath would notice and open them on its next pass. It would have worked for USB redirection. I argued against it anyway.

Two things were wrong with it. That layer only wakes up when the client sends something, so channels could only ever be opened in reaction to the client — while RDPEDYC is clear that the server is the side that opens them. And it asked a layer whose whole job is to speak RDPEDYC to also understand why a channel was wanted, a question only the protocol above it can answer. Convenient for USB redirection, useless to anything else, and paid for out of the layering.

What I proposed instead was that the DVC layer simply offer a way to create a channel and know nothing about who asked or why, leaving that decision to the layer that actually has the context.

ironrdp-rdpeusb PDU codec

IronRDP#1165 had left a PDU skeleton in place, and I finished the codec on top of it. The idea running through these PRs is to let the type system carry that context, so an invalid state is not merely rejected at runtime but impossible to build.

  • IronRDP#1294 - lift SHARED_MSG_HEADER out of every PDU struct, so callers cannot assemble a mismatched header
  • IronRDP#1321 - split the PDU and TS_URB enum wrappers
  • IronRDP#1403 - separate raw and validated InternalIoControl
  • IronRDP#1456 - type the URB completion payloads

URBDRC state machine

Sans-I/O protocol processors for both the server and the client side. This is one of the most interesting and challenging part of my GSoC journey :) The traits are the seam between the protocol and whatever backend sits behind it, so designing them meant working out the whole data flow first.

Setting up a new device channel is the trickiest part of that flow. When a device is plugged in on the client, the client asks the server to open a channel for it, and the server does, but what comes back carries nothing except a channel ID. To the DVC layer it is just another URBDRC channel, indistinguishable from the control channel or from any other device. The client still has to announce the device on it with ADD_DEVICE, and ADD_DEVICE needs that device's information.

Nothing in the protocol correlates the two, and the processor cannot do it either — it has no way of knowing which device is its own. Only the backend knows, because it is the side that asked for the channel in the first place. So that became part of the trait: when a new channel appears, the backend is asked to hand over the device it has been waiting to redirect, and the device information is fetched from it again later, at the moment ADD_DEVICE is actually assembled.

  • IronRDP#1365 - the client processors
  • IronRDP#1394 - the server processors, plus the backend-facing io model shared by both sides

USB model

Driving a redirected device through the processors alone meant assembling a TS_URB, choosing a URB function code, etc by hand. This work can be shared not just qemu-rdp. So I split the model into four layers:

  • ironrdp-usb — protocol-independent, no_std, sans-I/O, no dependencies. It describes USB operations without executing them, and parses only byte layouts USB itself defines.
  • ironrdp-rdpeusb::usb — translation. An ironrdp-usb request in, a complete RDPEUSB packet out, so the TS_URB payload, URB function, transfer envelope, flags and buffer shape cannot disagree.
  • ironrdp-server — the facade and the request lifetime: UsbDeviceHandle, request IDs, pending requests, cancellation, completion routing. A caller never names a TS_URB.
  • qemu-rdp — everything shaped by usbredir: packet types and IDs, ordering rules, in-flight scheduling, response reconstruction.

The full rationale could be found in IronRDP#1516.

Decoupling qemu-display's usbredir manager

qemu-display could already redirect a USB device to QEMU, but only a local physical one. Its usbredir module did not merely use usbredirhost — it was the usbredirhost session: a DeviceHandler implementation with its own Drop and raw file-descriptor polling, keyed by USB bus and device address. qemu_display::Error carried Rusb and Usbredir variants, so every consumer of the crate depended on libusb, and Display::usbredir() handed back a manager with nothing to substitute.

qemu-rdp has no local device to give it. Its device lives behind the RDP connection, and is driven by the client rather than by usbredirhost. But the part it does need was already written: finding a free chardev, creating the socket pair, registering one end over D-Bus, and tracking the live sessions and the free-channel count.

So I separated them. A UsbRedirBackend trait and a generic UsbRedir<B> keep the chardev orchestration in qemu-display; the usbredirhost session moved out to qemu-rdw, where it belongs; the rusb error variants left qemu_display:: Error; and Display::usbredir() became usbredir_chardevs() plus explicit backend injection.

  • qemu-display!8 - decouple the usbredir channel manager from the host backend

bridge URBDRC to usbredir in qemu-rdp

The bridge splits into a transport half and a protocol half. session_task frames the QEMU chardev and drives usbredir-proto's sans-I/O parser, holding no protocol state of its own; bridge holds all of it — negotiation, device state, packet translation, request correlation.

Future Work

Thoughts

Thanks

https://uchout.moe/posts/feed.xml