Skip to content

process: throw from a lazy property whose builder fails instead of storing undefined - #39436

Open
robobun wants to merge 6 commits into
mainfrom
farm/96f9ebe8/process-lazy-builders-propagate
Open

process: throw from a lazy property whose builder fails instead of storing undefined#39436
robobun wants to merge 6 commits into
mainfrom
farm/96f9ebe8/process-lazy-builders-propagate

Conversation

@robobun

@robobun robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • process.stdout, stderr, stdin, nextTick, finalization and allowedNodeEnvironmentFlags stay undefined for the rest of the process when they are first read at a moment their builder cannot run, and the process exits with code 1 although nothing was left uncaught. Repro: read one of them for the first time from the catch of a RangeError: Maximum call stack size exceeded. (logging from such a catch block does that), or console.log(process) there; stderr shows one RangeError: Maximum call stack size exceeded. uncaught-exception report per builder. Internal users break the same way: after one failed build every process.emitWarning(), new Worker(), ... fails with Failed to call nextTick.
  • Cause: the builders in src/jsc/bindings/BunProcess.cpp (callLazyProcessBuilder, constructStdioWriteStream, constructStdin, Process::constructNextTickFn, and with the same code constructEnv, constructProcessConfigObject, constructMainModuleProperty, Process_stubEmptyArray) clear the exception, pass it to reportUncaughtExceptionAtEventLoop and return jsUndefined(), which reifyStaticProperty stores as the property's value, so the builder never runs again.
  • getConsoleStdout / getConsoleStderr (src/jsc/bindings/ZigGlobalObject.cpp) have the same bug one level up: when the process.stdout read throws, JSObject::get returns undefined (not an empty value, so the existing if (!value) never fires) and the getter caches it in console._stdout.

Fix

Overlap with open PRs

This PR is the one carrying the failure policy of the process builders (BunProcess.cpp) and the dependent console getters. For the hunks it shares with other open PRs:

Background

  • process's properties come from a static hash table (processObjectTable). A PropertyCallback entry is not built when process is created: the first read calls its builder and stores the result as an ordinary own property (reifyStaticProperty). Several builders run a JS builtin (getStdioWriteStream, initializeNextTickQueue, ...), so they can throw, for instance when there is no stack left to enter JS.
  • Where the JSC side of the contract lives, in the WebKit main pins (eeab0404, scripts/build/deps/webkit.ts; same lines as in the previous pin, and getPropertySlot still uses the structure it read before the lookup, so the Fix stale-structure abort when a lazy Bun property builder throws mid-lookup #37001 point is unchanged): runtime/Lookup.h, the PropertyCallback branch of reifyStaticProperty, returns without putDirect when the builder returned empty; runtime/Lookup.cpp, setUpStaticFunctionSlot, runs the reify under DeferTerminationForAWhile and returns false (slot not found) when vm.exceptionForInspection() is set afterwards; runtime/JSObject.cpp, reifyAllStaticProperties, does the same per entry and returns without setting staticPropertiesReified, so the next bulk operation continues with the entries that are left.
  • A JS exception is a flag on the VM that native code checks after each fallible call (RETURN_IF_EXCEPTION). A function propagates a failure by returning an empty value with the flag set; once control is back in JS the exception is thrown there. JSObject::get is the exception to the empty-value convention: it returns undefined on failure.
  • ThrowScope vs TopExceptionScope: in exception-checking builds a ThrowScope pretends its function threw whenever it returns, so a caller without a check is flagged; a TopExceptionScope does not. JSC checks a builder's result with vm.exceptionForInspection(), which is not such a check, hence TopExceptionScope in the builders.
  • reifyAllStaticProperties builds every remaining entry of the table at once; JSC does this when an operation cannot be answered from the table, such as deleting one of its properties or spreading the object.
  • Structures: JSC describes an object's shape with a Structure; adding a property moves the object to a new one. JSObject::getPropertySlot reads the structure once per object in the prototype chain and, when the own lookup misses, asks that structure for the prototype. A builder that added a property and then failed makes that read stale, which assertion builds check for. Fix stale-structure abort when a lazy Bun property builder throws mid-lookup #37001 makes it re-read the structure.

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

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 24 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

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

How can I continue?

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

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

How do review limits work?

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

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

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6286e50a-fc83-46a5-a49f-9cffd22d90cd

📥 Commits

Reviewing files that changed from the base of the PR and between 486e78c and 09056d8.

📒 Files selected for processing (4)
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/node/process/process-stdio-stack-overflow-fixture.js
  • test/js/node/process/process.test.js

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

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fixed; rebased onto main after #29642 landed (head 09056d8: this PR no longer touches bindings.cpp, the only conflict, resolved by taking main). Previous head was green (build 100130, 179/179); CI for the rebased head pending.

Reproduced with the released binary and with a debug build of main by reading process.stdout (and the other five) for the first time from the catch block of a stack overflow, and with Bun.inspect(process) there: the properties stayed undefined afterwards, stderr got one RangeError report per builder, exit code 1. The cases under lazy process properties whose builder throws in test/js/node/process/process.test.js all fail without the src/ changes (0 pass / 8 fail on a debug build, 0 / 11 on the release binary, which also runs the three release-only cases) and pass with them; the property walk case additionally fails on a build that has everything except the bindings.cpp change.

Open point for whoever lands this: the engine-side assertion described in the body (#37001) is hit by assertion builds when process.stdout is first read a few hundred frames above the stack limit; release builds are fine either way. Landing #37001 first avoids that window.

@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 didn't find any bugs. Because it changes exception-propagation semantics across the process lazy-property builders and depends on specific JSC-side handling (reifyStaticProperty / setUpStaticFunctionSlot behavior with a pending exception, TopExceptionScope vs ThrowScope), a human look would still be worthwhile.

Checked: each changed builder still uses TopExceptionScope so the JSC vm.exceptionForInspection() caller contract holds; the new getIndex checks in constructStdioWriteStream cover both reads; the getConsoleStdout/getConsoleStderr fix correctly replaces the dead if (!value) guard (JSObject::get returns undefined, not empty); the forEachProperty reorder clears the exception before the continue so the next static-table lookup doesn't run with one pending. The four new tests each assert the specific broken behavior (undefined property, uncaught-exception report, exit 1) and are stated to fail on the unfixed build.

Extended reasoning...

Overview

This PR changes how eight lazy PropertyCallback builders on the process object (callLazyProcessBuilder, constructStdioWriteStream, constructStdin, constructNextTickFn, constructEnv, constructProcessConfigObject, constructMainModuleProperty, Process_stubEmptyArray) respond when the underlying JS they call throws: instead of clearing the exception, reporting it via reportUncaughtExceptionAtEventLoop, and returning jsUndefined() (which reifyStaticProperty then permanently stores), they now RETURN_IF_EXCEPTION(scope, {}) so the exception propagates, the property stays un-reified, and the next read retries the builder. It also fixes getConsoleStdout/getConsoleStderr to propagate rather than cache undefined, and reorders CLEAR_IF_EXCEPTION in forEachProperty so a failed lookup's exception is cleared before the next static-table lookup runs. Four subprocess tests cover the stack-overflow path, Bun.inspect(process) during unwind, a non-stack-overflow builder failure (Set removed), and the bulk-reification (delete process.x) path.

Security risks

None identified. This is error-handling plumbing; no user input parsing, no auth/crypto, no new external surface.

Level of scrutiny

High. Per the repo's review rules, exception-check placement in JSC bindings is the most-blocked category. The correctness of this change hinges on JSC-side invariants the PR description cites (oven-sh/WebKit#282/#306 landed via #34669): that reifyStaticProperty stores nothing on an empty result, that setUpStaticFunctionSlot reports not-found so the caller throws, and that reifyAllStaticProperties stops at the failing entry. The PR keeps TopExceptionScope in the builders because JSC's caller checks via vm.exceptionForInspection() rather than a scope check — that reasoning is sound but is exactly the kind of subtle contract a maintainer should confirm against the pinned WebKit. The change also affects the node:worker_threads preload's bulk reification path.

Other factors

The PR description is unusually thorough — it names the mechanism, the JSC PRs that enabled it, related/superseded PRs (#30612, #37258, #38821, #29642), and lists what was run under BUN_JSC_validateExceptionChecks=1. The tests are well-constructed (subprocess-isolated, assert stderr empty and exit 0, exercise both single-read and bulk-reify paths, and include a non-stack-overflow trigger so the test isn't only about the RangeError case). The bug-hunting pass found nothing. Still, this is a semantic change to error handling across a widely-used object, not a mechanical fix, so it should not be auto-approved.

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated

@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 didn't find any bugs. Because it changes exception-propagation semantics for the process lazy property builders and relies on a specific contract in the pinned JSC fork (reifyStaticProperty / setUpStaticFunctionSlot handling an empty builder result), a human familiar with the WebKit fork should confirm before merge.

What was reviewed:

  • The eight builder sites in BunProcess.cpp now match the pattern already used by constructVersions/constructReport and the Bun.* builders — RETURN_IF_EXCEPTION(scope, {}) under a TopExceptionScope.
  • getConsoleStdout/getConsoleStderr: the old if (!value) guard was dead because JSObject::get returns undefined on failure, not empty; the new ThrowScope + RETURN_IF_EXCEPTION is the right check and prevents caching undefined into console._stdout/_stderr.
  • forEachProperty in bindings.cpp: moving CLEAR_IF_EXCEPTION before the continue is correct — with a pending exception the next getPropertySlot misbehaves; ruled out that this drops the not-found check (it doesn't, hasProperty is captured first).
  • Callers of constructNextTickFn via queueNextTick already RETURN_IF_EXCEPTION after the get, so the new propagation is handled there.
Extended reasoning...

Overview

The PR replaces the clear-and-report-and-store-undefined pattern in eight process lazy property builders (callLazyProcessBuilder, constructStdioWriteStream, constructStdin, constructNextTickFn, constructEnv, constructProcessConfigObject, constructMainModuleProperty, Process_stubEmptyArray) with RETURN_IF_EXCEPTION(scope, {}), so a builder failure surfaces as an ordinary throw at the read site and the property stays lazy for the next read. It also fixes getConsoleStdout/getConsoleStderr to propagate instead of caching undefined, moves the exception-clear in forEachProperty so a failed lookup doesn't poison the next one, adds two getIndex exception checks in constructStdioWriteStream, updates a fixture comment, and adds four subprocess tests.

Security risks

None identified. This is error-propagation plumbing; no new inputs are parsed, no auth/crypto/permissions touched.

Level of scrutiny

High. This is C++ JSC bindings code on a very hot object (process), and the correctness of returning empty from a PropertyCallback builder depends on the pinned WebKit fork's reifyStaticProperty / setUpStaticFunctionSlot / reifyAllStaticProperties handling that case (oven-sh/WebKit#282, #306 per the description). I could not verify those files locally (the WebKit vendor tree is not present in this checkout), so the contract is taken on the PR description's word. The change is consistent with existing builders (constructVersions, constructReport) that already use this pattern, which is strong circumstantial evidence, but a maintainer who knows the fork should confirm — especially the bulk-reification path used by the node:worker_threads preload.

Other factors

  • The mechanical transformation is uniform and net-negative in lines; each site keeps its TopExceptionScope (comment explains why: JSC checks with vm.exceptionForInspection(), which doesn't satisfy a ThrowScope).
  • queueNextTick (BunProcess.cpp:4291) reads nextTick via get under a ThrowScope and already has RETURN_IF_EXCEPTION after it, so callers of the changed constructNextTickFn are covered.
  • The forEachProperty change is a two-line reorder that only widens when the clear runs; behaviour when hasProperty is true is identical.
  • Tests are subprocess-based, drain both pipes concurrently, assert stderr empty and exit code 0, cover the RangeError path, a non-stack-overflow builder failure, bulk reification via delete, and Bun.inspect(process) (Windows-skipped with a stated reason). The author verified they fail on an unfixed build.
  • The comment-cop bot's remarks about long comments were addressed in 4e52164 and all threads are resolved.
  • CI was still noted as pending in the timeline.

Given the reliance on JSC fork internals and the breadth of behaviour touched (every lazy process property, console._stdout/_stderr, Bun.inspect), this warrants a human sign-off rather than an automated approval.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

This PR supersedes #30612 and #37258; I am closing both in favour of it. What I checked against a debug build of this branch (4e52164):

  • process: retry nextTick initialization in queueNextTick after failed first reification #30612 (queueNextTick retries a failed nextTick build): its test, test/js/node/process/process-lazy-property-stack-overflow.test.ts, fails on the current released build (the first new Worker() near the limit gets Error: Failed to call nextTick, process.nextTick stays undefined, the later healthy Worker never posts) and passes on this branch, so the retry is not needed here. It needs --timeout raised on a debug build (74s here): with the builder now throwing, the child's Worker attempt fails with a RangeError at every unwinding frame until Worker construction plus the builder fit in the remaining stack (about 2260 frames on this debug build, about 630 with nextTick built up front), and each failed attempt has already started a worker that nothing terminates.
  • process: don't run the uncaught-exception machinery from inside a lazy property lookup #37258 (defer the report to a microtask, plus the env initializer): on Linux its survives a clobbered global breaking the env builder mid-walk script runs clean on this debug build (caught:TypeError, alive, exit 0, no storedPrototype assertion). Its Set and Symbol scenarios give threw:TypeError at the read, no uncaughtException listener call, and the property is built on the next read once the global is restored, for allowedNodeEnvironmentFlags as well as for stdout, stderr and stdin. Its two defers ... tests assert the clear-and-report ordering, which this branch removes, so they fail by design; the Set case here covers the same ground. Its m_processEnvObject initializer and constructEnvObject hunks are covered by process.env: make a failed env build throw instead of aborting (Windows 0xC0000409 on Bun.$ / Bun.sql / process.env near the stack limit) #38821, which also rewrites constructEnv, so whichever of the two lands second needs a small rebase in BunProcess.cpp.

One suggestion: the four tests here reach the properties through JS reads, while #30612 was about the internal Process::queueNextTick path (get followed by RETURN_IF_EXCEPTION). A case where the first use after the failed read is internal would pin that path, and it takes a single spawn, since the Worker constructor queues the process 'worker' event through emitOnNextTick. This script prints {"atLimit":"threw RangeError","workerEvent":true,"nextTick":"function"} and exits 0 on this branch (0.5s on the debug build); on the released build it prints {"atLimit":"undefined","constructorThrew":"Error: Failed to call nextTick"} with a RangeError report on stderr and exits 1:

let atLimit;
function recurse() {
  try {
    recurse();
  } catch {
    if (atLimit !== undefined) return;
    try {
      atLimit = typeof process.nextTick;
    } catch (e) {
      atLimit = "threw " + e.constructor.name;
    }
  }
}
recurse();
process.on("worker", () => {
  console.log(JSON.stringify({ atLimit, workerEvent: true, nextTick: typeof process.nextTick }));
  w.terminate();
});
let w;
try {
  w = new Worker("data:text/javascript,");
} catch (e) {
  console.log(JSON.stringify({ atLimit, constructorThrew: String(e) }));
}

@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 didn't find any bugs. Because it changes exception-propagation semantics across the process lazy-property builders and forEachProperty, relies on specific pinned-WebKit behavior in reifyStaticProperty/setUpStaticFunctionSlot, and has a stated assertion-build abort window until #37001 lands, a human look would still be worthwhile.

What was reviewed:

  • The eight builder sites in BunProcess.cpp — each keeps TopExceptionScope and swaps clear+report+jsUndefined() for RETURN_IF_EXCEPTION(scope, {}); the two new getIndex checks in constructStdioWriteStream complete the contract.
  • getConsoleStdout/getConsoleStderrDECLARE_THROW_SCOPE is correct for a custom getter (matches getConsoleConstructor); get() returns undefined on failure so the old if (!value) was dead.
  • forEachPropertyImpl — clearing before the !hasProperty continue matches the sibling loop at ~line 5810 that already does this.
  • Tests spawn subprocesses, drain stdout/stderr/exited concurrently, gate the stdio unwind cases on !isDebug && !isASAN, and skip the Bun.inspect(process) case on Windows with a stated reason.
Extended reasoning...

Overview

The PR changes how eight lazy PropertyCallback builders on process (BunProcess.cpp) respond to a JS exception during construction: instead of clearing the exception, reporting it as uncaught, and returning jsUndefined() (which reifyStaticProperty then permanently stores), they now return empty with the exception pending via RETURN_IF_EXCEPTION(scope, {}). The pinned JSC handles this by not storing the property and reporting the slot as not found, so the read throws and the next read retries the builder. The same pattern is applied to getConsoleStdout/getConsoleStderr in ZigGlobalObject.cpp (which were caching undefined because JSObject::get returns undefined rather than empty on failure), and forEachPropertyImpl in bindings.cpp is reordered so a pending exception from one property lookup is cleared before the next iteration. ~270 lines of new tests in process.test.js cover stack-overflow-at-first-read, retry-after-unwind, non-stack builder failures (Set deleted), bulk reification via delete, the Bun.inspect property walk, and the internal queueNextTick path via emitWarning.

Security risks

None identified. This is exception-propagation plumbing; no auth, crypto, permissions, or untrusted-input parsing is touched.

Level of scrutiny

High. The change is small in line count but sits at the JSC/Bun boundary and changes user-observable semantics: what was previously an uncaught-exception report plus a permanently-undefined property is now a throw at the read site with retry-on-next-read. Correctness depends on specific behavior in the pinned WebKit fork (reifyStaticProperty not storing on empty return, setUpStaticFunctionSlot checking vm.exceptionForInspection(), reifyAllStaticProperties stopping without setting staticPropertiesReified) — behavior the PR body traces to oven-sh/WebKit#282/#306 landed via #34669. The PR also documents a known assertion-build abort window (stdio builders that partially reify process.nextTick before overflowing) that remains until #37001's getPropertySlot change is in the pin, and gates the affected tests to release builds accordingly. That kind of engine-contract dependency and cross-PR ordering is exactly what a maintainer should sign off on.

Other factors

  • The change is consistent with existing builders (versions, report, Bun.*) that already use this pattern, and the bindings.cpp reorder matches the sibling loop in the same file.
  • The comment-cop bot flagged long comments; the author trimmed them in 4e52164 and all four threads are resolved.
  • The PR supersedes #30612 and #37258 (author is closing both) and overlaps with #38821 (constructEnv) and #29642 (identical bindings.cpp hunk) — merge-order coordination is a human decision.
  • Tests are thorough and follow harness conventions (describe.concurrent, bunEnv/bunExe, Promise.all on stdout/stderr/exited, exact-value assertions on JSON output, exit code asserted last, per-case skips with stated reasons). The author states every case fails on the unfixed build and ran BUN_JSC_validateExceptionChecks=1 on the repro scripts.
  • No prior claude[bot] review on this PR.

@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 automated bug hunt found no issues. The change itself is coherent and well-tested, but it is a deliberate policy reversal for how process lazy-property builder failures propagate (the prior clear-and-report was kept intentionally per #31831), it relies on specific behavior in the pinned WebKit's reifyStaticProperty/setUpStaticFunctionSlot, and it has an explicit merge-ordering question with #37001 (assertion builds can abort in a narrow window if this lands first). A human should sign off on the policy and the landing order.

What was reviewed:

  • The eight BunProcess.cpp builders now RETURN_IF_EXCEPTION(scope, {}) — matches the contract described for reifyStaticProperty returning without putDirect on empty.
  • getConsoleStdout/getConsoleStderr: replacing if (!value) with RETURN_IF_EXCEPTION is correct since JSObject::get returns undefined (not empty) on throw.
  • forEachProperty in bindings.cpp: reordering CLEAR_IF_EXCEPTION before the !hasProperty continue is safe; the slot is only used when hasProperty is true.
  • New tests use describe.concurrent with per-case subprocesses, drain stdout/stderr/exited together, and gate the stdio unwind cases on !isDebug && !isASAN for the stated #37001 reason.
Extended reasoning...

Overview

The PR changes the failure policy of the lazy process.* property builders in src/jsc/bindings/BunProcess.cpp (callLazyProcessBuilder, constructStdioWriteStream, constructStdin, constructNextTickFn, constructEnv, constructProcessConfigObject, constructMainModuleProperty, Process_stubEmptyArray) from clear-exception + reportUncaughtExceptionAtEventLoop + return jsUndefined() to RETURN_IF_EXCEPTION(scope, {}). It also fixes getConsoleStdout/getConsoleStderr in ZigGlobalObject.cpp to propagate the exception instead of caching undefined, reorders the exception clear in forEachProperty (bindings.cpp), updates a fixture comment, and adds a ~270-line test suite.

Security risks

None identified. This is exception-propagation plumbing in the JSC bindings; no auth, crypto, untrusted-input parsing, or privilege boundaries are touched.

Level of scrutiny

High. This is not a mechanical fix — it reverses a policy that #31831 explicitly kept ("out of caution about the worker preload's bulk reification"), and correctness depends on the pinned WebKit fork's reifyStaticProperty / setUpStaticFunctionSlot / reifyAllStaticProperties handling empty builder results the way the PR body describes. The PR also documents a known assertion-build abort window that exists until #37001 lands, and interacts with four other open PRs (#29642, #38821, #38987, #37001) plus two it supersedes (#30612, #37258). A maintainer should confirm the policy is the one they want and decide the landing order relative to #37001.

Other factors

CI is green on all 179 lanes per the robobun status comment. The comment-cop bot feedback about paragraph-long comments was addressed (all threads resolved). Tests are thorough — they cover direct reads at the stack limit, console._stdout/_stderr, Bun.inspect(process), bulk reification via spread, the internal queueNextTick path via emitWarning, and retry-while-unwinding — and the author verified 0/8 (debug) and 0/11 (release) fail on the unfixed build. The TopExceptionScope choice is justified (JSC checks via vm.exceptionForInspection(), which does not satisfy a ThrowScope). Nothing looks wrong to me; the deferral is purely because this is a cross-cutting policy change with an open engine-side dependency and an explicit "open point for whoever lands this" from the author.

…oring undefined

The PropertyCallback builders behind process.stdout, stderr, stdin,
nextTick, finalization, allowedNodeEnvironmentFlags, channel, env,
config, mainModule, _preload_modules and moduleLoadList cleared the
exception thrown while building the value, reported it as an uncaught
exception and returned undefined, which reifyStaticProperty stored for
good. A builder that failed once (for example because it was first read
from the catch block of a stack overflow) left the property undefined
for the rest of the process and made it exit with code 1.

Return empty with the exception pending instead, like the other process
and Bun builders: JSC stores nothing, the read throws, and the next read
runs the builder again.

console._stdout / console._stderr check for that exception instead of
caching the undefined that JSObject::get returns on failure, and
forEachProperty clears the exception a failed lookup left before looking
up the next property, since a pending exception makes the next static
table lookup report its property as missing after reifying it.
On Windows process.env is built by a JS builtin and carries a JS custom
inspect function; both still abort the process when run at the stack
limit (LazyProperty initializers), which this test would hit before
reaching what it covers.
…ile unwinding

The property walk case makes one builder fail without involving the
stack (Set removed), so it observes the forEachProperty change on every
lane: the properties declared after the failed one must still be listed.
process.emitWarning covers Process::queueNextTick, the internal consumer
of the nextTick builder. The unwinding cases read the property again
every 64 frames until a read has enough stack; the stdio ones only run
on builds without assertions, since those builders add a property to
process before they can still fail, which the pinned engine's
getPropertySlot asserts on. The inspect-at-the-limit case now inspects
once, at the deepest frame.
…ugProcess

Spreading process reifies the table the same way and does not depend on
a particular stub staying a table entry.
@robobun
robobun force-pushed the farm/96f9ebe8/process-lazy-builders-propagate branch from 5b7f858 to 09056d8 Compare August 18, 2026 04:52

@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 didn't find any bugs. Because it changes the failure policy of the process lazy-property builders in JSC bindings and carries an explicit landing-order dependency (#37001, plus overlaps with #38821/#38987), a human should decide the landing order and confirm the assertion-build abort window described in the body is acceptable.

What was reviewed:

  • The eight builder sites in BunProcess.cpp — each swaps clear-and-report + jsUndefined() for RETURN_IF_EXCEPTION(scope, {}); the TopExceptionScope is retained so JSC's vm.exceptionForInspection() check is satisfied.
  • getConsoleStdout/getConsoleStderr — the if (!value) guard was dead (JSObject::get returns undefined, not empty), so the new RETURN_IF_EXCEPTION is the correct replacement.
  • New tests: assertions are non-vacuous, subprocess pipes are drained concurrently, release-only cases are gated on !isDebug && !isASAN, and the Windows skip has a stated reason.
Extended reasoning...

Overview

The PR touches src/jsc/bindings/BunProcess.cpp (8 lazy PropertyCallback builders), src/jsc/bindings/ZigGlobalObject.cpp (console._stdout/_stderr custom getters), a test fixture comment, and adds a ~270-line describe.concurrent block to process.test.js. Each builder site is a mechanical swap: the clear-exception → reportUncaughtExceptionAtEventLoopreturn jsUndefined() block becomes RETURN_IF_EXCEPTION(scope, {}), so a failed builder leaves the exception pending and returns empty. JSC's reifyStaticProperty then stores nothing, the read throws, and the next read reruns the builder. constructStdioWriteStream also gains exception checks after its two getIndex calls so the whole function honours the same contract. The console getters replace a dead if (!value) check (JSObject::get never returns empty) with a proper ThrowScope + RETURN_IF_EXCEPTION.

Security risks

None identified. This is error-propagation plumbing; no new inputs are parsed and no trust boundary is crossed.

Level of scrutiny

High. Although each hunk is small and repetitive, this is a policy change in core JSC bindings that reverses a deliberate prior choice (#31831 kept clear-and-report out of caution about worker-preload bulk reification). The correctness depends on engine behaviour in the pinned WebKit (reifyStaticProperty / setUpStaticFunctionSlot / reifyAllStaticProperties all handling an empty builder result), which the PR body cites but which a maintainer should confirm against the current pin. The PR also names an open engine-side issue (#37001) that makes assertion builds abort in a specific stack-depth window if this lands first — that landing-order call is a human decision.

Other factors

CI was reported green on an earlier commit (build 100130, 179/179) with only a test-trigger tweak since. The tests are thorough (direct read, bulk reification via spread, Bun.inspect walk, internal queueNextTick path via emitWarning, retry-while-unwinding) and the author verified 0/8 → 8/8 on debug and 0/11 → 11/11 on release. The comment-cop bot's flags were addressed (comments trimmed to one line). The PR body explicitly maps overlaps with four other open PRs and proposes a landing order — coordinating that is outside what automated review should decide.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant