Skip to content

feat(rdpeusb): implement urbdrc client#1365

Merged
Benoît Cortier (CBenoit) merged 6 commits into
Devolutions:masterfrom
uchouT:urbdrc-client
Jun 29, 2026
Merged

feat(rdpeusb): implement urbdrc client#1365
Benoît Cortier (CBenoit) merged 6 commits into
Devolutions:masterfrom
uchouT:urbdrc-client

Conversation

@uchouT

@uchouT uchouT (uchouT) commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Resolves #1137

The device dvc processor manages a pending_io map internally and offers API for upper layer to construct completion DvcMessage.

@uchouT uchouT (uchouT) marked this pull request as draft June 9, 2026 09:42
@uchouT uchouT (uchouT) marked this pull request as ready for review June 10, 2026 08:36
@uchouT

uchouT (uchouT) commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Marc-Andre Lureau (@elmarco) Benoît Cortier (@CBenoit)
Hi, I've implemented the DVC processors for the urbdrc client. I would like to implement the urbdrc listener in a subsequent PR, as this PR has become too large and will require some modifications to drdynvc.

[EDIT]: UrbdrcListener is implemented in this PR.

@uchouT uchouT (uchouT) changed the title feat(rdpeusb): implement urbdrc control client feat(rdpeusb): implement urbdrc client dvc processor Jun 10, 2026
@uchouT uchouT (uchouT) changed the title feat(rdpeusb): implement urbdrc client dvc processor feat(rdpeusb): implement urbdrc client Jun 11, 2026
@uchouT uchouT (uchouT) force-pushed the urbdrc-client branch 10 times, most recently from c25c053 to b478576 Compare June 15, 2026 19:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Not ready to approve

The client state machine currently diverges from the issue’s required startup sequencing and adds substantial new protocol logic without the acceptance-criteria unit tests.

Pull request overview

Adds an ironrdp-rdpeusb client-side implementation of the URBDRC dynamic virtual channel, including a control-channel processor, per-device channel processor, and backend-neutral device metadata conversion used to construct RDPEUSB PDUs.

Changes:

  • Introduces UrbdrcListener, UrbdrcControlClient, and UrbdrcDeviceClient with a backend trait surface and pending-IO completion helpers.
  • Adds backend-neutral DeviceInfoADD_DEVICE conversion (PnP-style IDs, container IDs, capabilities) following observed FreeRDP behavior.
  • Integrates RDPEUSB PDUs with the DVC layer by implementing ironrdp_dvc::DvcEncode and adding an ironrdp-dvc dependency.
File summaries
File Description
crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs Adds DvcEncode impls and a helper to extract transfer request IDs.
crates/ironrdp-rdpeusb/src/pdu/sink.rs Marks sink PDUs as DVC-encodable (DvcEncode).
crates/ironrdp-rdpeusb/src/pdu/notify.rs Marks CHANNEL_CREATED as DVC-encodable (DvcEncode).
crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs Marks interface-manipulation PDUs as DVC-encodable (DvcEncode).
crates/ironrdp-rdpeusb/src/pdu/header.rs Exposes InterfaceId::from_raw within the crate for client allocation.
crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs Marks completion PDUs as DVC-encodable (DvcEncode).
crates/ironrdp-rdpeusb/src/pdu/caps.rs Marks capability PDUs as DVC-encodable (DvcEncode).
crates/ironrdp-rdpeusb/src/lib.rs Adds channel name, exports client module, and introduces InvalidDeviceInterfaceId error type.
crates/ironrdp-rdpeusb/src/client/mod.rs Implements the URBDRC listener + control/device client processors and backend traits.
crates/ironrdp-rdpeusb/src/client/device.rs Implements backend-neutral USB device facts and ADD_DEVICE PDU construction.
crates/ironrdp-rdpeusb/Cargo.toml Adds public dependency on ironrdp-dvc.
Cargo.lock Records the new ironrdp-dvc dependency edge.

Copilot's findings

  • Files reviewed: 11/12 changed files
  • Comments generated: 8

Note

Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +67 to +90
const fn alloc(&mut self) -> InterfaceId {
self.id += 1;
if self.id > 0x3F_FF_FF_FF {
panic!("USB device amount overflow")
}
InterfaceId::from_raw(self.id)
}
}

impl DvcChannelListener for UrbdrcListener {
fn channel_name(&self) -> &str {
CHANNEL_NAME
}

fn create(&mut self, channel_id: u32) -> Option<Box<dyn DvcProcessor>> {
if let Some(callback) = self.on_capability_exchanged.take() {
self.device_man.control_channel_assigned(channel_id);
Some(Box::new(UrbdrcControlClient::new(callback)))
} else {
#[expect(clippy::as_conversions)]
self.device_man.take_device_for_channel(channel_id).map(|backend| {
Box::new(UrbdrcDeviceClient::new(self.iface_man.alloc(), backend).expect("invalid interface id"))
as Box<dyn DvcProcessor>
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uchouT (@uchouT) this is intentionally post-poned? You could turn alloc() -> Option and remove all the expect/panic.

Comment thread crates/ironrdp-rdpeusb/src/client/mod.rs
Comment thread crates/ironrdp-rdpeusb/src/client/mod.rs
Comment thread crates/ironrdp-rdpeusb/src/client/mod.rs
Comment thread crates/ironrdp-rdpeusb/src/client/mod.rs
Comment thread crates/ironrdp-rdpeusb/src/client/mod.rs
Comment thread crates/ironrdp-rdpeusb/src/lib.rs Outdated
Comment thread crates/ironrdp-rdpeusb/src/client/mod.rs Outdated
Signed-off-by: uchouT <i@uchout.moe>
@uchouT

Copy link
Copy Markdown
Contributor Author

Updated ironrdp-dvc deps to launch ci, and accepted style suggestions by Copilot. No other changes. Benoît Cortier (@CBenoit)

uchouT (uchouT) and others added 2 commits June 24, 2026 19:26
`ADD_DEVICE` message is constructed following FreeRDP's pattern.

`UrbdrcDeviceClient` maintains a pending IO request map. When the packet
is handled asynchronously, the information for the completion message
construction (such as output buffer size) will be stored in this map,
and `UrbdrcDeviceClient` provides public APIs for completion message
construction, which will consume the map items internally.

Signed-off-by: uchouT <i@uchout.moe>
Signed-off-by: uchouT <i@uchout.moe>

Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Add test for new device sequence and channel setup sequence.

Use hardcoded message id when constructing `ADD_VIRTUAL_CHANNEL`.

Signed-off-by: uchouT <i@uchout.moe>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated 3 comments.

Comment on lines +143 to +146
// Follow FreeRDP use device id as message id
Ok(Box::new(AddVirtualChannel {
msg_id: ADD_VIRTUAL_CHANNEL_MSG_ID,
}))
Comment on lines +267 to +271
#[derive(Debug, Clone)]
pub struct DeviceText {
pub hresult: u32,
pub description: String,
}
Comment on lines +286 to +329
// Ref: [New Device Sequence][1.3.1.2]
// [1.3.1.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/7e3da218-9cdc-4ebd-bb76-e70202c7f264
#[test]
fn new_device_sequence() {
let udev_iface = InterfaceId::try_from(4).expect("valid device interface id");
let backend = Box::new(TestDeviceBackend::new(simple_device_info()));
let mut client = UrbdrcDeviceClient::new(udev_iface, backend).expect("device client should be created");

assert!(!client.ready_for_io());

let resp = client
.process(
99,
&encode_pdu(&UrbdrcServerDevicePdu::ChanCreated(ChannelCreated {
msg_id: 21,
direction: Direction::ToClient,
})),
)
.expect("channel-created notification should succeed");
assert_eq!(resp.len(), 1);
let UrbdrcClientDevicePdu::ChanCreated(response) = decode_device_msg(&resp[0]) else {
panic!("expected channel-created response");
};
assert_eq!(response.msg_id, 21);
assert_eq!(response.direction, Direction::ToServer);
assert!(!client.ready_for_io());

let resp = client
.process(
99,
&encode_pdu(&UrbdrcServerDevicePdu::IfaceRelease(InterfaceRelease {
iface_id: proxy_iface_id(InterfaceId::NOTIFY_CLIENT),
msg_id: 22,
})),
)
.expect("notification release should succeed");
assert_eq!(resp.len(), 1);
assert!(client.ready_for_io());

let UrbdrcClientDevicePdu::AddDev(add_device) = decode_device_msg(&resp[0]) else {
panic!("expected add device");
};
assert_eq!(add_device.usb_device, udev_iface);
}
@uchouT

uchouT (uchouT) commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

I added some tests covering new device add sequence and channel setup sequence, and I plan to add io sequence integration test when server state machine is settled.

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi! Great work!

There is an acceptance criteria from issue #1137 that is not upheld:

Protocol-level unit tests with mock backend

It would be nice to have a "protocol-level unit tests with mock backend" covering the control channel setup sequence, the device channel setup sequence, and IO dispatch/completion round-trips. That being said, I’m happy if this is submitted as a follow up 🙂

Overall, I think it’s a very solid PR, and I’m good with the idea of merging now for velocity, and you could send follow up PRs for anything remaining. How do you want to proceed?

use crate::{CHANNEL_NAME, InvalidDeviceInterfaceId};

pub mod device;
pub use device::*;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style:  We tend to prefer explicit re-exports for clarity: pub use device::{DeviceInfo, UsbConnectionSpeed, ...}

Comment on lines +719 to +723
if no_ack {
self.backend
.transfer_out(channel_id, request_id.into(), transfer_out_pdu)?;
Ok(Vec::new())
} else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UrbdrcDeviceBackend::transfer_out contract documents None = pending and Some = immediate completion. In the no-ack path, Some(response) is silently swallowed. Either the contract should explicitly say "returning Some is meaningless in no-ack context" or the backend shouldn't be expected to produce a response at all (different method signature for no-ack vs. ack).

Ok(Vec::new())
}
}
IoCtl(io_ctl_pdu) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: The IoCtl and InternalIoCtl arms are nearly identical. A shared helper taking a Pending variant + msg_id + max_output_buf_size could eliminate the repetition cleanly. As you prefer.

Comment thread crates/ironrdp-rdpeusb/src/client/mod.rs
Comment thread crates/ironrdp-rdpeusb/src/client/device.rs Outdated
@CBenoit

Copy link
Copy Markdown
Member

I added some tests covering new device add sequence and channel setup sequence, and I plan to add io sequence integration test when server state machine is settled.

Oops, I just saw this after submitting my review I was drafting over several days. Sounds good to me. Thank you!

@uchouT

Copy link
Copy Markdown
Contributor Author

Benoît Cortier (@CBenoit) Thanks for the review!

I've addressed the two inconsistent problems. And all other issues you pointed out make sense to me, but I'd like to address them in the follow-up PR since I've modified them locally when implementing server state machine.

Comment on lines +67 to +90
const fn alloc(&mut self) -> InterfaceId {
self.id += 1;
if self.id > 0x3F_FF_FF_FF {
panic!("USB device amount overflow")
}
InterfaceId::from_raw(self.id)
}
}

impl DvcChannelListener for UrbdrcListener {
fn channel_name(&self) -> &str {
CHANNEL_NAME
}

fn create(&mut self, channel_id: u32) -> Option<Box<dyn DvcProcessor>> {
if let Some(callback) = self.on_capability_exchanged.take() {
self.device_man.control_channel_assigned(channel_id);
Some(Box::new(UrbdrcControlClient::new(callback)))
} else {
#[expect(clippy::as_conversions)]
self.device_man.take_device_for_channel(channel_id).map(|backend| {
Box::new(UrbdrcDeviceClient::new(self.iface_man.alloc(), backend).expect("invalid interface id"))
as Box<dyn DvcProcessor>
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uchouT (@uchouT) this is intentionally post-poned? You could turn alloc() -> Option and remove all the expect/panic.

Comment thread crates/ironrdp-rdpeusb/src/client/mod.rs Outdated

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you Marc-Andre Lureau (@elmarco) uchouT (@uchouT)

I think we got to a pretty decent state, and the PR is big enough, let’s merge and address any remaining concern in follow up PRs 🙂

@CBenoit Benoît Cortier (CBenoit) enabled auto-merge (squash) June 29, 2026 12:52
@CBenoit Benoît Cortier (CBenoit) merged commit 66c9b5d into Devolutions:master Jun 29, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

[ironrdp-rdpeusb] Client-side protocol state machine

4 participants