Skip to content

tls: defer upgraded-duplex teardown to the owning context's close task - #31364

Closed
Jarred-Sumner wants to merge 2 commits into
mainfrom
claude/hardening-fix-pre-108-http2-upgrade-test-asan-segv
Closed

Jarred-Sumner wants to merge 2 commits into
mainfrom
claude/hardening-fix-pre-108-http2-upgrade-test-asan-segv

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

UpgradedDuplex::on_close tore the SSLWrapper down in place even though it is invoked from the wrapper's own close callback, with wrapper frames still on the stack below it that re-read the wrapper's fields after it returns. Teardown is now deferred to the owner's next-tick Close task, which already frees the containing DuplexUpgradeContext and runs the same teardown via Drop.

Adds two cases to node-http2-upgrade.test.mts: 20 sequential connect/request/close cycles against an upgraded net.Server -> Http2SecureServer, and raw-socket writes after the server-side session is destroyed. Existing cases in that file and node-tls-connect.test.ts still pass.

@robobun

robobun commented May 24, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 9:05 PM PT - May 28th, 2026

❌ @Jarred-Sumner, your commit 1f67f36 has some failures in Build #58793 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 31364

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

bun-31364 --bun

@coderabbitai

coderabbitai Bot commented May 24, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 359129b4-12de-4128-a6e6-f94d4f40e59a

📥 Commits

Reviewing files that changed from the base of the PR and between 4c48d6c and 1f67f36.

📒 Files selected for processing (1)
  • src/runtime/socket/UpgradedDuplex.rs
💤 Files with no reviewable changes (1)
  • src/runtime/socket/UpgradedDuplex.rs

Walkthrough

This PR fixes HTTP/2 upgrade socket lifecycle by moving teardown from the on_close event handler to the owning DuplexUpgradeContext's Drop. Early teardown corrupted active SSL wrapper stack frames. Two regression tests validate sequential upgrade connection ordering and session destruction safety.

Changes

HTTP/2 Upgrade Socket Lifecycle

Layer / File(s) Summary
Deferred teardown lifecycle and documentation
src/runtime/socket/UpgradedDuplex.rs
on_close no longer calls early teardown(); instead, teardown is deferred to the owning context's Drop to avoid dropping the SSLWrapper while &mut frames remain on the stack. Detailed comment and updated teardown() doc clarify the lifetime constraint and Option payload initialization semantics.
Socket close ordering regression tests
test/js/node/http2/node-http2-upgrade.test.mts
Two new regression tests validate the fix: one opens 20 sequential upgraded connections and asserts each raw socket closes cleanly in order; the other destroys the server-side HTTP/2 session and verifies subsequent raw socket operations remain safe.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: deferring teardown logic from UpgradedDuplex::on_close to the owning context's close task.
Description check ✅ Passed The description provides clear explanation of what the PR does and how it was verified (existing and new tests), matching the repository's template structure.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

I didn't find any issues — the reasoning in the PORT NOTE checks out against SSLWrapper::trigger_close_callback/flush and DuplexUpgradeContext::on_close — but this is unsafe-Rust TLS teardown that shifts when the wrapper, timer, and JS function-data are released, so it's worth a human pass from someone who owns this path.

Extended reasoning...

Overview

The Rust change is a single deletion: UpgradedDuplex::on_close no longer calls this.teardown() inline, and instead relies on the already-enqueued DuplexUpgradeContext next-tick Close task to drop the struct (whose Drop impl runs the same teardown()). The rest is a detailed PORT NOTE comment plus an updated doc comment on teardown(). Two regression tests are added to node-http2-upgrade.test.mts exercising sequential connect/close cycles and post-destroy raw-socket writes.

Security risks

This is TLS socket lifecycle code. The bug being fixed is real UB: on_close is invoked from inside SSLWrapper::trigger_close_callback, and writing self.wrapper = None there leaves the niche-optimized Option<SSLWrapper> payload uninitialized while frames like flush() (src/uws/lib.rs:657–661) still re-read self.ssl after the callback returns — a wild SSL* into BoringSSL. The fix removes that hazard rather than introducing one. I see no new injection/auth/data-exposure surface.

Level of scrutiny

High. The correctness of deferring teardown rests on cross-file invariants: (1) DuplexUpgradeContext::on_close unconditionally calls deinit_in_next_tick() (socket_body.rs:3585) and has already take()n tls; (2) closed_notified is set before the callback fires (uws/lib.rs:822), gating handle_traffic/update_handshake_state and the data/write/close trampolines; (3) the SSL* stays live until Drop, so any re-entry that does slip through operates on valid memory. I verified all three, but this is exactly the kind of raw-pointer + re-entrancy + niche-layout reasoning a maintainer should confirm.

Other factors

There is a behavioral shift worth a second pair of eyes: previously teardown() ran eagerly, so the event-loop timer was cleared and the four JS host-function data pointers were nulled inside on_close. Now those stay live until the next tick. If the JS end() call (via call_write_or_end) or TLSSocket::on_close synchronously re-enters on_received_data/on_writable, they'll now reach a live wrapper instead of bailing on null function-data — closed_notified should make those inert, but it's a different gate than before. The new tests cover the crash repro well; no CODEOWNERS entry matches these paths.

UpgradedDuplex::on_close used to tear the SSLWrapper down in place even
though it is invoked from the wrapper's own close callback, with wrapper
frames still on the stack below it. Defer the teardown to the owner's
next-tick Close task, which already frees the containing
DuplexUpgradeContext (and runs the same teardown via Drop).

Adds two cases to node-http2-upgrade.test.mts covering repeated
sequential connection close and post-destroy raw-socket events.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/hardening-fix-pre-108-http2-upgrade-test-asan-segv branch from 4c48d6c to 9a4f69f Compare May 28, 2026 21:16
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Classified the failing check: build #57663's only hard failure is test/js/node/http/node-http-backpressure-max.test.ts timing out at 60s on the darwin-14 shard. That test is plain node:http over TCP streaming a 4GB buffer with no TLS involved, so it never reaches the UpgradedDuplex path this PR changes — it's an unrelated CI machine flake (every other failure in the build is tagged flaky and passed on retry). Rebased the branch onto current origin/main (clean — main hasn't touched UpgradedDuplex.rs or the test file since the old base, commit is now 9a4f69f), rebuilt, and re-ran the http2-upgrade and TLS upgraded-duplex tests locally; all pass. Force-pushed for a fresh CI run.

Comment thread src/runtime/socket/UpgradedDuplex.rs Outdated
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

@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 tightening the PORT NOTE — the re-entry gates now read accurately. I have nothing further, but since this is a memory-safety change in unsafe Rust on the BoringSSL FFI path whose correctness leans on the DuplexUpgradeContext drop-ordering invariant, I'd like a human to give it a final look.

Extended reasoning...

Overview

The PR removes the in-place this.teardown() call from UpgradedDuplex::on_close (src/runtime/socket/UpgradedDuplex.rs) and replaces it with a detailed PORT NOTE explaining why teardown must be deferred to the owning DuplexUpgradeContext's next-tick Close task (which drops the struct and runs teardown() via Drop). The rationale is that on_close is invoked from inside SSLWrapper::trigger_close_callback with live &mut SSLWrapper frames still on the stack that re-read self.ssl after return; writing self.wrapper = None in place leaves the niche-optimized Option payload uninitialized and feeds a wild SSL* to BoringSSL. The teardown() doc comment is updated to state the same constraint. Two regression tests are added to test/js/node/http2/node-http2-upgrade.test.mts: 20 sequential connect/request/close cycles, and raw-socket writes after server-side session destroy.

I previously flagged that the comment's sent_ssl_shutdown clause overstated the re-entry gate; the author verified against src/uws/lib.rs, reworded the note to cite closed_notified + the still-valid ssl pointer as the actual gates, and resolved the thread. I spot-checked the cross-file invariant the fix relies on — DuplexUpgradeContext::on_close at socket_body.rs:3604 does unconditionally null self.tls and call deinit_in_next_tick() — so the comment's claims hold.

Security risks

No new attack surface is introduced; the change removes a use-after-uninit on the TLS path. The residual risk is the inverse: if any future caller reaches UpgradedDuplex::on_close via a handler whose on_close does not schedule a deferred drop, the wrapper would now leak instead of being torn down — but the only wired handler is DuplexUpgradeContext, which always schedules it. No auth, crypto-primitive, or permission logic is touched.

Level of scrutiny

High. This is unsafe Rust managing raw SSL* lifetime across re-entrant FFI callbacks, and correctness hinges on a cross-file drop-ordering invariant (DuplexUpgradeContext → next-tick Close → Drop for UpgradedDuplex → teardown). The diff is tiny (one removed call + comments + tests), but the blast radius of getting TLS connection teardown wrong is large — this is exactly the class of change that benefits from a second human pair of eyes on the lifecycle reasoning.

Other factors

CI build #58768's only failure is a Windows-aarch64 timeout in an unrelated plain-HTTP abort test that the author classified as a flake (passed on retry on another Windows shard). The bug-hunting system found nothing. The new tests exercise both the sequential-realloc and the post-close re-entry paths and also run under Node.js for parity. Given all that, the PR looks correct to me — I'm deferring purely on the "unsafe + TLS lifecycle" scrutiny bar, not on any open concern.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Looked into the red buildkite/bun status: it comes only from the darwin-14-aarch64 and darwin-26-aarch64 test shards, which expired waiting for a macOS aarch64 agent and never actually ran, so there is no test output attributable to this diff. The jobs relevant to this change (debian-13-x64-asan-test-bun, darwin-14-x64, and all Linux test shards) all passed at 1f67f36, and the only test failures in the build were known-flaky files unrelated to UpgradedDuplex.rs / node-http2-upgrade.test.mts that passed on retry. The expired darwin aarch64 shards have been retried and are queued; no code change is needed here.

cirospaciari pushed a commit that referenced this pull request Sep 25, 2026
… pipe (#43806)

### Problem
- A TLS server over a Duplex crashes when its `ALPNCallback` calls
`socket.write()` or `socket.end(data)`: `panic(main thread):
Segmentation fault at address 0xB0`. A remote client triggers it.
- Over a synchronous Duplex pair, a `write()` made while the peer's last
handshake flight is queued completes the handshake unreported: no
`secureConnect` or `secure`, and no `rejectUnauthorized` check.
- Cause: `SSLWrapper::write_data` (`src/uws/lib.rs:851`) calls
`SSL_write` while the handshake is pending, and `SSL_write` runs
`SSL_do_handshake` itself. Inside `ALPNCallback` that call fails, and
the close frees the SSL under the running handshake. Elsewhere it can
finish the handshake with no `on_handshake`.

### Fix
- While the state is `HandshakePending`, `write_data` runs
`handle_traffic` and returns `WantRead` without `SSL_write`. Only
`update_handshake_state` drives the initial handshake, and it reports
every result.
- Correct because `UpgradedDuplex` and `WindowsNamedPipe` already park a
`WantRead` write and retry it after `on_handshake`. Where `SSL_write`
returned `WANT_READ` before, nothing changes.
- Verified: 8 new tests in `test/js/node/tls/node-tls-connect.test.ts`
fail on a build of `main` and pass with the change. They also pass on
Windows x64.

### Background
- `SSLWrapper` runs BoringSSL over memory BIOs for TLS on a `Duplex`, a
Windows named pipe, or a CONNECT tunnel.
- Its `handle_traffic` pass calls `SSL_do_handshake` and fires
`on_handshake`, where JS emits `secureConnect` and applies
`rejectUnauthorized`. A nested call only flushes, so a synchronous
peer's answer stays queued.

### Downsides
- `socket.destroy()` from `ALPNCallback` over a Duplex still crashes: it
reaches the same free through `close`. #31364 defers that teardown.
- A failed handshake with a write parked now reports like one with no
write: with `rejectUnauthorized: false`, a wrong `secureConnect` before
the error (#32929 corrects it).

<details><summary>Notes</summary>

- Found by a code audit of `SSLWrapper`. No user reported it.
- The crash on main, from an ASAN debug build: the write in
`ALPNCallback` reaches `SSL_write`, whose nested `SSL_do_handshake`
fails in `tls_can_accept_handshake_data`. `write_data` takes its error
branch: `trigger_close_callback`, `UpgradedDuplex::on_close`,
`teardown`, `SSLWrapper::deinit`, `SSL_free`. The outer
`SSL_do_handshake` then reads the freed ClientHello in
`SSL_select_next_proto`. It happens on TLS 1.2 and 1.3, on Linux and
Windows, with the in-memory pair and with a Duplex over a TCP socket.
- Repro for the unreported handshake: both sides are `Duplex` streams
whose `write()` pushes the chunk into the peer at once. The client
issues `tlsSocket.write()` from inside its transport `write()`, after
the push. With TLS 1.3 that is the ClientHello write. With TLS 1.2 it is
the client's second flight. The server case needs the client created
first, so that the client's engine is idle and answers from inside the
server's transport write.
- Stock `stream.duplexPair()` delivers synchronously too. With it, a
server `keylog` listener that writes on the client socket hits the same
window on TLS 1.3: the client never emits `secureConnect` on
1.4.3-canary.
- Results on 1.4.3-canary, TLS 1.3, a chain that fails verification,
default `rejectUnauthorized`: the server gets `secure` with the client
certificate and the application data, and the client emits neither
`secureConnect` nor `error`. Node v26.3.0 emits the client `error` and
the server sees nothing. With the fix Bun does the same.
- The measurement behind the second Downsides bullet: a client with
`minVersion: "TLSv1.3", maxVersion: "TLSv1.2"` over a Duplex,
`rejectUnauthorized: false`, and a write right after `tls.connect()`.
1.4.3-canary: `write callback ERR_SOCKET_CLOSED`, `error
ERR_SOCKET_CLOSED`, `close`. With the change: `secureConnect
authorized=false UNABLE_TO_GET_ISSUER_CERT` first, then the same three.
Without the write both builds emit only that `secureConnect`. Node emits
`error ERR_SSL_NO_PROTOCOLS_AVAILABLE`.
- The fd-backed path already parks these writes. `us_internal_ssl_write`
parks a write made inside `SSL_do_handshake` (`ssl_in_use`) and sets
`ssl_read_input_length = 0`, so its `SSL_write` cannot read a peer
flight.
- Node does not park a write made inside `ALPNCallback`. Its handshake
fails with `ERR_SSL_UNEXPECTED_MESSAGE`. Bun's fd-backed path delivers
it after the handshake ("writing to the socket from inside SNICallback
or ALPNCallback delivers the data after the handshake" in
`node-tls-server.test.ts`). The Duplex path now does the same for
`write()` and `end(data)`.
- The rejected-certificate rows: the identity verdict (`servername` not
in the certificate) runs on both TLS versions, the chain verdict on TLS
1.3 only. A TLS 1.2 client has the chain verdict before its second
flight, so a client that rejects the chain during the handshake (#43767,
merged) never reaches that write. On TLS 1.3 the early write still gets
past that check on `main`: the chain row fails there with `server
received secret`.
- A TLS 1.2 client that rejects the chain still sends its client
certificate before the `error`, as Node does. A TLS 1.2 renegotiation is
still driven by reads and writes. A bare `end()` in the same window does
not reach `write_data` with data, and behaves the same with and without
the change.
- A `debug_assert!` in `update_handshake_state` pins the invariant: the
initial handshake never completes outside it.
- `data` is not read after `handle_traffic` in the new branch. The
nested `on_writable` flush can free the buffer that `data` points into
(`buffered_data_for_node_net`). The existing `WANT_READ` branch has the
same rule. A review suggested a call to `SSL_write` after
`handle_traffic` when the handshake completed inside it. I built that:
it is a heap-use-after-free under ASAN in `SSL_write`, and the bytes are
not lost without it, because the nested flush sends them.
- `ProxyTunnel` and `WebSocketProxyTunnel` write only after
`on_handshake`, so the guard does not change them.
- Self-reviewed. Addressed: the crash explanation, the TLS 1.2 rejected
row that #43767 makes unreachable, the claim that only the two broken
cases change, the `debug_assert!`, the `end(data)` and TCP rows, and the
duplicate Duplex-pair helper. Not in this PR: the `destroy()` case
(#31364), the failure report (#32929).
- Rebased on `main` abc26b7. Suites run with the debug build on that
base: every file in `test/js/node/tls/` and `test/js/bun/net/`,
`proxy.test.ts`, `websocket-proxy.test.ts` and
`fetch-proxy-connect-tunnel-split-envelope.test.ts`. Five tests fail,
and each fails the same way on a build of `main`: `SNICallback runs even
when the requested servername matches the bind hostname` (`ECONNREFUSED`
in this container), `concurrent Workers all see the same CA certificate
lists` (needs 5.0 to 5.2 s on `main`, over the 5 s default), `node:net
handle whose socket never starts connecting is collectable`, `an unref'd
Bun.listen() with no other references keeps accepting across GC`, and
`should not call drain before handshake` (needs `www.example.com`).
- Trial merge with #43924 (a3b00db): no conflict. On the merged tree
`node-tls-connect.test.ts`, `node-tls-duplex-end-verify.test.ts`,
`renegotiation.test.ts`, `tls-reject-before-client-cert.test.ts`,
`node-tls-wrapped-socket-close.test.ts` and `node-tls-upgrade.test.ts`
pass.
- Vendored Node tests run with the debug build:
`test-tls-connect-stream-writes`, `test-tls-destroy-stream`,
`test-tls-destroy-stream-12`, `test-tls-js-stream`,
`test-tls-on-empty-socket`, `test-tls-socket-close`,
`test-tls-socket-default-options`, `test-tls-socket-destroy`,
`test-tls-socket-failed-handshake-emits-error`,
`test-tls-streamwrap-buffersize`,
`test-tls-transport-destroy-after-own-gc`,
`test-tls-wrap-econnreset-pipe`, `test-tls-wrap-event-emmiter`,
`test-double-tls-server`, `test-http2-generic-streams`,
`test-http2-generic-streams-sendfile`, `test-tls-inception`,
`test-tls-alpn-server-client`, `test-tls-close-error`,
`test-tls-handshake-error`.
- Windows x64, debug build of the rebased head:
`node-tls-connect.test.ts` (96 pass, 1 skip),
`tls-reject-before-client-cert.test.ts` (117 pass, 2 skip), and
`node-tls-namedpipes.test.ts` (6 pass, with a 60 s timeout). On the
earlier base `should work with named pipes and tls` (400 connections)
needed 5.8 s on that debug build with the change and 5.9 s without it,
so it exceeds the 5 s default there either way.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 8 failed, 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-connect.test.ts
bun test v1.4.3 (367d939)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.95ms]
(pass) should thow ECONNRESET if FIN is received before handshake [321.60ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [6.80ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [138.65ms]
(pass) should be able to grab the JSStreamSocket constructor [22.65ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [132.66ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [91.20ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when connect is called with only options
(skip) tls.connect > should process port a
... (truncated)

release without fix: 15 failed, 18 skipped
bun test v1.4.3-canary.1 (367d939)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [0.05ms]
(pass) should thow ECONNRESET if FIN is received before handshake [8.80ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [0.27ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [5.64ms]
(pass) should be able to grab the JSStreamSocket constructor [0.37ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [6.71ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [5.79ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when connect is called with only options
(skip) tls.connect > should process port and host correctly
(skip) tls.connect > should process port, host, and callback correctly
(skip) tls.connect > should handle the absence of a callback gracefully
(skip) tls.c
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-connect.test.ts
bun test v1.4.3 (367d939)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.73ms]
(pass) should thow ECONNRESET if FIN is received before handshake [304.31ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [6.88ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [96.91ms]
(pass) should be able to grab the JSStreamSocket constructor [12.12ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [73.43ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [74.52ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when connect is called with only options
(skip) tls.connect > should process port and
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     b2cb31d
  features     lto, baseline

23 deps, 136 codegen, 1176 objects in 1233ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch lolhtml
[lolhtml] up to date
[2/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
244 units: 172 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[3/4] reconfigure
[1/1499] mkdir stamps
[2/1499] mkdir codegen
[3/1499] rustc unicode_xid 
[4/1499] rustc unicode_ident 
[5/1499] install /workspace/bun
bun install v1.4.3-canary.1 (367d939)

Checked 26 installs across 65 packages (no changes) [155.00ms]
[6/1499] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[7/1499] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939)

Checked 1 install across 2 packages (no changes) [4.00ms]
[8/1499] gen ErrorCode+*.h
[9/1499] gen string-map defines_t
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/uws/lib.rs                            |   9 +
 test/js/node/tls/node-tls-connect.test.ts | 302 +++++++++++++++++++++++++++++-
 2 files changed, 306 insertions(+), 5 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                       reads  edits  tests
src/uws/lib.rs                                 5      6     55
test/js/node/tls/node-tls-connect.test.ts      8     15     51
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Closing: main already has this fix. Thank you for the diagnosis in this PR.

#32521 (46c54cd) changed UpgradedDuplex::teardown(). It calls SSLWrapper::deinit() in place and never assigns None to wrapper. deinit() sets closed_notified and clears ssl, so the SSLWrapper frames below on_close read valid memory. The wrapper drops when the queued close task frees the DuplexUpgradeContext. That is the point this PR defers to. Since #46335 (ebcd487), that task is queued only after every entry point of the engine has returned.

I checked main at 5c6d4e2 with a debug build (ASAN):

  • test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts: 8 pass, 0 fail, no ASAN report, in 2 runs.
  • The two cases from this PR, added to test/js/node/http2/node-http2-upgrade.test.mts: 28 pass, 0 fail, no ASAN report, in 3 runs.
  • On main those two cases no longer reach UpgradedDuplex: the UpgradedDuplex debug log prints nothing for them. The TLS wrap of an accepted net.Socket adopts its file descriptor (adoptServerTLS in src/js/node/net.ts). So I ran both scenarios in a local script with the accepted socket behind a plain Duplex, which does use UpgradedDuplex. 21 connections closed, no ASAN report.
  • With teardown() changed back to self.wrapper.set(None), the first file fails 4 of 8 tests (1 run). The failures are AddressSanitizer: heap-use-after-free and panic: null reference produced under SSLWrapper::handle_traffic. So the tests on main cover this bug.

Two results that were not clean:

  • The unmodified node-http2-upgrade.test.mts failed 1 of 26 on its first run. "tests should run on node.js" timed out after 5000ms. It passed in the next 3 runs of the file. I did not find the cause. The case runs the file under node and does not use UpgradedDuplex.
  • test/js/node/tls/node-tls-connect.test.ts, which this PR names: 351 pass, 4 fail. The 4 failures are the cases that connect to bun.sh:443. Bun 1.4.3 fails the same 4 on this machine, so I read them as a network limit of the machine.

#46522 also lists this PR as already fixed on main. Reopen this PR if the crash still shows.

@robobun robobun closed this Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants