From 73868c575ea5b1b3ef958dd3eb08364e4fc9d9bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:45:47 +0000 Subject: [PATCH] Leave a body stream unsized when the file's st_size is 0 A procfs file is a regular file whose st_size is 0. The body getter, textStream() and WebAssembly streaming compilation resolved the size first, so the file reader got a limit of 0 bytes and the stream was empty. Add Blob::resolve_size_for_stream: it resolves the size as before and keeps the blob's window when the cached st_size is 0, so the reader runs to EOF. A file with a nonzero st_size keeps its stat size as the limit, and the reader still ends without an extra read(). --- src/runtime/webcore/Blob.rs | 14 +++++++++ src/runtime/webcore/Body.rs | 4 +-- src/runtime/webcore/wasm_streaming.rs | 2 +- test/js/web/fetch/body.test.ts | 37 ++++++++++++++++++++++-- test/js/web/fetch/wasm-streaming.test.ts | 10 ++++++- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index d985e71cef97..5298c6fc531f 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -246,6 +246,8 @@ pub(crate) trait BlobExt { fn stat(&self, cx: &mut JsScope<'_>) -> JsResult; fn get_size(&self) -> f64; fn resolve_size(&self); + /// `resolve_size`, except a file whose `st_size` is 0 (procfs) keeps its window: that size is not a read budget. + fn resolve_size_for_stream(&self); fn resolved_size(&self) -> (SizeType, SizeType); fn constructor( cx: &mut JsScope<'_>, @@ -2064,6 +2066,18 @@ impl BlobExt for Blob { } } + fn resolve_size_for_stream(&self) { + let (offset, size) = (self.offset.get(), self.size.get()); + self.resolve_size(); + if let Some(store) = self.store.get() + && let store::Data::File(file) = &store.data + && file.max_size == 0 + { + self.offset.set(offset); + self.size.set(size); + } + } + /// Non-mutating variant of [`resolve_size`]: returns the `(offset, size)` that /// `resolve_size` would assign without touching `self`. For callers /// (e.g. `ByteBlobLoader::setup`) that need the resolved size of a `Blob` diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 43c3d7f789c9..19a71299a215 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -790,7 +790,7 @@ impl Value { Value::InternalBlob(_) | Value::Blob(_) | Value::WTFStringImpl(_) => { // `deinit` must run on every exit incl. `?` paths. let blob = scopeguard::guard(self.use_(), |mut b| b.deinit()); - blob.resolve_size(); + blob.resolve_size_for_stream(); let blob_size = blob.size.get(); ReadableStream::from_blob_copy_ref(cx, &blob, blob_size)? } @@ -832,7 +832,7 @@ impl Value { Value::Blob(_) => { let stream = { let blob = scopeguard::guard(self.use_(), |mut b| b.deinit()); - blob.resolve_size(); + blob.resolve_size_for_stream(); if blob.needs_to_read_file() || blob.is_s3() { let blob_size = blob.size.get(); let bytes = ReadableStream::from_blob_copy_ref(cx, &blob, blob_size)?; diff --git a/src/runtime/webcore/wasm_streaming.rs b/src/runtime/webcore/wasm_streaming.rs index 5ad7829fab7d..068b350b27ad 100644 --- a/src/runtime/webcore/wasm_streaming.rs +++ b/src/runtime/webcore/wasm_streaming.rs @@ -138,7 +138,7 @@ fn get_body_stream_or_bytes_for_wasm_streaming( }; // `defer blob.detach()` — RAII via scopeguard. let blob = scopeguard::guard(blob, |b: Blob| b.detach()); - blob.resolve_size(); + blob.resolve_size_for_stream(); let size = blob.size.get(); return ReadableStream::from_blob_copy_ref(&this.js_thread(context), &blob, size); } diff --git a/test/js/web/fetch/body.test.ts b/test/js/web/fetch/body.test.ts index 10899d962eca..02facbc3700b 100644 --- a/test/js/web/fetch/body.test.ts +++ b/test/js/web/fetch/body.test.ts @@ -352,11 +352,33 @@ for (const { body, fn } of bodyTypes) { }); }); - // A procfs file is a regular file whose st_size is 0. The body getter stats the - // file, and that cached stat must not turn a later read of the Bun.file() into "". + // A procfs file is a regular file whose st_size is 0. A stream that trusts that size + // as a byte budget ends before it reads anything, and the stat the body getter + // caches must not turn a later read of the Bun.file() into "". describe.skipIf(!isLinux)("made from a procfs Bun.file()", () => { const path = "/proc/version"; + test("the body stream reads the whole file", async () => { + const expected = await Bun.file(path).text(); + expect(expected.length).toBeGreaterThan(0); + const drain = async (stream: ReadableStream) => { + let text = ""; + for await (const chunk of stream) text += Buffer.from(chunk).toString(); + return text; + }; + expect({ + "for await": await drain(fn(Bun.file(path)).body!), + "Bun.readableStreamToText": await Bun.readableStreamToText(fn(Bun.file(path)).body!), + "new Response(body).text()": await new Response(fn(Bun.file(path)).body).text(), + "textStream()": (await Array.fromAsync(fn(Bun.file(path)).textStream())).join(""), + }).toEqual({ + "for await": expected, + "Bun.readableStreamToText": expected, + "new Response(body).text()": expected, + "textStream()": expected, + }); + }); + test("the body getter does not make a later text() on the same Bun.file() empty", async () => { const expected = await Bun.file(path).text(); expect(expected).not.toBe(""); @@ -364,6 +386,17 @@ for (const { body, fn } of bodyTypes) { expect(fn(file).body).toBeInstanceOf(ReadableStream); expect(await file.text()).toBe(expected); }); + + test("clone() after the body getter reads the whole file on both copies", async () => { + const expected = await Bun.file(path).text(); + const original = fn(Bun.file(path)); + expect(original.body).toBeInstanceOf(ReadableStream); + const clone = original.clone(); + expect({ clone: await clone.text(), original: await original.text() }).toEqual({ + clone: expected, + original: expected, + }); + }); }); }); for (const { string, buffer } of utf8) { diff --git a/test/js/web/fetch/wasm-streaming.test.ts b/test/js/web/fetch/wasm-streaming.test.ts index 1bdb2acd1ba2..3c6a60d62db6 100644 --- a/test/js/web/fetch/wasm-streaming.test.ts +++ b/test/js/web/fetch/wasm-streaming.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { tmpdirSync } from "harness"; +import { isLinux, tmpdirSync } from "harness"; import { ok } from "node:assert/strict"; @@ -213,6 +213,14 @@ describe("WebAssembly.compileStreaming", () => { const response = await fetch("data:application/wasm,This is not actually Wasm"); expect(WebAssembly.compileStreaming(response)).rejects.toBeInstanceOf(WebAssembly.CompileError); }); + + // /proc/version is a regular file whose st_size is 0, and it is not Wasm. The error shows that + // its bytes reached the compiler: a stream capped at st_size fails as "expected a module of at + // least 8 bytes" instead. + test.skipIf(!isLinux)("reads a procfs Bun.file() response to EOF", async () => { + const response = new Response(Bun.file("/proc/version"), { headers: { "Content-Type": "application/wasm" } }); + expect(WebAssembly.compileStreaming(response)).rejects.toThrow("module doesn't start with '\\0asm'"); + }); }); describe("WebAssembly.instantiateStreaming", () => {