Repository navigation
Conversation
…r drain On POSIX, PosixStreamingWriter buffers small writes until the combined size crosses its CHUNK_SIZE threshold and then drains the whole buffer. try_write_newly_buffered_data returned the total bytes drained (older buffered chunks plus the new one), and FileSink::to_result forwarded that straight to JS, so a write(81) could return 4131 when it happened to be the call that crossed the threshold. The buffer path already returned the chunk length, so a caller summing returns double-counted every previously-buffered byte. try_write_newly_buffered_data now takes the caller's chunk length and reports it (for Wrote) or the chunk-relative portion (for Done) on the synchronous arms. Pending is unchanged: its caller-facing value is re-derived from buffered_len() in FileSink::bytes_accepted and was already correct after #33538. Fixes #12194
|
Warning Review limit reached
Next review available in: 45 seconds Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 5:22 PM PT - Jul 26th, 2026
❌ @robobun, your commit bdde7de has 2 failures in
Add 🧪 To try this PR locally: bunx bun-pr 35999That installs a local version of the PR into your bun-35999 --bun |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
LGTM — focused return-value accounting fix; bytes-on-disk unchanged.
What was reviewed:
- Traced all three callers (
write,write_latin1/write_utf16viamaybe_write_newly_buffered_data) — each passes the encoded chunk length it already computed. - Confirmed
parent_on_writestill receives the total drained amount, soFileSink::writtenaccounting is unchanged; only the JS-facing return viato_resultis affected. - Checked the other
PosixStreamingWriterconsumer (Terminal.rs) — it ignores per-arm counts and returnsinput_lendirectly, so unaffected. Donearm ordering:old_bufferedis captured beforeoutgoing.reset(), and only locals are used afterparent_on_write— no reentrancy hazard.
Extended reasoning...
Overview
This PR fixes #12194: on POSIX, FileSink::write() returned the total bytes drained from the internal buffer (including previously-buffered chunks) when a write triggered the auto-flush threshold, instead of the current chunk's byte length. Callers summing return values to track bytes written would double-count.
The fix threads the caller's chunk_len into try_write_newly_buffered_data and returns it on the fully-drained Wrote arm; the Done arm now returns the chunk-relative portion (amt - old_buffered). parent_on_write still receives the full drained total, so FileSink's internal written counter and on_write bookkeeping are unchanged. Pending is untouched — its caller-facing value is already re-derived from buffered_len() in FileSink::bytes_accepted (fixed in #33538).
Security risks
None. This is return-value accounting only; the actual bytes written to the fd are unchanged.
Level of scrutiny
Medium — this is core I/O plumbing, but the change is narrowly scoped to one private helper's return value on two match arms. I verified:
- All three call sites pass the correct chunk length (encoded byte count for latin1/utf16,
buf.len()for raw bytes). - The only other
PosixStreamingWriterparent besidesFileSinkisTerminal, which explicitly ignores per-arm counts (Terminal.rs:1517-1522) and returnsinput_lendirectly — its comment even documents the pre-fix behavior this PR corrects. - The condition rewrite (
amt == size→amt < sizeelse fully-drained) is equivalent sincetry_writecannot report more thanoutgoing.slice().len(). old_bufferedis computed beforereset()and beforeparent_on_write(which may re-enter), and the return uses only locals afterward.
Other factors
- The new test covers all three encoding paths (raw bytes, Latin-1, UTF-16) with three separate buffer-drain crossings, and asserts both per-call return values and that the sum equals
statSync().size. It's correctly skipped on Windows where file writes always return a promise. - PR description documents
USE_SYSTEM_BUN=1failure (66041/40010/20001vs expected65536/40000/20000) and passing runs offilesink.test.ts,spawn-streaming-stdin,fs-promises-writeFile-async-iterator,process-stdio, andrust:check-all. - CI status shows only "Failed to create agent" infrastructure errors, not test failures.
… the chunk_len invariant as debug_assert
There was a problem hiding this comment.
LGTM — narrow return-value fix in one private helper, all call sites updated, thorough test.
What was reviewed:
- Traced all three callers of
try_write_newly_buffered_data(write,write_latin1/write_utf16viamaybe_write_newly_buffered_data) — each passes the encoded chunk length it already computed. - Checked the other
PosixStreamingWriterconsumer (Terminal.rs) — it ignores the per-arm count and returnsinput_lendirectly, so unaffected. - Verified
parent_on_writestill receives the total drainedamt, soFileSink's written-bytes accounting is unchanged;old_bufferedin theDonearm is captured beforereset()/parent_on_write, so re-entrancy is not an issue. - Test hits all three encoding paths across the auto-flush boundary and cross-checks against the on-disk file size.
Extended reasoning...
Overview
Fixes a return-value bug in PosixStreamingWriter::try_write_newly_buffered_data (src/io/PipeWriter.rs): when a write() call pushes the internal buffer past the auto-flush threshold and the drain succeeds synchronously, the function previously returned the total bytes drained (including previously-buffered chunks) instead of the caller's chunk length. FileSink::to_result maps Wrote(n) straight to the JS return, so sink.write(chunk) over-reported. The fix threads chunk_len into the helper and returns it on the fully-drained Wrote arm; the Done arm returns the chunk-relative portion (amt - old_buffered, saturating). Pending is unchanged — its caller-facing value is already re-derived in FileSink::bytes_accepted. Net change is ~20 lines in one private function plus its three call sites, and one new test.
Security risks
None. This adjusts an integer return value that surfaces to JS as the byte count from FileSink.write(). No allocation sizes, buffer bounds, or fd handling are affected. parent_on_write continues to receive the actual drained amt, so internal accounting is untouched.
Level of scrutiny
Low-to-medium. The function is private and has exactly three call sites, all in the same file and all updated. The other PosixStreamingWriterParent consumer, Terminal, ignores the WriteResult count entirely (it matches only on Err vs not-Err and returns input_len) — its own comment already documents this exact over-reporting hazard as the reason. Windows uses a separate writer and routes through Pending/bytes_accepted, so is unaffected as the PR states. The Wrote-arm restructure (if amt < size { … return Pending } else { … return Wrote(chunk_len) }) is behaviour-equivalent to the old if amt == size shape modulo the returned count, since amt is bounded by outgoing.slice().len().
Other factors
- The
Donearm capturesold_buffered = outgoing.size() - chunk_lenbeforereset()and beforeparent_on_write(which the file's own comment notes may re-enterwrite()), so the computation is sound. Thedebug_assert!(outgoing.size() >= chunk_len)holds because the chunk was just appended andtry_writetakes&self. - The new test is placed in the existing
filesink.test.ts, usestempDir, isskipIf(!isPosix)with a stated reason, exercises the raw-bytes / latin1 / utf16 paths across three flush boundaries, asserts exact per-write returns, and cross-checks the sum againstfs.statSync().size. The PR verified it fails underUSE_SYSTEM_BUN=1and passes underbun bd, andrust:check-allis green. - The comment-cop bot flagged the doc comment twice (both threads resolved); the final three-line form documents the parameter contract and matches the surrounding file's style.
|
CI status for bdde7de (build 82808): 192/196 jobs passed,
All other failures were retried and passed ( |
|
Re-checked against current main (165dc9f) since #12194 was closed: this PR is still needed. #12194 was closed for the backpressured ( |
Fixes #12194.
What
On POSIX,
Bun.file(...).writer().write(chunk)normally returns the byte length ofchunk. When the sink's internal buffer crosses the auto-flush threshold (4096 bytes, 16384 on Apple Silicon) it instead returned the total bytes drained, which includes every previously-buffered chunk. A caller that sums the returns to track bytes written double-counts everything buffered so far.Repro
Every call writes 81 bytes. The 51st call (buffer crosses 4096) returns 4131 = 51 * 81 instead of 81, so the summed total is almost twice the file size. The bytes on disk are correct; only the return value is wrong.
Cause
PosixStreamingWriter::writetakes two paths. When the combined size stays underCHUNK_SIZEit appends and returnsWriteResult::Wrote(chunk_len). When it crosses the threshold with data already buffered it appends, thentry_write_newly_buffered_datadrains the whole buffer and returnedWriteResult::Wrote(total_drained).FileSink::to_resultmapsWrote(n)straight to the JS return value, so the caller sees the total.This is the synchronous counterpart of #33538, which fixed the async (
Pending) arm viaFileSink::bytes_accepted. #33532 had this fix but was closed as superseded by #33538; that PR did not touch theWrote/Donearms, so the synchronous path was still wrong.Fix
try_write_newly_buffered_datanow takes the caller'schunk_lenand returns it on the fully-drainedWrotearm. TheDonearm returns the chunk-relative portion that reached the fd before EOF.parent_on_writestill receives the total drained (unchanged), andPendingis unchanged: its caller-facing value is already re-derived frombuffered_len()inFileSink::bytes_accepted. The three callers (write,write_latin1/write_utf16viamaybe_write_newly_buffered_data) pass the encoded chunk length they already have.Windows is unaffected:
WindowsStreamingWriter::writereturnsPending(0)for async file writes and routes throughbytes_accepted.Verification
New test in
filesink.test.tswrites a small ASCII string, a largeUint8Array, a Latin-1 string and a UTF-16 string in a pattern that triggers three buffer-drain boundaries, and asserts each return equalsBuffer.byteLength(chunk)and the sum matches the final file size.USE_SYSTEM_BUN=1 bun testfails with66041/40010/20001instead of65536/40000/20000bun bd test filesink.test.ts: 51 passbun bd test spawn-streaming-stdin.test.ts,fs-promises-writeFile-async-iterator.test.ts,process-stdio.test.ts: passbun run rust:check-all: 10 ok, 0 failedno test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/filesink.test.ts