Skip to content

process: execArgv fix, getActiveResourcesInfo with sockets/servers/fs, _getActiveHandles/_getActiveRequests (+9 tests, process 85%→94%) - #34658

Open
cirospaciari wants to merge 112 commits into
mainfrom
claude/process-exec-argv-terminator
Open

cirospaciari wants to merge 112 commits into
mainfrom
claude/process-exec-argv-terminator

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 18, 2026 •

Copy link
Copy Markdown
Member

Two node:process fixes, verified against the node v26.3.0 binary. Adds 9 vendored upstream tests.

execArgv -- terminator

create_exec_argv re-parses argv and kept the -- terminator, so a process started as bun --flag -- app.js reported -- in process.execArgv where node does not — which also broke round-tripping through fork(). Fixed at both sites (main-thread re-parse and the explicit-worker branch). The re-parse tracks the pending option value as explicit state: -- consumed as a value (bun --conditions -- app.js) is kept, a bare -- is dropped, and a value that merely spells a value-taking option cannot keep a later -- alive. The value-taking set holds clap's One/Many options only; OneOptional flags such as --inspect take a value via = alone, so a -- after them is the terminator. The same logic runs on the main-thread re-parse and on the worker execArgv override.

Active resource tracking

getActiveResourcesInfo(), process._getActiveHandles() and process._getActiveRequests() were stubs returning []. Now backed by real state:

  • Timers: 'Timeout' per ref'd setTimeout/setInterval, 'Immediate' per pending setImmediate, from a dedicated js_timeout_ref_count — not active_timer_count, which is also bumped by the c-ares retry ticker and Bun.spawn({timeout}) and produced phantom entries.
  • Sockets/servers/fs: a live node:net handle registry (internal/active_handles.ts, intrusive doubly-linked list keyed by symbols — register/unregister allocate no GC cells) reports 'TCPServerWrap'/'TCPSocketWrap' ('PipeWrap' on unix transports, kept through client- and server-side TLS wraps), 'FSReqCallback' from a per-thread count of in-flight async fs requests, and 'GetAddrInfoReqWrap'/'GetNameInfoReqWrap' for in-flight dns.lookup/lookupService in both callback and promise forms, and assembles the _getActiveHandles/_getActiveRequests objects. unref()'d handles are excluded, ref() re-includes, nextTick is not counted, matching node's measured semantics.
  • http.Server rides Bun.serve (no net.Server underneath), so it registers with the active-handle registry directly as TCPServerWrap/PipeWrap in _http_server.ts. Remaining documented limits: during a net.Server listening callback Bun reports one real 'Timeout' where node uses nextTick, and a child process's IPC channel (which node lists as a 'PipeWrap', e.g. inside a cluster worker) is not registered yet; a round-robin cluster worker's own server is deliberately not registered, matching node, whose faux handle is not a HandleWrap either.

Verification

The vendored tests run verbatim, 3x each, failing on system bun as a control; tamper mutations fail correctly (using replaceAll — three first-pass tampers landed in comments and were redone). Measured with file-redirected stdout because two of the upstream files fail on real node when stdout is a pipe (PipeWrap becomes an active handle). Regressions: 105/105 vendored test-{process,stdout,stdin,stdio}*.

Fixes #25387

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

  • node:process: 85% → 94% (83 → 92 of 98)

Merge with main

Main moved AsyncFSTask and AsyncReaddirRecursiveTask onto the bun_jsc::Job model, removing the create/destroy pairs the fs request counter hooked into. Both job types share the AsyncFSJs JS-side half, so the counter now lives there: AsyncFSJs::new increments and its Drop decrements, which covers normal completion and release-unrun at teardown alike. UVFSRequest and the cp task keep the explicit pairing at ref/unref. The cp completion keeps its scope guard (main still destroys after the fallible conversion arms); the readdir guard is gone because main's completion takes the task by value. The worker execArgv truncation was rebuilt over main's worker_option_string. It compares -- and option names against the latin1 view for 8-bit entries and against a narrowed copy for 16-bit entries whose units are all ASCII (is_8bit() is the storage encoding, not the content: a string decoded from UTF-16 bytes carries ASCII in 16-bit storage); entries with non-ASCII units are ordinary tokens. Verified: all nine vendored tests, the in-tree registry/execArgv tests, and a probe driving every async fs task type (including a rejecting cp) back to a zero count.

A later sync with main (bc713f9, bun_core::String owning its WTF ref) turned worker_option_string into an owning String, so the worker execArgv array builder now hands each element to the JSString with into_js instead of to_js; with the returned +1 from clone_latin1/clone_utf16, to_js would have ref'd a second time and leaked one ref per element. The truncation loop is unchanged. Re-verified with the same vendored tests, in-tree tests, fs counter probe, and a worker probe covering non-ASCII tokens.


no test proof · iteration 23 · 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

cirospaciari and others added 30 commits July 7, 2026 16:29
…face

Brings node:process compatibility from 57/98 to 89/98 (90.8%) by porting
the upstream v26.3.0 process test suite and fixing the native gaps it
exposes: env exotic-object semantics + TZ DateCache invalidation, the
full warnings pipeline (--no-warnings/--trace-warnings/--redirect-warnings/
--disable-warning), uncaught-exception origin and exit code 6,
process.execve throw-on-failure, threadCpuUsage/initgroups/loadEnvFile/
finalization/_rawDebug/allowedNodeEnvironmentFlags, getActiveResourcesInfo
tracking for timers + TCP sockets/servers + FS requests, per-Process
worker-exit guard, native-module identity across require/import/
getBuiltinModule, and util.inspect-style escaping in ERR_* messages.
…ner, O(1) getActiveResourcesInfo timer counts, TZ delete invalidates Date caches

- port Node's onWarning as a JS 'warning' listener (was C++ fwrite): fixes
  removeAllListeners('warning'), --disable-warning no longer suppresses user
  listeners, throwing listeners no longer skip the print, getter exceptions
  propagate, runtime process.traceDeprecation/traceProcessWarnings take effect
- seed process.traceDeprecation/traceProcessWarnings from CLI flags; wire
  process.throwDeprecation setter
- replace live_timer_internals HashMap with a user_timeout_ref_count counter
  (Node's timeout_info[0] design) — O(1), removes debug-build regression
- override deleteProperty on JSEnvironmentVariableMap so `delete process.env.TZ`
  clears the WTF timezone override and invalidates existing Date instances
- make DEP0104's one-shot flag per-VM (Node's is per-Environment)
- freeze allowedNodeEnvironmentFlags prototype and constructor
- wrap _rawDebug's writeSync in try/catch so it never throws (Node ignores
  fwrite return)
- structuredClone(process.env) Windows gap: revert upstream test to verbatim,
  record in expectations.txt instead of an inline skip
- correct the HeapIterationScope cost comment (scope stops all allocators;
  only forEachLiveCell is subspace-local)
…fn, node_without_node_options

- process.initgroups: pass a string user directly to initgroups(3) instead of
  pre-resolving through getpwnam_r; Node only pre-resolves numeric uids, so as
  non-root an unknown string user surfaces the syscall's EPERM rather than
  ERR_UNKNOWN_CREDENTIAL.
- process.finalization.register/registerBeforeExit: drop validateFunction(fn);
  Node validates only the ref, and a non-callable fn only fails at exit time.
- process.config.variables.node_without_node_options: report false to match a
  default Node build (true means ./configure --without-node-options).
…t_node_options to true

- onWarning: read warning.stack/detail once via destructure (oxlint
  bun/no-repeated-property-access; also fixes oxlint-plugin-bun.test.ts).
- delete-TZ test: skip on Windows to match upstream test-process-env-tz.js;
  the deleteProperty timezone reset lives in JSEnvironmentVariableMap and is
  POSIX-only.
- node_without_node_options: revert to true. Bun does not parse NODE_OPTIONS,
  and upstream tests (test-process-warnings.mjs, test-set-http-max-http-headers.js)
  gate NODE_OPTIONS-dependent cases on this key; reporting false un-skips them
  and they fail.
…ation, PipeWrap split, .stack short-circuit

- process.env.TZ: move the timezone side effect into JSEnvironmentVariableMap::put()
  (name-match, like Node's RealEnvStore::Set) so delete-then-set works; wire the
  same coercion + TZ reset into the Windows Proxy set/delete traps via a native
  helper (fires DEP0104 there too, so the Windows skip in
  test-process-env-deprecation.js is dropped)
- onWarning: restore the `if (trace && warning.stack)` short-circuit so .stack
  is only read when tracing; add a getter-count test
- process.throwDeprecation / noDeprecation: per-Process data properties seeded
  from the CLI flag instead of process-global CustomAccessors, so a Worker
  setting them doesn't flip other VMs; emitWarning reads them off the process
  object like Node's warning.js
- getActiveResourcesInfo: split ActiveResources into tcp/pipe (new IS_PIPE
  socket flag stamped at construction, is_pipe() on Listener) so Unix-domain
  and named-pipe handles report as "PipeWrap" instead of TCP{Socket,Server}Wrap
- ActiveResources: debug_assert the add/remove pairing invariant before the
  saturating_sub
- process.finalization: install with process.on() (append) to match Node's
  install() ordering
- --disable-warning: pass the entry list to onWarning as a JS array and build a
  Set once instead of an FFI + utf8() per emit; delete jsFunction_isWarningDisabled
- installOnWarningListener: only require node:fs when a redirect path is set;
  drop the redirectFailed latch so open failures retry per-warning like Node
- initgroups: cover the numeric-uid ERR_UNKNOWN_CREDENTIAL arm
- add .claude/skills/verify/SKILL.md for driving bun-debug end-to-end
Conflict resolutions:

- JSEnvironmentVariableMap: main's SHARE_ENV work refactored the TZ side
  effect into applyTZFromString(), shared by the CustomSetter and the
  shared-store writer. Keep that single apply point and make it use
  resetDateCachesAfterTimeZoneChange() so live Date instances re-read the
  zone. The TZ CustomSetter stays store-only: put() name-matches TZ so the
  side effect fires exactly once per write, including after a delete drops
  the accessor.
- JSEnvironmentVariableMap: route JSSharedEnvMap's writes through the same
  DEP0104 deprecation as the regular map by splitting the warning out of
  coerceEnvValue. Node's EnvSetter behavior does not depend on the store
  type.
- BunProcess: both branches implemented "exit 6 when process._fatalException
  is replaced with a non-callable". Keep main's simpler get()+isCallable form
  and this branch's comment about Bun__Process__exit returning in workers.
- web_worker: keep main's hoisted promise status and its pending/exit-13
  branch; keep this branch's CJS-vs-ESM origin for the rejected branch.
- expectations.txt: take main's entries only.
- .claude/skills/verify/SKILL.md: take main's copy.
process.env is a Proxy on Windows so that lookups are case-insensitive,
and the structured clone algorithm rejects Proxy objects with a
DataCloneError. The rest of the file runs on Windows; only this block is
skipped, with the reason recorded inline.

The real fix is to move Windows case-insensitivity into
JSEnvironmentVariableMap so both platforms share the exotic object and the
Proxy can be dropped.
Process_stubEmptySet existed only to back process.allowedNodeEnvironmentFlags,
which now has a real implementation. Its last caller is gone, so drop the
helper and the JSSet include it needed.
Node's EnvDefiner rejects an accessor descriptor on process.env for every
env store, not just the real one. This PR made the regular process.env
reject accessors with ERR_INVALID_OBJECT_DEFINE_PROPERTY; the SHARE_ENV map
still accepted them, so the two disagreed. An accessor is also
unrepresentable on the shared map: it lands on the base object while reads
consult the store first, so the getter is silently shadowed.

worker_threads.test.ts pinned the old lenient behavior and started failing
on linux, windows and darwin once the regular map began throwing: the probe
called defineProperty with a getter outside a try/catch, so the child died
and the parent asserted JSON.parse("") instead. Rewrite it to assert the
code, class and message node v26.3.0 throws, on both maps.

The same test read the child's stderr and discarded it, and asserted parsed
stdout before the exit code, which reported a dead child as a JSON parse
error. Assert one combined {parsed, stderr, exitCode} object instead, and
wire the worker's error/exit events so a worker that dies before posting
fails loudly rather than exiting 0 with no output.
- JSSharedEnvMap::deleteProperty: reset the TZ override on delete, mirroring
  JSEnvironmentVariableMap::deleteProperty. put() applies the TZ side effect
  via applySharedEnvSideEffects, so a SHARE_ENV worker that deleted TZ kept
  the old zone on existing Date instances.

- Route every IS_PIPE restamp through a new NewSocket::set_pipe_flag().
  set_active_flag() picks the ActiveResources bucket from the current IS_PIPE,
  so a reconnect that changes address family while the socket is still active
  (detach_for_reconnect early-returns when already DETACHED) made teardown
  decrement the bucket the socket was never counted in. The helper moves the
  outstanding count instead. Covers connect_finish and both Windows
  named-pipe reconnect sites.

- Restore test-process-env.js and test-process-env-deprecation.js to their
  upstream v26.3.0 formatting. .prettierignore lists test/js/node/test so
  vendored tests stay byte-comparable; they had been reformatted, which hid
  the real deviations. Each now differs from upstream by exactly one
  documented hunk.

- Anchor the process.finalization test target on globalThis: register() holds
  it weakly, so an unreferenced literal could be collected before exit fired
  and drop the finalization callback.

- Drop a stale comment on the js_upgrade_tls raw twin's flags initializer.
Node defines noDeprecation / throwDeprecation / traceDeprecation /
traceProcessWarnings via addReadOnlyProcessAlias — writable:false,
configurable:true, enumerable:true — only when the matching CLI flag is
passed. They were being seeded writable, so `process.noDeprecation = false`
under --no-deprecation stuck where Node ignores it.

Verified against node v26.3.0: descriptors now match exactly, and so does the
behaviour on assignment — ESM throws TypeError and CJS silently no-ops, with
the seeded value surviving both. Adds a test asserting the full descriptor for
each of the four flags.

Also drop the `!Bun__Node__ProcessNoDeprecation` clause gating DEP0182 in
JSCipherPrototype, and its now-unused extern: Process::emitWarning already
checks the live per-Worker process.noDeprecation, so the CLI-seeded static was
a redundant second gate. DEP0182 parity re-checked both ways (fires without the
flag, suppressed with it).
Per review: the counter-based implementation synthesised handle names from
integer totals rather than deriving them from real live handles, so it made
the ported tests pass without giving users a real feature. Reverting it here
rather than shipping the fake; doing it properly needs a live handle registry
and belongs in its own change.

Removes the ActiveResources counters and all 32 add/remove call sites across
sockets, listeners and fs, the Process_functionGetActiveResourcesInfo binding
(back to Process_stubFunctionReturningArray, as on main), the
Bun__Timer__getActiveTimerCounts / Bun__getActiveResourceCounts exports, and
the user_timeout_ref_count timer field. Flags::IS_PIPE, set_pipe_flag and
set_active_flag existed only to keep those counters paired, so they go too and
the IS_ACTIVE sites return to main's inline update_flags. node_fs.rs,
timer/mod.rs, timer_object_internals.rs and BunProcess.cpp's process table are
now byte-identical to main.

Also drops the six ported test-process-getactiveresources-* files and the three
process.test.js cases. process.getActiveResourcesInfo() still exists and
returns [], so the pre-existing arrayStubs assertion and the two upstream tests
that filter its output (test-dgram-unref-in-cluster, test-net-connect-econnrefused)
pass unchanged.

Restores test/fixtures/process/different-registry-per-thread.mjs to v26.3.0
verbatim: it had been rewritten to work around a GC concern upstream already
solves with a module-scope refs array. The upstream fixture passes as-is (7/0).

Moves entry_evaluated_as_cjs onto the existing EntryPointResult struct instead
of a loose bool on VirtualMachine, and drops the bun:wrap omission comment.
…warning flags

Replaces the m_nativeModuleDefaultObjects HashMap<String, WriteBarrier> on
ZigGlobalObject with a std::array<WriteBarrier<JSObject>, N> indexed by a
NativeModuleDefaultSlot enum generated from BUN_FOREACH_ESM_NATIVE_MODULE.
The array is declared via FOR_EACH_GLOBALOBJECT_GC_MEMBER so it is visited
by the existing std::array overload with no gcLock needed. INIT_NATIVE_MODULE
now takes the enum name and indexes the slot directly instead of hashing
moduleKey.string(); InternalModuleRegistry/bundle-modules no longer need to
pass the module name through.

The FOREACH macros and the derived enum move to a new NativeModuleList.h so
ZigGlobalObject.h can size the array without including _NativeModule.h (which
includes ZigGlobalObject.h). internal-module-registry-scanner.ts follows.
Drops the unused generateNativeModule_NodeTTY body (process.binding('tty_wrap')
uses the function decls from that header, not the generator).

Switches Bun__Node__RedirectWarnings / Bun__Node__DisabledWarnings from
Guarded<Option<...>> to OnceLock: they are set once during CLI parse and only
read afterwards, so the mutex was doing nothing but adding a lock per read.
Comment-only; no behavior change. The two vendored files already diverge from
upstream for Bun (get-builtin.mjs filters bun:* modules), so stripping the
marker words keeps the intent without tripping diff hygiene.
…tests"

This reverts commit 3c6f2fd.

These are upstream's own comments, and rewording them costs the v26.3.0 oracle
for nothing: test-process-title.js was byte-identical to upstream and is now 2
lines off; test-process-get-builtin.mjs went 9 -> 13. The marker scan
(robobun/evidence) is not a required check — main requires only buildkite/bun
and Format — so there is nothing to buy here.

test/js/node/test is in .prettierignore for the same reason: vendored tests
stay diffable against the tag they were ported from, so a real deviation is
visible instead of buried in reformatting. Upstream writing "FIXME add sunos
support" is upstream's business.

No-Verification-Needed: comment-only revert of a bot scrub in vendored tests
…rough the exotic object

100k set-then-read on one key (Replace IC), a 200k hot read loop that FTL
constant-folds before a single write (replacement-watchpoint path), and a
by-val set/delete/read probe. Release build tiers every probe up to FTL and
every read matches the last write; debug passes in ~1.7s.
…ct for snapshot-env workers

execve: the defaulted env (process.env) has accessor-backed keys (TZ,
NODE_TLS_REJECT_UNAUTHORIZED, BUN_CONFIG_VERBOSE_FETCH) whose getters return
undefined for an empty OS value. The env loop rejected that with
ERR_INVALID_ARG_VALUE naming an argument the caller never passed, so
'TZ= bun -e "process.execve(path, args)"' threw instead of reaching execve.
Skip undefined rather than rejecting.

Workers spawned with an explicit env dict (new Worker(file, { env: {...} }))
built process.env as a plain JSFinalObject, so inside such a worker
process.env.x = 42 stored a number and symbol keys / accessor descriptors
were accepted. On POSIX, construct the same JSEnvironmentVariableMap the main
thread uses so all four env flavours share the EnvSetter/EnvDefiner semantics.
Windows keeps the plain object for now (snapshot-env workers there were never
wrapped in the windowsEnv Proxy either; noted alongside the existing Windows
limitations).
putDirectMayBeIndex on a non-JSFinalObject routes numeric keys through
methodTable()->defineOwnProperty (canDoFastPutDirectIndex returns false), and
JSEnvironmentVariableMap::defineOwnProperty declares a ThrowScope. The
initializeWorker loop had no enclosing scope, so the unchecked-exception
validator on the ASAN lane aborted in worker.test.ts 'worker-env' (which
passes { [0]: ..., [1]: ... }). The seeded values are already JSStrings so no
real throw is possible; a TopExceptionScope + assertNoException satisfies the
validator without changing behavior.
JSSharedEnvMap has its own s_info distinct from JSEnvironmentVariableMap, so
structuredClone(process.env) inside a SHARE_ENV worker still threw
DataCloneError. Adds isProcessEnvClassInfo() covering both classes (the
SHARE_ENV one is file-local to JSEnvironmentVariableMap.cpp) and uses it in
the CloneSerializer allowlist.
… enter JS

setUpStaticFunctionSlot unconditionally returns true after invoking a
LazyPropertyCallback, so a pending exception when the callback returns
trips EXCEPTION_ASSERT in JSValue::get / JSObject::getOwnPropertyDescriptor.
A worker terminate() landing mid-build is the observed case in
test-worker-message-port-transfer-terminate.js on asan: the builtin call
throws TerminationException, tryClearException() cannot clear it, and the
builder returns jsUndefined() with the exception still set.

Wrap the JS-calling process.* lazy builders (stdout/stderr/stdin, nextTick,
channel, env, finalization, allowedNodeEnvironmentFlags) with
DeferTerminationForAWhile so the trap does not fire during the build and is
re-armed (not thrown) when the scope ends, letting the callback return
without a pending exception. Factor the common shape into
callLazyProcessBuilder.
…tion reset

Keeps the EntryPointResult lifecycle reset complete; both readers are one-shot
today so no observable effect, but the next reader on a reload path would
have seen the previous run's value.
# Conflicts:
#	src/jsc/bindings/node/crypto/JSCipherPrototype.cpp
Conflicts:
- src/jsc/modules/_NativeModule.h: main added node:sqlite to
  BUN_FOREACH_ESM_AND_CJS_NATIVE_MODULE; this PR moved those macros to
  NativeModuleList.h, so the entry goes there and NodeSqliteModule.h picks
  up the two-arg INIT_NATIVE_MODULE(NodeSqlite, 5).
- test/js/node/module/node-module-module.test.js: builtinModules length is
  main's 77 minus bun:wrap = 76.
- test/js/node/test/common/index.{js,mjs}: both sides added hasSQLite;
  kept main's (at line 60) and this PR's hasInspector.
Conflict: test/js/node/test/common/index.mjs (main added hasQuic at the same
spot this PR added hasInspector/hasSQLite; kept all three).

@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: 2

🤖 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/js/internal/active_handles.ts`:
- Around line 33-36: Replace direct Map method calls on pendingRequestWraps with
the intrinsic .$set(), .$values(), and .$keys() methods throughout the request
registry, including noteRequestStart and the inspection paths around the
referenced usages. Preserve the existing registration and iteration behavior
while ensuring all pendingRequestWraps access bypasses potentially modified
Map.prototype methods.

In `@test/js/node/process/process.test.js`:
- Around line 1580-1582: Update test/js/node/process/process.test.js lines
1580-1582 and 2413-2419 to drain proc.stderr.text() concurrently with
proc.stdout.text() and proc.exited via Promise.all. At both sites, validate the
expected stderr before parsing stdout; in lines 2413-2419 parse JSON from
stdout.trim(), and keep the exit-code assertion last.
🪄 Autofix

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: ecea9782-9e0e-4483-8a0f-0aed91675ceb

📥 Commits

Reviewing files that changed from the base of the PR and between da04506 and e1cabd8.

📒 Files selected for processing (4)
  • src/js/internal/active_handles.ts
  • src/js/node/net.ts
  • src/runtime/node/node_process.rs
  • test/js/node/process/process.test.js

Comment thread src/js/internal/active_handles.ts Outdated
Comment thread test/js/node/process/process.test.js Outdated
The request registry's Map access goes through $set/$delete/$forEach so a
tampered Map.prototype cannot break registration or inspection. The two
new subprocess tests drain stderr concurrently and surface it when
stdout comes back empty.
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts
…keep the transport kind

A close arriving from a superseded handle (a lost family-autoselection
attempt, a raw handle handed to a TLS wrap) used to set kclosed and run
the end-delivery tail on the still-live socket, which also swallowed the
current handle's own close later (skipping its unregisterHandle). Such
closes now return before touching state; a null _handle still falls
through so the ordinary post-destroy close keeps settling pending writes.

The client-side tls.connect({socket}) block now stamps this[kHandleKind]
from the wrapped connection, mirroring e1cabd8's server-side arms, so
a TLS wrap over a unix socket reports PipeWrap from the open handler.
Comment thread src/js/internal/active_handles.ts Outdated
Same pass as the Map intrinsics: the inspection walks build their result
arrays with the intrinsic so a tampered Array.prototype.push cannot run
inside _getActiveHandles()/getActiveResourcesInfo().

@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: 1

Caution

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

⚠️ Outside diff range comments (1)
test/js/node/process/process.test.js (1)

2345-2349: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert child stderr before handling stdout.

Both tests drain stderr but do not validate it. In the second test, stderr is only an expect() message. A child diagnostic can then be hidden by a stdout assertion or JSON parse failure.

  • test/js/node/process/process.test.js#L2345-L2349: Assert expect(stderr).toBe("") before asserting stdout.
  • test/js/node/process/process.test.js#L2410-L2416: Assert expect(stderr).toBe("") before testing or parsing stdout.

As per coding guidelines, subprocess tests must drain and validate stderr before stdout handling, with the exit-code assertion last. Based on learnings, bunEnv makes the empty-stderr assertion stable here.

🤖 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 `@test/js/node/process/process.test.js` around lines 2345 - 2349, The
subprocess assertions in test/js/node/process/process.test.js at lines 2345-2349
and 2410-2416 must validate stderr before handling stdout: add
expect(stderr).toBe("") immediately after draining stderr in both tests, then
retain the existing stdout assertions or parsing, with the exitCode assertion
last; ensure the processes use bunEnv so the empty-stderr expectation remains
stable.

Sources: Coding guidelines, Learnings

🤖 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 `@test/js/node/process/process.test.js`:
- Around line 2392-2415: Update the inline TLS client test around tls.connect
and the setImmediate callback to retain the returned TLS socket, then use
bounded polling until process._getActiveHandles().includes(tlsSocket) is true.
Assert this registration condition before reading
process.getActiveResourcesInfo(), while preserving the existing resource-kind
assertions and clean process exit.

---

Outside diff comments:
In `@test/js/node/process/process.test.js`:
- Around line 2345-2349: The subprocess assertions in
test/js/node/process/process.test.js at lines 2345-2349 and 2410-2416 must
validate stderr before handling stdout: add expect(stderr).toBe("") immediately
after draining stderr in both tests, then retain the existing stdout assertions
or parsing, with the exitCode assertion last; ensure the processes use bunEnv so
the empty-stderr expectation remains stable.
🪄 Autofix

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: f2241f0b-b456-489c-830a-10c94089993f

📥 Commits

Reviewing files that changed from the base of the PR and between a7ed4a2 and e4dabb2.

📒 Files selected for processing (3)
  • src/js/internal/active_handles.ts
  • src/js/node/net.ts
  • test/js/node/process/process.test.js

Comment thread test/js/node/process/process.test.js
One setImmediate could sample before the TLS wrapper registered, and the
listener plus the accepted socket already satisfy the pipe count, so the
kind assertion did not necessarily exercise the wrap. Both fixtures now
poll _getActiveHandles() for the wrap (bounded) and report the inclusion
bit; the subprocess assertions gate on empty stderr first.
Comment thread src/js/node/dns.ts Outdated
Comment thread src/js/internal/active_handles.ts Outdated
… literal

dns.promises.lookup/lookupService now park a wrap like node's
createLookupPromise, so in-flight promise-form lookups appear in
_getActiveRequests()/getActiveResourcesInfo(). noteRequestStart takes
the kind string from the caller: the wraps' shared prototype is
reachable through _getActiveRequests(), so reading constructor.name
off the wrap could run user tampering.
Comment thread test/js/node/process/process.test.js
dns.ts: union of main's guardCallback wrapping and this branch's request-wrap registration. node_fs.rs: keep both the pending-request counter and main's concurrent-poster accounting. node_process.rs: keep the awaiting_value parser and graft main's NODE_SHORT_ALIASES extension into the shared value-param set. Verified: debug build + full process.test.js suite (163 pass).
Comment thread test/js/node/process/process.test.js
The justification was trimmed in 39ea986: the expected transcript is a
node v26.3.0 run of the same script, and node keeps the closing wrap
listed until uv's OnClose (no JS-side signal), so closed:0 only holds
after a settle. Sampling in the close callback directly would print
closed:1 there and break the same-script parity.

@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 and the bug-hunting pass found no new issues; every prior thread is resolved and the latest commit (66c07eb) adds the requested rationale comment for the post-close setTimeout. Because the change is large and threads registration/unregistration through hot socket-lifecycle paths (notably the new stale-handle early return in SocketHandlers2.close) plus reorders native async-task teardown in node_fs.rs via scopeguard, a human look is still worthwhile.

What was reviewed:

  • internal/active_handles.ts intrusive list — double-register/unregister idempotence, iteration under lazy prune, $arrayPush/$set tamper hardening.
  • net.ts register/unregister placement across connect/open/close/destroy/TLS-wrap arms; the SocketHandlers2.close stale-handle guard falls through on _handle == null so post-destroy close tail work still runs.
  • node_fs.rs PENDING_ASYNC_REQUESTS inc/dec pairing across all four task types and the scopeguard-based destroy() reorder in AsyncCpTask/AsyncReaddirRecursiveTask::run_from_js_thread.
  • create_exec_argv -- handling for value-taking vs OneOptional params on both the main-thread and worker-override branches.
Extended reasoning...

Overview

Two node:process fixes across 22 files: (1) process.execArgv no longer includes the -- terminator, with a state-machine re-parse that distinguishes value-consuming options from OneOptional ones; (2) getActiveResourcesInfo() / _getActiveHandles() / _getActiveRequests() are now backed by real state — a new internal/active_handles.ts intrusive-list registry wired through net.ts, _http_server.ts, and dns.ts; a per-thread js_timeout_ref_count in the timer subsystem; and a PENDING_ASYNC_REQUESTS thread-local in node_fs.rs. Nine vendored upstream tests plus ~170 lines of Bun-side tests in process.test.js.

Security risks

None identified. The active-handles registry is per-VM JS state keyed by symbols; it exposes live socket/server objects via _getActiveHandles() (matching Node's underscore-prefixed introspection contract), and the tamper-hardening passes ($set/$delete/$forEach/$arrayPush, literal kind argument to noteRequestStart) close the prototype-pollution surface prior review rounds flagged. No auth, crypto, permission, or path-handling changes.

Level of scrutiny

High. This is not a mechanical change: it weaves registerHandle/unregisterHandle calls into ~15 sites across net.ts connect/open/close/destroy/TLS-upgrade paths, and adds a behavioral early return to SocketHandlers2.close (if (self._handle != null && socket !== self._handle) return;) that suppresses a superseded handle's close from marking the still-live socket closed. That guard is correct as written (it falls through on _handle == null so ordinary post-destroy close still delivers end/settles pending writes), but socket-close ordering is exactly the kind of path where a subtle regression would surface as a hang or a lost 'end' in real workloads. Separately, node_fs.rs moves Self::destroy(self) in AsyncCpTask::run_from_js_thread and AsyncReaddirRecursiveTask::run_from_js_thread from an explicit call before resolve/reject into a scopeguard armed at function entry — so destroy now runs on the ?-propagated early-return arms too. That's the intended fix (the counter would otherwise leak on those arms), but it changes native teardown ordering in a memory-sensitive path.

Other factors

The PR has been through ~20 iterations with extensive bot review; every prior inline thread is resolved, including the .skipIf(isWindows) question on the unix-socket http.Server test (author resolved without a gate — Windows AF_UNIX filesystem sockets are supported and CI covers it). Test coverage is thorough: 9 upstream tests run verbatim, plus targeted Bun tests for the sync-throw-no-leak case, TLS-wrap PipeWrap preservation (client and server side, gated on registration), and the execArgv value-vs-terminator matrix. Given the breadth of hot-path instrumentation and the native lifecycle refactor, I'm deferring rather than approving.

robobun and others added 2 commits August 16, 2026 19:46
Four conflicts, all in files this PR touches:

node_fs.rs: main moved AsyncFSTask and AsyncReaddirRecursiveTask onto the
bun_jsc::Job model, removing the create/destroy pairs this PR hooked the
PENDING_ASYNC_REQUESTS counter into. Both Job-based types now share the
AsyncFSJs JS-half, so the counter is carried by that struct instead:
AsyncFSJs::new increments and its Drop decrements, which covers normal
completion and release_unrun at teardown alike. UVFSRequest and the cp
task keep their explicit pairing at ref/unref. The cp scopeguard is kept
(main still destroys after the fallible fs_to_js arms); the readdir
scopeguard is dropped because main's then() takes the task by value.

node_process.rs: main replaced the worker execArgv copy with
worker_option_string. The awaiting_value truncation is rebuilt over it,
matching `--` and option names against the latin1 slice; 16-bit entries
cannot spell either, so they are treated as ordinary tokens.

_http_server.ts: main removed the isHTTPS variable whose assignment sat
between this PR's registry lines; keep the registry lines, drop the dead
assignment.

process.test.js: both sides appended tests at EOF; kept both sets.
Comment thread src/js/node/net.ts

@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 pass and found no new issues; all prior review threads are resolved. Given the scope — the new close-guard in SocketHandlers2.close (net.ts), registration/unregistration threaded through every net.ts/TLS handle-swap path, the per-thread fs request counter on AsyncFSJs/cp lifecycle, and the new js_timeout_ref_count — a maintainer sign-off would still be worthwhile.

Checked this run: the intrusive-list registerHandle/unregisterHandle pairing across connect/destroy/TLS-upgrade/reinitialize paths (unregister is idempotent via the kKind == null guard, and the connect try/catch covers synchronous validation throws); pending_request_begin/end balance on every AsyncFSJs construct/drop and the cp scopeguard; the execArgv -- re-parse for both main-thread and worker-override branches including the OneOptional (--inspect) case; and forEachActive's captured-next traversal surviving in-loop unregisterHandle.

Extended reasoning...

Overview

This PR implements process.getActiveResourcesInfo(), process._getActiveHandles(), and process._getActiveRequests() (previously stubs), and fixes process.execArgv to drop the -- terminator. It touches 22 files across the Rust runtime (node_fs.rs, node_process.rs, timer/{mod,Timer,timer_object_internals}.rs), C++ bindings (BunProcess.cpp), and hot-path JS builtins (net.ts, dns.ts, _http_server.ts, plus a new internal/active_handles.ts). Nine upstream Node tests are vendored and seven in-tree tests added.

Security risks

None identified. The active-handles registry is observability-only; it stores references to objects the user already holds and exposes them back via debugging APIs Node also exposes. No auth, crypto, or permission surfaces are touched. Prior tamper-resistance concerns ($arrayPush, Map intrinsics, noteRequestStart taking a literal kind) have been addressed in earlier iterations.

Level of scrutiny

High. net.ts is one of the most complex, load-bearing files in the Node compat layer, and this PR threads register/unregister calls through ~15 sites including TLS upgrade, family-autoselection reinitialize, server accept, and destroy. It also adds a behavioral guard to SocketHandlers2.close (if (self._handle != null && socket !== self._handle) return) that changes when a superseded handle's close event is suppressed — well-commented and sensible, but not purely additive observability. The Rust side adds a thread-local counter whose increment/decrement must balance across every async-fs task lifetime (normal completion, error, teardown release-unrun), and a new js_timeout_ref_count alongside the existing active_timer_count that must stay in sync with set_enable_keeping_event_loop_alive.

Other factors

The PR has been through ~115 timeline events and multiple review rounds; every prior inline thread from me, CodeRabbit, and cirospaciari is marked resolved. The bug-hunting system found nothing this run. Test coverage is substantial (9 vendored upstream tests + 7 targeted in-tree tests covering ref/unref, TLS wrap kind preservation, synchronous-throw leak, dns promise form). The PR description documents remaining known limits (IPC channel PipeWrap, cluster round-robin worker) and the merge-with-main rebase.

I'm deferring rather than approving because the net.ts close-path change and the breadth of lifecycle hook points across net/TLS/fs/timer are exactly the kind of change where a maintainer with runtime context on net.ts should sign off, and no human approval is on the PR yet.

One conflict, in node_process.rs: bc713f9 made bun_core::String own
its WTF ref, so worker_option_string now returns an owning String and
the worker execArgv element conversion moved from to_js to into_js. The
awaiting_value truncation over the raw impls is unchanged; its array
builder takes main's into_js so the fresh +1 from clone_latin1/utf16
moves into the JSString instead of being ref'd a second time.
Comment thread src/runtime/node/node_process.rs Outdated
is_8bit() is the storage encoding, not the content. A string decoded
from UTF-16 bytes (Buffer#toString("utf16le"), TextDecoder utf-16le)
carries ASCII content in 16-bit storage, and the worker execArgv scan
treated any 16-bit entry as an ordinary token: a 16-bit "--conditions"
never armed the pending value, so a following "--" truncated the array
(the fork() regression the guard exists for), and a 16-bit "--" was not
recognized as the terminator. Narrow ASCII 16-bit entries through
bun_core::strings::narrow_ascii_u16 before comparing; entries with
non-ASCII units or longer than any option name are ordinary tokens.

Adds a fixture covering the utf16le-decoded value-taking option, the
utf16le-decoded terminator, and a non-ASCII control token.

@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 revision and found no new issues — c8fa7ff addresses the last open finding (the worker execArgv scan now narrows 16-bit-stored ASCII via narrow_ascii_u16, with a covering test). Given the scope — 22 files across the timer subsystem, fs async task lifecycle, net.ts socket handlers (including the new superseded-handle early-return in SocketHandlers2.close), and BunProcess.cpp — a human pass is still warranted before merge.

What was reviewed:

  • create_exec_argv ---terminator state machine on both main-thread and worker paths, including the 16-bit storage encoding fix and the OneOptional exclusion.
  • active_handles.ts intrusive-list register/unregister pairing across net.ts connect/destroy/TLS-wrap arms and _http_server.ts listen/close — no leaked entries found on synchronous throw paths.
  • PENDING_ASYNC_REQUESTS counter balance across AsyncFSJs::new/Drop, UVFSRequest, and the cp task's scopeguard-ordered destroy.
  • js_timeout_ref_count bookkeeping in set_enable_keeping_event_loop_alive — confirmed it tracks only JS timeout/interval objects, not the DNS retry ticker or spawn timeouts.
Extended reasoning...

Overview

This PR replaces stub process.getActiveResourcesInfo()/_getActiveHandles()/_getActiveRequests() with real backing state and fixes process.execArgv ---terminator handling. It spans 22 files: a new internal/active_handles.ts intrusive-list registry; registration hooks threaded through net.ts (socket open/close/destroy, TLS wrap paths, server listen/close), _http_server.ts, and dns.ts (callback + promise forms); a per-thread PENDING_ASYNC_REQUESTS counter in node_fs.rs hooked into AsyncFSJs/UVFSRequest/cp task lifecycles; a new js_timeout_ref_count in the timer subsystem; BunProcess.cpp LUT changes; and the create_exec_argv rewrite in node_process.rs. Nine vendored upstream Node tests plus in-tree coverage.

Security risks

None identified. The changes are observability/compat surface — no auth, crypto, permissions, or untrusted-input parsing beyond the existing argv re-parse (which reads process argv, not network data). The tamper-resistance of active_handles.ts was already hardened in earlier iterations ($-intrinsics, literal kind arguments).

Level of scrutiny

High. This is not a mechanical change: it threads registration/unregistration calls through ~15 sites in net.ts's socket state machine, adds a behavioral early-return to SocketHandlers2.close (superseded-handle guard), touches timer refcount bookkeeping, and reorders NewAsyncCpTask destroy via scopeguard. Each of these is a hot path where an unbalanced register/unregister or a mis-ordered early-return could leak entries, hang the loop, or regress socket-close semantics. The PR has been through many review iterations (8+ inline findings from prior runs, all now addressed), which itself signals the change is non-trivial.

Other factors

The last outstanding inline finding (16-bit-stored ASCII in the worker execArgv scan) was addressed by c8fa7ff with narrow_ascii_u16 and a dedicated test. All prior inline threads are marked resolved. Test coverage is thorough (9 vendored + 8 in-tree targeted tests including TLS-wrap PipeWrap preservation, synchronous-throw leak checks, and the encoding edge case). The PR description documents known remaining limits (IPC PipeWrap, cluster faux-listen). I am deferring rather than approving because the surface area — particularly the net.ts close-handler behavioral change and the fs task lifecycle reordering — merits a maintainer's eye, not because any specific defect remains.

One conflict, in net.ts: e54bbfa (keep the handle reading while a
socket is paused) reworded the kPausedUnref comment that sits directly
after this PR's registry helpers. Kept the helpers, took main's wording.
All 22 register/unregister sites survived; the readStop/pauseOnCreate
rework touches no handle swap.

This branch has not been deployed

No deployments
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.

process.execArgv includes user options with the same names as bun's options

4 participants