Skip to content

ArrayBufferSink: throw ERR_STREAM_WRITE_AFTER_END on write() after end() - #36493

Open
robobun wants to merge 7 commits into
mainfrom
farm/d9e9ca0d/arraybuffersink-write-after-end
Open

robobun wants to merge 7 commits into
mainfrom
farm/d9e9ca0d/arraybuffersink-write-after-end

Conversation

@robobun

@robobun robobun commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

Bun.ArrayBufferSink.write() called after end() reported success (the full byte count), appended the data to its internal Vec, and grew RSS, but the next end()/flush() returned an empty buffer. A caller doing written += sink.write(chunk) would account bytes that were retained in memory and never recoverable, even after a subsequent start().

Reproduction

const t = new Bun.ArrayBufferSink();
t.start({ asUint8Array: true }); t.write("a"); t.end();
const rss0 = process.memoryUsage.rss();
let acc = 0; for (let i = 0; i < 100; i++) acc += t.write("Q".repeat(1 << 20));
const rss1 = process.memoryUsage.rss();
const e2 = t.end(), f = t.flush();
console.log({ writeAfterEnd_returned: acc, end2_len: e2.byteLength, flush: f,
  rssDeltaMB: Math.round((rss1 - rss0) / 1048576) });
// before: { writeAfterEnd_returned: 104857600, end2_len: 0, flush: 0, rssDeltaMB: ~228 }
// after:  throws ERR_STREAM_WRITE_AFTER_END at the first write

Cause

ArrayBufferSink::write/write_latin1/write_utf16 unconditionally append to self.bytes and return Writable::Owned(len). end_from_js sets self.done = true and takes self.bytes, leaving an empty Vec that subsequent writes grow again, while every later end_from_js short-circuits on self.done and returns an empty buffer.

The generic JSSink::js_write host-fn body already checks get_pending_error() before dispatching but had no ended-state guard.

Fix

JsSinkType gains an opt-in THROW_ON_WRITE_AFTER_END associated const (default false). JSSink::js_write throws ERR_STREAM_WRITE_AFTER_END ("write after end") when that const is set and done() is true.

Only ArrayBufferSink opts in: its done bit is set exclusively by end_from_js and cleared by start, so done() there is exactly "user called end()". FileSink (wrapped by node:fs's writeFast fast-path, which routes stream-state errors through callbacks), HTTPServerWritable (whose done is also set on client abort), and NetworkSink keep their existing write-after-done behavior unchanged. start() still resets the sink for reuse.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/bun/util/arraybuffersink.test.ts -t "after end"
  2 fail  (received byte count / 104857600 wrote)

$ bun bd test test/js/bun/util/arraybuffersink.test.ts test/js/bun/util/filesink.test.ts test/js/bun/spawn/spawn-stdin-destroy.test.ts
  69 pass, 0 fail

$ bun bd test test/js/bun/http/async-iterator-stream.test.ts test/js/web/fetch/body-stream.test.ts
  91 + 9086 pass, 0 fail

[review] gate passed · iteration 2 · 3 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/arraybuffersink.test.ts
bun test v1.4.0 (e366efd21)

test/js/bun/util/arraybuffersink.test.ts:
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyz" [12.77ms]
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" [8.68ms]
(pass) ArrayBufferSink > "😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [9.91ms]
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [13.56ms]
(pass) ArrayBufferSink > "(rope) abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [13.33ms]
(pass) ArrayBufferSink > "(array) abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [13.97ms]
(pass) ArrayBufferSink > start({ highWaterMark: Infinity }) does not abort the process [1131.56ms]
(pass) ArrayBufferSink > start({ highWaterMark: 1e15 }) does not abort the process [1099.10ms]
(pass) ArrayBufferSink > start({ highWaterMark: Number.MAX_SAFE_INTEGER }) does not abort th
... (truncated)

release without fix: 5 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/bun/util/arraybuffersink.test.ts:
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyz" [0.17ms]
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" [0.03ms]
(pass) ArrayBufferSink > "😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [0.03ms]
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [0.03ms]
(pass) ArrayBufferSink > "(rope) abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [0.03ms]
(pass) ArrayBufferSink > "(array) abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [0.06ms]
93 |       stdout: "pipe",
94 |       stderr: "pipe",
95 |     });
96 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
97 | 
98 |     expect(stderr).not.toContain("memory allocation");
                            ^
error: expect(received).not.toContain(expected)

Expected to not contain: "memory allocation"
Received: "memory allocation of 9223372036854775807 bytes failed\nnote: run with
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/arraybuffersink.test.ts
bun test v1.4.0 (e366efd21)

test/js/bun/util/arraybuffersink.test.ts:
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyz" [12.11ms]
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" [8.25ms]
(pass) ArrayBufferSink > "😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [9.88ms]
(pass) ArrayBufferSink > "abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [13.43ms]
(pass) ArrayBufferSink > "(rope) abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [13.63ms]
(pass) ArrayBufferSink > "(array) abcdefghijklmnopqrstuvwxyz😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌" [13.87ms]
(pass) ArrayBufferSink > start({ highWaterMark: Infinity }) does not abort the process [1115.51ms]
(pass) ArrayBufferSink > start({ highWaterMark: 1e15 }) does not abort the process [1091.51ms]
(pass) ArrayBufferSink > start({ highWaterMark: Number.MAX_SAFE_INTEGER }) does not abort th
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e366efd216
  features     baseline

22 deps, 108 codegen, 1171 objects in 784ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [4.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [9.00ms]
[4/1234] gen ErrorCode+*.h
[5/1234] gen bindgenv2
[6/1234] fetch picohttpparser
[picohttpparser] up to date
[7/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1234] gen .bind.ts → GeneratedBindings.cpp
[9/1234] fetch zlib
[zlib] up to date
[10/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindin
... (truncated)
diff hotspot
src/runtime/webcore/ArrayBufferSink.rs   |  1 +
 src/runtime/webcore/Sink.rs              |  7 +++
 test/js/bun/util/arraybuffersink.test.ts | 83 +++++++++++++++++++++++++++++++-
 3 files changed, 90 insertions(+), 1 deletion(-)

gate history · 1 passed · 1 rejected · iteration 2

evidence per changed file
file                                      reads  edits  tests
src/runtime/webcore/ArrayBufferSink.rs        1      2      0
src/runtime/webcore/Sink.rs                   2      6      0
test/js/bun/util/arraybuffersink.test.ts      2      4      0

Bun.ArrayBufferSink.write() after end() was appending to the internal
Vec and returning the written byte count, but end()/flush() read from
the post-end reset state and returned an empty buffer. A caller doing
'written += sink.write(chunk)' saw success accounting for data that
was retained in RSS and never surfaced.

JSSink::js_write now checks JsSinkType::done() before dispatching to
the sink's write path and throws ERR_STREAM_WRITE_AFTER_END, matching
the documented 'Once .end() is called, no more data can be written'
contract and Node's ERR_STREAM_WRITE_AFTER_END. This also replaces
FileSink's odd boolean 'true' return on write-after-end with the same
error.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The sink write path now throws ERR_STREAM_WRITE_AFTER_END after termination. ArrayBufferSink, FileSink, and stdin tests validate rejected writes, reset behavior, and bounded memory retention.

Sink write lifecycle

Layer / File(s) Summary
Write-after-end guard
src/runtime/webcore/Sink.rs
JSSink<T>::js_write rejects writes when the sink reports completion.
Terminated sink regression coverage
test/js/bun/util/arraybuffersink.test.ts, test/js/bun/util/filesink.test.ts, test/js/bun/spawn/spawn-stdin-destroy.test.ts
Tests verify error codes, discarded writes, restart behavior, RSS limits, and termination behavior for file sinks and stdin.
🚥 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 is specific and accurately summarizes the main change: throwing ERR_STREAM_WRITE_AFTER_END after ArrayBufferSink.end().
Description check ✅ Passed The description covers the bug, fix, and verification, though it uses different section headings than the template.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/util/arraybuffersink.test.ts`:
- Around line 145-150: Update the catch block in the RSS fixture’s sink write
loop to assert that the caught exception has the expected error code for writing
after end, and only then increment threw; rethrow or otherwise fail on
unexpected errors so the test proves the intended contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 29e1930b-a2fe-49e4-b71f-ab23fc3c46c4

📥 Commits

Reviewing files that changed from the base of the PR and between bbe3f6a and 96d97a7.

📒 Files selected for processing (3)
  • src/runtime/webcore/Sink.rs
  • test/js/bun/util/arraybuffersink.test.ts
  • test/js/bun/util/filesink.test.ts

Comment thread test/js/bun/util/arraybuffersink.test.ts
robobun added 3 commits July 30, 2026 21:53
…ild exit

The child in bad-fixture.js throws on startup, so by the time the test
writes to stdin the pipe is closed and the FileSink is done. write()
now throws instead of silently returning true; the test still asserts
child.exited resolves to 1 (the original 'TODO' rejection bug) and
nothing crashes under GC.
Comment thread src/runtime/webcore/Sink.rs Outdated
Comment thread src/runtime/webcore/Sink.rs Outdated
Comment thread test/js/bun/util/arraybuffersink.test.ts Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/spawn/spawn-stdin-destroy.test.ts`:
- Around line 20-26: Update the test’s stdin operations so the expected
ERR_STREAM_WRITE_AFTER_END error is captured and asserted specifically around
the write() call that should fail, rather than sharing one try/catch with
flush(). Handle flush() in a separate check only if its behavior also requires
coverage, ensuring the test proves the write operation is the source of the
failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3855260e-b834-4963-bb29-42ae37272801

📥 Commits

Reviewing files that changed from the base of the PR and between 96d97a7 and d9b3f9b.

📒 Files selected for processing (2)
  • test/js/bun/spawn/spawn-stdin-destroy.test.ts
  • test/js/bun/util/arraybuffersink.test.ts

Comment thread test/js/bun/spawn/spawn-stdin-destroy.test.ts Outdated
…onst

The generic done() check in js_write applied to all JsSinkType
implementors, but done() does not mean 'user called end()' for every
sink: HTTPServerWritable sets it on client abort, and FileSink is
wrapped by node:fs's writeFast fast-path which expects stream-state
errors to reach the callback rather than throw synchronously.

Gate the throw on a new THROW_ON_WRITE_AFTER_END associated const,
default false, set true only for ArrayBufferSink (whose done bit is
set exclusively by end_from_js and cleared by start). FileSink,
HTTPServerWritable, and NetworkSink behavior is unchanged.

Also: RSS threshold branches on isASAN || isDebug per convention.
Comment thread src/runtime/webcore/ArrayBufferSink.rs Outdated
Comment thread src/runtime/webcore/Sink.rs Outdated
Comment thread src/runtime/webcore/Sink.rs Outdated
@robobun robobun changed the title sink: throw ERR_STREAM_WRITE_AFTER_END on write() after end() ArrayBufferSink: throw ERR_STREAM_WRITE_AFTER_END on write() after end() Jul 30, 2026
Comment thread src/runtime/webcore/Sink.rs 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 — the fix is now scoped to ArrayBufferSink via the opt-in THROW_ON_WRITE_AFTER_END trait const, so FileSink/HTTPServerWritable/NetworkSink behavior is unchanged, addressing the concerns I raised on the earlier revision.

What was reviewed:

  • Confirmed ArrayBufferSink::done() is set only by end_from_js and cleared by start(), so the guard fires exactly on user-called end(); start() reset is covered by the first test.
  • Verified no src/js/ internal callers of ArrayBufferSink exist, so the new synchronous throw can't break a builtin the way the earlier FileSink change would have via writeFast.
  • ErrorCode::ERR_STREAM_WRITE_AFTER_END.throw(global, format_args!(...)) matches the existing pattern at Sink.rs:414 and the code is already registered in ErrorCode.ts.
  • RSS test: subprocess-isolated, drains pipes concurrently, asserts the specific error code in the catch, threshold branches on isASAN || isDebug (well below the ~200 MiB unfixed leak).
Extended reasoning...

Overview

Adds a write-after-end guard to JSSink::js_write gated on a new JsSinkType::THROW_ON_WRITE_AFTER_END associated const (default false). Only ArrayBufferSink opts in. 1 line in ArrayBufferSink.rs, 7 lines in Sink.rs, and two new tests in arraybuffersink.test.ts. Earlier revisions applied the guard unconditionally to all sink types; after my prior review flagged that this broke FileSink (via the writeFast fast-path in child.stdin) and gave misleading errors for HTTPServerWritable on client abort, the author scoped it via the opt-in const and reverted the filesink.test.ts/spawn-stdin-destroy.test.ts changes.

Security risks

None. This adds a guard that throws earlier on a misuse pattern; no new input parsing, no auth/crypto/permissions surface. The guard runs before any argument coercion, so no user JS is entered before the check.

Level of scrutiny

Moderate — this is a user-facing behavior change to Bun.ArrayBufferSink.write(), but it replaces clearly-broken behavior (silent data loss + unbounded RSS growth reporting success) with the standard Node error code for exactly this situation. The compile-time const gate means the other three JsSinkType implementors are provably unaffected (the branch is dead code for them). The done() predicate for ArrayBufferSink is trivially self.done, set only in end_from_js and cleared in start(), so there is no ambiguity about what state triggers the throw.

Other factors

  • All prior review feedback resolved: the CodeRabbit error-code assertion in the RSS fixture catch block, my isASAN || isDebug threshold nit, and the comment-cop doc-comment length flags were all applied.
  • The error uses the centralized bun_jsc::ErrorCode machinery and matches the invocation pattern already used two lines up in get_this (Sink.rs:414).
  • Tests follow harness conventions: await using for the spawned proc, Promise.all on stdout/stderr/exited, Buffer.alloc(n, fill) instead of .repeat(), exit code asserted last, specific error code asserted (not bare toThrow()), and the start()-resets-the-sink contract is explicitly covered.
  • Verified against USE_SYSTEM_BUN=1 (fails) and bun bd (passes) per the PR body's evidence block.

This branch has not been deployed

No deployments
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