Skip to content

streams: deliver the source error to native sinks on controller.close(error) - #40669

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/1c315e52/jssink-close-error
Aug 27, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
farm/1c315e52/jssink-close-error

Conversation

@robobun

@robobun robobun commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A ReadableStream pumped into a native sink fails, and the sink treats it as a clean end. S3Client.write(key, stream) commits the buffered bytes as a complete single-file PUT (the object exists, truncated) while the returned promise rejects with the source error. new HTMLRewriter().transform(new Response(stream)).text() resolves to "" when the stream is already errored at transform() time.
  • Cause: readStreamIntoSink calls controller.close(error) on the sink controller before it rejects the pump promise (BunStreamSource.cpp, rsisAbrupt → rsisSinkClose). The generated ${controller}__close (src/codegen/generate-jssink.ts) never read the argument, and JSSink::js_close (src/runtime/webcore/Sink.rs) always called end(None). Every native sink saw EOF.

Fix

  • close(reason) now reaches the sink: the generated controller method passes callFrame->argument(0) to ${name}__close, and js_close routes a non-empty reason to a new JsSinkType::close_with_error(this, global, reason). Its default is the old clean end, so ArrayBufferSink, FetchRequestBodySink, the HTTP response sinks and FileSink behave as before (their owners already handle the pump rejection).
  • NetworkSink (S3) fails the upload instead of committing: task.fail before any PUT is scheduled, with the source error stashed so the caller's promise rejects with it. RewriterPipe fails the rewrite with the error, so the output body rejects.
  • Correct because the pump has already decided the body is incomplete when it closes with an error; the sink is the only party that did not know. The pump rejection reactions still run afterwards and still release their refs, so the ownership protocol of each sink is unchanged.
  • Verified: test/js/bun/s3/s3.test.ts (two new rows, stock bun commits a 2048 byte PUT) and test/js/workerd/html-rewriter.test.js (one new test, stock bun resolves ""). Also filesink.test.ts, the spawn-stdin-* files, streams.test.js, serve-stream-body-error.test.ts, serve-direct-readable-stream.test.ts, body.test.ts, body-stream.test.ts, fetch.stream.test.ts, fetch-backpressure.test.ts, html-rewriter-doctype.test.ts.

Background

  • A native sink (JsSinkType in Sink.rs) is a Rust writer behind a JS controller object. JSSink::assign_to_stream starts readStreamIntoSink, the JS pump: it reads the stream and calls the controller's write, end, or close(error). The pump also returns a promise the sink's owner reacts to.
  • close_with_error takes *mut Self, like finalize: NetworkSink::fail fires the S3 task's completion callback synchronously, and that callback borrows the sink again.
  • The sink's own JS close() method (${name}__doClose) still passes no reason.
Notes

Wire before the fix, S3 stub on loopback, stream enqueues two 1 KB chunks then controller.error(new Error("boom")): the stub receives PUT /my_bucket/obj with Content-Length: 2048 and a complete body; client.write rejects with boom. After: no PUT, the same rejection. A stream that closes cleanly still produces the PUT.

HTMLRewriter before the fix: a stream that errors after the first chunk already rejected (the pump's reject reaction is the terminal step on the Pending arm). Only the already-errored input resolved to "": readMany throws synchronously, so close(error) and the rejection both happen inside transform(), the pipe had already run end_rewrite from the clean close, and the Rejected arm's end_from_stream(Some(err)) found it done.

FileSink (spawn stdin, Bun.write(path, stream)) keeps the default. Its pump reactions already handle the failure (Bun.write rejects; the spawn path aborts the stream). Closing without flushing would drop the bytes the source produced before it failed depending on whether the auto-flusher had run yet, and spawn-stdin-readable-stream-edge-cases.test.ts ("ReadableStream with exception in pull") pins that those bytes reach the child. A pipe has no incomplete-message signal beyond EOF, so there is nothing else for the sink to do there; what a JS observer for a failed spawn stdin stream should look like is a separate question.

Related: #40596 fixes the same family on the Bun.serve response side.


no test proof · iteration 7 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/workerd/html-rewriter.test.js

…(error)

When a ReadableStream pumped into a native sink fails, readStreamIntoSink
calls `controller.close(error)` on the sink controller before it rejects
the pump promise. The generated `__close` never read that argument and
`JSSink::js_close` always called `end(None)`, so every native sink saw a
clean end:

- S3 (`NetworkSink::end`) committed the buffered bytes as a complete
  single-file PUT while the caller's promise rejected.
- HTMLRewriter (`RewriterPipe::end_from_stream(None)`) ended the rewrite
  with the partial output; for an input that was already errored when
  `transform()` ran, the Rejected arm found the pipe done and `.text()`
  resolved to "".

The controller's `close(reason)` now passes the reason through to a new
`JsSinkType::close_with_error(this, global, reason)`. Its default is the
old clean end, which is right for the sinks whose owner handles the pump
rejection (ArrayBufferSink, FetchRequestBodySink, the HTTP response
sinks, FileSink). NetworkSink fails the upload and stashes the error for
the caller's promise; RewriterPipe fails the rewrite with it.

The sink's own `close()` method keeps passing no reason.
@coderabbitai

coderabbitai Bot commented Aug 27, 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: 33439c86-7ef6-4ba6-b167-b3ac9797e301

📥 Commits

Reviewing files that changed from the base of the PR and between bedc5c0 and 89a2beb.

📒 Files selected for processing (7)
  • src/codegen/generate-jssink.ts
  • src/jsc/bindings/headers.h
  • src/runtime/api/html_rewriter.rs
  • src/runtime/webcore/Sink.rs
  • src/runtime/webcore/streams.rs
  • test/js/bun/s3/s3.test.ts
  • test/js/workerd/html-rewriter.test.js

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


Walkthrough

Generated sink bindings now propagate JavaScript close reasons. Runtime sinks route those reasons through failure handlers. S3 and HTML rewriter tests cover explicit, undefined, synchronous, and asynchronous stream errors.

Changes

Stream close reason propagation

Layer / File(s) Summary
Close reason ABI
src/codegen/generate-jssink.ts, src/jsc/bindings/headers.h
Sink close bindings now accept a JavaScript reason. Clean closes pass an empty value. Generated Rust thunks use raw sink pointers.
Sink close dispatch
src/runtime/webcore/Sink.rs
JsSinkType provides close_with_error. js_close dispatches clean and reason-bearing closes separately.
Stream failure handlers
src/runtime/api/html_rewriter.rs, src/runtime/webcore/streams.rs
HTML rewriting converts close reasons to StreamError::JSValue. NetworkSink records pump failures, settles writes, and aborts S3 uploads.
Regression coverage
test/js/bun/s3/s3.test.ts, test/js/workerd/html-rewriter.test.js
Tests cover stream failures with explicit and undefined reasons, failed upload suppression, rejected transformations, and later upload recovery.

Suggested reviewers: jarred-sumner, dylan-conway, alii

Merge Risk: ⚪ Minimal · up to 89a2b

No actionable merge-blocking risk remains at the current head; the supplied evidence does not identify a concrete product, runtime, data, security, or availability issue.

🚥 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 describes the main change: forwarding source errors from controller.close(error) to native stream sinks.
Description check ✅ Passed The description explains the problem, implementation, affected sinks, behavior changes, and verification results. It does not use the template headings exactly, but it provides the required informatio…
Full details: Description check

Explanation

The description explains the problem, implementation, affected sinks, behavior changes, and verification results. It does not use the template headings exactly, but it provides the required information and remains focused on the pull request.


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

@robobun

robobun commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:00 PM PT - Aug 27th, 2026

⏳ @robobun, your commit 89a2beb is still building in Build #107027, but has 2 failures so far (All Failures):

@robobun

robobun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Ready for review at 3af8c5a.

Reproduced with a loopback S3 stub: a ReadableStream that enqueues two 1 KB chunks and then errors made stock bun send a complete PUT /my_bucket/obj with 2048 bytes while client.write rejected with the source error. With this change no PUT is sent and the promise still rejects with boom. For HTMLRewriter, an input that is already errored at transform() time made .text() resolve to ""; it now rejects with the source error.

CI on build 107008: every lane that runs the tests this PR adds or changes is green on all platforms. The red lanes are test/bundler/transpiler/macro-test.test.ts (ASAN leak) and test/js/web/url/url.test.ts (macOS x64), which fail on main as well (both reported to main-break triage), plus tests that passed on retry or when run alone.

Not changed here: FileSink (spawn stdin, Bun.write(path, stream)) keeps the clean end on a failed source. The reasoning is in the PR body; what a JS observer for a failed spawn stdin stream should look like is a design question.

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

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/runtime/api/html_rewriter.rs — Stale doc comment on js_pump_reaction_pending still claims "the generated ${controller}__close drops its error argument" — this PR makes that false (the argument is now forwarded), and the PR updated the parallel wording at ~L1481 and in html-rewriter.test.js but missed this one

    Extended reasoning...

    A future reader of js_pump_reaction_pending is told the close argument is dropped and that the deferral exists because the error is unavailable here — the opposite of what the code now does — and may remove or reorder the guard on that basis; base branch's comment was accurate, so this PR introduces the inaccuracy. Fix: reword to match the updated comment at end_from_stream (the pump calls close(...) before the promise settles; the reaction remains the terminal authority).

    Verification: nit — /home/claude/bun/src/runtime/api/html_rewriter.rs:732-734 still reads: "The generated ${controller}__close drops its error argument, so end_from_stream defers terminal work to the reaction (which carries the real error) while this is set." This PR makes that statement false: generate-jssink.ts now emits `${name}__close(lexicalGlobalObject, ptr,… | nit — The field doc comment at…

Comment thread src/runtime/webcore/Sink.rs Resolved
controller.error() with no argument errors the stream with undefined, and
the pump then calls close(undefined). Keying the sink's decision on the
reason's nullishness read that as a clean close. The generated controller
close now passes the empty value when close() was called with no
argument, so any argument, nullish or not, fails the sink. NetworkSink
only stashes a non-nullish reason for the caller's promise; the generic
S3 error covers the rest.

Also rewords the js_pump_reaction_pending field comment, which still said
the close argument was dropped.
Comment thread src/codegen/generate-jssink.ts Resolved
Comment thread src/codegen/generate-jssink.ts Resolved
Comment thread src/runtime/api/html_rewriter.rs Resolved
Comment thread src/runtime/api/html_rewriter.rs Resolved
Comment thread src/runtime/webcore/Sink.rs Resolved
Comment thread src/runtime/webcore/Sink.rs Resolved
Comment thread src/runtime/webcore/streams.rs Resolved
Comment thread src/codegen/generate-jssink.ts Resolved
Comment thread src/codegen/generate-jssink.ts Resolved
Comment thread src/runtime/api/html_rewriter.rs Resolved
Comment thread src/runtime/webcore/Sink.rs Resolved
Comment thread src/runtime/webcore/Sink.rs Resolved
Comment thread src/runtime/webcore/streams.rs Resolved
@Jarred-Sumner
Jarred-Sumner merged commit cc1853c into main Aug 27, 2026
8 of 9 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/1c315e52/jssink-close-error branch August 27, 2026 22:01

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

The nullish-reason gap from my earlier comment is closed by 8d99f00 — js_close now branches on reason.is_empty() (argument presence via argumentCount() > 0, not the value's truthiness), and both s3.test.ts and html-rewriter.test.js gained a controller.error()-with-no-reason case that fails on stock bun.

Extended reasoning...

This run followed up on the commit pushed after the prior review. The earlier concern — that js_close gated close_with_error on !reason.is_empty_or_undefined_or_null(), so a source that errored with undefined/null still hit the clean-end path and committed truncated output — is now addressed: the generated ${controller}__close encodes "no argument" as the empty JSValue via callFrame->argumentCount() > 0, js_close checks only reason.is_empty(), and the new S3 (error() without a reason) and HTMLRewriter (undefined reason) test rows exercise exactly that path. The remaining inline finding this run is separate from that and speaks for itself.

Comment thread src/codegen/generate-jssink.ts Outdated
Comment on lines +397 to +402
// close(reason): the pump's abrupt path passes the source's error, which
// can itself be undefined (controller.error() with no argument); a clean
// close passes no argument. "No argument" is encoded as the empty value so
// the sink can tell the two apart.
${name}__close(lexicalGlobalObject, ptr,
callFrame->argumentCount() > 0 ? JSC::JSValue::encode(callFrame->argument(0)) : JSC::JSValue::encode(JSC::JSValue()));

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.

🔴 ${controller}__close is the JS-visible close method on the sink-controller prototype (line 1228), and a type: 'direct' ReadableStream hands that controller straight to the user's pull(controller) (readDirectStream, BunStreamSource.cpp:857-859). Keying the error path on callFrame->argumentCount() > 0 therefore lets user code that calls controller.close(anything) — including controller.close(undefined) — trip close_with_error on NetworkSink and RewriterPipe, aborting an upload / rejecting a rewrite that on base would have committed cleanly. …

Extended reasoning...

…The pump is not the only caller of this method; the abrupt-close signal should be carried on a channel user code can't reach (e.g. a distinct internal entry point invoked by rsisSinkClose, or a private slot on the controller set by rsisAbrupt and read here), not on the public method's argument count.

const rs = new ReadableStream({
  type: 'direct',
  async pull(c) { c.write(new Uint8Array(3)); c.close(undefined); },
});
await new Bun.S3Client({endpoint:`http://127.0.0.1:${port}`,bucket:'b',accessKeyId:'x',secretAccessKey:'x'}).write('k', rs);

On base: c.close(undefined) → end(None) → the 3-byte PUT is sent and write() resolves. After this PR: argumentCount()>0, so NetworkSink__close receives a non-empty reason, js_close routes to close_with_error → fail_from_js_pump → task.fail() aborts the upload and write() rejects with S3Error: ReadableStream ended with an error even though the source closed cleanly. The same shape (new HTMLRewriter().transform(new Response(rs)).text()) now rejects with undefined where base resolved with the…

Verification: normal — ${controller}__close is the public close method on the sink-controller prototype (generate-jssink.ts:1228: close ${controller}__close ReadOnly|DontDelete|Function 0), and readDirectStream hands that controller directly to the user's pull(controller) (BunStreamSource.cpp:857-859: pullArgs.append(sinkController); … call(globalObject, pull, …)). assignToStream… | normal —…

Jarred-Sumner pushed a commit that referenced this pull request Aug 28, 2026
)

### Problem
- A `type: "direct"` ReadableStream whose `pull()` ends with
`controller.close(undefined)` (or `null`, `false`, `""`) is treated as a
failed source. `S3Client.write` sends no PutObject and rejects with
`ReadableStream ended with an error`.
`HTMLRewriter.transform(...).text()` rejects with `undefined`. 1.4.0
commits the PUT and resolves the text. `close()` is unaffected.
- Cause: #40669 made the generated `${controller}__close`
(`src/codegen/generate-jssink.ts`) forward `argument(0)` whenever
`argumentCount() > 0`, so `JSSink::js_close`
(`src/runtime/webcore/Sink.rs`) routed `undefined` to
`close_with_error`. The pump (`rsisAbrupt`, `BunStreamSource.cpp`)
reports a failed source through the same JS method, and its error can
itself be `undefined`.

### Fix
- The JS-facing `close(error?)` treats a falsy argument as a clean
close, the truthiness rule `readDirectStreamCloseImpl` already uses for
the same value. The host function body moves to a shared
`${controller}__closeWithReason`, where an empty `reason` is a clean
close.
- The pump gets its own entry, `WebCore::closeSinkControllerWithError`,
which dispatches on the controller class and always delivers the
failure, `undefined` included. `rsisSinkClose` uses it for native sink
controllers.
- Correct because the two callers mean different things by the same
value: user code passes an optional error, the pump passes "the source
failed, with this reason". The Rust contract in `js_close` is unchanged:
empty means clean, anything else means failed.
- Verified: `test/js/bun/s3/s3.test.ts` and
`test/js/workerd/html-rewriter.test.js` (six new rows each, four per
file fail on main). Also the serve direct stream, streams, spawn stdin,
fetch body and backpressure suites.

### Background
- A native sink (`JsSinkType` in `Sink.rs`) is a Rust writer behind a JS
controller object. For a `type: "direct"` stream, `pull(controller)`
receives that sink controller, so the user's `controller.close()` is the
generated host function.
- `readStreamIntoSink` is the pump for non-direct streams. When the
source errors, `rsisAbrupt` closes the sink with the error, then rejects
the pump promise.
- `JsSinkType::close_with_error` is the #40669 hook: `NetworkSink` (S3)
aborts the upload, `RewriterPipe` fails the rewrite, every other sink
keeps the clean end.

<details><summary>Notes</summary>

Repro on main (debug build, same results with `null`, `false`, `""`):

```js
const mk = () => new ReadableStream({ type: "direct", pull(c) { c.write("<b>hello</b>"); c.close(undefined); } });
await new HTMLRewriter().transform(new Response(mk())).text(); // rejects with undefined
await s3.write("k", new Response(mk()));                       // rejects, no PUT on the wire
```

`close(new Error("boom"))` on a direct stream keeps the #40669 behavior:
no PUT, the promise rejects with `boom`, the HTMLRewriter body rejects
with the error. Both test files pin that row too.

The `error() without a reason` rows from #40669 still pass: that path is
the pump, which now calls `closeSinkControllerWithError(controller,
undefined)` and reaches `close_with_error` as before.

`readableStreamCancel` on a stream bound to a native sink also calls the
JS `close(reason)`. `cancel()` with no reason is a clean close again (as
in 1.4.0), `cancel(error)` fails the sink.

Suites run: `serve-direct-readable-stream.test.ts`,
`serve-stream-body-error.test.ts`, `streams.test.js`,
`spawn-stdin-readable-stream*.test.ts`, `body.test.ts`,
`body-stream.test.ts`, `fetch.stream.test.ts`,
`fetch-backpressure.test.ts`. In the last one, the four `h3 receive
backpressure` tests time out in this container when the whole file runs
and pass in isolation. They fail the same way on an unmodified build of
main here.

</details>

<!-- robobun:evidence:begin -->

---

**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/workerd/html-rewriter.test.js

<!-- robobun:evidence:end -->
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