Skip to content

Worker: swap process.env for SHARE_ENV only after the worker thread is spawned - #45433

Draft
robobun wants to merge 1 commit into
robobun/20c50f58/worker-transfer-after-validationfrom
robobun/d95ff96e/share-env-swap-after-transfer
Draft

robobun wants to merge 1 commit into
robobun/20c50f58/worker-transfer-after-validationfrom
robobun/d95ff96e/share-env-swap-after-transfer

Conversation

@robobun

@robobun robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Stacks on #44942.

Problem

  • new Worker() with env: SHARE_ENV replaces process.env and Bun.env even when it throws DataCloneError and starts no thread. Node v26.3.0 keeps process.env. Found in review of Worker: consume the transfer list after every option has validated #44942, not reported.
  • ensureSharedEnvStoreForWorker (JSEnvironmentVariableMap.cpp:907) publishes the env store and swaps process.env. JSWorker.cpp:366 calls it before serialization.

Fix

  • sharedEnvStoreForNewWorker reads process.env into an unpublished store, between serialization and the transfer (a beforeTransfer step in SerializedScriptValue::create).
  • joinSharedEnvTree publishes the store and swaps process.env after Worker::start spawned a thread. It cannot throw.
  • Verified: worker-transfer-list-node.test.ts (Node passes it), worker-transfer-list.test.ts. Worker: consume the transfer list after every option has validated #44942 fails 11 of 17 new tests. Other suites: Notes.
  • Self-reviewed: 38 concerns raised, 31 addressed (Notes).

Background

Downsides

  • The first SHARE_ENV worker that starts still replaces process.env (fix(worker): preserve process.env identity when sharing environments #44371's goal), and now also orphans a reference taken inside a workerData getter.
  • One more SerializedScriptValue::create parameter: +0 to +7 instructions per call, new Worker() +36, .text +1,024 bytes (linux-x64). Maintainers: is the parameter acceptable?
  • A constructor that throws after a thread started still swaps (Notes).
Notes

Repro

const { Worker, SHARE_ENV } = require("node:worker_threads");
const before = process.env;
try {
  new Worker("", { eval: true, env: SHARE_ENV, workerData: () => {} });
} catch (e) {
  console.log(`${e.name} | process.env is the same object: ${process.env === before}`);
}
Runtime Output
Node v26.3.0 DataCloneError | process.env is the same object: true
Bun 1.4.3, main, #44942 DataCloneError | process.env is the same object: false
this branch DataCloneError | process.env is the same object: true

The global constructor does the same (env: Symbol.for("nodejs.worker_threads.SHARE_ENV") or shareEnv: true).

Where it comes from, and how far it goes

Order of the constructor now

  1. Worker::create rejects options (Worker: consume the transfer list after every option has validated #44942).
  2. SerializedScriptValue::create serializes workerData. Getters run here.
  3. sharedEnvStoreForNewWorker returns the store of the tree that the thread is in, or reads process.env into a new store. With a transfer list, create calls it as beforeTransfer, ahead of the existing check that the list did not change. Without one, the constructor calls it after create returns, because nothing was consumed. node:worker_threads always has a transfer list (its own ports).
  4. The transfer list is detached and the ports are disentangled.
  5. Worker::start spawns the thread with the store.
  6. joinSharedEnvTree publishes the store on the context and swaps process.env and Bun.env. The constructor skips it when start spawned no thread (hasExited()).

No step from 3 to 6 runs JavaScript on the thread, so step 6 finds either no tree or the tree of the store (asserted).

Why the read is a step of create

The read can run script and throw. On Windows a worker with an env object has a plain object as process.env, so a getter on it runs in the read, and the main thread reads through the windowsEnv Proxy. On POSIX only a value that a write stored without coercion reaches it (an indexed write does that today). A read after create returns would then throw with the caller's buffers detached, which #44942 removed. A read before serialization would miss what a workerData getter writes.

Constructors that throw and no longer swap

  • workerData or environmentData that cannot be cloned, a getter that throws, a port that is not in transferList.
  • A transfer list that changed during serialization, a failed transferArrayBuffers, disentanglePorts.
  • A thread that the OS refuses. Node keeps the object there too.
  • A constructor that returns a dead Worker because its context stopped (a disposed Bun.ModuleGraph), at birth or during serialization.

process.env that script assigned

The swap still replaces the env property of process, whatever script put there before the constructor, as on #44942. It keeps what script assigns or deletes while the constructor serializes, which the earlier swap also kept. joinSharedEnvTree gets the value of the property from before serialization to tell the two apart. Node keeps every assignment. That is a separate change.

Behaviour against Node v26.3.0 (29 probe scripts, debug builds of #44942 and of this branch)

  • Output lines equal to Node: 4 before, 10 after. Output fields equal to Node: 50 of 104 before, 69 of 104 after.
  • 23 fields move to the Node value. Examples: the identity after each throw above, Bun.env, the key order of process.env after a throw, process.env === before inside a workerData getter, a write that a getter makes through a reference from before the constructor (it now reaches the parent and the worker).
  • 3 fields move away, all after a first SHARE_ENV constructor that starts a worker: a reference or a WeakMap key that a workerData getter takes from process.env is the object from before the swap, and it does not see the worker's writes. Bun 1.4.3 swapped before the getter ran. os.tmpdir() first called inside such a getter keeps the old object the same way.
  • 1 more field changes because a constructor that throws no longer converts the env: a raw object that an indexed write left in process.env stays an object. That write is an existing defect (process.env[5] = "a"; process.env[5] = {} stores the object on Bun 1.4.3).

Measurements (release builds of #44942 729307255af and of this branch, linux-x64. perf and valgrind are not installed here: the counts are gdb single-step counts of one host call on the calling thread, pthread_create not stepped.)

#44942 this branch
instructions, structuredClone({ a: 1 }) 1,669 1,669
instructions, structuredClone(i, { transfer: [] }) 3,560 3,564
instructions, structuredClone(buf, { transfer: [buf] }) 6,305 6,308
instructions, port.postMessage("x") 548 548
instructions, port.postMessage(null, [port]) 4,928 4,931
instructions, postMessage("x") in a worker 506 508
instructions, postMessage(buf, [buf]) in a worker 3,326 3,333
instructions, global new Worker(f) 11,853 11,889
instructions, new Worker(f, { data, transferList }) 23,270 23,277
allocator calls, global new Worker(f) 47 47
allocator calls, { data, transferList } 72 72
allocator calls, node:worker_threads new Worker(f) 77 77
allocator calls, first { env: SHARE_ENV } 130 130
allocator calls, second { env: SHARE_ENV } 77 77
allocator calls, { env: SHARE_ENV, workerData: () => {} } (throws) 134 81
instructions, the same constructor that throws 166,157 119,698
store inserts and publishes, constructor that throws (7-key env, debug builds) 7 and 1 0 and 0
store inserts and publishes, first success with a getter write 8 and 1 8 and 1
store inserts and publishes, second worker 0 and 0 0 and 0
.text (llvm-size -A) 65,784,661 65,785,685 (+1,024)
SerializedScriptValue::create (the shared overload) 20,207 B 20,120 B
the constructor 15,700 B 16,006 B
ensureSharedEnvStoreForWorker / the three functions that replace it 2,074 B 2,364 B
the test-only structuredCloneAdvanced 2,464 B 2,648 B
  • Some calls take a longer allocator path in a sample (a new block, a drain that is scheduled). Each row compares samples on the same path. The node:worker_threads rows have one more allocator call in some samples, when JSC allocates a new WeakBlock for the wrapper.
  • arm64 and Windows were not measured. A stand-in call site with the same parameter classes, compiled by clang for aarch64-linux, arm64-macos, x64-windows and arm64-windows, gains 1 instruction: the store of the null argument.
  • Exception exits after the first detach: 2, as before (disentanglePorts, Worker::start). The read that create does not call as beforeTransfer runs only for an empty transfer list.
  • Store ref and deref: +1 pair for each SHARE_ENV constructor, 0 for the others.

Still open, not changed here

  • A first SHARE_ENV constructor that starts a worker replaces process.env and Bun.env. A reference from before it goes stale. Bun's own holders are os.tmpdir() (src/js/node/os.ts:3), Bun.$ (src/js/builtins/shell.ts:262), Bun.SQL (src/js/internal/sql/shared.ts:1620), src/js/internal/html.ts:10, the --trace-env Proxy, and import { env } from "node:process". fix(worker): preserve process.env identity when sharing environments #44371 has that goal. Its design puts read and enumeration hooks on the process.env of every process.
  • Two constructors throw after a SHARE_ENV thread did start, so process.env is the view: a node:worker_threads subclass whose on() throws (the wrapper adds its listeners after the native constructor), and an outer constructor that throws after one of its workerData getters started a SHARE_ENV worker.
  • bun test --isolate carries the env tree of a file that started a SHARE_ENV worker into the next file. A file whose constructor only threw no longer leaves one.
  • A process.env write whose value coercion starts the first SHARE_ENV worker goes to the object from before the swap.
  • A refused thread still consumes the transfer list, as in Node (Worker: consume the transfer list after every option has validated #44942 lists it, Worker: throw ERR_WORKER_INIT_FAILED when the OS refuses the thread #41675 changes its error).

Open PRs that touch the same lines

Tests

  • worker-transfer-list-node.test.ts uses node:test and Node modules only. Each new row runs in a process of its own (process.execPath -e), because a thread founds a tree once. node --test: 11 pass.
  • 17 new tests. 11 fail on Worker: consume the transfer list after every option has validated #44942: the node constructor that throws (four shapes, then a worker that must get a write made through the reference from before), a getter's write and delete through a reference from before (node and the global Worker), the global Worker that throws (env symbol and shareEnv: true, four shapes), a refused thread (an LD_PRELOAD shim for pthread_create, Linux with a C compiler), a context stopped at birth and during serialization, a worker thread as the founder, and three rows of the beforeTransfer contract (through the test-only structuredCloneAdvanced).
  • 6 pass on Worker: consume the transfer list after every option has validated #44942 too and pin what the new order must keep: an env read that throws leaves the transfer list with the caller (it fails with beforeTransfer disabled), a worker that a getter starts shares the env with the outer worker, a second and a third worker do not swap again (it fails without the early return of joinSharedEnvTree), an assignment to process.env inside a getter stays, --trace-env still shares, beforeTransfer does not run for an empty list.
  • Debug ASAN build: both files pass (36 tests), also under BUN_JSC_validateExceptionChecks=1. Also worker_threads.test.ts (151), structuredClone-classes.test.ts, structured-clone.test.ts, message-channel.test.ts, message-event.test.ts, message-port-pipe.test.ts, worker-postmessage-transfer.test.ts and worker.test.ts, with the CI timeout: 540 pass, 1 fails (worker with event listeners doesn't close event loop 2, a 1 s timer that the debug build of Worker: consume the transfer list after every option has validated #44942 misses too). worker-terminate-funnels.test.ts and worker-terminate-lifetime.test.ts time out on both debug builds here.

Self-review: not done here

  • A test-only switch that fails the next worker spawn, instead of the LD_PRELOAD shim. The shim has a precedent (test/cli/watch/watch.test.ts), and the switch would edit the spawn arm that Worker: throw ERR_WORKER_INIT_FAILED when the OS refuses the thread #41675 rewrites. The row now disables the JSC helper thread that could start while the shim refuses.
  • One table of every exit against every observable, in place of the rows.
  • A Windows row for the main thread (a patched Array.prototype.slice under the Proxy). The worker row covers Windows with a getter.
  • The sibling fixes: Bun's own holders read process.env at use, --isolate drops the tree, node:worker_threads adds its listeners before the thread starts. Each is a separate change.
  • No tracking issue was filed. fix(worker): preserve process.env identity when sharing environments #44371 is the open change for the success path.

…s spawned

The first `new Worker()` with `env: SHARE_ENV` on a thread founds an env tree:
it seeds a store from process.env, publishes the store, and replaces
process.env and Bun.env with a view of the store. The constructor did all of
that before it serialized workerData. A constructor that threw afterwards and
started no thread (DataCloneError, a getter that throws, a thread that the OS
refuses) had already replaced process.env.

Split the founding in two. sharedEnvStoreForNewWorker() reads process.env into
a store that no thread has joined. It runs once workerData is serialized, so a
process.env write made by a workerData getter is in the store. It runs before
the transfer list is consumed, because the read can throw:
SerializedScriptValue::create() takes a step to run before it detaches a
transfer list that is not empty. joinSharedEnvTree() publishes the store and
swaps process.env. It cannot throw, and it runs only after Worker::start()
spawned a thread.

joinSharedEnvTree() keeps what script assigned to process.env while the
constructor ran, as the swap did when it ran before serialization.
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:43 PM PT - Oct 15th, 2026

✅ @robobun, your commit 1a26787aec90f2715330b60c6704d7f0c90d144a passed in Build #126295! 🎉


🧪   To try this PR locally:

bunx bun-pr 45433

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

bun-45433 --bun

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft, stacked on #44942. It stays a draft until #44941 and #44942 have a review.

CI: Buildkite build #126295 passed on 1a26787. The one red check is the helper workflow that searches for related issues. It runs no tests. It also fails on other pull requests opened the same day. A push does not run it again.

How I reproduced it:

const { Worker, SHARE_ENV } = require("node:worker_threads");
const before = process.env;
try {
  new Worker("", { eval: true, env: SHARE_ENV, workerData: () => {} });
} catch (e) {
  console.log(`${e.name} | process.env is the same object: ${process.env === before}`);
}

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant