Visitar URL original
fix(clipboard): stop caching denied clipboard reads on Win32 (backport #24357) by mergify[bot] · Pull Request #24449 · unoplatform/uno · GitHub
Skip to content

fix(clipboard): stop caching denied clipboard reads on Win32 (backport #24357) - #24449

Merged
agneszitte merged 8 commits into
release/stable/6.7from
mergify/bp/release/stable/6.7/pr-24357
Sep 11, 2026
Merged

agneszitte merged 8 commits into
release/stable/6.7from
mergify/bp/release/stable/6.7/pr-24357

Conversation

@mergify

@mergify mergify Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

GitHub Issue: tracked internally — no public issue to link.

PR Type:

🐞 Bugfix

What changed? 🚀

Current behavior. Run N instances of a Skia Win32 app, copy text in any one of them, and only one instance can paste. In the other N−1, Clipboard.GetContent().Contains(Text) returns false, TextBox.CanPasteClipboardContent is false, and the Paste item vanishes from the context menu — until the clipboard changes again, when a different instance wins and a different set breaks.

Cause. OpenClipboard is a global exclusive lock with exactly one winner, and it is deniable. Win32ClipboardExtension took that lock with a single attempt and no retry, materialised every format under it, then cached the result — successes and failures alike — until the next WM_CLIPBOARDUPDATE.

Because AddClipboardFormatListener broadcasts WM_CLIPBOARDUPDATE to every listener at once, all N instances race for the lock on the same tick. One wins; the rest get ERROR_ACCESS_DENIED, which the old code could not distinguish from an empty clipboard, and then cached that empty DataPackage. Three compounding defects: a denied read looked like an empty one, the failure was cached, and the read ran synchronously from WndProc.

This is not only a multi-instance bug — with no competitor at all, a single un-retried OpenClipboard still failed 3–13 % of the time on an idle desktop.

Availability no longer takes the lock

  • Probe the known formats with IsClipboardFormatAvailable, which is documented lock-free and measured 100 % correct while another process holds the lock — including the Excel-style never-rendered delay-render case, where it answers without triggering a render.
  • Then make one non-blocking EnumClipboardFormats attempt for full fidelity, so app-specific format names still surface. If that attempt is denied, the package is still correct for every format Uno can decode and is simply not cached — the next call retries. Retry across calls, never inside one, so nothing on the paste-enablement path can block.
  • Key the cache on GetClipboardSequenceNumber (also lock-free) rather than on WM_CLIPBOARDUPDATE, so a cached package can never outlive the clipboard generation it was built from. Verified that the sequence number bumps on EmptyClipboard/SetClipboardData and is visible to other processes while the clipboard is still held.
  • A jittered warm-up after WM_CLIPBOARDUPDATE (~60/150/400 ms) fills the complete list in the background. The jitter is not cosmetic: processes woken by one broadcast back off in lockstep and collide again without it.

Payloads are fetched lazily, on demand

Via SetDataProvider, one format at a time, awaiting the lock instead of sleeping on it. This also fixes a pre-existing use-after-free: the old deferred providers captured an HGLOBAL from GetClipboardData and GlobalLocked it inside the callback — i.e. after CloseClipboard, when the clipboard-owned handle is no longer valid. Providers now re-fetch by format id and verify the sequence number first.

Writes stop holding the lock across arbitrary work

SetContent resolves all payloads before opening the clipboard, so it no longer pumps the event loop — dispatching arbitrary UI work, and possibly a re-entrant WM_CLIPBOARDUPDATE — while holding the global lock. Eager materialisation of 21 delay-rendered formats measured 976 ms, during which every other application's clipboard write fails.

SetContent and Clear retry the open 10 × 100 ms (WPF's cadence, shared by its OleRetryCount/OleRetryDelay) and now check that they actually hold the lock. Previously ClipboardDisposable logged the denial and continued with _shouldClose == false, so SetContent issued every SetClipboardData against a clipboard it had never opened, and Clear silently no-oped.

The retry budget is deliberately split: one cheap attempt on the read hot path, the full budget only on payload fetch / write. A 10 × 100 ms loop inside GetContent() would block the dispatcher for up to 1 s per call, per TextBox, on every clipboard change — putting CanPasteClipboardContent back on a global exclusive lock.

Measured

8 instances × 3 copies, then 12 × 5, driving a real Uno app and publishing from a third-party writer (PowerShell Set-Clipboard):

dead Ctrl+V Set-Clipboard retries needed
before, 8 instances 21/24 — 87.5 % 1
after, 8 instances 0/24 — 0.0 % 0
after, 12 instances 0/60 — 0.0 % 0

87.5 % is exactly (N−1)/N. Format fidelity is preserved, not just paste: after the fix every instance reports the complete list including DataObject and Ole Private Data — app-specific names with no table entry, learnable only from EnumClipboardFormats.

Chose user32 over OLE, deliberately

OleGetClipboard does give lock-free enumeration, which user32 cannot. But measurement showed its proxy.GetData payload path is 0 % against raw-published content, so an OLE rewrite would have needed the same retry logic and would not have fixed this bug.

The drag/drop boundary, and what did get cleaned up

Drop's decode path is deliberately not merged into the clipboard's: ReadContentIntoPackage and drop's two decoder bypasses still use the old eager path, so a clipboard fix cannot regress drop. Those readers now live in their own partial, Win32ClipboardExtension.DragDrop.cs, so the boundary is visible in the file layout instead of only in a comment.

The one thing that was unified is the pure pixel work: a single ConvertDibToBmp(void* dib, uint dibSize) now serves both paths, since the DIB→BMP header rewrite has nothing to do with where the handle came from. That conversion is derived from SDL, so this also adds the corresponding zlib-license notice to THIRD-PARTY-NOTICES.md. The remaining duplication — GetText ×2 and drop's decoder dispatch — is left for a follow-up.

Commits

fix(clipboard) the fix itself — everything described above
refactor(clipboard) split the drag/drop-shared readers into a partial
refactor(clipboard) dedupe the DIB→BMP conversion; SDL attribution
test(clipboard) scope the test's P/Invokes to a file static class so a sibling partial cannot reach them

PR Checklist ✅

Given_Clipboard.Win32.cs asserts the contention is real before asserting the fix: a background thread takes OpenClipboard from a message-only window and holds it, and the test fails unless OpenClipboard is genuinely denied with ERROR_ACCESS_DENIED. Confirmed to discriminate — reverting only the clipboard file makes it fail with the bug's exact log signature.

🤖 Generated with Claude Code


This is an automatic backport of pull request #24357 done by [Mergify](https://mergify.com).

Xiaoy312 and others added 8 commits September 8, 2026 15:24
`OpenClipboard` is a global exclusive lock with exactly one winner, and it
is deniable. The Win32 clipboard read took that lock with a single attempt
and no retry, materialised *every* format under it, then cached the result
- successes and failures alike - until the next `WM_CLIPBOARDUPDATE`.

Since `AddClipboardFormatListener` broadcasts `WM_CLIPBOARDUPDATE` to every
listener at once, N instances of an app all race for the lock on the same
tick. One wins; the other N-1 cache an *empty* `DataPackage`, so
`Contains(Text)` is false, `TextBox.CanPasteClipboardContent` is false, and
the Paste menu item disappears until the clipboard changes again. Measured
at exactly (N-1)/N: 21/24 dead with 8 instances, 0/24 after this change.
A denied read was also indistinguishable from an empty clipboard even with
a single instance - an un-retried `OpenClipboard` failed 3-13% of the time
on an idle desktop.

Availability no longer needs the lock at all:

- Probe the known formats with `IsClipboardFormatAvailable`, which is
  documented lock-free and measured 100% correct while another process
  holds the lock - including the never-rendered delay-render case, where
  it answers without triggering a render.
- Then make ONE non-blocking `EnumClipboardFormats` attempt for full
  fidelity, so app-specific format names still surface. If that attempt is
  denied the package is still correct for every format Uno can decode, and
  the read is simply not cached: the next call retries. Retry across calls,
  never inside one, so nothing on the paste-enablement path can block.
- Key the cache on `GetClipboardSequenceNumber` (also lock-free) rather
  than on `WM_CLIPBOARDUPDATE`, so a cached package can never outlive the
  clipboard generation it was built from.
- A jittered warm-up after `WM_CLIPBOARDUPDATE` (~60/150/400 ms) fills the
  complete list in the background. Jitter matters: processes woken by one
  broadcast back off in lockstep and collide again without it.

Payloads are now fetched lazily via `SetDataProvider`, on demand, one
format at a time, awaiting the lock instead of sleeping on it. This also
fixes a pre-existing use-after-free: the old deferred providers captured an
`HGLOBAL` from `GetClipboardData` and `GlobalLock`ed it inside the callback,
i.e. after `CloseClipboard`, when the handle is no longer valid. Providers
re-fetch by format id and verify the sequence number first.

`SetContent` resolves all payloads *before* opening the clipboard, so it no
longer pumps the event loop - dispatching arbitrary UI work, and possibly a
re-entrant `WM_CLIPBOARDUPDATE` - while holding the global lock. Eager
materialisation of 21 delay-rendered formats measured 976 ms, during which
every other application's clipboard write fails; `Set-Clipboard` from
PowerShell now succeeds first try against 12 running instances.

`SetContent` and `Clear` retry the open 10 x 100 ms (WPF's cadence, which
its `OleRetryCount`/`OleRetryDelay` share) and check that they actually
hold the lock. Previously `ClipboardDisposable` logged the denial and
continued with `_shouldClose == false`, so `SetContent` issued every
`SetClipboardData` against a clipboard it had never opened and `Clear`
silently no-oped.

Drag/drop is deliberately untouched: `ReadContentIntoPackage` and drop's
two decoder bypasses still share the old eager path, so a clipboard fix
cannot regress drop. Unifying those decoders is left as a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7c32f60)
Move ReadContentIntoPackage, GetFileDropList and their private
GetText/GetBitmap helpers -- the only Win32ClipboardExtension members
consumed by Win32DragDropExtension -- into
Win32ClipboardExtension.DragDrop.cs. Pure move, verified byte-identical;
also drops the now-unused Windows.Storage and Windows.Win32.UI.Shell
usings from the original file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit ff9a9f7)
The BMP file-header wrap existed verbatim in both Win32ClipboardExtension
and its drag-drop partial. Both now call a single ConvertDibToBmp helper.

The SDL zlib notice moves from the file header onto that helper. SDL's
bih_size/dib_size arithmetic had already been replaced by an independently
sourced colorTableSize switch on biCompression, so only the BMP format
spec remains shared - a file-wide notice overstated the derivation.
Scoping it also restores attribution to the drag-drop copy, which lost it
when the partial was split out, and follows the existing precedent in
Win32WindowWrapper.SetIcon.

Also adds the missing SDL entry to THIRD-PARTY-NOTICES.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3d12682)
The user32/kernel32 entry points were members of the Given_Clipboard
partial, so its cross-platform sibling could reach them with nothing to
flag the misuse: the Skia test assembly is a single generic binary that
also runs on X11, macOS, wasm and mobile, where these throw
DllNotFoundException. A `file` class is a compiler-enforced boundary,
and only the members the test calls stay visible inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3088dbc)
A holder that never returns from Join is still owning the clipboard and
would poison every test after this one. The Join result is captured in
the finally and asserted after it, so a genuine failure inside the try
still propagates first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 293a3e9)
GC.KeepAlive only rooted the delegate to the end of
CreateMessageOnlyWindow, while the registered class and the window both
outlived it, so lpfnWndProc dangled from the moment it returned. Thread
exit is the trigger: Windows destroys the thread's windows and
dispatches WM_DESTROY through that pointer, which is precisely what
holder.Join waits on.

A built-in class already has a native window procedure, so there is no
delegate to keep alive and no class to register - which also removes the
class that was never unregistered and the CurrentManagedThreadId name a
recycled id could collide with. The window is now destroyed on the
thread that created it.

Measured against the previous shape: OpenClipboard(hwnd) succeeds, a
contending thread is denied with ERROR_ACCESS_DENIED,
IsClipboardFormatAvailable still reports the text, and the payload
survives destroying its owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit fb1f466)
EmptyClipboard and SetClipboardData were unchecked, so a failed setup
surfaced as the Contains(StandardDataFormats.Text) assertion further
down, reading as a product regression rather than a broken premise. Each
step now records which call failed and with what error, asserted as soon
as the holder reports back.

The flag gating CloseClipboard stays strictly "we own the lock and have
to release it": a holder that took the clipboard and then failed still
has to release it, or it poisons every later test. The UI thread no
longer reads it, so it moved into the lambda as a local, leaving no
cross-thread flag to overload.

SetClipboardData only takes ownership of the HGLOBAL on success, so the
very failure path being surfaced here leaked the allocation. Allocation
and free are now paired in Win32.TrySetUnicodeText, which also lets
SetClipboardData and CF_UNICODETEXT go private.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 09f27a5)
GlobalAlloc and GlobalLock were unchecked, so a failure there reached
Marshal.Copy and threw on the holder thread - which has a finally and no
catch, so it would take the test runner down and produce no results XML
rather than failing the test.

Both are now checked and report the Win32 error, freeing the handle when
the lock is what failed. Neither import set SetLastError, so the error
they reported would have been a stale one from an unrelated call; both
set it now.

Defensive hardening, not a root-cause fix: a 56-byte GlobalAlloc failing
is not a condition that has been observed. The value is the missing
SetLastError and a helper whose every step reports instead of throwing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1912902)
@agneszitte
agneszitte merged commit da217df into release/stable/6.7 Sep 11, 2026
13 checks passed
@agneszitte
agneszitte deleted the mergify/bp/release/stable/6.7/pr-24357 branch September 11, 2026 04:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants