socket: hold Bun.listen/Bun.connect callbacks in a GC-visited internal-fields cell - #31859
Conversation
Walkthrough
ChangesSocket Handler Callback Validation
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 7:27 AM PT - Jul 8th, 2026
❌ @robobun, your commit bdf9938 has 5 failures in
🧪 To try this PR locally: bunx bun-pr 31859That installs a local version of the PR into your bun-31859 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — clean reordering so validation errors return before a Handlers (and its unconditional Drop → unprotect) ever exists.
Extended reasoning...
Overview
This PR fixes a fuzzer-found debug-build panic (assertion failed: self.protection_count > 0) in Handlers::from_generated (src/runtime/socket/Handlers.rs). Previously the Handlers struct was constructed first, then callbacks were validated; an early return Err(...) on a non-callable handler or missing data/drain dropped a Handlers that had never been protect()ed, and Drop unconditionally calls unprotect(). The fix moves all fallible validation into local variables via a validated_callback! macro before constructing the struct, so every constructed Handlers is guaranteed to reach protect() (the only remaining steps between construction and protect() — with_async_context_if_needed and protect itself — are infallible). Validation order, error messages, and success-path behavior are unchanged. A subprocess-based regression test in test/js/bun/net/socket.test.ts exercises both error paths through both Bun.connect and Bun.listen.
Security risks
None introduced. The change actually closes a latent release-build hazard where unbalanced gcUnprotect calls could steal protection from a shared callback on another live socket. No new inputs, no auth/crypto/permissions surface.
Level of scrutiny
Moderate — this is GC-protection / memory-safety code in the socket runtime, but the change is a pure reordering of existing logic within a single ~40-line function. The macro is a straightforward refactor of the old assign_callback! that yields a value instead of mutating a field. I traced that JSValue::ZERO ↔ is_empty() semantics match the old code, that field iteration order is identical, and that nothing between struct construction and protect() can early-return.
Other factors
The PR description is precise about both the debug assert and the release-build unbalanced-unprotect consequence, and correctly notes this is the sole construction site for Handlers. The new test asserts exact error strings and exit code 0, so a regression to the panic would fail it on debug builds. No CODEOWNERS apply to this path, and the bug-hunting system found no issues.
f54c42e to
8b498d7
Compare
There was a problem hiding this comment.
LGTM — straightforward reordering so validation happens before Handlers is constructed, eliminating the unprotected-Drop path.
Extended reasoning...
Overview
This PR reorders Handlers::from_generated in src/runtime/socket/Handlers.rs so that all callback validation (the per-field is_callable() checks and the "at least data or drain" check) runs against local variables before the Handlers struct is constructed. Previously the struct was built first and validated afterward; an error return then dropped a Handlers whose callbacks were never protect()ed, but whose Drop unconditionally calls unprotect() — tripping a debug assert and issuing unbalanced gcUnprotect calls in release. A subprocess regression test in test/js/bun/net/socket.test.ts exercises both error paths through both Bun.connect and Bun.listen.
Security risks
None introduced. This is a crash/UB fix on an input-validation error path; no new surface area, no auth/crypto/permissions involved. If anything it closes a theoretical release-build hazard (stolen GC protection on a shared callback).
Level of scrutiny
Moderate — it touches GC protect/unprotect bookkeeping in the socket runtime, which is lifetime-sensitive. But the change itself is purely a code-motion: the validation logic, field order, and error messages are byte-for-byte preserved; only the point at which the struct is constructed moves to after the last fallible check. After construction, with_async_context_if_needed (infallible FFI) and protect() run immediately, so every constructed Handlers is protected before it can drop. The reasoning is clearly documented in an inline comment.
Other factors
- The new test is well-scoped, runs in a subprocess so a panic surfaces as a test failure, and asserts exact error messages and exit code.
- The single CI failure (
test/cli/install/bunx.test.ts) is unrelated to socket code. - A bot flagged #31817 as a possible duplicate; that's a merge-coordination question for maintainers, not a correctness concern with this change.
- The bug-hunting system found no issues.
…dler path (#31861) ### Problem Fuzzilli hit a nested panic while Bun was already processing a crash in a debug build: ``` panic: assertion failed: self.protection_count > 0 ... panicked at src/base64/lib.rs:318:15: attempt to negate with overflow thread panicked while processing panic. aborting. ``` The crash handler encodes backtrace addresses into the bun.report trace string by splitting each u64 into two u32 halves and bitcasting them to `i32` (`write_u64_as_two_vlqs` in `src/crash_handler/lib.rs`). `vlq::encode_slow_path` computes the magnitude of negative values with `-value`, which overflows for `i32::MIN`. So an address half of exactly `0x80000000` panics the panic hook: debug builds abort before the crash report or backtrace is written, and release builds silently wrap. The Zig reference (`src/sourcemap/VLQ.zig`) has the same checked negation, so this was inherited by the port; sourcemap callers never pass `i32::MIN`, only the crash handler's bitcast address halves do. ### Fix Compute the magnitude with `value.unsigned_abs()` in `encode_slow_path` (`src/base64/lib.rs`). The sign-magnitude VLQ format cannot represent `i32::MIN` in 32 bits regardless (its magnitude is 2^31), so that one value keeps degrading to "-0" exactly as release builds already emit, and the wire format bun.report decodes is unchanged. Everything in the representable domain `-(2^31 - 1)..=2^31 - 1` encodes identically to before. The encoder just can't panic anymore, which matters because it runs inside the panic hook. ### Test JS-level regression test in `test/js/bun/sourcemap/internal-sourcemap-roundtrip.test.ts`: `InternalSourceMap` sync-entry state is raw i32 and `appendVLQTo` computes deltas with `saturating_sub`, so a hand-crafted blob whose first window starts at generated column `i32::MIN` drives `VLQ::encode(i32::MIN)` through the existing `bun:internal-for-testing` `toVLQ` surface. On the unfixed encoder the spawned process aborts with "attempt to negate with overflow" (debug builds); with the fix it emits the wrapped "-0" encoding (`BAAA`) and exits 0. Also unit tests in the `vlq` module of `bun_base64`: a roundtrip over the representable domain including both extremes (pinning the documented `"+/////D"` / `"//////D"` encodings), and `encode(i32::MIN)` decoding to 0 without panicking. The latter fails on the unfixed code with the same overflow panic (`cargo test -p bun_base64`). `bun_base64` is in `MIRI_CRATES`, so CI runs these via `cargo miri test` on changes under `src/base64/`; verified locally that miri passes. The crash handler trigger itself (a backtrace address half equal to `0x80000000`) is not controllable from a test; the sourcemap path exercises the identical encoder edge. The primary crash in the fuzzer report (the `protection_count` assert) is fixed separately in #31859.
|
CI triage for the remaining failures:
The new regression test passed on all platforms in both builds. |
8b498d7 to
4fb1950
Compare
There was a problem hiding this comment.
LGTM — clean reordering so validation completes before the Handlers (and its Drop) ever exists; behavior and error messages on the success/error paths are unchanged.
Extended reasoning...
Overview
This PR fixes a fuzzer-found crash in Handlers::from_generated (src/runtime/socket/Handlers.rs). Previously the Handlers struct was constructed with zeroed callback fields, then each callback was validated and assigned in turn; an early return Err(...) on a non-callable handler or the missing-data/drain check dropped a Handlers whose protect() had never run, and Drop unconditionally calls unprotect(). The fix hoists all callback validation (and the data/drain presence check) into locals via a validated_callback! macro, then constructs the struct only once everything has passed — so every constructed Handlers reaches protect() before it can be dropped. A subprocess regression test in test/js/bun/net/socket.test.ts exercises both error paths through both Bun.connect and Bun.listen.
Security risks
None introduced. The change is a pure reordering of existing validation; no new inputs, no new FFI, no changed error semantics. If anything it closes a (narrow) GC-unprotect imbalance that could in theory let a shared callback be collected while another socket still uses it.
Level of scrutiny
Moderate-low. While this lives in GC-protection / Drop territory, the diff is mechanical: the macro body is byte-for-byte the same check as before but yields a value instead of assigning a field, validation order and error strings are unchanged, and the only code between construction and protect() (with_async_context_if_needed) is infallible. The single construction site means listen, connect, and reload are all covered.
Other factors
- Regression test added and reported green on all platforms; remaining CI failures were triaged as pre-existing on main.
- No CODEOWNERS entry for this path.
- A duplicate-PR bot flagged #31817 with the same fix; that's a merge-coordination question for maintainers, not a correctness concern with this diff.
|
CI triage for build #63130 on the rebased commit (4fb1950): The only failing test is All other test jobs passed, including the new regression test on every platform. The earlier |
4fb1950 to
72b05ee
Compare
|
Extended this PR to also cover Sentry BUN-3PK7 (SIGSEGV in On top of the existing validate-before-construct reordering:
Added a second regression test that reproduces the stolen-protection collection on a release build (listener's callbacks are collected and the next accept fails) and the debug assertion on an unfixed debug build. Rebased onto current main. |
|
Gated |
There was a problem hiding this comment.
Thanks for gating Malloc=1 behind isWindows — no further issues from my side, but since this changes release-mode GC protect/unprotect semantics in the socket runtime (and overlaps with #31817), it's worth a human sign-off.
Extended reasoning...
Overview
This PR fixes an unbalanced gcUnprotect in src/runtime/socket/Handlers.rs: previously Handlers::from_generated constructed the struct first and then validated/assigned each callback field, so a validation error dropped a partially-assigned Handlers whose Drop impl unconditionally called unprotect() — issuing gcUnprotect on callbacks that were never gcProtected. The fix (1) validates all 13 callbacks into locals before constructing the struct, (2) replaces the debug-only protection_count: u32 with a release-mode protected: bool that gates unprotect(), and (3) routes protect()/unprotect() through the existing for_each_callback_field! macro and zeroes each field after unprotecting for idempotence. Two subprocess regression tests are added to test/js/bun/net/socket.test.ts.
Since my last review, commit 827ae05 applied the suggested isWindows guard around Malloc: "1" in the stolen-protection test, matching the pattern used elsewhere in the test tree. The bug-hunting system found no further issues on the current revision.
Security risks
None identified. This is a memory-safety / GC-lifecycle correctness fix; it does not touch auth, crypto, permissions, or any user-input parsing surface beyond what already existed. The change strictly narrows the conditions under which gcUnprotect is called.
Level of scrutiny
High. This is core runtime Rust code managing JSC GC root lifecycle for every Bun.listen/Bun.connect/reload()/upgradeTLS call, and it changes release-build behavior (the old protection_count was #[cfg(debug_assertions)]-only; the new protected flag is always present and actively gates unprotect() in production). The reasoning in the PR description is thorough and the fix looks correct to me — validation order and error messages are preserved, the struct is only built once all inputs are known-good, and the protected flag makes Drop safe regardless of construction path. But GC protect/unprotect balance bugs are subtle (this PR itself is fixing a production SIGSEGV from one), so a maintainer familiar with the Handlers/NewSocket ownership model should confirm the new semantics, particularly that no caller relied on unprotect() running with protected == false.
Other factors
- The duplicate-PR bot flagged #31817 as fixing the same bug with the same approach; a human should decide which PR to land.
- CI build #64642 on the latest commit (827ae05) was still building when I reviewed; prior builds passed except for unrelated network flakes per the robobun triage comments.
- The two new regression tests are well-constructed (subprocess isolation, exact-output assertions, fail-before verified) and the
Malloc=1Windows portability issue I raised previously has been addressed.
|
CI triage for build #64642 on 827ae05: The only
The same
|
|
Addendum now that #64642 has finished. Additional
|
827ae05 to
d4ca31a
Compare
|
Rebased onto current main (d4ca31a). The conflict was in |
There was a problem hiding this comment.
The Windows Malloc=1 gate looks good and I didn't find any further issues, but this reworks the GC protect/unprotect lifecycle for socket handlers (memory-safety-critical, fixes a production SIGSEGV) and there's a flagged sibling PR #31817 covering the same bug — worth a human deciding which lands.
Extended reasoning...
Overview
This PR fixes an unbalanced gcUnprotect in src/runtime/socket/Handlers.rs: previously Handlers::from_generated constructed the struct, assigned callbacks one-by-one, and returned an error mid-assignment on a bad callback — dropping a Handlers whose Drop impl unconditionally called unprotect() on callbacks that were never protect()ed. In debug builds this tripped an assertion; in release builds it stole GC protection from other live sockets sharing the same callback identities (node:net's module-level handler table), leading to a SIGSEGV on a freed JSCell (Sentry BUN-3PK7).
The fix has two parts: (1) validate all 13 callbacks into locals before constructing the struct, so an error return never drops a Handlers; (2) replace the debug-only protection_count: u32 with a release-mode protected: bool so unprotect() is a no-op when protect() never ran, and zero each field after unprotecting so a stale cell can never reach Bun__JSValue__unprotect twice. protect()/unprotect() now route through the existing for_each_callback_field! macro. Two subprocess regression tests are added to test/js/bun/net/socket.test.ts (one for each symptom), with documented fail-before behavior.
Security risks
None in the adversarial sense — this is a crash/UAF fix, not an auth/crypto/permissions change. The change strictly reduces memory-safety risk by closing an unbalanced-unprotect path and adding a release-mode guard.
Level of scrutiny
High. This is GC-protection lifecycle code on a hot path (every Bun.listen/Bun.connect/reload/upgradeTLS). Getting protect/unprotect balance wrong in either direction causes either UAF (under-protect) or leaks (over-protect). The change looks correct to me — validation order and error messages are byte-identical, the protected flag is set/cleared in the only two places it should be, and for_each_callback_field! already covers the same 13 fields the hand-rolled lists did — but this is exactly the kind of change a maintainer who owns the socket runtime should sign off on rather than a bot.
Other factors
- My earlier inline comment (unguarded
Malloc: "1"would abort the subprocess on Windows) was addressed in 827ae05 and verified green on Windows shards in build #64642; the comment is resolved. - The bug-hunting system found no issues on the current revision.
- CI on prior builds passed for
socket.test.tson every platform; remaining red is pre-existing (napi.test.tsWindows CRLF, React experimental tag resolution,verify-baseline) and reproduces on unrelated branches. The latest rebase (d4ca31a) is still building (#64968) but only resolved a test-file append conflict with no code changes. - github-actions flagged #31817 as a likely duplicate fixing the same bug with the same approach in the same files — a human should reconcile which one merges.
- No CODEOWNERS cover the modified files.
There was a problem hiding this comment.
The Windows Malloc=1 guard is in and I didn't find anything else — deferring to a human only because this reworks GC protect/unprotect lifecycle in the socket runtime (debug counter → release-mode flag, field zeroing on unprotect), which is the kind of memory-safety change that deserves a maintainer's eyes even when it reads correctly.
Extended reasoning...
Overview
This PR fixes Sentry BUN-3PK7 by reordering Handlers::from_generated in src/runtime/socket/Handlers.rs so all 13 callback fields are validated into locals before the struct is constructed, and by replacing the debug-only protection_count: u32 with a release-mode protected: bool that gates unprotect(). protect()/unprotect() now route through the existing for_each_callback_field! macro, and unprotect() zeroes each field via core::mem::replace after unprotecting. Two subprocess regression tests are added to test/js/bun/net/socket.test.ts. My one prior finding (unguarded Malloc: "1" on Windows) was addressed in 827ae05 and the inline comment is resolved; the latest push is a clean rebase over the setTypeOfService test with no change to Handlers.rs or the new test bodies.
Security risks
None identified. This is a GC-protection balance fix; it doesn't touch auth, crypto, permissions, or untrusted-input parsing. The validation logic, error messages, and field ordering are byte-identical to before — only the timing of struct construction relative to validation changed.
Level of scrutiny
High. This is memory-safety code on a hot production path: Handlers backs every Bun.listen/Bun.connect/reload/upgradeTLS socket, and the change alters how JSC gcProtect/gcUnprotect calls are balanced. The semantic shift from a debug-only counter (assert on underflow) to a release-mode bool (silent no-op on underflow, debug_assert on double-protect) is strictly safer than the old release behavior, and zeroing fields after unprotect makes a repeat call harmless — but lifecycle changes to GC roots are exactly where a subtle mistake produces rare, hard-to-reproduce crashes, so a maintainer should confirm the new invariants match how Handlers is used across socket_body.rs / reload / upgradeTLS.
Other factors
The reasoning in the PR description is thorough and the two regression tests are well-constructed (verified fail-before on both debug-assert and release stolen-protection paths). CI on the post-fix commit shows socket.test.ts passing on every platform including Windows; remaining CI failures (napi.test.ts CRLF, React experimental registry, streams-leak, verify-baseline) are documented as present on unrelated branches at the same base. The bug-hunting system found nothing on this revision. I'm not approving solely because the guidelines reserve auto-approval for changes simple enough that a human needn't look — GC-lifecycle edits in core runtime don't meet that bar regardless of correctness.
|
CI triage for build #64978 on 1257933 (still running at time of writing): Three
For the audit trail on the GC lifecycle review: on this branch The previous build (#64968) failed only on a buildkite-agent artifact download timeout (no tests ran on that shard), and the one allowed retrigger was spent on it, so I'm not pushing another. The napi.test.ts Windows failure from earlier builds is gone: the fix (89f7b19) is on main and included in the rebase. |
|
Build #64978 has finished (failed). Final triage, superseding my in-progress comment above, which also had one attribution error I want to correct: Four
|
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Can we refactor this to make handlers a C++ JSInternalFieldsObjectImpl subclass with a JSValue visited for each callback and then have some small bindings to create/get/set fields on it? That way we don't need to protect/unprotect anything and it's kept alive by the socket holding it keeping it alive? And then for hot reloading, we can still update teh callbacks in-place
|
Fixed in 87cae09: dropped the |
|
Build #70439 (87cae09, comment-only push):
Not re-rolling. |
|
Fixed in bdf9938: dropped the three stale |
|
Build #70450 (bdf9938, comment-only push): only hard error is |
Rebased onto fc865b3 across #31859's Handlers rework: the OCSP callbacks are now two more fields on JSSocketHandlers (JSInternalFieldObjectImpl<16>), and the socket dispatches use the ThisPtr + Rc<Handlers> shape the sibling dispatches moved to. Server side: BoringSSL's cert_cb (the one server hook that runs after the ClientHello extensions are parsed, after select_certificate_cb's SNI context swap, and can pause) dispatches 'OCSPRequest' with the connection's certificate and issuer as DER; an asynchronous listener suspends the handshake (SSL_ERROR_WANT_X509_LOOKUP, parked like the existing async-SNICallback suspension) until handle.resumeOCSP() re-drives it. callback(err) surfaces as 'tlsClientError' and drops the connection. Client side: requestOCSP enables stapling before the ClientHello goes out, and BoringSSL's legacy OCSP callback delivers the response from inside the handshake via us_dispatch_ocsp_response, so destroying the socket in the listener still aborts the session. Destroying a client socket from inside a handshake callback no longer emits 'secureConnect'; us_internal_ssl_close settles the handshake on its way out. TLS over a generic Duplex / a Windows named pipe (the SSLWrapper engine) warns once instead of silently accepting requestOCSP. scripts/build/codegen.ts declares bindgenv2's generated headers as ninja outputs, so adding a field to a .bindv2.ts dictionary no longer leaves an object file compiled against the old struct layout.
…nstead of as Strong handles UpgradedDuplex held five StrongOptional handles per TLS-over-Duplex connection: the origin stream plus the four native listener thunks it creates for get_js_handlers. Each Strong is an independent HandleSet entry whose lifetime is tied to the native struct rather than to the JS wrapper's reachability. Move all five onto the JSTLSSocket wrapper as visited values: slots (duplexOrigin, duplexOnData, duplexOnEnd, duplexOnWritable, duplexOnClose). UpgradedDuplex keeps plain JSValue shadows for its own reads and stack-roots the wrapper across on_close so the slots stay valid through the close handler's downgrade + re-entry into JS. Teardown still neuters the thunks' function data via the shadows. Same pattern as #31859 (socket handlers) and #34346 (server callbacks).
…nstead of as Strong handles (#34672) ### What `UpgradedDuplex` (the TLS engine that drives `tls.connect({ socket: <Duplex> })` and the equivalent server wrap) held five `StrongOptional` handles per connection: the origin stream plus the four native listener thunks created in `get_js_handlers`. Each `Strong` is an independent `HandleSet` entry whose lifetime is tied to the native struct rather than to the JS wrapper's reachability. Move all five onto the `JSTLSSocket` wrapper as visited `values:` slots (`duplexOrigin`, `duplexOnData`, `duplexOnEnd`, `duplexOnWritable`, `duplexOnClose`). `UpgradedDuplex` keeps plain `JSValue` shadows for its own reads and stack-roots the wrapper across `on_close` so the slots stay valid through the close handler's downgrade + re-entry into JS. `teardown` still neuters the thunks' function data via the shadows; the slots themselves are dropped with the wrapper. Same pattern as #31859 (socket handlers into a visited cell) and #34346 (server callbacks traced from the JS wrapper). ### Why `heapStats().protectedObjectTypeCounts` before/after, 20 live upgrades: ``` before after Function (protected) +80 0 Object (protected) +20 0 TLSSocket (protected) +20 +20 // the wrapper's own self-reference, unchanged ``` There is no correctness bug to point at here: `teardown` releases all five Strongs and the existing tests exercise that path. The change is about the rooting model. A `Strong` is a VM-global root, so these five per connection are invisible to the GC's reachability graph and live exactly as long as the native struct does, no longer and no shorter. Holding them on the wrapper makes them ordinary traced edges: alive while the wrapper is, collected when it isn't, and reported in heap snapshots as edges from the `TLSSocket` that owns them. ### Verification - New test in `test/js/bun/net/socket-retention.test.ts` asserts the `Function`/`Object` protected-count delta across 20 live upgrades stays below `N` (was `4N` and `N` respectively). - Existing duplex coverage passes: `node-tls-connect.test.ts` (TLS over `Duplex`, `session`/`keylog`, destroy-in-data), `node-tls-duplex-close-throw-uaf.test.ts` (close/error ordering), `renegotiation.test.ts` (duplex path), `socket-retention.test.ts` (wrapper lifetime). - `rust:check-all` clean across all targets. <!-- robobun:evidence:begin --> --- **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/net/socket-retention.test.ts <!-- robobun:evidence:end -->
…l-fields cell (#31859) Reworks the fix as requested in oven-sh/bun#31859 (review): instead of patching the `gcProtect`/`gcUnprotect` bookkeeping in `Handlers`, remove it. ### What changed - New `Bun::JSSocketHandlers` (`src/jsc/bindings/JSSocketHandlers.{h,cpp}`): a `JSC::JSInternalFieldObjectImpl<13>` holding the socket callbacks as GC-visited internal fields, modeled on `JSNextTickQueue`, with C ABI `create`/`getField`/`setField` for the Rust side. - `Handlers` (Rust) no longer stores 13 raw `JSValue`s. It stores the cell plus one RAII `Strong` root held for the native struct's lifetime. `protect()`, `unprotect()`, the `protection_count`/`protected` bookkeeping, and the per-field macro are gone; callback reads go through named accessors that read the cell. - The listener's JS object and each socket's JS wrapper hold the cell in a codegen'd visited `values` slot (`sockets.classes.ts`), so the callbacks are reachable from every object that can still invoke them. - `listener.reload()` validates the new options and then writes the fields of the existing cell in place. Live sockets pick up the new callbacks with no second `Handlers`, no whole-struct overwrite of the shared allocation, and no `active_connections` preservation dance. - The client TLS handshake path used to clear `onOpen` with a raw-pointer write plus a single-field `unprotect` on the shared, freely aliased struct; it is now one in-place field clear. ### Why `Handlers::from_generated` constructed the struct and then validated it, so a validation error dropped a never-protected `Handlers` whose `Drop` unconditionally called `unprotect()`. In debug builds that is the fuzzer's `protection_count > 0` panic. In release it issued unbalanced `gcUnprotect` calls that could strip another live socket's protection of the same callback functions (node:net passes one module-level handler table to every connection), after which GC collects them and the next use crashes (Sentry BUN-3PK7). With the callbacks in a visited cell there is no count to unbalance: an error return before the cell exists drops nothing that needs releasing, and once it exists its lifetime is GC reachability from the listener and socket wrappers. The one manual root left is the single `Strong` the native `Handlers` holds on its own cell, created at construction and released by `Drop`, the same lifetime the old `protect()` had. It covers the windows where no JS wrapper exists yet (an outgoing `Bun.connect` before `open` fires, the upgraded-duplex and named-pipe paths), and being RAII it cannot be unbalanced. ### Tests Both regression tests from the earlier revision carry over unchanged and pass: - `socket handler validation errors throw instead of crashing` (error messages and validation order are byte-identical to before) - `socket handler validation errors don't steal GC protection from live sockets sharing the same callbacks` (the BUN-3PK7 scenario; fails on a bun without this change) Also run locally on this change: the existing `reload()` tests (`preserves active_connections`, `backs out cleanly when a handler getter closes the socket mid-reload`), `socket-retention.test.ts` (wrapper GC lifetime), `handle-leak` and `net-mongodb-pattern-leak` (100k connections, flat RSS), the `upgradeTLS` collectability tests, and `rust:check-all` for the `cfg(windows)` socket code. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Reworks the fix as requested in #31859 (review): instead of patching the
gcProtect/gcUnprotectbookkeeping inHandlers, remove it.What changed
Bun::JSSocketHandlers(src/jsc/bindings/JSSocketHandlers.{h,cpp}): aJSC::JSInternalFieldObjectImpl<13>holding the socket callbacks as GC-visited internal fields, modeled onJSNextTickQueue, with C ABIcreate/getField/setFieldfor the Rust side.Handlers(Rust) no longer stores 13 rawJSValues. It stores the cell plus one RAIIStrongroot held for the native struct's lifetime.protect(),unprotect(), theprotection_count/protectedbookkeeping, and the per-field macro are gone; callback reads go through named accessors that read the cell.valuesslot (sockets.classes.ts), so the callbacks are reachable from every object that can still invoke them.listener.reload()validates the new options and then writes the fields of the existing cell in place. Live sockets pick up the new callbacks with no secondHandlers, no whole-struct overwrite of the shared allocation, and noactive_connectionspreservation dance.onOpenwith a raw-pointer write plus a single-fieldunprotecton the shared, freely aliased struct; it is now one in-place field clear.Why
Handlers::from_generatedconstructed the struct and then validated it, so a validation error dropped a never-protectedHandlerswhoseDropunconditionally calledunprotect(). In debug builds that is the fuzzer'sprotection_count > 0panic. In release it issued unbalancedgcUnprotectcalls that could strip another live socket's protection of the same callback functions (node:net passes one module-level handler table to every connection), after which GC collects them and the next use crashes (Sentry BUN-3PK7).With the callbacks in a visited cell there is no count to unbalance: an error return before the cell exists drops nothing that needs releasing, and once it exists its lifetime is GC reachability from the listener and socket wrappers. The one manual root left is the single
Strongthe nativeHandlersholds on its own cell, created at construction and released byDrop, the same lifetime the oldprotect()had. It covers the windows where no JS wrapper exists yet (an outgoingBun.connectbeforeopenfires, the upgraded-duplex and named-pipe paths), and being RAII it cannot be unbalanced.Tests
Both regression tests from the earlier revision carry over unchanged and pass:
socket handler validation errors throw instead of crashing(error messages and validation order are byte-identical to before)socket handler validation errors don't steal GC protection from live sockets sharing the same callbacks(the BUN-3PK7 scenario; fails on a bun without this change)Also run locally on this change: the existing
reload()tests (preserves active_connections,backs out cleanly when a handler getter closes the socket mid-reload),socket-retention.test.ts(wrapper GC lifetime),handle-leakandnet-mongodb-pattern-leak(100k connections, flat RSS), theupgradeTLScollectability tests, andrust:check-allfor thecfg(windows)socket code.