Report BuildArtifact bytes to the GC and size FileRoute blobs in the constructor

RO
3mo ago
+160-67

Problem

  • A Bun.build() artifact tells the GC nothing about the bytes it holds: one wrapping 64 KiB reports 232 bytes to estimateShallowMemoryUsageOf, and a loop that builds and drops results never triggers a collection (32 x 1 MiB builds, all 32 artifacts still alive afterwards).
  • Bun.serve file routes cost different amounts depending on how they were made: an HTML manifest route reports 272 bytes less than a Bun.file() route for the same file, and a route made from an outdir artifact (directly or via new Response(artifact)) reports nothing for its blob.
  • Cause: a blob's size estimate is a cache that only gets filled when the blob is handed to JS or constructed from JS. Artifacts and routes store blobs that took neither path, and the artifact class did not report a size to the GC at all.

Fix

  • BuildArtifact now reports a size (struct + output path + the blob's cached estimate), and every artifact kind (in-memory, saved, copied) has its blob sized once, before the JS wrapper exists.
  • FileRoute sizes the blob in its one constructor, and the two JS-side entry points (a Response, a Blob) now go through that constructor instead of building the struct by hand, so a route's cost no longer depends on where its blob came from.
  • Both readers stop double counting: the blob lives inline in each struct and the blob estimate already includes size_of::<Blob>(), so that term is subtracted (104 bytes per route in release before this).
  • Why sizing at construction is right: JSC reads these numbers during marking, so they must be a cheap read of a stored value, and neither an artifact nor a route replaces its blob after construction, so the value computed then stays correct.
  • Verification: four new tests (artifact size deltas, extraMemorySize covering a 512 KiB artifact after Bun.gc(true), route costs no longer depending on the blob's source) fail on a debug build of main and pass with the fix. The double-count correction is a constant per route and is not covered by them.

Background

  • JSC only sees the JS cell of a native object. Bytes held outside it count only if the object reports them as extra memory; that report drives collections and is what estimateShallowMemoryUsageOf and heapStats().extraMemorySize show.
  • Bun's generated classes opt in with estimatedSize: true in the .classes.ts file, which makes the wrapper call the type's estimated_size() from the collector, so that function must only read cached values.
  • Blob caches its own estimate in reported_estimated_size. calculate_estimated_byte_size() fills it, dupe() copies whatever is there, and normally only handing the blob to JS fills it, so a blob that stays native reports 0 unless its owner asks.
  • BuildArtifact is an entry of Bun.build().outputs; it embeds a Blob inline (the bytes for in-memory builds, a file reference for outdir builds) plus its output path.
  • FileRoute is the static route Bun.serve builds from a Bun.file(), a Response, an artifact, or an HTML import manifest entry; it also embeds a Blob inline, and estimateShallowMemoryUsageOf(server) includes its routes.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-api.test.ts test/js/bun/http/bun-serve-file.test.ts

Original description

Repro

import { estimateShallowMemoryUsageOf, heapStats } from "bun:jsc";

const { outputs: [artifact] } = await Bun.build({ entrypoints: ["./entry.js"] }); // entry.js holds a 64 KiB string
console.log(artifact.size, estimateShallowMemoryUsageOf(artifact)); // 65581 232
Bun.gc(true);
console.log(heapStats().extraMemorySize); // ~22 KB: the GC never hears about the 64 KiB

On bun 1.4.0 the wrapper reports the bare struct whatever the artifact holds: an outdir artifact reports the same 232 bytes whether its path is 28 or 148 bytes long, and a process that runs Bun.build() in a loop and drops the results never triggers a collection on their account (32 x 1 MiB in-memory builds: all 32 artifacts still alive afterwards, extraMemorySize 11 KB).

The same cache feeds Bun.serve: a Bun.file() route and a bundled HTML manifest entry for the same file build the same FileRoute, but estimateShallowMemoryUsageOf(server) comes out 272 bytes lower for the manifest one (size_of::<Blob>() + size_of::<Store>()), and a route made from an outdir BuildArtifact (directly or via new Response(artifact)) reports nothing for its blob at all.

Cause

Blob.reported_estimated_size is a cache that only Blob::to_js and the Blob constructor fill (calculate_estimated_byte_size); dupe() copies whatever the source has. Two readers were fed blobs that never went through either:

  • BuildArtifact (src/runtime/api/JSBundler.classes.ts) had no estimatedSize, so the generated JSBuildArtifact::memoryCost was size_of::<BuildArtifact>() and the wrapper never called reportExtraMemoryAllocated / reportExtraMemoryVisited. The inline blob output_file_jsc.rs builds for it was never sized either.
  • FileRoute::memory_cost() (src/runtime/server/FileRoute.rs) adds blob.reported_estimated_size, but init_from_blob (the manifest path in server_body.rs::from_options) stored the blob as built, and the two struct literals in from_js stored a dupe(), so the route only reported a number when whoever created the source blob happened to have computed one.

Fix

  • BuildArtifact: estimatedSize: true plus an inherent estimated_size() that returns the struct, the output path and the blob's cached estimate (the blob estimate already contains size_of::<Blob>(), so that part of the struct is subtracted rather than counted twice). output_file_jsc.rs::to_js now builds the blob and path per arm and has one tail that calls calculate_estimated_byte_size() before the wrapper is created, so all three artifact kinds (buffer, saved, copy) are sized and the size exists by the time BuildArtifact__create reports it. estimated_size only reads the cache, like Blob/Request/Response, because the generated visitChildren calls it from the collector.
  • FileRoute::init_from_blob computes the estimate of the blob it takes ownership of, and both from_js branches now go through it instead of duplicating the struct literal (they passed the same headers/status through InitOptions anyway; the Blob branch's hardcoded has_*: false is what headers_from(None, ..) produces). A FileRoute's cost no longer depends on where its blob came from. FileRoute::memory_cost also gets the same size_of::<Blob>() correction as BuildArtifact: its blob is inline too, so size_of::<FileRoute>() plus the blob estimate counted the Blob struct twice (104 bytes per route in release); with every route now carrying an estimate, that applied to all of them.

Why computing once at construction is the right shape: JSC reads these numbers during marking, so they have to be stable and cheap to read, which is why the cache exists; the owner that stores a blob for reporting is the one that knows the blob is final, so it fills the cache, same as Blob::to_js does for JS-visible blobs. Neither an artifact nor a route changes its blob afterwards.

Verification

test/bundler/bun-build-api.test.ts:

  • in-memory artifact: two builds with equal-length names, one holding a 64 KiB literal; the artifacts' estimates differ by exactly large.size - small.size (65537), i.e. the output bytes are counted once (unfixed: both artifacts report the bare struct, delta 0)
  • outdir artifact: two builds of the same module under names differing by 101 bytes report exactly 202 bytes apart, the path once as .path and once in the file store (unfixed: 0)
  • child process builds one 512 KiB artifact, Bun.gc(true), then artifact.size <= heapStats().extraMemorySize < 2 * artifact.size (unfixed: ~22-27 KB; fixed: 551715, the artifact plus the process's few tens of KiB). This is the mark-time report; it is independent of GC timing because the artifact is held alive across the forced collection, and the upper bound pins it to being reported once.

test/js/bun/http/bun-serve-file.test.ts: one server reloaded with each route in turn, so only the route differs between readings. Asserted as one object: the manifest route costs exactly the same as the Bun.file() route built from the same path string (unfixed: 272 release / 696 debug less), and artifact routes, direct and as new Response(artifact), report exactly the 101 extra path bytes (unfixed: 0). These are exact because every other term (server, route key, headers, struct sizes, JS cell) is identical between the two readings; the values are stable across repeated debug and release runs. The size_of::<Blob>() correction in memory_cost is a constant per route and therefore not observable from these differences; it is visible as a Bun.file() route costing 376 bytes less in a debug build than before the correction.

All four fail on a debug build of main with the test files applied and pass with the fix. Also run on the fixed debug build: the whole of both files, bun-serve-static, bun-serve-routes, bun-serve-html-manifest, html-import-manifest, blob.test.ts; cargo clippy -p bun_runtime is clean.