Skip to content

perf(desktop-gpui): read and decode the screenshot clipboard image off the UI thread - #2756

Open
celeroncoder wants to merge 2 commits into
CapSoftware:mainfrom
celeroncoder:perf/gpui-clipboard-decode-off-main
Open

celeroncoder wants to merge 2 commits into
CapSoftware:mainfrom
celeroncoder:perf/gpui-clipboard-decode-off-main

Conversation

@celeroncoder

@celeroncoder celeroncoder commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The Linux arm was built and tested locally. The macOS and Windows arms were written in the same shape and verified by reading, since I don't have access to macOS or Windows machines; CI has since built the GPUI desktop app on aarch64 and x86_64 macOS, Windows and Linux and run the macOS and Windows test jobs, all green, so this is out of draft.

After a screenshot, screenshot_finished copied the image to the clipboard on the UI thread: std::fs::read of the full-resolution PNG (tens of MB on a Retina display), then per platform the clipboard conversion, right when the tray, library and editor are also refreshing. On Linux that was a format sniff, bytes.to_vec(), a content hash and another clone inside ClipboardItem::new_image; on Windows a full RustImageData::from_bytes decode; on macOS NSData + NSImage + the pasteboard write.

Change

copy_image_to_clipboard(path, cx) now returns a Task: the file read and prepare_clipboard_image run on the background executor, and only write_clipboard_image runs on the UI thread. Each platform module exports the same three items (ClipboardImage, prepare_clipboard_image, write_clipboard_image):

  • Linux: prepare sniffs the format and builds the ClipboardItem from the owned bytes (no to_vec(), no new_image clone; the hash runs in the background). Write is try_write_to_clipboard.
  • Windows: prepare does the RustImageData::from_bytes decode in the background (RustImageData is {u32, u32, Option<DynamicImage>}, so it is Send). ClipboardContext::new and set_image stay on the UI thread.
  • macOS: only the file read moves; NSData, NSImage and the pasteboard write stay on the main thread, same body as before.

Error strings are unchanged. The same treatment is applied to the library "Copy" action, the settings "Copy" action and the editor's export-to-clipboard; the editor's success toast still fires only after the write succeeds.

Because prepares now finish in completion order rather than click order, a process-wide generation counter (ClipboardWrites) stamps every request synchronously and the foreground step only writes if no newer request exists; a superseded copy is skipped with a debug log and returns Ok(()), so only the newest copy ever reaches the clipboard. Nothing after screenshot_finished reads the clipboard, so the only observable change is that the clipboard is populated a few milliseconds after the editor opens instead of before.

Results

Linux, debug build (dependencies at opt-level 2), synthetic 5120×2880 PNG of 27.8 MB, medians of 5 runs, 2 vCPU aarch64 Linux box.

main @ 2a61427 This PR
UI-thread work before the clipboard write 48.0 ms (file read alone 8.7 ms) 0 ms
Background prepare — 14.4 ms

The try_write_to_clipboard call itself is unchanged and not included. On Windows, set_image still re-encodes to PNG and BMP on the UI thread; that is the largest remaining cost there and is out of scope here. macOS keeps its AppKit main-thread work by design.

Overlap

How this was made

This PR is agent-generated.

  • The finding comes from an audit of main by OpenCode scouts and Gemini 3.8 Flash, re-checked against the source by the orchestrating Claude session.
  • Claude Opus 5.5 wrote the change on current main, checked clipboard_rs for Send, and took the Linux measurement above.
  • Review round 2: Greptile flagged that out-of-order prepares could let an older copy overwrite a newer one; DeepSeek V4.1 Flash analysed the call sites and specified the generation-counter fix, Claude Opus 5.5 implemented it with four unit tests, and Gemini 3.8 Flash reviewed the hunk and re-ran the checks.
  • Gemini 3.8 Flash (Antigravity) reviewed the first diff independently: it confirmed the Send bounds from the clipboard_rs and gpui sources, the identical signatures across the three platform modules, that every task is detached and every toast fires after the write, re-ran cargo check, clippy and the platform:: tests on Linux, and approved. Neither agent had a macOS or Windows machine to build on.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

@celeroncoder is attempting to deploy a commit to the Cap Software Inc Team on Vercel.

A member of the Team first needs to authorize it.

@celeroncoder

Copy link
Copy Markdown
Contributor Author

cc @richiemcilroy for review (GitHub doesn't let a fork request reviewers, so tagging instead).

@celeroncoder
celeroncoder marked this pull request as ready for review October 20, 2026 12:27
Comment thread apps/desktop-gpui/src/platform.rs Outdated
Comment on lines +565 to +574
cx.spawn(async move |cx| {
let image = cx
.background_executor()
.spawn(async move {
let bytes = std::fs::read(&path)
.map_err(|error| format!("Could not read the clipboard image: {error}"))?;
prepare_clipboard_image(bytes)
})
.await?;
cx.update(|cx| write_clipboard_image(image, cx))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Older copies replace newer ones

copy_image_to_clipboard now writes in the order background work finishes, not the order users click Copy. The library leaves its Copy buttons active and detaches every request. If a user copies a large screenshot and then a smaller one while the first is still preparing, the older request can finish last and replace the newer image. Track request order and skip stale writes, or queue the copies so an older request cannot overwrite a newer one.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop-gpui/src/platform.rs
Line: 565-574

Comment:
**Older copies replace newer ones**

`copy_image_to_clipboard` now writes in the order background work finishes, not the order users click Copy. The library leaves its Copy buttons active and detaches every request. If a user copies a large screenshot and then a smaller one while the first is still preparing, the older request can finish last and replace the newer image. Track request order and skip stale writes, or queue the copies so an older request cannot overwrite a newer one.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid, and it is a regression this PR introduced: before the change the read and write were synchronous in the click handler, so writes happened in click order. Two call sites can genuinely invert: the library Copy (main_window.rs, library_action has no disabled state) and the settings Copy (settings_window.rs, row_button is called with disabled: false), and they are different windows in the same process. screenshot_finished is reachable but needs a second capture to start inside the first copy's prepare. The editor export cannot invert: exporting gates the action and disables the buttons, and it does not go through this helper.

Fixed in 779c03c with a process-wide generation counter in the shared helper rather than per call site, since the windows share one clipboard. platform.rs gains a small ClipboardWrites { latest: AtomicU64 } (request() / is_current()); copy_image_to_clipboard stamps its id synchronously before spawning, and the foreground step skips the write (debug log, Ok(())) when a newer request exists, otherwise writes. The compare and the write sit in the same cx.update closure, so only the highest id can ever reach the clipboard. A superseded write is Ok(()) because it is not a failure: the clipboard already holds the newer image, and the helper's call sites alert on Err. The editor path is untouched. ClipboardWrites is unit-tested without an App: newest request wins, a completed write does not invalidate a later request, concurrent requests get distinct ids, ids are strictly increasing.

Fix written by Claude Opus 5.5 from a DeepSeek V4.1 Flash analysis of the finding, reviewed by Gemini 3.8 Flash.

@richiemcilroy

Copy link
Copy Markdown
Member

Thanks @celeroncoder! Greptile is at 4/5 here. We only merge once Greptile reaches 5/5, so please address its findings in the summary above (or reply to them if you think one is wrong), then comment @greptileai review to re-score.

@celeroncoder

Copy link
Copy Markdown
Contributor Author

@greptileai review

@celeroncoder

Copy link
Copy Markdown
Contributor Author

The red macos / build and test / Engine verify on 779c03c is cap-verify::crash crash_safety failing the fragment-span check for crash/recorder-kill/{inproc,oop}/static-screen (the known B1 keyframe-interval case). The same two cases fail on today's main pushes (runs 39544976143 and 39539573847) and on most PRs since, so it's independent of this change, which only touches the clipboard helper in platform.rs. Everything else on this commit is green.

This branch has not been deployed

No deployments
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