From c46aba1f688842a4a2df478fccb74f5cf1a8b96a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:54:56 +0000 Subject: [PATCH 1/8] s3: resolve streamed write/download with the byte count, not 0 Every S3 write entry point is documented as resolving with the number of bytes written, and the buffered path (Uint8Array/Blob/string) already did that. The streamed paths all resolved a hardcoded 0: - upload_stream (Response with a ReadableStream body, Bun.file, Bun.write(s3file, Bun.file)) via S3UploadStreamWrapper::resolve - writable_stream (S3File.writer().end()) via wrapper_callback - Bun.write(path, s3file) via on_file_stream_resolve_request_stream MultiPartUpload never tracked a cumulative byte count, so there was nothing to resolve with. Track it as uploaded, bumped on each part and on the single-PUT response. NetworkSink mirrors that into a wrote field via on_writable, because the JS sink wrapper can be collected while the upload is in flight (nothing references it once end() hands back a Promise), which detaches the task pointer before the completion callback runs. end_from_js is also reordered so the end promise exists before the upload is triggered. The download path already had FileSink.written available; read it instead of resolving 0 in on_file_stream_resolve_request_stream and the synchronous-completion branches of pipe_readable_stream_to_blob. --- src/runtime/webcore/Blob.rs | 13 +- src/runtime/webcore/s3/client.rs | 11 +- src/runtime/webcore/s3/multipart.rs | 9 +- src/runtime/webcore/streams.rs | 28 ++-- test/js/bun/s3/s3-write-return-bytes.test.ts | 137 +++++++++++++++++++ 5 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 test/js/bun/s3/s3-write-return-bytes.test.ts diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 48af5644a8bf..e162dd37650e 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -1663,12 +1663,14 @@ impl BlobExt for Blob { return Ok(promise_value); } jsc::js_promise::Status::Fulfilled => { + // SAFETY: `file_sink` holds our +1 ref; live until deref below. + let written = unsafe { (*file_sink).written.get() }; // SAFETY: release our +1 ref on the sink. unsafe { webcore::FileSink::deref(file_sink) }; readable_stream.done(global_this); return Ok(JSPromise::resolved_promise_value( global_this, - JSValue::js_number(0.0), + JSValue::js_number(written as f64), )); } jsc::js_promise::Status::Rejected => { @@ -1693,12 +1695,14 @@ impl BlobExt for Blob { ); } } + // SAFETY: `file_sink` holds our +1 ref; live until deref below. + let written = unsafe { (*file_sink).written.get() }; // SAFETY: release our +1 ref on the sink. unsafe { webcore::FileSink::deref(file_sink) }; Ok(JSPromise::resolved_promise_value( global_this, - JSValue::js_number(0.0), + JSValue::js_number(written as f64), )) } @@ -5959,7 +5963,10 @@ pub fn on_file_stream_resolve_request_stream( if let Some(stream) = strong.get(global_this) { stream.done(global_this); } - this.promise.resolve(global_this, JSValue::js_number(0.0))?; + // SAFETY: `this.sink` is the +1 ref held until `Drop` below; live here. + let written = unsafe { (*this.sink).written.get() }; + this.promise + .resolve(global_this, JSValue::js_number(written as f64))?; Ok(JSValue::UNDEFINED) } diff --git a/src/runtime/webcore/s3/client.rs b/src/runtime/webcore/s3/client.rs index 80271e2b89eb..72f78cb5651a 100644 --- a/src/runtime/webcore/s3/client.rs +++ b/src/runtime/webcore/s3/client.rs @@ -445,12 +445,14 @@ pub(crate) fn writable_stream( let _exit_guard = unsafe { bun_jsc::event_loop::EventLoop::enter_scope(event_loop) }; match result { S3UploadResult::Success => { + let uploaded = sink.wrote; if sink.flush_promise.has_value() { sink.flush_promise - .resolve(global, JSValue::js_number(0.0))?; + .resolve(global, JSValue::js_number(uploaded as f64))?; } if sink.end_promise.has_value() { - sink.end_promise.resolve(global, JSValue::js_number(0.0))?; + sink.end_promise + .resolve(global, JSValue::js_number(uploaded as f64))?; } } S3UploadResult::Failure(err) => { @@ -498,6 +500,7 @@ pub(crate) fn writable_stream( available: IntegerBitSet::init_full(), current_part_number: 1, ref_count: core::cell::Cell::new(2), // +1 for the stream + uploaded: 0, ended: false, options, acl: None, @@ -675,9 +678,10 @@ impl S3UploadStreamWrapper { match &result { S3UploadResult::Success => { if self_.end_promise.has_value() { + let uploaded = self_.task_mut().uploaded; self_ .end_promise - .resolve(&self_.global, JSValue::js_number(0.0))?; + .resolve(&self_.global, JSValue::js_number(uploaded as f64))?; self_.end_promise = bun_jsc::JSPromiseStrong::empty(); } } @@ -852,6 +856,7 @@ pub fn upload_stream( available: IntegerBitSet::init_full(), current_part_number: 1, ref_count: core::cell::Cell::new(2), // +1 for the stream ctx (only deinit after task and context ended) + uploaded: 0, ended: false, options, acl, diff --git a/src/runtime/webcore/s3/multipart.rs b/src/runtime/webcore/s3/multipart.rs index 16c37a2af1e0..13a708c4455d 100644 --- a/src/runtime/webcore/s3/multipart.rs +++ b/src/runtime/webcore/s3/multipart.rs @@ -129,6 +129,10 @@ pub struct MultiPartUpload { pub current_part_number: u16, pub ref_count: Cell, // intrusive refcount — see bun_ptr::IntrusiveRc + /// Total payload bytes acknowledged by the server so far. Surfaced as the + /// resolved value of `S3File.write()` / `.writer().end()` so callers can + /// verify the transfer size. + pub uploaded: u64, pub ended: bool, pub options: MultiPartUploadOptions, @@ -308,6 +312,7 @@ impl UploadPart { this.part_number ); let sent = this.data().len(); + ctx.uploaded += sent as u64; this.free_allocated_slice(); // we will need to order this ctx.multipart_etags.push(UploadPartResult { @@ -459,8 +464,10 @@ impl MultiPartUpload { S3UploadResult::Success => { scoped_log!(S3MultiPartUpload, "singleSendUploadResponse success"); + let sent = this.buffered.size() as u64; + this.uploaded += sent; if let Some(callback) = this.on_writable { - callback(this, this.callback_context, this.buffered.size() as u64); + callback(this, this.callback_context, sent); } this.done() } diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index d4dfdc47f1cf..6fae954b73a2 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -2047,6 +2047,11 @@ pub struct NetworkSink { // JSC_BORROW: process-lifetime VM global; safe `Deref` via `BackRef`. pub global_this: Option>, pub high_water_mark: BlobSizeType, + /// Cumulative payload bytes the server has acknowledged so far, mirrored + /// from `MultiPartUpload.uploaded` on each `on_writable` callback. Read by + /// the completion callback to resolve `end()`/`flush()` because `task` may + /// already be detached (GC of the JS wrapper) by then. + pub wrote: u64, pub flush_promise: JSPromiseStrong, pub end_promise: JSPromiseStrong, pub ended: bool, @@ -2061,6 +2066,7 @@ impl Default for NetworkSink { signal: Signal::default(), global_this: None, high_water_mark: 2048, + wrote: 0, flush_promise: JSPromiseStrong::default(), end_promise: JSPromiseStrong::default(), ended: false, @@ -2155,10 +2161,11 @@ impl NetworkSink { flushed, task.state as u8 ); + this.wrote = task.uploaded; if this.flush_promise.has_value() { let global = this.global_this.expect("global_this set at construction"); this.flush_promise - .resolve(&global, JSValue::js_number(flushed as f64))?; + .resolve(&global, JSValue::js_number(this.wrote as f64))?; } Ok(()) } @@ -2274,27 +2281,24 @@ impl NetworkSink { } pub fn end_from_js(&mut self, _global_this: &JSGlobalObject) -> bun_sys::Result { - let _ = self.end(None); if self.end_promise.has_value() { // we are already waiting for the end + let _ = self.end(None); return bun_sys::Result::Ok(self.end_promise.value()); } if self.task.is_some() { - // we need to wait for the task to end + // `end()` may run the upload to completion synchronously (local + // endpoint / warm connection), which fires `wrapper_callback` + // before we return. Create the promise first so the callback has + // it to resolve with the byte count. self.end_promise = JSPromiseStrong::init(self.global_this()); let value = self.end_promise.value(); - if !self.ended { - self.ended = true; - // we need to send EOF - if let Some(task) = self.task_mut() { - let _ = task.write_bytes(b"", true); - } - self.signal.close(None); - } + let _ = self.end(None); return bun_sys::Result::Ok(value); } + let _ = self.end(None); // task already detached - bun_sys::Result::Ok(JSValue::js_number(0.0)) + bun_sys::Result::Ok(JSValue::js_number(self.wrote as f64)) } pub fn to_js(&mut self, global_this: &JSGlobalObject) -> JSValue { diff --git a/test/js/bun/s3/s3-write-return-bytes.test.ts b/test/js/bun/s3/s3-write-return-bytes.test.ts new file mode 100644 index 000000000000..6b88cb0c2cd1 --- /dev/null +++ b/test/js/bun/s3/s3-write-return-bytes.test.ts @@ -0,0 +1,137 @@ +import { S3Client, type S3Options } from "bun"; +import { describe, expect, it } from "bun:test"; +import { tempDir } from "harness"; +import path from "node:path"; + +// Every write entry point is typed/documented as "Promise resolving to number of +// bytes written". Buffered sources already returned the true count; streamed +// sources (ReadableStream body, Bun.file, writer().end(), download-to-file) +// resolved a hardcoded 0. + +describe("s3 write() resolves with bytes transferred", () => { + const PAYLOAD = 300_000; + + function mockOrigin() { + let received = 0; + const server = Bun.serve({ + port: 0, + async fetch(req) { + received = (await req.arrayBuffer()).byteLength; + const url = new URL(req.url); + if (url.search === "?uploads=") { + // InitiateMultipartUpload + return new Response( + `abc123`, + { status: 200 }, + ); + } + if (req.method === "GET") { + return new Response(Buffer.alloc(PAYLOAD, "x")); + } + return new Response("", { status: 200, headers: { etag: '"e"' } }); + }, + }); + const options: S3Options = { + endpoint: server.url.href, + accessKeyId: "a", + secretAccessKey: "b", + bucket: "bk", + region: "us-east-1", + }; + return { + server, + options, + client: new S3Client(options), + received: () => received, + [Symbol.dispose]() { + server.stop(true); + }, + }; + } + + it("buffered: Uint8Array source returns byte count", async () => { + using m = mockOrigin(); + const n = await m.client.write("k", new Uint8Array(PAYLOAD)); + expect({ returned: n, received: m.received() }).toEqual({ returned: PAYLOAD, received: PAYLOAD }); + }); + + it("streamed: Response with ReadableStream body returns byte count", async () => { + using m = mockOrigin(); + const stream = new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(PAYLOAD)); + c.close(); + }, + }); + const n = await m.client.write("k", new Response(stream)); + expect({ returned: n, received: m.received() }).toEqual({ returned: PAYLOAD, received: PAYLOAD }); + }); + + it("streamed: Bun.file source returns byte count", async () => { + using m = mockOrigin(); + using dir = tempDir("s3-write-ret", { + "src.bin": Buffer.alloc(PAYLOAD, "A"), + }); + const n = await Bun.write(m.client.file("k"), Bun.file(path.join(String(dir), "src.bin"))); + expect({ returned: n, received: m.received() }).toEqual({ returned: PAYLOAD, received: PAYLOAD }); + }); + + it("writer(): end() returns total bytes written", async () => { + using m = mockOrigin(); + const w = m.client.file("k").writer(); + w.write(new Uint8Array(PAYLOAD)); + w.write(new Uint8Array(PAYLOAD)); + w.write(new Uint8Array(PAYLOAD)); + const n = await w.end(); + expect({ returned: n, received: m.received() }).toEqual({ + returned: PAYLOAD * 3, + received: PAYLOAD * 3, + }); + }); + + it("writer(): end() returns total bytes for a multipart upload", async () => { + let partsReceived = 0; + using server = Bun.serve({ + port: 0, + async fetch(req) { + const body = await req.arrayBuffer(); + if (req.method === "POST" && req.url.includes("?uploads=")) { + return new Response( + "abc123", + { status: 200 }, + ); + } + if (req.method === "POST" && req.url.includes("uploadId=")) { + return new Response( + '"etag"', + { status: 200 }, + ); + } + partsReceived += body.byteLength; + return new Response("", { status: 200, headers: { etag: '"e"' } }); + }, + }); + const client = new S3Client({ + endpoint: server.url.href, + accessKeyId: "a", + secretAccessKey: "b", + bucket: "bk", + region: "us-east-1", + }); + const partSize = 5 * 1024 * 1024; + const total = partSize + 1024 * 1024; + const w = client.file("k").writer({ partSize }); + w.write(Buffer.alloc(total, "a")); + const n = await w.end(); + server.stop(true); + expect({ returned: n, received: partsReceived }).toEqual({ returned: total, received: total }); + }); + + it("download: Bun.write(path, s3file) returns bytes written to disk", async () => { + using m = mockOrigin(); + using dir = tempDir("s3-dl-ret", {}); + const dest = path.join(String(dir), "out.bin"); + const n = await Bun.write(dest, m.client.file("k")); + expect({ returned: n, onDisk: Bun.file(dest).size }).toEqual({ returned: PAYLOAD, onDisk: PAYLOAD }); + }); +}); From 8eda131816a3fc3426b0d99413c416ab8cbb4057 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:58:06 +0000 Subject: [PATCH 2/8] [autofix.ci] apply automated fixes --- test/js/bun/s3/s3-write-return-bytes.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/js/bun/s3/s3-write-return-bytes.test.ts b/test/js/bun/s3/s3-write-return-bytes.test.ts index 6b88cb0c2cd1..fd1a72a76f22 100644 --- a/test/js/bun/s3/s3-write-return-bytes.test.ts +++ b/test/js/bun/s3/s3-write-return-bytes.test.ts @@ -102,10 +102,9 @@ describe("s3 write() resolves with bytes transferred", () => { ); } if (req.method === "POST" && req.url.includes("uploadId=")) { - return new Response( - '"etag"', - { status: 200 }, - ); + return new Response('"etag"', { + status: 200, + }); } partsReceived += body.byteLength; return new Response("", { status: 200, headers: { etag: '"e"' } }); From 3b3bab08723f2d4b2dbadc9e1b7ef435652e5910 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:18:59 +0000 Subject: [PATCH 3/8] address review: resolve flush() with cumulative bytes, trim comments, drop redundant stop --- src/runtime/webcore/s3/multipart.rs | 4 +--- src/runtime/webcore/streams.rs | 14 ++++---------- test/js/bun/s3/s3-write-return-bytes.test.ts | 1 - 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/runtime/webcore/s3/multipart.rs b/src/runtime/webcore/s3/multipart.rs index 13a708c4455d..1dea93636c4e 100644 --- a/src/runtime/webcore/s3/multipart.rs +++ b/src/runtime/webcore/s3/multipart.rs @@ -129,9 +129,7 @@ pub struct MultiPartUpload { pub current_part_number: u16, pub ref_count: Cell, // intrusive refcount — see bun_ptr::IntrusiveRc - /// Total payload bytes acknowledged by the server so far. Surfaced as the - /// resolved value of `S3File.write()` / `.writer().end()` so callers can - /// verify the transfer size. + /// Server-acknowledged payload bytes; resolves `S3File.write()` / `.writer().end()`. pub uploaded: u64, pub ended: bool, diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index 6fae954b73a2..627250b6c09c 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -2047,10 +2047,7 @@ pub struct NetworkSink { // JSC_BORROW: process-lifetime VM global; safe `Deref` via `BackRef`. pub global_this: Option>, pub high_water_mark: BlobSizeType, - /// Cumulative payload bytes the server has acknowledged so far, mirrored - /// from `MultiPartUpload.uploaded` on each `on_writable` callback. Read by - /// the completion callback to resolve `end()`/`flush()` because `task` may - /// already be detached (GC of the JS wrapper) by then. + /// Mirrors `MultiPartUpload.uploaded`; survives `task` detaching before completion. pub wrote: u64, pub flush_promise: JSPromiseStrong, pub end_promise: JSPromiseStrong, @@ -2188,7 +2185,7 @@ impl NetworkSink { if self.done { return bun_sys::Result::Ok(JSPromise::resolved_promise_value( global_this, - JSValue::js_number(0.0), + JSValue::js_number(self.wrote as f64), )); } // flush more @@ -2200,7 +2197,7 @@ impl NetworkSink { // we are done flushing no backpressure bun_sys::Result::Ok(JSPromise::resolved_promise_value( global_this, - JSValue::js_number(0.0), + JSValue::js_number(self.wrote as f64), )) } @@ -2287,10 +2284,7 @@ impl NetworkSink { return bun_sys::Result::Ok(self.end_promise.value()); } if self.task.is_some() { - // `end()` may run the upload to completion synchronously (local - // endpoint / warm connection), which fires `wrapper_callback` - // before we return. Create the promise first so the callback has - // it to resolve with the byte count. + // end() may complete synchronously; the promise must exist before it runs. self.end_promise = JSPromiseStrong::init(self.global_this()); let value = self.end_promise.value(); let _ = self.end(None); diff --git a/test/js/bun/s3/s3-write-return-bytes.test.ts b/test/js/bun/s3/s3-write-return-bytes.test.ts index fd1a72a76f22..fb2eb48d2834 100644 --- a/test/js/bun/s3/s3-write-return-bytes.test.ts +++ b/test/js/bun/s3/s3-write-return-bytes.test.ts @@ -122,7 +122,6 @@ describe("s3 write() resolves with bytes transferred", () => { const w = client.file("k").writer({ partSize }); w.write(Buffer.alloc(total, "a")); const n = await w.end(); - server.stop(true); expect({ returned: n, received: partsReceived }).toEqual({ returned: total, received: total }); }); From 2d452c5c47b77fb7ea6367e77d68aed169f49e4a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:42:40 +0000 Subject: [PATCH 4/8] test: cover S3Client.write(key, Bun.file) and writer flush(), skip writer tests under ASAN The writer() tests trip LeakSanitizer on the release-asan lane via a pre-existing NetworkSink leak (fix open in #34999); skip them under ASAN until that lands. --- test/js/bun/s3/s3-write-return-bytes.test.ts | 28 +++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/test/js/bun/s3/s3-write-return-bytes.test.ts b/test/js/bun/s3/s3-write-return-bytes.test.ts index fb2eb48d2834..b7a20622ccdf 100644 --- a/test/js/bun/s3/s3-write-return-bytes.test.ts +++ b/test/js/bun/s3/s3-write-return-bytes.test.ts @@ -1,8 +1,12 @@ import { S3Client, type S3Options } from "bun"; import { describe, expect, it } from "bun:test"; -import { tempDir } from "harness"; +import { isASAN, tempDir } from "harness"; import path from "node:path"; +// writer() leaks its NetworkSink (pre-existing, fix in #34999), which trips +// LeakSanitizer; skip those tests under ASAN until that PR lands. +const itWriter = it.skipIf(isASAN); + // Every write entry point is typed/documented as "Promise resolving to number of // bytes written". Buffered sources already returned the true count; streamed // sources (ReadableStream body, Bun.file, writer().end(), download-to-file) @@ -76,7 +80,16 @@ describe("s3 write() resolves with bytes transferred", () => { expect({ returned: n, received: m.received() }).toEqual({ returned: PAYLOAD, received: PAYLOAD }); }); - it("writer(): end() returns total bytes written", async () => { + it("streamed: S3Client.write(key, Bun.file) returns byte count", async () => { + using m = mockOrigin(); + using dir = tempDir("s3-write-ret-direct", { + "src.bin": Buffer.alloc(PAYLOAD, "B"), + }); + const n = await m.client.write("k", Bun.file(path.join(String(dir), "src.bin"))); + expect({ returned: n, received: m.received() }).toEqual({ returned: PAYLOAD, received: PAYLOAD }); + }); + + itWriter("writer(): end() returns total bytes written", async () => { using m = mockOrigin(); const w = m.client.file("k").writer(); w.write(new Uint8Array(PAYLOAD)); @@ -89,7 +102,7 @@ describe("s3 write() resolves with bytes transferred", () => { }); }); - it("writer(): end() returns total bytes for a multipart upload", async () => { + itWriter("writer(): end() returns total bytes for a multipart upload", async () => { let partsReceived = 0; using server = Bun.serve({ port: 0, @@ -121,8 +134,15 @@ describe("s3 write() resolves with bytes transferred", () => { const total = partSize + 1024 * 1024; const w = client.file("k").writer({ partSize }); w.write(Buffer.alloc(total, "a")); + // flush() resolves with the cumulative bytes the server has acknowledged: + // the one full part; the 1 MiB remainder stays buffered until end(). + const flushed = await w.flush(); const n = await w.end(); - expect({ returned: n, received: partsReceived }).toEqual({ returned: total, received: total }); + expect({ flushed, returned: n, received: partsReceived }).toEqual({ + flushed: partSize, + returned: total, + received: total, + }); }); it("download: Bun.write(path, s3file) returns bytes written to disk", async () => { From dd502709d1aecd7feaf1ae468d4ec65b00b06dbe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:26:33 +0000 Subject: [PATCH 5/8] s3: keep flush() per-call delta contract, only end() reports cumulative bytes Resolving flush() with the cumulative count broke the flush loops in s3.test.ts, which rely on flush() returning the bytes flushed by that call and 0 when nothing is pending. flush() resolution now matches main exactly; end() still resolves with the total uploaded. --- src/runtime/webcore/s3/client.rs | 7 +++---- src/runtime/webcore/streams.rs | 8 +++++--- test/js/bun/s3/s3-write-return-bytes.test.ts | 9 +-------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/runtime/webcore/s3/client.rs b/src/runtime/webcore/s3/client.rs index 72f78cb5651a..e0c657243592 100644 --- a/src/runtime/webcore/s3/client.rs +++ b/src/runtime/webcore/s3/client.rs @@ -445,14 +445,13 @@ pub(crate) fn writable_stream( let _exit_guard = unsafe { bun_jsc::event_loop::EventLoop::enter_scope(event_loop) }; match result { S3UploadResult::Success => { - let uploaded = sink.wrote; + // flush() keeps its delta contract: 0 = nothing left pending. if sink.flush_promise.has_value() { - sink.flush_promise - .resolve(global, JSValue::js_number(uploaded as f64))?; + sink.flush_promise.resolve(global, JSValue::js_number(0.0))?; } if sink.end_promise.has_value() { sink.end_promise - .resolve(global, JSValue::js_number(uploaded as f64))?; + .resolve(global, JSValue::js_number(sink.wrote as f64))?; } } S3UploadResult::Failure(err) => { diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index 627250b6c09c..a061e2178e6f 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -2161,8 +2161,10 @@ impl NetworkSink { this.wrote = task.uploaded; if this.flush_promise.has_value() { let global = this.global_this.expect("global_this set at construction"); + // flush() resolves with the per-call delta, 0 = nothing pending; only + // end() reports the cumulative `wrote`. this.flush_promise - .resolve(&global, JSValue::js_number(this.wrote as f64))?; + .resolve(&global, JSValue::js_number(flushed as f64))?; } Ok(()) } @@ -2185,7 +2187,7 @@ impl NetworkSink { if self.done { return bun_sys::Result::Ok(JSPromise::resolved_promise_value( global_this, - JSValue::js_number(self.wrote as f64), + JSValue::js_number(0.0), )); } // flush more @@ -2197,7 +2199,7 @@ impl NetworkSink { // we are done flushing no backpressure bun_sys::Result::Ok(JSPromise::resolved_promise_value( global_this, - JSValue::js_number(self.wrote as f64), + JSValue::js_number(0.0), )) } diff --git a/test/js/bun/s3/s3-write-return-bytes.test.ts b/test/js/bun/s3/s3-write-return-bytes.test.ts index b7a20622ccdf..8f4120835cdb 100644 --- a/test/js/bun/s3/s3-write-return-bytes.test.ts +++ b/test/js/bun/s3/s3-write-return-bytes.test.ts @@ -134,15 +134,8 @@ describe("s3 write() resolves with bytes transferred", () => { const total = partSize + 1024 * 1024; const w = client.file("k").writer({ partSize }); w.write(Buffer.alloc(total, "a")); - // flush() resolves with the cumulative bytes the server has acknowledged: - // the one full part; the 1 MiB remainder stays buffered until end(). - const flushed = await w.flush(); const n = await w.end(); - expect({ flushed, returned: n, received: partsReceived }).toEqual({ - flushed: partSize, - returned: total, - received: total, - }); + expect({ returned: n, received: partsReceived }).toEqual({ returned: total, received: total }); }); it("download: Bun.write(path, s3file) returns bytes written to disk", async () => { From f4d41e70fe7105402855f736553a8025d291ecbb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:27:33 +0000 Subject: [PATCH 6/8] condense flush comment to one line --- src/runtime/webcore/streams.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index a061e2178e6f..2fff12c6aac9 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -2161,8 +2161,7 @@ impl NetworkSink { this.wrote = task.uploaded; if this.flush_promise.has_value() { let global = this.global_this.expect("global_this set at construction"); - // flush() resolves with the per-call delta, 0 = nothing pending; only - // end() reports the cumulative `wrote`. + // flush() resolves with the per-call delta; only end() reports cumulative `wrote`. this.flush_promise .resolve(&global, JSValue::js_number(flushed as f64))?; } From 6b46111ad0415fcafbc195f201decec414cd1efa Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:29:52 +0000 Subject: [PATCH 7/8] [autofix.ci] apply automated fixes --- src/runtime/webcore/s3/client.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runtime/webcore/s3/client.rs b/src/runtime/webcore/s3/client.rs index e0c657243592..710768d6f629 100644 --- a/src/runtime/webcore/s3/client.rs +++ b/src/runtime/webcore/s3/client.rs @@ -447,7 +447,8 @@ pub(crate) fn writable_stream( S3UploadResult::Success => { // flush() keeps its delta contract: 0 = nothing left pending. if sink.flush_promise.has_value() { - sink.flush_promise.resolve(global, JSValue::js_number(0.0))?; + sink.flush_promise + .resolve(global, JSValue::js_number(0.0))?; } if sink.end_promise.has_value() { sink.end_promise From 0cf8847707c61df4fb12b2d406a86dbe7fefa2c8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:05:08 +0000 Subject: [PATCH 8/8] test: clear proxy env so the localhost mock origin works on proxied machines --- test/js/bun/s3/s3-write-return-bytes.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/js/bun/s3/s3-write-return-bytes.test.ts b/test/js/bun/s3/s3-write-return-bytes.test.ts index 8f4120835cdb..c034061d54ff 100644 --- a/test/js/bun/s3/s3-write-return-bytes.test.ts +++ b/test/js/bun/s3/s3-write-return-bytes.test.ts @@ -3,6 +3,13 @@ import { describe, expect, it } from "bun:test"; import { isASAN, tempDir } from "harness"; import path from "node:path"; +// The S3 client routes through HTTP_PROXY without consulting NO_PROXY, which +// breaks the localhost mock origin on proxied machines. +process.env.HTTP_PROXY = ""; +process.env.HTTPS_PROXY = ""; +process.env.http_proxy = ""; +process.env.https_proxy = ""; + // writer() leaks its NetworkSink (pre-existing, fix in #34999), which trips // LeakSanitizer; skip those tests under ASAN until that PR lands. const itWriter = it.skipIf(isASAN);