SKILL.md
5,913 tokens · o200k_base · 25,704 bytes
Source excerpt starting at line 1.---name: javascriptcore-garbage-collectordescription: JSC GC reference for Bun. Use for use-after-free, JS object leaks, "collected too early", or when touching WriteBarrier, visitChildren, visitAdditionalChildren, JSRef, JSC::Strong/Weak, hasPendingActivity, ensureStillAlive, addOpaqueRoot, reportExtraMemoryAllocated, IsoSubspace, HeapAnalyzer, finalize.--- # JavaScriptCore's Garbage Collector (Riptide) Riptide is **non-moving, generational, parallel, mostly-concurrent, conservative-on-the-stack**. Understanding those five words prevents most GC bugs in Bun. ## The mental model The heap is a graph. GC does a breadth-first search from **roots** → marks everything it reaches → everything unmarked is freed (lazily, on next allocation from that block). It does NOT compact or move objects — pointers stay stable for an object's lifetime. **Two collection modes:** - **Eden GC**: only scans newly-allocated objects + remembered set. Fast, frequent.- **Full GC**: scans everything. Slower, rarer. **It runs concurrently.** Marking happens on background threads _while JS is executing_; the mutator only stops at brief safepoints. `visitChildren` runs **off the main thread, racing with your code**. ## How the VM gathers roots Roots are not a hardcoded list — they are **marking constraints** registered with `Heap::addMarkingConstraint()` and run to fixpoint. The built-in set lives in `Heap::addCoreConstraints()` (`vendor/WebKit/Source/JavaScriptCore/heap/Heap.cpp:2970`): | Tag | Name | What it marks || ----- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- || `Cs` | Conservative Scan | Native stack + registers of every JS thread, scanned word-by-word (`gatherStackRoots` → `ConservativeRoots`). Also JIT stub routines. World is stopped for this. || `Msr` | Misc Small Roots | `vm.smallStrings`, `m_protectedValues` (`JSValueProtect`/`gcProtect`), `MarkedArgumentBuffer` lists, `vm.exception()` / `lastException()` / `m_terminationException` || `Sh` | Strong Handles | `m_handleSet.visitStrongHandles()` — every `JSC::Strong<T>`. Also `vm().visitAggregate()` (atom string tables etc.) || `D` | Debugger | Sampling profiler, type profiler, ShadowChicken || `Ws` | Weak Sets | Iterates every `WeakBlock`; calls `WeakHandleOwner::isReachableFromOpaqueRoots()` to decide whether a weak ref should _become_ strong this cycle || `O` | Output | Calls `visitOutputConstraints()` on already-marked cells in output-constraint subspaces (executables, WeakMaps). This is the "re-run after marking discovers more" hook || `Jw` | JIT Worklist | CodeBlocks queued for compilation || `Cb` | CodeBlocks | Executing/compiling CodeBlocks | Bun registers an additional constraint, `DOMGCOutputConstraint` (`src/jsc/bindings/BunGCOutputConstraint.cpp`), which calls `visitOutputConstraints` on every marked cell in Bun's output-constraint subspaces (event targets, generated classes with `visitAdditionalChildren`, etc.). **Constraint volatility** controls when they re-run during the fixpoint: - `GreyedByExecution` — may produce new grey cells whenever the mutator runs (re-run after every resume)- `GreyedByMarking` — may produce new grey cells when _other_ marking happens (re-run after each drain)- `SeldomGreyed` — usually doesn't add anything; run last ## Object layout: the 8-byte JSCell header Every GC-managed object inherits `JSCell` (`runtime/JSCell.h`): ```| StructureID (4) | indexingTypeAndMisc (1) | JSType (1) | flags (1) | cellState (1) |``` - `StructureID` — compressed hidden-class pointer- `indexingTypeAndMisc` — 2 bits are an embedded `WTF::Lock` (the **cell lock**); always CAS this byte- `cellState` — inlined GC color, used by the write barrier Out-of-line, in the `MarkedBlock` footer (or `PreciseAllocation` header for objects >~8KB): - `isMarked` bit — survived last GC- `isNewlyAllocated` bit — allocated since last GC Liveness = `isMarked || isNewlyAllocated` (with logical-versioning so blocks aren't swept eagerly). ## CellState and the write barrier `vendor/WebKit/Source/JavaScriptCore/heap/CellState.h`: ```cppPossiblyBlack = 0 // visited (or old-space-pending-rescan during full GC)DefinitelyWhite = 1 // new / unmarkedPossiblyGrey = 2 // on the mark stack``` Generational + concurrent GC share **one** retreating-wavefront barrier: ```cpp// After: obj->field = newValueif (obj->cellState <= blackThreshold) // 0 normally, bumped while GC is marking writeBarrierSlowPath(obj); // → put obj on remembered set / revisit``` **You almost never write this by hand.** Use `WriteBarrier<T>` as the field type and call `.set(vm, owner, value)` — it stores then barriers. A raw `JSCell*` / `JSValue` member without a `WriteBarrier` wrapper is a bug: eden GC will free the target out from under you. `LazyProperty<Owner, T>`, `LazyClassStructure`, and `WriteBarrierStructureID` are barrier-aware variants for lazily-initialized fields and structures. ## Allocation: where objects live `bmalloc/libpas` provides pages; JSC carves them up: - **`MarkedBlock`** — 16KB block, fixed cell size (segregated free list). Footer holds bitvectors. 16-byte minimum cell alignment. `addr & ~(16KB-1)` → block, so liveness checks are O(1).- **`PreciseAllocation`** — large objects (>~8KB), individually `malloc`'d, 96-byte GC header. Always returns addresses with `addr % 16 == 8` so `ptr & 8` distinguishes them from MarkedBlock cells.- **`CompleteSubspace`** — size-segregated set of `BlockDirectory`s for general JS objects.- **`IsoSubspace`** — one subspace per C++ type (security: a freed cell can only be reused for the _same_ type, defeating type-confusion UAF). **Every Bun class with native fields needs its own IsoSubspace** — `subspaceFor<T>` in the header, slot in `BunClientData`/`DOMIsoSubspaces`. **Allocation may trigger GC.** A safepoint exists at every allocation. Never assume "I just allocated X, so Y from before is still alive" unless Y is rooted. ## Conservative stack scanning — what it does and doesn't guarantee `vendor/WebKit/Source/JavaScriptCore/heap/ConservativeRoots.cpp` walks the native stack/registers word-by-word (after `MachineThreads::tryCopyOtherThreadStacks` snapshots them). Any aligned word inside a live `MarkedBlock` cell or `PreciseAllocation` is a root. **This means:** a `JSCell*` / `JSValue` in a C++/Rust local variable _usually_ keeps the object alive — no `Handle`/`Local` ceremony like V8. **This does NOT mean you're always safe.** The compiler may dead-store-eliminate the local after its last visible use, or never spill it. If you extract an interior pointer (`string->characters8()`, butterfly storage, typed-array `vector()`) and then call something that can allocate, the original cell may no longer be on the stack: ```cppJSC::EnsureStillAliveScope keepAlive(cell); // RAII: forces cell onto stack until scope end// ... use interior pointer, call things that allocate ...``` or `ensureStillAliveHere(cell)`. In Rust: `value.ensure_still_alive()`. ## `visitChildren` — the per-cell tracing hook ```cpp// In header:DECLARE_VISIT_CHILDREN;WriteBarrier<JSObject> m_callback;WriteBarrier<Unknown> m_cachedValue; // In .cpp:template<typename Visitor>void JSFoo::visitChildrenImpl(JSCell* cell, Visitor& visitor) { auto* thisObject = jsCast<JSFoo*>(cell); ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); // ALWAYS call base first visitor.append(thisObject->m_callback); visitor.append(thisObject->m_cachedValue);}DEFINE_VISIT_CHILDREN(JSFoo);``` **Rules — runs concurrently on a GC thread:** - No allocation. No `toJS`, no `jsString`, nothing that touches `vm.heap`.- No `ref()`/`deref()` of `RefCounted` (not thread-safe).- No locks the main thread might also take while allocating (deadlock).- If a field can be torn by a racing mutator, take `Locker locker { thisObject->cellLock() }` in both `visitChildren` and the mutating site.- Forgetting to `append()` a `WriteBarrier` field → use-after-free, often eden-GC-only, often only under load. ## `visitAdditionalChildren` and output constraints `visitChildren` only sees the cell's own fields. When a JS wrapper's liveness should propagate to **other JS objects reachable through native state** (event listeners, observers, the JS values held inside a wrapped C++ object), Bun uses the WebCore pattern: ```cpp// Custom hook called from BOTH places:template<typename Visitor>void JSFoo::visitAdditionalChildren(Visitor& visitor) { wrapped().listeners().visitJSEventListeners(visitor); visitor.addOpaqueRoot(&wrapped());} // 1) From visitChildren (normal marking):DEFINE_VISIT_CHILDREN_WITH_MODIFIER(..., JSFoo) { ... thisObject->visitAdditionalChildren(visitor);} // 2) From visitOutputConstraints (constraint fixpoint re-scan):template<typename Visitor>void JSFoo::visitOutputConstraints(JSCell* cell, Visitor& visitor) { auto* thisObject = jsCast<JSFoo*>(cell); Base::visitOutputConstraints(thisObject, visitor); thisObject->visitAdditionalChildren(visitor);}``` **Why two entry points?** `visitChildren` runs once when the cell turns grey. But marking may later discover that some _other_ native object (an opaque root) is live, which retroactively makes more of _this_ cell's references live. `visitOutputConstraints` is re-invoked by `DOMGCOutputConstraint` during the constraint fixpoint to catch that. To make a class participate, its IsoSubspace must be registered as an **output-constraint subspace** (`clientSubspaceFor*` with `outputConstraint` in `BunClientData` / generated `ZigGeneratedClasses.cpp`). The codegen does this automatically when `.classes.ts` has `hasPendingActivity`, `own` properties, or event-target semantics. ## Opaque roots — liveness through non-JSCell pointers When native objects form a graph that should keep wrappers alive: ```cpp// In some wrapper's visitAdditionalChildren:visitor.addOpaqueRoot(nativePtr); // "nativePtr is reachable" // Elsewhere, deciding whether ANOTHER wrapper survives:bool JSBarOwner::isReachableFromOpaqueRoots(Handle<Unknown> h, void* ctx, AbstractSlotVisitor& v, ASCIILiteral* reason) { auto* bar = static_cast<Bar*>(ctx); if (UNLIKELY(reason)) *reason = "Bar is in document tree"_s; return v.containsOpaqueRoot(bar->ownerNode());}``` The opaque-root set is just a `HashSet<void*>` rebuilt each cycle. It's how DOM trees stay alive as a unit. ## `JSC::Weak<T>`, `WeakImpl`, `WeakBlock`, `WeakHandleOwner` `JSC::Weak<T>` (`vendor/WebKit/Source/JavaScriptCore/heap/Weak.h`) is the GC-aware weak pointer. It does **not** keep its target alive; `.get()` returns `nullptr` after the target is collected. Under the hood: - Each `Weak<T>` owns a `WeakImpl*` (`vendor/WebKit/Source/JavaScriptCore/heap/WeakImpl.h`): `{ JSValue, WeakHandleOwner* (low bits = state), void* context }`. State is `Live → Dead → Finalized → Deallocated`.- `WeakImpl`s are slab-allocated in 1KB **`WeakBlock`s** (`vendor/WebKit/Source/JavaScriptCore/heap/WeakBlock.h`, `blockSize = 1024`). Every `MarkedBlock` and `PreciseAllocation` has a `WeakSet` — a linked list of `WeakBlock`s for cells in that container.- During the `Ws` constraint, each `WeakBlock::visit()` walks its `WeakImpl`s; for each one whose target is **not yet marked**, it calls `WeakHandleOwner::isReachableFromOpaqueRoots(handle, context, visitor, &reason)`. Return `true` → the target is marked (the weak ref is "upgraded" this cycle). This is how `hasPendingActivity()` and opaque-root reachability keep wrappers alive even when nothing strongly references them.- After marking, `WeakBlock::reap()` flips unmarked `Live` impls to `Dead`. `WeakBlock::sweep()` later runs `WeakHandleOwner::finalize(handle, context)` on each `Dead` impl, then frees the slot. **`finalize` runs on the mutator thread but the cell is already dead — do not touch its JS fields.** Typical use: drop the wrapper from a native→JS wrapper cache. ```cppstruct MyOwner final : public JSC::WeakHandleOwner { bool isReachableFromOpaqueRoots(Handle<Unknown>, void* ctx, AbstractSlotVisitor& v, ASCIILiteral*) override { return static_cast<NativeThing*>(ctx)->hasPendingActivity(); } void finalize(Handle<Unknown>, void* ctx) override { static_cast<NativeThing*>(ctx)->m_wrapper = nullptr; }};JSC::Weak<JSFoo> m_wrapper { jsFoo, &myOwnerSingleton, nativeThing };``` `Weak<T>` is **move-only** (allocates a `WeakImpl`). Don't put it in a hot path; cache it. ## `JSRef` — the native↔wrapper reference pattern When a native object needs to hold a reference back to its own JS wrapper, **use `JSRef`** (`src/jsc/JSRef.rs`), not `gcProtect`, not a raw `JSValue` field, and usually not `Strong` directly. `JSRef` is a tagged union with three states: - `Weak` — a bare `JSValue`. Does **not** keep the wrapper alive. Valid only because the wrapper's `finalize()` will flip this to `Finalized` before the cell is freed, so `try_get()` returns `None` instead of a dangling pointer. (This is _not_ a `JSC::Weak`; it's cheaper — no `WeakImpl` allocation.)- `Strong` — wraps `bun_jsc::Strong` (a `JSC::Strong<Unknown>` root). Keeps the wrapper alive.- `Finalized` — terminal; `try_get()` returns `None`. Pattern: **strong while busy, weak while idle.** ```rustthis_value: JSRef, // initialized with JSRef::empty() // On construction / when work starts:self.this_value.set_strong(js_wrapper, global); // or .upgrade(global) // When the last in-flight operation completes:self.this_value.downgrade(); // Strong -> Weak, GC may now collect // In any callback that needs the wrapper:let Some(js_this) = self.this_value.try_get() else { return }; // In the codegen'd finalize():self.this_value.finalize();``` See `ServerWebSocket`, `UDPSocket`, `MySQLConnection`, `ValkeyClient` for real examples. **`JSRef` requires a finalizer.** The `Weak` state is only sound because the codegen'd `finalize()` flips it to `Finalized` before the cell is reused. If your `.classes.ts` entry has `finalize: true` (almost all native-backed classes do), `JSRef` is the default choice for self-references. **`JSRef` vs `hasPendingActivity`:** prefer `JSRef`. `hasPendingActivity: true` is a GC-thread-polled atomic predicate; its only real justification is when **many concurrent operations** independently keep the wrapper alive and there's no single place to call `upgrade()`/`downgrade()` — i.e., refcount-style liveness where the count is touched from multiple threads. That's uncommon. If you can identify "work started" / "work finished" edges, use `JSRef`. Don't add `hasPendingActivity` reflexively; it costs a constraint-fixpoint poll on every GC. ## `gcProtect` / `JSValueProtect` — almost never `gcProtect()` / `JSValueProtect()` push into `Heap::m_protectedValues` (a ref-counted root map, visited by the `Msr` constraint). It's the legacy C-API mechanism. **Avoid it in Bun:** - It's a raw global root with manual unprotect — easy to leak.- It has no owner, so heap snapshots can't attribute the retention.- `bun_jsc::Strong` / `JSRef` give the same guarantee with RAII and a destructor. The only legitimate uses are inside the JSC C API shims themselves, or one-off debugging. ## Extra-memory reporting — `reportExtraMemoryAllocated` / `reportExtraMemoryVisited` The GC schedules itself by bytes-allocated-since-last-GC. It only sees JSCell allocations, so a 32-byte wrapper around a 50MB native buffer looks like 32 bytes → GC never triggers → OOM. **Contract — both halves are required:** ```cpp// 1) When the native memory is allocated (or the wrapper takes ownership):vm.heap.reportExtraMemoryAllocated(ownerCell, byteCount); // 2) In visitChildren, every time the cell is visited:visitor.reportExtraMemoryVisited(thisObject->wrapped().byteSize());``` - `reportExtraMemoryAllocated` adds to the "since last GC" counter and may **immediately trigger a GC** (it's a safepoint). Call it _after_ the cell is fully constructed.- `reportExtraMemoryVisited` adds to the "live bytes after this GC" counter, which sets the next trigger threshold. **If you forget this half**, the heap's high-water mark drifts down each cycle and you get back-to-back full GCs (the "GC death spiral").- 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. ## `HeapAnalyzer` — heap snapshots and labelling `vendor/WebKit/Source/JavaScriptCore/heap/HeapAnalyzer.h` is the abstract visitor used to build heap snapshots (Web Inspector "Heap Snapshot", and Bun's V8-compatible `BunV8HeapSnapshotBuilder`). When a snapshot is requested, marking runs with an analyzer attached and each cell's `analyzeHeap` static is called: ```cppvoid JSFoo::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) { auto* thisObject = jsCast<JSFoo*>(cell); Base::analyzeHeap(cell, analyzer); analyzer.setWrappedObjectForCell(cell, &thisObject->wrapped()); analyzer.setLabelForCell(cell, thisObject->wrapped().url().string()); if (auto* child = thisObject->m_callback.get()) analyzer.analyzePropertyNameEdge(cell, child, vm.propertyNames->callback.impl());}``` API (`HeapAnalyzer`): - `analyzeNode(cell)` — record a node- `analyzeEdge(from, to, RootMarkReason)` / `analyzePropertyNameEdge` / `analyzeVariableNameEdge` / `analyzeIndexEdge` — record a labelled edge- `setWrappedObjectForCell(cell, void*)` — link wrapper → native pointer- `setLabelForCell(cell, String)` — human-readable name in the snapshot- `setOpaqueRootReachabilityReasonForCell` — why a weakly-held wrapper survived If your class shows up as an opaque blob in heap snapshots, implement `analyzeHeap`. ## How to keep things alive (decision table) | Scenario | Mechanism || ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- || JSCell field pointing to another JSCell | `WriteBarrier<T>` member + `visitor.append(m_field)` in `visitChildren` || Native state inside the wrapped C++ object holds JS values | `visitAdditionalChildren` + register subspace as output-constraint || C++/Rust local across allocation/call | Conservative scan (free) — add `EnsureStillAliveScope` / `value.ensure_still_alive()` if extracting interior pointers or seeing release-only crashes || Native object holds its own JS wrapper (class has `finalize: true`) | **`JSRef`** — `upgrade()` when work starts, `downgrade()` when idle. **This is the default.** || Native object owns an arbitrary JS value (callback, options object) | `bun_jsc::Strong` — drop in `finalize()`. Watch for cycles || C++ non-GC object owns a JS value as a root | `JSC::Strong<T>`. **Danger:** cycle if the JS value can reach back → leak || Weak ref with resurrection predicate / finalize callback (C++) | `JSC::Weak<T>` + `WeakHandleOwner` || Wrapper kept alive by **many concurrent operations** with no single busy/idle edge | `.classes.ts` `hasPendingActivity: true` (atomic flag polled on GC thread). **Uncommon — prefer `JSRef` if you can.** || Group of wrappers share lifetime via a native graph | `visitor.addOpaqueRoot(ptr)` + `containsOpaqueRoot(ptr)` || Temporarily forbid GC in a critical section | `DeferGC deferGC(vm)` — defers until scope exit. Never hold across user JS || Tell GC about off-heap memory you own | `reportExtraMemoryAllocated` on alloc **and** `reportExtraMemoryVisited` in `visitChildren` || ~~Mark a value as root from C API~~ | ~~`gcProtect` / `JSValueProtect`~~ — **avoid**; use `bun_jsc::Strong` / `JSRef` instead | ## Destruction & finalizers - `static constexpr bool needsDestruction = true` → C++ destructor runs when the cell is swept. Sweep is **lazy** (next allocation from that block, or `IncrementalSweeper`), so destruction is delayed arbitrarily. Do not rely on it for prompt resource release — expose explicit `close()`/`dispose()`.- In `.classes.ts`, `finalize: true` → native `finalize()` called from the destructor. Same laziness applies.- `WeakHandleOwner::finalize` runs earlier (at weak-reap time) but the cell is already dead; only use it to clear caches.- Destructors run on the mutator thread but **other JS objects may already be swept** — do not dereference `WriteBarrier` fields in a destructor. ## Debugging GC issues ```bash# Force synchronous, frequent GC — turns rare races into immediate crashesBUN_JSC_collectContinuously=1 BUN_JSC_useConcurrentGC=0 bun-debug test.js # Zero free cells so UAF reads are obviousBUN_JSC_scribbleFreeCells=1 # Validate the GC's own bookkeepingBUN_JSC_verifyGC=1 BUN_JSC_verboseVerifyGC=1 # See what's being collected / heap growthBUN_JSC_logGC=2 BUN_JSC_showObjectStatistics=1 # Force GC from JSBun.gc(true) // sync full GCrequire('bun:jsc').heapStats()``` If a bug only reproduces with concurrent GC **on** → missing write barrier or `visitChildren` race.If it only reproduces with `collectContinuously=1` → something isn't rooted across an allocation.If memory grows but `heapStats().heapSize` doesn't → missing `reportExtraMemoryAllocated`.If GC runs constantly with little garbage → missing `reportExtraMemoryVisited`. ## Key source files - `vendor/WebKit/Source/JavaScriptCore/heap/Heap.cpp` — `collectImpl`, `addCoreConstraints` (root list)- `vendor/WebKit/Source/JavaScriptCore/heap/SlotVisitor.cpp` / `SlotVisitorInlines.h` — `drain()`, `append`, `addOpaqueRoot`, `reportExtraMemoryVisited`- `vendor/WebKit/Source/JavaScriptCore/heap/MarkedBlock.h`, `vendor/WebKit/Source/JavaScriptCore/heap/PreciseAllocation.h` — cell containers, `isLive`- `vendor/WebKit/Source/JavaScriptCore/heap/CellState.h`, `runtime/WriteBarrier.h`, `runtime/WriteBarrierInlines.h`- `vendor/WebKit/Source/JavaScriptCore/heap/ConservativeRoots.cpp`, `vendor/WebKit/Source/JavaScriptCore/heap/MachineStackMarker.cpp`- `vendor/WebKit/Source/JavaScriptCore/heap/Weak.h`, `vendor/WebKit/Source/JavaScriptCore/heap/WeakImpl.h`, `vendor/WebKit/Source/JavaScriptCore/heap/WeakBlock.h`, `vendor/WebKit/Source/JavaScriptCore/heap/WeakSet.h`, `vendor/WebKit/Source/JavaScriptCore/heap/WeakHandleOwner.h`- `vendor/WebKit/Source/JavaScriptCore/heap/HeapAnalyzer.h`, `vendor/WebKit/Source/JavaScriptCore/heap/HeapSnapshotBuilder.cpp`, Bun: `vendor/WebKit/Source/JavaScriptCore/heap/BunV8HeapSnapshotBuilder.cpp`- `vendor/WebKit/Source/JavaScriptCore/heap/DeferGC.h`, `vendor/WebKit/Source/JavaScriptCore/heap/Strong.h`, `vendor/WebKit/Source/JavaScriptCore/heap/HandleSet.h`- `runtime/JSCell.h` / `JSCellInlines.h` — header layout, `visitChildren` base- Bun: `src/jsc/bindings/BunGCOutputConstraint.cpp`, `ZigGeneratedClasses.cpp` (codegen'd `visitChildren` / `visitOutputConstraints`)
Discovery context
Discovered by repository scan. No exact path reference found in the snapshot’s root CLAUDE.md.