Skip to content

streams: do not swallow an ERR_INVALID_THIS error from an async-iterable body - #43758

Merged
Jarred-Sumner merged 3 commits into
mainfrom
robobun/9de2ca11/async-iterable-invalid-this-swallow
Sep 22, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
robobun/9de2ca11/async-iterable-invalid-this-swallow

Conversation

@robobun

@robobun robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • An async-iterable body (new Response(asyncGen()), a fetch upload, a Bun.serve response) swallows each iterator error with code: "ERR_INVALID_THIS", for example from ReadableStream.prototype.tee.call({}). text() resolves truncated, a server accepts the upload as complete, and a read() loop never settles.
  • The cause is the by-code check in asyncIterFinishWithError (src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp:180). streams: add the ERR_INVALID_STATE code to the locked-stream errors of cancel, pipeTo and pipeThrough #43714 removed the same check for ERR_INVALID_STATE.

Fix

Background

  • BunAsyncIterableSource.cpp feeds a ReadableStream from the iterator. Its pump calls iterator.next() and writes each value to the consumer's controller.
  • The pull is the promise that the pump returns to the stream. A resolved pull ends the body, a rejected pull fails it.
  • A sink is a native consumer (an HTTP response, a file, an upload). A detached sink lost its destination.

Downsides

  • A generator that ends its body early with an ERR_INVALID_THIS error now fails the body. The repo has no such use.
  • Such an error now resets a Bun.serve response. Before, the client got a complete 200.
Notes

Repro (runs under Node.js and Bun):

const ways = {
  "own error with the code": () => { throw Object.assign(new Error("mine"), { code: "ERR_INVALID_THIS" }); },
  "ReadableStream.prototype.tee.call({})": () => { ReadableStream.prototype.tee.call({}); },
  "control: plain TypeError": () => { throw new TypeError("plain"); },
};
const body = way => (async function* () { yield "first;"; ways[way](); yield "NEVER"; })();
const settle = p => Promise.race([p.then(v => "resolved " + JSON.stringify(v), e => "rejected " + (e.code ?? e.name)), new Promise(r => setTimeout(() => r("NEVER SETTLES"), 3000))]);
for (const way of Object.keys(ways)) {
  console.log(way);
  console.log("   text()      ", await settle(new Response(body(way)).text()));
  console.log("   reader loop ", await settle((async () => { const r = new Response(body(way)).body.getReader(); while (!(await r.read()).done); return "drained"; })()));
}
way consumer Bun 1.3.14 Bun 1.4.3-canary (367d939) this PR and Node.js v26.3.0
own error with the code, tee.call({}) text() never settles resolves "first;" rejects
own error with the code, tee.call({}) reader loop never settles never settles rejects
locked getter of ReadableStream or WritableStream on {} both rejects (the error had no code) as the rows above rejects

So the swallow is older than the C++ streams rewrite (#33193) for an error that already carried the code. It is new in 1.4.0 for the two locked getters, which got the code with the rewrite.

Wider probes, all on an ASAN debug build with Malloc=1, no sanitizer report:

  • 8 things to throw (own error, frozen error, a plain object with the code, three wrong-receiver calls, and a control) x 7 places (after the first yield, before it, after the last one, in a finally at the normal end, a custom iterator whose next() rejects or throws, an async generator function as the body) x 8 consumers (text(), arrayBuffer(), reader loop, for await, pipeTo(), Bun.write(), fetch upload, Bun.serve response). The 1.4.3 canary swallows 336 of 336 cells with the code. This PR: each cell has the outcome of the plain TypeError control, none hangs, and there is no unhandled rejection.
  • 11 consumers (the above plus request.text(), Readable.fromWeb(), a node:http response fed through pipeline) after a 6 byte and an 8 MiB prefix: the 1.4.3 canary swallows 22 of 22 cells, this PR 0.
  • Cases where the consumer goes away: reader.cancel() with and without a reason, a for await that breaks, a client that aborts a Bun.serve stream, a raw socket destroyed in the middle of the response, an upload whose server closes early, an upload aborted by its signal. In each case the generator ends quietly, throws a plain Error from its finally, or throws an error with the code from its finally. The 21 outcomes are the same before and after this change, with no unhandled rejection. These cases do not reach the error tail: the cancel() or close() hook of the source sets m_cancelled first.

Producers of the code. Under src/jsc/bindings/webcore/streams only the brand checks of the spec classes throw it (ReadableStream, WritableStream, TransformStream, their readers, writers and controllers, the queuing strategies, the compression and text streams). The pump calls none of them. JSDirectStreamController.cpp, BunStreamSource.cpp, BunStreamConsumers.cpp and the generated JSSink.cpp do not throw it. On the sink side only JSSink::get_this (src/runtime/webcore/Sink.rs:473) throws it, for CAST_FAILED. ${name}__fromJS (src/codegen/generate-jssink.ts) returns that value only when this is neither the sink nor its controller, and the pump calls write, flush and end with the controller as this. The five methods of the JS-facing JSDirectStreamController are bound functions and are no-ops once the source ended.

The test: the generator of the upload throws once its request is at the server, so the server side is deterministic. With the src/ of main (4ada08b) the ERR_INVALID_THIS case fails: text() and the upload resolve, the server reads complete, 6 bytes, and read() never settles. The ERR_INVALID_STATE case passes there. With this change both pass, 20 of 20 runs on a Linux ASAN build and 20 of 20 on a Windows x64 debug build. An upload that never settles ends in the timeout of the test runner. The test has no timer of its own.

Self-review, three concerns, all addressed. (1) Can a sink or controller still send the code to the pump when the consumer is gone? No, see the list of producers above. (2) The first version of the test repeated the #43714 test. The two are now one test.each. (3) The upload raced the throw against the connect. The generator now throws once its request is at the server, and the test asserts what the server read. The review bot raised two optional points on the test. The server-side assertion covers the first. For the second (an upload that never settles has no diagnostics) I changed the comment and did not add a timer, because the test rules of this repo do not allow one.

Suites run with the debug build: test/js/bun/http/async-iterator-stream.test.ts (97 pass), test/js/web/fetch/body-async-iterator.test.ts, test/js/bun/http/serve-direct-readable-stream.test.ts (169 pass), test/js/bun/http/serve-error-handler-stream.test.ts, test/js/web/fetch/body-clone.test.ts, test/js/web/streams/streams.test.js (614 pass), test/js/web/fetch/body.test.ts, test/js/web/fetch/body-stream.test.ts (9086 pass), test/js/bun/http/bun-server.test.ts. The two coded-error tests also pass with BUN_JSC_validateExceptionChecks=1.

…ble body

asyncIterFinishWithError treated every error with the ERR_INVALID_THIS code as
a consumer that is gone: it called iterator.return() and resolved the pull. The
check dates from when a detached sink threw that code on write(). Since #15234
a detached sink throws a plain Error, and a sink whose destination closed
returns 0, so the check matched only errors from the iterator itself, for
example a wrong-receiver call in the generator. The body then ended as if it
were complete, or its reader never settled.

The error now takes the same path as every other error. errorCodeIs() has no
caller left and goes with it.
@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 4 billable files and costs up to $1.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 5 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 854e6d4d-bb5d-477c-8a4e-10c761b64280

📥 Commits

Reviewing files that changed from the base of the PR and between 4ada08b and 56f2c5b.

📒 Files selected for processing (4)
  • src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp
  • src/jsc/bindings/webcore/streams/WebStreamsInternals.h
  • src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
  • test/js/bun/http/async-iterator-stream.test.ts

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

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced with the script in the Notes of the PR body, on Bun 1.4.3-canary (367d939) and on a debug build of main (4ada08b). When the generator throws an error with code: "ERR_INVALID_THIS", new Response(gen()).text() resolves "first;", a fetch upload is accepted as a complete request, and a read() loop never settles. Node.js v26.3.0 and this branch reject in each case.

PR: #43758

@robobun

robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:08 PM PT - Sep 21st, 2026

✅ @robobun, your commit 56f2c5b0ae4d1e9d873ccd5be28c5ebe4d888ee6 passed in Build #119576! 🎉


🧪   To try this PR locally:

bunx bun-pr 43758

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

bun-43758 --bun

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings on the test, I also checked the PR's claim that no native sink still throws an error with this code into the pump: in src/runtime/webcore/Sink.rs, get_this throws ERR_INVALID_THIS only for CAST_FAILED (wrong receiver), while a detached sink throws a plain Error with no code or returns 0 bytes for CONTROLLER_DETACHED, so the deleted branch in asyncIterFinishWithError has no in-tree producer left. errorCodeIs has no remaining callers outside the removed site.

Extended reasoning...

The change deletes the ERR_INVALID_THIS special case from the async-iterable ReadableStream source in src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp, removes the now-unused errorCodeIs helper, and generalizes the existing coded-error test into a test.each with an added upload path; it touches no security-sensitive surface. The two inline findings are about assertion strength in the test, not the C++ fix, and the ruled-out check confirms no native sink path routes this code to the pump.

Comment thread test/js/bun/http/async-iterator-stream.test.ts Outdated
Comment thread test/js/bun/http/async-iterator-stream.test.ts Outdated
The generator now throws once its request is at the server, and the test
records whether the server read a complete body or an aborted one.
Comment thread src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp 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 — a small, self-contained deletion of a by-code special case in the async-iterable stream source, with the test extended to cover both codes and the upload path.

What was reviewed:

  • Confirmed from src/runtime/webcore/Sink.rs (get_this) that a detached sink now throws a plain Error or returns 0 bytes, and ERR_INVALID_THIS is only thrown for a wrong receiver — so the removed early return no longer guarded any real sink path.
  • Checked that errorCodeIs has no remaining callers in src/ after its removal from WebStreamsMisc.cpp / WebStreamsInternals.h.
  • Read the remaining error tail: iterator.throw() is still followed by RETURN_IF_EXCEPTION, and the cancelled check still resolves instead of rejecting.
  • The server-side request body: aborted assertion added in the second commit addresses the earlier gap where the test could not distinguish an accepted truncated upload from a rejected one.
Extended reasoning...

The change removes a special case in asyncIterFinishWithError (src/jsc/bindings/webcore/streams/BunAsyncIterableSource.cpp) that swallowed iterator errors carrying code ERR_INVALID_THIS, deletes the now-unused errorCodeIs helper, and generalizes the existing ERR_INVALID_STATE test into a test.each over both codes plus a fetch upload with a server-side assertion. It touches no auth, crypto, or input-parsing surface; the user-visible effect is that such errors now fail the body like every other error, matching Node and the precedent set by #43714 for ERR_INVALID_STATE. Approval is based on the change being a pure deletion whose justification I verified from Sink.rs, no remaining callers of the deleted helper, no CODEOWNERS entry covering the changed files, and the prior review's substantive point being addressed in a follow-up commit. I did not run the test locally (no debug build was present in this checkout).

@Jarred-Sumner
Jarred-Sumner merged commit 320c84c into main Sep 22, 2026
6 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/9de2ca11/async-iterable-invalid-this-swallow branch September 22, 2026 23:04
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