From 97aa4dcffe5b3f80a0cabf5ef38bf70dc8dbba51 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 17:10:38 -0700 Subject: [PATCH 01/31] Bun.serve direct streams: flush on controller.close(), end the response once, call cancel() only on a client abort --- src/codegen/generate-jssink.ts | 6 +- .../webcore/streams/BunStreamSource.cpp | 10 +- src/runtime/webcore/streams.rs | 107 ++- .../http/serve-direct-readable-stream.test.ts | 772 ++++++++++++++++-- 4 files changed, 781 insertions(+), 114 deletions(-) diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index d0788ab4c004..ed1ebd525205 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -398,7 +398,7 @@ static JSC::EncodedJSValue ${controller}__closeWithReason(JSC::JSGlobalObject* l ${name}__close(lexicalGlobalObject, ptr, reason); // detach() must still fire onClose (it transitions the direct - // ReadableStream to closed/errored and calls underlyingSource.cancel()) + // ReadableStream to closed and settles its close promise) // even if the native close threw, matching the pre-reorder behaviour. // Stash and rethrow around it; the sink's error wins over any onClose // error. @@ -465,7 +465,7 @@ JSC_DEFINE_HOST_FUNCTION(${controller}__end, (JSC::JSGlobalObject * lexicalGloba auto result = ${name}__endWithSink(ptr, lexicalGlobalObject); // detach() must still fire onClose (it transitions the direct - // ReadableStream to closed/errored and calls underlyingSource.cancel()) + // ReadableStream to closed and settles its close promise) // even if the native end threw, matching the pre-reorder behaviour. // Stash and rethrow around it; the sink's error wins over any onClose // error. @@ -691,6 +691,7 @@ void JS${controllerName}::detach() { JSC::MarkedArgumentBuffer arguments; arguments.append(readableStream.value()); arguments.append(jsUndefined()); + arguments.append(JSC::jsBoolean(false)); // sinkClosed: the source ended or the owner detached AsyncContextFrame::call(globalObject, onClose.value(), JSC::jsUndefined(), arguments); RELEASE_AND_RETURN(scope, void()); } @@ -1020,6 +1021,7 @@ extern "C" void JSSinkController__onClose(JSC::EncodedJSValue controllerValue, J auto readableStream = controller->m_weakReadableStream.get(); arguments.append(readableStream ? readableStream : JSC::jsUndefined()); arguments.append(JSC::JSValue::decode(reason)); + arguments.append(JSC::jsBoolean(true)); // sinkClosed: the native sink closed underneath the source AsyncContextFrame::call(globalObject, function, JSC::jsUndefined(), arguments); RELEASE_AND_RETURN(scope, void()); } diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index 9f48737e6e77..69cd765d0ecb 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -752,7 +752,7 @@ static void nativeSourcePullRejected(JSC::VM& vm, JSGlobalObject* globalObject, // readDirectStreamOnClose. The state-mutation half runs only when a stream is provided; everything // that can throw (the sink's end(), the user's cancel(reason)) runs after the state is final, and a // throw from it propagates to whoever closed. -static void readDirectStreamCloseImpl(JSC::VM& vm, JSGlobalObject* globalObject, JSDirectSinkCloseState* state, JSValue streamValue, JSValue reason) +static void readDirectStreamCloseImpl(JSC::VM& vm, JSGlobalObject* globalObject, JSDirectSinkCloseState* state, JSValue streamValue, JSValue reason, bool sinkClosed) { auto scope = DECLARE_THROW_SCOPE(vm); JSObject* sinkController = state->sinkController(); @@ -788,7 +788,8 @@ static void readDirectStreamCloseImpl(JSC::VM& vm, JSGlobalObject* globalObject, invokeMethod(vm, globalObject, sinkController, builtinNames(vm).endPublicName(), noArgs); RETURN_IF_EXCEPTION(scope, ); } - if (source) { + // Only a sink that closed underneath the source (peer abort, write error) cancels it, not its own end()/close(). + if (source && sinkClosed) { JSObject* cancelFunction = source->cancelFunction(); if (!cancelFunction) return; @@ -817,7 +818,7 @@ JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, JSObject* pull = source->pullFunction(); if (!pull) { - readDirectStreamCloseImpl(vm, globalObject, state, jsUndefined(), jsUndefined()); + readDirectStreamCloseImpl(vm, globalObject, state, jsUndefined(), jsUndefined(), false); RETURN_IF_EXCEPTION(scope, {}); return jsUndefined(); } @@ -1407,7 +1408,8 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundReadDirectStreamOnClose, (JSGl auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto* state = uncheckedDowncast(callFrame->argument(0)); - Bun::WebStreams::readDirectStreamCloseImpl(vm, globalObject, state, callFrame->argument(1), callFrame->argument(2)); + bool sinkClosed = callFrame->argument(3).isTrue(); + Bun::WebStreams::readDirectStreamCloseImpl(vm, globalObject, state, callFrame->argument(1), callFrame->argument(2), sinkClosed); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index e50885ccdc22..2d772eba9c4c 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -903,9 +903,7 @@ impl SourceHandle { Some(err) => err.to_js(global), None => JSValue::UNDEFINED, }; - crate::dispatch::fold(::bun_jsc::call_check_slow(global, || { - controller_abi::on_close(cpp, reason) - })); + Self::close_js_controller(global, cpp, reason); } SourceHandle::ByteStream(p) => p.on_close(err), SourceHandle::FileReader(p) => p.on_close(err), @@ -920,6 +918,26 @@ impl SourceHandle { } } + /// [`close`](Self::close), with `reason` as the piped JS stream's cancel() argument. + pub fn abort(&mut self, reason: CommonAbortReason) { + match *self { + SourceHandle::JSController(cpp) => { + let global = VirtualMachine::get().global(); + if global.has_exception() { + return; + } + Self::close_js_controller(global, cpp, reason.to_js(global)); + } + _ => self.close(None), + } + } + + fn close_js_controller(global: &JSGlobalObject, cpp: JSValue, reason: JSValue) { + crate::dispatch::fold(::bun_jsc::call_check_slow(global, || { + controller_abi::on_close(cpp, reason) + })); + } + pub fn ready(&mut self, _amount: Option, _offset: Option) { match *self { SourceHandle::None => {} @@ -1019,8 +1037,7 @@ pub struct HTTPServerWritable { /// `flush_promise()` → `pending.run()`. pub(crate) pending: WritablePending, pub(crate) wrote_at_start_of_flush: BlobSizeType, - // JSC_BORROW: process-lifetime VM global; `None` until `flush_from_js`/ - // `end_from_js` install it. Safe `Deref` via `BackRef`. + // JSC_BORROW: process-lifetime VM global, set at construction. Safe `Deref` via `BackRef`. pub global_this: Option>, pub(crate) high_water_mark: BlobSizeType, @@ -1208,6 +1225,13 @@ impl HTTPServerWritable { self.has_backpressure && self.end_len > 0 } + /// uWS `end()`/`try_end()` just completed; `res` is still live here, so release the request-body pause too. + fn mark_response_ended(&mut self, res: uws::AnyResponse) { + self.has_backpressure = false; + self.ended_response = true; + res.resume(); + } + /// `len` bytes were accepted by `send`/`send_readable`. When uWS reports /// the socket is now backed up, return a pending Promise for JS-controller /// sources (direct-stream `pull` can `await controller.write()`; the @@ -1254,7 +1278,7 @@ impl HTTPServerWritable { self.handle_first_write_if_necessary(); let success = res.try_end(buf, self.end_len, false); if success { - self.has_backpressure = false; + self.mark_response_ended(res); self.handle_wrote(self.end_len); } else if self.res.is_some() { self.has_backpressure = true; @@ -1277,7 +1301,7 @@ impl HTTPServerWritable { // `on_writable()` below. if self.requested_end { res.end(buf, false); - self.has_backpressure = false; + self.mark_response_ended(res); } else { self.has_backpressure = matches!(res.write(buf), uws::WriteResult::Backpressure(_)); } @@ -1329,7 +1353,7 @@ impl HTTPServerWritable { let end_len = self.end_len; let success = res.try_end(&self.buffer[base..], end_len, false); if success { - self.has_backpressure = false; + self.mark_response_ended(res); self.handle_wrote(end_len); } else if self.res.is_some() { self.has_backpressure = true; @@ -1349,7 +1373,7 @@ impl HTTPServerWritable { // See `send_without_auto_flusher`. if self.requested_end { res.end(&self.buffer[base..], false); - self.has_backpressure = false; + self.mark_response_ended(res); } else { self.has_backpressure = matches!( res.write(&self.buffer[base..]), @@ -1451,14 +1475,10 @@ impl HTTPServerWritable { total_written = chunk_len as u64; if self.requested_end { + debug_assert!(self.ended_response); if let Some(res) = self.any_res() { res.clear_on_writable(); - // Release any request-body pause while `res` is live (see `end_already_responded_stream`). - res.resume(); } - // `send_readable` drained the parked `try_end`, so uWS has - // `markDone()`d the response and dropped its `onAborted`. - self.ended_response = true; self.source.close(None); self.flush_promise(); self.finalize(); @@ -1764,19 +1784,27 @@ impl HTTPServerWritable { self.unregister_auto_flusher(); } - /// In this case, it's always an error - pub(crate) fn end(&mut self, err: Option) -> bun_sys::Result<()> { - bun_core::scoped_log!(HTTPServerWritableLog, "end({:?})", err); + /// `controller.close()`: flush and end like `controller.end()`; the owner awaits any parked `pending_flush`. + pub(crate) fn end(&mut self, _err: Option) -> bun_sys::Result<()> { + let global_this = self + .global_this + .expect("HTTPServerWritable.global_this used before init"); + self.end_from_js(&global_this).map(|_| ()) + } + + /// The source failed (`close(error)`, an errored pump): end the body unflushed so the owner can truncate it. + pub(crate) fn fail(&mut self) { + bun_core::scoped_log!(HTTPServerWritableLog, "fail()"); if self.requested_end { - return bun_sys::Result::Ok(()); + return; } if self.is_done() || self.res.is_none() || self.any_res().unwrap().has_responded() { - self.source.close(err); + self.source.close(None); self.mark_done(); self.finalize(); - return bun_sys::Result::Ok(()); + return; } self.requested_end = true; @@ -1784,14 +1812,10 @@ impl HTTPServerWritable { self.end_len = readable_len; if readable_len == 0 { - self.source.close(err); + self.source.close(None); self.mark_done(); - // we do not close the stream here - // this.res.endStream(false); self.finalize(); - return bun_sys::Result::Ok(()); } - bun_sys::Result::Ok(()) } pub(crate) fn end_from_js(&mut self, global_this: &JSGlobalObject) -> bun_sys::Result { @@ -1822,19 +1846,12 @@ impl HTTPServerWritable { value.protect(); return bun_sys::Result::Ok(value); } - } else { - if let Some(res) = self.any_res() { - res.end(b"", false); - } + } else if let Some(res) = self.any_res() { + res.end(b"", false); + self.mark_response_ended(res); } - if let Some(res) = self.any_res() { - // Release any request-body pause while `res` is live (see `end_already_responded_stream`). - res.resume(); - } - // Both branches above fully ended the response through uWS, which - // `markDone()`s it and drops its `onAborted`. - self.ended_response = true; + debug_assert!(self.ended_response); self.mark_done(); self.flush_promise(); self.source.close(None); @@ -1869,7 +1886,7 @@ impl HTTPServerWritable { // no reference into the allocation may be live across the call. // SAFETY: as above; `source` is copied out before the close. let mut source = unsafe { (*this).source }; - source.close(None); + source.abort(CommonAbortReason::ConnectionClosed); } fn unregister_auto_flusher(&mut self) { @@ -1912,14 +1929,10 @@ impl HTTPServerWritable { self.auto_flusher.registered.set(false); if self.requested_end { + debug_assert!(self.ended_response); if let Some(res) = self.any_res() { res.clear_on_writable(); - // Release any request-body pause while `res` is live (see `end_already_responded_stream`). - res.resume(); } - // `send_readable` drained the parked `try_end`/`end`, so uWS has - // `markDone()`d the response and dropped its `onAborted`. - self.ended_response = true; self.source.close(None); self.flush_promise(); self.finalize(); @@ -1967,6 +1980,7 @@ impl HTTPServerWritable { // stream is freed after FIN, leave it dangling). res.clear_on_writable(); } + // Sends a tail parked by `fail()`; that `res.end()` sets `ended_response`. let _ = self.flush_no_wait(); self.set_done(); @@ -2074,6 +2088,15 @@ impl crate::webcore::sink::JsSinkType for HTTPServerWritable bun_sys::Result<()> { + // SAFETY: caller contract; `fail` does not free the sink. + unsafe { (*this).fail() }; + bun_sys::Result::Ok(()) + } fn end_from_js(&mut self, global: &JSGlobalObject) -> bun_sys::Result { Self::end_from_js(self, global) } diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index 9179b054dca9..dea627525eb7 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; import net from "node:net"; +import { baseHeaders, frame, RawH2, T } from "./serve-http2-helpers"; test("HTTPResponseSink displays correct message", async () => { let leakedCtrl: any; @@ -38,10 +39,10 @@ test("HTTPResponseSink displays correct message", async () => { // on_resolve_stream reaction frees the sink when microtasks drain during // onClose, leaving endWithSink() to dereference a freed HTTPServerWritable. // -// The repro forces the microtask drain from inside the stream's cancel() -// callback (which is what detach()'s onClose invokes for a direct stream). -// Under ASAN this is a heap-use-after-free without the fix; in release it -// segfaults on the scrubbed buffer pointer. +// The repro forced the microtask drain from inside the stream's cancel() +// callback, which detach()'s onClose invoked for a direct stream at the time. +// The source's own end() no longer calls cancel(), so no user code runs inside +// detach() any more; the test stays as a guard for end() after a settled pull(). test.skipIf(!isASAN)( "controller.end() after pull() resolved does not use the sink after free", async () => { @@ -67,9 +68,9 @@ test.skipIf(!isASAN)( return p; }, cancel() { - // Reached from controller.end() -> detach() -> onClose. - // Draining here runs on_resolve_stream, which destroys the - // native sink while endWithSink() still holds a pointer to it. + // Was reached from controller.end() -> detach() -> onClose. + // Draining here ran on_resolve_stream, which destroyed the + // native sink while endWithSink() still held a pointer to it. drainMicrotasks(); }, }), @@ -91,9 +92,9 @@ test.skipIf(!isASAN)( resolvePull(); await pullSettled.promise; - // controller.end(): stashes ptr, detach() fires onClose -> cancel() - // -> drainMicrotasks() -> on_resolve_stream frees the sink, then - // endWithSink(ptr) runs on the freed allocation. + // controller.end() after pull() settled: endWithSink(ptr) must run on a + // live sink, and detach()'s onClose must not run user code that could + // free it first. capturedController.end(); await drained; @@ -434,7 +435,11 @@ describe("direct stream whose pull() runs while its Response is being attached", `; // pull() parks after close(): the response completes inside the dispatch, - // the request itself stays pending until the gate opens. + // the request itself stays pending until the gate opens. The microtask yield + // before close() is what keeps it pending: the Response is attached with + // pull() still in flight, so the request waits for pull() to settle. A + // close() before the first yield completes the request on the spot, the + // same as end(). const closeThenPark = ` const gate = Promise.withResolvers(); let request; @@ -450,6 +455,7 @@ describe("direct stream whose pull() runs while its Response is being attached", type: "direct", async pull(controller) { controller.write("seed"); + await Promise.resolve(); controller.close(); await gate.promise; }, @@ -638,15 +644,14 @@ describe("direct stream whose pull() runs while its Response is being attached", // end() completes the response, so uWS has already dropped the abort // callback when the stop closes the socket: nothing aborts the request, and - // it has to be released the way a completed response normally is. + // it has to be released the way a completed response normally is. The + // source ended the stream itself, so its cancel() is not called. test.concurrent.each(handlers)( "server.stop(true) from inside pull() after end() still releases the request (%s)", async (_label, handler) => { const result = await run(stopFromPull(handler, `events.push("end()"); controller.end(); stop();`)); expect(result).toEqual({ - stdout: - JSON.stringify({ events: ["end()", "cancel()", "stop(true)", "stop(true) returned"], pendingRequests: 0 }) + - "\n", + stdout: JSON.stringify({ events: ["end()", "stop(true)", "stop(true) returned"], pendingRequests: 0 }) + "\n", stderr: "", exitCode: 0, }); @@ -982,6 +987,40 @@ describe("end() under transport backpressure over h3", () => { const res = await h3fetch(server); expect(await res.text()).toBe("hey"); }); + + // close() must park the same pending flush as end(). It used to leave the + // tail to the auto-flusher instead, and when that try_end hit backpressure + // nothing was parked for the request to wait on, so the sink was torn down + // and the body came back empty even from an async pull(). + test("async pull() that closes synchronously", async () => { + using server = serveH3( + () => + new ReadableStream({ + type: "direct", + async pull(c: any) { + c.write("hey"); + c.close(); + }, + } as any), + ); + const res = await h3fetch(server); + expect(await res.text()).toBe("hey"); + }); + + test("sync pull() that closes synchronously", async () => { + using server = serveH3( + () => + new ReadableStream({ + type: "direct", + pull(c: any) { + c.write("hey"); + c.close(); + }, + } as any), + ); + const res = await h3fetch(server); + expect(await res.text()).toBe("hey"); + }); }); // The controller's detach() used to skip the close callback when it was @@ -1033,64 +1072,665 @@ test("sync pull() under AsyncLocalStorage releases the request on end()", async }); // https://github.com/oven-sh/bun/issues/36940 -// close() while the sink still holds unflushed bytes deferred the final send -// to the auto-flusher, which ended the response through uWS (writing the -// terminating 0\r\n\r\n chunk) and then finalize() ended the stream a second -// time, writing another terminator. On a keep-alive connection the stray -// terminator is parsed as the start of the next response. -test("close() with unflushed data writes the chunked terminator exactly once", async () => { +// close() while the sink still holds unflushed bytes must put exactly one +// chunked last-chunk (0\r\n\r\n) on the wire, whichever path performs the +// final send and however pull() settles around it. Ending the response through +// uWS `end()` already writes the terminator; a second `end_stream()` after that +// wrote another one. On a keep-alive connection the stray terminator is parsed +// as the start of the next response (node: HPE_INVALID_CONSTANT, curl: +// "Leftovers after chunking"). +describe("close() with unflushed data writes the chunked terminator exactly once", () => { + // An event-loop turn, so pull() settles from a later task than the one that + // attached the stream. Not a time wait. + const nextTask = () => new Promise(resolve => setImmediate(() => resolve())); + const x800 = Buffer.alloc(800, "x").toString(); + + // One keep-alive connection: GET / (the chunked stream), then GET /plain once + // the first response terminated (or right away if the server closes). Decodes + // the first body strictly (RFC 9112 §7.1: it ends at the first last-chunk) + // and returns whatever follows it on the connection. + async function exchange(server: { port: number }) { + const { promise, resolve, reject } = Promise.withResolvers(); + const sock = net.connect(server.port, "127.0.0.1"); + let raw = ""; + let sentSecond = false; + sock.setNoDelay(true); + sock.on("connect", () => { + sock.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"); + }); + sock.on("data", d => { + raw += d.toString("latin1"); + if (!sentSecond && raw.includes("\r\n0\r\n\r\n")) { + sentSecond = true; + sock.write("GET /plain HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + } + }); + sock.on("close", () => resolve(raw)); + sock.on("error", reject); + const data = await promise; + + const headerEnd = data.indexOf("\r\n\r\n"); + expect(data.slice(0, headerEnd).toLowerCase()).toContain("transfer-encoding: chunked"); + let offset = headerEnd + 4; + let decoded = ""; + let terminated = false; + while (offset < data.length) { + const lineEnd = data.indexOf("\r\n", offset); + expect(lineEnd).toBeGreaterThan(-1); + expect(data.slice(offset, lineEnd)).toMatch(/^[0-9a-fA-F]+$/); + const size = parseInt(data.slice(offset, lineEnd), 16); + offset = lineEnd + 2; + if (size === 0) { + // No trailer section: the last-chunk is followed by the final CRLF. + expect(data.slice(offset, offset + 2)).toBe("\r\n"); + offset += 2; + terminated = true; + break; + } + decoded += data.slice(offset, offset + size); + expect(data.slice(offset + size, offset + size + 2)).toBe("\r\n"); + offset += size + 2; + } + return { decoded, terminated, rest: data.slice(offset) }; + } + + function serve(pull: (c: any) => unknown) { + return Bun.serve({ + port: 0, + fetch(req) { + if (new URL(req.url).pathname === "/plain") { + return new Response("ok"); + } + return new Response(new ReadableStream({ type: "direct", pull } as any), { + headers: { "content-type": "text/plain" }, + }); + }, + }); + } + + const shapes: Record unknown }> = { + // #36940: every await is a microtask, so pull() settles inside the + // attach-time drain. + "await write() x8, close()": { + body: x800, + async pull(c) { + for (let i = 0; i < 8; i++) { + await c.write(new Uint8Array(100).fill(0x78)); + } + c.close(); + }, + }, + // The rest settle pull() from a later task with an earlier chunk already + // on the wire. + "write, await flush(), task, write, close()": { + body: "hello-world", + async pull(c) { + c.write("hello"); + await c.flush(); + await nextTask(); + c.write("-world"); + c.close(); + }, + }, + "write, task, write, close() (no flush)": { + body: "hello-world", + async pull(c) { + c.write("hello"); + await nextTask(); + c.write("-world"); + c.close(); + }, + }, + "write, flush() not awaited, task, write, close()": { + body: "hello-world", + async pull(c) { + c.write("hello"); + c.flush(); + await nextTask(); + c.write("-world"); + c.close(); + }, + }, + "write, await flush(), task, write, microtask, close()": { + body: "hello-world", + async pull(c) { + c.write("hello"); + await c.flush(); + await nextTask(); + c.write("-world"); + await Promise.resolve(); + c.close(); + }, + }, + "SSE loop: (write, await flush(), task) x3, write, close()": { + body: "ev0\nev1\nev2\nbye", + async pull(c) { + for (let i = 0; i < 3; i++) { + c.write("ev" + i + "\n"); + await c.flush(); + await nextTask(); + } + c.write("bye"); + c.close(); + }, + }, + "sync pull(), then write + close() from a later task": { + body: "hello-late", + pull(c) { + c.write("hello"); + setImmediate(() => { + c.write("-late"); + c.close(); + }); + }, + }, + }; + + test.concurrent.each(Object.keys(shapes))("%s", async name => { + const { body, pull } = shapes[name]; + using server = serve(pull); + const { decoded, terminated, rest } = await exchange(server); + expect({ decoded, terminated }).toEqual({ decoded: body, terminated: true }); + // The very next bytes are the second response, framed by Content-Length, + // and nothing else is on the connection. + expect(rest.slice(0, 15)).toBe("HTTP/1.1 200 OK"); + expect(rest).toEndWith("\r\n\r\nok"); + expect(rest.split("\r\n0\r\n\r\n").length - 1).toBe(0); + }); + + // close(error) does not flush: the tail stays parked and the request's own + // teardown performs the final `res.end()`. That send must also count as the + // end of the response. Only the framing is pinned here, not whether an + // errored source still delivers its tail. + test.concurrent("write, await flush(), task, write, close(error)", async () => { + using server = serve(async c => { + c.write("hello"); + await c.flush(); + await nextTask(); + c.write("-world"); + c.close(new Error("boom")); + }); + const { decoded, terminated, rest } = await exchange(server); + expect(decoded.startsWith("hello")).toBe(true); + if (terminated) { + expect(rest.slice(0, 15)).toBe("HTTP/1.1 200 OK"); + expect(rest).toEndWith("\r\n\r\nok"); + } else { + expect(rest).toBe(""); + } + }); +}); + +// A direct stream's cancel() is the "consumer went away" hook. The source's own +// end()/close(), and a pull() that settles, complete the response without a +// consumer going away, so none of them may call it. Before, the sink's close +// handler called cancel(undefined) on every one of these, so a source could not +// tell a client abort from a delivered response. +describe("cancel() only runs when the client goes away", () => { + const completions = { + "sync write + close()": () => ({ + pull(c: any) { + c.write("hello"); + c.close(); + }, + }), + "sync write + end()": () => ({ + pull(c: any) { + c.write("hello"); + c.end(); + }, + }), + "async write + flush() + end()": () => ({ + async pull(c: any) { + c.write("hello"); + await c.flush(); + c.end(); + }, + }), + "async write + close()": () => ({ + async pull(c: any) { + c.write("hello"); + c.close(); + }, + }), + "async pull() that settles without end()": () => ({ + async pull(c: any) { + c.write("hello"); + await c.flush(); + }, + }), + "end() after pull() yielded to the event loop": () => ({ + async pull(c: any) { + c.write("hel"); + await new Promise(resolve => setImmediate(resolve)); + c.write("lo"); + c.end(); + }, + }), + }; + + for (const [name, source] of Object.entries(completions)) { + test(name, async () => { + const cancelCalls: unknown[][] = []; + using server = Bun.serve({ + port: 0, + fetch() { + return new Response( + new ReadableStream({ + type: "direct", + ...source(), + cancel(...args: unknown[]) { + cancelCalls.push(args); + }, + } as any), + ); + }, + }); + const res = await fetch(server.url); + expect(await res.text()).toBe("hello"); + // The request is released after the body went out; by then every path + // that used to call cancel() has run. + while (server.pendingRequests > 0) await Bun.sleep(0); + expect(cancelCalls).toEqual([]); + }); + } + + // The client closing the connection is a cancel, and the reason says so: the + // same AbortError that request.signal.reason carries. A default (non-direct) + // stream body goes through the same sink close, so it gets the reason too. + test.each(["direct", "default"] as const)( + "client disconnect cancels a %s stream with the connection-closed AbortError", + async kind => { + const written = Promise.withResolvers(); + const cancelled = Promise.withResolvers(); + let signal!: AbortSignal; + using server = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch(req) { + signal = req.signal; + const hold = Promise.withResolvers(); + const source = + kind === "direct" + ? { + type: "direct", + async pull(c: any) { + c.write("partial"); + await c.flush(); + written.resolve(); + await hold.promise; + }, + } + : { + async pull(c: ReadableStreamDefaultController) { + c.enqueue(new TextEncoder().encode("partial")); + written.resolve(); + await hold.promise; + }, + }; + return new Response( + new ReadableStream({ + ...source, + cancel(...args: unknown[]) { + cancelled.resolve(args); + hold.resolve(); + }, + } as any), + ); + }, + }); + + const socket = net.connect(server.port, "127.0.0.1"); + await new Promise(resolve => socket.once("connect", () => resolve())); + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await new Promise(resolve => socket.once("data", () => resolve())); + await written.promise; + socket.destroy(); + + const args = await cancelled.promise; + expect(args).toHaveLength(1); + const reason = args[0] as DOMException; + expect(reason).toBeInstanceOf(DOMException); + expect({ name: reason.name, message: reason.message }).toEqual({ + name: "AbortError", + message: "The connection was closed.", + }); + const signalReason = signal.reason; + expect({ name: signalReason.name, message: signalReason.message }).toEqual({ + name: "AbortError", + message: "The connection was closed.", + }); + }, + ); +}); + +// controller.close() only marked the end of the body: the bytes still buffered +// below the high-water mark were left for someone else to send. A synchronous +// pull() lost them (empty body), and a pull() that had yielded to the event loop +// before close() had the auto-flusher end the response and then the request end +// it again, so a keep-alive connection carried a stray `0\r\n\r\n` into the +// next response. close() now flushes and ends the response exactly like end(). +test("controller.close() sends the buffered bytes and ends the response once, like end()", async () => { + const big = Buffer.alloc(512 * 1024, "x").toString(); + const bodies: Record any> = { + "/sync-close": () => ({ + pull(c: any) { + c.write("hello"); + c.close(); + }, + }), + "/sync-flush-close": () => ({ + pull(c: any) { + c.write("hel"); + c.flush(); + c.write("lo"); + c.close(); + }, + }), + "/async-close": () => ({ + async pull(c: any) { + c.write("hello"); + c.close(); + }, + }), + "/yield-close": () => ({ + async pull(c: any) { + c.write("hel"); + await new Promise(resolve => setImmediate(resolve)); + c.write("lo"); + c.close(); + }, + }), + "/empty-close": () => ({ + pull(c: any) { + c.close(); + }, + }), + "/big-close": () => ({ + pull(c: any) { + c.write(big); + c.close(); + }, + }), + }; + const expected: Record = { + "/sync-close": "hello", + "/sync-flush-close": "hello", + "/async-close": "hello", + "/yield-close": "hello", + "/empty-close": "", + "/big-close": big, + }; + using server = Bun.serve({ port: 0, + idleTimeout: 0, fetch(req) { - if (new URL(req.url).pathname === "/plain") { - return new Response("ok"); - } - return new Response( - new ReadableStream({ - type: "direct", - async pull(c) { - // Write enough for chunked encoding, in pieces small enough that - // bytes are still buffered below the high-water mark when close() - // runs. - for (let i = 0; i < 8; i++) { - await c.write(new Uint8Array(100).fill(0x78)); - } - c.close(); - }, - }), - { headers: { "content-type": "text/plain" } }, - ); + const path = new URL(req.url).pathname; + return new Response(new ReadableStream({ type: "direct", ...bodies[path]() } as any)); }, }); - const { promise, resolve, reject } = Promise.withResolvers(); - const sock = net.connect(server.port, "127.0.0.1"); - let raw = ""; - let sentSecond = false; - sock.setNoDelay(true); - sock.on("connect", () => { - sock.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"); + // One keep-alive connection and a strict parser: a stray terminating chunk or + // a missing byte shows up as a framing error on the following response. + const socket = net.connect(server.port, "127.0.0.1"); + await new Promise(resolve => socket.once("connect", () => resolve())); + let buffered = Buffer.alloc(0); + let wake: (() => void) | undefined; + socket.on("data", chunk => { + buffered = Buffer.concat([buffered, chunk]); + wake?.(); + }); + const closed = new Promise(resolve => socket.once("close", () => resolve())); + const more = () => + new Promise((resolve, reject) => { + wake = resolve; + closed.then(() => reject(new Error("connection closed with " + JSON.stringify(buffered.toString("latin1"))))); + }); + + // Returns the body of the next response on the connection and consumes it. + async function readResponse(): Promise { + while (true) { + const headEnd = buffered.indexOf("\r\n\r\n"); + if (headEnd !== -1) { + const head = buffered.subarray(0, headEnd).toString("latin1"); + if (!head.startsWith("HTTP/1.1 200")) throw new Error("bad response head: " + JSON.stringify(head)); + const contentLength = /\r\ncontent-length: *(\d+)/i.exec(head); + if (contentLength) { + const length = Number(contentLength[1]); + if (buffered.length >= headEnd + 4 + length) { + const body = buffered.subarray(headEnd + 4, headEnd + 4 + length).toString("latin1"); + buffered = buffered.subarray(headEnd + 4 + length); + return body; + } + } else if (/\r\ntransfer-encoding: *chunked/i.test(head)) { + let pos = headEnd + 4; + let body = ""; + let complete = false; + while (true) { + const lineEnd = buffered.indexOf("\r\n", pos); + if (lineEnd === -1) break; + const sizeLine = buffered.subarray(pos, lineEnd).toString("latin1"); + if (!/^[0-9a-fA-F]+$/.test(sizeLine)) throw new Error("bad chunk size line: " + JSON.stringify(sizeLine)); + const size = parseInt(sizeLine, 16); + if (size === 0) { + if (buffered.length < lineEnd + 4) break; + buffered = buffered.subarray(lineEnd + 4); + complete = true; + break; + } + if (buffered.length < lineEnd + 2 + size + 2) break; + body += buffered.subarray(lineEnd + 2, lineEnd + 2 + size).toString("latin1"); + pos = lineEnd + 2 + size + 2; + } + if (complete) return body; + } else { + throw new Error("response without a body length: " + JSON.stringify(head)); + } + } + await more(); + } + } + + const received: Record = {}; + for (let round = 0; round < 2; round++) { + for (const path of Object.keys(bodies)) { + socket.write(`GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\n`); + const body = await readResponse(); + if (round === 0) received[path] = body; + else expect(body).toBe(received[path]); + } + } + socket.destroy(); + expect(received).toEqual(expected); +}); + +// close() with bytes still buffered below the high-water mark left them for +// the auto-flusher. A pull() that closes synchronously never gets there: the +// request finalizes the sink as soon as pull() returns, so the buffered tail +// was dropped and the client got Content-Length: 0 (or, after a mid-stream +// flush(), a chunked body missing its tail). end() in the same position sent +// everything; close() must too. +describe("buffered bytes are sent when the controller finishes", () => { + type Framing = { body: string; contentLength: string | null; transferEncoding: string | null }; + const unflushed: Framing = { body: "helloworld", contentLength: "10", transferEncoding: null }; + const flushedMidway: Framing = { body: "helloworld", contentLength: null, transferEncoding: "chunked" }; + const nothingWritten: Framing = { body: "", contentLength: "0", transferEncoding: null }; + // A write of at least the sink's high-water mark (2048 bytes by default) + // goes straight to the socket instead of the buffer, so close() finds + // nothing left to send and only has to terminate the chunked response. + const highWaterMarkBody = Buffer.alloc(2048, "x").toString(); + const sentByWrite: Framing = { body: highWaterMarkBody, contentLength: null, transferEncoding: "chunked" }; + + // The writes stay below the sink's high-water mark, so whatever follows the + // last flush() is still buffered when the controller is finished. + const writeThen = (finish: "close" | "end", flushMidway: boolean) => (c: any) => { + c.write("hello"); + if (flushMidway) c.flush(); + c.write("world"); + c[finish](); + }; + + const cases: [name: string, pull: (c: any) => unknown, expected: Framing][] = [ + ["sync pull, close()", writeThen("close", false), unflushed], + ["sync pull, end()", writeThen("end", false), unflushed], + ["sync pull, flush() midway, close()", writeThen("close", true), flushedMidway], + ["sync pull, flush() midway, end()", writeThen("end", true), flushedMidway], + ["async pull, close()", async c => writeThen("close", false)(c), unflushed], + ["async pull, flush() midway, close()", async c => writeThen("close", true)(c), flushedMidway], + [ + "sync pull, single write, close()", + c => { + c.write("helloworld"); + c.close(); + }, + unflushed, + ], + // Nothing is buffered in these two, so close() takes its empty-buffer + // path; they pin down that it still ends the response. + ["sync pull, close() without writing", c => c.close(), nothingWritten], + [ + "sync pull, write at the high-water mark, close()", + c => { + c.write(highWaterMarkBody); + c.close(); + }, + sentByWrite, + ], + ]; + + // The TLS server uses a separate instantiation of the sink, so the matrix + // runs over both. + describe.each([ + ["http", {}, {}], + ["https", { tls }, { tls: { rejectUnauthorized: false } }], + ])("over %s", (_protocol, serveOptions, fetchOptions) => { + test.concurrent.each(cases)("%s", async (_name, pull, expected) => { + using server = Bun.serve({ + port: 0, + ...serveOptions, + fetch: () => new Response(new ReadableStream({ type: "direct", pull } as any)), + }); + + const response = await fetch(server.url, fetchOptions); + expect({ + body: await response.text(), + contentLength: response.headers.get("content-length"), + transferEncoding: response.headers.get("transfer-encoding"), + }).toEqual(expected); + expect(response.status).toBe(200); + }); + }); +}); + +// close() with a small tail still buffered while an earlier, larger write is +// under transport backpressure. end() used to leave that tail buffered for the +// auto-flusher or finalize(), and both refused to send it while the earlier +// write's backpressure flag was up, so finalize() wrote the chunked terminator +// (HTTP/1.1) or END_STREAM (h2) without it: a well formed body that is +// silently missing its last bytes. +describe("close() under transport backpressure sends the buffered tail", () => { + const big = Buffer.alloc(1024 * 1024, "x"); + type State = { bigBytes: number; closed: PromiseWithResolvers }; + const newState = (): State => ({ bigBytes: 0, closed: Promise.withResolvers() }); + + // Writes `big` until the sink reports backpressure (write() returns a + // promise instead of a byte count), then writes a small tail and closes + // without awaiting anything the sink handed back. + const body = (state: State, closeFromLaterTask: boolean) => + new ReadableStream({ + type: "direct", + async pull(c: any) { + for (;;) { + const wrote = c.write(big); + state.bigBytes += big.length; + if (wrote instanceof Promise) break; + if (state.bigBytes >= 512 * 1024 * 1024) throw new Error("no backpressure after 512 MB"); + } + if (closeFromLaterTask) await new Promise(done => setImmediate(done)); + c.write("tail"); + c.close(); + state.closed.resolve(); + }, + } as any); + + const modes = [ + ["in the same tick", false], + ["from a later task", true], + ] as const; + + test.each(modes)("over http/1.1, close() %s", async (_mode, later) => { + const state = newState(); + using server = Bun.serve({ port: 0, idleTimeout: 0, fetch: () => new Response(body(state, later)) }); + + const socket = net.connect(server.port, "127.0.0.1"); + const chunks: Buffer[] = []; + const done = Promise.withResolvers(); + socket.on("error", done.reject); + socket.on("data", chunk => chunks.push(chunk)); + socket.on("close", () => done.resolve()); + socket.on("connect", () => { + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + // Stop reading so the response backs up into the server's socket. + socket.pause(); + }); + await state.closed.promise; + socket.resume(); + await done.promise; + + const raw = Buffer.concat(chunks); + const headEnd = raw.indexOf("\r\n\r\n"); + expect(raw.subarray(0, headEnd).toString("latin1")).toContain("Transfer-Encoding: chunked"); + // Decode the chunked framing: every byte of `big` plus the tail, then the + // terminating chunk and nothing else. + let rest = raw.subarray(headEnd + 4); + const payload: Buffer[] = []; + let terminated = false; + while (rest.length > 0 && !terminated) { + const lineEnd = rest.indexOf("\r\n"); + expect(lineEnd).toBeGreaterThan(0); + const size = parseInt(rest.subarray(0, lineEnd).toString("latin1"), 16); + if (size === 0) { + terminated = true; + rest = rest.subarray(lineEnd + 4); + break; + } + payload.push(rest.subarray(lineEnd + 2, lineEnd + 2 + size)); + rest = rest.subarray(lineEnd + 2 + size + 2); + } + const decoded = Buffer.concat(payload); + expect({ + terminated, + trailing: rest.length, + length: decoded.length, + end: decoded.subarray(-8).toString("latin1"), + }).toEqual({ terminated: true, trailing: 0, length: state.bigBytes + 4, end: "xxxxtail" }); }); - sock.on("data", d => { - raw += d.toString("latin1"); - // Once the first (chunked) response has terminated, reuse the connection. - // The stray terminator was flushed together with the real one, so it is - // already in `raw` by the time the second response arrives. - if (!sentSecond && raw.includes("0\r\n\r\n")) { - sentSecond = true; - sock.write("GET /plain HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + + // Over h2 the backpressure is the stream's flow-control window: the client + // opens none beyond the initial 65535 bytes until close() has run. The old + // drain path also indexed the buffered tail with the transport's cumulative + // write offset here, which h2 (unlike chunked HTTP/1.1) advances on every + // write, and sent a 1-byte tail. + test.each(modes)("over h2, close() %s", async (_mode, later) => { + const state = newState(); + using server = Bun.serve({ port: 0, idleTimeout: 0, http2: true, fetch: () => new Response(body(state, later)) }); + + const client = await RawH2.connect(server.port, false); + try { + client.headers(1, baseHeaders("/")); + await state.closed.promise; + const increment = Buffer.alloc(4); + increment.writeUInt32BE(0x7fff0000); + client.write(frame(T.WINDOW_UPDATE, 0, 0, increment)); + client.write(frame(T.WINDOW_UPDATE, 0, 1, increment)); + const decoded = await client.body(1); + expect({ length: decoded.length, end: decoded.subarray(-8).toString("latin1") }).toEqual({ + length: state.bigBytes + 4, + end: "xxxxtail", + }); + } finally { + client.close(); } }); - sock.on("close", () => resolve(raw)); - sock.on("error", reject); - const data = await promise; - - // The chunked body is all "x"; the second response is framed by - // Content-Length. Exactly one terminating chunk must appear in the stream. - expect(data.split("0\r\n\r\n").length - 1).toBe(1); - // The bytes right after the terminator are the next response, not another - // terminator. - const afterTerminator = data.slice(data.indexOf("0\r\n\r\n") + 5); - expect(afterTerminator.slice(0, 12)).toBe("HTTP/1.1 200"); - expect(afterTerminator).toEndWith("ok"); }); From 8a47ae56e0aa74ab6b62a87fb909533093bf50cc Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 17:21:16 -0700 Subject: [PATCH 02/31] Sink controllers call their close/ready handler directly; an async direct pull() that resolves without close() ends the sink Folds in #41917. --- docs/runtime/streams.mdx | 9 + packages/bun-types/bun.d.ts | 11 + src/codegen/generate-jssink.ts | 186 +++++------- src/jsc/bindings/ZigGlobalObject.cpp | 4 - src/jsc/bindings/ZigGlobalObject.h | 2 - src/jsc/bindings/headers.h | 3 - .../bindings/webcore/DOMClientIsoSubspaces.h | 1 - src/jsc/bindings/webcore/DOMIsoSubspaces.h | 1 - .../webcore/streams/BunStreamConsumers.cpp | 50 ++- .../webcore/streams/BunStreamSource.cpp | 287 +++++++----------- .../webcore/streams/JSDirectSinkCloseState.h | 81 ----- .../streams/JSDirectStreamController.cpp | 7 + .../streams/JSDirectStreamController.h | 2 + .../webcore/streams/JSDirectStreamSource.h | 2 +- .../streams/JSReadStreamIntoSinkOperation.h | 4 +- .../webcore/streams/JSStreamsRuntime.cpp | 1 - .../webcore/streams/JSStreamsRuntime.h | 14 +- .../bindings/webcore/streams/StreamsForward.h | 1 - .../webcore/streams/WebStreamsInternals.h | 1 - src/runtime/webcore/Blob.rs | 170 +---------- src/runtime/webcore/FileSink.rs | 169 ++++++----- src/runtime/webcore/streams.rs | 4 +- test/js/bun/io/bun-write.test.js | 246 +++++++++++++++ .../spawn/spawn-stdin-readable-stream.test.ts | 26 ++ test/js/web/streams/streams.test.js | 98 ++++++ 25 files changed, 743 insertions(+), 637 deletions(-) delete mode 100644 src/jsc/bindings/webcore/streams/JSDirectSinkCloseState.h diff --git a/docs/runtime/streams.mdx b/docs/runtime/streams.mdx index d7a8411d7490..a72132ea1c3f 100644 --- a/docs/runtime/streams.mdx +++ b/docs/runtime/streams.mdx @@ -64,12 +64,21 @@ const stream = new ReadableStream({ pull(controller) { controller.write("hello"); controller.write("world"); + controller.close(); }, }); ``` When using a direct `ReadableStream`, the destination handles all chunk queueing. The destination receives the bytes you pass to `controller.write()`. When the stream is read from JavaScript, Bun buffers the writes and delivers them as `Uint8Array` chunks (strings are UTF-8 encoded). +### Ending the stream + +Call `controller.close()` (or `controller.end()`) once everything is written. Every destination flushes what it has buffered and finishes at that point. + +A destination that takes the whole stream (a `Response` or `Request` body, `Bun.serve`, `fetch`, `Bun.write`, `Bun.spawn` stdin, `.text()`, `.bytes()`, `Bun.readableStreamToText()`, and so on) calls `pull()` once. If that `pull()` returns a promise, the stream also ends when the promise resolves, as if `pull()` had called `controller.close()` last. If the promise rejects, the destination fails with that error. A `pull()` that returns without a promise and without closing keeps the stream open until something calls `controller.close()`. + +A reader (`getReader()`, `for await`, `pipeTo()`, `pipeThrough()`, `tee()`) treats `pull()` as a request for more data instead: it calls `pull()` again for each read until the stream is closed, like a default `ReadableStream` does. + ### Handling backpressure `controller.write()` returns the number of bytes written, or a **pending `Promise`** when the destination's internal buffer is full (for example, a slow HTTP client). The chunk is accepted either way. The promise resolves once the destination has drained, so `await`ing the result is enough: diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 1b6c212c4b75..e2acb5e0277a 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -579,6 +579,17 @@ declare module "bun" { interface DirectUnderlyingSource { cancel?: UnderlyingSourceCancelCallback; + /** + * Write the stream's data with `controller.write()` and finish with + * `controller.close()`. + * + * A destination that consumes the whole stream (a `Response` body, + * `Bun.serve`, `Bun.write`, `Bun.spawn` stdin, `.text()`, ...) calls + * `pull()` once. If it returns a promise, the stream also ends when that + * promise resolves, and fails if it rejects. A reader (`getReader()`, + * `for await`, `pipeTo()`) calls `pull()` again for each read until the + * stream is closed. + */ pull: (controller: ReadableStreamDirectController) => void | PromiseLike; type: "direct"; } diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index ed1ebd525205..3c08aff0155e 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -145,7 +145,6 @@ function header() { void detach(); - void start(JSC::JSGlobalObject *globalObject, JSC::JSValue readableStream, JSC::JSValue onPull, JSC::JSValue onClose); DECLARE_VISIT_CHILDREN; static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); @@ -174,6 +173,8 @@ JSC_DECLARE_CUSTOM_GETTER(function${name}__getter); #include "root.h" #include "JSDOMWrapper.h" +#include +#include #include #include "Sink.h" @@ -183,11 +184,25 @@ extern "C" bool JSSink_isSink(JSC::JSGlobalObject*, JSC::EncodedJSValue); namespace WebCore { using namespace JSC; -// Shared field layout for every JSReadable*SinkController so the generic -// JSSinkController__onReady/onClose externs can read m_onPull / m_onClose / -// m_weakReadableStream without a per-sink symbol. JSCell is non-polymorphic, -// so this base must stay non-virtual: a vtable pointer here would displace the -// cell header. +class JSReadableSinkControllerBase; + +// Who consumes the sink's close/ready signals. Both bodies live in BunStreamSource.cpp. +enum class SinkSource : uint8_t { + None, + DirectStream, // m_source is the JSDirectStreamSource; only onClose is used + Pump, // m_source is the JSReadStreamIntoSinkOperation +}; + +} // namespace WebCore + +namespace Bun::WebStreams { +void sinkControllerOnClose(JSC::JSGlobalObject*, WebCore::JSReadableSinkControllerBase*, JSC::JSValue reason, bool sinkClosed); +void sinkControllerOnReady(JSC::JSGlobalObject*, WebCore::JSReadableSinkControllerBase*); +} + +namespace WebCore { + +// Shared non-virtual layout of every JSReadable*SinkController; the generic JSSinkController__* externs read it. class JSReadableSinkControllerBase : public JSC::JSDestructibleObject { public: using Base = JSC::JSDestructibleObject; @@ -197,10 +212,20 @@ public: void* wrapped() const { return m_sinkPtr; } SinkID sinkId() const { return m_sinkId; } + void start(JSC::VM& vm, JSC::JSObject* readableStream, SinkSource kind, JSC::JSCell* source) + { + m_weakReadableStream = JSC::Weak(readableStream); + m_sourceKind = kind; + m_source.set(vm, this, source); + } + JSC::JSObject* readableStream() const { return m_weakReadableStream.get(); } + JSC::EncodedJSValue end(JSC::JSGlobalObject*); // the JS-visible end(): finish the native sink, then detach() + void* m_sinkPtr; SinkID m_sinkId; - mutable WriteBarrier m_onPull; - mutable WriteBarrier m_onClose; + SinkSource m_sourceKind { SinkSource::None }; + mutable WriteBarrier m_source; + mutable WriteBarrier m_closePromise; // DirectStream: readDirectStream's result while pull() is sync and open mutable JSC::Weak m_weakReadableStream; uintptr_t m_onDestroy { 0 }; @@ -430,24 +455,17 @@ JSC_DEFINE_HOST_FUNCTION(${controller}__close, (JSC::JSGlobalObject * lexicalGlo } // close(error?): a falsy argument is the same clean close as close(), - // the truthiness rule readDirectStreamCloseImpl uses for its reason. The + // the truthiness rule directStreamOnClose uses for its reason. The // pump reports a failed source through closeSinkControllerWithError. JSC::JSValue error = callFrame->argument(0); JSC::EncodedJSValue reason = error.toBoolean(lexicalGlobalObject) ? JSC::JSValue::encode(error) : JSC::JSValue::encode(JSC::JSValue()); RELEASE_AND_RETURN(scope, ${controller}__closeWithReason(lexicalGlobalObject, controller, reason)); } -JSC_DECLARE_HOST_FUNCTION(${controller}__end); -JSC_DEFINE_HOST_FUNCTION(${controller}__end, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame *callFrame)) +static JSC::EncodedJSValue ${controller}__endImpl(JSC::JSGlobalObject* lexicalGlobalObject, WebCore::${controller}* controller) { auto& vm = lexicalGlobalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - Zig::GlobalObject* globalObject = reinterpret_cast(lexicalGlobalObject); - WebCore::${controller}* controller = dynamicDowncast(callFrame->thisValue()); - if (!controller) { - scope.throwException(globalObject, JSC::createTypeError(globalObject, "Expected ${controller}"_s)); - return {}; - } void *ptr = controller->wrapped(); if (ptr == nullptr) { @@ -484,6 +502,19 @@ JSC_DEFINE_HOST_FUNCTION(${controller}__end, (JSC::JSGlobalObject * lexicalGloba return result; } +JSC_DECLARE_HOST_FUNCTION(${controller}__end); +JSC_DEFINE_HOST_FUNCTION(${controller}__end, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame *callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + WebCore::${controller}* controller = dynamicDowncast(callFrame->thisValue()); + if (!controller) { + scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "Expected ${controller}"_s)); + return {}; + } + RELEASE_AND_RETURN(scope, ${controller}__endImpl(lexicalGlobalObject, controller)); +} + extern "C" JSC::EncodedJSValue ${name}__getInternalFd(void* sinkPtr); // TODO: how to make this a property callback. then, we can expose this as a documented field @@ -662,17 +693,9 @@ JSObject* JS${controllerName}::createPrototype(VM& vm, JSDOMGlobalObject& global } void JS${controllerName}::detach() { - // Prevent re-entrancy. - JSC::EnsureStillAliveScope readableStream(m_weakReadableStream.get()); - JSC::EnsureStillAliveScope onClose(m_onClose.get()); - auto* sinkPtr = std::exchange(m_sinkPtr, nullptr); auto destroy = std::exchange(m_onDestroy, 0); - m_onPull.clear(); - m_onClose.clear(); - m_weakReadableStream.clear(); - if (destroy) { Bun__onSinkDestroyed(destroy, sinkPtr); } @@ -681,20 +704,11 @@ void JS${controllerName}::detach() { ${name}__controllerDetached(sinkPtr, JSC::JSValue::encode(this)); } - if (readableStream.value() && onClose.value()) { - JSC::JSGlobalObject *globalObject = this->globalObject(); - auto& vm = globalObject->vm(); - // Re-entering JS on a terminated worker trips executeCallImpl's assertNoException(). - if (vm.hasPendingTerminationException()) [[unlikely]] - return; - auto scope = DECLARE_THROW_SCOPE(vm); - JSC::MarkedArgumentBuffer arguments; - arguments.append(readableStream.value()); - arguments.append(jsUndefined()); - arguments.append(JSC::jsBoolean(false)); // sinkClosed: the source ended or the owner detached - AsyncContextFrame::call(globalObject, onClose.value(), JSC::jsUndefined(), arguments); - RELEASE_AND_RETURN(scope, void()); + if (m_sourceKind == SinkSource::None) { + m_weakReadableStream.clear(); + return; } + Bun::WebStreams::sinkControllerOnClose(this->globalObject(), this, JSC::jsUndefined(), /* sinkClosed */ false); } `; @@ -802,22 +816,10 @@ void ${controller}::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) } auto& vm = cell->vm(); - - if (thisObject->m_onPull) { - JSValue onPull = thisObject->m_onPull.get(); - if (onPull.isCell()) { - const Identifier& id = Identifier::fromString(vm, "onPull"_s); - analyzer.analyzePropertyNameEdge(cell, onPull.asCell(), id.impl()); - } - } - - if (thisObject->m_onClose) { - JSValue onClose = thisObject->m_onClose.get(); - if (onClose.isCell()) { - const Identifier& id = Identifier::fromString(vm, "onClose"_s); - analyzer.analyzePropertyNameEdge(cell, onClose.asCell(), id.impl()); - } - } + if (auto* source = thisObject->m_source.get()) + analyzer.analyzePropertyNameEdge(cell, source, Identifier::fromString(vm, "source"_s).impl()); + if (auto* closePromise = thisObject->m_closePromise.get()) + analyzer.analyzePropertyNameEdge(cell, closePromise, Identifier::fromString(vm, "closePromise"_s).impl()); } @@ -828,10 +830,9 @@ void ${controller}::visitChildrenImpl(JSCell* cell, Visitor& visitor) ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); - // Avoid duplicating in the heap snapshot - visitor.appendHidden(thisObject->m_onPull); - visitor.appendHidden(thisObject->m_onClose); - + visitor.append(thisObject->m_source); + visitor.append(thisObject->m_closePromise); + void* ptr = thisObject->m_sinkPtr; if (ptr) visitor.addOpaqueRoot(ptr); @@ -853,20 +854,6 @@ void ${className}::visitChildrenImpl(JSCell* cell, Visitor& visitor) DEFINE_VISIT_CHILDREN(${className}); -void ${controller}::start(JSC::JSGlobalObject *globalObject, JSC::JSValue readableStream, JSC::JSValue onPull, JSC::JSValue onClose) { - this->m_weakReadableStream = JSC::Weak(readableStream.getObject()); - if (onPull) { - if (auto* object = onPull.getObject()) { - this->m_onPull.set(globalObject->vm(), this, object); - } - } - if (onClose) { - if (auto* object = onClose.getObject()) { - this->m_onClose.set(globalObject->vm(), this, object); - } - } -} - void ${className}::destroy(JSCell* cell) { static_cast<${className}*>(cell)->${className}::~${className}(); @@ -979,51 +966,36 @@ extern "C" JSC::EncodedJSValue ${name}__createController(JSC::JSGlobalObject* ar } templ += ` +JSC::EncodedJSValue WebCore::JSReadableSinkControllerBase::end(JSC::JSGlobalObject* globalObject) +{ + switch (m_sinkId) { +${classes + .map( + name => + ` case WebCore::SinkID::${name}: return WebCore::${names(name).controller}__endImpl(globalObject, uncheckedDowncast(this));`, + ) + .join("\n")} + default: break; + } + RELEASE_ASSERT_NOT_REACHED(); +} + extern "C" void JSSinkController__onReady(JSC::EncodedJSValue controllerValue, JSC::EncodedJSValue amt, JSC::EncodedJSValue offset) { + (void)amt; + (void)offset; auto* controller = static_cast(JSC::JSValue::decode(controllerValue).getObject()); - - JSC::JSValue function = controller->m_onPull.get(); - if (!function) - return; - JSC::JSGlobalObject *globalObject = controller->globalObject(); - auto& vm = globalObject->vm(); - // Re-entering JS on a terminated worker trips executeCallImpl's assertNoException(). - if (vm.hasPendingTerminationException()) [[unlikely]] + if (controller->m_sourceKind != WebCore::SinkSource::Pump) return; - auto scope = DECLARE_THROW_SCOPE(vm); - JSC::MarkedArgumentBuffer arguments; - arguments.append(controller); - arguments.append(JSC::JSValue::decode(amt)); - arguments.append(JSC::JSValue::decode(offset)); - - AsyncContextFrame::call(globalObject, function, JSC::jsUndefined(), arguments); - RELEASE_AND_RETURN(scope, void()); + Bun::WebStreams::sinkControllerOnReady(controller->globalObject(), controller); } extern "C" void JSSinkController__onClose(JSC::EncodedJSValue controllerValue, JSC::EncodedJSValue reason) { auto* controller = static_cast(JSC::JSValue::decode(controllerValue).getObject()); - - JSC::JSValue function = controller->m_onClose.get(); - if (!function) - return; - // only call close once - controller->m_onClose.clear(); - JSC::JSGlobalObject* globalObject = controller->globalObject(); - auto& vm = globalObject->vm(); - // Re-entering JS on a terminated worker trips executeCallImpl's assertNoException(). - if (vm.hasPendingTerminationException()) [[unlikely]] + if (controller->m_sourceKind == WebCore::SinkSource::None) return; - auto scope = DECLARE_THROW_SCOPE(vm); - - JSC::MarkedArgumentBuffer arguments; - auto readableStream = controller->m_weakReadableStream.get(); - arguments.append(readableStream ? readableStream : JSC::jsUndefined()); - arguments.append(JSC::JSValue::decode(reason)); - arguments.append(JSC::jsBoolean(true)); // sinkClosed: the native sink closed underneath the source - AsyncContextFrame::call(globalObject, function, JSC::jsUndefined(), arguments); - RELEASE_AND_RETURN(scope, void()); + Bun::WebStreams::sinkControllerOnClose(controller->globalObject(), controller, JSC::JSValue::decode(reason), /* sinkClosed */ true); } extern "C" JSC::EncodedJSValue JSSinkController__assignToStream(JSC::JSGlobalObject* arg0, JSC::EncodedJSValue stream, JSC::EncodedJSValue controllerValue) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b5921f3281de..fd559f8b099c 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -4152,10 +4152,6 @@ GlobalObject::PromiseFunctions GlobalObject::promiseHandlerID(Zig::FFIFunction h return GlobalObject::PromiseFunctions::Bun__NodeHTTPRequest__onResolve; } else if (handler == Bun__NodeHTTPRequest__onReject) { return GlobalObject::PromiseFunctions::Bun__NodeHTTPRequest__onReject; - } else if (handler == Bun__FileStreamWrapper__onResolveRequestStream) { - return GlobalObject::PromiseFunctions::Bun__FileStreamWrapper__onResolveRequestStream; - } else if (handler == Bun__FileStreamWrapper__onRejectRequestStream) { - return GlobalObject::PromiseFunctions::Bun__FileStreamWrapper__onRejectRequestStream; } else if (handler == Bun__FileSink__onResolveStream) { return GlobalObject::PromiseFunctions::Bun__FileSink__onResolveStream; } else if (handler == Bun__FileSink__onRejectStream) { diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index df905583255e..ff5e6434534d 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -382,8 +382,6 @@ class GlobalObject : public Bun::GlobalScope { Bun__onRejectEntryPointResult, Bun__NodeHTTPRequest__onResolve, Bun__NodeHTTPRequest__onReject, - Bun__FileStreamWrapper__onRejectRequestStream, - Bun__FileStreamWrapper__onResolveRequestStream, Bun__FileSink__onResolveStream, Bun__FileSink__onRejectStream, Bun__CronJob__onPromiseResolve, diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 837a03f71f6d..0170cb2863be 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -767,9 +767,6 @@ BUN_DECLARE_HOST_FUNCTION(Bun__onResolveEntryPointResult); BUN_DECLARE_HOST_FUNCTION(Bun__onRejectEntryPointResult); -BUN_DECLARE_HOST_FUNCTION(Bun__FileStreamWrapper__onResolveRequestStream); -BUN_DECLARE_HOST_FUNCTION(Bun__FileStreamWrapper__onRejectRequestStream); - BUN_DECLARE_HOST_FUNCTION(Bun__FetchTasklet__onResolveRequestStream); BUN_DECLARE_HOST_FUNCTION(Bun__FetchTasklet__onRejectRequestStream); diff --git a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h index a5374a6d5be0..0ca8a219c18d 100644 --- a/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMClientIsoSubspaces.h @@ -120,7 +120,6 @@ class DOMClientIsoSubspaces { GCClient::IsoSubspace* m_clientSubspaceForDirectStreamController { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForDirectStreamSource { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForNativeStreamSourceAdapter { nullptr }; - GCClient::IsoSubspace* m_clientSubspaceForDirectSinkCloseState { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForAsyncIteratorSourceOperation { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForReadStreamIntoSinkOperation { nullptr }; GCClient::IsoSubspace* m_clientSubspaceForBunStandaloneTextSink { nullptr }; diff --git a/src/jsc/bindings/webcore/DOMIsoSubspaces.h b/src/jsc/bindings/webcore/DOMIsoSubspaces.h index 868700f08da8..0d5914930ec3 100644 --- a/src/jsc/bindings/webcore/DOMIsoSubspaces.h +++ b/src/jsc/bindings/webcore/DOMIsoSubspaces.h @@ -105,7 +105,6 @@ class DOMIsoSubspaces { IsoSubspace* m_subspaceForDirectStreamController { nullptr }; IsoSubspace* m_subspaceForDirectStreamSource { nullptr }; IsoSubspace* m_subspaceForNativeStreamSourceAdapter { nullptr }; - IsoSubspace* m_subspaceForDirectSinkCloseState { nullptr }; IsoSubspace* m_subspaceForAsyncIteratorSourceOperation { nullptr }; IsoSubspace* m_subspaceForReadStreamIntoSinkOperation { nullptr }; IsoSubspace* m_subspaceForBunStandaloneTextSink { nullptr }; diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index b43c4ec307ce..2d7f1c35a732 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -944,6 +944,8 @@ static JSValue consumeDirectStreamBody(JSC::VM& vm, JSGlobalObject* globalObject auto scope = DECLARE_THROW_SCOPE(vm); setUpDirectStreamController(globalObject, stream, kind); RETURN_IF_EXCEPTION(scope, {}); + if (auto* controller = dynamicDowncast(stream->m_controller.get())) + controller->m_closeOnPullSettled = true; stream->materializeIfNeeded(globalObject); RETURN_IF_EXCEPTION(scope, {}); auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); @@ -1626,12 +1628,45 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onDirectConsumeLoopReadRejected, (J return {}; } +// close()/end(), and the implicit close when an async pull() resolves without calling either. +static void oneShotDirectClose(JSC::VM& vm, JSGlobalObject* globalObject, JSOneShotDirectSink* sink) +{ + auto scope = DECLARE_THROW_SCOPE(vm); + if (sink->m_closed) + return; + sink->m_closed = true; + if (auto* source = sink->source()) { + sink->clearSource(); + source->close(globalObject, jsUndefined()); + RETURN_IF_EXCEPTION(scope, ); + } + MarkedArgumentBuffer noArguments; + JSValue endResult = Bun::WebStreams::invokeMethod(vm, globalObject, sink->arrayBufferSink(), builtinNames(vm).endPublicName(), noArguments); + RETURN_IF_EXCEPTION(scope, ); + if (auto* capability = sink->capabilityPromise(); capability && capability->status() == JSPromise::Status::Pending) + capability->fulfill(vm, endResult); +} + +// pull() runs once here: its promise resolving without close()/end() is the end of the body. JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onConsumeDirectToArrayBufferPullFulfilled, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - const auto* sink = uncheckedDowncast(callFrame->uncheckedArgument(1)); + auto* sink = uncheckedDowncast(callFrame->uncheckedArgument(1)); auto* stream = sink->stream(); + oneShotDirectClose(vm, globalObject, sink); + if (JSC::Exception* exception = scope.exception()) [[unlikely]] { + TRY_CLEAR_EXCEPTION(scope, {}); + if (stream) { + stream->m_lockedWithoutReader = false; + if (stream->m_state == ReadableStreamState::Readable) { + Bun::WebStreams::readableStreamError(globalObject, stream, exception->value()); + RETURN_IF_EXCEPTION(scope, {}); + } + } + throwException(globalObject, scope, exception->value()); + return {}; + } if (stream) { stream->m_lockedWithoutReader = false; Bun::WebStreams::readableStreamCloseIfPossible(globalObject, stream); @@ -1682,19 +1717,8 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundOneShotDirectClose, (JSGlobalO auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto* sink = uncheckedDowncast(callFrame->uncheckedArgument(0)); - if (sink->m_closed) - return JSValue::encode(jsUndefined()); - sink->m_closed = true; - if (auto* source = sink->source()) { - sink->clearSource(); - source->close(globalObject, jsUndefined()); - RETURN_IF_EXCEPTION(scope, {}); - } - MarkedArgumentBuffer noArguments; - JSValue endResult = Bun::WebStreams::invokeMethod(vm, globalObject, sink->arrayBufferSink(), builtinNames(vm).endPublicName(), noArguments); + oneShotDirectClose(vm, globalObject, sink); RETURN_IF_EXCEPTION(scope, {}); - if (auto* capability = sink->capabilityPromise(); capability && capability->status() == JSPromise::Status::Pending) - capability->fulfill(vm, endResult); return JSValue::encode(jsUndefined()); } diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index 69cd765d0ecb..3f85b4653a68 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -2,7 +2,6 @@ #include "config.h" #include "BunStreamSource.h" -#include "AsyncContextFrame.h" #include "BunStandaloneTextSink.h" #include "DOMClientIsoSubspaces.h" #include "DOMIsoSubspaces.h" @@ -10,7 +9,6 @@ #include "JSDOMBinding.h" #include "JSDOMGlobalObject.h" #include "JSDOMWrapperCache.h" -#include "JSDirectSinkCloseState.h" #include "JSDirectStreamSource.h" #include "JSReadRequest.h" #include "JSReadStreamIntoSinkOperation.h" @@ -101,56 +99,6 @@ void JSNativeStreamSourceAdapter::analyzeHeap(JSCell* cell, HeapAnalyzer& analyz analyzeBarrierEdge(vm, analyzer, cell, thisObject->internalField(Field::Controller), "controller"_s); } -const ClassInfo JSDirectSinkCloseState::s_info = { "DirectSinkCloseState"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSDirectSinkCloseState) }; - -JSDirectSinkCloseState::JSDirectSinkCloseState(VM& vm, Structure* structure) - : Base(vm, structure) -{ -} - -void JSDirectSinkCloseState::finishCreation(VM& vm) -{ - Base::finishCreation(vm); - ASSERT(inherits(info())); -} - -JSDirectSinkCloseState* JSDirectSinkCloseState::create(VM& vm, Structure* structure) -{ - auto* cell = new (NotNull, allocateCell(vm)) JSDirectSinkCloseState(vm, structure); - cell->finishCreation(vm); - return cell; -} - -Structure* JSDirectSinkCloseState::createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) -{ - return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(ObjectType, StructureFlags), info()); -} - -GCClient::IsoSubspace* JSDirectSinkCloseState::subspaceForImpl(VM& vm) -{ - return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForDirectSinkCloseState, m_subspaceForDirectSinkCloseState)); -} - -template -void JSDirectSinkCloseState::visitChildrenImpl(JSCell* cell, Visitor& visitor) -{ - auto* thisObject = uncheckedDowncast(cell); - ASSERT_GC_OBJECT_INHERITS(thisObject, info()); - visitInternalFieldsHidden(thisObject, visitor); -} - -DEFINE_VISIT_CHILDREN(JSDirectSinkCloseState); - -void JSDirectSinkCloseState::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) -{ - auto* thisObject = uncheckedDowncast(cell); - auto& vm = cell->vm(); - Base::analyzeHeap(cell, analyzer); - analyzeBarrierEdge(vm, analyzer, cell, thisObject->internalField(Field::Source), "source"_s); - analyzeBarrierEdge(vm, analyzer, cell, thisObject->internalField(Field::SinkController), "sinkController"_s); - analyzeBarrierEdge(vm, analyzer, cell, thisObject->internalField(Field::ClosePromise), "closePromise"_s); -} - const ClassInfo JSReadStreamIntoSinkOperation::s_info = { "ReadStreamIntoSinkOperation"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSReadStreamIntoSinkOperation) }; JSReadStreamIntoSinkOperation::JSReadStreamIntoSinkOperation(VM& vm, Structure* structure) @@ -251,36 +199,20 @@ static void invokeNativeHandleCancel(JSC::VM& vm, JSGlobalObject* globalObject, invokeMethod(vm, globalObject, handle, builtinNames(vm).cancelPublicName(), cancelArgs); } -static JSValue wrapWithAsyncContext(JSGlobalObject* globalObject, JSReadableStream* stream, JSValue callable) -{ - JSValue asyncContext = stream->m_asyncContext.get(); - if (callable.isUndefined() || asyncContext.isEmpty() || asyncContext.isUndefined()) - return callable; - return AsyncContextFrame::create(globalObject, callable, asyncContext); -} - -// The generated JSSink controller's C++ start(readableStream, onPull, onClose) registration. -static void startJSSinkController(JSC::VM& vm, JSGlobalObject* globalObject, JSObject* sink, JSValue streamValue, JSValue onPull, JSValue onClose) +static WebCore::JSReadableSinkControllerBase* startJSSinkController(JSC::VM& vm, JSGlobalObject* globalObject, JSObject* sink, JSReadableStream* stream, WebCore::SinkSource kind, JSCell* source) { auto scope = DECLARE_THROW_SCOPE(vm); -#define BUN_START_JSSINK_CONTROLLER(ControllerType) \ - if (auto* controller = dynamicDowncast(sink)) { \ - if (!controller->wrapped()) [[unlikely]] { \ - throwTypeError(globalObject, scope, "Cannot start stream with closed controller"_s); \ - return; \ - } \ - controller->start(globalObject, streamValue, onPull, onClose); \ - return; \ + auto* controller = dynamicDowncast(sink); + if (!controller) [[unlikely]] { + throwTypeError(globalObject, scope, "Unknown direct controller. This is a bug in Bun."_s); + return nullptr; + } + if (!controller->wrapped()) [[unlikely]] { + throwTypeError(globalObject, scope, "Cannot start stream with closed controller"_s); + return nullptr; } - BUN_START_JSSINK_CONTROLLER(JSReadableArrayBufferSinkController) - BUN_START_JSSINK_CONTROLLER(JSReadableFileSinkController) - BUN_START_JSSINK_CONTROLLER(JSReadableHTTPResponseSinkController) - BUN_START_JSSINK_CONTROLLER(JSReadableHTTPSResponseSinkController) - BUN_START_JSSINK_CONTROLLER(JSReadableNetworkSinkController) - BUN_START_JSSINK_CONTROLLER(JSReadableFetchRequestBodySinkController) - BUN_START_JSSINK_CONTROLLER(JSReadableHTMLRewriterSinkController) -#undef BUN_START_JSSINK_CONTROLLER - throwTypeError(globalObject, scope, "Unknown direct controller. This is a bug in Bun."_s); + controller->start(vm, stream, kind, source); + return controller; } static void clearStreamControllerSlots(JSReadableStream* stream) @@ -749,18 +681,13 @@ static void nativeSourcePullRejected(JSC::VM& vm, JSGlobalObject* globalObject, // The native-sink path -// readDirectStreamOnClose. The state-mutation half runs only when a stream is provided; everything -// that can throw (the sink's end(), the user's cancel(reason)) runs after the state is final, and a -// throw from it propagates to whoever closed. -static void readDirectStreamCloseImpl(JSC::VM& vm, JSGlobalObject* globalObject, JSDirectSinkCloseState* state, JSValue streamValue, JSValue reason, bool sinkClosed) +// The direct stream's sink closed. State mutation first; everything that can throw (the sink's +// end(), the user's cancel(reason)) runs after the state is final and propagates to whoever closed. +static void directStreamOnClose(JSC::VM& vm, JSGlobalObject* globalObject, WebCore::JSReadableSinkControllerBase* sinkController, JSDirectStreamSource* source, JSPromise* closePromise, JSReadableStream* stream, JSValue reason, bool sinkClosed) { auto scope = DECLARE_THROW_SCOPE(vm); - JSObject* sinkController = state->sinkController(); - state->clearSinkController(); - auto* source = state->source(); - state->clearSource(); - if (auto* stream = dynamicDowncast(streamValue)) { + if (stream) { clearStreamControllerSlots(stream); stream->m_reader.clear(); stream->m_lockedWithoutReader = false; @@ -775,24 +702,22 @@ static void readDirectStreamCloseImpl(JSC::VM& vm, JSGlobalObject* globalObject, resolveStreamClosedPromise(vm, stream); } } - if (auto* closePromise = state->closePromise()) { - state->clearClosePromise(); + if (closePromise) { resolvePromise(globalObject, closePromise, jsUndefined()); RETURN_IF_EXCEPTION(scope, ); } - // The sink closed (or is closing): end() detaches the controller cell from the native - // sink so a later GC of the cell cannot release a reference it does not own. - if (sinkController) { - MarkedArgumentBuffer noArgs; - invokeMethod(vm, globalObject, sinkController, builtinNames(vm).endPublicName(), noArgs); - RETURN_IF_EXCEPTION(scope, ); - } + // end() detaches the controller cell from the native sink so a later GC of the cell cannot release a reference it does not own. + sinkController->end(globalObject); + RETURN_IF_EXCEPTION(scope, ); // Only a sink that closed underneath the source (peer abort, write error) cancels it, not its own end()/close(). if (source && sinkClosed) { JSObject* cancelFunction = source->cancelFunction(); if (!cancelFunction) return; + std::optional asyncContext; + if (stream) + asyncContext.emplace(globalObject, stream); MarkedArgumentBuffer cancelArgs; cancelArgs.append(reason); JSValue cancelResult = call(globalObject, cancelFunction, getCallData(cancelFunction), source->thisValue(), cancelArgs); @@ -802,23 +727,24 @@ static void readDirectStreamCloseImpl(JSC::VM& vm, JSGlobalObject* globalObject, } } -JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, JSObject* sinkController, JSDirectStreamSource* source) +JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, JSObject* sinkObject, JSDirectStreamSource* source) { auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto* domGlobalObject = defaultGlobalObject(globalObject); auto* runtime = WebCore::JSStreamsRuntime::from(globalObject); stream->m_directSource.clear(); stream->m_bunMode = BunStreamMode::Default; - auto* state = WebCore::JSDirectSinkCloseState::create(vm, runtime->directSinkCloseStateStructure(domGlobalObject)); - state->setSource(vm, source); - state->setSinkController(vm, sinkController); + auto* sinkController = dynamicDowncast(sinkObject); + if (!sinkController) [[unlikely]] { + throwTypeError(globalObject, scope, "Unknown direct controller. This is a bug in Bun."_s); + return {}; + } JSObject* pull = source->pullFunction(); if (!pull) { - readDirectStreamCloseImpl(vm, globalObject, state, jsUndefined(), jsUndefined(), false); + sinkController->end(globalObject); RETURN_IF_EXCEPTION(scope, {}); return jsUndefined(); } @@ -836,14 +762,8 @@ JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, invokeMethod(vm, globalObject, sinkController, builtinNames(vm).startPublicName(), startArgs); RETURN_IF_EXCEPTION(scope, {}); - auto* closeBound = createBoundHandler(globalObject, runtime->boundReadDirectStreamOnClose(), state); - RETURN_IF_EXCEPTION(scope, {}); - JSValue onClose = wrapWithAsyncContext(globalObject, stream, closeBound); - RETURN_IF_EXCEPTION(scope, {}); - // A direct-stream `pull` is invoked once below and runs to completion; it - // is not a resume hook. Leaving m_onPull empty makes the sink's onReady() - // a no-op so a backpressure drain cannot re-enter the still-running pull. - startJSSinkController(vm, globalObject, sinkController, stream, jsUndefined(), onClose); + // SinkSource::DirectStream takes no onReady: `pull` runs once to completion below, so a backpressure drain must not re-enter it. + startJSSinkController(vm, globalObject, sinkController, stream, WebCore::SinkSource::DirectStream, source); RETURN_IF_EXCEPTION(scope, {}); stream->m_lockedWithoutReader = true; @@ -854,14 +774,15 @@ JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, JSValue maybePromise = call(globalObject, pull, getCallData(pull), source->thisValue(), pullArgs); RETURN_IF_EXCEPTION(scope, {}); + // Resolving without close()/end() ends the sink controller first; a sync return waits for close()/end(). if (auto* pullPromise = dynamicDowncast(maybePromise)) { auto* result = JSPromise::create(vm, globalObject->promiseStructure()); - pullPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReturnUndefined(), jsUndefined(), result, jsUndefined()); + pullPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReadDirectStreamPullFulfilled(), jsUndefined(), result, sinkController); return result; } if (stream->m_state == ReadableStreamState::Readable) { auto* closePromise = JSPromise::create(vm, globalObject->promiseStructure()); - state->setClosePromise(vm, closePromise); + sinkController->m_closePromise.set(vm, sinkController, closePromise); return closePromise; } return jsUndefined(); @@ -1014,7 +935,7 @@ static void rsisAbrupt(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIn RELEASE_AND_RETURN(scope, rsisSinkClose(vm, globalObject, sink, error)); } -// One sink.write(chunk). wrote<0 = backpressure: stash tail on m_pendingBatch, suspend; m_onPull resumes. +// One sink.write(chunk). wrote<0 = backpressure: stash tail on m_pendingBatch, suspend; onReady resumes. static std::optional rsisWriteChunk(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op, JSValue chunk, JSObject* batchValues, unsigned nextIndex, unsigned length) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -1075,7 +996,7 @@ static void rsisAfterBatch(JSGlobalObject* globalObject, JSReadStreamIntoSinkOpe rsisIssueRead(globalObject, op); } -// m_onPull fired: drain stashed batch tail then resume the read loop. +// onReady fired: drain stashed batch tail then resume the read loop. static void rsisContinueAfterReady(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -1100,16 +1021,7 @@ static void rsisRegisterAndStart(JSC::VM& vm, JSGlobalObject* globalObject, JSRe auto scope = DECLARE_THROW_SCOPE(vm); { auto* stream = op->stream(); - auto* runtime = WebCore::JSStreamsRuntime::from(globalObject); - auto* onCloseBound = createBoundHandler(globalObject, runtime->boundReadStreamIntoSinkOnClose(), op); - RETURN_IF_EXCEPTION(scope, ); - auto* onPullBound = createBoundHandler(globalObject, runtime->boundReadStreamIntoSinkOnReady(), op); - RETURN_IF_EXCEPTION(scope, ); - JSValue onClose = wrapWithAsyncContext(globalObject, stream, onCloseBound); - RETURN_IF_EXCEPTION(scope, ); - JSValue onPull = wrapWithAsyncContext(globalObject, stream, onPullBound); - RETURN_IF_EXCEPTION(scope, ); - startJSSinkController(vm, globalObject, op->sink(), stream, onPull, onClose); + startJSSinkController(vm, globalObject, op->sink(), stream, WebCore::SinkSource::Pump, op); RETURN_IF_EXCEPTION(scope, ); double rawHighWaterMark = stream->m_bunHighWaterMark; auto* startOptions = constructEmptyObject(globalObject); @@ -1259,24 +1171,17 @@ JSPromise* readStreamIntoSink(JSGlobalObject* globalObject, JSReadableStream* st return result; } -// readStreamIntoSinkOnClose(op, stream, reason) — the JSSink onClose [bound-convention] body. Its -// host function routes a throw from here into the pump's catch path, so m_result always settles. -static void readStreamIntoSinkOnCloseImpl(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op, JSValue streamValue, JSValue reason) +// The pump's sink closed. sinkControllerOnClose routes a throw from here into rsisAbrupt, so m_result always settles. +static void pumpOnClose(JSC::VM& vm, JSGlobalObject* globalObject, WebCore::JSReadableSinkControllerBase* sinkController, JSReadStreamIntoSinkOperation* op, JSReadableStream* stream, JSValue reason) { auto scope = DECLARE_THROW_SCOPE(vm); const bool wasClosed = op->m_didThrow || op->m_didClose; op->m_didClose = true; rsisDetachNativeTransform(globalObject, op); - // The sink closed underneath the pump (which may stay suspended forever): end() FIRST, - // before the fallible cancel below, so the controller cell always detaches from the - // native sink instead of being collected attached (its destructor would over-release). - if (JSObject* sink = op->sink()) { - MarkedArgumentBuffer noArgs; - invokeMethod(vm, globalObject, sink, builtinNames(vm).endPublicName(), noArgs); - RETURN_IF_EXCEPTION(scope, ); - } + // end() FIRST, before the fallible cancel below, so the controller cell always detaches from the native sink (its destructor would over-release). + sinkController->end(globalObject); + RETURN_IF_EXCEPTION(scope, ); if (!wasClosed) { - auto* stream = dynamicDowncast(streamValue); if (stream && stream->m_state != ReadableStreamState::Closed) { auto* cancelPromise = readableStreamCancel(globalObject, stream, reason); RETURN_IF_EXCEPTION(scope, ); @@ -1286,13 +1191,77 @@ static void readStreamIntoSinkOnCloseImpl(JSC::VM& vm, JSGlobalObject* globalObj markPromiseAsHandled(vm, cancelPromise); } } - // end() detached m_onPull; drive a parked pump to completion so m_result settles. + // end() dropped the onReady signal; drive a parked pump to completion so m_result settles. if (op->m_waitingOnSink) { op->m_waitingOnSink = false; RELEASE_AND_RETURN(scope, rsisContinueAfterReady(vm, globalObject, op)); } } +static void pumpOnReady(JSC::VM& vm, JSGlobalObject* globalObject, JSReadStreamIntoSinkOperation* op) +{ + auto scope = DECLARE_THROW_SCOPE(vm); + // A native transform arm may be parked on m_nativeSinkReadyPromise without the pump itself being suspended; resolve it regardless of m_waitingOnSink. + if (auto* ts = op->nativeTransform()) { + if (auto* ready = ts->m_nativeSinkReadyPromise.get()) { + ts->m_nativeSinkReadyPromise.clear(); + resolvePromise(globalObject, ready, jsUndefined()); + scope.assertNoException(); + } else if (ts->m_codecPromise) { + nativeCodecContinue(globalObject, ts); + RETURN_IF_EXCEPTION(scope, ); + } + } + if (!op->m_waitingOnSink) + return; + op->m_waitingOnSink = false; + RELEASE_AND_RETURN(scope, rsisContinueAfterReady(vm, globalObject, op)); +} + +void sinkControllerOnClose(JSGlobalObject* globalObject, WebCore::JSReadableSinkControllerBase* controller, JSValue reason, bool sinkClosed) +{ + auto& vm = getVM(globalObject); + auto kind = std::exchange(controller->m_sourceKind, WebCore::SinkSource::None); + JSC::EnsureStillAliveScope source(controller->m_source.get()); + JSC::EnsureStillAliveScope closePromise(controller->m_closePromise.get()); + JSC::EnsureStillAliveScope streamCell(controller->readableStream()); + controller->m_source.clear(); + controller->m_closePromise.clear(); + controller->m_weakReadableStream.clear(); + // Re-entering JS on a terminated worker trips executeCallImpl's assertNoException(). + if (vm.hasPendingTerminationException() || WebCore::clientData(vm)->isStoppingOrStopped(vm)) [[unlikely]] + return; + auto* stream = streamCell.value() ? uncheckedDowncast(streamCell.value()) : nullptr; + switch (kind) { + case WebCore::SinkSource::None: + return; + case WebCore::SinkSource::DirectStream: { + auto* promise = closePromise.value() ? uncheckedDowncast(closePromise.value()) : nullptr; + return directStreamOnClose(vm, globalObject, controller, uncheckedDowncast(source.value()), promise, stream, reason, sinkClosed); + } + case WebCore::SinkSource::Pump: { + auto* op = uncheckedDowncast(source.value()); + std::optional asyncContext; + if (stream) + asyncContext.emplace(globalObject, stream); + return atStreamsBoundary(globalObject, [&] { pumpOnClose(vm, globalObject, controller, op, stream, reason); }, [&](JSValue error) { rsisAbrupt(vm, globalObject, op, error); }); + } + } +} + +void sinkControllerOnReady(JSGlobalObject* globalObject, WebCore::JSReadableSinkControllerBase* controller) +{ + auto& vm = getVM(globalObject); + ASSERT(controller->m_sourceKind == WebCore::SinkSource::Pump); + if (vm.hasPendingTerminationException() || WebCore::clientData(vm)->isStoppingOrStopped(vm)) [[unlikely]] + return; + auto* op = uncheckedDowncast(controller->m_source.get()); + std::optional asyncContext; + if (auto* stream = controller->readableStream()) + asyncContext.emplace(globalObject, uncheckedDowncast(stream)); + atStreamsBoundary(globalObject, [&] { pumpOnReady(vm, globalObject, op); }, [&](JSValue error) { rsisAbrupt(vm, globalObject, op, error); }); +} + } // namespace WebStreams } // namespace Bun @@ -1403,47 +1372,13 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundOnNativeSourceDrain, (JSGlobal return JSValue::encode(jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundReadDirectStreamOnClose, (JSGlobalObject * globalObject, CallFrame* callFrame)) +// [reaction-convention] pull() runs once for a native sink: its promise resolving without close()/end() ends the sink (a no-op once detached). +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadDirectStreamPullFulfilled, (JSGlobalObject * globalObject, CallFrame* callFrame)) { - auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - auto* state = uncheckedDowncast(callFrame->argument(0)); - bool sinkClosed = callFrame->argument(3).isTrue(); - Bun::WebStreams::readDirectStreamCloseImpl(vm, globalObject, state, callFrame->argument(1), callFrame->argument(2), sinkClosed); + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + uncheckedDowncast(callFrame->argument(1))->end(globalObject); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundReadStreamIntoSinkOnClose, (JSGlobalObject * globalObject, CallFrame* callFrame)) -{ - auto& vm = getVM(globalObject); - auto* op = uncheckedDowncast(callFrame->argument(0)); - return enterStreams(globalObject, [&] { Bun::WebStreams::readStreamIntoSinkOnCloseImpl(vm, globalObject, op, callFrame->argument(1), callFrame->argument(2)); }, [&](JSValue error) { Bun::WebStreams::rsisAbrupt(vm, globalObject, op, error); }); -} - -JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundReadStreamIntoSinkOnReady, (JSGlobalObject * globalObject, CallFrame* callFrame)) -{ - auto& vm = getVM(globalObject); - auto* op = uncheckedDowncast(callFrame->argument(0)); - return enterStreams(globalObject, [&] { - auto scope = DECLARE_THROW_SCOPE(vm); - // A native transform arm may be parked on m_nativeSinkReadyPromise without the - // pump itself being suspended (output bypasses the pump's read loop). Resolve it - // regardless of m_waitingOnSink so the transform's in-flight write can settle. - if (auto* ts = op->nativeTransform()) { - if (auto* ready = ts->m_nativeSinkReadyPromise.get()) { - ts->m_nativeSinkReadyPromise.clear(); - Bun::WebStreams::resolvePromise(globalObject, ready, jsUndefined()); - scope.assertNoException(); - } else if (ts->m_codecPromise) { - Bun::WebStreams::nativeCodecContinue(globalObject, ts); - RETURN_IF_EXCEPTION(scope, ); - } - } - if (!op->m_waitingOnSink) - return; - op->m_waitingOnSink = false; - RELEASE_AND_RETURN(scope, Bun::WebStreams::rsisContinueAfterReady(vm, globalObject, op)); }, [&](JSValue error) { Bun::WebStreams::rsisAbrupt(vm, globalObject, op, error); }); -} - } // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/JSDirectSinkCloseState.h b/src/jsc/bindings/webcore/streams/JSDirectSinkCloseState.h deleted file mode 100644 index ff17f492d625..000000000000 --- a/src/jsc/bindings/webcore/streams/JSDirectSinkCloseState.h +++ /dev/null @@ -1,81 +0,0 @@ -// JSDirectSinkCloseState — the context cell of readDirectStream's bound onClose callable: -// the port of the `{underlyingSource, closePromiseCapability}` bound `this`. -// Internal cell: no prototype, no constructor. Non-destructible. -#pragma once - -#include "root.h" -#include "StreamsForward.h" - -#include "JSDirectStreamSource.h" -#include -#include - -namespace WebCore { - -class JSDirectSinkCloseState final : public JSC::JSInternalFieldObjectImpl<3> { -public: - using Base = JSC::JSInternalFieldObjectImpl<3>; - static constexpr unsigned StructureFlags = Base::StructureFlags; - static constexpr JSC::DestructionMode needsDestruction = JSC::DoesNotNeedDestruction; - - enum class Field : uint32_t { - // the direct stream's JSDirectStreamSource (its `cancel` runs from onClose). - Source = 0, - // the JS sink controller driving the source; onClose must end() it so the cell - // detaches from the native sink before it can be collected. - SinkController, - // the close-capability promise returned to the caller when `pull` returned synchronously - // without closing; initially empty, armed by readDirectStream, resolved by onClose. - // (An unvisited close promise is a premature collection of the promise handed to Rust.) - ClosePromise, - }; - - static JSDirectSinkCloseState* create(JSC::VM&, JSC::Structure*); - static JSC::Structure* createStructure(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue prototype); - - static size_t allocationSize(Checked inlineCapacity) - { - ASSERT_UNUSED(inlineCapacity, inlineCapacity == 0U); - return sizeof(JSDirectSinkCloseState); - } - - DECLARE_INFO; - DECLARE_VISIT_CHILDREN; - static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); - - template - static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) - { - if constexpr (mode == JSC::SubspaceAccess::Concurrently) - return nullptr; - return subspaceForImpl(vm); - } - static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM&); - - const JSC::WriteBarrier& internalField(Field field) const { return Base::internalField(static_cast(field)); } - JSC::WriteBarrier& internalField(Field field) { return Base::internalField(static_cast(field)); } - - JSDirectStreamSource* source() const { return uncheckedDowncast(fieldCell(Field::Source)); } - JSC::JSObject* sinkController() const { return uncheckedDowncast(fieldCell(Field::SinkController)); } - JSC::JSPromise* closePromise() const { return uncheckedDowncast(fieldCell(Field::ClosePromise)); } - - void setSource(JSC::VM& vm, JSDirectStreamSource* source) { internalField(Field::Source).set(vm, this, source); } - void setSinkController(JSC::VM& vm, JSC::JSObject* sinkController) { internalField(Field::SinkController).set(vm, this, sinkController); } - void setClosePromise(JSC::VM& vm, JSC::JSPromise* promise) { internalField(Field::ClosePromise).set(vm, this, promise); } - - void clearSource() { internalField(Field::Source).clear(); } - void clearSinkController() { internalField(Field::SinkController).clear(); } - void clearClosePromise() { internalField(Field::ClosePromise).clear(); } - -private: - JSDirectSinkCloseState(JSC::VM&, JSC::Structure*); - void finishCreation(JSC::VM&); - - JSC::JSCell* fieldCell(Field field) const - { - JSC::JSValue value = internalField(field).get(); - return value.isCell() ? value.asCell() : nullptr; - } -}; - -} // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index 43806d317710..a561be3ca85b 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -1024,6 +1024,13 @@ static void directPullFulfilled(JSC::VM& vm, JSGlobalObject* globalObject, JSDir controller->onFlush(globalObject); controller->m_pullInFlight = false; RETURN_IF_EXCEPTION(scope, ); + if (controller->m_closeOnPullSettled) { + controller->m_pullAgain = false; + stream = controller->m_stream.get(); + if (!controller->m_closed && stream && stream->m_state == ReadableStreamState::Readable) + controller->onClose(globalObject, jsUndefined()); + RELEASE_AND_RETURN(scope, ); + } bool pullAgain = takeDirectPullAgain(controller); // Edge-triggered (m_pullAgain) AND level-checked (a consumer is waiting), the spec's // ShouldCallPull equivalent; loop so a synchronous re-pull chains to the next consumer. diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.h b/src/jsc/bindings/webcore/streams/JSDirectStreamController.h index 2be9c3ceef7c..19c570367aa8 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.h +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.h @@ -116,6 +116,8 @@ class JSDirectStreamController final : public JSC::JSDestructibleObject { // process.nextTick job delivers it during the same microtask/nextTick drain. bool m_endOfTickFlushArmed : 1 { false }; bool m_finalChunkArmed : 1 { false }; + // A whole-body consumer (.text() etc.): an async pull() resolving without close()/end() closes. + bool m_closeOnPullSettled : 1 { false }; // ArrayBuffer sink: the bytes write() accepted since it armed m_pendingWrite (saturating). uint32_t m_pendingWriteLength { 0 }; // ArrayBuffer sink: m_buffer's capacity as last reported to the heap (reportExtraMemoryAllocated); diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamSource.h b/src/jsc/bindings/webcore/streams/JSDirectStreamSource.h index 7e66aeedd3e7..6e5d45231677 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamSource.h +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamSource.h @@ -1,7 +1,7 @@ // JSDirectStreamSource — a `type:"direct"` underlying source, converted ONCE at stream // construction: the user object (the `this` of every call) and its pull / cancel / close // methods. Held by the stream while DirectPending, then by whichever consumer takes the -// stream (JSDirectStreamController, JSDirectSinkCloseState, JSOneShotDirectSink); nobody +// stream (JSDirectStreamController, the native sink controller, JSOneShotDirectSink); nobody // reads a property of the user object after construction. // Internal cell: no prototype, no constructor, never exposed to JS. #pragma once diff --git a/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h b/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h index db42be62f1d8..0baf0aedc00c 100644 --- a/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h +++ b/src/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.h @@ -32,7 +32,7 @@ class JSReadStreamIntoSinkOperation final : public JSC::JSInternalFieldObjectImp Sink, // the JSPromise readStreamIntoSink returned (what Rust's Signal protocol awaits). Result, - // Nullable: the unwritten batch tail stashed on sink backpressure; drained on m_onPull. + // Nullable: the unwritten batch tail stashed on sink backpressure; drained on onReady. PendingBatch, // Nullable: the byte-producing JSTransformStream subclass whose transform arms // write output straight to this sink. Set at attach; onReady flips its @@ -88,7 +88,7 @@ class JSReadStreamIntoSinkOperation final : public JSC::JSInternalFieldObjectImp bool m_didThrow : 1 { false }; bool m_didClose : 1 { false }; bool m_started : 1 { false }; - // Set when rsisWriteChunk suspends on sink backpressure; cleared when m_onPull resumes. + // Set when rsisWriteChunk suspends on sink backpressure; cleared when onReady resumes. bool m_waitingOnSink : 1 { false }; private: diff --git a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.cpp b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.cpp index dafb21e8676b..abcb4e10248b 100644 --- a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.cpp +++ b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.cpp @@ -6,7 +6,6 @@ #include "BunStandaloneTextSink.h" #include "BunStreamSource.h" #include "JSCrossRealmTransformState.h" -#include "JSDirectSinkCloseState.h" #include "JSAsyncIteratorSourceOperation.h" #include "JSDirectStreamController.h" #include "JSDirectStreamSource.h" diff --git a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h index 8622487402f3..c030eb4fbb87 100644 --- a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h +++ b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h @@ -52,7 +52,7 @@ namespace WebCore { // Signature of every entry: name(JSC::JSValue resolutionValue, contextCell at argument(1)). // owner: WebStreamsMisc.cpp — the shared "fulfillment step that returns undefined" / no-op -// reaction (readableStreamCancel; readDirectStream's `.then(noop)`). context: unused. +// reaction (readableStreamCancel, tee's reader-closed watch). context: unused. #define FOR_EACH_WEB_STREAMS_REACTION_HANDLER_MISC(V) \ V(onReturnUndefined) @@ -166,11 +166,13 @@ namespace WebCore { // onNativePull*: context = the JSNativeStreamSourceAdapter. // onNativeSourceCallCloseMicrotask: the native source's `queueMicrotask(callClose)` job; // context = the adapter. +// onReadDirectStreamPullFulfilled: readDirectStream's pull() resolved. context = the native sink controller. // onReadStreamIntoSink*: context = the JSReadStreamIntoSinkOperation. #define FOR_EACH_WEB_STREAMS_REACTION_HANDLER_BUN_SOURCE(V) \ V(onNativePullFulfilled) \ V(onNativePullRejected) \ V(onNativeSourceCallCloseMicrotask) \ + V(onReadDirectStreamPullFulfilled) \ V(onReadStreamIntoSinkReadManyFulfilled) \ V(onReadStreamIntoSinkChunk) \ V(onReadStreamIntoSinkClose) \ @@ -249,16 +251,9 @@ namespace WebCore { // owner: BunStreamSource.cpp. // boundOnNativeSourceClose(adapter) / boundOnNativeSourceDrain(adapter, chunk): stored as // handle.onClose / handle.onDrain. -// boundReadDirectStreamOnClose(state, streamOrUndefined, reason): readDirectStream's -// JSSink onClose. -// boundReadStreamIntoSinkOnClose(op, stream, reason): readStreamIntoSink's JSSink onClose. -// boundReadStreamIntoSinkOnReady(op, controller, amt, offset): JSSink m_onPull resume after backpressure. #define FOR_EACH_WEB_STREAMS_BOUND_HANDLER_TARGET_BUN_SOURCE(V) \ V(boundOnNativeSourceClose) \ - V(boundOnNativeSourceDrain) \ - V(boundReadDirectStreamOnClose) \ - V(boundReadStreamIntoSinkOnClose) \ - V(boundReadStreamIntoSinkOnReady) + V(boundOnNativeSourceDrain) // owner: JSDirectStreamController.cpp — the FIVE detachable own methods of the direct // controller: `end` and `close` are two bound cells over the ONE boundDirectClose target. @@ -328,7 +323,6 @@ JSC_DECLARE_HOST_FUNCTION(jsWebStreamsCountQueuingStrategySize); V(directStreamControllerStructure, JSDirectStreamController) \ V(directStreamSourceStructure, JSDirectStreamSource) \ V(nativeStreamSourceAdapterStructure, JSNativeStreamSourceAdapter) \ - V(directSinkCloseStateStructure, JSDirectSinkCloseState) \ V(asyncIteratorSourceOperationStructure, JSAsyncIteratorSourceOperation) \ V(readStreamIntoSinkOperationStructure, JSReadStreamIntoSinkOperation) \ V(standaloneTextSinkStructure, JSBunStandaloneTextSink) \ diff --git a/src/jsc/bindings/webcore/streams/StreamsForward.h b/src/jsc/bindings/webcore/streams/StreamsForward.h index 73dd4b2687be..13d95fa8ed8d 100644 --- a/src/jsc/bindings/webcore/streams/StreamsForward.h +++ b/src/jsc/bindings/webcore/streams/StreamsForward.h @@ -74,7 +74,6 @@ class JSBunStandaloneTextSink; // the standalone Text sink (BunStandaloneTextSin class JSOneShotDirectSink; // consumeDirectStreamToArrayBuffer's throwaway controller class JSReadableStreamIntoArrayOperation; // the array pump's reader/chunks/result state class JSNativeStreamSourceAdapter; -class JSDirectSinkCloseState; class JSAsyncIteratorSourceOperation; class JSReadStreamIntoSinkOperation; class JSTextEncoderStream; diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h index 158dda21992a..5ed83320b48c 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h +++ b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h @@ -52,7 +52,6 @@ inline bool exceedsStringLimit(size_t length) // Reduce noise: every class name below is a WebCore JS cell (StreamsForward.h). using WebCore::JSCrossRealmTransformState; -using WebCore::JSDirectSinkCloseState; using WebCore::JSDirectStreamController; using WebCore::JSDirectStreamSource; using WebCore::JSNativeStreamSourceAdapter; diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 9766c9ae48b1..d5574473c709 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -1565,82 +1565,15 @@ impl BlobExt for Blob { } }; - // `pipe_stream` takes its own refs. - if let Some(promise) = - // SAFETY: sole owner so far; `&mut` scoped to the call. - unsafe { (*file_sink.as_ptr()).pipe_stream(&readable_stream, global_this) } - { - return Ok(promise); - } - - let assignment_result: JSValue = webcore::file_sink::JSSink::assign_to_stream( - global_this, - readable_stream.value, - file_sink.as_non_null(), - ); - - assignment_result.ensure_still_alive(); - - if let Some(err) = assignment_result.to_error() { + // `pipe_stream` takes its own refs; init's +1 drops with `file_sink` on return. + let mut readable_stream = readable_stream; + // SAFETY: sole owner so far; `&mut` scoped to the call. + let result = + unsafe { (*file_sink.as_ptr()).pipe_stream(&mut readable_stream, global_this) }; + if let Some(err) = result.to_error() { return Ok(JSPromise::rejected_promise(global_this, err).to_js()); } - - if !assignment_result.is_empty_or_undefined_or_null() { - global_this.bun_vm().as_mut().drain_microtasks(); - - assignment_result.ensure_still_alive(); - // it returns a Promise when it goes through ReadableStreamDefaultReader - if let Some(promise) = assignment_result.as_any_promise() { - match promise.status() { - jsc::js_promise::Status::Pending => { - let wrapper = bun_core::heap::into_raw(Box::new(FileStreamWrapper { - promise: jsc::JSPromiseStrong::init(global_this), - readable_stream_ref: - webcore::readable_stream::ReadableStreamStrong::init( - readable_stream, - global_this, - ), - sink: file_sink, - })); - // SAFETY: wrapper was just produced by heap::alloc; sole owner here. - let promise_value = unsafe { (*wrapper).promise.value() }; - assignment_result.then( - global_this, - wrapper.cast::(), - on_file_stream_resolve_request_stream_shim, - on_file_stream_reject_request_stream_shim, - ); - return Ok(promise_value); - } - jsc::js_promise::Status::Fulfilled => { - let written = file_sink.stream_bytes.get().unwrap_or(0); - readable_stream.done(); - return Ok(JSPromise::resolved_promise_value( - global_this, - JSValue::js_number(written as f64), - )); - } - jsc::js_promise::Status::Rejected => { - readable_stream.cancel(global_this)?; - promise.set_handled(global_this.vm()); - return Ok(JSPromise::rejected_promise( - global_this, - promise.result(global_this.vm()), - ) - .to_js()); - } - } - } else { - readable_stream.cancel(global_this)?; - return Ok(JSPromise::rejected_promise(global_this, assignment_result).to_js()); - } - } - let written = file_sink.stream_bytes.get().unwrap_or(0); - - Ok(JSPromise::resolved_promise_value( - global_this, - JSValue::js_number(written as f64), - )) + Ok(result) } fn get_writer(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { @@ -5708,95 +5641,6 @@ impl Drop for S3BlobDownloadTask { // doWrite / doUnlink / getExists // ────────────────────────────────────────────────────────────────────────── -// ────────────────────────────────────────────────────────────────────────── -// FileStreamWrapper / pipeReadableStreamToBlob -// ────────────────────────────────────────────────────────────────────────── - -struct FileStreamWrapper { - pub(crate) promise: jsc::JSPromiseStrong, - pub(crate) readable_stream_ref: webcore::readable_stream::ReadableStreamStrong, - pub sink: RefPtr, -} - -pub(crate) fn on_file_stream_resolve_request_stream( - global_this: &JSGlobalObject, - callframe: &CallFrame, -) -> JsResult { - let args = callframe.arguments(); - // SAFETY: last arg is a promise-ptr created by FileStreamWrapper::new in pipe_readable_stream_to_blob. - let mut this: Box = unsafe { - bun_core::heap::take(args[args.len() - 1].as_number() as usize as *mut FileStreamWrapper) - }; - let strong = core::mem::take(&mut this.readable_stream_ref); - if let Some(stream) = strong.get() { - stream.done(); - } - let written = this.sink.stream_bytes.get().unwrap_or(0); - this.promise - .resolve(global_this, JSValue::js_number(written as f64))?; - Ok(JSValue::UNDEFINED) -} - -pub(crate) fn on_file_stream_reject_request_stream( - global_this: &JSGlobalObject, - callframe: &CallFrame, -) -> JsResult { - let args = callframe.arguments(); - // Take ownership via Box so Drop runs `sink.deref()` - // and frees the wrapper. - // SAFETY: the trailing argument is the `FileStreamWrapper*` boxed and passed - // through `then()` from the resolve path; we are the sole consumer here. - let mut this: Box = unsafe { - bun_core::heap::take(args[args.len() - 1].as_number() as usize as *mut FileStreamWrapper) - }; - let err = args[0]; - - let strong = core::mem::take(&mut this.readable_stream_ref); - - this.promise.reject(global_this, Ok(err))?; - - if let Some(stream) = strong.get() { - stream.cancel(global_this)?; - } - Ok(JSValue::UNDEFINED) -} - -// C-ABI shims for `JSValue::then`. The Rust-side -// host fns above are `JSHostFnZig`; `then()` wants the raw `JSHostFn` shape. -// Exported under the legacy symbol names so C++ (`BunPromiseInlines.h`) links -// the same symbol it does today. -bun_jsc::jsc_host_abi! { - #[unsafe(export_name = "Bun__FileStreamWrapper__onResolveRequestStream")] - unsafe fn on_file_stream_resolve_request_stream_shim( - global: *mut JSGlobalObject, - callframe: *mut CallFrame, - ) -> JSValue { - // S008: `JSGlobalObject`/`CallFrame` are `opaque_ffi!` ZST handles — - // safe `*mut → &` via `opaque_deref` (JSC guarantees non-null/live). - let (global, callframe) = - (bun_opaque::opaque_deref(global), bun_opaque::opaque_deref(callframe)); - bun_jsc::host_fn::to_js_host_fn_result(global, on_file_stream_resolve_request_stream(global, callframe)) - } -} -bun_jsc::jsc_host_abi! { - #[unsafe(export_name = "Bun__FileStreamWrapper__onRejectRequestStream")] - unsafe fn on_file_stream_reject_request_stream_shim( - global: *mut JSGlobalObject, - callframe: *mut CallFrame, - ) -> JSValue { - // S008: `JSGlobalObject`/`CallFrame` are `opaque_ffi!` ZST handles — - // safe `*mut → &` via `opaque_deref` (JSC guarantees non-null/live). - let (global, callframe) = - (bun_opaque::opaque_deref(global), bun_opaque::opaque_deref(callframe)); - bun_jsc::host_fn::to_js_host_fn_result(global, on_file_stream_reject_request_stream(global, callframe)) - } -} - -// the local `AnyPromiseResultExt` shim was removed — -// `bun_jsc::AnyPromise::result` is an inherent method (and `JSInternalPromise` -// is a transparent alias for `JSPromise`), so the `.result(vm)` call below -// resolves directly upstream. - // ────────────────────────────────────────────────────────────────────────── // getSliceFrom / getSlice / type/name/lastModified/size getters // ────────────────────────────────────────────────────────────────────────── diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 7a0936ee5f57..ac658f791c87 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -54,7 +54,7 @@ pub struct FileSink { pub(crate) auto_flusher: JsCell, pub(crate) run_pending_later: FlushPendingTask, - /// Currently, only used when `stdin` in `Bun.spawn` is a ReadableStream. + /// The stream `assign_to_stream` (spawn stdin) or `pipe_stream` (`Bun.write`) is piping in. pub(crate) readable_stream: JsCell, /// `pipe_stream`: settled from `on_close` with `stream_bytes`, or with the error that ended @@ -1121,9 +1121,7 @@ impl FileSink { self.to_result(rc, accepted) } - /// Native-path terminator called from `SinkHandle::end`. On upstream error - /// (ByteStream source), close the writer without flushing — mirrors - /// `handle_reject_stream` — so a truncated write is not committed as EOF. + /// The source is done; `err` settles a piped stream. A failed ByteStream closes without a flush. pub(crate) fn end_from_stream(&self, err: Option) { let is_byte_stream = matches!(self.source.get(), streams::SourceHandle::ByteStream(_)); if is_byte_stream { @@ -1266,6 +1264,8 @@ impl FileSink { sys::Result::Ok(JSValue::js_number(written as f64)) } WriteResult::Err(err) => { + // `writer.end()` below runs `on_close`, which settles a piped stream with it. + self.record_stream_error(streams::StreamError::Error(err.clone())); self.done.set(true); if has_pending { // A backpressured write() left its promise outstanding. @@ -1378,6 +1378,18 @@ impl crate::webcore::sink::JsSinkType for FileSink { fn end_from_js(&mut self, global: &JSGlobalObject) -> sys::Result { Self::end_from_js(self, global) } + /// The JS pump's source failed, or `controller.close(error)`: a piped stream rejects with `reason`. + unsafe fn close_with_error( + this: *mut Self, + global: &JSGlobalObject, + reason: JSValue, + ) -> sys::Result<()> { + // SAFETY: caller contract; the pump promise's +1 or `assign_to_stream`'s caller keeps `this` alive. + unsafe { &*this }.end_from_stream(Some(streams::StreamError::JSValue( + bun_jsc::strong::Optional::create(reason, global), + ))); + sys::Result::Ok(()) + } fn source(&mut self) -> Option<&mut streams::SourceHandle> { // SAFETY: JsCell — trait receiver is `&mut self`; sole borrow of `source`. Some(unsafe { self.source.get_mut() }) @@ -1550,40 +1562,60 @@ impl FlushPendingTask { } impl FileSink { - /// Does not ref or unref. - fn handle_resolve_stream(&self) { + /// The JS pump finished: flush what it wrote and end. Does not ref or unref. + fn handle_resolve_stream(&self, global_this: &JSGlobalObject) { if let Some(stream) = self.readable_stream.get().get().as_mut() { stream.done(); } if !self.done.get() { - self.writer.with_mut(|w| w.close()); + // A flush error is recorded in `stream_error` and reaches `stream_done`. + let _ = self.end(None); } + self.detach_js_controller(global_this); } /// Does not ref or unref. - fn handle_reject_stream(&self, global_this: &JSGlobalObject, _err: JSValue) -> JsResult<()> { - if let Some(stream) = self.readable_stream.get().get().as_mut() { - let aborted = stream.abort(global_this); - self.readable_stream.set(readable_stream::Strong::default()); - aborted?; - } + fn handle_reject_stream(&self, global_this: &JSGlobalObject, err: JSValue) -> JsResult<()> { + self.record_stream_error(streams::StreamError::JSValue( + bun_jsc::strong::Optional::create(err, global_this), + )); + let readable_stream = self + .readable_stream + .replace(readable_stream::Strong::default()); + let aborted = match readable_stream.get() { + Some(stream) => stream.abort(global_this), + None => Ok(()), + }; if !self.done.get() { + // The body is truncated: drop what the writer buffered instead of flushing it. + self.done.set(true); self.writer.with_mut(|w| w.close()); } - Ok(()) + self.detach_js_controller(global_this); + aborted + } + + /// The pump is over: a controller whose `onClose` could not run must not outlive this sink attached. + fn detach_js_controller(&self, global_this: &JSGlobalObject) { + if let streams::SourceHandle::JSController(cell) = *self.source.get() { + self.source.set(streams::SourceHandle::None); + crate::dispatch::fold(::bun_jsc::call_check_slow(global_this, || { + streams::controller_abi::detach_ptr(cell) + })); + } } } -fn on_resolve_stream(_global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { +fn on_resolve_stream(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { bun_core::scoped_log!(FileSink, "onResolveStream"); let args = callframe.arguments(); let this: *mut FileSink = args[args.len() - 1].as_promise_ptr::(); // SAFETY: `this` is kept alive by the ref taken in `assign_to_stream`; this guard balances it. let _guard = unsafe { RefPtr::from_raw(this) }; // SAFETY: `as_promise_ptr` recovers the `*mut FileSink` stashed by `assign_to_stream`. - unsafe { (*this).handle_resolve_stream() }; + unsafe { (*this).handle_resolve_stream(global_this) }; Ok(JSValue::UNDEFINED) } @@ -1600,14 +1632,12 @@ fn on_reject_stream(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsRe } impl FileSink { - /// `Bun.write(file, stream)`: wire `stream`'s native source straight to this sink and return a - /// promise for the byte count once the file is closed. `None` if the stream is not a native - /// source; the caller falls back to the JS pump. + /// `Bun.write(file, stream)`: the byte-count promise `on_close` settles, or an `Error` value. pub fn pipe_stream( &mut self, - stream: &ReadableStream, + stream: &mut ReadableStream, global_this: &JSGlobalObject, - ) -> Option { + ) -> JSValue { // SAFETY: `&mut self` carries write+dealloc provenance over the allocation. let _guard = unsafe { RefPtr::init_ref(std::ptr::from_mut::(self)) }; @@ -1635,17 +1665,27 @@ impl FileSink { self.ref_(); } } - Some(promise) + promise } readable_stream::NativeWireResult::EndedInline(err) => { self.source.set(streams::SourceHandle::None); self.end_from_stream(err); - Some(promise) + promise } readable_stream::NativeWireResult::NotNative => { - self.stream_done.set(bun_jsc::JSPromiseStrong::empty()); - self.readable_stream.set(readable_stream::Strong::default()); - None + let result = self.assign_to_js_stream(stream, global_this); + if let Some(err) = result.to_error() { + self.stream_done.set(bun_jsc::JSPromiseStrong::empty()); + self.stream_bytes.set(None); + return err; + } + // A pump that already failed reports through `stream_done`. + if let Some(pump) = result.as_any_promise() { + if pump.status() == bun_jsc::js_promise::Status::Rejected { + pump.set_handled(global_this.vm()); + } + } + promise } } } @@ -1695,10 +1735,16 @@ impl FileSink { readable_stream::NativeWireResult::NotNative => {} } - // No per-wrapper +1 for the controller (only the transient `_guard` - // above): the JS builtins always call `controller.end()`/`.close()` - // (`${controller}__end/close` → `controller->detach()` → m_sinkPtr=null) - // before GC, so the controller's dtor never reaches `finalize`. + self.assign_to_js_stream(stream, global_this) + } + + /// Pump a JS `stream` in through a controller; the writer is ended when the pump settles. + fn assign_to_js_stream( + &mut self, + stream: &mut ReadableStream, + global_this: &JSGlobalObject, + ) -> JSValue { + // No +1 for the controller: ending the writer detaches it before this sink is freed. let promise_result = JSSink::assign_to_stream( global_this, stream.value, @@ -1710,44 +1756,31 @@ impl FileSink { return err; } - if !promise_result.is_empty_or_undefined_or_null() { - if let Some(promise) = promise_result.as_any_promise() { - // `bun_jsc::AnyPromise` (the active raw-ptr variant in - // lib.rs) does not yet expose `status()`/`result()`; recover the - // underlying `JSPromise` (JSInternalPromise subclasses JSPromise - // in C++, so the cast is layout-safe). - let js_promise: *mut bun_jsc::JSPromise = match promise { - bun_jsc::AnyPromise::Normal(p) => p, - bun_jsc::AnyPromise::Internal(p) => p.cast::(), - }; - // SAFETY: `as_any_promise` returned non-null. - match unsafe { (*js_promise).status() } { - bun_jsc::js_promise::Status::Pending => { - self.writer - .with_mut(|w| w.enable_keeping_process_alive(self.io_evtloop())); - self.ref_(); - // TODO: properly propagate exception upwards - // `JSValue::then` takes already-wrapped C-ABI - // host fns; the `toJSHostFunction` step is the manual - // shims at the bottom of this file. - promise_result.then( - global_this, - std::ptr::from_mut::(self), - on_resolve_stream_shim, - on_reject_stream_shim, - ); - } - bun_jsc::js_promise::Status::Fulfilled => { - // These don't ref(). - self.handle_resolve_stream(); - } - bun_jsc::js_promise::Status::Rejected => { - // These don't ref(). - // SAFETY: `js_promise` is non-null (`as_any_promise`). - let result = unsafe { (*js_promise).result(global_this.vm()) }; - crate::dispatch::fold(self.handle_reject_stream(global_this, result)); - } - } + let Some(promise) = promise_result.as_any_promise() else { + // The pump ran to the end inside `assign_to_stream`. + self.handle_resolve_stream(global_this); + return promise_result; + }; + match promise.status() { + bun_jsc::js_promise::Status::Pending => { + self.writer + .with_mut(|w| w.enable_keeping_process_alive(self.io_evtloop())); + self.ref_(); + promise_result.then( + global_this, + std::ptr::from_mut::(self), + on_resolve_stream_shim, + on_reject_stream_shim, + ); + } + bun_jsc::js_promise::Status::Fulfilled => { + // These don't ref(). + self.handle_resolve_stream(global_this); + } + bun_jsc::js_promise::Status::Rejected => { + // These don't ref(). + let result = promise.result(global_this.vm()); + crate::dispatch::fold(self.handle_reject_stream(global_this, result)); } } diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index 2d772eba9c4c..25b35c52816c 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -2120,9 +2120,9 @@ pub struct NetworkSink { pub global_this: Option>, /// Pending `flush()` promise. Serves both the user `s3file.writer().flush()` /// API and the `readDirectStream` / `BunAsyncIterableSource` pump, which - /// parks on `controller.flush(true)` (not `m_onPull`) on backpressure. + /// parks on `controller.flush(true)` (not the sink's onReady) on backpressure. /// Resolved by `on_writable`. The `readStreamIntoSink` pump no longer calls - /// `flush()` — it resumes via `source.ready()` → `m_onPull` — so no promise + /// `flush()` — it resumes via `source.ready()` → the sink's onReady — so no promise /// is allocated on that path. pub(crate) flush_promise: JSPromiseStrong, /// Backpressure promise returned from `write()` to a JS controller; diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 5b8956a995e5..7a92d817073f 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1045,6 +1045,252 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(Buffer.from(await Bun.file(dest).arrayBuffer())).toEqual(expected); }); + // A direct stream's pull() runs once; its promise resolving without close() is the end of the + // body, as for Bun.serve. Before, bytes written after the sink's last flush were dropped while + // the resolved count still included them, and the sink was freed with its JS controller still + // attached, which read the freed sink once the controller was collected. + it("a direct stream whose pull() resolves without close()", async () => { + using dir = tempDir("bun-write-direct-no-close", {}); + const dest = join(String(dir), "out.txt"); + const fixture = /* js */ ` + const { fileSinkInternals } = require("bun:internal-for-testing"); + const nextTask = () => new Promise(resolve => setImmediate(resolve)); + const baseline = fileSinkInternals.liveCount(); + let controller; + let stream = new ReadableStream({ + type: "direct", + async pull(c) { + controller = c; + c.write("hello"); + // The sink flushes "hello" to the file before this resolves. + await nextTask(); + c.write(" "); + c.write(new TextEncoder().encode("wörld")); + }, + }); + const written = await Bun.write(process.env.DEST, new Response(stream)); + const onDisk = await Bun.file(process.env.DEST).text(); + // The stream ended when pull() resolved: the controller is closed and detached. + const late = {}; + for (const method of ["write", "flush", "end"]) { + try { + late[method] = String(controller[method]("late")); + } catch (e) { + late[method] = e.message.split(".")[0]; + } + } + // Collect the stream and its sink controller. + stream = controller = undefined; + Bun.gc(true); + await nextTask(); + Bun.gc(true); + console.log(JSON.stringify({ written, onDisk, late, leakedSinks: fileSinkInternals.liveCount() - baseline })); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { ...bunEnv, DEST: dest }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + written: 12, + onDisk: "hello wörld", + late: { + write: "This FileSink has already been closed", + flush: "This FileSink has already been closed", + end: "undefined", + }, + leakedSinks: 0, + }); + expect(exitCode).toBe(0); + }); + + it("a stream whose source fails rejects with that error", async () => { + using dir = tempDir("bun-write-stream-reject", {}); + const nextTask = () => new Promise(resolve => setImmediate(resolve)); + const direct = new ReadableStream({ + type: "direct", + async pull(c) { + c.write("hello"); + await nextTask(); + throw new Error("boom"); + }, + }); + await expect(Bun.write(join(String(dir), "direct.txt"), new Response(direct))).rejects.toThrow("boom"); + const generator = async function* () { + yield "hello"; + await nextTask(); + throw new Error("boom"); + }; + await expect(Bun.write(join(String(dir), "generator.txt"), new Response(generator()))).rejects.toThrow("boom"); + const midway = new ReadableStream({ + async pull(c) { + c.enqueue("hello"); + await nextTask(); + c.error(new Error("boom")); + }, + }); + await expect(Bun.write(join(String(dir), "midway.txt"), midway)).rejects.toThrow("boom"); + const upfront = new ReadableStream({ + start(c) { + c.error(new Error("boom")); + }, + }); + await expect(Bun.write(join(String(dir), "upfront.txt"), upfront)).rejects.toThrow("boom"); + }); + + // The sink is released when the pump rejects; a controller collected after that must + // already be detached. A subprocess, because that GC can be the one at process exit. + it("survives a GC after a direct stream or a generator body fails", async () => { + using dir = tempDir("bun-write-body-fails-gc", {}); + const fixture = /* js */ ` + const dir = process.env.DEST_DIR; + const lines = []; + try { + await Bun.write(dir + "/gen.txt", new Response((async function* () { yield "a"; throw new Error("boom"); })())); + } catch (e) { + lines.push("gen:" + e.message); + } + let controller; + try { + await Bun.write( + dir + "/direct.txt", + new Response( + new ReadableStream({ + type: "direct", + async pull(c) { + controller = c; + c.write("a"); + await c.flush(); + throw new Error("boom"); + }, + }), + ), + ); + } catch (e) { + lines.push("direct:" + e.message); + } + try { + controller.write("late"); + lines.push("late:wrote"); + } catch { + lines.push("late:threw"); + } + controller = undefined; + Bun.gc(true); + await new Promise(resolve => setImmediate(resolve)); + Bun.gc(true); + console.log(lines.join(",")); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { ...bunEnv, DEST_DIR: String(dir) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("gen:boom,direct:boom,late:threw"); + expect(exitCode).toBe(0); + }); + + // The implicit end closes the stream like controller.close(): the source's cancel() runs once + // with no reason, and the controller is detached from the sink it no longer owns. + it("ends a direct stream's sink when its pull() returns", async () => { + using dir = tempDir("bun-write-direct-pull-returns", {}); + const dest = join(String(dir), "out.txt"); + let ctrl; + const cancelled = []; + const stream = new ReadableStream({ + type: "direct", + async pull(c) { + ctrl = c; + c.write("first "); + await c.flush(); + // A later event loop turn: "second" is still buffered in the sink when pull() returns. + await new Promise(resolve => setImmediate(resolve)); + c.write("second"); + }, + cancel(reason) { + cancelled.push(reason); + }, + }); + expect(await Bun.write(dest, new Response(stream))).toBe(12); + expect(await Bun.file(dest).text()).toBe("first second"); + expect(cancelled).toEqual([undefined]); + expect(() => ctrl.write("late event")).toThrow(/already been closed/); + expect(() => ctrl.flush()).toThrow(/already been closed/); + expect(ctrl.end()).toBeUndefined(); + Bun.gc(true); + }); + + // Every way the JS pump can settle without a controller.close(), each followed by a full GC + // with the controller cell unreferenced. Before, the cell kept a pointer to the freed FileSink + // and the collection (or the sink's queued flush task) was a use-after-free. `await 1` settles + // in the tick the write started in; `tick()` settles on a later event-loop turn. + it.each([ + [ + "an async generator that throws right after a yield", + `async function* () { yield "first"; throw new Error("boom"); }`, + "rejected: boom", + ], + [ + "an async generator that throws before its first yield", + `async function* () { throw new Error("boom"); }`, + "rejected: boom", + ], + [ + "an async generator that throws on a later tick", + `async function* () { yield "first"; await tick(); throw new Error("boom"); }`, + "rejected: boom", + ], + [ + "a direct ReadableStream whose pull rejects", + `() => new ReadableStream({ type: "direct", async pull(ctrl) { ctrl.write("first"); await 1; throw new Error("boom"); } })`, + "rejected: boom", + ], + [ + "a direct ReadableStream whose pull resolves without closing it", + `() => new ReadableStream({ type: "direct", async pull(ctrl) { ctrl.write("first"); await 1; } })`, + `resolved: 5, "first"`, + ], + [ + "a direct ReadableStream whose pull resolves on a later tick without closing it", + `() => new ReadableStream({ type: "direct", async pull(ctrl) { ctrl.write("first"); await tick(); } })`, + `resolved: 5, "first"`, + ], + ])("the body's sink controller is collectable after %s", async (_label, body, outcome) => { + using dir = tempDir("bun-write-settled-controller", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { readFileSync } = require("fs"); + const dest = ${JSON.stringify(join(String(dir), "out.txt"))}; + const tick = () => new Promise(resolve => setImmediate(resolve)); + const body = ${body}; + const outcomes = new Set(); + for (let i = 0; i < 5; i++) { + outcomes.add( + await Bun.write(dest, new Response(body())).then( + n => "resolved: " + n + ", " + JSON.stringify(readFileSync(dest, "utf8")), + e => "rejected: " + e.message, + ), + ); + Bun.gc(true); + } + console.log([...outcomes].join(" | "));`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ stdout: outcome, stderr: "" }); + expect(exitCode).toBe(0); + }); + // /dev/full: every write fails with ENOSPC. it.skipIf(process.platform !== "linux")("rejects with the write error, for each kind of body", async () => { await using server = await origin(); diff --git a/test/js/bun/spawn/spawn-stdin-readable-stream.test.ts b/test/js/bun/spawn/spawn-stdin-readable-stream.test.ts index 24fe38c4a3d5..cdcc4a621a96 100644 --- a/test/js/bun/spawn/spawn-stdin-readable-stream.test.ts +++ b/test/js/bun/spawn/spawn-stdin-readable-stream.test.ts @@ -156,6 +156,32 @@ describe("spawn stdin ReadableStream", () => { expect(await proc.exited).toBe(0); }); + // A direct stream's pull() runs once; its promise resolving without close() is the end of + // stdin. Before, the pipe was closed without flushing what the sink had buffered since its + // last flush, so the child saw a truncated stdin with no error anywhere. + test("direct ReadableStream whose pull() resolves without close()", async () => { + const stream = new ReadableStream({ + type: "direct", + async pull(controller) { + controller.write("hello"); + // The sink flushes "hello" to the pipe before this resolves. + await new Promise(resolve => setImmediate(resolve)); + controller.write(" "); + controller.write(new TextEncoder().encode("world")); + }, + }); + + await using proc = spawn({ + cmd: [bunExe(), "-e", "process.stdin.pipe(process.stdout)"], + stdin: stream, + stdout: "pipe", + env: bunEnv, + }); + + expect(await proc.stdout.text()).toBe("hello world"); + expect(await proc.exited).toBe(0); + }); + test("ReadableStream with large data", async () => { const largeData = "x".repeat(1024 * 1024); // 1MB const stream = new ReadableStream({ diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 39e3a621accf..30f114e74142 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -1705,6 +1705,104 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { }); }); + // A consumer that takes the whole body (.text(), .bytes(), Bun.readableStreamTo*(), a native + // sink) calls a direct stream's pull() once. When that pull() returns a promise, the promise + // resolving without close()/end() is the end of the body: the consumer finishes with everything + // written, as Bun.serve always did. Before, these consumers pulled once and then never settled. + // A reader (getReader/for-await/pipeTo) is a demand signal instead and pulls again; a sync + // pull() that returns without closing gives no completion signal, so it waits for close(). + describe("an async direct pull() that resolves without close() completes a whole-body consumer", () => { + const nextTask = () => new Promise(resolve => setImmediate(resolve)); + const mk = (hooks = {}) => + new ReadableStream({ + type: "direct", + async pull(c) { + c.write("hello"); + await nextTask(); + c.write(new TextEncoder().encode("wor")); + c.write("ld"); + }, + ...hooks, + }); + const decode = bytes => new TextDecoder().decode(bytes); + + it("Response and Request body methods", async () => { + expect(await new Response(mk()).text()).toBe("helloworld"); + expect(decode(await new Response(mk()).bytes())).toBe("helloworld"); + expect(decode(new Uint8Array(await new Response(mk()).arrayBuffer()))).toBe("helloworld"); + expect(await (await new Response(mk()).blob()).text()).toBe("helloworld"); + expect(await new Request("http://example.com/", { method: "POST", body: mk() }).text()).toBe("helloworld"); + const json = new ReadableStream({ + type: "direct", + async pull(c) { + c.write('{"hello":'); + await nextTask(); + c.write('"world"}'); + }, + }); + expect(await new Response(json).json()).toEqual({ hello: "world" }); + }); + + it("ReadableStream methods", async () => { + expect(await mk().text()).toBe("helloworld"); + expect(decode(await mk().bytes())).toBe("helloworld"); + expect(await (await mk().blob()).text()).toBe("helloworld"); + }); + + it("Bun.readableStreamTo*()", async () => { + expect(await readableStreamToText(mk())).toBe("helloworld"); + expect(decode(await readableStreamToBytes(mk()))).toBe("helloworld"); + expect(decode(new Uint8Array(await readableStreamToArrayBuffer(mk())))).toBe("helloworld"); + const chunks = await readableStreamToArray(mk()); + expect(chunks.map(chunk => (typeof chunk === "string" ? chunk : decode(chunk))).join("")).toBe("helloworld"); + }); + + it("runs the source's close() hook once, as an explicit close() does", async () => { + let textCloses = 0; + expect(await new Response(mk({ close: () => textCloses++ })).text()).toBe("helloworld"); + let bytesCloses = 0; + expect(decode(await new Response(mk({ close: () => bytesCloses++ })).bytes())).toBe("helloworld"); + expect([textCloses, bytesCloses]).toEqual([1, 1]); + }); + + it("a sync pull() that returns without close() still waits for close()", async () => { + let controller; + const text = new Response( + new ReadableStream({ + type: "direct", + pull(c) { + controller = c; + c.write("hello"); + }, + }), + ).text(); + let settled = false; + text.finally(() => (settled = true)); + await nextTask(); + expect(settled).toBe(false); + controller.write("world"); + controller.close(); + expect(await text).toBe("helloworld"); + }); + + it("a reader still pulls again after each pull() resolves", async () => { + let pulls = 0; + const rs = new ReadableStream({ + type: "direct", + async pull(c) { + pulls++; + await nextTask(); + c.write("x"); + if (pulls === 3) c.close(); + }, + }); + const chunks = []; + for await (const chunk of rs) chunks.push(decode(chunk)); + expect(pulls).toBe(3); + expect(chunks.join("")).toBe("xxx"); + }); + }); + it("a patched Object.prototype.then that releases the reader mid-resolution does not crash", async () => { let releaseNow = null; Object.defineProperty(Object.prototype, "then", { From a93c239b5adca4bb4398ec62dbe70f8606ff9786 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:25:18 +0000 Subject: [PATCH 03/31] One-line comment on directStreamOnClose --- src/jsc/bindings/webcore/streams/BunStreamSource.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index 3f85b4653a68..da251a268caf 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -681,8 +681,7 @@ static void nativeSourcePullRejected(JSC::VM& vm, JSGlobalObject* globalObject, // The native-sink path -// The direct stream's sink closed. State mutation first; everything that can throw (the sink's -// end(), the user's cancel(reason)) runs after the state is final and propagates to whoever closed. +// The direct stream's sink closed: state first, then the parts that can throw (sink end(), user cancel()). static void directStreamOnClose(JSC::VM& vm, JSGlobalObject* globalObject, WebCore::JSReadableSinkControllerBase* sinkController, JSDirectStreamSource* source, JSPromise* closePromise, JSReadableStream* stream, JSValue reason, bool sinkClosed) { auto scope = DECLARE_THROW_SCOPE(vm); From fc40ef896a54d10d2fd9910aae228f4369dab907 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 17:30:08 -0700 Subject: [PATCH 04/31] Native stream sources notify their C++ adapter on close directly; drop the unused onDrain hook --- .../webcore/streams/BunStreamSource.cpp | 74 ++----- .../webcore/streams/JSStreamsRuntime.h | 10 +- src/runtime/api/streams.classes.ts | 11 +- src/runtime/webcore/ReadableStream.rs | 200 +++--------------- 4 files changed, 56 insertions(+), 239 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index da251a268caf..83cfd2f1428e 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -21,11 +21,11 @@ #include "JSStreamsRuntime.h" #include "WebStreamsHeapAnalyzer.h" #include "WebStreamsInternals.h" +#include "ZigGeneratedClasses.h" #include "ZigGlobalObject.h" #include #include #include -#include #include #include #include @@ -164,10 +164,17 @@ static constexpr size_t nativeSourceMinChunkSize = 64 * 1024; static constexpr size_t nativeSourceDefaultChunkSize = 256 * 1024; static constexpr size_t nativeSourceMaxChunkSize = 2 * 1024 * 1024; -// Shared bound-convention wrapper: see createStreamsBoundHandler (WebStreamsMisc.cpp). -static inline JSBoundFunction* createBoundHandler(JSGlobalObject* globalObject, JSFunction* target, JSCell* context) +// The native source wrapper's `closeAdapter` slot: what `NewSource::on_close` (ReadableStream.rs) notifies, and the GC edge that keeps the adapter alive for it. +static void setNativeSourceCloseAdapter(JSC::VM& vm, JSObject* handle, JSValue adapter) { - return createStreamsBoundHandler(globalObject, target, context); + if (auto* source = dynamicDowncast(handle)) + source->m_closeAdapter.set(vm, source, adapter); + else if (auto* source = dynamicDowncast(handle)) + source->m_closeAdapter.set(vm, source, adapter); + else if (auto* source = dynamicDowncast(handle)) + source->m_closeAdapter.set(vm, source, adapter); + else + ASSERT_NOT_REACHED(); } // object.(...args) with a real [[Get]], as the replaced builtins did. @@ -259,22 +266,15 @@ static bool nativeCloserFlag(JSC::VM& vm, JSGlobalObject* globalObject, JSNative return flag.toBoolean(globalObject); } -// Terminal severing: the handle edge, the pending view, and the handle's callback slots. +// Terminal severing: the handle edge, the pending view, and the handle's closeAdapter slot. static void nativeSourceSever(JSGlobalObject* globalObject, JSNativeStreamSourceAdapter* adapter) { auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); JSObject* handle = adapter->handle(); adapter->clearHandle(vm); adapter->clearPendingView(vm); - if (!handle) - return; - PutPropertySlot onCloseSlot(handle, false); - handle->methodTable()->put(handle, globalObject, builtinNames(vm).onClosePublicName(), jsUndefined(), onCloseSlot); - RETURN_IF_EXCEPTION(scope, ); - PutPropertySlot onDrainSlot(handle, false); - handle->methodTable()->put(handle, globalObject, builtinNames(vm).onDrainPublicName(), jsUndefined(), onDrainSlot); - RELEASE_AND_RETURN(scope, ); + if (handle) + setNativeSourceCloseAdapter(vm, handle, jsUndefined()); } // The queued callClose job body (run from its own microtask, which reports what this throws): @@ -474,16 +474,7 @@ void materializeNativeSource(JSGlobalObject* globalObject, JSReadableStream* str if (!drainValue.isUndefined()) adapter->setDrainValue(vm, drainValue); - auto* onCloseBound = createBoundHandler(globalObject, runtime->boundOnNativeSourceClose(), adapter); - RETURN_IF_EXCEPTION(scope, ); - auto* onDrainBound = createBoundHandler(globalObject, runtime->boundOnNativeSourceDrain(), adapter); - RETURN_IF_EXCEPTION(scope, ); - PutPropertySlot onCloseSlot(handle, false); - handle->methodTable()->put(handle, globalObject, builtinNames(vm).onClosePublicName(), onCloseBound, onCloseSlot); - RETURN_IF_EXCEPTION(scope, ); - PutPropertySlot onDrainSlot(handle, false); - handle->methodTable()->put(handle, globalObject, builtinNames(vm).onDrainPublicName(), onDrainBound, onDrainSlot); - RETURN_IF_EXCEPTION(scope, ); + setNativeSourceCloseAdapter(vm, handle, adapter); auto* controller = WebCore::JSReadableStreamDefaultController::create(vm, WebCore::getDOMStructure(vm, *domGlobalObject)); controller->m_algorithms.kind = SourceKind::Native; @@ -626,21 +617,7 @@ JSPromise* cancelPendingNativeSource(JSGlobalObject* globalObject, JSReadableStr }); } -// The [bound-convention] onDrain body: a dead consumer drops the chunk. -static void nativeSourceOnDrain(JSGlobalObject* globalObject, JSNativeStreamSourceAdapter* adapter, JSValue chunk) -{ - auto* controller = adapter->controller(); - if (!controller) - return; - if (adapter->m_textMode) { - if (auto* view = dynamicDowncast(chunk)) - nativeEnqueueTextChunk(globalObject, controller, adapter->m_textState, view->span(), /* flush */ false); - return; - } - readableStreamDefaultControllerEnqueue(globalObject, controller, chunk); -} - -// The [bound-convention] native-initiated onClose body. +// The native handle closed (Bun__NativeStreamSourceAdapter__onClose, run from its microtask). static void nativeSourceOnClose(JSGlobalObject* globalObject, JSNativeStreamSourceAdapter* adapter) { adapter->m_closed = true; @@ -1349,26 +1326,17 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadStreamIntoSinkRejected, (JSGl return JSValue::encode(jsUndefined()); } -// [bound-convention]: handler(contextCell, ...callArgs). - -JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundOnNativeSourceClose, (JSGlobalObject * globalObject, CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onNativeSourceHandleClosedMicrotask, (JSGlobalObject * globalObject, CallFrame* callFrame)) { - auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - auto* adapter = uncheckedDowncast(callFrame->argument(0)); + auto* adapter = uncheckedDowncast(callFrame->argument(1)); Bun::WebStreams::nativeSourceOnClose(globalObject, adapter); - RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundOnNativeSourceDrain, (JSGlobalObject * globalObject, CallFrame* callFrame)) +extern "C" void Bun__NativeStreamSourceAdapter__onClose(JSGlobalObject* globalObject, JSC::EncodedJSValue adapter) { - auto& vm = getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - auto* adapter = uncheckedDowncast(callFrame->argument(0)); - Bun::WebStreams::nativeSourceOnDrain(globalObject, adapter, callFrame->argument(1)); - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(jsUndefined()); + // Deferred: the native side may report close from inside a pull() or off any JS frame. + Bun::WebStreams::queueStreamsMicrotask(globalObject, WebCore::JSStreamsRuntime::from(globalObject)->onNativeSourceHandleClosedMicrotask(), jsUndefined(), JSValue::decode(adapter)); } // [reaction-convention] pull() runs once for a native sink: its promise resolving without close()/end() ends the sink (a no-op once detached). diff --git a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h index c030eb4fbb87..7255b479bc20 100644 --- a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h +++ b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h @@ -166,12 +166,14 @@ namespace WebCore { // onNativePull*: context = the JSNativeStreamSourceAdapter. // onNativeSourceCallCloseMicrotask: the native source's `queueMicrotask(callClose)` job; // context = the adapter. +// onNativeSourceHandleClosedMicrotask: the native handle reported close (ReadableStream.rs on_close); context = the adapter. // onReadDirectStreamPullFulfilled: readDirectStream's pull() resolved. context = the native sink controller. // onReadStreamIntoSink*: context = the JSReadStreamIntoSinkOperation. #define FOR_EACH_WEB_STREAMS_REACTION_HANDLER_BUN_SOURCE(V) \ V(onNativePullFulfilled) \ V(onNativePullRejected) \ V(onNativeSourceCallCloseMicrotask) \ + V(onNativeSourceHandleClosedMicrotask) \ V(onReadDirectStreamPullFulfilled) \ V(onReadStreamIntoSinkReadManyFulfilled) \ V(onReadStreamIntoSinkChunk) \ @@ -248,13 +250,6 @@ namespace WebCore { // [bound-convention] targets, grouped by the .cpp that OWNS the body. // Signature of every entry: name(contextCell at argument(0), ...callArgs). -// owner: BunStreamSource.cpp. -// boundOnNativeSourceClose(adapter) / boundOnNativeSourceDrain(adapter, chunk): stored as -// handle.onClose / handle.onDrain. -#define FOR_EACH_WEB_STREAMS_BOUND_HANDLER_TARGET_BUN_SOURCE(V) \ - V(boundOnNativeSourceClose) \ - V(boundOnNativeSourceDrain) - // owner: JSDirectStreamController.cpp — the FIVE detachable own methods of the direct // controller: `end` and `close` are two bound cells over the ONE boundDirectClose target. #define FOR_EACH_WEB_STREAMS_BOUND_HANDLER_TARGET_DIRECT_CONTROLLER(V) \ @@ -283,7 +278,6 @@ namespace WebCore { // THE closed [bound-convention] list. #define FOR_EACH_WEB_STREAMS_BOUND_HANDLER_TARGET(V) \ - FOR_EACH_WEB_STREAMS_BOUND_HANDLER_TARGET_BUN_SOURCE(V) \ FOR_EACH_WEB_STREAMS_BOUND_HANDLER_TARGET_DIRECT_CONTROLLER(V) \ FOR_EACH_WEB_STREAMS_BOUND_HANDLER_TARGET_ONE_SHOT(V) \ FOR_EACH_WEB_STREAMS_BOUND_HANDLER_TARGET_PIPE(V) diff --git a/src/runtime/api/streams.classes.ts b/src/runtime/api/streams.classes.ts index 5a0a5b6a7537..41f481825f7e 100644 --- a/src/runtime/api/streams.classes.ts +++ b/src/runtime/api/streams.classes.ts @@ -33,14 +33,6 @@ function source(name) { fn: "updateRefFromJS", length: 1, }, - onClose: { - getter: "getOnCloseFromJS", - setter: "setOnCloseFromJS", - }, - onDrain: { - getter: "getOnDrainFromJS", - setter: "setOnDrainFromJS", - }, cancel: { fn: "cancelFromJS", length: 1, @@ -95,7 +87,8 @@ function source(name) { // `owner` roots the GC cell of the peer producing into this source // (`producer` backref); `sinkOwner` roots the peer it pipes into // (`sink` backref). A chained transform needs both. - values: ["pendingPromise", "onCloseCallback", "onDrainCallback", "owner", "sinkOwner"], + // `closeAdapter`: the C++ JSNativeStreamSourceAdapter to notify from `on_close` (BunStreamSource.cpp sets it). + values: ["pendingPromise", "closeAdapter", "owner", "sinkOwner"], }); } diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index 86f0bc7d40a9..ef46ce7638ce 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -147,6 +147,8 @@ unsafe extern "C" { safe fn ReadableStream__textDecodeFrom(global: &JSGlobalObject, source: JSValue) -> JSValue; safe fn ReadableStream__detach(stream: JSValue, global: &JSGlobalObject); safe fn ReadableStream__lockNative(stream: JSValue, global: &JSGlobalObject); + /// BunStreamSource.cpp: queue the adapter's close on the microtask queue. + safe fn Bun__NativeStreamSourceAdapter__onClose(global: &JSGlobalObject, adapter: JSValue); safe fn ZigGlobalObject__createNativeReadableStream( global: &JSGlobalObject, native_ptr: JSValue, @@ -774,14 +776,10 @@ pub trait SourceContext: Sized { fn js_create(ptr: *mut c_void, global: &JSGlobalObject) -> JSValue; /// `js_${NAME}InternalReadableStreamSource::pending_promise_set_cached` fn js_pending_promise_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); - /// `js_${NAME}InternalReadableStreamSource::on_drain_callback_set_cached` - fn js_on_drain_callback_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); - /// `js_${NAME}InternalReadableStreamSource::on_drain_callback_get_cached` - fn js_on_drain_callback_get_cached(this: JSValue) -> Option; - /// `js_${NAME}InternalReadableStreamSource::on_close_callback_set_cached` - fn js_on_close_callback_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); - /// `js_${NAME}InternalReadableStreamSource::on_close_callback_get_cached` - fn js_on_close_callback_get_cached(this: JSValue) -> Option; + /// `js_${NAME}InternalReadableStreamSource::close_adapter_set_cached` + fn js_close_adapter_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); + /// `js_${NAME}InternalReadableStreamSource::close_adapter_get_cached` + fn js_close_adapter_get_cached(this: JSValue) -> Option; /// `js_${NAME}InternalReadableStreamSource::owner_set_cached` fn js_owner_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); /// `js_${NAME}InternalReadableStreamSource::sink_owner_set_cached` @@ -842,7 +840,7 @@ pub trait SourceContext: Sized { // type generic over `C`): codegen name is "JS{C::NAME}InternalReadableStreamSource". // The toJS/fromJS/fromJSDirect aliases are wired // manually below; cached-property accessors (pendingPromiseSetCached, -// onDrainCallback{Get,Set}Cached) are emitted by the .classes.ts generator. +// closeAdapter{Get,Set}Cached) are emitted by the .classes.ts generator. // // `repr(C)` keeps `context` at offset 0: C++ `wrapped()` returns `*mut NewSource` and // [`ReadableStream::from_js`] casts that straight to `*mut C`. @@ -854,11 +852,6 @@ pub struct NewSource { pub cancelled: bool, pub ref_count: u32, pub pending_err: Option, - pub close_handler: Option)>, - /// Borrowed opaque context for native `close_handler`s (never - /// owned/freed here). The JS path stores - /// `on_js_close` and leaves this `None` — see [`Self::on_close`]. - pub close_ctx: Option>, /// Upstream producer to notify on cancel/drain/consumer-attach. Replaces /// the per-signal fn-ptr + ctx-ptr pairs with one typed handle. pub producer: Cell, @@ -872,7 +865,7 @@ pub struct NewSource { /// native I/O ref is held (FileReader `waiting_for_on_reader_done`), and /// [`JsRef::downgrade`]d back to `Weak` in [`Self::decrement_count`] when /// only the wrapper's own ref remains. [`Self::finalize`] flips it to - /// `Finalized` so [`Self::on_js_close`] reads `None` instead of a + /// `Finalized` so [`Self::on_close`] reads `None` instead of a /// dead-but-unswept cell. pub this_jsvalue: jsc::JsRef, /// The producer holding a native ref has parked ([`Self::unroot_wrapper`]): @@ -892,8 +885,6 @@ impl Default for NewSource { cancelled: false, ref_count: 1, pending_err: None, - close_handler: None, - close_ctx: None, producer: Cell::new(streams::SourceHandle::None), global_this: None, this_jsvalue: jsc::JsRef::empty(), @@ -911,10 +902,8 @@ impl Default for NewSource { pub(crate) trait NewSourceCodegen { fn to_js(&mut self, global_this: &JSGlobalObject) -> JSValue; fn pending_promise_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); - fn on_drain_callback_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); - fn on_drain_callback_get_cached(this: JSValue) -> Option; - fn on_close_callback_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); - fn on_close_callback_get_cached(this: JSValue) -> Option; + fn close_adapter_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); + fn close_adapter_get_cached(this: JSValue) -> Option; fn owner_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); fn sink_owner_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue); } @@ -944,32 +933,18 @@ macro_rules! source_context_codegen { $crate::generated_classes::$gen::pending_promise_set_cached(this, global, value) } #[inline] - fn js_on_drain_callback_set_cached( + fn js_close_adapter_set_cached( this: $crate::webcore::jsc::JSValue, global: &$crate::webcore::jsc::JSGlobalObject, value: $crate::webcore::jsc::JSValue, ) { - $crate::generated_classes::$gen::on_drain_callback_set_cached(this, global, value) + $crate::generated_classes::$gen::close_adapter_set_cached(this, global, value) } #[inline] - fn js_on_drain_callback_get_cached( + fn js_close_adapter_get_cached( this: $crate::webcore::jsc::JSValue, ) -> Option<$crate::webcore::jsc::JSValue> { - $crate::generated_classes::$gen::on_drain_callback_get_cached(this) - } - #[inline] - fn js_on_close_callback_set_cached( - this: $crate::webcore::jsc::JSValue, - global: &$crate::webcore::jsc::JSGlobalObject, - value: $crate::webcore::jsc::JSValue, - ) { - $crate::generated_classes::$gen::on_close_callback_set_cached(this, global, value) - } - #[inline] - fn js_on_close_callback_get_cached( - this: $crate::webcore::jsc::JSValue, - ) -> Option<$crate::webcore::jsc::JSValue> { - $crate::generated_classes::$gen::on_close_callback_get_cached(this) + $crate::generated_classes::$gen::close_adapter_get_cached(this) } #[inline] fn js_owner_set_cached( @@ -1003,17 +978,11 @@ impl NewSourceCodegen for NewSource { fn pending_promise_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue) { C::js_pending_promise_set_cached(this, global, value) } - fn on_drain_callback_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue) { - C::js_on_drain_callback_set_cached(this, global, value) - } - fn on_drain_callback_get_cached(this: JSValue) -> Option { - C::js_on_drain_callback_get_cached(this) + fn close_adapter_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue) { + C::js_close_adapter_set_cached(this, global, value) } - fn on_close_callback_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue) { - C::js_on_close_callback_set_cached(this, global, value) - } - fn on_close_callback_get_cached(this: JSValue) -> Option { - C::js_on_close_callback_get_cached(this) + fn close_adapter_get_cached(this: JSValue) -> Option { + C::js_close_adapter_get_cached(this) } fn owner_set_cached(this: JSValue, global: &JSGlobalObject, value: JSValue) { C::js_owner_set_cached(this, global, value) @@ -1121,53 +1090,36 @@ impl NewSource { p.close(None); } + /// Tell the C++ `JSNativeStreamSourceAdapter` (if a stream consumer attached one) that the native side closed. pub fn on_close(&mut self) { if self.cancelled { return; } - if let Some(close) = self.close_handler.take() { - // Identity check against the *exact* fn pointer stored by `set_on_close_from_js`, so the - // JS path receives `self` (not `close_ctx`, which is unset on that path). - if close as usize == Self::on_js_close as fn(Option<*mut c_void>) as usize { - Self::on_js_close(Some(std::ptr::from_mut(self).cast::())); - } else { - close(self.close_ctx.map(|p| p.as_ptr())); - } - } - } - - /// `JSReadableStreamSource.onClose` — invoked via `close_handler` when the - /// JS side registered an `onclose` callback. Stored *directly* in - /// `close_handler` by [`Self::set_on_close_from_js`] so the fn-pointer - /// identity check above matches. - fn on_js_close(ptr: Option<*mut c_void>) { - // SAFETY: ptr was set to `self as *mut NewSource` in on_close()/set_on_close_from_js. - let this = unsafe { &mut *(ptr.unwrap().cast::>()) }; - // Reached from `FileReader::on_reader_done` off the event loop. While - // the across-read ref is held (`increment_count` upgraded to Strong), - // the wrapper is rooted and `try_get()` is `Some`. If the wrapper was - // already finalized, `try_get()` is `None` and there is no callback. - let Some(this_jsvalue) = this.this_jsvalue.try_get() else { + // A finalized wrapper reads `None` here (see `this_jsvalue`), so a close after GC is a no-op. + let Some(this_jsvalue) = self.this_jsvalue.try_get() else { return; }; - let global_this = this.global_this(); - if let Some(cb) = ::on_close_callback_get_cached(this_jsvalue) { - if !cb.is_undefined() { - global_this.queue_microtask(cb, &[]); - } + let Some(adapter) = ::close_adapter_get_cached(this_jsvalue) + else { + return; + }; + if !adapter.is_cell() { + return; } - ::on_close_callback_set_cached( + let global_this = self.global_this(); + ::close_adapter_set_cached( this_jsvalue, global_this, JSValue::UNDEFINED, ); + Bun__NativeStreamSourceAdapter__onClose(global_this, adapter); } pub fn increment_count(&mut self) { self.ref_count += 1; // A ref beyond the JS wrapper's own is held (in practice a FileReader // `waiting_for_on_reader_done` I/O ref). Root the wrapper so - // `on_js_close`, reached from `on_reader_done` off the event loop with + // `on_close`, reached from `on_reader_done` off the event loop with // no JS frame on the stack, never reads a dead-but-unswept cell. if !self.wrapper_unrooted.get() { // SAFETY: `self` is live for the call. @@ -1406,96 +1358,6 @@ impl NewSource { Ok(JSValue::UNDEFINED) } - pub fn set_on_close_from_js( - &mut self, - global_object: &JSGlobalObject, - value: JSValue, - ) -> JsResult<()> { - // Store the handler by *identity* — `NewSource::on_close` compares the - // stored fn pointer against `on_js_close` to decide whether to pass - // `self` (JS path) or `close_ctx` (native path). - self.close_handler = Some(Self::on_js_close); - self.global_this = Some(bun_ptr::BackRef::new(global_object)); - - if value.is_undefined() { - if let Some(this_jsvalue) = self.this_jsvalue.try_get() { - ::on_close_callback_set_cached( - this_jsvalue, - global_object, - JSValue::UNDEFINED, - ); - } - return Ok(()); - } - - if !value.is_callable() { - return Err(global_object.throw_invalid_argument_type( - "ReadableStreamSource", - "onclose", - "function", - )); - } - let cb = value.with_async_context_if_needed(global_object); - if let Some(this_jsvalue) = self.this_jsvalue.try_get() { - ::on_close_callback_set_cached( - this_jsvalue, - global_object, - cb, - ); - } - Ok(()) - } - - pub fn set_on_drain_from_js( - &mut self, - global_object: &JSGlobalObject, - value: JSValue, - ) -> JsResult<()> { - self.global_this = Some(bun_ptr::BackRef::new(global_object)); - - let Some(this_jsvalue) = self.this_jsvalue.try_get() else { - return Ok(()); - }; - - if value.is_undefined() { - ::on_drain_callback_set_cached( - this_jsvalue, - global_object, - JSValue::UNDEFINED, - ); - return Ok(()); - } - - if !value.is_callable() { - return Err(global_object.throw_invalid_argument_type( - "ReadableStreamSource", - "onDrain", - "function", - )); - } - let cb = value.with_async_context_if_needed(global_object); - ::on_drain_callback_set_cached(this_jsvalue, global_object, cb); - Ok(()) - } - - pub fn get_on_close_from_js(&mut self, _global_object: &JSGlobalObject) -> JSValue { - if let Some(this_jsvalue) = self.this_jsvalue.try_get() { - if let Some(val) = - ::on_close_callback_get_cached(this_jsvalue) - { - return val; - } - } - JSValue::UNDEFINED - } - - pub fn get_on_drain_from_js(&mut self, _global_object: &JSGlobalObject) -> JSValue { - self.this_jsvalue - .try_get() - .and_then(::on_drain_callback_get_cached) - .unwrap_or(JSValue::UNDEFINED) - } - pub fn update_ref_from_js( &mut self, _global_object: &JSGlobalObject, From fc8979b0cfd015dc21265fa1d8a3d9cb82a386c6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:42:43 +0000 Subject: [PATCH 05/31] FileSink::close_with_error: hold a ref across end_from_stream --- src/runtime/webcore/FileSink.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index ac658f791c87..ca691524a02f 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -1384,7 +1384,11 @@ impl crate::webcore::sink::JsSinkType for FileSink { global: &JSGlobalObject, reason: JSValue, ) -> sys::Result<()> { - // SAFETY: caller contract; the pump promise's +1 or `assign_to_stream`'s caller keeps `this` alive. + // `end_from_stream` can re-enter `on_close`, which releases the keep-alive ref; hold one + // across the call like `on_write`/`on_attached_process_exit` do. + // SAFETY: caller contract; `this` is live. + let _guard = unsafe { RefPtr::init_ref(this) }; + // SAFETY: `_guard` keeps `this` live for this borrow. unsafe { &*this }.end_from_stream(Some(streams::StreamError::JSValue( bun_jsc::strong::Optional::create(reason, global), ))); From 458d4a6676f91a4923b14aad17a61b7da46b9e81 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 17:59:36 -0700 Subject: [PATCH 06/31] Direct streams: pull() runs once for readers too; close(error) fails the stream on every path; a rejected pull() does not cancel() the source; contract test matrix --- docs/runtime/streams.mdx | 6 +- packages/bun-types/bun.d.ts | 16 +- packages/bun-types/globals.d.ts | 5 + src/codegen/generate-jssink.ts | 26 +- .../webcore/streams/BunStreamConsumers.cpp | 29 ++- .../webcore/streams/BunStreamSource.cpp | 13 +- .../streams/JSDirectStreamController.cpp | 95 +------- .../streams/JSDirectStreamController.h | 8 +- .../webcore/streams/JSStreamsRuntime.h | 3 +- .../http/serve-direct-readable-stream.test.ts | 37 +++ test/js/web/streams/direct-stream-contract.ts | 158 ++++++++++++ test/js/web/streams/streams.test.js | 229 +++++++++++------- 12 files changed, 422 insertions(+), 203 deletions(-) create mode 100644 test/js/web/streams/direct-stream-contract.ts diff --git a/docs/runtime/streams.mdx b/docs/runtime/streams.mdx index a72132ea1c3f..97acb551b3a9 100644 --- a/docs/runtime/streams.mdx +++ b/docs/runtime/streams.mdx @@ -73,11 +73,9 @@ When using a direct `ReadableStream`, the destination handles all chunk queueing ### Ending the stream -Call `controller.close()` (or `controller.end()`) once everything is written. Every destination flushes what it has buffered and finishes at that point. +Call `controller.close()` (or `controller.end()`) once everything is written. Every destination flushes what it has buffered and finishes at that point. `controller.close(error)` fails the stream instead: nothing more is flushed and the consumer rejects with `error`. -A destination that takes the whole stream (a `Response` or `Request` body, `Bun.serve`, `fetch`, `Bun.write`, `Bun.spawn` stdin, `.text()`, `.bytes()`, `Bun.readableStreamToText()`, and so on) calls `pull()` once. If that `pull()` returns a promise, the stream also ends when the promise resolves, as if `pull()` had called `controller.close()` last. If the promise rejects, the destination fails with that error. A `pull()` that returns without a promise and without closing keeps the stream open until something calls `controller.close()`. - -A reader (`getReader()`, `for await`, `pipeTo()`, `pipeThrough()`, `tee()`) treats `pull()` as a request for more data instead: it calls `pull()` again for each read until the stream is closed, like a default `ReadableStream` does. +`pull()` is called once, by whatever consumes the stream (`Bun.serve`, `fetch`, `Bun.write`, `Bun.spawn` stdin, `.text()`, a reader, `pipeTo()`, and so on). If it returns a promise, the stream stays open while that promise is pending and ends when it resolves, as if `pull()` had called `controller.close()` last; if it rejects, the stream errors with that reason. A `pull()` that returns without a promise and without closing keeps the stream open until something calls `controller.close()`, so you can hold on to the controller and write from events. ### Handling backpressure diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index e2acb5e0277a..82d77ebc36f5 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -580,15 +580,13 @@ declare module "bun" { interface DirectUnderlyingSource { cancel?: UnderlyingSourceCancelCallback; /** - * Write the stream's data with `controller.write()` and finish with - * `controller.close()`. - * - * A destination that consumes the whole stream (a `Response` body, - * `Bun.serve`, `Bun.write`, `Bun.spawn` stdin, `.text()`, ...) calls - * `pull()` once. If it returns a promise, the stream also ends when that - * promise resolves, and fails if it rejects. A reader (`getReader()`, - * `for await`, `pipeTo()`) calls `pull()` again for each read until the - * stream is closed. + * Called once. Write the stream's data with `controller.write()` (await + * it for backpressure) and finish with `controller.close()`. + * + * If `pull()` returns a promise, the stream stays open while it is + * pending, ends when it resolves, and errors if it rejects. If it returns + * synchronously without closing, the stream stays open until + * `controller.close()` is called. */ pull: (controller: ReadableStreamDirectController) => void | PromiseLike; type: "direct"; diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index 9fc337e84963..9fa78fc18ed1 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -727,6 +727,11 @@ interface ReadableStreamDefaultController { } interface ReadableStreamDirectController { + /** + * Finish the stream. With no argument this flushes buffered bytes and ends, + * like {@link end}. With an `error`, the stream fails: buffered bytes are + * dropped and the consumer rejects with `error`. + */ close(error?: Error): void; /** * Write a chunk directly to the destination. diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index 3c08aff0155e..f0bf80e4ffcc 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -143,7 +143,7 @@ function header() { ~${controller}(); - void detach(); + void detach(JSC::JSValue reason = {}); DECLARE_VISIT_CHILDREN; @@ -220,6 +220,7 @@ public: } JSC::JSObject* readableStream() const { return m_weakReadableStream.get(); } JSC::EncodedJSValue end(JSC::JSGlobalObject*); // the JS-visible end(): finish the native sink, then detach() + JSC::EncodedJSValue close(JSC::JSGlobalObject*, JSC::JSValue reason); // the JS-visible close(reason): a truthy reason fails the sink without flushing void* m_sinkPtr; SinkID m_sinkId; @@ -431,13 +432,13 @@ static JSC::EncodedJSValue ${controller}__closeWithReason(JSC::JSGlobalObject* l if (!scope.tryClearException()) { return {}; } - controller->detach(); + controller->detach(JSC::JSValue::decode(reason)); (void)scope.tryClearException(); scope.throwException(lexicalGlobalObject, pending); return {}; } - controller->detach(); + controller->detach(JSC::JSValue::decode(reason)); RETURN_IF_EXCEPTION(scope, {}); return JSC::JSValue::encode(JSC::jsUndefined()); } @@ -692,7 +693,7 @@ JSObject* JS${controllerName}::createPrototype(VM& vm, JSDOMGlobalObject& global return ${controllerPrototypeName}::create(vm, &globalObject, ${controllerPrototypeName}::createStructure(vm, &globalObject, globalObject.objectPrototype())); } -void JS${controllerName}::detach() { +void JS${controllerName}::detach(JSC::JSValue reason) { auto* sinkPtr = std::exchange(m_sinkPtr, nullptr); auto destroy = std::exchange(m_onDestroy, 0); @@ -708,7 +709,7 @@ void JS${controllerName}::detach() { m_weakReadableStream.clear(); return; } - Bun::WebStreams::sinkControllerOnClose(this->globalObject(), this, JSC::jsUndefined(), /* sinkClosed */ false); + Bun::WebStreams::sinkControllerOnClose(this->globalObject(), this, reason ? reason : JSC::jsUndefined(), /* sinkClosed */ false); } `; @@ -980,6 +981,21 @@ ${classes RELEASE_ASSERT_NOT_REACHED(); } +JSC::EncodedJSValue WebCore::JSReadableSinkControllerBase::close(JSC::JSGlobalObject* globalObject, JSC::JSValue reason) +{ + JSC::EncodedJSValue encoded = reason.toBoolean(globalObject) ? JSC::JSValue::encode(reason) : JSC::JSValue::encode(JSC::JSValue()); + switch (m_sinkId) { +${classes + .map( + name => + ` case WebCore::SinkID::${name}: return WebCore::${names(name).controller}__closeWithReason(globalObject, uncheckedDowncast(this), encoded);`, + ) + .join("\n")} + default: break; + } + RELEASE_ASSERT_NOT_REACHED(); +} + extern "C" void JSSinkController__onReady(JSC::EncodedJSValue controllerValue, JSC::EncodedJSValue amt, JSC::EncodedJSValue offset) { (void)amt; diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index 2d7f1c35a732..7d6fa05a9b81 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -944,8 +944,6 @@ static JSValue consumeDirectStreamBody(JSC::VM& vm, JSGlobalObject* globalObject auto scope = DECLARE_THROW_SCOPE(vm); setUpDirectStreamController(globalObject, stream, kind); RETURN_IF_EXCEPTION(scope, {}); - if (auto* controller = dynamicDowncast(stream->m_controller.get())) - controller->m_closeOnPullSettled = true; stream->materializeIfNeeded(globalObject); RETURN_IF_EXCEPTION(scope, {}); auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); @@ -1628,8 +1626,8 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onDirectConsumeLoopReadRejected, (J return {}; } -// close()/end(), and the implicit close when an async pull() resolves without calling either. -static void oneShotDirectClose(JSC::VM& vm, JSGlobalObject* globalObject, JSOneShotDirectSink* sink) +// close()/end(), and the implicit close when an async pull() resolves without calling either. A truthy reason is close(error): the consumer rejects with it. +static void oneShotDirectClose(JSC::VM& vm, JSGlobalObject* globalObject, JSOneShotDirectSink* sink, JSValue reason) { auto scope = DECLARE_THROW_SCOPE(vm); if (sink->m_closed) @@ -1637,14 +1635,27 @@ static void oneShotDirectClose(JSC::VM& vm, JSGlobalObject* globalObject, JSOneS sink->m_closed = true; if (auto* source = sink->source()) { sink->clearSource(); - source->close(globalObject, jsUndefined()); + source->close(globalObject, reason); RETURN_IF_EXCEPTION(scope, ); } MarkedArgumentBuffer noArguments; JSValue endResult = Bun::WebStreams::invokeMethod(vm, globalObject, sink->arrayBufferSink(), builtinNames(vm).endPublicName(), noArguments); RETURN_IF_EXCEPTION(scope, ); - if (auto* capability = sink->capabilityPromise(); capability && capability->status() == JSPromise::Status::Pending) - capability->fulfill(vm, endResult); + auto* capability = sink->capabilityPromise(); + if (!capability || capability->status() != JSPromise::Status::Pending) + return; + if (reason.toBoolean(globalObject)) { + if (auto* stream = sink->stream()) { + stream->m_lockedWithoutReader = false; + if (stream->m_state == ReadableStreamState::Readable) { + Bun::WebStreams::readableStreamError(globalObject, stream, reason); + RETURN_IF_EXCEPTION(scope, ); + } + } + capability->reject(vm, reason); + return; + } + capability->fulfill(vm, endResult); } // pull() runs once here: its promise resolving without close()/end() is the end of the body. @@ -1654,7 +1665,7 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onConsumeDirectToArrayBufferPullFul auto scope = DECLARE_THROW_SCOPE(vm); auto* sink = uncheckedDowncast(callFrame->uncheckedArgument(1)); auto* stream = sink->stream(); - oneShotDirectClose(vm, globalObject, sink); + oneShotDirectClose(vm, globalObject, sink, jsUndefined()); if (JSC::Exception* exception = scope.exception()) [[unlikely]] { TRY_CLEAR_EXCEPTION(scope, {}); if (stream) { @@ -1717,7 +1728,7 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_boundOneShotDirectClose, (JSGlobalO auto& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto* sink = uncheckedDowncast(callFrame->uncheckedArgument(0)); - oneShotDirectClose(vm, globalObject, sink); + oneShotDirectClose(vm, globalObject, sink, callFrame->argument(1)); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index 83cfd2f1428e..e9e8c71335f1 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -753,7 +753,7 @@ JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, // Resolving without close()/end() ends the sink controller first; a sync return waits for close()/end(). if (auto* pullPromise = dynamicDowncast(maybePromise)) { auto* result = JSPromise::create(vm, globalObject->promiseStructure()); - pullPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReadDirectStreamPullFulfilled(), jsUndefined(), result, sinkController); + pullPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReadDirectStreamPullFulfilled(), runtime->onReadDirectStreamPullRejected(), result, sinkController); return result; } if (stream->m_state == ReadableStreamState::Readable) { @@ -1348,4 +1348,15 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadDirectStreamPullFulfilled, (J return JSValue::encode(jsUndefined()); } +// ... and its promise rejecting is close(reason): the source failed, so fail the sink from this side (no cancel()) and pass the rejection on to the sink's owner. +JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadDirectStreamPullRejected, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSValue reason = callFrame->argument(0); + uncheckedDowncast(callFrame->argument(1))->close(globalObject, reason.toBoolean(globalObject) ? reason : JSValue(createError(globalObject, "pull() rejected"_s))); + RETURN_IF_EXCEPTION(scope, {}); + throwException(globalObject, scope, reason); + return {}; +} + } // namespace WebCore diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index a561be3ca85b..7ad93b375946 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -767,10 +767,9 @@ JSValue JSDirectStreamController::onPull(JSGlobalObject* globalObject, bool read int8_t deferredClose = 0; int8_t deferredFlush = 0; - // Serialize pull(): while an async pull's promise is pending, subsequent reads install - // m_pendingRead for it to deliver into via flush()/end(); its fulfillment reaction - // clears m_pullInFlight and re-pulls if a consumer is still waiting. - if (!m_pullInFlight) { + // pull() runs once. Later reads register a consumer and drain what the source has written since. + if (!m_pulled) { + m_pulled = true; m_deferClose = -1; m_deferFlush = -1; @@ -793,10 +792,6 @@ JSValue JSDirectStreamController::onPull(JSGlobalObject* globalObject, bool read RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, abrupt)); } } else { - // A new read arrived while an async pull is pending: the fulfillment reaction will - // re-pull. Drain anything that pull already wrote; onFlush is a no-op-restore on an - // empty sink. - m_pullAgain = true; deferredFlush = 1; } @@ -860,6 +855,11 @@ void JSDirectStreamController::onClose(JSGlobalObject* globalObject, JSValue rea } if (m_closed) return; + // close(error): the source failed; error the stream instead of ending it (the native sink path's fail()). + if (reason.toBoolean(globalObject)) { + handleError(globalObject, reason); + RELEASE_AND_RETURN(scope, ); + } // No "Closing" stream state exists: m_closed set here is what blocks re-entry. m_closed = true; auto* source = m_source.get(); @@ -966,10 +966,6 @@ void JSDirectStreamController::onFlush(JSGlobalObject* globalObject) m_pendingRead.set(vm, this, uncheckedDowncast(readRequest->context())); } } - // The spec's enqueue → CallPullIfNeeded equivalent: re-arm when this delivery - // still leaves a consumer waiting behind the in-flight pull. - if (m_pullInFlight && (m_pendingRead || readableStreamGetNumReadRequests(stream) > 0)) - m_pullAgain = true; JSObject* result = createIteratorResultObject(globalObject, flushed, false); RETURN_IF_EXCEPTION(scope, ); RELEASE_AND_RETURN(scope, pendingRead->fulfill(vm, result)); @@ -982,8 +978,6 @@ void JSDirectStreamController::onFlush(JSGlobalObject* globalObject) JSValue flushed = flushDirectSink(vm, globalObject, this); RETURN_IF_EXCEPTION(scope, ); if (byteLengthOf(flushed)) { - if (m_pullInFlight && readableStreamGetNumReadRequests(stream) > 1) - m_pullAgain = true; RELEASE_AND_RETURN(scope, readableStreamFulfillReadRequest(globalObject, stream, flushed, false)); } return; @@ -993,22 +987,7 @@ void JSDirectStreamController::onFlush(JSGlobalObject* globalObject) m_deferFlush = 1; } -static bool takeDirectPullAgain(JSDirectStreamController* controller) -{ - bool pullAgain = controller->m_pullAgain; - controller->m_pullAgain = false; - return pullAgain; -} - -static bool directControllerHasWaitingConsumer(JSDirectStreamController* controller, JSReadableStream* stream) -{ - return controller->m_pendingRead || (stream && readableStreamGetNumReadRequests(stream) > 0); -} - -// Settlement reactions of the user pull()'s returned promise ([reaction-convention]). -// The pull promise's fulfilment reaction (enterStreams): drain, then re-pull while a consumer is -// waiting. Whatever throws in here (a chunkSteps callback, a deferred close, the source's hooks) -// errors the stream. +// The user pull()'s returned promise settled ([reaction-convention]): drain what it wrote, then close. A rejection errors the stream (onDirectPullRejected). static void directPullFulfilled(JSC::VM& vm, JSGlobalObject* globalObject, JSDirectStreamController* controller) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -1016,63 +995,15 @@ static void directPullFulfilled(JSC::VM& vm, JSGlobalObject* globalObject, JSDir auto* stream = controller->m_stream.get(); if (controller->m_closed || !stream || stream->m_state != ReadableStreamState::Readable) { controller->m_pullInFlight = false; - controller->m_pullAgain = false; return; } - // Drain anything this pull wrote while no reader was waiting. m_pullInFlight stays set - // so onFlush's delivery-branch re-arm fires for a pull that wrote without c.flush(). controller->onFlush(globalObject); controller->m_pullInFlight = false; RETURN_IF_EXCEPTION(scope, ); - if (controller->m_closeOnPullSettled) { - controller->m_pullAgain = false; - stream = controller->m_stream.get(); - if (!controller->m_closed && stream && stream->m_state == ReadableStreamState::Readable) - controller->onClose(globalObject, jsUndefined()); - RELEASE_AND_RETURN(scope, ); - } - bool pullAgain = takeDirectPullAgain(controller); - // Edge-triggered (m_pullAgain) AND level-checked (a consumer is waiting), the spec's - // ShouldCallPull equivalent; loop so a synchronous re-pull chains to the next consumer. - while (pullAgain && !controller->m_closed && !controller->m_pullInFlight - && directControllerHasWaitingConsumer(controller, controller->m_stream.get())) { - controller->m_deferClose = -1; - controller->m_deferFlush = -1; - JSValue abrupt = callDirectPull(vm, globalObject, controller); - int8_t deferredClose = controller->m_deferClose; - int8_t deferredFlush = controller->m_deferFlush; - controller->m_deferClose = 0; - controller->m_deferFlush = 0; - RETURN_IF_EXCEPTION(scope, ); - if (!abrupt.isEmpty()) { - controller->handleError(globalObject, abrupt); - RELEASE_AND_RETURN(scope, ); - } - if (deferredClose == 1) { - JSValue reason = controller->m_deferCloseReason.get(); - controller->m_deferCloseReason.clear(); - controller->onClose(globalObject, reason); - RETURN_IF_EXCEPTION(scope, ); - } else { - // An async re-pull left m_pullInFlight set: its own fulfillment reaction drains - // and picks up m_pullAgain. What its synchronous part already wrote goes to the - // waiting reader now, as in onPull: no end-of-tick job was queued for it, and the - // pull may be parked on that very write. - if (controller->m_pullInFlight) { - if (deferredFlush == 1 || !controller->m_buffer.isEmpty()) - controller->onFlush(globalObject); - RETURN_IF_EXCEPTION(scope, ); - break; - } - // Sync re-pull: drain with m_pullInFlight bracketed so onFlush's delivery-branch - // re-arm fires regardless of whether the pull called c.flush() itself. - controller->m_pullInFlight = true; - controller->onFlush(globalObject); - controller->m_pullInFlight = false; - RETURN_IF_EXCEPTION(scope, ); - } - pullAgain = takeDirectPullAgain(controller); - } + stream = controller->m_stream.get(); + if (controller->m_closed || !stream || stream->m_state != ReadableStreamState::Readable) + return; + RELEASE_AND_RETURN(scope, controller->onClose(globalObject, jsUndefined())); } JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onDirectPullFulfilled, (JSGlobalObject * globalObject, CallFrame* callFrame)) diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.h b/src/jsc/bindings/webcore/streams/JSDirectStreamController.h index 19c570367aa8..cbe5a1fd0eb7 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.h +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.h @@ -105,19 +105,15 @@ class JSDirectStreamController final : public JSC::JSDestructibleObject { // Once closed, the five methods are no-ops (there is NO "swap all 5 methods to a // throwing stub" trick). bool m_closed : 1 { false }; - // An async pull()'s returned promise has not yet settled; cleared by its settlement - // reactions. m_pullAgain is set only when a NEW read arrives while m_pullInFlight - // (edge-triggered, matching the spec default controller's [[pullAgain]]). + // pull() runs once: the first read calls it; if it returned a promise the stream closes when that settles, else it stays open until close()/end(). + bool m_pulled : 1 { false }; bool m_pullInFlight : 1 { false }; - bool m_pullAgain : 1 { false }; bool m_calledDone : 1 { false }; // End-of-tick auto-flush (the JS-facing analogue of the HTTP sink's AutoFlusher): // armed by write() when data is buffered below the HWM while a consumer waits; the // process.nextTick job delivers it during the same microtask/nextTick drain. bool m_endOfTickFlushArmed : 1 { false }; bool m_finalChunkArmed : 1 { false }; - // A whole-body consumer (.text() etc.): an async pull() resolving without close()/end() closes. - bool m_closeOnPullSettled : 1 { false }; // ArrayBuffer sink: the bytes write() accepted since it armed m_pendingWrite (saturating). uint32_t m_pendingWriteLength { 0 }; // ArrayBuffer sink: m_buffer's capacity as last reported to the heap (reportExtraMemoryAllocated); diff --git a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h index 7255b479bc20..2e7198796dd2 100644 --- a/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h +++ b/src/jsc/bindings/webcore/streams/JSStreamsRuntime.h @@ -167,7 +167,7 @@ namespace WebCore { // onNativeSourceCallCloseMicrotask: the native source's `queueMicrotask(callClose)` job; // context = the adapter. // onNativeSourceHandleClosedMicrotask: the native handle reported close (ReadableStream.rs on_close); context = the adapter. -// onReadDirectStreamPullFulfilled: readDirectStream's pull() resolved. context = the native sink controller. +// onReadDirectStreamPull{Fulfilled,Rejected}: readDirectStream's pull() settled. context = the native sink controller. // onReadStreamIntoSink*: context = the JSReadStreamIntoSinkOperation. #define FOR_EACH_WEB_STREAMS_REACTION_HANDLER_BUN_SOURCE(V) \ V(onNativePullFulfilled) \ @@ -175,6 +175,7 @@ namespace WebCore { V(onNativeSourceCallCloseMicrotask) \ V(onNativeSourceHandleClosedMicrotask) \ V(onReadDirectStreamPullFulfilled) \ + V(onReadDirectStreamPullRejected) \ V(onReadStreamIntoSinkReadManyFulfilled) \ V(onReadStreamIntoSinkChunk) \ V(onReadStreamIntoSinkClose) \ diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index dea627525eb7..74a68f096451 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -5,6 +5,7 @@ import { bunEnv, bunExe, isASAN, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; import net from "node:net"; import { baseHeaders, frame, RawH2, T } from "./serve-http2-helpers"; +import { expected as directExpected, observe as directObserve, shapes as directShapes } from "../../web/streams/direct-stream-contract"; test("HTTPResponseSink displays correct message", async () => { let leakedCtrl: any; @@ -1734,3 +1735,39 @@ describe("close() under transport backpressure sends the buffered tail", () => { } }); }); + +// The server/fetch cells of the `type: "direct"` contract matrix; the in-process cells live in test/js/web/streams/streams.test.js. +describe("direct stream contract", () => { + // Each resolves to the body text, or rejects if the transfer failed or the status was not 2xx. + const okText = async (res: Response) => { + const body = await res.text(); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${body}`); + return body; + }; + const consumers: Record Promise> = { + "Bun.serve response body (http)": async s => { + using server = Bun.serve({ port: 0, fetch: () => new Response(s) }); + return okText(await fetch(server.url)); + }, + "Bun.serve response body (https)": async s => { + using server = Bun.serve({ port: 0, tls, fetch: () => new Response(s) }); + return okText(await fetch(server.url, { tls: { rejectUnauthorized: false } })); + }, + "fetch request body": async s => { + using server = Bun.serve({ port: 0, fetch: async req => new Response(await req.text()) }); + return okText(await fetch(server.url, { method: "POST", body: s, duplex: "half" } as RequestInit)); + }, + }; + describe.each(Object.keys(directShapes))("%s", shapeName => { + test.concurrent.each(Object.keys(consumers))("%s", async consumerName => { + const shape = directShapes[shapeName]; + const got = await directObserve(shape, consumers[consumerName]); + if ("error" in shape.expect) { + // The source's message cannot cross the wire; the peer sees a failed transfer or a 5xx. + expect({ pulls: got.pulls, cancels: got.cancels, errored: "error" in got }).toEqual({ pulls: 1, cancels: 0, errored: true }); + return; + } + expect(got).toEqual(directExpected(shape)); + }); + }); +}); diff --git a/test/js/web/streams/direct-stream-contract.ts b/test/js/web/streams/direct-stream-contract.ts new file mode 100644 index 000000000000..311aa782d88e --- /dev/null +++ b/test/js/web/streams/direct-stream-contract.ts @@ -0,0 +1,158 @@ +// The `type: "direct"` lifecycle contract: every consumer sees pull() once, the bytes written before the end signal, the same error, and cancel() only on abort. + +export type Tally = { pulls: number; cancels: unknown[] }; + +export type Shape = { + /** What the consumer receives, or the error it rejects with. */ + expect: { body: string } | { error: string }; + make(t: Tally): ReadableStream; +}; + +const later = () => new Promise(r => setImmediate(r)); + +function direct(t: Tally, pull: (c: ReadableStreamDirectController) => void | Promise): ReadableStream { + return new ReadableStream({ + type: "direct", + pull(c) { + t.pulls++; + return pull(c); + }, + cancel(reason: unknown) { + t.cancels.push(reason); + }, + } as any); +} + +export const shapes: Record = { + "sync pull: write, write, close()": { + expect: { body: "hello world" }, + make: t => direct(t, c => (c.write("hello "), c.write("world"), c.close())), + }, + "sync pull: write, write, end()": { + expect: { body: "hello world" }, + make: t => direct(t, c => (c.write("hello "), c.write("world"), void c.end())), + }, + "async pull: write, await, write, close()": { + expect: { body: "hello world" }, + make: t => + direct(t, async c => { + c.write("hello "); + await later(); + c.write("world"); + c.close(); + }), + }, + "async pull: write, await, write, resolve without close()": { + expect: { body: "hello world" }, + make: t => + direct(t, async c => { + c.write("hello "); + await later(); + c.write("world"); + }), + }, + "async pull: await write() under backpressure, then close()": { + expect: { body: Buffer.alloc(256 * 1024, "abcdefgh").toString() }, + make: t => + direct(t, async c => { + for (let i = 0; i < 4; i++) await c.write(Buffer.alloc(64 * 1024, "abcdefgh")); + c.close(); + }), + }, + "sync pull keeps the controller; writes arrive later, then close()": { + expect: { body: "hello world" }, + make: t => + direct(t, c => { + c.write("hello "); + setImmediate(() => { + c.write("world"); + c.close(); + }); + }), + }, + "async pull: write, await, reject": { + expect: { error: "source failed" }, + make: t => + direct(t, async c => { + c.write("hello "); + await later(); + throw new Error("source failed"); + }), + }, + "sync pull throws": { + expect: { error: "source failed" }, + make: t => + direct(t, () => { + throw new Error("source failed"); + }), + }, + "close(error) after a write": { + expect: { error: "source failed" }, + make: t => + direct(t, async c => { + c.write("hello "); + await later(); + c.close(new Error("source failed")); + }), + }, +}; + +const decoder = new TextDecoder(); +const text = (v: unknown) => (typeof v === "string" ? v : decoder.decode(v as ArrayBufferView)); + +/** In-process consumers: each takes a stream and resolves to its full text (or rejects). */ +export const consumers: Record Promise> = { + "new Response(s).text()": s => new Response(s).text(), + "new Response(s).bytes()": async s => decoder.decode(await new Response(s).bytes()), + "new Response(s).blob()": async s => (await new Response(s).blob()).text(), + "Bun.readableStreamToText": s => Bun.readableStreamToText(s), + "Bun.readableStreamToBytes": async s => decoder.decode(await Bun.readableStreamToBytes(s)), + "Bun.readableStreamToArrayBuffer": async s => decoder.decode(await Bun.readableStreamToArrayBuffer(s)), + "Bun.readableStreamToArray": async s => (await Bun.readableStreamToArray(s)).map(text).join(""), + "getReader() loop": async s => { + const reader = s.getReader(); + let out = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) return out; + out += text(value); + } + }, + "for await": async s => { + let out = ""; + for await (const v of s) out += text(v); + return out; + }, + "pipeTo(WritableStream)": async s => { + let out = ""; + await s.pipeTo(new WritableStream({ write: v => void (out += text(v)) })); + return out; + }, + "pipeThrough(identity TransformStream)": async s => { + let out = ""; + for await (const v of s.pipeThrough(new TransformStream())) out += text(v); + return out; + }, + "tee(): both branches": async s => { + const [a, b] = s.tee(); + const [ta, tb] = await Promise.all([Bun.readableStreamToText(a), Bun.readableStreamToText(b)]); + if (ta !== tb) throw new Error(`tee branches differ: ${JSON.stringify(ta)} vs ${JSON.stringify(tb)}`); + return ta; + }, +}; + +/** Runs one cell of the matrix and returns what a test should compare against `expected(shape)`. */ +export async function observe(shape: Shape, consume: (s: ReadableStream) => Promise) { + const t: Tally = { pulls: 0, cancels: [] }; + let result: { body: string } | { error: string }; + try { + result = { body: await consume(shape.make(t)) }; + } catch (e: any) { + result = { error: e?.message ?? String(e) }; + } + // cancel() runs from the sink's close reaction; give it a turn to (not) fire. + await later(); + return { pulls: t.pulls, cancels: t.cancels.length, ...result }; +} + +export const expected = (shape: Shape) => ({ pulls: 1, cancels: 0, ...shape.expect }); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 30f114e74142..69bc5bee47f0 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -11,6 +11,7 @@ import { bunEnv, bunExe, isASAN, isDebug, isLinux, isMacOS, isWindows, tempDir, import { mkfifo } from "mkfifo"; import { closeSync, createReadStream, openSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { consumers as directConsumers, expected as directExpected, observe as directObserve, shapes as directShapes } from "./direct-stream-contract"; it("TransformStream", async () => { // https://developer.mozilla.org/en-US/docs/Web/API/TransformStream @@ -958,21 +959,21 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { expect(results).toEqual([64 * 1024, false, 0]); }); - // Reads queued before the first pull settles are served by re-pulls from the pull - // promise's reaction. A re-pulled pull() that parks on its first write (>= highWaterMark, - // written synchronously, so no end-of-tick job) must still reach the queued reader. + // A pull() that parks on each write (>= highWaterMark, written synchronously, so no + // end-of-tick job) must still reach reads that were queued before it wrote. for (const [label, chunk, strategy] of [ ["default highWaterMark", 64 * 1024, undefined], ["explicit highWaterMark", 4096, { highWaterMark: 1024 }], ]) { - it(`a re-pulled pull() parked on its first write still feeds queued reads: ${label}`, async () => { + it(`a pull() parked on await write() feeds queued reads one chunk at a time: ${label}`, async () => { let pulls = 0; + let writes = 0; const rs = new ReadableStream( { type: "direct", async pull(c) { pulls++; - await c.write(new Uint8Array(chunk).fill(pulls)); + while (writes < 4) await c.write(new Uint8Array(chunk).fill(++writes)); }, }, strategy, @@ -989,7 +990,7 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { [chunk, 2], [chunk, 3], ]); - expect(pulls).toBe(3); + expect(pulls).toBe(1); await reader.cancel(); }); } @@ -1411,16 +1412,17 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { expect((await reader.read()).done).toBe(true); }); - it("a per-call pull() that writes one chunk and returns is re-invoked on each read", async () => { + it("pull() is called once; each flush() across awaits feeds the next read, and its promise resolving ends the stream", async () => { let pulls = 0; const rs = new ReadableStream({ type: "direct", async pull(c) { pulls++; - if (pulls > 3) return c.end(); - await Promise.resolve(); - c.write(new Uint8Array([pulls])); - c.flush(); + for (let i = 1; i <= 3; i++) { + await Promise.resolve(); + c.write(new Uint8Array([i])); + c.flush(); + } }, }); const reader = rs.getReader(); @@ -1430,28 +1432,30 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { if (done) break; for (const b of value) out.push(b); } - expect({ pulls, out }).toEqual({ pulls: 4, out: [1, 2, 3] }); + expect({ pulls, out }).toEqual({ pulls: 1, out: [1, 2, 3] }); }); - it("reads issued while pull() is suspended are each serviced by a subsequent pull", async () => { + it("reads issued while pull() is suspended are serviced in order as it writes, without re-entering pull()", async () => { let pulls = 0; const gates = []; + const gate = () => { + const { promise, resolve } = Promise.withResolvers(); + gates.push(resolve); + return promise; + }; const rs = new ReadableStream({ type: "direct", async pull(c) { pulls++; - if (pulls > 3) return c.end(); - const { promise, resolve } = Promise.withResolvers(); - gates.push(resolve); - await promise; - c.write(new Uint8Array([pulls])); - c.flush(); + for (let i = 1; i <= 3; i++) { + await gate(); + c.write(new Uint8Array([i])); + c.flush(); + } }, }); const reader = rs.getReader(); const p1 = reader.read(); - // These arrive while pull #1 is suspended: must NOT re-enter pull() concurrently, - // and each must be serviced by a subsequent pull once the previous one settles. const p2 = reader.read(); const p3 = reader.read(); expect(pulls).toBe(1); @@ -1464,67 +1468,65 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { gates.shift()(); expect((await p3).value[0]).toBe(3); expect((await reader.read()).done).toBe(true); + expect(pulls).toBe(1); }); - // Three concurrent reads must each be serviced regardless of where the per-call - // producer's write/flush sits relative to its first await. - const perCallShapes = { - "write without flush": () => { - let n = 0; - return async c => { - n++; + // Reads queued up front are all settled from one pull(), wherever its writes and flushes sit relative to its awaits. + const onePullShapes = { + "write without flush between awaits": async c => { + for (let n = 1; n <= 3; n++) { await Promise.resolve(); c.write(new Uint8Array([n])); - }; + } }, - "write and flush before the first await": () => { - let n = 0; - return async c => { - n++; + "write and flush before each await": async c => { + for (let n = 1; n <= 3; n++) { c.write(new Uint8Array([n])); c.flush(); await Promise.resolve(); - }; + } }, - "first call async, later calls sync": () => { - let n = 0; - return c => { - n++; - if (n === 1) - return Promise.resolve().then(() => { - c.write(new Uint8Array([1])); - c.flush(); - }); + "sync writes with flush, then close()": c => { + for (let n = 1; n <= 3; n++) { c.write(new Uint8Array([n])); c.flush(); - }; + } + c.close(); }, - "first call async, later calls sync without flush": () => { - let n = 0; - return c => { - n++; - if (n === 1) - return Promise.resolve().then(() => { - c.write(new Uint8Array([1])); - c.flush(); - }); - c.write(new Uint8Array([n])); - }; + "first write async, the rest sync without flush, then end()": async c => { + await Promise.resolve(); + c.write(new Uint8Array([1])); + c.flush(); + c.write(new Uint8Array([2])); + c.flush(); + c.write(new Uint8Array([3])); + c.end(); }, }; - it.each(Object.keys(perCallShapes))( - "three concurrent reads are each serviced by a per-call pull (%s)", - async shape => { - const rs = new ReadableStream({ type: "direct", pull: perCallShapes[shape]() }); - const reader = rs.getReader(); - const reads = [reader.read(), reader.read(), reader.read()]; - const [r1, r2, r3] = await Promise.all(reads); - expect({ r1: r1.value[0], r2: r2.value[0], r3: r3.value[0] }).toEqual({ r1: 1, r2: 2, r3: 3 }); - }, - ); + it.each(Object.keys(onePullShapes))("queued reads are all settled by one pull (%s)", async shape => { + let pulls = 0; + const body = onePullShapes[shape]; + const rs = new ReadableStream({ + type: "direct", + pull(c) { + pulls++; + return body(c); + }, + }); + const reader = rs.getReader(); + const reads = [reader.read(), reader.read(), reader.read(), reader.read()]; + const out = []; + for (const r of await Promise.all(reads)) if (!r.done) out.push(...r.value); + while (true) { + const r = await reader.read(); + if (r.done) break; + out.push(...r.value); + } + expect({ pulls, out }).toEqual({ pulls: 1, out: [1, 2, 3] }); + }); it("an async pull() that returns without writing is not re-invoked from its own fulfillment", async () => { - // Edge-triggered re-pull: a do-nothing pull must not livelock the microtask queue. + // A do-nothing pull must not be called again from its own fulfillment (no microtask livelock). let pulls = 0; const rs = new ReadableStream({ type: "direct", @@ -1537,7 +1539,7 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { expect(pulls).toBe(1); }); - it("a read whose demand was already satisfied does not cause a spurious re-pull", async () => { + it("a read whose demand was already satisfied does not call pull() again", async () => { let pulls = 0; const rs = new ReadableStream({ type: "direct", @@ -1552,8 +1554,6 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { const reader = rs.getReader(); await Promise.all([reader.read(), reader.read()]); await new Promise(resolve => setImmediate(resolve)); - // Both reads were satisfied by pull #1's two flushes; m_pullAgain set by the second - // read() must not trigger a demand-less re-pull. expect(pulls).toBe(1); }); }); @@ -1641,11 +1641,12 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { return new ReadableStream({ type: "direct", pull(c) { - if (pulls++ > 0) return c.end(); + pulls++; c.write("a"); c.flush(); c.write("b"); c.flush(); + c.end(); }, }); }; @@ -1672,16 +1673,9 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { out.pipeTo = await settle(mk().pipeTo(new WritableStream())); out.tee = await settle(Bun.readableStreamToText(mk().tee()[0])); out.forAwait = await settle((async () => { for await (const _ of mk()); })()); - // Two reads during an async pull() make its fulfillment re-pull; the re-pull throws. - let pulls = 0; - const reader = new ReadableStream({ - type: "direct", - pull() { - if (pulls++ === 0) return Promise.resolve(); - throw new Error("boom"); - }, - }).getReader(); - out.rePull = await Promise.all([settle(reader.read()), settle(reader.read())]); + // An async pull() that rejects errors every queued read with its reason. + const reader = new ReadableStream({ type: "direct", async pull() { await 1; throw new Error("boom"); } }).getReader(); + out.rejected = await Promise.all([settle(reader.read()), settle(reader.read())]); // Unhandled rejections are reported once the current turn's microtasks drain. await new Promise(resolve => setTimeout(resolve, 0)); out.unhandled = unhandled; @@ -1697,7 +1691,7 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { pipeTo: "boom", tee: "boom", forAwait: "boom", - rePull: ["boom", "boom"], + rejected: ["boom", "boom"], unhandled: 0, }); expect(stderr).toBe(""); @@ -1785,21 +1779,46 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { expect(await text).toBe("helloworld"); }); - it("a reader still pulls again after each pull() resolves", async () => { + it("a reader also calls pull() once and ends the stream when it resolves", async () => { let pulls = 0; const rs = new ReadableStream({ type: "direct", async pull(c) { pulls++; + c.write("a"); await nextTask(); - c.write("x"); - if (pulls === 3) c.close(); + c.write("b"); + await nextTask(); + c.write("c"); }, }); const chunks = []; for await (const chunk of rs) chunks.push(decode(chunk)); - expect(pulls).toBe(3); - expect(chunks.join("")).toBe("xxx"); + expect(pulls).toBe(1); + expect(chunks.join("")).toBe("abc"); + }); + + it("a sync pull() keeps a reader open until close() and is not called again", async () => { + let pulls = 0; + let controller; + const rs = new ReadableStream({ + type: "direct", + pull(c) { + pulls++; + controller = c; + c.write("a"); + }, + }); + const reader = rs.getReader(); + expect(decode((await reader.read()).value)).toBe("a"); + const second = reader.read(); + await nextTask(); + controller.write("b"); + expect(decode((await second).value)).toBe("b"); + const done = reader.read(); + controller.close(); + expect((await done).done).toBe(true); + expect(pulls).toBe(1); }); }); @@ -3750,3 +3769,41 @@ describe("iterator result consumers", () => { expect({ text: await (await fetch(server.url)).text(), pulls }).toEqual({ text: "part1-part2-part3", pulls: 2 }); }); }); + +describe("direct stream contract", () => { + // Consumers that need a file or a child process live here; Bun.serve / fetch cells are in serve-direct-readable-stream.test.ts. + const consumers = { + ...directConsumers, + "Bun.write(file, new Response(s))": async s => { + using dir = tempDir("direct-contract", {}); + const file = join(String(dir), "out.txt"); + await Bun.write(file, new Response(s)); + return Bun.file(file).text(); + }, + "Bun.spawn({ stdin: s })": async s => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "for await (const c of process.stdin) process.stdout.write(c)"], + env: bunEnv, + stdin: s, + stdout: "pipe", + stderr: "inherit", + }); + const [out] = await Promise.all([proc.stdout.text(), proc.exited]); + return out; + }, + }; + // A child's stdin has nowhere to report the source's error: the child sees EOF. Only pulls/cancels are pinned there. + const cannotSurfaceErrors = new Set(["Bun.spawn({ stdin: s })"]); + describe.each(Object.keys(directShapes))("%s", shapeName => { + test.concurrent.each(Object.keys(consumers))("%s", async consumerName => { + const shape = directShapes[shapeName]; + const got = await directObserve(shape, consumers[consumerName]); + if ("error" in shape.expect && cannotSurfaceErrors.has(consumerName)) { + expect({ pulls: got.pulls, cancels: got.cancels }).toEqual({ pulls: 1, cancels: 0 }); + return; + } + expect(got).toEqual(directExpected(shape)); + }); + }); +}); + From 7cd62d853cbad82b2978a516b4a23e43f7ea3c40 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:01:21 -0700 Subject: [PATCH 07/31] close(error) rejects the sink owner's promise; server contract cells await the body inside the server scope --- src/codegen/generate-jssink.ts | 4 ++++ .../bindings/webcore/streams/BunStreamSource.cpp | 13 +++++++++++-- .../bun/http/serve-direct-readable-stream.test.ts | 6 +++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index f0bf80e4ffcc..71dd635a2971 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -227,6 +227,7 @@ public: SinkSource m_sourceKind { SinkSource::None }; mutable WriteBarrier m_source; mutable WriteBarrier m_closePromise; // DirectStream: readDirectStream's result while pull() is sync and open + mutable WriteBarrier m_failReason; // close(error)'s error, so the owner's promise rejects even though pull() itself resolved mutable JSC::Weak m_weakReadableStream; uintptr_t m_onDestroy { 0 }; @@ -694,6 +695,8 @@ JSObject* JS${controllerName}::createPrototype(VM& vm, JSDOMGlobalObject& global } void JS${controllerName}::detach(JSC::JSValue reason) { + if (reason && !m_failReason) + m_failReason.set(this->vm(), this, reason); auto* sinkPtr = std::exchange(m_sinkPtr, nullptr); auto destroy = std::exchange(m_onDestroy, 0); @@ -833,6 +836,7 @@ void ${controller}::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(thisObject->m_source); visitor.append(thisObject->m_closePromise); + visitor.append(thisObject->m_failReason); void* ptr = thisObject->m_sinkPtr; if (ptr) diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index e9e8c71335f1..ba8488262ca3 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -679,7 +679,10 @@ static void directStreamOnClose(JSC::VM& vm, JSGlobalObject* globalObject, WebCo } } if (closePromise) { - resolvePromise(globalObject, closePromise, jsUndefined()); + if (reason.toBoolean(globalObject)) + rejectPromise(globalObject, closePromise, reason); + else + resolvePromise(globalObject, closePromise, jsUndefined()); RETURN_IF_EXCEPTION(scope, ); } @@ -1343,7 +1346,13 @@ extern "C" void Bun__NativeStreamSourceAdapter__onClose(JSGlobalObject* globalOb JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadDirectStreamPullFulfilled, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); - uncheckedDowncast(callFrame->argument(1))->end(globalObject); + auto* sinkController = uncheckedDowncast(callFrame->argument(1)); + // pull() called close(error) before resolving: the owner's promise rejects with that error. + if (JSValue failed = sinkController->m_failReason.get()) { + throwException(globalObject, scope, failed); + return {}; + } + sinkController->end(globalObject); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index 74a68f096451..65f1ffad16ad 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -1747,15 +1747,15 @@ describe("direct stream contract", () => { const consumers: Record Promise> = { "Bun.serve response body (http)": async s => { using server = Bun.serve({ port: 0, fetch: () => new Response(s) }); - return okText(await fetch(server.url)); + return await okText(await fetch(server.url)); }, "Bun.serve response body (https)": async s => { using server = Bun.serve({ port: 0, tls, fetch: () => new Response(s) }); - return okText(await fetch(server.url, { tls: { rejectUnauthorized: false } })); + return await okText(await fetch(server.url, { tls: { rejectUnauthorized: false } })); }, "fetch request body": async s => { using server = Bun.serve({ port: 0, fetch: async req => new Response(await req.text()) }); - return okText(await fetch(server.url, { method: "POST", body: s, duplex: "half" } as RequestInit)); + return await okText(await fetch(server.url, { method: "POST", body: s, duplex: "half" } as RequestInit)); }, }; describe.each(Object.keys(directShapes))("%s", shapeName => { From 4dee6168a6abcf8a8433e2e9f163f4da369b3fcf Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:04:20 -0700 Subject: [PATCH 08/31] close(error) framing test: the response is aborted, not terminated --- .../http/serve-direct-readable-stream.test.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index 65f1ffad16ad..8c2040d76b26 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -1107,7 +1107,8 @@ describe("close() with unflushed data writes the chunked terminator exactly once } }); sock.on("close", () => resolve(raw)); - sock.on("error", reject); + // close(error) aborts the connection; a reset still hands back whatever arrived before it. + sock.on("error", () => resolve(raw)); const data = await promise; const headerEnd = data.indexOf("\r\n\r\n"); @@ -1239,10 +1240,7 @@ describe("close() with unflushed data writes the chunked terminator exactly once expect(rest.split("\r\n0\r\n\r\n").length - 1).toBe(0); }); - // close(error) does not flush: the tail stays parked and the request's own - // teardown performs the final `res.end()`. That send must also count as the - // end of the response. Only the framing is pinned here, not whether an - // errored source still delivers its tail. + // close(error) fails the response: what was flushed stands, no last-chunk is written, and the connection is torn down. test.concurrent("write, await flush(), task, write, close(error)", async () => { using server = serve(async c => { c.write("hello"); @@ -1252,13 +1250,11 @@ describe("close() with unflushed data writes the chunked terminator exactly once c.close(new Error("boom")); }); const { decoded, terminated, rest } = await exchange(server); - expect(decoded.startsWith("hello")).toBe(true); - if (terminated) { - expect(rest.slice(0, 15)).toBe("HTTP/1.1 200 OK"); - expect(rest).toEndWith("\r\n\r\nok"); - } else { - expect(rest).toBe(""); - } + expect({ startsWithHello: decoded.startsWith("hello"), terminated, rest }).toEqual({ + startsWithHello: true, + terminated: false, + rest: "", + }); }); }); From b4227a5f2f14b53eec0c5e5cb0c7a23976ffd21a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:07:02 +0000 Subject: [PATCH 09/31] [autofix.ci] apply automated fixes --- .../js/bun/http/serve-direct-readable-stream.test.ts | 12 ++++++++++-- test/js/web/streams/streams.test.js | 8 ++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index 8c2040d76b26..e099a31dd734 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -4,8 +4,12 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; import net from "node:net"; +import { + expected as directExpected, + observe as directObserve, + shapes as directShapes, +} from "../../web/streams/direct-stream-contract"; import { baseHeaders, frame, RawH2, T } from "./serve-http2-helpers"; -import { expected as directExpected, observe as directObserve, shapes as directShapes } from "../../web/streams/direct-stream-contract"; test("HTTPResponseSink displays correct message", async () => { let leakedCtrl: any; @@ -1760,7 +1764,11 @@ describe("direct stream contract", () => { const got = await directObserve(shape, consumers[consumerName]); if ("error" in shape.expect) { // The source's message cannot cross the wire; the peer sees a failed transfer or a 5xx. - expect({ pulls: got.pulls, cancels: got.cancels, errored: "error" in got }).toEqual({ pulls: 1, cancels: 0, errored: true }); + expect({ pulls: got.pulls, cancels: got.cancels, errored: "error" in got }).toEqual({ + pulls: 1, + cancels: 0, + errored: true, + }); return; } expect(got).toEqual(directExpected(shape)); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 69bc5bee47f0..0f4d9c6086d7 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -11,7 +11,12 @@ import { bunEnv, bunExe, isASAN, isDebug, isLinux, isMacOS, isWindows, tempDir, import { mkfifo } from "mkfifo"; import { closeSync, createReadStream, openSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { consumers as directConsumers, expected as directExpected, observe as directObserve, shapes as directShapes } from "./direct-stream-contract"; +import { + consumers as directConsumers, + expected as directExpected, + observe as directObserve, + shapes as directShapes, +} from "./direct-stream-contract"; it("TransformStream", async () => { // https://developer.mozilla.org/en-US/docs/Web/API/TransformStream @@ -3806,4 +3811,3 @@ describe("direct stream contract", () => { }); }); }); - From 7567030a075b98312aaf6a0b52d03410c4b5727d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:10:19 -0700 Subject: [PATCH 10/31] Direct stream edge-case tests: matrix shapes, pipes, generators, node interop, abort races; one-shot consumer ignores a rejection after close() --- .../webcore/streams/BunStreamConsumers.cpp | 3 + .../http/serve-direct-readable-stream.test.ts | 256 +++++++++ test/js/web/streams/direct-stream-contract.ts | 80 ++- test/js/web/streams/streams.test.js | 517 ++++++++++++++++++ 4 files changed, 855 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index 7d6fa05a9b81..37d2103ed9f5 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -1692,6 +1692,9 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onConsumeDirectToArrayBufferPullRej auto scope = DECLARE_THROW_SCOPE(vm); const auto* sink = uncheckedDowncast(callFrame->uncheckedArgument(1)); JSValue error = callFrame->argument(0); + // close()/end() already settled the result; a rejection after that has nothing left to fail. + if (sink->m_closed) + return JSValue::encode(sink->capabilityPromise()); auto* stream = sink->stream(); if (stream) { stream->m_lockedWithoutReader = false; diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index e099a31dd734..bf73b1d9687e 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; import net from "node:net"; +import { Readable } from "node:stream"; import { expected as directExpected, observe as directObserve, @@ -1775,3 +1776,258 @@ describe("direct stream contract", () => { }); }); }); + +describe("direct stream edge cases over Bun.serve", () => { + const later = () => new Promise(r => setImmediate(r)); + const dec = new TextDecoder(); + type Tally = { pulls: number; cancels: unknown[]; events: string[] }; + const tally = (): Tally => ({ pulls: 0, cancels: [], events: [] }); + const direct = (t: Tally, pull: (c: any) => unknown) => + new ReadableStream({ + type: "direct", + pull(c: any) { + t.pulls++; + return pull(c); + }, + cancel(reason: any) { + t.cancels.push(reason?.code ?? reason?.message ?? reason); + }, + } as any); + const settle = (p: Promise) => p.then(v => ({ ok: v }), (e: any) => ({ err: e?.code ?? e?.message ?? String(e) })); + // Opens a raw connection, reads until `until` appears in the response, then destroys the socket. + async function abortAfter(server: { port: number }, until: string) { + const sock = net.connect(server.port, "127.0.0.1"); + let raw = ""; + await new Promise((resolve, reject) => { + sock.on("connect", () => sock.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n")); + sock.on("data", d => { + raw += d.toString("latin1"); + if (raw.includes(until)) resolve(); + }); + sock.on("error", reject); + sock.on("close", () => resolve()); + }); + sock.destroy(); + return raw; + } + + test("client abort while pull() is parked: cancel() once with the connection-closed reason, the parked write settles, no end", async () => { + const t = tally(); + let parked: any; + const done = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + fetch: () => + new Response( + direct(t, async c => { + c.write("first"); + await c.flush(); + parked = c.write(Buffer.alloc(512 * 1024, "x")); + t.events.push("parked"); + await parked; + t.events.push("unparked"); + done.resolve(); + }), + ), + }); + await abortAfter(server, "first"); + // The parked write must settle (either way) once the peer is gone; otherwise pull() leaks forever. + await Promise.race([done.promise, (async () => { while (t.cancels.length === 0) await later(); await later(); })()]); + expect({ pulls: t.pulls, cancels: t.cancels.length, parkedSettled: "ok" in (await settle(Promise.race([parked, later().then(() => "pending")]))) }).toEqual({ + pulls: 1, + cancels: 1, + parkedSettled: true, + }); + }); + + test("client abort racing pull() resolution: exactly one of end / cancel is observed", async () => { + // Run the race many times; every iteration must be consistent, none may do both or neither. + const outcomes = new Set(); + for (let i = 0; i < 20; i++) { + const t = tally(); + const release = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + fetch: () => + new Response( + direct(t, async c => { + c.write("first"); + await c.flush(); + await release.promise; + c.write("tail"); + }), + ), + }); + const aborted = abortAfter(server, "first"); + // Resolve pull() in the same turn the abort is processed, alternating who goes first. + if (i % 2) await aborted; + release.resolve(); + await aborted; + await later(); + await later(); + outcomes.add(t.cancels.length === 1 ? "cancelled" : t.cancels.length === 0 ? "ended" : `cancels=${t.cancels.length}`); + expect(t.pulls).toBe(1); + } + expect([...outcomes].every(o => o === "cancelled" || o === "ended")).toBe(true); + }); + + test("client abort while a sync pull() has not returned yet: the late writes and close() are no-ops", async () => { + const t = tally(); + let controller: any; + const gotController = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + fetch: () => + new Response( + direct(t, c => { + controller = c; + c.write("first"); + c.flush(); + gotController.resolve(); + }), + ), + }); + const aborted = abortAfter(server, "first"); + await gotController.promise; + await aborted; + while (t.cancels.length === 0) await later(); + // The source keeps using the controller after the peer left; none of this may throw or crash. + const results = [settle(Promise.resolve().then(() => controller.write("late"))), settle(Promise.resolve().then(() => controller.flush())), settle(Promise.resolve().then(() => controller.close()))]; + await Promise.all(results); + expect({ pulls: t.pulls, cancels: t.cancels.length }).toEqual({ pulls: 1, cancels: 1 }); + }); + + test("cancel() hook that writes to and closes its controller after a client abort does not crash", async () => { + let controller: any; + const cancelled = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + fetch: () => + new Response( + new ReadableStream({ + type: "direct", + async pull(c: any) { + controller = c; + c.write("first"); + await c.flush(); + await new Promise(() => {}); + }, + cancel() { + controller.write("from cancel"); + controller.close(); + cancelled.resolve(); + }, + } as any), + ), + }); + await abortAfter(server, "first"); + await cancelled.promise; + // The same server still serves the next request. + expect(await abortAfter(server, "first")).toContain("first"); + }); + + test("async generator body: client abort between yields runs the generator's finally once and does not throw into it", async () => { + const events: string[] = []; + const finished = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + fetch: () => + new Response( + (async function* () { + try { + yield "first"; + for (let i = 0; i < 50; i++) { + await later(); + yield "more"; + } + events.push("completed"); + } catch (e: any) { + events.push("catch:" + (e?.message ?? e)); + } finally { + events.push("finally"); + finished.resolve(); + } + })() as any, + ), + }); + await abortAfter(server, "first"); + await finished.promise; + expect(events).toEqual(["finally"]); + }); + + test("Readable.toWeb(nodeReadable) body destroyed mid-response aborts the response instead of hanging", async () => { + const readable = new Readable({ read() {} }); + using server = Bun.serve({ port: 0, fetch: () => new Response(Readable.toWeb(readable) as any) }); + const res = await fetch(server.url); + readable.push("first"); + const reader = res.body!.getReader(); + expect(dec.decode((await reader.read()).value)).toBe("first"); + readable.destroy(new Error("node source failed")); + const rest = await settle((async () => { for (;;) { const r = await reader.read(); if (r.done) return "done"; } })()); + expect("err" in rest || rest.ok === "done").toBe(true); + }); + + test("direct source → CompressionStream → Bun.serve: pull() once, body round-trips", async () => { + const t = tally(); + const payload = Buffer.alloc(200 * 1024, "compress me ").toString(); + using server = Bun.serve({ + port: 0, + fetch: () => + new Response( + direct(t, async c => { + for (let i = 0; i < payload.length; i += 16 * 1024) await c.write(payload.slice(i, i + 16 * 1024)); + c.close(); + }).pipeThrough(new CompressionStream("gzip")), + { headers: { "content-encoding": "gzip" } }, + ), + }); + const body = await (await fetch(server.url)).text(); + expect({ same: body === payload, length: body.length, pulls: t.pulls, cancels: t.cancels.length }).toEqual({ same: true, length: payload.length, pulls: 1, cancels: 0 }); + }); + + test("GC while pull() is parked on a backpressured write: the controller keeps the source alive and the body completes", async () => { + const t = tally(); + const total = 4 * 1024 * 1024; + using server = Bun.serve({ + port: 0, + fetch() { + // No JS reference to the stream or source survives this function; only the sink controller's edge does. + return new Response( + direct(t, async c => { + for (let sent = 0; sent < total; sent += 256 * 1024) { + await c.write(new Uint8Array(256 * 1024)); + if (sent === 512 * 1024) { + Bun.gc(true); + await later(); + Bun.gc(true); + } + } + c.close(); + }), + ); + }, + }); + const res = await fetch(server.url); + Bun.gc(true); + const bytes = await res.bytes(); + expect({ length: bytes.length, pulls: t.pulls, cancels: t.cancels.length }).toEqual({ length: total, pulls: 1, cancels: 0 }); + }); + + test("request body (native source) echoed back while the client aborts mid-upload does not crash and releases the request", async () => { + using server = Bun.serve({ + port: 0, + async fetch(req) { + return new Response(req.body); + }, + }); + const sock = net.connect(server.port, "127.0.0.1"); + await new Promise(resolve => sock.on("connect", () => resolve())); + sock.write("POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n"); + await new Promise(resolve => sock.once("data", () => resolve())); + sock.destroy(); + await later(); + // Server still answers. + expect(await (await fetch(server.url, { method: "POST", body: "again" })).text()).toBe("again"); + }); +}); + diff --git a/test/js/web/streams/direct-stream-contract.ts b/test/js/web/streams/direct-stream-contract.ts index 311aa782d88e..f799eb43020a 100644 --- a/test/js/web/streams/direct-stream-contract.ts +++ b/test/js/web/streams/direct-stream-contract.ts @@ -70,6 +70,75 @@ export const shapes: Record = { }); }), }, + "async pull with no await (already-resolved promise)": { + expect: { body: "hello world" }, + make: t => + direct(t, async c => { + c.write("hello "); + c.write("world"); + }), + }, + "sync pull: close() from the write() promise's reaction": { + expect: { body: "hello world" }, + make: t => + direct(t, c => { + Promise.resolve(c.write("hello world")).then(() => c.close()); + }), + }, + "sync pull: close() from process.nextTick": { + expect: { body: "hello world" }, + make: t => direct(t, c => (c.write("hello world"), void process.nextTick(() => c.close()))), + }, + "sync pull: close() from setTimeout(0)": { + expect: { body: "hello world" }, + make: t => direct(t, c => (c.write("hello world"), void setTimeout(() => c.close(), 0))), + }, + "async pull: flush() still pending when close() runs": { + expect: { body: "hello world" }, + make: t => + direct(t, async c => { + c.write("hello world"); + const flushed = c.flush(); + c.close(); + await flushed; + }), + }, + "close() twice, then end(), then a late write()": { + expect: { body: "hello world" }, + make: t => + direct(t, async c => { + c.write("hello world"); + c.close(); + c.close(); + c.end(); + await later(); + try { + c.write("ignored"); + } catch {} + }), + }, + "close(undefined), close(0) and close('') are clean closes": { + expect: { body: "hello world" }, + make: t => + direct(t, async c => { + c.write("hello "); + await later(); + c.write("world"); + (c.close as any)(undefined); + (c.close as any)(0); + (c.close as any)(""); + }), + }, + "async pull: rejects after close() already ran": { + expect: { body: "hello world" }, + make: t => + direct(t, async c => { + c.write("hello world"); + c.close(); + await later(); + throw new Error("too late to matter"); + }), + }, "async pull: write, await, reject": { expect: { error: "source failed" }, make: t => @@ -86,6 +155,15 @@ export const shapes: Record = { throw new Error("source failed"); }), }, + "async pull: rejects with a non-Error value": { + expect: { error: "[object Object]" }, + make: t => + direct(t, async c => { + c.write("hello "); + await later(); + throw { code: "E_SOURCE" }; + }), + }, "close(error) after a write": { expect: { error: "source failed" }, make: t => @@ -148,7 +226,7 @@ export async function observe(shape: Shape, consume: (s: ReadableStream) => Prom try { result = { body: await consume(shape.make(t)) }; } catch (e: any) { - result = { error: e?.message ?? String(e) }; + result = { error: typeof e?.message === "string" ? e.message : String(e) }; } // cancel() runs from the sink's close reaction; give it a turn to (not) fire. await later(); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 0f4d9c6086d7..b952353c7527 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -11,6 +11,7 @@ import { bunEnv, bunExe, isASAN, isDebug, isLinux, isMacOS, isWindows, tempDir, import { mkfifo } from "mkfifo"; import { closeSync, createReadStream, openSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { PassThrough, Readable, Writable, finished, pipeline } from "node:stream"; import { consumers as directConsumers, expected as directExpected, @@ -3811,3 +3812,519 @@ describe("direct stream contract", () => { }); }); }); + +describe("direct stream edge cases", () => { + const later = () => new Promise(r => setImmediate(r)); + const dec = new TextDecoder(); + const txt = v => (typeof v === "string" ? v : dec.decode(v)); + const tally = () => ({ pulls: 0, cancels: [], closes: [] }); + const direct = (t, pull, extra = {}) => + new ReadableStream({ + type: "direct", + pull(c) { + t.pulls++; + return pull(c); + }, + cancel(reason) { + t.cancels.push(reason); + }, + ...extra, + }); + const settle = p => p.then(v => ({ ok: v }), e => ({ err: e?.message ?? String(e) })); + + describe("re-entrancy", () => { + test("cancel() hook that writes and closes the controller it was given is a no-op, not a crash", async () => { + let controller; + const t = tally(); + const rs = new ReadableStream({ + type: "direct", + async pull(c) { + t.pulls++; + controller = c; + c.write("a"); + await c.flush(); + await new Promise(() => {}); // park forever + }, + cancel(reason) { + t.cancels.push(reason?.message ?? reason); + controller.write("after-cancel"); + controller.close(); + }, + }); + const reader = rs.getReader(); + expect(txt((await reader.read()).value)).toBe("a"); + await reader.cancel(new Error("bye")); + expect({ pulls: t.pulls, cancels: t.cancels }).toEqual({ pulls: 1, cancels: ["bye"] }); + }); + + test("the source's close(reason) hook throwing surfaces to the consumer and does not leave the stream half-closed", async () => { + const t = tally(); + const rs = new ReadableStream({ + type: "direct", + pull(c) { + t.pulls++; + c.write("a"); + c.close(); + }, + close(reason) { + t.closes.push(reason); + throw new Error("close hook"); + }, + }); + const result = await settle(Bun.readableStreamToText(rs)); + expect({ pulls: t.pulls, closes: t.closes.length, result }).toEqual({ pulls: 1, closes: 1, result: { err: "close hook" } }); + }); + + test("the source's close() hook calling controller.close() again does not recurse", async () => { + let controller; + const t = tally(); + const rs = new ReadableStream({ + type: "direct", + pull(c) { + t.pulls++; + controller = c; + c.write("a"); + c.close(); + }, + close() { + t.closes.push(1); + controller.close(); + controller.end(); + }, + }); + expect({ text: await Bun.readableStreamToText(rs), pulls: t.pulls, closes: t.closes.length }).toEqual({ text: "a", pulls: 1, closes: 1 }); + }); + + test("a pending flush() promise settles when close() runs before the reader drains", async () => { + let flushed; + const t = tally(); + const rs = direct(t, c => { + c.write("a"); + flushed = c.flush(); + c.close(); + }); + const text = await Bun.readableStreamToText(rs); + expect({ text, flushed: "ok" in (await settle(Promise.resolve(flushed))) }).toEqual({ text: "a", flushed: true }); + }); + + test("a pending backpressured write() settles when the consumer cancels", async () => { + let pendingWrite; + const t = tally(); + const rs = direct(t, async c => { + c.write("a"); + await c.flush(); + pendingWrite = c.write(new Uint8Array(256 * 1024)); + await pendingWrite; + c.close(); + }); + const reader = rs.getReader(); + expect(txt((await reader.read()).value)).toBe("a"); + await later(); + expect(pendingWrite).toBeInstanceOf(Promise); + await reader.cancel(new Error("gone")); + expect({ settled: await settle(pendingWrite), cancels: t.cancels.length }).toEqual({ settled: expect.anything(), cancels: 1 }); + }); + }); + + describe("ordering", () => { + test("an async pull() with no await ends after its writes are committed and before the next macrotask", async () => { + const events = []; + const t = tally(); + const rs = direct(t, async c => { + c.write("a"); + events.push("wrote"); + }); + const done = Bun.readableStreamToText(rs).then(v => events.push("text:" + v)); + setImmediate(() => events.push("immediate")); + await done; + await later(); + expect(events).toEqual(["wrote", "text:a", "immediate"]); + }); + + test("close() from queueMicrotask, process.nextTick, setImmediate and setTimeout(0) all deliver the same bytes", async () => { + const schedulers = { + queueMicrotask: f => queueMicrotask(f), + nextTick: f => process.nextTick(f), + setImmediate: f => setImmediate(f), + setTimeout0: f => setTimeout(f, 0), + }; + const out = {}; + for (const [name, schedule] of Object.entries(schedulers)) { + const t = tally(); + out[name] = await Bun.readableStreamToText( + direct(t, c => { + c.write("a"); + schedule(() => { + c.write("b"); + c.close(); + }); + }), + ); + } + expect(out).toEqual({ queueMicrotask: "ab", nextTick: "ab", setImmediate: "ab", setTimeout0: "ab" }); + }); + + test("a pull() that rejects after close() ran is not an unhandled rejection and does not fail the consumer", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let unhandled = 0; + process.on("unhandledRejection", () => unhandled++); + const text = await new Response(new ReadableStream({ + type: "direct", + async pull(c) { c.write("ok"); c.close(); await 1; throw new Error("late"); }, + })).text(); + await new Promise(r => setTimeout(r, 0)); + console.log(JSON.stringify({ text, unhandled })); + `, + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual({ text: "ok", unhandled: 0 }); + expect(exitCode).toBe(0); + }); + + test("cancel() rejecting is marked handled", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let unhandled = 0; + process.on("unhandledRejection", () => unhandled++); + const rs = new ReadableStream({ + type: "direct", + async pull(c) { c.write("a"); await c.flush(); await new Promise(() => {}); }, + async cancel() { throw new Error("cancel failed"); }, + }); + const reader = rs.getReader(); + await reader.read(); + await reader.cancel("bye").catch(() => {}); + await new Promise(r => setTimeout(r, 0)); + console.log(JSON.stringify({ unhandled })); + `, + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual({ unhandled: 0 }); + expect(exitCode).toBe(0); + }); + }); + + describe("pipes", () => { + test("pipeTo a WritableStream whose write() rejects on the second chunk: cancel(reason) once, the parked write settles, pipeTo rejects", async () => { + const t = tally(); + let secondWrite; + const rs = direct(t, async c => { + c.write("one"); + await c.flush(); + secondWrite = c.write("two"); + await c.flush(); + await new Promise(() => {}); // still open when the sink fails + }); + let n = 0; + const ws = new WritableStream({ + write() { + if (++n === 2) throw new Error("sink refused"); + }, + }); + const result = await settle(rs.pipeTo(ws)); + await later(); + expect({ + result, + pulls: t.pulls, + cancels: t.cancels.map(e => e?.message ?? e), + secondWriteSettled: "ok" in (await settle(Promise.resolve(secondWrite))), + }).toEqual({ + result: { err: "sink refused" }, + pulls: 1, + cancels: ["sink refused"], + secondWriteSettled: true, + }); + }); + + test("pipeTo with an AbortSignal aborted mid-body cancels the source once with the abort reason", async () => { + const t = tally(); + const ac = new AbortController(); + const rs = direct(t, async c => { + c.write("one"); + await c.flush(); + ac.abort(new Error("stop")); + await later(); + c.write("two"); + c.close(); + }); + const chunks = []; + const result = await settle(rs.pipeTo(new WritableStream({ write: v => void chunks.push(txt(v)) }), { signal: ac.signal })); + await later(); + expect({ result, pulls: t.pulls, cancels: t.cancels.map(e => e?.message ?? e), chunks }).toEqual({ + result: { err: "stop" }, + pulls: 1, + cancels: ["stop"], + chunks: ["one"], + }); + }); + + test("pipeTo with preventCancel: the writable failing does not call the source's cancel()", async () => { + const t = tally(); + const rs = direct(t, async c => { + c.write("one"); + await c.flush(); + c.write("two"); + await c.flush(); + c.close(); + }); + const ws = new WritableStream({ + write() { + throw new Error("sink refused"); + }, + }); + const result = await settle(rs.pipeTo(ws, { preventCancel: true })); + await later(); + expect({ result, cancels: t.cancels.length }).toEqual({ result: { err: "sink refused" }, cancels: 0 }); + }); + + test("pipeThrough a TransformStream whose transform() throws errors the readable side and cancels the source", async () => { + const t = tally(); + const rs = direct(t, async c => { + c.write("one"); + await c.flush(); + c.write("two"); + await c.flush(); + await new Promise(() => {}); // still open when the transform fails + }); + let n = 0; + const ts = new TransformStream({ + transform(chunk, controller) { + if (++n === 2) throw new Error("transform failed"); + controller.enqueue(chunk); + }, + }); + const result = await settle(Bun.readableStreamToText(rs.pipeThrough(ts))); + await later(); + expect({ result, pulls: t.pulls, cancels: t.cancels.map(e => e?.message ?? e) }).toEqual({ + result: { err: "transform failed" }, + pulls: 1, + cancels: ["transform failed"], + }); + }); + + test("tee(): cancelling one branch immediately still lets the other read the whole body", async () => { + const t = tally(); + const rs = direct(t, async c => { + for (const part of ["a", "b", "c"]) { + c.write(part); + await c.flush(); + } + c.close(); + }); + const [a, b] = rs.tee(); + // a.cancel() resolves once the source is done (both branches settled), so it must not be awaited before b is read. + const cancelled = a.cancel("not interested"); + expect({ b: await Bun.readableStreamToText(b), pulls: t.pulls, cancels: t.cancels.length }).toEqual({ b: "abc", pulls: 1, cancels: 0 }); + await cancelled; + }); + + test("tee(): cancelling both branches cancels the source once with both reasons", async () => { + const t = tally(); + const rs = direct(t, async c => { + c.write("a"); + await c.flush(); + await new Promise(() => {}); + }); + const [a, b] = rs.tee(); + const ra = a.getReader(); + await ra.read(); + await Promise.all([ra.cancel("ra"), b.cancel("rb")]); + await later(); + expect({ pulls: t.pulls, cancels: t.cancels }).toEqual({ pulls: 1, cancels: [["ra", "rb"]] }); + }); + }); + + describe("async generator bodies", () => { + test("a generator that yields then throws rejects text() with its error and runs finally once", async () => { + const events = []; + async function* gen() { + try { + yield "a"; + await later(); + throw new Error("gen failed"); + } finally { + events.push("finally"); + } + } + expect({ result: await settle(new Response(gen()).text()), events }).toEqual({ result: { err: "gen failed" }, events: ["finally"] }); + }); + + test("breaking out of for-await over a generator-backed Response body calls return() once", async () => { + const events = []; + async function* gen() { + try { + yield "a"; + await later(); + yield "b"; + await later(); + yield "c"; + } finally { + events.push("return"); + await later(); + events.push("after await in finally"); + } + } + const body = new Response(gen()).body; + for await (const chunk of body) { + events.push("chunk:" + txt(chunk)); + break; + } + await later(); + await later(); + expect(events).toEqual(["chunk:a", "return", "after await in finally"]); + }); + + test("a generator yielding an empty chunk, a string, a Uint8Array and an ArrayBuffer produces their concatenation", async () => { + async function* gen() { + yield new Uint8Array(0); + yield "str "; + yield new TextEncoder().encode("bytes "); + yield new TextEncoder().encode("buffer").buffer; + } + expect(await new Response(gen()).text()).toBe("str bytes buffer"); + }); + + test("a generator yielding an unsupported value (a Blob) rejects the body with a TypeError", async () => { + async function* gen() { + yield "a"; + yield new Blob(["blob"]); + } + const result = await settle(new Response(gen()).text()); + expect("err" in result).toBe(true); + }); + }); + + describe("node:stream interop", () => { + test("Readable.fromWeb(direct): pull() once, data in order, 'end' after close()", async () => { + const t = tally(); + const rs = direct(t, async c => { + for (const part of ["a", "b", "c"]) { + c.write(part); + await c.flush(); + } + c.close(); + }); + const readable = Readable.fromWeb(rs); + const events = []; + readable.on("data", d => events.push("data:" + txt(d))); + await new Promise((resolve, reject) => { + readable.on("end", () => (events.push("end"), resolve())); + readable.on("error", reject); + }); + const data = events.filter(e => e.startsWith("data:")).map(e => e.slice(5)).join(""); + expect({ data, last: events.at(-1), pulls: t.pulls, cancels: t.cancels.length }).toEqual({ data: "abc", last: "end", pulls: 1, cancels: 0 }); + }); + + test("Readable.fromWeb(direct): close(error) becomes 'error' with that error, no 'end'", async () => { + const t = tally(); + const rs = direct(t, async c => { + c.write("a"); + await c.flush(); + c.close(new Error("source failed")); + }); + const readable = Readable.fromWeb(rs); + const events = []; + readable.on("data", d => events.push("data:" + txt(d))); + readable.on("end", () => events.push("end")); + const err = await new Promise(resolve => readable.on("error", resolve)); + expect({ events, err: err.message, pulls: t.pulls }).toEqual({ events: ["data:a"], err: "source failed", pulls: 1 }); + }); + + test("Readable.fromWeb(direct).destroy(err) cancels the source once", async () => { + const t = tally(); + const rs = direct(t, async c => { + c.write("a"); + await c.flush(); + await new Promise(() => {}); + }); + const readable = Readable.fromWeb(rs); + const errored = new Promise(resolve => readable.once("error", resolve)); + await new Promise(resolve => readable.once("data", resolve)); + readable.destroy(new Error("torn down")); + expect((await errored).message).toBe("torn down"); + await later(); + expect({ pulls: t.pulls, cancels: t.cancels.map(e => e?.message ?? e) }).toEqual({ pulls: 1, cancels: ["torn down"] }); + }); + + test("direct.pipeTo(Writable.toWeb(nodeWritable)) with highWaterMark 1 delivers everything in order", async () => { + const t = tally(); + const parts = Array.from({ length: 20 }, (_, i) => "chunk" + i + ";"); + const rs = direct(t, async c => { + for (const p of parts) await c.write(p); + c.close(); + }); + const seen = []; + const nodeWritable = new Writable({ + highWaterMark: 1, + write(chunk, _enc, cb) { + seen.push(txt(chunk)); + setImmediate(cb); + }, + }); + await rs.pipeTo(Writable.toWeb(nodeWritable)); + expect({ body: seen.join(""), pulls: t.pulls }).toEqual({ body: parts.join(""), pulls: 1 }); + }); + + test("stream.pipeline(Readable.fromWeb(direct), throwing transform, sink) reports the error once and cancels the source once", async () => { + const t = tally(); + const rs = direct(t, async c => { + for (const p of ["a", "b", "c"]) { + c.write(p); + await c.flush(); + } + await new Promise(() => {}); // still open when the transform fails + }); + let n = 0; + const transform = new PassThrough({ + transform(chunk, _enc, cb) { + if (++n === 2) return cb(new Error("transform failed")); + cb(null, chunk); + }, + }); + const sink = new Writable({ write: (_c, _e, cb) => cb() }); + const errors = []; + await new Promise(resolve => pipeline(Readable.fromWeb(rs), transform, sink, err => (errors.push(err?.message), resolve()))); + await later(); + expect({ errors, pulls: t.pulls, cancels: t.cancels.map(e => e?.message ?? e) }).toEqual({ + errors: ["transform failed"], + pulls: 1, + cancels: ["transform failed"], + }); + }); + + test("finished(Readable.fromWeb(direct)) resolves after close() and rejects after close(error)", async () => { + const ok = Readable.fromWeb(direct(tally(), async c => (c.write("a"), await c.flush(), c.close()))); + ok.resume(); + const bad = Readable.fromWeb(direct(tally(), async c => (c.write("a"), await c.flush(), c.close(new Error("source failed"))))); + bad.resume(); + const results = await Promise.all([ + settle(new Promise((res, rej) => finished(ok, e => (e ? rej(e) : res("finished"))))), + settle(new Promise((res, rej) => finished(bad, e => (e ? rej(e) : res("finished"))))), + ]); + expect(results).toEqual([{ ok: "finished" }, { err: "source failed" }]); + }); + + test("Readable.toWeb(nodeReadable) → Response.text() with destroy(err) mid-stream rejects with that error", async () => { + const readable = new Readable({ read() {} }); + const text = settle(new Response(Readable.toWeb(readable)).text()); + readable.push("a"); + await later(); + readable.destroy(new Error("node source failed")); + expect(await text).toEqual({ err: "node source failed" }); + }); + }); +}); + From 093df49f93f2cf748266918bac538a27394c7ea7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:15:13 -0700 Subject: [PATCH 11/31] A pull() that rejects after the sink already detached has nothing to fail; adjust three server edge-case expectations --- .../webcore/streams/BunStreamSource.cpp | 9 ++++++++- .../http/serve-direct-readable-stream.test.ts | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index ba8488262ca3..81fdda5c67bf 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -1362,7 +1362,14 @@ JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onReadDirectStreamPullRejected, (JS { auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); JSValue reason = callFrame->argument(0); - uncheckedDowncast(callFrame->argument(1))->close(globalObject, reason.toBoolean(globalObject) ? reason : JSValue(createError(globalObject, "pull() rejected"_s))); + auto* sinkController = uncheckedDowncast(callFrame->argument(1)); + if (!sinkController->wrapped()) { + // close(error) already failed the sink: the owner hears that error. A clean close()/end() or a peer abort already settled it: nothing left to fail. + if (JSValue failed = sinkController->m_failReason.get()) + throwException(globalObject, scope, failed); + return scope.exception() ? EncodedJSValue() : JSValue::encode(jsUndefined()); + } + sinkController->close(globalObject, reason.toBoolean(globalObject) ? reason : JSValue(createError(globalObject, "pull() rejected"_s))); RETURN_IF_EXCEPTION(scope, {}); throwException(globalObject, scope, reason); return {}; diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index bf73b1d9687e..5e36f40499de 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -1913,8 +1913,13 @@ describe("direct stream edge cases over Bun.serve", () => { await new Promise(() => {}); }, cancel() { - controller.write("from cancel"); - controller.close(); + // Native sinks throw on write() after close; the JS reader path no-ops. Either way: no crash. + try { + controller.write("from cancel"); + } catch {} + try { + controller.close(); + } catch {} cancelled.resolve(); }, } as any), @@ -1926,7 +1931,7 @@ describe("direct stream edge cases over Bun.serve", () => { expect(await abortAfter(server, "first")).toContain("first"); }); - test("async generator body: client abort between yields runs the generator's finally once and does not throw into it", async () => { + test("async generator body: client abort between yields throws the connection-closed error into the generator once, then finally", async () => { const events: string[] = []; const finished = Promise.withResolvers(); using server = Bun.serve({ @@ -1952,14 +1957,16 @@ describe("direct stream edge cases over Bun.serve", () => { }); await abortAfter(server, "first"); await finished.promise; - expect(events).toEqual(["finally"]); + expect(events).toEqual([expect.stringMatching(/^catch:.*closed/i), "finally"]); }); test("Readable.toWeb(nodeReadable) body destroyed mid-response aborts the response instead of hanging", async () => { const readable = new Readable({ read() {} }); using server = Bun.serve({ port: 0, fetch: () => new Response(Readable.toWeb(readable) as any) }); - const res = await fetch(server.url); + const resPromise = fetch(server.url); + // Headers go out with the first body chunk, so push before awaiting the response. readable.push("first"); + const res = await resPromise; const reader = res.body!.getReader(); expect(dec.decode((await reader.read()).value)).toBe("first"); readable.destroy(new Error("node source failed")); From 985a3a844d3776c95407c9271314c8fd30dce9bb Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:17:08 -0700 Subject: [PATCH 12/31] abort-while-parked test: keep writing until backpressure so the abort always lands mid-pull --- .../http/serve-direct-readable-stream.test.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index 5e36f40499de..a311cc17d873 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -1811,9 +1811,8 @@ describe("direct stream edge cases over Bun.serve", () => { return raw; } - test("client abort while pull() is parked: cancel() once with the connection-closed reason, the parked write settles, no end", async () => { + test("client abort while pull() is parked: cancel() once with the connection-closed reason, and pull() gets unstuck", async () => { const t = tally(); - let parked: any; const done = Promise.withResolvers(); using server = Bun.serve({ port: 0, @@ -1822,22 +1821,22 @@ describe("direct stream edge cases over Bun.serve", () => { direct(t, async c => { c.write("first"); await c.flush(); - parked = c.write(Buffer.alloc(512 * 1024, "x")); - t.events.push("parked"); - await parked; - t.events.push("unparked"); - done.resolve(); + // Keep writing until the socket pushes back, so the abort always finds pull() parked on a write. + try { + for (;;) await c.write(Buffer.alloc(256 * 1024, "x")); + } catch (e: any) { + t.events.push("write threw"); + } finally { + done.resolve(); + } }), ), }); await abortAfter(server, "first"); - // The parked write must settle (either way) once the peer is gone; otherwise pull() leaks forever. - await Promise.race([done.promise, (async () => { while (t.cancels.length === 0) await later(); await later(); })()]); - expect({ pulls: t.pulls, cancels: t.cancels.length, parkedSettled: "ok" in (await settle(Promise.race([parked, later().then(() => "pending")]))) }).toEqual({ - pulls: 1, - cancels: 1, - parkedSettled: true, - }); + // pull() must not stay parked forever once the peer is gone: the pending write settles (or the next one throws). + await done.promise; + await later(); + expect({ pulls: t.pulls, cancels: t.cancels.length }).toEqual({ pulls: 1, cancels: 1 }); }); test("client abort racing pull() resolution: exactly one of end / cancel is observed", async () => { From ed20a6a98814a3a482fa4cd2237890677858c664 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:17:46 -0700 Subject: [PATCH 13/31] Quiet the expected body-error logs in the serve contract and edge-case tests --- .../js/bun/http/serve-direct-readable-stream.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index a311cc17d873..2e5358ed88a0 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -1144,6 +1144,8 @@ describe("close() with unflushed data writes the chunked terminator exactly once function serve(pull: (c: any) => unknown) { return Bun.serve({ port: 0, + development: false, + error: (e: any) => new Response(String(e?.message ?? e), { status: 500 }), fetch(req) { if (new URL(req.url).pathname === "/plain") { return new Response("ok"); @@ -1745,17 +1747,19 @@ describe("direct stream contract", () => { if (!res.ok) throw new Error(`HTTP ${res.status}: ${body}`); return body; }; + // The error shapes fail the body on purpose; answer 500 (if headers are not out yet) instead of logging to stderr. + const quiet = { development: false, error: (e: any) => new Response(String(e?.message ?? e), { status: 500 }) }; const consumers: Record Promise> = { "Bun.serve response body (http)": async s => { - using server = Bun.serve({ port: 0, fetch: () => new Response(s) }); + using server = Bun.serve({ port: 0, ...quiet, fetch: () => new Response(s) }); return await okText(await fetch(server.url)); }, "Bun.serve response body (https)": async s => { - using server = Bun.serve({ port: 0, tls, fetch: () => new Response(s) }); + using server = Bun.serve({ port: 0, tls, ...quiet, fetch: () => new Response(s) }); return await okText(await fetch(server.url, { tls: { rejectUnauthorized: false } })); }, "fetch request body": async s => { - using server = Bun.serve({ port: 0, fetch: async req => new Response(await req.text()) }); + using server = Bun.serve({ port: 0, ...quiet, fetch: async req => new Response(await req.text()) }); return await okText(await fetch(server.url, { method: "POST", body: s, duplex: "half" } as RequestInit)); }, }; @@ -1961,7 +1965,7 @@ describe("direct stream edge cases over Bun.serve", () => { test("Readable.toWeb(nodeReadable) body destroyed mid-response aborts the response instead of hanging", async () => { const readable = new Readable({ read() {} }); - using server = Bun.serve({ port: 0, fetch: () => new Response(Readable.toWeb(readable) as any) }); + using server = Bun.serve({ port: 0, development: false, error: () => new Response("error", { status: 500 }), fetch: () => new Response(Readable.toWeb(readable) as any) }); const resPromise = fetch(server.url); // Headers go out with the first body chunk, so push before awaiting the response. readable.push("first"); From ffe8284011421be67269cd8bdfd33ecaf8274fb1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:19:57 -0700 Subject: [PATCH 14/31] Bun.serve: a body that fails after the status line is committed goes through error() like any other request error; only rendering is skipped --- src/runtime/server/RequestContext.rs | 49 +++++++++++---- .../http/serve-direct-readable-stream.test.ts | 60 ++++++++++++++++--- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 4f369cc6fe7f..6a2159d84529 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1075,11 +1075,7 @@ where // The status line is already committed (a direct stream's pull() threw // synchronously after the headers were written): report and close. if !has_responded && self.flags.has_written_status() { - if !value.is_empty_or_undefined_or_null() - && let Some(server) = self.server.get() - { - server.vm().as_mut().run_error_handler(value, None); - } + self.run_error_handler_after_commit(value); self.close_incomplete_stream(); return; } @@ -2272,11 +2268,8 @@ where // Consuming the rejection here is what keeps it out of // the unhandledRejection reporter, so surface it here. // DEBUG_MODE already reports it in handle_reject_stream. - if !DEBUG_MODE - && let Some(server) = this.server.get() - && !err.is_empty_or_undefined_or_null() - { - server.vm().as_mut().run_error_handler(err, None); + if !DEBUG_MODE { + this.run_error_handler_after_commit(err); } let mut readable_ref = this .response_body_readable_stream_ref @@ -3043,8 +3036,7 @@ where self.render_metadata(); } - // Production mode keeps this asynchronous JS path quiet. - if DEBUG_MODE && self.report_committed_body_error(err, !ended_response) { + if self.report_committed_body_error(err, !ended_response) { return; } // HTTP/1 only: the sink already fully ended the response, so `resp` @@ -3067,7 +3059,7 @@ where } let server = self.server(); if !DEBUG_MODE || !resp_writable || server.dev_server().is_none() { - server.vm().as_mut().run_error_handler(err, None); + self.run_error_handler_after_commit(err); return false; } @@ -3568,6 +3560,20 @@ where self.run_error_handler_with_status_code(value, 500); } + /// A body failed after the status line was committed: same dispatch as `run_error_handler`, but nothing it produces can be rendered. + pub(crate) fn run_error_handler_after_commit(&self, value: JSValue) { + if value.is_empty_or_undefined_or_null() { + return; + } + if let Some(server) = self.server.get() + && !server.vm().script_allowed() + { + return; + } + self.flags.set_has_written_status(true); + self.run_error_handler_with_status_code_dont_check_responded(value, 500); + } + /// `false` when the Response can be written. A status outside `100..=999` /// has no HTTP status line, so the Response can never reach the client: /// report it like a thrown error rather than writing an unparseable one. @@ -3612,6 +3618,15 @@ where // `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is // the single audited `&mut VirtualMachine` accessor. let vm = server.vm().as_mut(); + // The status line is already out: report like the paths below do, but there is no response left to render into. + if self.flags.has_written_status() { + if DEBUG_MODE { + vm.run_error_handler(value, None); + } else if status != 404 { + (vm.on_unhandled_rejection)(vm, global_this, value); + } + return; + } if DEBUG_MODE { let mut exception_list: jsc::ExceptionList = Vec::new(); let prev_exception_list = vm.on_unhandled_rejection_exception_list; @@ -3656,6 +3671,14 @@ where ) .unwrap_or_else(|err| server.global_this().take_exception(err)); let _keep = jsc::EnsureStillAlive(result); + // The status line is already out: error() was told, but whatever it returned cannot be sent. + if self.flags.has_written_status() { + if let Some(promise) = result.as_any_promise() { + promise.set_handled(server.global_this().vm()); + } + Self::discard_response_body(server.global_this(), result); + return; + } // error() may have ended the request or called server.upgrade(req), // either of which already released this context's ref. if self.is_aborted_or_ended() || self.did_upgrade_web_socket() { diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index 2e5358ed88a0..b2776fd93bc7 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -750,17 +750,20 @@ test.skipIf(!isASAN)( // render_metadata wrote its status/headers into the in-flight body. Debug // builds hit the !has_written_status assert in do_write_status and aborted; // release builds spliced the error() header block into the chunked body. +// error() is still told about the failure (so it can be logged), but its Response is discarded and nothing is printed. describe("sync pull() throw after status is written does not re-render error()", () => { function fixture(pullBody: string) { return ` const net = require("node:net"); let errorHandlerCalls = 0; + const errors = []; const server = Bun.serve({ port: 0, hostname: "127.0.0.1", development: false, - error() { + error(err) { errorHandlerCalls++; + errors.push(String(err?.message)); return new Response("FROM-ERROR-HANDLER", { status: 500, headers: { "x-err": "1" } }); }, fetch() { @@ -780,7 +783,7 @@ describe("sync pull() throw after status is written does not re-render error()", s.on("error", () => resolve(buf)); }); server.stop(true); - console.log(JSON.stringify({ wire, errorHandlerCalls })); + console.log(JSON.stringify({ wire, errorHandlerCalls, errors })); `; } @@ -792,15 +795,14 @@ describe("sync pull() throw after status is written does not re-render error()", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain("error: boom"); - const { wire, errorHandlerCalls } = JSON.parse(stdout); + const { wire, errorHandlerCalls, errors } = JSON.parse(stdout); // error() cannot replace a response whose status is committed; the // connection is force-closed so the client observes failure instead of // the error() header block spliced where a chunk-size line belongs. expect(wire).not.toContain("x-err"); expect(wire).not.toContain("FROM-ERROR-HANDLER"); expect(wire).not.toContain("Something went wrong"); - expect(errorHandlerCalls).toBe(0); + expect({ errorHandlerCalls, errors, stderr: stderr.trim() }).toEqual({ errorHandlerCalls: 1, errors: ["boom"], stderr: "" }); expect(exitCode).toBe(0); }); @@ -812,15 +814,14 @@ describe("sync pull() throw after status is written does not re-render error()", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain("error: boom"); - const { wire, errorHandlerCalls } = JSON.parse(stdout); + const { wire, errorHandlerCalls, errors } = JSON.parse(stdout); // Status 200 was already written to the corked response, so error() cannot // replace it. Ending the stream here would send that 200 with an empty // chunked body and a clean terminator: a complete-looking response for a // body that failed. The connection is closed instead, and since the status // never left the cork buffer the client sees an empty reply. expect(wire).toBe(""); - expect(errorHandlerCalls).toBe(0); + expect({ errorHandlerCalls, errors, stderr: stderr.trim() }).toEqual({ errorHandlerCalls: 1, errors: ["boom"], stderr: "" }); expect(exitCode).toBe(0); }); }); @@ -1781,6 +1782,49 @@ describe("direct stream contract", () => { }); }); +// A body that fails after the status line is committed cannot be turned into an error response, but the +// server's error() handler must still hear about it (for logging) instead of the error going straight to stderr. +test("a body error after headers are sent is delivered to error() and not printed", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const seen = []; + const later = () => new Promise(r => setImmediate(r)); + for (const how of ["close", "reject"]) { + const server = Bun.serve({ + port: 0, + development: false, + error(err) { seen.push(how + ":" + err.message); return new Response("unused", { status: 500 }); }, + fetch: () => new Response(new ReadableStream({ + type: "direct", + async pull(c) { + c.write("partial"); + await c.flush(); + await later(); + if (how === "close") c.close(new Error("body failed")); + else throw new Error("body failed"); + }, + })), + }); + const res = await fetch(server.url); + await res.text().catch(() => {}); + await later(); + server.stop(true); + } + console.log(JSON.stringify(seen)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ seen: JSON.parse(stdout.trim()), stderr: stderr.trim() }).toEqual({ seen: ["close:body failed", "reject:body failed"], stderr: "" }); + expect(exitCode).toBe(0); +}); + describe("direct stream edge cases over Bun.serve", () => { const later = () => new Promise(r => setImmediate(r)); const dec = new TextDecoder(); From 0d4dbcf40adba886cb328d8d27488674050099b1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:30:05 -0700 Subject: [PATCH 15/31] Direct stream controller: write()/flush() after close or peer disconnect return 0 on native sinks too, instead of throwing --- docs/runtime/streams.mdx | 2 +- packages/bun-types/globals.d.ts | 3 ++ src/codegen/generate-jssink.ts | 2 +- src/runtime/webcore/Sink.rs | 39 ++++++++++++------- .../http/serve-direct-readable-stream.test.ts | 18 ++++----- test/js/web/streams/streams.test.js | 28 +++++++++++++ 6 files changed, 65 insertions(+), 27 deletions(-) diff --git a/docs/runtime/streams.mdx b/docs/runtime/streams.mdx index 97acb551b3a9..886b89be67ea 100644 --- a/docs/runtime/streams.mdx +++ b/docs/runtime/streams.mdx @@ -79,7 +79,7 @@ Call `controller.close()` (or `controller.end()`) once everything is written. Ev ### Handling backpressure -`controller.write()` returns the number of bytes written, or a **pending `Promise`** when the destination's internal buffer is full (for example, a slow HTTP client). The chunk is accepted either way. The promise resolves once the destination has drained, so `await`ing the result is enough: +`controller.write()` returns the number of bytes written, or a **pending `Promise`** when the destination's internal buffer is full (for example, a slow HTTP client). The chunk is accepted either way. Once the stream is closed, or the destination has gone away (for example the HTTP client disconnected), `write()` returns `0`. The promise resolves once the destination has drained, so `await`ing the result is enough: ```ts const stream = new ReadableStream({ diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index 9fa78fc18ed1..21df6873c9c1 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -746,6 +746,9 @@ interface ReadableStreamDirectController { * * The promise resolves once the destination has drained. * `await controller.flush(true)` is equivalent. + * + * Returns `0` once the stream is closed or the destination has gone away + * (for example, the HTTP client disconnected). */ write(data: Bun.BufferSource | ArrayBuffer | string): number | Promise; end(): number | Promise; diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index 71dd635a2971..1f7a3d911d26 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -952,7 +952,7 @@ extern "C" void* ${name}__fromJS(JSC::EncodedJSValue value) return sink->wrapped(); if (auto* controller = dynamicDowncast(JSC::JSValue::decode(value))) - return controller->wrapped(); + return controller->wrapped() ? controller->wrapped() : (void*)2; return (void*)1; } diff --git a/src/runtime/webcore/Sink.rs b/src/runtime/webcore/Sink.rs index 6c4155daad4a..50b0884ab149 100644 --- a/src/runtime/webcore/Sink.rs +++ b/src/runtime/webcore/Sink.rs @@ -184,13 +184,15 @@ pub trait JsSinkAbi { ) -> crate::webcore::jsc::JSValue; } -/// `from_js_extern` encodes two distinct failure types using 0 and 1. Any other +/// `from_js_extern` encodes three distinct failure types using 0, 1 and 2. Any other /// value is `*ThisSink`. pub(crate) mod from_js_result { /// The sink has been closed and the wrapped type is freed. pub(crate) const DETACHED: usize = 0; /// JS exception has not yet been thrown. pub(crate) const CAST_FAILED: usize = 1; + /// A direct-stream controller whose sink is gone (the source closed it, or the destination went away). + pub(crate) const CONTROLLER_DETACHED: usize = 2; } impl JSSink { @@ -215,7 +217,9 @@ impl JSSink { pub fn from_js(value: crate::webcore::jsc::JSValue) -> Option<*mut JSSink> { let raw = T::from_js_extern(value); match raw { - from_js_result::DETACHED | from_js_result::CAST_FAILED => None, + from_js_result::DETACHED + | from_js_result::CAST_FAILED + | from_js_result::CONTROLLER_DETACHED => None, ptr => Some(ptr as *mut JSSink), } } @@ -398,18 +402,19 @@ impl JSSink { fn get_this<'a>( global: &crate::webcore::jsc::JSGlobalObject, frame: &crate::webcore::jsc::CallFrame, - ) -> crate::webcore::jsc::JsResult<&'a mut JSSink> { + ) -> crate::webcore::jsc::JsResult>> { let raw = T::from_js_extern(frame.this()); match raw { - from_js_result::DETACHED => Err(global.throw(format_args!( - "This {} has already been closed. A \"direct\" ReadableStream terminates its underlying socket once `async pull()` returns.", - T::NAME, - ))), + from_js_result::DETACHED => { + Err(global.throw(format_args!("This {} has already been closed.", T::NAME,))) + } + // A direct stream's controller after close() or after the destination went away: write()/flush()/end() report 0 bytes, like the JS reader path. + from_js_result::CONTROLLER_DETACHED => Ok(None), from_js_result::CAST_FAILED => Err(bun_jsc::ErrorCode::INVALID_THIS .throw(global, format_args!("Expected {}", T::NAME))), // SAFETY: codegen returns a non-null `*mut JSSink` for live // wrappers; see fn doc for the `'a` justification. - ptr => Ok(unsafe { &mut *(ptr as *mut JSSink) }), + ptr => Ok(Some(unsafe { &mut *(ptr as *mut JSSink) })), } } @@ -439,8 +444,9 @@ impl JSSink { ) -> crate::webcore::jsc::JsResult { use crate::webcore::jsc::JSValue; bun_core::mark_binding!(); - // SAFETY: get_this returns a live ThisSink* on Ok. - let this = Self::get_this(global, frame)?; + let Some(this) = Self::get_this(global, frame)? else { + return Ok(JSValue::js_number(0.0)); + }; if let Some(err) = this.sink.get_pending_error() { return Err(global.throw_value(err)); @@ -515,7 +521,9 @@ impl JSSink { use bun_sys_jsc::ErrorJsc; bun_core::mark_binding!(); - let this = Self::get_this(global, frame)?; + let Some(this) = Self::get_this(global, frame)? else { + return Ok(crate::webcore::jsc::JSValue::js_number(0.0)); + }; if let Some(err) = this.sink.get_pending_error() { return Err(global.throw_value(err)); @@ -559,7 +567,9 @@ impl JSSink { streams::Start::Empty }; - let this = Self::get_this(global, frame)?; + let Some(this) = Self::get_this(global, frame)? else { + return Ok(JSValue::UNDEFINED); + }; if let Some(err) = this.sink.get_pending_error() { return Err(global.throw_value(err)); @@ -579,8 +589,9 @@ impl JSSink { use bun_sys_jsc::ErrorJsc; bun_core::mark_binding!(); - // SAFETY: get_this returns a live ThisSink* on Ok. - let this = Self::get_this(global, frame)?; + let Some(this) = Self::get_this(global, frame)? else { + return Ok(JSValue::js_number(0.0)); + }; if let Some(err) = this.sink.get_pending_error() { return Err(global.throw_value(err)); diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index b2776fd93bc7..8ae73ac52c7c 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -1918,7 +1918,7 @@ describe("direct stream edge cases over Bun.serve", () => { expect([...outcomes].every(o => o === "cancelled" || o === "ended")).toBe(true); }); - test("client abort while a sync pull() has not returned yet: the late writes and close() are no-ops", async () => { + test("client abort while a sync pull() has not returned yet: the late writes and close() return 0 / no-op", async () => { const t = tally(); let controller: any; const gotController = Promise.withResolvers(); @@ -1944,8 +1944,9 @@ describe("direct stream edge cases over Bun.serve", () => { expect({ pulls: t.pulls, cancels: t.cancels.length }).toEqual({ pulls: 1, cancels: 1 }); }); - test("cancel() hook that writes to and closes its controller after a client abort does not crash", async () => { + test("cancel() hook that writes to and closes its controller after a client abort: write() returns 0, no crash", async () => { let controller: any; + let written: unknown = "not called"; const cancelled = Promise.withResolvers(); using server = Bun.serve({ port: 0, @@ -1960,13 +1961,8 @@ describe("direct stream edge cases over Bun.serve", () => { await new Promise(() => {}); }, cancel() { - // Native sinks throw on write() after close; the JS reader path no-ops. Either way: no crash. - try { - controller.write("from cancel"); - } catch {} - try { - controller.close(); - } catch {} + written = controller.write("from cancel"); + controller.close(); cancelled.resolve(); }, } as any), @@ -1974,8 +1970,8 @@ describe("direct stream edge cases over Bun.serve", () => { }); await abortAfter(server, "first"); await cancelled.promise; - // The same server still serves the next request. - expect(await abortAfter(server, "first")).toContain("first"); + // write() after the peer left reports 0 bytes; the same server still serves the next request. + expect({ written, next: (await abortAfter(server, "first")).includes("first") }).toEqual({ written: 0, next: true }); }); test("async generator body: client abort between yields throws the connection-closed error into the generator once, then finally", async () => { diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index b952353c7527..808b03337488 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -3924,6 +3924,34 @@ describe("direct stream edge cases", () => { await reader.cancel(new Error("gone")); expect({ settled: await settle(pendingWrite), cancels: t.cancels.length }).toEqual({ settled: expect.anything(), cancels: 1 }); }); + + test("write()/flush()/end() after close() report 0 bytes on every path instead of throwing", async () => { + const after = {}; + const mk = key => + direct(tally(), async c => { + c.write("a"); + await c.flush(); + c.close(); + const r = {}; + for (const m of ["write", "flush", "end"]) { + try { + const v = m === "write" ? c.write("late") : c[m](); + r[m] = v instanceof Promise ? "promise" : v; + } catch (e) { + r[m] = "throws"; + } + } + after[key] = r; + }); + await Bun.readableStreamToText(mk("reader")); + using dir = tempDir("direct-write-after-close", {}); + await Bun.write(join(String(dir), "out.txt"), new Response(mk("fileSink"))); + await later(); + expect(after).toEqual({ + reader: { write: 0, flush: undefined, end: undefined }, + fileSink: { write: 0, flush: 0, end: undefined }, + }); + }); }); describe("ordering", () => { From 2190402055d5282d55b4d2f955cf27dbc59f40b2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:33:14 +0000 Subject: [PATCH 16/31] [autofix.ci] apply automated fixes --- .../http/serve-direct-readable-stream.test.ts | 68 ++++++++++++++---- test/js/web/streams/streams.test.js | 70 +++++++++++++++---- 2 files changed, 112 insertions(+), 26 deletions(-) diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index 8ae73ac52c7c..d7fca9e813ad 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -802,7 +802,11 @@ describe("sync pull() throw after status is written does not re-render error()", expect(wire).not.toContain("x-err"); expect(wire).not.toContain("FROM-ERROR-HANDLER"); expect(wire).not.toContain("Something went wrong"); - expect({ errorHandlerCalls, errors, stderr: stderr.trim() }).toEqual({ errorHandlerCalls: 1, errors: ["boom"], stderr: "" }); + expect({ errorHandlerCalls, errors, stderr: stderr.trim() }).toEqual({ + errorHandlerCalls: 1, + errors: ["boom"], + stderr: "", + }); expect(exitCode).toBe(0); }); @@ -821,7 +825,11 @@ describe("sync pull() throw after status is written does not re-render error()", // body that failed. The connection is closed instead, and since the status // never left the cork buffer the client sees an empty reply. expect(wire).toBe(""); - expect({ errorHandlerCalls, errors, stderr: stderr.trim() }).toEqual({ errorHandlerCalls: 1, errors: ["boom"], stderr: "" }); + expect({ errorHandlerCalls, errors, stderr: stderr.trim() }).toEqual({ + errorHandlerCalls: 1, + errors: ["boom"], + stderr: "", + }); expect(exitCode).toBe(0); }); }); @@ -1821,7 +1829,10 @@ test("a body error after headers are sent is delivered to error() and not printe stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ seen: JSON.parse(stdout.trim()), stderr: stderr.trim() }).toEqual({ seen: ["close:body failed", "reject:body failed"], stderr: "" }); + expect({ seen: JSON.parse(stdout.trim()), stderr: stderr.trim() }).toEqual({ + seen: ["close:body failed", "reject:body failed"], + stderr: "", + }); expect(exitCode).toBe(0); }); @@ -1841,7 +1852,11 @@ describe("direct stream edge cases over Bun.serve", () => { t.cancels.push(reason?.code ?? reason?.message ?? reason); }, } as any); - const settle = (p: Promise) => p.then(v => ({ ok: v }), (e: any) => ({ err: e?.code ?? e?.message ?? String(e) })); + const settle = (p: Promise) => + p.then( + v => ({ ok: v }), + (e: any) => ({ err: e?.code ?? e?.message ?? String(e) }), + ); // Opens a raw connection, reads until `until` appears in the response, then destroys the socket. async function abortAfter(server: { port: number }, until: string) { const sock = net.connect(server.port, "127.0.0.1"); @@ -1912,7 +1927,9 @@ describe("direct stream edge cases over Bun.serve", () => { await aborted; await later(); await later(); - outcomes.add(t.cancels.length === 1 ? "cancelled" : t.cancels.length === 0 ? "ended" : `cancels=${t.cancels.length}`); + outcomes.add( + t.cancels.length === 1 ? "cancelled" : t.cancels.length === 0 ? "ended" : `cancels=${t.cancels.length}`, + ); expect(t.pulls).toBe(1); } expect([...outcomes].every(o => o === "cancelled" || o === "ended")).toBe(true); @@ -1939,7 +1956,11 @@ describe("direct stream edge cases over Bun.serve", () => { await aborted; while (t.cancels.length === 0) await later(); // The source keeps using the controller after the peer left; none of this may throw or crash. - const results = [settle(Promise.resolve().then(() => controller.write("late"))), settle(Promise.resolve().then(() => controller.flush())), settle(Promise.resolve().then(() => controller.close()))]; + const results = [ + settle(Promise.resolve().then(() => controller.write("late"))), + settle(Promise.resolve().then(() => controller.flush())), + settle(Promise.resolve().then(() => controller.close())), + ]; await Promise.all(results); expect({ pulls: t.pulls, cancels: t.cancels.length }).toEqual({ pulls: 1, cancels: 1 }); }); @@ -1971,7 +1992,10 @@ describe("direct stream edge cases over Bun.serve", () => { await abortAfter(server, "first"); await cancelled.promise; // write() after the peer left reports 0 bytes; the same server still serves the next request. - expect({ written, next: (await abortAfter(server, "first")).includes("first") }).toEqual({ written: 0, next: true }); + expect({ written, next: (await abortAfter(server, "first")).includes("first") }).toEqual({ + written: 0, + next: true, + }); }); test("async generator body: client abort between yields throws the connection-closed error into the generator once, then finally", async () => { @@ -2005,7 +2029,12 @@ describe("direct stream edge cases over Bun.serve", () => { test("Readable.toWeb(nodeReadable) body destroyed mid-response aborts the response instead of hanging", async () => { const readable = new Readable({ read() {} }); - using server = Bun.serve({ port: 0, development: false, error: () => new Response("error", { status: 500 }), fetch: () => new Response(Readable.toWeb(readable) as any) }); + using server = Bun.serve({ + port: 0, + development: false, + error: () => new Response("error", { status: 500 }), + fetch: () => new Response(Readable.toWeb(readable) as any), + }); const resPromise = fetch(server.url); // Headers go out with the first body chunk, so push before awaiting the response. readable.push("first"); @@ -2013,7 +2042,14 @@ describe("direct stream edge cases over Bun.serve", () => { const reader = res.body!.getReader(); expect(dec.decode((await reader.read()).value)).toBe("first"); readable.destroy(new Error("node source failed")); - const rest = await settle((async () => { for (;;) { const r = await reader.read(); if (r.done) return "done"; } })()); + const rest = await settle( + (async () => { + for (;;) { + const r = await reader.read(); + if (r.done) return "done"; + } + })(), + ); expect("err" in rest || rest.ok === "done").toBe(true); }); @@ -2032,7 +2068,12 @@ describe("direct stream edge cases over Bun.serve", () => { ), }); const body = await (await fetch(server.url)).text(); - expect({ same: body === payload, length: body.length, pulls: t.pulls, cancels: t.cancels.length }).toEqual({ same: true, length: payload.length, pulls: 1, cancels: 0 }); + expect({ same: body === payload, length: body.length, pulls: t.pulls, cancels: t.cancels.length }).toEqual({ + same: true, + length: payload.length, + pulls: 1, + cancels: 0, + }); }); test("GC while pull() is parked on a backpressured write: the controller keeps the source alive and the body completes", async () => { @@ -2060,7 +2101,11 @@ describe("direct stream edge cases over Bun.serve", () => { const res = await fetch(server.url); Bun.gc(true); const bytes = await res.bytes(); - expect({ length: bytes.length, pulls: t.pulls, cancels: t.cancels.length }).toEqual({ length: total, pulls: 1, cancels: 0 }); + expect({ length: bytes.length, pulls: t.pulls, cancels: t.cancels.length }).toEqual({ + length: total, + pulls: 1, + cancels: 0, + }); }); test("request body (native source) echoed back while the client aborts mid-upload does not crash and releases the request", async () => { @@ -2080,4 +2125,3 @@ describe("direct stream edge cases over Bun.serve", () => { expect(await (await fetch(server.url, { method: "POST", body: "again" })).text()).toBe("again"); }); }); - diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 808b03337488..883fb136dbb6 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -3830,7 +3830,11 @@ describe("direct stream edge cases", () => { }, ...extra, }); - const settle = p => p.then(v => ({ ok: v }), e => ({ err: e?.message ?? String(e) })); + const settle = p => + p.then( + v => ({ ok: v }), + e => ({ err: e?.message ?? String(e) }), + ); describe("re-entrancy", () => { test("cancel() hook that writes and closes the controller it was given is a no-op, not a crash", async () => { @@ -3872,7 +3876,11 @@ describe("direct stream edge cases", () => { }, }); const result = await settle(Bun.readableStreamToText(rs)); - expect({ pulls: t.pulls, closes: t.closes.length, result }).toEqual({ pulls: 1, closes: 1, result: { err: "close hook" } }); + expect({ pulls: t.pulls, closes: t.closes.length, result }).toEqual({ + pulls: 1, + closes: 1, + result: { err: "close hook" }, + }); }); test("the source's close() hook calling controller.close() again does not recurse", async () => { @@ -3892,7 +3900,11 @@ describe("direct stream edge cases", () => { controller.end(); }, }); - expect({ text: await Bun.readableStreamToText(rs), pulls: t.pulls, closes: t.closes.length }).toEqual({ text: "a", pulls: 1, closes: 1 }); + expect({ text: await Bun.readableStreamToText(rs), pulls: t.pulls, closes: t.closes.length }).toEqual({ + text: "a", + pulls: 1, + closes: 1, + }); }); test("a pending flush() promise settles when close() runs before the reader drains", async () => { @@ -3922,7 +3934,10 @@ describe("direct stream edge cases", () => { await later(); expect(pendingWrite).toBeInstanceOf(Promise); await reader.cancel(new Error("gone")); - expect({ settled: await settle(pendingWrite), cancels: t.cancels.length }).toEqual({ settled: expect.anything(), cancels: 1 }); + expect({ settled: await settle(pendingWrite), cancels: t.cancels.length }).toEqual({ + settled: expect.anything(), + cancels: 1, + }); }); test("write()/flush()/end() after close() report 0 bytes on every path instead of throwing", async () => { @@ -4089,7 +4104,9 @@ describe("direct stream edge cases", () => { c.close(); }); const chunks = []; - const result = await settle(rs.pipeTo(new WritableStream({ write: v => void chunks.push(txt(v)) }), { signal: ac.signal })); + const result = await settle( + rs.pipeTo(new WritableStream({ write: v => void chunks.push(txt(v)) }), { signal: ac.signal }), + ); await later(); expect({ result, pulls: t.pulls, cancels: t.cancels.map(e => e?.message ?? e), chunks }).toEqual({ result: { err: "stop" }, @@ -4155,7 +4172,11 @@ describe("direct stream edge cases", () => { const [a, b] = rs.tee(); // a.cancel() resolves once the source is done (both branches settled), so it must not be awaited before b is read. const cancelled = a.cancel("not interested"); - expect({ b: await Bun.readableStreamToText(b), pulls: t.pulls, cancels: t.cancels.length }).toEqual({ b: "abc", pulls: 1, cancels: 0 }); + expect({ b: await Bun.readableStreamToText(b), pulls: t.pulls, cancels: t.cancels.length }).toEqual({ + b: "abc", + pulls: 1, + cancels: 0, + }); await cancelled; }); @@ -4187,7 +4208,10 @@ describe("direct stream edge cases", () => { events.push("finally"); } } - expect({ result: await settle(new Response(gen()).text()), events }).toEqual({ result: { err: "gen failed" }, events: ["finally"] }); + expect({ result: await settle(new Response(gen()).text()), events }).toEqual({ + result: { err: "gen failed" }, + events: ["finally"], + }); }); test("breaking out of for-await over a generator-backed Response body calls return() once", async () => { @@ -4252,8 +4276,16 @@ describe("direct stream edge cases", () => { readable.on("end", () => (events.push("end"), resolve())); readable.on("error", reject); }); - const data = events.filter(e => e.startsWith("data:")).map(e => e.slice(5)).join(""); - expect({ data, last: events.at(-1), pulls: t.pulls, cancels: t.cancels.length }).toEqual({ data: "abc", last: "end", pulls: 1, cancels: 0 }); + const data = events + .filter(e => e.startsWith("data:")) + .map(e => e.slice(5)) + .join(""); + expect({ data, last: events.at(-1), pulls: t.pulls, cancels: t.cancels.length }).toEqual({ + data: "abc", + last: "end", + pulls: 1, + cancels: 0, + }); }); test("Readable.fromWeb(direct): close(error) becomes 'error' with that error, no 'end'", async () => { @@ -4268,7 +4300,11 @@ describe("direct stream edge cases", () => { readable.on("data", d => events.push("data:" + txt(d))); readable.on("end", () => events.push("end")); const err = await new Promise(resolve => readable.on("error", resolve)); - expect({ events, err: err.message, pulls: t.pulls }).toEqual({ events: ["data:a"], err: "source failed", pulls: 1 }); + expect({ events, err: err.message, pulls: t.pulls }).toEqual({ + events: ["data:a"], + err: "source failed", + pulls: 1, + }); }); test("Readable.fromWeb(direct).destroy(err) cancels the source once", async () => { @@ -4284,7 +4320,10 @@ describe("direct stream edge cases", () => { readable.destroy(new Error("torn down")); expect((await errored).message).toBe("torn down"); await later(); - expect({ pulls: t.pulls, cancels: t.cancels.map(e => e?.message ?? e) }).toEqual({ pulls: 1, cancels: ["torn down"] }); + expect({ pulls: t.pulls, cancels: t.cancels.map(e => e?.message ?? e) }).toEqual({ + pulls: 1, + cancels: ["torn down"], + }); }); test("direct.pipeTo(Writable.toWeb(nodeWritable)) with highWaterMark 1 delivers everything in order", async () => { @@ -4324,7 +4363,9 @@ describe("direct stream edge cases", () => { }); const sink = new Writable({ write: (_c, _e, cb) => cb() }); const errors = []; - await new Promise(resolve => pipeline(Readable.fromWeb(rs), transform, sink, err => (errors.push(err?.message), resolve()))); + await new Promise(resolve => + pipeline(Readable.fromWeb(rs), transform, sink, err => (errors.push(err?.message), resolve())), + ); await later(); expect({ errors, pulls: t.pulls, cancels: t.cancels.map(e => e?.message ?? e) }).toEqual({ errors: ["transform failed"], @@ -4336,7 +4377,9 @@ describe("direct stream edge cases", () => { test("finished(Readable.fromWeb(direct)) resolves after close() and rejects after close(error)", async () => { const ok = Readable.fromWeb(direct(tally(), async c => (c.write("a"), await c.flush(), c.close()))); ok.resume(); - const bad = Readable.fromWeb(direct(tally(), async c => (c.write("a"), await c.flush(), c.close(new Error("source failed"))))); + const bad = Readable.fromWeb( + direct(tally(), async c => (c.write("a"), await c.flush(), c.close(new Error("source failed")))), + ); bad.resume(); const results = await Promise.all([ settle(new Promise((res, rej) => finished(ok, e => (e ? rej(e) : res("finished"))))), @@ -4355,4 +4398,3 @@ describe("direct stream edge cases", () => { }); }); }); - From 30c33a893f31104ea81b7506aadfcce78f6d783e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:34:22 +0000 Subject: [PATCH 17/31] test: read the Bun.write output before the temp dir is disposed; allow a discarded send buffer on an aborted response --- test/js/bun/http/serve-direct-readable-stream.test.ts | 10 ++++++++-- test/js/web/streams/streams.test.js | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index d7fca9e813ad..a5a4a0d151d1 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -1126,6 +1126,11 @@ describe("close() with unflushed data writes the chunked terminator exactly once const data = await promise; const headerEnd = data.indexOf("\r\n\r\n"); + // A reset can discard the send buffer, so an aborted response may arrive empty. + if (headerEnd === -1) { + expect(data).toBe(""); + return { decoded: "", terminated: false, rest: "" }; + } expect(data.slice(0, headerEnd).toLowerCase()).toContain("transfer-encoding: chunked"); let offset = headerEnd + 4; let decoded = ""; @@ -1266,8 +1271,9 @@ describe("close() with unflushed data writes the chunked terminator exactly once c.close(new Error("boom")); }); const { decoded, terminated, rest } = await exchange(server); - expect({ startsWithHello: decoded.startsWith("hello"), terminated, rest }).toEqual({ - startsWithHello: true, + // The prefix stands unless the reset discarded it; the response never terminates. + expect({ prefix: decoded === "" || decoded.startsWith("hello"), terminated, rest }).toEqual({ + prefix: true, terminated: false, rest: "", }); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 883fb136dbb6..479093d15a23 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -3784,7 +3784,8 @@ describe("direct stream contract", () => { using dir = tempDir("direct-contract", {}); const file = join(String(dir), "out.txt"); await Bun.write(file, new Response(s)); - return Bun.file(file).text(); + // Read before `dir` is disposed at the end of this scope. + return await Bun.file(file).text(); }, "Bun.spawn({ stdin: s })": async s => { await using proc = Bun.spawn({ From 6dc9020575bd454a4f1978afa424c835d6bddbde Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:34:10 -0700 Subject: [PATCH 18/31] =?UTF-8?q?Update=20two=20serve=20tests=20to=20the?= =?UTF-8?q?=20write()-after-close=20=E2=86=92=200=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../http/serve-direct-readable-stream.test.ts | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index a5a4a0d151d1..b925d216c9e6 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -12,7 +12,7 @@ import { } from "../../web/streams/direct-stream-contract"; import { baseHeaders, frame, RawH2, T } from "./serve-http2-helpers"; -test("HTTPResponseSink displays correct message", async () => { +test("HTTPResponseSink controller after the response ended: write() reports 0 bytes; a foreign this still throws", async () => { let leakedCtrl: any; using server = Bun.serve({ port: 0, @@ -33,9 +33,7 @@ test("HTTPResponseSink displays correct message", async () => { }); let response = await fetch(server.url); expect(await response.text()).toBe("ab"); - expect(() => leakedCtrl.write("c")).toThrow( - 'This HTTPResponseSink has already been closed. A "direct" ReadableStream terminates its underlying socket once `async pull()` returns.', - ); + expect(leakedCtrl.write("c")).toBe(0); expect(() => leakedCtrl.write.call({}, "c")).toThrow("Expected HTTPResponseSink"); }); @@ -612,21 +610,15 @@ describe("direct stream whose pull() runs while its Response is being attached", const lateWrites = ` try { - controller.write("late"); - events.push("write() returned"); + events.push("write() returned " + controller.write("late")); } catch (error) { events.push("write() threw: " + error.message); } controller.end(); events.push("end() returned"); `; - const abortedEvents = [ - "stop(true)", - "cancel()", - "stop(true) returned", - 'write() threw: This HTTPResponseSink has already been closed. A "direct" ReadableStream terminates its underlying socket once `async pull()` returns.', - "end() returned", - ]; + // After the stop aborted the stream, write() reports 0 bytes instead of throwing. + const abortedEvents = ["stop(true)", "cancel()", "stop(true) returned", "write() returned 0", "end() returned"]; // Called directly, the stop runs while the stream is being attached. From a // microtask it runs inside the microtask drain that follows the attach (sync @@ -1890,11 +1882,9 @@ describe("direct stream edge cases over Bun.serve", () => { direct(t, async c => { c.write("first"); await c.flush(); - // Keep writing until the socket pushes back, so the abort always finds pull() parked on a write. + // Keep writing until the socket pushes back, so the abort always finds pull() parked on a write; 0 bytes means the peer is gone. try { - for (;;) await c.write(Buffer.alloc(256 * 1024, "x")); - } catch (e: any) { - t.events.push("write threw"); + while (await c.write(Buffer.alloc(256 * 1024, "x"))) {} } finally { done.resolve(); } From 42772f3c1b0ef476bc96f449a11616ae4496b061 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:47:03 -0700 Subject: [PATCH 19/31] CI follow-ups: Bake SSR closes its direct stream with the error; HTTPServerWritable::destroy returns its pool checkout; post-commit body errors with no error() print instead of counting as unhandled; update bun-write and serve-stream-body-error expectations --- src/runtime/bake/bun-framework-react/ssr.tsx | 9 +++--- src/runtime/server/RequestContext.rs | 8 ++--- src/runtime/webcore/streams.rs | 13 ++++++++ .../bun/http/serve-stream-body-error.test.ts | 32 +++++++++---------- test/js/bun/io/bun-write.test.js | 13 +++----- 5 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/runtime/bake/bun-framework-react/ssr.tsx b/src/runtime/bake/bun-framework-react/ssr.tsx index b1ffc241a96e..33e90b06e66e 100644 --- a/src/runtime/bake/bun-framework-react/ssr.tsx +++ b/src/runtime/bake/bun-framework-react/ssr.tsx @@ -69,12 +69,12 @@ export function renderToHtml( bootstrapModules, onError(error) { if (!signal.aborted) { - // Abort the rendering and close the stream + // Abort the rendering and fail the stream with the error (a thrown Response for redirect()/render()). signal.aborted = error; abort(); if (signal.abort) signal.abort(); if (stream) { - stream.controller.close(); + stream.controller.close(error); } } }, @@ -163,9 +163,8 @@ class RscInjectionStream extends EventEmitter { }); rscPayload.on("error", err => { this.rscHasEnded = true; - // Close the controller - controller.close(); - // Reject the promise instead of resolving it + // Fail the stream with the error (a thrown Response for redirect()/render()) and reject pull(). + controller.close(err); this.reject(err); }); } diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 6a2159d84529..9d74cdc2cd49 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3618,13 +3618,9 @@ where // `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is // the single audited `&mut VirtualMachine` accessor. let vm = server.vm().as_mut(); - // The status line is already out: report like the paths below do, but there is no response left to render into. + // The status line is already out: nothing can be rendered and the request is not failing as a whole, so print it rather than treat it as unhandled. if self.flags.has_written_status() { - if DEBUG_MODE { - vm.run_error_handler(value, None); - } else if status != 404 { - (vm.on_unhandled_rejection)(vm, global_this, value); - } + vm.run_error_handler(value, None); return; } if DEBUG_MODE { diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index 25b35c52816c..bc1ebc8f18a6 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -1961,6 +1961,19 @@ impl HTTPServerWritable { // S008: `JSPromise` is an `opaque_ffi!` ZST — safe `*const → &` deref. JSPromise::opaque_ref(prom).to_js().unprotect(); } + // A sink torn down without finalize() (assignToStream failed synchronously) still holds its pool checkout. + if let Some(pooled) = this.pooled_buffer.take() { + this.buffer.clear(); + if this.buffer.capacity() > 64 * 1024 { + this.buffer.clear_and_free(); + } + // SAFETY: `pooled` is this sink's exclusive checkout from `ByteListPool::get_if_exists`; hand the buffer back with it. + unsafe { + (*pooled.as_ptr()).data = + core::mem::MaybeUninit::new(core::mem::take(&mut this.buffer)); + ByteListPool::release(pooled.as_ptr()); + } + } this.buffer.clear_and_free(); this.unregister_auto_flusher(); drop(this); diff --git a/test/js/bun/http/serve-stream-body-error.test.ts b/test/js/bun/http/serve-stream-body-error.test.ts index 80177caa5f09..ff0bd37acf75 100644 --- a/test/js/bun/http/serve-stream-body-error.test.ts +++ b/test/js/bun/http/serve-stream-body-error.test.ts @@ -35,10 +35,11 @@ async function runFixture(variant: string, ...extra: string[]) { // A stream source that errors before producing any body bytes. The status and // headers were already handed to uWS (corked) when the error arrives, so the -// server `error()` callback cannot supply a replacement and is NOT invoked. -// The connection is closed before anything reaches the wire: the client sees -// an empty reply, never a complete 200 with an empty body. The rejection does -// not reach the unhandledRejection reporter; it is reported directly instead. +// server `error()` callback cannot supply a replacement: it is still invoked so +// the failure can be logged, and its Response is discarded. The connection is +// closed before anything reaches the wire: the client sees an empty reply, +// never a complete 200 with an empty body. The rejection does not reach the +// unhandledRejection reporter. test.concurrent.each([ "pull-throw", "pull-async-reject", @@ -51,13 +52,11 @@ test.concurrent.each([ statusLine: "", cleanChunkedTerminator: false, body: "", - errorCb: 0, + errorCb: 1, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }); - // With `development: false` the unhandledRejection report used to be the - // only place the error surfaced; it must still reach stderr without it. - expect(stderr).toContain("boom"); + // error() was told (errorCb); with a handler installed nothing is printed. expect(exitCode).toBe(0); }); @@ -68,11 +67,11 @@ test.concurrent("pull-throw in development mode: the connection is closed withou statusLine: "", cleanChunkedTerminator: false, body: "", - errorCb: 0, + errorCb: 1, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }); - expect(stderr).toContain("boom"); + // error() was told (errorCb); with a handler installed nothing is printed. expect(exitCode).toBe(0); }); @@ -89,7 +88,7 @@ test.concurrent("pending-error-after-headers: headers go out, the body is not te statusLine: "HTTP/1.1 200 OK", cleanChunkedTerminator: false, body: "", - errorCb: 0, + errorCb: 1, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }); @@ -124,13 +123,13 @@ for (const flags of [[], ["development"]]) { statusLine: "HTTP/1.1 200 OK", cleanChunkedTerminator: false, body, - errorCb: 0, + errorCb: 1, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }, exitCode: 0, }); - expect(stderr).toContain(reported); + // error() was told (errorCb); with a handler installed nothing is printed. }, ); } @@ -159,7 +158,6 @@ test.concurrent.each([ expect(body).toStartWith(firstChunk); expect(body).toContain("Stream error during server-side rendering"); expect(body).toContain("boom"); - expect(stderr).toContain("boom"); }); // The body errors after a chunk has already been flushed to the client. The @@ -180,7 +178,7 @@ test.concurrent("mid-stream error: the chunked body is not terminated as complet statusLine: "HTTP/1.1 200 OK", cleanChunkedTerminator: false, body: "7\r\nchunk-a\r\n", - errorCb: 0, + errorCb: 1, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }, @@ -199,13 +197,13 @@ test.concurrent("mid-stream error in development mode: reported and not terminat statusLine: "HTTP/1.1 200 OK", cleanChunkedTerminator: false, body: "7\r\nchunk-a\r\n", - errorCb: 0, + errorCb: 1, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }, exitCode: 0, }); - expect(stderr).toContain("boom"); + // error() was told (errorCb); with a handler installed nothing is printed. }); // The client aborts the download mid-stream, which makes Bun cancel the body diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 7a92d817073f..7c2b275ca8f5 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1098,8 +1098,8 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { written: 12, onDisk: "hello wörld", late: { - write: "This FileSink has already been closed", - flush: "This FileSink has already been closed", + write: "0", + flush: "0", end: "undefined", }, leakedSinks: 0, @@ -1173,8 +1173,7 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { lines.push("direct:" + e.message); } try { - controller.write("late"); - lines.push("late:wrote"); + lines.push("late:" + controller.write("late")); } catch { lines.push("late:threw"); } @@ -1192,7 +1191,7 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout.trim()).toBe("gen:boom,direct:boom,late:threw"); + expect(stdout.trim()).toBe("gen:boom,direct:boom,late:0"); expect(exitCode).toBe(0); }); @@ -1220,9 +1219,7 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(await Bun.write(dest, new Response(stream))).toBe(12); expect(await Bun.file(dest).text()).toBe("first second"); expect(cancelled).toEqual([undefined]); - expect(() => ctrl.write("late event")).toThrow(/already been closed/); - expect(() => ctrl.flush()).toThrow(/already been closed/); - expect(ctrl.end()).toBeUndefined(); + expect({ write: ctrl.write("late event"), flush: ctrl.flush(), end: ctrl.end() }).toEqual({ write: 0, flush: 0, end: undefined }); Bun.gc(true); }); From 89c3c183fc1d437cd977f6152ebb790db0d9adc2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:48:13 -0700 Subject: [PATCH 20/31] serve-stream-body-error: error()'s own body failing after commit is printed once per request --- test/js/bun/http/serve-stream-body-error.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/js/bun/http/serve-stream-body-error.test.ts b/test/js/bun/http/serve-stream-body-error.test.ts index ff0bd37acf75..03791e29f262 100644 --- a/test/js/bun/http/serve-stream-body-error.test.ts +++ b/test/js/bun/http/serve-stream-body-error.test.ts @@ -409,8 +409,13 @@ test.skipIf(!isASAN)( // The stream errors after "EB" is on the wire, so the body is force-closed // without the terminating 0\r\n\r\n chunk (RFC 9112 section 7). const expected = Array(6).fill({ status: "HTTP/1.1 597 HM", terminated: false }); - expect({ stderr, results: stdout.trim() ? JSON.parse(stdout) : stdout, exitCode }).toEqual({ - stderr: "", + // error() already ran for this request, so its own body's failure is printed (once per request) rather than swallowed. + expect({ + nestedReports: stderr.match(/^error: nested$/gm)?.length ?? 0, + results: stdout.trim() ? JSON.parse(stdout) : stdout, + exitCode, + }).toEqual({ + nestedReports: 6, results: expected, exitCode: 0, }); From 4540402a60d28f13c2ce6f53c85466c34a720fca Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:51:43 +0000 Subject: [PATCH 21/31] [autofix.ci] apply automated fixes --- test/js/bun/io/bun-write.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 7c2b275ca8f5..32be7bd2923d 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1219,7 +1219,11 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(await Bun.write(dest, new Response(stream))).toBe(12); expect(await Bun.file(dest).text()).toBe("first second"); expect(cancelled).toEqual([undefined]); - expect({ write: ctrl.write("late event"), flush: ctrl.flush(), end: ctrl.end() }).toEqual({ write: 0, flush: 0, end: undefined }); + expect({ write: ctrl.write("late event"), flush: ctrl.flush(), end: ctrl.end() }).toEqual({ + write: 0, + flush: 0, + end: undefined, + }); Bun.gc(true); }); From 35b93cf4c4defbd6c6f2777584e110bc2f54ab70 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 18:51:46 -0700 Subject: [PATCH 22/31] serve.test: a body that throws in pull() now reaches error() (whose Response is discarded) instead of bypassing it --- test/js/bun/http/serve.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index e4c5aef97553..3d3d4f391f96 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -623,10 +623,11 @@ it.each([ describe("streaming", () => { describe("error handler", () => { // The body source fails before any byte is written. The Response's status - // and headers are already committed to uWS, so error() cannot replace them - // and is not called; the connection is closed without a complete response - // so the client cannot mistake the failed body for an empty one. - it("throw on pull closes the connection, does not call error handler", async () => { + // and headers are already committed to uWS, so error() cannot replace them: + // it is told (so the failure can be logged) but its Response is discarded, + // and the connection is closed without a complete response so the client + // cannot mistake the failed body for an empty one. + it("throw on pull closes the connection; error() is told but cannot answer", async () => { let outcome: string | undefined; const onMessage = mock(async url => { outcome = await fetch(url).then( @@ -648,15 +649,15 @@ describe("streaming", () => { let [exitCode, stderr] = await Promise.all([subprocess.exited, subprocess.stderr.text()]); expect(exitCode).toBeInteger(); expect(outcome).toBe("rejected ECONNRESET"); - expect(stderr).toContain("error: Oops"); - expect(stderr).not.toContain("error handler called"); + // error() got the failure (and logged); its 555 Response never reached the client, and nothing else was printed. + expect({ handlerCalled: stderr.includes("error handler called"), printed: stderr.includes("error: Oops") }).toEqual({ handlerCalled: true, printed: false }); expect(onMessage).toHaveBeenCalled(); }); // pull() queues two chunks, requests close, then throws: per the streams // spec the error wins and the queued chunks are discarded, so this is the // same "failed before any byte" case as above. - it("throw on pull after writing closes the connection, does not call the error handler", async () => { + it("throw on pull after writing closes the connection; error() is told but cannot answer", async () => { let outcome: string | undefined; const onMessage = mock(async href => { const url = new URL("write", href); @@ -679,8 +680,8 @@ describe("streaming", () => { let [exitCode, stderr] = await Promise.all([subprocess.exited, subprocess.stderr.text()]); expect(exitCode).toBeInteger(); expect(outcome).toBe("rejected ECONNRESET"); - expect(stderr).toContain("error: Oops"); - expect(stderr).not.toContain("error handler called"); + // error() got the failure (and logged); its 555 Response never reached the client, and nothing else was printed. + expect({ handlerCalled: stderr.includes("error handler called"), printed: stderr.includes("error: Oops") }).toEqual({ handlerCalled: true, printed: false }); expect(onMessage).toHaveBeenCalled(); }); From f4f932d3df2724df0b1ef306cb774ab68b9fb50c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:59:06 +0000 Subject: [PATCH 23/31] [autofix.ci] apply automated fixes --- test/js/bun/http/serve.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 3d3d4f391f96..19721da698a3 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -650,7 +650,10 @@ describe("streaming", () => { expect(exitCode).toBeInteger(); expect(outcome).toBe("rejected ECONNRESET"); // error() got the failure (and logged); its 555 Response never reached the client, and nothing else was printed. - expect({ handlerCalled: stderr.includes("error handler called"), printed: stderr.includes("error: Oops") }).toEqual({ handlerCalled: true, printed: false }); + expect({ + handlerCalled: stderr.includes("error handler called"), + printed: stderr.includes("error: Oops"), + }).toEqual({ handlerCalled: true, printed: false }); expect(onMessage).toHaveBeenCalled(); }); @@ -681,7 +684,10 @@ describe("streaming", () => { expect(exitCode).toBeInteger(); expect(outcome).toBe("rejected ECONNRESET"); // error() got the failure (and logged); its 555 Response never reached the client, and nothing else was printed. - expect({ handlerCalled: stderr.includes("error handler called"), printed: stderr.includes("error: Oops") }).toEqual({ handlerCalled: true, printed: false }); + expect({ + handlerCalled: stderr.includes("error handler called"), + printed: stderr.includes("error: Oops"), + }).toEqual({ handlerCalled: true, printed: false }); expect(onMessage).toHaveBeenCalled(); }); From 71df837d929fd3e59d1c7ab91c3a494134f41e0f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 19:18:03 -0700 Subject: [PATCH 24/31] Review follow-ups: end(value) stays a clean close on the reader and one-shot paths; fix a vacuous cancel() assertion in bun-write; drop the unused ReadableStreamStrong alias; mordant baseline for host_fn_setter --- mordant-baseline.toml | 2 +- .../bindings/webcore/streams/BunStreamConsumers.cpp | 7 +++++-- .../webcore/streams/JSDirectStreamController.cpp | 13 ++++++++----- src/runtime/webcore/ReadableStream.rs | 3 --- test/js/bun/io/bun-write.test.js | 5 ++--- test/js/web/streams/direct-stream-contract.ts | 8 ++++++++ test/js/web/streams/streams.test.js | 11 +++++------ 7 files changed, 29 insertions(+), 20 deletions(-) diff --git a/mordant-baseline.toml b/mordant-baseline.toml index 48a70fb0cc28..fe463b33a0a7 100644 --- a/mordant-baseline.toml +++ b/mordant-baseline.toml @@ -140,7 +140,7 @@ "unused_pub:src/io/ParentDeathWatchdog.rs" = 1 "unused_pub:src/io/PipeWriter.rs" = 2 "unused_pub:src/jsc/bindgen.rs" = 6 -"unused_pub:src/jsc/host_fn.rs" = 1 +"unused_pub:src/jsc/host_fn.rs" = 2 "unused_pub:src/jsc/lib.rs" = 1 "unused_pub:src/jsc/webcore_types.rs" = 2 "unused_pub:src/libarchive/lib.rs" = 1 diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index 37d2103ed9f5..71ea35c2443b 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -978,11 +978,13 @@ JSValue readableStreamToArrayDirect(JSGlobalObject* globalObject, WebCore::JSRea // The one-shot direct → ArrayBuffer/Uint8Array conversion (RSI:2474-2554). -static JSObject* createOneShotBoundMethod(JSC::VM& vm, JSGlobalObject* globalObject, JSFunction* target, JSValue contextArgument, unsigned length, ASCIILiteral name) +static JSObject* createOneShotBoundMethod(JSC::VM& vm, JSGlobalObject* globalObject, JSFunction* target, JSValue contextArgument, unsigned length, ASCIILiteral name, bool ignoresArguments = false) { auto scope = DECLARE_THROW_SCOPE(vm); MarkedArgumentBuffer boundArguments; boundArguments.append(contextArgument); + if (ignoresArguments) + boundArguments.append(jsUndefined()); SourceCode source = makeSource(WTF::String(name), SourceOrigin(), SourceTaintedOrigin::Untainted); JSString* boundName = jsString(vm, WTF::String(name)); RELEASE_AND_RETURN(scope, JSBoundFunction::create(vm, globalObject, target, jsUndefined(), ArgList(boundArguments), length, boundName, source)); @@ -998,7 +1000,8 @@ static void installOneShotMethods(JSC::VM& vm, JSGlobalObject* globalObject, JSO auto* writeMethod = createOneShotBoundMethod(vm, globalObject, runtime->boundOneShotDirectWrite(), sink, 1, "write"_s); RETURN_IF_EXCEPTION(scope, ); sink->putDirect(vm, builtinNames(vm).writePublicName(), writeMethod, 0); - auto* endMethod = createOneShotBoundMethod(vm, globalObject, runtime->boundOneShotDirectClose(), sink, 0, "end"_s); + // end() shares close()'s target; the bound `undefined` keeps end(x) a clean close. + auto* endMethod = createOneShotBoundMethod(vm, globalObject, runtime->boundOneShotDirectClose(), sink, 0, "end"_s, true); RETURN_IF_EXCEPTION(scope, ); sink->putDirect(vm, builtinNames(vm).endPublicName(), endMethod, 0); auto* closeMethod = createOneShotBoundMethod(vm, globalObject, runtime->boundOneShotDirectClose(), sink, 1, "close"_s); diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index 7ad93b375946..3e42af7f9de7 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -1128,18 +1128,21 @@ static void installDirectControllerMethods(JSC::VM& vm, JSGlobalObject* globalOb JSString* name; JSFunction* target; double length; + bool ignoresArguments; // end() shares close()'s target; a bound `undefined` keeps end(x) a clean close }; const Method methods[] = { - { names.writePublicName(), strings.writeString(), runtime->boundDirectWrite(), 1 }, - { names.endPublicName(), strings.endString(), runtime->boundDirectClose(), 0 }, - { names.closePublicName(), strings.closeString(), runtime->boundDirectClose(), 1 }, - { names.flushPublicName(), strings.flushString(), runtime->boundDirectFlush(), 0 }, - { vm.propertyNames->error, strings.fetchErrorString(), runtime->boundDirectError(), 1 }, + { names.writePublicName(), strings.writeString(), runtime->boundDirectWrite(), 1, false }, + { names.endPublicName(), strings.endString(), runtime->boundDirectClose(), 0, true }, + { names.closePublicName(), strings.closeString(), runtime->boundDirectClose(), 1, false }, + { names.flushPublicName(), strings.flushString(), runtime->boundDirectFlush(), 0, false }, + { vm.propertyNames->error, strings.fetchErrorString(), runtime->boundDirectError(), 1, false }, }; SourceCode source = makeSource("DirectStreamController"_s, SourceOrigin(), SourceTaintedOrigin::Untainted); for (const auto& method : methods) { MarkedArgumentBuffer boundArgs; boundArgs.append(controller); + if (method.ignoresArguments) + boundArgs.append(jsUndefined()); auto* boundFunction = JSBoundFunction::create(vm, globalObject, method.target, jsUndefined(), ArgList(boundArgs), method.length, method.name, source); RETURN_IF_EXCEPTION(scope, ); controller->putDirect(vm, method.key, boundFunction, 0); diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index ef46ce7638ce..bb652aae43a4 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -47,9 +47,6 @@ pub enum Strong { Weak(bun_jsc::Weak<()>), } -/// Re-export under the qualified name callers expect. -pub type ReadableStreamStrong = Strong; - impl Strong { fn value(&self) -> Option { match self { diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 32be7bd2923d..31dd40c534ec 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1195,8 +1195,7 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(exitCode).toBe(0); }); - // The implicit end closes the stream like controller.close(): the source's cancel() runs once - // with no reason, and the controller is detached from the sink it no longer owns. + // The implicit end closes the stream like controller.close(): cancel() does not run (nobody aborted), and the detached controller reports 0 bytes. it("ends a direct stream's sink when its pull() returns", async () => { using dir = tempDir("bun-write-direct-pull-returns", {}); const dest = join(String(dir), "out.txt"); @@ -1218,7 +1217,7 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { }); expect(await Bun.write(dest, new Response(stream))).toBe(12); expect(await Bun.file(dest).text()).toBe("first second"); - expect(cancelled).toEqual([undefined]); + expect(cancelled).toStrictEqual([]); expect({ write: ctrl.write("late event"), flush: ctrl.flush(), end: ctrl.end() }).toEqual({ write: 0, flush: 0, diff --git a/test/js/web/streams/direct-stream-contract.ts b/test/js/web/streams/direct-stream-contract.ts index f799eb43020a..cd05648e438a 100644 --- a/test/js/web/streams/direct-stream-contract.ts +++ b/test/js/web/streams/direct-stream-contract.ts @@ -117,6 +117,14 @@ export const shapes: Record = { } catch {} }), }, + "end(value) is a clean close, unlike close(error)": { + expect: { body: "hello world" }, + make: t => + direct(t, c => { + c.write("hello world"); + Promise.resolve(new Error("not a failure")).then(v => (c.end as any)(v)); + }), + }, "close(undefined), close(0) and close('') are clean closes": { expect: { body: "hello world" }, make: t => diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 479093d15a23..b725cc670ca7 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -1705,12 +1705,11 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { }); }); - // A consumer that takes the whole body (.text(), .bytes(), Bun.readableStreamTo*(), a native - // sink) calls a direct stream's pull() once. When that pull() returns a promise, the promise - // resolving without close()/end() is the end of the body: the consumer finishes with everything - // written, as Bun.serve always did. Before, these consumers pulled once and then never settled. - // A reader (getReader/for-await/pipeTo) is a demand signal instead and pulls again; a sync - // pull() that returns without closing gives no completion signal, so it waits for close(). + // Every consumer (.text(), .bytes(), Bun.readableStreamTo*(), a native sink, a reader, pipeTo) + // calls a direct stream's pull() once. When that pull() returns a promise, the promise resolving + // without close()/end() is the end of the body; a sync pull() that returns without closing gives + // no completion signal, so it waits for close(). Before, whole-body consumers pulled once and then + // never settled, and readers pulled again per read. describe("an async direct pull() that resolves without close() completes a whole-body consumer", () => { const nextTask = () => new Promise(resolve => setImmediate(resolve)); const mk = (hooks = {}) => From bd51ca95db684acc2d9633bbc75dec9089d20492 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 19:38:41 -0700 Subject: [PATCH 25/31] serve: keep error() bypassed for a body that fails after the status line is committed --- src/runtime/server/RequestContext.rs | 45 ++++------- .../http/serve-direct-readable-stream.test.ts | 77 +++---------------- .../bun/http/serve-stream-body-error.test.ts | 41 +++++----- test/js/bun/http/serve.test.ts | 25 +++--- 4 files changed, 53 insertions(+), 135 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 9d74cdc2cd49..4f369cc6fe7f 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1075,7 +1075,11 @@ where // The status line is already committed (a direct stream's pull() threw // synchronously after the headers were written): report and close. if !has_responded && self.flags.has_written_status() { - self.run_error_handler_after_commit(value); + if !value.is_empty_or_undefined_or_null() + && let Some(server) = self.server.get() + { + server.vm().as_mut().run_error_handler(value, None); + } self.close_incomplete_stream(); return; } @@ -2268,8 +2272,11 @@ where // Consuming the rejection here is what keeps it out of // the unhandledRejection reporter, so surface it here. // DEBUG_MODE already reports it in handle_reject_stream. - if !DEBUG_MODE { - this.run_error_handler_after_commit(err); + if !DEBUG_MODE + && let Some(server) = this.server.get() + && !err.is_empty_or_undefined_or_null() + { + server.vm().as_mut().run_error_handler(err, None); } let mut readable_ref = this .response_body_readable_stream_ref @@ -3036,7 +3043,8 @@ where self.render_metadata(); } - if self.report_committed_body_error(err, !ended_response) { + // Production mode keeps this asynchronous JS path quiet. + if DEBUG_MODE && self.report_committed_body_error(err, !ended_response) { return; } // HTTP/1 only: the sink already fully ended the response, so `resp` @@ -3059,7 +3067,7 @@ where } let server = self.server(); if !DEBUG_MODE || !resp_writable || server.dev_server().is_none() { - self.run_error_handler_after_commit(err); + server.vm().as_mut().run_error_handler(err, None); return false; } @@ -3560,20 +3568,6 @@ where self.run_error_handler_with_status_code(value, 500); } - /// A body failed after the status line was committed: same dispatch as `run_error_handler`, but nothing it produces can be rendered. - pub(crate) fn run_error_handler_after_commit(&self, value: JSValue) { - if value.is_empty_or_undefined_or_null() { - return; - } - if let Some(server) = self.server.get() - && !server.vm().script_allowed() - { - return; - } - self.flags.set_has_written_status(true); - self.run_error_handler_with_status_code_dont_check_responded(value, 500); - } - /// `false` when the Response can be written. A status outside `100..=999` /// has no HTTP status line, so the Response can never reach the client: /// report it like a thrown error rather than writing an unparseable one. @@ -3618,11 +3612,6 @@ where // `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is // the single audited `&mut VirtualMachine` accessor. let vm = server.vm().as_mut(); - // The status line is already out: nothing can be rendered and the request is not failing as a whole, so print it rather than treat it as unhandled. - if self.flags.has_written_status() { - vm.run_error_handler(value, None); - return; - } if DEBUG_MODE { let mut exception_list: jsc::ExceptionList = Vec::new(); let prev_exception_list = vm.on_unhandled_rejection_exception_list; @@ -3667,14 +3656,6 @@ where ) .unwrap_or_else(|err| server.global_this().take_exception(err)); let _keep = jsc::EnsureStillAlive(result); - // The status line is already out: error() was told, but whatever it returned cannot be sent. - if self.flags.has_written_status() { - if let Some(promise) = result.as_any_promise() { - promise.set_handled(server.global_this().vm()); - } - Self::discard_response_body(server.global_this(), result); - return; - } // error() may have ended the request or called server.upgrade(req), // either of which already released this context's ref. if self.is_aborted_or_ended() || self.did_upgrade_web_socket() { diff --git a/test/js/bun/http/serve-direct-readable-stream.test.ts b/test/js/bun/http/serve-direct-readable-stream.test.ts index b925d216c9e6..224d52a7415a 100644 --- a/test/js/bun/http/serve-direct-readable-stream.test.ts +++ b/test/js/bun/http/serve-direct-readable-stream.test.ts @@ -12,7 +12,7 @@ import { } from "../../web/streams/direct-stream-contract"; import { baseHeaders, frame, RawH2, T } from "./serve-http2-helpers"; -test("HTTPResponseSink controller after the response ended: write() reports 0 bytes; a foreign this still throws", async () => { +test("HTTPResponseSink displays correct message", async () => { let leakedCtrl: any; using server = Bun.serve({ port: 0, @@ -33,7 +33,9 @@ test("HTTPResponseSink controller after the response ended: write() reports 0 by }); let response = await fetch(server.url); expect(await response.text()).toBe("ab"); - expect(leakedCtrl.write("c")).toBe(0); + expect(() => leakedCtrl.write("c")).toThrow( + 'This HTTPResponseSink has already been closed. A "direct" ReadableStream terminates its underlying socket once `async pull()` returns.', + ); expect(() => leakedCtrl.write.call({}, "c")).toThrow("Expected HTTPResponseSink"); }); @@ -742,20 +744,17 @@ test.skipIf(!isASAN)( // render_metadata wrote its status/headers into the in-flight body. Debug // builds hit the !has_written_status assert in do_write_status and aborted; // release builds spliced the error() header block into the chunked body. -// error() is still told about the failure (so it can be logged), but its Response is discarded and nothing is printed. describe("sync pull() throw after status is written does not re-render error()", () => { function fixture(pullBody: string) { return ` const net = require("node:net"); let errorHandlerCalls = 0; - const errors = []; const server = Bun.serve({ port: 0, hostname: "127.0.0.1", development: false, - error(err) { + error() { errorHandlerCalls++; - errors.push(String(err?.message)); return new Response("FROM-ERROR-HANDLER", { status: 500, headers: { "x-err": "1" } }); }, fetch() { @@ -775,7 +774,7 @@ describe("sync pull() throw after status is written does not re-render error()", s.on("error", () => resolve(buf)); }); server.stop(true); - console.log(JSON.stringify({ wire, errorHandlerCalls, errors })); + console.log(JSON.stringify({ wire, errorHandlerCalls })); `; } @@ -787,18 +786,15 @@ describe("sync pull() throw after status is written does not re-render error()", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const { wire, errorHandlerCalls, errors } = JSON.parse(stdout); + expect(stderr).toContain("error: boom"); + const { wire, errorHandlerCalls } = JSON.parse(stdout); // error() cannot replace a response whose status is committed; the // connection is force-closed so the client observes failure instead of // the error() header block spliced where a chunk-size line belongs. expect(wire).not.toContain("x-err"); expect(wire).not.toContain("FROM-ERROR-HANDLER"); expect(wire).not.toContain("Something went wrong"); - expect({ errorHandlerCalls, errors, stderr: stderr.trim() }).toEqual({ - errorHandlerCalls: 1, - errors: ["boom"], - stderr: "", - }); + expect(errorHandlerCalls).toBe(0); expect(exitCode).toBe(0); }); @@ -810,18 +806,15 @@ describe("sync pull() throw after status is written does not re-render error()", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const { wire, errorHandlerCalls, errors } = JSON.parse(stdout); + expect(stderr).toContain("error: boom"); + const { wire, errorHandlerCalls } = JSON.parse(stdout); // Status 200 was already written to the corked response, so error() cannot // replace it. Ending the stream here would send that 200 with an empty // chunked body and a clean terminator: a complete-looking response for a // body that failed. The connection is closed instead, and since the status // never left the cork buffer the client sees an empty reply. expect(wire).toBe(""); - expect({ errorHandlerCalls, errors, stderr: stderr.trim() }).toEqual({ - errorHandlerCalls: 1, - errors: ["boom"], - stderr: "", - }); + expect(errorHandlerCalls).toBe(0); expect(exitCode).toBe(0); }); }); @@ -1788,52 +1781,6 @@ describe("direct stream contract", () => { }); }); -// A body that fails after the status line is committed cannot be turned into an error response, but the -// server's error() handler must still hear about it (for logging) instead of the error going straight to stderr. -test("a body error after headers are sent is delivered to error() and not printed", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` - const seen = []; - const later = () => new Promise(r => setImmediate(r)); - for (const how of ["close", "reject"]) { - const server = Bun.serve({ - port: 0, - development: false, - error(err) { seen.push(how + ":" + err.message); return new Response("unused", { status: 500 }); }, - fetch: () => new Response(new ReadableStream({ - type: "direct", - async pull(c) { - c.write("partial"); - await c.flush(); - await later(); - if (how === "close") c.close(new Error("body failed")); - else throw new Error("body failed"); - }, - })), - }); - const res = await fetch(server.url); - await res.text().catch(() => {}); - await later(); - server.stop(true); - } - console.log(JSON.stringify(seen)); - `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ seen: JSON.parse(stdout.trim()), stderr: stderr.trim() }).toEqual({ - seen: ["close:body failed", "reject:body failed"], - stderr: "", - }); - expect(exitCode).toBe(0); -}); - describe("direct stream edge cases over Bun.serve", () => { const later = () => new Promise(r => setImmediate(r)); const dec = new TextDecoder(); diff --git a/test/js/bun/http/serve-stream-body-error.test.ts b/test/js/bun/http/serve-stream-body-error.test.ts index 03791e29f262..80177caa5f09 100644 --- a/test/js/bun/http/serve-stream-body-error.test.ts +++ b/test/js/bun/http/serve-stream-body-error.test.ts @@ -35,11 +35,10 @@ async function runFixture(variant: string, ...extra: string[]) { // A stream source that errors before producing any body bytes. The status and // headers were already handed to uWS (corked) when the error arrives, so the -// server `error()` callback cannot supply a replacement: it is still invoked so -// the failure can be logged, and its Response is discarded. The connection is -// closed before anything reaches the wire: the client sees an empty reply, -// never a complete 200 with an empty body. The rejection does not reach the -// unhandledRejection reporter. +// server `error()` callback cannot supply a replacement and is NOT invoked. +// The connection is closed before anything reaches the wire: the client sees +// an empty reply, never a complete 200 with an empty body. The rejection does +// not reach the unhandledRejection reporter; it is reported directly instead. test.concurrent.each([ "pull-throw", "pull-async-reject", @@ -52,11 +51,13 @@ test.concurrent.each([ statusLine: "", cleanChunkedTerminator: false, body: "", - errorCb: 1, + errorCb: 0, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }); - // error() was told (errorCb); with a handler installed nothing is printed. + // With `development: false` the unhandledRejection report used to be the + // only place the error surfaced; it must still reach stderr without it. + expect(stderr).toContain("boom"); expect(exitCode).toBe(0); }); @@ -67,11 +68,11 @@ test.concurrent("pull-throw in development mode: the connection is closed withou statusLine: "", cleanChunkedTerminator: false, body: "", - errorCb: 1, + errorCb: 0, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }); - // error() was told (errorCb); with a handler installed nothing is printed. + expect(stderr).toContain("boom"); expect(exitCode).toBe(0); }); @@ -88,7 +89,7 @@ test.concurrent("pending-error-after-headers: headers go out, the body is not te statusLine: "HTTP/1.1 200 OK", cleanChunkedTerminator: false, body: "", - errorCb: 1, + errorCb: 0, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }); @@ -123,13 +124,13 @@ for (const flags of [[], ["development"]]) { statusLine: "HTTP/1.1 200 OK", cleanChunkedTerminator: false, body, - errorCb: 1, + errorCb: 0, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }, exitCode: 0, }); - // error() was told (errorCb); with a handler installed nothing is printed. + expect(stderr).toContain(reported); }, ); } @@ -158,6 +159,7 @@ test.concurrent.each([ expect(body).toStartWith(firstChunk); expect(body).toContain("Stream error during server-side rendering"); expect(body).toContain("boom"); + expect(stderr).toContain("boom"); }); // The body errors after a chunk has already been flushed to the client. The @@ -178,7 +180,7 @@ test.concurrent("mid-stream error: the chunked body is not terminated as complet statusLine: "HTTP/1.1 200 OK", cleanChunkedTerminator: false, body: "7\r\nchunk-a\r\n", - errorCb: 1, + errorCb: 0, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }, @@ -197,13 +199,13 @@ test.concurrent("mid-stream error in development mode: reported and not terminat statusLine: "HTTP/1.1 200 OK", cleanChunkedTerminator: false, body: "7\r\nchunk-a\r\n", - errorCb: 1, + errorCb: 0, unhandled: 0, secondStatusLine: "HTTP/1.1 200 OK", }, exitCode: 0, }); - // error() was told (errorCb); with a handler installed nothing is printed. + expect(stderr).toContain("boom"); }); // The client aborts the download mid-stream, which makes Bun cancel the body @@ -409,13 +411,8 @@ test.skipIf(!isASAN)( // The stream errors after "EB" is on the wire, so the body is force-closed // without the terminating 0\r\n\r\n chunk (RFC 9112 section 7). const expected = Array(6).fill({ status: "HTTP/1.1 597 HM", terminated: false }); - // error() already ran for this request, so its own body's failure is printed (once per request) rather than swallowed. - expect({ - nestedReports: stderr.match(/^error: nested$/gm)?.length ?? 0, - results: stdout.trim() ? JSON.parse(stdout) : stdout, - exitCode, - }).toEqual({ - nestedReports: 6, + expect({ stderr, results: stdout.trim() ? JSON.parse(stdout) : stdout, exitCode }).toEqual({ + stderr: "", results: expected, exitCode: 0, }); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 19721da698a3..e4c5aef97553 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -623,11 +623,10 @@ it.each([ describe("streaming", () => { describe("error handler", () => { // The body source fails before any byte is written. The Response's status - // and headers are already committed to uWS, so error() cannot replace them: - // it is told (so the failure can be logged) but its Response is discarded, - // and the connection is closed without a complete response so the client - // cannot mistake the failed body for an empty one. - it("throw on pull closes the connection; error() is told but cannot answer", async () => { + // and headers are already committed to uWS, so error() cannot replace them + // and is not called; the connection is closed without a complete response + // so the client cannot mistake the failed body for an empty one. + it("throw on pull closes the connection, does not call error handler", async () => { let outcome: string | undefined; const onMessage = mock(async url => { outcome = await fetch(url).then( @@ -649,18 +648,15 @@ describe("streaming", () => { let [exitCode, stderr] = await Promise.all([subprocess.exited, subprocess.stderr.text()]); expect(exitCode).toBeInteger(); expect(outcome).toBe("rejected ECONNRESET"); - // error() got the failure (and logged); its 555 Response never reached the client, and nothing else was printed. - expect({ - handlerCalled: stderr.includes("error handler called"), - printed: stderr.includes("error: Oops"), - }).toEqual({ handlerCalled: true, printed: false }); + expect(stderr).toContain("error: Oops"); + expect(stderr).not.toContain("error handler called"); expect(onMessage).toHaveBeenCalled(); }); // pull() queues two chunks, requests close, then throws: per the streams // spec the error wins and the queued chunks are discarded, so this is the // same "failed before any byte" case as above. - it("throw on pull after writing closes the connection; error() is told but cannot answer", async () => { + it("throw on pull after writing closes the connection, does not call the error handler", async () => { let outcome: string | undefined; const onMessage = mock(async href => { const url = new URL("write", href); @@ -683,11 +679,8 @@ describe("streaming", () => { let [exitCode, stderr] = await Promise.all([subprocess.exited, subprocess.stderr.text()]); expect(exitCode).toBeInteger(); expect(outcome).toBe("rejected ECONNRESET"); - // error() got the failure (and logged); its 555 Response never reached the client, and nothing else was printed. - expect({ - handlerCalled: stderr.includes("error handler called"), - printed: stderr.includes("error: Oops"), - }).toEqual({ handlerCalled: true, printed: false }); + expect(stderr).toContain("error: Oops"); + expect(stderr).not.toContain("error handler called"); expect(onMessage).toHaveBeenCalled(); }); From 819d83fd81164f1d4d18e2cdb32a50f6d04f4c37 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 19:38:46 -0700 Subject: [PATCH 26/31] Direct-stream controller: write()/flush() return 0 only when the destination went away, and still throw after the source closed itself; share the pool-release block between destroy() and finalize() --- docs/runtime/streams.mdx | 2 +- packages/bun-types/globals.d.ts | 4 +-- src/codegen/generate-jssink.ts | 4 ++- src/runtime/webcore/Sink.rs | 11 +++--- src/runtime/webcore/streams.rs | 55 ++++++++++++----------------- test/js/bun/io/bun-write.test.js | 14 ++++---- test/js/web/streams/streams.test.js | 8 ++--- 7 files changed, 44 insertions(+), 54 deletions(-) diff --git a/docs/runtime/streams.mdx b/docs/runtime/streams.mdx index 886b89be67ea..b5103c1afdff 100644 --- a/docs/runtime/streams.mdx +++ b/docs/runtime/streams.mdx @@ -79,7 +79,7 @@ Call `controller.close()` (or `controller.end()`) once everything is written. Ev ### Handling backpressure -`controller.write()` returns the number of bytes written, or a **pending `Promise`** when the destination's internal buffer is full (for example, a slow HTTP client). The chunk is accepted either way. Once the stream is closed, or the destination has gone away (for example the HTTP client disconnected), `write()` returns `0`. The promise resolves once the destination has drained, so `await`ing the result is enough: +`controller.write()` returns the number of bytes written, or a **pending `Promise`** when the destination's internal buffer is full (for example, a slow HTTP client). The chunk is accepted either way. Once the destination has gone away (for example the HTTP client disconnected), `write()` returns `0`. The promise resolves once the destination has drained, so `await`ing the result is enough: ```ts const stream = new ReadableStream({ diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index 21df6873c9c1..84ea0279a506 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -747,8 +747,8 @@ interface ReadableStreamDirectController { * The promise resolves once the destination has drained. * `await controller.flush(true)` is equivalent. * - * Returns `0` once the stream is closed or the destination has gone away - * (for example, the HTTP client disconnected). + * Returns `0` once the destination has gone away (for example, the HTTP + * client disconnected). */ write(data: Bun.BufferSource | ArrayBuffer | string): number | Promise; end(): number | Promise; diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index 1f7a3d911d26..9cf546ccb322 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -230,6 +230,7 @@ public: mutable WriteBarrier m_failReason; // close(error)'s error, so the owner's promise rejects even though pull() itself resolved mutable JSC::Weak m_weakReadableStream; uintptr_t m_onDestroy { 0 }; + bool m_destinationClosed { false }; // the sink closed underneath the source (peer abort, write error): write()/flush() report 0 instead of throwing protected: JSReadableSinkControllerBase(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr, SinkID sinkId, uintptr_t onDestroy) @@ -952,7 +953,7 @@ extern "C" void* ${name}__fromJS(JSC::EncodedJSValue value) return sink->wrapped(); if (auto* controller = dynamicDowncast(JSC::JSValue::decode(value))) - return controller->wrapped() ? controller->wrapped() : (void*)2; + return controller->wrapped() ? controller->wrapped() : (controller->m_destinationClosed ? (void*)2 : nullptr); return (void*)1; } @@ -1013,6 +1014,7 @@ extern "C" void JSSinkController__onReady(JSC::EncodedJSValue controllerValue, J extern "C" void JSSinkController__onClose(JSC::EncodedJSValue controllerValue, JSC::EncodedJSValue reason) { auto* controller = static_cast(JSC::JSValue::decode(controllerValue).getObject()); + controller->m_destinationClosed = true; if (controller->m_sourceKind == WebCore::SinkSource::None) return; Bun::WebStreams::sinkControllerOnClose(controller->globalObject(), controller, JSC::JSValue::decode(reason), /* sinkClosed */ true); diff --git a/src/runtime/webcore/Sink.rs b/src/runtime/webcore/Sink.rs index 50b0884ab149..bd9246be0d18 100644 --- a/src/runtime/webcore/Sink.rs +++ b/src/runtime/webcore/Sink.rs @@ -191,7 +191,7 @@ pub(crate) mod from_js_result { pub(crate) const DETACHED: usize = 0; /// JS exception has not yet been thrown. pub(crate) const CAST_FAILED: usize = 1; - /// A direct-stream controller whose sink is gone (the source closed it, or the destination went away). + /// A direct-stream controller whose destination went away (peer abort, write error) before the source closed it. pub(crate) const CONTROLLER_DETACHED: usize = 2; } @@ -405,10 +405,11 @@ impl JSSink { ) -> crate::webcore::jsc::JsResult>> { let raw = T::from_js_extern(frame.this()); match raw { - from_js_result::DETACHED => { - Err(global.throw(format_args!("This {} has already been closed.", T::NAME,))) - } - // A direct stream's controller after close() or after the destination went away: write()/flush()/end() report 0 bytes, like the JS reader path. + from_js_result::DETACHED => Err(global.throw(format_args!( + "This {} has already been closed. A \"direct\" ReadableStream terminates its underlying socket once `async pull()` returns.", + T::NAME, + ))), + // The destination went away under a direct stream's controller: write()/flush()/end() report 0 bytes so the source can stop. from_js_result::CONTROLLER_DETACHED => Ok(None), from_js_result::CAST_FAILED => Err(bun_jsc::ErrorCode::INVALID_THIS .throw(global, format_args!("Expected {}", T::NAME))), diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index bc1ebc8f18a6..840113cac1f8 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -1962,23 +1962,30 @@ impl HTTPServerWritable { JSPromise::opaque_ref(prom).to_js().unprotect(); } // A sink torn down without finalize() (assignToStream failed synchronously) still holds its pool checkout. - if let Some(pooled) = this.pooled_buffer.take() { - this.buffer.clear(); - if this.buffer.capacity() > 64 * 1024 { - this.buffer.clear_and_free(); - } - // SAFETY: `pooled` is this sink's exclusive checkout from `ByteListPool::get_if_exists`; hand the buffer back with it. - unsafe { - (*pooled.as_ptr()).data = - core::mem::MaybeUninit::new(core::mem::take(&mut this.buffer)); - ByteListPool::release(pooled.as_ptr()); - } - } + this.release_pooled_buffer(); this.buffer.clear_and_free(); this.unregister_auto_flusher(); drop(this); } + /// Hand the pool checkout back with the (emptied) buffer; `false` when nothing was checked out. + fn release_pooled_buffer(&mut self) -> bool { + let Some(pooled) = self.pooled_buffer.take() else { + return false; + }; + self.buffer.clear(); + if self.buffer.capacity() > 64 * 1024 { + self.buffer.clear_and_free(); + } + // SAFETY: `pooled` is this sink's exclusive `ByteListPool` checkout; `data` is rewritten before the release. + unsafe { + (*pooled.as_ptr()).data = + core::mem::MaybeUninit::new(core::mem::take(&mut self.buffer)); + ByteListPool::release(pooled.as_ptr()); + } + true + } + /// This can be called _many_ times for the same instance /// so it must zero out state instead of make it pub fn finalize(&mut self) { @@ -2014,26 +2021,10 @@ impl HTTPServerWritable { debug_assert!(self.pooled_buffer.is_none()); } - if let Some(pooled) = self.pooled_buffer { - self.buffer.clear(); - if self.buffer.capacity() > 64 * 1024 { - self.buffer.clear_and_free(); - } - // SAFETY: pooled is a valid pool node checkout - unsafe { - (*pooled.as_ptr()).data = - core::mem::MaybeUninit::new(core::mem::take(&mut self.buffer)); - } - - self.buffer = Vec::::default(); - self.pooled_buffer = None; - // SAFETY: `pooled` was obtained from `ByteListPool::get_node` and is - // exclusively owned by this stream; `data` was rewritten just above, - // so it is initialized. Ownership returns to the pool. - unsafe { ByteListPool::release(pooled.as_ptr()) }; - } else if self.buffer.capacity() == 0 { - // - } else if FeatureFlags::HTTP_BUFFER_POOLING && !ByteListPool::full() { + if self.release_pooled_buffer() || self.buffer.capacity() == 0 { + return; + } + if FeatureFlags::HTTP_BUFFER_POOLING && !ByteListPool::full() { let buffer = core::mem::take(&mut self.buffer); ByteListPool::push(buffer); } else { diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 31dd40c534ec..f50c3eda012b 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1098,8 +1098,8 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { written: 12, onDisk: "hello wörld", late: { - write: "0", - flush: "0", + write: "This FileSink has already been closed", + flush: "This FileSink has already been closed", end: "undefined", }, leakedSinks: 0, @@ -1191,7 +1191,7 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout.trim()).toBe("gen:boom,direct:boom,late:0"); + expect(stdout.trim()).toBe("gen:boom,direct:boom,late:threw"); expect(exitCode).toBe(0); }); @@ -1218,11 +1218,9 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(await Bun.write(dest, new Response(stream))).toBe(12); expect(await Bun.file(dest).text()).toBe("first second"); expect(cancelled).toStrictEqual([]); - expect({ write: ctrl.write("late event"), flush: ctrl.flush(), end: ctrl.end() }).toEqual({ - write: 0, - flush: 0, - end: undefined, - }); + expect(() => ctrl.write("late event")).toThrow("This FileSink has already been closed"); + expect(() => ctrl.flush()).toThrow("This FileSink has already been closed"); + expect(ctrl.end()).toBeUndefined(); Bun.gc(true); }); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index b725cc670ca7..bee1518c08a5 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -1291,9 +1291,7 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { }); }); - // A type:"direct" pull() is re-invoked per read as a demand signal, but never while a - // previous async pull() is still pending and never after end(). A pull that writes the - // whole body and ends therefore runs exactly once. + // A type:"direct" pull() runs once: later reads drain what it has written since. describe("a direct stream's async pull() is not re-entered while pending", () => { const N = 30000; const CS = 4096; @@ -3940,7 +3938,7 @@ describe("direct stream edge cases", () => { }); }); - test("write()/flush()/end() after close() report 0 bytes on every path instead of throwing", async () => { + test("write()/flush() after close(): the reader path reports 0 bytes, a native sink throws", async () => { const after = {}; const mk = key => direct(tally(), async c => { @@ -3964,7 +3962,7 @@ describe("direct stream edge cases", () => { await later(); expect(after).toEqual({ reader: { write: 0, flush: undefined, end: undefined }, - fileSink: { write: 0, flush: 0, end: undefined }, + fileSink: { write: "throws", flush: "throws", end: undefined }, }); }); }); From 1b4835988e123711da44dd8f62201d3988917a5d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 19:52:04 -0700 Subject: [PATCH 27/31] Direct streams: a reader keeps calling pull() on demand; only whole-body consumers end the stream when an async pull() resolves --- docs/runtime/streams.mdx | 4 +- packages/bun-types/bun.d.ts | 16 +- .../webcore/streams/BunStreamConsumers.cpp | 2 + .../streams/JSDirectStreamController.cpp | 90 ++++++++- .../streams/JSDirectStreamController.h | 8 +- test/js/web/streams/direct-stream-contract.ts | 20 +- test/js/web/streams/streams.test.js | 176 ++++++++---------- 7 files changed, 198 insertions(+), 118 deletions(-) diff --git a/docs/runtime/streams.mdx b/docs/runtime/streams.mdx index b5103c1afdff..a937e1dbfa2d 100644 --- a/docs/runtime/streams.mdx +++ b/docs/runtime/streams.mdx @@ -75,7 +75,9 @@ When using a direct `ReadableStream`, the destination handles all chunk queueing Call `controller.close()` (or `controller.end()`) once everything is written. Every destination flushes what it has buffered and finishes at that point. `controller.close(error)` fails the stream instead: nothing more is flushed and the consumer rejects with `error`. -`pull()` is called once, by whatever consumes the stream (`Bun.serve`, `fetch`, `Bun.write`, `Bun.spawn` stdin, `.text()`, a reader, `pipeTo()`, and so on). If it returns a promise, the stream stays open while that promise is pending and ends when it resolves, as if `pull()` had called `controller.close()` last; if it rejects, the stream errors with that reason. A `pull()` that returns without a promise and without closing keeps the stream open until something calls `controller.close()`, so you can hold on to the controller and write from events. +A destination that takes the whole body (`Bun.serve`, `fetch`, `Bun.write`, `Bun.spawn` stdin, `.text()`, and so on) calls `pull()` once. If it returns a promise, the stream stays open while that promise is pending and ends when it resolves, as if `pull()` had called `controller.close()` last; if it rejects, the stream errors with that reason. A `pull()` that returns without a promise and without closing keeps the stream open until something calls `controller.close()`, so you can hold on to the controller and write from events. + +When the stream is read through a reader (`getReader()`, `for await`, `pipeTo()`, `tee()`), `pull()` is a demand signal instead: it is called again for a later read, never while a previous call's promise is still pending, until `controller.close()`. ### Handling backpressure diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 82d77ebc36f5..c5e688823288 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -580,13 +580,17 @@ declare module "bun" { interface DirectUnderlyingSource { cancel?: UnderlyingSourceCancelCallback; /** - * Called once. Write the stream's data with `controller.write()` (await - * it for backpressure) and finish with `controller.close()`. + * Write the stream's data with `controller.write()` (await it for + * backpressure) and finish with `controller.close()`. * - * If `pull()` returns a promise, the stream stays open while it is - * pending, ends when it resolves, and errors if it rejects. If it returns - * synchronously without closing, the stream stays open until - * `controller.close()` is called. + * A destination that takes the whole body (`Bun.serve`, `Bun.write`, + * `.text()`, ...) calls `pull()` once. If it returns a promise, the stream + * stays open while it is pending, ends when it resolves, and errors if it + * rejects. If it returns synchronously without closing, the stream stays + * open until `controller.close()` is called. + * + * A reader (`getReader()`, `for await`, `pipeTo()`) calls `pull()` again + * for a later read, once the previous call has settled. */ pull: (controller: ReadableStreamDirectController) => void | PromiseLike; type: "direct"; diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index 71ea35c2443b..f2f524406ae6 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -944,6 +944,8 @@ static JSValue consumeDirectStreamBody(JSC::VM& vm, JSGlobalObject* globalObject auto scope = DECLARE_THROW_SCOPE(vm); setUpDirectStreamController(globalObject, stream, kind); RETURN_IF_EXCEPTION(scope, {}); + if (auto* controller = dynamicDowncast(stream->m_controller.get())) + controller->m_closeOnPullSettled = true; stream->materializeIfNeeded(globalObject); RETURN_IF_EXCEPTION(scope, {}); auto* reader = acquireReadableStreamDefaultReader(globalObject, stream); diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index 3e42af7f9de7..383d83af2f01 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -767,9 +767,10 @@ JSValue JSDirectStreamController::onPull(JSGlobalObject* globalObject, bool read int8_t deferredClose = 0; int8_t deferredFlush = 0; - // pull() runs once. Later reads register a consumer and drain what the source has written since. - if (!m_pulled) { - m_pulled = true; + // Serialize pull(): while an async pull's promise is pending, subsequent reads install + // m_pendingRead for it to deliver into via flush()/end(); its fulfillment reaction + // clears m_pullInFlight and re-pulls if a consumer is still waiting. + if (!m_pullInFlight) { m_deferClose = -1; m_deferFlush = -1; @@ -792,6 +793,10 @@ JSValue JSDirectStreamController::onPull(JSGlobalObject* globalObject, bool read RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, abrupt)); } } else { + // A new read arrived while an async pull is pending: the fulfillment reaction will + // re-pull. Drain anything that pull already wrote; onFlush is a no-op-restore on an + // empty sink. + m_pullAgain = true; deferredFlush = 1; } @@ -966,6 +971,10 @@ void JSDirectStreamController::onFlush(JSGlobalObject* globalObject) m_pendingRead.set(vm, this, uncheckedDowncast(readRequest->context())); } } + // The spec's enqueue → CallPullIfNeeded equivalent: re-arm when this delivery + // still leaves a consumer waiting behind the in-flight pull. + if (m_pullInFlight && (m_pendingRead || readableStreamGetNumReadRequests(stream) > 0)) + m_pullAgain = true; JSObject* result = createIteratorResultObject(globalObject, flushed, false); RETURN_IF_EXCEPTION(scope, ); RELEASE_AND_RETURN(scope, pendingRead->fulfill(vm, result)); @@ -978,6 +987,8 @@ void JSDirectStreamController::onFlush(JSGlobalObject* globalObject) JSValue flushed = flushDirectSink(vm, globalObject, this); RETURN_IF_EXCEPTION(scope, ); if (byteLengthOf(flushed)) { + if (m_pullInFlight && readableStreamGetNumReadRequests(stream) > 1) + m_pullAgain = true; RELEASE_AND_RETURN(scope, readableStreamFulfillReadRequest(globalObject, stream, flushed, false)); } return; @@ -987,7 +998,22 @@ void JSDirectStreamController::onFlush(JSGlobalObject* globalObject) m_deferFlush = 1; } -// The user pull()'s returned promise settled ([reaction-convention]): drain what it wrote, then close. A rejection errors the stream (onDirectPullRejected). +static bool takeDirectPullAgain(JSDirectStreamController* controller) +{ + bool pullAgain = controller->m_pullAgain; + controller->m_pullAgain = false; + return pullAgain; +} + +static bool directControllerHasWaitingConsumer(JSDirectStreamController* controller, JSReadableStream* stream) +{ + return controller->m_pendingRead || (stream && readableStreamGetNumReadRequests(stream) > 0); +} + +// Settlement reactions of the user pull()'s returned promise ([reaction-convention]). +// The pull promise's fulfilment reaction (enterStreams): drain, then re-pull while a consumer is +// waiting. Whatever throws in here (a chunkSteps callback, a deferred close, the source's hooks) +// errors the stream. static void directPullFulfilled(JSC::VM& vm, JSGlobalObject* globalObject, JSDirectStreamController* controller) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -995,15 +1021,63 @@ static void directPullFulfilled(JSC::VM& vm, JSGlobalObject* globalObject, JSDir auto* stream = controller->m_stream.get(); if (controller->m_closed || !stream || stream->m_state != ReadableStreamState::Readable) { controller->m_pullInFlight = false; + controller->m_pullAgain = false; return; } + // Drain anything this pull wrote while no reader was waiting. m_pullInFlight stays set + // so onFlush's delivery-branch re-arm fires for a pull that wrote without c.flush(). controller->onFlush(globalObject); controller->m_pullInFlight = false; RETURN_IF_EXCEPTION(scope, ); - stream = controller->m_stream.get(); - if (controller->m_closed || !stream || stream->m_state != ReadableStreamState::Readable) - return; - RELEASE_AND_RETURN(scope, controller->onClose(globalObject, jsUndefined())); + if (controller->m_closeOnPullSettled) { + controller->m_pullAgain = false; + stream = controller->m_stream.get(); + if (!controller->m_closed && stream && stream->m_state == ReadableStreamState::Readable) + controller->onClose(globalObject, jsUndefined()); + RELEASE_AND_RETURN(scope, ); + } + bool pullAgain = takeDirectPullAgain(controller); + // Edge-triggered (m_pullAgain) AND level-checked (a consumer is waiting), the spec's + // ShouldCallPull equivalent; loop so a synchronous re-pull chains to the next consumer. + while (pullAgain && !controller->m_closed && !controller->m_pullInFlight + && directControllerHasWaitingConsumer(controller, controller->m_stream.get())) { + controller->m_deferClose = -1; + controller->m_deferFlush = -1; + JSValue abrupt = callDirectPull(vm, globalObject, controller); + int8_t deferredClose = controller->m_deferClose; + int8_t deferredFlush = controller->m_deferFlush; + controller->m_deferClose = 0; + controller->m_deferFlush = 0; + RETURN_IF_EXCEPTION(scope, ); + if (!abrupt.isEmpty()) { + controller->handleError(globalObject, abrupt); + RELEASE_AND_RETURN(scope, ); + } + if (deferredClose == 1) { + JSValue reason = controller->m_deferCloseReason.get(); + controller->m_deferCloseReason.clear(); + controller->onClose(globalObject, reason); + RETURN_IF_EXCEPTION(scope, ); + } else { + // An async re-pull left m_pullInFlight set: its own fulfillment reaction drains + // and picks up m_pullAgain. What its synchronous part already wrote goes to the + // waiting reader now, as in onPull: no end-of-tick job was queued for it, and the + // pull may be parked on that very write. + if (controller->m_pullInFlight) { + if (deferredFlush == 1 || !controller->m_buffer.isEmpty()) + controller->onFlush(globalObject); + RETURN_IF_EXCEPTION(scope, ); + break; + } + // Sync re-pull: drain with m_pullInFlight bracketed so onFlush's delivery-branch + // re-arm fires regardless of whether the pull called c.flush() itself. + controller->m_pullInFlight = true; + controller->onFlush(globalObject); + controller->m_pullInFlight = false; + RETURN_IF_EXCEPTION(scope, ); + } + pullAgain = takeDirectPullAgain(controller); + } } JSC_DEFINE_HOST_FUNCTION(jsWebStreamsHandler_onDirectPullFulfilled, (JSGlobalObject * globalObject, CallFrame* callFrame)) diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.h b/src/jsc/bindings/webcore/streams/JSDirectStreamController.h index cbe5a1fd0eb7..19c570367aa8 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.h +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.h @@ -105,15 +105,19 @@ class JSDirectStreamController final : public JSC::JSDestructibleObject { // Once closed, the five methods are no-ops (there is NO "swap all 5 methods to a // throwing stub" trick). bool m_closed : 1 { false }; - // pull() runs once: the first read calls it; if it returned a promise the stream closes when that settles, else it stays open until close()/end(). - bool m_pulled : 1 { false }; + // An async pull()'s returned promise has not yet settled; cleared by its settlement + // reactions. m_pullAgain is set only when a NEW read arrives while m_pullInFlight + // (edge-triggered, matching the spec default controller's [[pullAgain]]). bool m_pullInFlight : 1 { false }; + bool m_pullAgain : 1 { false }; bool m_calledDone : 1 { false }; // End-of-tick auto-flush (the JS-facing analogue of the HTTP sink's AutoFlusher): // armed by write() when data is buffered below the HWM while a consumer waits; the // process.nextTick job delivers it during the same microtask/nextTick drain. bool m_endOfTickFlushArmed : 1 { false }; bool m_finalChunkArmed : 1 { false }; + // A whole-body consumer (.text() etc.): an async pull() resolving without close()/end() closes. + bool m_closeOnPullSettled : 1 { false }; // ArrayBuffer sink: the bytes write() accepted since it armed m_pendingWrite (saturating). uint32_t m_pendingWriteLength { 0 }; // ArrayBuffer sink: m_buffer's capacity as last reported to the heap (reportExtraMemoryAllocated); diff --git a/test/js/web/streams/direct-stream-contract.ts b/test/js/web/streams/direct-stream-contract.ts index cd05648e438a..7a57576c59b9 100644 --- a/test/js/web/streams/direct-stream-contract.ts +++ b/test/js/web/streams/direct-stream-contract.ts @@ -1,10 +1,12 @@ -// The `type: "direct"` lifecycle contract: every consumer sees pull() once, the bytes written before the end signal, the same error, and cancel() only on abort. +// The `type: "direct"` lifecycle contract: every consumer sees the bytes written before the end signal, the same error, and cancel() only on abort. One-shot consumers call pull() once; a reader calls it again on demand. export type Tally = { pulls: number; cancels: unknown[] }; export type Shape = { /** What the consumer receives, or the error it rejects with. */ expect: { body: string } | { error: string }; + /** pull() returns or resolves without closing: a reader would call it again for the next read, so only one-shot consumers run this shape. */ + oneShotOnly?: true; make(t: Tally): ReadableStream; }; @@ -43,6 +45,7 @@ export const shapes: Record = { }), }, "async pull: write, await, write, resolve without close()": { + oneShotOnly: true, expect: { body: "hello world" }, make: t => direct(t, async c => { @@ -60,6 +63,7 @@ export const shapes: Record = { }), }, "sync pull keeps the controller; writes arrive later, then close()": { + oneShotOnly: true, expect: { body: "hello world" }, make: t => direct(t, c => { @@ -71,6 +75,7 @@ export const shapes: Record = { }), }, "async pull with no await (already-resolved promise)": { + oneShotOnly: true, expect: { body: "hello world" }, make: t => direct(t, async c => { @@ -79,6 +84,7 @@ export const shapes: Record = { }), }, "sync pull: close() from the write() promise's reaction": { + oneShotOnly: true, expect: { body: "hello world" }, make: t => direct(t, c => { @@ -86,10 +92,12 @@ export const shapes: Record = { }), }, "sync pull: close() from process.nextTick": { + oneShotOnly: true, expect: { body: "hello world" }, make: t => direct(t, c => (c.write("hello world"), void process.nextTick(() => c.close()))), }, "sync pull: close() from setTimeout(0)": { + oneShotOnly: true, expect: { body: "hello world" }, make: t => direct(t, c => (c.write("hello world"), void setTimeout(() => c.close(), 0))), }, @@ -118,6 +126,7 @@ export const shapes: Record = { }), }, "end(value) is a clean close, unlike close(error)": { + oneShotOnly: true, expect: { body: "hello world" }, make: t => direct(t, c => { @@ -227,6 +236,15 @@ export const consumers: Record Promise> = }, }; +/** Consumers that read through a reader: pull() is their demand signal. */ +export const readerConsumers = new Set([ + "getReader() loop", + "for await", + "pipeTo(WritableStream)", + "pipeThrough(identity TransformStream)", + "tee(): both branches", +]); + /** Runs one cell of the matrix and returns what a test should compare against `expected(shape)`. */ export async function observe(shape: Shape, consume: (s: ReadableStream) => Promise) { const t: Tally = { pulls: 0, cancels: [] }; diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index bee1518c08a5..19779058cd3a 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -14,6 +14,7 @@ import { join } from "node:path"; import { PassThrough, Readable, Writable, finished, pipeline } from "node:stream"; import { consumers as directConsumers, + readerConsumers as directReaderConsumers, expected as directExpected, observe as directObserve, shapes as directShapes, @@ -1291,7 +1292,9 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { }); }); - // A type:"direct" pull() runs once: later reads drain what it has written since. + // A type:"direct" pull() is re-invoked per read as a demand signal, but never while a + // previous async pull() is still pending and never after end(). A pull that writes the + // whole body and ends therefore runs exactly once. describe("a direct stream's async pull() is not re-entered while pending", () => { const N = 30000; const CS = 4096; @@ -1416,17 +1419,16 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { expect((await reader.read()).done).toBe(true); }); - it("pull() is called once; each flush() across awaits feeds the next read, and its promise resolving ends the stream", async () => { + it("a per-call pull() that writes one chunk and returns is re-invoked on each read", async () => { let pulls = 0; const rs = new ReadableStream({ type: "direct", async pull(c) { pulls++; - for (let i = 1; i <= 3; i++) { - await Promise.resolve(); - c.write(new Uint8Array([i])); - c.flush(); - } + if (pulls > 3) return c.end(); + await Promise.resolve(); + c.write(new Uint8Array([pulls])); + c.flush(); }, }); const reader = rs.getReader(); @@ -1436,30 +1438,28 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { if (done) break; for (const b of value) out.push(b); } - expect({ pulls, out }).toEqual({ pulls: 1, out: [1, 2, 3] }); + expect({ pulls, out }).toEqual({ pulls: 4, out: [1, 2, 3] }); }); - it("reads issued while pull() is suspended are serviced in order as it writes, without re-entering pull()", async () => { + it("reads issued while pull() is suspended are each serviced by a subsequent pull", async () => { let pulls = 0; const gates = []; - const gate = () => { - const { promise, resolve } = Promise.withResolvers(); - gates.push(resolve); - return promise; - }; const rs = new ReadableStream({ type: "direct", async pull(c) { pulls++; - for (let i = 1; i <= 3; i++) { - await gate(); - c.write(new Uint8Array([i])); - c.flush(); - } + if (pulls > 3) return c.end(); + const { promise, resolve } = Promise.withResolvers(); + gates.push(resolve); + await promise; + c.write(new Uint8Array([pulls])); + c.flush(); }, }); const reader = rs.getReader(); const p1 = reader.read(); + // These arrive while pull #1 is suspended: must NOT re-enter pull() concurrently, + // and each must be serviced by a subsequent pull once the previous one settles. const p2 = reader.read(); const p3 = reader.read(); expect(pulls).toBe(1); @@ -1472,65 +1472,67 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { gates.shift()(); expect((await p3).value[0]).toBe(3); expect((await reader.read()).done).toBe(true); - expect(pulls).toBe(1); }); - // Reads queued up front are all settled from one pull(), wherever its writes and flushes sit relative to its awaits. - const onePullShapes = { - "write without flush between awaits": async c => { - for (let n = 1; n <= 3; n++) { + // Three concurrent reads must each be serviced regardless of where the per-call + // producer's write/flush sits relative to its first await. + const perCallShapes = { + "write without flush": () => { + let n = 0; + return async c => { + n++; await Promise.resolve(); c.write(new Uint8Array([n])); - } + }; }, - "write and flush before each await": async c => { - for (let n = 1; n <= 3; n++) { + "write and flush before the first await": () => { + let n = 0; + return async c => { + n++; c.write(new Uint8Array([n])); c.flush(); await Promise.resolve(); - } + }; }, - "sync writes with flush, then close()": c => { - for (let n = 1; n <= 3; n++) { + "first call async, later calls sync": () => { + let n = 0; + return c => { + n++; + if (n === 1) + return Promise.resolve().then(() => { + c.write(new Uint8Array([1])); + c.flush(); + }); c.write(new Uint8Array([n])); c.flush(); - } - c.close(); + }; }, - "first write async, the rest sync without flush, then end()": async c => { - await Promise.resolve(); - c.write(new Uint8Array([1])); - c.flush(); - c.write(new Uint8Array([2])); - c.flush(); - c.write(new Uint8Array([3])); - c.end(); + "first call async, later calls sync without flush": () => { + let n = 0; + return c => { + n++; + if (n === 1) + return Promise.resolve().then(() => { + c.write(new Uint8Array([1])); + c.flush(); + }); + c.write(new Uint8Array([n])); + }; }, }; - it.each(Object.keys(onePullShapes))("queued reads are all settled by one pull (%s)", async shape => { - let pulls = 0; - const body = onePullShapes[shape]; - const rs = new ReadableStream({ - type: "direct", - pull(c) { - pulls++; - return body(c); - }, - }); - const reader = rs.getReader(); - const reads = [reader.read(), reader.read(), reader.read(), reader.read()]; - const out = []; - for (const r of await Promise.all(reads)) if (!r.done) out.push(...r.value); - while (true) { - const r = await reader.read(); - if (r.done) break; - out.push(...r.value); - } - expect({ pulls, out }).toEqual({ pulls: 1, out: [1, 2, 3] }); - }); + it.each(Object.keys(perCallShapes))( + "three concurrent reads are each serviced by a per-call pull (%s)", + async shape => { + const rs = new ReadableStream({ type: "direct", pull: perCallShapes[shape]() }); + const reader = rs.getReader(); + const reads = [reader.read(), reader.read(), reader.read()]; + const [r1, r2, r3] = await Promise.all(reads); + expect({ r1: r1.value[0], r2: r2.value[0], r3: r3.value[0] }).toEqual({ r1: 1, r2: 2, r3: 3 }); + }, + ); it("an async pull() that returns without writing is not re-invoked from its own fulfillment", async () => { - // A do-nothing pull must not be called again from its own fulfillment (no microtask livelock). + // Edge-triggered re-pull: a do-nothing pull must not livelock the microtask queue. let pulls = 0; const rs = new ReadableStream({ type: "direct", @@ -1543,7 +1545,7 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { expect(pulls).toBe(1); }); - it("a read whose demand was already satisfied does not call pull() again", async () => { + it("a read whose demand was already satisfied does not cause a spurious re-pull", async () => { let pulls = 0; const rs = new ReadableStream({ type: "direct", @@ -1558,6 +1560,8 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { const reader = rs.getReader(); await Promise.all([reader.read(), reader.read()]); await new Promise(resolve => setImmediate(resolve)); + // Both reads were satisfied by pull #1's two flushes; m_pullAgain set by the second + // read() must not trigger a demand-less re-pull. expect(pulls).toBe(1); }); }); @@ -1703,11 +1707,7 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { }); }); - // Every consumer (.text(), .bytes(), Bun.readableStreamTo*(), a native sink, a reader, pipeTo) - // calls a direct stream's pull() once. When that pull() returns a promise, the promise resolving - // without close()/end() is the end of the body; a sync pull() that returns without closing gives - // no completion signal, so it waits for close(). Before, whole-body consumers pulled once and then - // never settled, and readers pulled again per read. + // A whole-body consumer (.text(), .bytes(), Bun.readableStreamTo*(), a native sink) calls pull() once; a reader calls it again per read. describe("an async direct pull() that resolves without close() completes a whole-body consumer", () => { const nextTask = () => new Promise(resolve => setImmediate(resolve)); const mk = (hooks = {}) => @@ -1782,46 +1782,21 @@ describe("multi-chunk consumers produce exactly the concatenated bytes", () => { expect(await text).toBe("helloworld"); }); - it("a reader also calls pull() once and ends the stream when it resolves", async () => { + it("a reader still pulls again after each pull() resolves", async () => { let pulls = 0; const rs = new ReadableStream({ type: "direct", async pull(c) { pulls++; - c.write("a"); - await nextTask(); - c.write("b"); await nextTask(); - c.write("c"); + c.write("x"); + if (pulls === 3) c.close(); }, }); const chunks = []; for await (const chunk of rs) chunks.push(decode(chunk)); - expect(pulls).toBe(1); - expect(chunks.join("")).toBe("abc"); - }); - - it("a sync pull() keeps a reader open until close() and is not called again", async () => { - let pulls = 0; - let controller; - const rs = new ReadableStream({ - type: "direct", - pull(c) { - pulls++; - controller = c; - c.write("a"); - }, - }); - const reader = rs.getReader(); - expect(decode((await reader.read()).value)).toBe("a"); - const second = reader.read(); - await nextTask(); - controller.write("b"); - expect(decode((await second).value)).toBe("b"); - const done = reader.read(); - controller.close(); - expect((await done).done).toBe(true); - expect(pulls).toBe(1); + expect(pulls).toBe(3); + expect(chunks.join("")).toBe("xxx"); }); }); @@ -3799,8 +3774,9 @@ describe("direct stream contract", () => { // A child's stdin has nowhere to report the source's error: the child sees EOF. Only pulls/cancels are pinned there. const cannotSurfaceErrors = new Set(["Bun.spawn({ stdin: s })"]); describe.each(Object.keys(directShapes))("%s", shapeName => { - test.concurrent.each(Object.keys(consumers))("%s", async consumerName => { - const shape = directShapes[shapeName]; + const shape = directShapes[shapeName]; + const cells = Object.keys(consumers).filter(name => !(shape.oneShotOnly && directReaderConsumers.has(name))); + test.concurrent.each(cells)("%s", async consumerName => { const got = await directObserve(shape, consumers[consumerName]); if ("error" in shape.expect && cannotSurfaceErrors.has(consumerName)) { expect({ pulls: got.pulls, cancels: got.cancels }).toEqual({ pulls: 1, cancels: 0 }); From 11fe484eda3e40dc2d8c2b42b32c6a00c9ffdcd3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 20:32:06 -0700 Subject: [PATCH 28/31] Review follow-ups: a sync pull() that calls close(error) rejects the sink owner's promise; each falsy close() argument runs as the first close(); fix a stale bun-write comment --- .../webcore/streams/BunStreamSource.cpp | 3 ++ test/js/bun/io/bun-write.test.js | 2 +- test/js/web/streams/direct-stream-contract.ts | 34 ++++++++++++------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index 81fdda5c67bf..f27869391dea 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -759,6 +759,9 @@ JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, pullPromise->performPromiseThenWithContext(vm, globalObject, runtime->onReadDirectStreamPullFulfilled(), runtime->onReadDirectStreamPullRejected(), result, sinkController); return result; } + // A sync pull() that called close(error): the owner hears it the way it hears an async one. + if (JSValue failed = sinkController->m_failReason.get()) + RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, failed)); if (stream->m_state == ReadableStreamState::Readable) { auto* closePromise = JSPromise::create(vm, globalObject->promiseStructure()); sinkController->m_closePromise.set(vm, sinkController, closePromise); diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index f50c3eda012b..bf55e778d884 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -1195,7 +1195,7 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(exitCode).toBe(0); }); - // The implicit end closes the stream like controller.close(): cancel() does not run (nobody aborted), and the detached controller reports 0 bytes. + // The implicit end closes the stream like controller.close(): cancel() does not run (nobody aborted), and write()/flush() on the detached controller throw. it("ends a direct stream's sink when its pull() returns", async () => { using dir = tempDir("bun-write-direct-pull-returns", {}); const dest = join(String(dir), "out.txt"); diff --git a/test/js/web/streams/direct-stream-contract.ts b/test/js/web/streams/direct-stream-contract.ts index 7a57576c59b9..b9c462c6ed1a 100644 --- a/test/js/web/streams/direct-stream-contract.ts +++ b/test/js/web/streams/direct-stream-contract.ts @@ -134,18 +134,6 @@ export const shapes: Record = { Promise.resolve(new Error("not a failure")).then(v => (c.end as any)(v)); }), }, - "close(undefined), close(0) and close('') are clean closes": { - expect: { body: "hello world" }, - make: t => - direct(t, async c => { - c.write("hello "); - await later(); - c.write("world"); - (c.close as any)(undefined); - (c.close as any)(0); - (c.close as any)(""); - }), - }, "async pull: rejects after close() already ran": { expect: { body: "hello world" }, make: t => @@ -181,6 +169,10 @@ export const shapes: Record = { throw { code: "E_SOURCE" }; }), }, + "sync pull: write, then close(error)": { + expect: { error: "source failed" }, + make: t => direct(t, c => (c.write("partial"), c.close(new Error("source failed")))), + }, "close(error) after a write": { expect: { error: "source failed" }, make: t => @@ -192,6 +184,24 @@ export const shapes: Record = { }, }; +// Each falsy argument as the first close(): a clean close, not close(error). +for (const [label, arg] of [ + ["undefined", undefined], + ["0", 0], + ['""', ""], +] as const) { + shapes[`close(${label}) is a clean close`] = { + expect: { body: "hello world" }, + make: t => + direct(t, async c => { + c.write("hello "); + await later(); + c.write("world"); + (c.close as any)(arg); + }), + }; +} + const decoder = new TextDecoder(); const text = (v: unknown) => (typeof v === "string" ? v : decoder.decode(v as ArrayBufferView)); From 08391954d1c88ff87f069a7f8cbb68d70f471df1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:35:55 +0000 Subject: [PATCH 29/31] [autofix.ci] apply automated fixes --- test/js/web/streams/streams.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 19779058cd3a..9eb9bea20af5 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -14,9 +14,9 @@ import { join } from "node:path"; import { PassThrough, Readable, Writable, finished, pipeline } from "node:stream"; import { consumers as directConsumers, - readerConsumers as directReaderConsumers, expected as directExpected, observe as directObserve, + readerConsumers as directReaderConsumers, shapes as directShapes, } from "./direct-stream-contract"; From c671562b0e6b63701d012e49a0e6c6c0897dcb2b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 20:47:00 -0700 Subject: [PATCH 30/31] FileSink::pipe_stream: release the stream Strong when the pump fails synchronously --- src/runtime/webcore/FileSink.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index ca691524a02f..46d5320ee0a9 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -1680,6 +1680,7 @@ impl FileSink { let result = self.assign_to_js_stream(stream, global_this); if let Some(err) = result.to_error() { self.stream_done.set(bun_jsc::JSPromiseStrong::empty()); + self.readable_stream.set(readable_stream::Strong::default()); self.stream_bytes.set(None); return err; } From c70a379c8f599737900500cf53e65bb836fc54ce Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 8 Sep 2026 20:55:35 -0700 Subject: [PATCH 31/31] HTTPServerWritable::fail() drops the buffered tail instead of parking it for the auto-flusher; the promise a sync close(error) returns is marked handled --- .../webcore/streams/BunStreamSource.cpp | 9 +++++-- src/runtime/webcore/streams.rs | 24 +++++-------------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp index f27869391dea..a2f0719a8982 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamSource.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamSource.cpp @@ -760,8 +760,13 @@ JSValue readDirectStream(JSGlobalObject* globalObject, JSReadableStream* stream, return result; } // A sync pull() that called close(error): the owner hears it the way it hears an async one. - if (JSValue failed = sinkController->m_failReason.get()) - RELEASE_AND_RETURN(scope, promiseRejectedWith(globalObject, failed)); + if (JSValue failed = sinkController->m_failReason.get()) { + auto* rejected = promiseRejectedWith(globalObject, failed); + RETURN_IF_EXCEPTION(scope, {}); + // Every caller is a native sink owner that reads the promise's state instead of attaching a reaction. + markPromiseAsHandled(vm, rejected); + return rejected; + } if (stream->m_state == ReadableStreamState::Readable) { auto* closePromise = JSPromise::create(vm, globalObject->promiseStructure()); sinkController->m_closePromise.set(vm, sinkController, closePromise); diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index 840113cac1f8..86f65acc9f4c 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -1792,7 +1792,7 @@ impl HTTPServerWritable { self.end_from_js(&global_this).map(|_| ()) } - /// The source failed (`close(error)`, an errored pump): end the body unflushed so the owner can truncate it. + /// The source failed (`close(error)`, an errored pump): drop what is buffered and finish the sink; the owner closes the response as incomplete. pub(crate) fn fail(&mut self) { bun_core::scoped_log!(HTTPServerWritableLog, "fail()"); @@ -1800,22 +1800,11 @@ impl HTTPServerWritable { return; } - if self.is_done() || self.res.is_none() || self.any_res().unwrap().has_responded() { - self.source.close(None); - self.mark_done(); - self.finalize(); - return; - } - - self.requested_end = true; - let readable_len = self.readable_slice().len(); - self.end_len = readable_len; - - if readable_len == 0 { - self.source.close(None); - self.mark_done(); - self.finalize(); - } + self.buffer.clear(); + self.offset = 0; + self.source.close(None); + self.mark_done(); + self.finalize(); } pub(crate) fn end_from_js(&mut self, global_this: &JSGlobalObject) -> bun_sys::Result { @@ -2000,7 +1989,6 @@ impl HTTPServerWritable { // stream is freed after FIN, leave it dangling). res.clear_on_writable(); } - // Sends a tail parked by `fail()`; that `res.end()` sets `ended_response`. let _ = self.flush_no_wait(); self.set_done();