Report File and S3File sizes to the GC when their wrappers are created

RO
3mo ago
+126-3

Problem

  • new File([bytes], name) reports nothing to the garbage collector: estimateShallowMemoryUsageOf returns 48 (the bare cell) where the same Blob returns about 1 MiB. Reproduces on bun 1.4.0 and main.
  • Constructing and dropping 256 x 1 MiB Files in a fresh process leaves all 256 alive at about 286 MiB RSS; the same loop with Blobs leaves about 9 alive at about 50 MiB, because JSC never learns that the Files own anything.
  • Cause: a Blob's size is a value cached on the native object, and whoever creates the JS wrapper has to fill it in and report it to JSC. The generated Blob constructor does both; the hand-written new File() path did neither, and the S3 file path computed the size but never reported it.
  • A File built over an existing blob shares that blob's store, so it inherited whatever the source had cached (0 when the source was itself a File) and never counted its own name.

Fix

  • The File constructor computes the size once the name and content type are final, at the same point the Blob constructor does.
  • The File and S3 file wrapper sites report that size to JSC right after creating the wrapper, as the generated constructor does; both the plain and the subclass branches of the File constructor pass through that site.
  • Only what is reported to the GC changes, nothing stored on the Blob does, so the blast radius is GC accounting, heap snapshots and estimateShallowMemoryUsageOf.
  • Verification: three new tests fail on a system bun and pass with the fix. One checks the reported size for the File shapes above; two spawn a child that constructs 512 unreferenced Files or S3 files each owning about 64 KB and count the survivors: exactly 512 without the fix, 10 to 25 with it (asserted below 256).

Background

  • Bun's Blob is a native object. File and S3File are the same native Blob with extra flags, exposed through different JS wrappers; the generated Blob constructor is one wrapper site, new File() and S3 files are the two hand-written ones.
  • reportExtraMemoryAllocated tells JSC that a cell owns native memory the GC cannot see. That number feeds the allocation budget that decides when to collect; the size a cell reports during marking only matters once a collection is already running.
  • The Blob's estimated size is a cached field rather than computed on demand because the GC reads it from the marking thread, so whoever creates the wrapper has to fill it first.
  • estimateShallowMemoryUsageOf from bun:jsc returns what a cell reports, so it probes this cache directly without waiting for a collection.

no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/blob.test.ts

Original description

Repro

import { estimateShallowMemoryUsageOf } from "bun:jsc";
const big = Buffer.alloc(1024 * 1024, "x");
console.log(estimateShallowMemoryUsageOf(new Blob([big])));          // 1048896
console.log(estimateShallowMemoryUsageOf(new File([big], "a.bin")));  // 48 (the bare cell)

Reproduces on bun 1.4.0 and on main. The visible consequence is a loop that constructs large Files and drops them: 256 x 1 MiB new File(...) in a fresh process leaves all 256 Blob cells alive and the process at ~286 MiB RSS, because JSC never learns the Files own anything. The same loop with new Blob(...) leaves about 9 cells alive at ~50 MiB.

Cause

Blob.reported_estimated_size is a cache. Blob__estimatedSize (what JSBlob::estimatedSize, JSBlob::visitChildren and JSBlob::memoryCost read) only returns it, since it is also called from the GC marking thread, so whoever hands a Blob to a JS wrapper has to fill it first, and the code creating the wrapper has to tell JSC about it with reportExtraMemoryAllocated (that is what feeds the allocation budget that triggers collections; mark-time reportExtraMemoryVisited only matters once a collection is already running).

The generated Blob__create / JSBlobConstructor::construct do both. The two hand-written wrapper sites did not:

  • new File(): jsdom_file_construct (src/runtime/webcore/Blob.rs) never called calculate_estimated_byte_size(), and JSDOMFile::construct (src/jsc/bindings/JSDOMFile.cpp) wrapped the pointer with a bare JSBlob::create. A File built from bytes therefore reported 0 for its whole life; a File built over an existing blob (new File([blob], name), which shares the store through dupe()) reported whatever the source had cached, so it never included its own name and was 0 again whenever the source was itself a File. Either way nothing was reported at allocation.
  • S3 files: BlobExt::to_js does compute the size, but constructS3FileInternal (src/jsc/bindings/JSS3File.cpp) also wrapped the pointer without reporting it, so S3 blobs only counted at mark time. Smaller effect (an S3 blob owns its credentials copy rather than a payload), same hole; the comment in S3Client.rs about to_js giving JSC "the correct GC pressure" describes what this makes true.

Fix

  • jsdom_file_construct: calculate_estimated_byte_size() once the name and content type are final, right before Blob::new, mirroring the Blob constructor. is_jsdom_file is set afterwards and does not take part in the size.
  • JSDOMFile::construct and constructS3FileInternal: reportExtraMemoryAllocated(instance, JSBlob::memoryCost(ptr)) after creating the wrapper, as the generated constructor does. Both the plain and the subclass (newTarget) branches of the File constructor end there.

This only changes what the wrappers report to the GC; nothing about what they store. The cached value is otherwise read only by the GC hooks and by DOMFormData::memoryCost (Body::estimated_size ignores blobs), so the blast radius is GC accounting, heap snapshots and estimateShallowMemoryUsageOf.

Related: #32430 and #30328 contain the File half of this as a side effect of giving File its own prototype. This is the accounting fix on its own, so it can land regardless of which of those does.

Out of scope (same symptom, different paths, being handled separately): Blobs that reach JS through the C++ WebCore::Blob wrapper (multipart formData() entries, WebSocket binaryType = "blob" messages) report 0 because Blob__dupe/Blob__fromBytes never compute the size; new File([blob], name) writes the name into the store it shares with blob; and a file-backed blob does not count its path (PathLike::estimated_size is 0 for string-backed paths), which is why the tests below only assert the name for that case.

Verification

Added to test/js/web/fetch/blob.test.ts, next to the File constructor tests (the size-reporting group in heap-snapshot.test.ts would have been the other candidate, but two of its existing tests run past the default per-test timeout on a debug+ASAN build, which is being looked at separately):

  • new File() reports its size to the GC like new Blob() does: a File reports at least what the equivalent Blob does plus its name, for a plain payload, a payload with type, a name-only File, a File wrapping an existing File (shared store), a File over a Bun.file() store and over an S3 store (the constructor's other arm, where the name is kept on the File rather than in the store), a File subclass, and a File appended to FormData (FormData size and formData.get() both see it). Without the fix these return 48, or the source's size (320 / 438) for the wrapped cases, against expectations of 64 KiB or more.
  • reports its size to the GC when constructed, once for new File(...) and once for client.file(...): a child process constructs 512 unreferenced instances that each own ~64 KB and prints how many instances heapStats() still counts. Bun sets JSC's minimum per-cycle allocation budget to 8 MiB, which is about what a fresh process gets, so that is worth several collections if and only if the wrapper reports the size. The child takes a heapStats() baseline before the loop, because heapStats() itself runs a full collection when no GC has happened yet (the case under BUN_GC_TIMER_DISABLE=1, which bunEnv inherits; the first version of this test passed without the fix in that configuration), and checks that constructing one retained instance moves the counter by exactly one, so the assertion cannot pass by counting the wrong thing. Without the fix exactly 512 survive, with or without BUN_GC_TIMER_DISABLE=1; with it 10 to 25 do (release, debug+ASAN, and a 16-way oversubscribed debug+ASAN run). The assertion is < 256.

All three fail with USE_SYSTEM_BUN=1 bun test and pass with bun bd test, in both GC timer configurations; the whole of blob.test.ts passes on the debug+ASAN build with them in it. blob-cow.test.ts, blob-file-name-ownership.test.ts, structured-clone-blob-file.test.ts, FormData.test.ts and the S3 tests that run without network access still pass on the debug build.