Skip to content

FileSink: return per-chunk byte count when a write flushes buffered data - #33532

Closed
robobun wants to merge 2 commits into
mainfrom
farm/9f6e4c03/filesink-write-cumulative
Closed

robobun wants to merge 2 commits into
mainfrom
farm/9f6e4c03/filesink-write-cumulative

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #12194

What

FileSink.write() is documented to return the number of bytes written for the chunk passed to it (bun-types: "@returns Number of bytes written"), and test/js/bun/util/filesink.test.ts asserts this ("write result is not cumulative"). That test only covers fd-backed sinks with an await between writes, so it never exercises the buffered -> flush path.

When a chunk is appended to a non-empty internal buffer and the combined size crosses the streaming writer's CHUNK_SIZE (4096), the buffer is drained and write() returned the total number of bytes drained (older buffered chunks included) instead of the current chunk's size. Any written += sink.write(chunk) accounting loop was then wrong by the amount of previously buffered data.

Repro

import * as fs from "node:fs";
const p = `${process.env.TMPDIR ?? "/tmp"}/fsink-cum-${process.pid}.bin`;

const w = Bun.file(p).writer();
const r1 = w.write("a".repeat(505));        // 505    (buffered; correct)
const r2 = w.write(new Uint8Array(33060));  // 33565  <- 505 + 33060 (triggers flush)
const r3 = w.write(new Uint8Array(10));     // 10     (buffered; correct)
const r4 = w.write("b".repeat(70000));      // 70010  <- 10 + 70000
await w.end();
console.log("sum of returns:", r1 + r2 + r3 + r4);     // 104090
console.log("actual file size:", fs.statSync(p).size); // 103575

The data written to disk is byte-perfect; only the returned counts are wrong.

Cause

In src/io/PipeWriter.rs, try_write_newly_buffered_data is reached after the new chunk has already been appended to outgoing. On the fully-drained branch it returned WriteResult::Wrote(amt) where amt is the bytes drained from the whole buffer, not the bytes of the chunk that triggered the drain. FileSink::to_result forwards that straight to JS.

Fix

try_write_newly_buffered_data now takes the chunk length and returns WriteResult::Wrote(chunk_len) on the fully-drained path. Draining of older buffered bytes is what flush()'s return value accounts for. The partial-write / pending / error paths are unchanged.

Verification

New test in filesink.test.ts ("write result is not cumulative when the chunk flushes buffered bytes") covers bytes, latin1, and utf16 chunks that each flush previously buffered data, and asserts the summed returns equal the file size.

  • USE_SYSTEM_BUN=1 bun test -> fails (returns 66041/40010/20001 instead of 65536/40000/20000)
  • bun bd test -> passes
  • full filesink.test.ts, terminal.test.ts, process-stdio.test.ts, spawn-streaming-stdin.test.ts pass (other StreamingWriter consumers)
  • bun run rust:check-all -> 10 ok, 0 failed

@coderabbitai

coderabbitai Bot commented Jul 6, 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: 25 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: dda95c58-6f02-4d72-b89d-226ffe0557c1

📥 Commits

Reviewing files that changed from the base of the PR and between 3f67971 and 97b3f3a.

📒 Files selected for processing (2)
  • src/io/PipeWriter.rs
  • test/js/bun/util/filesink.test.ts

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

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:21 AM PT - Jul 7th, 2026

❌ @robobun, your commit 97b3f3a has some failures in Build #69605 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33532

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

bun-33532 --bun

@github-actions github-actions Bot added the claude label Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. FileSink.write incoherencies #12194 - Reports FileSink.write() returning a value greater than the size of the buffer passed to it, which is exactly the cumulative-vs-per-chunk return value bug fixed in try_write_newly_buffered_data

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

Fixes #12194

🤖 Generated with Claude Code

Comment thread src/io/PipeWriter.rs
Comment thread test/js/bun/util/filesink.test.ts
Jarred-Sumner pushed a commit that referenced this pull request Jul 7, 2026
…33538)

## What

A backpressured `FileSink.write()` returns a Promise, and that Promise
resolved to the wrong number of bytes. `bun-types` documents the return
as "Number of bytes written or, if the write is pending, a Promise
resolving to the number of bytes", so it is the only progress signal a
FileSink gives for an async write. When the write could not complete
synchronously, the Promise resolved to the bytes the last partial
`write(2)` pushed to the fd instead of the bytes the chunk handed over,
so a 20000-byte chunk could resolve to 4096 even though every byte was
delivered. A `written += await sink.write(chunk)` loop then under-counts
by an unbounded amount.

This is the async counterpart of the synchronous cumulative-return bug
in #33532; they are different code paths and fix independently.

## Repro

```js
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const FIFO = `${process.env.TMPDIR ?? "/tmp"}/fsink-cnt-${process.pid}.fifo`;
execSync(`mkfifo ${FIFO}`);
// hold the read end open but do NOT drain it yet, so the write only partially fits
const rfd = fs.openSync(FIFO, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);

const sink = Bun.file(FIFO).writer({ highWaterMark: 16 });
sink.write("A".repeat(60000));              // fills the pipe buffer
const r2 = sink.write("B".repeat(20000));   // partial write(2) => a pending Promise

let delivered = 0;
const buf = Buffer.alloc(65536);
const t = setInterval(() => { try { let n; while ((n = fs.readSync(rfd, buf)) > 0) delivered += n; } catch {} }, 5);
console.log("resolved to", await r2, "(expected 20000)");   // => 4096
await sink.end();
clearInterval(t); fs.closeSync(rfd); fs.unlinkSync(FIFO);
```

Every byte reaches the reader; only the resolved count is wrong.

## Cause

`FileSink::to_result` seeded the pending accumulator with the partial
`write(2)` return (`p.consumed += pending_written`), and
`FileSink::on_write` then overwrote it on every drain with that drain's
own count (`p.consumed = amount`). So the value handed to the Promise
was whatever the final partial `write(2)` returned, not the bytes the
caller's chunk contributed.

## Fix

Credit the pending accumulator with the bytes the writer actually took
off the caller's hands in the `write()`/`flush()`/`end()` call: what
reached the fd plus what it buffered for later (`buffered_len()` on the
streaming writer, measured before and after the call). The writer never
accepts part of a chunk, so for a `Pending` result this is the chunk's
own encoded byte count. `on_write` no longer overwrites `consumed` with
the per-drain amount, and the accumulator is reset to zero when its
Promise settles so the next pending operation starts fresh.

## Verification

Two new tests in `filesink.test.ts` (socketpair so the write goes async)
assert a backpressured binary write and a backpressured string write
each resolve to the chunk's byte count, and that every byte is
delivered.

- `USE_SYSTEM_BUN=1 bun test` -> fails (resolves to 219264 instead of
4194304 / 2097152)
- `bun bd test` -> passes
- full `filesink.test.ts` (46 tests), `spawn-streaming-stdin.test.ts`,
`fs-promises-writeFile-async-iterator.test.ts` pass
- `bun run rust:check-all` -> 10 ok, 0 failed

The existing `Bun.file(fd).writer() write/end under GC pressure does not
crash` test was a 200-iteration stress loop that times out under
debug+ASAN in slower environments; reduced to 50 iterations, which still
reproduces the original crash it guards against.
robobun added 2 commits July 7, 2026 03:04
FileSink.write() is documented to return the number of bytes accepted
from the current chunk. When a chunk is appended to a non-empty outgoing
buffer and the combined size crosses CHUNK_SIZE, the buffer is drained
and the streaming writer reported the total bytes drained (older buffered
chunks included) instead of the chunk's own size. Any written += write(chunk)
accounting loop was then wrong by the size of the previously buffered data.

try_write_newly_buffered_data now takes the chunk length and returns it on
the fully-drained path, so the count reflects the accepted chunk. Buffer
draining is what flush()'s return value accounts for.
The fully-drained Done arm of try_write_newly_buffered_data returned the
total bytes drained from the buffer (older chunks included), the same
over-reporting shape the Wrote arm had. Return only the portion of the
drained bytes belonging to the current chunk. The Pending arm returns a
promise whose resolution value is accounted for separately.
@robobun
robobun force-pushed the farm/9f6e4c03/filesink-write-cumulative branch from b97c9a1 to 97b3f3a Compare July 7, 2026 03:11
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for 97b3f3a: 282 test jobs passed, filesink.test.ts is green on every platform that ran (Linux, macOS x64, Windows, Alpine). The only red lanes are macOS aarch64 infra:

  • 2 darwin-26-aarch64 test jobs: buildkite-agent artifact download timed out after 120s (runner never received the binary)
  • 2 darwin-14-aarch64 test jobs: expired in queue after 3 automatic retries (never ran)

Neither touched the diff and no test that actually executed failed. The previous build (69314) hit the same artifact-download timeout on darwin aarch64 plus an unrelated terminal.test.ts PTY spawn timeout on darwin x64 (read path only, passed on 69605).

This is ready for a maintainer to merge.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #33538. #12194 is now closed as fixed on main.

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.

FileSink.write incoherencies

1 participant