GSoC 2026 final report
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
URBDRCto 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
urbdrcbug fixes. - QEMU - just a documentation fix for the D-Bus chardev.
I'll pick some highlights from my GSoC project below.
demo video. (Bad oral English warnings!)
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
Refactoring 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_HEADERout of every PDU struct, so callers cannot assemble a mismatched header - IronRDP#1321 - split the PDU and
TS_URBenum 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
iomodel shared by both sides
Getting it working first
The protocol library was finished, and completely untested against anything real. So before polishing it any further I went for an end-to-end prototype: wire usbredir into qemu-rdp, redirect one device, and find out whether the state machine I had written actually held up.
qemu-rdp builds on ironrdp-server, so the wiring had to start there. I opened
IronRDP#1417 as a draft to
hold the place, then moved over to qemu-rdp and put usbredir in as fast as I
could. It worked in the end: FreeRDP connected, and a USB flash drive showed up
inside the guest. LLMs helped a lot during that push.
The result was buggy and not something I would want to maintain, but that was never the point. The point was to find out what I did not know yet, and it delivered:
- a couple of bugs in FreeRDP's own urbdrc client
- a set of edge cases in the Linux USB API that I only cornered by stracing the kernel to see what was actually being submitted — an interesting journey in itself :)
- USB state management and USB-to-RDPEUSB translation turned out to be far more
work than I expected, and meanwhile I ran into macrdp, another RDP server
built on
ironrdp-serverthat is solving exactly the same problem.
By the end of that detour I had enough context to see how the work should be actuall landed.
USB model
The duplicated part was never really application work. Driving a redirected
device through the protocol processors alone meant assembling TS_URB
structures and tracking USB device state by hand, and doing it again in every
application that wanted to redirect a device. So I split it into three layers,
each one allowed to speak only its own vocabulary:
ironrdp-usb— USB and nothing else: protocol-independent,no_std, sans-I/O, no dependencies. It describes USB operations without executing them, and parses only the byte layouts USB itself defines.ironrdp-rdpeusb::usb— translation. Anironrdp-usbrequest goes in, a complete RDPEUSB packet comes out, so theTS_URBpayload, URB function, transfer envelope, flags and buffer shape cannot disagree with each other.ironrdp-server— the facade, and the request lifetime that comes with it: request IDs, completion routing, and RAII pending requests that cancel themselves on the wire when dropped. A caller never names aTS_URB.
Above those sits the application. qemu-rdp only has to deal with what usbredir itself imposes, and macrdp, or anything else built on IronRDP, gets the same three layers for free. The full rationale is in IronRDP#1516.
- IronRDP#1682 - the new
ironrdp-usbcrate - IronRDP#1683 - the RDPEUSB translation module
- IronRDP#1417 - the server integration and the device facade
Rebuilding the qemu-rdp bridge
With the three layers in place I went back to qemu-rdp and rewrote the USB side on top of them.
The rewrite splits along the line the layering suggested. session_task is the
transport half: it frames the QEMU chardev, drives usbredir-proto's sans-I/O
parser, and holds no USB or usbredir state of its own. bridge is everything
else — capability negotiation, device state, packet translation, and
correlating each usbredir request with the RDP completion that eventually
answers it.
Correlation turned out to be the subtle one. A usbredir request and an RDPEUSB request are not the same object and do not end at the same moment, so the piece that tracks them in flight tracks only their lifetime; what a request is for rides along with it and is never interpreted there. Lifetime and meaning stay in different places.
Future Work
- Land the rest of the IronRDP work. The new crates need a release before anything downstream can depend on them.
- Interrupt IN and isochronous transfers in qemu-rdp. Only what a mass-storage device needs is wired up today.
- Test against mstsc. FreeRDP has been my only client so far, and the Windows one decides what the spec means in practice.
- URBDRC support in
ironrdp-client. IronRDP can serve a redirected device, but cannot yet redirect one. - Rebuild rdw-rdp on
ironrdp-client, now that it is a library rather than a binary — and pick up URBDRC along with it.
Thoughts
Actually, I have a lot to talk about, but to not make this post tedious, I just list tl;dr here. More about them may be published in the future, tagged with #gsoc-2026.
Data matters a lot
Data comes first, and code is shaped to fit it. Looking back, most of the time I thought I was designing behaviour I was really deciding something else: what to store, where it should live, and who is allowed to know it. The hard question was never what should this function do, but who holds this piece of information, and when.
With the wrong data, code turns ugly and fills up with hacks. With the right data, it comes out simple and elegant.
Deciding what not to do
In The one ring problem: abstraction and our quest for power (I found this
blog series in IronRDP repo's ARCHITECTURE.md), Ted Kaminski describes how
programmers are tempted to reach for ever more power in their abstractions. The
reach is tempting because power arrives as convenience and bills you later.
Most of the decisions I am happy with this summer were refusals. Again and again I was tempted to let a layer learn one small thing about the layer above it, because it would have made the case in front of me much easier. Leaving it out was never a limitation I ran into; it was a capability I declined to take, and it always felt like the long way round.
What you get back is a boundary the next person can reason about without reading your code. The less a layer exposes, the less there is to get wrong.
Communication in open source
In open source, people meet each other through the work itself. IronRDP's maintainer @CBenoit reached out on Matrix to say he liked my sense of architecture, and later asked for my help on the project's LLM harness — something well outside what I had signed up for. None of that came from introducing myself well.
There is a real freedom in that. No pleasantries to perform, no self-packaging (I feel really uncomfortable with it!!), no reading the room. You say the thing, you show the code, and the work carries exactly the weight it earns.
Thanks
- Marc-André Lureau - my mentor, who helped enormously throughout, needless to say
- IronRDP community - for the reviews, and I really learned a lot from it
- glamberson - we had a nice discussion about where the spec is ambiguous
- clintcan - macrdp maintainer, helps me with the test, and offers valuable mstsc cases
- playbahn - who did much of the early work on the PDU codec
- QEMU community - for taking me on as a GSoC org, and for
-display dbusand usbredir, the two things this whole project stands on - Google - for running Summer of Code, which is how all of this started