From d4dfedc0c8b4e779b31893352e5229a295694c08 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:55:29 +0000 Subject: [PATCH 01/21] zstd: stop zero-filling the output buffers of Bun.zstdCompress/zstdDecompress decompress_alloc and the Bun.zstdCompress{,Sync} bodies allocated their output with vec![0u8; n] / resize(n, 0) and then let zstd overwrite it, paying for a memset over the whole output (the compress bound, or the frame's content size) on every call. Reserve the capacity and let zstd write into it instead, the same way compress_append already works. The streaming decoder used for frames without a content size, or with one above the 16 MiB preallocation limit, now also starts from a bounded guess (the input size, or the limit) instead of doubling up from 4 KiB, which copied about as many bytes as the result is long. --- src/runtime/api/BunObject.rs | 73 ++++++++++++++--------------------- src/zstd/lib.rs | 66 ++++++++++++++++++++++++------- test/js/bun/util/zstd.test.ts | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 58 deletions(-) diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 5730c82c3d7e..6bdf53ab20ba 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2752,30 +2752,22 @@ pub mod JSZstd { let buffer = coerce_compress_buffer(global_this, buffer_value)?; let input = buffer.slice(); - // Calculate max compressed size - let max_size = bun_zstd::compress_bound(input.len()); - // The zero-fill - // here is output-irrelevant (zstd overwrites the prefix it reports). - // PERF: use Box::new_uninit_slice — profile if hot. - let mut output = vec![0u8; max_size]; - - // Perform compression with context - let compressed_size = match bun_zstd::compress(&mut output, input, Some(level)) { - bun_zstd::Result::Success(size) => size, - bun_zstd::Result::Err(err) => { - drop(output); - return Err(global_this - .err(jsc::ErrCode::ZSTD, format_args!("{}", bstr::BStr::new(err))) - .throw()); - } - }; - - // Resize to actual compressed size - if compressed_size < output.len() { - output.truncate(compressed_size); - output.shrink_to_fit(); + // zstd only initializes the bytes it reports, so the bound is reserved + // rather than zero-filled (a second pass over the whole buffer). + let mut output: Vec = Vec::with_capacity(bun_zstd::compress_bound(input.len())); + + if let bun_zstd::Result::Err(err) = + bun_zstd::compress_append(&mut output, input, Some(level)) + { + drop(output); + return Err(global_this + .err(jsc::ErrCode::ZSTD, format_args!("{}", bstr::BStr::new(err))) + .throw()); } + // Release the slack between the compressed size and the bound. + output.shrink_to_fit(); + JSValue::create_buffer(global_this, output.leak()) } @@ -2828,32 +2820,23 @@ pub mod JSZstd { if this.is_compress { let max_size = bun_zstd::compress_bound(input.len()); - // Surface OOM as a rejected promise instead of aborting. The - // zero-fill is output-irrelevant (zstd overwrites the prefix it reports). - let mut output: Vec = Vec::new(); - if output.try_reserve_exact(max_size).is_err() { + // Surface OOM as a rejected promise instead of aborting. As in + // `compress_sync`, the bound is only reserved, not zero-filled. + if this.output.try_reserve_exact(max_size).is_err() { this.error_message = Some(b"Out of memory"); return Some(done); } - output.resize(max_size, 0); - this.output = output; - - this.output = match bun_zstd::compress(&mut this.output, input, Some(this.level)) { - bun_zstd::Result::Success(size) => 'blk: { - if size < this.output.len() { - let mut out = core::mem::take(&mut this.output); - out.truncate(size); - out.shrink_to_fit(); - break 'blk out; - } - break 'blk core::mem::take(&mut this.output); - } - bun_zstd::Result::Err(err) => { - this.output = Vec::new(); - this.error_message = Some(err); - return Some(done); - } - }; + + if let bun_zstd::Result::Err(err) = + bun_zstd::compress_append(&mut this.output, input, Some(this.level)) + { + this.output = Vec::new(); + this.error_message = Some(err); + return Some(done); + } + + // Release the slack between the compressed size and the bound. + this.output.shrink_to_fit(); } else { this.output = match bun_zstd::decompress_alloc(input) { Ok(v) => v, diff --git a/src/zstd/lib.rs b/src/zstd/lib.rs index 73021c7b136d..0c568e45d03a 100644 --- a/src/zstd/lib.rs +++ b/src/zstd/lib.rs @@ -290,6 +290,32 @@ pub fn decompress(dest: &mut [u8], src: &[u8]) -> Result { Result::Success(result) } +/// [`decompress`] into `out`'s spare capacity (append mode). The spare +/// capacity is the output bound handed to zstd, so callers reserve the +/// decompressed size first; zstd fails with `dstSize_tooSmall` when the +/// frames do not fit. On success `out.len()` is advanced by the number of +/// bytes written. +fn decompress_append(out: &mut Vec, src: &[u8]) -> Result { + let spare = out.spare_capacity_mut(); + // SAFETY: spare/src are valid for their lengths; ZSTD_decompress reads src + // and writes at most `spare.len()` bytes into spare. + let rc = unsafe { + c::ZSTD_decompress( + spare.as_mut_ptr().cast::(), + spare.len(), + src.as_ptr().cast::(), + src.len(), + ) + }; + if c::ZSTD_isError(rc) != 0 { + // SAFETY: ZSTD_getErrorName returns a static NUL-terminated string. + return Result::Err(unsafe { ZStr::from_c_ptr(c::ZSTD_getErrorName(rc)) }); + } + // SAFETY: zstd has initialized `rc` bytes at the start of spare. + unsafe { bun_core::vec::commit_spare(out, rc) }; + Result::Success(rc) +} + /// Decompress data, automatically allocating the output buffer. /// Returns owned slice that must be freed by the caller. /// Handles both frames with known and unknown content sizes. @@ -309,7 +335,16 @@ pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> // 1. Content size is unknown, OR // 2. Reported size exceeds safety limit (to prevent malicious inputs claiming huge sizes) if size == ZSTD_CONTENTSIZE_UNKNOWN || size > MAX_PREALLOCATE_SIZE { - let mut list: Vec = Vec::new(); + // Doubling up from one step copies about as many bytes as the result + // is long, so start from a bounded guess instead: the header size is + // untrusted and gets at most what the fast path below would allocate; + // without one, a frame's output is rarely smaller than its input. + let initial_capacity = if size == ZSTD_CONTENTSIZE_UNKNOWN { + src.len().clamp(STREAMING_OUTPUT_STEP, MAX_PREALLOCATE_SIZE) + } else { + MAX_PREALLOCATE_SIZE + }; + let mut list: Vec = Vec::with_capacity(initial_capacity); let mut reader = ZstdReaderArrayList::init(src, &mut list)?; reader.read_all(true)?; @@ -317,15 +352,14 @@ pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> return Ok(list); } - // Fast path: size is known and within reasonable limits - let mut output = vec![0u8; size]; + // Fast path: size is known and within reasonable limits. zstd writes every + // byte it reports, so the buffer is handed over uninitialized; zero-filling + // it first would cost a second pass over the whole output. + let mut output: Vec = Vec::with_capacity(size); - match decompress(&mut output, src) { - Result::Success(actual_size) => { - output.truncate(actual_size); - Ok(output) - } - // `output` is freed by Drop above. + match decompress_append(&mut output, src) { + Result::Success(_) => Ok(output), + // `output` is freed by Drop. Result::Err(_) => Err(ZstdError::DecompressionFailed), } } @@ -337,6 +371,11 @@ pub fn get_decompressed_size(src: &[u8]) -> usize { pub use bun_core::compress::State; +/// Minimum spare output capacity handed to `ZSTD_decompressStream` per call. +/// The whole spare capacity is offered, so the `Vec` doubles past this as the +/// output grows. +const STREAMING_OUTPUT_STEP: usize = 4096; + struct ZstdReaderArrayList<'a> { pub(crate) input: &'a [u8], // We operate on the caller's Vec directly via the `&mut` borrow. @@ -425,7 +464,8 @@ impl<'a> ZstdReaderArrayList<'a> { // SAFETY: write-only spare; ZSTD_decompressStream initializes the // first `out_buf.pos` bytes. - let spare = unsafe { bun_core::vec::reserve_spare_bytes(self.list_ptr, 4096) }; + let spare = + unsafe { bun_core::vec::reserve_spare_bytes(self.list_ptr, STREAMING_OUTPUT_STEP) }; let mut in_buf = c::ZSTD_inBuffer { src: next_in.as_ptr().cast::(), size: next_in.len(), @@ -540,8 +580,8 @@ impl StreamingDecoder { } /// Consume all of `input`, appending decompressed bytes to `out` - /// (growing in 4096-byte steps). Returns `ShortRead` when more input is - /// required and `is_done` is false. + /// (growing in `STREAMING_OUTPUT_STEP` steps). Returns `ShortRead` when + /// more input is required and `is_done` is false. pub fn decompress( &mut self, input: &[u8], @@ -573,7 +613,7 @@ impl StreamingDecoder { return Err(ZstdError::ZstdDecompressionError); } - out.reserve(4096); + out.reserve(STREAMING_OUTPUT_STEP); let spare = out.spare_capacity_mut(); let mut in_buf = c::ZSTD_inBuffer { src: next_in.as_ptr().cast::(), diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index 9529b63fd99b..990ee9b9cbbe 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -252,6 +252,74 @@ describe("Zstandard compression", async () => { } }); +// zstdDecompressSync sizes its output from the frame header when the content size is +// present and at most 16 MiB. Anything else is decoded in a stream into a buffer that +// starts at a guess (the input size, or the 16 MiB limit) and grows as needed. +describe("decompressing frames whose size is not known up front", () => { + const MiB = 1024 * 1024; + + // CompressionStream does not know the total size when it writes the frame header. + async function compressWithoutContentSize(data: Uint8Array): Promise { + const frame = await new Response(new Response(data).body!.pipeThrough(new CompressionStream("zstd"))).bytes(); + // RFC 8878 3.1.1.1.1: with the Frame_Content_Size_flag (bits 7-6) and the + // Single_Segment_flag (bit 5) both clear, the header carries no content size. + expect(frame[4] & 0xe0).toBe(0); + return frame; + } + + function patternBytes(length: number): Buffer { + // Incompressible, but deterministic: xorshift32. + const bytes = Buffer.alloc(length); + let x = 0x9e3779b9; + for (let i = 0; i < length; i += 4) { + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + bytes.writeUInt32LE(x >>> 0, i); + } + return bytes; + } + + it.concurrent("output larger than the input (the buffer has to grow)", async () => { + const original = Buffer.from(JSON.stringify(Array.from({ length: 20_000 }, (_, i) => ({ id: i, ok: true })))); + const frame = await compressWithoutContentSize(original); + expect(frame.length).toBeLessThan(original.length / 4); + + expect(zstdDecompressSync(frame)).toEqual(original); + expect(await zstdDecompress(frame)).toEqual(original); + }); + + it.concurrent("output smaller than the input (the initial buffer is enough)", async () => { + const original = patternBytes(256 * 1024); + const frame = await compressWithoutContentSize(original); + expect(frame.length).toBeGreaterThan(original.length); + + expect(zstdDecompressSync(frame)).toEqual(original); + expect(await zstdDecompress(frame)).toEqual(original); + }); + + it.concurrent("several frames in one input", async () => { + const parts = [patternBytes(64 * 1024), Buffer.alloc(300 * 1024, "abc"), Buffer.from("tail")]; + const frames = await Promise.all(parts.map(compressWithoutContentSize)); + const original = Buffer.concat(parts); + + expect(zstdDecompressSync(Buffer.concat(frames))).toEqual(original); + expect(await zstdDecompress(Buffer.concat(frames))).toEqual(original); + }); + + it.concurrent("content size in the header just over the 16 MiB limit", async () => { + // The streaming decoder starts with exactly 16 MiB of room, so this frame fills it + // completely and still has one byte to go. + const original = Buffer.alloc(16 * MiB + 1, 0x5a); + const frame = zstdCompressSync(original); + + const fromSync = zstdDecompressSync(frame); + expect([fromSync.length, fromSync.equals(original)]).toEqual([original.length, true]); + const fromAsync = await zstdDecompress(frame); + expect([fromAsync.length, fromAsync.equals(original)]).toEqual([original.length, true]); + }); +}); + describe("sync compression argument handling", () => { it("zstdCompressSync evaluates the options object before capturing the input", () => { const input = new Uint8Array(64).fill(97); From 64f15e9ec58d5600b2120cc4abc9cfcaec058181 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:30:27 +0000 Subject: [PATCH 02/21] zstd: make the output buffer allocations fallible The size of every buffer Bun.zstdCompress*/zstdDecompress* allocates is derived from the input (the compression bound, or whatever the frames claim to decompress to), so a failed allocation is now an out-of-memory error thrown or rejected to the caller instead of an abort. This covers the up-front reservations as well as the growth steps of the streaming decoder, and guards against ZSTD_compressBound returning an error code for oversized input. --- src/runtime/api/BunObject.rs | 173 +++++++++++++++++++--------------- src/zstd/lib.rs | 28 ++++-- test/js/bun/util/zstd.test.ts | 86 ++++++++++++++++- 3 files changed, 203 insertions(+), 84 deletions(-) diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 6bdf53ab20ba..4e0896c84833 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2740,33 +2740,97 @@ pub mod JSZstd { .throw_invalid_arguments(format_args!("Expected buffer to be a string or buffer"))) } - #[bun_jsc::host_fn] - pub(crate) fn compress_sync( - global_this: &JSGlobalObject, - callframe: &CallFrame, - ) -> JsResult { - let (buffer_value, options_val) = parse_compress_args(global_this, callframe)?; + /// Why a `Bun.zstd*` call produced no output. Shared by the sync functions + /// (thrown) and [`ZstdJob`] (rejected), so both report the same errors. + pub(crate) enum Failure { + /// The output buffer could not be allocated. Its size is derived from + /// the input (the compression bound, or whatever the frames claim to + /// decompress to), so this is an error for the caller, not a crash. + OutOfMemory, + /// Compression was refused or failed; an `ERR_ZSTD` with this message. + Compression(&'static [u8]), + /// Decompression failed; an `ERR_ZSTD` naming the error. + Decompression(bun_zstd::ZstdError), + } + + impl Failure { + fn throw(self, global_this: &JSGlobalObject) -> jsc::JsError { + match self { + Failure::OutOfMemory => global_this.throw_out_of_memory(), + failure => global_this.throw_value(failure.to_js(global_this)), + } + } - let level = get_level(global_this, options_val)?; + fn to_js(self, global_this: &JSGlobalObject) -> JSValue { + match self { + Failure::OutOfMemory => global_this.create_out_of_memory_error(), + Failure::Compression(message) => global_this + .err( + jsc::ErrCode::ZSTD, + format_args!("{}", bstr::BStr::new(message)), + ) + .to_js(), + Failure::Decompression(err) => global_this + .err( + jsc::ErrCode::ZSTD, + format_args!("Decompression failed: {}", err), + ) + .to_js(), + } + } + } - let buffer = coerce_compress_buffer(global_this, buffer_value)?; - let input = buffer.slice(); + impl From for Failure { + fn from(err: bun_zstd::ZstdError) -> Self { + match err { + bun_zstd::ZstdError::OutOfMemory => Failure::OutOfMemory, + err => Failure::Decompression(err), + } + } + } - // zstd only initializes the bytes it reports, so the bound is reserved - // rather than zero-filled (a second pass over the whole buffer). - let mut output: Vec = Vec::with_capacity(bun_zstd::compress_bound(input.len())); + /// Compress `input` into a buffer sized to the compression bound, shrunk + /// to the compressed size afterwards. + fn compress_to_vec(input: &[u8], level: i32) -> Result, Failure> { + let max_size = bun_zstd::compress_bound(input.len()); + // `ZSTD_compressBound` returns an error code instead of a size once the + // input exceeds `ZSTD_MAX_INPUT_SIZE`. + if bun_zstd::is_error(max_size) { + return Err(Failure::Compression(b"Input is too large to compress")); + } + + // The bound is only reserved, not zero-filled: zstd initializes exactly + // the bytes it reports, and filling the buffer first would be a second + // pass over the whole thing. + let mut output: Vec = Vec::new(); + output + .try_reserve_exact(max_size) + .map_err(|_| Failure::OutOfMemory)?; if let bun_zstd::Result::Err(err) = bun_zstd::compress_append(&mut output, input, Some(level)) { - drop(output); - return Err(global_this - .err(jsc::ErrCode::ZSTD, format_args!("{}", bstr::BStr::new(err))) - .throw()); + return Err(Failure::Compression(err.as_bytes())); } // Release the slack between the compressed size and the bound. output.shrink_to_fit(); + Ok(output) + } + + #[bun_jsc::host_fn] + pub(crate) fn compress_sync( + global_this: &JSGlobalObject, + callframe: &CallFrame, + ) -> JsResult { + let (buffer_value, options_val) = parse_compress_args(global_this, callframe)?; + + let level = get_level(global_this, options_val)?; + + let buffer = coerce_compress_buffer(global_this, buffer_value)?; + + let output = + compress_to_vec(buffer.slice(), level).map_err(|failure| failure.throw(global_this))?; JSValue::create_buffer(global_this, output.leak()) } @@ -2778,19 +2842,8 @@ pub mod JSZstd { ) -> JsResult { let (buffer, _) = parse_compress_buffer_and_options(global_this, callframe)?; - let input = buffer.slice(); - - let output = match bun_zstd::decompress_alloc(input) { - Ok(v) => v, - Err(err) => { - return Err(global_this - .err( - jsc::ErrCode::ZSTD, - format_args!("Decompression failed: {}", err), - ) - .throw()); - } - }; + let output = bun_zstd::decompress_alloc(buffer.slice()) + .map_err(|err| Failure::from(err).throw(global_this))?; JSValue::create_buffer(global_this, output.leak()) } @@ -2804,8 +2857,8 @@ pub mod JSZstd { pub buffer: bun_jsc::ThreadSafe, pub is_compress: bool, pub level: i32, - pub output: Vec, - pub error_message: Option<&'static [u8]>, + /// Filled in by `run`. + pub result: Result, Failure>, } impl jsc::JobContext for ZstdJob { @@ -2818,62 +2871,31 @@ pub mod JSZstd { ) -> Option> { let input = this.buffer.slice(); - if this.is_compress { - let max_size = bun_zstd::compress_bound(input.len()); - // Surface OOM as a rejected promise instead of aborting. As in - // `compress_sync`, the bound is only reserved, not zero-filled. - if this.output.try_reserve_exact(max_size).is_err() { - this.error_message = Some(b"Out of memory"); - return Some(done); - } - - if let bun_zstd::Result::Err(err) = - bun_zstd::compress_append(&mut this.output, input, Some(this.level)) - { - this.output = Vec::new(); - this.error_message = Some(err); - return Some(done); - } - - // Release the slack between the compressed size and the bound. - this.output.shrink_to_fit(); + this.result = if this.is_compress { + compress_to_vec(input, this.level) } else { - this.output = match bun_zstd::decompress_alloc(input) { - Ok(v) => v, - Err(_) => { - this.error_message = Some(b"Decompression failed"); - return Some(done); - } - }; - } + bun_zstd::decompress_alloc(input).map_err(Failure::from) + }; Some(done) } fn then( - mut this: Self, + this: Self, mut promise: jsc::JSPromiseStrong, cx: &jsc::JsThread<'_>, ) -> JsResult<()> { let global_this = cx.global(); let promise = promise.swap(); - if let Some(err_msg) = this.error_message { - promise.reject_with_async_stack( + match this.result { + Ok(output) => promise.settle( global_this, - Ok(global_this - .err( - jsc::ErrCode::ZSTD, - format_args!("{}", bstr::BStr::new(err_msg)), - ) - .to_js()), - )?; - return Ok(()); + JSValue::create_buffer(global_this, output.leak()), + ), + Err(failure) => { + promise.reject_with_async_stack(global_this, Ok(failure.to_js(global_this))) + } } - - let output_slice = core::mem::take(&mut this.output); - let buffer_value = JSValue::create_buffer(global_this, output_slice.leak()); - promise.settle(global_this, buffer_value)?; - Ok(()) } } @@ -2892,8 +2914,7 @@ pub mod JSZstd { buffer: bun_jsc::ThreadSafe::adopt(buffer), is_compress, level, - output: Vec::new(), - error_message: None, + result: Ok(Vec::new()), }, promise, ); diff --git a/src/zstd/lib.rs b/src/zstd/lib.rs index 0c568e45d03a..b9fff614643f 100644 --- a/src/zstd/lib.rs +++ b/src/zstd/lib.rs @@ -188,6 +188,9 @@ pub enum Result { #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] pub enum ZstdError { + /// The output buffer could not be allocated or grown. Its size comes from + /// the (untrusted) input, so this is reported instead of aborting. + OutOfMemory, InvalidZstdData, DecompressionFailed, ZstdFailedToCreateInstance, @@ -320,6 +323,8 @@ fn decompress_append(out: &mut Vec, src: &[u8]) -> Result { /// Returns owned slice that must be freed by the caller. /// Handles both frames with known and unknown content sizes. /// For safety, if the reported decompressed size exceeds 16MB, streaming decompression is used instead. +/// Every output allocation is fallible ([`ZstdError::OutOfMemory`]) because the +/// input decides how large it is. pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> { let size = get_decompressed_size(src); @@ -344,7 +349,9 @@ pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> } else { MAX_PREALLOCATE_SIZE }; - let mut list: Vec = Vec::with_capacity(initial_capacity); + let mut list: Vec = Vec::new(); + list.try_reserve_exact(initial_capacity) + .map_err(|_| ZstdError::OutOfMemory)?; let mut reader = ZstdReaderArrayList::init(src, &mut list)?; reader.read_all(true)?; @@ -355,7 +362,10 @@ pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> // Fast path: size is known and within reasonable limits. zstd writes every // byte it reports, so the buffer is handed over uninitialized; zero-filling // it first would cost a second pass over the whole output. - let mut output: Vec = Vec::with_capacity(size); + let mut output: Vec = Vec::new(); + output + .try_reserve_exact(size) + .map_err(|_| ZstdError::OutOfMemory)?; match decompress_append(&mut output, src) { Result::Success(_) => Ok(output), @@ -462,10 +472,11 @@ impl<'a> ZstdReaderArrayList<'a> { return Err(ZstdError::ZstdDecompressionError); } - // SAFETY: write-only spare; ZSTD_decompressStream initializes the - // first `out_buf.pos` bytes. - let spare = - unsafe { bun_core::vec::reserve_spare_bytes(self.list_ptr, STREAMING_OUTPUT_STEP) }; + if self.list_ptr.try_reserve(STREAMING_OUTPUT_STEP).is_err() { + self.state = State::Error; + return Err(ZstdError::OutOfMemory); + } + let spare = self.list_ptr.spare_capacity_mut(); let mut in_buf = c::ZSTD_inBuffer { src: next_in.as_ptr().cast::(), size: next_in.len(), @@ -613,7 +624,10 @@ impl StreamingDecoder { return Err(ZstdError::ZstdDecompressionError); } - out.reserve(STREAMING_OUTPUT_STEP); + if out.try_reserve(STREAMING_OUTPUT_STEP).is_err() { + self.state = State::Error; + return Err(ZstdError::OutOfMemory); + } let spare = out.spare_capacity_mut(); let mut in_buf = c::ZSTD_inBuffer { src: next_in.as_ptr().cast::(), diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index 990ee9b9cbbe..c2e2cbe374a3 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -9,7 +9,7 @@ import { zstdDecompressSync, } from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, rss } from "harness"; +import { bunEnv, bunExe, isASAN, rss } from "harness"; import path from "path"; describe("Zstandard compression", async () => { @@ -320,6 +320,90 @@ describe("decompressing frames whose size is not known up front", () => { }); }); +// The output buffers are sized by the input: the compression bound of the caller's data, or +// whatever the (possibly hostile) frames say they decompress to. When that allocation fails +// the call has to throw or reject, not take the process down. ASAN's allocation cap makes the +// failure deterministic: native allocations above CAP_MIB fail, while the JS Buffers the +// script itself creates are backed by JSC's own allocator and are not affected. +describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", () => { + const MiB = 1024 * 1024; + const CAP_MIB = 8; + + it("compress and decompress, sync and async", async () => { + // All three decompress to more than the cap and each reaches the allocation differently: + // a header size under the 16 MiB limit is allocated up front; one above it starts the + // streaming decoder at 16 MiB; no header size starts small and fails while growing. + const frames = { + headerSize: zstdCompressSync(Buffer.alloc(12 * MiB)), + headerSizeAboveLimit: zstdCompressSync(Buffer.alloc(32 * MiB)), + noHeaderSize: await new Response( + new Response(Buffer.alloc(12 * MiB)).body!.pipeThrough(new CompressionStream("zstd")), + ).bytes(), + }; + expect(frames.noHeaderSize[4] & 0xe0).toBe(0); + const framesBase64 = Object.fromEntries( + Object.entries(frames).map(([name, frame]) => [name, Buffer.from(frame).toString("base64")]), + ); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const frames = Object.fromEntries( + Object.entries(${JSON.stringify(framesBase64)}).map(([name, b64]) => [name, Buffer.from(b64, "base64")]), + ); + const describeError = e => ({ name: e.name, message: e.message }); + const results = {}; + // The compression bound of this is a little over the cap. + const input = Buffer.alloc(${2 * CAP_MIB} * 1024 * 1024); + try { results.compressSync = Bun.zstdCompressSync(input).length; } catch (e) { results.compressSync = describeError(e); } + results.compress = await Bun.zstdCompress(input).then(out => out.length, describeError); + for (const [name, frame] of Object.entries(frames)) { + try { results["decompressSync " + name] = Bun.zstdDecompressSync(frame).length; } catch (e) { results["decompressSync " + name] = describeError(e); } + results["decompress " + name] = await Bun.zstdDecompress(frame).then(out => out.length, describeError); + } + results.afterwards = Bun.zstdDecompressSync(Bun.zstdCompressSync("still works")).toString(); + results.afterwardsAsync = (await Bun.zstdDecompress(await Bun.zstdCompress("still works"))).toString(); + console.log(JSON.stringify(results)); + `, + ], + env: { + ...bunEnv, + // detect_leaks=0: LeakSanitizer cannot see through JSC cells to the natives they own. + ASAN_OPTIONS: [ + bunEnv.ASAN_OPTIONS, + "allocator_may_return_null=1", + `max_allocation_size_mb=${CAP_MIB}`, + "detect_leaks=0", + ] + .filter(Boolean) + .join(":"), + }, + stdout: "pipe", + // ASAN logs a warning for every refused allocation; not asserted on. + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + const outOfMemory = { name: "RangeError", message: "Out of memory" }; + expect(stdout, `the child printed nothing and exited with ${exitCode}`).not.toBe(""); + expect(JSON.parse(stdout)).toEqual({ + compressSync: outOfMemory, + compress: outOfMemory, + "decompressSync headerSize": outOfMemory, + "decompress headerSize": outOfMemory, + "decompressSync headerSizeAboveLimit": outOfMemory, + "decompress headerSizeAboveLimit": outOfMemory, + "decompressSync noHeaderSize": outOfMemory, + "decompress noHeaderSize": outOfMemory, + afterwards: "still works", + afterwardsAsync: "still works", + }); + expect(exitCode).toBe(0); + }); +}); + describe("sync compression argument handling", () => { it("zstdCompressSync evaluates the options object before capturing the input", () => { const input = new Uint8Array(64).fill(97); From 1f061f2e5ca90aab40fe9ca09695f5cf17588063 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:40:07 +0000 Subject: [PATCH 03/21] zstd: shorten comments, drain the child's stderr in the allocation failure test --- src/runtime/api/BunObject.rs | 20 ++++++-------------- src/zstd/lib.rs | 30 +++++++++--------------------- test/js/bun/util/zstd.test.ts | 6 +++--- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 4e0896c84833..be06b0a3ba9c 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2740,16 +2740,13 @@ pub mod JSZstd { .throw_invalid_arguments(format_args!("Expected buffer to be a string or buffer"))) } - /// Why a `Bun.zstd*` call produced no output. Shared by the sync functions - /// (thrown) and [`ZstdJob`] (rejected), so both report the same errors. + /// Error of a `Bun.zstd*` call: thrown by the sync functions, rejected by [`ZstdJob`]. pub(crate) enum Failure { - /// The output buffer could not be allocated. Its size is derived from - /// the input (the compression bound, or whatever the frames claim to - /// decompress to), so this is an error for the caller, not a crash. + /// The output buffer, whose size the input decides, could not be allocated. OutOfMemory, - /// Compression was refused or failed; an `ERR_ZSTD` with this message. + /// An `ERR_ZSTD` with this message. Compression(&'static [u8]), - /// Decompression failed; an `ERR_ZSTD` naming the error. + /// An `ERR_ZSTD` naming the error. Decompression(bun_zstd::ZstdError), } @@ -2789,19 +2786,14 @@ pub mod JSZstd { } } - /// Compress `input` into a buffer sized to the compression bound, shrunk - /// to the compressed size afterwards. fn compress_to_vec(input: &[u8], level: i32) -> Result, Failure> { let max_size = bun_zstd::compress_bound(input.len()); - // `ZSTD_compressBound` returns an error code instead of a size once the - // input exceeds `ZSTD_MAX_INPUT_SIZE`. + // `ZSTD_compressBound` returns an error code for inputs over `ZSTD_MAX_INPUT_SIZE`. if bun_zstd::is_error(max_size) { return Err(Failure::Compression(b"Input is too large to compress")); } - // The bound is only reserved, not zero-filled: zstd initializes exactly - // the bytes it reports, and filling the buffer first would be a second - // pass over the whole thing. + // Reserved, not zero-filled: zstd initializes exactly the bytes it reports. let mut output: Vec = Vec::new(); output .try_reserve_exact(max_size) diff --git a/src/zstd/lib.rs b/src/zstd/lib.rs index b9fff614643f..6ab694c1c09f 100644 --- a/src/zstd/lib.rs +++ b/src/zstd/lib.rs @@ -188,8 +188,7 @@ pub enum Result { #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] pub enum ZstdError { - /// The output buffer could not be allocated or grown. Its size comes from - /// the (untrusted) input, so this is reported instead of aborting. + /// The output buffer, whose size the (untrusted) input decides, could not be allocated. OutOfMemory, InvalidZstdData, DecompressionFailed, @@ -293,11 +292,7 @@ pub fn decompress(dest: &mut [u8], src: &[u8]) -> Result { Result::Success(result) } -/// [`decompress`] into `out`'s spare capacity (append mode). The spare -/// capacity is the output bound handed to zstd, so callers reserve the -/// decompressed size first; zstd fails with `dstSize_tooSmall` when the -/// frames do not fit. On success `out.len()` is advanced by the number of -/// bytes written. +/// [`decompress`] into `out`'s spare capacity, which is the output bound; commits the bytes written. fn decompress_append(out: &mut Vec, src: &[u8]) -> Result { let spare = out.spare_capacity_mut(); // SAFETY: spare/src are valid for their lengths; ZSTD_decompress reads src @@ -323,8 +318,7 @@ fn decompress_append(out: &mut Vec, src: &[u8]) -> Result { /// Returns owned slice that must be freed by the caller. /// Handles both frames with known and unknown content sizes. /// For safety, if the reported decompressed size exceeds 16MB, streaming decompression is used instead. -/// Every output allocation is fallible ([`ZstdError::OutOfMemory`]) because the -/// input decides how large it is. +/// Output allocations fail with [`ZstdError::OutOfMemory`] instead of aborting. pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> { let size = get_decompressed_size(src); @@ -340,13 +334,11 @@ pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> // 1. Content size is unknown, OR // 2. Reported size exceeds safety limit (to prevent malicious inputs claiming huge sizes) if size == ZSTD_CONTENTSIZE_UNKNOWN || size > MAX_PREALLOCATE_SIZE { - // Doubling up from one step copies about as many bytes as the result - // is long, so start from a bounded guess instead: the header size is - // untrusted and gets at most what the fast path below would allocate; - // without one, a frame's output is rarely smaller than its input. let initial_capacity = if size == ZSTD_CONTENTSIZE_UNKNOWN { + // A frame's output is rarely smaller than its input. src.len().clamp(STREAMING_OUTPUT_STEP, MAX_PREALLOCATE_SIZE) } else { + // The header size is untrusted: reserve no more than the fast path below would. MAX_PREALLOCATE_SIZE }; let mut list: Vec = Vec::new(); @@ -359,9 +351,7 @@ pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> return Ok(list); } - // Fast path: size is known and within reasonable limits. zstd writes every - // byte it reports, so the buffer is handed over uninitialized; zero-filling - // it first would cost a second pass over the whole output. + // Fast path: size is known and within reasonable limits let mut output: Vec = Vec::new(); output .try_reserve_exact(size) @@ -381,9 +371,7 @@ pub fn get_decompressed_size(src: &[u8]) -> usize { pub use bun_core::compress::State; -/// Minimum spare output capacity handed to `ZSTD_decompressStream` per call. -/// The whole spare capacity is offered, so the `Vec` doubles past this as the -/// output grows. +/// Minimum spare output capacity offered to `ZSTD_decompressStream` per call. const STREAMING_OUTPUT_STEP: usize = 4096; struct ZstdReaderArrayList<'a> { @@ -591,8 +579,8 @@ impl StreamingDecoder { } /// Consume all of `input`, appending decompressed bytes to `out` - /// (growing in `STREAMING_OUTPUT_STEP` steps). Returns `ShortRead` when - /// more input is required and `is_done` is false. + /// (growing in 4096-byte steps). Returns `ShortRead` when more input is + /// required and `is_done` is false. pub fn decompress( &mut self, input: &[u8], diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index c2e2cbe374a3..8d93ed90ab84 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -381,13 +381,13 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", .join(":"), }, stdout: "pipe", - // ASAN logs a warning for every refused allocation; not asserted on. + // ASAN logs a warning for every refused allocation; drained, not asserted on. stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const outOfMemory = { name: "RangeError", message: "Out of memory" }; - expect(stdout, `the child printed nothing and exited with ${exitCode}`).not.toBe(""); + expect(stdout, `the child printed nothing and exited with ${exitCode}\nstderr:\n${stderr}`).not.toBe(""); expect(JSON.parse(stdout)).toEqual({ compressSync: outOfMemory, compress: outOfMemory, From 649f88425e87b7c98fba7d71057cb620bf40576c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:06:18 +0000 Subject: [PATCH 04/21] zlib, brotli: let the codecs see a failed allocation of their own state The allocator hooks handed to zlib and brotli aborted (or hit an unreachable!) when the allocation failed. Both libraries are written to handle a null from their allocator (Z_MEM_ERROR, a failed instance creation or an alloc error code), and the callers already map those to out-of-memory errors, so return null and let that code run. --- src/bun_alloc/c_thunks.rs | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/src/bun_alloc/c_thunks.rs b/src/bun_alloc/c_thunks.rs index c8cfc8f59407..50ca2c9cff65 100644 --- a/src/bun_alloc/c_thunks.rs +++ b/src/bun_alloc/c_thunks.rs @@ -18,12 +18,9 @@ use crate::default_alloc as raw; // ────────────────────────────────────────────────────────────────────────── /// zlib `alloc_func` → default allocator `malloc(items * size)` (non-zeroing). +/// Null on failure, which zlib reports as `Z_MEM_ERROR`. pub extern "C" fn mi_malloc_items(_: *mut c_void, items: c_uint, size: c_uint) -> *mut c_void { - let p = raw::malloc((items * size) as usize); - if p.is_null() { - unreachable!(); - } - p + raw::malloc(items as usize * size as usize) } /// `(opaque, ptr)` → default allocator `free(ptr)`; opaque cookie ignored. @@ -57,6 +54,9 @@ pub unsafe extern "C" fn mi_free_bytes(bytes: *mut c_void, _ctx: *mut c_void) { /// - `free(_, ptr: *mut c_void)` — paired with either alloc. `unsafe` /// (precondition: `ptr` was allocated by this zone / the default allocator). /// +/// Both allocators return null on failure; zlib turns that into `Z_MEM_ERROR` +/// and brotli into a failed instance creation or an OOM error state. +/// /// Intended to be invoked inside a `mod XxxAllocator { … }` so call sites can /// keep referring to `XxxAllocator::alloc` / `::free` via a local `pub use`. #[macro_export] @@ -67,16 +67,11 @@ macro_rules! c_thunks_for_zone { len: usize, ) -> *mut ::core::ffi::c_void { if $crate::heap_breakdown::ENABLED { - return match $crate::get_zone!($name).malloc_zone_malloc(len) { - Some(p) => p, - None => $crate::out_of_memory(), - }; - } - let p = $crate::default_alloc::malloc(len); - if p.is_null() { - $crate::out_of_memory(); + return $crate::get_zone!($name) + .malloc_zone_malloc(len) + .unwrap_or(::core::ptr::null_mut()); } - p + $crate::default_alloc::malloc(len) } pub extern "C" fn calloc_items( @@ -85,18 +80,11 @@ macro_rules! c_thunks_for_zone { len: ::core::ffi::c_uint, ) -> *mut ::core::ffi::c_void { if $crate::heap_breakdown::ENABLED { - return match $crate::get_zone!($name) + return $crate::get_zone!($name) .malloc_zone_calloc(items as usize, len as usize) - { - Some(p) => p, - None => $crate::out_of_memory(), - }; - } - let p = $crate::default_alloc::calloc(items as usize, len as usize); - if p.is_null() { - $crate::out_of_memory(); + .unwrap_or(::core::ptr::null_mut()); } - p + $crate::default_alloc::calloc(items as usize, len as usize) } pub unsafe extern "C" fn free(_: *mut ::core::ffi::c_void, data: *mut ::core::ffi::c_void) { From ff5139f51b059148e73b26b2fe5025aecf9548a1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:06:18 +0000 Subject: [PATCH 05/21] zlib: make the output buffer allocations fallible ZlibReaderArrayList and ZlibCompressorArrayList grow (and the compressor pre-sizes to deflateBound) with try_reserve, returning the existing ZlibError::OutOfMemory; the shared step() used by DeflateEncoder and InflateDecoder reports a failed growth as Z_MEM_ERROR, which every caller already handles. The WebSocket permessage-deflate paths map that to their existing OutOfMemory variants, and bun create stops seeding the tarball buffer with 16 KiB of zeros. --- .../websocket_client/WebSocketDeflate.rs | 11 +++++- src/runtime/cli/create_command.rs | 4 +-- src/zlib/lib.rs | 34 ++++++++++++++----- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/http_jsc/websocket_client/WebSocketDeflate.rs b/src/http_jsc/websocket_client/WebSocketDeflate.rs index bd81ed2f73f8..4e439b667fa0 100644 --- a/src/http_jsc/websocket_client/WebSocketDeflate.rs +++ b/src/http_jsc/websocket_client/WebSocketDeflate.rs @@ -159,7 +159,10 @@ impl PerMessageDeflate { } } - let mut in_with_trailer: Vec = Vec::with_capacity(in_buf.len() + DEFLATE_TRAILER.len()); + let mut in_with_trailer: Vec = Vec::new(); + in_with_trailer + .try_reserve_exact(in_buf.len() + DEFLATE_TRAILER.len()) + .map_err(|_| DecompressError::OutOfMemory)?; in_with_trailer.extend_from_slice(in_buf); in_with_trailer.extend_from_slice(&DEFLATE_TRAILER); @@ -183,6 +186,9 @@ impl PerMessageDeflate { saw_stream_end = true; break; } + if rc == zlib::ReturnCode::MemError { + return Err(DecompressError::OutOfMemory); + } if rc != zlib::ReturnCode::Ok { return Err(DecompressError::InflateFailed); } @@ -221,6 +227,9 @@ impl PerMessageDeflate { zlib::FlushValue::SyncFlush, ); remaining = &remaining[consumed..]; + if rc == zlib::ReturnCode::MemError { + return Err(CompressError::OutOfMemory); + } if rc != zlib::ReturnCode::Ok { return Err(CompressError::DeflateFailed); } diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index 62f438717a75..d0f7b2e89fa8 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -461,9 +461,7 @@ impl CreateCommand { progress.refresh(); - let file_buf = vec![0u8; 16384]; - - let mut tarball_buf_list: Vec = file_buf; + let mut tarball_buf_list: Vec = Vec::with_capacity(16384); let mut gunzip = Zlib::ZlibReaderArrayList::init( tarball_bytes.list.as_slice(), &mut tarball_buf_list, diff --git a/src/zlib/lib.rs b/src/zlib/lib.rs index 1080004a4863..0516f863e627 100644 --- a/src/zlib/lib.rs +++ b/src/zlib/lib.rs @@ -335,11 +335,16 @@ impl<'a> ZlibReaderArrayList<'a> { self.state = ZlibReaderArrayListState::Error; return Err(ZlibError::ZlibError); } + if self + .list_ptr + .try_reserve(remaining_budget.min(4096)) + .is_err() + { + self.state = ZlibReaderArrayListState::Error; + return Err(ZlibError::OutOfMemory); + } // SAFETY: zlib writes the tail; len is truncated to `total_out` before any read. - let (next_out, avail_out) = unsafe { - self.list_ptr - .reserve_expand_tail(remaining_budget.min(4096)) - }; + let (next_out, avail_out) = unsafe { self.list_ptr.reserve_expand_tail(0) }; self.zlib.next_out = next_out; // Clamp so a single inflate call cannot write past `max_output_size`. self.zlib.avail_out = avail_out.min(remaining_budget) as uInt; @@ -780,9 +785,11 @@ impl<'a> ZlibCompressorArrayList<'a> { uLong::try_from(input.len()).expect("int cast"), ) }; - // ensureTotalCapacityPrecise → reserve_exact let need = (bound as usize).saturating_sub(zlib_reader.list_ptr.len()); - zlib_reader.list_ptr.reserve_exact(need); + if zlib_reader.list_ptr.try_reserve_exact(need).is_err() { + drop(zlib_reader); + return Err(ZlibError::OutOfMemory); + } zlib_reader.zlib.avail_out = zlib_reader.list_ptr.capacity() as uInt; zlib_reader.zlib.next_out = zlib_reader.list_ptr.as_mut_ptr(); @@ -846,8 +853,13 @@ impl<'a> ZlibCompressorArrayList<'a> { // flush parameter). if self.zlib.avail_out == 0 { + if self.list_ptr.try_reserve(4096).is_err() { + self.end(); + self.state = ZlibCompressorArrayListState::Error; + return Err(ZlibError::OutOfMemory); + } // SAFETY: zlib writes the tail; len is truncated to `total_out` before any read. - let (next_out, avail_out) = unsafe { self.list_ptr.reserve_expand_tail(4096) }; + let (next_out, avail_out) = unsafe { self.list_ptr.reserve_expand_tail(0) }; self.zlib.next_out = next_out; self.zlib.avail_out = avail_out as uInt; } @@ -964,7 +976,8 @@ impl DeflateEncoder { /// Reserves at least `reserve` spare bytes in `out`, points /// `next_in`/`avail_in` at `input` and `next_out`/`avail_out` at the /// spare, calls `deflate(flush)`, and advances `out.len()` by the bytes - /// produced. Returns `(bytes_consumed_from_input, return_code)`. Inputs + /// produced. Returns `(bytes_consumed_from_input, return_code)`, with + /// `MemError` and nothing consumed when `out` cannot be grown. Inputs /// larger than `u32::MAX` are clamped; callers loop and advance `input` /// by `consumed`. pub fn step( @@ -1180,11 +1193,14 @@ fn step( flush: FlushValue, op: unsafe extern "C" fn(*mut zStream_struct, FlushValue) -> ReturnCode, ) -> (usize, ReturnCode) { + if out.try_reserve(reserve).is_err() { + return (0, ReturnCode::MemError); + } + let in_len = input.len().min(u32::MAX as usize); strm.next_in = input.as_ptr(); strm.avail_in = in_len as uInt; - out.reserve(reserve); let spare = out.spare_capacity_mut(); let out_len = spare.len().min(u32::MAX as usize); strm.next_out = spare.as_mut_ptr().cast::(); From b70e8f594130ce9632bbbe19197d971e7ba8adb4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:06:18 +0000 Subject: [PATCH 06/21] libdeflate: make the output buffer allocations fallible compress_to_vec reserves the compression bound itself and decompress_to_vec_grow grows with try_reserve_exact; both return an AllocError instead of aborting. Bun.gzipSync/deflateSync/gunzipSync/ inflateSync reserve their output with try_reserve and throw the standard out-of-memory error (a gzip trailer size that cannot be reserved falls back to growing from a small buffer), Bun.Archive's gzip output rejects with it, and fetch's libdeflate fast path falls back to streaming when the trailer size cannot be reserved. --- src/http/InternalState.rs | 22 ++++++-- src/libdeflate_sys/libdeflate.rs | 27 +++++----- src/runtime/api/Archive.rs | 64 +++++++--------------- src/runtime/api/BunObject.rs | 91 ++++++++++++++++++-------------- src/runtime/cli/audit_command.rs | 5 +- 5 files changed, 101 insertions(+), 108 deletions(-) diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index 2bef8005cbb6..7b287e4f7719 100644 --- a/src/http/InternalState.rs +++ b/src/http/InternalState.rs @@ -296,10 +296,16 @@ impl<'a> InternalState<'a> { if (estimated_size as usize) > deflater.shared_buffer.len() && estimated_size < 32 * 1024 * 1024 { - self.decoded_body.list.reserve_exact( - (estimated_size as usize).saturating_sub(self.decoded_body.list.len()), - ); self.decoded_body.list.clear(); + // A trailer can lie; the streaming path below allocates only what is really there. + if self + .decoded_body + .list + .try_reserve_exact(estimated_size as usize) + .is_err() + { + break 'libdeflate; + } let result = deflater.decompressor_mut().decompress_to_vec( buffer, &mut self.decoded_body.list, @@ -337,9 +343,15 @@ impl<'a> InternalState<'a> { // libdeflate decodes a single member; unconsumed input means // a multi-member gzip stream. Let the zlib path handle it. if result.status == bun_libdeflate::Status::Success && result.read == buffer.len() { - self.decoded_body + if self + .decoded_body .list - .reserve_exact(result.written.saturating_sub(self.decoded_body.list.len())); + .try_reserve_exact(result.written) + .is_err() + { + self.compressed_body.reset(); + return Err(bun_alloc::AllocError.into()); + } self.decoded_body .list .extend_from_slice(&deflater.shared_buffer[0..result.written]); diff --git a/src/libdeflate_sys/libdeflate.rs b/src/libdeflate_sys/libdeflate.rs index a60099bf48bf..b29a1aca0ed0 100644 --- a/src/libdeflate_sys/libdeflate.rs +++ b/src/libdeflate_sys/libdeflate.rs @@ -3,6 +3,8 @@ use core::mem::MaybeUninit; use core::ptr::NonNull; use std::sync::Once; +use bun_alloc::AllocError; + /// Valid `compression_level` range for `libdeflate_alloc_compressor`. Values /// outside this range make the allocator return NULL (indistinguishable from OOM), /// so callers must range-check first. @@ -154,29 +156,22 @@ impl Compressor { } } - /// Compress `input` into `out`'s **spare capacity** (append mode). - /// - /// Does not clear `out`; on [`Status::Success`] `out.len()` is advanced by - /// `result.written`. libdeflate compress never returns `InsufficientSpace` - /// when `out` was sized via [`max_bytes_needed`](Self::max_bytes_needed), - /// so callers need no retry loop. - /// - /// Safe replacement for the open-coded - /// `compress_into(out.spare_capacity_mut()) + unsafe { set_len }` pattern, - /// and for the zero-init `vec![0u8; bound]` + `truncate` form. + /// Compress `input` onto the end of `out`, reserving the bound first; `Err` if that allocation fails. pub fn compress_to_vec( &mut self, input: &[u8], out: &mut Vec, encoding: Encoding, - ) -> Result { + ) -> core::result::Result { + out.try_reserve_exact(self.max_bytes_needed(input, encoding)) + .map_err(|_| AllocError)?; let result = self.compress_into(input, out.spare_capacity_mut(), encoding); if result.status == Status::Success { // SAFETY: result.written ≤ spare.len() and libdeflate has // initialized spare[..result.written]. unsafe { out.set_len(out.len() + result.written) }; } - result + Ok(result) } pub(crate) fn zlib(&mut self, input: &[u8], output: &mut [u8]) -> Result { @@ -441,21 +436,23 @@ impl Decompressor { /// [`Status::InsufficientSpace`] — clamped at `max_capacity` — until /// success, hard error, or `out.capacity() >= max_capacity` (returned as /// the final `InsufficientSpace`). On success, `out.len() == result.written`. + /// `Err` when a growth step cannot be allocated. pub fn decompress_to_vec_grow( &mut self, input: &[u8], out: &mut Vec, encoding: Encoding, max_capacity: usize, - ) -> Result { + ) -> core::result::Result { loop { out.clear(); let result = self.decompress_to_vec(input, out, encoding); if result.status != Status::InsufficientSpace || out.capacity() >= max_capacity { - return result; + return Ok(result); } let new_cap = out.capacity().max(1).saturating_mul(2).min(max_capacity); - out.reserve_exact(new_cap.saturating_sub(out.len())); + out.try_reserve_exact(new_cap.saturating_sub(out.len())) + .map_err(|_| AllocError)?; } } } diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index f62b51994e5f..205ddb8daaf3 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -816,18 +816,10 @@ enum BlobOutputType { Bytes, } -#[derive(thiserror::Error, strum::IntoStaticStr, Debug)] -enum BlobError { - #[error("GzipInitFailed")] - GzipInitFailed, - #[error("GzipCompressFailed")] - GzipCompressFailed, -} - enum BlobResult { Compressed(Vec), Uncompressed, - Err(BlobError), + Err(CompressError), } pub struct BlobContext { @@ -842,7 +834,7 @@ impl TaskContext for BlobContext { self.result = match &self.compress { Compression::Gzip(opts) => match compress_gzip(self.store.shared_view(), opts.level) { Ok(data) => BlobResult::Compressed(data), - Err(e) => BlobResult::Err(e.into()), + Err(e) => BlobResult::Err(e), }, Compression::None => BlobResult::Uncompressed, }; @@ -850,9 +842,7 @@ impl TaskContext for BlobContext { fn run_from_js(&mut self, global: &JSGlobalObject) -> JsResult { match core::mem::replace(&mut self.result, BlobResult::Uncompressed) { - BlobResult::Err(e) => Ok(PromiseResult::Reject( - global.create_error_instance(format_args!("{}", <&'static str>::from(&e))), - )), + BlobResult::Err(e) => Ok(PromiseResult::Reject(e.to_js(global))), BlobResult::Compressed(data) => { // self.result already replaced with Uncompressed above — ownership transferred Ok(PromiseResult::Resolve(match self.output_type { @@ -917,17 +907,9 @@ fn start_blob_task( )) } -#[derive(thiserror::Error, strum::IntoStaticStr, Debug)] -enum WriteError { - #[error("GzipInitFailed")] - GzipInitFailed, - #[error("GzipCompressFailed")] - GzipCompressFailed, -} - enum WriteResult { Success, - Err(WriteError), + Err(CompressError), SysErr(bun_sys::Error), } @@ -951,9 +933,7 @@ impl TaskContext for WriteContext { fn run_from_js(&mut self, global: &JSGlobalObject) -> JsResult { Ok(match &self.result { WriteResult::Success => PromiseResult::Resolve(JSValue::UNDEFINED), - WriteResult::Err(e) => PromiseResult::Reject( - global.create_error_instance(format_args!("{}", <&'static str>::from(e))), - ), + WriteResult::Err(e) => PromiseResult::Reject(e.to_js(global)), WriteResult::SysErr(sys_err) => PromiseResult::Reject(sys_err.to_js(global)), }) } @@ -970,7 +950,7 @@ impl WriteContext { Compression::Gzip(opts) => { compressed_buf = match compress_gzip(source_data, opts.level) { Ok(v) => v, - Err(e) => return WriteResult::Err(e.into()), + Err(e) => return WriteResult::Err(e), }; &compressed_buf } @@ -1224,22 +1204,16 @@ enum CompressError { GzipInitFailed, #[error("GzipCompressFailed")] GzipCompressFailed, + /// The output buffer (sized by the data being compressed) could not be allocated. + #[error("OutOfMemory")] + OutOfMemory, } -impl From for BlobError { - fn from(e: CompressError) -> Self { - match e { - CompressError::GzipInitFailed => BlobError::GzipInitFailed, - CompressError::GzipCompressFailed => BlobError::GzipCompressFailed, - } - } -} - -impl From for WriteError { - fn from(e: CompressError) -> Self { - match e { - CompressError::GzipInitFailed => WriteError::GzipInitFailed, - CompressError::GzipCompressFailed => WriteError::GzipCompressFailed, +impl CompressError { + fn to_js(&self, global: &JSGlobalObject) -> JSValue { + match self { + CompressError::OutOfMemory => global.create_out_of_memory_error(), + other => global.create_error_instance(format_args!("{}", <&'static str>::from(other))), } } } @@ -1251,12 +1225,10 @@ fn compress_gzip(data: &[u8], level: u8) -> Result, CompressError> { let mut compressor = libdeflate::OwnedCompressor::new(i32::from(level)).ok_or(CompressError::GzipInitFailed)?; - let max_size = compressor.max_bytes_needed(data, libdeflate::Encoding::Gzip); - - // The scratch is heap-allocated either way, so a small-input threshold is - // dead weight — just size the Vec to `max_size` once. - let mut output = Vec::with_capacity(max_size); - let result = compressor.compress_to_vec(data, &mut output, libdeflate::Encoding::Gzip); + let mut output = Vec::new(); + let result = compressor + .compress_to_vec(data, &mut output, libdeflate::Encoding::Gzip) + .map_err(|_| CompressError::OutOfMemory)?; if result.status != libdeflate::Status::Success { return Err(CompressError::GzipCompressFailed); } diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index be06b0a3ba9c..10c332ed624d 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2433,29 +2433,34 @@ pub mod JSZlib { let buffer = coerce_compress_buffer(global_this, buffer_value)?; let compressed = buffer.slice(); - let mut list: Vec = 'brk: { - if is_gzip && compressed.len() > 64 { - // 0 1 2 3 4 5 6 7 - // +---+---+---+---+---+---+---+---+ - // | CRC32 | ISIZE | - // +---+---+---+---+---+---+---+---+ - let estimated_size: u32 = u32::from_le_bytes( - compressed[compressed.len() - 4..][..4] - .try_into() - .expect("infallible: size matches"), - ); - // If it's > 256 MB, let's rely on dynamic allocation to minimize the risk of OOM. - if estimated_size > 0 && estimated_size < 256 * 1024 * 1024 { - break 'brk Vec::with_capacity((estimated_size as usize).max(64)); - } + let mut list: Vec = Vec::new(); + let mut reserved = false; + if is_gzip && compressed.len() > 64 { + // 0 1 2 3 4 5 6 7 + // +---+---+---+---+---+---+---+---+ + // | CRC32 | ISIZE | + // +---+---+---+---+---+---+---+---+ + let estimated_size: u32 = u32::from_le_bytes( + compressed[compressed.len() - 4..][..4] + .try_into() + .expect("infallible: size matches"), + ); + // If it's > 256 MB, let's rely on dynamic allocation to minimize the risk of OOM. + if estimated_size > 0 && estimated_size < 256 * 1024 * 1024 { + // The trailer is untrusted; if its size cannot be reserved, start small and grow. + reserved = list + .try_reserve_exact((estimated_size as usize).max(64)) + .is_ok(); } - - break 'brk Vec::with_capacity(if compressed.len() > 512 { + } + if !reserved { + list.try_reserve_exact(if compressed.len() > 512 { compressed.len() } else { 32 - }); - }; + }) + .map_err(|_| global_this.throw_out_of_memory())?; + } match library { Library::Zlib => { @@ -2481,10 +2486,16 @@ pub mod JSZlib { } }; - if reader.read_all(true).is_err() { - let msg = reader.error_message().unwrap_or(b"Zlib returned an error"); - return Err(global_this - .throw_value(ZigString::init(msg).to_error_instance(global_this))); + match reader.read_all(true) { + Ok(()) => {} + Err(zlib::ZlibError::OutOfMemory) => { + return Err(global_this.throw_out_of_memory()); + } + Err(_) => { + let msg = reader.error_message().unwrap_or(b"Zlib returned an error"); + return Err(global_this + .throw_value(ZigString::init(msg).to_error_instance(global_this))); + } } // NOTE: the reader *borrows* `list_ptr`, // so drop the reader to release the borrow, then leak the owned @@ -2505,7 +2516,8 @@ pub mod JSZlib { }; let max_output = ArrayBuffer::MAX_SIZE as usize; let result = decompressor - .decompress_to_vec_grow(compressed, &mut list, encoding, max_output); + .decompress_to_vec_grow(compressed, &mut list, encoding, max_output) + .map_err(|_| global_this.throw_out_of_memory())?; match result.status { bun_libdeflate::Status::Success if list.len() <= max_output => {} bun_libdeflate::Status::Success | bun_libdeflate::Status::InsufficientSpace => { @@ -2597,11 +2609,8 @@ pub mod JSZlib { match library { Library::Zlib => { - let mut list: Vec = Vec::with_capacity(if compressed.len() > 512 { - compressed.len() - } else { - 32 - }); + // `init` reserves `deflateBound` of the input. + let mut list: Vec = Vec::new(); let mut reader = match zlib::ZlibCompressorArrayList::init( compressed, @@ -2626,10 +2635,16 @@ pub mod JSZlib { } }; - if reader.read_all().is_err() { - let msg = reader.error_message().unwrap_or(b"Zlib returned an error"); - return Err(global_this - .throw_value(ZigString::init(msg).to_error_instance(global_this))); + match reader.read_all() { + Ok(()) => {} + Err(zlib::ZlibError::OutOfMemory) => { + return Err(global_this.throw_out_of_memory()); + } + Err(_) => { + let msg = reader.error_message().unwrap_or(b"Zlib returned an error"); + return Err(global_this + .throw_value(ZigString::init(msg).to_error_instance(global_this))); + } } // NOTE: see gunzip path — reader borrows `list`, so drop // it before leaking `list` into the ArrayBuffer. @@ -2656,12 +2671,10 @@ pub mod JSZlib { bun_libdeflate::Encoding::Deflate }; - let mut list: Vec = Vec::with_capacity( - // This allocation size is unfortunate, but it's not clear how to avoid it with libdeflate. - compressor.max_bytes_needed(compressed, encoding), - ); - - let result = compressor.compress_to_vec(compressed, &mut list, encoding); + let mut list: Vec = Vec::new(); + let result = compressor + .compress_to_vec(compressed, &mut list, encoding) + .map_err(|_| global_this.throw_out_of_memory())?; if result.status != bun_libdeflate::Status::Success { drop(list); return Err(global_this.throw(format_args!( diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index f23723595e2d..746c2b3e6496 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -697,9 +697,8 @@ fn send_audit_request( libdeflate::load(); let mut compressor = libdeflate::OwnedCompressor::new(6).ok_or(bun_alloc::AllocError)?; - let max_compressed_size = compressor.max_bytes_needed(body, libdeflate::Encoding::Gzip); - let mut compressed_body = Vec::with_capacity(max_compressed_size); - let _ = compressor.compress_to_vec(body, &mut compressed_body, libdeflate::Encoding::Gzip); + let mut compressed_body = Vec::new(); + let _ = compressor.compress_to_vec(body, &mut compressed_body, libdeflate::Encoding::Gzip)?; drop(compressor); let final_compressed_body = compressed_body; From 25ed5772774f8e4cee4a6ff86fbb4464b50eee0f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:06:18 +0000 Subject: [PATCH 07/21] brotli, fetch compression, CompressionStream: fallible output buffers, no zero-fill bun_brotli grows the streaming decoder's output with try_reserve and reports that, and brotli's own alloc error codes, as a new Error::OutOfMemory. fetch({ compress }) reserves the brotli and zstd spill buffers instead of zero-filling them to the bound (new encode_append writes into spare capacity like compress_append), and reports failed allocations as out-of-memory instead of a compression failure. MutableString::grow_by / grow_if_needed, which fetch's decompression output goes through, now really are fallible. CompressionStream/DecompressionStream reserve each step's output with try_reserve and reject with the out-of-memory error, also for Z_MEM_ERROR and zstd's memory_allocation error. The allocation failure test now also covers Bun.gzipSync/deflateSync/ gunzipSync/inflateSync with both libraries and fetch() responses encoded with gzip, deflate, br and zstd. --- src/brotli/error.rs | 4 + src/brotli/lib.rs | 49 +++++- src/bun_core/string/MutableString.rs | 6 +- src/http/compress_body.rs | 57 ++++--- src/runtime/webcore/CompressionStreamCoder.rs | 37 +++-- test/js/bun/util/zstd.test.ts | 150 ++++++++++++++---- 6 files changed, 225 insertions(+), 78 deletions(-) diff --git a/src/brotli/error.rs b/src/brotli/error.rs index 503187df06a0..2b38bb1a5d19 100644 --- a/src/brotli/error.rs +++ b/src/brotli/error.rs @@ -6,6 +6,9 @@ pub enum Error { BrotliFailedToCreateInstance, #[error("BrotliDecompressionError")] BrotliDecompressionError, + /// The output, or the decoder state the stream's window size dictates, could not be allocated. + #[error("OutOfMemory")] + OutOfMemory, #[error("ShortRead")] ShortRead, } @@ -17,6 +20,7 @@ impl Error { Self::BrotliFailedToLoad => "BrotliFailedToLoad", Self::BrotliFailedToCreateInstance => "BrotliFailedToCreateInstance", Self::BrotliDecompressionError => "BrotliDecompressionError", + Self::OutOfMemory => "OutOfMemory", Self::ShortRead => "ShortRead", } } diff --git a/src/brotli/lib.rs b/src/brotli/lib.rs index 58e34a6e1a89..e89fc6652446 100644 --- a/src/brotli/lib.rs +++ b/src/brotli/lib.rs @@ -120,7 +120,10 @@ impl StreamingDecoder { self.state, ReaderState::Uninitialized | ReaderState::Inflating ) { - out.reserve(4096); + if out.try_reserve(4096).is_err() { + self.state = ReaderState::Error; + return Err(crate::Error::OutOfMemory); + } let spare = out.spare_capacity_mut(); let out_len = spare.len(); let mut next_out: *mut u8 = spare.as_mut_ptr().cast::(); @@ -160,7 +163,17 @@ impl StreamingDecoder { } c::BrotliDecoderResult::err => { self.state = ReaderState::Error; - return Err(crate::Error::BrotliDecompressionError); + return Err(match c::BrotliDecoderGetErrorCode(self.brotli_mut()) { + c::BrotliDecoderErrorCode2::ERROR_ALLOC_CONTEXT_MODES + | c::BrotliDecoderErrorCode2::ERROR_ALLOC_TREE_GROUPS + | c::BrotliDecoderErrorCode2::ERROR_ALLOC_CONTEXT_MAP + | c::BrotliDecoderErrorCode2::ERROR_ALLOC_RING_BUFFER_1 + | c::BrotliDecoderErrorCode2::ERROR_ALLOC_RING_BUFFER_2 + | c::BrotliDecoderErrorCode2::ERROR_ALLOC_BLOCK_TYPE_TREES => { + crate::Error::OutOfMemory + } + _ => crate::Error::BrotliDecompressionError, + }); } c::BrotliDecoderResult::needs_more_input => { self.state = ReaderState::Inflating; @@ -220,3 +233,35 @@ pub fn encode( }; (ok != 0).then_some(out_len) } + +/// [`encode`] into `out`'s spare capacity (callers reserve the bound first), advancing `out.len()`. +pub fn encode_append( + quality: core::ffi::c_int, + lgwin: core::ffi::c_int, + mode: c::BrotliEncoderMode, + input: &[u8], + out: &mut Vec, +) -> Option { + let spare = out.spare_capacity_mut(); + let mut out_len = spare.len(); + // SAFETY: input/spare are valid for their lengths; BrotliEncoderCompress + // only reads `input` and writes at most `out_len` bytes into spare, + // updating `out_len` to the number written. + let ok = unsafe { + c::BrotliEncoderCompress( + quality, + lgwin, + mode, + input.len(), + input.as_ptr(), + &raw mut out_len, + spare.as_mut_ptr().cast::(), + ) + }; + if ok == 0 { + return None; + } + // SAFETY: brotli initialized the first `out_len` bytes of spare. + unsafe { bun_core::vec::commit_spare(out, out_len) }; + Some(out_len) +} diff --git a/src/bun_core/string/MutableString.rs b/src/bun_core/string/MutableString.rs index 6e361e3e78fb..cb2465ad12d3 100644 --- a/src/bun_core/string/MutableString.rs +++ b/src/bun_core/string/MutableString.rs @@ -63,8 +63,7 @@ impl MutableString { #[inline] pub fn grow_if_needed(&mut self, amount: usize) -> Result<(), AllocError> { - self.list.reserve(amount); - Ok(()) + self.list.try_reserve(amount).map_err(|_| AllocError) } pub(crate) fn writable_n_bytes_assume_capacity(&mut self, amount: usize) -> &mut [u8] { @@ -226,8 +225,7 @@ impl MutableString { #[inline] pub fn grow_by(&mut self, amount: usize) -> Result<(), AllocError> { - self.list.reserve(amount); - Ok(()) + self.list.try_reserve(amount).map_err(|_| AllocError) } #[inline] diff --git a/src/http/compress_body.rs b/src/http/compress_body.rs index 787deebf3b99..c1a396936f8b 100644 --- a/src/http/compress_body.rs +++ b/src/http/compress_body.rs @@ -84,8 +84,8 @@ pub(crate) fn compress_into( } /// libdeflate one-shot fast path into `state.shared_buffer`. Returns `None` -/// when the worst-case bound exceeds the shared buffer — caller falls back to -/// [`compress_zlib_streaming`]. +/// when the worst-case bound exceeds the shared buffer or a compressor cannot +/// be allocated — caller falls back to [`compress_zlib_streaming`]. fn compress_libdeflate_fast( state: &mut LibdeflateState, input: &[u8], @@ -101,9 +101,10 @@ fn compress_libdeflate_fast( shared_buffer, .. } = state; - let cached = compressor.get_or_insert_with(|| { - OwnedCompressor::new(DEFAULT_DEFLATE_LEVEL).unwrap_or_else(|| bun_core::out_of_memory()) - }); + if compressor.is_none() { + *compressor = Some(OwnedCompressor::new(DEFAULT_DEFLATE_LEVEL)?); + } + let cached = compressor.as_mut()?; // Bound is level-independent — use the cached handle so the slow-path // bail-out doesn't pay for a temp compressor it never uses. @@ -115,9 +116,7 @@ fn compress_libdeflate_fast( // pinned to DEFAULT_DEFLATE_LEVEL. let mut tmp: Option = None; let compressor: &mut Compressor = match level { - Some(l) if l != DEFAULT_DEFLATE_LEVEL => { - tmp.insert(OwnedCompressor::new(l).unwrap_or_else(|| bun_core::out_of_memory())) - } + Some(l) if l != DEFAULT_DEFLATE_LEVEL => tmp.insert(OwnedCompressor::new(l)?), _ => cached, }; @@ -141,8 +140,10 @@ fn compress_zlib_streaming( let window_bits = if gzip { 15 + 16 } else { 15 }; // libdeflate accepts 0..=12; zlib only 0..=9. let level = level.unwrap_or(DEFAULT_DEFLATE_LEVEL).min(9); - let mut encoder = DeflateEncoder::new(level, window_bits, 8, 0) - .map_err(|_| crate::Error::CompressionFailed)?; + let mut encoder = DeflateEncoder::new(level, window_bits, 8, 0).map_err(|err| match err { + bun_zlib::ZlibError::OutOfMemory => crate::Error::Alloc(bun_alloc::AllocError), + _ => crate::Error::CompressionFailed, + })?; // `avail_in` is `c_uint`; `step()` clamps to u32::MAX per call so a // ≥4 GiB body isn't truncated — we loop until `remaining` is empty. @@ -163,6 +164,10 @@ fn compress_zlib_streaming( match rc { ReturnCode::StreamEnd => return Ok(()), ReturnCode::Ok => continue, + ReturnCode::MemError => { + out.clear(); + return Err(crate::Error::Alloc(bun_alloc::AllocError)); + } _ => { out.clear(); return Err(crate::Error::CompressionFailed); @@ -197,16 +202,12 @@ fn compress_brotli( } else { input.len() + 1024 }; - spill.resize(cap, 0); - match bun_brotli::encode(quality, window, mode, input, spill) { - Some(n) => { - spill.truncate(n); - Ok(CompressOutput::Spilled) - } - None => { - spill.clear(); - Err(crate::Error::CompressionFailed) - } + spill + .try_reserve_exact(cap) + .map_err(|_| bun_alloc::AllocError)?; + match bun_brotli::encode_append(quality, window, mode, input, spill) { + Some(_) => Ok(CompressOutput::Spilled), + None => Err(crate::Error::CompressionFailed), } } @@ -227,15 +228,11 @@ fn compress_zstd( }; } - spill.resize(bound, 0); - match bun_zstd::compress(spill, input, level) { - bun_zstd::Result::Success(n) => { - spill.truncate(n); - Ok(CompressOutput::Spilled) - } - bun_zstd::Result::Err(_) => { - spill.clear(); - Err(crate::Error::CompressionFailed) - } + spill + .try_reserve_exact(bound) + .map_err(|_| bun_alloc::AllocError)?; + match bun_zstd::compress_append(spill, input, level) { + bun_zstd::Result::Success(_) => Ok(CompressOutput::Spilled), + bun_zstd::Result::Err(_) => Err(crate::Error::CompressionFailed), } } diff --git a/src/runtime/webcore/CompressionStreamCoder.rs b/src/runtime/webcore/CompressionStreamCoder.rs index a78ff306d79b..e3fdd8ea604b 100644 --- a/src/runtime/webcore/CompressionStreamCoder.rs +++ b/src/runtime/webcore/CompressionStreamCoder.rs @@ -64,13 +64,23 @@ impl Format { const CHUNK: usize = 16 * 1024; /// Room for one codec call: grows `out` by up to [`CHUNK`], clamped to `cap`. -fn spare(out: &mut Vec, cap: usize) -> &mut [core::mem::MaybeUninit] { +fn spare(out: &mut Vec, cap: usize) -> Result<&mut [core::mem::MaybeUninit], CodecError> { debug_assert!(out.len() < cap); let budget = cap - out.len(); - out.reserve(budget.min(CHUNK)); + out.try_reserve(budget.min(CHUNK)) + .map_err(|_| CodecError::OutOfMemory)?; let spare = out.spare_capacity_mut(); let len = spare.len().min(budget); - &mut spare[..len] + Ok(&mut spare[..len]) +} + +/// `CodecError` for a `ZSTD_isError` return value. +fn zstd_error(rc: usize, message: &'static str) -> CodecError { + if zstd::ZSTD_getErrorCode(rc) == zstd::ZSTD_error_memory_allocation { + CodecError::OutOfMemory + } else { + CodecError::Message(message) + } } /// The rest of a chunk (or flush) whose last step stopped at the output cap. @@ -167,6 +177,8 @@ impl Drop for CompressionStreamCoder { #[derive(Clone, Copy)] enum CodecError { TrailingJunk, + /// The output buffer or the codec's own state could not be allocated. + OutOfMemory, Message(&'static str), /// Brotli decoder error; `BrotliDecoderErrorString` (static C string). /// Surfaced as TypeError with `.code = "ERR_" + ` for node:zlib compat. @@ -351,7 +363,7 @@ impl CompressionStreamCoder { }; s.next_in = remaining.as_ptr(); s.avail_in = take as u32; - let spare = spare(out, cap); + let spare = spare(out, cap)?; s.next_out = spare.as_mut_ptr().cast(); s.avail_out = spare.len().min(u32::MAX as usize) as u32; let before = s.avail_out; @@ -367,6 +379,7 @@ impl CompressionStreamCoder { match rc { zlib::ReturnCode::Ok | zlib::ReturnCode::BufError => {} zlib::ReturnCode::StreamEnd => return Ok(Progress::Done), + zlib::ReturnCode::MemError => return Err(CodecError::OutOfMemory), _ => return Err(CodecError::Message("deflate failed")), } if s.avail_out != 0 && remaining.is_empty() { @@ -410,7 +423,7 @@ impl CompressionStreamCoder { }; s.next_in = remaining.as_ptr(); s.avail_in = take as u32; - let spare = spare(out, cap); + let spare = spare(out, cap)?; s.next_out = spare.as_mut_ptr().cast(); s.avail_out = spare.len().min(u32::MAX as usize) as u32; let before = s.avail_out; @@ -447,6 +460,7 @@ impl CompressionStreamCoder { zlib::ReturnCode::NeedDict => { return Err(CodecError::Message("Missing dictionary")); } + zlib::ReturnCode::MemError => return Err(CodecError::OutOfMemory), _ => return Err(CodecError::Message("inflate failed")), } if s.avail_out != 0 && remaining.is_empty() { @@ -471,7 +485,7 @@ impl CompressionStreamCoder { consumed: input.len() - avail_in, }); } - let spare = spare(out, cap); + let spare = spare(out, cap)?; let mut next_out: *mut u8 = spare.as_mut_ptr().cast(); let mut avail_out: usize = spare.len(); let before = avail_out; @@ -514,7 +528,7 @@ impl CompressionStreamCoder { consumed: input.len() - avail_in, }); } - let spare = spare(out, cap); + let spare = spare(out, cap)?; let mut next_out: *mut u8 = spare.as_mut_ptr().cast(); let mut avail_out: usize = spare.len(); let before = avail_out; @@ -575,7 +589,7 @@ impl CompressionStreamCoder { consumed: input_buf.pos, }); } - let spare = spare(out, cap); + let spare = spare(out, cap)?; let mut output_buf = zstd::ZSTD_outBuffer { dst: spare.as_mut_ptr().cast(), size: spare.len(), @@ -595,7 +609,7 @@ impl CompressionStreamCoder { // bytes. unsafe { out.set_len(out.len() + output_buf.pos) }; if zstd::ZSTD_isError(remaining) != 0 { - return Err(CodecError::Message("zstd encode failed")); + return Err(zstd_error(remaining, "zstd encode failed")); } if input_buf.pos == input_buf.size && (!finish || remaining == 0) { return Ok(Progress::Done); @@ -642,7 +656,7 @@ impl CompressionStreamCoder { consumed: input_buf.pos, }); } - let spare = spare(out, cap); + let spare = spare(out, cap)?; let mut output_buf = zstd::ZSTD_outBuffer { dst: spare.as_mut_ptr().cast(), size: spare.len(), @@ -661,7 +675,7 @@ impl CompressionStreamCoder { // bytes. unsafe { out.set_len(out.len() + output_buf.pos) }; if zstd::ZSTD_isError(remaining) != 0 { - return Err(CodecError::Message("zstd decode failed")); + return Err(zstd_error(remaining, "zstd decode failed")); } if remaining == 0 { self.ended = true; @@ -823,6 +837,7 @@ fn codec_error_to_js(global: &JSGlobalObject, e: &CodecError) -> JSValue { format_args!("Trailing junk found after the end of the compressed stream"), ) .to_js(), + CodecError::OutOfMemory => global.create_out_of_memory_error(), CodecError::Message(msg) => global.create_type_error_instance(format_args!("{msg}")), CodecError::Brotli(detail) => { let code = format!("ERR_{detail}"); diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index 8d93ed90ab84..ae00f204dd63 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -10,6 +10,7 @@ import { } from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { bunEnv, bunExe, isASAN, rss } from "harness"; +import zlib from "node:zlib"; import path from "path"; describe("Zstandard compression", async () => { @@ -328,43 +329,26 @@ describe("decompressing frames whose size is not known up front", () => { describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", () => { const MiB = 1024 * 1024; const CAP_MIB = 8; + const outOfMemory = { name: "RangeError", message: "Out of memory" }; - it("compress and decompress, sync and async", async () => { - // All three decompress to more than the cap and each reaches the allocation differently: - // a header size under the 16 MiB limit is allocated up front; one above it starts the - // streaming decoder at 16 MiB; no header size starts small and fails while growing. - const frames = { - headerSize: zstdCompressSync(Buffer.alloc(12 * MiB)), - headerSizeAboveLimit: zstdCompressSync(Buffer.alloc(32 * MiB)), - noHeaderSize: await new Response( - new Response(Buffer.alloc(12 * MiB)).body!.pipeThrough(new CompressionStream("zstd")), - ).bytes(), - }; - expect(frames.noHeaderSize[4] & 0xe0).toBe(0); - const framesBase64 = Object.fromEntries( - Object.entries(frames).map(([name, frame]) => [name, Buffer.from(frame).toString("base64")]), + // Runs `script` in a child whose native allocations above the cap fail. `inputs` arrive in the + // child as Buffers in a `inputs` object; the script prints a JSON object, which is returned. + async function runCapped(inputs: Record, script: string): Promise { + const inputsBase64 = Object.fromEntries( + Object.entries(inputs).map(([name, bytes]) => [name, Buffer.from(bytes).toString("base64")]), ); - await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", /* js */ ` - const frames = Object.fromEntries( - Object.entries(${JSON.stringify(framesBase64)}).map(([name, b64]) => [name, Buffer.from(b64, "base64")]), + const inputs = Object.fromEntries( + Object.entries(${JSON.stringify(inputsBase64)}).map(([name, b64]) => [name, Buffer.from(b64, "base64")]), ); const describeError = e => ({ name: e.name, message: e.message }); const results = {}; - // The compression bound of this is a little over the cap. - const input = Buffer.alloc(${2 * CAP_MIB} * 1024 * 1024); - try { results.compressSync = Bun.zstdCompressSync(input).length; } catch (e) { results.compressSync = describeError(e); } - results.compress = await Bun.zstdCompress(input).then(out => out.length, describeError); - for (const [name, frame] of Object.entries(frames)) { - try { results["decompressSync " + name] = Bun.zstdDecompressSync(frame).length; } catch (e) { results["decompressSync " + name] = describeError(e); } - results["decompress " + name] = await Bun.zstdDecompress(frame).then(out => out.length, describeError); - } - results.afterwards = Bun.zstdDecompressSync(Bun.zstdCompressSync("still works")).toString(); - results.afterwardsAsync = (await Bun.zstdDecompress(await Bun.zstdCompress("still works"))).toString(); + const attempt = (name, fn) => { try { results[name] = fn().length; } catch (e) { results[name] = describeError(e); } }; + ${script} console.log(JSON.stringify(results)); `, ], @@ -385,10 +369,40 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - const outOfMemory = { name: "RangeError", message: "Out of memory" }; expect(stdout, `the child printed nothing and exited with ${exitCode}\nstderr:\n${stderr}`).not.toBe(""); - expect(JSON.parse(stdout)).toEqual({ + expect(exitCode).toBe(0); + return JSON.parse(stdout); + } + + it.concurrent("zstd: compress and decompress, sync and async", async () => { + // All three decompress to more than the cap and each reaches the allocation differently: + // a header size under the 16 MiB limit is allocated up front; one above it starts the + // streaming decoder at 16 MiB; no header size starts small and fails while growing. + const frames = { + headerSize: zstdCompressSync(Buffer.alloc(12 * MiB)), + headerSizeAboveLimit: zstdCompressSync(Buffer.alloc(32 * MiB)), + noHeaderSize: await new Response( + new Response(Buffer.alloc(12 * MiB)).body!.pipeThrough(new CompressionStream("zstd")), + ).bytes(), + }; + expect(frames.noHeaderSize[4] & 0xe0).toBe(0); + + const results = await runCapped( + frames, + /* js */ ` + // The compression bound of this is a little over the cap. + const input = Buffer.alloc(${2 * CAP_MIB} * 1024 * 1024); + attempt("compressSync", () => Bun.zstdCompressSync(input)); + results.compress = await Bun.zstdCompress(input).then(out => out.length, describeError); + for (const [name, frame] of Object.entries(inputs)) { + attempt("decompressSync " + name, () => Bun.zstdDecompressSync(frame)); + results["decompress " + name] = await Bun.zstdDecompress(frame).then(out => out.length, describeError); + } + results.afterwards = Bun.zstdDecompressSync(Bun.zstdCompressSync("still works")).toString(); + results.afterwardsAsync = (await Bun.zstdDecompress(await Bun.zstdCompress("still works"))).toString(); + `, + ); + expect(results).toEqual({ compressSync: outOfMemory, compress: outOfMemory, "decompressSync headerSize": outOfMemory, @@ -400,7 +414,81 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", afterwards: "still works", afterwardsAsync: "still works", }); - expect(exitCode).toBe(0); + }); + + it.concurrent("gzip and deflate, with zlib and with libdeflate", async () => { + // gunzip reads the size from the gzip trailer (12 MiB, which cannot be reserved, so it + // starts small); inflate has no such hint. Both then fail while growing towards 12 MiB. + const streams = { + gzip: gzipSync(Buffer.alloc(12 * MiB)), + deflate: deflateSync(Buffer.alloc(12 * MiB)), + }; + + const results = await runCapped( + streams, + /* js */ ` + // Both compression bounds of this are a little over the cap. + const input = Buffer.alloc(${2 * CAP_MIB} * 1024 * 1024); + for (const library of ["zlib", "libdeflate"]) { + attempt("gzipSync " + library, () => Bun.gzipSync(input, { library })); + attempt("deflateSync " + library, () => Bun.deflateSync(input, { library })); + attempt("gunzipSync " + library, () => Bun.gunzipSync(inputs.gzip, { library })); + attempt("inflateSync " + library, () => Bun.inflateSync(inputs.deflate, { library })); + } + const text = bytes => new TextDecoder().decode(bytes); + results.afterwards = text(Bun.gunzipSync(Bun.gzipSync("still works"))) + " " + text(Bun.inflateSync(Bun.deflateSync("still works", { library: "libdeflate" }), { library: "libdeflate" })); + `, + ); + expect(results).toEqual({ + "gzipSync zlib": outOfMemory, + "deflateSync zlib": outOfMemory, + "gunzipSync zlib": outOfMemory, + "inflateSync zlib": outOfMemory, + "gzipSync libdeflate": outOfMemory, + "deflateSync libdeflate": outOfMemory, + "gunzipSync libdeflate": outOfMemory, + "inflateSync libdeflate": outOfMemory, + afterwards: "still works still works", + }); + }); + + it.concurrent("fetch() decompressing a response body", async () => { + // Each body decompresses to 12 MiB: the gzip trailer's size cannot be reserved up front and + // every streaming decoder (zlib, brotli, zstd) fails while growing its output. + const zeros = Buffer.alloc(12 * MiB); + const bodies = { + gzip: gzipSync(zeros), + deflate: zlib.deflateSync(zeros), + br: zlib.brotliCompressSync(zeros, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 1 } }), + zstd: zstdCompressSync(zeros), + afterwards: gzipSync(Buffer.from("still works")), + }; + + const results = await runCapped( + bodies, + /* js */ ` + using server = Bun.serve({ + port: 0, + fetch(req) { + const name = new URL(req.url).pathname.slice(1); + return new Response(inputs[name], { headers: { "Content-Encoding": name === "afterwards" ? "gzip" : name } }); + }, + }); + for (const name of Object.keys(inputs)) { + results[name] = await fetch(new URL(name, server.url)) + .then(res => res.text()) + .then(text => text.length > 64 ? text.length : text, e => ({ name: e.name, code: e.code })); + } + `, + ); + const fetchOutOfMemory = { name: "TypeError", code: "OutOfMemory" }; + expect(results).toEqual({ + gzip: fetchOutOfMemory, + deflate: fetchOutOfMemory, + br: fetchOutOfMemory, + zstd: fetchOutOfMemory, + afterwards: "still works", + }); }); }); From e0e731be784485c0ffa1c62dea19c4d843beab20 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:18:37 +0000 Subject: [PATCH 08/21] zlib: allocate the inflate and encoder state without zeroing it zlib-ng's own default allocator is plain malloc and it initializes the state it allocates, so the zeroing thunk was a second pass over ~42 KiB per inflate and ~350 KiB per deflate stream. Also trims comments. --- src/bun_alloc/c_thunks.rs | 16 +++++----------- src/http/compress_body.rs | 4 ++-- src/runtime/api/BunObject.rs | 5 +---- src/zlib/lib.rs | 12 ++++-------- 4 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/bun_alloc/c_thunks.rs b/src/bun_alloc/c_thunks.rs index 50ca2c9cff65..577a0baa830e 100644 --- a/src/bun_alloc/c_thunks.rs +++ b/src/bun_alloc/c_thunks.rs @@ -49,13 +49,12 @@ pub unsafe extern "C" fn mi_free_bytes(bytes: *mut c_void, _ctx: *mut c_void) { /// Generated items: /// - `malloc_size(_, len: usize) -> *mut c_void` — brotli-shape, non-zeroing. /// Safe `extern "C" fn` (opaque cookie ignored; body is all-safe). -/// - `calloc_items(_, items: c_uint, len: c_uint) -> *mut c_void` — zlib-shape, -/// zeroing. Safe `extern "C" fn` (same rationale). +/// - `malloc_items(_, items: c_uint, len: c_uint) -> *mut c_void` — zlib-shape, +/// non-zeroing (zlib-ng's own default is plain `malloc`). Safe `extern "C" fn` (same rationale). /// - `free(_, ptr: *mut c_void)` — paired with either alloc. `unsafe` /// (precondition: `ptr` was allocated by this zone / the default allocator). /// -/// Both allocators return null on failure; zlib turns that into `Z_MEM_ERROR` -/// and brotli into a failed instance creation or an OOM error state. +/// Both allocators return null on failure, which zlib and brotli report as their own OOM errors. /// /// Intended to be invoked inside a `mod XxxAllocator { … }` so call sites can /// keep referring to `XxxAllocator::alloc` / `::free` via a local `pub use`. @@ -74,17 +73,12 @@ macro_rules! c_thunks_for_zone { $crate::default_alloc::malloc(len) } - pub extern "C" fn calloc_items( + pub extern "C" fn malloc_items( _: *mut ::core::ffi::c_void, items: ::core::ffi::c_uint, len: ::core::ffi::c_uint, ) -> *mut ::core::ffi::c_void { - if $crate::heap_breakdown::ENABLED { - return $crate::get_zone!($name) - .malloc_zone_calloc(items as usize, len as usize) - .unwrap_or(::core::ptr::null_mut()); - } - $crate::default_alloc::calloc(items as usize, len as usize) + malloc_size(::core::ptr::null_mut(), items as usize * len as usize) } pub unsafe extern "C" fn free(_: *mut ::core::ffi::c_void, data: *mut ::core::ffi::c_void) { diff --git a/src/http/compress_body.rs b/src/http/compress_body.rs index c1a396936f8b..0e1e1ecd18be 100644 --- a/src/http/compress_body.rs +++ b/src/http/compress_body.rs @@ -84,8 +84,8 @@ pub(crate) fn compress_into( } /// libdeflate one-shot fast path into `state.shared_buffer`. Returns `None` -/// when the worst-case bound exceeds the shared buffer or a compressor cannot -/// be allocated — caller falls back to [`compress_zlib_streaming`]. +/// when the bound exceeds the shared buffer or no compressor can be allocated — caller falls back to +/// [`compress_zlib_streaming`]. fn compress_libdeflate_fast( state: &mut LibdeflateState, input: &[u8], diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 10c332ed624d..d4ec114c92d8 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2436,10 +2436,7 @@ pub mod JSZlib { let mut list: Vec = Vec::new(); let mut reserved = false; if is_gzip && compressed.len() > 64 { - // 0 1 2 3 4 5 6 7 - // +---+---+---+---+---+---+---+---+ - // | CRC32 | ISIZE | - // +---+---+---+---+---+---+---+---+ + // The gzip trailer is CRC32 then ISIZE, the uncompressed size mod 2^32 (RFC 1952 2.3.1). let estimated_size: u32 = u32::from_le_bytes( compressed[compressed.len() - 4..][..4] .try_into() diff --git a/src/zlib/lib.rs b/src/zlib/lib.rs index 0516f863e627..fd326ba3ab0a 100644 --- a/src/zlib/lib.rs +++ b/src/zlib/lib.rs @@ -165,18 +165,15 @@ pub enum ZlibError { bun_core::impl_tag_error!(ZlibError); -// zlib `alloc_func`/`free_func` thunks → mimalloc. Shared by `ZlibReader` and -// `ZlibCompressorArrayList`. Intentionally -// `mi_malloc`, NOT `mi_calloc` (see `ZlibAllocator::alloc` for the zeroing -// heap-breakdown variant used by `ZlibReaderArrayList`). pub(crate) use bun_alloc::c_thunks::{ mi_free_opaque as zlib_mi_free, mi_malloc_items as zlib_mi_malloc, }; +// zlib `alloc_func`/`free_func` thunks tagged with the "zlib" heap-breakdown zone. #[allow(non_snake_case)] mod ZlibAllocator { bun_alloc::c_thunks_for_zone!("zlib"); - pub(crate) use calloc_items as alloc; + pub(crate) use malloc_items as alloc; } pub struct ZlibReaderArrayList<'a> { @@ -976,10 +973,9 @@ impl DeflateEncoder { /// Reserves at least `reserve` spare bytes in `out`, points /// `next_in`/`avail_in` at `input` and `next_out`/`avail_out` at the /// spare, calls `deflate(flush)`, and advances `out.len()` by the bytes - /// produced. Returns `(bytes_consumed_from_input, return_code)`, with - /// `MemError` and nothing consumed when `out` cannot be grown. Inputs + /// produced. Returns `(bytes_consumed_from_input, return_code)`. Inputs /// larger than `u32::MAX` are clamped; callers loop and advance `input` - /// by `consumed`. + /// by `consumed`. A failed growth of `out` is `MemError` with nothing consumed. pub fn step( &mut self, input: &[u8], From c23ab9ba0ef23a598168c8bef2e5d64c21a82b67 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:13:22 +0000 Subject: [PATCH 09/21] image: decode, resize, rotate and flip into uninitialized buffers Every codec zero-filled the full w*h*4 output before libspng, libjpeg-turbo, the BMP/GIF decoders or the resize/rotate/flip kernels overwrote all of it; reserve the buffer and commit it once the producer reports success. The GIF decoder appends indices instead of writing into a pre-zeroed buffer (the short-stream tail is still padded with the transparent index), and the quantizer builds its index order with collect(). --- src/runtime/image/codec_bmp.rs | 14 ++++--- src/runtime/image/codec_gif.rs | 74 ++++++++++++++++----------------- src/runtime/image/codec_jpeg.rs | 9 ++-- src/runtime/image/codec_png.rs | 8 ++-- src/runtime/image/codecs.rs | 21 ++++++---- src/runtime/image/quantize.rs | 5 +-- 6 files changed, 70 insertions(+), 61 deletions(-) diff --git a/src/runtime/image/codec_bmp.rs b/src/runtime/image/codec_bmp.rs index 78804cc8edbf..99138109019e 100644 --- a/src/runtime/image/codec_bmp.rs +++ b/src/runtime/image/codec_bmp.rs @@ -147,7 +147,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result = Vec::with_capacity(h.width as usize * h.height as usize * 4); let mut y: u32 = 0; while y < h.height { @@ -157,7 +157,6 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result Result> rs) & (1u32 << rw).wrapping_sub(1), rw); - dst[xs * 4 + 1] = to8((pix >> gs) & (1u32 << gw).wrapping_sub(1), gw); - dst[xs * 4 + 2] = to8((pix >> bs) & (1u32 << bw).wrapping_sub(1), bw); - dst[xs * 4 + 3] = if h.a_mask == 0 { + let a = if h.a_mask == 0 { 0xFF } else { to8((pix >> as_) & (1u32 << aw).wrapping_sub(1), aw) }; + out.extend_from_slice(&[ + to8((pix >> rs) & (1u32 << rw).wrapping_sub(1), rw), + to8((pix >> gs) & (1u32 << gw).wrapping_sub(1), gw), + to8((pix >> bs) & (1u32 << bw).wrapping_sub(1), bw), + a, + ]); x += 1; } y += 1; diff --git a/src/runtime/image/codec_gif.rs b/src/runtime/image/codec_gif.rs index 58aa4cb4e714..9f3d52d453a9 100644 --- a/src/runtime/image/codec_gif.rs +++ b/src/runtime/image/codec_gif.rs @@ -12,6 +12,8 @@ //! transparency index. Animated/disposal/NETSCAPE loop are skipped — Sharp's //! default `pages:1` does the same. +use core::mem::MaybeUninit; + use super::codecs; /// Sub-block-aware bit reader. GIF wraps the LZW bitstream in length- @@ -95,10 +97,18 @@ struct Dict { unsafe impl bun_core::Zeroable for Dict {} impl Dict { - /// Walk the prefix chain into `scratch` (reversed), then copy forwards - /// into `out`. Returns bytes written and the FIRST byte of the string - /// (needed for the K-ω-K case where the new code refers to itself). - fn emit(&self, code_: u16, clear: u16, out: &mut [u8], scratch: &mut [u8]) -> (usize, u8) { + /// Walk the prefix chain into `scratch` (reversed), then append the string + /// to `out`, truncated so `out` never exceeds `npix` entries. Returns the + /// FIRST byte of the string (needed for the K-ω-K case where the new code + /// refers to itself). + fn emit( + &self, + code_: u16, + clear: u16, + out: &mut Vec, + npix: usize, + scratch: &mut [u8], + ) -> u8 { let mut code = code_; let mut n: usize = 0; while code >= clear { @@ -116,11 +126,9 @@ impl Dict { scratch[n] = code as u8; // root: literal byte n += 1; let first: u8 = scratch[n - 1]; - let cap = n.min(out.len()); - for k in 0..cap { - out[k] = scratch[n - 1 - k]; - } - (cap, first) + let take = n.min(npix - out.len()); + out.extend(scratch[n - take..n].iter().rev()); + first } } @@ -251,9 +259,7 @@ fn decode_frame( let mut dict: Box = bun_core::boxed_zeroed(); let mut scratch = [0u8; 4096]; - // PERF: zero-init for safety; uninitialized would be faster if hot. - let mut idx = vec![0u8; npix]; - let mut written: usize = 0; + let mut idx: Vec = Vec::with_capacity(npix); let mut bits = Bits { src: bytes, @@ -263,7 +269,7 @@ fn decode_frame( nbits: 0, eof: false, }; - while written < npix { + while idx.len() < npix { let code = bits.read(size); if bits.eof && code == 0 { break; @@ -284,16 +290,11 @@ fn decode_frame( // its own first byte. let first: u8; if code < avail { - let r = dict.emit(code, clear, &mut idx[written..], &mut scratch); - written += r.0; - first = r.1; + first = dict.emit(code, clear, &mut idx, npix, &mut scratch); } else if code == avail && prev.is_some() { - let r = dict.emit(prev.unwrap(), clear, &mut idx[written..], &mut scratch); - written += r.0; - first = r.1; - if written < npix { - idx[written] = first; - written += 1; + first = dict.emit(prev.unwrap(), clear, &mut idx, npix, &mut scratch); + if idx.len() < npix { + idx.push(first); } } else { return Err(codecs::Error::DecodeFailed); // out-of-range code @@ -316,21 +317,16 @@ fn decode_frame( prev = Some(code); } bits.drain(); - // A short or truncated stream (early EOI/eof) leaves `idx[written..]` as - // raw mimalloc bytes. Those would be mapped through an attacker-controlled - // palette into the output — a heap-memory disclosure. Filling with the - // transparent index (or 0) makes the unfilled region transparent/background - // instead, which is what browsers do for short frames. - if written < npix { - idx[written..].fill(trns.unwrap_or(0)); - } + // Short/truncated stream: pad the tail with the transparent index (or 0), as browsers do. + idx.resize(npix, trns.unwrap_or(0)); // ── interlace reorder ────────────────────────────────────────────────── // GIF interlacing writes rows in 4 passes (every 8th from 0, every 8th // from 4, every 4th from 2, every 2nd from 1). The decoded `idx` is in // pass order; remap to scan order while expanding so we don't allocate a // second index buffer. - let mut out = vec![0u8; npix * 4].into_boxed_slice(); + let mut out: Vec = Vec::with_capacity(npix * 4); + let (pixels, _) = out.spare_capacity_mut().as_chunks_mut::<4>(); let mut pal: [[u8; 4]; 256] = [[0, 0, 0, 255]; 256]; for c in 0..ct.len() / 3 { @@ -348,7 +344,7 @@ fn decode_frame( while y < h { expand_row( &idx[(src_y as usize) * (w as usize)..][..w as usize], - &mut out[(y as usize) * (w as usize) * 4..], + &mut pixels[(y as usize) * (w as usize)..][..w as usize], &pal, ); y += p[1]; @@ -360,25 +356,27 @@ fn decode_frame( while y < h { expand_row( &idx[(y as usize) * (w as usize)..][..w as usize], - &mut out[(y as usize) * (w as usize) * 4..], + &mut pixels[(y as usize) * (w as usize)..][..w as usize], &pal, ); y += 1; } } + // SAFETY: every row in 0..h was expanded above (the passes partition 0..h): all npix slots set. + unsafe { bun_core::vec::commit_spare(&mut out, npix * 4) }; Ok(codecs::Decoded { - rgba: out.into_vec(), + rgba: out, width: w, height: h, icc_profile: None, }) } -/// One row of palette indices → RGBA. Scalar 4-byte copy per pixel — see file -/// comment for why this isn't a Highway kernel. +/// One row of palette indices → RGBA pixel slots. Scalar 4-byte copy per +/// pixel — see file comment for why this isn't a Highway kernel. #[inline] -fn expand_row(idx: &[u8], out: &mut [u8], pal: &[[u8; 4]; 256]) { +fn expand_row(idx: &[u8], out: &mut [[MaybeUninit; 4]], pal: &[[u8; 4]; 256]) { for (x, &c) in idx.iter().enumerate() { - out[x * 4..][..4].copy_from_slice(&pal[c as usize]); + out[x] = pal[c as usize].map(MaybeUninit::new); } } diff --git a/src/runtime/image/codec_jpeg.rs b/src/runtime/image/codec_jpeg.rs index 67ff5cb592b2..8ddf53aee05f 100644 --- a/src/runtime/image/codec_jpeg.rs +++ b/src/runtime/image/codec_jpeg.rs @@ -229,9 +229,10 @@ pub(crate) fn decode( }, ); } - let mut out = vec![0u8; w as usize * ht as usize * 4]; - // SAFETY: `h` is live; src ptr/len come from a valid `&[u8]`; dst is the - // exclusive `out` buffer sized `w*ht*4` and the explicit pitch + cropping + let out_len = w as usize * ht as usize * 4; + let mut out: Vec = Vec::with_capacity(out_len); + // SAFETY: `h` is live; src ptr/len come from a valid `&[u8]`; dst is `out`'s + // exclusive `w*ht*4` bytes of capacity and the explicit pitch + cropping // region above bound libjpeg-turbo's writes to that allocation. if unsafe { tj3Decompress8( @@ -250,6 +251,8 @@ pub(crate) fn decode( if unsafe { tj3Get(h, TJPARAM_JPEGWIDTH) != rw || tj3Get(h, TJPARAM_JPEGHEIGHT) != rh } { return Err(codecs::Error::DecodeFailed); } + // SAFETY: rc 0 (no warning) with unchanged dims means all `ht` rows of `w` pixels were written. + unsafe { bun_core::vec::commit_spare(&mut out, out_len) }; // Extract the APP2 ICC profile (if the source carried one). The marker // parser ran during tj3DecompressHeader, so this is a copy-out of diff --git a/src/runtime/image/codec_png.rs b/src/runtime/image/codec_png.rs index d876e8b833e1..d5b88cbc9077 100644 --- a/src/runtime/image/codec_png.rs +++ b/src/runtime/image/codec_png.rs @@ -123,13 +123,13 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result = Vec::with_capacity(size); + // SAFETY: ctx is valid; out has `size` bytes of capacity, which libspng only writes. if unsafe { spng_decode_image( ctx, out.as_mut_ptr(), - out.len(), + size, SPNG_FMT_RGBA8, SPNG_DECODE_TRNS, ) @@ -137,6 +137,8 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result = vec![0u8; out_sz + scratch_sz]; - // SAFETY: block has out_sz + scratch_sz bytes; dst at [0..out_sz), scratch at [out_sz..). + let mut block: Vec = Vec::with_capacity(out_sz + scratch_sz); + // SAFETY: capacity = dst [0..out_sz) + scratch; the kernel fills scratch before reading it. let rc = unsafe { bun_image_resize_rgba8( src.as_ptr(), @@ -705,9 +705,10 @@ pub(crate) fn resize( if rc != 0 { return Err(Error::OutOfMemory); } + // SAFETY: rc 0 means the vertical pass stored all dst_w×dst_h pixels, i.e. out_sz bytes. + unsafe { bun_core::vec::commit_spare(&mut block, out_sz) }; // Drop the scratch tail; mimalloc's shrink is in-place when the new size // fits the same block, so this is free. - block.truncate(out_sz); block.shrink_to_fit(); // PERF: Vec::shrink_to_fit may not be in-place — profile if hot. Ok(block) @@ -734,8 +735,9 @@ pub(crate) fn rotate(src: &[u8], w: u32, h: u32, degrees: u32) -> Result return Err(e), } } - let mut out: Vec = vec![0u8; (dw as usize) * (dh as usize) * 4]; - // SAFETY: src has w*h*4 bytes; out has dw*dh*4 bytes; degrees is multiple of 90. + let out_len = (dw as usize) * (dh as usize) * 4; + let mut out: Vec = Vec::with_capacity(out_len); + // SAFETY: src has w*h*4 bytes; out has dw*dh*4 bytes of capacity; degrees is multiple of 90. unsafe { bun_image_rotate_rgba8( src.as_ptr(), @@ -745,6 +747,8 @@ pub(crate) fn rotate(src: &[u8], w: u32, h: u32, degrees: u32) -> Result Result return Err(e), } } - let mut out: Vec = vec![0u8; (w as usize) * (h as usize) * 4]; - // SAFETY: src and out both have w*h*4 bytes. + let out_len = (w as usize) * (h as usize) * 4; + let mut out: Vec = Vec::with_capacity(out_len); + // SAFETY: src has w*h*4 bytes; out has w*h*4 bytes of capacity. unsafe { bun_image_flip_rgba8( src.as_ptr(), @@ -773,5 +778,7 @@ pub(crate) fn flip(src: &[u8], w: u32, h: u32, horizontal: bool) -> Result = vec![0u32; n as usize]; - for (i, o) in order.iter_mut().enumerate() { - *o = u32::try_from(i).expect("int cast"); - } + let mut order: Vec = (0..n).collect(); let mut boxes: Vec = Vec::with_capacity(want as usize); boxes.push(shrink(rgba, &order, 0, n)); From e7467f4c90712c4733fb011155c2a2a9f1465164 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:13:22 +0000 Subject: [PATCH 10/21] encoding, TextDecoder, Blob: build transcoded buffers without zero-filling them Hex decoding (both string widths), UTF-16 narrowing, latin1 -> UTF-16 and plain byte copies all allocated zeroed buffers and then overwrote them; they now write into spare capacity (or simply to_vec / concat / collect) like the base64 arm next to them already did. The structured-clone Blob reader copies its payload straight out of the input instead of zeroing a buffer first. --- src/runtime/webcore/Blob.rs | 15 ++-- src/runtime/webcore/TextDecoder.rs | 18 ++--- src/runtime/webcore/encoding.rs | 108 ++++++++++++++--------------- 3 files changed, 70 insertions(+), 71 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 09f779871129..6c89abc05d2d 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -4022,13 +4022,14 @@ fn read_slice>( reader: &mut bun_io::FixedBufferStream, len: usize, ) -> crate::Result> { - if len > reader.buffer.as_ref().len().saturating_sub(reader.pos) { - return Err(crate::Error::TooSmall); - } - let mut slice = vec![0u8; len]; - reader - .read_exact(&mut slice) - .map_err(|_| crate::Error::TooSmall)?; + let buffer = reader.buffer.as_ref(); + let end = reader + .pos + .checked_add(len) + .filter(|&end| end <= buffer.len()) + .ok_or(crate::Error::TooSmall)?; + let slice = buffer[reader.pos..end].to_vec(); + reader.pos = end; Ok(slice) } diff --git a/src/runtime/webcore/TextDecoder.rs b/src/runtime/webcore/TextDecoder.rs index e25b03c28aea..1371509aae47 100644 --- a/src/runtime/webcore/TextDecoder.rs +++ b/src/runtime/webcore/TextDecoder.rs @@ -300,10 +300,15 @@ impl TextDecoder { // // => The reason we need to encode it is because TextDecoder "latin1" is actually CP1252, while WebKit latin1 is 8-bit utf-16 let out_length = strings::element_length_cp1252_into_utf16(buffer_slice); - let mut bytes = vec![0u16; out_length].into_boxed_slice(); - - let out = strings::copy_cp1252_into_utf16(&mut bytes, buffer_slice); + let mut units: Vec = Vec::with_capacity(out_length); + // SAFETY: `units` has `out_length` spare units; `buf` is only ever written. + let buf = + unsafe { core::slice::from_raw_parts_mut(units.as_mut_ptr(), out_length) }; + let out = strings::copy_cp1252_into_utf16(buf, buffer_slice); + // SAFETY: the copy above initialized all `out_length` units (one per input byte). + unsafe { units.set_len(out_length) }; // The boxed slice is a tight allocation (no excess capacity). + let bytes = units.into_boxed_slice(); // SAFETY: `bytes` was allocated by the global allocator; `into_raw` // transfers ownership of the buffer to JSC's external-string finalizer. Ok(unsafe { @@ -321,13 +326,8 @@ impl TextDecoder { let joined_owned: Box<[u8]>; let buffered = self.buffered.get(); let joined: &[u8] = if buffered.len > 0 { - let buffered_len = buffered.len as usize; - let mut storage = - vec![0u8; buffered_len + buffer_slice.len()].into_boxed_slice(); - storage[0..buffered_len].copy_from_slice(buffered.slice()); - storage[buffered_len..].copy_from_slice(buffer_slice); + joined_owned = [buffered.slice(), buffer_slice].concat().into_boxed_slice(); self.buffered.set(Buffered::default()); - joined_owned = storage; &joined_owned } else { buffer_slice diff --git a/src/runtime/webcore/encoding.rs b/src/runtime/webcore/encoding.rs index 18b4d520c834..062a32d240e6 100644 --- a/src/runtime/webcore/encoding.rs +++ b/src/runtime/webcore/encoding.rs @@ -6,6 +6,7 @@ use crate::webcore::jsc::{JSGlobalObject, JSValue, JsResult, StringJsc as _}; use bun_core::String as BunString; use bun_core::strings; use bun_simdutf_sys::simdutf as bun_simdutf; +use core::mem::MaybeUninit; // `bun_core::String` exposes safe `Vec`/`Vec` → WTF::ExternalStringImpl // constructors; delegate so the FFI ownership-transfer invariant is enforced @@ -312,9 +313,12 @@ pub(crate) fn to_bun_string_from_owned_slice(input: Vec, encoding: Encoding) // paper and an allocator change could surface it. Mirrors // `construct_from_u16`'s utf16le arm, which avoids the same // reinterpret for the same reason. - let mut as_u16 = vec![0u16; usable_len / 2]; - let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut as_u16); - dst.copy_from_slice(&input[..usable_len]); + let as_u16: Vec = input[..usable_len] + .as_chunks::<2>() + .0 + .iter() + .map(|&unit| u16::from_ne_bytes(unit)) + .collect(); create_external_globally_allocated_utf16(as_u16) } @@ -679,8 +683,7 @@ pub(crate) unsafe fn write_u16(input: &[u8]) -> Vec { } match encoding_from_u8(ENCODING) { - Encoding::Buffer => { - let mut to = vec![0u8; input.len()]; - to.copy_from_slice(input); - to - } - Encoding::Latin1 | Encoding::Ascii => { - let mut to = vec![0u8; input.len()]; - to.copy_from_slice(input); - to - } + Encoding::Buffer | Encoding::Latin1 | Encoding::Ascii => input.to_vec(), Encoding::Utf8 => { // need to encode strings::allocate_latin1_into_utf8(input).unwrap_or_default() @@ -716,29 +710,18 @@ fn construct_from_u8(input: &[u8]) -> Vec { // (`copy_latin1_into_utf16` is exactly that loop). Write the bytes // directly into a `Vec` so we never depend on an allocator- // layout-dependent `Vec → Vec` header reinterpret. - let mut to = vec![0u8; input.len() * 2]; - for (out, &b) in to.as_chunks_mut::<2>().0.iter_mut().zip(input) { - *out = u16::from(b).to_ne_bytes(); + let out_len = input.len() * 2; + let mut to: Vec = Vec::with_capacity(out_len); + let (pairs, _) = to.spare_capacity_mut().as_chunks_mut::<2>(); + for (out, &b) in pairs.iter_mut().zip(input) { + *out = u16::from(b).to_ne_bytes().map(MaybeUninit::new); } + // SAFETY: the loop wrote one pair per input byte, i.e. all `out_len` reserved bytes. + unsafe { to.set_len(out_len) }; to } - Encoding::Hex => { - if input.len() < 2 { - return Vec::new(); - } - - let mut to = vec![0u8; input.len() / 2]; - let wrote = strings::decode_hex_to_bytes_truncate(&mut to, input); - if wrote == 0 { - // No valid hex pairs were decoded (e.g. Buffer.from("zz", "hex")). The - // allocation is unreachable once we return a zero-length slice, so free - // it here instead of leaking it. - return Vec::new(); - } - to.truncate(wrote); - to - } + Encoding::Hex => construct_from_hex(input), Encoding::Base64 | Encoding::Base64url => { const TRIM_CHARS: &[u8] = b"\r\n\t \x0B"; // \x0B = vertical tab @@ -776,11 +759,7 @@ fn construct_from_u16(input: &[u16]) -> Vec { match encoding_from_u8(ENCODING) { Encoding::Utf8 => strings::to_utf8_alloc_with_type(input), - Encoding::Latin1 | Encoding::Buffer | Encoding::Ascii => { - let mut to = vec![0u8; input.len()]; - strings::copy_u16_into_u8(&mut to, input); - to - } + Encoding::Latin1 | Encoding::Buffer | Encoding::Ascii => narrow_u16_to_u8(input), // string is already encoded, just need to copy the data Encoding::Ucs2 | Encoding::Utf16le => { // `input: &[u16]` is the source bytes verbatim; copy them @@ -789,32 +768,51 @@ fn construct_from_u16(input: &[u16]) -> Vec { bytemuck::cast_slice::(input).to_vec() } - Encoding::Hex => { - if input.len() < 2 { - return Vec::new(); - } - - let mut to = vec![0u8; input.len() / 2]; - let wrote = strings::decode_hex_to_bytes_truncate(&mut to, input); - if wrote == 0 { - return Vec::new(); - } - to.truncate(wrote); - to - } + Encoding::Hex => construct_from_hex(input), Encoding::Base64 | Encoding::Base64url => { // Match Node.js: two-byte strings are decoded from the low byte of // each UTF-16 code unit (so e.g. U+013D behaves like '=' and // U+1234 like '4'), the same narrowing Node's lenient fallback // decoder applies. - let mut narrowed = vec![0u8; input.len()]; - strings::copy_u16_into_u8(&mut narrowed, input); - construct_from_u8::(&narrowed) + construct_from_u8::(&narrow_u16_to_u8(input)) } } } +/// The low byte of every code unit, in a fresh exactly-sized `Vec`. +fn narrow_u16_to_u8(input: &[u16]) -> Vec { + let mut out: Vec = Vec::with_capacity(input.len()); + // SAFETY: `copy_u16_into_u8` only writes `dst`, initializing exactly `input.len()` bytes. + unsafe { + bun_core::vec::fill_spare(&mut out, 0, |dst| { + strings::copy_u16_into_u8(&mut dst[..input.len()], input); + (input.len(), ()) + }) + }; + out +} + +/// Decodes hex pairs up to the first invalid one (`Buffer.from("..", "hex")` semantics). +fn construct_from_hex(input: &[Char]) -> Vec { + let outlen = input.len() / 2; + if outlen == 0 { + return Vec::new(); + } + + let mut to: Vec = Vec::new(); + // SAFETY: the returned spare bytes are write-only until committed. + let dest = unsafe { bun_core::vec::reserve_spare_bytes(&mut to, outlen) }; + let wrote = strings::decode_hex_to_bytes_truncate(&mut dest[..outlen], input); + // `create_buffer` frees nothing for an empty slice, so an empty result must not own memory. + if wrote == 0 { + return Vec::new(); + } + // SAFETY: the decoder initialized the first `wrote` bytes (`wrote <= outlen <= capacity`). + unsafe { bun_core::vec::commit_spare(&mut to, wrote) }; + to +} + // ────────────────────────────────────────────────────────────────────────── // `String` / `ZigString` encoding extension traits. // From 09b8ffeace93a1a788e724eb8528338288f1e24b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:13:22 +0000 Subject: [PATCH 11/21] fs, archive, quic: stop zeroing the 16-64 KiB read scratch buffers on every call Bun.file() reads, the copyFile/cp fallbacks, archive extraction and QUIC stream reads each declared a zeroed stack array per call (or per loop iteration) and only ever used the prefix the read filled. bun_sys::UninitBuf is the stack-array form of the spare-capacity idiom; Archive.files() reads straight into the destination Vec, which also drops a memcpy per chunk. --- src/runtime/api/Archive.rs | 50 +++++++++++++-------------- src/runtime/node/node_fs.rs | 9 ++--- src/runtime/node/quic/stream.rs | 12 ++++--- src/runtime/webcore/blob/copy_file.rs | 4 ++- src/runtime/webcore/blob/read_file.rs | 14 +++----- src/sys/copy_file.rs | 7 ++-- src/sys/lib.rs | 18 ++++++++++ 7 files changed, 69 insertions(+), 45 deletions(-) diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index 205ddb8daaf3..b4e0df36c60d 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -1083,31 +1083,29 @@ impl FilesContext { // Read data incrementally so untrusted entry sizes don't drive allocation. let mut data: Vec = Vec::new(); - if size > 0 { - let mut total_read: usize = 0; - let mut buf = [0u8; 64 * 1024]; - while total_read < size { - let to_read = (size - total_read).min(buf.len()); - let read = archive.read_data(&mut buf[..to_read]); - if read < 0 { - // Read error. - // NOTE: both `data` and `entries` drop automatically here. - // SAFETY: `archive` is the live `read_new()` handle opened above. - return Ok(if let Some(err) = Self::clone_error_string(&archive) { - FilesResult::LibarchiveErr(err) - } else { - FilesResult::Err(FilesError::ReadError) - }); - } - if read == 0 { - break; - } - let bytes_read = usize::try_from(read).expect("int cast"); - data.try_reserve(bytes_read) - .map_err(|_| bun_alloc::AllocError)?; - data.extend_from_slice(&buf[..bytes_read]); - total_read += bytes_read; + while data.len() < size { + let to_read = (size - data.len()).min(64 * 1024); + data.try_reserve(to_read) + .map_err(|_| bun_alloc::AllocError)?; + // SAFETY: `archive_read_data` only stores into the slice; the written prefix is committed below. + let dest = unsafe { &mut bun_core::vec::spare_bytes_mut(&mut data)[..to_read] }; + let read = archive.read_data(dest); + if read < 0 { + // Read error. + // NOTE: both `data` and `entries` drop automatically here. + // SAFETY: `archive` is the live `read_new()` handle opened above. + return Ok(if let Some(err) = Self::clone_error_string(&archive) { + FilesResult::LibarchiveErr(err) + } else { + FilesResult::Err(FilesError::ReadError) + }); + } + if read == 0 { + break; } + let bytes_read = usize::try_from(read).expect("int cast"); + // SAFETY: `archive_read_data` returns exactly the byte count it wrote (`<= to_read`). + unsafe { bun_core::vec::commit_spare(&mut data, bytes_read) }; } // errdefer free(data) — handled by Drop @@ -1338,6 +1336,9 @@ fn extract_to_disk_filtered( let mut count: u32 = 0; let mut entry: *mut lib::Entry = core::ptr::null_mut(); + let mut stack_buf = bun_sys::UninitBuf::<{ 64 * 1024 }>::uninit(); + // SAFETY: `archive_read_data` is the only writer of `buf`; each chunk reads back only `buf[..bytes_read]`. + let buf = unsafe { stack_buf.as_bytes_mut() }; while archive.read_next_header(&mut entry).succeeded() { let entry_ref = lib::Entry::opaque_ref(entry); @@ -1427,7 +1428,6 @@ fn extract_to_disk_filtered( if size > 0 { // Read archive data and write to file let mut remaining = size; - let mut buf = [0u8; 64 * 1024]; while remaining > 0 { let to_read = remaining.min(buf.len()); let read = archive.read_data(&mut buf[..to_read]); diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index dd6773bdc442..e39a96983374 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -4714,13 +4714,14 @@ impl NodeFS { unsafe { libc::posix_fadvise(src_fd.native(), 0, 0, libc::POSIX_FADV_SEQUENTIAL) }; } - let mut stack_buf = [0u8; 64 * 1024]; - let stack_buf_len = stack_buf.len(); + const STACK_BUF_LEN: usize = 64 * 1024; + let mut stack_buf = sys::UninitBuf::::uninit(); let mut buf_to_free: Vec = Vec::new(); - let mut buf: &mut [u8] = &mut stack_buf; + // SAFETY: `Syscall::read` is the only writer of `buf`; each iteration reads back only `buf[..amt]`. + let mut buf: &mut [u8] = unsafe { stack_buf.as_bytes_mut() }; 'maybe_allocate_large_temp_buf: { - if stat_size > stack_buf_len * 16 { + if stat_size > STACK_BUF_LEN * 16 { // Don't allocate more than 8 MB at a time let clamped_size: usize = stat_size.min(8 * 1024 * 1024); // The slab must stay uninitialised: `Vec::resize` here was a diff --git a/src/runtime/node/quic/stream.rs b/src/runtime/node/quic/stream.rs index 1d9937165621..279e5264481b 100644 --- a/src/runtime/node/quic/stream.rs +++ b/src/runtime/node/quic/stream.rs @@ -1021,8 +1021,10 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: return; }; if ctx.is_null() { - let mut buf = [0u8; 4096]; - while stream.read(&mut buf) > 0 {} + let mut stack_buf = bun_sys::UninitBuf::<4096>::uninit(); + // SAFETY: lsquic only stores into the slice and the drained bytes are never read back. + let buf = unsafe { stack_buf.as_bytes_mut() }; + while stream.read(buf) > 0 {} return; } // SAFETY: `ctx` is the live QuicStream we returned from on_new_stream. @@ -1074,10 +1076,12 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: if stream.received_early_data() { qs.with_state(|s| s.received_early_data = 1); } - let mut buf = [0u8; 16 * 1024]; + let mut stack_buf = bun_sys::UninitBuf::<{ 16 * 1024 }>::uninit(); + // SAFETY: lsquic is the only writer of `buf`; each iteration reads back only `buf[..n]`. + let buf = unsafe { stack_buf.as_bytes_mut() }; let mut got_any = false; loop { - let n = stream.read(&mut buf); + let n = stream.read(buf); match n { n if n > 0 => { qs.push_inbound(&buf[..n as usize], false); diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 4c6ba47e57d7..70a088e4dca4 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -988,7 +988,9 @@ fn read_write_loop_capped( cap: SizeType, total: &mut u64, ) -> bun_sys::Result<()> { - let mut buf = [0u8; 64 * 1024]; + let mut stack_buf = bun_sys::UninitBuf::<{ 64 * 1024 }>::uninit(); + // SAFETY: `read` is the only writer of `buf`; each iteration reads back only `buf[..amt]`. + let buf = unsafe { stack_buf.as_bytes_mut() }; let mut remaining = cap; while remaining > 0 { let want = (buf.len() as SizeType).min(remaining) as usize; diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index e009e7fca39f..f809ae19be7c 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -814,11 +814,9 @@ impl ReadFile { // // 64 KB is large, but since this is running in a thread // with it's own stack, it should have sufficient space. - // hoisted out of the loop and zero-initialized once — the - // one-time 64 KB memset is negligible next to the per-iteration - // syscall, and avoids the `MaybeUninit` → `&mut [u8]` cast (uninit - // bytes behind a `&[u8]` is technically UB even when never read). - let mut stack_buffer = [0u8; 64 * 1024]; + let mut stack_storage = bun_sys::UninitBuf::<{ 64 * 1024 }>::uninit(); + // SAFETY: only `do_read` writes into it and only `stack_buffer[..read_amount]` is read back. + let stack_buffer = unsafe { stack_storage.as_bytes_mut() }; // `do_read` never touches `self.buffer`; move it out so the read // target slice (which may point into its spare capacity) can be // held as a safe `&mut [u8]` across the `&mut self` call. @@ -826,7 +824,7 @@ impl ReadFile { while self.state.load(Ordering::Relaxed) == ClosingState::Running as u8 { let (use_stack, buf) = Self::remaining_buffer( &mut buffer, - &mut stack_buffer, + stack_buffer, self.max_length, self.read_off, ); @@ -838,9 +836,7 @@ impl ReadFile { // We might read into the stack buffer, so we need to copy it into the heap. if use_stack { - // `do_read` wrote `read_amount` initialized bytes at - // `stack_buffer[..read_amount]`; the stack array is live - // for this iteration. + // `do_read` initialized exactly `stack_buffer[..read_amount]` (0 on error/retry). let read = &stack_buffer[..read_amount]; if buffer.capacity() == 0 { // We need to allocate a new buffer diff --git a/src/sys/copy_file.rs b/src/sys/copy_file.rs index bf320925c4a3..46c4f32393cf 100644 --- a/src/sys/copy_file.rs +++ b/src/sys/copy_file.rs @@ -10,6 +10,8 @@ use crate::E; use crate::Fd; #[cfg(not(any(target_os = "linux", target_os = "android")))] use crate::Tag; +#[cfg(not(windows))] +use crate::UninitBuf; // `declare_scope!` uses the ident as both static name AND tag string, but // `copy_file` would shadow `pub fn copy_file()` below. Hand-expand with the @@ -433,8 +435,9 @@ pub(crate) fn copy_file_range( #[cfg(not(windows))] pub(crate) fn copy_file_read_write_loop(in_: fd_t, out: fd_t, len: usize) -> crate::Result { - // PERF: 32 KiB stack buffer is zero-initialized — profile if it shows up on a hot path - let mut buf = [0u8; 8 * 4096]; + let mut stack_buf = UninitBuf::<{ 8 * 4096 }>::uninit(); + // SAFETY: `read` below is the only writer of `buf`; only `buf[..amt_read]` is read back. + let buf = unsafe { stack_buf.as_bytes_mut() }; let adjusted_count = buf.len().min(len); match crate::read(Fd::from_native(in_ as _), &mut buf[0..adjusted_count]) { Ok(amt_read) => { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 4a1c4833770e..9efc5a86dda7 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -93,6 +93,24 @@ pub mod copy_file; // Directory-entry kind — same set as `bun_core::FileKind`. pub use bun_core::FileKind as EntryKind; +/// Uninitialized `[u8; N]` stack scratch for a `read(2)`-like producer: the array form of `bun_core::vec::spare_bytes_mut`. +pub struct UninitBuf(core::mem::MaybeUninit<[u8; N]>); + +impl UninitBuf { + #[inline(always)] + pub const fn uninit() -> Self { + Self(core::mem::MaybeUninit::uninit()) + } + + /// # Safety + /// The bytes are uninitialized: only a producer may store into the slice, and only the prefix it reports written may be read. + #[inline(always)] + pub unsafe fn as_bytes_mut(&mut self) -> &mut [u8] { + // SAFETY: `MaybeUninit<[u8; N]>` is laid out as `[u8; N]`; the caller upholds the write-only contract. + unsafe { core::slice::from_raw_parts_mut(self.0.as_mut_ptr().cast::(), N) } + } +} + // `bun.DirIterator`. // // A readdir-style directory iterator. Notable behaviors: From 704da0ce64b7c377b7899374bb16d8923cf8ee03 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:13:22 +0000 Subject: [PATCH 12/21] http, h2, websocket, tls wrapper: drop dead and redundant buffer zeroing - Every HTTP/1 request allocated and zeroed a 32 KiB (or 512 KiB) buffer whose only use was its length, then allocated the real Vec; the size classes stay, the dead buffers go. - Each queued HTTP/2 DATA frame allocated a zeroed full-size 16 KiB buffer; it now holds exactly its payload (with full-frame capacity so corking still appends in place). - The TLS wrapper zeroed a 64 KiB stack buffer per traffic pass and per re-entrant write; it is uninitialized now and only the prefix each SSL_read/BIO_read/pop reported is exposed. - WebSocket sends skip the UTF-8 transcode when the message is too small to compress, and transcode into spare capacity otherwise. --- src/http/HTTPThread.rs | 90 +++----------------------- src/http/lib.rs | 15 ++--- src/http_jsc/websocket_client.rs | 31 +++++---- src/runtime/api/bun/h2_frame_parser.rs | 13 ++-- src/uws/lib.rs | 83 +++++++++++++++++------- 5 files changed, 100 insertions(+), 132 deletions(-) diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index 09e3273ccbf4..a50b72510228 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -135,7 +135,6 @@ pub struct HttpThread { pub(crate) has_awoken: AtomicBool, pub(crate) timer: Instant, pub(crate) lazy_libdeflater: Option>, - pub(crate) lazy_request_body_buffer: Option>, /// Every `ThreadlocalAsyncHTTP` box currently in flight on this thread. /// Inserted by [`start_queued_task`] right after `heap::release`; removed @@ -189,69 +188,20 @@ impl HttpThread { has_awoken: AtomicBool::new(false), timer: Instant::now(), lazy_libdeflater: None, - lazy_request_body_buffer: None, in_flight: Vec::new(), } } } -pub struct HeapRequestBodyBuffer { - pub(crate) buffer: [u8; 512 * 1024], - // Plain write cursor into `buffer`. - pub(crate) cursor: usize, -} - -// SAFETY: `[u8; N]` and `usize` are both valid at the all-zero bit pattern. -unsafe impl bun_core::Zeroable for HeapRequestBodyBuffer {} - -impl HeapRequestBodyBuffer { - pub(crate) fn init() -> Box { - bun_core::boxed_zeroed() - } - - pub(crate) fn put(mut self: Box) { - // SAFETY: HTTP-thread-only access to the global. - let thread = crate::http_thread_mut(); - if thread.lazy_request_body_buffer.is_none() { - self.cursor = 0; // .reset() - thread.lazy_request_body_buffer = Some(self); - } else { - // This case hypothetically should never happen - drop(self); - } - } -} - -pub enum RequestBodyBuffer { - // Option<> so Drop can `.take()` the Box and hand it to `put()` (which consumes by value). - Heap(Option>), - // Inline stack buffer with a heap fallback. - Stack(Box<[u8; REQUEST_BODY_SEND_STACK_BUFFER_SIZE]>), -} - -impl Drop for RequestBodyBuffer { - fn drop(&mut self) { - if let Self::Heap(heap) = self { - if let Some(h) = heap.take() { - h.put(); - } - } - } -} - -impl RequestBodyBuffer { - fn allocated_slice(&mut self) -> &mut [u8] { - match self { - Self::Heap(heap) => &mut heap.as_mut().unwrap().buffer, - Self::Stack(stack) => &mut stack[..], - } - } - - pub(crate) fn to_array_list(&mut self) -> Vec { - // A `Vec` cannot adopt a foreign allocator+buffer, so this - // allocates a fresh Vec of the same capacity. - // Callers that can should write into allocated_slice() directly instead. - Vec::with_capacity(self.allocated_slice().len()) +/// Initial capacity of the `Vec` that `send_initial_request_payload` assembles +/// the request head (plus as much of the body as fits) into. +pub(crate) fn request_body_send_buffer_capacity(estimated_size: usize) -> usize { + const SMALL: usize = 32 * 1024; + const LARGE: usize = 512 * 1024; + if estimated_size >= SMALL { + LARGE + } else { + SMALL } } @@ -301,8 +251,6 @@ impl LibdeflateState { } } -pub(crate) const REQUEST_BODY_SEND_STACK_BUFFER_SIZE: usize = 32 * 1024; - pub(crate) type Queue = UnboundedQueue>; // Clone: bitwise OK for the `*const c_void` CA-string pointers — they borrow @@ -408,26 +356,6 @@ impl HttpThread { u64::try_from(self.timer.elapsed().as_nanos()).expect("int cast") } - #[inline] - pub(crate) fn get_request_body_send_buffer( - &mut self, - estimated_size: usize, - ) -> RequestBodyBuffer { - if estimated_size >= REQUEST_BODY_SEND_STACK_BUFFER_SIZE { - if self.lazy_request_body_buffer.is_none() { - bun_core::scoped_log!( - HTTPThread_log, - "Allocating HeapRequestBodyBuffer due to {} bytes request body", - estimated_size - ); - return RequestBodyBuffer::Heap(Some(HeapRequestBodyBuffer::init())); - } - - return RequestBodyBuffer::Heap(self.lazy_request_body_buffer.take()); - } - RequestBodyBuffer::Stack(Box::new([0u8; REQUEST_BODY_SEND_STACK_BUFFER_SIZE])) - } - pub(crate) fn deflater(&mut self) -> &mut LibdeflateState { if self.lazy_libdeflater.is_none() { let decompressor = bun_libdeflate_sys::libdeflate::OwnedDecompressor::new() diff --git a/src/http/lib.rs b/src/http/lib.rs index 57bfb2fccb51..f8911643f7c6 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -2192,13 +2192,13 @@ impl<'a> HTTPClient<'a> { /// /// For large files, we want to avoid extra network send overhead /// So we do two things: - /// 1. Use a 32 KB stack buffer for small files - /// 2. Use a 512 KB heap buffer for large files + /// 1. Use a 32 KB buffer for small files + /// 2. Use a 512 KB buffer for large files /// This only has an impact on http:// /// /// On https://, we are limited to a 16 KB TLS record size. #[inline] - fn get_request_body_send_buffer(&self) -> http_thread::RequestBodyBuffer { + fn get_request_body_send_buffer(&self) -> Vec { let actual_estimated_size = self.request_body().len() + self.estimated_request_header_byte_length(); let estimated_size = if HTTPClient::is_https(self) { @@ -2206,7 +2206,9 @@ impl<'a> HTTPClient<'a> { } else { actual_estimated_size * 2 }; - http_thread().get_request_body_send_buffer(estimated_size) + Vec::with_capacity(http_thread::request_body_send_buffer_capacity( + estimated_size, + )) } pub(crate) fn is_keep_alive_possible(&self) -> bool { @@ -2990,10 +2992,7 @@ impl<'a> HTTPClient<'a> { ) -> crate::Result { self.compress_body_for_send(true)?; - let mut request_body_buffer = self.get_request_body_send_buffer(); - // request_body_buffer drops at scope exit (was `defer .deinit()`) - let mut temporary_send_buffer = request_body_buffer.to_array_list(); - // temporary_send_buffer drops at scope exit + let mut temporary_send_buffer = self.get_request_body_send_buffer(); let writer = &mut temporary_send_buffer; // Vec impls bun_io::Write diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index 9e687f807adf..8c284d83d56b 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -952,26 +952,34 @@ impl WebSocket { return self.send_data_uncompressed(bytes, do_write, opcode); } + // Small messages aren't worth the deflate overhead (or the transcode below). + let (_, content_byte_len) = bytes.frame_and_content_len(); + if !self.should_compress(content_byte_len, opcode) { + return self.send_data_uncompressed(bytes, do_write, opcode); + } + // The compressor consumes UTF-8/raw bytes, so transcode first. let utf8_storage: Vec; let content_to_compress: &[u8] = match bytes { Copy::Utf16(utf16) => { - let content_byte_len: usize = strings::element_length_utf16_into_utf8(utf16); - let mut buf = vec![0u8; content_byte_len]; - let encode_result = strings::copy_utf16_into_utf8(&mut buf, utf16); - buf.truncate(encode_result.written as usize); - utf8_storage = buf; + utf8_storage = strings::to_utf8_alloc(utf16); &utf8_storage } Copy::Latin1(latin1) => { - let content_byte_len: usize = strings::element_length_latin1_into_utf8(latin1); if content_byte_len == latin1.len() { // It's all ascii, we don't need to copy it an extra time. latin1 } else { - let mut buf = vec![0u8; content_byte_len]; - let encode_result = strings::copy_latin1_into_utf8(&mut buf, latin1); - buf.truncate(encode_result.written as usize); + let mut buf = Vec::with_capacity(content_byte_len); + // SAFETY: copy_latin1_into_utf8 only writes into the spare bytes and + // reports how many it wrote; fill_spare commits exactly that many. + unsafe { + bun_core::vec::fill_spare(&mut buf, 0, |spare| { + let r = strings::copy_latin1_into_utf8(spare, latin1); + (r.written as usize, ()) + }) + }; + debug_assert_eq!(buf.len(), content_byte_len); utf8_storage = buf; &utf8_storage } @@ -980,11 +988,6 @@ impl WebSocket { Copy::Raw(_) => unreachable!(), }; - // Small messages aren't worth the deflate overhead. - if !self.should_compress(content_to_compress.len(), opcode) { - return self.send_data_uncompressed(bytes, do_write, opcode); - } - let mut compressed: Vec = Vec::new(); let compressed_ok = self.deflate.borrow_mut().as_mut().is_some_and(|deflate| { deflate diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index ec5cb1a3fd94..f0a255ab35c7 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1992,7 +1992,7 @@ impl Stream { } if lf!().len == 0 { // we have an empty frame with means we can just use this frame with a new buffer - lf!().buffer = vec![0u8; MAX_PAYLOAD_SIZE_WITHOUT_FRAME]; + lf!().buffer = Vec::with_capacity(MAX_PAYLOAD_SIZE_WITHOUT_FRAME); } let max_size = MAX_PAYLOAD_SIZE_WITHOUT_FRAME as u32; let remaining = max_size - lf!().len; @@ -2000,8 +2000,7 @@ impl Stream { // ok we can cork frames let consumed_len = (remaining as usize).min(bytes.len()); let merge = &bytes[0..consumed_len]; - let len = lf!().len as usize; - lf!().buffer[len..len + consumed_len].copy_from_slice(merge); + lf!().buffer.extend_from_slice(merge); lf!().len += u32::try_from(consumed_len).expect("int cast"); bun_output::scoped_log!(H2FrameParser, "dataFrame merged {}", consumed_len); @@ -2044,7 +2043,7 @@ impl Stream { end_stream ); - let mut frame = PendingFrame { + let frame = PendingFrame { end_stream, len: u32::try_from(bytes.len()).expect("int cast"), offset: 0, @@ -2052,7 +2051,10 @@ impl Stream { buffer: if bytes.is_empty() { Vec::new() } else { - vec![0u8; MAX_PAYLOAD_SIZE_WITHOUT_FRAME] + // Full-frame capacity so later writes cork into this frame without reallocating. + let mut buffer = Vec::with_capacity(MAX_PAYLOAD_SIZE_WITHOUT_FRAME); + buffer.extend_from_slice(bytes); + buffer }, callback: if callback.is_callable() { StrongOptional::create(callback, &global_this) @@ -2061,7 +2063,6 @@ impl Stream { }, }; if !bytes.is_empty() { - frame.buffer[0..bytes.len()].copy_from_slice(bytes); global_this.vm().deprecated_report_extra_memory(bytes.len()); } bun_output::scoped_log!(H2FrameParser, "dataFrame enqueued {}", frame.len); diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 90954168fd52..f95044559e3b 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -198,6 +198,34 @@ pub mod ssl_wrapper { /// writes we loop until we have no more data to write/backpressure. const BUFFER_SIZE: usize = 65536; + /// Stack scratch for `SSL_read` / `BIO_read` / the pending-event pops; left + /// uninitialized, only the producer-reported prefix is ever read back. + struct IoBuffer(core::mem::MaybeUninit<[u8; BUFFER_SIZE]>); + + impl IoBuffer { + #[inline(always)] + const fn uninit() -> Self { + IoBuffer(core::mem::MaybeUninit::uninit()) + } + + #[inline(always)] + fn as_mut_ptr(&mut self) -> *mut u8 { + self.0.as_mut_ptr().cast() + } + + /// View `[0..len]` as `&[u8]`. + /// + /// # Safety + /// A producer must have written every byte in `[0..len]`. + #[inline(always)] + unsafe fn filled(&self, len: usize) -> &[u8] { + debug_assert!(len <= BUFFER_SIZE); + // SAFETY: caller contract — `[0..len]` was written by the producer; + // `len <= BUFFER_SIZE` keeps the slice in-bounds. + unsafe { core::slice::from_raw_parts(self.0.as_ptr().cast::(), len) } + } + } + /// Cap on peer-initiated TLS renegotiations per /// [`MAX_RENEGOTIATION_WINDOW`]. Mirrors the `us_reneg_policy` defaults in /// the uSockets C path (openssl.c) and Node's @@ -687,7 +715,7 @@ pub mod ssl_wrapper { // SSL_shutdown only queues close_notify into the write BIO; nothing // else pumps it on the memory-BIO paths (duplex / named pipe), so // drain it now or the peer never sees our shutdown. - let mut buffer = [0u8; BUFFER_SIZE]; + let mut buffer = IoBuffer::uninit(); self.handle_writing(&mut buffer); ret == 1 // truly closed } @@ -974,7 +1002,7 @@ pub mod ssl_wrapper { } /// Handle reading data. Returns true if we can call handle_writing. - fn handle_reading(&self, buffer: &mut [u8; BUFFER_SIZE]) -> bool { + fn handle_reading(&self, buffer: &mut IoBuffer) -> bool { let mut read: usize = 0; // read data from the input BIO @@ -984,13 +1012,13 @@ pub mod ssl_wrapper { return false; }; - let available = &mut buffer[read..]; - // SAFETY: ssl is a live SSL*; available is a valid mutable slice. + // SAFETY: ssl is a live SSL*; `read < BUFFER_SIZE`, so the pointer and + // length describe the unwritten tail of `buffer`. let just_read = unsafe { boring_sys::SSL_read( ssl.as_ptr(), - available.as_mut_ptr().cast::(), - c_int::try_from(available.len()).expect("int cast"), + buffer.as_mut_ptr().add(read).cast::(), + c_int::try_from(BUFFER_SIZE - read).expect("int cast"), ) }; log!("just read {}", just_read); @@ -1054,7 +1082,8 @@ pub mod ssl_wrapper { // flush the reading if read > 0 { log!("triggering data callback (read {})", read); - self.trigger_data_callback(&buffer[0..read]); + // SAFETY: the SSL_read calls above wrote `[0..read]` contiguously. + self.trigger_data_callback(unsafe { buffer.filled(read) }); // The data callback may have closed the connection if self.ssl.get().is_none() || self.flags.closed_notified() { return false; @@ -1079,13 +1108,14 @@ pub mod ssl_wrapper { self.handle_end_of_renegotiation(); read += usize::try_from(just_read).expect("int cast"); - if read == buffer.len() { + if read == BUFFER_SIZE { log!( "triggering data callback (read {}) and resetting read buffer", read ); // we filled the buffer - self.trigger_data_callback(&buffer[0..read]); + // SAFETY: the SSL_read calls above wrote `[0..read]` contiguously. + self.trigger_data_callback(unsafe { buffer.filled(read) }); // The callback may have closed the connection - check before continuing // Check ssl first as a proxy for whether we were deinited if self.ssl.get().is_none() || self.flags.closed_notified() { @@ -1097,7 +1127,8 @@ pub mod ssl_wrapper { // we finished reading if read > 0 { log!("triggering data callback (read {})", read); - self.trigger_data_callback(&buffer[0..read]); + // SAFETY: the SSL_read calls above wrote `[0..read]` contiguously. + self.trigger_data_callback(unsafe { buffer.filled(read) }); // The callback may have closed the connection // Check ssl first as a proxy for whether we were deinited if self.ssl.get().is_none() || self.flags.closed_notified() { @@ -1107,7 +1138,7 @@ pub mod ssl_wrapper { true } - fn handle_writing(&self, buffer: &mut [u8]) { + fn handle_writing(&self, buffer: &mut IoBuffer) { let mut read: usize = 0; loop { let Some(ssl) = self.ssl.get() else { return }; @@ -1116,19 +1147,20 @@ pub mod ssl_wrapper { else { return; }; - let available = &mut buffer[read..]; - // SAFETY: output is a valid BIO*; available is a valid mutable slice. + // SAFETY: output is a valid BIO*; `read < BUFFER_SIZE`, so the pointer and + // length describe the unwritten tail of `buffer`. let just_read = unsafe { boring_sys::BIO_read( output.as_ptr(), - available.as_mut_ptr().cast::(), - c_int::try_from(available.len()).expect("int cast"), + buffer.as_mut_ptr().add(read).cast::(), + c_int::try_from(BUFFER_SIZE - read).expect("int cast"), ) }; if just_read > 0 { read += usize::try_from(just_read).expect("int cast"); - if read == buffer.len() { - self.trigger_wanna_write_callback(&buffer[0..read]); + if read == BUFFER_SIZE { + // SAFETY: the BIO_read calls above wrote `[0..read]` contiguously. + self.trigger_wanna_write_callback(unsafe { buffer.filled(read) }); read = 0; } } else { @@ -1136,7 +1168,8 @@ pub mod ssl_wrapper { } } if read > 0 { - self.trigger_wanna_write_callback(&buffer[0..read]); + // SAFETY: the BIO_read calls above wrote `[0..read]` contiguously. + self.trigger_wanna_write_callback(unsafe { buffer.filled(read) }); } } @@ -1148,7 +1181,7 @@ pub mod ssl_wrapper { fn handle_traffic(&self) { if self.traffic.get() != Traffic::Idle { log!("handleTraffic re-entered, flushing and deferring to the outer pass"); - let mut buffer = [0u8; BUFFER_SIZE]; + let mut buffer = IoBuffer::uninit(); self.handle_writing(&mut buffer); self.traffic.set(Traffic::RerunRequested); return; @@ -1168,7 +1201,7 @@ pub mod ssl_wrapper { if self.update_handshake_state() { // shared stack buffer for reading and writing // PERF: 64KiB on-stack array — verify stack-size headroom. - let mut buffer = [0u8; BUFFER_SIZE]; + let mut buffer = IoBuffer::uninit(); // drain the input BIO first self.handle_writing(&mut buffer); @@ -1202,7 +1235,7 @@ pub mod ssl_wrapper { /// `init_with_ctx`), so this is a no-op FFI probe otherwise. The /// callbacks run JS which may close the wrapper; `self.ssl` is /// re-checked between pops. - fn flush_pending_events(&self, buffer: &mut [u8; BUFFER_SIZE]) { + fn flush_pending_events(&self, buffer: &mut IoBuffer) { if self.handlers.get().on_session.is_some() { loop { let Some(ssl) = self.ssl.get() else { return }; @@ -1218,9 +1251,11 @@ pub mod ssl_wrapper { if len <= 0 { break; } + // SAFETY: the pop memcpy'd exactly `len` bytes into `[0..len]`. + let entry = unsafe { buffer.filled(len as usize) }; let handlers = self.handlers.get(); if let Some(on_session) = handlers.on_session { - on_session(handlers.ctx, &buffer[..len as usize]); + on_session(handlers.ctx, entry); } } } @@ -1239,9 +1274,11 @@ pub mod ssl_wrapper { if len <= 0 { break; } + // SAFETY: the pop memcpy'd exactly `len` bytes into `[0..len]`. + let entry = unsafe { buffer.filled(len as usize) }; let handlers = self.handlers.get(); if let Some(on_keylog) = handlers.on_keylog { - on_keylog(handlers.ctx, &buffer[..len as usize]); + on_keylog(handlers.ctx, entry); } } } From 8f7d42f4e9352f9883c17bc23ee6770acdb1ecd5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:13:22 +0000 Subject: [PATCH 13/21] randomFill, compile cache, XML, bytea, bundler chunks, JSON tape, sourcemaps: no zero-fill before the producer writes randomFill's scratch is filled by RAND_bytes in place of a resize(0); the compile cache preads into reserved capacity (and now fails the lookup instead of aborting when the header's size cannot be allocated); XML.parse transcodes UTF-16 input straight into the arena; bytea cells decode into reserved capacity and are freed with the exact length they were allocated with; bundler chunk assembly commits the bytes it actually wrote; the JSON tape appends strings within each chunk's capacity instead of zeroing whole chunks; standalone sourcemaps use decompress_alloc, which also handles a frame without a content size instead of turning it into a giant vec!. --- src/ast/e.rs | 23 +++++++----------- src/bundler/Chunk.rs | 26 ++++++++++---------- src/jsc/NodeCompileCache.rs | 22 +++++++++++------ src/parsers/xml.rs | 11 +++++---- src/runtime/node/node_crypto_binding.rs | 32 ++++++++++++++++++++----- src/sourcemap/lib.rs | 20 ++++------------ src/sql_jsc/postgres/DataCell.rs | 20 +++++++++++----- src/sql_jsc/shared/SQLDataCell.rs | 15 +++--------- 8 files changed, 91 insertions(+), 78 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index 0cb8ed0eced8..72a304b195b6 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -991,7 +991,6 @@ pub struct JsonTape { prop_value_locs: Vec, item_locs: Vec, str_chunks: Vec, TapeAlloc>, - str_used: usize, pub encoding: StrEncoding, } @@ -1014,7 +1013,6 @@ impl JsonTape { prop_value_locs: Vec::new_in(alloc), item_locs: Vec::new_in(alloc), str_chunks: Vec::new_in(alloc), - str_used: 0, encoding: StrEncoding::Utf8, } } @@ -1061,7 +1059,7 @@ impl JsonTape { (first, rows.len() as u32) } - /// Copy decoded string bytes into the tape; chunks never move once handed out. + /// Copy decoded string bytes into the tape; chunks grow only within capacity and so never move. pub fn alloc_str(&mut self, bytes: &[u8]) -> Str { self.alloc_str_join(bytes, b"") } @@ -1072,26 +1070,21 @@ impl JsonTape { let fits = self .str_chunks .last() - .is_some_and(|c| c.len() - self.str_used >= len); + .is_some_and(|c| c.capacity() - c.len() >= len); if !fits { let cap = len.max(Self::STR_CHUNK); - let mut chunk: Vec = Vec::with_capacity_in(cap, self.alloc()); - chunk.resize(cap, 0); + let chunk: Vec = Vec::with_capacity_in(cap, self.alloc()); self.str_chunks.push(chunk); - self.str_used = 0; } let chunk = self.str_chunks.last_mut().expect("chunk pushed above"); - let out = &mut chunk[self.str_used..self.str_used + len]; + let start = chunk.len(); if len <= 32 { - for (o, &c) in out.iter_mut().zip(a.iter().chain(b)) { - *o = c; - } + chunk.extend(a.iter().chain(b)); } else { - out[..a.len()].copy_from_slice(a); - out[a.len()..].copy_from_slice(b); + chunk.extend_from_slice(a); + chunk.extend_from_slice(b); } - self.str_used += len; - Str::new(out) + Str::new(&chunk[start..]) } /// The row buffers, for a reader that resolves spans itself. diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 7246ac2eb4d7..0ba9f0038c35 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -479,17 +479,13 @@ type DynAlloc = (); /// Until `DynAlloc` is a real trait object, route /// through the global arena; mimalloc handles large allocations via mmap -/// already. +/// already. Returns an empty `Vec` with exactly `n` bytes of capacity for the +/// caller to fill and commit. #[inline] -fn alloc_buf(_arena: DynAlloc, n: usize) -> Result, AllocError> { - // Zero-fill is required for soundness: `set_len` over uninit bytes violates - // `Vec`'s safety contract, and `into_boxed_slice` may shrink-realloc (memcpy - // of uninit). The memset cost is negligible next to the subsequent memcpy - // that fully overwrites the buffer. +fn alloc_buf(_arena: DynAlloc, n: usize) -> Result, AllocError> { let mut v: Vec = Vec::new(); v.try_reserve_exact(n).map_err(|_| AllocError)?; - v.resize(n, 0); - Ok(v.into_boxed_slice()) + Ok(v) } /// Extract the `OutputFile` index from a trailing `AdditionalFile` entry @@ -844,8 +840,11 @@ impl IntermediateOutput { }; let arena = allocator_to_use.unwrap_or_else(|| Self::allocator_for_size(count)); - let mut total_buf = alloc_buf(*arena, count + debug_id_len)?; - let mut remain: &mut [u8] = &mut total_buf; + let total_len = count + debug_id_len; + let mut total_buf = alloc_buf(*arena, total_len)?; + // SAFETY: the loop below only copies bytes into `remain`; only the prefix it wrote is committed. + let mut remain: &mut [u8] = + unsafe { &mut bun_core::vec::spare_bytes_mut(&mut total_buf)[..total_len] }; for piece in pieces.slice() { let data = piece.data(); @@ -1049,10 +1048,13 @@ impl IntermediateOutput { } debug_assert!(remain.is_empty()); - debug_assert!(total_buf.len() == count + debug_id_len); + let written = total_len - remain.len(); + // SAFETY: `remain` advanced past exactly the `written` bytes the loop initialized. + unsafe { bun_core::vec::commit_spare(&mut total_buf, written) }; + debug_assert!(total_buf.len() == total_len); Ok(CodeResult { - buffer: total_buf, + buffer: total_buf.into_boxed_slice(), shifts: if ENABLE_SOURCE_MAP_SHIFTS { shifts } else { diff --git a/src/jsc/NodeCompileCache.rs b/src/jsc/NodeCompileCache.rs index 38d23c3147a0..ba8f8a793d33 100644 --- a/src/jsc/NodeCompileCache.rs +++ b/src/jsc/NodeCompileCache.rs @@ -721,13 +721,21 @@ fn read_cache_file(state: &CacheState, key: u64, entry: &mut Entry, code: Option // SAFETY: the mapping is `total` bytes and outlives this borrow. Some((base, _)) => unsafe { core::slice::from_raw_parts(base.as_ptr(), total) }, None => { - let mut contents = vec![0u8; total]; - match file.pread_all(&mut contents, 0) { - Ok(n) if n == total => {} - _ => { - finish(line, &|| "reading header failed\n".into()); - return; - } + let mut contents: Vec = Vec::new(); + if contents.try_reserve_exact(total).is_err() { + finish(line, &|| "allocation failed\n".into()); + return; + } + // SAFETY: `pread_all` only writes into the spare bytes and returns how many it filled. + let read = unsafe { + bun_core::vec::fill_spare(&mut contents, 0, |spare| { + let read = file.pread_all(&mut spare[..total], 0).ok(); + (read.unwrap_or(0), read) + }) + }; + if read != Some(total) { + finish(line, &|| "reading header failed\n".into()); + return; } heap_contents = contents; &heap_contents diff --git a/src/parsers/xml.rs b/src/parsers/xml.rs index 1a51bb4bce13..e5d27b911a22 100644 --- a/src/parsers/xml.rs +++ b/src/parsers/xml.rs @@ -1266,13 +1266,16 @@ impl<'a, 'log, U: Unit> Scanner<'a, 'log, U> { } }) .collect(); - let mut utf8 = vec![0u8; simdutf::length::utf8::from::utf16::le(&units)]; - let result = simdutf::convert::utf16::to::utf8::with_errors::le(&units, &mut utf8); + let len = simdutf::length::utf8::from::utf16::le(&units); + let slot = self.bump.alloc_uninit_slice::(len); + // SAFETY: simdutf only writes into `utf8`; only the `result.count` bytes it wrote are read. + let utf8: &'a mut [u8] = + unsafe { core::slice::from_raw_parts_mut(slot.as_mut_ptr().cast::(), len) }; + let result = simdutf::convert::utf16::to::utf8::with_errors::le(&units, utf8); if !result.is_successful() { return Err(self.err(result.count * 2, "Invalid UTF-16")); } - utf8.truncate(result.count); - self.src = Self::units_of(self.bump.alloc_slice_copy(&utf8)); + self.src = Self::units_of(&utf8[..result.count]); self.pos = 0; self.transcoded = true; Ok(()) diff --git a/src/runtime/node/node_crypto_binding.rs b/src/runtime/node/node_crypto_binding.rs index fb91d000cdd6..3b7c55410fa6 100644 --- a/src/runtime/node/node_crypto_binding.rs +++ b/src/runtime/node/node_crypto_binding.rs @@ -208,8 +208,13 @@ pub mod random { /// which keeps its VM alive). InPlace { bytes: JsPtr, length: usize }, /// `randomFill`: the caller's buffer stays untouched until completion; - /// fill `scratch` off-thread and copy it in at `offset` on the JS thread. - Scratch { scratch: Vec, offset: u32 }, + /// `scratch` arrives empty with `size` bytes reserved, is filled + /// off-thread and copied in at `offset` on the JS thread. + Scratch { + scratch: Vec, + size: usize, + offset: u32, + }, } #[derive(bun_jsc::JsAffine)] @@ -234,7 +239,16 @@ pub mod random { done: bun_jsc::Completion, ) -> Option> { match this { - RandomFillJob::Scratch { scratch, .. } => boringssl::rand_bytes(scratch), + RandomFillJob::Scratch { scratch, size, .. } => { + let size = *size; + // SAFETY: `rand_bytes` only writes, and fills every byte of the slice it is given. + unsafe { + bun_core::vec::fill_spare(scratch, 0, |spare| { + boringssl::rand_bytes(&mut spare[..size]); + (size, ()) + }) + } + } RandomFillJob::InPlace { bytes, length } => { // SAFETY: `bytes` points into the ArrayBuffer `value` keeps alive; // the ticket keeps the VM (and so that buffer) alive; `length` is @@ -253,7 +267,10 @@ pub mod random { fn then(this: Self, js: RandomFillJs, cx: &JsThread<'_>) -> JsResult<()> { let global = cx.global(); - if let RandomFillJob::Scratch { scratch, offset } = this { + if let RandomFillJob::Scratch { + scratch, offset, .. + } = this + { if let Some(mut buf) = js.value.value().as_array_buffer(global) { let off = offset as usize; let dst = buf.slice_mut(); @@ -713,12 +730,15 @@ pub mod random { if scratch.try_reserve_exact(size).is_err() { return Err(global.throw_out_of_memory()); } - scratch.resize(size, 0); schedule( global, callback, - RandomFillJob::Scratch { scratch, offset }, + RandomFillJob::Scratch { + scratch, + size, + offset, + }, buf_value, ); diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index 5aaf6e92f0f4..15cf317bb6f6 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -685,22 +685,10 @@ pub mod SerializedSourceMap { let decompressed = self.decompressed_files[index].get_or_init(|| { let sp = self.map.compressed_source_file_at(index); let compressed_file = sp.slice(self.map.bytes); - let size = bun_zstd::get_decompressed_size(compressed_file); - - let mut bytes = vec![0u8; size]; - match bun_zstd::decompress(&mut bytes, compressed_file) { - bun_zstd::Result::Err(err) => { - bun_core::warn!( - "Source map decompression error: {}", - ::bstr::BStr::new(err.as_bytes()), - ); - Vec::new() - } - bun_zstd::Result::Success(n) => { - bytes.truncate(n); - bytes - } - } + bun_zstd::decompress_alloc(compressed_file).unwrap_or_else(|err| { + bun_core::warn!("Source map decompression error: {}", err); + Vec::new() + }) }); if decompressed.is_empty() { None diff --git a/src/sql_jsc/postgres/DataCell.rs b/src/sql_jsc/postgres/DataCell.rs index 3daf8e8135eb..c76aaac5ecd4 100644 --- a/src/sql_jsc/postgres/DataCell.rs +++ b/src/sql_jsc/postgres/DataCell.rs @@ -20,12 +20,20 @@ bun_core::declare_scope!(PostgresDataCell, visible); fn parse_bytea(hex: &[u8]) -> Result { let len = hex.len() / 2; - let mut buf = vec![0u8; len].into_boxed_slice(); - // errdefer free(buf) → Box drops on `?` - - let written = bun_core::decode_hex_to_bytes(&mut buf, hex) - .map_err(|_| AnyPostgresError::InvalidByteSequence)?; - let ptr = bun_core::heap::into_raw(buf).cast::(); + let mut buf: Vec = Vec::new(); + buf.try_reserve_exact(len) + .map_err(|_| AnyPostgresError::OutOfMemory)?; + // SAFETY: the decoder only writes into the spare bytes and returns how many it filled. + let written = unsafe { + bun_core::vec::fill_spare(&mut buf, 0, |spare| { + match bun_core::decode_hex_to_bytes(&mut spare[..len], hex) { + Ok(written) => (written, Ok(written)), + Err(_) => (0, Err(AnyPostgresError::InvalidByteSequence)), + } + }) + }?; + // `SQLDataCell::deinit` frees this as a `Box<[u8]>` of exactly `written` bytes. + let ptr = bun_core::heap::into_raw(buf.into_boxed_slice()).cast::(); Ok(SQLDataCell { tag: Tag::Bytea, diff --git a/src/sql_jsc/shared/SQLDataCell.rs b/src/sql_jsc/shared/SQLDataCell.rs index 3cad2fe4baf1..6e5b5631ace7 100644 --- a/src/sql_jsc/shared/SQLDataCell.rs +++ b/src/sql_jsc/shared/SQLDataCell.rs @@ -206,18 +206,9 @@ impl SQLDataCell { let len = bytea[1]; // Build the fat pointer with the safe `ptr::slice_from_raw_parts_mut` // (no `&mut` reference materialized); only `Box::from_raw` is unsafe. - // SAFETY: bytea[0]/bytea[1] are ptr/len of a buffer allocated - // via the global allocator. The only `free_value=1` Bytea - // producer is `parse_bytea` - // (postgres/DataCell.rs), which allocates exactly `hex.len()/2` - // bytes and stores `decode_hex_to_bytes`'s return. With that - // call-site invariant (`source.len() >= 2 * dest.len()`), the - // non-truncating decoder cannot exhaust the input before the - // destination fills, so on success it returns `dest.len()` — - // hence allocation size == len and the `Box<[u8]>` layout - // below matches the allocation. (In general the decoder may - // return less than `dest.len()` when the input runs out first; - // this proof depends on parse_bytea's allocation size.) + // SAFETY: the only `free_value=1` Bytea producer is `parse_bytea` + // (postgres/DataCell.rs), which stores the ptr/len of a + // `Box<[u8]>` of exactly `len` bytes, so the layout matches. unsafe { drop(Box::<[u8]>::from_raw(ptr::slice_from_raw_parts_mut(p, len))) }; } Tag::Array => { From 2b6ea6483932f53d9259c0c8c57de996766ace20 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:13:22 +0000 Subject: [PATCH 14/21] paths: join into an uninitialized scratch buffer Every join*() zeroed a 4 KiB (u8) or 8 KiB (u16) stack scratch, also when it then spilled to the heap. Both variants now write into MaybeUninit storage and only expose the prefix they filled; unit tests cover the empty, normalizing and spill-to-heap cases for both widths. --- src/paths/resolve_path.rs | 118 ++++++++++++++++++++++++++++++-------- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 6cd439d8eea6..f6dfea1a045d 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1,4 +1,5 @@ use core::cell::UnsafeCell; +use core::mem::MaybeUninit; use crate::fs as Fs; use crate::{MAX_PATH_BYTES, PathBuffer, SEP, SEP_POSIX, SEP_WINDOWS}; @@ -1480,6 +1481,22 @@ pub fn join_string_buf_w_same<'a, P: PlatformT>(buf: &'a mut [u16], parts: &[&[u join_string_buf_t_same::(buf, parts) } +const JOIN_TEMP_LEN: usize = 4096; + +/// Uninitialized scratch for `join_string_buf_t*`'s unnormalized concatenation of `count` units. +#[inline] +fn join_temp_buf<'a, T>( + stack: &'a mut [MaybeUninit; JOIN_TEMP_LEN], + heap: &'a mut Vec, + count: usize, +) -> &'a mut [MaybeUninit] { + if count * 2 > JOIN_TEMP_LEN { + heap.reserve_exact(count * 2); + return heap.spare_capacity_mut(); + } + stack +} + /// Same-width `joinStringBufT`: parts already match `T`, so no UTF-8→16 transcode. /// split out of `join_string_buf_t` because Rust can't monomorphize on the /// parts' element types — callers pick the overload. @@ -1488,9 +1505,8 @@ fn join_string_buf_t_same<'a, T: PathChar, P: PlatformT>( parts: &[&[T]], ) -> &'a [T] { let mut written: usize = 0; - let mut temp_buf_: [T; 4096] = [T::from_u8(0); 4096]; - let mut temp_buf: &mut [T] = &mut temp_buf_; - let mut heap_temp_buf: Vec; + let mut stack_temp_buf = [const { MaybeUninit::::uninit() }; JOIN_TEMP_LEN]; + let mut heap_temp_buf: Vec = Vec::new(); let mut count: usize = 0; for part in parts { @@ -1500,12 +1516,7 @@ fn join_string_buf_t_same<'a, T: PathChar, P: PlatformT>( count += part.len() + 1; } - if count * 2 > temp_buf.len() { - heap_temp_buf = vec![T::from_u8(0); count * 2]; - temp_buf = &mut heap_temp_buf; - } - - temp_buf[0] = T::from_u8(0); + let temp_buf = join_temp_buf(&mut stack_temp_buf, &mut heap_temp_buf, count); for part in parts { if part.is_empty() { @@ -1513,11 +1524,11 @@ fn join_string_buf_t_same<'a, T: PathChar, P: PlatformT>( } if written > 0 { - temp_buf[written] = T::from_u8(P::P.separator()); + temp_buf[written].write(T::from_u8(P::P.separator())); written += 1; } - temp_buf[written..written + part.len()].copy_from_slice(part); + temp_buf[written..written + part.len()].write_copy_of_slice(part); written += part.len(); } @@ -1526,7 +1537,9 @@ fn join_string_buf_t_same<'a, T: PathChar, P: PlatformT>( return &buf[0..1]; } - normalize_string_node_t::(&temp_buf[0..written], buf) + // SAFETY: the loop above wrote every unit of `temp_buf[..written]`. + let joined = unsafe { temp_buf[..written].assume_init_ref() }; + normalize_string_node_t::(joined, buf) } pub fn join_string_buf_z<'a, P: PlatformT>(buf: &'a mut [u8], parts: &[&[u8]]) -> &'a ZStr { @@ -1550,9 +1563,8 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[& // Takes `&[&[u8]]` — every in-tree caller passes u8 // parts — and transcodes to u16 below when `T == u16`. let mut written: usize = 0; - let mut temp_buf_: [T; 4096] = [T::from_u8(0); 4096]; - let mut temp_buf: &mut [T] = &mut temp_buf_; - let mut heap_temp_buf: Vec; + let mut stack_temp_buf = [const { MaybeUninit::::uninit() }; JOIN_TEMP_LEN]; + let mut heap_temp_buf: Vec = Vec::new(); let mut count: usize = 0; for part in parts { @@ -1562,12 +1574,7 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[& count += part.len() + 1; } - if count * 2 > temp_buf.len() { - heap_temp_buf = vec![T::from_u8(0); count * 2]; - temp_buf = &mut heap_temp_buf; - } - - temp_buf[0] = T::from_u8(0); + let temp_buf = join_temp_buf(&mut stack_temp_buf, &mut heap_temp_buf, count); for part in parts { if part.is_empty() { @@ -1575,12 +1582,16 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[& } if written > 0 { - temp_buf[written] = T::from_u8(P::P.separator()); + temp_buf[written].write(T::from_u8(P::P.separator())); written += 1; } + let spare = &mut temp_buf[written..]; + // SAFETY: write-only view; `write_u8_part` only stores into it and returns the units written. + let dest: &mut [T] = + unsafe { core::slice::from_raw_parts_mut(spare.as_mut_ptr().cast::(), spare.len()) }; // Parts are always u8 (see fn-level comment); transcode iff T == u16. - written += T::write_u8_part(&mut temp_buf[written..], part); + written += T::write_u8_part(dest, part); } if written == 0 { @@ -1588,7 +1599,9 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[& return &buf[0..1]; } - normalize_string_node_t::(&temp_buf[0..written], buf) + // SAFETY: the loop above wrote every unit of `temp_buf[..written]`. + let joined = unsafe { temp_buf[..written].assume_init_ref() }; + normalize_string_node_t::(joined, buf) } /// Scratch buffer for `_join_abs_string_buf`'s unnormalized concatenation. @@ -2561,4 +2574,61 @@ mod tests { b"C:." ); } + + #[test] + fn join_string_buf_skips_empty_parts_and_normalizes() { + let mut out = [0u8; 64]; + assert_eq!( + join_string_buf::(&mut out, &[b"foo", b"", b"bar/../baz"]), + b"foo/baz" + ); + assert_eq!( + join_string_buf::(&mut out, &[b"", b""]), + b"." + ); + } + + #[test] + fn join_string_buf_spills_parts_longer_than_the_stack_scratch() { + let long = vec![b'a'; JOIN_TEMP_LEN]; + let mut expected = long.clone(); + expected.extend_from_slice(b"/y"); + + let mut out = vec![0u8; JOIN_TEMP_LEN + 16]; + assert_eq!( + join_string_buf::(&mut out, &[&long, b"x/../y"]), + &expected[..] + ); + } + + #[test] + fn join_string_buf_w_same_skips_empty_parts_and_normalizes() { + let foo: Vec = "foo".encode_utf16().collect(); + let rest: Vec = "bar\\..\\baz".encode_utf16().collect(); + let expected: Vec = "foo\\baz".encode_utf16().collect(); + + let mut out = [0u16; 64]; + assert_eq!( + join_string_buf_w_same::(&mut out, &[&foo, &[], &rest]), + &expected[..] + ); + assert_eq!( + join_string_buf_w_same::(&mut out, &[&[], &[]]), + &[b'.' as u16][..] + ); + } + + #[test] + fn join_string_buf_w_same_spills_parts_longer_than_the_stack_scratch() { + let long = vec![b'a' as u16; JOIN_TEMP_LEN]; + let rest: Vec = "x\\..\\y".encode_utf16().collect(); + let mut expected = long.clone(); + expected.extend_from_slice(&[b'\\' as u16, b'y' as u16]); + + let mut out = vec![0u16; JOIN_TEMP_LEN + 16]; + assert_eq!( + join_string_buf_w_same::(&mut out, &[&long, &rest]), + &expected[..] + ); + } } From 23f1506c429d337727c5c0922a8b73fa7fcb0cd3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:13:22 +0000 Subject: [PATCH 15/21] base64: encode and decode into spare capacity decode_alloc and the encoders allocated zeroed buffers and overwrote them; encode_append encodes onto the end of a Vec and replaces the resize(0)-then-encode pattern used for inline sourcemaps and data: URLs, and data: URL decoding goes through decode_alloc. --- src/base64/lib.rs | 43 +++++++++++++------ .../generateChunksInParallel.rs | 14 +----- .../linker_context/writeOutputFilesToDisk.rs | 6 +-- src/resolver/data_url.rs | 22 ++++------ 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/src/base64/lib.rs b/src/base64/lib.rs index 627be555a6f1..b85bbd5b6dec 100644 --- a/src/base64/lib.rs +++ b/src/base64/lib.rs @@ -106,22 +106,41 @@ pub enum DecodeAllocError { } pub fn decode_alloc(input: &[u8]) -> Result, DecodeAllocError> { - let mut dest = vec![0u8; decode_len(input)]; - let result = decode(&mut dest, input); + let len = decode_len(input); + let mut dest: Vec = Vec::with_capacity(len); + // SAFETY: both decoders behind `decode` only write the destination, never read it. + let destination = unsafe { bun_core::vec::spare_bytes_mut(&mut dest) }; + let result = decode(&mut destination[..len], input); if !result.is_successful() { return Err(DecodeAllocError::DecodingFailed); } - dest.truncate(result.count); + // SAFETY: on success the decoder wrote the first `result.count` (<= `len`) bytes of the spare. + unsafe { bun_core::vec::commit_spare(&mut dest, result.count) }; Ok(dest) } pub use bun_core::base64::encode; +/// [`encode`] appended to `out` (reserving the room itself); returns the number of bytes appended. +pub fn encode_append(out: &mut Vec, source: &[u8]) -> usize { + encode_append_impl(out, source, false) +} + +fn encode_append_impl(out: &mut Vec, source: &[u8], is_urlsafe: bool) -> usize { + let len = simdutf::base64::encode_len(source.len(), is_urlsafe); + // SAFETY: `encode_raw` writes exactly `len` bytes into the `len` spare bytes reserved here. + unsafe { + bun_core::vec::fill_spare(out, len, |spare| { + let written = simdutf::base64::encode_raw(source, spare.as_mut_ptr(), is_urlsafe); + debug_assert_eq!(written, len); + (written, written) + }) + } +} + pub fn encode_alloc(source: &[u8]) -> Vec { - let len = encode_len(source); - let mut destination = vec![0u8; len]; - let encoded_len = encode(&mut destination, source); - destination.truncate(encoded_len); + let mut destination = Vec::new(); + encode_append(&mut destination, source); destination } @@ -139,14 +158,10 @@ pub fn encode_url_safe(dest: &mut [u8], source: &[u8]) -> usize { simdutf::base64::encode(source, dest, true) } -/// `encode_url_safe` into a freshly-allocated `Vec` sized exactly via -/// `simdutf_encode_len_url_safe` (simdutf computes the exact no-padding length, so -/// the trailing `truncate` is a no-op kept for symmetry with `encode_alloc`). +/// [`encode_url_safe`] into a freshly-allocated `Vec`. pub fn simdutf_encode_url_safe_alloc(source: &[u8]) -> Vec { - let len = simdutf_encode_len_url_safe(source.len()); - let mut destination = vec![0u8; len]; - let encoded_len = encode_url_safe(&mut destination, source); - destination.truncate(encoded_len); + let mut destination = Vec::new(); + encode_append_impl(&mut destination, source, true); destination } diff --git a/src/bundler/linker_context/generateChunksInParallel.rs b/src/bundler/linker_context/generateChunksInParallel.rs index 81f52306020d..6da1126fe98a 100644 --- a/src/bundler/linker_context/generateChunksInParallel.rs +++ b/src/bundler/linker_context/generateChunksInParallel.rs @@ -759,12 +759,7 @@ pub(crate) fn generate_chunks_in_parallel( buf.extend_from_slice(&buffer); buf.extend_from_slice(source_map_start); - - let old_len = buf.len(); - // Capacity reserved above; resize zero-fills then base64 overwrites. - buf.resize(old_len + encode_len, 0); - let _ = bun_base64::encode(&mut buf[old_len..], &output_source_map); - + bun_base64::encode_append(&mut buf, &output_source_map); buf.push(b'\n'); buffer = buf.into_boxed_slice(); } @@ -1001,12 +996,7 @@ pub(crate) fn generate_chunks_in_parallel( buf.extend_from_slice(&code_result.buffer); buf.extend_from_slice(source_map_start); - - let old_len = buf.len(); - // Capacity reserved above; resize zero-fills then base64 overwrites. - buf.resize(old_len + encode_len, 0); - let _ = bun_base64::encode(&mut buf[old_len..], &output_source_map); - + bun_base64::encode_append(&mut buf, &output_source_map); buf.push(b'\n'); code_result.buffer = buf.into_boxed_slice(); drop(output_source_map); diff --git a/src/bundler/linker_context/writeOutputFilesToDisk.rs b/src/bundler/linker_context/writeOutputFilesToDisk.rs index 8be1e88e3f5d..7fc1deed29db 100644 --- a/src/bundler/linker_context/writeOutputFilesToDisk.rs +++ b/src/bundler/linker_context/writeOutputFilesToDisk.rs @@ -377,11 +377,7 @@ pub(crate) fn write_output_files_to_disk( buf.extend_from_slice(&code_result.buffer); buf.extend_from_slice(source_map_start); - - let old_len = buf.len(); - buf.resize(old_len + encode_len, 0); - let _ = bun_base64::encode(&mut buf[old_len..], &output_source_map); - + bun_base64::encode_append(&mut buf, &output_source_map); buf.push(b'\n'); code_result.buffer = buf.into_boxed_slice(); } diff --git a/src/resolver/data_url.rs b/src/resolver/data_url.rs index 2f06f4539302..5311f75685c1 100644 --- a/src/resolver/data_url.rs +++ b/src/resolver/data_url.rs @@ -177,14 +177,12 @@ impl<'a> DataURL<'a> { let percent_decoded: &[u8] = percent_decoded_owned.as_deref().unwrap_or(self.data); if self.is_base64 { - let len = bun_base64::decode_len(percent_decoded); - let mut buf = vec![0u8; len]; - // errdefer: `buf` drops automatically on error path - let result = bun_base64::decode(&mut buf, percent_decoded); - if !result.is_successful() || result.count != len { + let decoded = bun_base64::decode_alloc(percent_decoded) + .map_err(|_| DecodeDataError::Base64DecodeError)?; + if decoded.len() != bun_base64::decode_len(percent_decoded) { return Err(DecodeDataError::Base64DecodeError); } - return Ok(buf); + return Ok(decoded); } Ok(percent_decoded.to_vec()) @@ -221,13 +219,11 @@ impl<'a> DataURL<'a> { // When the percent-escape path bails, the payload must be // base64-encoded for real (the buffer is sized for the encoded form). - let mut base64buf = vec![0u8; total_base64_encode_len]; - let prefix_len = b"data:".len() + mime_type.len() + b";base64,".len(); - base64buf[..b"data:".len()].copy_from_slice(b"data:"); - base64buf[b"data:".len()..b"data:".len() + mime_type.len()].copy_from_slice(mime_type); - base64buf[b"data:".len() + mime_type.len()..prefix_len].copy_from_slice(b";base64,"); - let encoded_len = bun_base64::encode(&mut base64buf[prefix_len..], text); - base64buf.truncate(prefix_len + encoded_len); + let mut base64buf: Vec = Vec::with_capacity(total_base64_encode_len); + base64buf.extend_from_slice(b"data:"); + base64buf.extend_from_slice(mime_type); + base64buf.extend_from_slice(b";base64,"); + bun_base64::encode_append(&mut base64buf, text); base64buf } From b1274e347f76897994795fd8b6fdfe6b7e83e554 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:25:15 +0000 Subject: [PATCH 16/21] Shorten comments --- src/bun_alloc/c_thunks.rs | 5 +---- src/bundler/Chunk.rs | 3 +-- src/http/HTTPThread.rs | 3 +-- src/http/lib.rs | 3 +-- src/runtime/image/codec_gif.rs | 9 +++------ src/runtime/node/node_crypto_binding.rs | 3 +-- src/uws/lib.rs | 8 ++------ 7 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/bun_alloc/c_thunks.rs b/src/bun_alloc/c_thunks.rs index 577a0baa830e..1108adef976d 100644 --- a/src/bun_alloc/c_thunks.rs +++ b/src/bun_alloc/c_thunks.rs @@ -49,13 +49,10 @@ pub unsafe extern "C" fn mi_free_bytes(bytes: *mut c_void, _ctx: *mut c_void) { /// Generated items: /// - `malloc_size(_, len: usize) -> *mut c_void` — brotli-shape, non-zeroing. /// Safe `extern "C" fn` (opaque cookie ignored; body is all-safe). -/// - `malloc_items(_, items: c_uint, len: c_uint) -> *mut c_void` — zlib-shape, -/// non-zeroing (zlib-ng's own default is plain `malloc`). Safe `extern "C" fn` (same rationale). +/// - `malloc_items(_, items: c_uint, len: c_uint) -> *mut c_void` — zlib-shape, non-zeroing. Both return null on failure, which zlib and brotli report as their own OOM errors. /// - `free(_, ptr: *mut c_void)` — paired with either alloc. `unsafe` /// (precondition: `ptr` was allocated by this zone / the default allocator). /// -/// Both allocators return null on failure, which zlib and brotli report as their own OOM errors. -/// /// Intended to be invoked inside a `mod XxxAllocator { … }` so call sites can /// keep referring to `XxxAllocator::alloc` / `::free` via a local `pub use`. #[macro_export] diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 0ba9f0038c35..0f4c575892cb 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -479,8 +479,7 @@ type DynAlloc = (); /// Until `DynAlloc` is a real trait object, route /// through the global arena; mimalloc handles large allocations via mmap -/// already. Returns an empty `Vec` with exactly `n` bytes of capacity for the -/// caller to fill and commit. +/// already. Returns an empty `Vec` with `n` bytes of capacity for the caller to fill and commit. #[inline] fn alloc_buf(_arena: DynAlloc, n: usize) -> Result, AllocError> { let mut v: Vec = Vec::new(); diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index a50b72510228..912f7d4b903d 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -193,8 +193,7 @@ impl HttpThread { } } -/// Initial capacity of the `Vec` that `send_initial_request_payload` assembles -/// the request head (plus as much of the body as fits) into. +/// Initial capacity of the `Vec` the request head (plus as much body as fits) is assembled into. pub(crate) fn request_body_send_buffer_capacity(estimated_size: usize) -> usize { const SMALL: usize = 32 * 1024; const LARGE: usize = 512 * 1024; diff --git a/src/http/lib.rs b/src/http/lib.rs index f8911643f7c6..ab48559ff439 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -2192,8 +2192,7 @@ impl<'a> HTTPClient<'a> { /// /// For large files, we want to avoid extra network send overhead /// So we do two things: - /// 1. Use a 32 KB buffer for small files - /// 2. Use a 512 KB buffer for large files + /// 1. Use a 32 KB buffer for small files, 2. a 512 KB buffer for large files. /// This only has an impact on http:// /// /// On https://, we are limited to a 16 KB TLS record size. diff --git a/src/runtime/image/codec_gif.rs b/src/runtime/image/codec_gif.rs index 9f3d52d453a9..b309ee95d6eb 100644 --- a/src/runtime/image/codec_gif.rs +++ b/src/runtime/image/codec_gif.rs @@ -97,10 +97,7 @@ struct Dict { unsafe impl bun_core::Zeroable for Dict {} impl Dict { - /// Walk the prefix chain into `scratch` (reversed), then append the string - /// to `out`, truncated so `out` never exceeds `npix` entries. Returns the - /// FIRST byte of the string (needed for the K-ω-K case where the new code - /// refers to itself). + /// Appends `code_`'s string to `out` (never past `npix` entries) and returns its FIRST byte, which the K-ω-K case needs. fn emit( &self, code_: u16, @@ -372,8 +369,8 @@ fn decode_frame( }) } -/// One row of palette indices → RGBA pixel slots. Scalar 4-byte copy per -/// pixel — see file comment for why this isn't a Highway kernel. +/// One row of palette indices → RGBA slots. Scalar 4-byte copy per pixel — see file +/// comment for why this isn't a Highway kernel. #[inline] fn expand_row(idx: &[u8], out: &mut [[MaybeUninit; 4]], pal: &[[u8; 4]; 256]) { for (x, &c) in idx.iter().enumerate() { diff --git a/src/runtime/node/node_crypto_binding.rs b/src/runtime/node/node_crypto_binding.rs index 3b7c55410fa6..3244695f60e0 100644 --- a/src/runtime/node/node_crypto_binding.rs +++ b/src/runtime/node/node_crypto_binding.rs @@ -208,8 +208,7 @@ pub mod random { /// which keeps its VM alive). InPlace { bytes: JsPtr, length: usize }, /// `randomFill`: the caller's buffer stays untouched until completion; - /// `scratch` arrives empty with `size` bytes reserved, is filled - /// off-thread and copied in at `offset` on the JS thread. + /// `scratch` (empty, `size` bytes reserved) is filled off-thread and copied in at `offset` on the JS thread. Scratch { scratch: Vec, size: usize, diff --git a/src/uws/lib.rs b/src/uws/lib.rs index f95044559e3b..48352646f271 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -198,8 +198,7 @@ pub mod ssl_wrapper { /// writes we loop until we have no more data to write/backpressure. const BUFFER_SIZE: usize = 65536; - /// Stack scratch for `SSL_read` / `BIO_read` / the pending-event pops; left - /// uninitialized, only the producer-reported prefix is ever read back. + /// Uninitialized stack scratch for `SSL_read` / `BIO_read` / the pending-event pops; only the prefix they report is read back. struct IoBuffer(core::mem::MaybeUninit<[u8; BUFFER_SIZE]>); impl IoBuffer { @@ -213,10 +212,7 @@ pub mod ssl_wrapper { self.0.as_mut_ptr().cast() } - /// View `[0..len]` as `&[u8]`. - /// - /// # Safety - /// A producer must have written every byte in `[0..len]`. + /// `[0..len]` as `&[u8]`; unsafe because the caller asserts a producer wrote every one of those bytes. #[inline(always)] unsafe fn filled(&self, len: usize) -> &[u8] { debug_assert!(len <= BUFFER_SIZE); From 898e269725f79d05114cc5e8d9b45ea182b08302 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:38:33 +0000 Subject: [PATCH 17/21] Share one UninitBuf between bun_sys, the runtime and the TLS wrapper Lives in bun_core::vec next to the spare-capacity helpers it mirrors, so the uws wrapper can use it too instead of its own MaybeUninit wrapper. --- src/bun_core/lib.rs | 32 +++++++++++++++++++++++++++ src/runtime/api/Archive.rs | 2 +- src/runtime/node/node_fs.rs | 2 +- src/runtime/node/quic/stream.rs | 4 ++-- src/runtime/webcore/blob/copy_file.rs | 2 +- src/runtime/webcore/blob/read_file.rs | 2 +- src/sys/copy_file.rs | 2 +- src/sys/lib.rs | 18 --------------- src/uws/lib.rs | 25 ++------------------- test/js/bun/util/zstd.test.ts | 1 + 10 files changed, 42 insertions(+), 48 deletions(-) diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 79a1074da6de..faaafdd7f895 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -551,6 +551,38 @@ pub mod vec { r } } + + /// The stack-array form of [`spare_bytes_mut`]: `N` uninitialized bytes for a producer that reports how many it wrote. + pub struct UninitBuf(core::mem::MaybeUninit<[u8; N]>); + + impl UninitBuf { + #[inline(always)] + pub const fn uninit() -> Self { + Self(core::mem::MaybeUninit::uninit()) + } + + #[inline(always)] + pub fn as_mut_ptr(&mut self) -> *mut u8 { + self.0.as_mut_ptr().cast::() + } + + /// # Safety + /// Write-only view, same contract as [`spare_bytes_mut`]: only a producer may store into it, and only the prefix it reports may be read back. + #[inline(always)] + pub unsafe fn as_bytes_mut(&mut self) -> &mut [u8] { + // SAFETY: `MaybeUninit<[u8; N]>` has the layout of `[u8; N]`; the caller upholds the write-only contract. + unsafe { core::slice::from_raw_parts_mut(self.as_mut_ptr(), N) } + } + + /// # Safety + /// A producer must have written every byte of `[0..len]` (`len <= N`). + #[inline(always)] + pub unsafe fn filled(&self, len: usize) -> &[u8] { + debug_assert!(len <= N); + // SAFETY: caller contract: `[0..len]` is initialized and, as `len <= N`, inside the array. + unsafe { core::slice::from_raw_parts(self.0.as_ptr().cast::(), len) } + } + } } #[path = "Progress.rs"] diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index b4e0df36c60d..d30ab0537465 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -1336,7 +1336,7 @@ fn extract_to_disk_filtered( let mut count: u32 = 0; let mut entry: *mut lib::Entry = core::ptr::null_mut(); - let mut stack_buf = bun_sys::UninitBuf::<{ 64 * 1024 }>::uninit(); + let mut stack_buf = bun_core::vec::UninitBuf::<{ 64 * 1024 }>::uninit(); // SAFETY: `archive_read_data` is the only writer of `buf`; each chunk reads back only `buf[..bytes_read]`. let buf = unsafe { stack_buf.as_bytes_mut() }; diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index e39a96983374..a296f076ff45 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -4715,7 +4715,7 @@ impl NodeFS { } const STACK_BUF_LEN: usize = 64 * 1024; - let mut stack_buf = sys::UninitBuf::::uninit(); + let mut stack_buf = bun_core::vec::UninitBuf::::uninit(); let mut buf_to_free: Vec = Vec::new(); // SAFETY: `Syscall::read` is the only writer of `buf`; each iteration reads back only `buf[..amt]`. let mut buf: &mut [u8] = unsafe { stack_buf.as_bytes_mut() }; diff --git a/src/runtime/node/quic/stream.rs b/src/runtime/node/quic/stream.rs index 279e5264481b..c793dd975c30 100644 --- a/src/runtime/node/quic/stream.rs +++ b/src/runtime/node/quic/stream.rs @@ -1021,7 +1021,7 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: return; }; if ctx.is_null() { - let mut stack_buf = bun_sys::UninitBuf::<4096>::uninit(); + let mut stack_buf = bun_core::vec::UninitBuf::<4096>::uninit(); // SAFETY: lsquic only stores into the slice and the drained bytes are never read back. let buf = unsafe { stack_buf.as_bytes_mut() }; while stream.read(buf) > 0 {} @@ -1076,7 +1076,7 @@ pub(super) unsafe extern "C" fn on_stream_read(ctx: *mut c_void, s: *mut lsquic: if stream.received_early_data() { qs.with_state(|s| s.received_early_data = 1); } - let mut stack_buf = bun_sys::UninitBuf::<{ 16 * 1024 }>::uninit(); + let mut stack_buf = bun_core::vec::UninitBuf::<{ 16 * 1024 }>::uninit(); // SAFETY: lsquic is the only writer of `buf`; each iteration reads back only `buf[..n]`. let buf = unsafe { stack_buf.as_bytes_mut() }; let mut got_any = false; diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 70a088e4dca4..32b5b584b71a 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -988,7 +988,7 @@ fn read_write_loop_capped( cap: SizeType, total: &mut u64, ) -> bun_sys::Result<()> { - let mut stack_buf = bun_sys::UninitBuf::<{ 64 * 1024 }>::uninit(); + let mut stack_buf = bun_core::vec::UninitBuf::<{ 64 * 1024 }>::uninit(); // SAFETY: `read` is the only writer of `buf`; each iteration reads back only `buf[..amt]`. let buf = unsafe { stack_buf.as_bytes_mut() }; let mut remaining = cap; diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index f809ae19be7c..5428f04d2cb8 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -814,7 +814,7 @@ impl ReadFile { // // 64 KB is large, but since this is running in a thread // with it's own stack, it should have sufficient space. - let mut stack_storage = bun_sys::UninitBuf::<{ 64 * 1024 }>::uninit(); + let mut stack_storage = bun_core::vec::UninitBuf::<{ 64 * 1024 }>::uninit(); // SAFETY: only `do_read` writes into it and only `stack_buffer[..read_amount]` is read back. let stack_buffer = unsafe { stack_storage.as_bytes_mut() }; // `do_read` never touches `self.buffer`; move it out so the read diff --git a/src/sys/copy_file.rs b/src/sys/copy_file.rs index 46c4f32393cf..2499a41e1e05 100644 --- a/src/sys/copy_file.rs +++ b/src/sys/copy_file.rs @@ -11,7 +11,7 @@ use crate::Fd; #[cfg(not(any(target_os = "linux", target_os = "android")))] use crate::Tag; #[cfg(not(windows))] -use crate::UninitBuf; +use bun_core::vec::UninitBuf; // `declare_scope!` uses the ident as both static name AND tag string, but // `copy_file` would shadow `pub fn copy_file()` below. Hand-expand with the diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 9efc5a86dda7..4a1c4833770e 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -93,24 +93,6 @@ pub mod copy_file; // Directory-entry kind — same set as `bun_core::FileKind`. pub use bun_core::FileKind as EntryKind; -/// Uninitialized `[u8; N]` stack scratch for a `read(2)`-like producer: the array form of `bun_core::vec::spare_bytes_mut`. -pub struct UninitBuf(core::mem::MaybeUninit<[u8; N]>); - -impl UninitBuf { - #[inline(always)] - pub const fn uninit() -> Self { - Self(core::mem::MaybeUninit::uninit()) - } - - /// # Safety - /// The bytes are uninitialized: only a producer may store into the slice, and only the prefix it reports written may be read. - #[inline(always)] - pub unsafe fn as_bytes_mut(&mut self) -> &mut [u8] { - // SAFETY: `MaybeUninit<[u8; N]>` is laid out as `[u8; N]`; the caller upholds the write-only contract. - unsafe { core::slice::from_raw_parts_mut(self.0.as_mut_ptr().cast::(), N) } - } -} - // `bun.DirIterator`. // // A readdir-style directory iterator. Notable behaviors: diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 48352646f271..9bd99e7cb544 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -198,29 +198,8 @@ pub mod ssl_wrapper { /// writes we loop until we have no more data to write/backpressure. const BUFFER_SIZE: usize = 65536; - /// Uninitialized stack scratch for `SSL_read` / `BIO_read` / the pending-event pops; only the prefix they report is read back. - struct IoBuffer(core::mem::MaybeUninit<[u8; BUFFER_SIZE]>); - - impl IoBuffer { - #[inline(always)] - const fn uninit() -> Self { - IoBuffer(core::mem::MaybeUninit::uninit()) - } - - #[inline(always)] - fn as_mut_ptr(&mut self) -> *mut u8 { - self.0.as_mut_ptr().cast() - } - - /// `[0..len]` as `&[u8]`; unsafe because the caller asserts a producer wrote every one of those bytes. - #[inline(always)] - unsafe fn filled(&self, len: usize) -> &[u8] { - debug_assert!(len <= BUFFER_SIZE); - // SAFETY: caller contract — `[0..len]` was written by the producer; - // `len <= BUFFER_SIZE` keeps the slice in-bounds. - unsafe { core::slice::from_raw_parts(self.0.as_ptr().cast::(), len) } - } - } + /// Stack scratch shared by `SSL_read` / `BIO_read` / the pending-event pops. + type IoBuffer = bun_core::vec::UninitBuf; /// Cap on peer-initiated TLS renegotiations per /// [`MAX_RENEGOTIATION_WINDOW`]. Mirrors the `us_reneg_policy` defaults in diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index ae00f204dd63..972149c4b49d 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -458,6 +458,7 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", const zeros = Buffer.alloc(12 * MiB); const bodies = { gzip: gzipSync(zeros), + // Content-Encoding: deflate is the zlib-wrapped stream; Bun.deflateSync would emit raw deflate. deflate: zlib.deflateSync(zeros), br: zlib.brotliCompressSync(zeros, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 1 } }), zstd: zstdCompressSync(zeros), From 3e53755e8120d6e818221c73b118b2994e2e3859 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:31:12 +0000 Subject: [PATCH 18/21] zstd: report the decoder's own allocation failures as OutOfMemory ZSTD_decompressStream allocates a window-sized buffer once it has read the frame header; when that fails, Bun.zstdDecompress{,Sync} and fetch reported it as a generic decompression error while the brotli decoder and DecompressionStream already reported OutOfMemory. ZSTD_decompress can fail the same way for its context, so the fast path maps it as well. --- src/zstd/lib.rs | 40 ++++++++++++++++++++++++----------- test/js/bun/util/zstd.test.ts | 20 ++++++++++++++---- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/zstd/lib.rs b/src/zstd/lib.rs index 6ab694c1c09f..5988681f7c7b 100644 --- a/src/zstd/lib.rs +++ b/src/zstd/lib.rs @@ -188,7 +188,7 @@ pub enum Result { #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] pub enum ZstdError { - /// The output buffer, whose size the (untrusted) input decides, could not be allocated. + /// The output, or the decoder state the frame's window size dictates, could not be allocated. OutOfMemory, InvalidZstdData, DecompressionFailed, @@ -199,6 +199,17 @@ pub enum ZstdError { bun_core::impl_tag_error!(ZstdError); +impl ZstdError { + /// The error for a failed (`ZSTD_isError`) decompression call; `other` is the non-allocation failure. + fn for_decompression(rc: usize, other: ZstdError) -> ZstdError { + if c::ZSTD_getErrorCode(rc) == c::ZSTD_error_memory_allocation { + ZstdError::OutOfMemory + } else { + other + } + } +} + /// ZSTD_compress() : /// Compresses `src` content as a single zstd compressed frame into already allocated `dst`. /// NOTE: Providing `dstCapacity >= ZSTD_compressBound(srcSize)` guarantees that zstd will have @@ -293,7 +304,7 @@ pub fn decompress(dest: &mut [u8], src: &[u8]) -> Result { } /// [`decompress`] into `out`'s spare capacity, which is the output bound; commits the bytes written. -fn decompress_append(out: &mut Vec, src: &[u8]) -> Result { +fn decompress_append(out: &mut Vec, src: &[u8]) -> core::result::Result<(), ZstdError> { let spare = out.spare_capacity_mut(); // SAFETY: spare/src are valid for their lengths; ZSTD_decompress reads src // and writes at most `spare.len()` bytes into spare. @@ -306,12 +317,14 @@ fn decompress_append(out: &mut Vec, src: &[u8]) -> Result { ) }; if c::ZSTD_isError(rc) != 0 { - // SAFETY: ZSTD_getErrorName returns a static NUL-terminated string. - return Result::Err(unsafe { ZStr::from_c_ptr(c::ZSTD_getErrorName(rc)) }); + return Err(ZstdError::for_decompression( + rc, + ZstdError::DecompressionFailed, + )); } // SAFETY: zstd has initialized `rc` bytes at the start of spare. unsafe { bun_core::vec::commit_spare(out, rc) }; - Result::Success(rc) + Ok(()) } /// Decompress data, automatically allocating the output buffer. @@ -357,11 +370,8 @@ pub fn decompress_alloc(src: &[u8]) -> core::result::Result, ZstdError> .try_reserve_exact(size) .map_err(|_| ZstdError::OutOfMemory)?; - match decompress_append(&mut output, src) { - Result::Success(_) => Ok(output), - // `output` is freed by Drop. - Result::Err(_) => Err(ZstdError::DecompressionFailed), - } + decompress_append(&mut output, src)?; + Ok(output) } pub fn get_decompressed_size(src: &[u8]) -> usize { @@ -482,7 +492,10 @@ impl<'a> ZstdReaderArrayList<'a> { unsafe { c::ZSTD_decompressStream(self.zstd, &raw mut out_buf, &raw mut in_buf) }; if c::ZSTD_isError(rc) != 0 { self.state = State::Error; - return Err(ZstdError::ZstdDecompressionError); + return Err(ZstdError::for_decompression( + rc, + ZstdError::ZstdDecompressionError, + )); } let bytes_written = out_buf.pos; @@ -635,7 +648,10 @@ impl StreamingDecoder { }; if c::ZSTD_isError(rc) != 0 { self.state = State::Error; - return Err(ZstdError::ZstdDecompressionError); + return Err(ZstdError::for_decompression( + rc, + ZstdError::ZstdDecompressionError, + )); } let bytes_written = out_buf.pos; diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index 972149c4b49d..d3c2185bb15c 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -330,6 +330,10 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", const MiB = 1024 * 1024; const CAP_MIB = 8; const outOfMemory = { name: "RangeError", message: "Out of memory" }; + // An empty frame (magic, descriptor, window descriptor, one empty raw last block) without a content + // size and with a 16 MiB window: our output buffer stays small, but zstd itself has to allocate a + // window-sized buffer before it can decode anything, and that allocation is what fails under the cap. + const emptyFrameWith16MiBWindow = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd, 0x00, 14 << 3, 0x01, 0x00, 0x00]); // Runs `script` in a child whose native allocations above the cap fail. `inputs` arrive in the // child as Buffers in a `inputs` object; the script prints a JSON object, which is returned. @@ -375,7 +379,7 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", } it.concurrent("zstd: compress and decompress, sync and async", async () => { - // All three decompress to more than the cap and each reaches the allocation differently: + // The first three decompress to more than the cap and each reaches the allocation differently: // a header size under the 16 MiB limit is allocated up front; one above it starts the // streaming decoder at 16 MiB; no header size starts small and fails while growing. const frames = { @@ -384,8 +388,10 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", noHeaderSize: await new Response( new Response(Buffer.alloc(12 * MiB)).body!.pipeThrough(new CompressionStream("zstd")), ).bytes(), + largeWindow: emptyFrameWith16MiBWindow, }; expect(frames.noHeaderSize[4] & 0xe0).toBe(0); + expect(zstdDecompressSync(frames.largeWindow)).toHaveLength(0); const results = await runCapped( frames, @@ -411,6 +417,8 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", "decompress headerSizeAboveLimit": outOfMemory, "decompressSync noHeaderSize": outOfMemory, "decompress noHeaderSize": outOfMemory, + "decompressSync largeWindow": outOfMemory, + "decompress largeWindow": outOfMemory, afterwards: "still works", afterwardsAsync: "still works", }); @@ -453,8 +461,8 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", }); it.concurrent("fetch() decompressing a response body", async () => { - // Each body decompresses to 12 MiB: the gzip trailer's size cannot be reserved up front and - // every streaming decoder (zlib, brotli, zstd) fails while growing its output. + // The first four bodies decompress to 12 MiB: the gzip trailer's size cannot be reserved up front + // and every streaming decoder (zlib, brotli, zstd) fails while growing its output. const zeros = Buffer.alloc(12 * MiB); const bodies = { gzip: gzipSync(zeros), @@ -462,17 +470,20 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", deflate: zlib.deflateSync(zeros), br: zlib.brotliCompressSync(zeros, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 1 } }), zstd: zstdCompressSync(zeros), + "zstd-large-window": emptyFrameWith16MiBWindow, afterwards: gzipSync(Buffer.from("still works")), }; + const encodings: Record = { "zstd-large-window": "zstd", afterwards: "gzip" }; const results = await runCapped( bodies, /* js */ ` + const encodings = ${JSON.stringify(encodings)}; using server = Bun.serve({ port: 0, fetch(req) { const name = new URL(req.url).pathname.slice(1); - return new Response(inputs[name], { headers: { "Content-Encoding": name === "afterwards" ? "gzip" : name } }); + return new Response(inputs[name], { headers: { "Content-Encoding": encodings[name] ?? name } }); }, }); for (const name of Object.keys(inputs)) { @@ -488,6 +499,7 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", deflate: fetchOutOfMemory, br: fetchOutOfMemory, zstd: fetchOutOfMemory, + "zstd-large-window": fetchOutOfMemory, afterwards: "still works", }); }); From abd69494de5addef1af5b0cabfbf1fa48b7b0a70 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:31:12 +0000 Subject: [PATCH 19/21] UninitBuf::filled keeps the bounds check the sliced arrays had The TLS wrapper also hands SSL_read and BIO_read the unfilled tail as a slice again, so an offset past the buffer panics before any pointer math, as it did with the zeroed array. --- src/bun_core/lib.rs | 4 ++-- src/uws/lib.rs | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index faaafdd7f895..bb068af6d8cf 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -578,8 +578,8 @@ pub mod vec { /// A producer must have written every byte of `[0..len]` (`len <= N`). #[inline(always)] pub unsafe fn filled(&self, len: usize) -> &[u8] { - debug_assert!(len <= N); - // SAFETY: caller contract: `[0..len]` is initialized and, as `len <= N`, inside the array. + assert!(len <= N); + // SAFETY: `[0..len]` is inside the array (asserted above) and initialized (caller contract). unsafe { core::slice::from_raw_parts(self.0.as_ptr().cast::(), len) } } } diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 9bd99e7cb544..459d36949323 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -987,13 +987,14 @@ pub mod ssl_wrapper { return false; }; - // SAFETY: ssl is a live SSL*; `read < BUFFER_SIZE`, so the pointer and - // length describe the unwritten tail of `buffer`. + // SAFETY: write-only view of the unfilled tail; SSL_read only stores into it. + let available = unsafe { &mut buffer.as_bytes_mut()[read..] }; + // SAFETY: ssl is a live SSL*; available is a valid mutable slice. let just_read = unsafe { boring_sys::SSL_read( ssl.as_ptr(), - buffer.as_mut_ptr().add(read).cast::(), - c_int::try_from(BUFFER_SIZE - read).expect("int cast"), + available.as_mut_ptr().cast::(), + c_int::try_from(available.len()).expect("int cast"), ) }; log!("just read {}", just_read); @@ -1122,13 +1123,14 @@ pub mod ssl_wrapper { else { return; }; - // SAFETY: output is a valid BIO*; `read < BUFFER_SIZE`, so the pointer and - // length describe the unwritten tail of `buffer`. + // SAFETY: write-only view of the unfilled tail; BIO_read only stores into it. + let available = unsafe { &mut buffer.as_bytes_mut()[read..] }; + // SAFETY: output is a valid BIO*; available is a valid mutable slice. let just_read = unsafe { boring_sys::BIO_read( output.as_ptr(), - buffer.as_mut_ptr().add(read).cast::(), - c_int::try_from(BUFFER_SIZE - read).expect("int cast"), + available.as_mut_ptr().cast::(), + c_int::try_from(available.len()).expect("int cast"), ) }; if just_read > 0 { From 4677064a9ef6cd3b7e3fe662253b5f0ff41b323e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:58:36 +0000 Subject: [PATCH 20/21] DecompressionStream: report brotli's allocation failures as OutOfMemory The streaming brotli reader already mapped the ERROR_ALLOC_* codes; the CompressionStream coder stringified them like any other decode error. The match moves onto the error code type so both sites share it. --- src/brotli/lib.rs | 16 ++--- src/brotli_sys/brotli_c.rs | 15 +++++ src/runtime/webcore/CompressionStreamCoder.rs | 9 ++- test/js/bun/util/zstd.test.ts | 59 +++++++++++++++++-- 4 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/brotli/lib.rs b/src/brotli/lib.rs index e89fc6652446..06b77fc2af8a 100644 --- a/src/brotli/lib.rs +++ b/src/brotli/lib.rs @@ -163,17 +163,13 @@ impl StreamingDecoder { } c::BrotliDecoderResult::err => { self.state = ReaderState::Error; - return Err(match c::BrotliDecoderGetErrorCode(self.brotli_mut()) { - c::BrotliDecoderErrorCode2::ERROR_ALLOC_CONTEXT_MODES - | c::BrotliDecoderErrorCode2::ERROR_ALLOC_TREE_GROUPS - | c::BrotliDecoderErrorCode2::ERROR_ALLOC_CONTEXT_MAP - | c::BrotliDecoderErrorCode2::ERROR_ALLOC_RING_BUFFER_1 - | c::BrotliDecoderErrorCode2::ERROR_ALLOC_RING_BUFFER_2 - | c::BrotliDecoderErrorCode2::ERROR_ALLOC_BLOCK_TYPE_TREES => { + return Err( + if c::BrotliDecoderGetErrorCode(self.brotli_mut()).is_alloc_failure() { crate::Error::OutOfMemory - } - _ => crate::Error::BrotliDecompressionError, - }); + } else { + crate::Error::BrotliDecompressionError + }, + ); } c::BrotliDecoderResult::needs_more_input => { self.state = ReaderState::Inflating; diff --git a/src/brotli_sys/brotli_c.rs b/src/brotli_sys/brotli_c.rs index 31c088663d99..98df29882dbb 100644 --- a/src/brotli_sys/brotli_c.rs +++ b/src/brotli_sys/brotli_c.rs @@ -160,6 +160,21 @@ pub enum BrotliDecoderErrorCode2 { ERROR_UNREACHABLE = -31, } +impl BrotliDecoderErrorCode2 { + /// The decoder's allocator (its `alloc_func`) returned null. + pub fn is_alloc_failure(self) -> bool { + matches!( + self, + Self::ERROR_ALLOC_CONTEXT_MODES + | Self::ERROR_ALLOC_TREE_GROUPS + | Self::ERROR_ALLOC_CONTEXT_MAP + | Self::ERROR_ALLOC_RING_BUFFER_1 + | Self::ERROR_ALLOC_RING_BUFFER_2 + | Self::ERROR_ALLOC_BLOCK_TYPE_TREES + ) + } +} + #[repr(u32)] #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum BrotliDecoderParameter { diff --git a/src/runtime/webcore/CompressionStreamCoder.rs b/src/runtime/webcore/CompressionStreamCoder.rs index e3fdd8ea604b..dafb870b48d2 100644 --- a/src/runtime/webcore/CompressionStreamCoder.rs +++ b/src/runtime/webcore/CompressionStreamCoder.rs @@ -562,10 +562,13 @@ impl CompressionStreamCoder { } brotli::BrotliDecoderResult::needs_more_output => {} brotli::BrotliDecoderResult::err => { - // SAFETY: `p` is a live decoder; the error string is a - // static C string owned by the brotli library. + // SAFETY: `p` is a live decoder. + let ec = brotli::BrotliDecoderGetErrorCode(unsafe { &*p.as_ptr() }); + if ec.is_alloc_failure() { + return Err(CodecError::OutOfMemory); + } + // SAFETY: the error string is a static C string owned by the brotli library. let code = unsafe { - let ec = brotli::BrotliDecoderGetErrorCode(&*p.as_ptr()); core::ffi::CStr::from_ptr(brotli::BrotliDecoderErrorString(ec)) }; return Err(CodecError::Brotli( diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index d3c2185bb15c..d48c5d061030 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -326,14 +326,20 @@ describe("decompressing frames whose size is not known up front", () => { // the call has to throw or reject, not take the process down. ASAN's allocation cap makes the // failure deterministic: native allocations above CAP_MIB fail, while the JS Buffers the // script itself creates are backed by JSC's own allocator and are not affected. -describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", () => { +describe.skipIf(!isASAN)("a failed allocation is an error, not a crash", () => { const MiB = 1024 * 1024; const CAP_MIB = 8; const outOfMemory = { name: "RangeError", message: "Out of memory" }; - // An empty frame (magic, descriptor, window descriptor, one empty raw last block) without a content - // size and with a 16 MiB window: our output buffer stays small, but zstd itself has to allocate a - // window-sized buffer before it can decode anything, and that allocation is what fails under the cap. + + // Two streams whose decoders have to allocate a 16 MiB window (larger than the cap) before they can + // produce anything, so it is the codec's own allocation that fails, not one of ours: + // an empty zstd frame (magic, descriptor, window descriptor, one empty raw last block) without a + // content size, and a brotli stream compressed with a 2^24 byte window. const emptyFrameWith16MiBWindow = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd, 0x00, 14 << 3, 0x01, 0x00, 0x00]); + const brotliWith16MiBWindow = (input: Uint8Array) => + zlib.brotliCompressSync(input, { + params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 1, [zlib.constants.BROTLI_PARAM_LGWIN]: 24 }, + }); // Runs `script` in a child whose native allocations above the cap fail. `inputs` arrive in the // child as Buffers in a `inputs` object; the script prints a JSON object, which is returned. @@ -462,7 +468,8 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", it.concurrent("fetch() decompressing a response body", async () => { // The first four bodies decompress to 12 MiB: the gzip trailer's size cannot be reserved up front - // and every streaming decoder (zlib, brotli, zstd) fails while growing its output. + // and every streaming decoder (zlib, brotli, zstd) fails while growing its output. The two + // large-window bodies fail inside the codec instead. const zeros = Buffer.alloc(12 * MiB); const bodies = { gzip: gzipSync(zeros), @@ -470,10 +477,15 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", deflate: zlib.deflateSync(zeros), br: zlib.brotliCompressSync(zeros, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 1 } }), zstd: zstdCompressSync(zeros), + "br-large-window": brotliWith16MiBWindow(zeros), "zstd-large-window": emptyFrameWith16MiBWindow, afterwards: gzipSync(Buffer.from("still works")), }; - const encodings: Record = { "zstd-large-window": "zstd", afterwards: "gzip" }; + const encodings: Record = { + "br-large-window": "br", + "zstd-large-window": "zstd", + afterwards: "gzip", + }; const results = await runCapped( bodies, @@ -499,10 +511,45 @@ describe.skipIf(!isASAN)("a failed output allocation is an error, not a crash", deflate: fetchOutOfMemory, br: fetchOutOfMemory, zstd: fetchOutOfMemory, + "br-large-window": fetchOutOfMemory, "zstd-large-window": fetchOutOfMemory, afterwards: "still works", }); }); + + it.concurrent("DecompressionStream", async () => { + // A stream's own output is produced in small chunks, so only the codecs' window allocations can + // fail here; the default-window brotli stream decompresses all 12 MiB to prove that. + const zeros = Buffer.alloc(12 * MiB); + const streams = { + brotli: zlib.brotliCompressSync(zeros, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 1 } }), + brotliLargeWindow: brotliWith16MiBWindow(zeros), + zstdLargeWindow: emptyFrameWith16MiBWindow, + }; + + const results = await runCapped( + streams, + /* js */ ` + const decompressedLength = async (bytes, format) => { + let length = 0; + for await (const chunk of new Response(bytes).body.pipeThrough(new DecompressionStream(format))) { + length += chunk.length; + } + return length; + }; + for (const [name, bytes] of Object.entries(inputs)) { + results[name] = await decompressedLength(bytes, name.startsWith("brotli") ? "brotli" : "zstd").catch(describeError); + } + results.afterwards = await decompressedLength(inputs.brotli, "brotli"); + `, + ); + expect(results).toEqual({ + brotli: 12 * MiB, + brotliLargeWindow: outOfMemory, + zstdLargeWindow: outOfMemory, + afterwards: 12 * MiB, + }); + }); }); describe("sync compression argument handling", () => { From 139f31d9689bc9c105fa25cf736c9ef847b00e89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:29:54 +0000 Subject: [PATCH 21/21] Bun.zstd*: hand results to JS as boxed slices An empty result from the streaming decoder (a frame without a content size that decompresses to nothing) kept its 4 KiB reservation, and create_buffer registers no deallocator for an empty slice, so it leaked; LeakSanitizer caught it through the new large-window test. into_boxed_slice frees an empty result's allocation and trims the slack of a non-empty one, the same as the gzip functions do before handing over. --- src/runtime/api/BunObject.rs | 35 ++++++++++++++++------------ test/js/bun/util/zstd.test.ts | 44 ++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index d4ec114c92d8..84ecaf8ee1db 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -2752,7 +2752,7 @@ pub mod JSZstd { /// Error of a `Bun.zstd*` call: thrown by the sync functions, rejected by [`ZstdJob`]. pub(crate) enum Failure { - /// The output buffer, whose size the input decides, could not be allocated. + /// The output, whose size the input decides, or zstd's own state for it could not be allocated. OutOfMemory, /// An `ERR_ZSTD` with this message. Compression(&'static [u8]), @@ -2796,7 +2796,8 @@ pub mod JSZstd { } } - fn compress_to_vec(input: &[u8], level: i32) -> Result, Failure> { + /// Boxed (trimmed to the bytes produced) so an empty result owns no memory, as `create_buffer_from_box` requires. + fn compress_to_box(input: &[u8], level: i32) -> Result, Failure> { let max_size = bun_zstd::compress_bound(input.len()); // `ZSTD_compressBound` returns an error code for inputs over `ZSTD_MAX_INPUT_SIZE`. if bun_zstd::is_error(max_size) { @@ -2815,9 +2816,13 @@ pub mod JSZstd { return Err(Failure::Compression(err.as_bytes())); } - // Release the slack between the compressed size and the bound. - output.shrink_to_fit(); - Ok(output) + Ok(output.into_boxed_slice()) + } + + fn decompress_to_box(input: &[u8]) -> Result, Failure> { + bun_zstd::decompress_alloc(input) + .map(Vec::into_boxed_slice) + .map_err(Failure::from) } #[bun_jsc::host_fn] @@ -2832,9 +2837,9 @@ pub mod JSZstd { let buffer = coerce_compress_buffer(global_this, buffer_value)?; let output = - compress_to_vec(buffer.slice(), level).map_err(|failure| failure.throw(global_this))?; + compress_to_box(buffer.slice(), level).map_err(|failure| failure.throw(global_this))?; - JSValue::create_buffer(global_this, output.leak()) + JSValue::create_buffer_from_box(global_this, output) } #[bun_jsc::host_fn] @@ -2844,10 +2849,10 @@ pub mod JSZstd { ) -> JsResult { let (buffer, _) = parse_compress_buffer_and_options(global_this, callframe)?; - let output = bun_zstd::decompress_alloc(buffer.slice()) - .map_err(|err| Failure::from(err).throw(global_this))?; + let output = + decompress_to_box(buffer.slice()).map_err(|failure| failure.throw(global_this))?; - JSValue::create_buffer(global_this, output.leak()) + JSValue::create_buffer_from_box(global_this, output) } // --- Async versions --- @@ -2860,7 +2865,7 @@ pub mod JSZstd { pub is_compress: bool, pub level: i32, /// Filled in by `run`. - pub result: Result, Failure>, + pub result: Result, Failure>, } impl jsc::JobContext for ZstdJob { @@ -2874,9 +2879,9 @@ pub mod JSZstd { let input = this.buffer.slice(); this.result = if this.is_compress { - compress_to_vec(input, this.level) + compress_to_box(input, this.level) } else { - bun_zstd::decompress_alloc(input).map_err(Failure::from) + decompress_to_box(input) }; Some(done) } @@ -2892,7 +2897,7 @@ pub mod JSZstd { match this.result { Ok(output) => promise.settle( global_this, - JSValue::create_buffer(global_this, output.leak()), + JSValue::create_buffer_from_box(global_this, output), ), Err(failure) => { promise.reject_with_async_stack(global_this, Ok(failure.to_js(global_this))) @@ -2916,7 +2921,7 @@ pub mod JSZstd { buffer: bun_jsc::ThreadSafe::adopt(buffer), is_compress, level, - result: Ok(Vec::new()), + result: Ok(Box::default()), }, promise, ); diff --git a/test/js/bun/util/zstd.test.ts b/test/js/bun/util/zstd.test.ts index d48c5d061030..243003fe563e 100644 --- a/test/js/bun/util/zstd.test.ts +++ b/test/js/bun/util/zstd.test.ts @@ -13,6 +13,13 @@ import { bunEnv, bunExe, isASAN, rss } from "harness"; import zlib from "node:zlib"; import path from "path"; +// A hand-written empty frame: magic, a descriptor without a content size (so it goes through the +// streaming decoder), the window descriptor (the decoder allocates a window of 2^windowLog bytes before +// it can produce anything) and one empty raw last block. +const emptyFrameWithWindowLog = (windowLog: number) => + new Uint8Array([0x28, 0xb5, 0x2f, 0xfd, 0x00, (windowLog - 10) << 3, 0x01, 0x00, 0x00]); +const emptyFrameWith16MiBWindow = emptyFrameWithWindowLog(24); + describe("Zstandard compression", async () => { // Test data of various sizes const testCases = [ @@ -59,20 +66,32 @@ describe("Zstandard compression", async () => { // Ensure this input actually hits the streaming error path (not InvalidZstdData / fast path). expect(() => zstdDecompressSync(bad)).toThrowError(/ZstdDecompressionError/); + expectStreamingDecompressionNotToLeak(() => { + try { + zstdDecompressSync(bad); + } catch {} + }, "failed"); + }, 60_000); + + it("does not leak on streaming decompression of an empty result (unknown content size)", () => { + // The streaming path reserves an output buffer before it knows the result is empty; the empty + // Buffer handed to JS owns no memory, so that reservation has to be freed rather than leaked. + const frame = emptyFrameWithWindowLog(10); + expect(zstdDecompressSync(frame)).toHaveLength(0); + + expectStreamingDecompressionNotToLeak(() => zstdDecompressSync(frame), "empty"); + }, 60_000); + + function expectStreamingDecompressionNotToLeak(decompressOnce: () => void, what: string) { const iterations = 10000; function batch() { - for (let i = 0; i < iterations; i++) { - try { - zstdDecompressSync(bad); - } catch {} - } + for (let i = 0; i < iterations; i++) decompressOnce(); Bun.gc(true); return rss(); } // Warm up until RSS stabilizes (allocator / ASAN quarantine reach steady state). - // Without the fix each call leaks the ~4 KiB partial output buffer, so growth never - // converges and every batch adds ~40+ MiB. + // A leak of the ~4 KiB output buffer per call never converges: every batch adds 40+ MiB. let prev = batch(); let growthMiB = Infinity; for (let round = 0; round < 5; round++) { @@ -84,9 +103,9 @@ describe("Zstandard compression", async () => { expect( growthMiB, - `RSS grew by ${growthMiB.toFixed(1)} MiB over ${iterations} failed zstd decompressions after warmup`, + `RSS grew by ${growthMiB.toFixed(1)} MiB over ${iterations} ${what} zstd decompressions after warmup`, ).toBeLessThan(10); - }, 60_000); + } // Test with known zstd-compressed data describe("zstd CLI compatibility", () => { @@ -331,11 +350,8 @@ describe.skipIf(!isASAN)("a failed allocation is an error, not a crash", () => { const CAP_MIB = 8; const outOfMemory = { name: "RangeError", message: "Out of memory" }; - // Two streams whose decoders have to allocate a 16 MiB window (larger than the cap) before they can - // produce anything, so it is the codec's own allocation that fails, not one of ours: - // an empty zstd frame (magic, descriptor, window descriptor, one empty raw last block) without a - // content size, and a brotli stream compressed with a 2^24 byte window. - const emptyFrameWith16MiBWindow = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd, 0x00, 14 << 3, 0x01, 0x00, 0x00]); + // Like emptyFrameWith16MiBWindow, a stream whose decoder has to allocate a 16 MiB window (larger + // than the cap) before it can produce anything, so the codec's own allocation fails, not one of ours. const brotliWith16MiBWindow = (input: Uint8Array) => zlib.brotliCompressSync(input, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 1, [zlib.constants.BROTLI_PARAM_LGWIN]: 24 },