Repository navigation
Conversation
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
left a comment
There was a problem hiding this comment.
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 (
a5d1c682and thee258f6a0bump), the server'shandle_format_listcallson_readyon the first client list and thenon_remote_copyfor the same list, soinitial_remote_copy_pendinggates exactly that list. - Local-only writes.
prepareForNewContentsWithOptions(currentHostOnly)clears the pasteboard likeclearContents, 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 branchThe 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_fetchis stamped in the old backend'sDrop. 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.
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]>
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)com.apple.is-remote-clipboard(visible intypes()without starting the fetch).advertise_pasteboardskips such items, andon_format_data_requestanswers with an error instead of resolving one. If Apple drops the marker, behaviour reverts to today's.pb::write_itemsand the eager/lazy file publishes now useprepareForNewContentsWithOptions(currentHostOnly)(begin_local_write) instead ofclearContents. 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.skip_connect_time_fetchignores 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_ccis stamped in the backend'sDrop, 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.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.STALE_REPLY_GRACEis now 60 s. Onmain, 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.advertise_pasteboardreturns anAdvertisedenum (Sent / NothingToSend / SenderGone) so the retry loop stops when there's nothing to send.Trade-offs (documented in
docs/known-quirks.md)For reviewers
mainby itsremote_write_cc. This PR builds on that marker rather than duplicating it.on_readyfrom that list's handler, right beforeon_remote_copyfor the same list.Testing
cargo clippy --all-targets -- -D warningsclean;cargo test303 passed on this branch, merged with currentmain.The lost-reply, slow-reply, session-end stamp, and no-advertise tests were each checked by reverting the fix: the test fails without it.
🤖 Generated with Claude Code