Skip to content

Implement the async Clipboard API (navigator.clipboard) - #33312

Open
cirospaciari wants to merge 128 commits into
mainfrom
claude/clipboard-api-native
Open

cirospaciari wants to merge 128 commits into
mainfrom
claude/clipboard-api-native

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 3, 2026 •

Copy link
Copy Markdown
Member

What

Implements the W3C Clipboard API: navigator.clipboard (readText, writeText, read, write), ClipboardItem, and ClipboardEvent, backed by the OS clipboard on macOS, Windows, and Linux/BSD. A successful write fires copy and a successful read fires paste at navigator.clipboard.

await navigator.clipboard.writeText("hello");
console.log(await navigator.clipboard.readText()); // "hello"

await navigator.clipboard.write([
  new ClipboardItem({
    "text/plain": "hello",
    "text/html": new Blob(["<b>hello</b>"], { type: "text/html" }),
  }),
]);
const [item] = await navigator.clipboard.read();
console.log(item.types, await (await item.getType("text/plain")).text());

Architecture

Piece Follows Files
Clipboard: an EventTarget; navigator.clipboard is the lazily created per-global singleton JSPerformance / JSMessagePort webcore/Clipboard.{h,cpp}, webcore/JSClipboard.{h,cpp}
ClipboardItem: WebIDL record constructor, [SameObject] frozen types, getType(), static supports(); each representation is observed with DOMPromise::whenSettledWithResult, as upstream WebKit does JSCloseEvent (getDOMConstructor) webcore/ClipboardItem.{h,cpp}, webcore/JSClipboardItem.{h,cpp}
ClipboardEvent: an Event subclass JSCloseEvent webcore/ClipboardEvent.{h,cpp}, webcore/JSClipboardEvent.{h,cpp}
Platform backend: one bun_jsc::Job per 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 thread other JobContext implementors src/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 with AbortError) 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 one NSPasteboard call 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/plain text/html image/png Mechanism
macOS ✅ ✅ ✅ (a TIFF-only image is converted) NSPasteboard; read() takes every type under one lock and retries if another process writes meanwhile; a failed write leaves the pasteboard empty
Windows ✅ ✅ (CF_HTML) ✅ (also written as CF_DIBV5) Win32 clipboard; read() is one open span; a failed multi-format write leaves the clipboard empty
Linux / BSDs ✅ ✅ ✅ wl-clipboard or xclip; xsel can only read text; one representation per write

On Linux, read() first asks the selection owner which types it offers (wl-paste --list-types, xclip's TARGETS) 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 the PATH of 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 get SIGKILL.

Failures reject with a NotAllowedError whose 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 platform readText() also rejects with a NotAllowedError when 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 factory Blob__fromBytesWithNormalizedType, plus the impl-level Blob accessors the clipboard uses (Blob.rs).
  • spawn_sync: Options::forward_signals (default true) so work-pool spawns do not arm the process-wide signal forwarder, SyncStdio::Fd for a caller-owned stdin, and an opt-in Options::timeout (POSIX) with Options::linux_pdeathsig. A caller that sets no time limit makes the same system calls as before.
  • bun_threading::Mutex::try_lock is available in release builds.
  • bun_sys::windows: the global-memory and Sleep externs in kernel32, and a user32 module with the clipboard externs (replacing an empty placeholder re-export). EmptyClipboard, CloseClipboard, and SetClipboardData are unsafe; the owning wrappers live next to the backend and backend_wic uses them.
  • image_coregraphics_shim.cpp: the pasteboard lock, and Bun.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

  • ClipboardItem keys are the mimesniff serialization of the given type (parameters kept), as in Chrome; getType() matches that exactly first, then by essence.
  • File- and S3-backed Blobs (Bun.file()) can be written; their bytes are read in before the platform write, and a failed read rejects with NotAllowedError carrying the system error.
  • No permission prompts; ClipboardEvent.clipboardData is always null and a non-null init value throws (no DataTransfer); cut is never fired; write() takes one item; web custom formats ("web " prefix) are not implemented.

Downsides

  • There is no permission model. Every script that runs in Bun, and every dependency it loads, can read and replace the clipboard of the user with no prompt. A browser asks the user first. This PR does not decide whether Bun needs a flag or a prompt for this: that decision is for a maintainer.
  • navigator.clipboard cannot be assigned. In a module, navigator.clipboard = mock and Object.assign(navigator, { clipboard: mock }) throw TypeError: 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.
  • On Linux and the BSDs a helper that hangs holds one work-pool thread until the time limit of 10 seconds, once for each candidate helper. 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.
  • At the time limit the helper gets 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.
  • For a helper that does not hang, each run costs the pool thread 2 more system calls (23 to 25 for a read, 8 to 10 for a write). sync::Options grows 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 with BUN_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 ignores SIGTERM, a helper that the kernel cannot start, text that is too large for a string, payload staging, worker teardown); the Win32 formats driven through bun:ffi (foreign CF_HTML, text edge cases, PNG and the CF_DIBV5 layout); pbcopy/pbpaste and clip.exe/Get-Clipboard interop; and a race of reads, writes and Bun.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 with ASSERTION FAILED: string.size() <= String::MaxLength.
  • A helper that ignores SIGTERM was never ended, so readText() and writeText() never settled. The watchdog sent only SIGTERM.

Which fix for the helper that is never ended

Two fixes were weighed.

  1. kill -9 "$c" in the watchdog string. It is one line. Checked on d28ff32: the hang ends.
  2. A time limit in sync::spawn, and no shell. This PR takes it.

Reasons for 2:

  • The watchdog needs sh and sleep from the PATH of the script. With only the helpers on PATH the limit never fires.
  • Where output is captured through a socket pair, a process that the helper started holds the pair open and the wait never ends.
  • Each helper run started 4 processes (sh, the helper, the watchdog subshell, sleep). It now starts 1.
  • The test hook was an environment variable that production code read.
  • The 2026-09-15 review named the cause: the shell string, the quoting, the watchdog and the env var are "all there because spawn_sync has 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

  • The helper leads its own process group. A signal to the process group of Bun no longer reaches the daemon that xclip or wl-copy leaves behind.
  • An empty PATH finds no helper. The shell searched the working directory.
  • The helper gets its full path as argv[0].
  • A helper that is a script with no #! 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.
  • The helper of a read gets SIGKILL when the Bun process is gone (PR_SET_PDEATHSIG, Linux).
  • A read in a worker that is terminated starts no further helper.
  • /bin/sh is not needed.

Large text

  • Text up to the length limit of a string resolves. Larger text rejects with a NotAllowedError and fires no paste event.
  • Text of 1 GiB or more that is not valid UTF-8 also rejects with that message. read() with Blob.text() decodes such text. The shared converter (Zig::convertUTF8ToString) gives up at that size.
  • The whole text is still held in memory before the check.

Measurements (Linux x64)

  • A caller of sync::spawn that 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.
  • One helper run, pool thread, inside sync::spawn: 23 to 25 system calls for a read, 8 to 10 for a write (debug build). The 2 more are one wait4(WNOHANG) and one poll on the pidfd.
  • Without a pidfd (FreeBSD, or a kernel with no 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 6 poll and 7 wait4 calls. One that runs 2 s costs 26 and 27.
  • sync::Options: 112 to 128 bytes. sync::Result: 96 to 96.
  • Release 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.
  • Instructions per call: not measured. perf and valgrind are not on the machine.

Tests

  • These fail on d28ff32: PATH="" (it ran ./xclip), the size test (it resolved 1,048,577 characters), the worker read test (xsel started), the socket pair tests (timeout), the killed-process test (helper still alive). With the old env var at 1 second, a helper that ignores SIGTERM never settled and a child of the helper stayed.
  • These pass on d28ff32 too, and pin a clause of the new code: a helper that the kernel cannot start, a write in a terminated worker, the daemon of a write. With the clause removed, each of them fails.
  • The FreeBSD branch (no pidfd, no memfd) runs on Linux through BUN_FEATURE_FLAG_FORCE_WAITER_THREAD=1 and BUN_FEATURE_FLAG_DISABLE_MEMFD=1.
  • Not covered: an unset PATH (the default path is a constant of the system), FreeBSD itself, a real xclip or wl-clipboard in CI.
  • Not covered in CI: text of 1 GiB. Only a real payload of that size reaches the converter branch, and such a test needs several GiB and its own timeout. Checked by hand with a stand-in that prints 1 GiB: exit 134 on d28ff32, 1,073,741,825 characters with this PR.
  • Not run by me: Windows and macOS. The new readText() size test for the real clipboard runs there in CI for the first time.

Named for later

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

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.
@cirospaciari
cirospaciari requested a review from alii as a code owner July 3, 2026 20:10
@robobun

robobun commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 12:28 PM PT - Oct 2nd, 2026

✅ @robobun, your commit 94e7f1f07c533cd5a054082ae5da1eb7aabe4f29 passed in Build #122957! 🎉


🧪   To try this PR locally:

bunx bun-pr 33312

That installs a local version of the PR into your bun-33312 executable, so you can run:

bun-33312 --bun

@mintlify

mintlify Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jul 3, 2026, 8:12 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai

coderabbitai Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Clipboard, ClipboardItem, and ClipboardEvent support across Bun's typings, parser globals, WebCore/JSC bindings, native clipboard backends, runtime wiring, documentation, and tests.

Changes

Clipboard API implementation

Layer / File(s) Summary
TypeScript types and docs
packages/bun-types/globals.d.ts, docs/runtime/web-apis.mdx, test/integration/bun-types/bun-types.test.ts
Adds Navigator.clipboard, Clipboard, ClipboardItem, DataTransfer, and ClipboardEvent typings, updates the web API support table, and adjusts the DOM-less empty-interface expectation.
Parser globals and env vars
src/js_parser/defines_table.generated.rs, src/js_parser/defines_table.string-map.ts, src/bun_core/env_var.rs
Registers Clipboard and ClipboardItem as pure global identifiers and adds POSIX-only DISPLAY and WAYLAND_DISPLAY env accessors.
WebCore Clipboard and ClipboardEvent classes
src/jsc/bindings/webcore/Clipboard.*, ClipboardEvent.*, EventFactory.cpp, EventHeaders.h, EventTargetFactory.cpp, EventTargetHeaders.h, DOM*IsoSubspaces.h
Adds WebCore Clipboard and ClipboardEvent classes, their IDL definitions, and active event/eventtarget factory and ISO subspace wiring.
JSClipboardItem binding
src/jsc/bindings/JSClipboardItem.{h,cpp}
Implements the ClipboardItem JSC binding with MIME validation, constructor logic, accessors, getType() resolution, and class-structure setup.
JSClipboard and JSClipboardEvent bindings
src/jsc/bindings/webcore/JSClipboard.*, JSClipboardEvent.*
Implements Clipboard prototype methods and wrapper plumbing, plus the ClipboardEvent JS wrapper and related FFI hooks.
ZigGlobalObject wiring
src/jsc/bindings/ZigGlobalObject.{h,cpp}, ZigGlobalObject.lut.txt
Registers clipboard bindings in the global object lookup tables, GC members, constructor getters, and lazy navigator.clipboard initialization.
Native platform backends
src/jsc/bindings/image_coregraphics_shim.cpp, src/runtime/webcore.rs, src/runtime/webcore/clipboard.rs, src/spawn/process.rs
Adds macOS clipboard shim functions, the Rust backend for macOS/Windows/POSIX clipboard operations, and signal-forwarding gating for spawned helper processes.
Clipboard test suite
test/js/web/clipboard/clipboard.test.ts
Adds tests covering interface shape, ClipboardItem/ClipboardEvent behavior, read/write round trips, event firing, and helper-path behavior.

Suggested reviewers: alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: implementing the asynchronous Clipboard API through navigator.clipboard.
Description check ✅ Passed The description is comprehensive and covers the implementation, architecture, platform behavior, limitations, and extensive verification details. It uses a different heading from the template, but it …

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5d76ac6 and 2cafcd2.

📒 Files selected for processing (31)
  • docs/runtime/web-apis.mdx
  • packages/bun-types/globals.d.ts
  • src/bun_core/env_var.rs
  • src/js_parser/defines_table.generated.rs
  • src/js_parser/defines_table.string-map.ts
  • src/jsc/bindings/JSClipboardItem.cpp
  • src/jsc/bindings/JSClipboardItem.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/ZigGlobalObject.lut.txt
  • src/jsc/bindings/image_coregraphics_shim.cpp
  • src/jsc/bindings/webcore/Clipboard.cpp
  • src/jsc/bindings/webcore/Clipboard.h
  • src/jsc/bindings/webcore/Clipboard.idl
  • src/jsc/bindings/webcore/ClipboardEvent.cpp
  • src/jsc/bindings/webcore/ClipboardEvent.h
  • src/jsc/bindings/webcore/ClipboardEvent.idl
  • src/jsc/bindings/webcore/DOMClientIsoSubspaces.h
  • src/jsc/bindings/webcore/DOMIsoSubspaces.h
  • src/jsc/bindings/webcore/EventFactory.cpp
  • src/jsc/bindings/webcore/EventHeaders.h
  • src/jsc/bindings/webcore/EventTargetFactory.cpp
  • src/jsc/bindings/webcore/EventTargetHeaders.h
  • src/jsc/bindings/webcore/JSClipboard.cpp
  • src/jsc/bindings/webcore/JSClipboard.h
  • src/jsc/bindings/webcore/JSClipboardEvent.cpp
  • src/jsc/bindings/webcore/JSClipboardEvent.h
  • src/runtime/webcore.rs
  • src/runtime/webcore/clipboard.rs
  • test/integration/bun-types/bun-types.test.ts
  • test/js/web/clipboard/clipboard.test.ts

Comment thread src/jsc/bindings/JSClipboardItem.cpp Outdated
Comment thread src/jsc/bindings/webcore/ClipboardEvent.idl Outdated
Comment thread src/jsc/bindings/webcore/JSClipboard.cpp Outdated
Comment thread src/runtime/webcore/clipboard.rs Outdated
Comment thread src/runtime/webcore/clipboard.rs Outdated
Comment thread test/js/web/clipboard/clipboard.test.ts Outdated
Comment thread src/runtime/webcore/clipboard.rs Outdated
Comment thread src/runtime/webcore/clipboard.rs Outdated
Comment thread src/runtime/webcore/clipboard.rs Outdated
…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.
Comment thread src/runtime/webcore/clipboard.rs Outdated
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.

@coderabbitai coderabbitai Bot left a comment

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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 1274cd4 and 44104b4.

📒 Files selected for processing (5)
  • docs/runtime/web-apis.mdx
  • packages/bun-types/globals.d.ts
  • src/jsc/bindings/webcore/Clipboard.idl
  • src/runtime/webcore/clipboard.rs
  • test/js/web/clipboard/clipboard.test.ts

Comment thread src/runtime/webcore/clipboard.rs Outdated
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.
Comment thread src/runtime/webcore/clipboard.rs Outdated
Comment thread src/jsc/bindings/JSClipboardItem.cpp Outdated
- 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 Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread src/jsc/bindings/image_coregraphics_shim.cpp Outdated
cirospaciari and others added 2 commits July 16, 2026 20:07
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.
Comment thread src/runtime/webcore/clipboard.rs Outdated
@cirospaciari

cirospaciari commented Jul 17, 2026 •

Copy link
Copy Markdown
Member Author

All three addressed in 8ff1962.

No JSNativeStdFunction, no chaining. write() is a resumable loop over the item's representations instead of an N-deep .then() chain, and both it and ClipboardItem.getType() now register shared, non-capturing host functions through performPromiseThenWithContext — the same reaction convention the streams code uses (handler(value, contextCell), context at argument(1)). Grep for JSNativeStdFunction, Strong<, or ->then( in JSClipboard.cpp / JSClipboardItem.cpp and all three are zero.

One strong, in the returned promise. The state the lambdas used to capture lives in a context cell whose fields are visited — JSClipboardWriteState for write(), an InternalFieldTuple{item, index} for getType() — so the C++ side holds none at all. The last one was structural: C++ had to chain its promise onto the one Rust created. Bun__Clipboard__writeBlobs now takes the promise write() already returned and the scheduled job settles it, leaving that job's JSPromiseStrong as the only strong handle involved.

One behaviour change worth surfacing: only an object can be a thenable, so string representations normalize inline now and cost no reaction — getType() on a string resolves a tick earlier than on a Blob representation of the same item. Nothing pins that ordering; happy to route strings through a reaction too if you'd rather they stay uniform.

On CI: the red windows-11-aarch64 - test-bun was test/js/sql/postgres-{binary-array-bounds,invalid-message-length} — this branch predated #33962, the flake fix for exactly those two files. Merged main in 82b761a and those are green now.

That shard is still red on a different one: bun-install-registry > hoisting > peers > it should hoist 1.0.1 when peer *. This branch touches no install/resolver/lockfile code and the same shard is failing concurrently on unrelated PRs, so I don't think it's this change — but it's Windows-only and verdaccio-backed, so I couldn't reproduce it locally to prove that. A retry of that one job would settle it.

Comment thread src/jsc/bindings/JSClipboardItem.cpp Outdated
@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun adopt it

@claude claude Bot left a comment

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.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/runtime/webcore/clipboard.rs Outdated
Comment thread src/runtime/webcore/clipboard.rs Outdated
Comment thread test/js/web/clipboard/clipboard.test.ts
cirospaciari and others added 6 commits September 21, 2026 14:09
…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)

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/webcore/clipboard.rs
Comment thread src/jsc/bindings/webcore/ClipboardPlatform.cpp
Comment thread src/jsc/bindings/webcore/ClipboardPlatform.cpp
@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

@cirospaciari the dib_as_bmp fix is in 194f307, with the test that checks every pixel of the 3x2 bitmap.

Defect. For a BI_BITFIELDS bitmap, the CF_DIBV5 that Windows synthesizes repeats the three colour masks after the 124-byte header. The 3x2 bitmap is 160 bytes: 124 + 12 + 24. dib_as_bmp added the 12 mask bytes only for a 40-byte header, so the pixel offset was 12 bytes short. The masks became one row of pixels and the top row was lost. A producer's own CF_DIBV5 has no repeat, so a fixed 12 bytes for each V5 header is wrong too.

Fix. For BI_BITFIELDS with a V4 or V5 header, dib_as_bmp compares the 12 bytes after the header with the masks inside the header. If they are equal, it skips them. The colour table offset from 81acafe is unchanged.

Test. a BI_BITFIELDS bitmap reads back with every pixel in place has two cases: a CF_DIB whose synthesized CF_DIBV5 has the repeat, and a producer's own CF_DIBV5 without it. Each case asserts the raw layout that Windows offers, then all six pixels of the PNG from read(). Without the fix, the first case fails on Windows. A System.Drawing.Bitmap placed by PowerShell also reads back with exact pixels through read() and through Bun.Image.fromClipboard().

Runs at 9d5f7a2 plus my two commits:

  • windows-x64 with BUN_TEST_SYSTEM_CLIPBOARD=1: 61 pass, 17 skip, 0 fail. Your biClrUsed case passes.
  • Linux debug build: 49 pass, 30 skip, 0 fail.
  • Linux with CI=1 and BUN_JSC_validateExceptionChecks=1: 57 pass, 22 skip, 0 fail. The live process.env test and the UTF-16 text/html test pass.

Build 119379. The x64-asan failure (unchecked exception at src/jsc/JSPropertyIterator.rs:264) reproduced here at 5ba4ac3. It does not occur at 9d5f7a2. I had the same fix ready and dropped it for yours. 22621e8 adds only a test: a worker that never read process.env calls readText(), and the child runs with BUN_JSC_validateExceptionChecks=1. The test fails at 5ba4ac3 with that message and passes now.

One note on Env::snapshot. I found no script that makes it throw. On POSIX, process.env converts a value to a string at assignment and refuses an accessor descriptor. Only out of memory and termination remain. For write() of an item that a script built, schedule() runs from a promise reaction or from the file read callback, not inside the promise operation. An exception there does not become the rejection: the request is released and the promise never settles. I changed nothing for that case.

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.

@claude claude Bot left a comment

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.

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.

Comment thread src/runtime/image/backend_wic.rs
Comment thread src/jsc/bindings/webcore/ClipboardItem.cpp
This branch added them for the Linux clipboard backend, which now looks
the display up in its snapshot of process.env. Nothing reads them.

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/webcore/clipboard.rs
Comment thread src/jsc/bindings/ZigGlobalObject.h
@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

@cirospaciari I ran 6320501 and 45942c1 on the machines you do not have.

Results at 45942c1

  • windows-x64 with BUN_TEST_SYSTEM_CLIPBOARD=1: 61 pass, 17 skip, 0 fail.
  • Linux debug build with CI=1 and BUN_JSC_validateExceptionChecks=1: 57 pass, 0 fail. The build without the two env_var entries is good.

The read_types change does what it says. The clipboard held an 18 MB bitmap only (3000x2000, 24-bit CF_DIB). A second process tried to open the clipboard for its own window about every millisecond while read() ran. Debug build, two runs each:

clipboard.rs read() the other process could not open the clipboard for
from 22621e8 2267 ms, 2261 ms 2262 ms, 2256 ms
at 45942c1 2275 ms, 2274 ms 100 ms, 103 ms (the copy)

One finding from that measurement. My first poller passed a NULL window, as Bun does, and it was never turned away. OpenClipboard refuses a second opener only when its window differs from the first one's, and NULL does not differ from NULL. The second opener takes the open clipboard over, from any process, and its CloseClipboard closes it for both:

  • One process opens with NULL and reads CF_UNICODETEXT. A child opens with NULL (success) and closes. Then GetClipboardData in the first process fails with error 1418 (ERROR_CLIPBOARD_NOT_OPEN), and so does its CloseClipboard.
  • Two Bun processes, public API only, 6 s: each loop does one write() of three types, then one read(). Process a: 4 rejected writes, 18 empty reads and 26 torn items in 3853 reads. Process b: 6, 13 and 34 in 3855. A torn item has fewer than three types, or text/html from a different write than text/plain.

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. TRANSACTION covers the same gap inside one process.

I tried the usual remedy: one message-only window for each open span (CreateWindowExA with the STATIC class and HWND_MESSAGE, destroyed on drop, NULL as before if the window cannot be created). With it, the same two processes did 3217 and 3214 loops with 0 rejected writes, 0 empty reads and 0 torn items. The clipboard file has 62 pass and 0 fail (two runs, with the length check from my thread reply included), and the Bun.Image clipboard test passes. It is about 60 lines in clipboard.rs and two declarations in src/sys/windows/mod.rs.

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 dib_as_bmp length check.

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun thanks, the before/after numbers for read_types are exactly what that change needed.

On the two open questions:

  • Message-only window for OpenClipboard: a follow-up PR after this merges, not here. The finding is real (NULL does not exclude another NULL opener, in any process), and a clipboard window is what browsers use. But it is new Win32 surface that nobody reviewing this PR has asked for, the last maintainer review asked for less cross-operation machinery, not more ("isn't that a concern for userland code?"), and this head is green and waiting on re-review. Please keep the branch and the two-process repro; it makes a good self-contained PR with its own test.
  • dib_as_bmp length check: not needed. It only matters for a bitmap that starts with the three mask values as pixels.

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 Windows 11 aarch64 - test-bun shard 4 of 8, which was cancelled because its agent could not be created ([azure] Operation timed out after 600000ms). No test failed; the other seven arm64 shards and all eight x64 shards passed.

@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

@cirospaciari understood. I push nothing to this branch, and I drop the dib_as_bmp length check.

The window change waits for the merge. It is on robobun/3735dc8f/clipboard-window (ed138e4, one commit on top of 45942c1), and I open the follow-up PR from it after this PR merges. The commit has its own test, which does not depend on timing: the test holds the clipboard open with no window, as a console program does, and calls writeText(). Without the change, writeText() resolves, the text is replaced, and the holder's CloseClipboard fails. With the change, writeText() rejects with NotAllowedError, the holder still owns the open clipboard, and its text is intact. On windows-x64 the test fails before and passes after, and the file has 62 pass and 0 fail.

The two-process repro

Start two copies at the same time, with the arguments a 6000 and b 6000. Each copy prints its counts.

// 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.
@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

@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 src/runtime/image/codecs.rs, inside impl Encoded. It comes from #44086 (a7c73fd).

  • This branch added as_slice (#[cfg(windows)]) above from_owned, and from_owned kept #[allow(dead_code)].
  • Main replaced that attribute on from_owned with #[cfg(any(target_os = "macos", windows))].
  • Resolution: keep as_slice, and take the attribute from main for from_owned.
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):

  • The diff against main is the same as before the merge: 50 files, 6503 insertions, 204 deletions.
  • cargo check --workspace passes for x86_64-pc-windows-msvc, aarch64-apple-darwin, x86_64-unknown-freebsd, aarch64-linux-android and x86_64-unknown-linux-gnu. No warning names a file of this PR. That matters because Say the platform once: real #[cfg] in place of stubs, placeholders, let _ = x and allow(...) #44086 removed the allow(dead_code) escapes, so dead_code now sees all of bun_runtime.
  • test/internal/source-lints passes.
  • Linux debug build, test/js/web/clipboard/clipboard.test.ts: 49 pass, 0 fail. With CI=1 and BUN_JSC_validateExceptionChecks=1: 57 pass, 0 fail. My machine was under heavy load, so I ran the file with a 120 s test timeout.

I could not run Windows this time. The Windows lanes in CI are the check for the Win32 tests after the merge.

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun fix conflicts and rebase

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun adopt again this PR

@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

@cirospaciari done. The branch has no conflict with main now, and I drive this PR again.

  • d28ff32 merges main (a4f1429) into the branch. The one conflict, in src/runtime/image/codecs.rs, has the resolution from my last comment. The checks in that comment are for this exact tree.
  • I merged and did not rebase. The branch has 122 commits that are not on main, and 23 of them are merges of main. A rebase replays the other 99 without the conflict resolutions that those merges hold, and it needs a force push over the history that your checkout has. The PR is squash-merged, so the commit on main is the same in both cases. If you want a linear branch all the same, tell me and I do it.
  • Windows is the one platform I could not run for this merge. I read the Windows lanes of the CI run for d28ff32 when they finish.

The window change for OpenClipboard stays a follow-up PR after the merge, as you decided.

@claude claude Bot left a comment

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.

Code review found no issues

No high-confidence issues detected in this change.

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.
@robobun

robobun commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

@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 spawn_sync has no timeout. Commits 2 and 3 give spawn_sync a timeout and remove the watchdog. Two shapes end the hang of defect 2:

A: time limit in the spawn (pushed) B: kill -9 "$c" in the watchdog string
Size process.rs +85/-6, clipboard.rs +117/-69 1 line
Helper that ignores SIGTERM ended ended (I checked it on d28ff32)
PATH with the helpers and no sleep limit works limit never fires
Child of the helper holds a captured socket pair wait ends at the exit of the helper wait never ends
Processes for one helper run 1 4
Test hook bun:internal-for-testing env var that production code reads
Shared code a fourth wait mode in spawn_posix, opt-in, one user none

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

  1. readText() aborts the process on large text. Text of 1 GiB or more that is not all ASCII aborts with no message. Any text of 2 GiB or more aborts with ASSERTION FAILED: string.size() <= String::MaxLength. A stand-in xclip that prints 1 GiB reproduces it (exit 134).
  2. A helper that ignores SIGTERM is never ended. readText() and writeText() never settle, because the watchdog sends only SIGTERM.

The commits

  • 96de433301: readText() rejects text that does not fit a string. It stands alone. With only this commit the file passes (51 pass, 0 fail).
  • 8cfe6b5e8d: opt-in Options::timeout and Options::linux_pdeathsig in sync::spawn.
  • cb80b73428: the backend finds the helper on PATH and runs it directly. BUN_INTERNAL_CLIPBOARD_HELPER_TIMEOUT is gone.
  • e46e2a181b and 94e7f1f07c (added after the first review of the push): SyncStdio::Fd is documented and asserted as for stdin only, the waits in the helper tests have a bound, and the test with a real 1 GiB payload is removed because it is too slow for CI.

What changes for users with A

  • The helper leads its own process group. A signal to the process group of Bun no longer reaches the daemon that xclip or wl-copy leaves behind.
  • An empty PATH finds no helper. The shell searched the working directory.
  • A helper script with no #! line fails with "could not be started". spawn: retry via /bin/sh when exec returns ENOEXEC #31717 adds that retry to the spawn.
  • When Bun is killed, the kernel ends the helper of a read. The hung helper of a write stays, because the limit lives in the Bun process.
  • A read in a worker that is terminated starts no further helper. A write still tries its next helper.

Not in this push

  • JobContext::cancel, which the same review asked for. worker.terminate() still waits for the helper run in flight, up to 10 seconds.
  • The permission model, and navigator.clipboard = mock that now throws. Both are in the PR body under Downsides for a maintainer decision. I changed no code for them.

Verified

  • Linux x64 debug, bun bd test test/js/web/clipboard/clipboard.test.ts at 94e7f1f07c: 62 pass, 31 skip, 0 fail. At cb80b73428 with CI=1 and BUN_JSC_validateExceptionChecks=1: 71 pass, 23 skip, 0 fail. CI build 122957 passed on 94e7f1f07c.
  • Ten of the new test cases fail on d28ff32. Three more pass there and pin a clause of the new code. With the clause removed, each of them fails.
  • bun run rust:check-all for Windows x64, macOS arm64, FreeBSD x64 and Android arm64: ok. Source lints: 170 pass.
  • Not run by me: Windows and macOS. CI is the check for them.

The measurements and the limits that stay are in the Notes block of the PR body.

@claude claude Bot left a comment

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.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread test/js/web/clipboard/clipboard.test.ts Outdated
Comment thread test/js/web/clipboard/clipboard.test.ts Outdated
Comment thread src/spawn/process.rs Outdated
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.

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

.chain(argv[1..].iter().map(|word| word.as_bytes()))
.map(Box::from)
.collect(),
cwd: Box::from(b".".as_slice()),

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.

🟡 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).

Comment thread src/jsc/bindings/ZigGlobalObject.cpp

This branch was successfully deployed

1 active (outdated) deployment
staging - docs — 2368e166 Deployed Aug 12, 2026 by mintlify[bot]
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.

6 participants