From a1fec8ee1170d250a1cae4c9ee8e446976e7e19b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:49:57 +0000 Subject: [PATCH 01/13] streams: make text consumers throw instead of aborting past the string limit Bun.readableStreamToText, Response(stream).text(), and the direct-stream text sink abort the process (uncatchable SIGABRT) when the accumulated binary chunks total more than 2^31-1 bytes, even though every individual chunk fits. finishTextAccumulator guarded its reserveInitialCapacity estimate against uint32 max, but WTF::Vector caps capacity at INT32_MAX and CRASH()es above it, so totals in [2^31, 2^32) aborted before the existing exceedsStringLimit() throw could run. The direct stream's finishTextSink had no guard at all and hit the same CRASH() through incremental append. Check the estimated length against WTF::StringImpl::MaxLength up front (the estimate only undercounts, so exceeding the limit is final) and use tryReserveInitialCapacity/tryGrow/tryAppend for the byte vector so capacity and allocation failures surface as the same catchable out-of-memory RangeError these consumers already throw at the string limit. --- .../webcore/streams/BunStreamConsumers.cpp | 48 +++++++++-- .../streams/JSDirectStreamController.cpp | 31 ++++++- .../web/streams/streams-string-limit.test.ts | 82 +++++++++++++++++++ 3 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 test/js/web/streams/streams-string-limit.test.ts diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index ba28492cd6aa..f5641e859019 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -327,7 +327,13 @@ static bool appendChunkBytes(JSC::VM& vm, JSGlobalObject* globalObject, JSValue RETURN_IF_EXCEPTION(scope, false); if (size_t byteLength = utf8ByteLengthWithReplacement(string)) { size_t oldSize = bytes.size(); - bytes.grow(oldSize + byteLength); + // tryGrow/tryAppend: UTF-8 expansion can push the total past the estimate that + // sized the vector, and growing past the Vector capacity limit must surface as + // a catchable out-of-memory error, not a CRASH() in allocateBuffer. + if (!bytes.tryGrow(oldSize + byteLength)) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return false; + } size_t written = writeUTF8(string, bytes.mutableSpan().subspan(oldSize)); // The sizer and writer must agree; never expose ungrown (uninitialized) bytes. ASSERT(written == byteLength); @@ -337,13 +343,19 @@ static bool appendChunkBytes(JSC::VM& vm, JSGlobalObject* globalObject, JSValue return true; } if (auto* view = dynamicDowncast(chunk)) { - if (!view->isDetached()) - bytes.append(view->span()); + if (!view->isDetached() && !bytes.tryAppend(view->span())) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return false; + } return true; } if (auto* jsBuffer = dynamicDowncast(chunk)) { - if (auto* impl = jsBuffer->impl(); impl && !impl->isDetached()) - bytes.append(impl->span()); + if (auto* impl = jsBuffer->impl(); impl && !impl->isDetached()) { + if (!bytes.tryAppend(impl->span())) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return false; + } + } return true; } throwTypeError(globalObject, scope, "Expected an ArrayBuffer, ArrayBufferView, or string chunk"_s); @@ -709,10 +721,24 @@ static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObje return rope.substring(1); return rope; } - WTF::Vector bytes; + // The UTF-8 re-encode below only grows the estimate (binary bytes are exact, string + // chunks count UTF-16 code units), so an estimate past the string limit is final. + // Throw before touching the Vector: its capacity CRASH()es past INT32_MAX, so an + // estimate in [2^31, 2^32) would abort in reserveInitialCapacity before the + // exceedsStringLimit() throw below is ever reached. const double estimatedLength = accumulator.estimatedLength; - if (estimatedLength > 0 && estimatedLength < static_cast(std::numeric_limits::max())) - bytes.reserveInitialCapacity(static_cast(estimatedLength)); + if (estimatedLength > static_cast(WTF::StringImpl::MaxLength) + || exceedsStringLimit(static_cast(estimatedLength))) [[unlikely]] { + releaseAccumulated(); + throwOutOfMemoryError(globalObject, scope); + return WTF::String(); + } + WTF::Vector bytes; + if (estimatedLength > 0 && !bytes.tryReserveInitialCapacity(static_cast(estimatedLength))) [[unlikely]] { + releaseAccumulated(); + throwOutOfMemoryError(globalObject, scope); + return WTF::String(); + } for (auto& piece : accumulator.pieces) { JSValue value = piece.get(); if (!value) @@ -727,7 +753,11 @@ static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObje if (rope[0] == 0xFEFF) rope = rope.substring(1); WTF::CString utf8 = rope.utf8(); - bytes.append(std::span { reinterpret_cast(utf8.data()), utf8.length() }); + if (!bytes.tryAppend(std::span { reinterpret_cast(utf8.data()), utf8.length() })) [[unlikely]] { + releaseAccumulated(); + throwOutOfMemoryError(globalObject, scope); + return WTF::String(); + } } releaseAccumulated(); if (exceedsStringLimit(bytes.size())) [[unlikely]] { diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index e3086f419520..1539322bc814 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -271,21 +271,41 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect return rope; } + // The UTF-8 re-encode below only grows the estimate (binary bytes are exact, string + // chunks count UTF-16 code units), so an estimate past the string limit is final. + // Throw before appending anything: Vector's capacity CRASH()es past INT32_MAX, so a + // >2GB accumulation would abort in append before the exceedsStringLimit() throw + // below is ever reached. + const double estimatedLength = accumulator.estimatedLength; + if (estimatedLength > static_cast(WTF::StringImpl::MaxLength) + || Bun::WebStreams::exceedsStringLimit(static_cast(estimatedLength))) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return String(); + } Vector bytes; + if (estimatedLength > 0 && !bytes.tryReserveInitialCapacity(static_cast(estimatedLength))) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return String(); + } for (auto& piece : accumulator.pieces) { JSValue value = piece.get(); + bool appended = true; if (value.isString()) { String string = asString(value)->value(globalObject); RETURN_IF_EXCEPTION(scope, {}); auto utf8 = string.utf8(); - bytes.append(std::span { reinterpret_cast(utf8.data()), utf8.length() }); + appended = bytes.tryAppend(std::span { reinterpret_cast(utf8.data()), utf8.length() }); } else if (auto* view = dynamicDowncast(value)) { if (!view->isDetached()) - bytes.append(view->span()); + appended = bytes.tryAppend(view->span()); } else if (auto* buffer = dynamicDowncast(value)) { auto* impl = buffer->impl(); if (impl && !impl->isDetached()) - bytes.append(impl->span()); + appended = bytes.tryAppend(impl->span()); + } + if (!appended) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return String(); } } if (!accumulator.rope.isEmpty()) { @@ -293,7 +313,10 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect if (rope[0] == 0xFEFF) rope = rope.substring(1); auto utf8 = rope.utf8(); - bytes.append(std::span { reinterpret_cast(utf8.data()), utf8.length() }); + if (!bytes.tryAppend(std::span { reinterpret_cast(utf8.data()), utf8.length() })) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return String(); + } } if (Bun::WebStreams::exceedsStringLimit(bytes.size())) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); diff --git a/test/js/web/streams/streams-string-limit.test.ts b/test/js/web/streams/streams-string-limit.test.ts new file mode 100644 index 000000000000..dec3f1bb1106 --- /dev/null +++ b/test/js/web/streams/streams-string-limit.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import { totalmem } from "node:os"; + +// Consuming a stream as text must reject with a catchable error when the accumulated +// chunks exceed the maximum string length (2^31-1 bytes), instead of aborting the +// process in WTF::Vector's capacity check. Each child commits ~2.2GB. +const enoughMemory = totalmem() >= 8 * 1024 * 1024 * 1024; + +// 3 chunks of n bytes sum to 2^31+1: each chunk fits comfortably, the total does not. +function consumeToText(streamSource: string): string { + return ` + const n = 715827883; + const rs = ${streamSource}; + try { + const text = await Bun.readableStreamToText(rs); + console.log("resolved", text.length); + } catch (e) { + console.log("threw", e.name, e.message); + } + `; +} + +async function run(script: string): Promise<{ stdout: string; exitCode: number }> { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + return { stdout, exitCode }; +} + +describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past 2^31-1", () => { + test("queue-backed ReadableStream", async () => { + const { stdout, exitCode } = await run( + consumeToText(`new ReadableStream({ + start(c) { + for (let i = 0; i < 3; i++) c.enqueue(new Uint8Array(n)); + c.close(); + }, + })`), + ); + expect(stdout).toBe("threw RangeError Out of memory\n"); + expect(exitCode).toBe(0); + }); + + test("direct ReadableStream", async () => { + const { stdout, exitCode } = await run( + consumeToText(`new ReadableStream({ + type: "direct", + pull(c) { + for (let i = 0; i < 3; i++) c.write(new Uint8Array(n)); + c.end(); + }, + })`), + ); + expect(stdout).toBe("threw RangeError Out of memory\n"); + expect(exitCode).toBe(0); + }); + + test("Response(stream).text()", async () => { + const { stdout, exitCode } = await run(` + const n = 715827883; + const rs = new ReadableStream({ + start(c) { + for (let i = 0; i < 3; i++) c.enqueue(new Uint8Array(n)); + c.close(); + }, + }); + try { + const text = await new Response(rs).text(); + console.log("resolved", text.length); + } catch (e) { + console.log("threw", e.name, e.message); + } + `); + expect(stdout).toBe("threw RangeError Out of memory\n"); + expect(exitCode).toBe(0); + }); +}); From 1bb00c31cace865bc7784001f472694499cbb00c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:04:20 +0000 Subject: [PATCH 02/13] test: drain and assert child stderr in stream string-limit tests A regression back to the abort writes the panic banner and ASAN report to stderr; asserting the combined { stdout, stderr, exitCode } object surfaces that output directly in the failure diff instead of an empty-vs-expected stdout comparison. --- .../web/streams/streams-string-limit.test.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/test/js/web/streams/streams-string-limit.test.ts b/test/js/web/streams/streams-string-limit.test.ts index dec3f1bb1106..91ca86ad0cef 100644 --- a/test/js/web/streams/streams-string-limit.test.ts +++ b/test/js/web/streams/streams-string-limit.test.ts @@ -21,20 +21,22 @@ function consumeToText(streamSource: string): string { `; } -async function run(script: string): Promise<{ stdout: string; exitCode: number }> { +async function run(script: string): Promise<{ stdout: string; stderr: string; exitCode: number }> { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - return { stdout, exitCode }; + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; } +const threw = { stdout: "threw RangeError Out of memory\n", stderr: "", exitCode: 0 }; + describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past 2^31-1", () => { test("queue-backed ReadableStream", async () => { - const { stdout, exitCode } = await run( + const result = await run( consumeToText(`new ReadableStream({ start(c) { for (let i = 0; i < 3; i++) c.enqueue(new Uint8Array(n)); @@ -42,12 +44,11 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past }, })`), ); - expect(stdout).toBe("threw RangeError Out of memory\n"); - expect(exitCode).toBe(0); + expect(result).toEqual(threw); }); test("direct ReadableStream", async () => { - const { stdout, exitCode } = await run( + const result = await run( consumeToText(`new ReadableStream({ type: "direct", pull(c) { @@ -56,12 +57,11 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past }, })`), ); - expect(stdout).toBe("threw RangeError Out of memory\n"); - expect(exitCode).toBe(0); + expect(result).toEqual(threw); }); test("Response(stream).text()", async () => { - const { stdout, exitCode } = await run(` + const result = await run(` const n = 715827883; const rs = new ReadableStream({ start(c) { @@ -76,7 +76,6 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past console.log("threw", e.name, e.message); } `); - expect(stdout).toBe("threw RangeError Out of memory\n"); - expect(exitCode).toBe(0); + expect(result).toEqual(threw); }); }); From 7f36aff27d20cfbef037353eaec2e4eaa6f2df64 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:05:48 +0000 Subject: [PATCH 03/13] Tighten limit-check comments --- .../bindings/webcore/streams/BunStreamConsumers.cpp | 12 ++++-------- .../webcore/streams/JSDirectStreamController.cpp | 7 ++----- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index f5641e859019..d4ab8ef77340 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -327,9 +327,8 @@ static bool appendChunkBytes(JSC::VM& vm, JSGlobalObject* globalObject, JSValue RETURN_IF_EXCEPTION(scope, false); if (size_t byteLength = utf8ByteLengthWithReplacement(string)) { size_t oldSize = bytes.size(); - // tryGrow/tryAppend: UTF-8 expansion can push the total past the estimate that - // sized the vector, and growing past the Vector capacity limit must surface as - // a catchable out-of-memory error, not a CRASH() in allocateBuffer. + // UTF-8 expansion can overshoot the reserve, and Vector CRASH()es past + // INT32_MAX capacity; the try-variants keep both catchable. if (!bytes.tryGrow(oldSize + byteLength)) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return false; @@ -721,11 +720,8 @@ static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObje return rope.substring(1); return rope; } - // The UTF-8 re-encode below only grows the estimate (binary bytes are exact, string - // chunks count UTF-16 code units), so an estimate past the string limit is final. - // Throw before touching the Vector: its capacity CRASH()es past INT32_MAX, so an - // estimate in [2^31, 2^32) would abort in reserveInitialCapacity before the - // exceedsStringLimit() throw below is ever reached. + // estimatedLength only undercounts the result (binary sizes are exact, strings count + // UTF-16 units), so over the limit is final; Vector CRASH()es past INT32_MAX capacity. const double estimatedLength = accumulator.estimatedLength; if (estimatedLength > static_cast(WTF::StringImpl::MaxLength) || exceedsStringLimit(static_cast(estimatedLength))) [[unlikely]] { diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index 1539322bc814..96bbdebeff43 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -271,11 +271,8 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect return rope; } - // The UTF-8 re-encode below only grows the estimate (binary bytes are exact, string - // chunks count UTF-16 code units), so an estimate past the string limit is final. - // Throw before appending anything: Vector's capacity CRASH()es past INT32_MAX, so a - // >2GB accumulation would abort in append before the exceedsStringLimit() throw - // below is ever reached. + // estimatedLength only undercounts the result (binary sizes are exact, strings count + // UTF-16 units), so over the limit is final; Vector CRASH()es past INT32_MAX capacity. const double estimatedLength = accumulator.estimatedLength; if (estimatedLength > static_cast(WTF::StringImpl::MaxLength) || Bun::WebStreams::exceedsStringLimit(static_cast(estimatedLength))) [[unlikely]] { From d121cfea69e7862132d88356dd628a2b04566370 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:07:06 +0000 Subject: [PATCH 04/13] Shorten limit-check comments to one line --- src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp | 6 ++---- .../bindings/webcore/streams/JSDirectStreamController.cpp | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index d4ab8ef77340..535d65798c7b 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -327,8 +327,7 @@ static bool appendChunkBytes(JSC::VM& vm, JSGlobalObject* globalObject, JSValue RETURN_IF_EXCEPTION(scope, false); if (size_t byteLength = utf8ByteLengthWithReplacement(string)) { size_t oldSize = bytes.size(); - // UTF-8 expansion can overshoot the reserve, and Vector CRASH()es past - // INT32_MAX capacity; the try-variants keep both catchable. + // UTF-8 expansion can exceed the reserved estimate. if (!bytes.tryGrow(oldSize + byteLength)) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return false; @@ -720,8 +719,7 @@ static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObje return rope.substring(1); return rope; } - // estimatedLength only undercounts the result (binary sizes are exact, strings count - // UTF-16 units), so over the limit is final; Vector CRASH()es past INT32_MAX capacity. + // estimatedLength never overcounts the bytes, so an estimate past the limit is final. const double estimatedLength = accumulator.estimatedLength; if (estimatedLength > static_cast(WTF::StringImpl::MaxLength) || exceedsStringLimit(static_cast(estimatedLength))) [[unlikely]] { diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index 96bbdebeff43..1547a0f0b9bf 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -271,8 +271,7 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect return rope; } - // estimatedLength only undercounts the result (binary sizes are exact, strings count - // UTF-16 units), so over the limit is final; Vector CRASH()es past INT32_MAX capacity. + // estimatedLength never overcounts the bytes, so an estimate past the limit is final. const double estimatedLength = accumulator.estimatedLength; if (estimatedLength > static_cast(WTF::StringImpl::MaxLength) || Bun::WebStreams::exceedsStringLimit(static_cast(estimatedLength))) [[unlikely]] { From ee7d70763cab7f7862e17a4090b92409813e7745 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:22:07 +0000 Subject: [PATCH 05/13] Release the accumulator on the chunk-append error path too appendChunkBytes can now throw out-of-memory, and the loop return it flows through was the one error exit in finishTextAccumulator that skipped releaseAccumulated(). --- src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index 535d65798c7b..88eeeef146a6 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -738,9 +738,10 @@ static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObje if (!value) continue; bool appended = appendChunkBytes(vm, globalObject, value, bytes); - RETURN_IF_EXCEPTION(scope, WTF::String()); - if (!appended) + if (scope.exception() || !appended) [[unlikely]] { + releaseAccumulated(); return WTF::String(); + } } if (accumulator.rope.length()) { WTF::String rope = accumulator.rope.toString(); From e9cb9b54f65c9e09fe2d2b46433d05f9ffdbc78d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:42:32 +0000 Subject: [PATCH 06/13] Correct the direct-sink estimate comment User code runs between write() and end(), so a detach can make the estimate overcount there; the rejection is still the intended result. --- src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index 1547a0f0b9bf..f848d06b049a 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -271,7 +271,8 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect return rope; } - // estimatedLength never overcounts the bytes, so an estimate past the limit is final. + // Sizes are taken at write() time, so a buffer detached before end() can make this + // overcount; rejecting such an over-limit write set is still the right outcome. const double estimatedLength = accumulator.estimatedLength; if (estimatedLength > static_cast(WTF::StringImpl::MaxLength) || Bun::WebStreams::exceedsStringLimit(static_cast(estimatedLength))) [[unlikely]] { From 22a934d0ba9b5e56c638f6ac4ae71d1c5bca3201 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:43:39 +0000 Subject: [PATCH 07/13] Fold the direct-sink estimate comment onto one line --- src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index f848d06b049a..c30cd4d0cea2 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -271,8 +271,7 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect return rope; } - // Sizes are taken at write() time, so a buffer detached before end() can make this - // overcount; rejecting such an over-limit write set is still the right outcome. + // Sizes are recorded at write() time; rejecting even if buffers were detached later is intended. const double estimatedLength = accumulator.estimatedLength; if (estimatedLength > static_cast(WTF::StringImpl::MaxLength) || Bun::WebStreams::exceedsStringLimit(static_cast(estimatedLength))) [[unlikely]] { From 9e80871b381f145206a03690d6b2a93772784368 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:54:01 +0000 Subject: [PATCH 08/13] streams: encode text-consumer strings through simdutf instead of String::utf8() String::utf8() RELEASE_ASSERTs once its conversion scratch passes INT32_MAX (2x the length for 8-bit strings, 3x for 16-bit with lone surrogates), so a mixed stream with a near-limit string chunk still aborted inside the encode even when the UTF-8 total fit the string limit. Add appendUTF8WithinStringLimit: size with the simdutf sizer, reject past the string limit, then write straight into the byte vector (lone surrogates become U+FFFD, matching the chunk appenders). Use it for string pieces and the trailing rope in both finish arms. --- .../webcore/streams/BunStreamConsumers.cpp | 35 +++++++----- .../streams/JSDirectStreamController.cpp | 6 +-- .../webcore/streams/WebStreamsInternals.h | 4 ++ .../web/streams/streams-string-limit.test.ts | 53 +++++++++++++++++++ 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp index 88eeeef146a6..1145258a80a0 100644 --- a/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp +++ b/src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp @@ -283,6 +283,23 @@ static size_t writeUTF8(const WTF::String& string, std::span destinatio return Bun__encoding__writeUTF16(string.span16().data(), string.span16().size(), destination.data(), destination.size(), utf8); } +bool appendUTF8WithinStringLimit(const WTF::String& string, WTF::Vector& bytes) +{ + size_t byteLength = utf8ByteLengthWithReplacement(string); + if (!byteLength) + return true; + size_t oldSize = bytes.size(); + // UTF-8 expansion can exceed any reserve taken from the code-unit estimate. + if (exceedsStringLimit(oldSize + byteLength) || !bytes.tryGrow(oldSize + byteLength)) [[unlikely]] + return false; + size_t written = writeUTF8(string, bytes.mutableSpan().subspan(oldSize)); + // The sizer and writer must agree; never expose ungrown (uninitialized) bytes. + ASSERT(written == byteLength); + if (written < byteLength) [[unlikely]] + bytes.shrink(oldSize + written); + return true; +} + // `obj[name](...args)` with `this` = obj. static JSValue invokeMethod(JSC::VM& vm, JSGlobalObject* globalObject, JSObject* object, const Identifier& name, const MarkedArgumentBuffer& args) { @@ -325,18 +342,9 @@ static bool appendChunkBytes(JSC::VM& vm, JSGlobalObject* globalObject, JSValue if (chunk.isString()) { WTF::String string = asString(chunk)->value(globalObject); RETURN_IF_EXCEPTION(scope, false); - if (size_t byteLength = utf8ByteLengthWithReplacement(string)) { - size_t oldSize = bytes.size(); - // UTF-8 expansion can exceed the reserved estimate. - if (!bytes.tryGrow(oldSize + byteLength)) [[unlikely]] { - throwOutOfMemoryError(globalObject, scope); - return false; - } - size_t written = writeUTF8(string, bytes.mutableSpan().subspan(oldSize)); - // The sizer and writer must agree; never expose ungrown (uninitialized) bytes. - ASSERT(written == byteLength); - if (written < byteLength) [[unlikely]] - bytes.shrink(oldSize + written); + if (!appendUTF8WithinStringLimit(string, bytes)) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return false; } return true; } @@ -747,8 +755,7 @@ static WTF::String finishTextAccumulator(JSC::VM& vm, JSGlobalObject* globalObje WTF::String rope = accumulator.rope.toString(); if (rope[0] == 0xFEFF) rope = rope.substring(1); - WTF::CString utf8 = rope.utf8(); - if (!bytes.tryAppend(std::span { reinterpret_cast(utf8.data()), utf8.length() })) [[unlikely]] { + if (!appendUTF8WithinStringLimit(rope, bytes)) [[unlikely]] { releaseAccumulated(); throwOutOfMemoryError(globalObject, scope); return WTF::String(); diff --git a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp index c30cd4d0cea2..f27eae10ffbe 100644 --- a/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp +++ b/src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp @@ -289,8 +289,7 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect if (value.isString()) { String string = asString(value)->value(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto utf8 = string.utf8(); - appended = bytes.tryAppend(std::span { reinterpret_cast(utf8.data()), utf8.length() }); + appended = Bun::WebStreams::appendUTF8WithinStringLimit(string, bytes); } else if (auto* view = dynamicDowncast(value)) { if (!view->isDetached()) appended = bytes.tryAppend(view->span()); @@ -308,8 +307,7 @@ static String finishTextSink(JSC::VM& vm, JSGlobalObject* globalObject, JSDirect String rope = accumulator.rope.toString(); if (rope[0] == 0xFEFF) rope = rope.substring(1); - auto utf8 = rope.utf8(); - if (!bytes.tryAppend(std::span { reinterpret_cast(utf8.data()), utf8.length() })) [[unlikely]] { + if (!Bun::WebStreams::appendUTF8WithinStringLimit(rope, bytes)) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return String(); } diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h index d8dd5042e0d4..f802d92d9a92 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h +++ b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h @@ -597,6 +597,10 @@ JSC::JSValue readableStreamIntoText(JSC::JSGlobalObject*, JSReadableStream*); // JSC::JSValue readableStreamIntoArray(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp // Drop ONE leading U+FEFF, and only on the generic toText path. WTF::String withoutUTF8BOM(const WTF::String&); // userJS: no — BunStreamConsumers.cpp +// Appends `string` UTF-8 encoded (lone surrogates become U+FFFD, matching the chunk +// appenders). Returns false when the result would pass the string limit or the allocation +// fails; WTF's String::utf8() is avoided because its conversion caps abort instead. +bool appendUTF8WithinStringLimit(const WTF::String&, WTF::Vector& bytes); // userJS: no — BunStreamConsumers.cpp // The three *Direct conversion paths. JSC::JSValue readableStreamToTextDirect(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp diff --git a/test/js/web/streams/streams-string-limit.test.ts b/test/js/web/streams/streams-string-limit.test.ts index 91ca86ad0cef..51f41c7ba775 100644 --- a/test/js/web/streams/streams-string-limit.test.ts +++ b/test/js/web/streams/streams-string-limit.test.ts @@ -78,4 +78,57 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past `); expect(result).toEqual(threw); }); + + // WTF::String::utf8() aborts once its conversion needs more than INT32_MAX bytes of + // scratch (2x the length for 8-bit strings), so a mixed stream with a near-limit string + // chunk crashed in the encode even when the real UTF-8 total fit. The consumers now size + // and write through simdutf instead. + test( + "mixed chunks with a big ASCII string chunk resolve when the UTF-8 total fits", + async () => { + const result = await run(` + const big = Buffer.alloc(1200000000, "a").toString(); + const rs = new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array([65])); + c.enqueue(big); + c.close(); + }, + }); + try { + const text = await Bun.readableStreamToText(rs); + console.log("resolved", text.length); + } catch (e) { + console.log("threw", e.name, e.message); + } + `); + expect(result).toEqual({ stdout: "resolved 1200000001\n", stderr: "", exitCode: 0 }); + }, + 60_000, + ); + + test( + "mixed chunks whose UTF-8 expansion passes the limit reject", + async () => { + const result = await run(` + // 1.2e9 U+00E9 chars: a Latin1 string whose UTF-8 form is 2.4e9 bytes. + const big = Buffer.alloc(1200000000, 233).toString("latin1"); + const rs = new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array([65])); + c.enqueue(big); + c.close(); + }, + }); + try { + const text = await Bun.readableStreamToText(rs); + console.log("resolved", text.length); + } catch (e) { + console.log("threw", e.name, e.message); + } + `); + expect(result).toEqual(threw); + }, + 60_000, + ); }); From 46329b02225cf17bccb32ebf72643328ae69e696 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:56:12 +0000 Subject: [PATCH 09/13] Fold the header comment onto one line --- src/jsc/bindings/webcore/streams/WebStreamsInternals.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h index f802d92d9a92..a7a1badfb63f 100644 --- a/src/jsc/bindings/webcore/streams/WebStreamsInternals.h +++ b/src/jsc/bindings/webcore/streams/WebStreamsInternals.h @@ -597,9 +597,7 @@ JSC::JSValue readableStreamIntoText(JSC::JSGlobalObject*, JSReadableStream*); // JSC::JSValue readableStreamIntoArray(JSC::JSGlobalObject*, JSReadableStream*); // userJS: yes — BunStreamConsumers.cpp // Drop ONE leading U+FEFF, and only on the generic toText path. WTF::String withoutUTF8BOM(const WTF::String&); // userJS: no — BunStreamConsumers.cpp -// Appends `string` UTF-8 encoded (lone surrogates become U+FFFD, matching the chunk -// appenders). Returns false when the result would pass the string limit or the allocation -// fails; WTF's String::utf8() is avoided because its conversion caps abort instead. +// Appends `string` UTF-8 encoded (lone surrogates become U+FFFD); false = over the string limit or allocation failed. bool appendUTF8WithinStringLimit(const WTF::String&, WTF::Vector& bytes); // userJS: no — BunStreamConsumers.cpp // The three *Direct conversion paths. From bdace638778d053c1d7f5763efb26627b718a068 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:58:37 +0000 Subject: [PATCH 10/13] [autofix.ci] apply automated fixes --- .../web/streams/streams-string-limit.test.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/test/js/web/streams/streams-string-limit.test.ts b/test/js/web/streams/streams-string-limit.test.ts index 51f41c7ba775..92e7345a7728 100644 --- a/test/js/web/streams/streams-string-limit.test.ts +++ b/test/js/web/streams/streams-string-limit.test.ts @@ -83,10 +83,8 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past // scratch (2x the length for 8-bit strings), so a mixed stream with a near-limit string // chunk crashed in the encode even when the real UTF-8 total fit. The consumers now size // and write through simdutf instead. - test( - "mixed chunks with a big ASCII string chunk resolve when the UTF-8 total fits", - async () => { - const result = await run(` + test("mixed chunks with a big ASCII string chunk resolve when the UTF-8 total fits", async () => { + const result = await run(` const big = Buffer.alloc(1200000000, "a").toString(); const rs = new ReadableStream({ start(c) { @@ -102,15 +100,11 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past console.log("threw", e.name, e.message); } `); - expect(result).toEqual({ stdout: "resolved 1200000001\n", stderr: "", exitCode: 0 }); - }, - 60_000, - ); + expect(result).toEqual({ stdout: "resolved 1200000001\n", stderr: "", exitCode: 0 }); + }, 60_000); - test( - "mixed chunks whose UTF-8 expansion passes the limit reject", - async () => { - const result = await run(` + test("mixed chunks whose UTF-8 expansion passes the limit reject", async () => { + const result = await run(` // 1.2e9 U+00E9 chars: a Latin1 string whose UTF-8 form is 2.4e9 bytes. const big = Buffer.alloc(1200000000, 233).toString("latin1"); const rs = new ReadableStream({ @@ -127,8 +121,6 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past console.log("threw", e.name, e.message); } `); - expect(result).toEqual(threw); - }, - 60_000, - ); + expect(result).toEqual(threw); + }, 60_000); }); From 994ffa769db878c080c45ff47d7fd6fedfe3cbe0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:00:57 +0000 Subject: [PATCH 11/13] Add a direct-stream test for a buffer grown past the limit after write() --- .../web/streams/streams-string-limit.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/js/web/streams/streams-string-limit.test.ts b/test/js/web/streams/streams-string-limit.test.ts index 92e7345a7728..5f3e54ee4af9 100644 --- a/test/js/web/streams/streams-string-limit.test.ts +++ b/test/js/web/streams/streams-string-limit.test.ts @@ -123,4 +123,28 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past `); expect(result).toEqual(threw); }, 60_000); + + // The direct sink records sizes at write() time and reads the spans at end(), so a + // resizable ArrayBuffer grown in between bypasses the up-front estimate check; the + // append itself must reject the oversized span. + test("direct stream with a buffer grown past the limit after write() rejects", async () => { + const result = await run(` + const ab = new ArrayBuffer(8, { maxByteLength: 2400000000 }); + const rs = new ReadableStream({ + type: "direct", + pull(c) { + c.write(new Uint8Array(ab)); + ab.resize(2400000000); + c.end(); + }, + }); + try { + const text = await Bun.readableStreamToText(rs); + console.log("resolved", text.length); + } catch (e) { + console.log("threw", e.name, e.message); + } + `); + expect(result).toEqual(threw); + }); }); From 08917e27246e5834e98114309234d3a22f866902 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:03:37 +0000 Subject: [PATCH 12/13] Assert the resize succeeded before the rejection in the resizable-buffer test --- test/js/web/streams/streams-string-limit.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/js/web/streams/streams-string-limit.test.ts b/test/js/web/streams/streams-string-limit.test.ts index 5f3e54ee4af9..44510bca873d 100644 --- a/test/js/web/streams/streams-string-limit.test.ts +++ b/test/js/web/streams/streams-string-limit.test.ts @@ -135,6 +135,7 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past pull(c) { c.write(new Uint8Array(ab)); ab.resize(2400000000); + console.log("resized", ab.byteLength); c.end(); }, }); @@ -145,6 +146,10 @@ describe.skipIf(!enoughMemory)("text consumers reject binary chunks summing past console.log("threw", e.name, e.message); } `); - expect(result).toEqual(threw); + expect(result).toEqual({ + stdout: "resized 2400000000\nthrew RangeError Out of memory\n", + stderr: "", + exitCode: 0, + }); }); }); From 0189c88f42c2cafb55d8f86609567517babca1b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:45:03 +0000 Subject: [PATCH 13/13] ci: retrigger