From ab6165fa9b040faa6863e9af0bcabc3f1c7fae90 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:48:33 +0000 Subject: [PATCH 1/7] process.env: build the env object through a fallible accessor instead of a LazyProperty initializer On Windows createEnvironmentVariablesMap finishes the map by calling the windowsEnv builtin, so it can throw (first read near the stack limit, or re-entered through Bun.inspect.custom). The m_processEnvObject LazyProperty initializer could not report that: it ended in LazyProperty::set's RELEASE_ASSERT (or, when re-entered, in setUpStaticFunctionSlot's), which on Windows release builds is abort() -> __fastfail, so the process exited with 0xC0000409 and no output. Bun.$ reads process.env and bun:sql reads Bun.env while being created, so first-touching either near the stack limit died the same way. m_processEnvObject is now a WriteBarrier filled in by GlobalObject::processEnvObject(), which returns null with the exception pending and caches nothing on failure, so the read throws and the next read builds the real env. The process.env builder propagates instead of clearing and reifying undefined, and the remaining consumers check for the exception. --- src/jsc/bindings/BunObject.cpp | 5 +- src/jsc/bindings/BunProcess.cpp | 20 +-- .../BunProcessReportObjectWindows.cpp | 3 +- src/jsc/bindings/ImportMetaObject.cpp | 5 +- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 28 ++-- src/jsc/bindings/JSEnvironmentVariableMap.h | 5 +- src/jsc/bindings/JSPropertyIterator.cpp | 32 +++-- src/jsc/bindings/ZigGlobalObject.cpp | 24 +++- src/jsc/bindings/ZigGlobalObject.h | 7 +- src/jsc/bindings/webcore/JSWorker.cpp | 6 +- test/js/node/process/process.test.js | 127 +++++++++++++++++- 11 files changed, 205 insertions(+), 57 deletions(-) diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 04c1c4ca808b..21aa33c6de85 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -108,7 +108,10 @@ static JSValue constructWebViewObject(VM& vm, JSObject* bunObject); static JSValue constructEnvObject(VM& vm, JSObject* object) { - return uncheckedDowncast(object->globalObject())->processEnvObject(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* env = uncheckedDowncast(object->globalObject())->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); + return env; } JSC::EncodedJSValue flattenArrayOfBuffersIntoArrayBufferOrUint8Array(JSGlobalObject* lexicalGlobalObject, JSValue arrayValue, size_t maxLength, bool asUint8Array) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index dd5b7bda072f..8a8d255b7be8 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2636,7 +2636,9 @@ static JSValue constructReportObjectComplete(VM& vm, Zig::GlobalObject* globalOb }; auto constructEnvironmentVariables = [&]() -> JSC::JSValue { - return globalObject->processEnvObject(); + JSC::JSObject* env = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); + return env; }; auto constructCpus = [&]() -> JSC::JSValue { @@ -3217,15 +3219,13 @@ static JSValue constructRevision(VM& vm, JSObject* processObject) static JSValue constructEnv(VM& vm, JSObject* processObject) { auto* globalObject = uncheckedDowncast(processObject->globalObject()); - // Lazy property builder: exceptions must not propagate into - // reifyStaticProperty, which performs no exception check. - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSValue env = globalObject->processEnvObject(); - if (auto* exception = scope.exception()) [[unlikely]] { - (void)scope.tryClearException(); - Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); - return JSC::jsUndefined(); - } + // Returning empty with the exception pending makes the read throw and + // leaves `env` unreified, so the next read builds it (same as Bun.$ and + // Bun.env). Clearing and returning undefined would pin process.env to + // undefined for the rest of the process after one failed build. + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* env = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); return env; } diff --git a/src/jsc/bindings/BunProcessReportObjectWindows.cpp b/src/jsc/bindings/BunProcessReportObjectWindows.cpp index 6d6e4428f3e6..71d10c757e26 100644 --- a/src/jsc/bindings/BunProcessReportObjectWindows.cpp +++ b/src/jsc/bindings/BunProcessReportObjectWindows.cpp @@ -396,8 +396,9 @@ JSValue constructReportObjectWindows(VM& vm, Zig::GlobalObject* globalObject, Pr RETURN_IF_EXCEPTION(scope, {}); // Environment variables - report->putDirect(vm, Identifier::fromString(vm, "environmentVariables"_s), globalObject->processEnvObject(), 0); + JSObject* env = globalObject->processEnvObject(); RETURN_IF_EXCEPTION(scope, {}); + report->putDirect(vm, Identifier::fromString(vm, "environmentVariables"_s), env, 0); return report; } diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index d01ce5d6802c..15aa05b8f51e 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -516,7 +516,10 @@ JSC_DEFINE_CUSTOM_SETTER(jsImportMetaObjectSetter_require, (JSGlobalObject * jsG JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_env, (JSGlobalObject * jsGlobalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName)) { auto* globalObject = uncheckedDowncast(jsGlobalObject); - return JSValue::encode(globalObject->m_processEnvObject.getInitializedOnMainThread(globalObject)); + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + JSObject* env = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(env); } static const HashTableValue ImportMetaObjectPrototypeValues[] = { diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index a67acad614b7..855a6d081958 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -924,6 +924,7 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb // Founding a new tree. processEnvObject() forces the lazy init so the OS // environment is captured before the swap below. JSObject* envObject = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, nullptr); if (!envObject->staticPropertiesReified()) { envObject->reifyAllStaticProperties(globalObject); RETURN_IF_EXCEPTION(scope, nullptr); @@ -979,7 +980,7 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb return store; } -JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) +JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); @@ -998,7 +999,7 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) } JSArray* keyArray = constructEmptyArray(globalObject, nullptr, count); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, nullptr); #else auto* structure = JSEnvironmentVariableMap::createStructure(vm, globalObject, globalObject->objectPrototype()); JSC::JSObject* object = JSEnvironmentVariableMap::create(vm, structure); @@ -1078,9 +1079,9 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) ZigString nameStr = toZigString(name); if (Bun__getEnvValue(globalObject, &nameStr, &valueString)) { JSValue value = jsString(vm, Zig::toStringCopy(valueString)); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, nullptr); object->putDirectIndex(globalObject, *index, value, 0, PutDirectIndexLikePutDirect); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, nullptr); } continue; } @@ -1135,25 +1136,20 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) auto editWindowsEnvVar = JSC::JSFunction::create(vm, globalObject, 0, String("editWindowsEnvVar"_s), jsEditWindowsEnvVar, ImplementationVisibility::Public); JSC::JSFunction* getSourceEvent = JSC::JSFunction::create(vm, globalObject, processObjectInternalsWindowsEnvCodeGenerator(vm), globalObject); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, nullptr); JSC::MarkedArgumentBuffer args; args.append(object); args.append(keyArray); args.append(editWindowsEnvVar); args.append(JSC::JSFunction::create(vm, globalObject, 2, "coerceForWrite"_s, jsProcessEnvCoerceForWrite, ImplementationVisibility::Private)); args.append(JSC::JSFunction::create(vm, globalObject, 1, "resetForDelete"_s, jsProcessEnvResetForDelete, ImplementationVisibility::Private)); - auto clientData = WebCore::clientData(vm); JSC::CallData callData = JSC::getCallData(getSourceEvent); - NakedPtr returnedException = nullptr; - auto result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, getSourceEvent, callData, globalObject->globalThis(), args, returnedException); - RETURN_IF_EXCEPTION(scope, {}); - - if (returnedException) { - throwException(globalObject, scope, returnedException.get()); - return jsUndefined(); - } - - RELEASE_AND_RETURN(scope, result); + // Entering JS here fails with a RangeError when process.env is first read + // close to the stack limit; leave it pending for processEnvObject()'s caller. + JSValue result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, getSourceEvent, callData, globalObject->globalThis(), args); + RETURN_IF_EXCEPTION(scope, nullptr); + // windowsEnv returns `new Proxy(...)`, so a normal return is always an object. + return asObject(result); #else return object; #endif diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.h b/src/jsc/bindings/JSEnvironmentVariableMap.h index 90d0f055e7ee..9cc7458a6881 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -53,7 +53,10 @@ class JSEnvironmentVariableMap final : public JSC::JSNonFinalObject { } }; -JSC::JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); +// Returns null with an exception pending if building the map threw (on Windows +// the map is finished by a JS builtin). Callers go through +// Zig::GlobalObject::processEnvObject(), which caches the result. +JSC::JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); // Setting TZ must make *existing* Date instances recompute local time. JSC's DateCache // reset only clears shared slots; live DateInstances keep a Ref to DateInstanceData diff --git a/src/jsc/bindings/JSPropertyIterator.cpp b/src/jsc/bindings/JSPropertyIterator.cpp index e4de689c18f4..c7aa459502b5 100644 --- a/src/jsc/bindings/JSPropertyIterator.cpp +++ b/src/jsc/bindings/JSPropertyIterator.cpp @@ -56,24 +56,22 @@ extern "C" JSPropertyIterator* Bun__JSPropertyIterator__create(JSC::JSGlobalObje if (object->type() == JSC::ProxyObjectType) [[unlikely]] { // Check if we're actually iterating through the JSEnvironmentVariableMap's proxy. auto* zigGlobal = defaultGlobalObject(globalObject); - if (zigGlobal->m_processEnvObject.isInitialized()) { - if (object == zigGlobal->m_processEnvObject.get(zigGlobal)) { - object->methodTable()->getOwnPropertyNames( - object, - globalObject, - array, - DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, nullptr); - - *count = array.size(); - if (array.size() == 0) { - return nullptr; - } - - auto* iter = JSPropertyIterator::create(vm, array.releaseData()); - iter->isSpecialProxy = true; - return iter; + if (object == zigGlobal->m_processEnvObject.get()) { + object->methodTable()->getOwnPropertyNames( + object, + globalObject, + array, + DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, nullptr); + + *count = array.size(); + if (array.size() == 0) { + return nullptr; } + + auto* iter = JSPropertyIterator::create(vm, array.releaseData()); + iter->isSpecialProxy = true; + return iter; } } #endif diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index ca0613b6d856..9f9e057236f9 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2541,11 +2541,6 @@ void GlobalObject::finishCreation(VM& vm) init.set(toJS(init.owner, globalObject, globalObject->performance().get()).getObject()); }); - m_processEnvObject.initLater( - [](const JSC::LazyProperty::Initializer& init) { - init.set(Bun::createEnvironmentVariablesMap(static_cast(init.owner)).getObject()); - }); - m_processObject.initLater( [](const JSC::LazyProperty::Initializer& init) { auto* globalObject = defaultGlobalObject(init.owner); @@ -3086,6 +3081,25 @@ JSC_DEFINE_CUSTOM_GETTER(functionLazyNavigatorGetter, return JSC::JSValue::encode(static_cast(globalObject)->navigatorObject()); } +JSC::JSObject* GlobalObject::processEnvObject() +{ + if (JSObject* env = m_processEnvObject.get()) + return env; + + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* env = Bun::createEnvironmentVariablesMap(this); + RETURN_IF_EXCEPTION(scope, nullptr); + // The Windows builtin assigns onto an ordinary object, so user code (an + // Object.prototype setter) can run inside the build and read process.env; + // if that built one first, keep it so process.env, Bun.env and + // import.meta.env stay the same object. + if (JSObject* existing = m_processEnvObject.get()) + return existing; + m_processEnvObject.set(vm, this, env); + return env; +} + JSC::GCClient::IsoSubspace* GlobalObject::subspaceForImpl(JSC::VM& vm) { return WebCore::subspaceForImpl( diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 35a8a4f69ec6..6d89e25527f8 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -356,7 +356,10 @@ class GlobalObject : public Bun::GlobalScope { void forbidExecution(); Bun::Process* processObject() const { return m_processObject.getInitializedOnMainThread(this); } - JSC::JSObject* processEnvObject() const { return m_processEnvObject.getInitializedOnMainThread(this); } + // Builds process.env on first use. On Windows that runs a JS builtin, which can + // throw (stack overflow, clobbered globals); then this returns nullptr with the + // exception pending and caches nothing, so the next access tries again. + JSC::JSObject* processEnvObject(); JSC::JSObject* bunObject() const { return m_bunObject.getInitializedOnMainThread(this); } uint8_t drainMicrotasks(); @@ -563,7 +566,7 @@ class GlobalObject : public Bun::GlobalScope { \ V(public, Bun::JSMockModule, mockModule) \ \ - V(public, LazyPropertyOfGlobalObject, m_processEnvObject) \ + V(public, WriteBarrier, m_processEnvObject) \ \ V(public, LazyPropertyOfGlobalObject, m_JSS3FileStructure) \ V(public, LazyPropertyOfGlobalObject, m_S3ErrorStructure) \ diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 4a0e24e5280c..3d402e5e3759 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -271,8 +271,10 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: if (envValue && envValue.isCell()) { envObject = dynamicDowncast(envValue); - } else if (globalObject->m_processEnvObject.isInitialized()) { - envObject = globalObject->processEnvObject(); + } else { + // Copy process.env only if it was ever built (and so possibly + // modified); otherwise the worker reads the environment itself. + envObject = globalObject->m_processEnvObject.get(); } if (envObject) { diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index b2e793ed3f77..3116dcc5538f 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1,7 +1,7 @@ import { spawnSync, which } from "bun"; import { describe, expect, it } from "bun:test"; import { familySync } from "detect-libc"; -import { bunEnv, bunExe, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isDebug, isMacOS, isWindows, tempDir, tmpdirSync } from "harness"; import { basename, join, resolve } from "path"; const process_sleep = resolve(import.meta.dir, "process-sleep.js"); @@ -1438,6 +1438,131 @@ describe.concurrent(() => { delete process.env.BUN_TEST_ENV_PROXY; } }); + + // Windows finishes building process.env by calling the windowsEnv JS + // builtin (POSIX builds the map without entering JS), so a first read with + // almost no stack left throws a RangeError out of the builder. That used to + // hit the RELEASE_ASSERT in the LazyProperty initializer holding the env, + // which on Windows exits with STATUS_STACK_BUFFER_OVERRUN (0xC0000409) and + // prints nothing; Bun.$ reads process.env and bun:sql reads Bun.env while + // being created, so first-touching them died the same way. The read has to + // throw and the next one has to build the real env, so the child retries the + // entry point at every depth while unwinding and then checks the env. + async function firstReadNearStackLimitBuildsRealEnv(entryPoint) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const entryPoint = process.argv[1]; + let reached = false; + const readers = { + "process.env": () => { reached = true; return process.env; }, + "Bun.env": () => { reached = true; return Bun.env; }, + "import.meta.env": () => { reached = true; return import.meta.env; }, + "Bun.$": () => { reached = true; return Bun.$; }, + "Bun.sql": () => { reached = true; return Bun.sql; }, + }; + const read = readers[entryPoint]; + let value; + let readThrew = 0; + function recurse() { + try { + recurse(); + } catch {} + if (value !== undefined) return; + reached = false; + try { + value = read(); + } catch { + // Only count throws from the read itself, not from failing to + // enter the reader at the very bottom of the stack. + if (reached) readThrew++; + } + } + recurse(); + const result = { + retried: readThrew > 0, + type: typeof value, + envIntact: process.env.BUN_TEST_LAZY_ENV === "lazy-env-value", + sameObject: Bun.env === process.env && import.meta.env === process.env, + }; + if (entryPoint.endsWith("env")) result.isEnv = value === process.env; + if (entryPoint === "Bun.$") result.echo = (await value({ raw: ["echo $BUN_TEST_LAZY_ENV"] }).text()).trim(); + console.log(JSON.stringify(result));`, + entryPoint, + ], + env: { ...bunEnv, BUN_TEST_LAZY_ENV: "lazy-env-value" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const expected = { retried: true, envIntact: true, sameObject: true }; + if (entryPoint.endsWith("env")) Object.assign(expected, { type: "object", isEnv: true }); + else if (entryPoint === "Bun.$") Object.assign(expected, { type: "function", echo: "lazy-env-value" }); + else Object.assign(expected, { type: "function" }); + expect({ stderr, exitCode, result: stdout && JSON.parse(stdout) }).toEqual({ + stderr: "", + exitCode: 0, + result: expected, + }); + } + + it.each(["process.env", "import.meta.env"])( + "%s first read near the stack limit throws and the next read builds the real env", + firstReadNearStackLimitBuildsRealEnv, + ); + + // These three are looked up on the Bun object, and the env builder reifies + // Bun.inspect (transitioning Bun) before the point where it can throw, so + // assertion builds trip the stale-structure ASSERT in Structure::storedPrototype + // that the WebKit change in #37001 removes. Release builds (what CI runs on + // Windows) are unaffected. + it.skipIf(isDebug).each(["Bun.env", "Bun.$", "Bun.sql"])( + "%s first read near the stack limit throws and the next read builds the real env", + firstReadNearStackLimitBuildsRealEnv, + ); + + // The windowsEnv builtin assigns toJSON onto an ordinary object, so a setter + // on Object.prototype runs user code in the middle of the env build and can + // read process.env, re-entering it. The inner read used to come back empty + // without an exception, which is another RELEASE_ASSERT ("did not produce a + // property") and the same 0xC0000409 exit. Now the inner read builds the env + // and the outer one adopts it instead of installing a second object. + it("env build re-entered from user code yields one env object", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let inner; + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + set(fn) { + // Remove the trap first: the inner build assigns toJSON too. + delete Object.prototype.toJSON; + inner = process.env; + Object.defineProperty(this, "toJSON", { value: fn, writable: true, configurable: true, enumerable: true }); + }, + }); + const outer = Bun.env; + console.log(JSON.stringify({ + reentered: inner !== undefined, + oneObject: outer === inner && process.env === inner && import.meta.env === inner, + envIntact: inner.BUN_TEST_LAZY_ENV, + json: JSON.parse(JSON.stringify(process.env)).BUN_TEST_LAZY_ENV, + }));`, + ], + env: { ...bunEnv, BUN_TEST_LAZY_ENV: "lazy-env-value" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode, result: stdout && JSON.parse(stdout) }).toEqual({ + stderr: "", + exitCode: 0, + result: { reentered: true, oneObject: true, envIntact: "lazy-env-value", json: "lazy-env-value" }, + }); + }); } it("catches exceptions with process.setUncaughtExceptionCaptureCallback", async () => { From 2add110dcde052ac01881c4d2f3eac96f2aeef68 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:10:01 +0000 Subject: [PATCH 2/7] process.env builder: keep a TopExceptionScope so bulk reification stays clean under validateExceptionChecks reifyAllStaticProperties runs the process builders back to back without an exception check in between; the simulated throw a ThrowScope leaves behind made the next builder's TopExceptionScope fail exception check validation (delete process._fatalException on the ASAN lane). Check and return empty with the exception still pending instead. --- src/jsc/bindings/BunProcess.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 8a8d255b7be8..0e7d7f8b5a2e 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3219,13 +3219,17 @@ static JSValue constructRevision(VM& vm, JSObject* processObject) static JSValue constructEnv(VM& vm, JSObject* processObject) { auto* globalObject = uncheckedDowncast(processObject->globalObject()); - // Returning empty with the exception pending makes the read throw and - // leaves `env` unreified, so the next read builds it (same as Bun.$ and - // Bun.env). Clearing and returning undefined would pin process.env to - // undefined for the rest of the process after one failed build. - auto scope = DECLARE_THROW_SCOPE(vm); + // On failure return empty and leave the exception pending: the read throws + // and `env` stays unreified, so the next read builds it (like Bun.$). Caching + // undefined instead would pin process.env to undefined for the whole process. + // TopExceptionScope like the sibling builders: reifyAllStaticProperties runs + // builders back to back with no exception check in between, and the simulated + // throw a ThrowScope leaves behind fails the next builder's scope under + // BUN_JSC_validateExceptionChecks (`delete process._fatalException`). + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSObject* env = globalObject->processEnvObject(); - RETURN_IF_EXCEPTION(scope, {}); + if (scope.exception()) [[unlikely]] + return {}; return env; } From 3366b07611d1451ad80dc00101b2688834c6f5e6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:23:18 +0000 Subject: [PATCH 3/7] Trim comments --- src/jsc/bindings/BunProcess.cpp | 11 ++++------- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 2 -- src/jsc/bindings/JSEnvironmentVariableMap.h | 4 +--- src/jsc/bindings/ZigGlobalObject.cpp | 6 ++---- src/jsc/bindings/ZigGlobalObject.h | 4 +--- src/jsc/bindings/webcore/JSWorker.cpp | 3 +-- 6 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 0e7d7f8b5a2e..f0515ac97a08 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3219,13 +3219,10 @@ static JSValue constructRevision(VM& vm, JSObject* processObject) static JSValue constructEnv(VM& vm, JSObject* processObject) { auto* globalObject = uncheckedDowncast(processObject->globalObject()); - // On failure return empty and leave the exception pending: the read throws - // and `env` stays unreified, so the next read builds it (like Bun.$). Caching - // undefined instead would pin process.env to undefined for the whole process. - // TopExceptionScope like the sibling builders: reifyAllStaticProperties runs - // builders back to back with no exception check in between, and the simulated - // throw a ThrowScope leaves behind fails the next builder's scope under - // BUN_JSC_validateExceptionChecks (`delete process._fatalException`). + // Empty with the exception left pending: the read throws and `env` stays + // unreified for the next read (like Bun.$). Not a ThrowScope: its simulated + // throw fails the next builder under validateExceptionChecks, since + // reifyAllStaticProperties does not check between builders. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSObject* env = globalObject->processEnvObject(); if (scope.exception()) [[unlikely]] diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 855a6d081958..188572864104 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -1144,8 +1144,6 @@ JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) args.append(JSC::JSFunction::create(vm, globalObject, 2, "coerceForWrite"_s, jsProcessEnvCoerceForWrite, ImplementationVisibility::Private)); args.append(JSC::JSFunction::create(vm, globalObject, 1, "resetForDelete"_s, jsProcessEnvResetForDelete, ImplementationVisibility::Private)); JSC::CallData callData = JSC::getCallData(getSourceEvent); - // Entering JS here fails with a RangeError when process.env is first read - // close to the stack limit; leave it pending for processEnvObject()'s caller. JSValue result = JSC::profiledCall(globalObject, JSC::ProfilingReason::API, getSourceEvent, callData, globalObject->globalThis(), args); RETURN_IF_EXCEPTION(scope, nullptr); // windowsEnv returns `new Proxy(...)`, so a normal return is always an object. diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.h b/src/jsc/bindings/JSEnvironmentVariableMap.h index 9cc7458a6881..c14fb8798711 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -53,9 +53,7 @@ class JSEnvironmentVariableMap final : public JSC::JSNonFinalObject { } }; -// Returns null with an exception pending if building the map threw (on Windows -// the map is finished by a JS builtin). Callers go through -// Zig::GlobalObject::processEnvObject(), which caches the result. +// Null with an exception pending if it threw (Windows finishes the map in JS). JSC::JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); // Setting TZ must make *existing* Date instances recompute local time. JSC's DateCache diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 9f9e057236f9..03f8d8f47f18 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3090,10 +3090,8 @@ JSC::JSObject* GlobalObject::processEnvObject() auto scope = DECLARE_THROW_SCOPE(vm); JSObject* env = Bun::createEnvironmentVariablesMap(this); RETURN_IF_EXCEPTION(scope, nullptr); - // The Windows builtin assigns onto an ordinary object, so user code (an - // Object.prototype setter) can run inside the build and read process.env; - // if that built one first, keep it so process.env, Bun.env and - // import.meta.env stay the same object. + // User code run by the Windows builtin (an Object.prototype setter) may have + // read process.env and built one already; every entry point must share that one. if (JSObject* existing = m_processEnvObject.get()) return existing; m_processEnvObject.set(vm, this, env); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 6d89e25527f8..94e1b16fe9bd 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -356,9 +356,7 @@ class GlobalObject : public Bun::GlobalScope { void forbidExecution(); Bun::Process* processObject() const { return m_processObject.getInitializedOnMainThread(this); } - // Builds process.env on first use. On Windows that runs a JS builtin, which can - // throw (stack overflow, clobbered globals); then this returns nullptr with the - // exception pending and caches nothing, so the next access tries again. + // Null with the exception pending if building it threw; nothing is cached then. JSC::JSObject* processEnvObject(); JSC::JSObject* bunObject() const { return m_bunObject.getInitializedOnMainThread(this); } diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 3d402e5e3759..7b94ae45407f 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -272,8 +272,7 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: if (envValue && envValue.isCell()) { envObject = dynamicDowncast(envValue); } else { - // Copy process.env only if it was ever built (and so possibly - // modified); otherwise the worker reads the environment itself. + // Null (nothing to copy) unless process.env was ever built. envObject = globalObject->m_processEnvObject.get(); } From 4162aeca061f15a2f401e09c969c927572e95f40 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:27:57 +0000 Subject: [PATCH 4/7] Single-line comments --- src/jsc/bindings/BunProcess.cpp | 6 ++---- src/jsc/bindings/ZigGlobalObject.cpp | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index f0515ac97a08..b8a556cd01e0 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3219,12 +3219,10 @@ static JSValue constructRevision(VM& vm, JSObject* processObject) static JSValue constructEnv(VM& vm, JSObject* processObject) { auto* globalObject = uncheckedDowncast(processObject->globalObject()); - // Empty with the exception left pending: the read throws and `env` stays - // unreified for the next read (like Bun.$). Not a ThrowScope: its simulated - // throw fails the next builder under validateExceptionChecks, since - // reifyAllStaticProperties does not check between builders. + // Not a ThrowScope: reifyAllStaticProperties runs the next builder without an exception check, which validateExceptionChecks flags as an unchecked simulated throw. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSObject* env = globalObject->processEnvObject(); + // Left pending on purpose: the read throws and `env` stays unreified, so the next read builds it (like Bun.$). if (scope.exception()) [[unlikely]] return {}; return env; diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 03f8d8f47f18..2075c9fffc5c 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3090,8 +3090,7 @@ JSC::JSObject* GlobalObject::processEnvObject() auto scope = DECLARE_THROW_SCOPE(vm); JSObject* env = Bun::createEnvironmentVariablesMap(this); RETURN_IF_EXCEPTION(scope, nullptr); - // User code run by the Windows builtin (an Object.prototype setter) may have - // read process.env and built one already; every entry point must share that one. + // Built re-entrantly if user code run by the Windows builtin read process.env; everyone must get that one. if (JSObject* existing = m_processEnvObject.get()) return existing; m_processEnvObject.set(vm, this, env); From bd56dbb49057660f3cc8b103e2cbe00a466454e1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:43:35 +0000 Subject: [PATCH 5/7] Bun.env builder: same TopExceptionScope shape as the process.env builder --- src/jsc/bindings/BunObject.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 21aa33c6de85..013c848511ba 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -108,9 +108,11 @@ static JSValue constructWebViewObject(VM& vm, JSObject* bunObject); static JSValue constructEnvObject(VM& vm, JSObject* object) { - auto scope = DECLARE_THROW_SCOPE(vm); + // Same shape as constructEnv in BunProcess.cpp: the exception is left pending so the read throws and the property is retried next time. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSObject* env = uncheckedDowncast(object->globalObject())->processEnvObject(); - RETURN_IF_EXCEPTION(scope, {}); + if (scope.exception()) [[unlikely]] + return {}; return env; } From d624c95e2c666eaf52c268ad3ddec2a43628f0ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:52:00 +0000 Subject: [PATCH 6/7] Windows env map: define the key array entries instead of [[Set]] while walking the env table A [[Set]] into an array hole runs an indexed setter installed on Object.prototype, so user code could run in the middle of the walk, while `list` still points into the native env table; a re-entered build that then adds a variable can grow that table. It also dropped the intercepted key. putDirectIndex runs no user code, so the builtin call after the walk is the only point where user code can re-enter the build. --- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 4 +- test/js/node/process/process.test.js | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 188572864104..8f7f9bacfa7a 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -1045,7 +1045,9 @@ JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) // We can't really trust that the OS gives us valid UTF-8 auto name = String::fromUTF8ReplacingInvalidSequences(std::span { chars, len }); #if OS(WINDOWS) - keyArray->putByIndexInline(globalObject, (unsigned)i, jsString(vm, name), false); + // Define, don't [[Set]]: `list` points into the env table until this loop ends, and a [[Set]] can run an indexed setter from Object.prototype. + keyArray->putDirectIndex(globalObject, (unsigned)i, jsString(vm, name)); + RETURN_IF_EXCEPTION(scope, nullptr); #endif if (name == TZ) { hasTZ = true; diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 3116dcc5538f..f1c0beb33471 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1563,6 +1563,48 @@ describe.concurrent(() => { result: { reentered: true, oneObject: true, envIntact: "lazy-env-value", json: "lazy-env-value" }, }); }); + + // The only point where user code may run during the build is the builtin + // call above, after the walk over the native environment table is done. An + // indexed setter on Object.prototype makes every [[Set]] into an array hole + // run user code; the key array used to be filled with [[Set]] while the walk + // still held a pointer into that table, which both ran the setter from inside + // the walk (where a re-entered build may grow the table) and dropped the key. + it("building the env map runs no user code while walking the environment", async () => { + const env = { ...bunEnv, BUN_TEST_LAZY_ENV: "lazy-env-value" }; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let fired = 0; + Object.defineProperty(Object.prototype, "0", { + configurable: true, + set() { + fired++; + process.env; + }, + }); + const env = process.env; + delete Object.prototype[0]; + const keys = new Set(Object.keys(env)); + console.log(JSON.stringify({ + fired, + missing: JSON.parse(process.argv[1]).filter(key => !keys.has(key)), + cached: Bun.env === env, + }));`, + JSON.stringify(Object.keys(env)), + ], + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode, result: stdout && JSON.parse(stdout) }).toEqual({ + stderr: "", + exitCode: 0, + result: { fired: 0, missing: [], cached: true }, + }); + }); } it("catches exceptions with process.setUncaughtExceptionCaptureCallback", async () => { From fbc3079a5d9d390c036ed8ccea597cf3b5619d58 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:40:45 +0000 Subject: [PATCH 7/7] Env build: define index-named variables directly; document the builder throw policy On every platform a variable whose name is an array index was stored with putDirectIndex, which on the exotic POSIX env object goes through our defineOwnProperty -> put -> [[Set]] and so runs an indexed setter installed on Object.prototype while the build is still walking the native env table. If that setter threw, main dereferenced the empty build result (SIGSEGV). Use the base define, which runs no user code, and check for the exception before storing the env object into process.report on POSIX. The callLazyProcessBuilder comment now states both throw policies used by the process builders and why env propagates; the four comments claiming reifyStaticProperty performs no exception check were out of date. --- src/jsc/bindings/BunProcess.cpp | 35 +++++++--------- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 6 ++- test/js/node/process/process.test.js | 42 +++++++++++++++++++ 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index b8a556cd01e0..9404bff6a81b 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -1800,9 +1800,14 @@ static JSValue constructLoadEnvFile(VM& vm, JSObject* processObject) return JSC::JSFunction::create(vm, globalObject, processObjectInternalsLoadEnvFileCodeGenerator(vm), globalObject); } -// Lazy PropertyCallback builders that enter JS. reifyAllStaticProperties wraps these in -// DeferTerminationForAWhile; a non-termination throw is cleared+reported so the worker's -// reifyAllStaticProperties (node:worker_threads preload) doesn't leave a pending exception. +// Throw handling in the lazy process builders. Returning empty with the exception pending +// (constructEnv) reifies nothing, so the read throws and the next read retries; that builder's +// object is shared with Bun.env and import.meta.env and must not be cached in a failed state. +// The builders using the blocks below clear and report instead and reify undefined, so a failure +// inside the worker_threads preload's reifyAllStaticProperties (which defers termination around +// the builders) is reported rather than thrown out of an unrelated delete. Both use a +// TopExceptionScope: that bulk path does not check between builders, so a ThrowScope's simulated +// throw would fail the next builder under validateExceptionChecks. static JSValue callLazyProcessBuilder(VM& vm, JSC::JSGlobalObject* globalObject, JSC::FunctionExecutable* (*generator)(VM&), const JSC::ArgList& args) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); @@ -2635,12 +2640,6 @@ static JSValue constructReportObjectComplete(VM& vm, Zig::GlobalObject* globalOb return workers; }; - auto constructEnvironmentVariables = [&]() -> JSC::JSValue { - JSC::JSObject* env = globalObject->processEnvObject(); - RETURN_IF_EXCEPTION(scope, {}); - return env; - }; - auto constructCpus = [&]() -> JSC::JSValue { JSC::JSObject* cpus = JSC::constructEmptyArray(globalObject, nullptr); RETURN_IF_EXCEPTION(scope, {}); @@ -2688,8 +2687,9 @@ static JSValue constructReportObjectComplete(VM& vm, Zig::GlobalObject* globalOb RETURN_IF_EXCEPTION(scope, {}); report->putDirect(vm, JSC::Identifier::fromString(vm, "workers"_s), constructWorkers(), 0); RETURN_IF_EXCEPTION(scope, {}); - report->putDirect(vm, JSC::Identifier::fromString(vm, "environmentVariables"_s), constructEnvironmentVariables(), 0); + JSC::JSObject* environmentVariables = globalObject->processEnvObject(); RETURN_IF_EXCEPTION(scope, {}); + report->putDirect(vm, JSC::Identifier::fromString(vm, "environmentVariables"_s), environmentVariables, 0); report->putDirect(vm, JSC::Identifier::fromString(vm, "userLimits"_s), constructUserLimits(), 0); RETURN_IF_EXCEPTION(scope, {}); report->putDirect(vm, JSC::Identifier::fromString(vm, "sharedObjects"_s), constructSharedObjects(), 0); @@ -2774,8 +2774,7 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) // v8_use_snapshot: 1 // } // } - // Lazy property builder: exceptions must not propagate into - // reifyStaticProperty, which performs no exception check. + // Clears and reports rather than propagating; see callLazyProcessBuilder. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSC::JSObject* config = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); JSC::JSObject* variables = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); @@ -3219,10 +3218,9 @@ static JSValue constructRevision(VM& vm, JSObject* processObject) static JSValue constructEnv(VM& vm, JSObject* processObject) { auto* globalObject = uncheckedDowncast(processObject->globalObject()); - // Not a ThrowScope: reifyAllStaticProperties runs the next builder without an exception check, which validateExceptionChecks flags as an unchecked simulated throw. + // Propagates (returns empty with the exception pending); see callLazyProcessBuilder. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSObject* env = globalObject->processEnvObject(); - // Left pending on purpose: the read throws and `env` stays unreified, so the next read builds it (like Bun.$). if (scope.exception()) [[unlikely]] return {}; return env; @@ -4270,8 +4268,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_stubFunctionReturningArray, (JSGlobalObject * g static JSValue Process_stubEmptyArray(VM& vm, JSObject* processObject) { - // Lazy property builder: exceptions must not propagate into - // reifyStaticProperty, which performs no exception check. + // Clears and reports rather than propagating; see callLazyProcessBuilder. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSC::JSArray* array = JSC::constructEmptyArray(processObject->globalObject(), nullptr); if (auto* exception = scope.exception()) [[unlikely]] { @@ -4405,8 +4402,7 @@ extern "C" void Bun__Process__queueNextTick2(GlobalObject* globalObject, Encoded // return require.cache.get(Bun.main) static JSValue constructMainModuleProperty(VM& vm, JSObject* processObject) { - // Lazy property builder: exceptions must not propagate into - // reifyStaticProperty, which performs no exception check. + // Clears and reports rather than propagating; see callLazyProcessBuilder. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto* globalObject = defaultGlobalObject(processObject->globalObject()); auto* bun = globalObject->bunObject(); @@ -4445,8 +4441,7 @@ JSValue Process::constructNextTickFn(JSC::VM& vm, Zig::GlobalObject* globalObjec args.append(JSC::JSFunction::create(vm, globalObject, 1, String(), jsFunctionDrainMicrotaskQueue, ImplementationVisibility::Private)); args.append(JSC::JSFunction::create(vm, globalObject, 1, String(), jsFunctionReportUncaughtException, ImplementationVisibility::Private)); - // Lazy property builder: exceptions must not propagate into - // reifyStaticProperty, which performs no exception check. + // Clears and reports rather than propagating; see callLazyProcessBuilder. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSValue nextTickFunction = JSC::profiledCall(globalObject, ProfilingReason::API, initializer, JSC::getCallData(initializer), globalObject->globalThis(), args); if (auto* exception = scope.exception()) [[unlikely]] { diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 8f7f9bacfa7a..b8ab26000fee 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -1076,13 +1076,15 @@ JSObject* createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) // CustomGetterSetter doesn't support indexed properties yet. // This causes strange issues when the environment variable name is an integer. if (chars[0] >= '0' && chars[0] <= '9') [[unlikely]] { - if (auto index = parseIndex(identifier)) { + if (parseIndex(identifier)) { ZigString valueString = { nullptr, 0 }; ZigString nameStr = toZigString(name); if (Bun__getEnvValue(globalObject, &nameStr, &valueString)) { JSValue value = jsString(vm, Zig::toStringCopy(valueString)); RETURN_IF_EXCEPTION(scope, nullptr); - object->putDirectIndex(globalObject, *index, value, 0, PutDirectIndexLikePutDirect); + // The base define, not putDirectIndex: on this exotic object putDirectIndex ends in a [[Set]], which can run an indexed setter from Object.prototype while `list` is still in use. + PropertyDescriptor descriptor(value, 0); + JSObject::defineOwnProperty(object, globalObject, identifier, descriptor, false); RETURN_IF_EXCEPTION(scope, nullptr); } continue; diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index f1c0beb33471..8f222bcae732 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1396,6 +1396,48 @@ describe.concurrent(() => { expect(exitCode).toBe(0); }); + // A variable whose name is an array index ("0=zero") is stored on the env + // object as an indexed property while the build is still walking the native + // env table. Storing it with [[Set]] semantics consulted the prototype chain, + // so an indexed setter on Object.prototype ran user code from inside that walk + // on every platform; when it threw, main died dereferencing the empty result + // of the build. The entry is now defined directly, so the setter never runs and + // the variable still comes through. + it("an index-named variable is stored without running prototype setters during the env build", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let fired = 0; + let caught = null; + Object.defineProperty(Object.prototype, "0", { + configurable: true, + set() { + fired++; + throw new Error("boom"); + }, + }); + try { + process.env; + } catch (e) { + caught = e.message; + } + delete Object.prototype[0]; + const env = process.env; + console.log(JSON.stringify({ fired, caught, zero: env[0], named: env.BUN_TEST_LAZY_ENV, cached: Bun.env === env }));`, + ], + env: { ...bunEnv, "0": "zero", BUN_TEST_LAZY_ENV: "lazy-env-value" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode, result: stdout && JSON.parse(stdout) }).toEqual({ + stderr: "", + exitCode: 0, + result: { fired: 0, caught: null, zero: "zero", named: "lazy-env-value", cached: true }, + }); + }); + if (isWindows) { it("ownKeys trap windows process.env", () => { expect(() => Object.keys(process.env)).not.toThrow();