Skip to content

feat(node:worker_threads): implement markAsUncloneable#29424

Open
Arv1nt3 wants to merge 3 commits into
oven-sh:mainfrom
Arv1nt3:claude/worker_threads-markAsUncloneable
Open

feat(node:worker_threads): implement markAsUncloneable#29424
Arv1nt3 wants to merge 3 commits into
oven-sh:mainfrom
Arv1nt3:claude/worker_threads-markAsUncloneable

Conversation

@Arv1nt3
Copy link
Copy Markdown

@Arv1nt3 Arv1nt3 commented Apr 17, 2026

Summary

Implements worker_threads.markAsUncloneable(obj) to match Node.js behavior. Once marked, obj can no longer cross realms via structuredClone, MessagePort.postMessage, Worker workerData, or BroadcastChannel.postMessage — those paths now throw a DOMException with name === "DataCloneError". Primitives and ArrayBuffer / SharedArrayBuffer / Buffer-like objects are a no-op per spec. The effect is irreversible, and the marker is invisible to Object.keys, JSON.stringify, for-in, and Reflect.ownKeys.

Spec: https://nodejs.org/api/worker_threads.html#workermarkasuncloneableobject
First shipped in Node.js v21.0.0.

Fixes #29423

Why

undici 8.0.3 removed its runtime feature probe for markAsUncloneable (nodejs/undici#4968), and 8.1.0's lib/web/webidl/index.js now calls require("node:worker_threads").markAsUncloneable unconditionally. Every undici constructor that goes through webidl.util.markAsUncloneable(this)CacheStorage, Response, Request, Headers, FormData, WebSocket, EventSource — throws at module-load time on Bun:

TypeError: webidl.util.markAsUncloneable is not a function

This cascades into Next.js / Vitest / jsdom / cheerio apps running on Bun.

Repro before this PR:

$ bun -e 'require("undici/lib/web/cache/cachestorage.js"); new (require("undici")).CacheStorage'
TypeError: webidl.util.markAsUncloneable is not a function

After:

$ ./build/debug/bun-debug -e 'require("undici/lib/web/cache/cachestorage.js"); console.log("ok")'
ok

Implementation

  • Adds a JSC private-name identifier isUncloneable in src/js/builtins/BunBuiltinNames.h.
  • Adds jsFunctionMarkAsUncloneable in src/bun.js/bindings/webcore/Worker.cpp, exposed to the built-in node:worker_threads module via $newCppFunction. No-op on primitives, null, undefined, ArrayBuffer, SharedArrayBuffer, and any JSArrayBufferView (covers Buffer, TypedArrays, DataView). Otherwise it tags the object via putDirect with DontEnum | DontDelete | ReadOnly, so the marker is invisible to userland and cannot be cleared.
  • Adds a single three-line check in CloneSerializer::dumpIfTerminal (in SerializedScriptValue.cpp), placed before the isArray / object-type dispatch. Every structured-clone entry point funnels through this function, so a marked object at the root or nested inside arrays, plain objects, Map values, or Set entries is caught once and surfaced as SerializationReturnCode::DataCloneError, which the existing machinery converts to the appropriate DOMException.
  • src/js/node/worker_threads.ts swaps the throwNotImplemented stub for $newCppFunction("Worker.cpp", "jsFunctionMarkAsUncloneable", 1) and exports it on the default export, so it works via import { markAsUncloneable } from "node:worker_threads", the default export, CommonJS require("node:worker_threads"), and the bare worker_threads specifier.

Test matrix

test/js/node/worker_threads/markAsUncloneable.test.ts — 25 tests, 66 assertions, all passing on a debug ASAN build:

  • Arity, typeof === "function", surface exposure on default export / named / bare specifier / CJS.
  • Returns undefined.
  • No-op on all primitives (null, undefined, number, string, boolean, Symbol, BigInt); accepts functions.
  • structuredClone(marked) throws DataCloneError at the root and when nested inside arrays / plain objects / Map values / Set entries; also when the value itself is an array at the root or nested.
  • MessageChannel.port1.postMessage(marked) throws DataCloneError.
  • BroadcastChannel.postMessage(marked) throws DataCloneError.
  • new Worker(url, { workerData: marked }) throws DataCloneError.
  • ArrayBuffer, SharedArrayBuffer, Buffer, TypedArrays, DataView stay cloneable (spec no-op).
  • Marking is irreversible — a second clone still throws.
  • Marked object is still usable locally (property access / assignment / JSON.stringify).
  • Marker is hidden from Object.keys, JSON.stringify, for-in, Reflect.ownKeys, Object.getOwnPropertyNames, and spread.
  • Child-worker parity: markAsUncloneable works identically inside a worker thread.

test/js/node/worker_threads/markAsUncloneable-undici-regression.test.ts — end-to-end regression that bun installs real undici@^8.1.0 into a tempDir (skips cleanly offline), verifies the installed version from node_modules/undici/package.json, and requires the deep subpath undici/lib/web/cache/cachestorage.js (which is what triggers webidl.util.markAsUncloneable(this)). Asserts stdout is ok and stderr contains no markAsUncloneable is not a function.

test/js/node/worker_threads/worker_threads.test.ts gains markAsUncloneable entries in the module-property assertion list.

Scope

Intentionally tight: only markAsUncloneable. No other worker_threads behavior is modified; #15964 and related stability bugs remain open.

Local verification

Linux x64 (Ubuntu 24): bun run zig:check-all and bun bd OK; bun bd test for markAsUncloneable.test.ts (25/25) and test/regression/issue/29423.test.ts (1/1) OK.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Implements worker_threads.markAsUncloneable(obj) matching Node.js
semantics: once an object is marked, any subsequent structured-clone
attempt (structuredClone, MessagePort.postMessage, Worker workerData,
BroadcastChannel.postMessage) throws a DOMException with
name === "DataCloneError". Primitives and ArrayBuffer /
SharedArrayBuffer / TypedArray / DataView / Buffer are a no-op, matching
the Node spec. Marking is irreversible and the marker is hidden from
Object.keys / JSON / for-in / Reflect via a JSC private-name identifier.

The native hook lives in a single place: CloneSerializer::dumpIfTerminal
in SerializedScriptValue.cpp, which every clone path funnels through, so
marked objects are caught at the root and when nested inside arrays,
objects, Maps, and Sets.

Unblocks undici 8.0.3+ on Bun. Before this change:

    $ bun -e 'require("undici/lib/web/cache/cachestorage.js")'
    TypeError: webidl.util.markAsUncloneable is not a function

After:

    $ bun-debug -e 'require("undici/lib/web/cache/cachestorage.js"); console.log("ok")'
    ok

Spec: https://nodejs.org/api/worker_threads.html#workermarkasuncloneableobject

Fixes oven-sh#29423
@Arv1nt3 Arv1nt3 force-pushed the claude/worker_threads-markAsUncloneable branch from 6725688 to bae0e3e Compare April 17, 2026 23:13
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 17, 2026

Walkthrough

Adds Node's worker_threads.markAsUncloneable support: introduces a builtin private name, a host function that tags objects as uncloneable, and a serialization-time check that causes structured-clone and postMessage paths to throw DataCloneError for tagged objects. Includes API exposure and tests, plus an undici regression test.

Changes

Cohort / File(s) Summary
Builtin Name
src/js/builtins/BunBuiltinNames.h
Added isUncloneable builtin/private identifier and accessors (isUncloneablePublicName / isUncloneablePrivateName).
Serialization Enforcement
src/bun.js/bindings/webcore/SerializedScriptValue.cpp
Added #include "BunBuiltinNames.h" and an early check in CloneSerializer::dumpIfTerminal to reject objects carrying the isUncloneablePrivateName marker with SerializationReturnCode::DataCloneError.
Host Function (impl & decl)
src/bun.js/bindings/webcore/Worker.cpp, src/bun.js/bindings/webcore/Worker.h
Implemented and declared jsFunctionMarkAsUncloneable to tag objects (except ArrayBuffer/ArrayBufferView) with the private isUncloneable flag using non-enumerable, non-configurable, read-only attributes.
API Exposure (TS)
src/js/node/worker_threads.ts
Exported markAsUncloneable binding backed by the new C++ host function.
Unit Tests
test/js/node/worker_threads/markAsUncloneable.test.ts, test/js/node/worker_threads/worker_threads.test.ts
Added extensive tests verifying export shape, no-op behavior for primitives/buffers, DataCloneError on clone/postMessage/worker transfer for marked objects, irreversibility, and marker invisibility to enumeration/serialization.
Regression Test
test/regression/issue/29423.test.ts
Added integration test that installs undici@^8.1.0 and asserts requiring undici webidl modules does not error with markAsUncloneable is not a function.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: implementing markAsUncloneable in node:worker_threads.
Linked Issues check ✅ Passed All code changes fully satisfy #29423 requirements: markAsUncloneable is exported as a function, returns undefined, no-ops on primitives/ArrayBuffer/SharedArrayBuffer/JSArrayBufferView, throws DataCloneError when cloning marked objects, and fixes the undici regression.
Out of Scope Changes check ✅ Passed All changes are narrowly scoped to implementing markAsUncloneable: private-name identifier, JSC host function, serializer hook, TypeScript binding, and comprehensive test coverage; no unrelated modifications.
Description check ✅ Passed PR description is comprehensive and well-structured, covering all required sections: summary of implementation, motivation/why, detailed implementation steps, test coverage, scope, and local verification.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/node/worker_threads/markAsUncloneable-undici-regression.test.ts`:
- Around line 16-17: The TODO indicates this regression test should be moved
into the issue-specific regression folder; move the file currently named
markAsUncloneable-undici-regression.test.ts into
test/regression/issue/29423.test.ts (rename and relocate it) and remove or
update the TODO comment; ensure any imports or test-suite identifiers inside the
file still resolve after the move.

In `@test/js/node/worker_threads/markAsUncloneable.test.ts`:
- Around line 260-264: Add a conditional check to surface stderr when the child
process exitCode is non-zero: immediately before the existing
expect(exitCode).toBe(0) in the test (where stderr, stdout, and exitCode are
asserted), insert an if (exitCode !== 0) { expect(stderr).toBe(""); } so that
failing subprocesses print the full stderr diff; keep the existing assertions on
stderr and stdout intact and place this new guard just prior to the
expect(exitCode).toBe(0) line.
🪄 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: 62a7121f-0e3c-4e9d-a84c-0c94af2a6c54

📥 Commits

Reviewing files that changed from the base of the PR and between b197aac and bae0e3e.

📒 Files selected for processing (8)
  • src/bun.js/bindings/webcore/SerializedScriptValue.cpp
  • src/bun.js/bindings/webcore/Worker.cpp
  • src/bun.js/bindings/webcore/Worker.h
  • src/js/builtins/BunBuiltinNames.h
  • src/js/node/worker_threads.ts
  • test/js/node/worker_threads/markAsUncloneable-undici-regression.test.ts
  • test/js/node/worker_threads/markAsUncloneable.test.ts
  • test/js/node/worker_threads/worker_threads.test.ts

Comment on lines +16 to +17
// TODO(user): after filing the tracking issue, move this file to
// test/regression/issue/<N>.test.ts.
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.

⚠️ Potential issue | 🟠 Major

Move this confirmed regression test under the issue regression folder.

This PR already fixes confirmed issue #29423, so the TODO should not ship here; place this file at test/regression/issue/29423.test.ts instead. As per coding guidelines, test/**/*.test.ts: “For tests related to a specific numbered GitHub Issue, place the test in test/regression/issue/${issueNumber}.test.ts with a real issue number”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/node/worker_threads/markAsUncloneable-undici-regression.test.ts`
around lines 16 - 17, The TODO indicates this regression test should be moved
into the issue-specific regression folder; move the file currently named
markAsUncloneable-undici-regression.test.ts into
test/regression/issue/29423.test.ts (rename and relocate it) and remove or
update the TODO comment; ensure any imports or test-suite identifiers inside the
file still resolve after the move.

Comment thread test/js/node/worker_threads/markAsUncloneable.test.ts Outdated
Comment thread test/js/node/worker_threads/markAsUncloneable.test.ts
- Move the undici regression test to test/regression/issue/29423.test.ts
  (per repo convention for issue-linked tests) and drop the stale TODO.
- In markAsUncloneable.test.ts, build the child worker's file:// URL with
  pathToFileURL(join(dir, "worker.mjs")).href in the parent test scope and
  inject it via JSON.stringify, so paths with spaces or a Windows drive
  letter work correctly and are properly percent-encoded.
- Add `if (exitCode !== 0) expect(stderr).toBe("")` before the final
  exitCode assertion in the child-worker test so a non-zero exit surfaces
  the full stderr diff in CI logs.
- Bump the 29423 regression test's per-test timeout to 120s via the 3rd
  argument to test(...), so a cold-cache `bun install undici@^8.1.0` has
  room to either succeed or hit its existing "install failed → skip"
  branch instead of dying at the default 5s wall.
Verified locally with `bun bd test`:
- markAsUncloneable.test.ts: 25/25 pass (incl. child-worker at 2210ms)
- test/regression/issue/29423.test.ts: 1/1 pass (5921ms, real npm install)
No implementation or behavior changes.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/node/worker_threads/markAsUncloneable.test.ts`:
- Around line 1-274: The test "SharedArrayBuffer is unaffected by
markAsUncloneable (Node spec no-op)" is too strict by using expect(() =>
structuredClone(sab)).not.toThrow() — tighten it to only assert that
structuredClone does not throw a DataCloneError: catch the exception from
structuredClone(sab) (or use expect(...).not.toThrow(/DataCloneError/)) and
assert err?.name !== "DataCloneError"; update the assertion in that test (the
SharedArrayBuffer test that calls structuredClone(sab)) accordingly so unrelated
throws won't make the test flaky.

In `@test/regression/issue/29423.test.ts`:
- Around line 59-64: The current early-return on bun install failure (checking
installExit, installStdout, installStderr around the kUndiciVersion install)
causes the test to silently pass; change this to explicitly skip the test so CI
shows a skip instead of a green pass — for example, replace the return path with
a test.skip(...) or implement a test.skipIf(...) gated by a quick registry
reachability probe, and include the installExit/installStdout/installStderr
details in the skip message; apply the same change to the version-parse skip
branch (the branch around lines handling parsed version validation) so both
failure cases are surfaced as explicit skips rather than silent passes.
🪄 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: 7b52a9fe-fb9b-404f-9778-a30a99cfd15f

📥 Commits

Reviewing files that changed from the base of the PR and between bae0e3e and 389453c.

📒 Files selected for processing (2)
  • test/js/node/worker_threads/markAsUncloneable.test.ts
  • test/regression/issue/29423.test.ts

Comment thread test/js/node/worker_threads/markAsUncloneable.test.ts
Comment on lines +59 to +64
if (installExit !== 0) {
console.warn(
`[markAsUncloneable-undici-regression] Skipping: 'bun install undici@${kUndiciVersion}' failed (exit ${installExit}).\nstdout: ${installStdout}\nstderr: ${installStderr}`,
);
return;
}
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.

🧹 Nitpick | 🔵 Trivial

Silent pass on install failure — consider making the skip visible.

When bun install fails (e.g., offline CI, registry outage, or a real bun install regression), the test returns early and is reported as passed. A silent pass on a regression test can mask the very issue you're guarding against if, say, some future bun install bug starts returning non-zero across the board. Consider using test.skipIf keyed on a reachability probe, or at minimum preserve signal by running a second attempt or downgrading only to test.skip(...) so CI reports show the skip explicitly rather than a green check.

Same applies to the version-parse skip branch (Lines 77-89).

Not blocking — this is a deliberate tradeoff for an npm-network-dependent smoke test, and the console.warn does leave a breadcrumb.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/29423.test.ts` around lines 59 - 64, The current
early-return on bun install failure (checking installExit, installStdout,
installStderr around the kUndiciVersion install) causes the test to silently
pass; change this to explicitly skip the test so CI shows a skip instead of a
green pass — for example, replace the return path with a test.skip(...) or
implement a test.skipIf(...) gated by a quick registry reachability probe, and
include the installExit/installStdout/installStderr details in the skip message;
apply the same change to the version-parse skip branch (the branch around lines
handling parsed version validation) so both failure cases are surfaced as
explicit skips rather than silent passes.

… test

Narrow the "SharedArrayBuffer is unaffected" test from a blanket
expect(() => structuredClone(sab)).not.toThrow() to one that only
fails on DataCloneError, matching the test's own comment.
This follows up on CodeRabbit feedback on oven-sh#29424: if Bun's
structuredClone(SAB) path ever legitimately rejects for an unrelated
reason, the test now stays green as long as the rejection is not a
DataCloneError — which is the only thing markAsUncloneable semantics
actually pin down for SharedArrayBuffer.
Verified locally with:
  bun bd test test/js/node/worker_threads/markAsUncloneable.test.ts
  → 25 pass / 0 fail (66 expect() calls)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant