Skip to content

test(proxy-stress): return deadPort()'s bind(0) port, not the connect-autobound one - #35400

Open
robobun wants to merge 2 commits into
mainfrom
farm/4b278172/proxy-stress-deadport-selfconnect
Open

test(proxy-stress): return deadPort()'s bind(0) port, not the connect-autobound one#35400
robobun wants to merge 2 commits into
mainfrom
farm/4b278172/proxy-stress-deadport-selfconnect

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

proxy-stress-errors.test.ts flaked in CI on 5461e9d with:

error: expect(received).toMatch(expected)
Expected substring or pattern: /ECONNREFUSED|ConnectionRefused/
Received: "Malformed_HTTP_Response"
✗ proxy unreachable > proxy port refused, https origin

Reproduced locally at 1/2000 full-file runs.

Cause

#35269 changed deadPort() to hold a live TCP connection and return holder.address().port, the client-side local port, so a concurrent listen(0) can't steal it. That works for listen(0), but the port was allocated by connect(), which gives its inet_bind_bucket the fastreuse == -1 marker. Linux's connect-time port allocator (__inet_hash_connect) only checks 4-tuple uniqueness for such ports, so a later connect(127.0.0.1:P) can itself be assigned source port P. On loopback that is a TCP self-connect: the fetch client's proxy connection succeeds, it writes CONNECT host:port HTTP/1.1..., reads those same bytes back, and picohttp reports Malformed_HTTP_Response.

A standalone net.connect probe against the current deadPort() hits this in ~3/30000 attempts.

Fix

Return the server-side (sink) port instead. It was allocated by bind(0), so its bucket has fastreuse >= 0 and __inet_hash_connect skips it entirely, ruling out self-connect. The accepted socket (kept alive after server.close()) keeps the port in the bind hash without SO_REUSEADDR, so listen(0) still can't pick it either. Also wait for the server's 'connection' event before closing it so accepted is guaranteed to exist.

Verification

# before
full file, release:                   1 / 2000 runs hit Malformed_HTTP_Response
net.connect probe:                    3 / 30000 self-connects

# after
full file, release:                   0 / 2000 runs
-t "unreachable|proxy port refused":  0 / 3000 runs
net.connect probe:                    0 / 30000 self-connects
listen(0) churn alongside probe:      0 / 30000 collisions
bun bd (ASAN), all proxy-stress-*:    841 pass / 0 fail

no test proof · iteration 1 · docs-only change; test-proof not applicable

…-autobound one

#35269 made deadPort() hold a live TCP connection and return the
client-side local port so a concurrent listen(0) couldn't steal it.
That port is allocated by connect() (inet_bind_bucket fastreuse == -1),
which Linux's __inet_hash_connect will happily reassign as the source
port of a later connect() to a different 4-tuple. When that later
connect() is to the dead port itself on 127.0.0.1, source == dest and
the socket self-connects; the fetch client then reads back its own
CONNECT request line and fails with Malformed_HTTP_Response.

Return the server-side port instead: it was allocated by bind(0), so
its bucket has fastreuse >= 0 and __inet_hash_connect skips it outright.
The listen(0) allocator still skips it because the accepted socket
(which stays established after server.close()) keeps it in the bind
hash without SO_REUSEADDR.

before: 1/2000 full-file runs, net.connect probe 3/30000
after:  0/5000 runs, probe 0/30000 (+ 0/30000 listen(0) collisions)
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 38668a3d-1eaa-4ea9-9371-2f7ab039a876

📥 Commits

Reviewing files that changed from the base of the PR and between 50bb3bd and 9dcd231.

📒 Files selected for processing (1)
  • test/js/bun/http/proxy-stress-helpers.ts

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

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:50 AM PT - Jul 24th, 2026

@robobun, your commit 9dcd231e5bedcaf93139c6a0e0164f0dd5c2f641 passed in Build #79459! 🎉


🧪   To try this PR locally:

bunx bun-pr 35400

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

bun-35400 --bun

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

LGTM — targeted test-helper fix for a well-diagnosed 1/2000 flake.

What was reviewed:

  • Traced the port-source swap: sink's bind(0) port is returned instead of holder's connect-autobound port; after sink.close() nothing is listening, so callers still get ECONNREFUSED as expected.
  • Checked that await acceptedP.promise guarantees accepted is assigned before both sink.close() and [Symbol.dispose](), so the accepted?.destroy()accepted.destroy() change is safe.
  • Verified all three call sites in proxy-stress-errors.test.ts only need a refused-connection port and don't depend on which side of the pair it came from.
Extended reasoning...

Overview

This PR modifies a single test helper, deadPort() in test/js/bun/http/proxy-stress-helpers.ts, which produces a 127.0.0.1 port guaranteed to refuse connections for the duration of a test. The prior implementation (from #35269) returned the client-side local port of a held loopback connection. That port was allocated by connect()'s autobind, whose inet_bind_bucket gets fastreuse == -1; Linux's __inet_hash_connect will still consider such a port for a later outgoing connect()'s source port, and on loopback that can produce a TCP self-connect (~1/10000). The fetch client then reads back its own CONNECT line and fails with Malformed_HTTP_Response instead of ECONNREFUSED, flaking proxy-stress-errors.test.ts.

The fix returns the server-side bind(0) port instead, keeps the accepted socket alive so the port stays in the bind hash (blocking both listen(0) and connect-autobind reuse), and adds an explicit await on the server's 'connection' event so accepted is guaranteed assigned before sink.close() and before dispose. The doc comment is rewritten to explain both why the sink port is safe and why the holder port is not.

Security risks

None. Test-only infrastructure; no production code, no external inputs, no auth/crypto.

Level of scrutiny

Low. This is a ~20-line change to a test helper, fixing a rare flake with a precise kernel-level root cause and strong empirical verification (0/2000 full-file runs, 0/30000 connect probes, 0/30000 listen-churn collisions, 841 pass under ASAN). The change preserves the helper's contract (port refuses connections, held until dispose) — I confirmed the three consumers in proxy-stress-errors.test.ts only rely on that contract.

Other factors

  • The let accepted!: net.Socket definite-assignment + unconditional accepted.destroy() is sound: await acceptedP.promise runs before the DeadPort object is returned, so dispose can never see it unassigned. This also closes a theoretical race in the old code where sink.close() could fire before the connection callback.
  • Other deadPort/getDeadPort helpers in the repo (dgram.test.ts, websocket-utf16-headers.test.ts) are independent implementations and not affected.
  • No prior human review comments; CI build was still in progress at review time but this is a de-flake that strictly tightens the helper's guarantees.

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

LGTM — targeted test-helper flake fix, no production code touched.

What was reviewed:

  • deadPort() now returns the bind(0) server port instead of the connect()-autobound client port; the kernel-level rationale (Linux __inet_hash_connect / fastreuse → loopback self-connect) checks out.
  • The new acceptedP await closes the race where sink.close() could fire before the server-side 'connection' handler assigned accepted, so the accepted! definite-assignment and non-optional accepted.destroy() are safe.
  • Checked all callers of this deadPort (proxy-stress-errors.test.ts) — they only read .port and dispose, so the return-shape is unchanged; the identically-named helpers in websocket-utf16-headers/dgram tests are separate local functions.
Extended reasoning...

Overview

This PR modifies a single test-helper function, deadPort() in test/js/bun/http/proxy-stress-helpers.ts, to fix a ~1/2000 CI flake in proxy-stress-errors.test.ts. The previous implementation (from #35269) returned the client-side local port of a held TCP connection; on Linux, ports allocated by connect() get fastreuse == -1 and remain eligible for reuse by __inet_hash_connect, so a later connect(127.0.0.1:P) could be assigned source port P and self-connect on loopback — the fetch client would then read back its own CONNECT line and fail with Malformed_HTTP_Response instead of ECONNREFUSED. The fix returns the server's bind(0) port instead (whose bucket has fastreuse >= 0 and is skipped by both allocators), and keeps the accepted socket alive after server.close() so the port stays bound-but-not-listening.

Security risks

None. This is test-infrastructure-only code with no production surface.

Level of scrutiny

Low. The change is confined to one helper in a test support file, is ~15 net lines, and comes with detailed empirical verification (0/2000 full-file runs, 0/30000 self-connect probes, 0/30000 listen(0) collisions, 841 pass on ASAN). The mechanism is well-explained and matches known Linux TCP behavior.

Other factors

I verified the two additional correctness details in the diff: (1) awaiting acceptedP.promise before sink.close() guarantees accepted is assigned, so switching from let accepted: net.Socket | undefined + accepted?.destroy() to let accepted!: net.Socket + accepted.destroy() cannot NPE; (2) the return signature ({ port, [Symbol.dispose] }) is unchanged and all three call sites in proxy-stress-errors.test.ts only read .port and rely on using disposal. The doc comment was rewritten to record why the client-side port must not be used, satisfying the repo's "durable non-obvious content" comment guideline.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: proxy-stress-errors.test.ts (the only consumer of this change) passed on every lane in both build 79434 and build 79459. The remaining red lanes are all tagged [flaky]/[pre-existing] by the CI tooling and are unrelated to this test-helper-only diff:

  • build 79434: worker-transfer-terminate-stress (JSC exception-scope SIGABRT, pre-existing), complex-workspace, hot.test.ts, bun-install-registry
  • build 79459: complex-workspace, watch-many-dirs, cpu-prof, test-fastutf8stream-reopen, bun-install-patch, webview-chrome

Ready to merge.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Independent data point for this fix. While hardening the fixture from #39621, which copied the current deadPort() construction, the same self-connect showed up: a net.connect holder's local port used as the dead port self-connected 2 times in 60000 connects, and a Bun.connect holder's 9 times in 120000, on Linux 6.17 with the default port range. A port from bind() (measured with localAddress on the holder, which has the same effect as returning the sink's port as this PR does) self-connected 0 times in 180000. #39659 first changed this helper as well and now leaves it to this PR, so the two do not conflict.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants