Skip to content

process: back main-thread process.env by live libc environ on POSIX - #35270

Open
robobun wants to merge 9 commits into
mainfrom
claude/farm/50f3a566/process-env-environ-sync
Open

process: back main-thread process.env by live libc environ on POSIX#35270
robobun wants to merge 9 commits into
mainfrom
claude/farm/50f3a566/process-env-environ-sync

Conversation

@robobun

@robobun robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

process.env on the main thread (POSIX) was a lazy-getter snapshot of the startup environment: a JS process.env.X = "v" never called setenv(), so a bun:ffi getenv(), any native library reading its config from the environment, or an LD_PRELOAD tool saw the pristine launch environment for the life of the process. And a native setenv()/unsetenv() never reached process.env, not on the next read, not after Object.keys(), not after a spawn. JS and C ran on two silently divergent environments.

import { dlopen } from "bun:ffi";
const libc = dlopen("libc.so.6", {
  getenv: { args: ["cstring"], returns: "cstring" },
  setenv: { args: ["cstring", "cstring", "int"], returns: "int" },
});
const c = s => new TextEncoder().encode(s + "\0");

process.env.FOO = "from-js";
libc.symbols.getenv(c("FOO"));        // before: null,  now: "from-js"

libc.symbols.setenv(c("BAR"), c("from-c"), 1);
process.env.BAR;                       // before: undefined,  now: "from-c"

Fix

The main-thread process.env is now a JSRealEnvMap exotic object whose getOwnPropertySlot / put / deleteProperty / getOwnPropertyNames are live getenv / setenv / unsetenv / environ calls under a process-wide mutex, matching Node's RealEnvStore.

Bun also auto-loads .env files, and glibc's setenv is O(n), so seeding every .env key into environ would turn a large .env into O(n^2) startup. Instead .env-only keys are recorded in an overlay set; process.env reads fall back to the DotEnv map for exactly those keys, and a JS write promotes the key into environ (so native getenv then sees it). Native getenv() of a .env-only key that has never been written from JS still returns NULL, which is the pre-PR behavior.

JS writes also update Bun's internal DotEnv map so Bun.spawn({}) without an env: option, Bun.which, and fetch's proxy resolution observe runtime process.env changes.

Workers keep their snapshot env (Node gives workers a MapKVStore, not the RealEnvStore). A main-rooted SHARE_ENV store now writes through to setenv/unsetenv on POSIX like it already did to SetEnvironmentVariableW on Windows.

Also fixes: worker transpiler inlined process.env.* from the parent env

Worker and debugger VMs called configure_defines() without forcing LoadAllWithoutInlining, inheriting the Target::Bun default of LoadAll, so their transpiler rewrote process.env.X as a string literal from the cloned DotEnv map. A worker spawned with env: { X: "v" } would read the parent's launch value for bare process.env.X while globalThis.process.env.X returned "v":

$ SHARED_KEY=from-main bun -e 'new (require("worker_threads").Worker)("console.log(process.env.SHARED_KEY)",{eval:true,env:{SHARED_KEY:"from-A"}})'
from-main   # node prints from-A

This pre-dates the main change but was exposed by it (runtime JS writes now land in the internal map that the worker clones). Both secondary VMs now set LoadAllWithoutInlining before configure_defines(), matching bun run/bun test/bun repl.

Behavior changes

  • 'TZ' in process.env on POSIX now matches Node: false when TZ is not set in the OS environment (previously always true because of the always-installed TZ accessor).
  • Object.keys(process.env) on POSIX now enumerates in libc environ array order followed by .env-only keys in .env-file insertion order (previously DotEnv-map insertion order for everything).

Verification

test/js/node/process/process-env-environ-sync.test.ts covers JS→C (set / overwrite / delete), C→JS (setenv / unsetenv / enumeration), Bun.spawn inheritance of runtime writes, the in operator, Object.freeze not touching environ, an RSS leak guard, and the worker inlining bug. All fail on the previous build, all pass here. env.test.ts, worker_threads.test.ts, and the Node parallel env tests pass.

Fixes #34210

Overlap with open PRs

Not fixed here


no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/process/process-env-environ-sync.test.ts test/js/node/process/process.test.js

The main-thread process.env is now a JSRealEnvMap exotic object whose
get/set/delete/enumerate are live getenv/setenv/unsetenv/environ calls
under a process-wide mutex, matching Node's RealEnvStore. Previously it
was a lazy-getter snapshot of the startup environment, so a native
library's getenv() never observed a JS write and a native setenv() never
reached process.env; JS and C ran on two silently divergent environments
for the life of the process.

JS writes also update Bun's internal DotEnv map so Bun.spawn without an
env option, Bun.which, and fetch's proxy resolution observe runtime
process.env changes. .env-file values are seeded into environ when the
map is created so getenv() is the single source of truth and native
libraries see them too. Workers keep their snapshot env (Node gives
workers a MapKVStore, not the RealEnvStore); a main-rooted SHARE_ENV
store now writes through to setenv/unsetenv on POSIX like it already did
to SetEnvironmentVariableW on Windows.

Also fixes a pre-existing bug this exposes: worker and debugger VMs
called configure_defines() without forcing LoadAllWithoutInlining, so
their transpiler inlined process.env.X as a string literal from the
cloned DotEnv map. A worker spawned with env:{X:'v'} would read the
parent's launch value for bare process.env.X while
globalThis.process.env.X returned 'v'.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6624082b-8817-40c0-b7b6-12a4254a74b8

📥 Commits

Reviewing files that changed from the base of the PR and between 85160e8 and 96403f2.

📒 Files selected for processing (2)
  • src/runtime/api/BunObject.rs
  • test/js/node/util/parse_args/default-args.test.mjs

Walkthrough

Changes

The PR connects POSIX environ with Bun’s live main-thread process.env, synchronizes shared environment mutations, disables environment-value inlining in workers and debugger VMs, filters Bun launcher flags from execArgv, and adds synchronization and compatibility tests.

Process environment synchronization

Layer / File(s) Summary
POSIX environ bridge
src/runtime/api/BunObject.rs, src/jsc/bindings/JSEnvironmentVariableMap.cpp
Adds locked native operations for reading, writing, deleting, enumerating, and seeding POSIX environment variables.
Live environment map integration
src/jsc/bindings/JSEnvironmentVariableMap.cpp
Uses a live JSRealEnvMap on the non-Windows main thread and routes shared-map mutations through native OS synchronization.
Worker and debugger transpiler behavior
src/jsc/web_worker.rs, src/jsc/Debugger.rs
Sets dotenv behavior to LoadAllWithoutInlining before VM define configuration.
Validation and compatibility updates
test/js/node/process/process-env-environ-sync.test.ts, test/js/node/process/process.test.js, test/preload.ts, src/runtime/node/node_process.rs
Tests environment synchronization, inheritance, memory behavior, worker isolation, timezone labels, and CI preservation; excludes Bun launcher flags from execArgv expectations and parsing.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#34727 — Both modify process.env map behavior and descriptor handling in JSEnvironmentVariableMap.cpp.
  • oven-sh/bun#34728 — Both change non-Windows process.env property-operation paths.
  • oven-sh/bun#35254 — Both modify process.env property-definition and accessor behavior in JSEnvironmentVariableMap.cpp.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and specifically summarizes the main POSIX process.env backing change.
Description check ✅ Passed The description covers the change, rationale, behavior, and verification, though it uses custom headings instead of the template.

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. Runtime transpiler cache inlines process.env dot-reads during Worker-thread imports — later processes execute the first process's env values #34210 - PR fixes worker transpiler inlining by setting DotEnvBehavior::LoadAllWithoutInlining on worker/debugger VMs, preventing process.env.X from being baked as string literals
  2. child_process.execFileSync uses stale PATH for command resolution when options.env is omitted #29237 - PR makes process.env.PATH = newPath call setenv(), so child processes inheriting libc environ see runtime PATH mutations
  3. process.env is empty (0 keys) when bun runs inside macOS Seatbelt sandbox #27802 - PR replaces lazy-getter snapshot with live getenv()/environ reads, which may fix empty process.env under macOS Seatbelt sandbox
  4. os.homedir() uses process-start HOME snapshot instead of current process.env.HOME #29244 - PR makes process.env.HOME = x call setenv("HOME", x), so os.homedir() sees updated value via libc
  5. Bun Shell .env() PATH changes don't affect command resolution #25885 - PR makes process.env writes flow through to setenv(), so Bun Shell PATH changes affect command resolution

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #34210
Fixes #29237
Fixes #27802
Fixes #29244
Fixes #25885

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Stop inlining process.env dot-reads in Worker-thread transpiles #34211 - Fixes the same worker transpiler inlining bug (setting LoadAllWithoutInlining before configure_defines() in worker VMs)
  2. Bun.spawn/spawnSync, Bun.which: inherit live process.env instead of the startup snapshot #34972 - Fixes the same bug where Bun.spawn/Bun.which read from the startup env snapshot instead of live process.env

🤖 Generated with Claude Code

Comment thread src/runtime/api/BunObject.rs
Comment thread src/jsc/bindings/JSEnvironmentVariableMap.cpp
Comment thread test/js/node/process/process.test.js Outdated
Comment thread src/runtime/api/BunObject.rs Outdated
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:39 AM PT - Jul 23rd, 2026

@robobun, your commit 96403f2680ed7548ef353b3864bbe353c6de489d passed in Build #78679! 🎉


🧪   To try this PR locally:

bunx bun-pr 35270

That installs a local version of the PR into your bun-35270 executable, so you can run:

bun-35270 --bun

Comment thread src/jsc/bindings/JSEnvironmentVariableMap.cpp Outdated
robobun added 2 commits July 23, 2026 10:57
…fixups

- JSRealEnvMap::getOwnPropertySlot / defineOwnProperty: adopt the owned
  clone_utf8 via transferToWTFString() instead of ref-and-leak via
  toWTFString(ZeroCopy). Adds an RSS regression test.
- Bun__Process__setOSEnv / unsetOSEnv: take vm.proxy_env_storage.lock()
  around the DotEnv-map write so it cannot race a spawning worker's
  clone_with_allocator (the documented env-map serialisation point).
- setenv() return is now checked; the DotEnv map is only updated when
  environ was, so a rejected name (contains '=') cannot diverge the two.
- Replace the O(n^2) seed-into-environ with a .env-only overlay set:
  DotEnv-map keys absent from environ are recorded once and reads fall
  back to the map for exactly those keys. A JS write promotes a key into
  environ and removes it from the overlay. Large .env files no longer
  blow up startup and per-read cost stays O(|environ|).
- test/preload.ts: process.env writes now reach setenv, so stop
  overwriting an explicitly-passed CI value; otherwise a child spawned
  with CI=false to exercise test.only() would flip back to is_ci()=true.
- process-env-environ-sync.test.ts: guard libcPathForDlopen() on Windows.
- process.test.js: drop the now-live (previously dead) TZ branch that
  assumed a UTC host default.
…freeze)

JSRealEnvMap::defineOwnProperty entered the move-to-base + unsetenv path
for any descriptor without a value, which is what Object.freeze/seal
pass ({writable:false, configurable:false}) for every environ key. That
wiped libc environ and the DotEnv map. Gate the unsetenv on an actual
accessor descriptor; attribute-only descriptors are accepted as no-ops
on the live environ view. Same guard applied to JSSharedEnvMap so a
main-rooted SHARE_ENV freeze cannot reach unsetenv via syncOSEnv.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/JSEnvironmentVariableMap.cpp (1)

361-378: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not publish rejected environment names to SharedEnvStore.

Bun__Process__setOSEnv rejects empty, embedded-NUL, and = names, but this void helper lets the callers still execute store->set at Lines 584 and 654. A main-rooted SHARE_ENV then exposes a value that neither libc environ nor Bun’s env map accepted. Return success through this ABI/helper and update the store only after a successful native write.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/JSEnvironmentVariableMap.cpp` around lines 361 - 378, The
syncOSEnv helper must report whether the native environment update succeeded
instead of returning void. Propagate success from Bun__Process__setOSEnv and the
Windows equivalent, then update SharedEnvStore only when syncOSEnv confirms the
write succeeded at its callers around the store->set operations; rejected empty,
embedded-NUL, or “=” names must not be published.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/api/BunObject.rs`:
- Around line 2339-2340: Replace std::collections::HashSet with the
repository-approved hash-set implementation in DOTENV_OVERLAY and the related
initialization set around the referenced code, preserving their existing key and
overlay behavior.
- Around line 2493-2495: Update the overlay enumeration in the surrounding
environment-key collection flow to add only keys not already present in the
native keys collected from environ(). Preserve existing overlay-only keys, and
add an FFI regression test covering native setenv promotion of a key originating
from DOTENV_OVERLAY.

In `@test/js/node/process/process-env-environ-sync.test.ts`:
- Around line 11-12: Update the suite gate around “process.env <-> libc environ
on the main thread” so it excludes FreeBSD before evaluating
libcPathForDlopen(). Either narrow the condition to Linux and macOS, or extend
libcPathForDlopen() with a valid FreeBSD path before use.
- Around line 137-142: Reorder the assertions in the subprocess test so stdout
parsing and deltaMB validation occur before the exitCode check. Keep the
existing stderr assertion and threshold logic unchanged, and make the final
assertion in this block validate exitCode is 0.

---

Outside diff comments:
In `@src/jsc/bindings/JSEnvironmentVariableMap.cpp`:
- Around line 361-378: The syncOSEnv helper must report whether the native
environment update succeeded instead of returning void. Propagate success from
Bun__Process__setOSEnv and the Windows equivalent, then update SharedEnvStore
only when syncOSEnv confirms the write succeeded at its callers around the
store->set operations; rejected empty, embedded-NUL, or “=” names must not be
published.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5c4cda03-772a-49db-8e17-15520dcd2485

📥 Commits

Reviewing files that changed from the base of the PR and between 90da8da and 0361de0.

📒 Files selected for processing (5)
  • src/jsc/bindings/JSEnvironmentVariableMap.cpp
  • src/runtime/api/BunObject.rs
  • test/js/node/process/process-env-environ-sync.test.ts
  • test/js/node/process/process.test.js
  • test/preload.ts
💤 Files with no reviewable changes (1)
  • test/js/node/process/process.test.js

Comment thread src/runtime/api/BunObject.rs Outdated
Comment thread src/runtime/api/BunObject.rs Outdated
Comment thread test/js/node/process/process-env-environ-sync.test.ts Outdated
Comment thread test/js/node/process/process-env-environ-sync.test.ts Outdated
robobun added 2 commits July 23, 2026 11:12
… test on linux/macos

- Replace disallowed std::collections::HashSet with bun_collections::StringSet
  for the .env overlay and its init-time environ snapshot.
- enumerateOSEnv: a native setenv of a .env-only key (bypassing the JS write
  that would remove it from the overlay) must not yield the key twice.
- Bun__Process__setOSEnv now returns whether environ was updated; syncOSEnv
  propagates it so JSSharedEnvMap skips store->set when setenv rejected the
  name, keeping a main-rooted SHARE_ENV store consistent with environ.
- process-env-environ-sync.test.ts: gate on isLinux || isMacOS (libcPathForDlopen
  throws on FreeBSD); assert exitCode after stdout.
Release lanes retain ~20-25MB of mimalloc pages from the 20k transient
4KB StringImpl allocations; a real leak is 80MB+, so a single 40MB bound
discriminates on every build.
Comment thread src/runtime/api/BunObject.rs Outdated
Comment thread src/runtime/api/BunObject.rs
These are Bun-launcher flags, not Node engine options. Next.js and other
frameworks serialize process.execArgv into NODE_OPTIONS for spawned
workers; now that process.env.NODE_OPTIONS writes reach setenv(), a
Turbopack-spawned real node inherits NODE_OPTIONS=--bun from environ and
rejects it. Update the process.execArgv test fixtures accordingly.
Comment thread src/runtime/api/BunObject.rs Outdated
The function only iterates the map; use env_loader() (immutable) instead
of env_mut() so it does not form &mut Map that could alias a spawning
worker's clone_with_allocator read, matching getOSEnv's fallback.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/JSEnvironmentVariableMap.cpp (1)

780-799: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Object.freeze(process.env) still allows adding new keys on the main thread.
JSRealEnvMap::put() writes straight through to Bun__Process__setOSEnv() without an extensibility check, and defineOwnProperty()’s data-descriptor path just delegates there. That means process.env.NEWVAR = "x" can still mutate the OS environment after freeze; the new freeze test only covers existing keys.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/JSEnvironmentVariableMap.cpp` around lines 780 - 799, Update
JSRealEnvMap::put() and the data-descriptor path in defineOwnProperty() to honor
the object’s extensibility state before calling Bun__Process__setOSEnv(). When
the environment map is non-extensible, reject attempts to add keys that are not
already present, while preserving updates to existing properties and the
existing symbol/base handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/JSEnvironmentVariableMap.cpp`:
- Around line 780-799: Update JSRealEnvMap::put() and the data-descriptor path
in defineOwnProperty() to honor the object’s extensibility state before calling
Bun__Process__setOSEnv(). When the environment map is non-extensible, reject
attempts to add keys that are not already present, while preserving updates to
existing properties and the existing symbol/base handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 079f7ee8-c1dd-44cc-ab8f-52c27fd3b696

📥 Commits

Reviewing files that changed from the base of the PR and between 0361de0 and 85160e8.

📒 Files selected for processing (5)
  • src/jsc/bindings/JSEnvironmentVariableMap.cpp
  • src/runtime/api/BunObject.rs
  • src/runtime/node/node_process.rs
  • test/js/node/process/process-env-environ-sync.test.ts
  • test/js/node/process/process.test.js

Comment thread test/js/node/process/process-env-environ-sync.test.ts
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Build 78679 is green for this diff. The four remaining failures are all marked flaky (each passed on at least one retry) and touch nothing related to this change: webview-chrome.test.ts, napi.test.ts, test-fs-promises-file-handle-readFile.js, 20144.test.ts (darwin-only). process-env-environ-sync.test.ts passes on every lane. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Runtime transpiler cache inlines process.env dot-reads during Worker-thread imports — later processes execute the first process's env values

2 participants