diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ec3d76a55648..9c3d683436ee 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -210,17 +210,22 @@ pub trait BlobExt { get_cached: fn(JSValue) -> Option, set_cached: fn(JSValue, &JSGlobalObject, JSValue), ) -> JsResult; - fn get_text(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult; + fn fd_cached_stream(&self, this_value: JSValue) -> Option; + fn get_text(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult; fn get_text_clone(&self, global_object: &JSGlobalObject) -> Result; - fn get_json(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult; + fn get_json(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult; fn get_json_share(&self, global_object: &JSGlobalObject) -> Result; fn get_array_buffer_clone( &self, global_this: &JSGlobalObject, ) -> Result; - fn get_array_buffer(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult; + fn get_array_buffer( + &self, + global_this: &JSGlobalObject, + callframe: &CallFrame, + ) -> JsResult; fn get_bytes_clone(&self, global_this: &JSGlobalObject) -> Result; - fn get_bytes(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult; + fn get_bytes(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult; fn get_form_data(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult; fn get_exists_sync(&self) -> JSValue; fn do_write(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult; @@ -1218,7 +1223,26 @@ impl BlobExt for Blob { Ok(stream) } - fn get_text(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + + fn fd_cached_stream(&self, this_value: JSValue) -> Option { + // `stream_get_cached` is `uncheckedDowncast`. + js::from_js(this_value)?; + let store = self.store.get().as_deref()?; + let store::Data::File(f) = &store.data else { + return None; + }; + if !matches!(f.pathlike, PathOrFileDescriptor::Fd(_)) { + return None; + } + js::stream_get_cached(this_value) + } + + fn get_text(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + if let Some(stream) = self.fd_cached_stream(callframe.this()) { + return bun_jsc::from_js_host_call(global_this, || { + global_this.readable_stream_to_text(stream) + }); + } Ok(self.get_text_clone(global_this)?) } @@ -1227,7 +1251,12 @@ impl BlobExt for Blob { JSPromise::wrap(global_object, |g| self.to_string(g, Lifetime::Clone)) } - fn get_json(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + fn get_json(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + if let Some(stream) = self.fd_cached_stream(callframe.this()) { + return bun_jsc::from_js_host_call(global_this, || { + global_this.readable_stream_to_json(stream) + }); + } Ok(self.get_json_share(global_this)?) } @@ -1244,7 +1273,16 @@ impl BlobExt for Blob { JSPromise::wrap(global_this, |g| self.to_array_buffer(g, Lifetime::Clone)) } - fn get_array_buffer(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + fn get_array_buffer( + &self, + global_this: &JSGlobalObject, + callframe: &CallFrame, + ) -> JsResult { + if let Some(stream) = self.fd_cached_stream(callframe.this()) { + return bun_jsc::from_js_host_call(global_this, || { + global_this.readable_stream_to_array_buffer(stream) + }); + } Ok(self.get_array_buffer_clone(global_this)?) } @@ -1253,7 +1291,12 @@ impl BlobExt for Blob { JSPromise::wrap(global_this, |g| self.to_uint8_array(g, Lifetime::Clone)) } - fn get_bytes(&self, global_this: &JSGlobalObject, _: &CallFrame) -> JsResult { + fn get_bytes(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + if let Some(stream) = self.fd_cached_stream(callframe.this()) { + return bun_jsc::from_js_host_call(global_this, || { + global_this.readable_stream_to_bytes(stream) + }); + } Ok(self.get_bytes_clone(global_this)?) } diff --git a/test/js/bun/util/bun-stdin-locked.test.ts b/test/js/bun/util/bun-stdin-locked.test.ts new file mode 100644 index 000000000000..53ab03dbb8f5 --- /dev/null +++ b/test/js/bun/util/bun-stdin-locked.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// process.stdin is built on Bun.stdin.stream()'s reader. The Blob read helpers +// on Bun.stdin (text/json/arrayBuffer/bytes) used to bypass that stream and +// read fd 0 directly, so a program that armed process.stdin *and* awaited +// Bun.stdin.arrayBuffer() would see the piped bytes split between the two +// consumers with no error. They now route through the same cached stream and +// reject with ERR_INVALID_STATE, matching Bun.stdin.stream().getReader(). + +const payload = Buffer.alloc(256 * 1024, "x").toString(); + +describe.each(["arrayBuffer", "bytes", "text", "json"] as const)( + "Bun.stdin.%s() rejects when process.stdin holds the reader", + method => { + test.concurrent(method, async () => { + const child = ` + let n = 0; + process.stdin.on("data", c => (n += c.length)); + let result = { state: "pending" }; + Bun.stdin.${method}().then( + () => { result = { state: "resolved" }; }, + e => { result = { state: "rejected", code: e && (e.code || e.name) }; }, + ); + await new Promise(r => process.stdin.once("end", r)); + await Promise.resolve(); + process.stdout.write(JSON.stringify({ n, result })); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", child], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.write(payload); + await proc.stdin.end(); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + n: payload.length, + result: { state: "rejected", code: "ERR_INVALID_STATE" }, + }); + expect(exitCode).toBe(0); + }); + }, +); + +test.concurrent("Bun.stdin.arrayBuffer() with no other consumer reads every byte", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `process.stdout.write(String((await Bun.stdin.arrayBuffer()).byteLength));`], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.write(payload); + await proc.stdin.end(); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe(String(payload.length)); + expect(exitCode).toBe(0); +});