GC Relocation Contract
On this page 3
Moving collection is available through explicit Context.compactGarbage on a
quiescent precise-GC realm, automatic quiescent slab-pressure compaction after a
full collection leaves at least 512 KiB of reclaimable fragmented backing, or a
Context.requestGarbageCompaction consumed at the current AArch64 numeric
tier's declared precise checkpoint. Published code, tier metadata, bytecode
chunks, and native-frame storage do not move; the checkpoint has already
materialized every managed local/operand in registered roots. Running JS
threads, generic/native-host checkpoints, conservative stack scans, and
in-flight concurrent/parallel collections still fail closed. C and Objective-C
embedders use ZJSGlobalContextCreateGarbageCollected,
ZJSContextRequestGarbageCompaction, and ZJSContextCompactGarbage for the
same scheduled/direct boundaries.
The checked-in
gc-relocation-inventory.json covers
all nine production cell kinds and 27 pointer surfaces across runtime edges,
realm and interpreter roots, C and Objective-C handles, private ABI references,
inspector frames, threads/workers, promises/modules/timers, WebAssembly, native
stack scanning, and the existing native JIT frame. Every entry names its current
representation, source anchor, and required relocation or pinning rule.
Run the drift gate with:
zig build gc-relocation-inventory-check
The verifier derives the live CellKind enum from src/gc.zig, requires exact
ordered coverage and an executable rewriter for each kind, validates every
source anchor/boundary tag, and checks the fail-closed activation gates. It also
pins the current JIT compiler's object/string-constant rejection so the
quiescent allowance cannot silently outlive its pointer-free premise. The gate
is deliberately cheap enough for ordinary CI.
Code Contract
src/gc_relocation.zig defines the operations
later phases must use:
- a
StableCellIdrepresents logical identity independently of payload address; - a
ForwardingRecordretains the stable ID, kind, old/new payloads, and planning/copy/rewrite state; - required and optional strong edges must resolve to a destination;
- weak and atomic-weak edges may clear when no live destination exists;
- atomic strong and tagged-
Valueslots use compare/exchange rewriting; - interior projections preserve a proven byte offset from a relocated base;
- unmanaged static, arena, and interned strings remain unchanged;
- compaction is rejected while conservative scanning is active because a scanned machine word does not carry a precise base/slot to rewrite.
These types do not allocate destinations, copy cells, mutate the heap, or make raw native/JIT pointers safe. The ordered follow-ups are stop-the-world failure-atomic movement (#334), complete root/edge rewriting (#335), concurrent/native/JIT barriers (#336), and terminal evidence (#337).
The generic collector mechanism is now supplied by
zig-gc@5883b02. It
reserves the complete old-to-new plan before mutation, rolls every destination
back on OOM, rewrites moved and pinned cells, and commits live storage without
running finalizers. zig-js's owned GcCellBacking provides a matching
unpublished reserve/release/commit trio: relocation does not inflate mutator
allocation pressure, publication swaps under one size-class lock, and live-slot
accounting stays unchanged. The engine now invokes this mechanism through the
checked explicit stop-the-world policy, the automatic quiescent full-GC pressure
policy, and the declared moving-safepoint request path. Production
shared-realm/mid-script automatic scheduling remains the open release gate.
Paired post-commit verification hooks retain the forwarding map long enough to
trace every current root/live cell and trap if any audited slot still contains
an old payload address.
The first engine rewrite slice is complete under
#338: Function marking and
relocation now cover its closure, realm, home/super/wrapper objects,
import.meta, lexical this/new.target, shared derived-constructor this
cell, and captured with objects. Old functions are rescanned by minor GC
because super() can initialize the shared this cell after publication.
#339 also completes the two
small immutable side-cell graphs: bound functions rewrite their target,
captured this, and every bound argument, while module namespaces rewrite each
environment pointer and preserve their arena-owned name/deferred-module
metadata.
The mutable suspension graphs follow under
#340: generator marking and
relocation share environment, operand-stack, accumulator, persisted frame,
realm, import.meta, async-parent, and pending-request coverage; iterator
helpers rewrite all six Value slots. Both remain world-stopped during the
rewrite, matching their existing concurrent-mark finish deferral.
#341 completes Environment
payload rewriting across binding values, disposal state, live module aliases,
realm prototype, parent scope, and with object. Binding names, declaration
sets, allocator/accounting fields, and arena-owned containers stay in place;
the world-stopped rewrite deliberately takes no binding lock.
Promise state follows in #342: the settled value, wrapper, awaiting/forwarding links, inline reactions, and overflow reactions all rewrite through the same plan. Each reaction preserves the tracer's result-Promise versus resolve/reject branch, and the world-stopped commit takes no Promise lock.
Object rewriting starts with the hot/property graph in #343: prototype, inline and external named slots, dense elements, accessor payloads/descriptor cells, and C API custom-accessor cells move while Shape and backing-container addresses stay stable. Weak collection elements are intentionally deferred to their ordered weak-processing slice.
#344 extends Object rewriting to actual cold/rare union storage: boxed and getter/setter Values, constructor/proxy links, callable side cells, arguments-map Environment, and TypedArray/DataView buffer owners. Marker snapshots are never mutated, and native ArrayBuffer/arena metadata remains address-stable.
Internal weak ordering is covered by #346: dead finalization targets are nulled before sweep can leave a stale address, then surviving WeakRef, weak-collection, ephemeron, callback/held, and unregister-token slots rewrite. The pointer-keyed weak lookup cache is cleared because its old-address hashes are invalid; linear lookup remains correct and later mutations repopulate it.
Engine-owned native payloads gain paired mutating hooks in
#347. Promise resolver state,
interpreter Promise/iterator/combinator captures, VM async-resume links, and
Thread lock/condition/TLS/release queues now rewrite their hidden roots while
host-opaque payloads remain untouched. The audit also added the previously
missing Promise.prototype.finally constructor edge to marking.
WebAssembly-owned roots complete the Object boundary in
#348. WasmGcRef and instance
owners now expose paired by-value trace and by-pointer relocation callbacks;
imports, atomic globals/tables, execution roots, exceptions/wrappers, nested
externref/hostref slots, and cyclic GC aggregates all rewrite. Numeric,
funcref, i31, native-owner, and aggregate identities remain stable.
Root-registry rewriting begins in
#349: every microtask variant,
module graph edge, active interpreter operand/frame/environment root, cache,
debug frame, import.meta, and parked Wasm execution root now has a mutating
world-stopped traversal. The same pass now covers Context globals and builtins,
rejection/finalization/timer queues, C API boxes and prototype owners, private
strong/weak handles, Thread results and join promises, GIL task jobs, property
waiters, and the host exception slot. Queue, handle, and native-record addresses
and ordering remain stable; only their managed payloads change.
#350 composes those helpers into the collector-facing root and exact nine-kind cell dispatch. Its Context entrypoint opens a short-lived relocation token only after every fail-closed gate passes. A protected cyclic/aliased graph is moved repeatedly with exact live-cell/live-byte accounting, and deterministic scratch OOM proves the plan leaves its original graph and accounting untouched. The same witness executes pre-move Function/Environment/Promise/Generator/String representatives after two moves. Ordinary collection stays non-moving.
#351 adds that post-commit stale-address traversal to the engine. It reuses the non-mutating marker view, adds the weak-map/finalization-only surfaces that ordinary tracing omits, and runs only after all backing publication and heap indexes name destinations. The repeated-move witness also covers stable C/private handle addresses, live and dead weak embedding roots, WeakRef/WeakMap/WeakSet/finalization behavior, and standalone WebAssembly table/global identity. Table and global owners use paired native-slot audit/relocate callbacks, so their atomic JS root mirrors and the slots WebAssembly actually executes are rewritten together without adding plain native reads to concurrent marking.
#352 makes movement useful for
fragmented heaps. After sweep, each size class freezes its minimal dense leading
chunk count; cells already inside that prefix stay pinned while tail cells move
only into lower free or never-issued prefix slots. Relocation never grows cell
backing, explicit compaction returns every newly empty tail chunk, and a second
pass over the packed heap reports no_candidates without mutation.
#355 proves the native callback
boundary is fail-closed: reentrant C compaction is rejected while its interpreter
is active. At the next quiescent host boundary, the same counted
JSValueProtect wrapper survives while its managed payload moves and remains
fully usable.
#356 makes that C boundary diagnostic instead of boolean: embedders receive the exact unsupported, already-dense, planning-OOM, or compacted status plus optional exact moved-cell and moved-byte totals. Every non-moving outcome deterministically reports zero.
#357 gives Zig embedders the
same safe lifetime model through Context.protectValue and
Context.unprotectValue. A ProtectedValue address remains stable while its
contained Value is traced and rewritten; raw Value copies returned by
get() are valid only until the next compaction boundary.
#358 removes the blanket JIT flag rejection at that quiescent boundary. Its regression moves the rooted Function/Object graph around a ready numeric tier, proves the immutable native entry and arena-owned chunk stay stable, and enters the same tier afterward.
#359 admits exactly one active native boundary: an explicit pending request at the current AArch64 numeric checkpoint, after its compiler island publishes canonical frame locals, spills live operands, and records exact instruction/step state. Movement rewrites the active registered graph, clears the request only after a supported attempt, and resumes the same native entry. Other live native/interpreter boundaries remain rejected.
#360 exposes that scheduling
primitive to C and Objective-C hosts. A native callback may set the pending bit
but cannot move the heap reentrantly; after it unwinds, the declared native
checkpoint consumes the request. Long-lived C values still require counted
JSValueProtect storage across that boundary.
#361 makes the no-GIL policy deterministic: a parked spawned peer keeps a callback-scheduled request pending across repeated movement-safe native checkpoints, with the relocation token closed and movement count unchanged. After wake and join, the same request compacts usefully at the quiescent host boundary and reaches a dense fixed point.
Safepoint Rule
A raw old-space address is valid only while the relocation safepoint is held and its forwarding record remains live. Long-lived embedding references must use stable handle storage whose contained value is rewritten; the handle's own address does not move. A live frame may move only at a checkpoint whose compiler and runtime jointly declare complete materialization; the current AArch64 numeric tier is the sole admitted path. Other native frames and conservative stack scans remain unsupported; each future tier or native boundary must add its own precise maps, rewrite protocol, or per-cell pinning before admission.
Focused Evidence
zig build test -Dtest-filter=compaction
zig build test -Doptimize=ReleaseSafe -Dtest-filter=compaction
zig build test -Dtsan=true -Dtest-filter=compaction
zig build gc-relocation-inventory-check c-api-audit objc-api-audit test-c-api test-objc-api
The native-safepoint witness first offers the request to a marking-precise but movement-unsafe checkpoint and proves it remains pending. A warmed numeric tier then consumes it inside its compiler-generated checkpoint island, relocates the active Function/Object/protected graph, resumes the same immutable entry, and returns the exact loop result.