Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
f8734c7
process: reject partial property descriptors on process.env
cirospaciari Jul 18, 2026
5c8028f
test: quarantine worker-terminate ASAN crashes, matching main
cirospaciari Jul 20, 2026
7ef097b
worker: hold an exception scope while populating process.env
cirospaciari Jul 20, 2026
19227b9
worker: validate execArgv against a real flag policy table (+2 tests)
cirospaciari Jul 22, 2026
608e06c
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 22, 2026
6c0412a
worker: allow Bun run-surface flags in execArgv and NODE_OPTIONS
cirospaciari Jul 22, 2026
4d05af1
ci: keep the binary size allowance on the stack tip [allow size]
cirospaciari Jul 22, 2026
a2c7d8d
Merge ciro/worker-threads-node-tests into claude/process-env-descript…
cirospaciari Jul 22, 2026
6a6c7d2
ci: keep the binary size allowance on the stack tip [allow size]
cirospaciari Jul 22, 2026
2fdb75a
worker: take the JSLock before installing globalThis.gc
cirospaciari Jul 22, 2026
54baff8
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 22, 2026
52f6889
worker: cover the full process.execArgv surface in the execArgv policy
cirospaciari Jul 22, 2026
7f26d4d
ci: rebuild [allow size]
cirospaciari Jul 23, 2026
0bd590d
worker: split glued short-flag values in execArgv like the CLI parser
cirospaciari Jul 23, 2026
b07212e
worker/env: chain short execArgv flags like the CLI parser; no phanto…
cirospaciari Jul 23, 2026
7ce8a68
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 23, 2026
5c7c5b3
worker: chain short flags in NODE_OPTIONS validation too
cirospaciari Jul 23, 2026
4698960
process.env: reject valueless data descriptors and symbol keys like n…
cirospaciari Jul 23, 2026
5798e87
process: normalize glued short flags when building process.execArgv
cirospaciari Jul 23, 2026
11bb858
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 23, 2026
e2ba2d4
process: scope execArgv short-flag normalization to the bun/node entr…
cirospaciari Jul 23, 2026
12b617a
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 23, 2026
31b3953
Delete flaky GC-observation node tests (#35182)
Jarred-Sumner Jul 22, 2026
c545cec
process: close the -br/run gaps in execArgv short-flag normalization
robobun Jul 24, 2026
f359442
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 24, 2026
5e87cc7
clippy: use ? now that push_normalized_short_token returns Option
robobun Jul 24, 2026
d1b410e
worker: share the process.execArgv token builder with the inherit-pat…
robobun Jul 24, 2026
ebcb030
worker: honor prev_takes_value before short-chain normalization; acce…
robobun Jul 24, 2026
6068f57
test: pin the -pe execArgv round-trip
cirospaciari Jul 24, 2026
b1010fb
process.env: stringify defineProperty values like node; isolate SHARE…
robobun Jul 24, 2026
490b699
process.env: coerce defined values to strings; fail preventExtensions…
cirospaciari Jul 24, 2026
f9ac6f4
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 24, 2026
7a8bf0c
worker: only One/Many flags consume the next argv token
cirospaciari Jul 24, 2026
12d92a7
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 24, 2026
4f1552f
windowsEnv: fix defineProperty bookkeeping for always-present accesso…
cirospaciari Jul 24, 2026
1fce00b
test: pin windowsEnv defineProperty bookkeeping for always-present ac…
robobun Jul 24, 2026
01d9f03
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 24, 2026
e8a82b8
test: drop stale bookkeeping for the GC-observation tests deleted by …
robobun Jul 24, 2026
cec7675
test: run the windowsEnv special-accessor defineProperty check in a p…
robobun Jul 24, 2026
f38b64b
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 24, 2026
409d44d
test: drop the orphaned comment block for the deleted tls memleak entry
robobun Jul 24, 2026
42233c1
Merge remote-tracking branch 'origin/ciro/worker-threads-node-tests' …
robobun Aug 3, 2026
d7b446a
worker: use AUTO_PARAMS directly in table_map after #36184 made the s…
robobun Aug 3, 2026
bdfa422
Worker.cpp: guard WebWorker__dispatchError/dispatchErrorWithValue aga…
robobun Aug 3, 2026
cb25e5d
web_worker: clear the pending TerminationException (not just the requ…
robobun Aug 3, 2026
9f100bf
trim comments to <=3 lines, cite spec/node source
robobun Aug 3, 2026
4da9c57
Revert the shutdown clear_termination_exception swap
robobun Aug 3, 2026
9c8c3a4
Merge remote-tracking branch 'origin/ciro/worker-threads-node-tests' …
robobun Aug 3, 2026
462ca19
Merge remote-tracking branch 'origin/ciro/worker-threads-node-tests' …
robobun Aug 3, 2026
e623e6c
Merge remote-tracking branch 'origin/ciro/worker-threads-node-tests' …
robobun Aug 4, 2026
af68afa
clippy: SAFETY comment for us_loop_idle_ns raw call from 388af0e9
robobun Aug 4, 2026
1ade7f3
Merge remote-tracking branch 'origin/ciro/worker-threads-node-tests' …
robobun Aug 4, 2026
21c5d62
Merge branch 'main' into claude/process-env-descriptor-validation
cirospaciari Aug 5, 2026
bd9ab18
Merge branch 'ciro/worker-threads-node-tests' into claude/process-env…
cirospaciari Aug 5, 2026
3bb4d69
Merge branch 'main' into claude/process-env-descriptor-validation
robobun Aug 7, 2026
e75f5ab
Merge branch 'ciro/worker-threads-node-tests' into claude/process-env…
cirospaciari Aug 7, 2026
451b70a
worker_exec_argv: route the = split through strings::index_of_char_usize
cirospaciari Aug 7, 2026
846e516
windows: wrap a worker's env snapshot in the windowsEnv proxy
robobun Aug 7, 2026
8ce6a4c
process.env: refuse preventExtensions on the main-thread map and the …
robobun Aug 7, 2026
dae59d4
retitle the Windows accessor test for the post-merge reject semantics…
robobun Aug 7, 2026
80485f4
Merge branch 'ciro/worker-threads-node-tests' into claude/process-env…
cirospaciari Aug 7, 2026
80f9d48
Merge branch 'ciro/worker-threads-node-tests' into claude/process-env…
cirospaciari Aug 7, 2026
945f6c3
env maps: align symbol-key handling across put/defineProperty and pla…
robobun Aug 7, 2026
c198cfe
Trim comments to node-source/spec references
robobun Aug 7, 2026
18dfe4a
Merge branch 'ciro/worker-threads-node-tests' into claude/process-env…
robobun Aug 7, 2026
bb2f2c4
NODE_OPTIONS: consume a required flag's next token unconditionally
robobun Aug 8, 2026
c52ec62
env: match node's Symbol conversion message on the regular map's put …
robobun Aug 8, 2026
bcf6f06
Merge remote-tracking branch 'origin/ciro/worker-threads-node-tests' …
robobun Aug 16, 2026
9797b85
worker: acknowledge the manufactured TerminationException; stop befor…
robobun Aug 16, 2026
29bfe3b
windows worker env: check and propagate like the POSIX arm instead of…
robobun Aug 16, 2026
c5e31ec
worker: tokenize compile-exec-argv with the same helper process.execA…
robobun Aug 16, 2026
9423635
Merge remote-tracking branch 'origin/ciro/worker-threads-node-tests' …
robobun Aug 16, 2026
df50bae
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 16, 2026
2fe062b
ci: retrigger
robobun Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 98 additions & 6 deletions src/jsc/bindings/JSEnvironmentVariableMap.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include "BunProcess.h"
#include "ScriptExecutionContext.h"
#include "SharedEnvStore.h"
#include "ErrorCode.h"
#include "wtf/NeverDestroyed.h"
#include "WebCoreJSBuiltins.h"

Expand Down Expand Up @@ -392,6 +393,31 @@ static SharedEnvStore* sharedEnvStoreFor(JSC::JSObject* object)
return globalObject ? sharedEnvStoreFor(globalObject) : nullptr;
}

// node rejects anything but a full, fully-permissive data descriptor on
// process.env (src/node_env_var.cc, EnvDefiner). Bun deliberately still accepts
// accessors — see the "does not let the store shadow an accessor defined on
// process.env" test — so only the data-descriptor half of node's rule is
// enforced here: a descriptor carrying a value must spell out writable,
// enumerable and configurable, all true. Accessor and empty descriptors keep
// their existing behaviour. Returns false with an exception pending on reject.
static bool validateEnvPropertyDescriptor(JSC::JSGlobalObject* globalObject, const JSC::PropertyDescriptor& descriptor, JSC::ThrowScope& scope)
{
static constexpr auto dataDescriptorMessage = "'process.env' only accepts a configurable, writable, and enumerable data descriptor"_s;

if (!descriptor.value())
return true;

// A partial data descriptor is rejected even when what it does specify is
// permissive: node requires all three attributes to be present and true.
if (!descriptor.writablePresent() || !descriptor.enumerablePresent() || !descriptor.configurablePresent()
|| !descriptor.writable() || !descriptor.enumerable() || !descriptor.configurable()) {
scope.throwException(globalObject, createError(globalObject, Bun::ErrorCode::ERR_INVALID_OBJECT_DEFINE_PROPERTY, dataDescriptorMessage));
return false;
}

return true;
}

// process.env variant whose reads/writes/deletes/enumeration go through the
// tree's SharedEnvStore; no instance state, so no custom subspace.
class JSSharedEnvMap final : public JSC::JSNonFinalObject {
Expand Down Expand Up @@ -602,6 +628,9 @@ bool JSSharedEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* globalO
VM& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

if (!validateEnvPropertyDescriptor(globalObject, descriptor, scope))
return false;

auto* uid = propertyName.uid();
if (propertyName.isSymbol() || !uid || !descriptor.isDataDescriptor() || !descriptor.value()) {
// The descriptor lands on the Base object, but getOwnPropertySlot reads the
Expand Down Expand Up @@ -742,19 +771,82 @@ RefPtr<SharedEnvStore> ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb
return store;
}

// The ordinary (non-SHARE_ENV) process.env. A plain object apart from
// defineOwnProperty, which node intercepts to reject descriptors that are not
// fully-permissive data descriptors; without a method-table hook the validation
// has nowhere to live, so process.env needs its own class rather than a
// constructEmptyObject().
class JSProcessEnvMap final : public JSC::JSNonFinalObject {
public:
using Base = JSC::JSNonFinalObject;

static constexpr unsigned StructureFlags = Base::StructureFlags;

template<typename CellType, JSC::SubspaceAccess>
static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm)
{
STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSProcessEnvMap, Base);
return &vm.plainObjectSpace();
}

DECLARE_INFO;

static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype)
{
return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info());
}

static JSProcessEnvMap* create(JSC::VM& vm, JSC::Structure* structure)
{
JSProcessEnvMap* ptr = new (NotNull, JSC::allocateCell<JSProcessEnvMap>(vm)) JSProcessEnvMap(vm, structure);
ptr->finishCreation(vm);
return ptr;
}

static bool defineOwnProperty(JSObject* object, JSGlobalObject* globalObject, JSC::PropertyName propertyName, const JSC::PropertyDescriptor& descriptor, bool shouldThrow)
{
VM& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

if (!validateEnvPropertyDescriptor(globalObject, descriptor, scope))
return false;

RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, descriptor, shouldThrow));
}

private:
JSProcessEnvMap(JSC::VM& vm, JSC::Structure* structure)
: Base(vm, structure)
{
}

void finishCreation(JSC::VM& vm)
{
Base::finishCreation(vm);
}
};

const JSC::ClassInfo JSProcessEnvMap::s_info = { "ProcessEnv"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSProcessEnvMap) };

JSObject* createEmptyProcessEnvMap(Zig::GlobalObject* globalObject)
{
VM& vm = globalObject->vm();
return JSProcessEnvMap::create(vm, JSProcessEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()));
}

JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject)
{
VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);

void* list;
size_t count = Bun__getEnvCount(globalObject, &list);
JSC::JSObject* object = nullptr;
if (count < 63) {
object = constructEmptyObject(globalObject, globalObject->objectPrototype(), count);
} else {
object = constructEmptyObject(globalObject, globalObject->objectPrototype());
}
// Unlike the constructEmptyObject() this replaces, the storage is not
// pre-sized to the env count: JSNonFinalObject asserts it has no inline
// storage, so the vars below always land in the butterfly. Only JSFinalObject
Comment thread
cirospaciari marked this conversation as resolved.
Outdated
// gets inline slots, and it is `final` — a defineOwnProperty hook and inline
// storage are mutually exclusive here.
JSC::JSObject* object = JSProcessEnvMap::create(vm, JSProcessEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()));

#if OS(WINDOWS)
JSArray* keyArray = constructEmptyArray(globalObject, nullptr, count);
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/bindings/JSEnvironmentVariableMap.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ JSC::JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject);
// through the SharedEnvStore of the tree its global belongs to.
JSC::JSValue createSharedEnvironmentVariablesMap(Zig::GlobalObject* globalObject);

// Empty process.env for a worker that was handed a snapshot of the spawning
// thread's env: same class as the ordinary map so defineProperty validation
// applies on worker threads too. Caller populates it.
JSC::JSObject* createEmptyProcessEnvMap(Zig::GlobalObject* globalObject);

// Resolve the SHARE_ENV store for a worker spawned from `globalObject`: the
// spawning thread's existing store if it has one, otherwise a fresh store seeded
// from its `process.env` (which is then swapped to a write-through view).
Expand Down
13 changes: 12 additions & 1 deletion src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,10 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client,
if (executionContextId > -1) {
const auto initializeWorker = [&](WebCore::Worker& worker) -> void {
auto& options = worker.options();
// Outermost exception scope: this runs from Rust with no scope on the
// stack, and numeric env keys reach JSProcessEnvMap::defineOwnProperty
// (a throwing path) via putDirectMayBeIndex.
auto catchScope = DECLARE_TOP_EXCEPTION_SCOPE(vm);

if (options.env.has_value()) {
HashMap<String, String> map = *std::exchange(options.env, std::nullopt);
Expand All @@ -562,12 +566,16 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client,
strings.append(jsString(vm, value));
}

auto env = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), size >= JSFinalObject::maxInlineCapacity ? JSFinalObject::maxInlineCapacity : size);
auto env = Bun::createEmptyProcessEnvMap(globalObject);
size_t i = 0;
for (auto k : map) {
// They can have environment variables with numbers as keys.
// So we must use putDirectMayBeIndex to handle that.
env->putDirectMayBeIndex(globalObject, JSC::Identifier::fromString(vm, WTF::move(k.key)), strings.at(i++));
// Numeric keys route through JSProcessEnvMap::defineOwnProperty,
// a throwing path; check between calls to satisfy scope discipline.
if (catchScope.exception()) [[unlikely]]
break;
}
globalObject->m_processEnvObject.set(vm, globalObject, env);
} else if (options.sharedEnvStore) {
Expand All @@ -578,6 +586,9 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client,
globalObject->scriptExecutionContext()->setSharedEnvStore(*store);
globalObject->m_processEnvObject.set(vm, globalObject, Bun::createSharedEnvironmentVariablesMap(globalObject).getObject());
}
// Only fully-permissive data descriptors are defined above, so the
// env hook cannot reject them; nothing here may leave an exception.
catchScope.assertNoException();

// Ensure that the TerminationException singleton is constructed. Workers need this so
// that we can request their termination from another thread. For the main thread, we
Expand Down
18 changes: 18 additions & 0 deletions test/expectations.txt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,24 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ]
# Tests failed due to ASAN: SEGV on unknown address
[ ASAN ] test/integration/next-pages/test/dev-server.test.ts [ CRASH ]

# worker.terminate() lands while a process.* lazy PropertyCallback builder
# (stdout/stderr/stdin/nextTick/mainModule, via setupWorkerStdio) is in JS;
# tryClearException() refuses to clear the TerminationException, so the
# builder returns with it pending and reifyStaticProperty reports the slot
# found, tripping JSC's "ASSERTION FAILED: !scope.exception() || !result"
# in getOwnPropertyDescriptor / JSValue::get. Tracked in #34095; fix PRs
# #33966 and #33418. x64-asan only (e.g. builds 75570, 75601); release
# lanes are unaffected. Remove once either fix PR lands.
[ ASAN ] test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js [ CRASH ] # #34095: JSC assertion when terminate() interrupts a lazy PropertyCallback builder
# The stress test is the bun-owned 8×10-worker amplification of the above,
# but on CI it only ever hits JSC::ExceptionScope::assertNoException at
# ExceptionScope.h:61 (6/6: builds 75493/75495/75514/75597/75604/75606),
# which #33966 reports still reproducing at ~1/4000 workers AFTER its
# lazy-builder fix ("termination landing later in the bootstrap, after the
# stdio builders have completed"). Tracked separately in #34690; this entry
# is NOT removable with the one above.
[ ASAN ] test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts [ CRASH ] # #34690: ExceptionScope::assertNoException during worker terminate bootstrap

# Tests failed due to ASAN: use-after-poison
[ ASAN ] test/js/node/test/parallel/test-worker-unref-from-message-during-exit.js [ CRASH ]
[ ASAN ] test/napi/napi.test.ts [ CRASH ] # can throw an exception from an async_complete_callback
Expand Down
67 changes: 67 additions & 0 deletions test/js/node/test/parallel/test-worker-process-env.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use strict';
const common = require('../common');
const child_process = require('child_process');
const assert = require('assert');
const { Worker, workerData } = require('worker_threads');

// Test for https://github.com/nodejs/node/issues/24947.

if (!workerData && process.argv[2] !== 'child') {
process.env.SET_IN_PARENT = 'set';
assert.strictEqual(process.env.SET_IN_PARENT, 'set');

new Worker(__filename, { workerData: 'runInWorker' })
.on('exit', common.mustCall(() => {
// Env vars from the child thread are not set globally.
assert.strictEqual(process.env.SET_IN_WORKER, undefined);
}));

process.env.SET_IN_PARENT_AFTER_CREATION = 'set';

new Worker(__filename, {
workerData: 'resetEnv',
env: { 'MANUALLY_SET': true }
});

assert.throws(() => {
new Worker(__filename, { env: 42 });
}, {
name: 'TypeError',
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "options.env" property must be of type object or ' +
'one of undefined, null, or worker_threads.SHARE_ENV. Received type ' +
'number (42)'
});
} else if (workerData === 'runInWorker') {
// Env vars from the parent thread are inherited.
assert.strictEqual(process.env.SET_IN_PARENT, 'set');
assert.strictEqual(process.env.SET_IN_PARENT_AFTER_CREATION, undefined);
process.env.SET_IN_WORKER = 'set';
assert.strictEqual(process.env.SET_IN_WORKER, 'set');

assert.throws(
() => {
Object.defineProperty(process.env, 'DEFINED_IN_WORKER', {
value: 42
});
},
{
code: 'ERR_INVALID_OBJECT_DEFINE_PROPERTY',
name: 'TypeError',
message: '\'process.env\' only accepts a configurable, ' +
'writable, and enumerable data descriptor'
}
);


const { stderr } =
child_process.spawnSync(process.execPath, [__filename, 'child']);
assert.strictEqual(stderr.toString(), '', stderr.toString());
} else if (workerData === 'resetEnv') {
assert.deepStrictEqual(Object.keys(process.env), ['MANUALLY_SET']);
assert.strictEqual(process.env.MANUALLY_SET, 'true');
} else {
// Child processes inherit the parent's env, even from Workers.
assert.strictEqual(process.env.SET_IN_PARENT, 'set');
assert.strictEqual(process.env.SET_IN_WORKER, 'set');
}
Loading