Skip to content

jsc: report exceptions thrown by deferred work tasks#30849

Closed
robobun wants to merge 2 commits into
mainfrom
farm/992eba7d/finalization-registry-throw
Closed

jsc: report exceptions thrown by deferred work tasks#30849
robobun wants to merge 2 commits into
mainfrom
farm/992eba7d/finalization-registry-throw

Conversation

@robobun

@robobun robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Fuzzilli fingerprint: ef17fd336d106f22

What

Bun overrides JSC's DeferredWorkTimer hooks and runs scheduled tasks (FinalizationRegistry cleanup, Atomics.waitAsync, etc.) via runPendingWork() in JSCTaskScheduler.cpp. Unlike JSC's stock DeferredWorkTimer::doWork(), this did not catch exceptions thrown by the task, so a throwing FinalizationRegistry cleanup callback left a pending exception on the VM which tripped releaseAssertNoException() in the event loop's validation scope:

ASSERTION FAILED: Unexpected exception observed
Error Exception: calling ArrayBuffer constructor without new is invalid
!exception()
ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()

Repro

const fr = new FinalizationRegistry(() => { ArrayBuffer(); });
(function register() { fr.register({ a: 1 }, "held"); })();
Bun.gc(true);
setTimeout(() => { Bun.gc(true); }, 50);
setTimeout(() => {}, 200);

Fix

Match JSC's DeferredWorkTimer::doWork(): wrap the task in a top exception scope, clear any non-termination exception and route it through reportUncaughtExceptionAtEventLoop so it reaches process.on('uncaughtException') handlers (matching Node.js) instead of aborting.

Bun overrides JSC's DeferredWorkTimer hooks and runs scheduled tasks
(FinalizationRegistry cleanup, Atomics.waitAsync, etc.) via
runPendingWork() in JSCTaskScheduler.cpp. Unlike JSC's stock
DeferredWorkTimer::doWork(), this did not catch exceptions thrown by
the task, so a throwing FinalizationRegistry cleanup callback left a
pending exception on the VM which tripped releaseAssertNoException()
in the event loop's validation scope.

Match JSC's behavior: wrap the task in a top exception scope, clear
any non-termination exception and route it through
reportUncaughtExceptionAtEventLoop so it reaches process
'uncaughtException' handlers instead of aborting.
@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds exception scope handling for deferred JSC work, introduces a FinalizationRegistry exception test, refactors embedded file resolution, and reformats platform-specific conditional compilation attributes for consistency across multiple files.

Changes

Exception handling and file resolution improvements

Layer / File(s) Summary
Exception scope handling for deferred JSC work
src/jsc/bindings/JSCTaskScheduler.cpp
JSCTaskScheduler now wraps deferred task execution with DECLARE_TOP_EXCEPTION_SCOPE(vm) and reports uncaught exceptions at the event loop via globalObject->globalObjectMethodTable()->reportUncaughtExceptionAtEventLoop(...), except for termination scenarios.
FinalizationRegistry exception safety test
test/js/bun/jsc/finalization-registry-throw.test.ts
New test suite validates that exceptions thrown from finalizer callbacks are safely routed through uncaughtException handlers and do not crash the process, using repeated garbage collection to trigger cleanup callbacks.
Embedded file resolution refactoring
src/runtime/jsc_hooks.rs
resolve_embedded_file_to_buf separates the unsafe mutable dereference of Fs::FileSystem::instance() from the .tmpdir().ok()? call for improved code clarity.
Platform-gating attribute formatting
src/crash_handler/lib.rs, src/errno/lib.rs, src/perf/tracy.rs, src/runtime/cli/Arguments.rs, src/runtime/cli/run_command.rs, src/runtime/cli/upgrade_command.rs, src/runtime/webview/ChromeProcess.rs, src/spawn/process.rs, src/spawn_sys/spawn_process.rs
Multiple #[cfg(...)] and #[cfg_attr(...)] conditions are reformatted from single-line to multi-line expressions for consistency, affecting platform-specific code paths in crash handling, errno tests, Tracy profiling, CLI argument processing, command execution, process spawning, and Chrome webview integration.

Possibly related PRs

  • oven-sh/bun#30735: Reformats the same #[cfg(any(...))] and cfg_attr(...) platform gates in crash handler, errno tests, and Tracy that overlap with this PR's attribute formatting changes.
  • oven-sh/bun#30720: Introduces and refactors resolve_embedded_file_to_buf in runtime/jsc_hooks.rs for embedded shared library materialization, directly overlapping with this PR's tmpname and tmpdir handling refactoring.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'jsc: report exceptions thrown by deferred work tasks' is concise, specific, and clearly summarizes the main change in the PR.
Description check ✅ Passed The description thoroughly explains the problem, includes a reproduction case, and documents the fix, addressing both required template sections.
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 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:18 AM PT - May 15th, 2026

@autofix-ci[bot], your commit 8815ee1 has 16 failures in Build #54896 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30849

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

bun-30849 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. z3 optimizer crashes #25454 - z3 optimizer crashes with "Uncaught exception while handling uncaught exception", matching the unhandled exception in deferred work tasks that this PR fixes

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

Fixes #25454

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Report exceptions from DeferredWorkTimer tasks as uncaught #30835 - Same fix for reporting exceptions thrown by DeferredWorkTimer tasks as uncaught exceptions
  2. Report exceptions thrown from DeferredWorkTimer tasks #30844 - Same fix for reporting exceptions thrown from DeferredWorkTimer tasks
  3. Report exceptions thrown by DeferredWorkTimer tasks as uncaughtException #30846 - Same fix for reporting DeferredWorkTimer task exceptions as uncaughtException

🤖 Generated with Claude 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: 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/jsc/finalization-registry-throw.test.ts`:
- Around line 38-46: Move the exit code assertion to the end of each subprocess
test so failures show process output first: in the block where you await
Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) and assign
stdout, stderr, exitCode, delay the line expect(exitCode).toBe(0) until after
all other expectations (e.g., after the stderr assertions and the stdout "CAUGHT
from cleanup callback" check); apply the same change to the second subprocess
test around the other Promise.all usage (the block referenced by stdout, stderr,
exitCode later in the file).
- Around line 39-40: Remove the brittle negative stderr checks by deleting the
two expect calls expect(stderr).not.toContain("ASSERTION FAILED") and
expect(stderr).not.toContain("releaseAssertNoException") (and the duplicate pair
referenced at 72-73) from finalization-registry-throw.test.ts; instead assert
the concrete expected behavior for the test (e.g., existing exit/signal
expectations or expected stdout content) using the existing test harness
assertions so the test verifies positive outcomes rather than the absence of
panic strings.
🪄 Autofix (Beta)

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: c3edaf5b-2ab7-4cfd-af56-e7200523dad0

📥 Commits

Reviewing files that changed from the base of the PR and between bb1973e and 8815ee1.

📒 Files selected for processing (12)
  • src/crash_handler/lib.rs
  • src/errno/lib.rs
  • src/jsc/bindings/JSCTaskScheduler.cpp
  • src/perf/tracy.rs
  • src/runtime/cli/Arguments.rs
  • src/runtime/cli/run_command.rs
  • src/runtime/cli/upgrade_command.rs
  • src/runtime/jsc_hooks.rs
  • src/runtime/webview/ChromeProcess.rs
  • src/spawn/process.rs
  • src/spawn_sys/spawn_process.rs
  • test/js/bun/jsc/finalization-registry-throw.test.ts

Comment on lines +38 to +46
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("ASSERTION FAILED");
expect(stderr).not.toContain("releaseAssertNoException");
expect(exitCode).toBe(0);
// GC timing is non-deterministic; if the callback ran it must have been
// routed through uncaughtException rather than crashing the process.
if (!stdout.includes("SKIPPED")) {
expect(stdout).toContain("CAUGHT from cleanup callback");
}

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert exitCode last in both subprocess tests.

expect(exitCode) is currently not the last assertion in either test, which weakens failure diagnostics for spawned-process failures.

As per coding guidelines: "Assert the exit code last in tests - this gives a more useful error message on test failure."

Also applies to: 71-77

🤖 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/jsc/finalization-registry-throw.test.ts` around lines 38 - 46,
Move the exit code assertion to the end of each subprocess test so failures show
process output first: in the block where you await
Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) and assign
stdout, stderr, exitCode, delay the line expect(exitCode).toBe(0) until after
all other expectations (e.g., after the stderr assertions and the stdout "CAUGHT
from cleanup callback" check); apply the same change to the second subprocess
test around the other Promise.all usage (the block referenced by stdout, stderr,
exitCode later in the file).

Comment on lines +39 to +40
expect(stderr).not.toContain("ASSERTION FAILED");
expect(stderr).not.toContain("releaseAssertNoException");

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove negative checks for assertion/panic-style stderr strings.

These output-string negation checks are disallowed and brittle here; rely on behavior/assertions that must hold (signal/exit/expected stdout path) instead.

As per coding guidelines: "Never write tests that check for no 'panic' or 'uncaught exception' or similar in output - these will never fail in CI."

Also applies to: 72-73

🤖 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/jsc/finalization-registry-throw.test.ts` around lines 39 - 40,
Remove the brittle negative stderr checks by deleting the two expect calls
expect(stderr).not.toContain("ASSERTION FAILED") and
expect(stderr).not.toContain("releaseAssertNoException") (and the duplicate pair
referenced at 72-73) from finalization-registry-throw.test.ts; instead assert
the concrete expected behavior for the test (e.g., existing exit/signal
expectations or expected stdout content) using the existing test harness
assertions so the test verifies positive outcomes rather than the absence of
panic strings.

@robobun

robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30835 (same fix, submitted earlier, CI passing).

@robobun robobun closed this May 15, 2026
@robobun robobun deleted the farm/992eba7d/finalization-registry-throw branch May 15, 2026 17:17
Comment on lines +39 to +40
expect(stderr).not.toContain("ASSERTION FAILED");
expect(stderr).not.toContain("releaseAssertNoException");

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.

🟡 nit: Per root CLAUDE.md ("Writing Tests"), tests should never assert that stderr does not contain "panic"/"uncaught exception"/assertion strings — these checks never fail in CI. The expect(stderr).not.toContain("ASSERTION FAILED") / "releaseAssertNoException" lines here and at lines 72-73 can be dropped; expect(exitCode).toBe(0) and expect(proc.signalCode).toBeNull() already catch the regression (the release assert aborts via SIGABRT).

Extended reasoning...

What

Root CLAUDE.md, Writing Tests section, states:

NEVER write tests that check for no "panic" or "uncaught exception" or similar in the test output. These tests will never fail in CI.

This PR adds four such assertions in test/js/bun/jsc/finalization-registry-throw.test.ts:

  • Lines 39-40: expect(stderr).not.toContain("ASSERTION FAILED") / expect(stderr).not.toContain("releaseAssertNoException")
  • Lines 72-73: same pair in the second test

Grep across test/ shows no other file uses not.toContain("ASSERTION FAILED"), so this introduces a pattern the repo has explicitly avoided.

Why these assertions don't add signal

  1. CI builds don't print this string. ASSERTION FAILED: … releaseAssertNoException is the debug-build assertion banner. In CI's release builds, releaseAssertNoException calls RELEASE_ASSERTWTFCrash, which aborts without writing that exact text. So in the environment that matters, the negative substring match is vacuously true even when the bug regresses.
  2. They pass when the callback never fires. GC timing is non-deterministic (the test itself acknowledges this with the SKIPPED branch). If the cleanup callback never runs, stderr is empty and not.toContain(...) passes trivially — it's not actually exercising the fix.

Step-by-step on a regression

Suppose this PR's C++ change is reverted and the test runs in CI:

  1. Subprocess registers the FinalizationRegistry, GC fires the cleanup callback, callback throws.
  2. releaseAssertNoException triggers RELEASE_ASSERT → process receives SIGABRT.
  3. proc.exited resolves with exitCode === null, proc.signalCode === "SIGABRT".
  4. Test 1: expect(exitCode).toBe(0) fails → regression caught. ✅
  5. Test 2: expect(proc.signalCode).toBeNull() fails → regression caught. ✅
  6. Meanwhile, stderr in a release build does not contain the literal string "ASSERTION FAILED", so lines 39-40 / 72-73 still pass. They contributed nothing.

The exit-code/signal assertions are doing all the work; the stderr checks are dead weight that violate the documented convention.

Fix

Delete lines 39-40 and 72-73. The tests remain correct — expect(exitCode).toBe(0) (test 1) and expect(proc.signalCode).toBeNull() (test 2) are sufficient and are the assertions that actually fail on regression.

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