Repository navigation
Conversation
…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.
Collaborator
Author
|
Updated 8:43 PM PT - Oct 15th, 2026
✅ @robobun, your commit 1a26787aec90f2715330b60c6704d7f0c90d144a passed in 🧪 To try this PR locally: bunx bun-pr 45433That installs a local version of the PR into your bun-45433 --bun |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on #44942.
Problem
new Worker()withenv: SHARE_ENVreplacesprocess.envandBun.enveven when it throwsDataCloneErrorand starts no thread. Node v26.3.0 keepsprocess.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 swapsprocess.env.JSWorker.cpp:366calls it before serialization.Fix
sharedEnvStoreForNewWorkerreadsprocess.envinto an unpublished store, between serialization and the transfer (abeforeTransferstep inSerializedScriptValue::create).joinSharedEnvTreepublishes the store and swapsprocess.envafterWorker::startspawned a thread. It cannot throw.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.Background
SHARE_ENV, a worker and its parent thread share oneSharedEnvStore. The thread's first such worker creates it and swapsprocess.envfor a view of it.workerDatagetter writes toprocess.env(as in Node), so the read follows serialization. The read can throw, so it precedes the transfer (Worker: consume the transfer list after every option has validated #44942).process.env. Considered founding before the transfer: a refused thread still swaps.Downsides
SHARE_ENVworker that starts still replacesprocess.env(fix(worker): preserve process.env identity when sharing environments #44371's goal), and now also orphans a reference taken inside aworkerDatagetter.SerializedScriptValue::createparameter: +0 to +7 instructions per call,new Worker()+36,.text+1,024 bytes (linux-x64). Maintainers: is the parameter acceptable?Notes
Repro
DataCloneError | process.env is the same object: trueDataCloneError | process.env is the same object: falseDataCloneError | process.env is the same object: trueThe global constructor does the same (
env: Symbol.for("nodejs.worker_threads.SHARE_ENV")orshareEnv: true).Where it comes from, and how far it goes
SHARE_ENVcopies a pointer to the parent's env store (src/node_worker.cc:555), andprocess.envis one object for each environment. Node's one behaviour test,test-worker-process-env-shared.js, covers a worker that starts. No Node test constructs aSHARE_ENVworker that throws.SHARE_ENVworker has started on the thread. The first one that starts still replacesprocess.envandBun.env.Order of the constructor now
Worker::createrejects options (Worker: consume the transfer list after every option has validated #44942).SerializedScriptValue::createserializesworkerData. Getters run here.sharedEnvStoreForNewWorkerreturns the store of the tree that the thread is in, or readsprocess.envinto a new store. With a transfer list,createcalls it asbeforeTransfer, ahead of the existing check that the list did not change. Without one, the constructor calls it aftercreatereturns, because nothing was consumed.node:worker_threadsalways has a transfer list (its own ports).Worker::startspawns the thread with the store.joinSharedEnvTreepublishes the store on the context and swapsprocess.envandBun.env. The constructor skips it whenstartspawned 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
createThe read can run script and throw. On Windows a worker with an
envobject has a plain object asprocess.env, so a getter on it runs in the read, and the main thread reads through thewindowsEnvProxy. On POSIX only a value that a write stored without coercion reaches it (an indexed write does that today). A read aftercreatereturns would then throw with the caller's buffers detached, which #44942 removed. A read before serialization would miss what aworkerDatagetter writes.Constructors that throw and no longer swap
workerDataorenvironmentDatathat cannot be cloned, a getter that throws, a port that is not intransferList.transferArrayBuffers,disentanglePorts.Bun.ModuleGraph), at birth or during serialization.process.envthat script assignedThe swap still replaces the
envproperty ofprocess, 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.joinSharedEnvTreegets 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)
Bun.env, the key order ofprocess.envafter a throw,process.env === beforeinside aworkerDatagetter, a write that a getter makes through a reference from before the constructor (it now reaches the parent and the worker).SHARE_ENVconstructor that starts a worker: a reference or aWeakMapkey that aworkerDatagetter takes fromprocess.envis 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.process.envstays 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
729307255afand of this branch, linux-x64.perfandvalgrindare not installed here: the counts are gdb single-step counts of one host call on the calling thread,pthread_createnot stepped.)structuredClone({ a: 1 })structuredClone(i, { transfer: [] })structuredClone(buf, { transfer: [buf] })port.postMessage("x")port.postMessage(null, [port])postMessage("x")in a workerpostMessage(buf, [buf])in a workernew Worker(f)new Worker(f, { data, transferList })new Worker(f){ data, transferList }node:worker_threadsnew Worker(f){ env: SHARE_ENV }{ env: SHARE_ENV }{ env: SHARE_ENV, workerData: () => {} }(throws).text(llvm-size -A)SerializedScriptValue::create(the shared overload)ensureSharedEnvStoreForWorker/ the three functions that replace itstructuredCloneAdvancednode:worker_threadsrows have one more allocator call in some samples, when JSC allocates a newWeakBlockfor the wrapper.disentanglePorts,Worker::start). The read thatcreatedoes not call asbeforeTransferruns only for an empty transfer list.SHARE_ENVconstructor, 0 for the others.Still open, not changed here
SHARE_ENVconstructor that starts a worker replacesprocess.envandBun.env. A reference from before it goes stale. Bun's own holders areos.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-envProxy, andimport { 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 theprocess.envof every process.SHARE_ENVthread did start, soprocess.envis the view: anode:worker_threadssubclass whoseon()throws (the wrapper adds its listeners after the native constructor), and an outer constructor that throws after one of itsworkerDatagetters started aSHARE_ENVworker.bun test --isolatecarries the env tree of a file that started aSHARE_ENVworker into the next file. A file whose constructor only threw no longer leaves one.process.envwrite whose value coercion starts the firstSHARE_ENVworker goes to the object from before the swap.Open PRs that touch the same lines
joinSharedEnvTreenow runs after the transfer and after the spawn. It must not throw. bun test: store each file's path into process.argv[1] in place #40280 puts an exception exit on its rebind lines, and fix(worker): preserve process.env identity when sharing environments #44371 rewrites them.ensureSharedEnvStoreForWorker, which this PR replaces. It can call the two halves in sequence.Failed to spawn worker thread.JSWorker.cpp.Tests
worker-transfer-list-node.test.tsusesnode:testand 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.envsymbol andshareEnv: true, four shapes), a refused thread (anLD_PRELOADshim forpthread_create, Linux with a C compiler), a context stopped at birth and during serialization, a worker thread as the founder, and three rows of thebeforeTransfercontract (through the test-onlystructuredCloneAdvanced).beforeTransferdisabled), 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 ofjoinSharedEnvTree), an assignment toprocess.envinside a getter stays,--trace-envstill shares,beforeTransferdoes not run for an empty list.BUN_JSC_validateExceptionChecks=1. Alsoworker_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.tsandworker.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.tsandworker-terminate-lifetime.test.tstime out on both debug builds here.Self-review: not done here
LD_PRELOADshim. 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.Array.prototype.sliceunder the Proxy). The worker row covers Windows with a getter.process.envat use,--isolatedrops the tree,node:worker_threadsadds its listeners before the thread starts. Each is a separate change.