Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/javascriptcore-garbage-collector/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ visitor.reportExtraMemoryVisited(thisObject->wrapped().byteSize());
- If the size changes over time, report the delta on growth (`reportExtraMemoryAllocated(cell, newSize - oldSize)`) and report the current size in `visitChildren`.
- `deprecatedReportExtraMemory` exists for callers that can't satisfy the visit-side half — avoid it.

In `.classes.ts`, `estimatedSize: true` generates the `reportExtraMemoryVisited` side; you implement `estimated_size()` in Rust. You still call `reportExtraMemoryAllocated` (or the binding's helper) at allocation time.
In `.classes.ts`, `estimatedSize: true` generates both halves from one Rust method: the generated constructor and `${T}__create` pass `estimated_size()` to `reportExtraMemoryAllocated`, and `visitChildren` re-reports it as visited. Wrappers created by hand-written C++ (e.g. `JSDOMFile`) must do the allocation half themselves. If the object may merely take a reference to memory another wrapper already reported (a `Blob` sharing its store with the blob it was sliced from), add `newlyAllocatedSize: true` and implement `newly_allocated_size()`: the creation sites report that instead, while visits keep re-reporting `estimated_size()`. Otherwise every view reports the whole payload as a fresh allocation and JSC collects after every few views.

## `HeapAnalyzer` — heap snapshots and labelling

Expand Down
7 changes: 7 additions & 0 deletions bench/snippets/blob.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ bench("blob.slice()", function () {
return small.slice();
});

// slice() shares the source's bytes, so this only differs from the small case
// by what each new Blob tells the GC.
var large = new Blob([new Uint8Array(8 * 1024 * 1024)]);
bench("blob.slice() (8 MiB blob)", function () {
return large.slice();
});

if ((await small.text()) !== JSON.stringify("hello world ")) {
throw new Error("blob.text() failed");
}
Expand Down
19 changes: 19 additions & 0 deletions src/codegen/class-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,27 @@ export class ClassDefinition {
* ```
*
* Report `size_of::<Self>()` as well as any external allocations.
*
* The generated constructor and `${name}__create` also pass this value to
* `Heap::reportExtraMemoryAllocated` when the wrapper is created, and
* `visitChildren` re-reports it to `reportExtraMemoryVisited` on every GC.
* See `newlyAllocatedSize` when those two numbers differ.
*/
estimatedSize?: boolean;
/**
* Requires `estimatedSize`. When the native object may merely take a
* reference to memory that another wrapper already reported (a `Blob`
* sharing its store with the blob it was sliced from), `estimated_size()`
* is still what each GC visit re-reports as retained, but reporting it as
* freshly allocated for every new wrapper would schedule a collection per
* wrapper. With this set, wrapper creation reports this method instead:
* ```rust
* pub fn newly_allocated_size(&self) -> usize;
* ```
* Called once per wrapper, on the JS thread, right after the wrapper is
* created.
*/
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
newlyAllocatedSize?: boolean;
/**
* Used in heap snapshots.
*
Expand Down
74 changes: 32 additions & 42 deletions src/codegen/generate-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ function DOMJITName(fnName) {
return `${fnName}WithoutTypeChecks`;
}

// Emitted right after a wrapper (`instance`) adopts `ptr`. `visitChildren`
// always re-reports `estimatedSize`; see ClassDefinition.newlyAllocatedSize for
// why the allocation-time number can differ.
function reportExtraMemoryAllocated(typeName: string, obj: ClassDefinition) {
if (!obj.estimatedSize) return "";
const sizeFn = symbolName(typeName, obj.newlyAllocatedSize ? "newlyAllocatedSize" : "estimatedSize");
return `
auto size = ${sizeFn}(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);`;
}

function argTypeName(arg) {
return {
["bool"]: "bool",
Expand Down Expand Up @@ -652,13 +663,7 @@ ${
${
obj.call
? ` RETURN_IF_EXCEPTION(scope, {});
${
obj.estimatedSize
? `
auto size = ${symbolName(typeName, "estimatedSize")}(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);`
: ""
}
${reportExtraMemoryAllocated(typeName, obj)}

RELEASE_AND_RETURN(scope, JSValue::encode(instance));`
: ""
Expand Down Expand Up @@ -708,13 +713,7 @@ JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES ${name}::construct(JSC::JSGlobalObj
instance->m_ctx = ptr;
`) +
`
${
obj.estimatedSize
? `
auto size = ${symbolName(typeName, "estimatedSize")}(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);`
: ""
}
${reportExtraMemoryAllocated(typeName, obj)}

auto value = JSValue::encode(instance);
RELEASE_AND_RETURN(scope, value);
Expand Down Expand Up @@ -1294,6 +1293,13 @@ function generateClassHeader(typeName, obj: ClassDefinition) {

if (obj.estimatedSize) {
externs += `extern JSC_CALLCONV size_t ${symbolName(typeName, "estimatedSize")}(void* ptr);` + "\n";
if (obj.newlyAllocatedSize) {
externs += `extern JSC_CALLCONV size_t ${symbolName(typeName, "newlyAllocatedSize")}(void* ptr);` + "\n";
}
} else if (obj.newlyAllocatedSize) {
throw new Error(
`${typeName}: 'newlyAllocatedSize' only replaces the allocation-time half of 'estimatedSize'; set 'estimatedSize: true' as well.`,
);
}

for (const a of [...Object.values(klass), ...Object.values(proto)]) {
Expand Down Expand Up @@ -1809,13 +1815,7 @@ extern JSC_CALLCONV JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES ${typeName}__cr
auto &vm = globalObject->vm();
JSC::Structure* structure = globalObject->${className(typeName)}Structure();
${className(typeName)}* instance = ${className(typeName)}::create(vm, globalObject, structure, ptr);
${
obj.estimatedSize
? `
auto size = ${symbolName(typeName, "estimatedSize")}(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);`
: ""
}
${reportExtraMemoryAllocated(typeName, obj)}
return JSValue::encode(instance);
}

Expand All @@ -1830,13 +1830,7 @@ ${
jsvalueArray[i].setWithoutWriteBarrier(args->at(i));
}
${className(typeName)}* instance = ${className(typeName)}::create(vm, globalObject, structure, ptr, WTF::move(jsvalueArray));
${
obj.estimatedSize
? `
auto size = ${symbolName(typeName, "estimatedSize")}(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);`
: ""
}
${reportExtraMemoryAllocated(typeName, obj)}
return JSValue::encode(instance);
}`
: ""
Expand All @@ -1848,13 +1842,7 @@ ${
auto &vm = globalObject->vm();
JSC::Structure* structure = globalObject->${className(typeName)}Structure();
${className(typeName)}* instance = ${className(typeName)}::create(vm, globalObject, structure, ptr${obj.values.map(v => `, JSC::JSValue::decode(${v})`).join("")});
${
obj.estimatedSize
? `
auto size = ${symbolName(typeName, "estimatedSize")}(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);`
: ""
}
${reportExtraMemoryAllocated(typeName, obj)}
return JSValue::encode(instance);
}`
: ""
Expand All @@ -1871,13 +1859,7 @@ ${
jsvalueArray[i].setWithoutWriteBarrier(args->at(i));
}
${className(typeName)}* instance = ${className(typeName)}::create(vm, globalObject, structure, ptr, WTF::move(jsvalueArray)${obj.values.map(v => `, JSC::JSValue::decode(${v})`).join("")});
${
obj.estimatedSize
? `
auto size = ${symbolName(typeName, "estimatedSize")}(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);`
: ""
}
${reportExtraMemoryAllocated(typeName, obj)}
return JSValue::encode(instance);
}`
: ""
Expand Down Expand Up @@ -2184,6 +2166,7 @@ function generateRust(
noConstructor = false,
overridesToJS = false,
estimatedSize,
newlyAllocatedSize = false,
call = false,
memoryCost,
values = [],
Expand Down Expand Up @@ -2248,6 +2231,13 @@ function generateRust(
}
if (estimatedSize) {
thunk(symbolName(typeName, "estimatedSize"), `(this: &${T}) -> usize`, ` ${T}::estimated_size(this)`);
if (newlyAllocatedSize) {
thunk(
symbolName(typeName, "newlyAllocatedSize"),
`(this: &${T}) -> usize`,
` ${T}::newly_allocated_size(this)`,
);
}
}
if (!memoryCost && !estimatedSize) {
symbols.push(symbolName(typeName, "ZigStructSize"));
Expand Down
64 changes: 40 additions & 24 deletions src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ pub trait BlobExt {
Self: Sized;
fn calculate_estimated_byte_size(&self);
fn estimated_size(&self) -> usize;
fn newly_allocated_size(&self) -> usize;
fn to_js(&self, global_object: &JSGlobalObject) -> JSValue;
fn find_or_create_file_from_path(
path_or_fd: &mut PathOrFileDescriptor,
Expand Down Expand Up @@ -3521,36 +3522,24 @@ impl BlobExt for Blob {
// is_detached: defined once above; duplicate removed to fix E0034.

fn calculate_estimated_byte_size(&self) {
// in-memory size. not the size on disk.
let mut size: usize = core::mem::size_of::<Blob>();

if let Some(store) = self.store.get() {
size += core::mem::size_of::<Store>();
match &store.data {
store::Data::Bytes(bytes) => {
size += bytes.stored_name.len();
size += if self.size.get() != MAX_SIZE {
self.size.get() as usize
} else {
bytes.len() as usize
};
}
store::Data::File(file) => size += file.pathlike.estimated_size(),
store::Data::S3(s3) => size += s3.estimated_size(),
}
}

let ct = self.content_type.get();
self.reported_estimated_size.set(
size + (ct.as_slice().len() * (ct.is_owned() as usize))
+ self.name.get().byte_slice().len(),
);
self.reported_estimated_size
.set(estimate_in_memory_size(self, true));
}

fn estimated_size(&self) -> usize {
self.reported_estimated_size.get()
}

/// `Blob__newlyAllocatedSize`: what the JS wrapper being created for this
/// Blob reports as freshly allocated. A store that other Blobs also hold
/// (`slice()`, `dupe()`, `new Blob([blob])`, a FormData entry) is already
/// accounted for through them; reporting it again for every view made JSC
/// schedule a collection every few views of a large blob. Each view still
/// re-reports its window as retained memory on every GC via `estimated_size`.
Comment thread
robobun marked this conversation as resolved.
Outdated
fn newly_allocated_size(&self) -> usize {
estimate_in_memory_size(self, self.store().is_none_or(|store| store.has_one_ref()))
}

fn to_js(&self, global_object: &JSGlobalObject) -> JSValue {
// if cfg!(debug_assertions) { debug_assert!(self.is_heap_allocated()); }
self.calculate_estimated_byte_size();
Expand Down Expand Up @@ -3700,6 +3689,33 @@ impl BlobExt for Blob {
}
}

/// In-memory footprint, not the size on disk. `include_store` adds the
/// refcounted `Store` and what it owns (for bytes, this blob's window of them).
Comment thread
robobun marked this conversation as resolved.
Outdated
fn estimate_in_memory_size(blob: &Blob, include_store: bool) -> usize {
let mut size: usize = core::mem::size_of::<Blob>();

if include_store {
if let Some(store) = blob.store() {
size += core::mem::size_of::<Store>();
match &store.data {
store::Data::Bytes(bytes) => {
size += bytes.stored_name.len();
size += if blob.size.get() != MAX_SIZE {
blob.size.get() as usize
} else {
bytes.len() as usize
};
}
store::Data::File(file) => size += file.pathlike.estimated_size(),
store::Data::S3(s3) => size += s3.estimated_size(),
}
}
}

let ct = blob.content_type.get();
size + (ct.as_slice().len() * (ct.is_owned() as usize)) + blob.name.get().byte_slice().len()
}

// ──────────────────────────────────────────────────────────────────────────
// Basic accessors
// ──────────────────────────────────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/webcore/response.classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ export default [
storable: true,
},
estimatedSize: true,
// slice() / dupe() share the source's store; see Blob::newly_allocated_size.
newlyAllocatedSize: true,
values: ["stream"],
overridesToJS: true,
proto: {
Expand Down
40 changes: 40 additions & 0 deletions test/js/web/fetch/blob.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { heapStats } from "bun:jsc";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isASAN, tempDir } from "harness";
import type { BlobOptions } from "node:buffer";
Expand Down Expand Up @@ -600,6 +601,45 @@ describe("slice bounds are respected when streaming and serving", () => {
});
});

describe("a Blob sharing another Blob's bytes does not report them to the GC as newly allocated", () => {
const PAYLOAD = 1024 * 1024;
const VIEWS = 256;
const source = new Blob([new Uint8Array(PAYLOAD)]);
const bytes = new Uint8Array(PAYLOAD);
const formData = new FormData();
formData.append("f", source, "f.bin");

// JSC schedules a collection once the memory new wrappers report as allocated
// exceeds its allowance, which is at least 8 MiB right after a full GC. VIEWS
// wrappers that each report only their own struct stay far below that, so all
// of them are still alive afterwards. Wrappers that each report the whole
// PAYLOAD exceed it every few iterations, and only the last few survive.
async function blobsSurviving(make: () => unknown): Promise<number> {
Bun.gc(true);
const before = heapStats().objectTypeCounts.Blob ?? 0;
for (let i = 0; i < VIEWS; i++) await make();
return (heapStats().objectTypeCounts.Blob ?? 0) - before;
}

test.each([
["blob.slice()", () => source.slice()],
["new Blob([blob])", () => new Blob([source])],
["formData.get()", () => formData.get("f")],
["new Response(blob).blob()", () => new Response(source).blob()],
// Wrapped by the hand-written constructor in JSDOMFile.cpp, not the generated one.
["new File([blob], name)", () => new File([source], "f.bin")],
])("%s", async (_, make) => {
expect(await blobsSurviving(make)).toBe(VIEWS);
});

test.each([
["new Blob([bytes])", () => new Blob([bytes])],
["new Response(bytes).blob()", () => new Response(bytes).blob()],
])("%s copies the bytes and still reports them", async (_, make) => {
expect(await blobsSurviving(make)).toBeLessThan(VIEWS);
});
});

// Wrapping a Blob whose type is heap-owned (not in the mime table) with a
// known mime type overwrote content_type with a static pointer without
// clearing content_type_allocated, so GC sweep freed a static pointer.
Expand Down
Loading