Repository navigation
fix(server): keep ConnectionHandler reachable during a Preempt race - #2065
Conversation
Under ConnectionPolicy::Preempt, `run` took the connection handler out of `self` for the whole race, so hooks fired from the served connection (`on_connection_info`) found `None` and were silently skipped for every connection. Share the handler as `Rc<RefCell<Box<dyn ConnectionHandler>>>` instead: every hook is synchronous, so no borrow is held across an `.await`, and `RdpServer` is already `!Send`. Adds e2e regression tests (from the issue) asserting `on_accept` and `on_connection_info` reach the handler under Queue, Reject and Preempt. Fixes Devolutions#1969 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The ownership fix is sound and directly covered by regression tests.
Review effort: Balanced
Findings: None
What changed in this PR
Fixes ConnectionHandler availability during Preempt races without changing the public API.
Changes:
- Shares the handler through
Rc<RefCell<_>>during races. - Adds end-to-end coverage for all connection policies.
No material findings. Protocol review was unnecessary because wire behavior is unchanged.
| File | Description |
|---|---|
crates/ironrdp-server/src/server.rs |
Preserves handler access during preemption. |
crates/ironrdp-testsuite-extra/tests/e2e.rs |
Tests lifecycle hooks across policies. |
There was a problem hiding this comment.
The core fix is sound and minimal: sharing the ConnectionHandler via Rc<RefCell<...>> under Preempt resolves the #1969 borrow conflict without changing the public API (RdpServer was already !Send since it holds non-Send dyn SoundServerFactory), and all four call sites use statement-scoped borrow_mut() consistent with the synchronous trait contract. The take/restore plumbing removal is a strict simplification. I verified the !Send claim, the synchronous trait definition, every converted call site, and that the new test's required imports already exist. The four published findings are all valid, non-blocking observations: the new Preempt e2e test covers only the single-client symptom and never exercises the takeover race where the handler is actually shared; the shared-handler safety invariant is documented but unenforced; the test helper duplicates ~50 lines of existing handshake scaffolding; and three Box::pin wrappers are unnecessary by the file's own precedent.
…ure-scoped helper; test a real takeover - All four handler call sites go through `with_connection_handler`, whose synchronous closure scopes the RefCell borrow: holding it across an `.await` no longer compiles, instead of resting on a convention. - New e2e test drives an actual Preempt takeover with two clients and asserts every hook lands on the shared handler: both `on_accept`s, both `on_connection_info`s, and the evicted session's `on_disconnected`. Fails on master. - Factor the client handshake into `connect_client`, shared with `client_server_with_connector`, and the hook-recording server setup into `hook_recording_server`. - Drop the `Box::pin`s clippy's `large_futures` does not require. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Independent review confirms the fix for #1969: under ConnectionPolicy::Preempt the live connection and a candidate's on_accept can now both reach the shared ConnectionHandler. In pr-head, connection_handler is Option<Rc<RefCell<Box<dyn ConnectionHandler>>>>; the take/restore plumbing through the race's return tuple is deleted, and all four former access sites (accept, candidate accept, on_disconnected, on_connection_info) route through the new private with_connection_handler helper, whose RefCell borrow lives only inside a synchronous closure so it cannot span an .await. The !Send justification holds (the server already held non-Send factories) and the public builder API is unchanged; the Negotiated-entry skip preserving on_accept double-count semantics is intact. The e2e tests cover hooks under Queue/Reject/Preempt and a full preemption takeover asserting (2, 2, 1) hook counts plus eviction of the old transport. The single specialist candidate is verified accurate and accepted: a cal…
- [code-compressor] Touched call site still inlines the address lookup the PR just factored into local_addr_of — low 🟡 — crates/ironrdp-testsuite-extra/tests/e2e.rs
The PR introduces local_addr_of (oneshot channel + ServerEvent::GetLocalAddr + await/unwraps) and uses it in the new hook tests, and it edits client_server_with_connector to route the handshake through the new connect_client helper. The lines immediately above that edit still carry the identical three-line inline lookup. Replacing them with let server_addr = local_addr_of(&ev).await; is behavior-identical (same send/recv and unwraps, &ev already captured by the surrounding closure) and leaves the address lookup a single home. Low severity: test-only duplication with no correctness or protocol impact. The inlined lines are pre-existing context lines in the diff, not added lines, hence null line bounds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Benoît Cortier (CBenoit)
left a comment
There was a problem hiding this comment.
Thank you, LGTM
…ting-session-default Resolves conflicts with Devolutions#2065 and the UDP-transport work: - server.rs: keep both `ConnectionPolicy::default_for` and the new `MAX_EARLY_TUNNEL_PAYLOADS`; `dispatch_server_events` takes both `client_supports_errinfo` and `udp_transport` (with the same `too_many_arguments` expectation `client_loop` already carries), and the new unit test call passes the extra flag. - e2e.rs: take master's shared `connect_client` / `local_addr_of` helpers and rebuild the Hybrid takeover test on top of them instead of keeping a second copy of the client handshake. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Fixes #1969.
Problem
Under
ConnectionPolicy::Preempt,RdpServer::runtook the connection handler out ofselfbefore building the live connection (that connection borrows&mut selffor the whole race, while a candidate'son_acceptneeds the handler at the same time) and only put it back after the race. Every hook fired from inside the served connection therefore sawNoneand was skipped with nothing logged — in practiceon_connection_infonever fired, for every connection underPreempt, not just preempted ones.QueueandRejectwere unaffected.Fix
Share the handler rather than taking it:
connection_handleris nowOption<Rc<RefCell<Box<dyn ConnectionHandler>>>>. The race clones theRcfor the candidate'son_accept, while the live connection reaches the same handler throughself. The take/restore plumbing through the race's return tuple is removed.Why this shape is safe:
ConnectionHandlermethod is synchronous, so aborrow_mut()lives only for the call, never across an.await; with the server confined to one thread, the candidate'son_acceptand the live connection's hooks can't hold it at the same time.RdpServeris already!Send(it holds non-Sendfactories such asSoundServerFactory), so anRcfield takes nothing away from embedders. The public API (with_connection_handler(Option<Box<dyn ConnectionHandler>>)) is unchanged.Tests
The three e2e tests from the issue (by clintcan), in
ironrdp-testsuite-extra/tests/e2e.rs: a real client through the full handshake viarun()underQueue,RejectandPreempt, asserting bothon_acceptandon_connection_inforeach the handler. Without the fix thePreemptcase fails withon_accept=true, on_connection_info=false; with it all three pass.This is the prerequisite Benoît Cortier (@CBenoit) asked for on #1934 (mode-aware
Preemptdefault underHybrid), which would otherwise make this bug hit everyHybridembedder out of the box.🤖 Generated with Claude Code