Skip to content

Report failed promise settles in event-loop completions (subprocess, streams, blob, s3, valkey) - #37072

Open
robobun wants to merge 4 commits into
mainfrom
farm/31009f1a/settle-exception-discipline
Open

Report failed promise settles in event-loop completions (subprocess, streams, blob, s3, valkey)#37072
robobun wants to merge 4 commits into
mainfrom
farm/31009f1a/settle-exception-discipline

Conversation

@robobun

@robobun robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What

Fuzzing (Fuzzilli) keeps hitting a flaky debug assertion whose trigger moves between modules:

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

It fires when a native get() runs while a JS exception is already pending. The producer is a recurring defect class in event-loop completion code: a native completion settles a promise (or runs a completion callback), discards the result with let _ = and a // TODO: properly propagate exception upwards, and the pending exception rides the tick until some later native property read trips the assert. On release builds the stale exception instead gets misattributed to whatever JS runs next. #37004 (open) covers this class in the DNS drains; this PR covers the following sites from the same audit:

  • subprocess.rs exit path: the exited promise settle (exit code, signal, waitpid error) runs right before the onExit callback in the same task, so a failed settle fed its exception into the user's callback invocation.
  • ByteStream.rs on_cancel: the buffer-action reject (pending text()/arrayBuffer() on a native stream).
  • blob/copy_file.rs and blob/write_file.rs, both platforms:
    • The POSIX completions (CopyFile::then/reject, WriteFilePromise::run) run inside the task dispatch channel, whose error type is the terminated sentinel; the promise FFI wrappers collapse every settle failure to that sentinel, so a thrown exception was mislabeled as termination, stopped the tick drain early, and stayed pending. These now consult the VM via report_error_or_terminate (the dispatcher's designed reporter, previously unreachable because the label was erased upstream): a pending non-termination exception is reported and the task returns Ok, while real termination still unwinds the tick loop.
    • The Windows completions (CopyFileWindows::throw/resolve_promise, and the WriteFileWindows uv callbacks, which funnel through the same WriteFilePromise::run) are fire-and-forget; the copyfile ones report via report_active_exception_as_unhandled.
    • The cross-platform locked-body wait task (then_wrap) reports the same way.
  • s3/multipart.rs process_buffered: the single-request and multipart upload starts.
  • valkey_jsc/js_valkey.rs connection/idle timeout timers (the drain rewrite in redis: harden connection lifecycle (Handshake enum, promise-settlement fixes, -1251 LOC) #34829 covers the socket callbacks but not these two timer arms).

Fix

Check the settle result and, on failure, report the pending non-termination exception instead of discarding it: report_active_exception_as_unhandled in fire-and-forget completions (the idiom the socket, IPC, and http2 completions already use), report_error_or_terminate in completions that return into the task dispatch channel. The error label is never trusted, because the promise FFI wrappers collapse every failure to the terminated sentinel; the VM state decides, and termination exceptions stay pending as the event loop expects.

Scope

This PR does not exhaust the class. Sites with the same shape that remain, deliberately out of scope here and candidates for follow-ups in the same idiom: the streams.rs server-sink family (flush_promise/fulfill_promise discards in HTTPServerWritable/NetworkSink/Writable), ipc.rs:753, node_fs.rs:1746, node_fs_watcher.rs:877, ArrayBufferSink.rs:55, and the socket_body.rs sites (owned by a separate in-flight fix).

Sites audited and deliberately left alone because they are not in the class: Sink.rs js_close/js_end_with_sink (the generated JSSink.cpp callers check scope.exception() immediately after, so the exception does propagate), ReadableStream.rs Strong::get/to_any_blob (held values are always real stream cells, which take the throw-free tag path), and the JSValue::then registrations in FileSink.rs/bun_test.rs (the wrapper debug-asserts no non-termination exception internally).

An alternative worth noting for the maintainers: a single guard at JSC__JSPromise__resolve entry (bindings.cpp) converting a resolve-with-pending-exception into a rejection would close the whole class at once, at the cost of masking caller bugs. This PR keeps the per-site idiom #37004 established instead.

Why there is no failing-first test

A settle can only fail here with the VM already in an exceptional state, and none of these sites can be driven there from plain JS:

  • The settle values are primitives (exit codes, byte counts) or engine-created errors, and JSC's resolvePromise only consults user code (then lookup) for object resolutions; when that lookup throws, JSC catches it and converts it into a rejection of the same promise (JSPromise.cpp:693), leaving the VM clean, so hostile Object.prototype.then / Promise.prototype.then accessors cannot make these settles return an error.
  • The remaining producers are OOM mid-settle, a termination exception (behavior deliberately unchanged), or an exception leaked by some other buggy completion, which is the state this PR removes.

Empirically: running the touched flows (subprocess exit and signal settles, locked-body Bun.write, file-to-file copy) under a throwing Object.prototype.then accessor behaves identically on the unfixed release build and this branch's debug+ASAN build, settling correctly with no assert. The new tests pin that behavior so these completions stay healthy under the prototype-pollution state fuzzer processes run in; they are hardening pins, not fail-before reproductions, because the fixed branch is unreachable without an already-dirty VM.

Verification

  • bun bd (debug+ASAN) and cargo check --release; rust:check-all (all six target combos) for the platform-gated code in write_file.rs/copy_file.rs.
  • test/js/bun/spawn/spawn.test.ts, test/js/bun/io/bun-write.test.js (including the two new pollution tests, in both copyfile backends: default and BUN_FEATURE_FLAG_DISABLE_UV_FS_COPYFILE=1, on Linux and Windows), test/js/web/streams/streams.test.js, test/js/bun/s3/s3-connection-close.test.ts, test/js/valkey/reliability/connection-failures.test.ts all pass on the debug build.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/io/bun-write.test.js test/js/bun/spawn/spawn.test.ts

The subprocess exit path, ByteStream cancel, Bun.write completions
(copy_file, write_file, and the locked-body wait task), S3 multipart
buffering, and the valkey timeout timers all discarded the result of
settling a promise (or of a completion callback) with `let _ =`. A
failed settle leaves a JS exception pending on the VM, and these run as
event-loop completions with no host-call boundary to surface it, so the
stale exception rode the tick into unrelated JS: debug/ASAN builds hit
JSC's exception-state assertions and release builds misattribute the
error.

Check the settle result instead and report a pending non-termination
exception through the VM's unhandled-exception path, the same idiom the
socket, IPC, and http2 completions use. The error label cannot be
trusted to distinguish a thrown exception from termination (the FFI
wrappers collapse both to the terminated sentinel), so the VM state
decides: termination exceptions stay pending as the event loop expects.

Tests pin the touched completions (subprocess exit-code and signal
settles, locked-body and file-to-file Bun.write) under a hostile
Object.prototype.then accessor, the prototype-pollution state the
fuzzer runs scripts in.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The runtime now checks promise settlement and client failure results. Failed operations report active JavaScript exceptions instead of discarding them. Regression tests cover subprocess, streaming-write, and file-copy paths with a throwing Object.prototype.then accessor.

Promise settlement handling

Layer / File(s) Summary
Subprocess exit settlement
src/runtime/api/bun/subprocess.rs, test/js/bun/spawn/spawn.test.ts
Subprocess exit promise settlement now reports active exceptions. The test covers normal and signal-terminated exits with a throwing Object.prototype.then accessor.
Webcore settlement reporting
src/runtime/webcore/ByteStream.rs, src/runtime/webcore/blob/*, src/runtime/webcore/s3/multipart.rs, test/js/bun/io/bun-write.test.js
Stream, file, locked-value, and S3 callbacks now check promise operation results and report active exceptions. Bun.write regression coverage validates streaming writes and file copies.
Valkey timeout failures
src/runtime/valkey_jsc/js_valkey.rs
Idle and connection timeout callbacks now check client_fail results and report active exceptions.

Possibly related PRs

  • oven-sh/bun#36579: Hardens JavaScript promise and error handling around pending VM termination.
  • oven-sh/bun#36893: Modifies subprocess promise lifecycle handling.
  • oven-sh/bun#37067: Handles exceptions from failed promise settlements in native Rust callbacks.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change across the affected event-loop completion paths.
Description check ✅ Passed The description explains the problem, fix, scope, tests, and verification, although its headings differ from the repository template.

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

Comment thread test/js/bun/io/bun-write.test.js Outdated
… copyfile count

The Windows copyfile completion resolves 0 when the size is not known up
front (uv_fs_copyfile reports no byte count), so the expectation is
platform-aware. The fixture directory now comes from the harness so it
is removed on every exit path.
The resolved count on Windows depends on the backend: uv_fs_copyfile
reports none (so the promise resolves 0) while the fallback loop used
under BUN_FEATURE_FLAG_DISABLE_UV_FS_COPYFILE reports the real count,
and this file reruns itself with that flag. The destination content
check is the meaningful assertion.
Comment thread test/js/bun/io/bun-write.test.js Outdated
The POSIX halves of the Bun.write completions (WriteFilePromise::run,
CopyFile::then/reject) return into the task dispatch channel, whose
error type is the terminated sentinel; a thrown exception from a failed
settle was mislabeled as termination, stopped the tick drain early, and
stayed pending. Consult the VM via report_error_or_terminate instead:
report a pending non-termination exception and return Ok, keep real
termination as the Err that unwinds the tick loop.

WriteFilePromise::run is the completion callback on both platforms, so
the Windows write path now reports there too instead of inside
run_from_js_thread.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/webcore/blob/write_file.rs (1)

1364-1373: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Preserve TerminationException in all three callbacks. report_active_exception_as_unhandled clears termination through tryClearException. Do not ignore the Err from report_error_or_terminate; propagate JsTerminated or leave termination pending at the non-returning callback boundary.

🤖 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 `@src/runtime/webcore/blob/write_file.rs` around lines 1364 - 1373, Preserve
termination across all three callback error paths: in
src/runtime/webcore/blob/write_file.rs:1364-1373 and
src/runtime/webcore/blob/copy_file.rs:1659-1664 and 1751-1757, replace the
report_active_exception_as_unhandled handling around the relevant callback
settlement methods with logic that does not clear TerminationException,
propagates JsTerminated from report_error_or_terminate, or leaves termination
pending at the non-returning callback boundary.
🤖 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.

Outside diff comments:
In `@src/runtime/webcore/blob/write_file.rs`:
- Around line 1364-1373: Preserve termination across all three callback error
paths: in src/runtime/webcore/blob/write_file.rs:1364-1373 and
src/runtime/webcore/blob/copy_file.rs:1659-1664 and 1751-1757, replace the
report_active_exception_as_unhandled handling around the relevant callback
settlement methods with logic that does not clear TerminationException,
propagates JsTerminated from report_error_or_terminate, or leaves termination
pending at the non-returning callback boundary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c0206d00-eea8-4458-8f4b-b041f5485a86

📥 Commits

Reviewing files that changed from the base of the PR and between 32a2fb7 and 25c22c6.

📒 Files selected for processing (3)
  • src/runtime/webcore/blob/copy_file.rs
  • src/runtime/webcore/blob/write_file.rs
  • test/js/bun/io/bun-write.test.js

@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Re the review note about preserving TerminationException in the fire-and-forget callbacks (then_wrap, CopyFileWindows::throw/resolve_promise): the premise does not hold. report_active_exception_as_unhandled takes the exception via JSGlobalObject__tryTakeException, which calls ExceptionScope::tryClearException, and that function refuses to clear a termination exception (vendor/WebKit/Source/JavaScriptCore/runtime/ExceptionScope.h:120: it returns false without calling clearException when vm.isTerminationException(exception)). The helper then sees is_termination_exception() and skips reporting, so a TerminationException stays pending on the VM at these boundaries, which is the convention the event loop expects. These three callbacks return () and are not in the task dispatch channel, so there is no Err to propagate; the channel-returning completions (WriteFilePromise::run, CopyFile::then/reject) do propagate JsTerminated via report_error_or_terminate. This is also the same idiom the socket, IPC, and http2 completions already use.

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

Both earlier inline findings are addressed (harness tempDir in the parent, typeof wroteCopy for the backend-dependent count) and no new issues turned up this pass, but I'd like a human to sign off: this changes native error routing across six subsystems, swaps ?-propagation for report_error_or_terminate in the POSIX write/copy task completions, and the description explicitly asks maintainers to weigh the per-site idiom against a single JSC__JSPromise__resolve guard.

What was reviewed:

  • Confirmed report_error_or_terminate and report_active_exception_as_unhandled both consult VM state (not the erased error label) and leave termination pending, matching the PR's rationale for passing JsError::Thrown.
  • Checked the subprocess _ => catch-all arm now yields Ok(()) in release so settled binds; the then_wrap change copies global_this (a Copy BackRef) before the consuming call.
  • Verified the new bun-write pollution test survives the Windows BUN_FEATURE_FLAG_DISABLE_UV_FS_COPYFILE=1 self-rerun and cleans up via using dir.
Extended reasoning...

Overview

The PR replaces let _ = ...; // TODO discards on promise-settle results with explicit reporting at eight completion sites across subprocess.rs, ByteStream.rs, blob/copy_file.rs (POSIX + Windows), blob/write_file.rs (POSIX WriteFilePromise::run, Windows uv callbacks, and the locked-body then_wrap), s3/multipart.rs, and js_valkey.rs. Fire-and-forget completions call report_active_exception_as_unhandled; completions that return into the task-dispatch channel call report_error_or_terminate. Two new subprocess-fixture tests pin the write and spawn completions under a hostile Object.prototype.then accessor.

Security risks

None identified. The change surfaces already-pending exceptions instead of silently discarding them; no new user input is parsed and no trust boundary is crossed.

Level of scrutiny

Moderate-to-high. Each individual edit is small and follows an established idiom (#37004), but collectively they touch native completion paths in six subsystems, and the POSIX WriteFilePromise::run / CopyFile::then/reject edits change semantics: what previously ?-propagated as JsTerminated (because the FFI wrapper erases Thrown to the terminated sentinel) is now routed through report_error_or_terminate, which probes VM state to distinguish real termination from a leaked Thrown. I read both helpers in src/jsc/Task.rs and src/jsc/JSGlobalObject.rs and they match the PR's description — take_exception + is_termination_exception() decide, and termination is left pending / returned as Err respectively — but the correctness of that swap across every task-dispatch caller is a maintainer-level judgment.

Other factors

  • The description explicitly surfaces a design alternative (single guard at JSC__JSPromise__resolve) for maintainers to weigh against the per-site idiom; that's a decision for a human, not a bot.
  • The PR is candid that the new tests are hardening pins, not fail-before reproductions (the fixed branch is only reachable with an already-dirty VM). REVIEW.md normally requires a fail-before test; the justification is thorough but should be accepted by a maintainer, not by me.
  • My two prior inline findings on the bun-write test (temp-dir leak; Windows self-rerun byte-count mismatch) were both fixed in 15cf7ba and 32a2fb7, and the current diff reflects those fixes. Nothing else surfaced in this pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant