http: proxy stress test suite + fix streamed upload and on_writable UAF through tunnel - #32635
Conversation
…AF through tunnel Adds comprehensive stress testing of the HTTP client proxy code paths: 661 tests across 5 new files plus a shared adversarial proxy/origin helper. test/js/bun/http/proxy-stress-helpers.ts adversarial proxy + origin test/js/bun/http/proxy-stress-matrix.test.ts protocol x framing x encoding x body x keepalive test/js/bun/http/proxy-stress-lifecycle.test.ts RST/abort/close at every tunnel stage test/js/bun/http/proxy-stress-errors.test.ts CONNECT failures, auth, unreachable, TLS verify test/js/bun/http/proxy-stress-concurrent.test.ts pool churn + subprocess memory probes test/js/bun/http/proxy-stress-adversarial.test.ts stacked conditions + WebSocket proxy tunnel The adversarial proxy supports both CONNECT tunneling and absolute-form forwarding over a plain-TCP or TLS outer socket, with hooks to RST at any stage, split/trickle bytes, force CONNECT status codes, require auth, and track per-connection wire state. The adversarial origin shapes responses (content-length / chunked / close-delimited, all compression encodings) and can truncate at any byte offset. Two pre-existing bugs surfaced by the suite and fixed here in src/http/lib.rs: 1. Streamed request body (ReadableStream / async-iterator) through a CONNECT tunnel to an https origin hangs forever. The ProxyHeaders arm of on_writable computed has_sent_body from request_body().is_empty(), which is always true for HTTPRequestBody::Stream, so the request jumped to Done before the stream was ever signalled. Even with that fixed, write_to_stream_using_buffer wrote streamed body bytes to the outer socket instead of through the inner TLS session. Fixed by matching send_initial_request_payload's Bytes-only has_sent_body check and routing write_to_stream_using_buffer through ProxyTunnel::write when a tunnel is active. 2. heap-use-after-free in HTTPClient::on_writable when a TLS alert is buffered alongside the inner handshake ServerHello flight. on_handshake -> on_writable -> proxy.on_writable -> SSLWrapper::flush -> handle_reading processes the alert, fires on_close -> close_and_fail, which frees the ThreadlocalAsyncHTTP embedding *self via the result callback; control returns to on_writable and touches freed self. Fixed by checking socket.is_closed() after proxy.on_writable() before touching self again (same hazard already documented in start_proxy_handshake).
|
Updated 4:10 PM PT - Jun 23rd, 2026
❌ @robobun, your commit 554db3e has 1 failures in
🧪 To try this PR locally: bunx bun-pr 32635That installs a local version of the PR into your bun-32635 --bun |
|
Independently reproduced and fixed the |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughFixes the ChangesProxy tunnel fixes and stress test coverage
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 |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Re the duplicate-detector flag for #32636: that PR is a focused fix for bug 1 (streamed body through tunnel) only, with a single regression test added to |
|
Overlaps with (and supersedes) #32636, which I've closed. The Branch |
…ects, 1xx, IPv6, HTTP/1.0) 102 additional tests: early server reply (413/400/500) mid-upload across all proxy/origin combos; 3-5 hop redirect chains mixing http and https; 512B-16KB response header values; 1xx informational before 200 through tunnel; IPv6 literal origin; many proxy.headers entries; proxy:'' force-direct; HTTP/1.0 origin; redirect:manual/error; response body consumed as text/arrayBuffer/bytes/blob/json. Also drop unused errcode import from proxy-stress-concurrent.test.ts.
Drop stale design-narration comments and redundant stageHit promise from the abort-at-each-stage block in proxy-stress-lifecycle.test.ts; unused imports in proxy-stress-concurrent/matrix were already cleaned by autofix.ci + the previous commit.
There was a problem hiding this comment.
Actionable comments posted: 12
🤖 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 `@src/http/lib.rs`:
- Around line 2723-2765: The proxy tunnel write path is missing socket state
validation that exists in the ProxyHeaders path. Before the first
ProxyTunnel::write call that writes from the buffer, add a guard check to return
an error if socket.is_closed() or socket.is_shutdown() is true. This prevents
inner TLS from buffering bytes indefinitely on a dead outer connection, similar
to the protection already implemented in the ProxyHeaders path. Reference the
socket validation pattern used elsewhere in the codebase to ensure consistency.
- Around line 3143-3148: The `original_request_body` match expression in the
`has_sent_body` calculation does not properly reject `Sendfile` requests before
proxy tunneling, allowing them to reach the `ProxyBody::Sendfile` handler which
unconditionally panics. Add a guard before proxy tunneling is enabled (similar
to how `can_offer_h2()` and `can_try_h3_alt_svc()` work) that returns a
recoverable error when `Sendfile` is encountered in a proxy tunnel scenario, or
modify the match expression to explicitly handle `HTTPRequestBody::Sendfile` and
reject it with an appropriate error instead of allowing it to proceed and panic.
In `@test/js/bun/http/proxy-stress-adversarial.test.ts`:
- Around line 322-331: The server is bound to the IPv4 address 127.0.0.1 on line
322, but the fetch request on line 330 uses localhost which can resolve to
either IPv4 or IPv6 depending on the system configuration, causing
inconsistency. Replace localhost in the fetch URL with 127.0.0.1 to match the
server binding address and ensure the fetch request uses the same host that the
server is listening on.
In `@test/js/bun/http/proxy-stress-concurrent.test.ts`:
- Around line 112-115: The current assertions in the proxyTls conditional block
and the HTTP-proxy rejectUnauthorized case (around lines 226-231) are too
lenient and do not enforce connection reuse properly. In the proxyTls case where
expect(proxy.connectCount()).toBeLessThanOrEqual(5) is used, tighten this
assertion to enforce actual connection reuse by reducing the maximum allowed
CONNECT count significantly below five. Similarly, in the rejectUnauthorized
test case around lines 229-231, tighten the assertion to properly enforce that
the strict tunnel is being reused rather than creating a new connection for each
request. The goal is to make these assertions fail if connection pooling
regresses.
- Around line 79-80: Remove the per-test timeout values from the test blocks in
the proxy-stress-concurrent.test.ts file. Specifically, remove the second
argument (30_000 and other timeout values) that specifies the timeout duration
for each test, as Bun already has built-in timeouts and setting explicit
per-test timeouts weakens hang detection. This applies to the test blocks at
lines 79-80, 183, 232, and 273-274 in the file, where you should remove the
timeout parameter while keeping the test name and callback function intact.
- Around line 413-414: The current assertion using toBeGreaterThanOrEqual is too
lenient and allows the test to pass even when all requests fail. Replace the
check on result.completed and result.failed with an exact equality assertion
that completed plus failed equals iterations, then add mode-specific validation
checks below it that verify the expected outcomes for each test mode (complete,
concurrent-32, redirect, and abort modes should each have distinct failure
expectations to ensure abort paths are properly exercised and other modes
maintain expected completion rates).
- Around line 333-350: The test does not actually verify that the parked tunnel
was evicted before making the second request. Before writing the stray bytes to
liveClients, capture close event promises from each client socket so you can
wait for them to complete. Assert that liveClients is not empty and that a
parked tunnel exists before attempting eviction. After writing the bytes, await
all the close promises to ensure the eviction has actually completed before
proceeding to the second fetch request. In the finally block cleanup, destroy
any remaining live sockets to ensure proper resource cleanup.
In `@test/js/bun/http/proxy-stress-errors.test.ts`:
- Around line 247-295: In the first test case, when proxyTls is true, the outer
HTTPS proxy handshake fails before sending the CONNECT request, causing the test
to pass for the wrong reason. Restrict the first test to only run with HTTP
proxies by adding a condition that skips it when proxyTls is true, or provide
separate tls configuration options for the proxy connection versus the inner
origin connection. In the second test case, the checkServerIdentity function
unconditionally rejects all hostnames including the proxy itself. Modify the
checkServerIdentity callback to conditionally return undefined (no error) when
the hostname matches the proxy, and return new Error("pinned") only when it
matches the origin hostname. Additionally, add assertions to both test cases to
verify that the proxy received at least one CONNECT request (using a method like
proxy.connectCount() or similar) to confirm the tunnel was actually attempted
before the inner TLS failure occurred.
In `@test/js/bun/http/proxy-stress-helpers.ts`:
- Around line 55-58: The restoreProxyEnv function currently sets
previously-absent proxy environment variables to empty strings, which leaves
stale empty values that can interfere with subsequent tests. Modify the function
to properly delete environment variables that were originally absent (when
saved[key] is undefined) by setting them to undefined or using delete, rather
than setting them to empty strings, so that undefined variables remain properly
deleted and do not cause cross-test pollution.
- Line 232: The upstream error handler (around line 379-383) and upstream close
handler (around line 385-391) are using socket.destroy() which closes the
connection immediately and can discard the 502 response before it's sent.
Replace the destroy() calls with client.end() to allow responses to flush
gracefully, following the same pattern already demonstrated elsewhere in the
file (see lines 250-251 and 407-412 where client.write() is followed by
client.end()). Additionally, add boolean flags to track whether the client
socket has already been closed, preventing race conditions where both the error
and close handlers might fire and attempt to close the same socket twice, which
causes the upstream refused to 502 error tests to be flaky.
In `@test/js/bun/http/proxy-stress-lifecycle.test.ts`:
- Around line 315-352: Replace the polling mechanism that checks the want
predicate on proxy.connections[0] (the poller async function using setImmediate)
with a stage callback approach. Add a non-mutating stage callback parameter to
the proxy helper that gets invoked when each proxy stage is reached, and drive
the abort from that callback by calling ac.abort() and signalStage() when the
target stage parameter is reached. This ensures the abort happens at the actual
observable proxy stage event rather than relying on polling a connection record
that may never flip to the expected state.
In `@test/js/bun/http/proxy-stress-matrix.test.ts`:
- Around line 229-232: The assertion `expect(chunks).toBeGreaterThanOrEqual(1)`
in the proxy stress matrix test is too weak because a value of 1 is already
guaranteed by the earlier check `got === payload.length`, so this doesn't
actually detect if the body was buffered into a single read as the comment
suggests it should. Change the assertion to `expect(chunks).toBeGreaterThan(1)`
to properly enforce that multiple chunks are received, which will correctly
validate that the stream is not pre-buffering the 128KB body before exposing it
to the stream consumer.
🪄 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: e79a45c0-f6fb-48fe-9cd8-0db0d893198c
📒 Files selected for processing (8)
src/http/lib.rstest/js/bun/http/proxy-stress-adversarial.test.tstest/js/bun/http/proxy-stress-concurrent.test.tstest/js/bun/http/proxy-stress-errors.test.tstest/js/bun/http/proxy-stress-helpers.tstest/js/bun/http/proxy-stress-lifecycle.test.tstest/js/bun/http/proxy-stress-matrix.test.tstest/js/bun/http/proxy-stress-memory-fixture.ts
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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-stress-protocol.test.ts`:
- Around line 362-378: The test for 'proxy: "" forces direct' does not actually
set up an ambient proxy environment, so it passes regardless of whether proxy:
"" is being honored. To properly test that proxy: "" bypasses ambient proxies,
you need to configure the proxy URL created with createAdversarialProxy as the
ambient proxy in the environment before calling fetch, either by setting it as
an environment variable in an isolated subprocess context or using env isolation
to prevent race conditions with concurrent tests. Then keep the existing
assertion that proxy.connections.length equals 0 to prove the ambient proxy was
actually bypassed by the empty string proxy parameter.
- Around line 258-314: The test assertion on line 309 is too lenient and accepts
"resolved:502" as a valid outcome even though the beforeAll block (lines
262-273) has already confirmed that IPv6 loopback (::1) is available on this
host. Since ::1 is provably listenable, the proxy should successfully dial the
IPv6 origin and return a 200 response rather than failing with a 502. Tighten
the expectation in the contain() call to only accept the "200:v6" outcome, and
if the createAdversarialProxy helper is not properly handling bracketed IPv6
address format (like [::1]), normalize the IPv6 address format in that helper to
ensure correct connection handling.
🪄 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: 49039992-80a3-477c-9239-ab58b5f6fd44
📒 Files selected for processing (2)
test/js/bun/http/proxy-stress-lifecycle.test.tstest/js/bun/http/proxy-stress-protocol.test.ts
….test.ts src/http/lib.rs: guard write_to_stream_using_buffer's tunnel path on socket.is_closed()/is_shutdown(), matching the ProxyHeaders arm, so inner TLS writes don't buffer forever on a dead outer socket. proxy-stress-helpers.ts: strip IPv6 brackets before net.connect (getaddrinfo rejects '[::1]'); use client.end() instead of destroy() on upstream error/close so 502 replies flush; drop stale consumer list from the header comment. proxy-stress-protocol.test.ts: tighten IPv6 test to assert 200 now that the helper dials ::1 correctly; recast the proxy:'' test to document that an empty-string proxy option is currently treated as absent (fetch.rs get_length > 0 check), not as 'explicitly no proxy' per the FetchTasklet comment, so ambient env is not overridden. proxy-stress-errors.test.ts: assert connectCount() in the inner-TLS verification tests so an outer-proxy TLS failure can't mask them. proxy-stress-concurrent.test.ts: wait for the parked socket's close event in the idle-eviction test; tighten memory-probe assertions to exact iteration count + mode-specific completed/failed expectations; assert http-proxy reject_unauthorized reuse exactly. proxy-stress-matrix.test.ts: drop the vacuous chunks>=1 assertion. proxy-stress-adversarial.test.ts: fetch 127.0.0.1 to match the never-reply origin's bind address. proxy-stress-headers.test.ts: 76 new tests covering many response headers, duplicate Set-Cookie, Content-Type matrix, 206 Content-Range, decompress:false, CONNECT envelope shape, long URLs, proxy switching, and Content-Encoding after decompress.
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-stress-headers.test.ts`:
- Around line 289-306: The test "Content-Encoding header after decompress"
verifies the body is decompressed correctly but does not verify that the
Content-Encoding header is actually removed from the response, which contradicts
the stated behavior in the test comments. Add an assertion after the existing
body check that verifies the Content-Encoding header is absent by checking that
res.headers.get("content-encoding") returns a falsy value (null or undefined),
mirroring the inverse assertion pattern used in the decompress:false test case.
🪄 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: 8785cda4-127a-44bd-bdf5-6c7cc8d25103
📒 Files selected for processing (8)
src/http/lib.rstest/js/bun/http/proxy-stress-adversarial.test.tstest/js/bun/http/proxy-stress-concurrent.test.tstest/js/bun/http/proxy-stress-errors.test.tstest/js/bun/http/proxy-stress-headers.test.tstest/js/bun/http/proxy-stress-helpers.tstest/js/bun/http/proxy-stress-matrix.test.tstest/js/bun/http/proxy-stress-protocol.test.ts
💤 Files with no reviewable changes (1)
- test/js/bun/http/proxy-stress-matrix.test.ts
…ress Bun keeps the Content-Encoding header after transparent decompression (issue #5668). The comment claimed it was stripped; correct the comment and add the matching assertion so the tunnel path is verified to match direct fetch.
…r comments The file header listed folding, long header names, and User-Agent as covered, but none of those are tested in this file. write_proxy_connect does not send User-Agent on the CONNECT envelope, so drop that from the section comment as well.
- proxy-stress-helpers.ts: remove unused onConnection/forceUpstream options and absoluteFormCount() method (leftovers from abandoned designs) - proxy-stress-protocol.ts: gate IPv6 tests with describe.skipIf(!isIPv6()) instead of a bare early return, so hosts without ::1 report SKIPPED - proxy-stress-errors.ts: make the h2-not-offered test observe the actual inner-TLS ALPN result via a raw tls server that echoes socket.alpnProtocol - proxy-stress-adversarial.ts: tighten interleaved proxy/direct assertions (bytesUp unchanged across direct requests; http-proxy connectCount === 1)
…ertion
The separate { mode, growth } toEqual with expect.any(Number) could never
fail. Fold mode + the rounded ratio into the threshold check via a
withinBound:true key so they appear in the diff when the leak bound trips.
… stageReached - proxy-stress-adversarial.ts: the large binary frame round-trip test now rejects on a clean close before the echo arrives, matching the adjacent echo test. A clean server close fires onclose (not onerror), so without this the test hung to framework timeout on regression. - proxy-stress-lifecycle.ts: remove the write-only stageReached flag and its tautological assertion. await poller resolving is itself proof the stage was reached.
…m's handler SSLWrapper::write_data fires trigger_close_callback before returning a fatal Err, which runs proxy_tunnel::on_close -> close_and_fail and can synchronously free the HTTPClient via the result callback. Propagating that Err to write_to_stream's Err arm then dereferences freed memory. Match the pre-existing ProxyTunnel::write callers in on_writable: on a non-WantRead/WantWrite Err, return Ok(true) so write_to_stream only releases the independently-allocated stream buffer and returns without touching self. Also guard createAdversarialProxy's onData against re-entry between head parse and upstream connect, which could double-push a connection record and orphan the first dial when an absolute-form client sends body bytes in a separate packet before loopback connect completes.
…ally Matches the cleanup pattern used by every other inline-server test in the suite (duplicate Set-Cookie, h2 ALPN, 1xx/early-reply/HTTP1.0 origins). Moves the proxy creation inside the try so a failure there does not leak the listening origin.
There was a problem hiding this comment.
All prior review feedback has been addressed and I found nothing new this round, but the src/http/lib.rs changes touch UAF-prone proxy-tunnel lifecycle paths (three separate liveness/Err-propagation fixes around ProxyTunnel::write / SSLWrapper callbacks) that warrant a human sign-off.
Extended reasoning...
Overview
This PR adds a 661-test proxy stress suite (9 new files under test/js/bun/http/, ~3,500 lines) and fixes three bugs in src/http/lib.rs (~100 lines): (1) streamed request bodies through a CONNECT tunnel were never sent because has_sent_body was computed from the empty Stream buffer and write_to_stream_using_buffer wrote plaintext to the outer socket instead of routing through ProxyTunnel::write; (2) a heap-use-after-free in on_writable when a TLS alert is buffered with the inner handshake flight (the socket.is_closed() guard after proxy.on_writable()); (3) a UAF found during this review where the new tunnel branch's fatal Err from ProxyTunnel::write propagated to write_to_stream's handler after trigger_close_callback had already freed *self — fixed by returning Ok(true) to match the sibling callers' pattern.
Security risks
None introduced. The changes are defensive (liveness guards, routing plaintext through the inner TLS instead of the outer socket). The test suite only spins up loopback servers with self-signed certs and does not touch auth, permissions, or untrusted input parsing in production code.
Level of scrutiny
High for the src/http/lib.rs changes. This is production HTTP-client code on the proxy-tunnel write path, dealing with raw pointer dereferencing (proxy_tunnel::raw_as_mut), SSL wrapper callback ordering, and the exact UAF class this PR is fixing — one of the three fixes was surfaced during review, which confirms the area is subtle. The fixes look correct and well-commented, mirror established patterns elsewhere in the file (the let Ok(...) else { return; } idiom at the other two ProxyTunnel::write call sites), and are backed by deterministic ASAN repros, but memory-safety changes of this kind should have a human reviewer verify the lifecycle reasoning.
The test files are lower risk (new coverage only, no behavior changes to existing tests) but are large enough that flakiness on non-Linux CI lanes is worth watching once they land.
Other factors
This PR has been through extensive iterative review: ~12 inline findings from me and ~14 from CodeRabbit, every one addressed or declined with a clear rationale by the author. The UAF guard at line 2918 was independently reproduced on a separate branch. The duplicate PR #32636 (bug 1 only) was closed in favor of this one. CI showed musl build failures on commit 68fa164 (build-bun, not test failures); the most recent commit 440b4de is test-only, so those may be infra-related — worth confirming the latest CI run is green before merge. The Sendfile-through-proxy panic flagged by CodeRabbit is pre-existing on main and intentionally out of scope here.
CI statusThe diff is green on every proxy-related test across all lanes. Two consecutive builds each had a single unrelated failure on the same
Neither test uses All 32 review threads resolved; claude[bot]'s final review found nothing new and recommends human sign-off for the |
| // When tunneling through a proxy the stream body must go through | ||
| // the inner TLS session (ProxyTunnel::write), not the outer socket: | ||
| // writing plaintext to `socket` here would interleave it with the | ||
| // tunnel's TLS records and corrupt the stream at the origin. | ||
| // WantRead/WantWrite from the inner SSL are treated as backpressure | ||
| // so the next onWritable retries the flush. Encrypted bytes reach | ||
| // the outer socket via the SSLWrapper's write_encrypted callback, | ||
| // same as the ProxyHeaders/ProxyBody::Bytes paths. | ||
| if let Some(proxy_ptr) = self.proxy_tunnel.as_ref().map(|p| p.as_ptr()) { | ||
| // Same guard as the ProxyHeaders arm: inner TLS writes succeed | ||
| // at the SSL layer and buffer on a dead outer socket forever. | ||
| if socket.is_closed() || socket.is_shutdown() { | ||
| return Err(err!(ConnectionClosed)); | ||
| } | ||
| let proxy = proxy_tunnel::raw_as_mut(proxy_ptr); | ||
| let to_send_len = buffer.slice().len(); | ||
| if to_send_len > 0 { | ||
| match ProxyTunnel::write(proxy, buffer.slice()) { | ||
| Ok(amount) => { | ||
| self.state.request_sent_len += amount; | ||
| buffer.cursor += amount; | ||
| if amount < to_send_len { | ||
| if !data.is_empty() { | ||
| let _ = buffer.write(data); | ||
| } | ||
| return Ok(true); | ||
| } | ||
| if buffer.is_empty() { | ||
| buffer.reset(); | ||
| } | ||
| } | ||
| Err(e) if e == err!(WantRead) || e == err!(WantWrite) => { | ||
| if !data.is_empty() { | ||
| let _ = buffer.write(data); | ||
| } | ||
| return Ok(true); | ||
| } | ||
| // A fatal SSL_write error fires trigger_close_callback | ||
| // before returning, which runs proxy on_close → | ||
| // close_and_fail and may have freed *self via the | ||
| // result callback. Match the other ProxyTunnel::write | ||
| // callers: bail without touching self. Ok(true) makes | ||
| // write_to_stream only release the (independently | ||
| // allocated) stream buffer and return. | ||
| Err(_) => return Ok(true), | ||
| } | ||
| } | ||
| if !data.is_empty() { | ||
| match ProxyTunnel::write(proxy, data) { | ||
| Ok(sent) => { | ||
| self.state.request_sent_len += sent; | ||
| if sent < data.len() { | ||
| let _ = buffer.write(&data[sent..]); | ||
| return Ok(true); | ||
| } | ||
| } | ||
| Err(e) if e == err!(WantRead) || e == err!(WantWrite) => { | ||
| let _ = buffer.write(data); | ||
| return Ok(true); | ||
| } | ||
| // See the matching arm above: on_close already ran. | ||
| Err(_) => return Ok(true), | ||
| } | ||
| } | ||
| return Ok(false); | ||
| } | ||
|
|
There was a problem hiding this comment.
Can you simplify this code? It's very repetitive. And the comment is mostly unnecessary.
There was a problem hiding this comment.
Simplified in 554db3e. Collapsed both match blocks into let Ok(n) = ... else { ...; return Ok(true); } (all Err variants end up as backpressure anyway), dropped the redundant is_empty() guards, and cut the comments down to two short blocks. Net: 67 lines → 39.
Collapse the two match blocks into let-else form. All Err variants (WantRead, WantWrite, fatal) return Ok(true) as backpressure, so the distinction is unnecessary at this level. Trim the comments to the one line that matters for safety.
Bun triage confirmed our report (oven-sh/bun#33918) and root-caused it: with a ReadableStream body through a CONNECT tunnel, the ProxyHeaders stage treated the empty stream buffer as already-sent and the chunked plaintext went to the outer proxy socket instead of the TLS session. Fixed by oven-sh/bun#32635, first in 1.4.0; verified on canary through the same sandbox proxy that stalled (all four combos pass). The guard now buffers only proxied Bun < 1.4.0, so it retires itself as users upgrade. All four branches unit-verified: proxied 1.3.14 (buffered), direct 1.3.14 (streams), proxied 1.4.0 canary (streams), Node (streams). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Heads-up: |
|
proxy-stress-protocol.test.ts has been flaking on darwin since this landed (same port-reuse mechanism as the headers/matrix siblings); shared-proxy fix in #33987. |
…33975) ## Problem `test/js/bun/http/proxy-stress-headers.test.ts` has been flaking in CI since it was added in #32635, roughly once every ~100 runs across darwin (14/26, aarch64/x64), alpine x64-baseline, and debian x64-baseline lanes. Always exactly 1 of 76 tests, in two shapes: **`ConnectionRefused` on an https-origin test** (e.g. [build 71878](https://buildkite.com/bun/bun/builds/71878), [71453](https://buildkite.com/bun/bun/builds/71453), [71343](https://buildkite.com/bun/bun/builds/71343), [71279](https://buildkite.com/bun/bun/builds/71279), [70843](https://buildkite.com/bun/bun/builds/70843), [70725](https://buildkite.com/bun/bun/builds/70725), [70679](https://buildkite.com/bun/bun/builds/70679)): ``` error: Unable to connect. Is the computer able to access the url? path: "https://localhost:58938/", errno: 0, code: "ConnectionRefused" ✗ Content-Type through tunnel > http-proxy → https-origin Content-Type="application/octet-stream" ``` **Wrong Content-Type received on an http-origin test** (e.g. [build 71888](https://buildkite.com/bun/bun/builds/71888), [70668](https://buildkite.com/bun/bun/builds/70668), [70454](https://buildkite.com/bun/bun/builds/70454)), always receiving `text/html; charset=utf-8` (the second entry in the Content-Type matrix): ``` Expected: "application/x-www-form-urlencoded" Received: "text/html; charset=utf-8" ✗ Content-Type through tunnel > http-proxy → http-origin Content-Type="application/x-www-form-urlencoded" ``` Both fail on the single CI retry too. ## Cause 76 `test.concurrent` cases each created a fresh adversarial proxy + origin, for ~150 `listen(0, "127.0.0.1")` calls per file under the test runner's rolling `max_concurrency` window (20 in release, 5 under ASAN). As early tests finish and dispose their servers, later tests' `listen(0)` can be handed a just-freed port while a sibling test is still mid-dial (the proxy's `net.connect(port, "localhost")` goes through autoSelectFamily, adding async hops between port capture and connect). I could not reproduce the single-failure shape in isolation (150 runs linux, 105 serial runs on a macOS 26 CI box all clean; running this file past ~100 back-to-back iterations on macOS hits full ephemeral-port exhaustion instead, which is mass EADDRNOTAVAIL/502, not the single-test failure CI sees). Ruled out as causes: the HTTP client's keep-alive pool (lookup and release are both gated on `!disable_keepalive`, so `keepalive: false` never touches the pool), the HTTP-thread scratch buffers (`SHARED_REQUEST_HEADERS_BUF` etc. are filled and consumed within one callback with no yield), and node:net cross-connection state (`lookupAndConnectMultiple` builds a fresh per-call context). ## Fix Share one `{http, https}` proxy pair across the file, created in `beforeAll` and torn down in `afterAll`. The adversarial proxy is a stateless forwarder, so one long-lived pair serves every test that does not inspect `proxy.connections`; the two tests that do ("CONNECT envelope shape" and "switching proxy") keep per-test proxies. Proxy ports are now fixed for the file's lifetime, and per-file `listen(0)` churn drops from ~152 to ~84. Coverage is unchanged: 76 tests, 1206 expect() calls. All existing assertions preserved. ## Verification ``` bun bd test test/js/bun/http/proxy-stress-headers.test.ts # 76 pass, 0 fail (ASAN) ``` 150 consecutive runs on linux and 100 on a macOS 26 CI box, all clean. The sibling `proxy-stress-matrix.test.ts` has the same per-test proxy pattern (480 tests) but its payload is a constant `makeBody(n, "R")`, so if the same port-reuse race bites there the assertion cannot distinguish one origin's response from another's. Left alone for now since it is not red in CI. <!-- robobun:evidence:begin --> --- **[stamp-90s]** gate passed · iteration 0 · 1 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/bun/http/proxy-stress-headers.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/http/proxy-stress-headers.test.ts info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: checking for self-update (current version: 1.29.0) bun test v1.4.0 (9033b14) test/js/bun/http/proxy-stress-headers.test.ts: (pass) many response headers > http-proxy → http-origin, 5 response headers [1401.23ms] (pass) many response headers > http-proxy → http-origin, 50 response headers [1391.27ms] (pass) many response headers > http-proxy → http-origin, 200 response headers [1386.46ms] (pass) many response headers > http-proxy → https-origin, 5 response headers [1650.19ms] (pass) many response headers > http-proxy → https-origin, 50 response headers [1662.04ms] (pass) many response headers > https-proxy → http-origin, 5 response headers [501.52ms] (pass) many response headers > https-proxy → http-origin, 50 response headers [551.86ms] (pass) many response headers > http-proxy → https-origin, 200 response headers [1214.01ms] (pass) many response headers > https-proxy → http-origin, 200 response headers [1412.05ms] (pass) many response headers > https-proxy → https-origin, 5 response headers [1597.69ms] (pass) duplicate Set-Cookie through tunnel > http-proxy → http-origin, 3 Set-Cookie headers [750.87ms] (pass) many response headers > https-proxy → https-origin, 50 response headers [2082.60ms] (pass) many response headers > https-proxy → https-origin, 200 response headers [2041.62ms] (pass) duplicate Set-Cookie through tunnel > http-proxy → https-origin, 3 Set-Cookie headers [994.17ms] (pass) duplicate Set-Cookie through tunnel > https-proxy → http-origin, 3 Set-Cookie headers [844.36ms] (pass) Content-Type through tunnel > http-proxy → http-origin Content-Type="text/plain" [251.09ms] (pass) Co ... (truncated) Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/bun/http/proxy-stress-headers.test.ts | 33 +++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) ``` </details> **gate history** · 2 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/bun/http/proxy-stress-headers.test.ts 1 3 0 ``` </details> <!-- robobun:evidence:end -->
…#33987) ## Problem `test/js/bun/http/proxy-stress-protocol.test.ts` has been flaking on darwin since it was added in #32635. Always exactly 1 of 102 tests, across darwin 14 x64 and 26 aarch64: [build 71945](https://buildkite.com/bun/bun/builds/71945) (darwin 14 x64): the proxy's upstream dial reached an unrelated Express server instead of the test origin: ``` - "early" + "... Error ... Cannot POST / ..." ✗ early reply during upload > https-proxy → http-origin, 413 after 1KB of a 1MB upload ``` [build 71690](https://buildkite.com/bun/bun/builds/71690) (darwin 14 x64): reached a Verdaccio registry from another job on the same box: ``` - "final-5" + "... Verdaccio ... window.__VERDACCIO_BASENAME_UI_OPTIONS={...,\"base\":\"http://localhost:58343/\",...}" ✗ multi-hop redirect through proxy > https-proxy, 5-hop [s→h→s→h→s] ``` [build 71696](https://buildkite.com/bun/bun/builds/71696) (darwin 26 aarch64): 200 response from a server that was not the test origin: ``` TypeError: undefined is not an object (evaluating 'origin.requests[0].headers') ✗ large request headers through tunnel > http-proxy → http-origin, request header 512B ``` [build 71727](https://buildkite.com/bun/bun/builds/71727), [build 71795](https://buildkite.com/bun/bun/builds/71795): `ConnectionRefused` on the origin URL. All failed on the single CI retry. ## Cause Same mechanism #33975 diagnosed for `proxy-stress-headers.test.ts` and #33984 for `proxy-stress-matrix.test.ts`: 102 `test.concurrent` cases each create a fresh adversarial proxy + origin, issuing ~200 `listen(0, "127.0.0.1")` calls per file under the test runner's rolling concurrency window. As early tests dispose their servers, a later test's `listen(0)` can be handed a just-freed port while a sibling is still mid-dial (the proxy's `net.connect(port, "localhost")` goes through autoSelectFamily, adding async hops between port capture and connect). On the persistent darwin runners the `"localhost"` dial can additionally land on an unrelated IPv6 listener (Verdaccio / Express from another job), which is why builds 71690 and 71945 received recognisable third-party HTML. ## Fix Share one `{http, https}` proxy pair from `beforeAll` across the 68 tests that do not inspect `proxy.connections` (early-reply, large-headers, 1xx, HTTP/1.0, body-consumer matrices). The 32 tests that assert on the proxy's connection log (multi-hop redirect, IPv6, `many proxy.headers`, redirect manual/error) keep dedicated proxies. Per-file `listen(0)` churn drops from ~200 to ~134. Coverage is unchanged: 102 tests, 508 `expect()` calls, identical to main. Same approach as the two open sibling PRs. Introduced by #32635; sibling fixes are #33975 and #33984. ## Verification ``` bun bd test test/js/bun/http/proxy-stress-protocol.test.ts # 102 pass, 508 expect() (debug+ASAN) ``` 15 consecutive debug+ASAN runs and 30 release runs on linux, all clean. <!-- robobun:evidence:begin --> --- **[stamp-90s]** gate passed · iteration 0 · 1 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/bun/http/proxy-stress-protocol.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/http/proxy-stress-protocol.test.ts info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: checking for self-update (current version: 1.29.0) bun test v1.4.0 (e926c35) test/js/bun/http/proxy-stress-protocol.test.ts: (pass) early reply during upload > http-proxy → http-origin, 413 after 1KB of a 1MB upload [818.20ms] (pass) early reply during upload > http-proxy → http-origin, 400 after 1KB of a 1MB upload [787.76ms] (pass) early reply during upload > http-proxy → http-origin, 500 after 1KB of a 1MB upload [788.48ms] (pass) early reply during upload > http-proxy → https-origin, 413 after 1KB of a 1MB upload [988.57ms] (pass) early reply during upload > http-proxy → https-origin, 400 after 1KB of a 1MB upload [1089.31ms] (pass) early reply during upload > https-proxy → http-origin, 413 after 1KB of a 1MB upload [522.18ms] (pass) early reply during upload > https-proxy → http-origin, 400 after 1KB of a 1MB upload [526.23ms] (pass) early reply during upload > https-proxy → http-origin, 500 after 1KB of a 1MB upload [366.37ms] (pass) early reply during upload > http-proxy → https-origin, 500 after 1KB of a 1MB upload [687.29ms] (pass) early reply during upload > https-proxy → https-origin, 413 after 1KB of a 1MB upload [545.75ms] (pass) early reply during upload > https-proxy → https-origin, 400 after 1KB of a 1MB upload [521.02ms] (pass) early reply during upload > https-proxy → https-origin, 500 after 1KB of a 1MB upload [520.15ms] (pass) multi-hop redirect through proxy > http-proxy, 3-hop [h→h→h] [915.91ms] (pass) multi-hop redirect through proxy > http-proxy, 3-hop [s→s→s] [1297.09ms] (pass) multi-hop redirect through proxy > https-proxy, 3-hop [h→h→h] [932.18ms] (pass) multi-ho ... (truncated) Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/bun/http/proxy-stress-protocol.test.ts | 34 ++++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) ``` </details> **gate history** · 1 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/bun/http/proxy-stress-protocol.test.ts 1 12 0 ``` </details> <!-- robobun:evidence:end -->
…33984) ## Problem `test/js/bun/http/proxy-stress-matrix.test.ts` went red on darwin 14 aarch64 in [build 71934](https://buildkite.com/bun/bun/builds/71934): ``` error: Unable to connect. Is the computer able to access the url? path: "https://localhost:60036/", errno: 0, code: "ConnectionRefused" ✗ response matrix > https-proxy → https-origin chunked/deflate 65536B keepalive=true 334 pass / 1 fail ``` The runner additionally labelled it "5 crashes reported", which skipped the automatic retry; those crashes are the intentional ones from `run-crash-handler.test.ts` and `native_plugin_test` earlier in the same shard that the crash-report collector misattributed (the traces carry `0xDEADBEEF` / `invoked crashByPanic() handler`). The single ConnectionRefused is the actual failure. ## Cause Same mechanism #33975 diagnosed for the sibling `proxy-stress-headers.test.ts`: 335 `test.concurrent` cases each create a fresh adversarial proxy + origin, so the file issues ~676 `listen(0, "127.0.0.1")` calls in <1s under the runner's rolling concurrency window. As early tests dispose their servers, a later test's `listen(0)` can be handed a just-freed port while a sibling is still mid-dial (the proxy's `net.connect(port, "localhost")` goes through autoSelectFamily, adding async hops between port capture and connect). #33975 left this file alone because it was not yet red in CI; now it is. ## Fix Share one `{http, https}` proxy pair from `beforeAll` across the 316 tests that use a default stateless proxy (response / upload / stream / method matrices). Tests that pass non-default proxy options or assert on the full `proxy.connections` array (trickled, split-CONNECT, CONNECT-headers, hop-by-hop, redirect) keep dedicated proxies. This drops per-file `listen(0)` churn from ~676 to ~362. The response matrix's per-test `proxy.connections` assertions are preserved: each test owns a unique origin port for the duration of its `await using` scope, so `ownConnections()` filters the shared proxy's append-only log by that port (sliced from the index captured before the fetch) to unambiguously recover this test's record under `test.concurrent`, and asserts exactly-one-connection / CONNECT-vs-GET / `http://` target on it as before. 335 tests, 1126 `expect()` calls: identical to main. Introduced by #32635; related sibling fix is #33975. ## Verification ``` bun bd test test/js/bun/http/proxy-stress-matrix.test.ts # 335 pass, 1126 expect() (debug+ASAN) bun bd test test/js/bun/http/proxy-stress-matrix.test.ts -t "trickled" # 2 pass, 333 filtered, 0 fail ``` 10 consecutive release runs on linux, all clean (1126 expect() each). [Build 71938](https://buildkite.com/bun/bun/builds/71938) ran this file clean on every darwin lane (14 aarch64 × 2 shards, 14 x64, 26 aarch64 × 2). <!-- robobun:evidence:begin --> --- **[stamp-90s]** gate passed · iteration 1 · 1 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/bun/http/proxy-stress-matrix.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/http/proxy-stress-matrix.test.ts info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: checking for self-update (current version: 1.29.0) bun test v1.4.0 (fb9f9ef) test/js/bun/http/proxy-stress-matrix.test.ts: (pass) response matrix > http-proxy → http-origin content-length/identity 128B keepalive=false [832.21ms] (pass) response matrix > http-proxy → http-origin content-length/identity 128B keepalive=true [790.98ms] (pass) response matrix > http-proxy → http-origin content-length/identity 65536B keepalive=false [790.77ms] (pass) response matrix > http-proxy → http-origin content-length/identity 65536B keepalive=true [792.29ms] (pass) response matrix > http-proxy → http-origin content-length/gzip 128B keepalive=false [889.55ms] (pass) response matrix > http-proxy → http-origin content-length/gzip 128B keepalive=true [390.04ms] (pass) response matrix > http-proxy → http-origin content-length/gzip 65536B keepalive=false [389.42ms] (pass) response matrix > http-proxy → http-origin content-length/gzip 65536B keepalive=true [389.57ms] (pass) response matrix > http-proxy → http-origin content-length/deflate 128B keepalive=false [387.09ms] (pass) response matrix > http-proxy → http-origin content-length/deflate 128B keepalive=true [335.56ms] (pass) response matrix > http-proxy → http-origin content-length/deflate 65536B keepalive=false [327.15ms] (pass) response matrix > http-proxy → http-origin content-length/deflate 65536B keepalive=true [326.00ms] (pass) response matrix > http-proxy → http-origin content-length/br 128B keepalive=false [325.91ms] (pass) response matrix > http-proxy → http-origin content-length/br 128B keepalive=true [326.40ms] (pass) response matrix > http-proxy → http-o ... (truncated) Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/bun/http/proxy-stress-matrix.test.ts | 44 +++++++++++++++++++++------- 1 file changed, 33 insertions(+), 11 deletions(-) ``` </details> **gate history** · 5 passed · 0 rejected · iteration 1 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/bun/http/proxy-stress-matrix.test.ts 4 9 0 ``` </details> <!-- robobun:evidence:end -->
…ead of resolving 'localhost' (#34049) ## Problem `test/js/bun/http/proxy-stress-matrix.test.ts` went red on darwin 26 aarch64 in [build 72283](https://buildkite.com/bun/bun/builds/72283): ``` error: expect(received).toBe(expected) Expected: 200 Received: 204 ✗ method matrix > OPTIONS via https-proxy → http-origin 334 pass / 1 fail ``` The runner tagged it "crash reported" (the intentional crashes from `run-crash-handler.test.ts` earlier in the shard, as diagnosed in #33984), which skipped the automatic retry. ## Cause Nothing in this test file ever emits 204, and the adversarial origin's `buildResponse` defaults to 200 for every cell of the method matrix. The 204 came from outside the test process. `createAdversarialOrigin` binds to `127.0.0.1` but hands out a `http(s)://localhost:${port}` URL (so that Host / SNI / `checkServerIdentity` assertions see a hostname). The test proxy parses the absolute-form target, pulls `host = "localhost"`, and dials `net.connect(port, "localhost")`. On the darwin boxes `localhost` resolves to `[::1, 127.0.0.1]` with `::1` first, and autoSelectFamily tries addresses in that order: ``` $ ssh darwin-test-arm64-3 "bun -e '...dns.lookup(\"localhost\",{all:true},...)'" [{"address":"::1","family":6},{"address":"127.0.0.1","family":4}] ``` IPv4 and IPv6 ephemeral-port spaces are independent, so an unrelated process on the bare-metal CI host (another buildkite agent, a system daemon) can be listening on `[::1]:P` at the exact moment our origin holds `127.0.0.1:P`. Verified directly on darwin-test-arm64-3: when both `[::1]:P` (returns 204) and `127.0.0.1:P` (returns 200) are bound, `net.connect(P, "localhost")` connects to `::1` and reads back 204. On linux the same script reads 200. This is the second half of the mechanism #33984 ("the proxy's `net.connect(port, 'localhost')` goes through autoSelectFamily, adding async hops") reduced but didn't eliminate: #33984 shared the proxy pair to cut ephemeral-port churn; each test still creates a fresh origin, and each origin dial still resolves `localhost`. Introduced by #32635. ## Fix Normalize `host = "localhost"` to `"127.0.0.1"` in `createAdversarialProxy`'s upstream dial. Every origin in the suite binds `127.0.0.1`; the IPv6-literal tests in `proxy-stress-protocol.test.ts` pass `[::1]` explicitly and are unaffected. The origin URL (and therefore the client's Host header, CONNECT target, SNI, and the `checkServerIdentity` host argument) remains `localhost`, so none of the hostname-shape assertions in `proxy-stress-adversarial.test.ts` / `proxy-stress-headers.test.ts` change. ## Verification ``` bun bd test test/js/bun/http/proxy-stress-matrix.test.ts # 335 pass (debug+ASAN) bun bd test test/js/bun/http/proxy-stress-adversarial.test.ts \ test/js/bun/http/proxy-stress-headers.test.ts \ test/js/bun/http/proxy-stress-protocol.test.ts # 329 pass bun bd test test/js/bun/http/proxy-stress-errors.test.ts \ test/js/bun/http/proxy-stress-lifecycle.test.ts \ test/js/bun/http/proxy-stress-concurrent.test.ts # 177 pass ``` 15 consecutive release runs of `proxy-stress-matrix.test.ts`, all 335/335. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 1 · docs-only change; test-proof not applicable <!-- robobun:evidence:end -->
|
The |
…'t steal it (#35269) ## Problem `test/js/bun/http/proxy-stress-errors.test.ts` went red in [build 78599](https://buildkite.com/bun/bun/builds/78599) (debian 13 x64), [build 78501](https://buildkite.com/bun/bun/builds/78501) (debian 13 aarch64), and [build 77839](https://buildkite.com/bun/bun/builds/77839) (alpine 3.23 x64). Each sighting is a different "upstream unreachable via proxy" or "proxy authentication" subtest getting the wrong result: | build | test | expected | got | |---|---|---|---| | 77839 | `http-proxy, absolute-form upstream refused → 502` | 502 | 200 | | 77839 | `https-proxy → http-origin: wrong auth → 403` | origin not reached | `origin.requests.length == 1` | | 78501 | `https-proxy, CONNECT upstream refused → 502` | 502 | 407 | | 78599 | `http-proxy, absolute-form upstream refused → 502` | 502 | ECONNRESET | Each build's annotation also carries `1 crashes reported during this test`; those traces are `panic: Failed to start File Watcher: EAGAIN` from `src/jsc/hot_reloader.rs`, which this test cannot reach (it spawns no subprocess, uses no watcher). They are the shard-level crash-attribution issue already diagnosed in #33984/#34049 and only serve to suppress the automatic retry, promoting the flake to a hard fail. ## Cause `deadPort()` binds `127.0.0.1:0`, closes the server, and returns the port for the caller to dial expecting `ECONNREFUSED`. The doc comment says "nothing reuses it in the microseconds before the caller dials", which is false under `test.concurrent`: the file's 53 concurrent tests each issue one to three `listen(0, "127.0.0.1")` calls of their own, and the kernel's ephemeral allocator happily satisfies one of them with the port `deadPort()` just released. Mechanically: ``` $ bun -e '... oldDeadPort() then 20× listen(0), 500 trials ...' old deadPort: port grabbed by subsequent listen(0) in 3 / 500 trials ``` That explains every observed value: 200 is a concurrent `createAdversarialOrigin` answering; 407 is a concurrent `createAdversarialProxy({ tls: true, auth })` answering (the test proxy's upstream TCP connect succeeds, it replies 200 Connection Established, the client's inner TLS handshake completes against the auth proxy, which then 407s the GET); ECONNRESET is a concurrent TLS server receiving plaintext absolute-form bytes; and `origin.requests.length == 1` is this test's origin being another test's "dead" port. Introduced by #32635 (the file has always paired `deadPort()` with `test.concurrent`); same family as the ephemeral-port races #33975/#33984/#34049 fixed in the sibling files. ## Fix Hold the port as the local side of a live TCP connection for the lifetime of the test: bound, so `listen(0)` will not be handed it, and not listening, so `connect()` to it still gets `ECONNREFUSED`. `deadPort()` now returns a disposable and the three call sites become `using dead = await deadPort()`. ``` $ bun -e '... newDeadPort() held, 2000× listen(0) ...' listen(0) collisions with held port out of 2000: 0 connect to held port: error:ECONNREFUSED ``` ## Verification ``` # before (release bun, flake-prone subset -t "unreachable|authentication|unsupported proxy scheme") 6 / 200 runs had a failure # after (same command) 0 / 200 runs had a failure # full file, 100 release runs: stable # bun bd (debug+ASAN), 10 runs: 53 pass / 0 fail / 132 expect() each ``` All six sibling `proxy-stress-*.test.ts` files (788 tests) pass unchanged. <!-- robobun:evidence:begin --> --- **[stamp-90s]** gate passed · iteration 1 · 2 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/bun/http/proxy-stress-errors.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/http/proxy-stress-errors.test.ts bun test v1.4.0 (1e135bf) test/js/bun/http/proxy-stress-errors.test.ts: (pass) CONNECT failure status > http-proxy CONNECT → 400 is surfaced as-is [608.07ms] (pass) CONNECT failure status > http-proxy CONNECT → 403 is surfaced as-is [357.19ms] (pass) CONNECT failure status > http-proxy CONNECT → 407 is surfaced as-is [353.15ms] (pass) CONNECT failure status > http-proxy CONNECT → 500 is surfaced as-is [443.44ms] (pass) CONNECT failure status > http-proxy CONNECT → 502 is surfaced as-is [445.88ms] (pass) CONNECT failure status > http-proxy CONNECT → 503 is surfaced as-is [281.49ms] (pass) CONNECT failure status > http-proxy CONNECT → 504 is surfaced as-is [279.92ms] (pass) CONNECT failure status > https-proxy CONNECT → 400 is surfaced as-is [341.27ms] (pass) CONNECT failure status > https-proxy CONNECT → 403 is surfaced as-is [316.94ms] (pass) CONNECT failure status > https-proxy CONNECT → 407 is surfaced as-is [316.58ms] (pass) CONNECT failure status > https-proxy CONNECT → 500 is surfaced as-is [347.12ms] (pass) CONNECT failure status > https-proxy CONNECT → 502 is surfaced as-is [351.66ms] (pass) CONNECT failure status > https-proxy CONNECT → 503 is surfaced as-is [386.69ms] (pass) CONNECT failure status > https-proxy CONNECT → 504 is surfaced as-is [369.56ms] (pass) CONNECT failure status > CONNECT → 301 with Location is not followed [386.12ms] (pass) CONNECT failure status > CONNECT → 302 with Location is not followed [575.16ms] (pass) proxy unreachable > proxy port refused, http origin [449.75ms] (pass) proxy unreachable > proxy port refused, https origin [437.50ms] (pass) CONNECT failure status > https-proxy CONNECT → 101 fails even when the request asked to upgrade [614.67ms] (pass) CONNECT failure status > http-proxy CONNECT → 101 fails even when the request asked to upgrade [718.76ms] ... (truncated) Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/bun/http/proxy-stress-errors.test.ts | 12 +++---- test/js/bun/http/proxy-stress-helpers.ts | 49 +++++++++++++++++++++------- 2 files changed, 43 insertions(+), 18 deletions(-) ``` </details> **gate history** · 2 passed · 0 rejected · iteration 1 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/bun/http/proxy-stress-errors.test.ts 1 3 0 test/js/bun/http/proxy-stress-helpers.ts 2 4 0 ``` </details> <!-- robobun:evidence:end -->
What
Comprehensive stress testing of the HTTP client proxy code paths (fetch and WebSocket, both
ProxyTunnelandWebSocketProxyTunnel): 661 tests across 5 new files plus a shared adversarial proxy/origin helper and a subprocess memory-probe fixture.proxy-stress-helpers.tsproxy-stress-matrix.test.ts{http,https}proxy ×{http,https}origin × framing × encoding × body-size × keepalive response matrix; upload matrix across string/Uint8Array/Blob/FormData/ReadableStream/async-iterator; streamed response viagetReader(); trickled 1-byte-per-tick downstream; split CONNECT envelope; RFC 9110 §9.3.6 ignored-header handling; hop-by-hop stripping; method matrix; cross-scheme redirects.proxy-stress-lifecycle.test.tsproxy-stress-errors.test.tsproxy-stress-concurrent.test.tsreject_unauthorizedpool gate (lax → strict forces fresh CONNECT); 4× concurrent 4MB echo; idle pooled tunnel receiving stray data is evicted; 12 subprocess memory probes (6 modes × 2 proxy schemes, 300-1200 iterations each, RSS-growth bounded).proxy-stress-adversarial.test.tscheckServerIdentityapprove/reject/GC-loop; path/query edges;verbose:true;AbortSignal.timeout; interleaved proxy/direct to same origin; CONNECT target shape; WebSocket proxy matrix (ws/wss × http/https proxy) with echo, 256KB binary, RST at CONNECT, 407 without auth, rapid close, wss-via-https-proxy open/close churn under GC.Full suite runs in ~5 min under debug+ASAN.
Bugs found and fixed
Two pre-existing bugs surfaced by the suite (both reproduce on main without any test-helper changes). Fixed here in
src/http/lib.rs.1. Streamed request body through a CONNECT tunnel never sent
fetch()to anhttps://origin through any proxy with aReadableStream/ async-iterator body hangs forever: CONNECT succeeds, inner TLS handshake completes, theTransfer-Encoding: chunkedrequest head is written, then nothing. String/Uint8Array/Blob/FormData bodies are fine;http://origins (absolute-form, no tunnel) are fine.Cause (two layers):
RequestStage::ProxyHeadersarm ofon_writablecomputeshas_sent_body = self.request_body().is_empty(). ForHTTPRequestBody::Streamthe bytes buffer is always empty here, so the request jumps straight toRequestStage::Doneand theis_streaming_request_bodysignal path a few lines below never runs. The non-proxysend_initial_request_payloadalready gates this onmatches!(original_request_body, HTTPRequestBody::Bytes(_)).ProxyBodyis reached, itsStreamcase callsflush_stream→write_to_stream_using_buffer→write_to_socket(socket, …), which writes plaintext chunked bytes to the outer proxy socket instead of through the inner TLS session. TheBytescase right next to it correctly routes throughProxyTunnel::write.Fix: mirror the non-proxy path's
Bytes-onlyhas_sent_bodycheck (and matchingdebug_assert) in theProxyHeadersarm; inwrite_to_stream_using_buffer, route throughProxyTunnel::writewhenself.proxy_tunnel.is_some(), treatingWantRead/WantWritefrom the inner SSL as backpressure. Encrypted output reaches the outer socket via the existingwrite_encryptedcallback, same as theBytespath. This also covers theHTTPThreaddrain loop which calls the sameflush_stream.2.
heap-use-after-freeinHTTPClient::on_writablewhen a TLS alert is buffered with the inner handshake flightIf the origin closes or sends a TLS alert in the same buffer as its ServerHello flight (origin rejecting client cert, corrupted stream via misbehaving proxy, origin crash), the client's
on_handshake → on_writable → proxy.on_writable → SSLWrapper::flush → handle_readingchain processes the alert, fireson_close → close_and_fail, which runs the result callback and frees theThreadlocalAsyncHTTPembedding*self. Control returns toon_writable, which immediately readsself.state.flags.is_waiting_for_cert_checkon freed memory. Same bug class already documented instart_proxy_handshake's comment.Fix:
close_and_fail→terminate_socketsynchronously marks the outer socket closed, and the socket handle is owned by the event loop (outlives the client). Checksocket.is_closed()immediately afterproxy.on_writable()and return before touchingself.Deterministic ASAN repro in
proxy-stress-lifecycle.test.ts("TLS alert in same buffer as inner handshake"): a CONNECT proxy that double-writes every client→upstream byte, making the origin's TLS stack abort with an alert. Looped 20× withASAN_OPTIONS=…:abort_on_error=1so the HTTP-thread UAF aborts the subprocess before the main thread's clean exit wins the race.Verification
Fail-before (src/ stashed,
bun bd):proxy-stress-matrix.test.ts -t "https-origin POST ReadableStream|https-origin POST async-iterator"→ 8 fail (hang/timeout)proxy-stress-lifecycle.test.ts -t "TLS alert in same buffer"→ 1 fail (subprocess aborts withheap-use-after-free)Existing
test/js/bun/http/proxy.test.ts(48 tests) still passes.Related
SSLWrappercallback chain.ProxyTunnelclose path but only in the.zigreference files.