Skip to content

fix(clipboard): stop the Universal Clipboard double-sync with Mac RDP clients - #198

Open
antonmos wants to merge 8 commits into
clintcan:mainfrom
antonmos:claude/rdp-clipboard-transfer-slow-e2954b
Open

antonmos wants to merge 8 commits into
clintcan:mainfrom
antonmos:claude/rdp-clipboard-transfer-slow-e2954b

Conversation

@antonmos

@antonmos antonmos commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Why

With a Mac RDP client (Windows App for macOS) on the same iCloud account as the server, every clipboard change is synced twice: by CLIPRDR and by macOS Universal Clipboard (Handoff). The two raced, and the user kept seeing macOS's own Pasting from "<device>"… progress dialog, a slow Bluetooth/AWDL fetch, including at connect, on the client, naming the server. A debug trace during this work also showed a client→server copy stall, which the last commit fixes.

What changed (src/clipboard.rs, plus the two file-publish paths)

  1. Universal Clipboard promises are neither advertised nor read. macOS tags an item copied on another Apple device with the undocumented type com.apple.is-remote-clipboard (visible in types() without starting the fetch). advertise_pasteboard skips such items, and on_format_data_request answers with an error instead of resolving one. If Apple drops the marker, behaviour reverts to today's.
  2. macrdp's own pasteboard writes stay on this Mac. pb::write_items and the eager/lazy file publishes now use prepareForNewContentsWithOptions(currentHostOnly) (begin_local_write) instead of clearContents. Before, a client copy written to the server was Handoff-beaconed straight back to the client, replacing the client's real item with a promise pointing at the server, which the client then fetched over Bluetooth on the next connect.
  3. Connect-time sync picks one direction.
    • skip_connect_time_fetch ignores the client's connect-time Format List when the Mac holds local content (not a promise) changed since the last session ended. The Mac's clipboard is advertised instead. session_end_cc is stamped in the backend's Drop, only by a backend that handled a client's list: backends are built before authentication, so a failed attempt or a losing preemption candidate must not move it.
    • Otherwise the client's clipboard wins: the deferred connect-time advertise stands down while the client's clipboard is being fetched. Doing both raced and could swap the two clipboards.
    • The connect-time advertise is now a full advertise-until-accepted wave (advertise_wave, shared with the pasteboard poller, keeping fix(clipboard): keep polling the pasteboard while a format list awaits its answer #217's early wake-up on an acceptance or a pasteboard change), and every attempt skips a pasteboard holding our own write of the client's data.
  4. A lost fetch reply no longer holds up copies for long. The wait for a superseded request's reply is measured from when that request went out, and STALE_REPLY_GRACE is now 60 s. On main, after a reply that never comes, the next copy (and any copy within 5 s of it) isn't fetched. Here, the first copy made more than 60 s after the lost request is fetched at once, with a warning. The grace is deliberately long: a reply arriving after it is taken as the next copy's answer, so it has to outlast a merely slow reply such as a large image over a VPN.
  5. advertise_pasteboard returns an Advertised enum (Sent / NothingToSend / SenderGone) so the retry loop stops when there's nothing to send.

Trade-offs (documented in docs/known-quirks.md)

  • Content copied on a third device (an iPhone) is not relayed to the RDP client until the next copy.
  • Text copied inside the RDP client no longer reaches the user's other Apple devices through Handoff.
  • If both sides copied while disconnected and Handoff is off, the Mac's copy wins at connect.

For reviewers

  • Overlap with feat(clipboard): rich text in both directions (HTML Format + Rich Text Format) #189: the echo half of the original race was already fixed on main by its remote_write_cc. This PR builds on that marker rather than duplicating it.
  • Why the first-list assumption holds: the "connect-time list" is exactly the list that initializes the channel. ironrdp-cliprdr calls on_ready from that list's handler, right before on_remote_copy for the same list.
  • Still open: the root cause of the client going silent after the unanswered connect-time request is on the client side. The connect race this PR removes is the likely trigger, but that's unconfirmed.

Testing

  • cargo clippy --all-targets -- -D warnings clean; cargo test 303 passed on this branch, merged with current main.
  • New tests cover:
    • the Universal Clipboard marker paths;
    • the connect-time skip rules, including that it respects the rich-clipboard setting;
    • own-write suppression in an advertise wave;
    • the lost-reply timeout, and a slow reply not being taken as the next copy's answer (the review scenario);
    • only a backend that served a client stamping the session end;
    • no Mac advertise while the client's clipboard is being fetched.
      The lost-reply, slow-reply, session-end stamp, and no-advertise tests were each checked by reverting the fix: the test fails without it.
  • Live, on a MacBook Air server with a Windows App for macOS client:
    • The connect-time "Pasting from …" dialog stopped appearing after the local-only writes (fix 2) shipped. The skip and server-side refusal logs never fired, so this is attributed to fix 2 by elimination.
    • A debug trace confirmed client→server copies are fetched and pasted correctly.
    • Not yet seen live: the connect-time skip, the lost-reply timeout at 60 s, and the session-end stamp guard.

🤖 Generated with Claude Code

antonmos and others added 4 commits October 3, 2026 15:35
A Mac RDP client on the same iCloud account syncs every clipboard change
twice (CLIPRDR + Universal Clipboard). When the Mac pasteboard holds an
item tagged com.apple.is-remote-clipboard (a promise from another Apple
device), macrdp now neither advertises it to the client nor resolves it
for a format-data request, so it never triggers a Bluetooth/AWDL fetch
and the "Pasting from <device>..." dialog on the server.

The echo half of the race (re-advertising the client's own copy back to
it) was already fixed on main by remote_write_cc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ime fetch

The "Pasting from <server>..." dialog appeared on the CLIENT Mac at connect:
macrdp's connect-time fetch of the client's clipboard made the client
resolve a Universal Clipboard promise pointing back at the server.

- Write every pasteboard update with currentHostOnly, so content that came
  from the client (and temp-file URLs) is never Handoff-beaconed back to it.
- Ignore the client's connect-time Format List when the Mac holds local
  content copied since the last session ended; on_ready advertises the
  Mac's clipboard to the client instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… gate

Review follow-ups for the Universal Clipboard work:

- When the client's connect-time list is skipped, the Mac's clipboard is
  the only sync for that connect. on_ready now sends it as a full
  advertise-until-accepted wave (shared with the pasteboard poller as
  advertise_wave) instead of one send that mstsc could reject.
- Every wave attempt skips a pasteboard holding our own write of the
  client's data, and stops early when there is nothing to advertise:
  advertise_pasteboard returns an Advertised enum instead of a bool.
- The skip gate counts only what would be advertised (HTML/RTF only with
  rich clipboard on) and reads the change count and types under one guard.
- Tests for the Universal Clipboard marker paths, the rich-aware snapshot,
  and own-write suppression in a wave.
- Docs: iPhone-copied content is not relayed to the client at all (the
  earlier "after one Mac paste" claim was wrong), and local-only writes
  also stop RDP-client copies reaching the user's other Apple devices.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…the client at connect

Seen live with Windows App for macOS: the client never answered the
connect-time FormatDataRequest, and the Mac kept pasting old content.

- The wait for a superseded request's reply is now measured from when that
  request went out, not from the new copy. The request stayed "current"
  forever, so every later copy waited on a reply that would never come;
  one more than STALE_REPLY_GRACE overdue is now presumed lost (warned).
- At connect, the Mac's clipboard is no longer advertised while the
  client's connect-time clipboard is being fetched. Doing both raced and
  could swap the two clipboards; the client's clipboard wins at connect,
  and the Mac's is advertised only when the client's is skipped or empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@clintcan clintcan left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks @antonmos, this is a careful piece of work and the diagnosis of the double sync is convincing. Merged with current main it's clean: fmt (stable and nightly) and clippy --all-targets -D warnings pass, and all tests pass. I checked the parts I could:

  • The first Format List is the connect-time one. At both IronRDP pins (a5d1c682 and the e258f6a0 bump), the server's handle_format_list calls on_ready on the first client list and then on_remote_copy for the same list, so initial_remote_copy_pending gates exactly that list.
  • Local-only writes. prepareForNewContentsWithOptions(currentHostOnly) clears the pasteboard like clearContents, so swapping it in at the four write sites keeps the change-count markers right.
  • The tests can't touch the real clipboard, because under test each thread gets its own named pasteboard.

One change needed: the lost-reply timeout (fix 4) now misattributes a slow reply. On main, a new copy always waits for the superseded request's reply, however late, and discards it. This PR measures the wait from when that request went out, so a reply that takes more than STALE_REPLY_GRACE (5 s) is presumed lost and the new copy's request goes out at once. When the slow reply then arrives, it's taken as the answer to the new request. A slow reply isn't unusual: a large image copied from Windows can take well over 5 s over a VPN or ZeroTier link (a 1080p CF_DIB is about 8 MB, and MAX_INCOMING_PAYLOAD is 50 MB).

I reproduced it with a scratch test. Copy an image in Windows, wait 6 s with its DIB request still unanswered, then copy text. On this branch the text request goes out immediately, and the image's reply is decoded and pasted on the Mac as the user's text. On main the same test waits, discards the image reply, and pastes nothing wrong. The test body, for reference:

let (mut backend, mut rx) = test_backend();
backend.on_remote_copy(&[ClipboardFormat::new(ClipboardFormatId::CF_DIB)]);
let _ = rx.try_recv();
backend.fetch.current_since =
    Some(std::time::Instant::now() - std::time::Duration::from_secs(6));
backend.on_remote_copy(&[ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)]);
assert!(rx.try_recv().is_err()); // fails on this branch: the text request already went out
let dib = vec![0x41, 0x00, 0x42, 0x00, 0x43, 0x00, 0x00, 0x00];
backend.on_format_data_response(FormatDataResponse::new_data(dib));
assert_ne!(pb::read_string().as_deref(), Some("ABC")); // fails on this branch

The loss this was fixing is real but narrower than the description says. On main, after a reply that never comes, the next copy (and any copy within 5 s of it) is never fetched. The first copy after that is fetched normally, as a scratch test on main confirms. So the trade is one lost copy after a client bug against corrupted pastes on slow links, and I'd rather keep the latter impossible.

Measuring from the request is fine. What needs to change is the grace: a client that's merely slow mustn't fall inside it. One option is a much longer grace for presuming a reply lost, for example 60 s. Then a reply slower than that is the only case left that can be misattributed, while a lost reply costs at most the copies made within a minute of the request it lost. Whatever you choose, please add the scenario above as a test.

Smaller points, no change needed:

  • The Universal Clipboard marker paths and the connect-time skip haven't fired live yet, as you say. They're well bounded: if the marker never appears, behaviour is today's.
  • skip_connect_time_fetch is stamped in the old backend's Drop. If a new connection's first Format List could arrive before the old backend is dropped (a preempting reconnect), it would compare against an older stamp. That would at worst skip one connect-time fetch. I haven't confirmed the ordering either way, so treat this as a question.

antonmos and others added 4 commits October 29, 2026 20:26
Conflict in the pasteboard poller: clintcan#217 replaced the retry loop's fixed
sleeps with wait_for_answer, while this branch had moved that loop into
advertise_wave (shared with the connect-time advertise). Keep the move and
port clintcan#217 into it: each retry wait now ends early on the client's
acceptance or on a pasteboard change, so a second copy is announced at the
next poll instead of after the wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review on clintcan#198: measuring the superseded-reply wait from when the request
went out is right, but a 5 s grace presumed a merely slow reply lost. The
new copy's request then went out at once, and when the slow reply arrived
it was taken as that request's answer, so one copy's data was pasted as
another's. A large image is enough to trigger it over a VPN (a 1080p
CF_DIB is ~8 MB).

The grace is now 60 s. Only a reply slower than that can still be
misattributed, and a reply that truly never comes costs at most the copies
made within a minute of the request it lost. Adds the reviewer's scenario
as a test (a slow DIB reply, then a text copy): it fails at 5 s and passes
at 60 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
They call test_backend(), which only exists on macOS, so the Linux test
build failed to compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…n end

Backends are built before TLS and authentication, so a failed or abandoned
connection attempt, or a preemption candidate that lost, also drops one. Its
Drop stamped session_end_cc, erasing the evidence of a copy made on the Mac
while disconnected, so the next real connect fetched the client's clipboard
over it instead of skipping. Found while answering a review question on
clintcan#198. Stamp only from a backend that handled a client's list.

Also document the 60 s grace, the connect-time stand-down, and the takeover
ordering in the quirk notes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants