socket: don't leave exceptions pending when a connect promise settle or TLS session/keylog dispatch fails - #37067
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughChangesThe socket runtime now reports callback and promise failures as unhandled exceptions or propagates them through connection failures. TLS session and keylog dispatch use unit-returning handlers. Fault injection and tests cover these paths. Socket error handling
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
f5b4cf2 to
9f9f84e
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/runtime/socket/WindowsNamedPipeContext.rs:251-255—fail_and_releasestill discardshandle_connect_error's result with_ =, but its callers (WindowsNamedPipeContext::open()/connect()viaFailAndRelease::drop) run fromconnect_inner's named-pipe branch, which sets the connect promise before the call — sohandle_connect_errorcan returnErrwith a pending exception here, andconnect_innerthen doesErr(_) => return Ok(promise_value). The PR's stated exemption ("duplex TLS upgrade teardown … no connect promise") doesn't cover this site. Same fix ason_errora few lines up: read(*this).global_thisandreport_active_exception_as_unhandled(e)onErr.Extended reasoning...
What the bug is
WindowsNamedPipeContext::fail_and_releasestill uses the discard pattern this PR is eliminating:match_socket!(unsafe { (*this).socket }, |s: NewSocket<SSL>| _ = NewSocket::handle_connect_error(s, SystemErrno::ENOENT as i32, 0));
This is the same file, and the same pattern, as
on_error— which this PR did fix (lines 246–255) by capturingglobal_thisand callingreport_active_exception_as_unhandled(e)onErr.Code path that triggers it
Step by step:
Listener::connect_inner(Windows named-pipe branch, both TLS and TCP arms) does:let promise = jsc::JSPromise::create(global); let promise_value = promise.to_js(); handlers.set_promise(global, promise_value); // ← promise IS set // ... let named_pipe_result = ... WindowsNamedPipeContext::connect(...) / ::open(...); let named_pipe = match named_pipe_result { Ok(p) => p, Err(_) => return Ok(promise_value), // ← returns Ok from host call };
- Inside
WindowsNamedPipeContext::connect/open, aFailAndReleaseguard is armed viaSelf::armed(this). Ifnamed_pipe.open(...)?ornamed_pipe.connect(...)?fails (e.g.uv_pipe_initor the TLS wrapper init fails), the?returnsErrand the guard'sDroprunsfail_and_release(this). fail_and_releasecallsNewSocket::handle_connect_error(s, ENOENT, 0). The socket'shandlersis anRc::cloneof the sameHandlerson which step 1 calledset_promise, so the promise is present.- After this PR,
handle_connect_error's onlyErr-returning sites arejs_promise.reject(...)?(line 1187),handlers.reject_promise(err_val)?(line 1212 — the very site this PR changed), andpromise.reject_as_handled(...)?(line 1223). All three require a promise, which this path has. If any of those rejects fails (allocation failure),Erris returned with the exception still pending on the VM. fail_and_releasediscards thatErrwith_ =, thenconnect_innerseesErr(_)fromWindowsNamedPipeContext::connect/openand doesreturn Ok(promise_value)— returningOkfrom theBun.connect()host call with a non-termination exception pending.
That is exactly the
!scope.exception() || vm.hasPendingTerminationException()assertion trigger the PR description names.Why the PR's stated exemption doesn't apply
The description says:
The two
handle_connect_errorcalls in the duplex TLS upgrade teardown keep theirlet _ =: that flow has no connect promise, and everyErrpath inhandle_connect_errorrequires one, so no exception can escape there.That reasoning is sound for the two
DuplexUpgradeContextsites (socket_body.rs ~4342/4447), but it does not coverfail_and_release: its only callers set the connect promise before invoking it, so anErrcan escape. The site is not listed among the intentional exclusions.Impact
Same as the other sites this PR fixes: debug/ASAN assertion in
JSObjectInlines.hon the next nativeget, or in release the pending exception is misattributed to whatever runs next. Windows-only (the file is cfg-gated) and requires an allocation failure inside a promise reject, so like the rest of this PR it has no deterministic repro — but per REVIEW.md "Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep)… If a site is intentionally excluded, say so in the PR", leaving one sibling of the fixed pattern in the same file makes the fix incomplete.Fix
Mirror the
on_errorchange this PR already made ~60 lines above:fn fail_and_release(this: *mut Self) { // SAFETY: `this` is live; `global_this` is a Copy field read. let global = unsafe { (*this).global_this }; match_socket!(unsafe { (*this).socket }, |s: NewSocket<SSL>| { if let Err(e) = NewSocket::handle_connect_error(s, SystemErrno::ENOENT as i32, 0) { global.report_active_exception_as_unhandled(e); } }); unsafe { Self::deref(this) }; }
(
?isn't available here because it runs fromDrop, so the event-loop-dispatch idiom is the right one — same choice the PR made foron_errorandon_connect_error.)
|
Re the The named-pipe analysis is right that the connect promise is set before |
9f9f84e to
ea27963
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/socket/socket_body.rs (1)
1902-1912: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMove the session doc block onto
on_session.The doc comment at Lines 1902-1907 describes a TLS session arriving and the
sessionhandler dispatch. It now attaches tocreate_dispatch_buffer, becauseon_sessionmoved below the helper. A reader ofcreate_dispatch_buffergets a description of the session dispatch instead of the buffer allocation.Move those lines to
on_session(Line 1934) and keep only the buffer description on the helper.♻️ Proposed doc relocation
- /// A new resumable TLS session arrived (the peer's NewSessionTicket was - /// processed during an earlier `SSL_read`). Hands the serialized session - /// to the JS `session` handler, mirroring Node's `onnewsession` callback. - /// Dispatched from `ssl_flush_pending_session()` after the SSL stack has - /// unwound, so the JS handler may safely destroy the socket. - /// /// The JS `Buffer` for an `on_session`/`on_keylog` payload. The /// `session_buffer` fault rule simulates the allocation throwing: that /// failure needs JSC heap exhaustion, so it is unreachable from a test /// without injection. fn create_dispatch_buffer(global: &JSGlobalObject, len: usize) -> JsResult<JSValue> {Then prepend the moved block to
on_session:+ /// A new resumable TLS session arrived (the peer's NewSessionTicket was + /// processed during an earlier `SSL_read`). Hands the serialized session + /// to the JS `session` handler, mirroring Node's `onnewsession` callback. + /// Dispatched from `ssl_flush_pending_session()` after the SSL stack has + /// unwound, so the JS handler may safely destroy the socket. + /// /// Takes `ThisPtr<Self>` for the same re-entrancy reason as `on_writable`. pub(crate) fn on_session(this: bun_ptr::ThisPtr<Self>, session: &[u8]) {🤖 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/socket/socket_body.rs` around lines 1902 - 1912, Move the TLS resumable-session dispatch documentation from above create_dispatch_buffer to directly above on_session. Keep only the JS Buffer allocation and session_buffer fault-rule documentation attached to create_dispatch_buffer, preserving the existing wording and behavior.
🤖 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/socket/socket_body.rs`:
- Around line 1902-1912: Move the TLS resumable-session dispatch documentation
from above create_dispatch_buffer to directly above on_session. Keep only the JS
Buffer allocation and session_buffer fault-rule documentation attached to
create_dispatch_buffer, preserving the existing wording and behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: be299eba-12b1-4e2f-95d5-65eff924addd
📒 Files selected for processing (10)
packages/bun-usockets/src/internal/fault_inject.hsrc/js/internal-for-testing.tssrc/runtime/socket/Listener.rssrc/runtime/socket/WindowsNamedPipeContext.rssrc/runtime/socket/mod.rssrc/runtime/socket/socket_body.rssrc/runtime/socket/uws_dispatch.rssrc/uws_sys/lib.rstest/js/bun/net/socket-session-oom-fixture.tstest/js/bun/net/socket.test.ts
ea27963 to
118d50b
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/net/socket-session-oom-fixture.ts`:
- Around line 18-21: Update the uncaughtException handler around
gotUncaught/onUncaught so only the first exception resolves the promise; report
every subsequent uncaught exception to stderr or another visible error channel
instead of swallowing it, while preserving the fixture’s existing first-error
behavior.
🪄 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: 5922566e-df39-4b82-9b3d-589ca9f4a2b5
📒 Files selected for processing (10)
packages/bun-usockets/src/internal/fault_inject.hsrc/js/internal-for-testing.tssrc/runtime/socket/Listener.rssrc/runtime/socket/WindowsNamedPipeContext.rssrc/runtime/socket/mod.rssrc/runtime/socket/socket_body.rssrc/runtime/socket/uws_dispatch.rssrc/uws_sys/lib.rstest/js/bun/net/socket-session-oom-fixture.tstest/js/bun/net/socket.test.ts
118d50b to
352ade1
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
…settle or TLS dispatch buffer fails A JsResult Err from these paths means a JS exception is still pending on the VM. Several dispatch sites discarded that Err and returned to the uSockets/libuv event loop with the exception still set, so the next native JSC call in the same tick ran with it (in debug/ASAN builds that trips assertNoExceptionExceptTermination; in release the stale exception gets attributed to unrelated code). The exception has to be dealt with at the failure site, before the dispatch's scope guards drop: ScopeExit runs a microtask checkpoint on exit, and entering that checkpoint with a non-termination exception pending is itself the asserted condition. Each failed settle now reports its pending exception as unhandled right there (report_active_exception_as_unhandled, the same idiom the socket error handler, ipc, h2, fs-watcher and dns completions use). Termination exceptions stay pending, as the event loop expects. - handle_connect_error: report at the three promise-settle sites (the unwrap_or(true) swallow, the no-callback reject, reject_as_handled). - on_open: report a failed connect-promise resolve (previously swallowed with the exception left pending) and a failed reject of the open-callback error. - on_session / on_keylog: report a Buffer-allocation failure instead of returning Err into dispatch sites that discarded it. - All of the above, plus on_handshake and on_close (whose Err paths were structurally dead), become infallible so no call site can discard an Err from them again; every 'let _ =' at their dispatch call sites is gone. The session/keylog Buffer allocation only fails on JSC heap OOM, so a new session_buffer fault-injection rule (mirroring ssl_loop_buffer) simulates that throw deterministically; the fixture asserts the failure surfaces as an unhandled error and the event loop survives. Without the fix the same fixture dies on the ExceptionScope assert.
352ade1 to
41859c3
Compare
|
CI status: every failure in the latest runs (89690, 89695) is outside this diff. The one red test, test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts (SIGABRT on the x64-asan lane), is a pre-existing failure on main and has been reported separately; the rest are Windows-lane flakes that passed on retry (http3 handshake, bake deinitialization, terminal gaps, install registry). The socket, net, and tls suites, including the new fault-injection test on the asan lane, are green everywhere. Ready for review. |
There was a problem hiding this comment.
All prior review findings (mine and CodeRabbit's) are addressed at 41859c3; nothing new this pass. Deferring to a human given the scope — this reworks exception discipline across the socket dispatch layer (connect-error, open, handshake, close, session/keylog) plus the Windows named-pipe and duplex-upgrade call sites.
What was reviewed:
- Every
let _ =/.unwrap_or(true)swallow named in the description is gone; each now-infallible dispatch (handle_connect_error,on_connect_error,on_open,on_handshake,on_close,on_session,on_keylog) reports beforeexit_scope's microtask checkpoint. report_active_exception_as_unhandledordering vsexit_scope/has_exception()guards checked at each site; matches the ipc/h2/dns precedent.- New
session_bufferfault hook: enum value 12 consistent across fault_inject.h / uws_sys / testing_apis;us_fault_hitextern signature matches the C header (ssize_t*↔*mut isize); fd-rejection and error-string updated together. - Tests: fault-injection fixture asserts exactly one uncaught + a post-failure round trip; behavior tests cover session/keylog delivery, handler throws, sync unix ENOENT, and connectError-throw-as-rejection.
Extended reasoning...
Overview
The PR eliminates a class of stale-pending-exception bugs in src/runtime/socket/: dispatch sites that called a fallible promise-settle or Buffer-allocation, discarded the Err, and returned to the uSockets/libuv loop with a JS exception still set on the VM. Six dispatch functions (handle_connect_error, on_connect_error, on_open, on_handshake, on_close, on_session, on_keylog) are made infallible, with each internal failure reported via report_active_exception_as_unhandled before the scope-exit microtask checkpoint. Every call site across mod.rs, uws_dispatch.rs, WindowsNamedPipeContext.rs, Listener.rs, and the DuplexUpgradeContext in socket_body.rs drops its let _ =. A new session_buffer fault-injection rule makes the OOM-only Buffer-allocation failure deterministically testable, wired through fault_inject.h, uws_sys/lib.rs, internal-for-testing.ts, and the testing_apis parser. ~265 lines of new tests in socket.test.ts plus a spawned fixture cover every touched dispatch path.
Security risks
None identified. No auth/crypto/permission logic changes; the TLS surface touched is dispatch plumbing (session ticket / keylog delivery), not verification or trust decisions. The new fault-injection hook is compile-gated behind cfg(socket_fault_injection) and reachable only through bun:internal-for-testing.
Level of scrutiny
High. This is production-critical socket/TLS dispatch code that runs for every Bun.connect/Bun.listen connection, with cross-platform branches (Windows named pipes, duplex TLS upgrade) and release-build behavioral impact (exception misattribution, not just a debug assert). The change is mechanically consistent — the same report_active_exception_as_unhandled idiom applied at each site, matching existing precedent in ipc/h2/fs-watcher/dns — but the ordering vs exit_scope and the interaction with termination exceptions is subtle enough that it benefits from a maintainer's eye.
Other factors
I reviewed this across three prior iterations and raised four findings (the missed on_open sites, the still-fallible on_handshake/on_close, a misplaced doc comment, and the ungated us_fault_hit call); all were addressed, as was CodeRabbit's fixture-hardening nit. The bug-hunting system found nothing this run. Test coverage is strong: a fault-injection fixture that reproduces the exact assertion class the fuzzer hit, plus behavior tests for every touched dispatch path (including previously-untested TLS session/keylog delivery). The PR description states Windows named-pipe tests and cargo check on all 10 targets pass; I can't verify the Windows paths directly. Given the scope and the number of call sites across three transports, a human sign-off is warranted.
) `cargo clippy` and `cargo fmt --check` are red on main, so every PR touching Rust inherits failing Clippy and Format checks. Both workflows only run on pull_request, which is how the drift landed unnoticed. ## Clippy (2 errors) - `src/parsers/yaml.rs`: `bind_anchor` takes `PendingAnchor` by value on purpose, so that binding consumes the `#[must_use]` anchor token (landed via #37055, trips `-D clippy::needless-pass-by-value`). Added a targeted `#[allow]` with a one-line reason, matching existing usage elsewhere in the tree. - `src/runtime/socket/uws_handlers.rs`: #37067 changed `NewSocket::on_close` / `on_handshake` to return `()`, leaving two `swallow(...)` wrappers passing a unit value (`-D clippy::unit-arg`). Call them directly, matching the neighboring handlers in the same impl. `cargo clippy --workspace --no-deps` now exits 0. ## rustfmt (4 files) `src/bun_core/tty.rs`, `src/md/ansi_renderer.rs`, `src/runtime/bake/bake_body.rs`, `src/runtime/server/server_body.rs` had unformatted hunks. Ran `cargo fmt --all`; `cargo fmt --all --check` now exits 0. ## tsconfig - Root `tsconfig.json` still referenced `./src/bake`, which moved to `./src/runtime/bake` in the Rust rewrite, so `tsc --noEmit` failed immediately with TS6053. Updated the project reference. - `test/tsconfig.json` now excludes the three `test/regression/issue/14477/*-mismatch.tsx` fixtures: they contain deliberately mismatched JSX closing tags (the test asserts the parse error), which are unsuppressable TS17002 syntax errors. Note: `cd test && tsc --noEmit` still reports several thousand pre-existing semantic errors across the test suite; that is long-standing drift (not CI-enforced) and out of scope here. Also verified green on this branch: oxlint, clang-format check, prettier. ## Miri `cargo miri test` is also red on every PR: #37052 routed all byte search through the highway C++ kernels, and Miri cannot call foreign functions. The first caller to hit it is `bun_ptr::ref_count::type_base_name` (`strings::last_index_of` -> `highway_memrmem`). Under `cfg(miri)` the search wrappers in `src/highway/lib.rs` now take their scalar paths: the char and char-set scans reuse their existing short-input scalar prologue at every length, and the `mem*mem` wrappers get a scalar substring search. Kernels with no scalar form (hashing, hex, sourcemaps, lexer scans) stay FFI-only so a Miri-tested crate reaching one still fails loudly. Verified locally: `bun run rust:miri` green on bun_ptr (previously failing), bun_ast, bun_base64, bun_clap, bun_collections, bun_dispatch, bun_errno, and bun_hash; this PR's Miri workflow runs the full set. ## Verification The changes have no runtime-observable behavior: the proof is this PR's own Clippy and Format CI checks, which run `cargo clippy --workspace` and `cargo fmt --all --check` (both red on main, both green here), plus `tsc --noEmit` resolving again at the repo root. An earlier revision added a source-lint test walking the tsconfig reference graph; it was removed per maintainer feedback.
What
Fuzzing (Fuzzilli) keeps hitting this debug assertion in processes exercising sockets:
It fires when any native
JSObject::getruns while a non-termination JS exception is pending. Several socket dispatch sites could leave the VM in exactly that state: they call a fallible operation whoseErrmeans "a JS exception is pending", discard theErr, and return to the uSockets/libuv event loop. The next native JSC call in the same tick then runs with the stale exception. In debug/ASAN builds that trips the assert (JSObject::get, orassertNoExceptionExceptTerminationat the microtask-drain entry); in release the exception gets attributed to whatever runs next.The sites, all in
src/runtime/socket/:handle_connect_errorswallowed a failed promise reject withhandlers.reject_promise(err_val).unwrap_or(true)and returnedOkwith the exception still set (a// TODO: properly propagate exception upwardssite), and its other two settle sites (reject,reject_as_handled) returnedErrinto callers that all discarded it:connect()'s synchronousdo_connect()failure branch, theon_connect_errorevent-loop dispatch, the Windows named-pipeon_error/fail_and_release, and the duplex TLS upgrade teardown.on_openswallowed a failed resolve of the connect promise (the other// TODO: properly propagate exception upwardssite) and returned to the loop with the exception pending, and did the sameunwrap_or(true)swallow when rejecting with an error returned from theopencallback.us_dispatch_session/us_dispatch_keylog(and the duplex and named-pipe equivalents) discardedon_session/on_keylogresults; those returnErrwhen allocating the session/keylogBufferthrows.Fix
The pending exception has to be handled at the failure site, before the dispatch's scope guards drop:
ScopeExitruns a microtask checkpoint on scope exit, and entering that checkpoint with a non-termination exception pending is itself the asserted condition, so anErrpropagated or discarded past the guard is already too late. And the event-loop dispatch callers have no JS frame to propagate into in the first place.So each failed settle now reports its exception as unhandled right where it fails, via
report_active_exception_as_unhandled: the same idiom the socket error handler, ipc, h2, fs-watcher, and dns (#37004) completions use. That clears and reports a real throw, and leaves a termination exception pending, which is what the event loop expects (assertNoExceptionExceptTermination). With every failure handled internally,handle_connect_error,on_session, andon_keylogbecome infallible, andon_handshake/on_close(whoseErrpaths were structurally dead:verify_error_to_jscannot fail) are made infallible with them, so no dispatch call site can discard anErrfrom this family again; everylet _ =at those call sites is gone.Verification
The promise-settle failures (
JSPromise::resolve/reject) only occur on JSC heap OOM or termination mid-call, so those arms have no deterministic reproduction; they are covered by the behavior-level tests below plus the analysis above. The session/keylogBufferallocation has the same trigger, but for that one this PR adds asession_bufferfault-injection rule (mirroring the existingssl_loop_bufferrule for the one other allocation whose failure is unreachable without injection), which makes the fix deterministically testable:socket-session-oom-fixture.tsarms the rule, completes a TLS handshake with asessionhandler, and asserts the injected allocation failure surfaces as anuncaughtExceptionwhile the event loop survives (a TCP round trip completes afterwards and the process exits 0). With the hook but without the fix, the same fixture dies onASSERTION FAILED: ... !exception() || m_vm.hasPendingTerminationException()inExceptionScope::assertNoExceptionExceptTermination, the fuzzer's failure class.session/keylogdelivery (previously untested), throws fromsession/keylog/openhandlers reaching the socket's (or listener's)errorhandler whileconnect()still resolves, a synchronous unix connect failure rejecting withENOENT(run in a child with a relative socket path so macOS's 104-bytesun_pathlimit cannot clobber the errno), and a throw fromconnectErrorbecoming theconnect()rejection.socket-syscall-fault,tls-syscall-fault) pass with the new rule added.test/js/bun/net/,test/js/node/net/, and the node-tls connect suites show the identical failure set as a baseline build in this sandboxed environment (local DNS/IPv6 limitations), with no new failures.cargo checkpasses on all 10 platform targets.Related but distinct open work: #36808 gates these dispatch sites against an already-pending termination (entry-side), while this PR fixes exceptions the dispatch itself creates (exit-side); #35221 changes which errno the DNS-path
connectErrorreports, not the exception discipline.no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts