Skip to content

fix(session)!: serve channels and graphics that Windows moves onto a tunnel - #2008

Merged
Benoît Cortier (CBenoit) merged 4 commits into
Devolutions:masterfrom
AKolenda:fix/tunnel-channel-lifecycle-and-graphics
Sep 29, 2026
Merged

Benoît Cortier (CBenoit) merged 4 commits into
Devolutions:masterfrom
AKolenda:fix/tunnel-channel-lifecycle-and-graphics

Conversation

@AKolenda

@AKolenda AKolenda commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Once Soft-Sync has moved dynamic channels onto the reliable UDP tunnel, Windows keeps using the tunnel for more than channel data. Two gaps kept the graphics pipeline from working there.

dvc: channel lifecycle on the tunnel. After Soft-Sync, Windows opens new channels with Create Request PDUs sent on the tunnel (Video::Control, Video::Data, Geometry and AUDIO_PLAYBACK_DVC on the host I tested), and closes them there too. process_tunnel accepted only data PDUs, so the first Create Request failed the session. Create and Close are now handled on a tunnel once the Soft-Sync response has switched to it:

  • A channel created on a tunnel is bound to it.
  • The client sends every response back on the tunnel the request came from. That includes the NO_LISTENER response for a declined channel, which the Soft-Sync routing table cannot place because the channel is never bound.
  • The Create and Close handling is shared with the TCP path. A Close from the server goes through close_channel, so it also removes the channel's tunnel binding.
  • A Create Request on a tunnel before the capabilities exchange is refused. On TCP the client answers such a request with a Capabilities Response first, but Capabilities PDUs are not exchanged on a tunnel.
  • A tunnel stays in use once the Soft-Sync response has switched to it, even after every channel bound to it has closed, because the server can still open channels there. DrdynvcClient::switched_to_tunnel reports this, and ActiveStage::reliable_udp_dvc_tunnel_in_use now uses it instead of the current channel bindings. Otherwise, closing the last bound channel would make the client stop reading the tunnel, so a later Create Request on it would never be processed. If the UDP transport closes after the switch, the session fails as it already did while channels were bound.

session: graphics received on the tunnel. process_dvc_tunnel never drained the EGFX compositor, so frames decoded from tunnel data never reached the framebuffer and the window stayed black. It now takes the image, follows a pending ResetGraphics, composites completed frames and records their damage the same way process does for TCP-carried DVC data, and returns the graphics updates next to the message batch. The drain and the full-refresh widening after an output reset move into two private helpers shared by both paths.

Breaking change

ActiveStage::process_dvc_tunnel(&mut self, image, tunnel_type, payload) now takes the decoded image and returns (DvcMessageBatch, Vec<ActiveStageOutput>). ironrdp-client is the only caller in the workspace and is updated.

Testing

  • New dvc::client::channels_created_on_a_tunnel_are_bound_to_it in ironrdp-testsuite-core, covering refusal before Soft-Sync, an accepted and a declined Create on the tunnel, data routing afterwards, and Close on the tunnel.
  • New dvc::client::tunnel_stays_in_use_after_its_channels_close.
  • New dvc::client::tunnel_refuses_a_create_request_before_the_capabilities_exchange.
  • The active_stage_exposes_and_validates_soft_sync_routing unit test is updated for the new signature, and checks that the tunnel stays in use after the server closes every channel on it.
  • Live, on the first revision of this PR, against a Windows 11 host over RDP-UDP version 2, with the viewer built from a branch that also carries the other PRs of this series: the Create Requests above arrive on the tunnel and are answered on it, and the desktop renders through the graphics pipeline while the dynamic channels run over the reliable UDP tunnel.

Checks

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --features helper,__bench --locked -- -D warnings
  • cargo test --locked -p ironrdp-testsuite-core -p ironrdp-testsuite-extra, plus the lib tests of the crates touched here
  • cargo test --workspace --locked on a branch that merges this PR with the other Windows interop PRs from this series
  • typos on the changed files

Series

These PRs port the Windows interop fixes and Linux backends from a downstream IronRDP fork, so the fork can be retired. Each one is based on master and can be reviewed and merged on its own. I also checked that all of them merge cleanly together in this order.

Copilot AI balanced review requested due to automatic review settings September 26, 2026 06:24

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AKolenda
AKolenda deployed to llm-providers September 26, 2026 06:26 — with GitHub Actions Active
@github-actions github-actions Bot added breaking-change Includes a breaking change, and requires special scrutiny at the boundaries kind/protocol Affects RDP or related protocol behavior 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/M Size: up to 449 counted lines and 10 files; exceeds S in either measure triage/overlap Possible overlap with another pull request; advisory only labels Sep 26, 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.

The change is sound. Shared process_create/process_close keep the TCP path's behavior intact while letting tunnels carry Create/Close PDUs after Soft-Sync; created channels bind to their arrival tunnel, declined creates are still answered on it, and the reply_tunnel fallback covers the cases the Soft-Sync routing table cannot. The active_stage refactor preserves the drain/reset/full-refresh ordering, and the declared breaking signature change is updated in its only workspace caller; the new test covers refusal before Soft-Sync, accepted and declined tunnel creates, data routing, and close. One low-severity robustness gap survives review: once every tunnel-bound channel closes, the unbinding flips reliable_udp_dvc_tunnel_in_use() false while the transport stays open, so later tunnel payloads (including the Create Request that could re-bind a channel) are buffered forever instead of failing as the pre-change error path did.

Reduced coverage: optional reviewer code-compressor was unavailable.

Comment on lines +393 to +398
DrdynvcServerPdu::Close(close) => {
debug!(?tunnel_type, "Got DVC Close PDU on a multitransport tunnel: {close:?}");
let channel_id = close.channel_id();
let messages = self.process_close(channel_id);
Ok(DvcMessageBatch::new(channel_id, messages))
}

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.

[skeptical] Closing the last tunnel-bound channel stalls tunnel traffic permanently with no recovery path — low 🟡 — The shared process_close removes the tunnel binding, so after the server closes every channel bound to the reliable UDP tunnel, has_channels_on_tunnel (and reliable_udp_dvc_tunnel_in_use) returns false while the UDP transport stays open. In rdp.rs the udp_payload arm then stores tunnel payloads in pending_udp_payload, which the loop top drains only while the tunnel is in use; the later Create Request that could re-bind a channel is itself such a payload, and a duplicate Soft-Sync request is rejected, so the state can never recover and tunnel traffic is silently buffered instead of failing loudly like the pre-change error path. The same unbinding now also happens for server Closes arriving over TCP. The code chain is verified; whether a Windows host actually closes all tunnel channels and reuses the tunnel within one session is unverified, so severity stays low.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 963e3cf. The DRDYNVC client now records the tunnels its Soft-Sync response switched to (DrdynvcClient::switched_to_tunnel), and ActiveStage::reliable_udp_dvc_tunnel_in_use uses that instead of the current channel bindings. The tunnel therefore stays in use after its last channel closes, and a later Create Request on it is processed. process_tunnel now also rejects traffic on a tunnel the response did not switch to. Covered by the new dvc::client::tunnel_stays_in_use_after_its_channels_close and by a check added to active_stage_exposes_and_validates_soft_sync_routing, which fails without the change.

@github-actions github-actions Bot added the ai-reviewed/1 One automated review completed label Sep 26, 2026
@AKolenda AKolenda changed the title fix(dvc,session)!: serve channels and graphics that Windows moves onto a tunnel fix(session)!: serve channels and graphics that Windows moves onto a tunnel Sep 26, 2026
@AKolenda

Copy link
Copy Markdown
Contributor Author

On the overlap notice: #2007 and #2008 are complementary and merge cleanly in either order. #2007 decides which channels the Soft-Sync response binds when the request also lists channels the client declined. #2008 handles the Create, Close and graphics traffic that arrives on the tunnel after the switch. Against Windows 11 the graphics pipeline needs both to work over the tunnel.

@AKolenda
AKolenda deployed to llm-providers September 26, 2026 18:51 — with GitHub Actions Active

@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 PR lets DRDYNVC handle Create and Close PDUs on Soft-Sync tunnels after the switch, tracks switched tunnels separately from per-channel bindings so the tunnel stays in use after its channels close, routes responses back on the arriving transport (including declined Creates), and drains the EGFX compositor for tunnel-carried data via helpers shared with the TCP path. The core fixes check out against the head and MS-RDPEDYC, with tests covering the new paths. Three low-severity findings published: post-switch UDP transport loss now always fails the session (documented trade-off, TCP fallback unreachable), the tunnel Create path skipping the caps-response recovery the TCP path has, and process_close duplicating close_channel. The unused-API nit was rejected as defensible, test-supported public API.

  1. [code-compressor] process_close re-implements close_channel instead of delegating to it — low 🟡 — crates/ironrdp-dvc/src/client.rs
    process_close repeats close_channel (client.rs:311-315) line for line: remove the dynamic channel, drop the tunnel binding, and answer with a Close PDU only when the channel existed. Implementing it as a match over self.close_channel(channel_id) (Some => one-message Vec, None => empty) gives one implementation for the TCP and tunnel close paths, preventing drift on protocol-visible semantics; the trade-offs are log-only (client-initiated closes would also log) plus an unobservable flip in the order of the two map removals.

self.x224_processor
.get_svc_processor::<DrdynvcClient>()
.is_some_and(|drdynvc| drdynvc.has_channels_on_tunnel(SoftSyncTunnelType::RELIABLE_UDP))
.is_some_and(|drdynvc| drdynvc.switched_to_tunnel(SoftSyncTunnelType::RELIABLE_UDP))

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.

[skeptical] Post-switch UDP transport loss always fails the session; TCP fallback unreachable — low 🟡 — reliable_udp_dvc_tunnel_in_use now reports true from the Soft-Sync switch onward regardless of bound channels, so the unchanged client failure branches (rdp.rs:3158 and the udp_payload=None arm at 3266-3270) return TransportFailure for the rest of the session when the UDP transport closes after a switch; the warn-and-continue-on-TCP path is now reachable only before the switch. The PR documents this, and failing is defensible: after a fallback the server could still send Create or graphics traffic on the tunnel that the client would never read, and TransportFailure triggers auto-reconnect. Still an availability trade-off on flaky links (full reconnect instead of degraded TCP); worth confirming intent or documenting a narrower policy in a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intended. After the Soft-Sync exchange, MS-RDPEDYC 3.3.5.3.1 requires the server to keep sending each switched channel's data on the tunnel, and nothing moves a channel back to DRDYNVC. If the client fell back to TCP after losing the tunnel, those channels, the graphics pipeline among them, would stay silent for the rest of the session. So the session fails with TransportFailure, which starts the client's auto-reconnect when the policy allows it. Before the switch, no channel depends on the tunnel yet, and the client keeps running on TCP. I've kept that policy in this PR.

Comment on lines +391 to +402
DrdynvcServerPdu::Create(create_request) => {
debug!(
?tunnel_type,
"Got DVC Create Request PDU on a multitransport tunnel: {create_request:?}"
);
let channel_id = create_request.channel_id();
let (created, messages) = self.process_create(create_request)?;
if created {
self.tunnel_channels.insert(channel_id, tunnel_type);
}
Ok(DvcMessageBatch::new(channel_id, messages))
}

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.

[skeptical] Tunnel Create path skips the capabilities-response recovery the TCP path performs — low 🟡 — The TCP Create arm synthesizes a Capabilities Response when a Create Request arrives before the caps handshake (client.rs:548-554), defending against out-of-order server PDUs. process_tunnel's Create arm calls process_create with no such check, so a server that sends the Soft-Sync Request before capabilities and then a Create Request on the tunnel gets only a Create Response; cap_handshake_done stays false and a later TCP Create would emit a late caps response. Handling the precondition in the tunnel path (or asserting it) keeps the two Create paths consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0c520e8. A Create Request that arrives on a tunnel before the capabilities exchange is now refused. The TCP path recovers by sending a Capabilities Response on DRDYNVC first. Capabilities PDUs are not exchanged on a tunnel, so the tunnel path cannot do the same. Soft-Sync does not depend on the DVC version, so the Soft-Sync request can't rule this case out either. Covered by the new dvc::client::tunnel_refuses_a_create_request_before_the_capabilities_exchange; the two existing tunnel tests now run the capabilities exchange first.

@github-actions github-actions Bot added ai-reviewed/2 Final automated review completed and removed ai-reviewed/1 One automated review completed labels Sep 26, 2026
Marc-André Moreau (mamoreau-devolutions) pushed a commit that referenced this pull request Sep 28, 2026
…#2007)

Windows lists every dynamic channel it intends to move in its Soft-Sync
request, including the ones the client declined with NO_LISTENER.
Against a Windows 11 host the request lists channels 2, 6, 7, 8, 9, 10,
11 and 12 (CoreInput, MouseCursor, Graphics, Video, Geometry, ...), and
only channel 7, the graphics pipeline, is open.

`process_soft_sync_request` dropped a whole channel list as soon as one
ID in it was not open. The tunnel was then never switched, and the
channels the client had opened stayed on TCP while the server was
already sending them on the tunnel (MS-RDPEDYC 3.2.5.3.1).

Unopened channels are now skipped one by one, and the tunnel is switched
for the rest.

## Testing

- New `dvc::client::soft_sync_skips_channels_the_client_did_not_open` in
`ironrdp-testsuite-core`.
- Live, against a Windows 11 host over RDP-UDP version 2, with the
viewer built from a branch that also carries the tunnel and client PRs
of this series: the Soft-Sync request above now switches the tunnel, and
the graphics pipeline moves onto it.

## Checks

- `cargo fmt --all -- --check`
- `cargo clippy --workspace --all-targets --features helper,__bench
--locked -- -D warnings`
- `cargo test --locked -p ironrdp-testsuite-core -p
ironrdp-testsuite-extra`, plus the lib tests of the crates touched here
- `cargo test --workspace --locked` on a branch that merges this PR with
the other Windows interop PRs from this series
- `typos` on the changed files

## Series

These PRs port the Windows interop fixes and Linux backends from a
downstream IronRDP fork, so the fork can be retired. Each one is based
on `master` and can be reviewed and merged on its own. I also checked
that all of them merge cleanly together in this order.

- #2007 fix(dvc): Soft-Sync tunnel with declined channels
- #2008 fix(session)!: channels and graphics on the tunnel
- #2009 fix(rdpeudp): auto-detect on the tunnel
- #2010 fix(graphics)!: SRL streams from Windows
- #2011 fix(egfx): bitmap cache across ResetGraphics
- #2012 feat(session): bandwidth measurements during the session
- #2013 feat(client): graphics pipeline and RDP-UDP version options
- #2014 fix(client): resize reconnects on the graphics pipeline
- #2015 feat(client): transport event
- #2016 feat(cliprdr): Linux clipboard backend
- #2017 feat(rdpdr): printer on Linux and macOS

Co-authored-by: AKolenda <testedemail2222@gmail.com>
AKolenda added 4 commits September 27, 2026 23:12
…o a tunnel

Once Soft-Sync has moved dynamic channels onto the reliable UDP tunnel,
Windows keeps using the tunnel for more than channel data, and two gaps
kept the graphics pipeline from working there:

- dvc: after Soft-Sync, Windows opens new channels (AUDIO_PLAYBACK_DVC,
  RDS::Input) with Create Request PDUs sent on the tunnel, and closes them
  there too. `process_tunnel` accepted only data PDUs, so the first Create
  Request failed the session. Create and Close are now handled on a tunnel
  once Soft-Sync has completed. A channel created on a tunnel is bound to
  it, and the client sends every response back on the tunnel the request
  came from, including the NO_LISTENER response for a declined channel,
  which the Soft-Sync routing table cannot place.

- session: `process_dvc_tunnel` never drained the EGFX compositor, so frames
  decoded from tunnel data never reached the framebuffer and the window
  stayed black. It now takes the image, follows a pending ResetGraphics,
  composites completed frames and records their damage the same way
  `process` does for TCP-carried DVC data, and returns the graphics updates
  next to the message batch.

BREAKING CHANGE: `ActiveStage::process_dvc_tunnel` takes the decoded image
and returns the graphics updates along with the DVC message batch.
The reliable UDP tunnel counted as in use only while a channel was bound
to it. Once the server closed the last bound channel, the client stopped
processing tunnel payloads and buffered them, including a later Create
Request that would bind a new channel, so the tunnel stalled for good.

Record the tunnels the Soft-Sync response switched to and treat those as
in use for the rest of the session.
The Close handler repeated `close_channel`. It now calls it, so the TCP
and tunnel close paths share one implementation. `close_channel` drops a
channel's tunnel binding before checking whether the channel is open,
as the handler did.
…ange

A Create Request that arrives on TCP before the capabilities exchange is
answered with a Capabilities Response first. Capabilities PDUs are not
exchanged on a tunnel, so a Create Request on a tunnel before the
exchange was processed without one and left the handshake open. The
tunnel now refuses it.
@AKolenda
AKolenda force-pushed the fix/tunnel-channel-lifecycle-and-graphics branch from 963e3cf to 0c520e8 Compare September 28, 2026 05:17
@AKolenda
AKolenda deployed to llm-providers September 28, 2026 05:17 — with GitHub Actions Active
@github-actions github-actions Bot added needs-review A human reviewer is the current next actor size/L Size: up to 899 counted lines and 20 files; exceeds M in either measure and removed size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure triage/overlap Possible overlap with another pull request; advisory only labels Sep 28, 2026
@AKolenda

Copy link
Copy Markdown
Contributor Author

On the review's first finding, that process_close re-implements close_channel: fixed in 4fa8346. process_close now calls close_channel, so the TCP and tunnel close paths share one implementation. close_channel now drops the tunnel binding before it checks whether the channel is open, as process_close did.

@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! LGTM

@CBenoit
Benoît Cortier (CBenoit) merged commit bfd16a2 into Devolutions:master Sep 29, 2026
67 checks passed

This branch was successfully deployed

1 active deployment
llm-providers — 0c520e85 Deployed Sep 28, 2026 by AKolenda via Classify pull request #835
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/L Size: up to 899 counted lines and 20 files; exceeds M in either measure

Development

Successfully merging this pull request may close these issues.

3 participants