writeonce/runtime/src/obj.c
shoney.arickathil 841cb41c6b feat(runtime): incremental tri-color mark-sweep replaces RC (7b Phase 3a)
Reference counting and Bacon-Rajan trial deletion are gone from the runtime.
Traced (inferred-gc) objects now die only by the collector; owned values keep
deterministic drops exactly as before.

- wo_hdr: rc retired; borrow and the freed 4 bytes become a union — non-traced
  values keep the borrow word, traced objects use the 8 bytes as the intrusive
  sweep-list link. Header stays exactly 16 bytes. WHITE is now the all-zero
  color (allocations born white by memset); WO_F_BUF retired.
- gc.c rewritten: snapshot-at-beginning tri-color mark-sweep. Roots (frames'
  gc+owned masks) shaded atomically at cycle start; Yuasa deletion barrier
  shades the OLD target of every gcref edge deleted while marking (SETF
  overwrites + every owned-death path, which all funnel through wo_drop_kind's
  GCREF case); allocations mid-cycle born black. Mark AND sweep budgeted
  (WO_GC_BUDGET objects/slice), sweep resumes via a cursor; gray-worklist OOM
  degrades to a blacken-all cycle (frees nothing, never wrong). Owned interiors
  walked eagerly (single-owner trees), pruned by a per-class may-gcref bit
  computed at rt_init (fixpoint over kinds + v2 field_class/field_elem;
  conservative when metadata is absent).
- vm.c: safepoints at NEW (the heap-goal trigger), CALL, and backward JMP;
  root scan follows vm_unwind's governing-pc convention. Unwind's gc-mask
  branch just nulls the register. RC_INC/RC_DEC are accepted as no-ops until
  the emitter stops producing them (next commit) — which also deletes the old
  RC_DEC-on-nil trap that broke `?Node` gcref field stores.
- main.c pump: post-exit, a rootless cycle frees everything unreachable in
  budgeted slices; the trace line moved into wo_gc_slice (one format for pump
  and in-program slices). rt_destroy frees traced remnants (trap paths, tests).
- WO_GC_GOAL joins WO_GC_BUDGET/WO_GC_TRACE as an rt-owned knob (default 256
  KiB; a tiny goal forces mid-program cycles for testing).
- tests: test_cycle.c rewritten (abandoned cycle freed, rooted cycle survives,
  slices bounded, cycle-through-multi, repeated-cycle leak-freedom, and the
  spec's load-bearing DELETION-BARRIER test: an object hidden behind a black
  object mid-mark must survive). test_rc.c re-pinned to owned drops + the
  owned/traced boundary; test_obj.c asserts tracked-white-linked instead of
  rc=1.

Verified: make test + test-iso (all suites, ASan/UBSan; test_cycle 42/0,
test_rc 14/0) + cli_smoke; oop-e2e 79/0 (gc corpus traces unchanged: the new
slice math reproduces steps=1/freed=2 and steps=2/freed=4); employee 8/0;
log-watcher 7/0. THE RING RUNS: docs/examples/gc-cycle prints
`ring a -> b -> c -> a`, is reclaimed post-exit (freed=3 remaining=0), is ASan
clean, and survives an in-program cycle while rooted (WO_GC_GOAL=64: mid-run
slice frees 0, post-exit frees 3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:52:48 +02:00

191 lines
6.6 KiB
C

#include "obj.h"
#include <stdio.h>
#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) {
memset(a, 0, sizeof(*a));
a->base = malloc(cap ? cap : 1);
if (!a->base) return -1;
a->cap = cap;
return 0;
}
void wo_arena_destroy(wo_arena *a) {
free(a->base);
memset(a, 0, sizeof(*a));
}
void *wo_arena_alloc(wo_arena *a, size_t size) {
size = round16(size ? size : 1);
if (size > WO_ARENA_MAX_CLASS) return malloc(size);
size_t cls = size / 16u - 1u;
if (a->freelist[cls]) {
void *p = a->freelist[cls];
memcpy(&a->freelist[cls], p, sizeof(void *));
return p;
}
if (a->used + size > a->cap) return NULL; /* region OOM -> trap upstream */
void *p = a->base + a->used;
a->used += size;
return p;
}
void wo_arena_free(wo_arena *a, void *p, size_t size) {
if (!p) return;
size = round16(size ? size : 1);
if (size > WO_ARENA_MAX_CLASS) {
free(p);
return;
}
size_t cls = size / 16u - 1u;
memcpy(p, &a->freelist[cls], sizeof(void *));
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));
if (wo_arena_init(&rt->arena, heap_cap) != 0) return -1;
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
* sat in a buffer until exit — so a redirected service looked silent, and
* a killed one lost its log entirely. Content is unchanged, so every
* byte-exact fixture still compares equal. */
setvbuf(stdout, NULL, _IOLBF, 0);
return 0;
}
void wo_rt_destroy(wo_rt *rt) {
/* 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));
}
wo_hdr *wo_obj_new(wo_rt *rt, uint32_t class_id) {
const wo_classdesc *c = &rt->classes[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); /* color: WHITE by construction (all-zero) */
o->class_id = class_id;
if (c->flags & WO_CLASSF_GC) {
o->flags = WO_F_GC;
/* 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;
}
wo_str *wo_str_alloc(wo_rt *rt, uint32_t len) {
wo_str *s = wo_arena_alloc(&rt->arena, sizeof(wo_str) + len);
if (!s) return NULL;
memset(&s->h, 0, sizeof(s->h));
s->h.class_id = WO_CLS_STR;
s->len = len;
return s;
}
wo_str *wo_str_new(wo_rt *rt, const char *bytes, uint32_t len) {
wo_str *s = wo_str_alloc(rt, len);
if (!s) return NULL;
memcpy(s->data, bytes, len);
return s;
}
wo_str *wo_str_concat(wo_rt *rt, const wo_str *a, const wo_str *b) {
wo_str *s = wo_str_alloc(rt, a->len + b->len);
if (!s) return NULL;
memcpy(s->data, a->data, a->len);
memcpy(s->data + a->len, b->data, b->len);
return s;
}
int wo_str_eq(const wo_str *a, const wo_str *b) {
return a->len == b->len && memcmp(a->data, b->data, a->len) == 0;
}
void wo_str_free(wo_rt *rt, wo_str *s) {
if (!s || (s->h.flags & WO_F_CONST)) return; /* interned: outlives all */
wo_arena_free(&rt->arena, s, sizeof(wo_str) + s->len);
}