Repository navigation
Implement the async Clipboard API (navigator.clipboard) - #33312
cirospaciari wants to merge 128 commits into
Conversation
Adds the W3C Clipboard API: `navigator.clipboard` with `readText`, `writeText`, `read`, and `write`, the `copy`/`paste` events, and the `ClipboardItem` and `ClipboardEvent` classes, backed by the OS clipboard on macOS (NSPasteboard), Windows (Win32), and Linux/BSD (`wl-clipboard`, `xclip`, or `xsel`). Each interface is an individual native class with its own constructor: - `Clipboard` is a WebCore-style EventTarget subclass; `navigator.clipboard` is its lazily created per-global singleton and the four promise methods are native (JSClipboard.cpp). - `ClipboardItem` is a Bun-native class (prototype + constructor + LazyClassStructure) with WebIDL record validation, a frozen `types` array, `getType()` normalization, and a static `supports()` (JSClipboardItem.cpp). - `ClipboardEvent` is a WebCore-style Event subclass (JSClipboardEvent.cpp). - The three globals are lookup-table entries on the global object. The platform I/O lives in Rust (src/runtime/webcore/clipboard.rs): each operation runs as a work-pool job (never on the JS thread), settles its promise back on the JS thread, and fires the corresponding clipboard event only on success. The Rust backend is also the single source of truth for per-platform capabilities, which `ClipboardItem.supports()` and `write()` validation query through two exported predicates. Supported representations: `text/plain`, `text/html`, and `image/png` (no `text/html` on Windows, which needs a `CF_HTML` envelope). Failures reject with a `NotAllowedError` DOMException whose message names the fix. File-backed Blobs (`Bun.file()`) are rejected in `write()` with a TypeError instead of being written as empty data.
|
Updated 12:28 PM PT - Oct 2nd, 2026
✅ @robobun, your commit 94e7f1f07c533cd5a054082ae5da1eb7aabe4f29 passed in 🧪 To try this PR locally: bunx bun-pr 33312That installs a local version of the PR into your bun-33312 --bun |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds Clipboard, ClipboardItem, and ClipboardEvent support across Bun's typings, parser globals, WebCore/JSC bindings, native clipboard backends, runtime wiring, documentation, and tests. ChangesClipboard API implementation
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/bindings/JSClipboardItem.cpp`:
- Around line 189-195: Normalize clipboard MIME types before storing or
comparing them in ClipboardItem so case variants map to the canonical form.
Update the logic in JSClipboardItem::getType, the type collection/storage path
that appends to m_types, and the supports() call site to parse/serialize the
MIME string first, then use the canonical value for matching and reported types.
Also ensure invalid getType() input is validated through the MIME parser so it
throws the TypeError path instead of falling through to NotFoundError, and add
tests for uppercase keys, duplicate parsed keys, and invalid getType() input.
In `@src/jsc/bindings/webcore/ClipboardEvent.idl`:
- Around line 28-33: Remove Worker exposure from ClipboardEvent so it matches
the spec and stays out of worker contexts. Update the Exposure declaration on
the ClipboardEvent interface in the ClipboardEvent.idl definition to expose it
only in Window, and keep the constructor and clipboardData attribute unchanged
unless there is an explicit compatibility exception.
In `@src/jsc/bindings/webcore/JSClipboard.cpp`:
- Around line 262-276: The Clipboard write path in JSClipboard::write currently
rejects when items.size() > 1; update it so single-item backends fall back to
using items.at(0) instead of returning NotAllowedError for multiple items. Keep
the existing type validation loop for the selected Bun::JSClipboardItem, but
only apply the one-representation-per-item rejection when the backend truly
cannot handle multiple representations, not when there are multiple
ClipboardItems.
In `@src/runtime/webcore/clipboard.rs`:
- Around line 857-859: The clipboard helper path in `timeout_prefix()`/the
caller currently falls back to running `wl-paste`/`xclip`/`xsel` without any
timeout when no external timeout binary is found. Update the clipboard execution
flow in `clipboard.rs` so it either uses an internal spawn timeout or fails
closed instead of returning `argv` unwrapped, and ensure every
error/abort/timeout path actively completes the operation rather than leaving
the promise pending.
- Around line 988-989: The temp-file staging in the clipboard write path can
leave a partial payload behind when File::openat succeeds but
file.write_all(bytes).ok()? fails. In the clipboard staging logic around the
File::openat/write_all flow, make sure the created path is unlinked immediately
on any write failure before returning None, so the acquisition is paired with
cleanup at the same site.
In `@test/js/web/clipboard/clipboard.test.ts`:
- Around line 331-386: The clipboard event test only preserves text via
navigator.clipboard.readText, so it can overwrite richer clipboard contents and
fail to restore them in the finally block. Update the save/restore logic in this
test to capture and restore full clipboard items like the round-trip clipboard
test does, while keeping a text-only fallback when full-item access is
unavailable. Make sure the restore path still uses navigator.clipboard.writeText
for the fallback and that the event assertions remain unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: a48f19a4-f86c-462d-b048-0ae0e458e7a9
📒 Files selected for processing (31)
docs/runtime/web-apis.mdxpackages/bun-types/globals.d.tssrc/bun_core/env_var.rssrc/js_parser/defines_table.generated.rssrc/js_parser/defines_table.string-map.tssrc/jsc/bindings/JSClipboardItem.cppsrc/jsc/bindings/JSClipboardItem.hsrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.hsrc/jsc/bindings/ZigGlobalObject.lut.txtsrc/jsc/bindings/image_coregraphics_shim.cppsrc/jsc/bindings/webcore/Clipboard.cppsrc/jsc/bindings/webcore/Clipboard.hsrc/jsc/bindings/webcore/Clipboard.idlsrc/jsc/bindings/webcore/ClipboardEvent.cppsrc/jsc/bindings/webcore/ClipboardEvent.hsrc/jsc/bindings/webcore/ClipboardEvent.idlsrc/jsc/bindings/webcore/DOMClientIsoSubspaces.hsrc/jsc/bindings/webcore/DOMIsoSubspaces.hsrc/jsc/bindings/webcore/EventFactory.cppsrc/jsc/bindings/webcore/EventHeaders.hsrc/jsc/bindings/webcore/EventTargetFactory.cppsrc/jsc/bindings/webcore/EventTargetHeaders.hsrc/jsc/bindings/webcore/JSClipboard.cppsrc/jsc/bindings/webcore/JSClipboard.hsrc/jsc/bindings/webcore/JSClipboardEvent.cppsrc/jsc/bindings/webcore/JSClipboardEvent.hsrc/runtime/webcore.rssrc/runtime/webcore/clipboard.rstest/integration/bun-types/bun-types.test.tstest/js/web/clipboard/clipboard.test.ts
…ignals - Normalize ClipboardItem types to their lowercased serialization (the spec's parse-and-serialize step) and lowercase before the platform `supports()` check; record enumeration goes through the method table so Proxy and other exotic records work, re-checking enumerability per key. - Run every Linux/BSD clipboard helper through a `/bin/sh` watchdog so a hung selection owner is killed after 10s without depending on coreutils' `timeout`; missing helpers (exit 127/126) and timeouts (124) are told apart from real failures, and the temp payload file is unlinked when staging fails. - Only arm the sync spawner's process-wide signal forwarding (and the `Bun__currentSyncPID` handshake) on the main thread, mirroring the existing `no_orphans` gate, so work-pool callers like the clipboard jobs never replace the user's signal handlers. - Reject S3-backed Blobs in `write()` like file-backed ones, check MarkedArgumentBuffer overflow, and make `ClipboardItem.supports()` throw when called with no argument.
The previous commit gated the sync spawner's signal forwarding on the parent-death watchdog's arming thread, but that watchdog is not armed in every configuration, which left `bun run` without a SIGINT forwarder (caught by test/regression/issue/ctrl-c.test.ts in CI). Gate on the process's main thread (`cli_state::is_main_thread`) instead, which is the contract the C++ forwarder documents. Also rework the clipboard helper watchdog so its `sleep` cannot outlive the invocation or hold the helper's captured stdout open (which would have made reads take the full 10s wherever the capture uses a pipe): the watchdog group is fully redirected and reaps its `sleep` on TERM.
…native # Conflicts: # src/jsc/bindings/ZigGlobalObject.cpp # src/jsc/bindings/ZigGlobalObject.lut.txt
Windows was the one platform without `text/html`: reads and writes now go through the registered "HTML Format" (CF_HTML), wrapping the fragment in the offset-header envelope on write and extracting the fragment by its validated offsets on read. The supported-representation list is now the same on every platform, so the per-platform special cases in the docs, types, and tests are gone.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/webcore/clipboard.rs`:
- Around line 653-662: The `clipboard.rs` HTML handling in `cf_html_fragment`
fallback currently returns the full raw `CF_HTML` envelope when fragment
validation fails. Update the `text/html` branch to treat invalid
`StartFragment`/`EndFragment` data as an unavailable representation instead of
using `unwrap_or(bytes)`, so malformed payloads do not leak header/boilerplate
content; keep the behavior localized to the `Mime::TextHtml` path and
`cf_html_fragment` call site.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 26a01af6-7b6d-4e9c-a196-f22dcf2ceb81
📒 Files selected for processing (5)
docs/runtime/web-apis.mdxpackages/bun-types/globals.d.tssrc/jsc/bindings/webcore/Clipboard.idlsrc/runtime/webcore/clipboard.rstest/js/web/clipboard/clipboard.test.ts
If neither the offset header nor the fragment comment markers can be validated, reading `text/html` on Windows now reports the representation as absent instead of returning the raw envelope bytes as HTML.
- The helper watchdog installs its TERM trap before starting `sleep` and exits from the trap, so a fast helper can no longer orphan the `sleep` or send a stray kill after the parent already reaped the helper. - `getType()` lowercases its argument like the stored `types`, and the `ClipboardItem` constructor rejects two spellings of the same MIME type with a TypeError instead of producing a duplicated `types` list.
clippy (needless_pass_by_value): create_items_array only reads the representation list, so take a slice instead of consuming the Vec.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
At most, there should be one strong here and that's only in the Promise being returned.
Do not use JSC::JSNativeStdFunction. This should follow the existing pattern for resolve/reject functions. If you need to pass in the additional context argument to that Promise then use the context argument.
There should not be any promise chaining either.
Clipboard.prototype.write built an N-deep promise chain out of capturing JSNativeStdFunctions, rooting the state each lambda closed over in a JSC::Strong. Replace the chain with a resumable loop over the item's representations, and the lambdas with shared non-capturing host functions registered through performPromiseThenWithContext, following the reaction convention the streams code already uses: the handler is invoked as handler(value, contextCell) and reads its state from the context. The state one write carries across those reactions now lives in a JSClipboardWriteState cell whose fields are visited, so no Strong is needed on this side at all. Bun__Clipboard__writeBlobs takes the promise write() already returned and the scheduled job settles it, leaving the job's JSPromiseStrong as the only strong handle involved. Only an object can be a thenable, so string representations now normalize inline and cost no reaction; getType() on a string resolves without one. While here: the NSPasteboard reader stashed its retained NSData in a thread_local between the size and copy phases, so the copy phase ignored the UTI and trusted whatever was stashed. Hand back an explicit handle instead and have the caller pass it to bun_coregraphics_clipboard_take_data, which copies and releases it. Both phases report failure, so a shim that cannot load can no longer surface a run of zeroes as the clipboard's contents.
|
All three addressed in 8ff1962. No One strong, in the returned promise. The state the lambdas used to capture lives in a context cell whose fields are visited — One behaviour change worth surfacing: only an object can be a thenable, so string representations normalize inline now and cost no reaction — On CI: the red That shard is still red on a different one: |
|
@robobun adopt it |
…Used The Linux helpers took their environment from Bun's native env map, but a runtime assignment to process.env lives on the JS object and never reaches that map, so a script that set process.env.DISPLAY still got "no display" (and the test for it failed on the Linux CI lanes). The snapshot taken when an operation is scheduled now reads the process.env object itself, the way Bun.spawn reads an env option. A failure reading it leaves its exception pending and the promise operation rejects with it. On Linux, read() now transcodes text/html that a helper delivers as UTF-16 with a byte order mark, which is how Firefox serves it, to UTF-8. It came back as a garbled Blob before. The DIB test table gains a case with an explicit biClrUsed, so that branch of the colour table offset is exercised.
Scheduling a clipboard operation reads the script's environment first, which can fail. schedule() returned quietly in that case, so its caller could not tell "scheduled" from "failed" (the mordant lint flagged it). schedule() and the three functions exported to C++ now return whether the operation was scheduled, and each error is handled by name: a thrown exception or a terminated VM leaves nothing more to do, and an out-of-memory failure throws. As before, a failure leaves its exception pending, so the promise operation rejects with it.
The Linux helpers read the script's process.env through a small C++ accessor. process.env is created lazily on first use, and creating it can throw, but the accessor called it without a throw scope, so the debug build's exception check validation flagged an unchecked exception when a worker made its first clipboard call. The accessor now declares a scope, checks it, and returns an empty value when creating process.env threw. The Rust side reports an empty value as a thrown exception.
The previous commit gave the C++ accessor a throw scope. A throw scope asks its caller to check again when it ends, and the Rust side only tested the returned value for empty, which checks nothing. The debug build's exception check validation would still flag the next scope. The Linux environment snapshot now calls the accessor through from_js_host_call, which declares the scope that performs the check and asserts that an empty value means an exception is pending.
Windows offers CF_DIBV5 for every bitmap. For a BI_BITFIELDS one that it synthesizes (from CF_BITMAP or CF_DIB: a screenshot, Paint, .NET Clipboard.SetImage), it puts the three colour masks after the 124-byte header, although the V5 header already holds them. dib_as_bmp added those 12 bytes only for a 40-byte header, and it prefers CF_DIBV5, so bfOffBits pointed at the masks: the image shifted by three pixels, with the masks as three stray pixels. Bun.Image.fromClipboard() and navigator.clipboard.read() both returned that image. A producer's own CF_DIBV5 has no such repeat, so dib_as_bmp now looks for one: the 12 bytes after the header must equal the header's masks. The Win32 suite places a 3x2 bitmap both ways and checks every pixel. One pixel cannot show this: a 32-bit row of three pixels is as long as the masks, and two rows show the bottom-up order.
…peration A thread creates its process.env object on the first read. A worker that only calls navigator.clipboard.readText() makes that read inside the environment snapshot. The child runs with BUN_JSC_validateExceptionChecks, so a debug or ASAN build aborts when a caller on that path does not check for the exception. At 5ba4ac3 the child died with: unchecked exception at src/jsc/JSPropertyIterator.rs:264 (thrown from JSEnvironmentVariableMap.cpp:978)
|
@cirospaciari the Defect. For a Fix. For Test. Runs at 9d5f7a2 plus my two commits:
Build 119379. The x64-asan failure ( One note on |
When the Windows clipboard only holds a bitmap, read() converts it to PNG. It did the decode and the PNG encode with the clipboard still open and the process wide clipboard lock held. For a screenshot that takes long enough that other programs could not paste, and Bun.Image.fromClipboard() found nothing, until it finished. read_types now copies the bitmap out while the clipboard is open, closes it, and converts afterwards. write_types already works in that order.
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the two inline nits, I also checked the Windows Bun.Image.fromClipboard() path under a concurrent navigator.clipboard job — the try_lock/null result matches the behavior the PR description documents as intentional, so I did not flag it. One leftover worth a glance: the DISPLAY and WAYLAND_DISPLAY entries added in src/bun_core/env_var.rs (lines 143 and 200) no longer have any reader now that the Linux backend probes the snapshotted process.env map in clipboard.rs.
Extended reasoning...
This run posts two nits (a BI_BITFIELDS mask-repeat heuristic in backend_wic.rs and Blob type normalization in ClipboardItem.cpp) and the hunt stopped at its bug cap rather than running dry, with an outstanding CHANGES_REQUESTED from a maintainer, so approval is not on the table. The note records one concern examined and ruled out (Windows fromClipboard contention is documented as intentional) and one dead-code leftover confirmed by grep (the two env_var definitions have no consumers outside their declaration).
Still open from earlier reviews (1):
- Unresolved: 1 minor or pre-existing.
This branch added them for the Linux clipboard backend, which now looks the display up in its snapshot of process.env. Nothing reads them.
|
@cirospaciari I ran 6320501 and 45942c1 on the machines you do not have. Results at 45942c1
The
One finding from that measurement. My first poller passed a NULL window, as Bun does, and it was never turned away.
So "no other process writes between the types" holds against a program with a window, but not against a second Bun or another console program. I tried the usual remedy: one message-only window for each open span ( I did not push anything. Tell me which you want: in this PR, as a follow-up PR after the merge, or not at all. The same goes for the |
|
@robobun thanks, the before/after numbers for On the two open questions:
Please do not push to this branch for either. One CI note for build 119414 on 45942c1: the only job that did not pass is |
|
@cirospaciari understood. I push nothing to this branch, and I drop the The window change waits for the merge. It is on The two-process reproStart two copies at the same time, with the arguments // One of two processes that use the clipboard at the same time through the
// public API only. Every value a read returns must be one that a write produced.
const id = process.argv[2];
const until = performance.now() + Number(process.argv[3] ?? 3000);
const stats = { id, writes: 0, reads: 0, writeRejected: {}, readRejected: {}, emptyReads: 0, foreignReads: 0, tornItems: 0 };
const png = new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DQDwAEgAF/pooBPQAAAABJRU5ErkJggg==", "base64")], { type: "image/png" });
let i = 0;
while (performance.now() < until) {
const text = `p${id}-${i++}`;
try {
await navigator.clipboard.write([new ClipboardItem({ "text/plain": text, "text/html": `<b>${text}</b>`, "image/png": png })]);
stats.writes++;
} catch (e) {
stats.writeRejected[e.message] = (stats.writeRejected[e.message] ?? 0) + 1;
}
try {
const items = await navigator.clipboard.read();
stats.reads++;
if (items.length === 0) stats.emptyReads++;
else {
const types = [...items[0].types].sort().join(",");
// Both processes always write all three types in one write().
if (types !== "image/png,text/html,text/plain") stats.tornItems++;
else {
const plain = await (await items[0].getType("text/plain")).text();
const html = await (await items[0].getType("text/html")).text();
if (!/^p[ab]-\d+$/.test(plain)) stats.foreignReads++;
else if (html !== `<b>${plain}</b>`) stats.tornItems++;
}
}
} catch (e) {
stats.readRejected[e.message] = (stats.readRejected[e.message] ?? 0) + 1;
}
}
console.log(JSON.stringify(stats)); |
One conflict, in src/runtime/image/codecs.rs. This branch added Encoded::as_slice (Windows only) above Encoded::from_owned. On main, from_owned lost #[allow(dead_code)] for #[cfg(any(target_os = "macos", windows))] (#44086). The result keeps as_slice and takes main's attribute for from_owned.
|
@cirospaciari this PR now conflicts with main. I did not push anything: you asked me not to push to this branch, so the merge is yours, or mine if you say so. The conflict. There is one, in
impl Encoded {
#[cfg(windows)]
pub(crate) fn as_slice(&self) -> &[u8] {
// SAFETY: `bytes` is a live allocation owned by `self` until `Drop`.
unsafe { self.bytes.as_ref() }
}
#[cfg(any(target_os = "macos", windows))]
pub(crate) fn from_owned(bytes: Vec<u8>) -> Encoded {What I checked with that merge, locally (45942c1 merged with main at a4f1429):
I could not run Windows this time. The Windows lanes in CI are the check for the Win32 tests after the merge. |
|
@robobun fix conflicts and rebase |
|
@robobun adopt again this PR |
|
@cirospaciari done. The branch has no conflict with main now, and I drive this PR again.
The window change for |
String::fromUTF8ReplacingInvalidSequences aborts the process on two kinds of clipboard text. Text of 2 GiB or more fails its length assertion: ASSERTION FAILED: string.size() <= String::MaxLength Text of 1 GiB or more that is not all ASCII fails earlier, in the allocation of its conversion buffer, with no message. The completion that every platform shares now checks the length first and converts with Zig::convertUTF8ToString, which has no such buffer. Text that does not fit rejects with a NotAllowedError and fires no paste event. Text of 1 GiB or more that is not valid UTF-8 rejects in the same way.
sync::Options::timeout (POSIX) gives the child its own process group and a deadline. At the deadline the group gets SIGKILL. The wait ends when the child exits, so a process that the child started cannot hold the call open through a captured pipe. sync::Options::linux_pdeathsig passes the existing spawn option through. Both are off by default. A caller that sets neither makes the same system calls as before.
The backend ran each helper under a /bin/sh watchdog that sent SIGTERM after 10 seconds. A helper that ignores SIGTERM was never ended, and the operation never settled. The backend now finds the helper on the PATH of the script and runs it with the time limit of the synchronous spawn. At the limit the helper and its process group get SIGKILL. The helper of a read also gets SIGKILL when the Bun process is gone, and a read starts no further helper for a worker that is stopping. A write still tries its next helper then. The test hook moves from the BUN_INTERNAL_CLIPBOARD_HELPER_TIMEOUT environment variable to bun:internal-for-testing.
|
@cirospaciari I pushed three commits. They are for two defects in the Linux backend at d28ff32. One decision in them is yours, so I name it first. Your decision: the watchdog On 2026-09-15 you wrote that the watchdog stays because
I took A because the review of 2026-09-15 named the missing timeout as the cause of the shell string, and because B leaves the other three rows as they are. The cost of A is in shared code. A caller that sets no time limit makes the same system calls as before: 180 of 180 traces are identical. If you prefer B in this PR and A as a follow-up PR after the merge, say so. Then I replace commits 2 and 3 with the one line. The defects
The commits
What changes for users with A
Not in this push
Verified
The measurements and the limits that stay are in the Notes block of the PR body. |
For stdout and stderr the wait takes the fd as its own capture fd: it drains it and closes it. The only caller passes it for stdin.
The 1 GiB test held several GiB and needed its own timeout. The branch that it covered is reachable only with a payload of that size. A wait for a file that a stand-in writes now fails with a message when the file never comes.
| .chain(argv[1..].iter().map(|word| word.as_bytes())) | ||
| .map(Box::from) | ||
| .collect(), | ||
| cwd: Box::from(b".".as_slice()), |
There was a problem hiding this comment.
🟡 nit (optional): on Linux/BSD a script whose working directory has been deleted gets "No clipboard helper was found. Install wl-clipboard (Wayland) or xclip (X11)." even though the helper is installed. cwd: Box::from(b".") at clipboard.rs:1044 makes every helper spawn run chdir(".") in the child, which fails with ENOENT (or EACCES on an unsearchable cwd), and clipboard.rs:1063-1070 maps that errno to HelperRun::NotInstalled. Fix: leave cwd at its default so no chdir happens for any helper (reads, TARGETS probes and writes alike), and map only which/exec failures to NotInstalled so a failed spawn keeps surfacing as Unavailable::Spawn with its errno.
Why this was flagged
A Linux script runs navigator.clipboard.readText() after its working directory was removed; the same happens when the cwd loses search permission. run() at src/runtime/webcore/clipboard.rs:1039-1059 builds spawn_sync::Options with cwd: Box::from(b"."). spawn_process_posix at src/spawn_sys/spawn_process.rs:707-708 only skips chdir when cwd is empty, so the vfork child executes chdir(request->chdir) at src/jsc/bindings/bun-spawn.cpp:354-357; it fails with ENOENT and spawn_sync::spawn returns Ok(Err(ENOENT)). clipboard.rs:1063-1070 treats ENOENT|ENOTDIR|EACCES as HelperRun::NotInstalled, and read_types/write_types (clipboard.rs:1169-1173, 1199-1203) reject with Unavailable::NoHelper, whose text tells the user to install wl-clipboard or xclip. Options::default() at src/spawn/process.rs:2390 uses an empty cwd (no chdir), so a plain spawn of xclip from the same process succeeds; the base branch has no clipboard API. The errno filter was meant for a missing or non-executable helper, not for a chdir failure, so it does not protect this case.
Verification: clipboard.rs:1044 passes cwd: Box::from(b".".as_slice()) for every helper run. spawn_process.rs:707-708 only skips the chdir for an empty cwd. bun-spawn.cpp:354-357: the child executes chdir(request->chdir) and childFailed ships errno back. clipboard.rs:1063-1070 maps ENOENT | ENOTDIR | EACCES to HelperRun::NotInstalled; read_types returns Unavailable::NoHelper (1169-1173).
What
Implements the W3C Clipboard API:
navigator.clipboard(readText,writeText,read,write),ClipboardItem, andClipboardEvent, backed by the OS clipboard on macOS, Windows, and Linux/BSD. A successful write firescopyand a successful read firespasteatnavigator.clipboard.Architecture
Clipboard: anEventTarget;navigator.clipboardis the lazily created per-global singletonJSPerformance/JSMessagePortwebcore/Clipboard.{h,cpp},webcore/JSClipboard.{h,cpp}ClipboardItem: WebIDL record constructor,[SameObject]frozentypes,getType(), staticsupports(); each representation is observed withDOMPromise::whenSettledWithResult, as upstream WebKit doesJSCloseEvent(getDOMConstructor)webcore/ClipboardItem.{h,cpp},webcore/JSClipboardItem.{h,cpp}ClipboardEvent: anEventsubclassJSCloseEventwebcore/ClipboardEvent.{h,cpp},webcore/JSClipboardEvent.{h,cpp}bun_jsc::Jobper operation on the work pool; WebCore passes byte ranges tagged with a MIME enum and an opaque request, and the job completes or releases it on the JS threadJobContextimplementorssrc/runtime/webcore/clipboard.rs,webcore/ClipboardPlatform.{h,cpp}Operations are not ordered relative to each other. A
write()that is still collecting its item is superseded (rejects withAbortError) by any later write; once scheduled, a write lands whenever the platform runs it. In-process serialization exists only where the platform needs it: one Win32 clipboard transaction per process (OpenClipboard(NULL)does not exclude a second thread of the same process, and concurrent reads corrupted the heap), and oneNSPasteboardcall at a time on macOS (AppKit segfaulted otherwise).Bun.Image.fromClipboard()shares both; on Windows it takes the lock without waiting and makes a single open attempt, as before this PR.Platform behavior
text/plaintext/htmlimage/pngNSPasteboard;read()takes every type under one lock and retries if another process writes meanwhile; a failed write leaves the pasteboard emptyCF_HTML)CF_DIBV5)read()is one open span; a failed multi-format write leaves the clipboard emptywl-clipboardorxclip;xselcan only read text; one representation per writeOn Linux,
read()first asks the selection owner which types it offers (wl-paste --list-types, xclip'sTARGETS) and reads only those, and the first helper that reaches the display answers for the clipboard; an offered type it cannot deliver fails the read. A non-zero helper exit counts as "nothing copied" only when the helper says so; anything else, such as a stale$DISPLAY, is a failure. The payload of a write reaches the helper through a memfd, or a 0600 temp file unlinked before any helper runs, so it never has a name another process can open. The backend finds each helper on thePATHof the script and runs it directly. One helper run has a time limit of 10 seconds, because a hung X11 selection owner blocks a helper forever. At the limit the helper and its process group getSIGKILL.Failures reject with a
NotAllowedErrorwhose message says what to fix (no display, no helper installed, the helper failed, or the helper could not be started, with the system error). On every platformreadText()also rejects with aNotAllowedErrorwhen the text is too large for a string.Changes outside the clipboard
DOMPromise::whenSettledWithResult(JSDOMPromise.{h,cpp}): ported from upstream WebKit.WebCore::Blob::create(bytes, type, globalObject)(blob.{h,cpp}) with its Rust factoryBlob__fromBytesWithNormalizedType, plus the impl-level Blob accessors the clipboard uses (Blob.rs).spawn_sync:Options::forward_signals(defaulttrue) so work-pool spawns do not arm the process-wide signal forwarder,SyncStdio::Fdfor a caller-owned stdin, and an opt-inOptions::timeout(POSIX) withOptions::linux_pdeathsig. A caller that sets no time limit makes the same system calls as before.bun_threading::Mutex::try_lockis available in release builds.bun_sys::windows: the global-memory andSleepexterns inkernel32, and auser32module with the clipboard externs (replacing an empty placeholder re-export).EmptyClipboard,CloseClipboard, andSetClipboardDataareunsafe; the owning wrappers live next to the backend andbackend_wicuses them.image_coregraphics_shim.cpp: the pasteboard lock, andBun.Image's reader takes it too.bun:internal-for-testing:setClipboardHelperTimeoutForTesting(ms), the testing hook for the time limit of a helper run.Spec coverage and differences
ClipboardItemkeys are the mimesniff serialization of the given type (parameters kept), as in Chrome;getType()matches that exactly first, then by essence.Bun.file()) can be written; their bytes are read in before the platform write, and a failed read rejects withNotAllowedErrorcarrying the system error.ClipboardEvent.clipboardDatais alwaysnulland a non-null init value throws (noDataTransfer);cutis never fired;write()takes one item; web custom formats ("web "prefix) are not implemented.Downsides
navigator.clipboardcannot be assigned. In a module,navigator.clipboard = mockandObject.assign(navigator, { clipboard: mock })throwTypeError: Attempted to assign to readonly property.Bun 1.4.3 and Node 26 accept both, because the property does not exist there. A test suite that installs a clipboard mock by assignment breaks.Object.defineProperty(navigator, "clipboard", { value: mock })works before and after. This PR does not decide whether the property gets a setter.worker.terminate()waits for the helper run in flight. A read then stops. A write goes on to its next candidate, so with both displays a hung write holds the worker for up to 20 seconds.SIGKILL, so it cannot clean up. The limit lives in the Bun process: when Bun is killed, the hung helper of a write stays. The kernel ends the helper of a read.sync::Optionsgrows from 112 to 128 bytes. The release binary grows by 1,561 bytes.Tests
test/js/web/clipboard/clipboard.test.ts. Tests that touch the real clipboard run on CI or withBUN_TEST_SYSTEM_CLIPBOARD=1; the macOS and Windows CI lanes fail instead of skipping when the clipboard is unreachable. Coverage: the WebIDL surface, validation and rejection messages, supersession, GC and teardown, collection that consults no user-replaceable promise machinery, worker termination while collecting, events, round-trips; Linux helpers played by stand-ins on PATH (candidate order, the targets probe, clean versus display failures, crashes, the time limit for a helper that hangs and ignoresSIGTERM, a helper that the kernel cannot start, text that is too large for a string, payload staging, worker teardown); the Win32 formats driven throughbun:ffi(foreignCF_HTML, text edge cases, PNG and theCF_DIBV5layout);pbcopy/pbpasteandclip.exe/Get-Clipboardinterop; and a race of reads, writes andBun.Image.fromClipboard()on the in-process backends.Notes on the helper time limit and on large text (commits 96de433 to 94e7f1f)
Two defects at d28ff32
readText()aborted the process on large text. Text of 1 GiB or more that is not all ASCII aborted with no message. Any text of 2 GiB or more aborted withASSERTION FAILED: string.size() <= String::MaxLength.SIGTERMwas never ended, soreadText()andwriteText()never settled. The watchdog sent onlySIGTERM.Which fix for the helper that is never ended
Two fixes were weighed.
kill -9 "$c"in the watchdog string. It is one line. Checked on d28ff32: the hang ends.sync::spawn, and no shell. This PR takes it.Reasons for 2:
shandsleepfrom thePATHof the script. With only the helpers onPATHthe limit never fires.sh, the helper, the watchdog subshell,sleep). It now starts 1.spawn_synchas no stdin source and no timeout".The three commits are separable.
96de433301(large text) stands alone.What changes for a user, compared with the watchdog
xcliporwl-copyleaves behind.PATHfinds no helper. The shell searched the working directory.argv[0].#!line fails with "The clipboard helper could not be started". The shell ran such a file. spawn: retry via /bin/sh when exec returns ENOEXEC #31717 adds that retry to the spawn.SIGKILLwhen the Bun process is gone (PR_SET_PDEATHSIG, Linux)./bin/shis not needed.Large text
NotAllowedErrorand fires nopasteevent.read()withBlob.text()decodes such text. The shared converter (Zig::convertUTF8ToString) gives up at that size.Measurements (Linux x64)
sync::spawnthat sets no time limit makes the same system calls as before. 36 cases (memfd or socket pair, 3 stdio shapes, 6 child behaviours), 5 runs each, spawning thread traced with ptrace: 180 of 180 traces identical.sync::spawn: 23 to 25 system calls for a read, 8 to 10 for a write (debug build). The 2 more are onewait4(WNOHANG)and onepollon the pidfd.pidfd_open) the wait looks at the helper at intervals of 1, 2, 4 and so on up to 100 ms. A helper that runs 50 ms costs 6polland 7wait4calls. One that runs 2 s costs 26 and 27.sync::Options: 112 to 128 bytes.sync::Result: 96 to 96.bun,size: text 80,747,788 to 80,749,413 (+1,625), data 110,488 to 110,424 (-64). The file grows by 4,096 bytes.perfandvalgrindare not on the machine.Tests
PATH=""(it ran./xclip), the size test (it resolved 1,048,577 characters), the worker read test (xselstarted), the socket pair tests (timeout), the killed-process test (helper still alive). With the old env var at 1 second, a helper that ignoresSIGTERMnever settled and a child of the helper stayed.BUN_FEATURE_FLAG_FORCE_WAITER_THREAD=1andBUN_FEATURE_FLAG_DISABLE_MEMFD=1.PATH(the default path is a constant of the system), FreeBSD itself, a realxcliporwl-clipboardin CI.readText()size test for the real clipboard runs there in CI for the first time.Named for later
JobContext::cancelfor the clipboard job, which the 2026-09-15 review asked for.worker.terminate()still waits for the helper run in flight. With the limit in the spawn,cancelneeds one more wake source in that wait.bun installruns git since install: run git for git dependencies on the install thread's event loop #40734) needs no pool thread. That is a rewrite of the Linux backend.Self-reviewed: 5 concerns raised, 4 addressed. Not addressed: a CI test for text of 1 GiB (see Tests).
no test proof · iteration 36 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/clipboard/clipboard.test.ts