Skip to content

PipeReader: keep the outer read loop's scratch claim when a nested read is refused - #38873

Closed
robobun wants to merge 1 commit into
mainfrom
farm/3a61619c/pipe-reader-scratch-claim
Closed

PipeReader: keep the outer read loop's scratch claim when a nested read is refused#38873
robobun wants to merge 1 commit into
mainfrom
farm/3a61619c/pipe-reader-scratch-claim

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • An HTMLRewriter handler that starts a second streamed read while the outer document is being parsed gets the outer document's remaining bytes replaced by the second read's bytes. Repro: a handler doing new HTMLRewriter().transform(new Response(Bun.file(other))).text() makes the outer .text() come back as <b x="1">bbbb... (the other file's content) instead of the outer document. Same result whether the outer document comes from a regular file or from a stdin pipe.
  • HTMLRewriter: don't read a streamed input ahead of its reader #38656 added ReadScratchClaim (src/io/PipeReader.rs:161) so that only the outermost read loop on the thread reads into the per-loop scratch buffer, and nested loops read into their own _buffer. try_claim() was written as (!in_use.replace(true)).then_some(Self). bool::then_some takes its argument by value, so a ReadScratchClaim is constructed before the condition is tested; on the refused path then_some drops it, and impl Drop for ReadScratchClaim clears READ_SCRATCH_IN_USE. The first refused nested read therefore released the outer loop's claim, and every nested read after it claimed the scratch and refilled it under the outer loop's chunk.
  • transform() + .text() inside a handler is exactly two nested reads (the initial read transform() performs, then the drain .text() triggers), so the case HTMLRewriter: don't read a streamed input ahead of its reader #38656 was written for was still broken in its most natural form. The existing test only consumed a transform created outside the handler, which is a single nested read, so it kept passing.
  • Found by the session fixing test/js/node/test/parallel/test-http-chunk-problem.js after HTMLRewriter: don't read a streamed input ahead of its reader #38656; not addressed by PipeReader: don't re-deliver streamed bytes after a re-entrant read #38726, which fixed the re-delivery half of that break.

Fix

  • try_claim() tests the flag and sets it explicitly, so no claim value exists on the refused path and the outer loop's claim stays set until its own claim drops.
  • Correct because the claim is meant to be held for the whole outer loop: the chunk handed to on_read_chunk points into the scratch until that dispatch returns, and a nested loop of any depth or count during that window must stay out of it. Releasing it early is exactly the aliasing the claim exists to prevent.
  • With every nested read now reading into _buffer, read_blocking_pipe's streamed branch reserves 64 KiB instead of 16 KiB. In streaming mode that branch delivers and clears per read, so the reserve is the chunk size, and a consumer that re-pulls from inside its chunk handler (process.stdin.on("data") on a piped stdin) takes every read after the first through it. 16 KiB would have turned one chunk per pipe buffer into four (measurements below); 64 KiB is the default pipe buffer on Linux and macOS and keeps the chunking identical to the current release. The non-streaming (accumulating) reserve is unchanged.
  • Tests: test/js/workerd/html-rewriter.test.js, "streamed input pacing" block, two new tests: the outer document read from a regular file (read_with_fn) and from the child's stdin pipe (read_blocking_pipe), each with a handler that transforms and reads a second file whose bytes differ from the outer document's. Both fail on main with the outer output replaced by the other file's bytes, both pass with this change.
  • Also ran on the debug build: the whole html-rewriter file, spawn.test.ts, spawn-streaming-stdout/stdin, spawn-maxbuf, spawn-stream-serve, spawn-stdin-readable-stream, process-stdin, process-stdin-stale-hup, bun-stdin-slice, shell-blocking-pipe, shell-pipe-read-fault, pipeline_stack. The only failures were the stdin-fixtures tests (hard 1 s kill timer; touching process.stdout alone takes 1.7 s in this debug build) and shell-blocking-pipe's generateHeapSnapshot test (the snapshot alone exceeds the 5 s timeout here); both reproduce without this diff's code paths being involved.

Background

  • Per-loop read scratch: every event loop owns one 256 KiB PipeReadBuffer. The POSIX read loops in src/io/PipeReader.rs (read_with_fn for files, sockets and non-blocking pipes; read_blocking_pipe for blocking pipes such as a piped stdin) read into it and pass the filled slice straight to the parent's on_read_chunk, so a streamed chunk lives in the scratch until that callback returns.
  • Nested read: on_read_chunk can run user JS. FileReader's native-sink path hands the slice to HTMLRewriter, which runs content handlers while lol_html is still parsing out of it; a handler that starts or drains another streamed body runs another read loop synchronously, inside the outer one's dispatch. FileReader's JS-facing path copies the chunk before JS runs, which is why process.stdin consumers see no corruption, only the chunking difference above.
  • ReadScratchClaim: a thread-local flag plus a Drop guard. A read loop that gets the claim uses the scratch; one that is refused reads into the reader's own _buffer (16 KiB reserve in read_with_fn, which grows as it drains to EAGAIN; per-read delivery in read_blocking_pipe, so the reserve there is the chunk size).
Chunking measurements: 16 MiB piped into `process.stdin.on("data")`, 64 KiB pipes
build chunks delivered sizes
current release (before #38656) 256 256 x 64 KiB
main (claim released by the bug) 263 253 x 64 KiB, 9 x 16 KiB, 1 x 48 KiB
try_claim fix alone 1021 1020 x 16 KiB, 1 x 64 KiB
this PR (fix + 64 KiB streamed reserve) 256 256 x 64 KiB

The 9 x 16 KiB chunks on main are the one correctly refused nested read per outer dispatch; everything after it went back into the scratch. read_with_fn consumers (a child's stdout via child_process data events, same 16 MiB) deliver 76 chunks of ~220 KiB with this PR against 127 on the current release, so no reserve change is needed there.

Note for anyone reproducing in a container as root: uid 0 can end up with 8 KiB pipes there (pipe-user-pages-soft), which hides the chunking difference; run the pipeline as another uid.

…ad is refused

ReadScratchClaim::try_claim built its result with bool::then_some(Self),
whose argument is constructed before the condition is tested. On the
refused path that value was dropped straight away, and its Drop cleared
READ_SCRATCH_IN_USE, releasing the claim held by the outer read loop. So
only the first nested read under an outer dispatch stayed out of the
per-loop scratch buffer; every later one read into it, under the chunk
the outer loop was still delivering out of it.

Test the flag and set it explicitly instead. With every nested read now
reading into its own buffer, size read_blocking_pipe's streamed reserve
to a full default pipe buffer (64 KiB) so a consumer that re-pulls from
inside its chunk handler keeps getting one chunk per pipe buffer instead
of four.
@coderabbitai

coderabbitai Bot commented Aug 15, 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: 22 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: 93066e64-65a1-4453-ae9b-60d9483af07e

📥 Commits

Reviewing files that changed from the base of the PR and between b44b2c4 and 720b4b3.

📒 Files selected for processing (2)
  • src/io/PipeReader.rs
  • test/js/workerd/html-rewriter.test.js

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Aug 14th, 2026

@robobun, your commit 720b4b3 is building: #97360

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: superseded by #38886, which started from this branch (its two html-rewriter tests are carried over there), replaces the thread-local flag with a claim owned by the scratch buffer itself, and also covers fs.readFileSync inside a handler, a second borrower of the same buffer that this change could not see. Nothing from here is left to carry over: the refused-nested-read chunking this description measured does not apply to #38886's design, where a synchronous JS pull reads straight into the pull's own buffer (read_into) instead of the reader's 16 KiB reserve. Closed in favour of #38886.

@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 — the try_claim() fix is a provably-correct rewrite of the then_some(Self) footgun, and the streaming reserve bump is well-measured.

Checked: the refused path now constructs no ReadScratchClaim value, so Drop cannot clear READ_SCRATCH_IN_USE and release the outer loop's claim; read_with_fn's 16 KiB reserve is correctly left alone (it accumulates across reads, unlike read_blocking_pipe's per-read deliver-and-clear); the two new tests use distinguishable byte content ('a' vs 'b') so scratch aliasing shows up as wrong bytes in outer.text(), and cover both read loops (regular file → read_with_fn, stdin pipe → read_blocking_pipe).

Extended reasoning...

Overview

Two changes to src/io/PipeReader.rs plus two regression tests in test/js/workerd/html-rewriter.test.js:

  1. ReadScratchClaim::try_claim() is rewritten from (!in_use.replace(true)).then_some(Self) to an explicit test-then-set. The original was a Rust footgun: then_some evaluates its argument eagerly, so a ReadScratchClaim (a ZST with a Drop that clears the thread-local flag) was constructed unconditionally and dropped on the refused path — releasing the outer read loop's claim. The rewrite constructs Self only after the flag is confirmed unset, so no guard exists to drop on the None path.
  2. In read_blocking_pipe's no-scratch-claim branch, the streaming-mode reserve is bumped from 16 KiB to 64 KiB. Because streaming mode delivers-and-clears per read, the reserve is the effective chunk size; with the claim now correctly refused for every nested read, a re-pulling consumer (process.stdin.on('data')) would otherwise see 4× the chunk count. 64 KiB matches the default pipe buffer on Linux/macOS and restores the pre-#38656 chunking (256 × 64 KiB for a 16 MiB stream, per the PR's measurements). The non-streaming (accumulating) reserve is unchanged.

Security risks

None. This is single-threaded scratch-buffer bookkeeping — a thread-local Cell<bool> guarding which buffer a synchronous nested read loop reads into. No untrusted input parsing, no auth/crypto, no cross-thread state. The bug it fixes was a data-aliasing corruption (nested read overwriting the outer loop's chunk-in-progress), and the fix strictly narrows when the shared scratch is used.

Level of scrutiny

Medium-high — PipeReader.rs is a core POSIX I/O path (every pipe/file/stdin stream). But the change surface is tiny and mechanically verifiable: the try_claim() rewrite is an explicit expansion of what the one-liner was meant to do, and reading impl Drop for ReadScratchClaim (line 177-181) confirms exactly why constructing-then-dropping on the refused path was wrong. I checked read_with_fn (line 1311) still uses a 16 KiB reserve and confirmed the PR's rationale: that loop accumulates across reads until EAGAIN so the reserve is only the initial allocation, not the chunk size — no change needed there.

Other factors

  • Tests are placed alongside the existing #38656 test in the same describe block, use a second file with 'b' bytes vs the outer's 'a' bytes so corruption is directly observable in the outer output, and cover both read loops. The stdin variant spawns a child, drains all three pipes concurrently, and asserts stderr/stdout/exitCode in the right order.
  • The PR description includes a chunking measurement table proving the reserve bump exactly restores release-build behavior, and a list of the ~12 related test files run on the debug build.
  • MaxBuf::clamp_read_buf still clamps the read buffer after the reserve, so the 64 KiB bump doesn't defeat maxbuf enforcement.
  • The bug-hunting pass found nothing.

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.

3 participants