diff --git a/src/jsc/Debugger.rs b/src/jsc/Debugger.rs index 14293b00fa98..1a4cc797e286 100644 --- a/src/jsc/Debugger.rs +++ b/src/jsc/Debugger.rs @@ -430,6 +430,7 @@ impl Debugger { // `init` installs the freshly-boxed VM as this thread's singleton. let vm = VirtualMachine::get().as_mut(); + vm.transpiler.options.env.behavior = bun_dotenv::DotEnvBehavior::LoadAllWithoutInlining; vm.transpiler .configure_defines() .unwrap_or_else(|_| panic!("Failed to configure defines")); diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index fc29c4c824fa..dc44695677d1 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -33,6 +33,14 @@ extern "C" bool Bun__getEnvValue(JSGlobalObject* globalObject, const ZigString* extern "C" bool Bun__getEnvValueBunString(JSGlobalObject* globalObject, const BunString* name, BunString* value); extern "C" void Bun__setEnvValue(JSGlobalObject* globalObject, const BunString* name, const BunString* value); +#if !OS(WINDOWS) +extern "C" bool Bun__Process__getOSEnv(JSGlobalObject* globalObject, const BunString* name, BunString* out); +extern "C" bool Bun__Process__setOSEnv(JSGlobalObject* globalObject, const BunString* name, const BunString* value); +extern "C" void Bun__Process__unsetOSEnv(JSGlobalObject* globalObject, const BunString* name); +extern "C" void Bun__Process__enumerateOSEnv(void* ctx, void (*cb)(void*, const unsigned char*, size_t)); +extern "C" void Bun__Process__initOSEnvOverlay(JSGlobalObject* globalObject); +#endif + namespace Bun { using namespace WebCore; @@ -343,24 +351,34 @@ JSC_DEFINE_HOST_FUNCTION(jsEditWindowsEnvVar, (JSGlobalObject * global, JSC::Cal } #endif -// Founding a SHARE_ENV tree swaps main's process.env off the windowsEnv Proxy that -// called SetEnvironmentVariableW, so every mutation of a main-rooted shared store has -// to re-apply that write-through. Gated on the *store*, not the writing thread: node -// roots a main-founded tree at its RealEnvStore, so a worker writing through that tree -// reaches the OS env too. `value == nullptr` deletes. -static ALWAYS_INLINE void syncWindowsEnv(SharedEnvStore* store, const String& key, const String* value) +// Founding a SHARE_ENV tree swaps main's process.env off the object whose +// put()/delete() wrote through to the OS environment (the Windows Proxy's +// SetEnvironmentVariableW or the POSIX JSRealEnvMap's setenv/unsetenv), so +// every mutation of a main-rooted shared store has to re-apply that write- +// through. Gated on the *store*, not the writing thread: node roots a +// main-founded tree at its RealEnvStore, so a worker writing through that tree +// reaches the OS env too. `value == nullptr` deletes. Returns false when the +// native write was attempted and rejected (embedded NUL / `=` in name) so the +// caller can skip updating the SharedEnvStore and avoid a divergence. +static ALWAYS_INLINE bool syncOSEnv(JSGlobalObject* globalObject, SharedEnvStore* store, const String& key, const String* value) { -#if OS(WINDOWS) if (!store || !store->isMainRooted()) - return; + return true; +#if OS(WINDOWS) + UNUSED_PARAM(globalObject); if (value) Bun__Process__editWindowsEnvVar(Bun::toString(key), Bun::toString(*value)); else Bun__Process__editWindowsEnvVar(Bun::toString(key), { .tag = BunStringTag::Dead }); + return true; #else - UNUSED_PARAM(store); - UNUSED_PARAM(key); - UNUSED_PARAM(value); + BunString name = Bun::toString(key); + if (value) { + BunString val = Bun::toString(*value); + return Bun__Process__setOSEnv(globalObject, &name, &val); + } + Bun__Process__unsetOSEnv(globalObject, &name); + return true; #endif } @@ -565,8 +583,8 @@ bool JSSharedEnvMap::put(JSCell* cell, JSGlobalObject* globalObject, PropertyNam String keyStr = String(uid); applySharedEnvSideEffects(globalObject, keyStr, stringValue); - syncWindowsEnv(store, keyStr, &stringValue); - store->set(keyStr, stringValue); + if (syncOSEnv(globalObject, store, keyStr, &stringValue)) + store->set(keyStr, stringValue); return true; } @@ -583,7 +601,7 @@ bool JSSharedEnvMap::deleteProperty(JSCell* cell, JSGlobalObject* globalObject, return Base::deleteProperty(cell, globalObject, propertyName, slot); } - syncWindowsEnv(store, String(uid), nullptr); + syncOSEnv(globalObject, store, String(uid), nullptr); store->remove(String(uid)); // Also drop any own property the Base fallback installed (accessor descriptors). return Base::deleteProperty(cell, globalObject, propertyName, slot); @@ -611,11 +629,14 @@ bool JSSharedEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* globalO // an enumerable data property first: a partial descriptor then keeps that // enumerability, exactly as it does on the regular process.env. (Node rejects // accessors on process.env outright — on both maps — so match bun's own map.) - if (!propertyName.isSymbol() && uid) { + // Only do this for a genuine accessor: Object.freeze/seal pass attribute-only + // descriptors ({writable:false, configurable:false}) for every key and must + // not wipe the store — for a main-rooted tree that would unsetenv every var. + if (!propertyName.isSymbol() && uid && descriptor.isAccessorDescriptor()) { if (auto* store = sharedEnvStoreFor(object)) { String existing = store->get(String(uid)); if (!existing.isNull()) { - syncWindowsEnv(store, String(uid), nullptr); + syncOSEnv(globalObject, store, String(uid), nullptr); store->remove(String(uid)); object->putDirect(vm, propertyName, jsString(vm, existing), 0); } @@ -635,8 +656,8 @@ bool JSSharedEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* globalO String keyStr = String(uid); applySharedEnvSideEffects(globalObject, keyStr, stringValue); - syncWindowsEnv(store, keyStr, &stringValue); - store->set(keyStr, stringValue); + if (syncOSEnv(globalObject, store, keyStr, &stringValue)) + store->set(keyStr, stringValue); return true; } @@ -664,11 +685,206 @@ bool JSSharedEnvMap::deletePropertyByIndex(JSCell* cell, JSGlobalObject* globalO } String keyStr = String::number(index); - syncWindowsEnv(store, keyStr, nullptr); + syncOSEnv(globalObject, store, keyStr, nullptr); store->remove(keyStr); return Base::deletePropertyByIndex(cell, globalObject, index); } +#if !OS(WINDOWS) +// ============================================================================ +// Main-thread POSIX process.env: a live view of libc `environ`. +// +// Node's main-thread process.env is its RealEnvStore: every get/set/delete/ +// enumerate is a live getenv/setenv/unsetenv/environ call. Without this a JS +// `process.env.X = ...` never reaches a native library's `getenv("X")` and a +// native `setenv("Y", ...)` never reaches `process.env.Y` — JS and C run on +// two silently divergent environments for the life of the process. +// +// Workers still use a snapshot (the DotEnv-map-backed object below, or +// SHARE_ENV's JSSharedEnvMap); only the main thread touches `environ`. +class JSRealEnvMap final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + + static constexpr unsigned StructureFlags = Base::StructureFlags + | JSC::OverridesGetOwnPropertySlot + | JSC::InterceptsGetOwnPropertySlotByIndexEvenWhenLengthIsNotZero + | JSC::OverridesPut + | JSC::OverridesGetOwnPropertyNames + | JSC::GetOwnPropertySlotMayBeWrongAboutDontEnum + | JSC::ProhibitsPropertyCaching; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSRealEnvMap, 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 JSRealEnvMap* create(JSC::VM& vm, JSC::Structure* structure) + { + JSRealEnvMap* ptr = new (NotNull, JSC::allocateCell(vm)) JSRealEnvMap(vm, structure); + ptr->finishCreation(vm); + return ptr; + } + + static bool getOwnPropertySlot(JSObject*, JSGlobalObject*, JSC::PropertyName, JSC::PropertySlot&); + static bool put(JSCell*, JSGlobalObject*, JSC::PropertyName, JSC::JSValue, JSC::PutPropertySlot&); + static bool deleteProperty(JSCell*, JSGlobalObject*, JSC::PropertyName, JSC::DeletePropertySlot&); + static bool getOwnPropertySlotByIndex(JSObject*, JSGlobalObject*, unsigned, JSC::PropertySlot&); + static bool putByIndex(JSCell*, JSGlobalObject*, unsigned, JSC::JSValue, bool shouldThrow); + static bool deletePropertyByIndex(JSCell*, JSGlobalObject*, unsigned); + static void getOwnPropertyNames(JSObject*, JSGlobalObject*, JSC::PropertyNameArrayBuilder&, JSC::DontEnumPropertiesMode); + static bool defineOwnProperty(JSObject*, JSGlobalObject*, JSC::PropertyName, const JSC::PropertyDescriptor&, bool shouldThrow); + +private: + JSRealEnvMap(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM& vm) + { + Base::finishCreation(vm); + } +}; + +const JSC::ClassInfo JSRealEnvMap::s_info = { "ProcessEnv"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSRealEnvMap) }; + +bool JSRealEnvMap::getOwnPropertySlot(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot) +{ + VM& vm = JSC::getVM(globalObject); + auto* uid = propertyName.uid(); + if (propertyName.isSymbol() || !uid || uid->isEmpty()) + return Base::getOwnPropertySlot(object, globalObject, propertyName, slot); + + String keyStr = String(uid); + BunString name = Bun::toString(keyStr); + BunString value = { BunStringTag::Dead }; + if (!Bun__Process__getOSEnv(globalObject, &name, &value)) + return Base::getOwnPropertySlot(object, globalObject, propertyName, slot); + + // Bun__Process__getOSEnv writes an owned clone_utf8 (WTFStringImpl at +1); + // transferToWTFString adopts it, toWTFString(ZeroCopy) would ref and leak. + slot.setValue(object, 0, jsString(vm, value.transferToWTFString())); + return true; +} + +bool JSRealEnvMap::put(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* uid = propertyName.uid(); + if (propertyName.isSymbol() || !uid) + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); + + String stringValue = value.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + + String keyStr = String(uid); + applySharedEnvSideEffects(globalObject, keyStr, stringValue); + + BunString name = Bun::toString(keyStr); + BunString val = Bun::toString(stringValue); + Bun__Process__setOSEnv(globalObject, &name, &val); + return true; +} + +bool JSRealEnvMap::deleteProperty(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, DeletePropertySlot& slot) +{ + auto* uid = propertyName.uid(); + if (propertyName.isSymbol() || !uid) + return Base::deleteProperty(cell, globalObject, propertyName, slot); + + String keyStr = String(uid); + BunString name = Bun::toString(keyStr); + Bun__Process__unsetOSEnv(globalObject, &name); + return Base::deleteProperty(cell, globalObject, propertyName, slot); +} + +void JSRealEnvMap::getOwnPropertyNames(JSObject* object, JSGlobalObject* globalObject, PropertyNameArrayBuilder& propertyNames, DontEnumPropertiesMode mode) +{ + VM& vm = JSC::getVM(globalObject); + struct Ctx { + VM& vm; + PropertyNameArrayBuilder& names; + } ctx { vm, propertyNames }; + Bun__Process__enumerateOSEnv(&ctx, [](void* raw, const unsigned char* ptr, size_t len) { + auto& c = *static_cast(raw); + auto key = String::fromUTF8ReplacingInvalidSequences(std::span { ptr, len }); + c.names.add(JSC::Identifier::fromString(c.vm, key)); + }); + Base::getOwnPropertyNames(object, globalObject, propertyNames, mode); +} + +bool JSRealEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, const PropertyDescriptor& descriptor, bool shouldThrow) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* uid = propertyName.uid(); + // Node rejects accessors and partial descriptors on process.env via + // ERR_INVALID_OBJECT_DEFINE_PROPERTY; matching that is a separate behavior + // change with its own tests. Until then: + if (propertyName.isSymbol() || !uid) { + RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, descriptor, shouldThrow)); + } + + if (descriptor.value()) { + PutPropertySlot slot(object, shouldThrow); + RELEASE_AND_RETURN(scope, put(object, globalObject, propertyName, descriptor.value(), slot)); + } + + if (descriptor.isAccessorDescriptor()) { + // A user getter/setter would be shadowed by getOwnPropertySlot's + // getenv() read, so move the environ entry onto the base (enumerable + // data property, so a partial descriptor keeps enumerability) and + // unsetenv the key. + String keyStr = String(uid); + BunString name = Bun::toString(keyStr); + BunString existing = { BunStringTag::Dead }; + if (Bun__Process__getOSEnv(globalObject, &name, &existing)) + object->putDirect(vm, propertyName, jsString(vm, existing.transferToWTFString()), 0); + Bun__Process__unsetOSEnv(globalObject, &name); + RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, descriptor, shouldThrow)); + } + + // Attribute-only descriptor (Object.freeze/seal's {writable:false, + // configurable:false}). The live environ view has no meaningful + // writable/configurable state to change; accept without touching environ. + return true; +} + +bool JSRealEnvMap::getOwnPropertySlotByIndex(JSObject* object, JSGlobalObject* globalObject, unsigned index, PropertySlot& slot) +{ + VM& vm = JSC::getVM(globalObject); + return getOwnPropertySlot(object, globalObject, Identifier::from(vm, index), slot); +} + +bool JSRealEnvMap::putByIndex(JSCell* cell, JSGlobalObject* globalObject, unsigned index, JSValue value, bool shouldThrow) +{ + VM& vm = JSC::getVM(globalObject); + PutPropertySlot slot(cell, shouldThrow); + return put(cell, globalObject, Identifier::from(vm, index), value, slot); +} + +bool JSRealEnvMap::deletePropertyByIndex(JSCell* cell, JSGlobalObject* globalObject, unsigned index) +{ + String keyStr = String::number(index); + BunString name = Bun::toString(keyStr); + Bun__Process__unsetOSEnv(globalObject, &name); + return Base::deletePropertyByIndex(cell, globalObject, index); +} +#endif + JSValue createSharedEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); @@ -749,6 +965,22 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); +#if !OS(WINDOWS) + // Main thread: live `environ` view so JS process.env writes reach native + // getenv() and native setenv() reaches process.env. Workers fall through + // to the DotEnv-snapshot object below (Node gives workers a MapKVStore + // copy, not the RealEnvStore). + auto* context = globalObject->scriptExecutionContext(); + if (context && context->isMainThread()) { + // Record which DotEnv-map keys are `.env`-only so reads can fall back + // to the map for them without masking a native unsetenv of a real + // environ var. + Bun__Process__initOSEnvOverlay(globalObject); + auto* structure = JSRealEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()); + return JSRealEnvMap::create(vm, structure); + } +#endif + void* list; size_t count = Bun__getEnvCount(globalObject, &list); JSC::JSObject* object = nullptr; diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 718c8f01ff96..ed682bd01259 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1001,6 +1001,13 @@ impl WebWorker { // SAFETY: see post-publish note above. unsafe { + // The main-thread entry points (run/test/repl) set this before + // configure_defines(); without it the worker's transpiler inherits + // the `Target::Bun` default of LoadAll and inlines `process.env.X` + // as a string literal from the cloned DotEnv map, so a worker + // spawned with `env: {X: "v"}` reads the *parent's* value instead. + (*vm).transpiler.options.env.behavior = + bun_dotenv::DotEnvBehavior::LoadAllWithoutInlining; if (*vm).transpiler.configure_defines().is_err() { // Fall through to spin() → shutdown() for full teardown under // the API lock (flushLogs runs JS). Set terminate so spin() diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 77180e3042cb..cfdd3cfd795e 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2302,6 +2302,249 @@ pub mod environment_variables { let value = vm.env_loader().get(sliced.slice())?; Some(ZigString::init_utf8(value)) } + + // ─────────────────── live libc `environ` bridge (POSIX) ─────────────────── + // + // Node's main-thread `process.env` is its RealEnvStore: every get/set/ + // delete/enumerate is a live getenv/setenv/unsetenv/environ call. Bun's + // `process.env` previously sat on the DotEnv snapshot taken at startup, so + // a native library's `getenv()` never observed a JS write and a native + // `setenv()` never reached `process.env`. + // + // Bun also auto-loads `.env` files into the DotEnv map. Those keys are NOT + // seeded into `environ` — glibc's setenv is O(n) and a large .env turns + // seeding into O(n^2) — so `process.env` reads fall back to the DotEnv map + // for keys in the overlay set computed below. A JS write promotes the key + // into `environ` and removes it from the overlay, so native getenv then + // sees it. + // + // glibc's setenv/unsetenv are not thread-safe and getenv races with them; + // Node serializes through `per_process::env_var_mutex`. We do the same so + // two JS threads (main + a worker holding a main-rooted SHARE_ENV store) + // cannot corrupt `environ`. A user FFI `setenv` that bypasses this lock is + // outside our control, same as in Node. + + /// Serialises access to libc `environ` itself. Always taken *inside* + /// `vm.proxy_env_storage.lock()` (when both are held) so the DotEnv map + /// and `environ` update atomically with respect to a spawning worker's + /// `clone_with_allocator`. + #[cfg(not(windows))] + static ENVIRON_LOCK: bun_core::Mutex<()> = bun_core::Mutex::new(()); + + /// DotEnv-map keys that are not in `environ` (i.e. values from `.env` + /// files). Populated once when the main-thread `process.env` is created; + /// a JS write/delete removes the key. Always accessed under + /// `ENVIRON_LOCK`. + #[cfg(not(windows))] + static DOTENV_OVERLAY: bun_core::Mutex> = + bun_core::Mutex::new(None); + + /// NUL-terminate `s` into `buf`, rejecting embedded NULs (libc would + /// truncate at them and a later getenv would read back a different value). + #[cfg(not(windows))] + fn make_cstr(buf: &mut Vec, s: &[u8]) -> Option<*const core::ffi::c_char> { + if bun_core::strings::index_of_char(s, 0).is_some() { + return None; + } + buf.clear(); + buf.reserve(s.len() + 1); + buf.extend_from_slice(s); + buf.push(0); + Some(buf.as_ptr().cast()) + } + + /// Live `getenv()` under the environ lock, falling back to the DotEnv map + /// for `.env`-only keys (see `DOTENV_OVERLAY`). On success writes an owned + /// `BunString` (`clone_utf8`) the C++ side adopts via `transferToWTFString`. + #[cfg(not(windows))] + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Bun__Process__getOSEnv( + global_object: &JSGlobalObject, + name: &BunString, + out: &mut core::mem::MaybeUninit, + ) -> bool { + let name_slice = name.to_utf8(); + let name_bytes = name_slice.slice(); + let mut name_buf = Vec::new(); + let Some(name_c) = make_cstr(&mut name_buf, name_bytes) else { + return false; + }; + let in_overlay = { + let _guard = ENVIRON_LOCK.lock(); + // SAFETY: name_c is NUL-terminated; getenv reads until NUL. The + // returned pointer borrows the env block and is only valid while + // the lock is held. + let p = unsafe { libc::getenv(name_c) }; + if !p.is_null() { + // SAFETY: getenv returns a NUL-terminated string. + let bytes = unsafe { core::ffi::CStr::from_ptr(p) }.to_bytes(); + out.write(BunString::clone_utf8(bytes)); + return true; + } + DOTENV_OVERLAY + .lock() + .as_ref() + .is_some_and(|s| s.contains(name_bytes)) + }; + if !in_overlay { + return false; + } + let Some(bytes) = global_object.bun_vm().env_loader().map.get(name_bytes) else { + return false; + }; + out.write(BunString::clone_utf8(bytes)); + true + } + + /// Apply a JS `process.env` write to libc `environ` (so native `getenv` + /// observes it) and the DotEnv map (so Bun-internal consumers like + /// `Bun.spawn` without `env:` and `Bun.which` observe it). The DotEnv-map + /// write is skipped when `setenv` rejects the name (e.g. contains `=`), so + /// the two stores cannot diverge. Returns whether `environ` was updated so + /// callers can keep their own secondary store (SHARE_ENV) consistent. + #[cfg(not(windows))] + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Bun__Process__setOSEnv( + global_object: &JSGlobalObject, + name: &BunString, + value: &BunString, + ) -> bool { + let name_slice = name.to_utf8(); + let value_slice = value.to_utf8(); + let name_bytes = name_slice.slice(); + if name_bytes.is_empty() { + return false; + } + let mut name_buf = Vec::new(); + let mut value_buf = Vec::new(); + let (Some(name_c), Some(value_c)) = ( + make_cstr(&mut name_buf, name_bytes), + make_cstr(&mut value_buf, value_slice.slice()), + ) else { + return false; + }; + let vm = global_object.bun_vm().as_mut(); + // Serialises env_map.put against a spawning worker's + // clone_with_allocator (see rare_data::ProxyEnvStorage). + let _slots = vm.proxy_env_storage.lock(); + let rc = { + let _guard = ENVIRON_LOCK.lock(); + // SAFETY: both pointers are NUL-terminated for the duration of the call. + let rc = unsafe { libc::setenv(name_c, value_c, 1) }; + if rc == 0 { + if let Some(overlay) = DOTENV_OVERLAY.lock().as_mut() { + overlay.swap_remove(name_bytes); + } + } + rc + }; + if rc != 0 { + return false; + } + let env_map = &mut *vm.transpiler.env_mut().map; + bun_core::handle_oom(env_map.put(name_bytes, value_slice.slice())); + true + } + + #[cfg(not(windows))] + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Bun__Process__unsetOSEnv( + global_object: &JSGlobalObject, + name: &BunString, + ) { + let name_slice = name.to_utf8(); + let name_bytes = name_slice.slice(); + let mut name_buf = Vec::new(); + let Some(name_c) = make_cstr(&mut name_buf, name_bytes) else { + return; + }; + let vm = global_object.bun_vm().as_mut(); + let _slots = vm.proxy_env_storage.lock(); + { + let _guard = ENVIRON_LOCK.lock(); + // SAFETY: name_c is NUL-terminated for the duration of the call. + unsafe { libc::unsetenv(name_c) }; + if let Some(overlay) = DOTENV_OVERLAY.lock().as_mut() { + overlay.swap_remove(name_bytes); + } + } + let env_map = &mut *vm.transpiler.env_mut().map; + env_map.remove(name_bytes); + } + + /// Walk live `environ` plus the `.env`-only overlay and yield each key to + /// `cb`. Keys are copied out under the lock so a concurrent `setenv` + /// cannot invalidate a pointer mid-callback. + #[cfg(not(windows))] + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Bun__Process__enumerateOSEnv( + ctx: *mut core::ffi::c_void, + cb: extern "C" fn(*mut core::ffi::c_void, *const u8, usize), + ) { + let keys: Vec> = { + let _guard = ENVIRON_LOCK.lock(); + let overlay = DOTENV_OVERLAY.lock(); + let overlay_ref = overlay.as_ref().filter(|s| !s.is_empty()); + // A native setenv of an overlay key (bypassing the JS write path, + // which would have removed it) must not yield the key twice. + let mut seen = bun_collections::StringSet::new(); + let mut keys: Vec> = bun_sys::environ() + .iter() + .filter_map(|&entry| { + // SAFETY: environ entries are NUL-terminated C strings. + let line = unsafe { core::ffi::CStr::from_ptr(entry) }.to_bytes(); + let eq = bun_core::strings::index_of_char(line, b'=')? as usize; + if eq == 0 { + return None; + } + let key = &line[..eq]; + if overlay_ref.is_some_and(|o| o.contains(key)) { + bun_core::handle_oom(seen.insert(key)); + } + Some(Box::<[u8]>::from(key)) + }) + .collect(); + if let Some(o) = overlay_ref { + for key in o.keys() { + if !seen.contains(key) { + keys.push(key.clone()); + } + } + } + keys + }; + for key in &keys { + cb(ctx, key.as_ptr(), key.len()); + } + } + + /// Compute the `.env`-only overlay: DotEnv-map keys not present in the + /// current `environ`. Called once when the main-thread `process.env` is + /// created. Overlay keys read from the DotEnv map until a JS write + /// promotes them into `environ`. + #[cfg(not(windows))] + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Bun__Process__initOSEnvOverlay(global_object: &JSGlobalObject) { + let env_map = &global_object.bun_vm().env_loader().map; + let _guard = ENVIRON_LOCK.lock(); + let mut environ_keys = bun_collections::StringSet::new(); + for &entry in bun_sys::environ() { + // SAFETY: environ entries are NUL-terminated C strings. + let line = unsafe { core::ffi::CStr::from_ptr(entry) }.to_bytes(); + if let Some(eq) = bun_core::strings::index_of_char(line, b'=') + && eq > 0 + { + bun_core::handle_oom(environ_keys.insert(&line[..eq as usize])); + } + } + let mut overlay = bun_collections::StringSet::new(); + for key in env_map.map.keys() { + if !environ_keys.contains(key) { + bun_core::handle_oom(overlay.insert(key)); + } + } + *DOTENV_OVERLAY.lock() = Some(overlay); + } } #[unsafe(no_mangle)] diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index bae48904bf5c..b3a01d636b06 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -268,7 +268,13 @@ mod _impl { let arg: &[u8] = arg; if arg.len() >= 1 && arg[0] == b'-' { - args.push(BunString::clone_utf8(arg)); + // `--bun`/`-b` are Bun-launcher flags, not Node-compatible + // engine options. Frameworks like Next.js serialize execArgv + // into NODE_OPTIONS for child workers, and real node rejects + // `--bun` there. + if arg != b"--bun" && arg != b"-b" { + args.push(BunString::clone_utf8(arg)); + } prev = Some(arg); continue; } diff --git a/test/js/node/process/process-env-environ-sync.test.ts b/test/js/node/process/process-env-environ-sync.test.ts new file mode 100644 index 000000000000..fcbf4a2fc9e0 --- /dev/null +++ b/test/js/node/process/process-env-environ-sync.test.ts @@ -0,0 +1,189 @@ +// process.env on the main thread is a live view of libc `environ`: JS writes +// reach native getenv()/setenv() and native writes reach process.env, so a +// `bun:ffi` getenv() or a native library reading its config from the +// environment sees the same values JS does. Node's RealEnvStore semantics. +// +// POSIX only: Windows already routes JS->OS through SetEnvironmentVariableW +// and has no libc `environ` contract to test against via bun:ffi. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, isMacOS, libcPathForDlopen } from "harness"; + +const canDlopenLibc = isLinux || isMacOS; + +describe.skipIf(!canDlopenLibc)("process.env <-> libc environ on the main thread", () => { + const libc = JSON.stringify(canDlopenLibc ? libcPathForDlopen() : ""); + const ffiPrelude = ` + const { dlopen } = require("bun:ffi"); + const libc = dlopen(${libc}, { + getenv: { args: ["cstring"], returns: "cstring" }, + setenv: { args: ["cstring", "cstring", "int"], returns: "int" }, + unsetenv: { args: ["cstring"], returns: "int" }, + }); + const c = s => new TextEncoder().encode(s + "\\0"); + const cget = k => { const r = libc.symbols.getenv(c(k)); return r == null ? null : String(r) || null; }; + `; + + async function run(extraEnv: Record, body: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", ffiPrelude + body], + env: { ...bunEnv, ...extraEnv }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return JSON.parse(stdout); + } + + test.concurrent("JS writes reach native getenv()", async () => { + const out = await run( + { ENVSYNC_LAUNCH: "launchval", ENVSYNC_TODEL: "todel" }, + ` + process.env.ENVSYNC_NEW = "js-new"; + process.env.ENVSYNC_LAUNCH = "js-overwrote"; + delete process.env.ENVSYNC_TODEL; + console.log(JSON.stringify({ + set: cget("ENVSYNC_NEW"), + overwrite: cget("ENVSYNC_LAUNCH"), + deleted: cget("ENVSYNC_TODEL"), + })); + `, + ); + expect(out).toEqual({ set: "js-new", overwrite: "js-overwrote", deleted: null }); + }); + + test.concurrent("native setenv()/unsetenv() reach process.env", async () => { + const out = await run( + { ENVSYNC_PRESENT: "launchval" }, + ` + libc.symbols.setenv(c("ENVSYNC_FROMC"), c("c-set"), 1); + libc.symbols.setenv(c("ENVSYNC_PRESENT"), c("c-overwrote"), 1); + libc.symbols.unsetenv(c("ENVSYNC_PRESENT2")); + process.env.ENVSYNC_JSDEL = "js-set"; + libc.symbols.unsetenv(c("ENVSYNC_JSDEL")); + console.log(JSON.stringify({ + fromC: process.env.ENVSYNC_FROMC ?? null, + overwrite: process.env.ENVSYNC_PRESENT ?? null, + deletedJS: process.env.ENVSYNC_JSDEL ?? null, + keysHaveFromC: Object.keys(process.env).includes("ENVSYNC_FROMC"), + })); + `, + ); + expect(out).toEqual({ fromC: "c-set", overwrite: "c-overwrote", deletedJS: null, keysHaveFromC: true }); + }); + + test.concurrent("Object.keys reflects live environ", async () => { + const out = await run( + { ENVSYNC_ENUM: "x" }, + ` + const before = Object.keys(process.env).includes("ENVSYNC_ENUM"); + libc.symbols.unsetenv(c("ENVSYNC_ENUM")); + const afterNativeUnset = Object.keys(process.env).includes("ENVSYNC_ENUM"); + libc.symbols.setenv(c("ENVSYNC_ENUM2"), c("y"), 1); + const afterNativeSet = Object.keys(process.env).includes("ENVSYNC_ENUM2"); + console.log(JSON.stringify({ before, afterNativeUnset, afterNativeSet })); + `, + ); + expect(out).toEqual({ before: true, afterNativeUnset: false, afterNativeSet: true }); + }); + + // Runtime process.env writes also update Bun's internal env map so + // Bun.spawn({}) without an `env:` option inherits them. + test.concurrent("Bun.spawn without env: inherits runtime process.env writes", async () => { + const out = await run( + {}, + ` + process.env.ENVSYNC_SPAWN = "via-js"; + const r = Bun.spawnSync({ cmd: [process.execPath, "-e", "process.stdout.write(process.env.ENVSYNC_SPAWN ?? 'unset')"] }); + console.log(JSON.stringify({ child: r.stdout.toString() })); + `, + ); + expect(out).toEqual({ child: "via-js" }); + }); + + // Object.freeze/seal pass attribute-only descriptors for every key; those + // must not reach unsetenv and wipe the OS environment. + test.concurrent("Object.freeze(process.env) does not touch environ", async () => { + const out = await run( + { ENVSYNC_FREEZE: "kept" }, + ` + Object.freeze(process.env); + Object.defineProperty(process.env, "ENVSYNC_FREEZE", { writable: false }); + console.log(JSON.stringify({ + native: cget("ENVSYNC_FREEZE"), + js: process.env.ENVSYNC_FREEZE ?? null, + path: cget("PATH") !== null, + })); + `, + ); + expect(out).toEqual({ native: "kept", js: "kept", path: true }); + }); + + test.concurrent("'in' operator matches native presence", async () => { + const out = await run( + { ENVSYNC_IN: "x" }, + ` + const a = "ENVSYNC_IN" in process.env; + libc.symbols.unsetenv(c("ENVSYNC_IN")); + const b = "ENVSYNC_IN" in process.env; + const c_ = "ENVSYNC_NEVER" in process.env; + console.log(JSON.stringify({ a, b, c: c_ })); + `, + ); + expect(out).toEqual({ a: true, b: false, c: false }); + }); + + // Every process.env.X read allocates a fresh WTFStringImpl from getenv(); + // the C++ side must adopt (transferToWTFString), not ref-and-leak. With a + // 4KB value the leaked case is ~80MB here; the fixed case is the per-read + // transient-allocation overhead only (value-size-independent). + test.concurrent("reading process.env in a tight loop does not leak", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.env.ENVSYNC_LEAK = Buffer.alloc(4096, "x").toString(); + for (let i = 0; i < 5000; i++) process.env.ENVSYNC_LEAK; + Bun.gc(true); + const before = process.memoryUsage().rss; + for (let i = 0; i < 20000; i++) process.env.ENVSYNC_LEAK; + Bun.gc(true); + const after = process.memoryUsage().rss; + console.log(JSON.stringify({ deltaMB: (after - before) / 1024 / 1024 }));`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { deltaMB } = JSON.parse(stdout); + // A leak is 20000 x 4KB StringImpls = 80MB+ regardless of build type; + // the fixed path's transient allocation overhead is ~20-25MB on release + // (mimalloc retains freed pages) and similar on debug/ASAN. + expect(deltaMB).toBeLessThan(40); + expect(exitCode).toBe(0); + }); +}); + +// A worker's transpiler must not inline process.env.X from the parent's env +// map: a worker spawned with `env: {X: "v"}` has to read its own value. +test("worker with env: option does not inline process.env.* from parent env", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker( + 'require("worker_threads").parentPort.postMessage({ a: process.env.ENVSYNC_WK, b: globalThis.process.env.ENVSYNC_WK })', + { eval: true, env: { ENVSYNC_WK: "from-worker" } }, + ); + w.on("message", m => console.log(JSON.stringify(m)));`, + ], + env: { ...bunEnv, ENVSYNC_WK: "from-parent" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ a: "from-worker", b: "from-worker" }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index b191a4ed15ab..a99847feeb9e 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -251,11 +251,6 @@ it("ICU version does not regress", () => { it("process.env.TZ", () => { var origTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - // the default timezone is Etc/UTC - if (!("TZ" in process.env)) { - expect(origTimezone).toBe("Etc/UTC"); - } - const realOrigTimezone = origTimezone; if (origTimezone === "America/Anchorage") { origTimezone = "America/New_York"; @@ -1269,10 +1264,13 @@ it("process.hasUncaughtExceptionCaptureCallback", () => { }); it("process.execArgv", async () => { + // --bun/-b are Bun-launcher flags, not Node engine options, so they are + // excluded from execArgv: frameworks commonly serialize execArgv into + // NODE_OPTIONS for child workers and real node rejects --bun there. const fixtures = [ ["index.ts --bun -a -b -c", [], ["--bun", "-a", "-b", "-c"]], - ["--bun index.ts index.ts", ["--bun"], ["index.ts"]], - ["run -e bruh -b index.ts foo -a -b -c", ["-e", "bruh", "-b"], ["foo", "-a", "-b", "-c"]], + ["--bun index.ts index.ts", [], ["index.ts"]], + ["run -e bruh -b index.ts foo -a -b -c", ["-e", "bruh"], ["foo", "-a", "-b", "-c"]], ]; for (const [cmd, execArgv, argv] of fixtures) { diff --git a/test/js/node/util/parse_args/default-args.test.mjs b/test/js/node/util/parse_args/default-args.test.mjs index 84bd49db2181..801bb5ea21c8 100644 --- a/test/js/node/util/parse_args/default-args.test.mjs +++ b/test/js/node/util/parse_args/default-args.test.mjs @@ -55,15 +55,18 @@ describe("parseArgs default args", () => { return { stdout }; } + // --bun/-b are Bun-launcher flags, not Node engine options, so they are excluded + // from execArgv (frameworks commonly serialize execArgv into NODE_OPTIONS for + // child workers and real node rejects --bun there). test.each([ ["file-test.js --foo asdf", ["foo"], ["asdf"], []], // implicit run ["run file-test.js --foo asdf", ["foo"], ["asdf"], []], // explicit run - ["--bun file-test.js --foo asdf", ["foo"], ["asdf"], ["--bun"]], // implicit run, with bun "--bun" arg (should not appear in argv) - ["run --bun file-test.js --foo asdf", ["foo"], ["asdf"], ["--bun"]], // explicit run, with bun "--bun" arg (after the run) - ["--bun run file-test.js --foo asdf", ["foo"], ["asdf"], ["--bun"]], // explicit run, with bun "--bun" arg (before the run) - ["--bun run --env-file='' file-test.js --foo asdf", ["foo"], ["asdf"], ["--bun", "--env-file=''"]], // explicit run, multiple bun args + ["--bun file-test.js --foo asdf", ["foo"], ["asdf"], []], // implicit run, with bun "--bun" arg (should not appear in argv or execArgv) + ["run --bun file-test.js --foo asdf", ["foo"], ["asdf"], []], // explicit run, with bun "--bun" arg (after the run) + ["--bun run file-test.js --foo asdf", ["foo"], ["asdf"], []], // explicit run, with bun "--bun" arg (before the run) + ["--bun run --env-file='' file-test.js --foo asdf", ["foo"], ["asdf"], ["--env-file=''"]], // explicit run, multiple bun args ["run file-test.js --bun", ["bun"], [], []], // passing --bun only to the program - ["--bun run file-test.js --foo asdf -- --foo2 -- --foo3", ["foo"], ["asdf", "--foo2", "--", "--foo3"], ["--bun"]], + ["--bun run file-test.js --foo asdf -- --foo2 -- --foo3", ["foo"], ["asdf", "--foo2", "--", "--foo3"], []], //[`--bun -e ${evalSrc} --foo asdf`, ["foo"], ["asdf"]], // eval seems to crash when triggered from tests //[`--bun --eval ${evalSrc} --foo asdf`, ["foo"], ["asdf"]], //[`--eval "require('./file-test.js')" -- --foo asdf -- --bar`, ["foo"], ["asdf"]], diff --git a/test/preload.ts b/test/preload.ts index ec495d79315d..cfa4dbc4c74b 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -10,6 +10,10 @@ for (let key in process.env) { for (let key in harness.bunEnv) { if (key === "TZ") continue; + // process.env writes now reach setenv(); a spawned child that passed + // CI=false to exercise test.only() would otherwise be overwritten to CI=1 + // here and is_ci() (which reads getenv) would flip back. + if (key === "CI" && process.env.CI !== undefined) continue; if (harness.bunEnv[key] === undefined) continue; process.env[key] = harness.bunEnv[key] + ""; }