Repository navigation
feat(ffi): redirect smartcards through WinSCard on Windows - #2175
Marc-André Moreau (mamoreau-devolutions) merged 7 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The Avalonia example introduces competing reactivation reads and unsynchronized polling of mutable native session state.
2 open issues
What changed in this PR
Adds Windows smartcard redirection to the .NET FFI and completes unsupported smartcard requests instead of leaving server calls waiting.
Changes:
- Uses the native WinSCard backend on Windows.
- Adds deferred-completion polling and updates the Avalonia example.
- Adds IOCTL-specific error responses and regression tests.
| File | Description |
|---|---|
| ffi/src/session/mod.rs | Exposes and integrates deferred-completion polling. |
| ffi/src/connector/mod.rs | Selects the Windows native backend. |
| ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs | Documents smartcard behavior. |
| ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs | Adds native polling declaration. |
| ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs | Documents backend selection and polling. |
| ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs | Adds managed polling wrapper. |
| ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs | Polls completions during idle reads. |
| ffi/Cargo.toml | Adds Windows backend dependency. |
| crates/ironrdp-testsuite-core/tests/rdpdr/mod.rs | Tests noop responses and buffer limits. |
| crates/ironrdp-rdpdr/src/pdu/esc/mod.rs | Builds call-specific error returns. |
| crates/ironrdp-rdpdr/src/backend/noop.rs | Completes smartcard calls with no-service errors. |
| Cargo.lock | Records the FFI backend dependency. |
Review effort: Balanced
There was a problem hiding this comment.
The PR fixes a real hang: NoopRdpdrBackend previously returned no completion for smartcard IOCTLs, stalling server-side WinSCard. The new ScardCall::error_return plus the noop handle_scard_call completion follow MS-RDPESC 3.1.4/3.2.5.2 (ReturnCode in the IOCTL-specific return, IoStatus SUCCESS, BUFFER_TOO_SMALL retry path) and are covered by new testsuite tests. The Windows WinSCard passthrough, FFI deferred-poll API, and Avalonia lock/poll rework are coherent. Two low-severity wire deviations remain in the new error path: error returns carry nonzero cbContext/cbHandle from ScardContext::from_native(0) where MS-RDPESC 2.2.3 requires all non-ReturnCode fields to be zero, and unrecognized IOCTLs are completed with IoStatus SUCCESS plus a Long_Return body instead of an NTSTATUS error with no output. Maintainability findings are confirmed against pr-head: error_return duplicates ironrdp-rdpdr-native's private unsupported() mapping variant-for-variant, and the noop completion body re-imple…
Push a commit after addressing these findings. If no code change is needed, you may resolve inline threads and comment @github-actions review-ready to request human review.
There was a problem hiding this comment.
The PR correctly fixes the noop-backend smartcard hang by completing every MS-RDPESC IOCTL via ScardCall::error_response, centralizes error/buffer-too-small completion in esc::complete_call, and adds Windows WinSCard passthrough to the .NET FFI plus an additive PollRdpdrDeferred API. Protocol behavior matches MS-RDPESC 3.1.4/2.2.3/3.2.5.2. Six valid candidates remain: an open question on opt-in deferred polling (medium), an unverified STATUS_UNSUCCESSFUL fallback, a dead/discarding factory error arm, an abandoned pending ReadPdu in the example loop, and two low-severity duplication issues. All six are published; the factory-error finding is refined with a corrected line range; the rest are accepted substantially unchanged.
- [skeptical] Hang fix relies on hosts voluntarily adopting the new PollRdpdrDeferred API — medium 🟠 ❓ — ffi/src/session/mod.rs
Deferred RDPDR completions only reach the server via the per-frame flush in ActiveStage::process (lines 130-134) or the new opt-in poll_rdpdr_deferred (lines 137-145). A host that never polls on a timer gets completions only when the server sends traffic, but a server blocked in a WinSCard call awaiting the pending completion may send nothing, reproducing the hang. MAX_DEFERRED back-pressure (SCARD_E_NO_MEMORY) does not unblock it either. Nothing in the FFI enforces the polling contract; whether a Windows server always sends frames during an outstanding IOCTL, and whether all .NET consumers adopted the new API, could not be verified. - [skeptical] Unreachable factory error arm discards the underlying cause — low 🟡 — ffi/src/connector/mod.rs
with_static_channel_rdpdr builds the backend via WindowsRdpdrBackendFactory::from_drives(Vec::new()). Per factory.rs (from_drive_configuration, lines 139-157), every error variant (DuplicateDeviceId/RepeatedInitialSelection/UnconfiguredInitialSelection) requires at least one drive, so this call cannot fail and the '?' arm is dead code today. Its map_err also replaces any future, more specific factory error with the generic 'invalid RDPDR backend configuration', misdiagnosing a real failure if validation is added. Propagate the actual error or use a non-fallible construction for the smartcard-only configuration. - [skeptical] Read loop can exit while readPduTask is still pending and unobserved — low 🟡 — ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs
In ReadPduAndProcessActiveStage, the deferred-poll branch assigns keepLooping from HandleActiveStageOutput(deferred); if it returns false (e.g. Terminate), the while loop exits without awaiting the pending readPduTask started earlier in the iteration. If the abandoned ReadPdu subsequently faults (connection reset, stream error), the exception is unobserved and surfaces as UnobservedTaskException rather than the loop's Trace error path, hiding the disconnect cause. Await or observe the task before returning.
Push a commit after addressing these findings. If no code change is needed, you may resolve inline threads and comment @github-actions review-ready to request human review.
b2990fe to
bb28f93
Compare
bb28f93 to
2007db4
Compare
`NoopRdpdrBackend::handle_scard_call` returned no messages, so every MS-RDPESC IOCTL sent to an announced smartcard (EstablishContext, ListReaders, GetStatusChange, ...) was never completed and server-side WinSCard callers hung. The .NET FFI (`with_static_channel_rdpdr`) and `ironrdp-client` announce a smartcard on top of this backend. The noop backend now answers each call with the IOCTL-specific return structure carrying SCARD_E_NO_SERVICE and IoStatus STATUS_SUCCESS, as MS-RDPESC 3.1.4 requires for smart card errors, or STATUS_BUFFER_TOO_SMALL when the server's output buffer cannot hold it. The new `ScardCall::error_return` helper builds the matching return structure for any call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ClientConnector.WithStaticChannelRdpdr` now backs the announced smartcard with the native WinSCard passthrough from `ironrdp-rdpdr-native` on Windows, the same backend the viewer and ActiveX use. Other platforms keep the noop backend, which answers every call with SCARD_E_NO_SERVICE. The native backend completes blocking calls (GetStatusChange, Connect, Transmit, ...) on worker threads. `ActiveStage.Process` now forwards those completions after each frame, and the new `ActiveStage.PollRdpdrDeferred` lets hosts flush them while the server is idle. The Avalonia example polls every 50 ms from its read loop, like `ironrdp-client` does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Error returns built for failed smartcard calls carried a nonzero REDIR_SCARDCONTEXT/REDIR_SCARDHANDLE, while MS-RDPESC 2.2.3 requires all fields besides ReturnCode to be zero. Add `ScardContext::empty` and `ScardHandle::empty`, and use them in the shared error path and in the native backend's EstablishContext/Connect failures. An IOCTL with no known return structure is now completed with STATUS_UNSUCCESSFUL and no output (MS-RDPESC 3.1.4, FreeRDP) instead of a Long_Return the server cannot decode. ReleaseTartedEvent keeps its Long_Return. `ScardCall::error_response` replaces `error_return` and builds the whole completion, and `esc::complete_call` holds the STATUS_BUFFER_TOO_SMALL retry rule. The native backend now uses both instead of its private copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native backend's Connect failure after a WinSCard error still sent the negotiated protocol, which MS-RDPESC 2.2.3 requires to be zero. Replace the success guards in `complete_establish`/`complete_connect` with `establish_failed`/`connect_failed`, which every failure site now calls instead of building placeholder handles. Also: - ffi: keep the factory error as the cause instead of replacing it. - Avalonia example: report a read that fails after the session ended instead of leaving its exception unobserved. - testsuite: build smartcard requests with the shared device-control framing helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MS-RDPESC 2.2.3 zeroes every field besides ReturnCode on failure, but several error returns still carried non-NULL NDR pointers: - The shared error path built Transmit, Control, GetAttrib and ReadCache returns with an empty buffer, which encodes a referent ID and a conformant count. It now uses the existing NULL-buffer probe constructors. - GetStatusChange_Return/LocateCards_Return and GetReaderIcon_Return always wrote a pointer. Like FreeRDP, they now write a zero count and a NULL pointer when empty, and drop their payload on failure (reader states are kept on SCARD_E_TIMEOUT, as FreeRDP does). - The native backend sent pioRecvPci on a failed Transmit, and could pass WinSCard out-parameters through on failed Reconnect, GetTransmitCount and GetDeviceTypeId. Also: - ffi: `ActiveStage.Process` no longer flushes deferred RDPDR frames after DeactivateAll (stale channel IDs) or Terminate. - .NET: `Framed.Write` used a thread-affine Mutex across an await, which throws when the continuation resumes on another thread; it now uses SemaphoreSlim. - Avalonia example: guard pointer press/release before the session is active. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the `Framed.Write` lock fix, which #2161 makes with a test, and take the Avalonia example's ActiveStage lock as #2162 writes it (`_stageLock`, `WithStageLock`, `SendFastpath`). On top of #2162 this PR only adds the RDPDR poll in the read loop, the read-ordering fix and a guard in `SendFastpath`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fold the private `error_return` into `ScardCall::error_response`, inline the single-use `establish_failed`, and have the FFI's deferred RDPDR poll helper return a `Vec` instead of an `Option` used as a collection. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2007db4 to
8786bd9
Compare
b735d19
into
master
Adds clipboard file transfer, in both directions, to the Windows clipboard backend (`WinClipboard`), and stops it advertising formats it can't deliver. ActiveX and `ironrdp-client` get the feature as is; the .NET FFI gets it through new bindings, opt-in. ## Backend (`feat(cliprdr)`) **Remote to local.** When the remote format list has `FileGroupDescriptorW`, an OLE data object goes on the clipboard (`OleSetClipboard`). It offers `FileGroupDescriptorW` and one `FileContents` `IStream` per file (`lindex`), plus the other remote formats. - Streams read through `FileContents` RANGE requests carrying the `clipDataId` of the lock `Cliprdr` took for that list, so a paste keeps working after the remote clipboard changes, until the lock is released. Files over 4 GiB work. - OLE needs an STA: this is only enabled when `WinClipboard` is created on one. COM is never initialized on a thread that has none. - The objects are not agile and run on the clipboard thread. A stream read blocks it like `WM_RENDERFORMAT` handling already does (30 s timeout, chunks up to 1 MiB with read-ahead). Backend events arriving during a wait are now queued instead of dropped. **Local to remote.** `CF_HDROP` paths (directories walked into relative paths), or another application's virtual files (`FileGroupDescriptorW` + `FileContents`), are offered with `SendInitiateFileCopy` once the channel is ready. The remote's SIZE/RANGE requests are served, from the lock snapshot when a `clipDataId` is given. **Capabilities.** The backend now negotiates `STREAM_FILECLIP_ENABLED`, `FILECLIP_NO_FILE_PATHS`, `CAN_LOCK_CLIPDATA` and `HUGE_FILE_SUPPORT_ENABLED`. **Format filtering.** Handle-based formats (`CF_BITMAP`, `CF_ENHMETAFILE`, `CF_METAFILEPICT`, `CF_PALETTE`), private ranges, local paths and shell items (`CF_HDROP`, `FileNameW`, `Shell IDList Array`) and OLE storage formats are no longer advertised in either direction. Images still go through as `CF_DIB`, which Windows synthesizes. **Request validation.** `ironrdp-client` treats a rejected clipboard request as session-fatal, so the backend checks what it sends against the rules `Cliprdr` enforces: file list indexes, range bounds, huge-file positions, and released locks. **Also fixed.** The window subclass state leaked on drop, and `WM_DESTROY` freed it while `WM_NCDESTROY` could still use it. ## Viewer (`fix(viewer)`) Without a backend factory, `ironrdp-client` created `WinClipboard` on the RDP thread, which runs a tokio runtime and no Windows message loop, so clipboard redirection never worked in the viewer (no clipboard events, no backend wake-ups, no STA for OLE). The viewer now initializes OLE and creates the clipboard, with file transfer, on its winit thread and hands the factory to `RdpClient`. ## .NET FFI (`feat(ffi)`) - `ActiveStage.SubmitClipboardMessage` hands any backend message to the clipboard channel, file transfer included, and returns the frame to send. - `ActiveStage.DriveClipboardTimeouts` expires clipboard locks and file transfers. - `WinCliprdr.NewWithFileTransfer(bool)` opts in. `WinCliprdr.New()` keeps file transfer off, so existing consumers that only route the three original message types don't receive new ones. - `WinCliprdr.NextClipboardMessageTimeout(ms)` lets a loop wake up to drive the timeouts. - The Avalonia example opts in, submits every message through `SubmitClipboardMessage`, drives the timeouts, and logs a failed clipboard operation instead of stopping clipboard handling. Only the four affected generated files are included. ## Testing - `cargo xtask check fmt`, `check lints` and `check locks` pass. - 12 unit tests in `cliprdr_native::windows`: format filtering, file list building, file contents serving, range sizing, descriptor round-trips. - 6 tests in `cliprdr_native::windows_live` run a `CliprdrClient` over `WinClipboard` against an in-memory `CliprdrServer` on the real Windows clipboard. They are opt-in with `IRONRDP_WIN_CLIPBOARD_LIVE=1`, because they replace the clipboard and briefly open an Explorer window. All pass locally: - Explorer pastes remote folders and files, contents checked byte for byte. - A 5 GiB remote file is read through OLE past 4 GiB. - Remote handle and path formats are not offered locally. - Local folders reach the remote, both at connect time and when copied mid-session. - Local image formats are filtered. - Virtual files from another session reach the remote. - .NET: the solution builds and the smoke tests pass (9/9, one new). - End to end with `ironrdp-viewer` against a Windows 11 server (rdpclip): capabilities negotiated as `STREAM_FILECLIP_ENABLED | FILECLIP_NO_FILE_PATHS | CAN_LOCK_CLIPDATA | HUGE_FILE_SUPPORT_ENABLED`. A local folder (nested directories, an empty directory, an empty file, a 5 MiB random file) copied in Explorer was pasted into Explorer in the session, served under rdpclip's lock. Copying it back in the session and pasting it into a local Explorer window gave byte-identical files (SHA-256). Not verified by hand: ActiveX and the Avalonia example against a real server. ## Notes for review - This conflicts with #2175 in `MainWindow.axaml.cs` and the generated `ActiveStage` files. The clipboard loop body here is a single `SubmitClipboardMessage` call, so the merge only needs #2175's `WithActiveStage(...)` wrapper around it; the generated files can be regenerated. - Running the extra test suite locally may need `CMAKE` pointed at Visual Studio 2026's bundled CMake: `libopus_sys` fails with older CMake versions that lack the "Visual Studio 18 2026" generator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Problem
ClientConnector.WithStaticChannelRdpdrin the .NET FFI announces a smartcard device backed byNoopRdpdrBackend. That backend'shandle_scard_callreturned no messages, so the client never completed any MS-RDPESC IOCTL the server sent (EstablishContext, ListReaders, GetStatusChange, ...). Server-side WinSCard calls in the session hung, which can stall logon and UAC prompts that check for readers.ironrdp-clientattaches the same backend when RDPDR is enabled with no native backend.Fix
The noop backend answers every smartcard call. It replies with the return structure the server expects for that IOCTL, with
ReturnCode = SCARD_E_NO_SERVICEandIoStatus = STATUS_SUCCESS, as MS-RDPESC 3.1.4 requires for smart card errors. Shared helpers inironrdp-rdpdrbuild these replies, and the Windows backend now uses them too:ScardCall::error_responsebuilds the completion for a failed call. An IOCTL with no known return structure getsSTATUS_UNSUCCESSFULand no output, as in FreeRDP; this path is unreachable today.esc::complete_callrepliesSTATUS_BUFFER_TOO_SMALLwhen the structure does not fit the server's output buffer, so the server retries with a larger one (3.2.5.2).ReturnCode(2.2.3): empty context and handle (ScardContext::empty,ScardHandle::empty), NULL buffers, and no reader states (kept onSCARD_E_TIMEOUT, as FreeRDP does). This also fixes failure replies in the Windows backend (Connect, Transmit, Reconnect, GetTransmitCount, GetDeviceTypeId).Real WinSCard redirection in the FFI on Windows.
WithStaticChannelRdpdrnow backs the smartcard with the WinSCard passthrough fromironrdp-rdpdr-native, the same backend the viewer and ActiveX use. Other platforms keep the noop backend.The native backend runs blocking calls (GetStatusChange, Connect, Transmit, ...) on worker threads, and their completions only reach the server through
Rdpdr::poll_deferred_messages. So:ActiveStage.Processsends deferred RDPDR completions after each frame, asironrdp-clientdoes, except afterDeactivateAllorTerminate.ActiveStage.PollRdpdrDeferred()returns them while the server is idle. Hosts should call it about every 50 ms, serialized with their otherActiveStagecalls.The existing .NET API is unchanged;
PollRdpdrDeferredis an addition. The regenerated bindings only add the new method and doc comments.Related PRs
This PR builds on #2161 and #2162, both now merged into master:
Framed.Writesafe for concurrent writers, which the example's 50 ms poll relies on.ActiveStagecall in the Avalonia example. This PR only adds the RDPDR poll, the read-ordering fix and a null guard inSendFastpathon top of it.Testing
Rdpdrwith the noop backend, cover theSTATUS_BUFFER_TOO_SMALLand no-structure paths, and check that error replies for eight call types are all zero afterReturnCode.cargo testpasses forironrdp-rdpdr,ironrdp-rdpdr-nativeand the testsuite's rdpdr tests.cargo xtask check fmt,cargo xtask check locks,cargo xtask ffi buildandcargo xtask ffi bindings(which builds the .NET library) pass, and the Avalonia example builds.-D warningsis clean onironrdp-rdpdr,ironrdp-rdpdr-native,ironrdp-testsuite-coreandffi. The fullcargo xtask check lintscould not run locally because thelibopus_sysCMake build fails on that machine.Not tested: a live session with a reader and card, and building the FFI for non-Windows targets (left to CI).
🤖 Generated with Claude Code