writeonce/runtime/src/gc.c
shoney.arickathil e2c825843f feat(runtime): incremental tri-color mark-sweep replaces RC (7b Phase 3a)
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>
2026-08-19 16:52:48 +02:00

282 lines
10 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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;
}