tls: root the duplex origin and listener thunks through the wrapper instead of as Strong handles - #34672
Conversation
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Updated 9:31 PM PT - Jul 18th, 2026
❌ @robobun, your commit 99107ff has 3 failures in
🧪 To try this PR locally: bunx bun-pr 34672That installs a local version of the PR into your bun-34672 --bun |
|
Status: ready for review.
CI (build 75596): the remaining red is unrelated to this diff:
All three test files use plain TCPSocket (whose |
…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).
…wngrades the wrapper In the pre-open error path and the StartTLS failure path, handle_connect_error re-enters JS and downgrades the wrapper's strong self-reference before DuplexUpgradeContext::deinit reaches Drop's teardown. Neuter the thunks (and clear the shadows) while the wrapper is still strong so the later idempotent teardown never reads a shadow whose backing cell could have been collected.
b82160e to
7cc94c3
Compare
There was a problem hiding this comment.
No bugs found this pass; the earlier UAF window and the stderr nit are both addressed. Deferring to a human because this swaps Strong GC roots for wrapper-visited slots + unrooted JSValue shadows in the TLS-over-Duplex path — a lifetime-model change where a missed ordering is a UAF under GC pressure.
What was reviewed:
- Traced every path into
teardown():on_close(stack-roots the wrapper),DropviadeinitfromStartTLS(tls.is_none()andstart_tlsfailure — both now neuter shadows beforehandle_connect_errordowngrades), andEventState::Close(shadows already zeroed by the precedingon_close). - Confirmed the wrapper's
JSRefis strong (viamark_active()) for the entire window the shadows are read outside those brackets, soorigin/thunks stay rooted throughinternal_write/call_write_or_end. - Verified the codegen emits
duplex_*_set_cachedfor the newvalues:entries and thatTCPSocketkeeps the plain["handlers"]list.
Extended reasoning...
Overview
The PR moves five StrongOptional handles on UpgradedDuplex (the origin Duplex and four native listener thunks) onto the owning JSTLSSocket wrapper as visited values: slots, keeping plain JSValue shadows on the Rust struct for reads. teardown() still neuters the thunks' function data via the shadows and now also runs eagerly on both connect-error branches so the shadows are cleared before the wrapper's JSRef is downgraded. Files: UpgradedDuplex.rs, two call sites in socket_body.rs, the values: list in sockets.classes.ts, and a new protected-count regression test.
Security risks
No new attack surface. The risk is memory safety: a JSValue shadow that outlives its GC root is a use-after-free when set_function_data writes into a swept cell. My previous review identified exactly that on the pre-open-error → queued-StartTLS path; the fix (call upgrade.teardown() before handle_connect_error in both the !is_open on_error branch and the start_tls-failure branch) is in place and I re-traced it as correct — the wrapper's JSRef is still strong at both call sites, so the thunk cells are live when neutered, and the later Drop-path teardown() sees zeroed shadows.
Level of scrutiny
High. This is a GC-rooting refactor in TLS socket code, precisely the category REVIEW.md's "Root or copy every JSValue held beyond the current call" section is written for. There is no correctness bug being fixed (the PR says so), so the bar is that the new rooting model is at least as safe as the old one on every path. I traced the four teardown() entry points and the origin reads in call_write_or_end/on_received_data against the wrapper's JSRef state machine and found no window where a shadow is read after the wrapper can have been swept — but this is subtle enough (the first review pass found a real hole) that a human should confirm the reasoning, particularly the on_close → deinit_in_next_tick → EventState::Close sequence and whether any other caller of deinit_in_next_tick could exist.
Other factors
Both prior inline findings are resolved (the UAF fix in the second commit, the void stderr; fix in the third). The new test asserts protectedObjectTypeCounts deltas rather than absolute counts, uses the sibling tests' subprocess pattern, and would fail on main (fn≈4N, obj≈N). rust:check-all reported clean and the existing duplex/renegotiation/UAF regression tests are cited as passing. Given the GC-lifetime subtlety and that this is not a bug fix, I'm deferring rather than approving.
What
UpgradedDuplex(the TLS engine that drivestls.connect({ socket: <Duplex> })and the equivalent server wrap) held fiveStrongOptionalhandles per connection: the origin stream plus the four native listener thunks created inget_js_handlers. EachStrongis an independentHandleSetentry whose lifetime is tied to the native struct rather than to the JS wrapper's reachability.Move all five onto the
JSTLSSocketwrapper as visitedvalues:slots (duplexOrigin,duplexOnData,duplexOnEnd,duplexOnWritable,duplexOnClose).UpgradedDuplexkeeps plainJSValueshadows for its own reads and stack-roots the wrapper acrosson_closeso the slots stay valid through the close handler's downgrade + re-entry into JS.teardownstill 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().protectedObjectTypeCountsbefore/after, 20 live upgrades:There is no correctness bug to point at here:
teardownreleases all five Strongs and the existing tests exercise that path. The change is about the rooting model. AStrongis 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 theTLSSocketthat owns them.Verification
test/js/bun/net/socket-retention.test.tsasserts theFunction/Objectprotected-count delta across 20 live upgrades stays belowN(was4NandNrespectively).node-tls-connect.test.ts(TLS overDuplex,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-allclean across all targets.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