Skip to content

tls: keep a stream TLS engine's context alive while its entry points run - #46335

Merged
Jarred-Sumner merged 2 commits into
mainfrom
robobun/fb68b536/duplex-tls-keep-alive
Oct 21, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
robobun/fb68b536/duplex-tls-keep-alive

Conversation

@robobun

@robobun robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A callback of a TLS socket over a stream turns the event loop synchronously while the socket closes. Debug builds report AddressSanitizer: heap-use-after-free in us_ssl_wrapper_keylog under SSL_do_handshake, or in SSLWrapper::flush_pending_events. Release builds end with panic: RefCell already borrowed or stay silent.
  • DuplexUpgradeContext::on_close (src/runtime/socket/socket_body.rs) queued the task that frees the context at once. The nested turn ran it under the TLS engine.
  • Found by audit. No issue or crash report names it.

Fix

  • DuplexUpgradeContext is refcounted. The open connection holds one ref, and each UpgradedDuplex entry point holds one while it runs. The free task is queued at zero.
  • No free task exists while an entry point runs. A debug assertion checks that handlers run under a ref.
  • Verified: test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts. Ten cases abort on the base build and pass with the fix. Also twelve TLS, http2 and module-graph suites (Notes).
  • Self-reviewed: 12 concerns raised, 11 addressed. One exit has no test (Notes).

Background

  • DuplexUpgradeContext owns UpgradedDuplex, which owns the TLS engine (SSLWrapper) behind tls.connect({socket}).
  • expect(promise).resolves and Bun.build with an async plugin setup() run queued tasks while they wait.
  • Considered a check in the free task. It parks the free and polls. WindowsNamedPipe (net: keep a Windows named-pipe socket alive while its read callbacks run #39983) holds a ref at its entry points.

Downsides

  • Duplex TLS pays 9 instructions per guarded entry. An echo measures +0.11% instructions, and a control with no guard +0.06%.
  • A transport error before the engine starts takes 2 queue entries, not 1.
  • The binary grows by 8,192 bytes and one test-only host function.
Notes

What turns the loop, and what closes the socket

  • The wait is EventLoop::wait_for_promise (src/jsc/event_loop.rs). It calls tick(), which runs the task queue. The fixture uses two callers: a bun:test promise matcher in a plain script, and Bun.build with an async plugin setup() (src/runtime/api/JSBundler.rs).
  • The callback does not have to close the socket. Three of the ten cases close it from elsewhere: a destroy() in a later turn (the callback is the transport's final()), a transport that closes during the turn, and a Bun.ModuleGraph that is disposed.
  • tls: consolidate the open TLS fixes (both engines, node:tls, node:https, WebSocket, SQL) #44618 covered an ALPNCallback that destroys its socket with no loop turn (it kept the SSL until the pass ends). The owner was still freed by the queued task.

The ten cases on the base build (debug, ASAN)

Each one ends in heap-use-after-free. The free is DuplexUpgradeContext::deinit from run_event, or from release_unrun for the disposed graph.

Case Top frames of the read
ALPNCallback us_ssl_wrapper_keylog < us_ssl_keylog_cb < SSL_do_handshake
server 'keylog' SSLWrapper::flush_pending_events < traffic_pass
client 'secureConnect' UpgradedDuplex::is_shutdown < UpgradedDuplex::on_handshake
server 'data' SSLWrapper::dispatch_read < handle_reading
server 'data', turned by Bun.build() SSLWrapper::dispatch_read < handle_reading
client 'data' SSLWrapper::dispatch_read < handle_reading
the transport's write() SSLWrapper::handle_writing < write_data
the transport's final(), destroy() in a later turn UpgradedDuplex::finish_close < on_close
server 'data', the transport closes during the turn SSLWrapper::dispatch_read < handle_reading
server 'data', its Bun.ModuleGraph is disposed SSLWrapper::dispatch_read < handle_reading

Release build of the base, original repro (ALPNCallback destroys the socket, then expect().resolves): panic: RefCell already borrowed, 5 of 5 runs. The 'keylog' variant printed OK 5 of 5 (silent). Sentry was not queried.

How the tests decide

  • The fixture counts live contexts through duplexTLSInternals.liveCount(), so the tests do not need ASAN to fail.
  • Each callback case has a third context that nothing holds. The turn frees it. That shows that the turn ran the queued tasks: the report is { before: 3, afterTurn: 2, afterClose: 0 }. With no turn it is 3, and with the fault it is 1.
  • The turn is a promise that a microtask settles, so one tick() runs. One case turns with Bun.build() instead, so the test does not depend on the matcher alone.
  • The fourth test ends a connection in seven ways and expects zero contexts left. It does not abort on the base build. There it fails because the counter does not exist.
  • Each guard clause was removed once on the debug build (by a jump in gdb) to confirm that a test notices:
    • no teardown() in release_unrun: a string from the transport reaches the disposed graph as ERR_STREAM_WRAP.
    • no abort_handle.leave(): DuplexUpgradeContext entered after its last ref was released.
    • the count check in release_unrun: the graph case reaches it with a count of 1, so an unconditional free trips the assertion in deinit.

Direction

Sequencing

Not in this change

  • WindowsNamedPipe: net: keep a Windows named-pipe socket alive while its read callbacks run #39983 guards on_read, on_read_error, on_error, on_handshake and get_accepted_by. Seven entries that dispatch have no guard: on_writable, flush, on_timeout, encode_and_write, raw_write, close, shutdown. No fault is known there: its transport is libuv and runs no script in write, end, pause or resume. Read from the code, not run on Windows. Do not copy keep_alive to those entries as is: there, a ref taken at zero queues the free twice.
  • ProxyTunnel (src/http/ProxyTunnel.rs) runs on the HTTP thread, where no script runs.
  • The start-failure exit of the StartTLS arm now releases the connection's ref. It has no test. node:tls and node:http2 pass a prebuilt SSL_CTX, so the start fails only if SSL_new fails.

Measurements (linux x64, base 71bc818)

  • Instructions: perf is not installed here. The counts are perf_event_open (PERF_COUNT_HW_INSTRUCTIONS, user only) around the release binaries. One TLS echo over an in-process Duplex pair, 100,000 round trips of 1 KiB, BUN_JSC_useJIT=0, 10 interleaved runs each.
    • base 32,160,640,513, this PR 32,197,252,048: +366 per round trip of 321,600 (+0.11%). Run-to-run spread: 0.09% to 0.13%.
    • The same echo over loopback TCP (no guard on the path): base 20,199,900,220, this PR 20,211,412,604 (+0.06%). Spread: 0.07% to 0.11%.
    • Disassembly of UpgradedDuplex__flush: 4 instructions at entry and 5 at exit. A round trip takes 4 guards (gdb count on the debug build).
  • sizeof(DuplexUpgradeContext): 568 B -> 568 B (gdb on the debug builds). The two fields fit in padding.
  • Queue entries per duplex TLS socket (gdb hit counts on run_event, 50 cycles): normal close 2 -> 2, destroy() before the engine starts 2 -> 2, transport error before the engine starts 1 -> 2.
  • Live contexts: 2 of 3 inside the callback after the turn, 0 after unwinding. 0 after 1,000 cycles of each of three orders on the release build (4,000 sockets).
  • Release binary (llvm-size): .text 81,904,741 -> 81,909,861 (+5,120), .rodata +4,096, file 107,497,184 -> 107,505,376 (+8,192). Host functions: +1, testing only.

Suites (debug build, --timeout 90000)

Same results as the base build: node-tls-duplex-end-verify (115), node-tls-duplex-write-throw-error-value (1), node-tls-wrapped-socket-close (113), node-tls-server (218), node-tls-upgrade (21), node-http2-upgrade (23), renegotiation (58), node-tls-context (83), tls-fatal-error-closes (38), tls-reject-before-client-cert (126), module-graph-io (28), module-graph-isolation (413). node-tls-connect has the same 4 failures on both builds: those tests contact bun.sh, which this machine cannot reach. cargo clippy -p bun_runtime and test/internal/source-lints/ are clean.

DuplexUpgradeContext is now refcounted. The open connection holds one
ref, and each UpgradedDuplex entry point holds one while it runs. The
task that frees the context is queued when the count reaches zero.

Before, the close of the socket queued that task at once. A callback
that turned the event loop synchronously ran the task while the TLS
engine, BoringSSL and UpgradedDuplex were still on the stack.
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed.

  • Reproduced on a debug (ASAN) build of main 71bc818. The ten callback cases of test/js/node/tls/tls-duplex-destroy-then-turn-fixture.mjs each end in heap-use-after-free. The queued Close task of DuplexUpgradeContext frees the context under the TLS engine.
  • On a release build of the same base, an ALPNCallback that destroys its socket and then waits on expect().resolves ends in panic: RefCell already borrowed (5 of 5 runs).
  • With this branch the ten cases pass. The suites in the PR body give the same results as the base build.

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:14 AM PT - Oct 21st, 2026

✅ @robobun, your commit ba75f868b546040453c7bffb8abbc8c46982ba9f passed in Build #130110! 🎉


🧪   To try this PR locally:

bunx bun-pr 46335

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

bun-46335 --bun

@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 04e894c4-0e08-42c6-a234-bc680850dace
📥 Commits

Reviewing files that changed from the base of the PR and between 9634520 and ba75f86.

📒 Files selected for processing: 5
  • src/js/internal-for-testing.ts
  • src/runtime/socket/UpgradedDuplex.rs
  • src/runtime/socket/socket_body.rs
  • test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts
  • test/js/node/tls/tls-duplex-destroy-then-turn-fixture.mjs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

Walkthrough

The duplex TLS context now uses reference counting and queued cleanup. Runtime entry points retain the context while active. A testing API and lifecycle tests report live context counts across TLS-over-duplex callbacks and connection-ending cases.

Changes

TLS duplex context lifetime

Layer / File(s) Summary
Context reference counting and cleanup
src/runtime/socket/socket_body.rs
Duplex upgrade contexts track connection and entry-point references. Close, failure, and unrun paths release references, and the final release queues cleanup. Initialization and deinitialization update the live-context counter.
Entry-point lifetime retention
src/runtime/socket/UpgradedDuplex.rs, src/runtime/socket/socket_body.rs
Upgraded-duplex operations retain the context during TLS, wrapped-stream, and callback work. Socket callback paths check that a keepalive exists. on_writable flushes the TLS wrapper directly before dispatching the writable callback.
Live-count API and lifecycle tests
src/js/internal-for-testing.ts, src/runtime/socket/socket_body.rs, test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts, test/js/node/tls/tls-duplex-destroy-then-turn-fixture.mjs
The testing API exposes the live-context count. The fixture and tests check context counts across callback-triggered event-loop turns and connection-ending cases.

Possibly related issues

Suggested reviewers: dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to ba75f

The change fixes use-after-free crashes when TLS sockets over duplex streams close while callbacks turn the event loop. No concrete merge-blocking risk was found in the supplied context. The added tests cover the reported crash scenarios.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the primary change: keeping the stream TLS engine context alive while entry points execute.
Description check Passed The description provides detailed problem, fix, verification results, trade-offs, and test coverage. It does not use the template headings "What does this PR do?" and "How did you verify your code wor…
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.
Full details: Description check

Explanation

The description provides detailed problem, fix, verification results, trade-offs, and test coverage. It does not use the template headings "What does this PR do?" and "How did you verify your code works?", but it contains the required information under equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner merged commit ebcd487 into main Oct 21, 2026
11 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/fb68b536/duplex-tls-keep-alive branch October 21, 2026 20:28
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