Skip to content

Preserve part boundaries when streaming multi-part Blobs#28765

Closed
robobun wants to merge 7 commits intomainfrom
farm/d0b40a30/fix-blob-stream-chunking
Closed

Preserve part boundaries when streaming multi-part Blobs#28765
robobun wants to merge 7 commits intomainfrom
farm/d0b40a30/fix-blob-stream-chunking

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 1, 2026

Closes #28762

Problem

Blob.stream() concatenates all chunks into a single chunk when a Blob is constructed from multiple parts. For example:

const blob = new Blob([new Uint8Array([1,2,3]), new Uint8Array([4]), new Uint8Array([5,6])]);
// Bun: 1 chunk [1,2,3,4,5,6]
// Node: 3 chunks [1,2,3], [4], [5,6]

Cause

During Blob construction from multiple parts, StringJoiner concatenates everything into a single contiguous buffer, losing part boundary information. When ByteBlobLoader streams the data, both its drain() and onPull() methods deliver data based on the total blob size, producing one chunk for any blob under 2MB.

Fix

  1. StringJoiner: Added doneWithPartSizes() that returns both the joined buffer and an array of individual part sizes.
  2. Blob Store: Added part_sizes/part_count fields to the Bytes struct to preserve original part boundary metadata.
  3. ByteBlobLoader: drain() and onPull() now limit each chunk to the current part's size when part boundaries are available, delivering one chunk per original part.

Part boundaries are only used when:

  • No explicit chunkSize was passed to .stream()
  • The blob has no offset (not sliced)
  • The blob was constructed from multiple parts

Verification

bun bd test test/regression/issue/28762.test.ts    → 6 pass
USE_SYSTEM_BUN=1 bun test ...                      → 3 fail (bug confirmed)
test/js/web/fetch/stream-fast-path.test.ts         → 18 pass (no regressions)
test/js/web/streams/streams.test.js                → 66 pass (2 timeouts from ASAN debug build)

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 1, 2026

Updated 8:40 PM PT - Apr 1st, 2026

@robobun, your commit a02d6b0 has 5 failures in Build #43191 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28765

That installs a local version of the PR into your bun-28765 executable, so you can run:

bun-28765 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Record per-part sizes when joining Blob parts, persist them in byte-backed Blob storage, make the Blob streamer emit chunks bounded by those part boundaries, and add regression tests verifying Blob.stream() preserves original part segmentation.

Changes

Cohort / File(s) Summary
Blob bytes storage
src/bun.js/webcore/blob/Store.zig
Bytes now has part_sizes: ?[*]SizeType and part_count: SizeType; Store.memoryCost includes part_count * @sizeof(SizeType) when present; Bytes.deinit frees part_sizes.
Blob construction & part attachment
src/bun.js/webcore/Blob.zig
Added setPartSizes(store, part_sizes) and changed fromJSWithoutDeferGC() to call joiner.doneWithPartSizes(..., true) and attach part_sizes to the blob store in both construction branches.
String joiner output with parts
src/string/StringJoiner.zig
Added doneWithPartSizes(allocator, record_parts) returning { []u8, ?[]u52 }; done() delegates to it. When record_parts is true, allocates and fills part_sizes (for >1 parts) and returns both the joined buffer and part sizes; improved deinit() cleanup.
ByteBlob loader multipart tracking
src/bun.js/webcore/ByteBlobLoader.zig
Added internal multipart state (part_sizes, part_count, current_part, current_part_remain) and multipart-aware logic in setup(), onStart(), onPull(), and drain() to limit produced slices/drains to current part remainder, advance parts, and skip zero-length parts.
Tests: Blob.stream() chunking
test/regression/issue/28762.test.ts
Added regression tests constructing multi-part, single-part, and empty Blobs and asserting Blob.stream() emits chunks that preserve original part boundaries (counts and byte/segment equality).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Preserve part boundaries when streaming multi-part Blobs' accurately and concisely summarizes the main objective of the PR, which is to fix Blob.stream() to maintain part boundaries when a Blob is constructed from multiple parts.
Description check ✅ Passed The PR description comprehensively covers both required sections: what the PR does (Problem, Cause, Fix) and how it was verified (Verification with test results and specific test file names).
Linked Issues check ✅ Passed The PR directly addresses all requirements from issue #28762 by implementing changes to preserve part boundaries when streaming multi-part Blobs, ensuring Bun's behavior aligns with Node.js by producing separate chunks per original part.
Out of Scope Changes check ✅ Passed All changes are directly related to the stated objective of preserving part boundaries during multi-part Blob streaming. The modifications to StringJoiner, Blob Store, ByteBlobLoader, and the addition of regression tests are all necessary and in-scope for the fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this touches core blob streaming logic across multiple files with non-trivial memory ownership semantics — worth a human look before merging.

Extended reasoning...

Overview

This PR modifies 4 source files and adds a regression test. Changes span StringJoiner (new doneWithPartSizes API), Blob.Store.Bytes (new part_sizes/part_count fields with owned memory), ByteBlobLoader (new part-tracking state in setup/onStart/onPull/drain), and Blob.zig (wiring via setPartSizes). The regression test file covers 6 scenarios.

Security Risks

No security concerns. The change is purely about streaming chunk boundaries for in-memory blob data. No auth, crypto, or permission-sensitive paths are touched.

Level of Scrutiny

Moderately high. The fix is well-scoped and only activates when: no user-specified chunkSize, offset == 0, and part_count > 1. However, it introduces heap-allocated metadata (part_sizes) with ownership transferred from StringJoiner through setPartSizes into Bytes.deinit, which is a non-trivial ownership chain. The onPull logic assumes buffer.len >= max_part (guaranteed by onStart advertising max_part as chunk_size), but a caller using the reader directly with a smaller buffer would silently advance current_part without consuming the full part — a subtle invariant worth confirming is always upheld by the stream infrastructure.

Other Factors

The fix is well-documented with clear before/after semantics, and the test coverage is thorough. The PR description mentions passing the relevant stream regression tests. The u52 type used in StringJoiner.doneWithPartSizes correctly matches Blob.SizeType = u52. No existing approvals or reviewer comments to consider.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/webcore/blob/Store.zig`:
- Around line 481-484: The blob's auxiliary allocation part_sizes is not
included in memory accounting, so update both Store.memoryCost() and
Blob.calculateEstimatedByteSize() to account for it: when part_sizes != null add
part_count * `@sizeOf`(SizeType) (or equivalent byte size calculation using
SizeType) to the reported external/heap bytes; reference the fields part_sizes,
part_count and the functions Store.memoryCost() and
Blob.calculateEstimatedByteSize() and ensure the added bytes reflect that
part_sizes is an allocator-owned heap allocation.

In `@src/bun.js/webcore/ByteBlobLoader.zig`:
- Around line 58-61: The multipart guard currently uses blobe.offset == 0 which
incorrectly treats sliced(0, end) as a full blob; instead require that the blob
actually covers the entire underlying byte store by comparing the blob's range
to the store's total byte length. Update the has_parts condition (alongside
has_user_chunk_size) to check that blobe.offset == 0 AND blobe.length (or the
blob's end) equals the store's total bytes (e.g. store.data.bytes.total_size or
equivalent), so only an original/full blob with store.data == .bytes and
part_sizes != null and part_count > 1 enables multipart mode.

In `@test/regression/issue/28762.test.ts`:
- Around line 66-71: The test uses blob.arrayBuffer(), which bypasses
ByteBlobLoader; change it to consume Blob.stream() instead: call blob.stream(),
read all ReadableStreamDefaultReader chunks (via getReader()/read loop or by
piping to a collector), concatenate the Uint8Array chunks into one Uint8Array,
and assert that the concatenated result equals [1,2,3,4,5,6]; update the test
"Blob.stream() total content is correct after chunking" to use Blob.stream() and
the reader loop so the multipart chunking path (ByteBlobLoader) is exercised.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5d7d7827-32ff-4489-9a90-1918432605bb

📥 Commits

Reviewing files that changed from the base of the PR and between 3ed4186 and 0a96ac5.

📒 Files selected for processing (5)
  • src/bun.js/webcore/Blob.zig
  • src/bun.js/webcore/ByteBlobLoader.zig
  • src/bun.js/webcore/blob/Store.zig
  • src/string/StringJoiner.zig
  • test/regression/issue/28762.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/webcore/Blob.zig`:
- Around line 3405-3414: The function setPartSizes currently returns early when
store is null and thus drops the allocator-owned part_sizes slice, leaking
memory for multipart empty inputs; modify setPartSizes to free the allocated
part_sizes (use bun.default_allocator.free or equivalent) before any early
return when store is null or when sizes.len <= 1 so the slice backing memory is
reclaimed, and only after successful assignment to s.data.bytes.part_sizes /
s.data.bytes.part_count avoid freeing; reference the function setPartSizes, the
parameter part_sizes, the local sizes, and the fields s.data.bytes.part_sizes
and s.data.bytes.part_count to locate where to insert the free calls.

In `@src/bun.js/webcore/ByteBlobLoader.zig`:
- Around line 74-77: The code initializes and advances across parts using
current_part and current_part_remain but doesn't skip zero-length parts, causing
later non-empty parts to be merged; update initialization and all advance/read
paths that set or consume current_part_remain (including the initial set and the
advance logic used in the read functions around the ranges noted) to
loop/advance while current_part < part_count and part_sizes.?[current_part] ==
0, incrementing current_part and setting current_part_remain from the next
non-zero part_size (or 0 if none); ensure any place that falls back to
this.remain first normalizes by skipping zero-length parts so chunk boundaries
after empty parts are preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 07c4c304-8a65-4883-ae1b-ee23e6a6879a

📥 Commits

Reviewing files that changed from the base of the PR and between 0a96ac5 and de8c94c.

📒 Files selected for processing (4)
  • src/bun.js/webcore/Blob.zig
  • src/bun.js/webcore/ByteBlobLoader.zig
  • src/bun.js/webcore/blob/Store.zig
  • test/regression/issue/28762.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/webcore/ByteBlobLoader.zig`:
- Around line 60-63: The code currently computes has_user_chunk_size from
user_chunk_size > 0 which loses the signal that a caller explicitly passed a
non-positive chunk size; add a separate boolean parameter (e.g.
has_explicit_chunk_size or user_provided_chunk_size) threaded from the
Blob.getStream() call site and use that boolean instead of user_chunk_size > 0
when computing has_user_chunk_size; update the has_parts expression (used with
blobe.store, blobe.offset, blobe.size, store.data.bytes.part_sizes,
store.data.bytes.part_count) to check that the explicit-flag is false (i.e. only
enable part-boundary mode when no explicit chunk size was passed), and update
all call sites to pass the new boolean.

In `@src/string/StringJoiner.zig`:
- Around line 90-112: The new allocation of part_sizes in doneWithPartSizes can
fail and currently returns after freeing slice but leaves this' linked-list and
cloned buffers live; update doneWithPartSizes so that if allocating part_sizes
fails you clean up all resources: free the slice, deinit the StringJoiner (call
this.deinit() or the appropriate cleanup routine to free nodes and cloned
buffers) and ensure any node-level buffers are freed before returning the error;
also switch the allocation to use the project allocator helper (use the
allocator from src/allocators rather than bun.default_allocator) and use
errdefer/defer to centralize cleanup so failures in the part_sizes allocation
always run the same cleanup path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 09d7fab9-a5b9-4c1c-92d6-480b0f40c7b1

📥 Commits

Reviewing files that changed from the base of the PR and between de8c94c and a75875b.

📒 Files selected for processing (3)
  • src/bun.js/webcore/Blob.zig
  • src/bun.js/webcore/ByteBlobLoader.zig
  • src/string/StringJoiner.zig

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/string/StringJoiner.zig`:
- Around line 84-96: The current doneWithPartSizes implementation calls errdefer
this.deinit() unconditionally which causes StringJoiner to be invalidated on any
error (breaking done()'s previous contract); update the error handling so that
the node-list is only freed when the new part_sizes allocation actually succeeds
(i.e., scope the deinit to the branch that allocates/returns part_sizes) or,
alternatively, before propagating an error reset this.head/this.tail/this.len to
their empty-state so callers using errdefer joiner.deinit() won't double-free;
adjust the logic around doneWithPartSizes, done, deinit, and the part_sizes
allocation to ensure only the part_sizes OOM path consumes the joiner state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 47896103-bfd2-4594-885b-f5d704f06163

📥 Commits

Reviewing files that changed from the base of the PR and between a75875b and 1541388.

📒 Files selected for processing (1)
  • src/string/StringJoiner.zig

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/string/StringJoiner.zig`:
- Around line 117-133: The join routine frees every node but leaves the
StringJoiner state pointing to freed nodes; after the node-consumption loop (the
block that iterates current/node, calls prev.deinit(this.allocator), and before
the final bun.assert and return), reset the joiner internal state to the same
empty-state values used by deinit() — set head and tail to null and len to 0 (or
call the same helper used by deinit() if present) so future deinit() or reuse
won't touch freed nodes; update the function that returns .{ slice, part_sizes }
(the join method in StringJoiner) to perform this reset before returning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5aafa767-5b3e-45d6-a6df-f0109bb58810

📥 Commits

Reviewing files that changed from the base of the PR and between 1541388 and 8b0f361.

📒 Files selected for processing (1)
  • src/string/StringJoiner.zig

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — all previously flagged issues have been resolved.

Extended reasoning...

Overview

This PR fixes Blob.stream() to preserve original part boundaries when streaming multi-part Blobs. Changes span StringJoiner.zig (new doneWithPartSizes()), blob/Store.zig (part_sizes/part_count fields on Bytes), ByteBlobLoader.zig (per-part chunk delivery), Blob.zig (wiring), and a regression test file.

Security risks

None. This is a pure behavior fix in the streaming path with no auth, crypto, or permission code touched.

Level of scrutiny

The change is non-trivial (Zig memory management across multiple files), but the scope is well-contained: it only activates when a Blob has no explicit chunkSize, no offset, and was constructed from multiple parts. The fix is additive — existing single-part and sliced blobs are unaffected.

Other factors

All issues raised during review have been addressed: deinit() is now idempotent (head/tail/len zeroed after walk, preventing double-free), setPartSizes frees the allocation when store == null, memory accounting covers part_sizes in both memoryCost() and calculateEstimatedByteSize(), skipEmptyParts prevents merging after zero-length parts, and errdefer this.deinit() covers OOM cleanup in doneWithPartSizes. The regression test validates all edge cases including empty blobs, single parts, and string parts.

@robobun robobun force-pushed the farm/d0b40a30/fix-blob-stream-chunking branch 2 times, most recently from 2f8e953 to 78da3b2 Compare April 2, 2026 01:34
robobun and others added 7 commits April 2, 2026 02:44
When a Blob is constructed from multiple parts (e.g. new Blob([a, b, c])),
Blob.stream() now delivers each original part as a separate chunk instead
of concatenating everything into a single chunk.

The fix stores original part sizes in the Blob's byte store during
construction, then uses them in ByteBlobLoader's drain() and onPull()
to deliver chunks at part boundaries.
- Guard multipart mode on blob covering entire store (blob.size == store.size()),
  preventing sliced blobs from incorrectly using part boundaries
- Include part_sizes allocation in Store.memoryCost() and
  Blob.calculateEstimatedByteSize() for accurate GC accounting
- Handle partial reads in onPull/drain via current_part_remain tracking
- Use blob.stream() instead of blob.arrayBuffer() in content test so the
  ByteBlobLoader path is exercised
- Free part_sizes when store is null (empty blob leak)
- Add errdefer for slice in doneWithPartSizes (OOM leak)
- Skip zero-length parts in ByteBlobLoader to prevent merging
@robobun robobun force-pushed the farm/d0b40a30/fix-blob-stream-chunking branch from 376766e to a02d6b0 Compare April 2, 2026 02:44
Comment on lines 83 to 92
pub fn onStart(this: *ByteBlobLoader) streams.Start {
if (this.part_sizes) |sizes| {
var max_part: Blob.SizeType = 0;
for (sizes[0..this.part_count]) |s| {
max_part = @max(max_part, s);
}
return .{ .chunk_size = max_part };
}
return .{ .chunk_size = this.chunk_size };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The multipart streaming path removes the 16KB safety cap from drain(), allowing bun.handleOom(byte_list.clone(...)) to synchronously copy an entire large part in one call — e.g., a blob with a 100MB part triggers a 100MB blocking heap allocation on the hot stream/pipe path. Additionally, onStart() returns the uncapped max_part as the chunk_size hint, which can direct the JS stream infrastructure to allocate a receive buffer as large as the biggest part (e.g., 300MB for a 300MB-part blob). Fix drain() by applying @min(16384, this.current_part_remain); for onStart(), consider applying @min(max_part, 1024 * 1024 * 2) or a higher cap to bound worst-case allocations (noting a strict 2MB cap would break one-chunk-per-part for parts larger than 2MB).

Extended reasoning...

drain() removes the 16KB safety cap for multipart blobs

In ByteBlobLoader.drain() (lines 223-226), non-multipart blobs are capped at 16384 bytes per drain call via @min(16384, this.remain). For multipart blobs, max_drain is set to this.current_part_remain with no upper bound. Since drain() always clones synchronously via bun.handleOom(byte_list.clone(bun.default_allocator)), any blob constructed from large parts (e.g., new Blob([buf1, buf2]) where each part is 50MB) will cause drain() to allocate and copy 50MB in one blocking call on the hot stream path.

The 16KB cap exists to keep drain() a 'small best-effort grab' for stream backpressure handling. drain() is called from ResumableSink.zig during pipeTo() operations, where callers expect a small, immediately-available chunk -- not a full copy of a multi-MB blob part. Crucially, applying @min(16384, this.current_part_remain) does not break part-boundary semantics: current_part_remain is decremented by the amount actually drained (line 236), so multiple 16KB drain calls still correctly advance through part boundaries. The cap is safe to restore.

Concrete proof for drain(): Create new Blob([largeChunk1, largeChunk2]) where each chunk is 50MB, then call .stream().pipeTo(writable). During backpressure handling, ResumableSink calls drain(). Without the cap, max_drain = current_part_remain = 50MB, so byte_list.clone(...) synchronously copies 50MB into a new heap buffer, blocking the event loop and causing a large uncontrolled allocation on every such pipe operation.

onStart() returns uncapped max_part as chunk_size hint

In ByteBlobLoader.onStart() (lines 83-92), when part_sizes != null, the function computes max_part (the maximum of all part sizes) and returns it directly as chunk_size with no cap. The non-multipart path returns this.chunk_size, which is always capped at 2MB in setup() via @min(..., 1024 * 1024 * 2). The chunk_size returned from onStart() is used by the JS stream infrastructure to allocate the TypedArray buffer passed to each onPull() call -- so returning 300MB as chunk_size causes a ~300MB TypedArray allocation before the first onPull() even runs.

Addressing the refutation: One verifier argues this is intentional -- if chunk_size were capped at 2MB but a part is 5MB, buffer.len would be 2MB, and onPull() (which computes min(buffer.len, max_chunk)) would deliver only 2MB per call, requiring multiple pulls and breaking one-chunk-per-part semantics. This concern is technically valid for a strict 2MB cap. However, the original issue #28762 deals exclusively with tiny parts (1-59 bytes), and the PR's own tests only cover small parts. There is no evidence the author considered blobs with hundreds-of-MB individual parts. For the common case the feature was designed for, a 2MB cap is safe; for large parts, a higher cap (e.g., 32-64MB) would accommodate practical use cases while bounding worst-case allocations. At minimum, the unbounded allocation risk should be documented.

Concrete proof for onStart(): new Blob([new ArrayBuffer(300 * 1024 * 1024), new ArrayBuffer(300 * 1024 * 1024)]).stream() -- has_parts is true (two parts, no offset, no user chunk size), so max_part = 300MB. onStart() returns { .chunk_size = 300_000_000 }. The stream infrastructure allocates a 300MB TypedArray receive buffer before the first onPull() call, even though the data is already in memory and could be streamed in bounded chunks.

@Jarred-Sumner
Copy link
Copy Markdown
Collaborator

This isn't a bug!

Comment on lines 4187 to 4202
current = stack.pop() orelse break;
}

const joined = try joiner.done(bun.default_allocator);
const joined, const part_sizes = try joiner.doneWithPartSizes(bun.default_allocator, true);

if (!could_have_non_ascii) {
return Blob.initWithAllASCII(joined, bun.default_allocator, global, true);
const blob = Blob.initWithAllASCII(joined, bun.default_allocator, global, true);
setPartSizes(blob.store, part_sizes);
return blob;
}
return Blob.init(joined, bun.default_allocator, global);
const blob = Blob.init(joined, bun.default_allocator, global);
setPartSizes(blob.store, part_sizes);
return blob;
}

pub const Any = union(enum) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 When new Blob([multiPartBlob]) is called with a single-element array, the fast path at Blob.zig:4021 returns blob.dupe() instead of going through the joiner, causing the copy to inherit the original store's part_sizes/part_count. As a result, copy.stream() emits N chunks (one per original part) instead of 1, while new Blob([multiPartBlob, ""]).stream() correctly emits 1 chunk — an observable inconsistency introduced by this PR.

Extended reasoning...

What the bug is

This PR adds part_sizes/part_count fields to Blob.Store.Bytes and new multipart-aware logic in ByteBlobLoader.setup() that activates when those fields are non-null. The guard in setup() is:

const has_parts = !has_user_chunk_size and blobe.offset == 0 and blobe.size == store.size() and
    store.data == .bytes and store.data.bytes.part_sizes != null and store.data.bytes.part_count > 1;

This is correct when the store was constructed directly from multiple parts. However, the PR did not account for the single-element Blob fast path in fromJSWithoutDeferGC().

The specific code path that triggers it

At Blob.zig:3960, when new Blob([existingBlob]) is called, top_iter.len == 1 sets might_only_be_one_thing = true and extracts top_value. The fast path at line 3976 activates. At lines 4012-4021, when top_value.jsTypeLoose() == .DOMWrapper and top_value.as(Blob) succeeds and move == false (the fromJSClone path), the code executes return blob.dupe().

blob.dupe() copies the Blob struct value, sharing the same *Blob.Store pointer (ref-counted). Since the original store had part_sizes != null and part_count = N, the duped blob inherits both. The setPartSizes call introduced by this PR is only invoked on the joiner path (lines 4187-4202 of the diff), not on this fast path.

Why existing code does not prevent it

The fast path was designed as a zero-copy optimization: for a single-element Blob input, share the store rather than round-tripping through StringJoiner. This was safe before this PR because stores carried no per-part metadata. After this PR, the store carries part_sizes that alter streaming behavior, making shared-store semantics observable at the stream() API level.

Impact

new Blob([multi]).stream() emits N chunks (one per original part), while new Blob([multi, ""]).stream() emits 1 chunk (the multi-element path goes through the joiner, creating a fresh 1-node store with null part_sizes). This is a behavioral regression: pre-PR, both patterns emitted 1 chunk; post-PR, they differ.

Step-by-step proof

  1. const multi = new Blob([p1, p2, p3]) — store has part_sizes = [n1, n2, n3], part_count = 3.
  2. const copy = new Blob([multi])top_iter.len == 1, might_only_be_one_thing = true, top_value = multi.
  3. Fast path fires: top_value.jsTypeLoose() == .DOMWrapper, top_value.as(Blob) succeeds, move == falsereturn blob.dupe() at line 4021.
  4. copy.store == multi.store (same pointer, ref-count bumped). Store still has part_sizes != null, part_count = 3.
  5. copy.stream()ByteBlobLoader.setup(): has_parts evaluates to true (offset==0, size==store.size(), part_sizes!=null, part_count>1).
  6. Stream delivers 3 chunks instead of 1.

Fix

Strip part_sizes from the duped blob before returning it in the fast path, or route single-element Blob inputs through the joiner. The simplest fix is to clear the inherited part metadata on the duped copy:

var duped = blob.dupe();
if (duped.store) |s| {
    if (s.data == .bytes) {
        s.data.bytes.part_sizes = null;
        s.data.bytes.part_count = 0;
    }
}
return duped;

Note this is safe because dupe() increments the ref-count but the part_sizes are owned by the store — clearing them on one ref is not correct (would affect the original). Instead, the duped blob should be routed through the joiner to get a fresh store without part_sizes, OR setPartSizes should be called with null on the blob after dupe() if the store is shared (ref_count > 1).

Comment on lines 83 to +86
pub fn done(this: *StringJoiner, allocator: Allocator) ![]u8 {
const result, _ = try this.doneWithPartSizes(allocator, false);
return result;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 This is a pre-existing issue: doneWithEnd() (StringJoiner.zig:157-185) is missing both safety fixes that this PR explicitly applied to the sibling doneWithPartSizes(). (1) No errdefer this.deinit() guard -- if allocator.alloc() at line 169 fails with OOM, all accumulated linked-list nodes leak. (2) No state reset after the node walk -- this.head/this.tail/this.len are left as dangling pointers on the success path, so any future deinit() call would walk already-freed nodes causing use-after-free.

Extended reasoning...

What the bug is and how it manifests

doneWithEnd() at lines 157-185 of src/string/StringJoiner.zig has two safety issues identical to what this PR fixed in doneWithPartSizes(), but were not applied here.

Issue 1 - OOM path node leak: doneWithEnd() calls try allocator.alloc(u8, this.len + end.len) at line 169 with no errdefer this.deinit() guard. If this allocation fails with error.OutOfMemory, every linked-list node in the joiner leaks. The PR explicitly added errdefer this.deinit() to doneWithPartSizes() at line 96 but did not add the same to doneWithEnd().

Issue 2 - Missing state reset on success path: After the while loop (lines 172-179) frees every node via prev.deinit(this.allocator), this.head, this.tail, and this.len are never zeroed. The function returns with dangling non-null pointers. The PR explicitly added the state reset to doneWithPartSizes() at lines 132-134, and also made deinit() idempotent by zeroing fields at lines 148-150 -- but neither change was applied to doneWithEnd()'s own success path.

The specific code path that triggers it

For Issue 1: a caller accumulates N strings into the joiner, then calls doneWithEnd(allocator, suffix). The single try at line 169 fails. All N nodes remain unfreed.

For Issue 2: doneWithEnd() succeeds. this.head is now a dangling pointer to freed node memory. If any code path subsequently calls deinit() on the same joiner (e.g., an outer errdefer j.deinit() that fires because something later in the same scope fails), deinit() sees this.head != null, skips the early-return guard, enters the walk loop, and dereferences freed memory.

Why existing code does not prevent it

The PR audited and fixed the exact same two patterns in doneWithPartSizes() and made deinit() idempotent, but the pre-existing doneWithEnd() was not updated. The three callers (SourceMapStore.zig:189, Chunk.zig:564, sourcemap.zig:810) do not currently have outer errdefer j.deinit() guards, so the double-free scenario from Issue 2 is not reachable today. However, the OOM leak from Issue 1 is formally real whenever alloc() returns an error.

Impact

The OOM node leak (Issue 1) is a real memory leak on the error path. The missing state reset (Issue 2) is a latent use-after-free hazard: currently safe because no callers guard with errdefer j.deinit(), but fragile -- any future refactor adding such a guard to a caller would introduce heap corruption. Both issues are pre-existing relative to this PR.

Step-by-step proof for Issue 1

  1. A source-map builder pushes N strings into a StringJoiner via push(), accumulating N linked-list nodes.
  2. doneWithEnd(allocator, suffix) is called.
  3. allocator.alloc(u8, this.len + end.len) fails with error.OutOfMemory.
  4. The try propagates the error; there is no errdefer this.deinit() in scope.
  5. All N linked-list nodes and any heap-cloned string data they reference remain unfreed.

How to fix it

Apply the same two fixes from doneWithPartSizes(): add errdefer this.deinit() immediately after confirming this.head != null (before the try allocation), and add this.head = null; this.tail = null; this.len = 0 after the node-walk loop before return slice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blob.stream() concatenates all chunks into a single chunk instead of separate Uint8Arrays

2 participants