Skip to content

feat(server): wire UDP multitransport into ironrdp-server - #1954

Merged
Benoît Cortier (CBenoit) merged 14 commits into
Devolutions:masterfrom
lamco-admin:feat/server-multitransport-wiring
Sep 30, 2026
Merged

Benoît Cortier (CBenoit) merged 14 commits into
Devolutions:masterfrom
lamco-admin:feat/server-multitransport-wiring

Conversation

@glamberson

@glamberson Greg Lamberson (glamberson) commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Add RdpServerBuilder::with_udp_transport(udp_bind_addr): opt-in, None by default, no behavior change unless called.
  • When set (and the security mode is Tls or Hybrid, matching the reference client's Enhanced-Security-only gate), the acceptor offers UDP multitransport, and accept_finalize uses accept_finalize_with_multitransport with a callback that binds a fresh UDP socket per connection, reuses the connection's own TLS certificate (TlsAcceptor::config()) for the sideband transport, and calls accept_udp().
  • Once established, the transport is used to migrate EGFX graphics traffic off TCP: request_reliable_udp is called opportunistically the first time EGFX has data to send (its dynamic channel id is only known once the client opens it). From the request on, every server message on that channel goes over the tunnel, starting with the batch that triggered it: the request carries SOFT_SYNC_TCP_FLUSHED and the server MUST keep using the named tunnel immediately after sending it (MS-RDPEDYC 2.2.5.1, 3.3.5.3.1). DRDYNVC replies for a tunneled channel go over the tunnel too, whichever path produced them. A new client_loop select arm feeds incoming tunnel payloads into DrdynvcServer::process_tunnel(); payloads that arrive before the client's Soft-Sync Response are held and processed once it does (3.3.5.3.2), rather than dropped.
  • The UDP accept runs as an ordinary tokio::spawn task, so enabling UDP adds no runtime requirement for the caller.
  • A failure before EGFX has moved (bind, handshake, TLS, or the tunnel closing) leaves the session on TCP, matching the reference client's posture. Once EGFX is on the tunnel, the tunnel closing ends the connection: Soft-Sync cannot move a channel back to TCP (MS-RDPEDYC 2.2.5.1), and the tunnel lasts as long as the connection (MS-RDPEMT 1.3.3).
  • A client that answers the Initiate Multitransport Request with E_ABORT (MS-RDPBCGR 2.2.15.2) has given up on the sideband transport, so the pending UDP accept is stopped as soon as that response arrives, whether during finalization or later on the message channel, instead of holding its socket until the 15 s accept timeout. Windows clients send it about 2.7 s after connecting.
  • When the UDP bind address has an unspecified IP, each connection's socket binds to the local address that client reached over TCP instead. A socket bound to the unspecified address replies from whichever address the routing table picks, and on a host with several IPv6 addresses that is not always the one the client sent to: mstsc dropped the replies and gave up with E_ABORT. run() records the address itself; embedders driving run_connection_with pass it with the new RdpServer::set_connection_local_addr.
  • A successful Initiate Multitransport Response that arrives after finalization now enables EGFX migration for the rest of the session, provided Soft-Sync was negotiated. mstsc finishes its UDP bootstrap after the TCP finalization (0.87 s later in my test), so migration was previously decided before its response existed and the session never left TCP even with the sideband transport up.
  • The EGFX channel is found whichever way it was registered: an embedder that takes a frame handle from its GfxServerFactory registers it as GfxDvcBridge, which the migration lookup did not recognise, so it never sent the Soft-Sync Request. The Soft-Sync Request, the client's response (with the tunnels and channels it accepted) and the switch of EGFX onto UDP are now logged at debug level.

Validation

cargo xtask check fmt/lints/tests/typos/locks all pass, including --features egfx specifically (the new code has real feature-gated branches).

Two tests in ironrdp-testsuite-extra. The first confirms that configuring UDP transport on the server does not disturb a client that never advertises support for it (the common case for any client predating this feature). The second, egfx_moves_onto_the_udp_tunnel_with_soft_sync, drives a real client through connect_finalize_with_multitransport and the full RDPEUDP2 handshake, then checks that the EGFX batch that triggers the Soft-Sync Request goes over the tunnel, that client tunnel data arriving ahead of the Soft-Sync Response is processed, and that the reply to it comes back over the tunnel. The test enables ironrdp-server's egfx feature as a dev-dependency, so the EGFX-over-UDP path is now compiled in CI.

Notes

One thing caught and fixed while implementing this, not left in the diff:

  • An early draft discarded the SvcMessage (the SoftSyncRequest PDU) that request_reliable_udp() returns, meaning the server would decide to migrate but never actually tell the client. Fixed to encode and send it over TCP.

@github-actions github-actions Bot added breaking-change Includes a breaking change, and requires special scrutiny at the boundaries triage/overlap Possible overlap with another pull request; advisory only kind/protocol Affects RDP or related protocol behavior needs-review A human reviewer is the current next actor risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny scope/cross-cutting Spans multiple architectural boundaries size/XL Size: up to 1299 counted lines and 49 files; exceeds L in either measure labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Potential duplicate detected: #1951.

The acceptor portion (MultitransportBootstrapping state, set_multitransport_offer, GCC advertisement, message-channel request, acceptor.rs tests) matches #1951's described content, and the driver matches #1953's accept_finalize_with_multitransport; this cumulative diff duplicates both stacked PRs, closest single match #1951.

Maintainer review is required.

@github-actions github-actions Bot left a comment

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.

The acceptor-level multitransport work is sound (state machine, message-channel gating, GCC advertisement, response tolerance, tests), but the runtime integration has three significant defects: EGFX Soft-Sync is initiated without advertising SOFTSYNC_TCP_TO_UDP or awaiting a successful Initiate Multitransport Response (violates MS-RDPEDYC/MS-RDPBCGR), UdpTransportHandle::recv holds the tokio Mutex across the idle await and can deadlock sends, and the finalize handler awaits the full 15s UDP accept inline, stalling the handshake. Also published: the routing gate treats a declining SoftSyncResponse as acceptance, the false one-connection-at-a-time premise under preemption, unconditional UDP dependencies for an opt-in feature, and two minor cleanup/test-duplication items. Two optional compression proposals (defensive finalize guard, folding the bootstrapping state) were rejected.

Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-server/src/multitransport.rs
Comment thread crates/ironrdp-server/src/server.rs
Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-server/src/multitransport.rs Outdated
Comment thread crates/ironrdp-server/Cargo.toml
Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-testsuite-core/tests/server/multitransport_finalize.rs Outdated
@github-actions github-actions Bot added ai-reviewed/1 One automated review completed and removed needs-review A human reviewer is the current next actor labels Sep 11, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/server-multitransport-wiring branch from 03037a4 to 5f5e9bb Compare September 11, 2026 23:32
@github-actions github-actions Bot added size/XXL Size: 1300 or more counted lines or 50 or more files and removed size/XL Size: up to 1299 counted lines and 49 files; exceeds L in either measure labels Sep 11, 2026
@github-actions github-actions Bot added the scope/core Touches the core architectural tier label Sep 12, 2026

@github-actions github-actions Bot left a comment

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.

PR wires opt-in UDP multitransport through the acceptor (MultitransportBootstrapping state, late-response tolerance, accept_finalize_with_multitransport), a server-side RDPEUDP2+TLS+RDPEMT accept path, and soft-sync-gated EGFX migration, all default-off behind with_udp_transport. The state machine, GCC gating, strict response decoding, and the dvc outgoing-map fix check out. Published: a high-severity silent EGFX black-hole after mid-session tunnel closure (send failures unhandled, no TCP fallback), a migration gate frozen at acceptance that late responses can never enable, an integration test whose scripted client violates the S_OK/SOFTSYNC rule, and low-severity cleanup items (unused accessor, unconditional deps, two duplications). Rejected the speculative auto-detect swallow finding: the response decoder strictly validates SEC_TRANSPORT_RSP, so mismatched message-channel traffic fails decode and falls through.

  1. [code-compressor] Three new hard dependencies back only an opt-in runtime path — low 🟡 — crates/ironrdp-server/Cargo.toml
    ironrdp-rdpemt, ironrdp-rdpeudp, and ironrdp-rdpeudp-tokio are unconditional dependencies yet are consumed solely by src/multitransport.rs, whose runtime path only executes when udp_bind_addr is Some (None by default). Marking them optional behind a dedicated/egfx feature with #[cfg] pairs on the module and its plumbing would shrink the default build with identical runtime behavior; the author acknowledges the mechanical breadth in review.

Comment thread crates/ironrdp-server/src/server.rs
Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-testsuite-core/tests/server/multitransport_finalize.rs Outdated
Comment thread crates/ironrdp-acceptor/src/connection.rs Outdated
Comment thread crates/ironrdp-server/src/server.rs
Comment thread crates/ironrdp-server/src/server.rs Outdated
@github-actions github-actions Bot added risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny size/XXL Size: 1300 or more counted lines or 50 or more files and removed size/XL Size: up to 1299 counted lines and 49 files; exceeds L in either measure risk/medium Behavioral change that does not substantially alter a core public API labels Sep 28, 2026
@github-actions github-actions Bot added risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny and removed risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny breaking-change Includes a breaking change, and requires special scrutiny at the boundaries labels Sep 28, 2026
@github-actions github-actions Bot added the risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny label Sep 29, 2026
Depends on Devolutions#1953 (stacked branch, feat/acceptor-multitransport-finalize).

Add RdpServerBuilder::with_udp_transport(udp_bind_addr), opt-in and
None by default. When set (and the security mode is Tls or Hybrid,
matching the reference client's Enhanced-Security-only gate), the
acceptor offers reliable UDP multitransport, and accept_finalize uses
accept_finalize_with_multitransport with a callback that binds a fresh
UDP socket per connection, reuses the connection's own TLS certificate
(TlsAcceptor::config()) for the sideband transport, and calls
accept_udp(). Any failure at any stage falls back to TCP-only, never
fails the connection.

Once established, the transport is used to migrate EGFX graphics
traffic off TCP: request_reliable_udp is called opportunistically the
first time EGFX has data to send (its dynamic channel id is only known
once the client opens it, well after multitransport bootstrapping),
and outgoing EGFX SvcMessages route onto the tunnel via
encode_unframed_pdu() once the client has acknowledged the Soft-Sync
request. A new client_loop select arm feeds incoming tunnel payloads
into DrdynvcServer::process_tunnel(), whose responses go back over
TCP. A closed transport degrades the arm to pending forever rather
than ending the session, matching the non-fatal posture throughout.

Adds a regression test verifying that configuring UDP transport on the
server does not disturb a client that never advertises support for
it (the common case for any client that predates this feature).
Fixes three high-severity issues: Soft-Sync now requires both a
negotiated SOFT_SYNC_TCP_TO_UDP flag and a successful Initiate
Multitransport Response before migrating any channel, the shared UDP
transport handle exposes a lock-free sender independent of its
receive-side mutex, and the finalize handler no longer blocks the RDP
handshake on the UDP accept, spawning it instead and picking it up
opportunistically from client_loop's own select loop once it resolves.

Fixes a medium-severity bug in ironrdp-dvc's Soft-Sync response handling:
a declined channel stayed routed for outgoing data because the outgoing
tunnel map was never filtered by the response, only the incoming one.

Addresses four low-severity findings: corrects a false single-connection
premise in the UDP accept doc comment, documents the AddrInUse tradeoff
under session preemption, combines a duplicated drdynvc guard into one
failure path, and confirms two findings already resolved by rebasing
onto PR Devolutions#1953's own review-response commit.
Fixes a high-severity bug: after the sideband UDP tunnel closes, the
shared transport handle now gets cleared so dispatch_egfx_messages
actually falls back to TCP instead of silently dropping every
subsequent EGFX batch onto a dead connection.

Documents an accepted timing limitation: a late Initiate
Multitransport Response arriving after finalization completes cannot
retroactively enable Soft-Sync migration, since nothing on the message
channel recognizes it post-handoff. This degrades to TCP-only for the
session rather than causing any correctness issue.

Inherits the S_OK/SOFTSYNC test fix from PR Devolutions#1953 by rebasing onto its
review-response commit, reconciling the resulting connection.rs
conflict between that PR's bool-returning rename and this branch's own
earlier &mut self change for response tracking.

Addresses three low-severity findings: removes an unused accessor,
substitutes an equivalent enum match with the existing tls_acceptor()
helper, and reuses get_svc_processor() instead of inlining its body.
The UDP handshake is started with spawn_local, so with_udp_transport
makes run, run_connection and run_connection_with panic unless they are
driven inside a tokio LocalSet. State that on with_udp_transport and on
each entry point, and correct the code comment that said run already
documented it.
A client that answers the Initiate Multitransport Request with E_ABORT
has given up on the sideband transport (MS-RDPBCGR 2.2.15.2), yet the
server kept its UDP accept running and its socket bound until the 15 s
timeout, then logged a handshake timeout. Seen with a Windows client.
Abort the pending accept once finalization reports the failure; with no
response at all it keeps running, since the client may still connect.
Windows clients answer the Initiate Multitransport Request with E_ABORT
about 2.7 s after connecting, after finalization has completed, so the
earlier check never saw it and the accept still ran to its 15 s timeout.
Now built on Devolutions#1964, which decodes that late response on the message
channel: keep the pending accept's abort handle and stop the accept when
the response reports failure, and report a cancelled accept as a decline
rather than a panic.
…eached

A socket bound to the unspecified address replies from whichever local
address the routing table picks. On a host with several IPv6 addresses
that was not the one mstsc sent to, so mstsc dropped the replies and gave
up on the handshake with E_ABORT. When the configured UDP address is
unspecified, bind to the connection's local address instead: run() records
it, and embedders driving run_connection_with pass it through the new
RdpServer::set_connection_local_addr.
mstsc finishes its UDP bootstrap after the TCP finalization, so its
successful Initiate Multitransport Response arrives on the message
channel once the client loop is running. Migration was decided once at
finalization and never revisited, so the sideband transport came up and
EGFX stayed on TCP for the whole session. Keep the decision on the
connection instead: finalization sets it as before, and a later success
enables it when Soft-Sync was negotiated.
An embedder that takes a frame handle from its GfxServerFactory has the
channel registered as GfxDvcBridge, which the migration lookup did not
recognise, so the Soft-Sync Request was never sent and EGFX stayed on
TCP with the sideband transport up. Look the channel up as either type.
Log the Soft-Sync Request, the client's response with the tunnels and
channels it accepted, and the switch of EGFX onto UDP, which were silent.
The server now routes EGFX over the tunnel from the Soft-Sync Request
on, since the request carries SOFT_SYNC_TCP_FLUSHED and MS-RDPEDYC
3.3.5.3.1 requires the server to keep using the named tunnel
immediately after sending it. The Soft-Sync Response only narrows what
the server reads (2.2.5.2), so it no longer changes the outgoing tunnel.

Tunnel data that reaches the server before the Response is held and
processed once it arrives (3.3.5.3.2) instead of being dropped, and
DRDYNVC replies for a tunneled channel go over the tunnel whichever
path produced them.

The UDP accept is spawned with tokio::spawn, so the server no longer
needs a LocalSet. UdpTransportHandle::send returns nothing, and the
tunnel loop reads the transport handle once.

Adds an end-to-end test that brings up the tunnel with a real client
and checks all three routing rules; ironrdp-testsuite-extra now enables
ironrdp-server's egfx feature for it.
The server's run future grew past clippy::large_futures' 16 KiB limit
on Windows (16,440 bytes) with the DRDYNVC tunnel routing, failing the
workspace lint there. Box it in the example rather than keep a future
that size in main's stack frame.
Soft-Sync only moves channels onto a tunnel (MS-RDPEDYC 2.2.5.1 has no
TCP tunnel type), its request promises no more of their data over TCP,
and the tunnel lasts as long as the connection (MS-RDPEMT 1.3.3).
Sending EGFX over TCP after the tunnel closed therefore reached a
client with nowhere to read it: mstsc froze and reset the connection
about 19 s later. With EGFX on the tunnel, the connection now ends when
the tunnel does. With nothing moved, the session stays on TCP as
before.
@glamberson

Copy link
Copy Markdown
Contributor Author

Thanks for the review. On Soft-Sync being one-shot, that is the design, and the docs now say so. The request declares the TCP path flushed for the channel (SOFT_SYNC_TCP_FLUSHED, MS-RDPEDYC 2.2.5.1) and the server sends its data over the tunnel from then on (3.3.5.3.1), so a decline in the response no longer means EGFX stays on TCP: The response only lists the tunnels the client will write on (2.2.5.2). The spec has no path for taking a Soft-Sync back or offering a second one, so I did not add a re-request. The doc on dispatch_egfx_messages no longer calls the repeat request a no-op. It says the request is made once per connection and that the error from a later attempt is expected and only traced, and request_reliable_udp now says the same.

@glamberson

Copy link
Copy Markdown
Contributor Author

Thanks for the second look at this. Gating the three UDP crates behind an optional feature is feasible: A stub UdpTransportHandle for the feature-off build would keep the signatures that carry it unchanged, so only the module, the builder method and the accept call become conditional. It also brings a default-on or default-off choice for the feature, so I am leaving it out of this PR. If it is wanted it can be done in another PR.

The doc on dispatch_egfx_messages called a repeat request a no-op under
request_reliable_udp's idempotency guard, but the guard returns an error that
the caller only traces. Both docs now say the request is made once per
connection, that the state never returns to idle, and that the request declares
the TCP path flushed for the channel (SOFT_SYNC_TCP_FLUSHED, MS-RDPEDYC 2.2.5.1)
so its data goes over the tunnel from then on (3.3.5.3.1), whatever the client's
response lists.

@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.

LGTM

This branch was successfully deployed

1 active deployment
llm-providers — 332956b8 Deployed Sep 29, 2026 by glamberson via Classify pull request #1185
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed/2 Final automated review completed breaking-change Includes a breaking change, and requires special scrutiny at the boundaries kind/protocol Affects RDP or related protocol behavior needs-review A human reviewer is the current next actor risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny scope/core Touches the core architectural tier scope/cross-cutting Spans multiple architectural boundaries size/XXL Size: 1300 or more counted lines or 50 or more files triage/overlap Possible overlap with another pull request; advisory only

Development

Successfully merging this pull request may close these issues.

3 participants