Skip to content

Fix pending exception leak when a lazy property throws during inspect - #37292

Closed
robobun wants to merge 7 commits into
mainfrom
farm/f48c2e92/fix-inspect-pending-exception
Closed

robobun wants to merge 7 commits into
mainfrom
farm/f48c2e92/fix-inspect-pending-exception

Conversation

@robobun

@robobun robobun commented Aug 9, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes two crashes surfaced by a Fuzzilli-found abort (fingerprint 9615b92f1ed60a34):

ASSERTION FAILED: Unexpected exception observed on thread ...
Error Exception: undefined is not an object (evaluating 'createShellInterpreter')
!exception()
ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()

Deterministic reproduction for both:

globalThis.process = undefined;
console.log(Bun);

Fix 1: pending exception leak in the inspect property walk (bindings.cpp)

  1. console.log(Bun) walks properties via JSC__JSValue__forEachPropertyImpl. The Bun object has non-reified static properties, so the walk takes the slow path and calls getPropertySlot per property.
  2. Reaching $ runs the lazy builder constructBunShell, which executes the shell builtin's top level. That code reads process.env, so with process clobbered it throws a TypeError. JSC's setUpStaticFunctionSlot deliberately reports the slot as not found and leaves the exception pending for the caller to handle (Lookup.cpp).
  3. The walk did if (!getPropertySlot) continue; before its CLEAR_IF_EXCEPTION, so that pending exception was never cleared. The next re-entry into JSC trips releaseAssertNoException and the process aborts on debug builds. The ordered variant (JSC__JSValue__forEachPropertyOrdered) already clears before checking the result; only the non-ordered one had the two statements swapped. The fix reorders them to match.

About the misleading message text: the TypeError comes from process.env, but errors raised while a builtin function frame is on top get attributed to bytecode index 0 of that builtin (getBytecodeIndex skips builtin frames' real index), and the first expression info entry of the shell builtin is the createShellInterpreter binding. That is why the fuzzer's crash names a function the crashing script never used. The fuzzer hit this flakily because it needs one REPRL iteration to clobber a global and a later one to inspect Bun.

Fix 2: LazyProperty contract violation in the util.inspect initializers (ZigGlobalObject.cpp)

The same repro crashed the Windows CI lanes through a second path. Formatting a value with a nodejs.util.inspect.custom function (on Windows, console.log(Bun) reaches this through Bun.env) lazily initializes m_utilInspectFunction, whose initializer requires node:util. The module body throws with process clobbered, and the initializer returned early without calling init.set(). A LazyProperty initializer must always set the property: debug builds hit ASSERTION FAILED: !(initializer.property.m_pointer & lazyTag) and release builds fail fast (0xC0000409 on Windows; also reproducible on Linux release). The fix clears the non-termination exception and installs a stub inspect function, and does the same for m_utilInspectStylizeColorFunction with the no-color stylizer, so formatting degrades instead of aborting. Both paths are reachable from plain user JS on every platform.

How did you verify your code works?

  • Before the fixes, bun-debug -e 'globalThis.process = undefined; console.log(Bun)' aborts with the exact assertion above on Linux, and with the lazyTag assertion on Windows; release builds crash on the custom-inspect path on both platforms. After the fixes it prints the Bun object and exits 0 on both.
  • Added two regression tests in test/js/bun/util/inspect.test.js (one per fix). Each fails on an unfixed debug build (child aborts) and passes with this change.
  • bun bd test test/js/bun/util/inspect.test.js is green on Linux and Windows, and test/js/bun/console/ and test/js/node/util/ are green on Linux (two pre-existing stress-test timeouts also occur on an unmodified build).
  • The normal path is unchanged: util.inspect still reaches custom inspect functions when node:util loads fine.

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

…pect

JSC__JSValue__forEachPropertyImpl skipped its CLEAR_IF_EXCEPTION when
getPropertySlot returned false, but a lazy static property whose builder
throws (and a throwing proxy trap) reports the slot as not found while
leaving the exception pending. The walk then continued with a stale VM
exception, which trips releaseAssertNoException in debug builds.

Reproduced by clobbering a global the Bun.$ builtin reads at setup time
and then printing the Bun object:

  globalThis.process = undefined;
  console.log(Bun);

Clear the exception before the continue, matching
JSC__JSValue__forEachPropertyOrdered.
@github-actions github-actions Bot added the claude label Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 18 minutes

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

How can I continue?

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

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

How do review limits work?

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

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

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 30635982-5c13-4ce9-88ac-0b1d09fc4121

📥 Commits

Reviewing files that changed from the base of the PR and between a81eb32 and c4ad31e.

📒 Files selected for processing (1)
  • test/js/bun/util/inspect.test.js

Walkthrough

Property enumeration now clears lookup exceptions and skips missing properties. Lazy node:util.inspect and colored stylizer initialization now install fallbacks after failures. Child-process tests cover Bun logging and custom inspection when globalThis.process is unset.

Changes

Inspection resilience

Layer / File(s) Summary
Exception-safe property enumeration
src/jsc/bindings/bindings.cpp, test/js/bun/util/inspect.test.js
forEachPropertyImpl records the getPropertySlot result, clears lookup exceptions, and skips absent properties. A child-process test verifies that console.log(Bun) continues after a lazy property failure.
Lazy inspection fallbacks
src/jsc/bindings/ZigGlobalObject.cpp, test/js/bun/util/inspect.test.js
Lazy node:util.inspect and colored stylizer initialization validate results, clear failures, and install fallback functions. Child-process tests verify custom inspection output, color fallback behavior, and continued execution.

Possibly related PRs

Suggested reviewers: cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary exception-leak fix and is directly related to the pull request changes.
Description check ✅ Passed The description includes both required sections and provides detailed change context, reproduction steps, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@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 `@test/js/bun/util/inspect.test.js`:
- Around line 936-940: Add coverage in the test named “console.log survives a
lazy property builder throwing mid-walk” for a separate object whose proxy get
trap throws, then log it and assert execution still prints “SURVIVED” and exits
with code 0. Preserve the existing lazy-property case while exercising the
distinct throwing-proxy path.
- Around line 942-948: Update the Bun.spawn promise collection in this
subprocess test to drain proc.stderr concurrently with proc.stdout.text() and
proc.exited. Preserve the existing stdout and exitCode results while awaiting
all three operations together, ensuring the configured stderr pipe cannot block
the child process.
🪄 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: 39ebccea-9603-4b43-9b65-5e44b2ea4c7f

📥 Commits

Reviewing files that changed from the base of the PR and between cbc209f and 01f18bf.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/util/inspect.test.js

Comment on lines +936 to +940
it("console.log survives a lazy property builder throwing mid-walk", async () => {
const code = `
globalThis.process = undefined;
console.log(Bun);
console.log("SURVIVED");

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.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for the throwing proxy-trap path.

The C++ change handles both failed lazy property builders and throwing proxy traps. This test covers only the lazy Bun property path. Add a proxy get trap that throws and verify that execution still reaches SURVIVED with exit code 0.

As per coding guidelines, tests must cover the complete relevant variant matrix, including distinct error paths. The PR objective also identifies throwing proxy traps as a handled path.

🤖 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/bun/util/inspect.test.js` around lines 936 - 940, Add coverage in the
test named “console.log survives a lazy property builder throwing mid-walk” for
a separate object whose proxy get trap throws, then log it and assert execution
still prints “SURVIVED” and exits with code 0. Preserve the existing
lazy-property case while exercising the distinct throwing-proxy path.

Source: Coding guidelines

Comment thread test/js/bun/util/inspect.test.js Outdated
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Applied the stderr drain in 0d0389001f5, the test now awaits stdout, stderr, and exit together.

Skipping the proxy-trap test case: I checked it against the unfixed binary and a throwing get trap does not reach the changed branch. The formatter reads the proxy's target without invoking traps (a proxy with a throwing get trap prints its target's values and exits 0 on builds without this fix), so that case would pass before and after and adds no regression coverage. The reachable case for the fixed line is the lazy static property builder, which the test covers.

Comment thread test/js/bun/util/inspect.test.js Outdated
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

The stderr drain landed in 0d03890 before this review ran, the test now awaits stdout, stderr, and exit together.

Not asserting stderr is empty on purpose: the child is a debug build and some CI lanes emit harmless allocator or sanitizer notes on stderr, so an exact-empty check is a flake risk. The neighboring ASAN test in this file makes the same tradeoff. A regression still fails clearly here: the child aborts, SURVIVED never prints, and the exit code check fails.

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

LGTM — my earlier stderr-drain comment was stale on arrival; 0d03890 already fixed it and the author's rationale for not asserting stderr === "" (debug-build noise on some CI lanes) is sound.

What was reviewed:

  • The reorder in forEachPropertyImpl now matches forEachPropertyOrdered (bindings.cpp:5763-5765) — clear-then-check instead of check-then-clear.
  • Confirmed the no-exception path is behaviorally unchanged; only the throw-and-return-false case now clears before continue.
  • Test drains all three pipes concurrently; the declined proxy-trap coverage was verified by the author to not reach the changed branch.
Extended reasoning...

Overview

Two-file change: a 5-line reorder in src/jsc/bindings/bindings.cpp (JSC__JSValue__forEachPropertyImpl) so CLEAR_IF_EXCEPTION(scope) runs before the if (!hasProperty) continue; check, and a subprocess regression test in test/js/bun/util/inspect.test.js. The fix addresses a Fuzzilli-found debug-build abort where a lazy static property builder (Bun.$) throws during getPropertySlot, JSC returns false with a pending exception, and the old code continued past the clear, tripping releaseAssertNoException on the next JSC entry.

Security risks

None. This is exception-scope hygiene in the inspect/formatter path — no auth, crypto, permissions, or untrusted-input parsing. The change strictly narrows the window during which a pending exception can persist.

Level of scrutiny

Low. The change is mechanical: store the bool, clear, then branch — exactly what the sibling JSC__JSValue__forEachPropertyOrdered already does at bindings.cpp:5763-5765. The no-exception path is byte-for-byte equivalent to the old behavior. The PR description traces the mechanism from repro through JSC's setUpStaticFunctionSlot to the assertion, and the author verified the test aborts on an unfixed debug build.

Other factors

All prior review feedback is resolved: the stderr-drain concern (raised by both CodeRabbit and my earlier run) landed in 0d03890 before my comment posted, and the current diff confirms proc.stderr.text() is awaited in the Promise.all. The author declined the proxy-trap test suggestion after verifying it doesn't reach the changed branch on an unfixed binary — a valid "test would pass before and after" rejection per REVIEW.md. The choice not to assert empty stderr matches the neighboring ASAN test's tradeoff and avoids flakes from allocator/sanitizer chatter.

@robobun

robobun commented Aug 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:37 PM PT - Aug 9th, 2026

❌ @robobun, your commit c4ad31e has 2 failures in Build #91150 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37292

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

bun-37292 --bun

…rows

Requiring node:util runs the module body, which throws when user code has
clobbered globals it reads (globalThis.process = undefined). The lazy
property initializers for m_utilInspectFunction and
m_utilInspectStylizeColorFunction returned early on exception without
calling init.set(), violating the LazyProperty contract: debug builds hit
the lazyTag assertion and release builds fail fast.

Reachable from plain JS on every platform by formatting any object with a
nodejs.util.inspect.custom function after clobbering process. On Windows
console.log(Bun) reaches it through Bun.env, whose custom inspect loads
util.inspect.

Clear the non-termination exception and install a stub inspect function
(or the no-color stylizer) so formatting degrades instead of aborting.
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment on lines +1932 to +1933
// Fallback for when requiring node:util throws during lazy initialization of
// m_utilInspectFunction (e.g. globalThis.process was clobbered by user code).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment on lines +2327 to +2329
// Requiring node:util runs JS that can throw (e.g. user code clobbered
// globalThis.process). The initializer must still set the property, so
// degrade to a stub instead of crashing.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment on lines +2369 to +2370
// The initializer must still set the property even when util.inspect is
// unavailable; fall back to the stylizer that applies no colors.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@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/ZigGlobalObject.cpp:2352-2372 — The no-color fallback at lines 2369-2372 is unreachable for the scenario it targets: m_utilInspectFunction now always clears its exception and installs a value (real inspect or the stub), so scope.exception() at 2354 is never set, and getStylizeWithColor is a pure factory that returns a closure without touching its argument — so calling it with the stub succeeds and init.set(stylize) at 2364 always runs. What actually gets cached is a stylizeWithColor closure whose captured inspect is the bare host stub with no .styles/.colors; the first options.stylize(str, type) call with colors on hits undefined[styleType] and throws. Either check for the stub's identity before calling the factory and take the no-color path there, or attach empty .styles/.colors to the stub.

    Extended reasoning...

    What the bug is

    The m_utilInspectStylizeColorFunction initializer adds a fallback branch (lines 2369-2372) whose comment says "fall back to the stylizer that applies no colors" when util.inspect is unavailable. That branch is dead for the scenario this PR targets, and instead of the no-color stylizer, a broken stylizer closure gets cached — one that throws a TypeError the first time it's actually invoked.

    Why the fallback is unreachable

    Trace the initializer at ZigGlobalObject.cpp:2348-2373 after this PR's changes:

    1. Line 2352 calls utilInspectFunction(). The reworked m_utilInspectFunction initializer (lines 2314-2332) now handles the "requiring node:util threw" case by calling scope.tryClearException() and then init.set(<stub>). It always installs a value and never leaves an ordinary exception pending. So after line 2352, scope.exception() is unset.

    2. Line 2354 if (!scope.exception()) is therefore always entered.

    3. Line 2359 calls getStylizeWithColor(inspect) via profiledCall. Look at src/js/builtins/UtilInspect.ts:4-15:

      export function getStylizeWithColor(inspect: Inspect) {
        return function stylizeWithColor(str, styleType) {
          const style = inspect.styles[styleType];
          ...
        };
      }

      This is a pure factory — it returns a closure without dereferencing inspect at all. Passing the host stub jsFunctionUtilInspectFallback (a plain native JSFunction) succeeds with no exception; returnedException stays null.

    4. Line 2363 dynamicDowncast<JSFunction>(result) — the returned closure is a JSFunction, so this succeeds, init.set(stylize) runs at line 2364, and the initializer returns.

    Lines 2369-2372 are never reached. (Minor caveat: a TerminationException would survive tryClearException() and could reach the fallback, but that isn't the case this PR is about.)

    What actually gets cached

    A stylizeWithColor closure whose captured inspect is the bare host function jsFunctionUtilInspectFallback. That stub has no .styles or .colors properties — it's just JSFunction::create(vm, owner, 2, "inspect"_s, jsFunctionUtilInspectFallback, ...) with nothing attached.

    Step-by-step proof of the failure mode

    Concrete sequence with globalThis.process = undefined and colors enabled:

    1. User code clobbers process, then formats an object with a custom inspect that calls options.stylize("foo", "string"), on a TTY (or with FORCE_COLOR) so colors=true.
    2. UtilInspect.cpp selects utilInspectStylizeColorFunction() because colors is true.
    3. That triggers the m_utilInspectStylizeColorFunction initializer, which first triggers the m_utilInspectFunction initializer.
    4. Requiring node:util throws (process is undefined). The initializer clears the exception and installs the stub.
    5. Back in the color-stylizer initializer, scope.exception() is clear → factory is called with the stub → closure is returned → cached.
    6. The custom inspect callback runs and calls options.stylize("foo", "string").
    7. Inside the closure: inspect.styles[styleType] → the stub has no .styles → undefined["string"] → TypeError: undefined is not an object.

    The comment's promised behavior — "fall back to the stylizer that applies no colors" — never happens.

    Why the new tests don't catch this

    Both new tests spawn with stdout: "pipe", so stdout is not a TTY, colors defaults to false, and utilInspectStylizeNoColorFunction() is selected instead of the color one (UtilInspect.cpp:30). The second test's custom inspect also never calls options.stylize. Neither exercises the cached broken closure.

    Impact

    This is fuzzer-territory niche (user has already clobbered globalThis.process), and pre-PR the same scenario was a hard SIGABRT, so this PR is still a strict improvement. The stylize TypeError would also be caught by the exception handling around the custom-inspect call rather than aborting. But per REVIEW.md — "Every line you add must be demonstrably live" — this is four lines of dead code with a comment that describes behavior the code doesn't have, and the value that is cached is broken.

    How to fix

    Simplest: after calling utilInspectFunction(), check whether the returned function is the stub (e.g. compare its native function pointer, or set a flag on the global when installing the stub) and go straight to the no-color fallback if so — before calling the factory. Alternatively, attach empty styles/colors objects to the stub in the m_utilInspectFunction initializer so the cached closure degrades to a no-op instead of throwing. Either way the dead branch and its comment can then be removed or actually wired.

getStylizeWithColor returns a closure without reading its argument, so the
color stylizer initializer happily wrapped the fallback inspect stub and the
closure then threw on first stylize call (the stub has no styles or colors).
Check for inspect.styles before building the color stylizer so the broken
environment gets stylizeWithNoColor instead.
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the stylizer. getStylizeWithColor never dereferences its argument, so the color path cached a closure around the stub and the first options.stylize call with colors on threw (verified with FORCE_COLOR=1, the child died with a TypeError from stylizeWithColor and never reached the end of the script).

Fixed in 5c28744 by checking for inspect.styles before building the color stylizer, which is the capability the closure actually needs at call time. The stub has no styles, so the broken environment now takes the colorless stylizer, and the no-color fallback branch is reachable exactly when it should be. Verified both directions: FORCE_COLOR=1 with process clobbered prints unstyled output and exits 0, and a healthy environment still emits ANSI codes. Added a regression test for the colors case.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment on lines +2351 to +2352
// stylizeWithColor reads inspect.styles at call time; the fallback stub
// installed when node:util fails to load has none, so stay colorless then.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@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

🤖 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/bun/util/inspect.test.js`:
- Around line 990-992: Strengthen the assertions in this test by replacing the
separate stdout containment checks with one exact equality assertion against
"hi\nSURVIVED\n". Keep the existing exitCode assertion unchanged so the test
verifies both colorless output and successful execution.
🪄 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: 3f1eb219-eb8d-4046-9c29-195c68dfb27d

📥 Commits

Reviewing files that changed from the base of the PR and between 4349414 and a81eb32.

📒 Files selected for processing (2)
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/bun/util/inspect.test.js

Comment thread test/js/bun/util/inspect.test.js Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

This one arrived against an older sha. The stylizer gap it describes was the same finding from the previous review round and was fixed in 5c28744: the initializer now checks for inspect.styles before building the color stylizer (the suggested option 2), so the stub routes to stylizeWithNoColor and the fallback branch is live. The piped-stdout concern is covered too: the regression test added in that commit sets FORCE_COLOR=1, which enables colors under a pipe, and it failed with the stylize TypeError before the fix.

@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 the stylizer fix in 5c28744 and the follow-up test tightening — no bugs found this round. Because it rewrites two LazyProperty initializers in ZigGlobalObject.cpp (exception-scope handling, tryClearException, fallback host functions), a maintainer look would still be worthwhile.

What was reviewed:

  • bindings.cpp reorder now matches forEachPropertyOrdered (bindings.cpp:5763-5767) exactly.
  • The inspect.styles gate makes the no-color fallback reachable for the stub; getStylizeWithColor no longer captures a styles-less function.
  • tryClearException() leaves termination exceptions pending, and init.set() is now reached on every path in both initializers.
  • Tests drain stderr concurrently and the FORCE_COLOR case asserts exact colorless output.
Extended reasoning...

Overview

Three files: a 5-line reorder in src/jsc/bindings/bindings.cpp (forEachPropertyImpl now clears the exception before the !hasProperty continue, matching its ordered sibling), a ~22-net-line rewrite of two LazyProperty initializers in src/jsc/bindings/ZigGlobalObject.cpp (m_utilInspectFunction and m_utilInspectStylizeColorFunction) so they always call init.set() even when requiring node:util throws, plus a new 5-line host-function stub. Three subprocess regression tests in test/js/bun/util/inspect.test.js.

Security risks

None identified. The change swallows exceptions via tryClearException(), which by design leaves termination exceptions pending, so this does not create a way to survive a VM termination. The fallback stub is only installed when node:util itself fails to load — a deliberately broken environment. No new user-controlled input is parsed.

Level of scrutiny

Moderate-to-high. The bindings.cpp reorder is mechanical and mirrors the existing ordered variant line-for-line. The ZigGlobalObject.cpp change is more substantial: it replaces RETURN_IF_EXCEPTION early returns (which violated the LazyProperty must-always-set contract) with nested if (!scope.exception()) success paths and a shared fallback tail. This is JSC exception-scope C++ in a file that ships to every user, and this PR already went through one round where an earlier version installed a broken color stylizer (fixed in 5c28744 by gating on inspect.styles). That history argues for a human pass rather than auto-approval.

Other factors

  • All prior review feedback (mine and CodeRabbit's) has been addressed: stderr is drained concurrently, the FORCE_COLOR test asserts exact stdout, and the stylizer gate is live. The one declined nit (proxy-trap coverage) was declined with a checked reason — the formatter reads proxy targets without invoking traps, so that path does not reach the changed line.
  • The comment-cop bot fired on earlier commits; the current diff has single-line comments only.
  • dynamicDowncast replaces uncheckedDowncast on the happy path, which is strictly safer.
  • I did not find any other LazyProperty initializers in this file with the same RETURN_IF_EXCEPTION-before-init.set() shape that this PR leaves unfixed for the same trigger, but I did not exhaustively audit every initializer — worth a quick scan by someone who knows which ones can enter user JS.

@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the open question about other initializers with the same shape: scanned every initLater lambda in src/jsc/bindings. m_utilInspectFunction is the only one that requires an internal module, and m_utilInspectStylizeColorFunction (calling the getStylize builtin) is the only other one that runs JS. Both are the ones fixed here; the remaining RETURN_IF_EXCEPTION uses in ZigGlobalObject.cpp are in ordinary member functions, not lazy property initializers.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated into #37160, which carries the same bindings.cpp exception clearing plus a fix for the stale-structure assertion the narrower fix still hits, and handles the util.inspect lazy initializers with a stub that keeps options.stylize working. The three cases from this PR's tests (process clobbered during console.log(Bun), node:util failing to load with a custom inspect, and the FORCE_COLOR stylize path) are now rows of the two matrices in test/js/web/console/console-log.test.ts there. Closing.

@robobun robobun closed this Aug 13, 2026
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