Windows: sequence the clipboard restore after the target has read it#1770
Windows: sequence the clipboard restore after the target has read it#1770emerson-d-lopes wants to merge 2 commits into
Conversation
… a timer Closes cjpais#502. Windows only; the macOS and Linux paths are untouched. paste_via_clipboard writes the transcript, sleeps, sends the chord, sleeps again, then restores. The target reads the clipboard when its message pump reaches the paste, which is not when the keystroke was sent. If the app is busy the restore lands first and the user gets their previous clipboard. Any fixed delay has a lag that beats it, which is why raising the slider to 400ms does not close cjpais#502. Delayed rendering removes the guess. The text is published as a promise, SetClipboardData(CF_UNICODETEXT, NULL) with a hidden owner window, and Windows sends WM_RENDERFORMAT when a consumer actually asks for the data. That message is the signal that the target read the clipboard, so the restore is sequenced after the read rather than racing it. Three further defects on the same path: Transcripts no longer enter Windows clipboard history or sync to the Microsoft cloud clipboard. Writing CF_UNICODETEXT alone opts into both. Modifiers held at injection time are released first. In push-to-talk a modifier is held by construction and SendInput does not reset keyboard state, so a held Right-Alt turns Ctrl+V into AltGr+V. Injection into an elevated window is detected up front instead of failing silently, which MSDN confirms it does: neither the return value nor GetLastError reports a UIPI block. Verified against Chrome, VS Code, Notepad and Windows Terminal by making each application copy its own field back, and separately with a clipboard manager running. If the new path fails for any reason it logs and falls through to the existing implementation, so this cannot lose a transcript to an integration problem. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0.1.1 fixes an intermittent abort when publishing the delayed-render promise. SetClipboardData returns NULL on success for that call, which windows-rs surfaces as Err, and Win32 does not reset the thread last-error on success, so a stale code read as a failure that never happened. Seen while driving this branch through real dictations: one paste in four aborted with ERROR_NOT_FOUND and fell back to the timer path. With 0.1.1 the fallback no longer triggers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Update: this has now been run end to end through Handy itself, not just against the crate in isolation. Doing that found a bug, which is fixed in What the real run found. Driving this branch through actual dictations, one paste in four aborted and fell back to the existing timer path: The cause is a Win32 trap I walked straight into. The return value cannot detect failure there at all. 0.1.1 ignores it and establishes success from observable clipboard state instead: we own the clipboard, and Before and after, same machine, same flow, real speech into Notepad:
Small sample, and I would not present it as more than that. But the failure mode is understood rather than guessed at, and the previous clipboard survived every single dictation in both sessions, which is the behaviour #502 is about. Worth noting the fallback did its job here: the transcript still landed via the existing path, and the user would not have noticed anything beyond it being a bit slower. That is the intended failure posture for this change. Full build now verified too. Still unverified, and I would rather say so than let a green check imply otherwise: Windows on ARM, RDP/Citrix/VDI, games and other raw-input consumers, and Windows 10. |
|
Hi, it looks like this PR addresses the Windows platform bug, so I guess we can remove the "Close #XXX" as the issue is also on other platforms (like macOS). What do you think? |
|
Yeah this does not close #502 |
|
@MatteoGauthier @cjpais Agreed, and thanks for catching it. Updated the title and the first line: this is now "Part of #502, Windows path only" with no closing keyword, so merging it will not auto-close the issue. You are right about the scope. The mechanism here is Win32-specific (delayed-render promise plus Worth noting for whoever picks up the macOS side: the underlying race is the same shape (the restore is scheduled on a timer rather than sequenced after the consumer's read), so the diagnosis in this PR should transfer even though the API does not. |
Windows: sequence the clipboard restore after the target has read it
Part of #502, Windows path only. This does not close the issue: macOS and Linux use different paste paths and are untouched here, and #502 has reports on both.
The bug
paste_via_clipboardwrites the transcript, sleepspaste_delay_ms, sends the chord, sleepspaste_delay_after_ms, then restores. The target application reads the clipboard when its message pump reaches the paste, which is not when the keystroke was sent. If the app is busy, the restore lands first and the user gets their previous clipboard.Any fixed delay has a lag that beats it. That is why #502 has been open since December with 52 comments, and why raising the slider to 400ms does not close it.
I have a Windows machine and could reproduce it deterministically, which I know has been the blocker here.
Reproduction
A real Win32 window handling a real
WM_PASTE, with a controllable delay standing in for message-pump backlog. At a 120ms restore delay:Runnable:
cargo run --example repro_502in win-text-inject.The fix
Publish the text as a delayed-render promise rather than as data:
SetClipboardData(CF_UNICODETEXT, NULL)with a hidden owner window. Windows then sendsWM_RENDERFORMATwhen a consumer actually asks for the data. That message is the signal that the target read the clipboard, so the restore is sequenced after the read instead of racing it. No delay constant is involved.Two things I got wrong first, in case they save anyone time:
A sequence-number gate does not fix this. Restoring only when
GetClipboardSequenceNumberstill matches our own write sounds right and changes nothing: the sequence number is still ours, because nobody else wrote. We clobber ourselves.One render is not proof the paste finished. Chromium touches the clipboard more than once per paste, an early probe and then the real read. Restoring after the first
WM_RENDERFORMATlands between them and reproduces the original bug. The fix waits for renders to go quiet, with the clock starting from an observed read rather than from a guess.Three other defects fixed on the same path
Transcripts leak into clipboard history and the Microsoft cloud clipboard. Writing
CF_UNICODETEXTalone opts into both. Chrome's Incognito mode avoids this by registering four opt-out formats; almost nothing else does, including Handy and Wispr Flow (whose docs state that dictated text "may appear in Windows clipboard managers"). Now attached on every write.Held modifiers corrupt the chord. In push-to-talk a modifier is held by construction when injection fires. Per MSDN,
SendInput"does not reset the keyboard's current state." A user holding Right-Alt turns the synthesized Ctrl+V into AltGr+V, a different character on many layouts. Held modifiers are now released first, and deliberately not restored, since re-pressing one the user has since released leaves it stuck.Injection into elevated windows fails silently. Per MSDN, when
SendInputis blocked by UIPI, "neitherGetLastErrornor the return value will indicate the failure." The text just vanishes. An integrity-level comparison costs microseconds and lets the app say "elevated window, press Ctrl+V" instead. This is also the mechanism behind #434.Verification
Verified against real applications by making each app copy its own field back, which is the only readback that works across Win32, Chromium and Electron.
WM_GETTEXTcannot see Chromium or WinUI text, and UI Automation cannot insert at all.Clipboard managers
Worth stating explicitly, since it changes behaviour.
A manager that honours the opt-out formats does not read the promise at all. Windows clipboard history behaves this way, verified with history enabled.
A manager that ignores them consumes the promise on publish. Once anything renders it, the clipboard holds real data and Windows sends no further
WM_RENDERFORMAT, so the target's read becomes unobservable. The crate detects this, falls back to the timer restore, and reportsread_confirmed: falsehonestly instead of pretending. Without that handling the clipboard was never restored at all while a manager was running, which is worse than the original bug.Shape of the change
Two files, 86 lines. A
#[cfg(windows)]early return inpaste_via_clipboarddelegating to the crate, and the dependency. ExistingPasteMethodvalues map straight onto the crate's chords, so no settings or UI changes are needed.If it fails for any reason it logs and falls through to the current implementation, so this cannot lose a transcript to an integration problem.
win-text-injectis MIT OR Apache-2.0, Windows-only, and its sole dependency is thewindowscrate, which Handy already has. CI runs on stable and 1.82 across fmt, clippy, tests, doc tests and rustdoc, and exercises the clipboard against a real headless runner.Notes
PasteMethodis user-selected so this PR does not change any default, but it is worth knowing.Strategy::UnicodeTypein the crate is not yet exercised against a real window and is not used by this PR.