Skip to content

test(serve): fix darwin flake in the respond-without-reading-body backpressure test - #36088

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/75d3ce58/serve-backpressure-darwin-fix
Jul 28, 2026
Merged

test(serve): fix darwin flake in the respond-without-reading-body backpressure test#36088
Jarred-Sumner merged 2 commits into
mainfrom
farm/75d3ce58/serve-backpressure-darwin-fix

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

serve.test.ts has been red on the darwin lanes since #36072 landed (main #83238, also the PR's own build #83229 as a retry-flake). The subtest is request body backpressure > releases a paused request body when the handler responds without reading it, which #36072 added to cover the detach_response() resume at RequestContext.rs:2382.

EPIPE: broken pipe, write
 syscall: "write",
   errno: -32,
    code: "EPIPE"
✗ request body backpressure > releases a paused request body when the handler responds without reading it

Cause

The test sends a 32 MiB PUT over a raw net.Socket with Connection: close, lets the server's pre-stream pause stall the upload, then has the handler respond without touching req.body and waits for the client to receive the response.

Once the handler returns, render_bytes() writes the response via try_end() and uWS does shutdown(SHUT_WR) immediately followed by close() (HttpResponse.h:860-871). Because the socket was paused, the unread body bytes are still sitting in the kernel recv buffer, and close() with unread recv data sends RST. On macOS that RST races two things:

  1. the upload pump's sock.once('drain', writeMore), which is still armed from pumpUploadUntilPlateau; when it fires and writes into the reset peer the socket emits EPIPE, which the test's sock.once('error', reject) turns into a failure, and
  2. delivery of the already-written response bytes, which the RST can drop before the client's 'data' handler sees them.

On Linux the 'data' event reliably wins the race, so the test passed there; on darwin it loses ~70-80% of the time (verified on darwin-test-arm64-3 and darwin-test-x64-2 with the main #83238 binary).

detach_response()'s resp.resume() is also a no-op on this path: for a simple text body try_end() has already closed the socket by the time it runs (us_socket_resume early-returns on a closed socket). So with Connection: close the test was not actually observing the code it was written to cover.

Fix

Switch this one test to Connection: keep-alive. The server does not close after responding, so there is no RST, the response is delivered deterministically, and detach_response()'s resume runs on a live socket. The test now additionally asserts that the client's upload progresses past the plateau after the response, which is the direct observable of the resume (without it the keep-alive socket would stay paused and getSent() would never move).

Also: resolve the response promise on the body content rather than the header terminator, and drop the sock.once('error', reject); neither race applies on a keep-alive connection but both were latent flake on the old path. The pumpUploadUntilPlateau helper gains a connection parameter (defaulting to "close", so the other five callers are untouched) and returns a getSent() accessor.

Verification

With the main #83238 bun binary:

lane before after
darwin 26 aarch64 (darwin-test-arm64-3) 7/10 fail (EPIPE) 50/50 pass
darwin 14 x64 (darwin-test-x64-2) 8/10 fail (EPIPE) 20/20 pass
linux x64 debug+ASAN 10/10 pass 10/10 pass

USE_SYSTEM_BUN=1 bun test test/js/bun/http/serve.test.ts -t 'releases a paused' still fails on toBeLessThan(TOTAL) (no #36006 backpressure), so the test still exercises the same src/ change. This is a test-only diff; fail-before is against a Bun without #36006, not against this PR's own src/.


no test proof · iteration 0 · Platform-specific test-only change; deferring to CI.

…kpressure test

The test added in #36072 used Connection: close. Once the handler
responds without reading the body, uWS does shutdown(SHUT_WR) + close()
back-to-back, and close() with unread body bytes in the kernel recv
buffer sends RST on macOS. That RST races the still-armed 'drain'
writeMore (EPIPE into sock.once('error', reject)) and can drop the
response before the client reads it. 8/10 fail on darwin 14 x64 and
darwin 26 aarch64.

Switch to keep-alive so the server keeps the socket open after
responding; detach_response()'s resume then observably lets the upload
pump progress past the plateau, which is what the test was written to
cover. Also resolve on the body content (not the header terminator) and
drop the error-reject since no EPIPE is expected on this path.

50/50 pass on darwin aarch64, 20/20 on darwin x64, 10/10 on linux
debug+ASAN; still fails on a bun without #36006 (sentBeforeGate ==
TOTAL).
@coderabbitai

coderabbitai Bot commented Jul 27, 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: 8 minutes

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: bfc423f3-11bd-4f90-88bf-f4da0b84cc57

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and 1057b9f.

📒 Files selected for processing (1)
  • test/js/bun/http/serve.test.ts

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

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on darwin-test-arm64-3 and darwin-test-x64-2 with the main #83238 binary: the current test fails 7-8/10 with EPIPE, the rewritten keep-alive test passes 50/50 and 20/20 respectively. bun bd test test/js/bun/http/serve.test.ts -t 'request body backpressure' is 6/6 on linux debug+ASAN. The diff is test-only; fail-before is against a Bun without #36006's backpressure (sentBeforeGate == TOTAL), which USE_SYSTEM_BUN=1 still hits.

Self-review: approve; the one concern (unbounded resume poll making the expect vacuous) was addressed in 1057b9f with a 2s deadline.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun.serve does not drain-or-close a keep-alive connection when the handler responds before reading the full request body #35283 - This issue describes Bun.serve not draining or closing a keep-alive connection when the handler responds without reading the full request body — exactly the behavior this test validates. The test fix (switching to Connection: keep-alive and asserting detach_response()'s resp.resume() runs) directly exercises this scenario.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #35283

🤖 Generated with Claude Code

@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 — test-only de-flake with a well-traced root cause (RST-on-close-with-unread-recv-data racing the response on darwin) and verified 50/50 + 20/20 on the previously-failing lanes. The inline nit about the unbounded poll is worth addressing but doesn't block: a regression there still fails via test timeout.

Extended reasoning...

Overview

This PR touches a single test in test/js/bun/http/serve.test.ts — the releases a paused request body when the handler responds without reading it case in the request body backpressure suite added by #36072, which has been flaking on darwin lanes since it landed. The shared pumpUploadUntilPlateau helper gains a connection parameter (defaulting to "close", so the five other callers at lines 3357/3407/3447/3488 are unaffected) and returns a getSent() closure. The flaky test switches to Connection: keep-alive, drops the sock.once('error', reject) (a no-op error handler is already installed by the helper at line 3283), resolves the response promise on the body sentinel instead of the header terminator, and adds a post-response poll asserting the upload progressed past the plateau — the direct observable of detach_response()'s resp.resume().

Security risks

None. Test-only diff, no src/ changes, no new external inputs.

Level of scrutiny

Low-to-moderate. This is a targeted CI de-flake with a mechanistic root-cause writeup: uWS try_end()shutdown(SHUT_WR)close() with unread body bytes in the kernel recv buffer sends RST on macOS, which races both the still-armed drain pump (EPIPE) and delivery of the already-written response. Switching to keep-alive removes the close, so no RST, and lets the resume run on a live socket — which the PR notes was a no-op on the old close path anyway (us_socket_resume early-returns on a closed fd). The verification table (7-8/10 fail → 50/50 and 20/20 pass on the actual darwin runners, still green on linux debug+ASAN, still fails under USE_SYSTEM_BUN=1) is exactly what REVIEW.md asks for.

Other factors

The one finding is a nit: the expect(getSent()).toBeGreaterThan(sentBeforeGate) at line 3551 is tautological given the unbounded while (getSent() === sentBeforeGate) poll immediately before it — sent is strictly increasing, so the loop only exits when the assertion already holds, and a regression manifests as a hang → test-runner timeout rather than a failing expect. Bounding the poll with a deadline would make the assertion load-bearing per REVIEW.md's "poll a bounded window" / "every assertion must be able to fail", but the test still catches the regression either way. I also confirmed the other five pumpUploadUntilPlateau callers pass three positional args and so keep Connection: close, and that dropping sock.once('error', reject) cannot leave an unhandled error because the helper installs sock.on('error', () => {}) before returning.

Comment thread test/js/bun/http/serve.test.ts Outdated

@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 — test-only de-flake with a clear root cause, and the bounded-poll nit from the earlier pass is addressed in 1057b9f.

What was reviewed:

  • pumpUploadUntilPlateau's new connection param defaults to "close", so the other callers in this describe block are unchanged.
  • getSent() closure correctly observes post-return mutations of sent via the still-armed sock.once('drain', writeMore).
  • Dropping sock.once('error', reject) doesn't leave an unhandled-error path — the helper already installs sock.on('error', () => {}), and 'close' still resolves the response promise.
  • The 2s deadline on the post-response poll makes expect(getSent()).toBeGreaterThan(sentBeforeGate) the load-bearing assertion for the resume.
Extended reasoning...

Overview

Test-only change to one subtest in test/js/bun/http/serve.test.ts (the request body backpressure > releases a paused request body when the handler responds without reading it case added by #36072), plus two backward-compatible additions to the shared pumpUploadUntilPlateau helper: a connection parameter defaulting to "close" and a getSent() accessor in the return object. No src/ changes.

The de-flake mechanism is well-argued in the PR description (RST-on-close-with-unread-recv-data racing the response delivery on darwin) and matches the referenced uWS try_endshutdown+close sequence. Switching to Connection: keep-alive removes the RST race and, as a bonus, makes the test actually observe detach_response()'s resp.resume() on a live socket — the new bounded poll + toBeGreaterThan(sentBeforeGate) assertion is the direct observable of that.

Security risks

None. Test-only; no user-facing surface, no parsing of untrusted input, no network egress beyond localhost.

Level of scrutiny

Low. This is a targeted flake fix in test code, verified by the author on both failing darwin lanes (50/50 and 20/20 pass) and on linux debug+ASAN (10/10). The USE_SYSTEM_BUN=1 fail-before check confirms the test still exercises the same #36006 backpressure invariant. The helper's default parameter keeps the other five callers byte-identical in behavior.

Other factors

My previous review flagged the unbounded poll making the expect vacuous; the author addressed it in 1057b9f with a 2s deadline, and the thread is resolved. I checked that dropping the 'error' reject handler cannot produce an unhandled error event (the helper already swallows socket errors at line 3283) and that the response promise still has a terminal path via 'close'. The getSent() closure works because writeMore remains armed on 'drain' after the plateau helper returns, so sent continues to mutate once the server resumes reading. 2s is generous for a single drain-triggered write even under debug+ASAN.

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