Skip to content

Bun.Image: treat an empty Blob or buffer as zero bytes - #46117

Open
robobun wants to merge 1 commit into
mainfrom
robobun/4f911b9d/image-empty-blob-zero-bytes
Open

robobun wants to merge 1 commit into
mainfrom
robobun/4f911b9d/image-empty-blob-zero-bytes

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new Bun.Image(await req.bytes()).webp().blob() on an empty POST rejects Image: source ArrayBuffer was detached. new Blob([]).image() throws TypeError: Image() input must be a path string, data: URL, ArrayBuffer, TypedArray or Blob.
  • Cause: length stands for kind. source_from_js (src/runtime/image/Image.rs:420) rejects a Blob with no store. pin_for_task (:739) reads len == 0 as detached.

Fix

  • For an empty view, the Blob arm asks in_memory_view(). Zero bytes in memory become Source::Owned, which already rejects with ERR_IMAGE_UNKNOWN_FORMAT.
  • pin_for_task drops its len == 0 test, and js_thread_bytes returns None for a detached buffer. The borrow helper tells detached from empty.
  • Verified: test/js/bun/image/image.test.ts, 20 tests fail on main. Also ran image-adversarial, image-kernels, bindgen.
  • Self-reviewed: 18 concerns raised, 15 addressed. Not done (Notes): an "input is empty" message, a native copy of file Blobs, a wait for other PRs.

Background

Downsides

  • An empty buffer now rejects from a work-pool job, with ERR_IMAGE_UNKNOWN_FORMAT where handlers saw ERR_INVALID_STATE.
  • new Response(new Blob([]).image()) threw a coded TypeError. It now throws an uncoded Error, like other body errors (Bun.Image: set error.code on sync body-encode errors #40381).
  • Cost: source_from_js 91 to 93 instructions (in-memory Blob), 66 to 70 (file Blob). metadata() on a buffer: 1 more branch.
Notes

Reach. No user reported this. It came from a review comment on #45509. A search of the tracker for each message finds 0 issues. A Bun.serve handler can reach it with an empty POST or an empty file part of a form.

Repro (main):

await new Bun.Image(new Uint8Array(0)).png().bytes(); // rejects ERR_INVALID_STATE: Image: source ArrayBuffer was detached
new Blob([]).image(); // throws the TypeError
new File([], "a.png").image(); // throws the TypeError
(await new Response("").blob()).image(); // throws the TypeError
new Response(new Blob([png]).slice(0, 0).image()); // throws Image: fd/S3-backed Bun.file as a Response body

A handler that answers 400 for error.code === "ERR_IMAGE_UNKNOWN_FORMAT" answers 500 on main for new Bun.Image(await req.bytes()) on an empty POST, and for (await req.formData()).get("f").image() on an empty file part. It answers 400 here. This is for awaited terminals only: new Response(image) throws an error with no code on main and here.

The contract. packages/bun-types/bun.d.ts:9434 defines ERR_IMAGE_UNKNOWN_FORMAT as "input bytes didn't match any sniffer" and ERR_INVALID_STATE as "the input ArrayBuffer was transferred between construction and the terminal call". docs/runtime/image.mdx:16 says the constructor accepts a Blob.

What a store-less Blob is. Blob::init (src/jsc/webcore_types.rs:290) gives zero bytes no store. new Blob([]), new File([], name), structuredClone(new Blob([])), an empty FormData file part, await res.blob() of an empty body, a slice of a zero-size Blob and a zero-byte in-memory Bun.build() output all reach the throw. new Bun.Image(output) takes a BuildArtifact through the same arm, and so does output.image() of #45509.

Changes for input that works today.

  • A zero-length slice of an in-memory Blob is Source::Owned from construction. The Image holds no Strong on the Blob, and metadata() is settled at return. As a body it reports the format, not an fd/S3 file.
  • An empty buffer reports the format at every terminal. metadata() already did.
  • metadata() on a detached buffer rejects with ERR_INVALID_STATE. It reported the format. The other terminals already said "detached".

Measurements. Release builds of main 91e7e3b and of this branch, linux x64. Debug builds for the breakpoint count.

  • source_from_js, non-empty in-memory Blob: 91 vs 93 instructions in-symbol, 15 conditional branches and 3 calls on both (gdb nexti, 16 warm-ups then 3 calls, 2 runs). File Blob: 66 vs 70, 10 branches and 2 calls on both. Uint8Array: 64 vs 64.
  • ImagePrototype__doMetadata, PNG in a Uint8Array: 152 vs 150 instructions, 14 vs 15 conditional branches, 8 calls on both. In-memory Blob source: 131 vs 131.
  • Text (nm -S): source_from_js 1842 to 1893 B, ImagePrototype__doMetadata 1463 to 1455 B, pin_for_task 494 to 494 B, encode_for_body 1791 to 1791 B. The out-of-line copy of JSC__JSValue__unpinArrayBuffer (108 B) is gone: it has one call site fewer. .text is 81,665,765 B on both (size -A).
  • Protected Blobs per 1000 idle Images (heapStats().protectedObjectTypeCounts.Blob): zero-length slice 1000 to 0, empty Blob throws to 0, Bun.file 1000 to 1000.
  • Bun.peek.status at return: metadata() on an empty Blob throws to rejected, on a zero-length slice pending to rejected, on 10 shapes of empty buffer rejected to rejected. png().bytes() on an empty buffer rejected to pending.
  • Empty-buffer cells (5 shapes by 10 terminals) changed from "detached" to "unrecognised format": 45 of 50. The 5 metadata() cells already said it. Detached-buffer cells changed: 4 of 40, the metadata() cells.
  • Leaked pins after 1000 terminals on new ArrayBuffer(0): 0. Same for 1000 bodies and 1000 views.
  • Tests that reach the in-memory arm of read_bytes_to_handler (Blob.rs:629): 1 on main, 1 here (gdb breakpoint, image.test.ts).
  • image.test.ts on the release build of main: 20 tests fail. They are 19 of the 20 new cases and the changed S3 test. The other new case is a guard: a value that is not a Blob still throws ERR_INVALID_ARG_TYPE. The same 19 fail on 1.4.2.

in_memory_view() is now #[inline]. Without the attribute the new call site was the only out-of-line call: 15 more instructions, 3 more branches and a call for every file Blob. The store accessors it wraps are #[inline] already.

The in-memory arm of read_bytes_to_handler, and #38503. BlobReadChain is the only caller, and it now starts only for a file or S3 Blob. The arm still runs for a file Blob whose store an array body moved out after construction: new Response([file]) (Blob.rs:3084). The Image then reads zero bytes for a valid file, and new Response(image) refuses it with the fd/S3 message. The S3 test now reaches the arm through that move, in place of the zero-length slice. #38503 removes the move. If #38503 lands, that test row must flip. Then no test reaches the arm, and it can go, with the store-less branch of encode_for_body. If it does not land, the row keeps the arm tested.

Not changed.

sharp. sharp throws Input Buffer is empty at construction (lovell/sharp#2687, after lovell/sharp#2681). It can: its buffer is in memory. A Bun.Image source can be a file or an S3 object that is read at the terminal, and main already constructs from an empty Uint8Array.

Suites. bun bd test test/js/bun/image/image.test.ts: 123 pass, 5 skip (system-backend tests). image-kernels.test.ts: 37 pass. test/internal/bindgen.test.ts: 124 pass. websocket-server.test.ts -t Blob: 3 pass. image-adversarial.test.ts: 97 pass, 1 fail. The failure is decode/encode cycles plateau, which takes longer than the 5 s default on a local debug build. It fails the same way on main.

Why the new tests are in image.test.ts. image-adversarial.test.ts states that it does not check which error is thrown. These cases check which error an awaited terminal reports.

Overlap. This branch merges clean with #45314 and #46081 in either order (git merge-tree). It conflicts with #42185 in the pin_for_task arms: keep the arms without the len == 0 test. The buffer: cases fail if the test comes back.

Self-review, not done.

  • A message of its own for zero bytes (Image: input is empty). It changes the message for every empty source, files included. It is a separate decision.
  • A native copy of a file Blob at construction (Source::Blob(Box<Blob>)), so that a later store move cannot empty the source. It changes the result for non-empty file Blobs, so it is a change of its own.
  • A wait until the other open Bun.Image PRs land. The order is for a maintainer to choose. This branch merges clean with the two it overlaps most.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/image/image.test.ts

`source_from_js` chose the Blob source from the length of the in-memory
view, then from the presence of a store. A zero-byte in-memory Blob has
neither, so it reached the argument throw that is for a value that is
not a Blob. A zero-length slice of a byte store has a store, so it
became `Source::Blob`, and the synchronous body path refused it as an
fd/S3 file.

The Blob arm now asks `in_memory_view()` when the view is empty. Zero
bytes in memory are `Source::Owned`. Only a file- or S3-backed Blob is
`Source::Blob`. `in_memory_view()` is `#[inline]`, like the store
accessors it wraps, so the new call site adds no call.

`pin_for_task` reported `len == 0` as a detached buffer. The borrow
helper already answers 0 for a detached view or buffer, so an empty
buffer now goes to the decoder and the terminal rejects with
ERR_IMAGE_UNKNOWN_FORMAT.

`js_thread_bytes` gave `metadata()` a detached buffer as zero bytes. It
now returns `None` for one, as its doc says, so `metadata()` rejects
with ERR_INVALID_STATE like the other terminals.
@coderabbitai

coderabbitai Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: bc7a5ad8-e5ad-4935-92ee-4489a1590032
📥 Commits

Reviewing files that changed from the base of the PR and between 3880151 and 261fc5b.

📒 Files selected for processing: 3
  • src/runtime/image/Image.rs
  • src/runtime/webcore/Blob.rs
  • test/js/bun/image/image.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:18 PM PT - Oct 19th, 2026

✅ @robobun, your commit 261fc5ba74e5791fc2fb674897d19edadb4d23ee passed in Build #129120! 🎉


🧪   To try this PR locally:

bunx bun-pr 46117

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

bun-46117 --bun

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on a release build of main (91e7e3b) and on 1.4.2:

await new Bun.Image(new Uint8Array(0)).png().bytes(); // rejects ERR_INVALID_STATE: Image: source ArrayBuffer was detached
new Blob([]).image(); // throws TypeError: Image() input must be a path string, data: URL, ArrayBuffer, TypedArray or Blob

bun test test/js/bun/image/image.test.ts fails 20 tests on that build of main and passes with this branch.

PR: #46117

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked that dropping the len == 0 arms in pin_for_task cannot leak a pin: the kind-2 Pin(v) on an empty buffer is released by PipelineJs::_pin on the job path and by _pin scope exit on every return of encode_for_body. bun_core::ffi::slice accepts a null pointer at length 0, so an empty FastTypedArray with a null vector is safe, and in_memory_view() is None only for file/fd/S3 stores, so those still take Source::Blob.

Extended reasoning...

The change touches src/runtime/image/Image.rs (Blob classification in source_from_js, detached filter in js_thread_bytes, and the pin_for_task borrow match), adds #[inline] and a doc tweak in src/runtime/webcore/Blob.rs, and rewrites the zero-byte tests in test/js/bun/image/image.test.ts. It touches unsafe ArrayBuffer pinning and borrowed-slice code but no auth, crypto, or injection surface. The inline findings concern the store-moved-out Bun.file scenario (#38503) that the replaced test now relies on, which is a maintainer call, so a human look is still warranted.

Comment thread test/js/bun/image/image.test.ts
Comment thread src/runtime/image/Image.rs
@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

#38503 is closed. Its array body part is now #46732, a draft on top of #46719: new Response([file]) no longer takes the store out of file.

The row image-store-moved in test/js/bun/image/image.test.ts of this PR uses new Response([file]) to empty the file and expects file.size to be 0. Git merges the two branches with no conflict, and the row then fails. The PR that lands second has to change the row.

This branch has not been deployed

No deployments
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.

1 participant