- done/ (11): 1, 2, 3, 4, 6, 7, 7b, 9, 9b, 15, 16 — landed iterations (9/9b remainders live in the post-12 drain list, not in the files) - refine/ (8): 9c, 9d, 9e, 9f, 9g, 11, 13, 14 — everything marked "no spec yet / brainstorm before planning" - root keeps: 00-story (index), 05 (partial, plan 8 open), 8/10/12 (specs or plans exist), 17 (parked, spec+plan approved), 18 (next) - every cross-reference re-pathed and VERIFIED resolving: board, specs, plans, employee-list README, story table, intra-story links (moved files' relative links deepened one level; done/7b's 9e pointer now crosses to refine/) - pre-existing dead link noted, not touched: refine/11-fibers.md points at docs/plan/exploration/fibers/00-fibers.md which does not exist (predates the move) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
Inferred GC + incremental mark-sweep — implementation plan
Status: COMPLETE (2026-08-18) — all phases landed on branch
inferred-gc;just oop-acceptfully green. Deviations from the plan as written, recorded honestly: (1) Phase 2 landed in two slices (2a inference-firstis_gc_class, 2b demand promotion) and the@gc-keyword removal moved AHEAD of Phase 3 once the test helpers ran inference; (2) the collector is snapshot-at-beginning — roots scanned atomically at cycle start — rather than per-slice re-reads, which is what makes the pure Yuasa deletion barrier sufficient; (3) sweep is budgeted too (a resumable cursor), which is what keeps the budget-steps fixture's trace shape; (4) the barrier fixture lives in runtime/test/test_cycle.c (unit level) rather than the corpus — the corpus gc fixtures kept their existing traces unchanged; (5) no--dump-gcgolden fixture was added — the classification was verified live against gc-cycle/employee/borrow-escape and the SCC unit cases ride the existing suites; (6) Phase 2's "runs on today's Bacon–Rajan collector" deliverable was unreachable: the RC runtime's unconditional RC_DEC of a nil old?Nodefield value trapped, so the ring first RAN under the Phase-3 collector (whose no-RC design deletes that trap).
For agentic workers: use superpowers:executing-plans (inline) or subagent-driven-development. Steps are checkboxes. Per repo rule, this plan carries actions in words + verification commands, no code blocks — the design detail lives in the spec, which travels with this plan.
Goal: Replace developer-annotated @gc + reference counting with
compiler-inferred GC-ness and an incremental per-shard tri-color
mark-sweep collector, so the developer writes no memory annotations and the
whole @gc-RC bug class is deleted by construction.
Architecture: A new compiler pass classifies each class owned or gc
(structural SCC over the class-reference graph + demand promotion from the
ownership pass), feeding the single existing seam Types.is_gc_class. The
runtime retires RC + the Bacon–Rajan trial-deletion collector and grows a
per-shard traced object list swept by a tri-color marker with a Yuasa deletion
barrier; the 16-byte object header is repurposed (rc+borrow → sweep-list link),
so nothing grows.
Tech stack: OCaml stdlib only (compiler, no opam); C11 + libc only
(runtime); dune + make + just; golden + corpus + ASan gates.
Spec: ../specs/2026-08-11-inferred-gc-mark-sweep-design.md
(read it — every task argues from it). Worked example + diagrams:
../../examples/gc-cycle/README.md.
Global Constraints
- OCaml stdlib only; no Menhir/ppx/opam. C11, libc only, direct syscalls.
- The object header stays exactly 16 bytes (
_Static_assert(sizeof(wo_hdr)==16)must survive). ref Bis an id (Copy) and creates no class-graph edge — never traced by structure.- Land inference and the collector together as one coherent change set (dropping
@gcwithout replacing RC re-opens the exact bug class it caused). Phases below may commit incrementally, but the branch is not "done" until the collector is in and RC is gone. - Every GC-ness promotion is reported (a note);
@gcin source becomes a diagnostic, never silently accepted. - Collection is per-shard; no global stop-the-world, no cross-shard tracing, non-moving (no compaction).
- Verification gates, unchanged in name:
just woc-test,just wovm-test,just oop-e2e,just oop-accept; goldens re-blessed withWOC_BLESS=1 dune runtest --root compiler.
Phase 1 — Inference pass, additive (no emit/runtime change)
Deliverable: woc --dump-gc <path> prints the classification; no emitted
bytecode, golden, or runtime behavior changes yet (field kinds still derive
from the annotation, so existing goldens are untouched). Lowest-risk landing.
Task 1.1 — class-reference graph + Tarjan SCC (compiler/src/gcinfer.ml)
Files: Create compiler/src/gcinfer.ml; Modify compiler/src/dune (add gcinfer to modules).
- Build a directed graph over class names: edge
A → BwhenAhas a field whose resolved type isB,?B,multi B,map<B,_>, ormap<_,B>(unwrapNullable).ref Bcontributes no edge. Read fields fromAstclass decls; resolve names via the symbol tabletypes.mlalready builds. - Run Tarjan's SCC (hand-written, stdlib only). Classify a class traced iff it is in a non-trivial SCC or has a self-loop; else owned. Expose
Gcinfer.classify : <syms/classes> -> resultreturning the traced-name set plus, per traced class, the reason (a cycle path for the note). - Verify with a tiny OCaml unit in
compiler/test(or the existing runner) over: self-loop (Node.next: ?Node), mutual recursion (A.b:B,B.a:A),multi Self,map<_,Self>, and theref T-creates-no-edge case. Runjust woc-test; expected PASS. - Commit.
Task 1.2 — --dump-gc mode + golden
Files: Modify compiler/bin/main.ml (argv dispatch + usage), compiler/src/dump.ml (renderer). Test: new golden under compiler/test/golden/.
- Add a
--dump-gc <path>mode: run lex→parse→types, callGcinfer.classify, print oneName<pad>owned|gc<pad>(reason)line per class in declaration order (the spec's--dump-gcartifact shape). Reason is the cycle path for structural, empty for owned. - Add the usage line and the mode to the dispatch match (beside
--dump-owner). - Add a golden fixture: run
--dump-gcoverdocs/examples/gc-cycle(expectNode gc (cycle Node -> Node),Segment owned) and over the pricing corpus subset. Bless withWOC_BLESS=1. - Run
just woc-test; expected PASS, and existing goldens unchanged (no emit path touched). Commit.
Phase 2 — Demand promotion + rewire field kinds to the inferred set (still on RC runtime)
Deliverable: field GCREF-ness and owner exemptions derive from inference, not
the annotation; @gc in source is an error; the gc-cycle sample compiles and
runs on today's Bacon–Rajan collector. Goldens re-blessed. This proves the
front-end end to end before the collector swap.
Task 2.1 — demand half (promotion from ownership escapes)
Files: Modify compiler/src/gcinfer.ml, compiler/src/owner.ml (add a collect-promotions mode).
- Add a mode to the ownership pass that, instead of emitting
WO-E304/long-lived-alias errors, records the offending class.Gcinferruns owner in this mode, unions the recorded classes into the traced set, and re-runs — terminating because the set only grows (bounded by class count). Each demand promotion carries its escape-site note. - Verify: a
PriceCache-shaped fixture (acyclic, aliased) classifiesgc (alias escape, …)via--dump-gc.just woc-testPASS. Commit.
Task 2.2 — inference is the source of GC-ness; annotation errors
Files: Modify compiler/src/types.ml (is_gc_class reads the inferred set), compiler/src/parser.ml (@gc arm → diagnostic), compiler/src/dump.ml (stop rendering @gc), compiler/src/emit.ml/disasm.ml (class-flag provenance only; bit unchanged). Error: new WO-E1xx in docs/plan/oop-vm/01-error-catalog.md.
- Thread the inferred traced-set into the typing context so
Types.is_gc_classanswers from it. Field-kind derivation (→WO_K_GCREF) and everyowner.mlexemption then follow with no further change (that is the seam). - Turn the parser's
@gcacceptance into a WO-E1xx diagnostic pointing at inference +--dump-gc. Update the error catalog. - Re-bless every golden that rendered
@gc,flags=gc, or a changed GCREF field kind (WOC_BLESS=1). Convert thegc/corpus fixtures to drop@gcfrom source (they rely on inference now). - Verify:
docs/examples/gc-cyclenow emits (no WO-E301 — traced classes alias freely) and runs on the current runtime, printingring a -> b -> c -> a.just woc-test,just oop-e2ePASS. Commit.
Phase 3 — Runtime collector swap (the coherent landing)
Deliverable: RC and trial deletion are gone; the incremental tri-color
mark-sweep with the Yuasa barrier reclaims cycles; .wob version bumped. This
is the largest phase and lands with Phase 2's front-end.
Task 3.1 — header rewrite + per-shard traced list
Files: Modify runtime/src/wob.h (wo_hdr), runtime/src/obj.h (wo_rt list head + wo_obj_new links traced objects), runtime/src/gc.{c,h}.
- Repurpose the header: retire
rcand (for traced objects)borrow; give those 8 contiguous bytes to a 64-bit intrusive sweep-list link. Keep colors in the existingWO_F_COLORbits. The_Static_assert(sizeof(wo_hdr)==16)must still hold. wo_rtgains a traced-list head;wo_obj_newlinks a traced-class instance (class-flag bit set) in as white. Sweep recovers size viawo_obj_size(class table). RetirecycbufandWO_F_BUF.- Verify build both dispatch flavors:
just wovm-build+make -C runtime test test-iso. Commit.
Task 3.2 — tri-color incremental mark + Yuasa barrier + budgeted sweep
Files: Modify runtime/src/gc.{c,h}, runtime/src/vm.c (roots via pc gc-mask; barrier in SETF/map_set/push; retire RC_INC/RC_DEC cases; safepoints at back-edges/calls).
- Delete trial deletion (
mark_gray/scan_black/scan_/collect_white/white_free/zombie guard). Implement: roots = value/frame slots read via the per-pc gc-mask; grey worklist; mark budgetWO_GC_BUDGET; owned objects traversed-not-freed, skipping subtrees via the precomputed "transitively-contains-gcref" class bit; sweep frees white + unlinks, repaints black→white; heap-goal trigger;WO_GC_TRACEper-slice counts. - Yuasa deletion barrier: on a store into a
GCREFslot while marking, shade the old value grey. Lives in the VM store paths (no new opcode). - Verify:
just wovm-test. Commit.
Task 3.3 — emitter + format: retire RC, restate the gc-mask, bump .wob
Files: Modify compiler/src/emit.ml (drop emit_rc + escape-acquire anchor; gc-mask now = GC roots), compiler/src/owner.ml (delete rc table/elision/resolve_rc/release_gc/clobber rule), interpreter (opcodes 27–28 reserved), docs/plan/oop-vm/00-wob-format.md (version bump + reserved opcodes + gc-mask contract), runtime/src/loader.c if it validates opcodes.
- Stop emitting
RC_INC/RC_DEC; reserve the opcodes; bump the.wobversion in the format doc + loader constant. Restate the drop-table gc-mask contract as "GC roots at this pc".wo_drop_kindforWO_K_GCREFbecomes a no-op. - Re-bless all affected goldens (
--dump-bc,--dump-owner, disasm) withWOC_BLESS=1. - Verify:
just woc-test,just oop-e2e,just oop-accept. Commit.
Task 3.4 — fixtures: adversarial barrier + cycle rewrites
Files: Modify runtime/test/test_cycle.c, runtime/test/test_rc.c; corpus tests/corpus/gc/{abandoned-cycle,budget-steps,held-cycle}; add an adversarial barrier fixture.
- Rewrite
test_cycle.c/test_rc.coff rc assertions onto: abandoned cycle freed, rooted cycle survives, slices bounded, sweep-list leak-free after N cycles. Re-blessabandoned-cycle/budget-stepstraces; redefineheld-cycleas "a cycle rooted from a live frame survives a slice" (from inside a running program). - Add the barrier fixture: hide a traced object between slices (store into a blackened object, drop the original ref); it must survive with the barrier in and be freed (corruption) with it compiled out — the design's safety net.
- Verify:
just wovm-test,just oop-accept, ASan clean after repeated cycles. Commit.
Phase 4 — Migration: amend the normative docs
Files: docs/00-principles.md (principle 3), the OOP spec (decision table, §3 rule 5, §4 memory model), docs/plan/oop-vm/00-wob-format.md, 01-error-catalog.md (retire WO-W201, update WO-E304 wording, add the new WO-E1xx), 08-builtin-surface.md (delete the push special case + set gap), docs/00-status.md (record 7b superseding iteration 2's memory model), docs/stories/language-runtime-database/done/07b-inferred-gc-mark-sweep.md (status → done), docs/examples/gc-cycle/README.md (flip "Run status" to shipped + wire a just gc-cycle acceptance).
- Apply each amendment in the spec's §8 migration table. Add a
docs/examples/gc-cycleacceptance script +just gc-cyclerecipe running--dump-gc+ ring/owned demos underWO_GC_TRACE. - Verify:
just oop-acceptgreen;just gc-cyclegreen;git grep '@gc' -- '*.wo'returns nothing (success criterion 1). Commit.
Success criteria (from the spec §Success criteria)
- No
.wocontains@gc; using it is a diagnostic. --dump-gcclassifies every class; each traced reason is a cycle path or a named escape.RC_INC/RC_DECin no emitted image; opcodes reserved in the format doc.- The adversarial barrier fixture fails barrier-out, passes barrier-in.
- An abandoned cycle collects within budgeted slices; a rooted cycle survives.
- Whole corpus ASan-clean, including after repeated cycles — closing milestone-1 criterion 3.
Self-review notes
- Spec coverage: §1 inference → Ph1+2.1; §2 emitter → 2.2/3.3; §3 header+list → 3.1; §4 mark+barrier → 3.2; §5 error handling → inherent (no new mechanism); §6 deletions → 3.2/3.3; §7 testing → 3.4; §8 migration → Ph4. No gaps.
- Order risk: Phase 1 is provably golden-neutral (no emit path touched). Phase 2 first changes emitted output (field kinds) → re-bless. Phase 3 is the format bump → biggest re-bless. Phases 2+3 must ship together to satisfy the "coherent landing" constraint even though committed as steps.