Reference counting and Bacon-Rajan trial deletion are gone from the runtime. Traced (inferred-gc) objects now die only by the collector; owned values keep deterministic drops exactly as before. - wo_hdr: rc retired; borrow and the freed 4 bytes become a union — non-traced values keep the borrow word, traced objects use the 8 bytes as the intrusive sweep-list link. Header stays exactly 16 bytes. WHITE is now the all-zero color (allocations born white by memset); WO_F_BUF retired. - gc.c rewritten: snapshot-at-beginning tri-color mark-sweep. Roots (frames' gc+owned masks) shaded atomically at cycle start; Yuasa deletion barrier shades the OLD target of every gcref edge deleted while marking (SETF overwrites + every owned-death path, which all funnel through wo_drop_kind's GCREF case); allocations mid-cycle born black. Mark AND sweep budgeted (WO_GC_BUDGET objects/slice), sweep resumes via a cursor; gray-worklist OOM degrades to a blacken-all cycle (frees nothing, never wrong). Owned interiors walked eagerly (single-owner trees), pruned by a per-class may-gcref bit computed at rt_init (fixpoint over kinds + v2 field_class/field_elem; conservative when metadata is absent). - vm.c: safepoints at NEW (the heap-goal trigger), CALL, and backward JMP; root scan follows vm_unwind's governing-pc convention. Unwind's gc-mask branch just nulls the register. RC_INC/RC_DEC are accepted as no-ops until the emitter stops producing them (next commit) — which also deletes the old RC_DEC-on-nil trap that broke `?Node` gcref field stores. - main.c pump: post-exit, a rootless cycle frees everything unreachable in budgeted slices; the trace line moved into wo_gc_slice (one format for pump and in-program slices). rt_destroy frees traced remnants (trap paths, tests). - WO_GC_GOAL joins WO_GC_BUDGET/WO_GC_TRACE as an rt-owned knob (default 256 KiB; a tiny goal forces mid-program cycles for testing). - tests: test_cycle.c rewritten (abandoned cycle freed, rooted cycle survives, slices bounded, cycle-through-multi, repeated-cycle leak-freedom, and the spec's load-bearing DELETION-BARRIER test: an object hidden behind a black object mid-mark must survive). test_rc.c re-pinned to owned drops + the owned/traced boundary; test_obj.c asserts tracked-white-linked instead of rc=1. Verified: make test + test-iso (all suites, ASan/UBSan; test_cycle 42/0, test_rc 14/0) + cli_smoke; oop-e2e 79/0 (gc corpus traces unchanged: the new slice math reproduces steps=1/freed=2 and steps=2/freed=4); employee 8/0; log-watcher 7/0. THE RING RUNS: docs/examples/gc-cycle prints `ring a -> b -> c -> a`, is reclaimed post-exit (freed=3 remaining=0), is ASan clean, and survives an in-program cycle while rooted (WO_GC_GOAL=64: mid-run slice frees 0, post-exit frees 3). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
10 KiB
C
282 lines
10 KiB
C
/* gc.c — deterministic drops + incremental tri-color mark-sweep (iteration
|
||
* 7b, spec 2026-08-11). Replaces RC + Bacon–Rajan trial deletion outright:
|
||
* no reference counts exist anywhere; traced objects (inferred-gc classes)
|
||
* die only by sweep, owned values die deterministically exactly as before.
|
||
*
|
||
* Snapshot-at-beginning correctness argument, in one place:
|
||
* 1. When a cycle begins, the caller shades every root (the frames' gc
|
||
* and owned masks) before the mutator resumes — the snapshot.
|
||
* 2. While marking, deleting a gcref edge (a SETF overwrite, an owned
|
||
* value dying with a gcref inside) shades the OLD target first — the
|
||
* Yuasa deletion barrier. Every path that deletes such an edge
|
||
* funnels through wo_drop_kind's GCREF case or SETF's store site.
|
||
* 3. Objects allocated while a cycle runs are born black (live for this
|
||
* cycle) — a brand-new object can never be swept by the cycle it was
|
||
* born into.
|
||
* Together: anything reachable at snapshot time, or created after it,
|
||
* survives; only garbage that was already unreachable is swept. A
|
||
* pointer the mutator holds mid-cycle was obtained from a root, a field
|
||
* (protected by 1+2), or an allocation (protected by 3).
|
||
*
|
||
* The gray worklist holds TRACED objects only — sweep is the sole freer of
|
||
* traced objects, so a queued pointer can never dangle. Owned interiors
|
||
* are walked eagerly (they are single-owner trees: no cycles, no dedup
|
||
* needed), pruned by the per-class may-gcref bit. */
|
||
#include "gc.h"
|
||
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
|
||
#include "cont.h"
|
||
|
||
static int is_traced(const wo_hdr *o) { return (o->flags & WO_F_GC) != 0; }
|
||
|
||
static uint8_t color_of(const wo_hdr *o) { return o->flags & WO_F_COLOR; }
|
||
static void set_color(wo_hdr *o, uint8_t c) {
|
||
o->flags = (uint8_t)((o->flags & ~WO_F_COLOR) | c);
|
||
}
|
||
|
||
/* ---- deterministic destruction (owned values) -------------------------- */
|
||
|
||
static void multi_free(wo_rt *rt, wo_multi *m) {
|
||
for (uint32_t i = 0; i < m->len; i++)
|
||
wo_drop_kind(rt, m->elem_kind, m->items[i]);
|
||
free(m->items);
|
||
wo_arena_free(&rt->arena, m, sizeof(wo_multi));
|
||
}
|
||
|
||
static void map_free(wo_rt *rt, wo_map *m) {
|
||
for (uint32_t i = 0; i < m->len; i++) {
|
||
wo_drop_kind(rt, m->key_kind, m->keys[i]);
|
||
wo_drop_kind(rt, m->val_kind, m->vals[i]);
|
||
}
|
||
free(m->keys);
|
||
free(m->vals);
|
||
wo_arena_free(&rt->arena, m, sizeof(wo_map));
|
||
}
|
||
|
||
/* release a class object's contents then the object itself. Serves owned
|
||
* objects (drop paths) and dead traced objects (sweep): a traced object's
|
||
* gcref edges are no-ops here (their targets die by their own color), its
|
||
* owned interior is dropped — the traced object was that interior's single
|
||
* owner. */
|
||
static void class_free(wo_rt *rt, wo_hdr *o) {
|
||
const wo_classdesc *c = &rt->classes[o->class_id];
|
||
uint64_t *f = wo_fields(o);
|
||
for (uint32_t i = 0; i < c->field_cnt; i++)
|
||
wo_drop_kind(rt, c->kinds[i], f[i]);
|
||
wo_arena_free(&rt->arena, o, wo_obj_size(c));
|
||
}
|
||
|
||
void wo_drop_obj(wo_rt *rt, wo_hdr *o) {
|
||
if (!o) return;
|
||
switch (o->class_id) {
|
||
case WO_CLS_STR:
|
||
wo_str_free(rt, (wo_str *)o);
|
||
return;
|
||
case WO_CLS_MULTI:
|
||
multi_free(rt, (wo_multi *)o);
|
||
return;
|
||
case WO_CLS_MAP:
|
||
map_free(rt, (wo_map *)o);
|
||
return;
|
||
default:
|
||
class_free(rt, o);
|
||
return;
|
||
}
|
||
}
|
||
|
||
void wo_drop_kind(wo_rt *rt, uint8_t kind, uint64_t v) {
|
||
if (!v) return; /* null values ignored for every kind */
|
||
switch (kind) {
|
||
case WO_K_SCALAR:
|
||
return;
|
||
case WO_K_OWNED:
|
||
case WO_K_MULTI:
|
||
case WO_K_MAP:
|
||
wo_drop_obj(rt, (wo_hdr *)(uintptr_t)v);
|
||
return;
|
||
case WO_K_GCREF:
|
||
/* the Yuasa deletion barrier: this call site is deleting a gcref
|
||
* edge (an owned holder dying, a container element dropped). While
|
||
* marking, the old target must be shaded or the snapshot leaks
|
||
* reachability; any other time, tracing owns the lifetime and the
|
||
* edge's death means nothing. */
|
||
if (rt->gc_phase == WO_GC_MARK) wo_gc_shade(rt, (wo_hdr *)(uintptr_t)v);
|
||
return;
|
||
case WO_K_TEXT:
|
||
wo_str_free(rt, (wo_str *)(uintptr_t)v);
|
||
return;
|
||
default:
|
||
return; /* loader guarantees kinds; defensive no-op */
|
||
}
|
||
}
|
||
|
||
/* ---- traced list + shading --------------------------------------------- */
|
||
|
||
void wo_gc_track(wo_rt *rt, wo_hdr *o, size_t size) {
|
||
o->gclink = rt->gc_traced;
|
||
rt->gc_traced = o;
|
||
rt->gc_traced_cnt++;
|
||
rt->gc_alloc_bytes += size;
|
||
}
|
||
|
||
void wo_gc_shade(wo_rt *rt, wo_hdr *o) {
|
||
if (!o || !is_traced(o)) return;
|
||
if (color_of(o) != WO_COLOR_WHITE) return; /* gray or black: already safe */
|
||
set_color(o, WO_COLOR_GRAY);
|
||
if (rt->gc_gray.len == rt->gc_gray.cap) {
|
||
size_t ncap = rt->gc_gray.cap ? rt->gc_gray.cap * 2 : 64;
|
||
wo_hdr **ni = realloc(rt->gc_gray.items, ncap * sizeof(wo_hdr *));
|
||
if (!ni) {
|
||
/* cannot queue: this cycle can no longer prove anything dead.
|
||
* Flag it; the next slice blackens the whole traced list and
|
||
* the sweep frees nothing — a conservative, safe cycle. */
|
||
rt->gc_gray.oom = 1;
|
||
set_color(o, WO_COLOR_BLACK);
|
||
return;
|
||
}
|
||
rt->gc_gray.items = ni;
|
||
rt->gc_gray.cap = ncap;
|
||
}
|
||
rt->gc_gray.items[rt->gc_gray.len++] = o;
|
||
}
|
||
|
||
/* may this class's instances transitively hold a gcref? NULL table (or an
|
||
* out-of-range id) answers conservatively: traverse. */
|
||
static int class_may_gcref(const wo_rt *rt, uint32_t class_id) {
|
||
if (!rt->gc_may || class_id >= rt->class_cnt) return 1;
|
||
return rt->gc_may[class_id] != 0;
|
||
}
|
||
|
||
/* Walk an owned value's interior, shading every traced object reachable
|
||
* through it. Owned graphs are single-owner trees — no cycles, so plain
|
||
* recursion terminates; depth is data-structure depth, same as class_free.
|
||
* Traced objects themselves are shaded, never entered: their fields are
|
||
* scanned when the mark loop pops them gray. */
|
||
void wo_gc_scan_root(wo_rt *rt, wo_hdr *o) {
|
||
if (!o) return;
|
||
if (is_traced(o)) {
|
||
wo_gc_shade(rt, o);
|
||
return;
|
||
}
|
||
switch (o->class_id) {
|
||
case WO_CLS_STR:
|
||
return;
|
||
case WO_CLS_MULTI: {
|
||
wo_multi *m = (wo_multi *)o;
|
||
if (m->elem_kind == WO_K_GCREF || m->elem_kind == WO_K_OWNED ||
|
||
m->elem_kind == WO_K_MULTI || m->elem_kind == WO_K_MAP)
|
||
for (uint32_t i = 0; i < m->len; i++)
|
||
wo_gc_scan_root(rt, (wo_hdr *)(uintptr_t)m->items[i]);
|
||
return;
|
||
}
|
||
case WO_CLS_MAP: {
|
||
wo_map *mp = (wo_map *)o;
|
||
for (uint32_t i = 0; i < mp->len; i++) {
|
||
if (mp->key_kind == WO_K_GCREF || mp->key_kind == WO_K_OWNED ||
|
||
mp->key_kind == WO_K_MULTI || mp->key_kind == WO_K_MAP)
|
||
wo_gc_scan_root(rt, (wo_hdr *)(uintptr_t)mp->keys[i]);
|
||
if (mp->val_kind == WO_K_GCREF || mp->val_kind == WO_K_OWNED ||
|
||
mp->val_kind == WO_K_MULTI || mp->val_kind == WO_K_MAP)
|
||
wo_gc_scan_root(rt, (wo_hdr *)(uintptr_t)mp->vals[i]);
|
||
}
|
||
return;
|
||
}
|
||
default: {
|
||
if (o->class_id >= rt->class_cnt) return; /* defensive */
|
||
if (!class_may_gcref(rt, o->class_id)) return;
|
||
const wo_classdesc *c = &rt->classes[o->class_id];
|
||
uint64_t *f = wo_fields(o);
|
||
for (uint32_t i = 0; i < c->field_cnt; i++) {
|
||
uint8_t k = c->kinds[i];
|
||
if (k == WO_K_GCREF || k == WO_K_OWNED || k == WO_K_MULTI ||
|
||
k == WO_K_MAP)
|
||
wo_gc_scan_root(rt, (wo_hdr *)(uintptr_t)f[i]);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---- the cycle ---------------------------------------------------------- */
|
||
|
||
int wo_gc_want_start(const wo_rt *rt) {
|
||
return rt->gc_phase == WO_GC_IDLE && rt->gc_alloc_bytes >= rt->gc_goal &&
|
||
rt->gc_traced != NULL;
|
||
}
|
||
|
||
void wo_gc_begin(wo_rt *rt) {
|
||
rt->gc_gray.len = 0;
|
||
rt->gc_gray.oom = 0;
|
||
rt->gc_phase = WO_GC_MARK;
|
||
/* the caller shades the roots now, before the mutator resumes */
|
||
}
|
||
|
||
/* scan one gray (traced) object's out-edges, then blacken it */
|
||
static void scan_traced(wo_rt *rt, wo_hdr *o) {
|
||
const wo_classdesc *c = &rt->classes[o->class_id];
|
||
uint64_t *f = wo_fields(o);
|
||
for (uint32_t i = 0; i < c->field_cnt; i++) {
|
||
uint8_t k = c->kinds[i];
|
||
if (k == WO_K_GCREF) {
|
||
if (f[i]) wo_gc_shade(rt, (wo_hdr *)(uintptr_t)f[i]);
|
||
} else if (k == WO_K_OWNED || k == WO_K_MULTI || k == WO_K_MAP) {
|
||
wo_gc_scan_root(rt, (wo_hdr *)(uintptr_t)f[i]);
|
||
}
|
||
}
|
||
set_color(o, WO_COLOR_BLACK);
|
||
}
|
||
|
||
size_t wo_gc_slice(wo_rt *rt, size_t budget) {
|
||
size_t freed = 0;
|
||
if (rt->gc_phase == WO_GC_IDLE) return 0;
|
||
|
||
if (rt->gc_phase == WO_GC_MARK) {
|
||
if (rt->gc_gray.oom) {
|
||
/* worklist allocation failed mid-mark: blacken everything so
|
||
* the sweep frees nothing — a wasted cycle, never a wrong one */
|
||
for (wo_hdr *o = rt->gc_traced; o; o = o->gclink)
|
||
set_color(o, WO_COLOR_BLACK);
|
||
rt->gc_gray.len = 0;
|
||
}
|
||
size_t done = 0;
|
||
while (done < budget && rt->gc_gray.len > 0) {
|
||
wo_hdr *o = rt->gc_gray.items[--rt->gc_gray.len];
|
||
scan_traced(rt, o);
|
||
done++;
|
||
}
|
||
if (rt->gc_gray.len == 0) {
|
||
rt->gc_phase = WO_GC_SWEEP;
|
||
rt->gc_sweep = &rt->gc_traced;
|
||
}
|
||
}
|
||
|
||
if (rt->gc_phase == WO_GC_SWEEP) {
|
||
size_t done = 0;
|
||
wo_hdr **link = rt->gc_sweep;
|
||
while (done < budget && *link) {
|
||
wo_hdr *o = *link;
|
||
if (color_of(o) == WO_COLOR_WHITE) {
|
||
*link = o->gclink; /* unlink, then free contents + object */
|
||
rt->gc_traced_cnt--;
|
||
class_free(rt, o);
|
||
freed++;
|
||
} else {
|
||
set_color(o, WO_COLOR_WHITE); /* survivor: candidate next cycle */
|
||
link = &o->gclink;
|
||
}
|
||
done++;
|
||
}
|
||
rt->gc_sweep = link;
|
||
if (!*link) {
|
||
rt->gc_phase = WO_GC_IDLE;
|
||
rt->gc_sweep = NULL;
|
||
rt->gc_alloc_bytes = 0;
|
||
}
|
||
}
|
||
if (rt->gc_trace)
|
||
fprintf(stderr, "gc: step %zu budget=%zu freed=%zu remaining=%zu\n",
|
||
++rt->gc_step_no, budget, freed, rt->gc_traced_cnt);
|
||
return freed;
|
||
}
|