From 3df99fc8c040c52cce5e4d090e27856e8d1f5bbb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:50:30 +0000 Subject: [PATCH 1/6] Throw ERR_STRING_TOO_LONG instead of aborting for 2 GiB to 4 GiB strings Blob.text(), Bun.file().text(), fs.readFileSync(path, "utf8") and Blob.json() on 2^31..2^32-1 bytes aborted the process: the Rust-side guards in front of WTF string construction only checked Bun__stringSyntheticAllocationLimit (2^32-1 by default) and missed WTF::StringImpl::MaxLength (2^31-1), which StringImplShape enforces with a RELEASE_ASSERT. Lengths >= 2^32 were already caught. - bun_core::String::max_length() now clamps the synthetic limit to WTF::StringImpl::MaxLength, matching the C++ helpers.h checks - the create_external* guards use > instead of >=, so 2^31-1 (the largest valid WTF length) keeps working - ZigString__toJSONObject checks MaxLength too instead of falling through to JSONParse on a null string - BunString__createUTF8ForJS rejects oversized ASCII input instead of asserting - error messages report the real limit (2147483647, same as the C++ message) instead of 2^32-1 --- src/bun_core/string/mod.rs | 25 ++++++-- src/jsc/ZigString.rs | 2 +- src/jsc/bindings/BunString.cpp | 3 + src/jsc/bindings/bindings.cpp | 4 +- src/jsc/lib.rs | 4 +- test/js/node/fs/fs-oom.test.ts | 59 ++++++++++++++++- test/js/web/fetch/blob-oom.test.ts | 100 ++++++++++++++++++++++++++--- 7 files changed, 176 insertions(+), 21 deletions(-) diff --git a/src/bun_core/string/mod.rs b/src/bun_core/string/mod.rs index 0330f9f437e9..fbfff3a47bdf 100644 --- a/src/bun_core/string/mod.rs +++ b/src/bun_core/string/mod.rs @@ -274,7 +274,7 @@ impl String { /// that calls `callback(ctx, buffer, len)` when the impl is destroyed. /// /// External strings are WTF strings whose bytes live elsewhere; `bytes` is - /// borrowed (not copied). If `bytes.len() >= max_length()`, `callback` is + /// borrowed (not copied). If `bytes.len() > max_length()`, `callback` is /// invoked immediately and a `dead` string is returned. /// /// `Ctx` must be a pointer-sized type (raw pointer or `&T`); enforced by @@ -299,7 +299,7 @@ impl String { } let () = AssertPtrSized::::OK; debug_assert!(!bytes.is_empty()); - if bytes.len() >= Self::max_length() { + if bytes.len() > Self::max_length() { callback(ctx, bytes.as_ptr().cast_mut().cast::(), bytes.len()); return Self::DEAD; } @@ -336,11 +336,14 @@ impl String { s } - /// Max `WTF::StringImpl` length (in characters, not bytes). - /// Reads the process-wide [`STRING_ALLOCATION_LIMIT`] data slot. + /// Max `WTF::StringImpl` length (in characters, not bytes): the + /// process-wide [`STRING_ALLOCATION_LIMIT`] test knob clamped to + /// [`WTF_STRING_MAX_LENGTH`]. #[inline] pub fn max_length() -> usize { - STRING_ALLOCATION_LIMIT.load(Ordering::Relaxed) + STRING_ALLOCATION_LIMIT + .load(Ordering::Relaxed) + .min(WTF_STRING_MAX_LENGTH) } /// `bun.String.createStaticExternal` — wraps `bytes` in a @@ -405,7 +408,7 @@ impl String { if bytes.is_empty() { return Self::EMPTY; } - if bytes.len() >= Self::max_length() { + if bytes.len() > Self::max_length() { return Self::DEAD; } // Do NOT call `into_boxed_slice()` — when `len < capacity` it issues a @@ -424,7 +427,7 @@ impl String { if bytes.is_empty() { return Self::EMPTY; } - if bytes.len() >= Self::max_length() { + if bytes.len() > Self::max_length() { return Self::DEAD; } // See `create_external_globally_allocated_latin1` — avoid the @@ -2101,6 +2104,14 @@ pub mod lexer_tables { #[unsafe(export_name = "Bun__stringSyntheticAllocationLimit")] pub static STRING_ALLOCATION_LIMIT: AtomicUsize = AtomicUsize::new(u32::MAX as usize); +/// Mirror of `WTF::StringImpl::MaxLength` (`INT32_MAX`): the hard cap on WTF +/// string character count, enforced by `RELEASE_ASSERT` in the +/// `StringImplShape` constructors. [`STRING_ALLOCATION_LIMIT`] alone defaults +/// to `u32::MAX`, so guards that only consult it let lengths in +/// `2^31..2^32` through to an uncatchable abort; [`String::max_length`] +/// clamps to this. +pub const WTF_STRING_MAX_LENGTH: usize = i32::MAX as usize; + // ────────────────────────────────────────────────────────────────────────── // move-in: printer (MOVE_DOWN ← `bun_js_printer`) // diff --git a/src/jsc/ZigString.rs b/src/jsc/ZigString.rs index 1a82c01285c2..3058fa4804a8 100644 --- a/src/jsc/ZigString.rs +++ b/src/jsc/ZigString.rs @@ -43,7 +43,7 @@ pub unsafe fn to_external_u16(ptr: *const u16, len: usize, global: &JSGlobalObje let _ = global .err( crate::ErrorCode::STRING_TOO_LONG, - format_args!("Cannot create a string longer than 2^32-1 characters"), + format_args!("Cannot create a string longer than 2147483647 characters"), ) .throw(); return JSValue::ZERO; diff --git a/src/jsc/bindings/BunString.cpp b/src/jsc/bindings/BunString.cpp index 08d00c72f9ad..282d142f820f 100644 --- a/src/jsc/bindings/BunString.cpp +++ b/src/jsc/bindings/BunString.cpp @@ -94,6 +94,9 @@ extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue BunString__createUT return JSValue::encode(jsEmptyString(vm)); } if (simdutf::validate_ascii(ptr, length)) { + if (length > WTF::String::MaxLength) [[unlikely]] { + return Bun::ERR::STRING_TOO_LONG(scope, globalObject); + } return JSValue::encode(jsString(vm, WTF::String(std::span(reinterpret_cast(ptr), length)))); } diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 4069e991a080..b3f4c90e1823 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2530,8 +2530,8 @@ extern "C" JSC::EncodedJSValue ZigString__toJSONObject(const ZigString* strPtr, if (str.isNull()) { // isNull() will be true for empty strings and for strings which are too long. // So we need to check the length is plausibly due to a long string. - if (strPtr->len > Bun__stringSyntheticAllocationLimit) { - scope.throwException(globalObject, Bun::createError(globalObject, Bun::ErrorCode::ERR_STRING_TOO_LONG, "Cannot parse a JSON string longer than 2^32-1 characters"_s)); + if (strPtr->len > Bun__stringSyntheticAllocationLimit || strPtr->len > WTF::String::MaxLength) { + scope.throwException(globalObject, Bun::createError(globalObject, Bun::ErrorCode::ERR_STRING_TOO_LONG, "Cannot parse a JSON string longer than 2147483647 characters"_s)); return {}; } } diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index 0fb901df1940..b59ace1f259f 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -1649,7 +1649,7 @@ impl ZigStringJsc for bun_core::ZigString { let _ = global .err( crate::ErrorCode::STRING_TOO_LONG, - format_args!("Cannot create a string longer than 2^32-1 characters"), + format_args!("Cannot create a string longer than 2147483647 characters"), ) .throw(); return JSValue::ZERO; @@ -1684,7 +1684,7 @@ impl ZigStringJsc for bun_core::ZigString { let _ = global .err( crate::ErrorCode::STRING_TOO_LONG, - format_args!("Cannot create a string longer than 2^32-1 characters"), + format_args!("Cannot create a string longer than 2147483647 characters"), ) .throw(); return JSValue::ZERO; diff --git a/test/js/node/fs/fs-oom.test.ts b/test/js/node/fs/fs-oom.test.ts index ea453cd11874..b1f547deac9b 100644 --- a/test/js/node/fs/fs-oom.test.ts +++ b/test/js/node/fs/fs-oom.test.ts @@ -1,6 +1,6 @@ import { memfd_create, setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { closeSync, readFileSync, writeFileSync, writeSync } from "fs"; +import { closeSync, readFileSync, truncateSync, writeFileSync, writeSync } from "fs"; import { bunEnv, bunExe, isASAN, isLinux, isPosix, tempDir } from "harness"; import { join } from "path"; setSyntheticAllocationLimitForTesting(128 * 1024 * 1024); @@ -48,6 +48,63 @@ if (isLinux) { }); } +// Files in [2^31, 2^32) bytes used to abort the process when decoded to a +// string: the guards in front of WTF string construction only checked the +// synthetic allocation limit (2^32 - 1 by default) and missed +// WTF::StringImpl::MaxLength (2^31 - 1), tripping a RELEASE_ASSERT in +// StringImplShape. The fs layer reports the dead string as ENOMEM (it speaks +// errno), matching the existing >= 2^32 and /dev/zero behavior above. +// 2^31 - 1 is the largest length WTF accepts and must keep working. The file +// is sparse so only the in-memory read costs 2 GiB; each case runs in a +// subprocess to keep the peak away from the test runner. +describe("readFileSync at the 2 GiB string limit", () => { + const spawnRead = async (size: number) => { + using dir = tempDir("readfile-2gib", {}); + const file = join(String(dir), "big.txt"); + writeFileSync(file, "x"); + truncateSync(file, size); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + try { + const s = require("fs").readFileSync(${JSON.stringify(file)}, "utf8"); + console.log(JSON.stringify({ length: s.length })); + } catch (e) { + console.log(JSON.stringify({ name: e.name, code: e.code })); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { result: JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode })), exitCode }; + }; + + test( + "2^31 bytes throws ENOMEM instead of aborting", + async () => { + const { result, exitCode } = await spawnRead(2 ** 31); + expect(result).toEqual({ name: "Error", code: "ENOMEM" }); + expect(exitCode).toBe(0); + }, + 180_000, + ); + + test( + "2^31 - 1 bytes still decodes", + async () => { + const { result, exitCode } = await spawnRead(2 ** 31 - 1); + expect(result).toEqual({ length: 2 ** 31 - 1 }); + expect(exitCode).toBe(0); + }, + 180_000, + ); +}); + // The UTF-8 -> UTF-16 converters behind `fs.readFile*(.., "utf8")`, // `Buffer.prototype.toString("utf8")` and `TextDecoder.decode` must surface a // failed output-buffer allocation as a catchable error, never a process abort diff --git a/test/js/web/fetch/blob-oom.test.ts b/test/js/web/fetch/blob-oom.test.ts index dc7dd16bc092..658620722474 100644 --- a/test/js/web/fetch/blob-oom.test.ts +++ b/test/js/web/fetch/blob-oom.test.ts @@ -1,7 +1,7 @@ import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { unlinkSync } from "fs"; -import { tempDirWithFiles } from "harness"; +import { truncateSync, unlinkSync, writeFileSync } from "fs"; +import { bunEnv, bunExe, tempDir, tempDirWithFiles } from "harness"; import path from "path"; describe("Memory", () => { beforeAll(() => { @@ -20,13 +20,13 @@ describe("Memory", () => { test(".json() should throw an OOM without crashing the process.", () => { const array = [buf, buf, buf, buf, buf, buf, buf, buf, buf]; expect(async () => await new Blob(array).json()).toThrow( - "Cannot parse a JSON string longer than 2^32-1 characters", + "Cannot parse a JSON string longer than 2147483647 characters", ); }); test(".text() should throw an OOM without crashing the process.", () => { const array = [buf, buf, buf, buf, buf, buf, buf, buf, buf]; - expect(async () => await new Blob(array).text()).toThrow("Cannot create a string longer than 2^32-1 characters"); + expect(async () => await new Blob(array).text()).toThrow("Cannot create a string longer than 2147483647 characters"); }); test(".bytes() should throw an OOM without crashing the process.", () => { @@ -52,7 +52,7 @@ describe("Memory", () => { test(".text() should throw an OOM without crashing the process.", () => { expect(async () => await new Response(blob).text()).toThrow( - "Cannot create a string longer than 2^32-1 characters", + "Cannot create a string longer than 2147483647 characters", ); }); @@ -66,7 +66,7 @@ describe("Memory", () => { test(".json() should throw an OOM without crashing the process.", async () => { expect(async () => await new Response(blob).json()).toThrow( - "Cannot parse a JSON string longer than 2^32-1 characters", + "Cannot parse a JSON string longer than 2147483647 characters", ); }); }); @@ -83,7 +83,7 @@ describe("Memory", () => { test(".text() should throw an OOM without crashing the process.", () => { expect(async () => await new Request("http://localhost:3000", { body: blob }).text()).toThrow( - "Cannot create a string longer than 2^32-1 characters", + "Cannot create a string longer than 2147483647 characters", ); }); @@ -97,7 +97,7 @@ describe("Memory", () => { test(".json() should throw an OOM without crashing the process.", async () => { expect(async () => await new Request("http://localhost:3000", { body: blob }).json()).toThrow( - "Cannot parse a JSON string longer than 2^32-1 characters", + "Cannot parse a JSON string longer than 2147483647 characters", ); }); }); @@ -142,3 +142,87 @@ describe("Bun.file", () => { expect(async () => await Bun.file(tmpFile).arrayBuffer()).not.toThrow(); }); }); + +// Byte lengths in [2^31, 2^32) used to abort the process instead of throwing: +// the Rust-side guards in front of WTF string construction only checked the +// synthetic allocation limit (2^32 - 1 by default) and missed +// WTF::StringImpl::MaxLength (2^31 - 1), tripping +// "ASSERTION FAILED: data.size() <= MaxLength" / a RELEASE_ASSERT in +// StringImplShape. Lengths >= 2^32 were already caught. These allocate a real +// 2 GiB, so each case runs in a subprocess to keep the peak away from the +// test runner. +describe("byte sources at the 2 GiB string limit", () => { + test( + "Blob.text() and Blob.json() at 2^31 bytes throw ERR_STRING_TOO_LONG instead of aborting", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const results = []; + const report = e => ({ name: e.name, code: e.code, message: e.message }); + const blob = new Blob([new Uint8Array(2 ** 31)]); + await blob.text().then(() => results.push("TEXT_UNEXPECTED_SUCCESS"), e => results.push(report(e))); + await blob.json().then(() => results.push("JSON_UNEXPECTED_SUCCESS"), e => results.push(report(e))); + console.log(JSON.stringify(results)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual([ + { + name: "Error", + code: "ERR_STRING_TOO_LONG", + message: "Cannot create a string longer than 2147483647 characters", + }, + { + name: "Error", + code: "ERR_STRING_TOO_LONG", + message: "Cannot parse a JSON string longer than 2147483647 characters", + }, + ]); + expect(exitCode).toBe(0); + }, + 180_000, + ); + + test( + "Bun.file().text() at 2^31 bytes throws ERR_STRING_TOO_LONG instead of aborting", + async () => { + using dir = tempDir("blob-2gib", {}); + const file = path.join(String(dir), "big.txt"); + // Sparse where the filesystem supports it; reads back as 'x' + NUL bytes. + writeFileSync(file, "x"); + truncateSync(file, 2 ** 31); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const results = []; + const report = e => ({ name: e.name, code: e.code, message: e.message }); + await Bun.file(${JSON.stringify(file)}).text().then(() => results.push("UNEXPECTED_SUCCESS"), e => results.push(report(e))); + console.log(JSON.stringify(results)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual([ + { + name: "Error", + code: "ERR_STRING_TOO_LONG", + message: "Cannot create a string longer than 2147483647 characters", + }, + ]); + expect(exitCode).toBe(0); + }, + 180_000, + ); +}); From 3908e24d093fe39931640d511407f44376af406a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:52:18 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- test/js/node/fs/fs-oom.test.ts | 28 +++---- test/js/web/fetch/blob-oom.test.ts | 118 ++++++++++++++--------------- 2 files changed, 66 insertions(+), 80 deletions(-) diff --git a/test/js/node/fs/fs-oom.test.ts b/test/js/node/fs/fs-oom.test.ts index b1f547deac9b..029432a119bb 100644 --- a/test/js/node/fs/fs-oom.test.ts +++ b/test/js/node/fs/fs-oom.test.ts @@ -84,25 +84,17 @@ describe("readFileSync at the 2 GiB string limit", () => { return { result: JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode })), exitCode }; }; - test( - "2^31 bytes throws ENOMEM instead of aborting", - async () => { - const { result, exitCode } = await spawnRead(2 ** 31); - expect(result).toEqual({ name: "Error", code: "ENOMEM" }); - expect(exitCode).toBe(0); - }, - 180_000, - ); + test("2^31 bytes throws ENOMEM instead of aborting", async () => { + const { result, exitCode } = await spawnRead(2 ** 31); + expect(result).toEqual({ name: "Error", code: "ENOMEM" }); + expect(exitCode).toBe(0); + }, 180_000); - test( - "2^31 - 1 bytes still decodes", - async () => { - const { result, exitCode } = await spawnRead(2 ** 31 - 1); - expect(result).toEqual({ length: 2 ** 31 - 1 }); - expect(exitCode).toBe(0); - }, - 180_000, - ); + test("2^31 - 1 bytes still decodes", async () => { + const { result, exitCode } = await spawnRead(2 ** 31 - 1); + expect(result).toEqual({ length: 2 ** 31 - 1 }); + expect(exitCode).toBe(0); + }, 180_000); }); // The UTF-8 -> UTF-16 converters behind `fs.readFile*(.., "utf8")`, diff --git a/test/js/web/fetch/blob-oom.test.ts b/test/js/web/fetch/blob-oom.test.ts index 658620722474..c7f7b39f56a0 100644 --- a/test/js/web/fetch/blob-oom.test.ts +++ b/test/js/web/fetch/blob-oom.test.ts @@ -26,7 +26,9 @@ describe("Memory", () => { test(".text() should throw an OOM without crashing the process.", () => { const array = [buf, buf, buf, buf, buf, buf, buf, buf, buf]; - expect(async () => await new Blob(array).text()).toThrow("Cannot create a string longer than 2147483647 characters"); + expect(async () => await new Blob(array).text()).toThrow( + "Cannot create a string longer than 2147483647 characters", + ); }); test(".bytes() should throw an OOM without crashing the process.", () => { @@ -152,14 +154,12 @@ describe("Bun.file", () => { // 2 GiB, so each case runs in a subprocess to keep the peak away from the // test runner. describe("byte sources at the 2 GiB string limit", () => { - test( - "Blob.text() and Blob.json() at 2^31 bytes throw ERR_STRING_TOO_LONG instead of aborting", - async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` + test("Blob.text() and Blob.json() at 2^31 bytes throw ERR_STRING_TOO_LONG instead of aborting", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` const results = []; const report = e => ({ name: e.name, code: e.code, message: e.message }); const blob = new Blob([new Uint8Array(2 ** 31)]); @@ -167,62 +167,56 @@ describe("byte sources at the 2 GiB string limit", () => { await blob.json().then(() => results.push("JSON_UNEXPECTED_SUCCESS"), e => results.push(report(e))); console.log(JSON.stringify(results)); `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual([ - { - name: "Error", - code: "ERR_STRING_TOO_LONG", - message: "Cannot create a string longer than 2147483647 characters", - }, - { - name: "Error", - code: "ERR_STRING_TOO_LONG", - message: "Cannot parse a JSON string longer than 2147483647 characters", - }, - ]); - expect(exitCode).toBe(0); - }, - 180_000, - ); - - test( - "Bun.file().text() at 2^31 bytes throws ERR_STRING_TOO_LONG instead of aborting", - async () => { - using dir = tempDir("blob-2gib", {}); - const file = path.join(String(dir), "big.txt"); - // Sparse where the filesystem supports it; reads back as 'x' + NUL bytes. - writeFileSync(file, "x"); - truncateSync(file, 2 ** 31); - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual([ + { + name: "Error", + code: "ERR_STRING_TOO_LONG", + message: "Cannot create a string longer than 2147483647 characters", + }, + { + name: "Error", + code: "ERR_STRING_TOO_LONG", + message: "Cannot parse a JSON string longer than 2147483647 characters", + }, + ]); + expect(exitCode).toBe(0); + }, 180_000); + + test("Bun.file().text() at 2^31 bytes throws ERR_STRING_TOO_LONG instead of aborting", async () => { + using dir = tempDir("blob-2gib", {}); + const file = path.join(String(dir), "big.txt"); + // Sparse where the filesystem supports it; reads back as 'x' + NUL bytes. + writeFileSync(file, "x"); + truncateSync(file, 2 ** 31); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` const results = []; const report = e => ({ name: e.name, code: e.code, message: e.message }); await Bun.file(${JSON.stringify(file)}).text().then(() => results.push("UNEXPECTED_SUCCESS"), e => results.push(report(e))); console.log(JSON.stringify(results)); `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual([ - { - name: "Error", - code: "ERR_STRING_TOO_LONG", - message: "Cannot create a string longer than 2147483647 characters", - }, - ]); - expect(exitCode).toBe(0); - }, - 180_000, - ); + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout.trim() || JSON.stringify({ stdout, stderr, exitCode }))).toEqual([ + { + name: "Error", + code: "ERR_STRING_TOO_LONG", + message: "Cannot create a string longer than 2147483647 characters", + }, + ]); + expect(exitCode).toBe(0); + }, 180_000); }); From edd9c46e8f1b836bbf35865d4414b89b0be67d76 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:52:58 +0000 Subject: [PATCH 3/6] Tighten doc comments on the string length cap --- src/bun_core/string/mod.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/bun_core/string/mod.rs b/src/bun_core/string/mod.rs index fbfff3a47bdf..bf3019b0a33d 100644 --- a/src/bun_core/string/mod.rs +++ b/src/bun_core/string/mod.rs @@ -336,9 +336,8 @@ impl String { s } - /// Max `WTF::StringImpl` length (in characters, not bytes): the - /// process-wide [`STRING_ALLOCATION_LIMIT`] test knob clamped to - /// [`WTF_STRING_MAX_LENGTH`]. + /// Max `WTF::StringImpl` length (in characters, not bytes): + /// [`STRING_ALLOCATION_LIMIT`] clamped to [`WTF_STRING_MAX_LENGTH`]. #[inline] pub fn max_length() -> usize { STRING_ALLOCATION_LIMIT @@ -2104,12 +2103,8 @@ pub mod lexer_tables { #[unsafe(export_name = "Bun__stringSyntheticAllocationLimit")] pub static STRING_ALLOCATION_LIMIT: AtomicUsize = AtomicUsize::new(u32::MAX as usize); -/// Mirror of `WTF::StringImpl::MaxLength` (`INT32_MAX`): the hard cap on WTF -/// string character count, enforced by `RELEASE_ASSERT` in the -/// `StringImplShape` constructors. [`STRING_ALLOCATION_LIMIT`] alone defaults -/// to `u32::MAX`, so guards that only consult it let lengths in -/// `2^31..2^32` through to an uncatchable abort; [`String::max_length`] -/// clamps to this. +/// Mirror of `WTF::StringImpl::MaxLength` (`INT32_MAX`), which C++ enforces +/// with `RELEASE_ASSERT` in the `StringImplShape` constructors. pub const WTF_STRING_MAX_LENGTH: usize = i32::MAX as usize; // ────────────────────────────────────────────────────────────────────────── From 6f497339972e8b98400087b345f04758f89ff752 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:07:43 +0000 Subject: [PATCH 4/6] Skip the 2 GiB tests on machines with less than 10 GiB of memory --- test/js/node/fs/fs-oom.test.ts | 6 ++++-- test/js/web/fetch/blob-oom.test.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/test/js/node/fs/fs-oom.test.ts b/test/js/node/fs/fs-oom.test.ts index 029432a119bb..3547611ddf2c 100644 --- a/test/js/node/fs/fs-oom.test.ts +++ b/test/js/node/fs/fs-oom.test.ts @@ -2,6 +2,7 @@ import { memfd_create, setSyntheticAllocationLimitForTesting } from "bun:interna import { describe, expect, test } from "bun:test"; import { closeSync, readFileSync, truncateSync, writeFileSync, writeSync } from "fs"; import { bunEnv, bunExe, isASAN, isLinux, isPosix, tempDir } from "harness"; +import os from "node:os"; import { join } from "path"; setSyntheticAllocationLimitForTesting(128 * 1024 * 1024); @@ -56,8 +57,9 @@ if (isLinux) { // errno), matching the existing >= 2^32 and /dev/zero behavior above. // 2^31 - 1 is the largest length WTF accepts and must keep working. The file // is sparse so only the in-memory read costs 2 GiB; each case runs in a -// subprocess to keep the peak away from the test runner. -describe("readFileSync at the 2 GiB string limit", () => { +// subprocess to keep the peak away from the test runner, and the block skips +// on small machines (same gate as buffer.test.js's 4 GiB case). +describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("readFileSync at the 2 GiB string limit", () => { const spawnRead = async (size: number) => { using dir = tempDir("readfile-2gib", {}); const file = join(String(dir), "big.txt"); diff --git a/test/js/web/fetch/blob-oom.test.ts b/test/js/web/fetch/blob-oom.test.ts index c7f7b39f56a0..c81f55246089 100644 --- a/test/js/web/fetch/blob-oom.test.ts +++ b/test/js/web/fetch/blob-oom.test.ts @@ -2,6 +2,7 @@ import { setSyntheticAllocationLimitForTesting } from "bun:internal-for-testing" import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { truncateSync, unlinkSync, writeFileSync } from "fs"; import { bunEnv, bunExe, tempDir, tempDirWithFiles } from "harness"; +import os from "node:os"; import path from "path"; describe("Memory", () => { beforeAll(() => { @@ -152,8 +153,9 @@ describe("Bun.file", () => { // "ASSERTION FAILED: data.size() <= MaxLength" / a RELEASE_ASSERT in // StringImplShape. Lengths >= 2^32 were already caught. These allocate a real // 2 GiB, so each case runs in a subprocess to keep the peak away from the -// test runner. -describe("byte sources at the 2 GiB string limit", () => { +// test runner, and the block skips on small machines (same gate as +// buffer.test.js's 4 GiB case). +describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("byte sources at the 2 GiB string limit", () => { test("Blob.text() and Blob.json() at 2^31 bytes throw ERR_STRING_TOO_LONG instead of aborting", async () => { await using proc = Bun.spawn({ cmd: [ From fb170def19410fbc49931424489bd4d1fb553d6f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:17:45 +0000 Subject: [PATCH 5/6] Correct the SAFETY comment bound in create_external --- src/bun_core/string/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bun_core/string/mod.rs b/src/bun_core/string/mod.rs index bf3019b0a33d..19f1c54608ee 100644 --- a/src/bun_core/string/mod.rs +++ b/src/bun_core/string/mod.rs @@ -322,7 +322,7 @@ impl String { ExternalStringImplFreeFunction, extern "C" fn(*mut c_void, *mut c_void, usize), >(callback) }); - // SAFETY: bytes describes a valid slice; len < max_length checked. + // SAFETY: bytes describes a valid slice; len <= max_length checked. let s = unsafe { BunString__createExternal( bytes.as_ptr(), From ad378c8aeede6b6b34706c66c5cb02103f5965dd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:21:55 +0000 Subject: [PATCH 6/6] Drop per-test timeouts from the 2 GiB tests --- test/js/node/fs/fs-oom.test.ts | 4 ++-- test/js/web/fetch/blob-oom.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/js/node/fs/fs-oom.test.ts b/test/js/node/fs/fs-oom.test.ts index 3547611ddf2c..b2951b6756c8 100644 --- a/test/js/node/fs/fs-oom.test.ts +++ b/test/js/node/fs/fs-oom.test.ts @@ -90,13 +90,13 @@ describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("readFileSync at the 2 GiB strin const { result, exitCode } = await spawnRead(2 ** 31); expect(result).toEqual({ name: "Error", code: "ENOMEM" }); expect(exitCode).toBe(0); - }, 180_000); + }); test("2^31 - 1 bytes still decodes", async () => { const { result, exitCode } = await spawnRead(2 ** 31 - 1); expect(result).toEqual({ length: 2 ** 31 - 1 }); expect(exitCode).toBe(0); - }, 180_000); + }); }); // The UTF-8 -> UTF-16 converters behind `fs.readFile*(.., "utf8")`, diff --git a/test/js/web/fetch/blob-oom.test.ts b/test/js/web/fetch/blob-oom.test.ts index c81f55246089..03c54b6d2b90 100644 --- a/test/js/web/fetch/blob-oom.test.ts +++ b/test/js/web/fetch/blob-oom.test.ts @@ -188,7 +188,7 @@ describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("byte sources at the 2 GiB strin }, ]); expect(exitCode).toBe(0); - }, 180_000); + }); test("Bun.file().text() at 2^31 bytes throws ERR_STRING_TOO_LONG instead of aborting", async () => { using dir = tempDir("blob-2gib", {}); @@ -220,5 +220,5 @@ describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("byte sources at the 2 GiB strin }, ]); expect(exitCode).toBe(0); - }, 180_000); + }); });