Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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` call the generated `JS${T}::reportExtraMemoryAllocated(vm)` once the wrapper exists, and `visitChildren` re-reports `estimated_size()` as visited. Hand-written C++ that creates one of these wrappers itself (`JSBunRequest`, `JSBakeResponse`, the shell interpreter; `JSDOMFile` still needs it) calls the same member rather than reporting a size by hand. 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 member then reports 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
22 changes: 22 additions & 0 deletions src/codegen/class-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,30 @@ export class ClassDefinition {
* ```
*
* Report `size_of::<Self>()` as well as any external allocations.
*
* Also generates `JS${name}::reportExtraMemoryAllocated(vm)`, which every
* site creating a wrapper (the generated constructor and `${name}__create`,
* or hand-written C++) calls once to pass this value to
* `Heap::reportExtraMemoryAllocated`; `visitChildren` re-reports it to
* `reportExtraMemoryVisited` on every GC. See `newlyAllocatedSize` when
* those two numbers differ.
Comment thread
robobun marked this conversation as resolved.
Outdated
*/
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, `JS${name}::reportExtraMemoryAllocated(vm)`
* 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
100 changes: 57 additions & 43 deletions src/codegen/generate-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ function DOMJITName(fnName) {
return `${fnName}WithoutTypeChecks`;
}

// Emitted at every generated site that creates a wrapper (`instance`), once its
// m_ctx is set. The member it calls is generated below and is also what the
// hand-written creation sites (JSBunRequest, JSBakeResponse, ShellBindings)
// call, so the class definition alone decides what gets reported.
Comment thread
robobun marked this conversation as resolved.
Outdated
function reportExtraMemoryAllocated(obj: ClassDefinition) {
return obj.estimatedSize ? `instance->reportExtraMemoryAllocated(vm);` : "";
}

// The Rust symbol `JS${typeName}::reportExtraMemoryAllocated` hands to the heap.
// `visitChildren` always re-reports `estimatedSize`; see
// ClassDefinition.newlyAllocatedSize for why the creation-time number can differ.
Comment thread
robobun marked this conversation as resolved.
Outdated
function allocationSizeSymbol(typeName: string, obj: ClassDefinition) {
return symbolName(typeName, obj.newlyAllocatedSize ? "newlyAllocatedSize" : "estimatedSize");
}

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

RELEASE_AND_RETURN(scope, JSValue::encode(instance));`
: ""
Expand Down Expand Up @@ -708,13 +717,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(obj)}

auto value = JSValue::encode(instance);
RELEASE_AND_RETURN(scope, value);
Expand Down Expand Up @@ -1294,6 +1297,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 @@ -1415,7 +1425,19 @@ function generateClassHeader(typeName, obj: ClassDefinition) {
* Memory cost of the object from Zig, without necessarily having a JS wrapper alive.
*/
static size_t memoryCost(void* ptr);

${
obj.estimatedSize
? `
/**
* Tells the GC what creating this wrapper allocated. Every site that creates
* one, generated or hand-written, calls this once after m_ctx is set; which
* number it reports is decided by the class definition (estimatedSize /
* newlyAllocatedSize). visitChildren re-reports estimatedSize on every GC.
*/
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
void reportExtraMemoryAllocated(JSC::VM& vm);
`
: ""
}
void* m_ctx { nullptr };

${name}(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr${obj.valuesArray ? ", WTF::FixedVector<JSC::WriteBarrier<JSC::Unknown>>&& jsvalueArray_" : ""})
Expand Down Expand Up @@ -1657,6 +1679,14 @@ size_t ${name}::memoryCost(void* ptr) {
`;
}

if (obj.estimatedSize) {
output += `
void ${name}::reportExtraMemoryAllocated(JSC::VM& vm) {
vm.heap.reportExtraMemoryAllocated(this, ${allocationSizeSymbol(typeName, obj)}(m_ctx));
}
`;
}

output += `

size_t ${name}::estimatedSize(JSC::JSCell* cell, JSC::VM& vm) {
Expand Down Expand Up @@ -1809,13 +1839,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(obj)}
return JSValue::encode(instance);
}

Expand All @@ -1830,13 +1854,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(obj)}
return JSValue::encode(instance);
}`
: ""
Expand All @@ -1848,13 +1866,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(obj)}
return JSValue::encode(instance);
}`
: ""
Expand All @@ -1871,13 +1883,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(obj)}
return JSValue::encode(instance);
}`
: ""
Expand Down Expand Up @@ -2184,6 +2190,7 @@ function generateRust(
noConstructor = false,
overridesToJS = false,
estimatedSize,
newlyAllocatedSize = false,
call = false,
memoryCost,
values = [],
Expand Down Expand Up @@ -2248,6 +2255,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
7 changes: 2 additions & 5 deletions src/jsc/bindings/JSBakeResponse.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ extern "C" SYSV_ABI JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES ResponseClass__
extern "C" SYSV_ABI JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES ResponseClass__constructJSON(JSC::JSGlobalObject*, JSC::CallFrame*);
extern "C" SYSV_ABI JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES BakeResponseClass__constructRender(JSC::JSGlobalObject*, JSC::CallFrame*);
extern "C" SYSV_ABI JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES BakeResponseClass__constructRedirect(JSC::JSGlobalObject*, JSC::CallFrame*);
extern JSC_CALLCONV size_t Response__estimatedSize(void* ptr);

bool isJSXElement(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject)
{
Expand Down Expand Up @@ -224,8 +223,7 @@ class JSBakeResponseConstructor final : public JSC::InternalFunction {
instance->wrapInnerComponent(globalObject, vm, arg, responseOptions);
}

auto size = Response__estimatedSize(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);
instance->reportExtraMemoryAllocated(vm);

auto value = JSValue::encode(instance);
RELEASE_AND_RETURN(scope, value);
Expand All @@ -250,8 +248,7 @@ class JSBakeResponseConstructor final : public JSC::InternalFunction {

RETURN_IF_EXCEPTION(scope, {});

auto size = Response__estimatedSize(ptr);
vm.heap.reportExtraMemoryAllocated(instance, size);
instance->reportExtraMemoryAllocated(vm);

RELEASE_AND_RETURN(scope, JSValue::encode(instance));
}
Expand Down
6 changes: 1 addition & 5 deletions src/jsc/bindings/JSBunRequest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,10 @@ JSBunRequest::JSBunRequest(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr
, m_cookies(nullptr, JSC::WriteBarrierEarlyInit)
{
}
extern SYSV_ABI "C" size_t Request__estimatedSize(void* requestPtr);
extern "C" void Bun__JSRequest__calculateEstimatedByteSize(void* requestPtr);
void JSBunRequest::finishCreation(JSC::VM& vm)
{
Base::finishCreation(vm);

auto size = Request__estimatedSize(this->wrapped());
vm.heap.reportExtraMemoryAllocated(this, size);
reportExtraMemoryAllocated(vm);
}

template<typename Visitor>
Expand Down
6 changes: 1 addition & 5 deletions src/jsc/bindings/ShellBindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

#include "ZigGeneratedClasses.h"

extern "C" SYSV_ABI size_t ShellInterpreter__estimatedSize(void* ptr);

namespace Bun {

using namespace JSC;
Expand All @@ -23,9 +21,7 @@ extern "C" SYSV_ABI EncodedJSValue Bun__createShellInterpreter(Zig::GlobalObject
ASSERT(structure);

auto* result = WebCore::JSShellInterpreter::create(vm, globalObject, structure, ptr, WTF::move(args), resolveFn, rejectFn);

size_t size = ShellInterpreter__estimatedSize(ptr);
vm.heap.reportExtraMemoryAllocated(result, size);
result->reportExtraMemoryAllocated(vm);
return JSValue::encode(result);
}

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
Loading