Skip to content

process/worker: env descriptor validation, worker execArgv policy table with per-worker --expose-gc (+3 tests, worker 74%→76%) - #34654

Open
cirospaciari wants to merge 67 commits into
ciro/worker-threads-node-testsfrom
claude/process-env-descriptor-validation
Open

process/worker: env descriptor validation, worker execArgv policy table with per-worker --expose-gc (+3 tests, worker 74%→76%)#34654
cirospaciari wants to merge 67 commits into
ciro/worker-threads-node-testsfrom
claude/process-env-descriptor-validation

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 18, 2026

Copy link
Copy Markdown
Member

process.env descriptor validation and worker execArgv handling, verified against the node v26.3.0 binary. Adds 3 vendored upstream tests (2 active; test-worker-execargv-invalid.js skip-passes under Bun's node_without_node_options gate, and its cases are pinned by the in-tree worker_threads tests instead). Stacked on #34424. Also cherry-picks #35182 (deletes the three flaky --expose-gc GC-observation node tests) plus drops their stale expectations.txt/expected-durations.json entries.

process.env defineProperty validation

Object.defineProperty(process.env, k, { value: v }) silently succeeded where node throws ERR_INVALID_OBJECT_DEFINE_PROPERTY. process.env had no defineOwnProperty hook; workers build their env through a separate path (ZigGlobalObject.cpp) with the same gap, so the fix covers both.

descriptor bun before bun now node
{value: 42} (partial) accepted throws throws
full data descriptor accepted accepted (value stringified) accepted
{} empty accepted throws throws
Object.freeze / seal / preventExtensions accepted throws (plain TypeError; env stays extensible) throws
accessor accepted accepted (preserved) throws

The accessor row is a documented maintainer decision (worker_threads.test.ts deliberately asserts accessors are allowed); this narrows to the uncontroversial half rather than silently reversing it. A full data descriptor's value is stringified to match node's EnvDefiner and the string-only contract the Windows env sync relies on. Both env maps fail [[PreventExtensions]] so freeze/seal throw at that step (plain TypeError, no code) and the env stays extensible, matching node exactly. The Windows defineProperty proxy trap uses the set trap's envMapList predicate (so a first-time define of the always-present HTTP_PROXY/TZ/TLS accessor names stays enumerable) and string-coerces the post-define read before the OS sync.

Worker execArgv policy

Worker execArgv is validated against a real flag policy table (chaining the BASE/AUTO_ONLY/AUTO_OR_RUN param sets) instead of ad-hoc checks: node-illegal flags throw ERR_WORKER_INVALID_EXEC_ARGV listing every invalid flag, rejected required-value flags consume their value token (node arity), and per-worker --expose-gc installs gc() in that worker only. The --expose-gc install path takes the JSC API lock (JSLockHolder) since it previously ran before the worker thread held the lock, aborting under WeakSet::allocate's assertion in napi worker tests. Env population during worker init holds an exception scope with per-put checks.

process.execArgv construction and the inherit-path honoring scan share one argv re-parser (collect_process_exec_argv_tokens): bun_clap's glued/chained short-flag forms are normalized to the separate-token shape the worker validator accepts, value tokens are paired regardless of leading -, only One/Many flags claim the next token (an OneOptional like --inspect/--config does not), and NODE_SHORT_ALIASES (-pe) are recognized, so new Worker(url, {execArgv: process.execArgv}) and new Worker(url) round-trip across the CLI's accepted launch shapes for validation and --expose-gc. Preloads are honored only by an explicit execArgv: an inheriting worker does not re-run CLI -r (node re-runs it in both; widening that is a separate behavior decision).

Verification

test-worker-process-env 3/3 with 5 tamper points each exiting 1; descriptor parity re-checked against node across all cases including freeze/seal/preventExtensions + isExtensible; execArgv subset 14/14 and worker_threads.test.ts 112/0; napi test_instance_data fixture 10/10 post lock fix; Windows defineProperty bookkeeping tests 3/3 on a native Windows build; also run on the debug binary for the JSC structure assertions release skips.

Compat impact (upstream node v26.3.0 test files vendored, in-tree = passing)

  • worker_threads: 74% → 75% (108 → 110 of 146; the third vendored file skip-passes, see above)

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

Ride-along: cherry-pick of #35182

This branch carries a cherry-pick of #35182 (merged to main), which deletes three flaky GC-observation vendored tests (test-gc-http-client-connaborted.js, test-net-connect-memleak.js, test-tls-connect-memleak.js) with their stale expectations.txt/expected-durations.json entries; rationale in the #35182 thread (repeated collected-never-true flakes on main). The diff disappears once the branch rebases past the main merge-base.

Object.defineProperty(process.env, k, { value: v }) silently succeeded where
node throws ERR_INVALID_OBJECT_DEFINE_PROPERTY. process.env was a plain object
with per-key custom getters and no defineOwnProperty hook, so nothing validated
the descriptor. Workers build their env through a separate path in
ZigGlobalObject, which had the same gap.

Both paths now use a JSProcessEnvMap that rejects a descriptor which is not a
full data descriptor -- missing writable, enumerable or configurable, as node
requires.

Accessors, empty descriptors and Object.freeze are deliberately still accepted.
Node rejects all three, but test/js/node/worker_threads/worker_threads.test.ts
asserts accessors are allowed and documents the divergence as intentional, so
widening this to node's full rule is left as a separate decision.

Note JSProcessEnvMap derives from JSNonFinalObject, which forbids inline
storage, so process.env no longer pre-sizes its property slots; only
JSFinalObject gets inline slots and it is final, so a defineOwnProperty hook and
inline storage are mutually exclusive here.

Adds test-worker-process-env from Node v26.3.0, verbatim.
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator
Updated 7:21 PM PT - Aug 7th, 2026

@robobun, your commit c52ec62 has 1 failures in Build #90393 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34654

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

bun-34654 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node: add custom setters to process.env #19382 - Also overrides defineOwnProperty on process.env to reject partial property descriptors with ERR_INVALID_OBJECT_DEFINE_PROPERTY, using a near-identical C++ class approach
  2. process: port Node.js v26.3.0 process compatibility tests and fix the gaps they surface (env exotic-object/TZ semantics, warnings pipeline + CLI flags, uncaught origin/exit codes, execve throw, threadCpuUsage/finalization/loadEnvFile, native-module identity; +26 tests) #31831 - Broader Node.js v26.3.0 process compat PR that includes the same Object.defineProperty rejection on process.env among many other fixes

🤖 Generated with Claude Code

Same two expectations.txt entries main added in #34686 (tracked in #34095
and #34690); this branch predates that commit so CI still runs the tests.
Zig__GlobalObject__create is entered from Rust with no exception scope, and
numeric env keys now reach JSProcessEnvMap::defineOwnProperty (a throwing
path) through putDirectMayBeIndex. Declare the outermost scope in
initializeWorker, check between puts, and assert nothing is pending at the
end. Fixes the exception-check validation abort in worker.test.ts on the
asan runner.

@claude claude 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.

I didn't find bugs, but this warrants a human look: the author explicitly flags two tradeoffs for a reviewer to weigh (loss of inline storage on process.env and a now-reachable Windows envMapList ordering hazard), and the diff also adds two [ ASAN ] ... [ CRASH ] quarantine entries to test/expectations.txt that silently drop whole-file coverage.

What was reviewed:

  • validateEnvPropertyDescriptor against Node's EnvDefiner semantics; accessor/empty descriptors intentionally still pass through.
  • JSProcessEnvMap shares plainObjectSpace() via STATIC_ASSERT_ISO_SUBSPACE_SHARABLE, no instance fields — same shape as JSSharedEnvMap.
  • Exception-scope discipline in the worker init loop (DECLARE_TOP_EXCEPTION_SCOPE + per-iteration check + assertNoException) — descriptors defined there are always full/permissive so the hook can't reject.
  • createError with ERR_INVALID_OBJECT_DEFINE_PROPERTY produces a TypeError matching the vendored test's name/code/message.
Extended reasoning...

Overview

Replaces the plain constructEmptyObject() backing process.env with a new JSProcessEnvMap : JSNonFinalObject whose only method-table override is defineOwnProperty, so partial data descriptors throw ERR_INVALID_OBJECT_DEFINE_PROPERTY to match Node. The same validator is wired into the existing JSSharedEnvMap::defineOwnProperty (SHARE_ENV path) and the worker-snapshot path in ZigGlobalObject.cpp is switched to createEmptyProcessEnvMap. Also vendors Node's test-worker-process-env.js and adds two ASAN quarantine entries to test/expectations.txt.

Security risks

None apparent. This tightens validation (rejects previously-accepted descriptors) rather than loosening it, and doesn't touch auth/crypto/permissions.

Level of scrutiny

Medium-high. process.env is on every startup path and every spawn env merge; swapping its backing class from JSFinalObject to a JSNonFinalObject subclass is a structural change with a stated perf cost (no inline storage). The C++ itself is small and follows the neighboring JSSharedEnvMap pattern closely, and 343 tests were run green — but the author is explicitly asking a maintainer to accept the inline-storage tradeoff vs. the JS-wrapper alternative, and to acknowledge the Windows Proxy ordering hazard that this change makes reachable. Those are maintainer calls, not correctness bugs.

Other factors

  • expectations.txt additions: two new [ ASAN ] ... [ CRASH ] entries quarantine test-worker-message-port-transfer-terminate.js and worker-transfer-terminate-stress.test.ts on the ASAN lane. The comments are thorough and reference tracked issues (#34095, #34690), but per the file's own header this drops the entire files from the run — a maintainer should confirm that's acceptable and that these are genuinely pre-existing (the PR is stacked on #34424).
  • Duplicate PRs: the bot flagged #19382 and #31831 as covering the same ground with a near-identical approach; a human should decide which to land.
  • The exception-scope handling around putDirectMayBeIndex looks correct: numeric keys route through the new defineOwnProperty but the loop only ever supplies full permissive descriptors, so assertNoException() holds; the per-iteration check is there for scope-discipline under BUN_JSC_validateExceptionChecks=1.

@claude claude 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.

I didn't find any bugs, but this needs a human look: it changes the class of process.env (JSC bindings, method-table hook, inline-storage tradeoff), the description explicitly asks a reviewer to weigh two design points (the lost inline storage and the now-reachable Windows envMapList ordering hazard), and it quarantines two ASAN tests in expectations.txt. There are also two overlapping open PRs (#19382, #31831) to coordinate with.

What was reviewed:

  • validateEnvPropertyDescriptor gating — accessor/empty descriptors still fall through, matching the documented Bun divergence and the existing worker_threads.test.ts accessor test.
  • The new DECLARE_TOP_EXCEPTION_SCOPE in initializeWorker and the per-iteration exception check after putDirectMayBeIndex — the loop only writes fully-permissive data descriptors, so assertNoException() at the end is sound.
  • JSProcessEnvMap shape: no instance fields, STATIC_ASSERT_ISO_SUBSPACE_SHARABLE + plainObjectSpace(), so no custom subspace needed; RELEASE_AND_RETURN on the Base delegate is correct.
Extended reasoning...

Overview

The PR replaces process.env's backing object (previously a plain constructEmptyObject() / JSFinalObject) with a new JSProcessEnvMap : JSNonFinalObject whose only method-table override is defineOwnProperty, so it can reject partial data descriptors with ERR_INVALID_OBJECT_DEFINE_PROPERTY to match Node. The same validator is wired into the existing JSSharedEnvMap::defineOwnProperty (SHARE_ENV path) and the worker snapshot-env path in ZigGlobalObject.cpp. It adds Node's verbatim test-worker-process-env.js, and quarantines two ASAN worker-terminate tests in test/expectations.txt.

Security risks

None identified. The change tightens validation (rejecting descriptors that were previously silently accepted) rather than loosening it. No untrusted-input parsing, no auth/crypto surface.

Level of scrutiny

High. process.env is one of the most-touched objects in every Bun process; changing its class from JSFinalObject to a custom JSNonFinalObject subclass is a structural change with a documented perf cost (loss of inline storage) that the author explicitly flags for reviewer judgment. JSC bindings work involving method tables, subspaces, and exception-scope discipline is exactly the category the repo review guide singles out as most-blocked. The author also names a Windows ordering hazard in ProcessObjectInternals.ts that is now reachable but deliberately not fixed here.

Other factors

  • Quarantined tests: two new [ ASAN ] entries in expectations.txt. The comments are thorough and cite tracking issues (#34095, #34690) and fix PRs, but per the repo guide, disabling coverage warrants human sign-off — especially confirming these crashes are genuinely pre-existing on main and not introduced/amplified by this PR's added DECLARE_TOP_EXCEPTION_SCOPE in worker bootstrap.
  • Duplicate PRs: the bot flagged #19382 and #31831 as covering the same fix; someone should decide which lands.
  • Author explicitly requests reviewer input on the inline-storage tradeoff ("If that cost is unacceptable, the alternative is validating in JS at the process.env wrapper instead") — that's a design call, not something to auto-approve past.
  • Stacked on #34424, so merge order matters.

`new Worker(url, { execArgv })` accepted any array and honored almost
none of it. Match node_worker.cc (verified empirically on node v26.3.0):

- New policy table (src/runtime/cli/worker_exec_argv.rs) built from
  Bun's own RUNTIME/TRANSPILER CLI param tables plus the node
  env/isolate options Bun tolerates as no-ops. Unknown flags,
  per-process flags (--title, ...), V8 flags (--max-old-space-size,
  ...), and missing required values now throw
  ERR_WORKER_INVALID_EXEC_ARGV synchronously with node's exact message
  format, including the "X requires an argument" and multi-flag forms.
- Validate NODE_OPTIONS from an explicitly provided worker env the way
  node does: skipped when byte-identical to the parent's (so passing
  process.env through never throws), positionals pass through, V8
  options are tolerated, and disallowed/unknown options throw
  "X is not allowed in NODE_OPTIONS".
- Honor --require/-r/--preload/--import from an explicit execArgv as
  worker preloads (resolved worker-side, so a bad path errors at
  runtime like node, not at construction).
- Honor --expose-gc per worker (gc() is per-global in JSC): an explicit
  execArgv wins; inheriting workers chain the parent worker's value or
  the process execArgv (compile_exec_argv + BUN_OPTIONS for compiled
  executables). Deliberate superset of node, which rejects --expose-gc
  in worker execArgv; same for --stack-trace-limit (honored via
  pre_execution) and Bun-only runtime flags.
- The previous ad-hoc scanner (--no-addons/--use-system-ca/--cpu-prof)
  is replaced by the same table-driven scanner, parsed once per worker
  at create() and reused at start, so the honored set is always a
  subset of the accepted set.

Vendor test-worker-execargv-invalid.js and
test-worker-stdio-from-preload-module.js from node v26.3.0 (byte
identical; pass with this change, fail without it, pass on real node).
Not vendored: test-worker-execargv.js needs node's exact warning text
routed through worker stderr ("(node:pid) Warning ... at
Object.<anonymous>"); test-worker-cli-options.js needs
require('internal/options') inside a fresh worker and asserts the
--expose-gc rejection this change deliberately supersets;
test-worker-eval-typescript.js depends on --input-type strictness Bun's
transpiler does not implement.
@cirospaciari cirospaciari changed the title process: reject partial property descriptors on process.env (+1 test) process/worker: env descriptor validation, worker execArgv policy table with per-worker --expose-gc (+3 tests) Jul 22, 2026
Comment thread src/runtime/cli/worker_exec_argv.rs
Comment thread test/js/node/worker_threads/worker_threads.test.ts Outdated
The execArgv policy table was built from RUNTIME_PARAMS_ and
TRANSPILER_PARAMS_ only, so run-surface flags like --bun were rejected
with ERR_WORKER_INVALID_EXEC_ARGV. Next.js forwards --bun from
process.execArgv into its build workers' NODE_OPTIONS, which broke
next build under --bun. Chain AUTO_OR_RUN_PARAMS into the table so the
run-surface flags are accepted; unknown and per-process flags are still
rejected.
Comment thread src/runtime/cli/worker_exec_argv.rs Outdated
Comment thread src/runtime/cli/worker_exec_argv.rs
Comment thread src/runtime/cli/worker_exec_argv.rs Outdated
Comment thread test/js/node/worker_threads/worker_threads.test.ts Outdated
Comment thread test/js/node/worker_threads/worker_threads.test.ts Outdated
cirospaciari and others added 5 commits July 22, 2026 20:20
The per-worker --expose-gc honoring calls JSC__JSGlobalObject__addGc from
start_vm before the worker thread enters holdAPILock, and
putDirectNativeFunction allocates a weak handle that asserts the lock
(WeakSet::allocate, seen as a SIGABRT in the napi test_instance_data worker
phase). JSLock is recursive, so the main-path caller that already holds it
is unaffected.

[allow size]
- table_map now chains AUTO_ONLY_PARAMS and BASE_PARAMS_ so every flag
  create_exec_argv can emit into process.execArgv round-trips through
  new Worker({ execArgv: process.execArgv }) (--silent, --cwd, -c, ...),
  and scan_process_exec_argv consumes their values instead of treating
  a --cwd argument as the first positional (which hid --expose-gc from
  inheriting workers).
- a rejected flag with a required value now consumes its value token, so
  the ERR_WORKER_INVALID_EXEC_ARGV message lists every invalid flag.
- reworded the NODE_OPTIONS parent-comparison comments (Rust + JSWorker)
  to describe what the env_loader snapshot actually is.
- test hygiene: the fire-and-forget worker tests await exits; the
  inheriting --expose-gc test uses await using + piped stderr; new tests
  for --silent/--cwd round-trip, --cwd + --expose-gc inheritance, and
  rejected-flag value consumption.

[allow size]
@cirospaciari

Copy link
Copy Markdown
Member Author

🤖 CI status: every lane is green except ubuntu-25.04-x64, where test-tls-connect-memleak.js has now failed 5 consecutive runs across 3 different binaries of this branch. Mechanism (established with heap snapshots on the CI binary during the alpine investigation): the test's single gc()+setImmediate tick races queued concurrent-JIT plans, whose OSR-captured values strongly root the socket until the plan completes — the gc poll itself starves the compiler queue. A five-line completeAllJITPlans() drain in the explicit-GC path fixed this class empirically (30/30 on the previously-wedging musl lane) but was removed in review on the tls PR. Options: (a) re-propose the drain to main as a standalone engine fix, (b) quarantine this test on main alongside the existing #34631 musl entries, or (c) accept the retry lottery on this lane. Happy to execute any of the three.

@claude claude 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.

Thanks — 52f6889 addresses everything raised earlier (AUTO_ONLY_PARAMS/BASE_PARAMS_ coverage with regression tests, rejected-flag value consumption, awaited workers, subprocess assertion shape). No new issues found this pass. Still deferring to a human for the two tradeoffs the description explicitly flags (JSProcessEnvMap dropping inline storage, and the now-reachable Windows envMapList ordering hazard), plus the hand-maintained NODE_FLAGS policy table and the two new ASAN quarantine entries in test/expectations.txt.

What was reviewed:

  • table_map() now chains RUNTIME_PARAMS_ + TRANSPILER_PARAMS_ + AUTO_ONLY_PARAMS + BASE_PARAMS_, matching create_exec_argv's AUTO_PARAMS; --silent/--cwd round-trip and --cwd <dir> --expose-gc inheritance are covered by tests.
  • scan_exec_argv Reject branch consumes the value token; verified against the new [--max-old-space-size, 4096, --foo] test.
  • JSC__JSGlobalObject__addGc takes JSLockHolder before putDirectNativeFunction; recursive on the main path.
  • The --expose-gc test now awaits both workers via once(w, "message"); the run-surface-flags test awaits all four via once(w, "exit").
Extended reasoning...

Overview

This PR spans two features across 15 files: (1) a new JSProcessEnvMap JSC class so process.env gains a defineOwnProperty hook that rejects partial data descriptors with ERR_INVALID_OBJECT_DEFINE_PROPERTY (main thread, worker snapshot env, and SHARE_ENV paths), and (2) a new 546-line worker_exec_argv.rs module implementing node_worker.cc-style validation of new Worker(url, { execArgv, env: { NODE_OPTIONS } }) plus per-worker honouring of --expose-gc/--require. Supporting changes: WorkerExecArgv grew expose_gc/preloads fields (losing Copy), execArgv parsing moved from start_vm to create(), JSC__JSGlobalObject__addGc acquires the JSLock, and two ASAN worker-terminate tests are quarantined in test/expectations.txt with tracked issue numbers.

Security risks

None identified. The new validation tightens what worker construction accepts; it rejects with a typed error rather than executing anything. NODE_OPTIONS tokenization reuses append_options_env (the existing BUN_OPTIONS parser) rather than hand-rolling. No new untrusted-input parsing reaches allocation or filesystem paths.

Level of scrutiny

High — this warrants human review, not bot approval. It changes user-visible behavior on a public API (new Worker now throws ERR_WORKER_INVALID_EXEC_ARGV where it previously accepted silently), introduces a ~100-entry hand-maintained NODE_FLAGS table encoding Node's per-flag worker/env policy (a compat-taxonomy design decision), replaces process.env's object class with a documented startup-perf tradeoff, and adds two ASAN quarantine entries. The PR description itself asks a reviewer to weigh two specific tradeoffs.

Other factors

Earlier review rounds found a real regression (Bun's own process.execArgv failing validation because table_map() covered a strict subset of AUTO_PARAMS); it took two follow-up commits to fully close. Commit 52f6889 addresses every prior finding, each with a targeted test, and this run's bug hunt found nothing new — including re-checking the --stack-trace-limit doc claim and the --expose-gc test's worker cleanup, both ruled out. The change is well-tested (3 vendored Node tests + ~160 lines of Bun-owned tests), but the scope, the explicit reviewer-should-weigh items, and the expectations.txt additions put it outside what should merge without a maintainer look.

@cirospaciari

Copy link
Copy Markdown
Member Author

🤖 Update on the onGC wedge documented above: it has spread beyond this PR. test-net-connect-memleak.js is now deterministically wedged (retries don't clear it) on debian-13-x64 + ubuntu-25.04-x64 for both #34515 (build 78345) and #34719 (build 78350) merge binaries, in addition to this PR's ubuntu lane. Pattern: per-binary deterministic pinning on glibc x64 lanes, single-tick gc tests, post-removal of the completeAllJITPlans drain. Each new push re-rolls the binary layout, so branches with active development recover by luck — but the class now costs multiple lanes per build across the stack. The three options stand: (a) re-land the drain on main as a standalone engine fix, (b) main-side quarantine of the onGC single-tick family, (c) continue the retry lottery. Evidence: heap-snapshot retainer traces on the CI musl binary (JITWorkList roots), 30/30 cure rate with the drain, builds 77145/77334/78102/78138/78345/78350.

@claude claude 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.

No new findings this pass, but this touches enough JSC/worker-lifecycle surface (new JSProcessEnvMap method-table class, worker-VM init with JSLockHolder/exception-scope changes, 600-line execArgv policy module, Windows env-proxy trap rework) that it's worth a human look before landing.

What was reviewed:

  • JSProcessEnvMap / JSSharedEnvMap defineOwnProperty/preventExtensions hooks — descriptor validation ordering, symbol-key handling, value stringification, RELEASE_AND_RETURN placement.
  • scan_process_exec_argv's process-global LazyLock — initializer only runs on the main thread (nested workers take the Some(parent_worker) branch), so VirtualMachine::get() is safe there.
  • collect_process_exec_argv_tokens normalization vs. bun_clap arity (One/Many vs OneOptional), --prefixed values, NODE_SHORT_ALIASES handling.
  • Windows defineProperty trap: define-before-bookkeeping ordering, envMapList predicate parity with set trap, string-coercion of accessor reads before editWindowsEnvVar.
Extended reasoning...

Overview

This PR adds node-parity process.env descriptor validation and a worker execArgv policy table. It touches 19 files: a new JSProcessEnvMap C++ class with defineOwnProperty/preventExtensions method-table hooks (plus the same on JSSharedEnvMap); a new 632-line worker_exec_argv.rs module implementing the flag-policy table, argv re-parser, and NODE_OPTIONS validator with two extern-C entry points; worker-VM init changes in web_worker.rs/ZigGlobalObject.cpp (per-worker --expose-gc, JSLockHolder in addGc, top-level exception scope during env population); the Windows windowsEnv proxy defineProperty trap rework; and process.execArgv construction moved onto the shared token builder.

Security risks

None identified. The execArgv/NODE_OPTIONS validators only classify tokens against a static table; they don't execute or resolve anything. The env-map hooks tighten validation (reject where node rejects) rather than loosen it. No auth/crypto/permissions surface.

Level of scrutiny

High. This is not a simple or mechanical change: it adds a JSC class with method-table overrides (subspace sharing, structure creation), changes worker-VM startup ordering (JSLockHolder, exception scopes at global creation), and introduces a substantial parsing module whose correctness depends on matching both bun_clap's arity semantics and node's option-parser behavior. The PR went through ~20 review rounds that caught real bugs (including 🔴-severity ones around glued short-flag handling, OneOptional arity, and the Windows trap ordering), which confirms the surface is subtle.

Other factors

All prior inline findings are marked resolved and the current diff reflects the fixes (envMapList predicate, string-coercion in the Windows trap, subprocess-isolated tests, One|Many-only TAKES_VALUE, rejected-flag value-token consumption, NODE_SHORT_ALIASES in table_map). The bug-hunting system found nothing new this run. Test coverage is thorough (14 new execArgv tests, descriptor-matrix tests across all three env-map variants, 3 vendored upstream tests). The earlier ride-along deletions and VM.rs change have dropped out of the diff after the merge with main, so those findings are moot. Given the scope and the JSC/threading touchpoints, deferring to a human reviewer rather than auto-approving.

cpu-prof.test.ts: keep both the worker --cpu-prof-name inheritance test and main's PATH_MAX error tests.

No-Verification-Needed: merge-conflict resolution in a test file only; CI verifies the merged tree

@claude claude 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.

No new findings this pass; all prior inline comments are resolved. Deferring to a human reviewer given the scope — this is a 300-file stacked change reaching the HTTP/1 parser's Transfer-Encoding handling (request-smuggling surface), per-SNI client-certificate policy and a new fetch TLS session cache in openssl.c, plus the HTMLRewriter async-suspension fork switch and a CompressionStream rewrite alongside the titled worker execArgv / process.env work.

What was reviewed:

  • JSProcessEnvMap::defineOwnProperty / preventExtensions and the Windows proxy trap against node's EnvDefiner semantics; the accessor divergence is preserved and the value is stringified before Base::defineOwnProperty.
  • getTransferEncoding rework: chunked-must-be-last is enforced per token across multiple TE fields, multipleCodings only tightens Bun.serve, and useLenientTransferEncoding clears only the coding-shape verdict (TE+CL conflict still rejects).
  • The fetch session cache: sink is armed only after checkServerIdentity passes and is scoped to the per-SSLConfig context, so an unverified session is not laundered into a later strict caller.
Extended reasoning...

Overview

The PR title describes worker execArgv policy and process.env descriptor validation, but the diff is a 300-file, ~856 KB stacked change on #34424. Beyond the titled work (JSEnvironmentVariableMap.cpp, ProcessObjectInternals.ts, worker_exec_argv.rs, web_worker.rs, ZigGlobalObject.cpp), it carries: a rework of uWS HttpParser.h Transfer-Encoding parsing and maxHeaderSize accounting plus a node:http flood-prevention spill/replay path through JSNodeHTTPServerSocket.cpp; a new client-side TLS session cache (session_cache.rs, us_ssl_set_session_sink in openssl.c) and per-SNI client-certificate policy (us_ssl_ctx_set_sni_policy); an HTMLRewriter async-handler suspension redesign switching lol-html to the oven-sh fork with a new HTMLRewriterSink; a native CompressionStream/DecompressionStream replacing the JS builtin path; substantial node:http2 changes (perf_hooks entries, END_STREAM-on-final-DATA, session close grace); node:v8 GCProfiler/isStringOneByteRepresentation; napi external-string and NapiRef::ref semantics; JS printer/parser inlining fixes; large V8-shim additions; and a broad dead-code sweep across FFI sys crates and codegen.

Security risks

Several hunks sit directly on request-smuggling and TLS trust boundaries. HttpParser.h changes how multiple Transfer-Encoding headers and chunked-not-last are classified, adds a useLenientTransferEncoding bit wired from node:http's insecureHTTPParser/httpValidation, and switches maxHeaderSize to llhttp-style byte accounting with a framing slack constant — all of which change what a proxy/backend split can disagree on. openssl.c adds per-serverName verify_mode reapplication and a distinct session-id context so a session established under one SNI entry cannot be resumed under another (skipping client auth); the fetch session cache keys on (host, port, proxy_auth_hash) per HTTPContext and only inserts after the native identity check passes. These look correct on read-through, but they are exactly the class of change REVIEW.md flags for human sign-off.

Level of scrutiny

High. The titled worker/env work by itself is medium-risk and well-tested (three vendored node tests, worker_threads.test.ts at 112/0, Windows-specific bookkeeping tests), but it cannot be separated from the stacked base here, and the base reaches the HTTP parser, TLS handshake path, GC observation, and a vendored-dependency fork switch. This is well past the "simple, mechanical, self-contained" bar for auto-approval.

Other factors

Every prior inline finding from earlier passes (worker_exec_argv token pairing, env descriptor edge cases, Windows defineProperty trap ordering/predicate, expectations.txt cleanup, dead JSC__VM__clearHasTerminationRequest) is marked resolved, and the bug-hunting system found nothing new this run. The PR description accurately documents the intentional accessor divergence and the #35182 cherry-pick. Given no outstanding findings but very high scope, deferring once to a human reviewer is the right call rather than approving or staying silent.

…-descriptor-validation

No-Verification-Needed: comment-only conflict resolution (duplicate SAFETY comment wording)

@claude claude 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.

No new findings this pass. This is a substantial change — a new JSProcessEnvMap JSC class replacing the plain-object process.env, a 600+ line worker execArgv policy module with a hand-maintained NODE_FLAGS table, new Rust↔C++ FFI entry points, and behavioral tightening (previously-accepted defineProperty/execArgv/NODE_OPTIONS inputs now throw) — so it warrants a human look.

What was reviewed:

  • All three env-map variants (JSProcessEnvMap, JSSharedEnvMap, and the Windows windowsEnv proxy) route through the shared validateEnvPropertyDescriptor and each fails [[PreventExtensions]].
  • scan_process_exec_argv's LazyLock cache is process-global but only reads process-constant argv/BUN_OPTIONS, so the per-VM/worker state rule doesn't apply.
  • The JSLockHolder added in JSC__JSGlobalObject__addGc and the top exception scope in worker env init cover the two new call sites reached before the worker thread holds the API lock / has a scope.
  • Prior rounds' Windows defineProperty-trap fixes (envMapList predicate, post-define string coercion, subprocess test) are present at HEAD; the earlier expectations.txt / VM.rs hunks are no longer in the diff after the merges.
Extended reasoning...

Overview

This PR spans four layers: (1) a new JSProcessEnvMap JSC class in JSEnvironmentVariableMap.cpp that replaces the plain JSFinalObject backing process.env so defineOwnProperty/preventExtensions can be hooked for node parity, applied to the ordinary map, the worker snapshot map (ZigGlobalObject.cpp), and JSSharedEnvMap; (2) the Windows windowsEnv proxy defineProperty trap reordered and hardened; (3) a new src/runtime/cli/worker_exec_argv.rs implementing node's worker execArgv/NODE_OPTIONS policy over Bun's AUTO_PARAMS plus a large hand-curated NODE_FLAGS table, wired through two new extern "C" validators called from JSWorker.cpp and a parse_worker_exec_argv hook consumed in web_worker.rs; (4) process.execArgv construction moved onto a shared collect_process_exec_argv_tokens normalizer so the inherit-path scan and the JS-visible array agree. Roughly 1,200 lines added across 18 files, plus 3 vendored upstream node tests and ~350 lines of new in-tree tests.

Security risks

Low. The new validation paths are strictly tightening — inputs that used to be silently accepted now throw ERR_WORKER_INVALID_EXEC_ARGV / ERR_INVALID_OBJECT_DEFINE_PROPERTY. No new privilege, no new filesystem/network surface. The NODE_OPTIONS validator only inspects an explicit worker env value (skipped when byte-equal to the parent's), so it can't be used to reject an inherited environment. The FFI boundary passes borrowed WTF::StringImpl* arrays with a length and null-checks each entry.

Level of scrutiny

High. This is not a mechanical change: it swaps the concrete class of a hot, process-lifetime object (process.env) from an inline-capacity JSFinalObject to a JSNonFinalObject subclass (the diff comment acknowledges the loss of inline slots); it introduces a large policy table whose correctness is empirical ("verified against node v26.3.0") and will need maintenance as node's option set evolves; it changes user-visible behavior in ways that could break tooling that forwards arbitrary flags into worker execArgv/NODE_OPTIONS; and it touches worker startup ordering (--expose-gc install, exception-scope placement, JSLockHolder) where mistakes manifest as debug-only aborts or races. All of those are areas where a maintainer should sign off on the design choices, particularly the NODE_FLAGS accept/reject/env split and the deliberate accessor-descriptor divergence from node.

Other factors

The PR has been through many review iterations — every prior inline finding (Windows trap predicate, accessor-read coercion, vacuous-test shape, stale bookkeeping, dead extern) is marked resolved and the corresponding fixes are visible at HEAD. This run's bug hunt found nothing new. Test coverage is thorough for the surface it targets (descriptor matrix, freeze/seal, symbol keys, SHARE_ENV variant, glued/chained short flags, --expose-gc inherit chain, NODE_OPTIONS glued forms). It is stacked on #34424, so some of the surrounding web_worker.rs/Worker.cpp context belongs to the base branch. Given the scope and the number of judgment calls encoded in the policy table, deferring to a human reviewer rather than auto-approving.

Resolve the process.env overlap with the merged process compat work (#31831):
main's JSEnvironmentVariableMap and windowsEnv implementations supersede the
parallel JSProcessEnvMap machinery from this branch. Keep this branch's
SHARE_ENV descriptor tightening by porting it onto main's JSSharedEnvMap
(full data descriptor required, symbol keys throw after validation,
preventExtensions fails), and keep the JSLockHolder in addGc for per-worker
--expose-gc.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

🤖 Adopted. Latest at 945f6c3: symbol-key handling aligned across all env write paths — JSSharedEnvMap::put now throws the conversion TypeError like its own defineOwnProperty, the regular map, the Windows set trap, and node (pinned via the SHARE_ENV subprocess matrix, fail-before verified); the windowsEnv defineProperty trap validates the descriptor before coercing the key, so a symbol key with an invalid descriptor reports ERR_INVALID_OBJECT_DEFINE_PROPERTY like POSIX/node (pinned cross-platform, verified on native Windows). Clippy clean; zero unresolved threads; CI running.

cirospaciari and others added 3 commits August 7, 2026 12:39
main's byte-search source lint rejects scalar iter().position loops.

No-Verification-Needed: one-line lint conformance; the source-lint test passes locally
After the main merge took #31831's design, initializeWorker's Windows
branch stored a bare object, so a worker's process.env (explicit env or
the no-option snapshot) had no case-insensitivity and no set/defineProperty
validation; test-worker-process-env.js failed with a missing
ERR_INVALID_OBJECT_DEFINE_PROPERTY on both Windows lanes. The proxy wrap
is factored out of createEnvironmentVariablesMap and reused with the
OS-env sink disabled so worker writes stay thread-local like node.

@claude claude 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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/jsc/bindings/JSEnvironmentVariableMap.h:44-47 — The merge with main dropped the preventExtensions override from JSEnvironmentVariableMap (and never added a trap to the Windows windowsEnv Proxy) — only JSSharedEnvMap has it (JSEnvironmentVariableMap.cpp:613). So on POSIX main-thread and Windows, Object.preventExtensions(process.env) succeeds (Node throws) and a caught Object.freeze(process.env) leaves the env non-extensible with the wrong error code, contradicting the PR's own "Both env maps fail [[PreventExtensions]]" claim. Add static bool preventExtensions(JSObject*, JSGlobalObject*) { return false; } to JSEnvironmentVariableMap and a preventExtensions trap returning false to windowsEnv.

    Extended reasoning...

    What the bug is

    The PR description states "Both env maps fail [[PreventExtensions]] so freeze/seal throw at that step (plain TypeError, no code) and the env stays extensible", and the timeline confirms commit 490b699 implemented this for both JSEnvironmentVariableMap (POSIX process.env) and JSSharedEnvMap (SHARE_ENV workers). But after the 2026-08-07 merge with main — which brought in #31831's JSEnvironmentVariableMap implementation — the merge only ported the preventExtensions override to JSSharedEnvMap. Robobun's own merge summary ("preventExtensions fails so freeze/seal throw and the map stays extensible" — listed only under "SHARE_ENV descriptor tightening, ported onto main's JSSharedEnvMap") confirms the omission. JSEnvironmentVariableMap's header at lines 44-47 declares put/putByIndex/defineOwnProperty/deleteProperty but not preventExtensions, so CREATE_METHOD_TABLE(JSEnvironmentVariableMap) inherits JSNonFinalObject::preventExtensions, which marks the structure non-extensible and returns true. The Windows windowsEnv Proxy in ProcessObjectInternals.ts has no preventExtensions trap either (grep confirms zero hits), so the default trap forwards to the plain internalEnv target and succeeds.

    Step-by-step proof

    On POSIX main-thread (or Windows via the proxy), process.env is a JSEnvironmentVariableMap (see createEnvironmentVariablesMap in JSEnvironmentVariableMap.cpp / the #if OS(WINDOWS) branch that wraps a plain object in the windowsEnv Proxy):

    1. Object.preventExtensions(process.env)JSObject::preventExtensions (inherited) marks the structure non-extensible and returns true. Node throws TypeError: Cannot prevent extensions; here it silently succeeds. Object.isExtensible(process.env) now returns false (Node: true).
    2. try { Object.freeze(process.env) } catch {}Object.freeze first calls [[PreventExtensions]]succeeds (env now non-extensible), then walks own keys calling [[DefineOwnProperty]] with {writable:false, configurable:false}. The first key hits the new JSEnvironmentVariableMap::defineOwnProperty override, which throws ERR_INVALID_OBJECT_DEFINE_PROPERTY ("only accepts a configurable, writable, and enumerable data descriptor"). The user catches it. Node instead throws a plain TypeError: Cannot freeze (no .code) at the [[PreventExtensions]] step, and the env stays extensible afterwards.
    3. After step 2, process.env.NEW_VAR = 'x' now fails (silently in sloppy mode, throws in strict mode) because the structure is non-extensible — state corruption on the common main-thread path that Node does not exhibit.

    On Windows the same sequence goes through the windowsEnv Proxy: no preventExtensions trap → default trap calls Reflect.preventExtensions(internalEnv) on the plain-object target → succeeds → same divergence.

    Why nothing prevents it

    • Grep of JSEnvironmentVariableMap.cpp for preventExtensions returns exactly one hit at line 613, inside JSSharedEnvMap's class body. JSEnvironmentVariableMap has no override.
    • git show 45eda514 (main's #31831) confirms JSEnvironmentVariableMap was introduced without preventExtensions; the merge commit 3bb4d69 did not add it.
    • ProcessObjectInternals.ts has zero preventExtensions/isExtensible traps.
    • The only test coverage (worker_threads.test.ts:478-479, Object.freeze(process.env)out.extensibleAfterFreeze = Object.isExtensible(process.env)) runs inside a SHARE_ENV worker child, so it exercises JSSharedEnvMap and passes. process.test.js has zero freeze/preventExtensions/isExtensible assertions on the main-thread map.

    Why this is not a mere description mismatch

    The rubric's "PR-description mismatches are nit by default" exception does not apply: the code is clearly the code that's wrong — it produces incorrect behavior (accepts [[PreventExtensions]], leaves the env non-extensible after a caught freeze, throws the wrong error class) independent of what the description says. This is the PR's headline feature ("env descriptor validation", the Object.freeze / seal / preventExtensions row in the description table), the branch had it right pre-merge, and the merge silently dropped it for 2 of 3 parallel implementations. REVIEW.md: "Fix the whole class in the same PR — parallel switch arms, … POSIX/Windows branches" and "Cover the variant matrix, not just the repro".

    How to fix

    Two changes, both one-liners:

    1. In JSEnvironmentVariableMap.h, add alongside the existing method-table overrides (and mirror it in the .cpp if defined out-of-line, matching JSSharedEnvMap's in-class definition at line 613):
      static bool preventExtensions(JSC::JSObject*, JSC::JSGlobalObject*) { return false; }
    2. In ProcessObjectInternals.ts windowsEnv, add a Proxy trap:
      preventExtensions() { return false; },

    And add a main-thread pin in process.test.js (spawned subprocess, all platforms) asserting Object.preventExtensions(process.env) throws, Object.isExtensible(process.env) stays true after a caught Object.freeze, and the freeze error has no .code.

Comment thread src/jsc/web_worker.rs
…Windows proxy

The main merge took #31831's JSEnvironmentVariableMap, which lacks the
preventExtensions override 490b699 had added (only JSSharedEnvMap kept
it), and the windowsEnv Proxy never had the trap. Object.preventExtensions
succeeded where node throws, and a caught Object.freeze left process.env
non-extensible with the wrong error. Both now return false like
JSSharedEnvMap, pinned in-process across platforms.
Comment thread src/jsc/bindings/JSEnvironmentVariableMap.cpp Outdated
Comment thread test/js/node/process/process.test.js
Comment thread src/jsc/bindings/JSEnvironmentVariableMap.cpp Outdated
robobun and others added 3 commits August 7, 2026 20:46
…-descriptor-validation

ErrorCode.ts: keep both new codes. node_process.rs: keep this branch's design - the inline execArgv parser main re-added was deliberately replaced by worker_exec_argv::collect_process_exec_argv_tokens.

No-Verification-Needed: merge resolution restoring the branch's CI-green design; CI verifies the merged tree
…-descriptor-validation

node_process.rs: keep this branch's design again - the inline parser lives in worker_exec_argv here.

No-Verification-Needed: merge resolution restoring the branch's CI-green design; CI verifies the merged tree
Comment thread test/js/node/test/parallel/test-worker-execargv-invalid.js
Comment thread src/jsc/bindings/JSEnvironmentVariableMap.cpp Outdated
…tforms

JSSharedEnvMap::put silently accepted symbol keys via Base::put while its
defineOwnProperty (and node, the regular map, the Windows set trap) throw
the conversion TypeError; the two write hooks on one object disagreed.
The windowsEnv defineProperty trap checked the symbol key before the
descriptor, reporting the plain TypeError where POSIX and node report
ERR_INVALID_OBJECT_DEFINE_PROPERTY for an invalid descriptor. Pinned via
the SHARE_ENV subprocess matrix (symbolSet) and the descriptor-first
order in process.test.js.

@claude claude 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.

I reviewed this PR again at 945f6c3 and found no new issues — the symbol-key ordering finding from the previous pass is addressed (JSSharedEnvMap::put now throws on symbols, and the Windows defineProperty trap validates the descriptor before the key). Given the scope — a new ~630-line worker execArgv policy module with Rust↔C++ FFI, worker-thread init changes (JSLockHolder in addGc, expose_gc plumbing), and platform-gated Windows env-snapshot wrapping — a human sign-off is still warranted.

Checked this pass: the three preventExtensions sites (JSEnvironmentVariableMap, JSSharedEnvMap, windowsEnv Proxy) plus the process.test.js pin are all present; scan_process_exec_argv's LazyLock cache is process-constant so per-worker staleness isn't a concern; Bun__Worker__validateExecArgv's reinterpret_cast<WTF::StringImpl* const*>(execArgv.begin()) is guarded by the static_assert(sizeof(WTF::String) == sizeof(WTF::StringImpl*)); the Windows worker-snapshot path uses assertNoException on seed puts (JSString values, OOM-only).

Extended reasoning...

Overview

This PR spans 18 files across Rust (worker_exec_argv.rs new, web_worker.rs, jsc_hooks.rs, node_process.rs, VirtualMachine.rs), C++ (JSEnvironmentVariableMap.{cpp,h}, ZigGlobalObject.cpp, JSWorker.cpp, Worker.cpp), builtin JS (ProcessObjectInternals.ts), and tests. It adds Node-parity process.env descriptor validation across all three env-map implementations (POSIX exotic, SHARE_ENV, Windows Proxy), a worker execArgv/NODE_OPTIONS policy table with per-worker --expose-gc, and a shared process.execArgv token normalizer.

Security risks

Low. The new validation paths reject more inputs than before (fail-closed). NODE_OPTIONS tokenization reuses the existing append_options_env quote-aware parser; no new shell/exec surface. The FFI boundary passes WTF::StringImpl* arrays read-only with a size assertion. No auth/crypto/permissions code touched.

Level of scrutiny

High. This is not a mechanical change: it introduces a 630-line policy module derived from Node's option tables, rewires worker-thread init to parse execArgv at create() time (storing results on WebWorker for later start_vm use), adds a JSLockHolder to addGc because the new call site runs before the worker holds the API lock, and platform-gates a new Windows worker-env-snapshot proxy wrap. The WorkerExecArgv struct changed from Copy to Clone (it now owns a Vec), and scan_process_exec_argv caches per-process in a LazyLock. Each of these is individually reasonable but the interaction surface (worker init ordering, cross-thread state, platform branches) is exactly where subtle bugs hide.

Other factors

The PR has been through ~19 iterations; every prior finding I raised (preventExtensions merge regression, stale test title, redundant forward decl, byte-search lint, symbol-key ordering) is addressed at HEAD, with tests pinning each fix. Test coverage is thorough (17 new execArgv test cases, cross-platform env-descriptor matrix, SHARE_ENV subprocess). The vendored test-worker-execargv-invalid.js skip-passes (documented in the description); its cases are pinned in worker_threads.test.ts. A maintainer (cirospaciari) has been resolving threads. Given the breadth, a human should confirm the NODE_FLAGS policy table entries and the Windows worker-snapshot proxy wrap before merge.

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

🤖 Adopted. Latest at c52ec62: the regular map's put path now uses node/V8's exact "Cannot convert a Symbol value to a string" (the last of five write paths to align), and the inherit-path preload scope is documented honestly — an inheriting worker does not re-run CLI -r (explicit execArgv does; node re-runs in both; widening is left as an explicit follow-up decision), with the PR description's round-trip claim scoped accordingly. All review threads resolved; clippy clean. Only CI red remains the roaming base-branch !exception() termination race (#34424). CI running.

Comment thread src/runtime/cli/worker_exec_argv.rs
The validator gated the pop on the token not starting with a dash, so
NODE_OPTIONS '--redirect-warnings --no-warnings' failed with 'requires an
argument' while the execArgv sibling (and node's parser) consume the next
token as the value. Drop the gate so the two validators agree.
Comment thread src/jsc/bindings/JSEnvironmentVariableMap.cpp
Comment thread src/jsc/web_worker.rs
…path; document the inherit-path preload scope

The fifth sibling still said 'Cannot convert a symbol to a string'; node/V8
and the other four write paths say 'Cannot convert a Symbol value to a
string'. The parse_worker_exec_argv doc claimed the parent VM carries
preloads for inheriting workers; only cpu-prof has that fallback, so say
plainly that inherit does not re-run CLI -r and widening is a behavior
decision. PR description scoped the round-trip claim the same way.

@claude claude 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.

I reviewed this PR again after c52ec62 and found no new issues — the last three nits (regular-map Symbol message, NODE_OPTIONS required-value dash gate, inherit-path preload doc/claim) are all addressed. Given the scope — a new 560-line execArgv policy subsystem, JSC method-table overrides across three process.env implementations, worker-init lifecycle changes (JSLockHolder, execArgv parse moved to create()), and Windows-gated Proxy/C++ paths — a human look is still worthwhile.

What was reviewed:

  • preventExtensions now on all three env maps (regular/shared C++ + windowsEnv Proxy) with the restored process.test.js pin.
  • All five symbol-key write paths agree on validation order and the exact V8 message; JSSharedEnvMap::put no longer silently accepts symbols.
  • scan_exec_argv and the NODE_OPTIONS validator now agree on required-value token consumption (unconditional pop), pinned by the new dash-value test.
  • Inherit vs. explicit execArgv preload divergence is now documented as intentional in VirtualMachine.rs and the PR description; the round-trip claim is scoped to validation/--expose-gc.
Extended reasoning...

Overview

Two Node-compat features across 18 files: (1) process.env defineOwnProperty/preventExtensions validation on all three backing implementations (JSEnvironmentVariableMap, JSSharedEnvMap, the Windows windowsEnv Proxy) plus the Windows worker-snapshot path via a new wrapInWindowsEnvProxy; and (2) a worker execArgv/NODE_OPTIONS policy validator (src/runtime/cli/worker_exec_argv.rs, ~560 lines) driven from JSWorker.cpp, with per-worker --expose-gc and --require honoring wired through web_worker.rs. process.execArgv re-parsing is unified with the worker validator via collect_process_exec_argv_tokens(). ~340 lines of new tests in worker_threads.test.ts plus three vendored upstream files (one skip-passes by design, noted in the description).

Security risks

Low. Nothing auth/crypto/permissions-adjacent. The new validators parse user-supplied execArgv/NODE_OPTIONS strings but only to reject — no shell, no path resolution beyond what preloads already did, and preloads via explicit execArgv were already honorable via the preload option. FFI surface (Bun__Worker__validateExecArgv/...NodeOptions) copies WTF strings into owned Vec<u8> before parsing, so no borrowed-slice lifetime hazard. transferToWTFString() consumes the Rust-allocated BunString on the reject path, so no leak.

Level of scrutiny

High. This adds JSC method-table overrides (preventExtensions, defineOwnProperty on JSSharedEnvMap), reorders worker-init (execArgv parse moved from start_vm to create(), JSLockHolder added around addGc), introduces a hand-rolled argv re-parser that must agree with bun_clap's short-flag/glued/chained forms, and has substantial #if OS(WINDOWS) branching. The PR has been through 24 iterations; earlier rounds caught a merge-dropped preventExtensions override, sibling-divergent validators, and a JSLockHolder-missing assertion abort — all fixed, but that history argues for a human pass on the final shape.

Other factors

All prior automated findings (including the three from 2026-08-08) are resolved at HEAD c52ec62, and this run's bug hunt found nothing. Test coverage is thorough (variant matrices for glued/chained short flags, both validators pinned to agree, SHARE_ENV descriptor validation in a subprocess, freeze/isExtensible pinned on the regular map). The one open behavior decision — inherit-path workers not re-running CLI -r where node does — is now explicitly documented as deferred rather than claimed as parity. The PR is stacked on #34424 and carries a cherry-pick of #35182; a maintainer should confirm the base-branch CI red mentioned by robobun (!exception() termination race) is indeed unrelated.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants