Skip to content

test(fetch): deflake the textStream() drop-after-empty-decode case - #40947

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/95e04753/deflake-textstream-econnreset
Aug 30, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/95e04753/deflake-textstream-econnreset

Conversation

@robobun

@robobun robobun commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/web/fetch/body.test.ts > "rejects a fetch textStream() when the connection drops after an empty decode" fails in the parallel CI batch with received: "" instead of "A". The error code is the expected ECONNRESET. It passes when it runs alone.
  • The test server writes "A", 0xF0, 0x9F (one setImmediate apart) and then destroys the socket. Under load, the HTTP thread sees all of that before the JS thread has read anything. FetchTasklet::callback coalesces the updates into one progress update with fail set, and to_body_value (src/runtime/webcore/fetch/FetchTasklet.rs:1707) or on_body_received (:740) turns the body into an errored stream. The buffered "A" is discarded by design.

Fix

  • The server now waits until the client has consumed the first chunk before it writes the incomplete UTF-8 tail and drops the socket. A Promise.withResolvers resolved from the for await body is the handshake. A finally resolves it too, so the server still closes the socket if the stream ends early.
  • The drop still lands after the empty decodes (0xF0, 0x9F decode to nothing), which is what the test guards: the stall fixed in fetch: keep textStream() pulling when a native chunk decodes to nothing #36180 surfaced as a hang, not a rejection.
  • Verified: 24 parallel loops of the old test body failed 1400 of 3600 iterations. The new body: 0 of 3600. With bun test under the same load: old 32 of 192 failures, new 0 of 192 (release) and 0 of 60 (debug build). The whole file passes with bun bd test.

Background

  • res.textStream() on a fetch response is a native ByteStream source in text mode. The HTTP thread delivers body bytes and the terminal result to the JS thread through FetchTasklet.
  • FetchTasklet::callback posts at most one task at a time (has_schedule_callback). Results that arrive before the JS thread runs that task are merged: body bytes are appended to scheduled_response_buffer, and the latest fail or has_more wins.
  • When the merged update carries the response head and a failure, the body is BodyValue::Error and the bytes are dropped. When it carries a failure after the head was already delivered, ByteStream::on_data(Err) rejects the pending pull or, with no pull pending, append(Err) clears the buffer (fetch: release the buffered response body and error the reader when a streaming response is aborted #32662). Whether the consumer sees the bytes before the error depends on timing.
Notes
  • Repro: /tmp/repro-textstream.ts style loop (the test body, 150 iterations) run 24 times in parallel on a 16-core box. Failure rate 24% to 59% per process, all with received: "" and code: "ECONNRESET". The same loop with the handshake: 0 failures in 3600 iterations.
  • The single-process loop (no load) passes 300 of 300 with the old body, which is why the test passes alone.
  • The other cases in the same test.each table end with a clean 0\r\n\r\n. A coalesced successful end delivers the bytes (TemporaryAndDone), so they are not affected.
  • The data-then-error order is timing dependent in bun for any streaming fetch that fails mid-body. Browsers deliver the bytes received before the error first. This PR does not change that behavior. It only removes the dependence from the test.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/body.test.ts

The server now waits until the client has consumed the first chunk
before it writes the incomplete UTF-8 tail and destroys the socket.
Under load, the HTTP thread could see all three chunks and the drop
before the JS thread read anything. FetchTasklet coalesces them into
one progress update with the failure set, the body becomes an errored
stream, and the test saw received="" instead of "A".
@robobun

robobun commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:19 AM PT - Aug 30th, 2026

❌ @robobun, your commit 3a91639 has 1 failures in Build #108455 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 40947

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

bun-40947 --bun

@coderabbitai

coderabbitai Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f1a48ee0-4cc1-4cd0-bbb3-0e9d6f6a530e

📥 Commits

Reviewing files that changed from the base of the PR and between 2846500 and 3a91639.

📒 Files selected for processing (1)
  • test/js/web/fetch/body.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

Changes

Fetch stream reset handling

Layer / File(s) Summary
Synchronize stream consumption and connection reset
test/js/web/fetch/body.test.ts
The test waits for chunk "A" before sending incomplete UTF-8 bytes and resetting the connection. It preserves the expected ECONNRESET and received content checks. The synchronization signal resolves during iteration and cleanup.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to 3a916

This change only stabilizes a fetch streaming test by coordinating when the server sends the incomplete UTF-8 tail and closes the connection; it does not alter product behavior. No actionable merge-blocking risk remains beyond normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the fetch textStream() test and the deflake change for the connection-drop case.
Description check ✅ Passed The description explains the failure, the handshake-based fix, the preserved test behavior, and verification results. It does not use the template headings exactly, but it provides the required inform…
Full details: Description check

Explanation

The description explains the failure, the handshake-based fix, the preserved test behavior, and verification results. It does not use the template headings exactly, but it provides the required information and is sufficiently complete.


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

@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 — focused test-only deflake that swaps a timing race for an explicit handshake without weakening the assertion.

What was reviewed:

  • Confirmed the assertion ({ code: "ECONNRESET", received: "A" }) is unchanged, so the property guarded by the original test is preserved.
  • Checked for hang risk: the finally resolves consumedFirstChunk on every exit, so the server callback always reaches sock.destroy() and await using server can dispose; repeated .resolve() calls are no-ops.
  • Verified the pattern matches the repo's "await the condition, not time" rule — no sleep/setTimeout, Promise.withResolvers() handshake as endorsed in test/CLAUDE.md.
Extended reasoning...

Overview

This PR touches a single test in test/js/web/fetch/body.test.ts — the "rejects a fetch textStream() when the connection drops after an empty decode" case. The old version wrote three chunked bytes back-to-back (separated only by setImmediate in writeChunk) and immediately destroyed the socket, which under CI load let the HTTP thread coalesce the body bytes and the failure into one progress update, discarding "A" before the client observed it. The fix introduces a Promise.withResolvers() handshake: the server writes "A", awaits the client's confirmation from inside the for await loop, then writes the two partial-UTF-8 bytes and destroys. A finally block resolves the handshake unconditionally so the server callback can always proceed to sock.destroy() even if the stream ends or errors early. No runtime code is touched.

Security risks

None. This is a test-only change to a local in-process net.createServer fixture on 127.0.0.1 with port: 0. No new network surface, no credentials, no untrusted input handling.

Level of scrutiny

Low. The diff is ~15 lines in one test, mechanical in nature, and follows the exact deflake pattern the repo's test guidelines prescribe ("await the actual observable condition", Promise.withResolvers() for single-callback synchronization, no sleep/setTimeout). The key REVIEW.md concern for deflakes — "keep asserting the property the original assertion protected" — is satisfied: the expect({ code, received }).toEqual({ code: "ECONNRESET", received: "A" }) line is byte-identical, and the drop still lands after the two empty-decode bytes, which is what the test guards (the #36180 stall-vs-reject regression).

Other factors

I checked rawChunkedServer and writeChunk to rule out a new deadlock: [Symbol.asyncDispose] waits on server.close(), which would hang if the connection stayed open, but the finally guarantees consumedFirstChunk resolves on every path so the server body always reaches sock.destroy(). Multiple .resolve() calls (once per loop iteration plus the finally) are safe no-ops. The added comment explains a non-obvious FetchTasklet coalescing race — the kind of context CLAUDE.md says is worth keeping. No CODEOWNERS entry covers this path, the hunt exited on dry_streak with no findings, and there are no outstanding reviewer objections in the timeline.

@robobun

robobun commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

Status (build #108455): test/js/web/fetch/body.test.ts passed on every test lane that has finished, including the three lanes where it flaked before (Debian 13 aarch64, Debian 13 x64 ASAN, Ubuntu 25.04 aarch64).

The one red lane is test/js/web/url/url.test.ts on macOS x64 (TypeError: Invalid URL in the Unicode 16 IDNA case). This PR does not touch it, and it fails on main too. It is reported to main-break triage separately.

Reproduction of the flake, for reference: 24 parallel loops of the old test body fail 1400 of 3600 iterations with received: "". The new body: 0 of 3600 under the same load.

@Jarred-Sumner
Jarred-Sumner merged commit 54fd211 into main Aug 30, 2026
5 of 7 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/95e04753/deflake-textstream-econnreset branch August 30, 2026 07:19
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