From 841cb41c6bd999da6c285c619b5c9e56597600dc Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Wed, 19 Aug 2026 16:52:48 +0200 Subject: [PATCH] feat(runtime): incremental tri-color mark-sweep replaces RC (7b Phase 3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- runtime/src/gc.c | 372 ++++++++++++++++++-------------------- runtime/src/gc.h | 71 ++++++-- runtime/src/main.c | 52 ++---- runtime/src/obj.c | 80 +++++++- runtime/src/obj.h | 37 +++- runtime/src/vm.c | 83 +++++++-- runtime/src/wob.h | 29 ++- runtime/test/test_cycle.c | 178 +++++++++++------- runtime/test/test_obj.c | 13 +- runtime/test/test_rc.c | 52 +++--- 10 files changed, 598 insertions(+), 369 deletions(-) diff --git a/runtime/src/gc.c b/runtime/src/gc.c index fd8f01a..32384ab 100644 --- a/runtime/src/gc.c +++ b/runtime/src/gc.c @@ -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 #include #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; } diff --git a/runtime/src/gc.h b/runtime/src/gc.h index a75cbaa..d30a51f 100644 --- a/runtime/src/gc.h +++ b/runtime/src/gc.h @@ -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 */ diff --git a/runtime/src/main.c b/runtime/src/main.c index 674b7ee..e439904 100644 --- a/runtime/src/main.c +++ b/runtime/src/main.c @@ -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; } } diff --git a/runtime/src/obj.c b/runtime/src/obj.c index 6df9ea0..3b11b6c 100644 --- a/runtime/src/obj.c +++ b/runtime/src/obj.c @@ -4,6 +4,8 @@ #include #include +#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; } diff --git a/runtime/src/obj.h b/runtime/src/obj.h index 159bab7..472f37e 100644 --- a/runtime/src/obj.h +++ b/runtime/src/obj.h @@ -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. */ diff --git a/runtime/src/vm.c b/runtime/src/vm.c index 3f05bf0..d3b3553 100644 --- a/runtime/src/vm.c +++ b/runtime/src/vm.c @@ -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 } diff --git a/runtime/src/wob.h b/runtime/src/wob.h index 4d797b6..356f066 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -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) */ diff --git a/runtime/test/test_cycle.c b/runtime/test/test_cycle.c index 8013650..95996d9 100644 --- a/runtime/test/test_cycle.c +++ b/runtime/test/test_cycle.c @@ -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"); } diff --git a/runtime/test/test_obj.c b/runtime/test/test_obj.c index 63828fb..4cf5dd9 100644 --- a/runtime/test/test_obj.c +++ b/runtime/test/test_obj.c @@ -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"); diff --git a/runtime/test/test_rc.c b/runtime/test/test_rc.c index 3028dc3..45c1c1f 100644 --- a/runtime/test/test_rc.c +++ b/runtime/test/test_rc.c @@ -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"); }