http: fail with ConnectionClosed instead of asserting when outer proxy socket is dead at ProxyHeaders write#32462
Conversation
…y socket is dead at ProxyHeaders write The ProxyHeaders arm of on_writable and send_initial_request_payload both asserted !socket.is_shutdown()/!socket.is_closed() right before writing the initial request. When a fetch goes through a CONNECT proxy to an HTTPS target, the inner TLS handshake completes from buffered bytes inside on_handshake -> on_writable; a write on the outer connection in proxy.on_writable (or a close fired from the SSL wrapper's flush re-entry) can mark the outer socket closed/shut down before control reaches the ProxyHeaders write. Writing into a dead socket returns 0 and the request stalls at Headers forever; in builds with debug-assertions on (bun bd; historically Windows release builds, which used Zig ReleaseSafe) the assert fired instead. Convert both assertion sites to real runtime checks that surface error.ConnectionClosed through the existing close_and_fail path. Sentry BUN-2V7Z / BUN-36JT (100% Windows, ~28/day).
|
Updated 10:29 AM PT - Jun 17th, 2026
✅ @robobun, your commit c0a2e3e3b05af323d5b7e4a0add28411f6d30c08 passed in 🧪 To try this PR locally: bunx bun-pr 32462That installs a local version of the PR into your bun-32462 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughRuntime liveness checks ( ChangesProxy closed-socket write fix and regression test
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/http/proxy.test.ts`:
- Line 943: The spawn configuration at line 943 specifies stderr: "pipe" but
lacks stdout: "pipe". Add stdout: "pipe" to the spawn config options alongside
stderr: "pipe" to ensure consistency with other subprocess tests in the file and
maintain reliable behavior across different Bun versions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 548a7b34-4b08-4472-a852-7327832d30b2
📒 Files selected for processing (3)
src/http/lib.rstest/js/bun/http/proxy-handshake-closed-socket-fixture.tstest/js/bun/http/proxy.test.ts
- Fix incorrect comment claiming proxy env vars are cached at startup (they are read live via getenv; only delete vs assign differs). - Use test.concurrent for the two independent subprocess fixtures. - Remove unused postConnectClientFlights counter from the fixture.
There was a problem hiding this comment.
Nits from the previous pass are addressed and I found nothing further, but given the fail-before caveat (the Linux test can't deterministically reproduce the Windows race) and that this touches the HTTP client's proxy-tunnel write state machine, I'd like a maintainer to confirm the reasoning.
Extended reasoning...
Overview
This PR converts two debug_assert!(!socket.is_shutdown()/!socket.is_closed()) sites in src/http/lib.rs into runtime checks that fail the request with ConnectionClosed via the existing close_and_fail path. One site is in send_initial_request_payload (returns Err, caller already close_and_fails on error), the other is in the RequestStage::ProxyHeaders arm of on_writable (calls close_and_fail directly, matching the adjacent write_request OOM branch). A new fixture (proxy-handshake-closed-socket-fixture.ts) and two test.concurrent cases in proxy.test.ts exercise the path by RST'ing the outer proxy socket immediately after relaying the inner TLS server-handshake flight.
Security risks
None identified. The change is purely defensive — it converts a debug-build panic / release-build silent stall into a clean ConnectionClosed rejection. No new attack surface, no auth/crypto/permission logic touched. The test fixture binds to 127.0.0.1:0 and uses the harness's self-signed cert.
Level of scrutiny
Moderate-to-high. The Rust diff itself is small and mechanical (assert → guard using the established error pattern), and both hunks mirror adjacent error-handling code exactly. However:
- It sits inside the HTTP client's proxy-tunnel write state machine, which has known subtle reentrancy (the PR description walks through
on_handshake → on_writable → proxy.on_writable → wrapper.flush → handleTrafficsynchronously mutating socket state). A maintainer familiar with this state machine should confirmclose_and_failis safe to call from inside that synchronous chain at this point. - The PR's own Note on fail-before is candid that the race is not reproducible on Linux loopback, so the new test guards the code path's observable behaviour (clean rejection, no stall) but does not actually fail before the fix on CI platforms. The fix's correctness therefore rests on the prose argument about Windows AFD timing + ReleaseSafe assertions, which deserves human confirmation.
- The author explicitly noted the PR was "parked for maintainer review" when declining a cosmetic suggestion.
Other factors
- All three nits I raised on the previous revision (incorrect comment about env-var caching,
test.concurrent, deadpostConnectClientFlightscounter) were addressed in c0a2e3e and the threads are resolved. - The bug-hunting system found no issues.
- CodeRabbit's only suggestion was cosmetic (withdrawn).
- CI shows musl build failures at f41f875; unclear whether infrastructure or related to this change, but worth a glance before merge.
- Suggested reviewer per CodeRabbit: cirospaciari (who has reviewed adjacent proxy-tunnel PRs like #31959).
Crash signature
Panic: Internal assertion failureinsideon_writableafter the proxy-tunnel inner TLS handshake completes. Sentry BUN-2V7Z (~1080 lifetime, ~28/24h) and BUN-36JT (sibling). 100% Windows; affected 1.3.x back to firstSeen 2026-04-11.Stack (bun 1.3.14):
Cause
on_writablehas two sites that asserted the outer socket was live immediately before writing the initial request:send_initial_request_payload(Pending/Headers/Opened arm)ProxyHeadersarm (proxy tunnel: first HTTP write after the inner TLS handshake)When
fetch()goes through a CONNECT proxy to anhttps://target, the inner TLS handshake runs inside an in-processSSLWrapper.ProxyTunnel.on_handshakefires fromhandleTrafficwhile draining buffered bytes delivered by a singleonDatacall, then synchronously callsHTTPClient.on_writable. Between that call and theProxyHeaderswrite, the outer socket can already be closed or shut down:proxy.on_writable(run first, line 2849) writes buffered encrypted bytes withsocket.write()and thenwrapper.flush()re-entershandleTraffic; on an HTTPS proxy, a failing outerSSL_writesetsssl_fatal_errorsosocket.is_shutdown()flips true, and a buffered innerclose_notifyfireson_close -> close_and_fail -> terminate_socketsosocket.is_closed()flips true.In release builds without debug assertions, both sites fall through to
write_to_socket/ProxyTunnel::writewhich return 0 on a dead socket, leaving the request stuck atHeaders/ProxyHeadersuntil the idle timeout. In builds with debug assertions enabled the assert panicked.The Sentry cluster is 100% Windows because Windows Zig release builds used
ReleaseSafe(seescripts/build/zig.tsat the affected revisions: "since Bun 1.1, Windows builds use ReleaseSafe"), which enablesEnvironment.allow_assert. Other platforms shippedReleaseFastwhere the assert compiled out. Since the Rust rewrite the assert isdebug_assert!, so release builds on all platforms now stall instead of panicking;bun bdstill panics.Fix
Convert both assertion sites to real runtime checks that surface
error.ConnectionClosedthrough the existingclose_and_failpath.send_initial_request_payloadalready returnsResult<_, Error>and its caller alreadyclose_and_fails on error; theProxyHeadersarm callsclose_and_faildirectly (matching the adjacentwrite_requesterror branch).Verification
New test in
test/js/bun/http/proxy.test.ts(fixture atproxy-handshake-closed-socket-fixture.ts) runs 10 fetches through an HTTP and HTTPS CONNECT proxy that relays the backend's TLS server-handshake flight and immediately RSTs the outer connection. Every iteration rejects withECONNRESET/ConnectionClosed(notTimeoutError, not a panic). Fullproxy.test.ts(47 tests) passes.Note on fail-before
This race is not deterministically reproducible on Linux loopback. A 100-iteration probe against an unfixed
bun bdbuild (50 HTTP-proxy + 50 HTTPS-proxy) never tripped the assertion: on POSIX the proxy's RST is delivered to the client as a separate event loop iteration afteron_writablehas already returned, sosocket.is_closed()/is_shutdown()is still false at the assertion site. The Windows-specific timing that lands the dead-socket state inside the synchronouson_handshake -> on_writablewindow (AFD poll semantics plus ReleaseSafe assertions) cannot be reconstructed without instrumentingsrc/. The new test guards the code path and the observable behaviour (clean rejection, no stall) but does not fail-before on Linux.Related
handle_readingafter the client is freed); this PR is the request side (first write after handshake).http.zig sendInitialRequestPayload -> headerStr#23092 is a different crash site in the same function.