Skip to content

socket: hold Bun.listen/Bun.connect callbacks in a GC-visited internal-fields cell - #31859

Merged
Jarred-Sumner merged 29 commits into
mainfrom
farm/fa558331/socket-handlers-unprotected-drop
Jul 9, 2026
Merged

socket: hold Bun.listen/Bun.connect callbacks in a GC-visited internal-fields cell#31859
Jarred-Sumner merged 29 commits into
mainfrom
farm/fa558331/socket-handlers-unprotected-drop

Conversation

@robobun

@robobun robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Reworks the fix as requested in #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 JSValues. 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.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Handlers::from_generated is refactored to validate each JS callback field upfront using a new validated_callback! macro (undefined/null → JSValue::ZERO, non-callable → error), replacing post-construction per-field assignment. A new test verifies that malformed socket handler objects throw validation errors instead of crashing.

Changes

Socket Handler Callback Validation

Layer / File(s) Summary
validated_callback! macro and Handlers construction refactor
src/runtime/socket/Handlers.rs
Adds the validated_callback! macro that normalizes undefined/null callbacks to JSValue::ZERO and rejects non-callable values with throw_invalid_arguments. Moves the on_data/on_writable "at least one required" check before struct construction, initializes Handlers directly from validated locals, and removes the former assign_callback! macro and its post-construction field assignment loop.
Subprocess test for handler validation errors
test/js/bun/net/socket.test.ts
Adds a test that spawns a subprocess, iterates over two malformed handler shapes and both Bun.connect/Bun.listen APIs, captures thrown validation error messages, forces GC, and asserts exit code 0 and exact expected error strings.
🚥 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 matches the main change: socket callbacks are moved into a GC-visited internal-fields cell.
Description check ✅ Passed The PR description is detailed and covers the change, rationale, and verification, though it uses different headings than the template.

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

@github-actions github-actions Bot added the claude label Jun 4, 2026
@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:27 AM PT - Jul 8th, 2026

@robobun, your commit bdf9938 has 5 failures in Build #70450 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31859

That installs a local version of the PR into your bun-31859 executable, so you can run:

bun-31859 --bun

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. net: throw on an invalid socket handler config instead of aborting #31817 - Fixes the same bug: Handlers::from_generated drops an unprotected Handlers on validation errors, causing unbalanced gcUnprotect calls. Same fix approach (validate before constructing the struct), same files modified (src/runtime/socket/Handlers.rs, test/js/bun/net/socket.test.ts).

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — 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::ZEROis_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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — 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.

Jarred-Sumner pushed a commit that referenced this pull request Jun 5, 2026
…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.
@robobun

robobun commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for the remaining failures:

  • test/cli/install/bunx.test.ts is the only failing test in the test jobs, on every platform. It fails identically on a pristine main checkout locally (4 registry/network-dependent cases: the two github-repository installs, npm_config_user_agent, and the node-24 package), so it is unrelated to this diff, which touches only socket handler validation and socket.test.ts.
  • windows-x64-baseline-verify-baseline is red on main's two most recent commits (ef8952736b, 898ef56ed7) per the commit status API.

The new regression test passed on all platforms in both builds.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — 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.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build #63130 on the rebased commit (4fb1950):

The only failing test is test/integration/next-pages/test/dev-server.test.ts on darwin-26-aarch64, where puppeteer's postinstall failed downloading chrome-headless-shell from Google's CDN ("The browser folder exists but the executable is missing"). The same puppeteer download failure appears in neighboring build #63125. This is a network/CDN flake in an integration test's setup step, unrelated to this diff (socket handler validation in src/runtime/socket/Handlers.rs + a subprocess test in socket.test.ts).

All other test jobs passed, including the new regression test on every platform. The earlier bunx.test.ts failures from the pre-rebase builds are gone.

@robobun
robobun force-pushed the farm/fa558331/socket-handlers-unprotected-drop branch from 4fb1950 to 72b05ee Compare June 25, 2026 16:12
@robobun robobun changed the title fix(socket): don't drop an unprotected Handlers on handler validation errors fix(socket): never gcUnprotect a Handlers that was never protected (BUN-3PK7) Jun 25, 2026
@robobun

robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Extended this PR to also cover Sentry BUN-3PK7 (SIGSEGV in JSC::JSLockHolder from Bun__JSValue__unprotect during NewSocket<true>::deinit_and_destroy, 59 events on 1.4.0). Same root cause, release-build symptom: the unbalanced gcUnprotect on a validation-error drop steals protection from another live socket that shares the same callback identities (node:net's module-level handler table), GC collects the callback, and the live socket's finalizer later dereferences cell->vm() on the freed cell.

On top of the existing validate-before-construct reordering:

  • replaced the debug-only protection_count with a release-mode protected: bool so unprotect() is a no-op when protect() never ran
  • unprotect() now zeroes each field after unprotecting, so a stale cell can never reach Bun__JSValue__unprotect twice
  • protect()/unprotect() route through for_each_callback_field! so the 13-field list is defined once

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.

Comment thread test/js/bun/net/socket.test.ts Outdated
@robobun

robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Gated Malloc: "1" behind isWindows in the stolen-protection test (827ae05), matching the pattern in udp_socket.test.ts / websocket-server.test.ts / broadcast-channel-worker-gc.test.ts. On non-Windows the spread is a no-op vs the previous line; both regression tests still pass locally with bun bd test.

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

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=1 Windows portability issue I raised previously has been addressed.

@robobun

robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build #64642 on 827ae05:

The only [error] annotation is test/napi/napi.test.ts on Windows 2019 x64 and x64-baseline, with two failures:

The same [error] test/napi/napi.test.ts annotation appears on every other completed PR build rebased past 237de94: #64639, #64634, #64633, #64632, #64630 (five unrelated farm/* branches). Main's own build for d451445 (#64580) passed but only ran Windows build jobs, not Windows test jobs, so the regression wasn't caught there. A retry won't clear it.

socket.test.ts does not appear in any annotation; both new regression tests passed on every completed Windows shard, so the isWindows guard on Malloc=1 is working. The [warning] flaky items (dev-and-prod hmr, bun-install --cwd, spawn-pipe-leak RSS threshold, bun-install-security-provider IPC timeout, test-http2-window-size ECONNREFUSED) are unrelated to this diff.

@robobun

robobun commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Addendum now that #64642 has finished. Additional [error] annotations beyond napi.test.ts:

  • test/bake/dev/{request-cookies,ssg-pages-router,react-response,production,server-sourcemap}.test.ts on linux 13 x64-baseline and 25.04 x64-baseline: every failure is bun install unable to resolve scheduler@0.0.0-experimental-52912a14-20260625 from npm (today's React experimental tag), cascading into "Incompatible React versions". External registry timing, not this diff.
  • test/js/web/streams/streams-leak.test.ts on alpine 3.23 x64: same annotation present in #64639.
  • verify-baseline: same annotation present in #64633 and #64632.

socket.test.ts is still absent from every annotation on the finished build; both regression tests passed on all platforms. A retry would not clear napi.test.ts (deterministic, present on #64639/#64641/#64634/#64633/#64632/#64630), so I'm not re-rolling.

@robobun
robobun force-pushed the farm/fa558331/socket-handlers-unprotected-drop branch from 827ae05 to d4ca31a Compare June 26, 2026 12:22
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (d4ca31a). The conflict was in test/js/bun/net/socket.test.ts: 62e059dc68 (#32738) appended its setTypeOfService test at the same insertion point as the two tests here. Kept both; no change to either test body and none to Handlers.rs. All three tests pass locally with bun bd test.

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

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.ts on every platform; remaining red is pre-existing (napi.test.ts Windows 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.

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

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.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI triage for build #64978 on 1257933 (still running at time of writing):

Three [error] annotations so far, all timeouts on the single darwin 26 aarch64 test shard: serve-body-leak.test.ts (40s), test/regression/issue/20965.test.ts (90s), and terminal.test.ts. None of these touch sockets, and I checked that they cannot be affected by this diff rather than asserting it:

  • fetch (src/http/) and Bun.serve (uSockets) have no references to socket::Handlers, so neither test reaches Handlers::from_generated or the protect/unprotect path this PR changes.
  • As a control, I reran both failing test files on the same debug build twice, once with this branch's Handlers.rs and once with only that file reverted to main's. The result is identical in both runs: 2 pass, 6 fail, 5 errors (423.9s vs 424.0s). The local failures are the debug-build baseline for these timeout tests and are independent of this change.
  • Build #64642 ran the identical Handlers.rs and produced neither annotation.

For the audit trail on the GC lifecycle review: on this branch Handlers::unprotect() has exactly one caller, the Drop impl, so the field zeroing it adds only ever runs on an object nothing reads afterward.

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.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Build #64978 has finished (failed). Final triage, superseding my in-progress comment above, which also had one attribution error I want to correct: terminal.test.ts failed on darwin 14 x64, not on the darwin 26 shard as I wrote.

Four [error] annotations across three failed jobs, all darwin:

  • darwin 26 aarch64: serve-body-leak.test.ts and test/regression/issue/20965.test.ts, both timeouts. Covered by the control above: neither fetch nor Bun.serve references socket::Handlers, and an A/B with only Handlers.rs reverted to main's reproduces the identical local result.
  • darwin 14 aarch64: test/js/node/test/parallel/test-tls-client-destroy-soon.js. This one is worth calling out because, unlike the others, node:tls genuinely does go through Handlers::from_generated, so I checked it rather than assuming. It fails with assert.strictEqual(big.length, bytesRead) at line 66, a short read on destroySoon (2097152 sent vs 2048000 read). The identical assertion on the identical line and platform also fails on two branches that do not contain this PR's Handlers.rs change: build #64639 (farm/c15755bc/serve-request-fd, bytesRead 2080768) and build #64641 (farm/66562d4e/fix-archive-gzip-bun-write, bytesRead 2048000, the same count byte for byte). A branch without this diff reproducing the identical failure rules it out as a cause. The symptom also doesn't fit: a GC-protection bug in this code would surface as a collected callback and a crash, not 48 KB silently missing from a TLS transfer. No panic, protection_count, or Handlers frame appears anywhere in the output.
  • darwin 14 x64: terminal.test.ts (PTY spawn, unrelated; terminal-spawn.test.ts is already in the retried-flaky list of the previous build).

socket.test.ts, Handlers, and protection_count appear in zero annotations; both regression tests passed on every platform. The one permitted retrigger was spent on #64968 (artifact-download timeout, zero tests ran), so I'm not pushing another. All four failures are darwin-only timing or backpressure flakes that reproduce without this diff.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in 6e48359: moved the two-line fail_and_release doc from armed() to fail_and_release in WindowsNamedPipeContext.rs, same split as the ClearErrorQueue/ConnectErrorTeardown one in 2be9592.

Comment thread src/runtime/socket/Listener.rs Outdated
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in 87cae09: dropped the SAFETY: prefix on the two safe ThisPtr copy-assignments (connect_finish's socket_ref = socket reduced to a one-line plain comment; js_upgrade_duplex_to_tls's tls_ref = tls comment deleted), matching the five sibling sites.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Build #70439 (87cae09, comment-only push):

  • test/napi/napi.test.ts on win 2019 x64: napi_wrap > has the right lifetime fails "Condition was not met after 100 GC attempts". Fleet-wide Windows flake also on builds #70405, #70400, #70385, #70375, #70360, #70355 across six unrelated branches.
  • test/js/node/http/node-http.test.ts on win 11 aarch64: single ECONNREFUSED 127.0.0.1:49442 on "should make a https:// GET request when passed string as first arg" (1 of 129 tests). exampleSite() server race; the triggering commit only deleted two comment lines.
  • flaky annotation is dev-and-prod "(1 retry)".

Not re-rolling.

Comment thread src/runtime/socket/Listener.rs
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in bdf9938: dropped the three stale vm_ssl_ctx_cache doc lines and reordered so the remaining two-line doc sits above #[inline].

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Build #70450 (bdf9938, comment-only push): only hard error is test/js/sql/postgres-binary-array-bounds.test.ts on win 2019 x64-baseline with ERR_POSTGRES_CONNECTION_REFUSED, the same Windows Postgres runner outage triaged on #70271; also on neighboring unrelated-branch builds #70448, #70446, #70444, #70441. Flaky annotation is update_interactive_install + node-dns (google.com IP changed between two lookups), both retried. Not re-rolling.

@Jarred-Sumner
Jarred-Sumner merged commit ab6eb2d into main Jul 9, 2026
75 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/fa558331/socket-handlers-unprotected-drop branch July 9, 2026 00:23
robobun added a commit that referenced this pull request Jul 9, 2026
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.
robobun added a commit that referenced this pull request Jul 19, 2026
…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).
Jarred-Sumner pushed a commit that referenced this pull request Jul 19, 2026
…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 -->
liooil pushed a commit to liooil/poly that referenced this pull request Aug 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants