Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions src/jsc/bindings/BunObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,6 @@ static JSValue defaultBunSQLObject(VM& vm, JSObject* bunObject)
auto scope = DECLARE_THROW_SCOPE(vm);
auto* globalObject = defaultGlobalObject(bunObject->globalObject());
JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql);
#if BUN_DEBUG
if (scope.exception()) globalObject->reportUncaughtExceptionAtEventLoop(globalObject, scope.exception());
#endif
RETURN_IF_EXCEPTION(scope, {});
RELEASE_AND_RETURN(scope, sqlValue.getObject()->get(globalObject, vm.propertyNames->defaultKeyword));
}
Expand All @@ -331,9 +328,6 @@ static JSValue constructBunSQLObject(VM& vm, JSObject* bunObject)
auto scope = DECLARE_THROW_SCOPE(vm);
auto* globalObject = defaultGlobalObject(bunObject->globalObject());
JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql);
#if BUN_DEBUG
if (scope.exception()) globalObject->reportUncaughtExceptionAtEventLoop(globalObject, scope.exception());
#endif
RETURN_IF_EXCEPTION(scope, {});
auto clientData = WebCore::clientData(vm);
RELEASE_AND_RETURN(scope, sqlValue.getObject()->get(globalObject, clientData->builtinNames().SQLPublicName()));
Expand Down
41 changes: 41 additions & 0 deletions test/js/bun/bun-object/lazy-getter-module-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";

// Clobbering a global that an internal module needs makes that module fail to
// evaluate. The Bun.sql and Bun.SQL lazy getters used to report the failure
// while the exception was still pending on the VM, which aborted debug builds
// inside the uncaught exception handler (and in the process.emit path when
// process._fatalException had not been reified yet). The access must instead
// throw the evaluation error to the caller and leave the process healthy.
describe.concurrent("Bun object lazy getters", () => {
for (const reifyFatalException of [false, true]) {
test(`sql getter module failure propagates cleanly (fatalException reified: ${reifyFatalException})`, async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
${reifyFatalException ? "process._fatalException;" : ""}
globalThis.Object = undefined;
let caught = "";
try {
Bun.sql;
} catch (e) {
caught = e.constructor.name;
}
try {
Bun.SQL;
} catch (e) {}
console.log("caught " + caught);
`,
Comment on lines +26 to +30

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

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

Check warning on line 37 in test/js/bun/bun-object/lazy-getter-module-failure.test.ts

View check run for this annotation

Claude / Claude Code Review

Test pipes stderr but never drains it

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).
Comment on lines +33 to +37

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.

expect(exitCode).toBe(0);
Comment on lines +34 to +38

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

});
}
});