From 2c574262a377de69dae7c7f5c9eec4a7cbbdec49 Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sat, 26 Sep 2026 00:03:08 -0600 Subject: [PATCH 1/2] feat(client): report which transport carries the session An embedder had no way to tell whether a session's dynamic channels, the graphics pipeline among them, actually moved onto the reliable RDP-UDP tunnel or stayed on TCP, nor which RDP-UDP version the tunnel negotiated. Both depend on the server, and a failed UDP bootstrap falls back to TCP on its own. - rdpeudp: `RdpeudpConnection::negotiated_version` returns the version the handshake settled on. - rdpeudp-tokio: the driver records it when the connection is established, and `UdpTransport::negotiated_version` exposes it. - client: a new `RdpOutputEvent::Transport { reliable_udp, udp_version }` (with the `udp` feature) is sent once the session is active and again whenever either value changes: a tunnel comes up, Soft-Sync moves the channels onto it, or the session falls back to TCP. The viewer logs the event. --- crates/ironrdp-client/src/rdp.rs | 41 +++++++++++++++++++ crates/ironrdp-rdpeudp-tokio/src/driver.rs | 5 +++ crates/ironrdp-rdpeudp-tokio/src/stream.rs | 5 +++ crates/ironrdp-rdpeudp-tokio/src/transport.rs | 6 +++ crates/ironrdp-rdpeudp/src/connection.rs | 9 ++++ .../tests/rdpeudp/connection.rs | 3 ++ crates/ironrdp-viewer/src/app.rs | 6 +++ 7 files changed, 75 insertions(+) diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index a67d49c9af..1ca59d21dc 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -200,6 +200,20 @@ pub enum RdpOutputEvent { /// A cookie-based reconnect has completed successfully. AutoReconnected, Terminated(SessionResult), + /// The transport carrying the dynamic channels changed. + /// + /// Sent once the session is active, and again whenever a reliable RDP-UDP tunnel is + /// established, Soft-Sync moves the dynamic channels onto it, or the session falls back + /// to TCP. + #[cfg(feature = "udp")] + Transport { + /// The dynamic channels, the graphics pipeline among them, travel over the reliable + /// RDP-UDP tunnel. + reliable_udp: bool, + /// The RDP-UDP version the tunnel's handshake settled on, while a tunnel is open, + /// whether or not Soft-Sync has moved channels onto it yet. + udp_version: Option, + }, } /// A tightly packed changed region from the composited desktop framebuffer. @@ -3085,6 +3099,8 @@ async fn active_session( let mut graceful_shutdown_sent = false; let mut post_logon_redraw_requested = false; let mut pending_udp_payload: Option> = None; + #[cfg(feature = "udp")] + let mut announced_transport = None; let mut initial_outputs = if *graceful_close_receiver.borrow_and_update() { graceful_shutdown_sent = true; Some(active_stage.graceful_shutdown()?) @@ -4241,6 +4257,31 @@ async fn active_session( } } + #[cfg(feature = "udp")] + { + let transport = ( + active_stage.reliable_udp_dvc_tunnel_in_use(), + udp_tunnel + .transport + .as_ref() + .and_then(ironrdp_rdpeudp_tokio::UdpTransport::negotiated_version), + ); + if announced_transport != Some(transport) { + announced_transport = Some(transport); + let (reliable_udp, udp_version) = transport; + info!(reliable_udp, ?udp_version, "Session transport"); + let event = RdpOutputEvent::Transport { + reliable_udp, + udp_version, + }; + if !send_active_output_event(output_event_sender, event, close_receiver).await? { + return Ok(RdpControlFlow::TerminatedGracefully( + GracefulDisconnectReason::UserInitiated, + )); + } + } + } + if resize_queue.in_flight.is_none() && let Some(pending) = resize_queue.pending.as_ref() { diff --git a/crates/ironrdp-rdpeudp-tokio/src/driver.rs b/crates/ironrdp-rdpeudp-tokio/src/driver.rs index 25c55900d4..3f5b9656d5 100644 --- a/crates/ironrdp-rdpeudp-tokio/src/driver.rs +++ b/crates/ironrdp-rdpeudp-tokio/src/driver.rs @@ -316,6 +316,11 @@ impl Driver { Event::Connected => { if !self.connected_signaled { self.connected_signaled = true; + let version = self.conn.negotiated_version(); + if let Ok(mut shared) = self.shared.lock() { + shared.negotiated_version = version; + } + tracing::debug!(?version, "RDP-UDP handshake complete"); self.connected_notify.notify_one(); } } diff --git a/crates/ironrdp-rdpeudp-tokio/src/stream.rs b/crates/ironrdp-rdpeudp-tokio/src/stream.rs index 384faf44bd..3b7235e5ac 100644 --- a/crates/ironrdp-rdpeudp-tokio/src/stream.rs +++ b/crates/ironrdp-rdpeudp-tokio/src/stream.rs @@ -79,6 +79,10 @@ pub(crate) struct SharedIo { /// Set when the RDPEUDP2 connection has been cleanly shut down. pub(crate) closed: bool, + + /// The RDP-UDP version the handshake settled on, recorded by the driver once the + /// connection is established. + pub(crate) negotiated_version: Option, } impl SharedIo { @@ -93,6 +97,7 @@ impl SharedIo { write_room_waker: None, error: None, closed: false, + negotiated_version: None, } } diff --git a/crates/ironrdp-rdpeudp-tokio/src/transport.rs b/crates/ironrdp-rdpeudp-tokio/src/transport.rs index 3e53395a47..5e98a0bd0c 100644 --- a/crates/ironrdp-rdpeudp-tokio/src/transport.rs +++ b/crates/ironrdp-rdpeudp-tokio/src/transport.rs @@ -323,6 +323,12 @@ impl UdpTransport { } } + /// The RDP-UDP version the handshake settled on: version 1 or 2 for MS-RDPEUDP, + /// version 3 for MS-RDPEUDP2. + pub fn negotiated_version(&self) -> Option { + self.shared.lock().ok().and_then(|shared| shared.negotiated_version) + } + /// Whether the driver task is still running. pub fn is_alive(&self) -> bool { !self.driver_handle.is_finished() diff --git a/crates/ironrdp-rdpeudp/src/connection.rs b/crates/ironrdp-rdpeudp/src/connection.rs index 5051a44e64..a5453b24fc 100644 --- a/crates/ironrdp-rdpeudp/src/connection.rs +++ b/crates/ironrdp-rdpeudp/src/connection.rs @@ -927,6 +927,15 @@ impl RdpeudpConnection { self.params.as_ref().map(|p| p.mtu) } + /// The protocol version the handshake settled on, once it is complete: version 1 or 2 + /// for MS-RDPEUDP, version 3 for MS-RDPEUDP2. + pub fn negotiated_version(&self) -> Option { + self.params.as_ref().map(|params| match params.wire { + WireFormat::V1 { version } => UdpVersion(version), + WireFormat::V2 => UdpVersion::V3, + }) + } + /// Diagnostics for the MS-RDPEUDP version 1/2 data path; `None` on MS-RDPEUDP2. pub fn v1_stats(&self) -> Option { let params = self.params.as_ref()?; diff --git a/crates/ironrdp-testsuite-core/tests/rdpeudp/connection.rs b/crates/ironrdp-testsuite-core/tests/rdpeudp/connection.rs index c100c37c4f..a73b1d06d8 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpeudp/connection.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpeudp/connection.rs @@ -130,6 +130,7 @@ fn full_handshake_client_server() { .expect("handle SYN+ACK"); assert!(client.is_established()); + assert_eq!(client.negotiated_version(), Some(UdpVersion::V3)); // Client should emit Connected event let event = client.poll_event().expect("should have event"); @@ -1221,11 +1222,13 @@ fn a_client_follows_a_syn_ack_that_settles_on_version_2() { let mut client = RdpeudpConnection::connect(default_config(100), t).expect("connect"); client.poll_transmit(t).expect("SYN"); + assert_eq!(client.negotiated_version(), None); let mut bytes = version_2_syn_ack(); client .handle_datagram(&mut bytes, later(t, 50)) .expect("version 2 is a version both endpoints support"); assert!(client.is_established()); + assert_eq!(client.negotiated_version(), Some(UdpVersion::V2)); // The final handshake ACK acknowledges the SYN+ACK in MS-RDPEUDP framing. let ack = client.poll_transmit(later(t, 50)).expect("final ACK"); diff --git a/crates/ironrdp-viewer/src/app.rs b/crates/ironrdp-viewer/src/app.rs index 9a18f23ced..0d8acaa0ad 100644 --- a/crates/ironrdp-viewer/src/app.rs +++ b/crates/ironrdp-viewer/src/app.rs @@ -620,6 +620,12 @@ impl RpcApp { debug!(?control, "RAIL control received"); } RdpOutputEvent::WindowingOrders(_) => {} + RdpOutputEvent::Transport { + reliable_udp, + udp_version, + } => { + info!(reliable_udp, ?udp_version, "Session transport"); + } // Only produced when the client is built with `.with_desktop_updates()`, which the // viewer does not opt into: it always presents full-frame `Image` snapshots instead. RdpOutputEvent::DesktopUpdate(_) => {} From 723ae4fa3655a341d34049a80be9caea18d5615e Mon Sep 17 00:00:00 2001 From: AKolenda Date: Sat, 26 Sep 2026 12:40:24 -0600 Subject: [PATCH 2/2] fix(client): log the session transport once The client logged the transport when it emitted the event and the viewer logged it again on receipt. Leave logging to the embedder, as for the other session events, and drop the driver's handshake log, which repeated the one in the connect and accept flows. --- crates/ironrdp-client/src/rdp.rs | 1 - crates/ironrdp-rdpeudp-tokio/src/driver.rs | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 1ca59d21dc..f105b342e1 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -4269,7 +4269,6 @@ async fn active_session( if announced_transport != Some(transport) { announced_transport = Some(transport); let (reliable_udp, udp_version) = transport; - info!(reliable_udp, ?udp_version, "Session transport"); let event = RdpOutputEvent::Transport { reliable_udp, udp_version, diff --git a/crates/ironrdp-rdpeudp-tokio/src/driver.rs b/crates/ironrdp-rdpeudp-tokio/src/driver.rs index 3f5b9656d5..590568c597 100644 --- a/crates/ironrdp-rdpeudp-tokio/src/driver.rs +++ b/crates/ironrdp-rdpeudp-tokio/src/driver.rs @@ -316,11 +316,9 @@ impl Driver { Event::Connected => { if !self.connected_signaled { self.connected_signaled = true; - let version = self.conn.negotiated_version(); if let Ok(mut shared) = self.shared.lock() { - shared.negotiated_version = version; + shared.negotiated_version = self.conn.negotiated_version(); } - tracing::debug!(?version, "RDP-UDP handshake complete"); self.connected_notify.notify_one(); } }