diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 04c1c4ca808b..013c848511ba 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -108,7 +108,12 @@ static JSValue constructWebViewObject(VM& vm, JSObject* bunObject); static JSValue constructEnvObject(VM& vm, JSObject* object) { - return uncheckedDowncast(object->globalObject())->processEnvObject(); + // 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(); + if (scope.exception()) [[unlikely]] + return {}; + 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..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,10 +2640,6 @@ static JSValue constructReportObjectComplete(VM& vm, Zig::GlobalObject* globalOb return workers; }; - auto constructEnvironmentVariables = [&]() -> JSC::JSValue { - return globalObject->processEnvObject(); - }; - auto constructCpus = [&]() -> JSC::JSValue { JSC::JSObject* cpus = JSC::constructEmptyArray(globalObject, nullptr); RETURN_IF_EXCEPTION(scope, {}); @@ -2686,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); @@ -2772,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); @@ -3217,15 +3218,11 @@ 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. + // Propagates (returns empty with the exception pending); see callLazyProcessBuilder. 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(); - } + JSObject* env = globalObject->processEnvObject(); + if (scope.exception()) [[unlikely]] + return {}; return env; } @@ -4271,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]] { @@ -4406,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(); @@ -4446,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/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..b8ab26000fee 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); @@ -1044,7 +1045,9 @@ JSValue 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; @@ -1073,14 +1076,16 @@ JSValue 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, {}); - object->putDirectIndex(globalObject, *index, value, 0, PutDirectIndexLikePutDirect); - RETURN_IF_EXCEPTION(scope, {}); + RETURN_IF_EXCEPTION(scope, nullptr); + // 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; } @@ -1135,25 +1140,18 @@ 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); + 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..c14fb8798711 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -53,7 +53,8 @@ class JSEnvironmentVariableMap final : public JSC::JSNonFinalObject { } }; -JSC::JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); +// 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 // 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..2075c9fffc5c 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,22 @@ 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); + // 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); + 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..94e1b16fe9bd 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -356,7 +356,8 @@ 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); } + // 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); } uint8_t drainMicrotasks(); @@ -563,7 +564,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..7b94ae45407f 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -271,8 +271,9 @@ 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 { + // Null (nothing to copy) unless process.env was ever built. + 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..8f222bcae732 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"); @@ -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(); @@ -1438,6 +1480,173 @@ 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" }, + }); + }); + + // 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 () => {