Skip to content

bun object: don't report a propagating sql module failure as uncaught - #37198

Closed
robobun wants to merge 1 commit into
mainfrom
farm/2354935a/bun-sql-getter-pending-exception
Closed

bun object: don't report a propagating sql module failure as uncaught#37198
robobun wants to merge 1 commit into
mainfrom
farm/2354935a/bun-sql-getter-pending-exception

Conversation

@robobun

@robobun robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

What

Fuzzilli has been hitting this flaky assertion for a while (17 reports of the same fingerprint):

ASSERTION FAILED: !scope.exception() || vm.hasPendingTerminationException() || !hasProperty
JavaScriptCore/JSObjectInlines.h(137) : JSValue JSC::JSObject::get(JSGlobalObject *, PropertyName)

A report finally arrived with enough stderr context to reconstruct the donor-process state, and it reduces to a deterministic three liner:

process._fatalException;          // reify the property
globalThis.Object = undefined;    // make internal module evaluation throw
Bun.sql;                          // abort (debug builds)

The chain: the Bun.sql/Bun.SQL lazy getters carry a debug-only block that, when the BunSql internal module fails to evaluate, passes the failure to reportUncaughtExceptionAtEventLoop with the exception still pending on the VM. That handler enters Bun__handleUncaughtException, which does process->get(globalObject, "_fatalException"); entering that get with a pending exception and an existing property is exactly the asserted condition. When _fatalException has not been reified yet, the handler instead falls into the process.emit path with the pending exception and trips a Structure assertion, so both crash signatures share this root cause.

The report is also semantically wrong: the exception is not uncaught. RETURN_IF_EXCEPTION on the next line propagates it, and the property access throws it to the caller (every other caller of the reporter clears the exception first, for example constructStdioWriteStream).

Fix

Remove the two #if BUN_DEBUG report blocks. The module failure now propagates cleanly: Bun.sql throws the evaluation error as a catchable exception and the process stays healthy.

Test

test/js/bun/bun-object/lazy-getter-module-failure.test.ts runs the reduced repro in a subprocess for both variants (with and without _fatalException reified). Both abort the unfixed debug build with the respective assertions and pass with the fix; release builds were unaffected (asserts compiled out), which is why the fuzzer only saw this on the ASAN lane.

Related: the fingerprint also has slower-burn instances in event-loop completion paths with swallowed settle errors; those are tracked separately (#37004 for the DNS drains, #34829 for valkey). This PR fixes the instance that made the fuzzer reports reproducible.

The Bun.sql and Bun.SQL lazy getters had a debug-only block that passed a
failed BunSql module evaluation to reportUncaughtExceptionAtEventLoop with
the exception still pending on the VM. The handler then entered
process->get("_fatalException") (or the process.emit path when that
property was not yet reified) with the exception set, which trips the
exception-scope assertions in debug builds: JSObjectInlines.h(137) when
the property exists, or a Structure check in the emit path otherwise.
Found by fuzzing (a fuzzed script clobbered globalThis.Object, making the
module evaluation throw).

The report was also misleading: the exception is not uncaught, the next
line propagates it and the property access throws it to the caller.
Remove the blocks so the failure propagates cleanly.
@github-actions github-actions Bot added the claude label Aug 8, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The SQL constructors no longer perform debug-only exception reporting. A subprocess regression test verifies failed Bun.sql and Bun.SQL lazy getter access under both fatal-exception states.

Changes

SQL lazy getter failure handling

Layer / File(s) Summary
Remove duplicate SQL exception reporting
src/jsc/bindings/BunObject.cpp
The Bun.sql and Bun.SQL constructor paths remove debug-only exception-reporting calls. RETURN_IF_EXCEPTION continues to propagate errors.
Validate failed lazy getter access
test/js/bun/bun-object/lazy-getter-module-failure.test.ts
A subprocess test verifies TypeError propagation, safe repeated access, both fatal-exception states, expected output, and zero exit status.

Possibly related PRs

  • oven-sh/bun#37160: Removes the same debug exception-reporting blocks from the SQL lazy-property constructors.
  • oven-sh/bun#37175: Modifies the same BunObject.cpp SQL exception-handling paths.
  • oven-sh/bun#37191: Covers the same SQL lazy-property exception-handling behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: preventing incorrect uncaught-exception reporting for Bun SQL module failures.
Description check ✅ Passed The description explains the failure, root cause, fix, and subprocess verification, although its headings differ from the repository template.
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/bun-object/lazy-getter-module-failure.test.ts`:
- Around line 26-30: Update the inline Bun.SQL failure test to capture the
thrown error type in the catch block and include it in the logged output, then
update the expected output accordingly. Ensure the test explicitly fails or
reports when Bun.SQL returns normally rather than throwing.
- Around line 34-38: Update the process result handling around proc.stdout and
proc.exited to also read proc.stderr.text() concurrently. Capture the stderr
result and assert that it is empty alongside the existing stdout and exitCode
assertions.
🪄 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: decd5ee4-0688-4a67-ab44-bc60bbf33b7d

📥 Commits

Reviewing files that changed from the base of the PR and between 9d519e8 and 8b68312.

📒 Files selected for processing (2)
  • src/jsc/bindings/BunObject.cpp
  • test/js/bun/bun-object/lazy-getter-module-failure.test.ts
💤 Files with no reviewable changes (1)
  • src/jsc/bindings/BunObject.cpp

Comment on lines +26 to +30
try {
Bun.SQL;
} catch (e) {}
console.log("caught " + caught);
`,

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

target=$(git ls-files | rg '(^|/)lazy-getter-module-failure\.test\.ts$' | head -n 1)
printf '%s\n' "TARGET=$target"
wc -l "$target"
cat -n "$target"

printf '\n--- related references ---\n'
rg -n -C 4 'Bun\.SQL|lazy-getter-module-failure|caught TypeError|Bun\.sql' test src packages 2>/dev/null | head -n 240

Repository: oven-sh/bun

Length of output: 18427


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate source files ---'
git ls-files | rg '(^|/)(bun-object|lazy|getters?|sql).*\.(ts|zig|cpp|h|hpp|js)$' | head -n 160

printf '\n--- lazy getter and module failure references ---\n'
rg -n -C 5 'lazy.*getter|getter.*lazy|module.*fail|evaluation error|Bun\.sql|Bun\.SQL' src test --glob '*.{ts,tsx,js,jsx,zig,cpp,c,h,hpp}' | head -n 320

printf '\n--- relevant test conventions ---\n'
rg -n -C 5 'Bun\.spawn\(|stdout\.trim\(\)|e\.constructor\.name|caught = e' test/js test/regression --glob '*.test.{ts,tsx,js,jsx}' 2>/dev/null | head -n 260

Repository: oven-sh/bun

Length of output: 48091


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- SQL API generation input ---'
file=$(git ls-files | rg '(^|/)sql\.classes\.ts$' | head -n 1)
printf '%s\n' "FILE=$file"
wc -l "$file"
cat -n "$file" | sed -n '1,240p'

printf '\n--- Bun SQL exports and property registration ---\n'
rg -n -C 6 'sql\.classes|SQL|defaultSQLObject|lazyDefaultSQL|putDirect.*SQL|define.*SQL|property.*sql' src/runtime src/js src/jsc --glob '*.{ts,zig,cpp,c,h,hpp}' | head -n 360

printf '\n--- standalone output-invariant probe ---\n'
node - <<'JS'
function current({ sqlThrows, SQLThrows }) {
  let caught = "";
  try {
    if (sqlThrows) throw new TypeError("sql failure");
  } catch (e) {
    caught = e.constructor.name;
  }
  try {
    if (SQLThrows) throw new TypeError("SQL failure");
  } catch (e) {}
  return "caught " + caught;
}

function proposed({ sqlThrows, SQLThrows }) {
  let caught = "";
  let sqlCaught = "";
  try {
    if (sqlThrows) throw new TypeError("sql failure");
  } catch (e) {
    caught = e.constructor.name;
  }
  try {
    if (SQLThrows) throw new TypeError("SQL failure");
  } catch (e) {
    sqlCaught = e.constructor.name;
  }
  return "caught " + caught + " SQL " + sqlCaught;
}

for (const SQLThrows of [false, true]) {
  const input = { sqlThrows: true, SQLThrows };
  console.log(JSON.stringify({ input, current: current(input), proposed: proposed(input) }));
}
JS

Repository: oven-sh/bun

Length of output: 24117


Assert the Bun.SQL failure.

The empty catch does not prove that Bun.SQL throws. Capture its error type, include it in the output, and update the expected output. Otherwise, this test can pass when Bun.SQL returns normally.

🤖 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/bun-object/lazy-getter-module-failure.test.ts` around lines 26 -
30, Update the inline Bun.SQL failure test to capture the thrown error type in
the catch block and include it in the logged output, then update the expected
output accordingly. Ensure the test explicitly fails or reports when Bun.SQL
returns normally rather than throwing.

Source: Coding guidelines

Comment on lines +34 to +38
stderr: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout.trim()).toBe("caught TypeError");
expect(exitCode).toBe(0);

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
file=$(git ls-files 'test/js/bun/bun-object/lazy-getter-module-failure.test.ts' | head -n 1)
printf '%s\n' "FILE=$file"
if [ -n "$file" ]; then
  wc -l "$file"
  cat -n "$file"
fi
printf '%s\n' '--- relevant subprocess patterns ---'
rg -n -C 4 'lazy-getter-module-failure|stderr:\s*"pipe"|Promise\.all\(\[.*proc\.stdout|proc\.stderr\.text|proc\.exited' test/js/bun/bun-object -g '*.test.ts' || true

Repository: oven-sh/bun

Length of output: 4074


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- review guidance files ---'
git ls-files | rg '(^|/)REVIEW\.md$|situational|landing' || true

printf '%s\n' '--- subprocess assertions in neighboring tests ---'
rg -n -C 5 'stderr:\s*"pipe"|proc\.stderr\.text\(\)|proc\.exited' test/js/bun/bun-object -g '*.test.ts' | head -n 240

printf '%s\n' '--- bunEnv definition and usage ---'
rg -n -C 8 'export\s+(const|let)\s+bunEnv|bunEnv\s*=' test packages scripts -g '*.ts' -g '*.js' 2>/dev/null | head -n 160 || true

printf '%s\n' '--- structural verifier for the target subprocess ---'
python3 - <<'PY'
from pathlib import Path
import re

path = Path("test/js/bun/bun-object/lazy-getter-module-failure.test.ts").resolve()
text = path.read_text()
spawn = re.search(r'Bun\.spawn\(\{(?P<body>.*?)\n\s*\}\)', text, re.S)
assert spawn, "target Bun.spawn call not found"
body = spawn.group("body")
assert 'stdout: "pipe"' in body
assert 'stderr: "pipe"' in body
awaits = re.search(r'const \[(?P<vars>[^\]]+)\]\s*=\s*await Promise\.all\(\[(?P<items>[^\]]+)\]\)', text)
assert awaits, "subprocess Promise.all not found"
vars_ = [v.strip() for v in awaits.group("vars").split(",")]
items = [i.strip() for i in awaits.group("items").split(",")]
print("absolute_test_path:", path)
print("configured_streams:", [x for x in ("stdout", "stderr") if f'{x}: "pipe"' in body])
print("awaited_values:", vars_)
print("awaited_expressions:", items)
print("stderr_is_consumed:", any("proc.stderr" in item for item in items))
print("stderr_is_asserted:", bool(re.search(r'expect\([^)]*stderr|expect\(\{[^}]*stderr', text)))
PY

Repository: oven-sh/bun

Length of output: 10585


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- neighboring combined assertion ---'
sed -n '220,250p' test/js/bun/bun-object/deep-equals.test.ts

printf '%s\n' '--- bunEnv quiet settings ---'
sed -n '60,82p' test/harness.ts

printf '%s\n' '--- standalone unread-pipe probe ---'
python3 - <<'PY'
import errno
import fcntl
import os

read_fd, write_fd = os.pipe()
try:
    flags = fcntl.fcntl(write_fd, fcntl.F_GETFL)
    fcntl.fcntl(write_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
    chunk = b"x" * 4096
    total = 0
    while True:
        try:
            total += os.write(write_fd, chunk)
        except BlockingIOError as exc:
            assert exc.errno == errno.EAGAIN
            break
    print("unread_pipe_write_bytes_before_block:", total)
    print("unread_pipe_can_block_writer:", total > 0)
finally:
    os.close(write_fd)
    os.close(read_fd)
PY

Repository: oven-sh/bun

Length of output: 2342


Drain and assert the piped stderr.

Read proc.stderr.text() concurrently with stdout and proc.exited. Assert that stderr is empty with the expected stdout and exit code.

🤖 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/bun-object/lazy-getter-module-failure.test.ts` around lines 34 -
38, Update the process result handling around proc.stdout and proc.exited to
also read proc.stderr.text() concurrently. Capture the stderr result and assert
that it is empty alongside the existing stdout and exitCode assertions.

Sources: Coding guidelines, Learnings

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate: #37160 already removes these same two debug-report blocks as part of a broader fix for stale exceptions from failed lazy property initialization, and #37175 covers the inspect-side variants. For that PR's benefit, the reduced deterministic repro from the fuzzer investigation is three lines on a debug build: process._fatalException; globalThis.Object = undefined; Bun.sql. With _fatalException reified it aborts at the JSObjectInlines.h(137) exception-scope assert inside Bun__handleUncaughtException's process get, and without it at a Structure assert in the process.emit path, so a regression test covering both variants fails before the fix and passes after (see test/js/bun/bun-object/lazy-getter-module-failure.test.ts on this branch, free to lift).

@robobun robobun closed this Aug 8, 2026

@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 — removes two debug-only reportUncaughtExceptionAtEventLoop calls that fired with the exception still pending (every other caller clears first), letting the module-evaluation failure propagate via the existing RETURN_IF_EXCEPTION. The stderr-drain nit on the test is worth fixing but not blocking.

Extended reasoning...

Overview

Two three-line #if BUN_DEBUG blocks are removed from defaultBunSQLObject and constructBunSQLObject in src/jsc/bindings/BunObject.cpp. These blocks called reportUncaughtExceptionAtEventLoop while the exception was still pending on the VM, then immediately hit RETURN_IF_EXCEPTION to propagate that same exception — so the exception was never actually uncaught, and reporting it while still pending violates JSC's exception-scope invariant (the !scope.exception() || ... || !hasProperty assertion in JSObject::get). A new subprocess test in test/js/bun/bun-object/ reproduces both crash variants.

Security risks

None. This is debug-build-only code being deleted; release builds compiled it out already. No auth, crypto, permissions, or untrusted-input parsing is touched.

Level of scrutiny

Low. The removed code was semantically wrong (reporting an exception as uncaught while simultaneously propagating it) and unique to these two sites — I grepped every other reportUncaughtExceptionAtEventLoop call site in src/jsc/bindings/ and confirmed they all tryClearException() first, matching the PR description's claim. What remains after removal is the standard requireId → RETURN_IF_EXCEPTION → RELEASE_AND_RETURN shape used by every other lazy property callback in this file.

Other factors

The one finding is a test-quality nit: stderr: "pipe" is configured but never drained, so on regression the JSC assertion text would be discarded. Deadlock risk is negligible (abort output is well under the 64KB pipe buffer); the impact is purely diagnosability of a future failure. It does not affect the correctness of the fix or the test's pass/fail behavior on the fixed vs. unfixed build, so it does not block approval. The test correctly covers both the reified and non-reified _fatalException variants and both getters (Bun.sql and Bun.SQL).

Comment on lines +33 to +37
stdout: "pipe",
stderr: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout.trim()).toBe("caught TypeError");

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 test sets stderr: "pipe" but never drains it — only proc.stdout.text() and proc.exited are awaited. On a regressed debug build the assertion-abort message goes to stderr and would be silently discarded, leaving only an opaque expected "caught TypeError", got "" failure. Add proc.stderr.text() to the Promise.all and assert on a combined { stdout, stderr, exitCode } object (or drop stderr: "pipe" to inherit).

Extended reasoning...

What the bug is

The subprocess test configures stderr: "pipe" at line 34 but the drain at line 36 only reads stdout:

stderr: "pipe",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout.trim()).toBe("caught TypeError");
expect(exitCode).toBe(0);

REVIEW.md's subprocess-test rule is explicit: "Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) — an unread pipe fills the ~64KB OS buffer and deadlocks the child. assert a combined { stdout, stderr, exitCode } object." CLAUDE.md's canonical test template shows the same three-way drain.

Code path that triggers it

The test spawns bun -e with a script that clobbers globalThis.Object and then touches Bun.sql / Bun.SQL. On the fixed build the child writes ~20 bytes to stdout and nothing to stderr, so the unread pipe never fills and the test passes. But this test exists specifically to guard a debug-build assertion abort — and on the unfixed build (or any future regression), the child aborts with:

ASSERTION FAILED: !scope.exception() || vm.hasPendingTerminationException() || !hasProperty
JavaScriptCore/JSObjectInlines.h(137) : JSValue JSC::JSObject::get(...)

That message, plus the ASan/backtrace dump, goes to stderr. Because stderr is piped but never read, that output is discarded when the process is reaped.

Why existing code doesn't prevent it

Nothing in the test consumes proc.stderr. The await using proc disposer will close the pipe on exit, but by then the diagnostic bytes are gone — they were never collected into a JS string, so they can't appear in the test-failure output.

Impact

  • Deadlock risk: negligible. Even the abort path writes well under the ~64KB OS pipe buffer, so the child won't block on a full stderr pipe in practice.
  • Diagnosability: real. When this test regresses, CI will report only expected "caught TypeError", got "" and exitCode !== 0, with no indication of which JSC assertion tripped or in which of the two reifyFatalException variants. The whole point of this test is to catch a specific assertion; throwing away the assertion text defeats that.

Step-by-step proof

  1. Revert the BunObject.cpp change (re-add the #if BUN_DEBUG report block).
  2. Run bun bd test test/js/bun/bun-object/lazy-getter-module-failure.test.ts.
  3. The child aborts; proc.exited resolves to a nonzero code / signal; proc.stdout.text() resolves to "".
  4. The test fails on expect("").toBe("caught TypeError").
  5. The ASSERTION FAILED: !scope.exception() ... line and backtrace were written to the child's stderr pipe, which was never read — the failure output shows nothing about the actual crash.

Fix

const [stdout, stderr, exitCode] = await Promise.all([
  proc.stdout.text(),
  proc.stderr.text(),
  proc.exited,
]);
expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({
  stdout: "caught TypeError",
  stderr: "",
  exitCode: 0,
});

Alternatively, drop stderr: "pipe" so stderr inherits to the test runner's stderr — then a regression's abort message surfaces directly in CI logs. Either satisfies the REVIEW.md rule; the combined-object assertion is the repo convention.

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