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>
This commit is contained in:
shoney.arickathil 2026-08-19 16:52:48 +02:00
parent 2379a95016
commit e2c825843f
10 changed files with 598 additions and 369 deletions

View file

@ -1,9 +1,43 @@
/* 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]);
@ -21,7 +55,11 @@ static void map_free(wo_rt *rt, wo_map *m) {
wo_arena_free(&rt->arena, m, sizeof(wo_map));
}
/* release a class object's contents then the object itself */
/* 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);
@ -59,7 +97,12 @@ void wo_drop_kind(wo_rt *rt, uint8_t kind, uint64_t v) {
wo_drop_obj(rt, (wo_hdr *)(uintptr_t)v);
return;
case WO_K_GCREF:
wo_rc_dec(rt, (wo_hdr *)(uintptr_t)v);
/* 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);
@ -69,228 +112,171 @@ void wo_drop_kind(wo_rt *rt, uint8_t kind, uint64_t v) {
}
}
void wo_rc_inc(wo_hdr *o) { o->rc++; }
/* ---- traced list + shading --------------------------------------------- */
/* ---- cycle-candidate buffer ---- */
/* only classes that can point at other @gc objects can close a cycle */
static int class_possibly_cyclic(const wo_rt *rt, const wo_hdr *o) {
const wo_classdesc *c = &rt->classes[o->class_id];
for (uint32_t i = 0; i < c->field_cnt; i++) {
uint8_t k = c->kinds[i];
if (k == WO_K_GCREF || k == WO_K_MULTI || k == WO_K_MAP) return 1;
}
return 0;
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;
}
static void buf_push(wo_rt *rt, wo_hdr *o) {
if (rt->cycbuf.len == rt->cycbuf.cap) {
size_t ncap = rt->cycbuf.cap ? rt->cycbuf.cap * 2 : 16;
wo_hdr **ni = realloc(rt->cycbuf.items, ncap * sizeof(wo_hdr *));
if (!ni) return; /* can't buffer: conservative leak, never corrupt */
rt->cycbuf.items = ni;
rt->cycbuf.cap = ncap;
}
o->flags |= WO_F_BUF;
rt->cycbuf.items[rt->cycbuf.len++] = o;
}
/* swap-remove a specific object from the buffer (whites purged mid-step) */
static void buf_remove(wo_rt *rt, wo_hdr *o) {
for (size_t i = 0; i < rt->cycbuf.len; i++) {
if (rt->cycbuf.items[i] == o) {
rt->cycbuf.items[i] = rt->cycbuf.items[--rt->cycbuf.len];
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;
}
void wo_rc_dec(wo_rt *rt, wo_hdr *o) {
o->rc--;
if (o->rc == 0) {
/* zombie guard: a buffered candidate's death belongs to the cycle
* collector — it will notice rc 0 and free it in its own sweep */
if (o->flags & WO_F_BUF) return;
class_free(rt, 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;
}
/* survived a decrement and can sit on a cycle: buffer as a candidate,
* deduplicated by the flag (Bacon–Rajan possible-root heuristic) */
if (!(o->flags & WO_F_BUF) && class_possibly_cyclic(rt, o))
buf_push(rt, o);
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;
}
}
}
/* ---- budgeted Bacon–Rajan trial deletion ---- */
/* ---- the cycle ---------------------------------------------------------- */
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);
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;
}
/* visit every @gc edge out of a class object: gcref fields, plus gcref
* elements inside multi/map fields */
typedef void (*child_fn)(wo_rt *rt, wo_hdr *child, void *ctx);
static void visit_children(wo_rt *rt, wo_hdr *o, child_fn fn, void *ctx) {
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]) fn(rt, (wo_hdr *)(uintptr_t)f[i], ctx);
} else if (k == WO_K_MULTI) {
wo_multi *m = (wo_multi *)(uintptr_t)f[i];
if (m && m->elem_kind == WO_K_GCREF)
for (uint32_t j = 0; j < m->len; j++)
if (m->items[j]) fn(rt, (wo_hdr *)(uintptr_t)m->items[j], ctx);
} else if (k == WO_K_MAP) {
wo_map *mp = (wo_map *)(uintptr_t)f[i];
if (!mp) continue;
if (mp->key_kind == WO_K_GCREF)
for (uint32_t j = 0; j < mp->len; j++)
if (mp->keys[j]) fn(rt, (wo_hdr *)(uintptr_t)mp->keys[j], ctx);
if (mp->val_kind == WO_K_GCREF)
for (uint32_t j = 0; j < mp->len; j++)
if (mp->vals[j]) fn(rt, (wo_hdr *)(uintptr_t)mp->vals[j], ctx);
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]);
}
}
}
static void mark_gray(wo_rt *rt, wo_hdr *o);
static void mark_gray_child(wo_rt *rt, wo_hdr *c, void *ctx) {
(void)ctx;
c->rc--; /* trial-delete this edge */
mark_gray(rt, c);
}
static void mark_gray(wo_rt *rt, wo_hdr *o) {
if (color_of(o) == WO_COLOR_GRAY) return;
set_color(o, WO_COLOR_GRAY);
visit_children(rt, o, mark_gray_child, NULL);
}
static void scan_black(wo_rt *rt, wo_hdr *o);
static void scan_black_child(wo_rt *rt, wo_hdr *c, void *ctx) {
(void)ctx;
c->rc++; /* restore the trial-deleted edge */
if (color_of(c) != WO_COLOR_BLACK) scan_black(rt, c);
}
static void scan_black(wo_rt *rt, wo_hdr *o) {
set_color(o, WO_COLOR_BLACK);
visit_children(rt, o, scan_black_child, NULL);
}
static void scan(wo_rt *rt, wo_hdr *o, void *ctx);
static void scan_(wo_rt *rt, wo_hdr *o) {
if (color_of(o) != WO_COLOR_GRAY) return;
if (o->rc > 0) {
scan_black(rt, o); /* externally held: restore the whole subgraph */
return;
}
set_color(o, WO_COLOR_WHITE);
visit_children(rt, o, scan, NULL);
}
static void scan(wo_rt *rt, wo_hdr *o, void *ctx) {
(void)ctx;
scan_(rt, o);
}
size_t wo_gc_slice(wo_rt *rt, size_t budget) {
size_t freed = 0;
if (rt->gc_phase == WO_GC_IDLE) return 0;
/* gather whites into a step-local list (post-marking, pre-free) */
typedef struct {
wo_hdr **items;
size_t len, cap;
int oom;
} whites_t;
static void collect_white(wo_rt *rt, wo_hdr *o, void *ctx) {
whites_t *w = ctx;
if (color_of(o) != WO_COLOR_WHITE) return;
set_color(o, WO_COLOR_BLACK); /* dedup: gathered exactly once */
visit_children(rt, o, collect_white, ctx);
if (w->len == w->cap) {
size_t ncap = w->cap ? w->cap * 2 : 16;
wo_hdr **ni = realloc(w->items, ncap * sizeof(wo_hdr *));
if (!ni) {
w->oom = 1;
return;
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;
}
w->items = ni;
w->cap = ncap;
}
w->items[w->len++] = o;
}
/* Free a dead white: release contents but SKIP every @gc edge — all edge
* accounting was already settled by the gray/scan phases, and the pointed-
* at whites die in this same sweep. Containers holding gcref elements free
* only their backing. */
static void white_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++) {
uint8_t k = c->kinds[i];
uint64_t v = f[i];
if (!v || k == WO_K_SCALAR || k == WO_K_GCREF) continue;
if (k == WO_K_MULTI) {
wo_multi *m = (wo_multi *)(uintptr_t)v;
if (m->elem_kind != WO_K_GCREF)
for (uint32_t j = 0; j < m->len; j++)
wo_drop_kind(rt, m->elem_kind, m->items[j]);
free(m->items);
wo_arena_free(&rt->arena, m, sizeof(wo_multi));
} else if (k == WO_K_MAP) {
wo_map *mp = (wo_map *)(uintptr_t)v;
for (uint32_t j = 0; j < mp->len; j++) {
if (mp->key_kind != WO_K_GCREF)
wo_drop_kind(rt, mp->key_kind, mp->keys[j]);
if (mp->val_kind != WO_K_GCREF)
wo_drop_kind(rt, mp->val_kind, mp->vals[j]);
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;
}
free(mp->keys);
free(mp->vals);
wo_arena_free(&rt->arena, mp, sizeof(wo_map));
} else {
wo_drop_kind(rt, k, v); /* OWNED subtree, TEXT */
done++;
}
rt->gc_sweep = link;
if (!*link) {
rt->gc_phase = WO_GC_IDLE;
rt->gc_sweep = NULL;
rt->gc_alloc_bytes = 0;
}
}
wo_arena_free(&rt->arena, o, wo_obj_size(c));
}
size_t wo_gc_step(wo_rt *rt, size_t budget) {
size_t freed = 0, consumed = 0;
while (consumed < budget && rt->cycbuf.len > 0) {
wo_hdr *root = rt->cycbuf.items[--rt->cycbuf.len];
root->flags &= (uint8_t)~WO_F_BUF;
consumed++;
if (root->rc == 0) {
/* died while buffered (zombie guard) — plain deterministic free */
class_free(rt, root);
freed++;
continue;
}
/* trial deletion over this root's component (atomic per component) */
mark_gray(rt, root);
scan_(rt, root);
whites_t w = {0};
collect_white(rt, root, &w);
if (w.oom) { /* can't track whites: restore and retry next step */
scan_black(rt, root);
free(w.items);
buf_push(rt, root);
break;
}
/* purge gathered whites still sitting in the buffer, then free —
* deferred freeing removes every dangling-candidate hazard */
for (size_t i = 0; i < w.len; i++) {
if (w.items[i]->flags & WO_F_BUF) {
w.items[i]->flags &= (uint8_t)~WO_F_BUF;
buf_remove(rt, w.items[i]);
consumed++;
}
}
for (size_t i = 0; i < w.len; i++) {
white_free(rt, w.items[i]);
freed++;
}
free(w.items);
}
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;
}

View file

@ -1,32 +1,65 @@
/* gc.h — deterministic destruction + @gc reference counting (spec §4).
* Owned objects die deterministically via drop plans; @gc objects die at
* refcount zero. One kind-directed dispatcher is the workhorse: scalars
* ignored, owned values drop recursively, gc refs decrement, texts free,
* containers free element-wise then their backing. The budgeted cycle
* collector extends this module (Bacon–Rajan trial deletion). */
/* gc.h — deterministic destruction + incremental tri-color mark-sweep for
* traced (inferred-gc) objects (iteration 7b, spec 2026-08-11).
*
* Two reclamation systems, one module:
* - OWNED values die deterministically via drop plans (wo_drop_obj /
* wo_drop_kind), exactly as before.
* - TRACED objects (instances of classes the compiler inferred `gc`) die
* only by the collector: every traced allocation links onto the
* per-shard traced list; a cycle marks from the VM's root snapshot and
* sweeps the unmarked.
*
* The algorithm is snapshot-at-beginning: roots (the value/frame stacks,
* read through the per-pc gc/owned masks) are scanned atomically when a
* cycle starts; a Yuasa deletion barrier shades the OLD value of every
* gcref edge deleted while marking (SETF overwrites and owned-value deaths
* both funnel through here); objects allocated mid-cycle are born black.
* Marking and sweeping are budgeted (WO_GC_BUDGET objects per slice) so no
* slice's pause grows with the heap. */
#ifndef WO_GC_H
#define WO_GC_H
#include "obj.h"
/* Drop an 8-byte field/register value known to be of field kind `kind`.
* Null (0) values are ignored for every kind. */
* Null (0) values are ignored for every kind. WO_K_GCREF is the deletion
* barrier: while marking, the old target is shaded; otherwise a no-op —
* tracing owns traced lifetimes, so an owned value dying never frees them. */
void wo_drop_kind(wo_rt *rt, uint8_t kind, uint64_t v);
/* Drop any heap value by its header: native sentinels route to their own
* frees; class objects walk their kind array over the field slots, then
* free themselves. */
/* Drop any OWNED heap value by its header: native sentinels route to their
* own frees; class objects walk their kind array over the field slots, then
* free themselves. Never called on a traced object (sweep frees those). */
void wo_drop_obj(wo_rt *rt, wo_hdr *o);
void wo_rc_inc(wo_hdr *o);
/* Decrement; at zero, release contents and free — unless the object sits
* in the cycle-candidate buffer (WO_F_BUF): the collector owns its death. */
void wo_rc_dec(wo_rt *rt, wo_hdr *o);
/* Link a freshly allocated traced object onto the traced list and account
* its bytes toward the cycle trigger (called by wo_obj_new). */
void wo_gc_track(wo_rt *rt, wo_hdr *o, size_t size);
/* Budgeted cycle collection step (Bacon–Rajan trial deletion over the
* candidate buffer). Processes up to `budget` buffered roots (whole
* strongly-connected components process atomically, so overshoot is
* bounded); returns how many objects it freed. */
size_t wo_gc_step(wo_rt *rt, size_t budget);
/* Yuasa deletion barrier half: shade a traced object gray if it is still
* white. Also the root-shading primitive. Safe on any phase; only MARK
* callers need it. */
void wo_gc_shade(wo_rt *rt, wo_hdr *o);
/* Root visitor: shade a traced object, or walk an owned value's interior
* (fields, container elements) shading every traced object it can reach —
* pruned by the per-class may-gcref bit. Used for the root snapshot and by
* the mark phase when it crosses an owned field. */
void wo_gc_scan_root(wo_rt *rt, wo_hdr *o);
/* 1 = the trigger says a cycle should start (idle + traced bytes past the
* goal). The caller (the VM's safepoints, the post-exit pump) snapshots
* roots and calls wo_gc_begin. */
int wo_gc_want_start(const wo_rt *rt);
/* Enter MARK phase. The caller shades the roots (wo_gc_scan_root over the
* live frames' masks) immediately after — before the mutator resumes. */
void wo_gc_begin(wo_rt *rt);
/* One budgeted slice: pops up to `budget` gray objects and scans them;
* when the worklist drains, switches to SWEEP and frees up to `budget`
* unmarked traced objects per slice, repainting survivors white. Returns
* the number freed by this slice. No-op when idle. */
size_t wo_gc_slice(wo_rt *rt, size_t budget);
#endif /* WO_GC_H */

View file

@ -93,40 +93,26 @@ static int load_self_embedded(wo_module *mod, char *err, size_t errlen) {
return rc == 0 ? 1 : -1;
}
/* ---- gc pump -----------------------------------------------------------
* Deliberately the simplest possible driver over the plan-1 collector's
* already-budgeted step interface (wo_gc_step, gc.h): after the entry
* method returns, drain the cycle-candidate buffer in bounded slices until
* it is empty. This is post-exit-only pacing and nothing more — real
* scheduler-integrated pacing (stepping between turns of live work while
* the program keeps running) is sub-project 2's job; this milestone only
* proves the budgeted-step interface end to end and makes it observable.
* WO_GC_TRACE prints one stderr line per step (never stdout — the corpus
* harness diffs stdout byte-for-byte) so a fixture can assert "collection
* happened in bounded slices", not just "the leak is gone". */
/* ---- gc pump (iteration 7b) ---------------------------------------------
* After the entry method returns the stack is empty, so a collection cycle
* has no roots: everything still on the traced list is unreachable and one
* cycle frees it all, in budgeted slices (WO_GC_BUDGET objects per slice —
* rt owns the knobs now, read at init). WO_GC_TRACE prints one stderr line
* per slice (never stdout — the corpus harness diffs stdout byte-for-byte)
* so a fixture can assert "collection happened in bounded slices", not just
* "the leak is gone". A mid-program cycle interrupted by exit is finished
* here the same way. The zero-progress guard turns a would-be hang (a bug)
* into a leak the ASan gate reports instead. */
static void gc_pump(wo_vm *vm) {
size_t budget = 64; /* candidates per step: no prior art to size this
against (post-exit draining is new), so picked
to mirror WO_HEAP_MB's default 64 — small
enough that a deliberately oversized abandoned
structure visibly takes more than one step,
large enough that ordinary programs clear in
one or two */
const char *benv = getenv("WO_GC_BUDGET");
if (benv && benv[0]) {
char *end = NULL;
unsigned long v = strtoul(benv, &end, 10);
if (end && *end == '\0' && v >= 1 && v <= 1000000) budget = v;
}
int trace = getenv("WO_GC_TRACE") != NULL;
size_t step = 0;
while (vm->rt.cycbuf.len > 0) {
size_t before = vm->rt.cycbuf.len;
size_t freed = wo_gc_step(&vm->rt, budget);
step++;
if (trace)
fprintf(stderr, "gc: step %zu budget=%zu freed=%zu visited=%zu remaining=%zu\n", step,
budget, freed, before - vm->rt.cycbuf.len, vm->rt.cycbuf.len);
wo_rt *rt = &vm->rt;
size_t at_begin = rt->gc_traced_cnt;
while (rt->gc_traced) {
if (rt->gc_phase == WO_GC_IDLE) {
at_begin = rt->gc_traced_cnt;
wo_gc_begin(rt); /* no roots: the stack is empty at depth 0 */
}
wo_gc_slice(rt, rt->gc_budget);
if (rt->gc_phase == WO_GC_IDLE && rt->gc_traced_cnt == at_begin) break;
}
}

View file

@ -4,6 +4,8 @@
#include <stdlib.h>
#include <string.h>
#include "gc.h" /* wo_gc_track (traced allocations), wo_drop_obj (teardown) */
static size_t round16(size_t n) { return (n + 15u) & ~(size_t)15u; }
int wo_arena_init(wo_arena *a, size_t cap) {
@ -46,6 +48,45 @@ void wo_arena_free(wo_arena *a, void *p, size_t size) {
a->freelist[cls] = p;
}
/* Per-class "may transitively hold a gcref" fixpoint (iteration 7b): lets
* mark skip owned subtrees that cannot reach a traced object. Field kinds
* alone identify direct gcrefs; for owned/container fields the referenced
* class comes from the v2 field_class metadata when present — absent or
* unknown answers conservatively (may = 1), which is always safe. */
static void compute_gc_may(wo_rt *rt) {
if (rt->class_cnt == 0) return;
rt->gc_may = calloc(rt->class_cnt, 1);
if (!rt->gc_may) return; /* NULL = conservative everywhere */
int changed = 1;
while (changed) {
changed = 0;
for (uint32_t c = 0; c < rt->class_cnt; c++) {
if (rt->gc_may[c]) continue;
const wo_classdesc *cd = &rt->classes[c];
int may = 0;
for (uint32_t i = 0; i < cd->field_cnt && !may; i++) {
uint8_t k = cd->kinds[i];
if (k == WO_K_GCREF) {
may = 1;
} else if (k == WO_K_OWNED || k == WO_K_MULTI || k == WO_K_MAP) {
if (k != WO_K_OWNED && cd->field_elem &&
cd->field_elem[i] == WO_K_GCREF) {
may = 1;
} else if (cd->field_class && cd->field_class[i] < rt->class_cnt) {
if (rt->gc_may[cd->field_class[i]]) may = 1;
} else {
may = 1; /* referenced class unknown: conservative */
}
}
}
if (may) {
rt->gc_may[c] = 1;
changed = 1;
}
}
}
}
int wo_rt_init(wo_rt *rt, size_t heap_cap, const wo_classdesc *classes,
uint32_t class_cnt) {
memset(rt, 0, sizeof(*rt));
@ -53,6 +94,26 @@ int wo_rt_init(wo_rt *rt, size_t heap_cap, const wo_classdesc *classes,
rt->classes = classes;
rt->class_cnt = class_cnt;
rt->out = stdout;
/* collector knobs (iteration 7b). WO_GC_BUDGET = objects per slice;
* WO_GC_GOAL = traced bytes that trigger a cycle; WO_GC_TRACE = one
* stderr line per slice. Defaults: budget 64 (mirrors WO_HEAP_MB's
* default; small enough that an oversized abandoned structure visibly
* takes more than one slice), goal 256 KiB. */
rt->gc_budget = 64;
rt->gc_goal = 256u << 10;
const char *e;
if ((e = getenv("WO_GC_BUDGET")) && e[0]) {
char *end = NULL;
unsigned long v = strtoul(e, &end, 10);
if (end && *end == '\0' && v >= 1 && v <= 1000000) rt->gc_budget = v;
}
if ((e = getenv("WO_GC_GOAL")) && e[0]) {
char *end = NULL;
unsigned long v = strtoul(e, &end, 10);
if (end && *end == '\0' && v >= 1) rt->gc_goal = v;
}
rt->gc_trace = getenv("WO_GC_TRACE") != NULL;
compute_gc_may(rt);
/* Line-buffered, always: a long-running program (the driving workload's
* `watch`/`run`/`mcp` modes) writes progress with `print`, and stdio's
* default full buffering when stdout is a file or a pipe meant that output
@ -64,7 +125,18 @@ int wo_rt_init(wo_rt *rt, size_t heap_cap, const wo_classdesc *classes,
}
void wo_rt_destroy(wo_rt *rt) {
free(rt->cycbuf.items);
/* free whatever the collector still tracks — the post-exit pump should
* have emptied the list, but a teardown after a trap or a test that
* never pumped must still be leak-free. Sweep-order free is safe: a
* traced object's gcref edges are no-ops in class_free (phase is idle),
* and its owned interior has exactly one owner — this object. */
while (rt->gc_traced) {
wo_hdr *o = rt->gc_traced;
rt->gc_traced = o->gclink;
wo_drop_obj(rt, o);
}
free(rt->gc_gray.items);
free(rt->gc_may);
wo_arena_destroy(&rt->arena);
memset(rt, 0, sizeof(*rt));
}
@ -74,11 +146,13 @@ wo_hdr *wo_obj_new(wo_rt *rt, uint32_t class_id) {
size_t sz = wo_obj_size(c);
wo_hdr *o = wo_arena_alloc(&rt->arena, sz);
if (!o) return NULL;
memset(o, 0, sz);
memset(o, 0, sz); /* color: WHITE by construction (all-zero) */
o->class_id = class_id;
if (c->flags & WO_CLASSF_GC) {
o->flags = WO_F_GC;
o->rc = 1; /* the creating reference */
/* born black while a cycle runs: live-at-birth for that cycle */
if (rt->gc_phase != WO_GC_IDLE) o->flags |= WO_COLOR_BLACK;
wo_gc_track(rt, o, sz);
}
return o;
}

View file

@ -25,18 +25,39 @@ void wo_arena_destroy(wo_arena *a);
void *wo_arena_alloc(wo_arena *a, size_t size); /* NULL = region OOM */
void wo_arena_free(wo_arena *a, void *p, size_t size);
/* Collector phase (iteration 7b, gc.c). IDLE -> MARK at the heap-goal
* trigger; MARK -> SWEEP when the gray worklist drains; SWEEP -> IDLE when
* the traced-list cursor reaches the end. The Yuasa deletion barrier is
* active during MARK only. */
enum { WO_GC_IDLE = 0, WO_GC_MARK = 1, WO_GC_SWEEP = 2 };
/* Runtime context: what every module needs. One per shard (one total in
* milestone 1): the arena, the loaded class table, the cycle-candidate
* buffer (filled by gc.c), and the output stream builtin print writes to
* (tests point it at a temp file to capture output). */
* milestone 1): the arena, the loaded class table, the tracing collector's
* state (gc.c), and the output stream builtin print writes to (tests point
* it at a temp file to capture output). */
typedef struct wo_rt {
wo_arena arena;
const wo_classdesc *classes;
uint32_t class_cnt;
/* ---- tracing collector (iteration 7b) ---- */
wo_hdr *gc_traced; /* per-shard traced list: every live traced object */
size_t gc_traced_cnt; /* list length (trace/reporting only) */
struct {
wo_hdr **items;
wo_hdr **items; /* gray worklist: traced objects awaiting a scan */
size_t len, cap;
} cycbuf;
int oom; /* worklist realloc failed: finish the cycle freeing nothing */
} gc_gray;
int gc_phase; /* WO_GC_IDLE / MARK / SWEEP */
wo_hdr **gc_sweep; /* SWEEP: link slot the cursor resumes at */
size_t gc_alloc_bytes; /* traced bytes since the last cycle (trigger) */
size_t gc_goal; /* start a cycle past this many traced bytes */
size_t gc_budget; /* objects processed per slice (WO_GC_BUDGET) */
int gc_trace; /* WO_GC_TRACE: one stderr line per slice */
size_t gc_step_no; /* slices run so far (the trace's step counter) */
uint8_t *gc_may; /* per-class "may transitively hold a gcref" bit —
lets mark skip owned subtrees that cannot reach
a traced object. Computed at init; NULL =
conservative (traverse everything). */
void *out; /* FILE*; kept void* so obj.h needn't pull in stdio */
/* the database engine's handles (database/src), opaque here so the VM
core needn't include engine headers: db = wo_db*, wal = wo_wal*.
@ -50,8 +71,10 @@ int wo_rt_init(wo_rt *rt, size_t heap_cap, const wo_classdesc *classes,
uint32_t class_cnt); /* 0 ok, -1 alloc failure; out = stdout */
void wo_rt_destroy(wo_rt *rt);
/* New zeroed instance of a class-table class. @gc classes get the GC flag
* and rc 1 (the creating reference). NULL = OOM (VM traps WO_T_OOM). */
/* New zeroed instance of a class-table class. A traced (inferred-gc) class
* instance links itself onto the traced list, born white when the collector
* is idle and black during a cycle (live-at-birth for that cycle). NULL =
* OOM (VM traps WO_T_OOM). */
wo_hdr *wo_obj_new(wo_rt *rt, uint32_t class_id);
/* Strings: header + length + inline bytes, class id WO_CLS_STR. */

View file

@ -53,12 +53,49 @@ static void vm_release_frame(wo_vm *vm, uint32_t d, uint32_t pc, uint32_t keep_p
R[r] = 0;
}
if ((ent->gc & bit) && !(keep_gc & bit) && R[r]) {
wo_rc_dec(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
/* a traced reference dying with its frame: tracing owns the
* lifetime, and a mid-cycle root snapshot already shaded it —
* the register just goes away */
R[r] = 0;
}
}
}
/* ---- collector integration (iteration 7b) -------------------------------
* The root snapshot: shade every live frame's gc-masked registers (traced
* objects) and walk its owned-masked registers' interiors (owned values
* that may hold gcrefs). The governing drop entry per frame follows
* vm_unwind's convention — the current instruction for the innermost
* frame (the caller synced f->pc first), the CALL for outer ones. Runs
* once, atomically, when a cycle begins: bounded by the stack, not the
* heap. */
static void vm_gc_roots(wo_vm *vm) {
for (uint32_t d = vm->depth; d > 0; d--) {
const wo_frame *f = &vm->frames[d - 1];
const wo_methodrec *me = &vm->mod->methods[f->method];
uint32_t gpc = (d == vm->depth) ? f->pc : f->pc - 1;
const wo_dropent *ent = vm_dropent(me, gpc);
if (!ent) continue;
const uint64_t *R = vm->regs + f->base;
for (uint32_t r = 0; r < me->reg_cnt; r++) {
uint64_t bit = 1ull << r;
if (((ent->gc | ent->owned) & bit) && R[r])
wo_gc_scan_root(&vm->rt, (wo_hdr *)(uintptr_t)R[r]);
}
}
}
/* One safepoint: start a cycle when the trigger says so (snapshot the
* roots before the mutator resumes), then run one budgeted slice while a
* cycle is live. The caller synced the innermost frame's pc first. */
static void vm_gc_safepoint(wo_vm *vm) {
if (wo_gc_want_start(&vm->rt)) {
wo_gc_begin(&vm->rt);
vm_gc_roots(vm);
}
if (vm->rt.gc_phase != WO_GC_IDLE) wo_gc_slice(&vm->rt, vm->rt.gc_budget);
}
/* Trap unwinding — the spec's "traps never leak" promise (spec §6). Walk
* frames innermost to outermost down to (not including) [stop_depth];
* in each, the governing instruction is the trap pc for the innermost
@ -195,6 +232,18 @@ static int vm_run(wo_vm *vm, uint64_t *ret, wo_err *err) {
return -1; \
} while (0)
/* Collector safepoint (iteration 7b): placed at allocations, calls, and
* loop back-edges — the pcs that already carry drop-table entries, so the
* root snapshot's masks are exact. Costs one predictable branch when the
* collector is idle and the trigger is cold. */
#define GC_SAFEPOINT() \
do { \
if (vm->rt.gc_phase != WO_GC_IDLE || wo_gc_want_start(&vm->rt)) { \
vm->frames[vm->depth - 1].pc = pc - 1; \
vm_gc_safepoint(vm); \
} \
} while (0)
RELOAD();
/* dual-flavor dispatch, one shared case-body text (spec §5): computed
@ -293,6 +342,7 @@ dispatch:
}
CASE(JMP) : {
if (wo_ins_sbx(ins) < 0) GC_SAFEPOINT(); /* loop back-edge */
pc = (uint32_t)((int64_t)pc + wo_ins_sbx(ins));
NEXT();
}
@ -303,6 +353,7 @@ dispatch:
}
CASE(CALL) : {
GC_SAFEPOINT();
/* Lua-style window overlap: callee r0 = caller slot A; args sit at
* A..A+argc-1; the return value lands back in slot A */
const wo_methodrec *callee = &mod->methods[wo_ins_bx(ins)];
@ -356,6 +407,7 @@ dispatch:
}
CASE(NEW) : {
GC_SAFEPOINT(); /* allocation is the trigger's natural home */
wo_hdr *o = wo_obj_new(&vm->rt, wo_ins_bx(ins));
if (!o) TRAPF(WO_T_OOM, "out of memory");
R[wo_ins_a(ins)] = (uint64_t)(uintptr_t)o;
@ -385,6 +437,15 @@ dispatch:
wo_hdr *o = recv_check(vm, R[wo_ins_a(ins)], wo_ins_b(ins), &why);
if (!o) TRAPF(WO_T_BOUNDS, "%s", why);
uint64_t v = R[wo_ins_c(ins)];
/* Yuasa deletion barrier (iteration 7b): overwriting a gcref slot
* while marking deletes an edge the snapshot may depend on — shade
* the OLD target before the store. Inactive outside marking; owned
* fields, scalars and text pay nothing. */
if (vm->rt.gc_phase == WO_GC_MARK &&
vm->mod->classes[o->class_id].kinds[wo_ins_b(ins)] == WO_K_GCREF) {
uint64_t old = wo_fields(o)[wo_ins_b(ins)];
if (old) wo_gc_shade(&vm->rt, (wo_hdr *)(uintptr_t)old);
}
if (v && vm->mod->classes[o->class_id].kinds[wo_ins_b(ins)] == WO_K_TEXT) {
const wo_str *src = (const wo_str *)(uintptr_t)v;
if (src->h.class_id != WO_CLS_STR) TRAPF(WO_T_BOUNDS, "not a text value");
@ -427,18 +488,13 @@ dispatch:
NEXT();
}
CASE(RC_INC) : {
uint64_t v = R[wo_ins_a(ins)];
if (!v) TRAPF(WO_T_BOUNDS, "null receiver");
wo_rc_inc((wo_hdr *)(uintptr_t)v);
NEXT();
}
CASE(RC_DEC) : {
uint64_t v = R[wo_ins_a(ins)];
if (!v) TRAPF(WO_T_BOUNDS, "null receiver");
wo_rc_dec(&vm->rt, (wo_hdr *)(uintptr_t)v);
NEXT();
}
/* iteration 7b: reference counting is retired — tracing owns traced
* lifetimes, so alias bookkeeping means nothing. The opcodes stay
* accepted as no-ops until the emitter stops producing them and the
* format reserves 27–28 (the .wob version bump); a no-op is also what
* deletes the old RC_DEC-on-nil trap that broke `?Node` field stores. */
CASE(RC_INC) : NEXT();
CASE(RC_DEC) : NEXT();
CASE(CONCAT) : {
const char *why;
@ -570,6 +626,7 @@ dispatch:
#undef NEXT
#undef RELOAD
#undef TRAPF
#undef GC_SAFEPOINT
#undef DROP_CATCHES
}

View file

@ -75,26 +75,35 @@ enum {
#define WO_STACK_SLOTS 4096u
#define WO_MAX_FRAMES 256u
/* ---- object header: every heap value carries this (spec section 4) ---- */
typedef struct wo_hdr {
/* ---- object header: every heap value carries this (spec section 4;
* iteration 7b rewrote the second half). Retiring `rc` freed four bytes,
* and traced objects are exempt from borrow rules so their borrow word is
* dead too — those two adjacent words are one union: non-traced values use
* the borrow word, traced objects use the 8 bytes as the intrusive
* sweep-list link. The header stays exactly 16 bytes. ---- */
typedef struct wo_hdr wo_hdr;
struct wo_hdr {
uint32_t class_id; /* class-table index or a WO_CLS_* sentinel */
uint16_t shard_id; /* always 0 in milestone 1; reserved for sub-project 2 */
uint8_t flags;
uint8_t pad;
uint32_t borrow; /* WO_BORROW_FREE / reader count / WO_BORROW_EXCL */
uint32_t rc; /* strong count, @gc objects only */
} wo_hdr;
union {
uint32_t borrow; /* non-traced: WO_BORROW_FREE / readers / EXCL */
wo_hdr *gclink; /* traced: next object on the per-shard traced list */
};
};
_Static_assert(sizeof(wo_hdr) == 16, "object header must be exactly 16 bytes");
/* header flags */
#define WO_F_GC 0x01u /* instance of a @gc class: rc rules apply */
#define WO_F_BUF 0x02u /* sitting in the cycle-candidate buffer */
#define WO_F_GC 0x01u /* instance of a traced (inferred-gc) class */
#define WO_F_CONST 0x04u /* loader-interned constant (strings): free is a no-op */
/* two color bits for Bacon–Rajan trial deletion */
/* two color bits for tri-color incremental mark-sweep (iteration 7b).
* WHITE must be the all-zero value: wo_obj_new memsets the object, so an
* allocation outside a marking cycle is born white by construction. */
#define WO_F_COLOR 0x18u
#define WO_COLOR_BLACK 0x00u
#define WO_COLOR_WHITE 0x00u
#define WO_COLOR_GRAY 0x08u
#define WO_COLOR_WHITE 0x10u
#define WO_COLOR_BLACK 0x10u
/* native class-id sentinels (top of the u32 range; loader rejects user
* class counts anywhere near these) */

View file

@ -1,14 +1,17 @@
/* test_cycle — budgeted Bacon–Rajan cycle collection.
/* test_cycle — incremental tri-color mark-sweep (iteration 7b).
* Cycle classes use the malloc-path trick (~130 fields) so ASan proves
* every free. Budget semantics: a step consumes candidates from the buffer
* (roots popped + whites purged) up to `budget`, whole components atomic. */
* every free. Assertions per the 7b spec: an abandoned cycle is freed, a
* rooted cycle survives, slices are bounded (budget), the traced list is
* leak-free after repeated cycles, and — the load-bearing one — the Yuasa
* deletion barrier keeps an object alive when the mutator hides it behind
* an already-black object between marking slices. */
#include "cont.h"
#include "gc.h"
#include "t.h"
#define BIG 130
/* @gc class "GNode": field0 GCREF, field1 MULTI, rest scalars */
/* traced class "GNode": field0 GCREF, field1 MULTI, rest scalars */
static uint8_t gnode_kinds[BIG];
static wo_classdesc CLASSES[1];
@ -19,75 +22,70 @@ static void setup(void) {
.name = 0, .flags = WO_CLASSF_GC, .field_cnt = BIG, .kinds = gnode_kinds};
}
/* helper: link a->f0 = b, taking a reference on b */
static void link(wo_hdr *a, wo_hdr *b) {
wo_fields(a)[0] = (uint64_t)(uintptr_t)b;
wo_rc_inc(b);
/* run a whole cycle: shade the given roots, then budgeted slices to idle */
static size_t run_cycle(wo_rt *rt, wo_hdr **roots, size_t nroots, size_t budget) {
size_t freed = 0;
wo_gc_begin(rt);
for (size_t i = 0; i < nroots; i++) wo_gc_shade(rt, roots[i]);
while (rt->gc_phase != WO_GC_IDLE) freed += wo_gc_slice(rt, budget);
return freed;
}
static void test_two_object_cycle_collects(void) {
/* an abandoned two-object cycle is unreachable and collects whole */
static void test_abandoned_cycle_collects(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0);
wo_hdr *a = wo_obj_new(&rt, 0);
wo_hdr *b = wo_obj_new(&rt, 0);
link(a, b);
link(b, a);
/* drop both external handles: objects survive on cycle edges alone */
wo_rc_dec(&rt, a);
wo_rc_dec(&rt, b);
T_EQ(rt.cycbuf.len, 2); /* both buffered as candidates */
T_EQ(wo_gc_step(&rt, 16), 2); /* whole cycle freed (ASan proves it) */
T_EQ(rt.cycbuf.len, 0);
wo_fields(a)[0] = (uint64_t)(uintptr_t)b;
wo_fields(b)[0] = (uint64_t)(uintptr_t)a; /* a <-> b, no root */
T_EQ(rt.gc_traced_cnt, 2);
T_EQ(run_cycle(&rt, NULL, 0, 16), 2);
T_EQ(rt.gc_traced_cnt, 0);
/* nothing left: a further cycle frees nothing */
T_EQ(run_cycle(&rt, NULL, 0, 16), 0);
wo_rt_destroy(&rt);
}
static void test_budget_one_cycle_per_step(void) {
/* a rooted cycle survives every cycle that names it a root, then dies the
* moment it is abandoned */
static void test_rooted_cycle_survives(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0);
wo_hdr *a = wo_obj_new(&rt, 0), *b = wo_obj_new(&rt, 0);
wo_hdr *c = wo_obj_new(&rt, 0), *d = wo_obj_new(&rt, 0);
link(a, b);
link(b, a);
link(c, d);
link(d, c);
wo_rc_dec(&rt, a);
wo_rc_dec(&rt, b);
wo_rc_dec(&rt, c);
wo_rc_dec(&rt, d);
T_EQ(rt.cycbuf.len, 4);
/* budget 2 = one two-object component per step */
T_EQ(wo_gc_step(&rt, 2), 2);
T_EQ(rt.cycbuf.len, 2);
T_EQ(wo_gc_step(&rt, 2), 2);
T_EQ(rt.cycbuf.len, 0);
/* nothing left: a further step frees nothing */
T_EQ(wo_gc_step(&rt, 2), 0);
wo_fields(a)[0] = (uint64_t)(uintptr_t)b;
wo_fields(b)[0] = (uint64_t)(uintptr_t)a;
T_EQ(run_cycle(&rt, &a, 1, 16), 0); /* rooted: survives */
T_EQ(rt.gc_traced_cnt, 2);
T_EQ(run_cycle(&rt, &a, 1, 16), 0); /* survives repeated cycles */
T_EQ(rt.gc_traced_cnt, 2);
T_EQ(run_cycle(&rt, NULL, 0, 16), 2); /* abandoned: dies */
wo_rt_destroy(&rt);
}
static void test_externally_held_cycle_survives_then_dies(void) {
/* budget = objects per slice: four dead nodes at budget 2 need two sweep
* slices; each slice frees at most the budget */
static void test_budgeted_slices_bounded(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0);
wo_hdr *a = wo_obj_new(&rt, 0), *b = wo_obj_new(&rt, 0);
link(a, b);
link(b, a);
/* keep the external handle on a; drop only b's */
wo_rc_dec(&rt, b);
T_EQ(rt.cycbuf.len, 1);
T_EQ(wo_gc_step(&rt, 16), 0); /* held from outside: survives */
T_EQ(rt.cycbuf.len, 0); /* candidate consumed, flag cleared */
/* counts fully restored */
T_EQ(a->rc, 2);
T_EQ(b->rc, 1);
T_EQ(a->flags & (WO_F_BUF | WO_F_COLOR), 0);
T_EQ(b->flags & (WO_F_BUF | WO_F_COLOR), 0);
/* still usable, then truly dead */
wo_rc_dec(&rt, a);
T_EQ(rt.cycbuf.len, 1); /* re-buffered on the last external decrement */
T_EQ(wo_gc_step(&rt, 16), 2);
wo_hdr *n[4];
for (int i = 0; i < 4; i++) n[i] = wo_obj_new(&rt, 0);
wo_fields(n[0])[0] = (uint64_t)(uintptr_t)n[1];
wo_fields(n[1])[0] = (uint64_t)(uintptr_t)n[0];
wo_fields(n[2])[0] = (uint64_t)(uintptr_t)n[3];
wo_fields(n[3])[0] = (uint64_t)(uintptr_t)n[2];
wo_gc_begin(&rt);
size_t f1 = wo_gc_slice(&rt, 2); /* mark drains (nothing gray) + sweep 2 */
T_EQ(f1, 2);
T_EQ(rt.gc_traced_cnt, 2);
size_t f2 = wo_gc_slice(&rt, 2);
T_EQ(f2, 2);
T_EQ(rt.gc_traced_cnt, 0);
T_EQ(rt.gc_phase, WO_GC_IDLE);
wo_rt_destroy(&rt);
}
/* a cycle closed through a multi's gcref elements collects with its nodes */
static void test_cycle_through_multi_elements(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0);
@ -95,21 +93,77 @@ static void test_cycle_through_multi_elements(void) {
/* a --(multi element)--> b --(gcref field)--> a */
wo_multi *m = wo_multi_new(&rt, WO_K_GCREF);
T_EQ(wo_multi_push(m, (uint64_t)(uintptr_t)b), 0);
wo_rc_inc(b);
wo_fields(a)[1] = (uint64_t)(uintptr_t)m;
link(b, a);
wo_rc_dec(&rt, a);
wo_rc_dec(&rt, b);
T_EQ(wo_gc_step(&rt, 16), 2); /* multi head + backing freed with a */
T_EQ(rt.cycbuf.len, 0);
wo_fields(b)[0] = (uint64_t)(uintptr_t)a;
T_EQ(run_cycle(&rt, NULL, 0, 16), 2); /* multi head + backing freed with a */
T_EQ(rt.gc_traced_cnt, 0);
wo_rt_destroy(&rt);
}
/* THE BARRIER TEST (spec §7, the design's safety net). Mid-mark, the
* mutator hides a live object: it deletes the only still-white edge to
* `victim` after the object holding it was already scanned black. Without
* the deletion barrier the victim is swept while reachable — silent
* corruption. With it, the delete shades the victim first.
*
* root -> holder -> victim (holder scanned black in slice 1)
* mutator: root.f0 = victim; holder.f0 deleted <- barrier shades victim
* remaining slices must NOT free victim.
*/
static void test_deletion_barrier_keeps_hidden_object(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 1), 0);
wo_hdr *root = wo_obj_new(&rt, 0);
wo_hdr *holder = wo_obj_new(&rt, 0);
wo_hdr *victim = wo_obj_new(&rt, 0);
wo_fields(root)[0] = (uint64_t)(uintptr_t)holder;
wo_fields(holder)[0] = (uint64_t)(uintptr_t)victim;
wo_gc_begin(&rt);
wo_gc_shade(&rt, root);
/* slice 1, budget 1: scans root (blackens it, shades holder) */
(void)wo_gc_slice(&rt, 1);
/* slice 2, budget 1: scans holder (blackens it, shades victim)? No —
* order the hide BEFORE holder's scan would shade victim: rewire now,
* while holder is still gray but victim is white and only holder-held. */
wo_fields(root)[0] = (uint64_t)(uintptr_t)victim; /* hide behind BLACK root */
/* delete holder's edge — the mutator's overwrite; the store path's
* barrier is wo_drop_kind on the old value */
wo_drop_kind(&rt, WO_K_GCREF, wo_fields(holder)[0]); /* shades victim */
wo_fields(holder)[0] = 0;
/* finish the cycle */
while (rt.gc_phase != WO_GC_IDLE) (void)wo_gc_slice(&rt, 1);
/* victim survived: still on the traced list, still readable */
T_EQ(rt.gc_traced_cnt, 3);
T_EQ(wo_fields(root)[0], (uint64_t)(uintptr_t)victim);
/* abandon everything: next cycle frees all three */
T_EQ(run_cycle(&rt, NULL, 0, 16), 3);
wo_rt_destroy(&rt);
}
/* leak-freedom across repeated cycles: allocate, abandon, collect, N times;
* the traced list must end empty every round (ASan proves the frees) */
static void test_repeated_cycles_leak_free(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 18, CLASSES, 1), 0);
for (int round = 0; round < 8; round++) {
wo_hdr *a = wo_obj_new(&rt, 0), *b = wo_obj_new(&rt, 0), *c = wo_obj_new(&rt, 0);
wo_fields(a)[0] = (uint64_t)(uintptr_t)b;
wo_fields(b)[0] = (uint64_t)(uintptr_t)c;
wo_fields(c)[0] = (uint64_t)(uintptr_t)a;
T_EQ(run_cycle(&rt, NULL, 0, 2), 3);
T_EQ(rt.gc_traced_cnt, 0);
}
wo_rt_destroy(&rt);
}
int main(void) {
setup();
test_two_object_cycle_collects();
test_budget_one_cycle_per_step();
test_externally_held_cycle_survives_then_dies();
test_abandoned_cycle_collects();
test_rooted_cycle_survives();
test_budgeted_slices_bounded();
test_cycle_through_multi_elements();
test_deletion_barrier_keeps_hidden_object();
test_repeated_cycles_leak_free();
return t_report("test_cycle");
}

View file

@ -18,21 +18,24 @@ static void test_owned_object_zeroed(void) {
T_EQ(o->class_id, 0);
T_EQ(o->flags, 0);
T_EQ(o->borrow, WO_BORROW_FREE);
T_EQ(o->rc, 0);
T_EQ(wo_fields(o)[0], 0);
T_EQ(wo_fields(o)[1], 0);
wo_arena_free(&rt.arena, o, wo_obj_size(&CLASSES[0]));
wo_rt_destroy(&rt);
}
static void test_gc_object_rc1(void) {
/* iteration 7b: a traced-class instance carries the GC flag, is born white
* (collector idle), and links itself onto the traced list — rt_destroy is
* what frees it, so no manual arena_free here. */
static void test_gc_object_tracked(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 2), 0);
wo_hdr *o = wo_obj_new(&rt, 1);
T_CHECK(o != NULL);
T_CHECK(o->flags & WO_F_GC);
T_EQ(o->rc, 1);
wo_arena_free(&rt.arena, o, wo_obj_size(&CLASSES[1]));
T_EQ(o->flags & WO_F_COLOR, WO_COLOR_WHITE);
T_EQ(rt.gc_traced, o);
T_EQ(rt.gc_traced_cnt, 1);
wo_rt_destroy(&rt);
}
@ -78,7 +81,7 @@ static void test_const_string_survives_free(void) {
int main(void) {
test_owned_object_zeroed();
test_gc_object_rc1();
test_gc_object_tracked();
test_strings();
test_const_string_survives_free();
return t_report("test_obj");

View file

@ -1,4 +1,6 @@
/* test_rc — RC + drop plans: deterministic destruction.
/* test_rc — deterministic drops + the owned/traced boundary (iteration 7b:
* reference counting is gone; this suite now pins what replaced it on the
* owned side, and that owned deaths never free traced objects).
*
* Testing trick used by every memory test from here on: classes get ~130
* fields so instances exceed the 1024-byte size-class ceiling and take the
@ -11,7 +13,7 @@
/* class 0 "Node": field0 OWNED (child Node), field1 TEXT, rest scalars */
static uint8_t node_kinds[BIG];
/* class 1 "Shared" (@gc): all scalars */
/* class 1 "Shared" (traced): all scalars */
static uint8_t shared_kinds[BIG];
/* class 2 "Holder": field0 GCREF, field1 MULTI (of TEXT), rest scalars */
static uint8_t holder_kinds[BIG];
@ -48,19 +50,23 @@ static void test_owned_tree_recursive_drop(void) {
wo_rt_destroy(&rt);
}
/* A holder's gcref field decrements on drop; the final external decrement
* frees the @gc object. */
static void test_gcref_field_decrements(void) {
/* An owned holder dying must NOT free the traced object its gcref field
* points at — tracing owns that lifetime. The object stays on the traced
* list; a rootless cycle then frees it (and rt_destroy would too). */
static void test_holder_death_leaves_traced_alive(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 3), 0);
wo_hdr *shared = wo_obj_new(&rt, 1); /* rc = 1 (creating ref) */
wo_rc_inc(shared); /* holder's reference */
T_EQ(shared->rc, 2);
wo_hdr *shared = wo_obj_new(&rt, 1);
T_EQ(rt.gc_traced_cnt, 1);
wo_hdr *holder = wo_obj_new(&rt, 2);
wo_fields(holder)[0] = (uint64_t)(uintptr_t)shared;
wo_drop_obj(&rt, holder); /* drops holder, decrements shared to 1 */
T_EQ(shared->rc, 1);
wo_rc_dec(&rt, shared); /* final ref gone -> freed (ASan-proven) */
wo_drop_obj(&rt, holder); /* gcref edge is a no-op: shared survives */
T_EQ(rt.gc_traced_cnt, 1);
T_EQ(rt.gc_traced, shared);
/* one rootless cycle reclaims it */
wo_gc_begin(&rt);
while (rt.gc_phase != WO_GC_IDLE) wo_gc_slice(&rt, 16);
T_EQ(rt.gc_traced_cnt, 0);
wo_rt_destroy(&rt);
}
@ -78,26 +84,24 @@ static void test_container_fields_freed_with_holder(void) {
wo_rt_destroy(&rt);
}
/* rc_inc/rc_dec pairing frees exactly at zero. */
static void test_rc_zero_frees(void) {
/* Teardown safety net: traced objects still on the list when the runtime
* dies are freed by rt_destroy itself (a test or a trap path that never
* pumped must still be leak-free — ASan proves the malloc-path frees). */
static void test_rt_destroy_frees_traced_remnants(void) {
wo_rt rt;
T_EQ(wo_rt_init(&rt, 1 << 16, CLASSES, 3), 0);
wo_hdr *s = wo_obj_new(&rt, 1);
wo_rc_inc(s);
wo_rc_inc(s);
T_EQ(s->rc, 3);
wo_rc_dec(&rt, s);
wo_rc_dec(&rt, s);
T_EQ(s->rc, 1);
wo_rc_dec(&rt, s); /* freed here */
wo_rt_destroy(&rt);
(void)wo_obj_new(&rt, 1);
(void)wo_obj_new(&rt, 1);
T_EQ(rt.gc_traced_cnt, 2);
wo_rt_destroy(&rt); /* frees both (ASan-proven) */
T_CHECK(1);
}
int main(void) {
setup_classes();
test_owned_tree_recursive_drop();
test_gcref_field_decrements();
test_holder_death_leaves_traced_alive();
test_container_fields_freed_with_holder();
test_rc_zero_frees();
test_rt_destroy_frees_traced_remnants();
return t_report("test_rc");
}