Skip to content

process: don't cache Exception cell as process.nextTick on init failure#30285

Closed
robobun wants to merge 2 commits into
mainfrom
farm/4bb834e5/nexttick-exception-leak
Closed

process: don't cache Exception cell as process.nextTick on init failure#30285
robobun wants to merge 2 commits into
mainfrom
farm/4bb834e5/nexttick-exception-leak

Conversation

@robobun

@robobun robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a debug assertion failure at JSCell.cpp(268) (isSymbol() || isHeapBigInt()) found by Fuzzilli.

Root cause

process.nextTick is a lazy PropertyCallback. On first access, constructNextTickFn runs the JS initializeNextTickQueue builtin via JSC::profiledCall.

If that call fails — e.g. the first ever access to process.nextTick happens while the stack is already exhausted — Interpreter::executeCallImpl returns the JSC::Exception* cell itself as a JSValue (via return throwStackOverflowError(...) / RETURN_IF_EXCEPTION_WITH_TRAPS_DEFERRED(scope, scope.exception())). constructNextTickFn had no exception check and returned that value, which reifyStaticProperty then putDirect'd as the cached value of process.nextTick.

On the next call, JS tries to invoke that raw Exception cell (JSType CellType, not an object/string/symbol/bigint). The call-IC not-a-function path ends up in errorDescriptionForValueJSValue::toStringJSCell::toStringSlowCase, which asserts isSymbol() || isHeapBigInt() in debug builds and throws a misleading "Cannot convert a symbol to a string" TypeError in release builds.

Minimal repro:

function f() {
  try { f(); } catch (e) {}
  process.nextTick();
}
f();

Fix

Use the NakedPtr<Exception>& overload of profiledCall so the initializer's exception is caught (lazy PropertyCallbacks are assumed by JSC not to throw — leaving the exception pending also trips EXCEPTION_ASSERT(!scope.exception() || !hasSlot) in LLInt get_by_id). On failure, return jsUndefined() instead of the Exception cell so calling it later produces a normal "is not a function" TypeError rather than a crash.

How did you verify your code works?

  • Original fuzzer repro now exits cleanly with a catchable TypeError instead of aborting on the assertion, in both JIT and useJIT=0 modes.
  • Added regression test at test/js/node/process/process-nexttick-stack-overflow.test.ts which fails against current main (process.nextTick leaks as a non-function value) and passes with this change.
  • Existing process-nexttick.test.js suite still passes.
  • BUN_JSC_validateExceptionChecks=1 passes on the repro.

When process.nextTick is first accessed while the stack is exhausted,
the lazy initializer's profiledCall returns the JSC::Exception cell
(from Interpreter::executeCallImpl's throwStackOverflowError path).
That cell was being returned from the PropertyCallback and cached via
putDirect as the value of process.nextTick.

Calling that cell later reaches errorDescriptionForValue ->
JSCell::toStringSlowCase which asserts isSymbol() || isHeapBigInt()
in debug builds (and throws a misleading symbol-coercion TypeError in
release).

Use the exception-catching profiledCall overload so the callback never
throws (PropertyCallbacks are assumed not to) and return jsUndefined()
instead of the Exception cell on failure.
@github-actions github-actions Bot added the claude label May 5, 2026
@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e96807ea-d1c9-4d0e-b43d-88085bf4f6f4

📥 Commits

Reviewing files that changed from the base of the PR and between 1cebf6e and 40c002a.

📒 Files selected for processing (1)
  • test/js/node/process/process-nexttick-stack-overflow.test.ts

Walkthrough

Changed process.nextTick lazy-initializer to call the initializer via JSC::profiledCall with an out-parameter for exceptions, returning undefined on exception or non-object results and caching only object results. Added a test that accesses process.nextTick at maximum stack depth to verify behavior.

Changes

process.nextTick Safe Exception Handling

Layer / File(s) Summary
Core Implementation
src/jsc/bindings/BunProcess.cpp
constructNextTickFn() now invokes the initializer with JSC::profiledCall capturing a NakedPtr<JSC::Exception> out-parameter; if an exception is returned the function yields undefined; if call returns an object it caches m_nextTickFunction, otherwise returns undefined.
Test
test/js/node/process/process-nexttick-stack-overflow.test.ts
New test spawns a Bun child that recurses to stack exhaustion then accesses process.nextTick, asserting no stderr, stdout contains expected output, signalCode is null, and exit code is 0.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title precisely summarizes the main change: preventing caching of an Exception cell as process.nextTick when initialization fails.
Description check ✅ Passed The pull request description follows the template with complete sections: 'What does this PR do?' explains the bug fix with root cause analysis, and 'How did you verify your code works?' details multiple verification approaches.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:06 PM PT - May 5th, 2026

@autofix-ci[bot], your commit 40c002a has 1 failures in Build #51800 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30285

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

bun-30285 --bun

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Seg fault at address 0x5 #19650 - Reproduces the same bug pattern: stack exhaustion via recursion followed by API access in catch block causes segfault at address 0x5, matching the lazy property (reifyStaticProperty) Exception cell caching bug this PR fixes

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

Fixes #19650

🤖 Generated with Claude 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.

The fix looks correct and well-tested, but since it changes process.nextTick's lazy-init failure semantics (silently swallowing the exception and permanently caching undefined), it'd be good to have a maintainer confirm that tradeoff is preferred over alternatives like reporting the swallowed error.

Extended reasoning...

Overview

This PR modifies Process::constructNextTickFn in src/jsc/bindings/BunProcess.cpp (~6 lines) and adds a regression test. It switches the lazy PropertyCallback initializer for process.nextTick to the NakedPtr<Exception>& overload of JSC::profiledCall, so that if the JS initializeNextTickQueue builtin throws (e.g. stack overflow on first access), the exception is caught and jsUndefined() is returned instead of leaking the raw JSC::Exception cell as the cached property value. The non-object fallback now also returns jsUndefined() rather than the raw value. The happy path (initializer succeeds, returns a function) is unchanged.

Security risks

None identified. This is purely an error-path hardening change in JSC bindings; no new inputs, parsing, auth, or external surface area.

Level of scrutiny

Medium-high. process.nextTick is one of the hottest and most fundamental Node APIs in the runtime, and this is its one-shot lazy initializer. The diff is small and the pattern (NakedPtr<Exception> + profiledCall) is used widely elsewhere in src/jsc/bindings, and the C++-side caller Process::queueNextTick already handles a null m_nextTickFunction / non-object nextTick gracefully. However, the new behavior permanently reifies process.nextTick as undefined after a transient init failure and silently discards the underlying exception. The PR description convincingly argues this is forced by JSC's PropertyCallback invariant (cannot throw without tripping EXCEPTION_ASSERT in LLInt get_by_id), but whether to additionally surface/log the swallowed error, or whether the permanent-undefined degradation is acceptable, is a judgment call a maintainer should sign off on.

Other factors

  • Root-cause analysis in the PR description is thorough and matches the code; verified against reifyStaticProperty semantics and the existing queueNextTick fallback at BunProcess.cpp:3815-3821.
  • Regression test exercises the exact fuzzer scenario in a child process and asserts no crash / no Exception-cell leak.
  • robobun reports a build-cpp failure on macOS aarch64 for the pre-autofix commit; the C++ change itself looks syntactically standard, so this may be infra/flake, but it's another reason to have a human glance before merge.
  • No prior claude[bot] review on this PR.

@robobun

robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures on build #51800 are unrelated pre-existing flakes:

  • test-http-should-emit-close-when-connection-is-aborted.ts timeout on Windows aarch64 — also failing on unrelated branches in builds #51797, #51796, #51791
  • Lint JavaScript — pre-existing oxlint errors in src/js/internal/sql/errors.ts (not touched by this PR)
  • Remaining entries are in the flaky annotation bucket (hot.test.ts, html.test.ts, jsc-stress.test.ts, bun-install-registry.test.ts, spawn.test.ts on Windows aarch64)

The new process-nexttick-stack-overflow.test.ts passed on all platforms.

@robobun

robobun commented May 9, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #30441, which fixes the same root cause but reports the swallowed exception via reportUncaughtExceptionAtEventLoop (matching the existing constructStdin/Stdout/Stderr pattern) instead of silently dropping it — addressing the concern raised in the review above.

@robobun robobun closed this May 9, 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