blob: report the size of Blobs created by the native bindings to the GC

RO
3mo ago
+125-42

Problem

  • Blobs that bun builds natively tell the GC they are about 48 bytes whatever their payload: a File parsed from a multipart Request or Response body (and the FormData holding it, 81), a WebSocket message received with binaryType = "blob" (and its MessageEvent, 56), and Bun.WebView#screenshot(). new Blob() of the same 1 MiB reports 1048896. Reproduces on 1.4.0 and main.
  • Cause: a Blob's estimated size is a cached field that only the JS Blob constructor and the ordinary Rust to JS conversion fill in. The native entry points hand the Blob to JSC with the cache still 0, and duplicating a Blob copies its source's 0.
  • The bytes are still freed by the periodic event loop GC. What is wrong is the number JSC uses to schedule collections, and what heap snapshots and estimateShallowMemoryUsageOf show.

Fix

  • The four native entry points (dupe, from bytes, from bytes with type, from mmap with type) and the Blob constructor go through one helper that computes the estimate and then moves the Blob to the heap. The typed variants set the content type before the size is taken, so it is counted.
  • Correct because at these points the Blob is freshly built, exclusively owned, on the JS thread, and has its final fields, and every later reader (the JS wrapper, FormData, MessageEvent) reads that same allocation. The value only goes to JSC, and it now matches new Blob(sameBytes) plus the stored name and content type.
  • Producers with the same symptom (new File(...), Bun.Image#blob(), server and bundler accounting) are left to #37667, #37656 and a separate issue. Moving the computation into Blob::new itself is a follow-up once #37656 lands.
  • Verification: new heap-snapshot tests for the multipart, re-appended File and WebSocket cases fail on the released bun and pass with the fix. One assertion was added to each existing screenshot test; the Chrome one was run locally against a real Chrome (48 before, 1123 after, for a 370 byte PNG), the WebKit one is left to CI.

Background

  • JSC only sees memory it allocated itself. A native object holding a buffer outside the JS heap reports that size as extra memory, once at creation and again at every GC, so JSC knows when a collection is worth running. The number is advisory and does not decide when the object is freed.
  • Blob's estimate is a cache because the accessor is also called from the GC marking thread, so it never computes anything. Whoever builds the Blob has to compute it once, on the JS thread, before handing it over.
  • A Blob is assembled on the stack and then Blob::new moves it to a heap allocation whose pointer the C++ bindings wrap in a JS object. The cache has to be filled before that move and after every field it counts (name, content type) is set.
  • C++ code (multipart parsing, WebSocket, WebView) creates Blobs through a handful of extern "C" exports rather than the JS constructor, so a fix in the constructor never reaches them. Dupe copies an existing Blob, cache included.
  • estimateShallowMemoryUsageOf from bun:jsc returns the size JSC currently holds for one object, which is how the tests observe the number.

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/webview/webview-chrome.test.ts test/js/bun/webview/webview.test.ts

Original description

Repro

import { estimateShallowMemoryUsageOf } from "bun:jsc";

const payload = Buffer.alloc(1 << 20, "x");
console.log(estimateShallowMemoryUsageOf(new Blob([payload]))); // 1048896

const upload = new FormData();
upload.append("f", new Blob([payload]), "u.bin");
const parsed = await new Response(upload).formData();
console.log(parsed.get("f").size);                         // 1048576
console.log(estimateShallowMemoryUsageOf(parsed.get("f"))); // 48  (the bare cell)
console.log(estimateShallowMemoryUsageOf(parsed));          // 81

// Same for a WebSocket with ws.binaryType = "blob": a 1 MiB binary message
// arrives as a Blob whose estimate is 48, inside a MessageEvent whose estimate is 56.
// Same for Bun.WebView#screenshot(): a 370 byte PNG Blob reports 48.

Reproduces on bun 1.4.0 and on main (Request#formData() behaves the same as Response#formData()).

Cause

Blob.reported_estimated_size is a cache. The generated Blob__estimatedSize (what Blob__create's reportExtraMemoryAllocated, JSBlob::visitChildren, JSBlob::memoryCost and therefore WebCore::Blob::memoryCost() read) only returns it, because it is also called from the GC marking thread. Whoever creates a Blob that JSC will account for has to fill it first; BlobExt::to_js and the Blob constructor do.

The Blobs above never pass through either of those. They are created by the exports behind the C++ WebCore::Blob wrapper (src/jsc/bindings/blob.h) and the webview backends, and none of them computed the size:

  • Blob__dupe: every WebCore::Blob to JS conversion (src/jsc/bindings/blob.cpp: Blob__setAsFile, then Blob__dupe, then Blob__create), every multipart file entry (FormData.rs builds a stack Blob and appendBlob dupes it) and every formData.append(name, blob) from JS (Blob__dupeFromJS). dupe_with_content_type copies the source's cached value, which for a multipart entry is the never-computed 0, so the 0 was copied into the FormData entry and again into the JS wrapper. Blob__create then reported 0 bytes at allocation and the marking thread reported 0 at every GC.
  • Blob__fromBytes: WebSocket binary messages in blob mode (WebSocket.cpp). The JS-visible Blob is a Blob__dupe of this one, and MessageEvent::memoryCost() reads this one directly.
  • Blob__fromBytesWithType / Blob__fromMmapWithType: Bun.WebView#screenshot() on the Chrome and WebKit backends, which hand the pointer straight to Blob__create.

Fix

The four exports, and the Blob constructor (which already did the same two steps inline), go through one helper, new_for_bindings, which calls calculate_estimated_byte_size() and then Blob::new. Blob__fromBytes / Blob__fromBytesWithType / Blob__fromMmapWithType are restructured so the content type is set on the stack Blob before the size is taken; the two copies of the content type stamping become set_content_type_from_cstr. The 'static requirement on mime in the two *WithType doc comments is dropped, since the string has been copied into an owned content_type since #29910.

Why this is the right place: these exports are exactly the points where a Blob is handed to a consumer that reads the cache without going through BlobExt::to_js, and the freshly built Blob is exclusively owned and on the JS thread there, so computing is safe and covers every consumer of that allocation at once (the JS wrapper, DOMFormData::memoryCost, MessageEvent::memoryCost). Computing in Blob__dupe rather than at the toJS call site also means the estimate includes the file name that Blob__setAsFile stores just before it, and it fixes the JS append() path for a source whose own cache was never filled (a parsed entry appended to another FormData). The computation is a handful of field reads, negligible next to the heap allocation and wrapper creation in the same call. Behaviour is otherwise unchanged: the value is only reported to JSC, and it now reports what new Blob(sameBytes) reports (plus the stored name / content type). This does not produce unbounded retention on its own, since Bun's periodic event loop GC still collects these Blobs eventually; what was wrong is the accounting JSC uses to schedule collections and what heap snapshots and estimateShallowMemoryUsageOf show.

Sibling paths with the same symptom, intentionally not in this PR:

  • new File(...) (jsdom_file_construct + JSDOMFile.cpp): #37667.
  • Bun.Image#blob(), the one Rust producer that used the by-value JsClass::to_js (which also skips the computation): #37656, which makes that path compute the size and removes the split. This PR does not touch the functions #37656 or #37667 change, and new_for_bindings keeps working if calculate_estimated_byte_size becomes an inherent method there.
  • The BuildArtifact inline Blob and FileRoute::memory_cost() read the same cache for a Blob that is never computed; that is server / bundler accounting rather than a JS Blob and has been filed separately.

Where this should end up: Blob::new is the one heap-promotion point every reader of the cache goes through, so once #37656 has moved calculate_estimated_byte_size into bun_jsc (it has to live next to Blob::new to be called from there), the computation belongs in Blob::new itself, and new_for_bindings, the constructor's call and the explicit calls added by #37656 / #37667 all become deletable. Every current Blob::new caller either has its fields final at that point or recomputes in BlobExt::to_js afterwards, so that move is mechanical. It is not done here because it needs the hoist that #37656 is already making; this PR keeps the fix in bun_runtime so the three PRs stay independent, and the consolidation is a follow-up on top of whichever lands last.

Verification

Four tests added at the end of the "Native types report their size correctly" group in test/js/bun/util/heap-snapshot.test.ts, each asserting the estimate is between 1x and 2x a 1 MiB payload:

  • File parsed from a multipart Request body, and from a multipart Response body: the File itself and the parsed FormData (the latter goes through the entry's WebCore::Blob, i.e. the appendBlob dupe). Without the fix: 48 and 81.
  • A parsed multipart File appended to another FormData (Blob__dupeFromJS with an uncomputed source). Without the fix: 84.
  • A WebSocket message received with binaryType = "blob": event.data (the Blob__dupe) and the MessageEvent itself (MessageEvent::memoryCost() reads the Blob__fromBytes Blob, so this assertion is the one that depends on Blob__fromBytes computing). Without the fix: 48 and 56.

The two *WithType exports are covered by one added assertion in each existing screenshot test: test/js/bun/webview/webview-chrome.test.ts ("screenshot returns a PNG Blob", Blob__fromBytesWithType, runs on the Linux lanes that have Chrome) and test/js/bun/webview/webview.test.ts (same name, Blob__fromMmapWithType, macOS lanes). Each asserts the screenshot Blob reports at least what new Blob([itsBytes]) reports. The Chrome one was run here through a real Chrome: 48 vs 690 for a 370 byte PNG without the fix, 1123 vs 1114 with it (the 9 extra bytes are the owned image/png); the WebKit one is the same code path on a Bytes store and is left to CI. The existing Blob constructor coverage (blob.test.ts, the FormData test in the same group) still passes with the constructor routed through the helper.

With the fix the debug build reports 1049325 for the parsed File (new Blob of the same bytes: 1049320, the difference being the stored u.bin name), 1049361 for the FormData, 1049320 for the WebSocket Blob and 1049336 for its MessageEvent.

The heap-snapshot tests fail with USE_SYSTEM_BUN=1 bun test and pass with bun bd test. Also run on the fixed debug build: test/js/web/html/FormData.test.ts, FormData-multipart-serialization.test.ts, FormData-file-error-leak.test.ts, test/js/web/fetch/blob.test.ts, test/js/web/websocket/websocket-blob.test.ts (all pass; blob-file-name-ownership.test.ts and two pre-existing tests in heap-snapshot.test.ts only exceed the 5s default per-test timeout locally under debug+ASAN, which they also do without this change). cargo fmt --check is clean.