Skip to content

Merge ARC branch into main#87

Merged
NiceAndPeter merged 49 commits into
mainfrom
phase1/arc-stack
Jul 5, 2026
Merged

Merge ARC branch into main#87
NiceAndPeter merged 49 commits into
mainfrom
phase1/arc-stack

Conversation

@NiceAndPeter

Copy link
Copy Markdown
Owner

Summary

  • bring the ARC/runtime branch onto main
  • update GitHub Actions to validate the current Moon runtime surface
  • include the recent header splits, MoonVector removal, and identifier cleanup

Validation

  • local build
  • ARC regression
  • LuaTestSuite

Peter Neiss and others added 30 commits June 27, 2026 17:37
Wires the automatic-reference-counting discipline through the interpreter as an
accounting layer, with deterministic freeing gated behind MOON_ARC_DRAIN (off by
default). Default builds therefore keep Phase-0 behavior (full retain/release
bookkeeping, nothing freed) — verified ASan-clean and running the full feature
set (closures, metatables, varargs, coroutines, recursion, errors, sort).

Model: objects are born with refcount 1; a rooted slot (stack register, table
slot, upvalue) owns one reference. Writes are assign (retain new, release old),
creation is transfer (release old, move the birth reference in), scalar/nil
overwrites release the old collectable, and stack shrink (return / settop-down /
discard) releases+nils abandoned slots. Invariant: every in-range stack slot is
nil-or-owned, so every "release old" is safe; new Lua frames are nil-initialized
at precall. L->top floats below ci->top during execution, so release-on-shrink
is applied only at frame teardown, never to intra-function top moves.

Engine (mgc): incref/decref + slot helpers (slotRetain/Release/Assign/Init);
deferred moonC_drain reclaims a pending queue at safe points, revival-safe and
idempotent via a per-object ARCQUEUEDBIT (overlays the defunct generational age
bit 0). moonC_drainEnabled latches MOON_ARC_DRAIN once; when off, decref neither
queues nor frees, keeping the 'marked' bits pristine for the tracing-GC test
invariants.

Wired:
- Stack primitives (mstack): setSlot/copySlot/setNil + releaseRange/setTopArc/
  popNArc.
- Call/return (mdo): moveResults/genMoveResults, tailcall down-move, tryFuncTM
  shift (ARC-neutral + retain-only metamethod), Lua-frame nil-init at preCall,
  coroutine arg discard.
- VM registers (mvirtualmachine): MOVE, LOAD*, GET/SETUPVAL, GET{TABUP,TABLE,I,
  FIELD} (release-old + retain-fetched), SET{TABUP,TABLE,I,FIELD} via arcStore
  (mirrors Table::set without double counting), NEWTABLE/CLOSURE transfer, SELF,
  UNM/BNOT/NOT/LEN, arithmetic/bitwise/shift, SETLIST array fast-path. Drain is
  called from the checkGC safe point.

Validation: default build (drain off) ASan-clean; test_arc reclaims a controlled
acyclic graph (3/3) and leaks a cycle (0); testes/arc_smoke.lua with
MOON_ARC_DRAIN=1 shows real reclamation (peak RSS ~891MB -> ~347MB).

NOT yet UAF-safe with drain on: C-side root references (state init, C API pushes,
interpreter arg-passing, closure upvalues, string table, Proto constants) are not
retain-wired, so some objects reach 0 while still live. The release side is
wired but C-side acquires use raw writes — fixing that asymmetry is the next
increment. Full diagnosis and an ordered plan in docs/ARC_PHASE1_STATUS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tries

Adds an env-gated underflow oracle (MOON_ARC_CHECK) to moonC_decref: a refcount
underflow means an object was released more often than retained, i.e. an acquire
site is missing its retain. It fires even in the safe drain-off build, printing
the object type and a backtrace, so running ordinary scripts pinpoints exactly
where retains are missing. Using it, fixed the asymmetries that made the
execution path under-count:

- mapi reverse() (used by rotate/insert/remove): was a half-raw/half-setSlot
  swap that over-counted one operand and UNDER-counted the other (a real bug,
  not just ARC). Now a pure raw swap — a permutation is reference-count-neutral.
- mapi moon_settop: shrink now releases popped slots (setTopArc).
- mapi moon_copy: now an ARC assign (retain new, release old).
- mapi get-family (auxgetstr/gettable/geti/finishrawget/getmetatable): retain the
  fetched value the stack slot now owns (gettable also releases the replaced key).
- mtm moonT_adjustvarargs: erase the original function slot when moving it to the
  vararg frame base — it was duplicated (raw copy, original left in place) and so
  released twice. Now a clean move.
- mtm moonT_getvarargs: retain varargs copied into result registers.

Result: the execution path is now underflow-clean for ordinary scripts; the only
remaining oracle hit is at shutdown (io file __gc finalizers via
callallpendingfinalizers). Default build unaffected (drain off): ASan-clean,
test_arc green, full feature smoke passes.

Note: drain-on is still not UAF-safe — the oracle catches release>acquire, but
strings reaching 0 while still interned in the string table / referenced by Proto
constants is an under-count the oracle cannot see. Those (string-table free-unlink,
Proto constant retains) and the shutdown finalizer path remain (task #8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…API stores

Two fixes that make deterministic freeing (MOON_ARC_DRAIN=1) UAF-safe for simple
programs:

1. moonC_changemode was NOT neutered: lua_gc(GCGEN/GCINC) — which the interpreter
   calls at startup — ran a real generational collection (entergen -> sweep2old),
   freeing objects behind ARC's back; moonC_drain then touched freed memory. Now
   inert (records the kind only), matching the neutered moonC_step/moonC_fullgc.
   This also means the default build no longer collects via lua_gc, consistent
   with the fork's "no tracing GC" stance.

2. C-API table stores bypassed ARC accounting (parallel to the VM fast paths):
   - moon_settable/moon_setfield(auxsetstr)/moon_seti now go through a new
     apiStore() helper (capture old, retain new value + new collectable key, run
     the fast/slow store, release old) and release the popped operands (popNArc).
     moon_rawset/rawseti already used the ARC-aware Table::set/setInt.
   - moon_setmetatable retains the new metatable and releases the old one for
     table/userdata objects (arc_decrefchildren decrefs the metatable on free, so
     it must be retained on set). Global per-type metatables are roots — untouched.

Result under MOON_ARC_DRAIN=1 + ASan: trivial scripts, table build/field scripts,
and each feature in isolation (closures, metatables, varargs, coroutines,
recursion, strings, pcall, sort) are now use-after-free clean. Remaining
under-counts (task #8): closure-captured upvalues, the string table / Proto
constants (heavy string churn), some feature combinations, and the shutdown
finalizer path. Default build unaffected: drain-off ASan-clean, test_arc green,
feature smoke passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_callTMres)

moonT_callTMres moved the metamethod's result into 'res' with a raw copy
(*res = *--top), which (a) did not release res's old value and (b) left a stale
duplicate of the result in the abandoned source slot. When that slot was later
reused, the ARC slot discipline released the duplicate, double-freeing the
result (use-after-free for table-returning metamethods like __add/__index).

Now a proper ARC transfer: res takes ownership (retain new, release old via
moonC_slotAssign), then the source's reference is released and the slot erased so
it is not released again. Fixes UAF for metamethods that return a fresh object.
Default build unaffected: drain-off ASan-clean, test_arc green, feature smoke OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cat)

VirtualMachine::concat (OP_CONCAT and lua_concat) folded N string operands into
one result with raw stack writes: it overwrote the first operand slot with the
result without releasing it, left stale duplicates in the "first operand empty"
fast path, and popped the consumed operands raw (no release). With deterministic
freeing on, the result string was double-freed / consumed operands leaked.

Now: release the first operand before placing the result (transfer the new
string's birth reference into the slot); the empty-first-operand path is a proper
move (release old, move, erase source); consumed operands are released via
popNArc. Fixes the use-after-free for table.concat / '..' chains under
MOON_ARC_DRAIN=1.

Default build unaffected: drain-off ASan-clean, test_arc green, concat correct.

Remaining for drain-on UAF-safety (task #8): the string table / Proto constants
under-count (interned/constant strings reach 0 while still referenced — needs
freeobj string-table unlink + retaining Proto constants), closure-captured
upvalues, and the shutdown finalizer path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the MOON_ARC_CHECK underflow-oracle methodology, the acquire/release
asymmetries fixed so far (now trivial/table/all-individual-features are UAF-clean
under MOON_ARC_DRAIN=1), and the precisely characterised remaining work: error
-object propagation, string table / Proto constants, closure upvalues, shutdown
finalizers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d upvalues

With these the full feature smoke (closures, metatables incl. __add, varargs,
coroutines, recursion, strings/gsub, pcall/error, table.sort) runs use-after-free
clean under MOON_ARC_DRAIN=1 + ASan.

- Free-region pushes: setSlot (assign: retain new, release old) was used to push
  onto free slots that can hold stale garbage left by raw pops, double-releasing
  it. New MoonStack::pushSlot (retain new, no release) is used for the genuine
  pushes: moon_pushvalue/getiuservalue/getupvalue and the metamethod arg pushes
  in moonT_callTM/callTMres.
- Error propagation: moon_State::setErrorObj moved the error object with a raw
  copy + setTopPtr, leaving a stale duplicate and not releasing the discarded
  frame. Now a proper ARC transfer (release old dest, move, erase source) plus
  setTopArc to release the abandoned frame.
- Closures: pushClosure now retains each captured upvalue (the closure owns it;
  arc_decrefchildren decrefs it on free).
- arc_decrefchildren made consistent with what is actually retained on acquire:
  it decrefs table children + metatables + LCL upvalues only. Proto
  constants/source/nested protos, C-closure upvalues, and userdata user-values
  are referenced uncounted (load-time / not-yet-wired), so they are skipped to
  avoid under-count — they leak until those acquire sites are wired (task #8).

Default build unaffected: drain-off ASan-clean, test_arc green, feature smoke OK.

Remaining (task #8): string table / Proto constants under heavy churn (retain at
compile/undump), C-closure upvalues, userdata user-values, shutdown finalizers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Short-string interning under-counted shared strings, freeing them while still in
use (use-after-free during the next intern's bucket/cache compare under heavy
string churn):

- internshrstr returned an existing interned string on a hit WITHOUT
  incrementing its refcount, while the miss path returns a fresh string with
  birth refcount 1. The caller's reference was therefore uncounted on hits. Now
  a hit increments, matching the miss contract (the result always carries one
  owning reference for the caller).
- TString::create(const char*) used g->strcache, a cache of raw (uncounted)
  TString pointers keyed by C-string address; those dangle once ARC frees a
  string. Bypass it and intern directly (the cache is a pure speed optimization;
  reinstating it under ARC is a Phase-6 concern).

Now use-after-free clean under MOON_ARC_DRAIN=1 + ASan: trivial/table/error/full
-feature smoke AND the heavy string/table churn stress (testes/arc_smoke.lua),
with real reclamation (peak RSS ~891MB -> ~348MB). Default build unaffected:
drain-off ASan-clean, test_arc green, feature smoke passes.

Remaining (task #8): leak-tightening only (no UAF) — load-time retains for Proto
constants/source/nested protos, C-closure upvalues, userdata user-values; plus
the shutdown finalizer path. These currently leak by design (arc_decrefchildren
skips them) rather than under-count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the step 3-6 fixes (metamethod result transfer, error propagation,
free-region pushes, closure upvalues, conservative arc_decrefchildren, string
interning) and the validation status: trivial/table/error/full-feature smoke,
heavy string-table churn (arc_smoke), and a broad stress (pairs/gmatch/format/
nested closures/__index chains/table error objects/coroutines) all run
use-after-free clean under MOON_ARC_DRAIN=1 + ASan, with real reclamation.

Remaining task #8 items are leak-tightening (Proto constants, C-closure upvalues,
userdata user-values) or oracle-only transients (shutdown finalizers; pairs/
ipairs genMoveResults self-assignment) — none a known UAF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s (hot-loop leaks)

Deterministic freeing leaked in hot loops that create functions; ARC now bounds
memory for these (each previously grew without limit; now flat ~4MB):

- VM fast-path returns OP_RETURN0/OP_RETURN1 did the poscall inline with raw
  stack ops (setTopPtr / raw result copy), so every Lua call leaked its function
  slot and frame (postCall/moveResults, which release, were only used on the slow
  hook path). Now they release the returning frame: setTopArc for the discard
  cases, and setSlot(func,result)+setTopArc for the one-result case. This is the
  big one — it affects ALL Lua function calls.
- moonF_closeupval: a closed upvalue now retains the value it captures (released
  in arc_decrefchildren) and drops its birth/open-list reference, so upvalues are
  reclaimed once their capturing closures die.
- arc_decrefchildren: handle UPVAL (decref the closed value) and re-enable CCL
  (C-closure upvalues, now retained on acquire).
- moon_pushcclosure: retain the upvalues the C closure captures; release the
  source slots (popNArc).

Validated (MOON_ARC_DRAIN=1 + ASan, use-after-free clean): feat, big, arc_smoke,
coroutine loop, pairs loop. Memory bounded for closure/upvalue/metatable/
C-closure hot loops. Coroutine-creation loops improved ~8x (295MB -> 38MB);
fully reclaiming threads needs suspended-stack traversal (task #8, remaining).
Default build unaffected: test_arc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record step 7: function-call/closure/upvalue/C-closure leaks fixed; memory is
bounded under MOON_ARC_DRAIN=1 for tables, strings, closures, captured upvalues,
metatables, C-closures, and function-heavy code. Remaining: full thread
reclamation (suspended-stack traversal), userdata user-values, Proto constants
(bounded load-time leak), shutdown finalizers, and the pairs/ipairs oracle
transient — none a known UAF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
arc_decrefchildren now handles THREAD: a dead (abandoned) coroutine owns the live
values on its own stack (retained by its execution), so release them, mirroring
the GC's thread traversal over [stack, top). The stack array is still valid at
this point; freeobj frees it afterward. Open upvalues are left for now (they
leak, never under-count).

Combined with the earlier C-closure-upvalue fix (which lets the wrapper release
the thread), coroutine-creation loops are now bounded: the closures+metatables+
coroutines hot loop drops from ~466MB to ~4.3MB, use-after-free clean.

Validated UAF-clean under MOON_ARC_DRAIN=1 + ASan: triv/tab/err/feat/big/
arc_smoke and the combined hot loop. Memory bounded for all common hot-loop
patterns (tables, strings, closures, upvalues, metatables, C-closures, function
calls, coroutines). Default build: test_arc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…izer/debug

More setSlot-as-push sites that wrote free-region slots (which can hold stale
garbage left by raw pops) with assign semantics, double-releasing that garbage:
- callclosemethod (mfunc): pushes the __close metamethod + self/err.
- GCFinalizer (gc_finalizer): pushes the __gc finalizer + its argument.
- moon_getinfo (mdebug): pushes the function for the 'f' option.
- pushobject (mtests): test-harness push.

All switched to MoonStack::pushSlot (retain new, no release of the unowned old).
The callclosemethod one was confirmed by the MOON_ARC_CHECK oracle (a table
released below zero via __close, used by luaL_Buffer / string.dump).

Validated UAF-clean under MOON_ARC_DRAIN=1 + ASan: triv/tab/err/feat/big/
arc_smoke/coroutines. Default build: test_arc green, feature smoke passes.

Known remaining (drain-on): a gradual table under-count in debug/load-heavy code
(testes/db.lua) where a global table (e.g. 'string') is freed while still
referenced — surfaced via the real test suite under drain-on, not yet pinpointed.
Drain-on stays off by default, so this does not affect the shipped build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tupvalue

Four systemic missing-retain / over-release sites that caused long-lived
tables to be under-counted and freed while still referenced under drain-on
(found by tracing the db.lua drain-on UAF with the MOON_ARC_CHECK oracle):

- OP_TFORCALL (mvirtualmachine.cpp): the generic-for dispatch copied the
  iterator function, state, and control into the call's argument slots with
  raw assignments. The iterator call's postCall then released those slots,
  releasing references that were never acquired -- under-counting the loop
  state by 1 every iteration (e.g. the table passed to pairs/ipairs). Use
  setSlot so the owned arg slots retain on copy.

- Table::tableNext / moon_next (mtable.cpp, mapi.cpp): the hash path wrote the
  value into the free slot above 'top' with setSlot, releasing the stale
  free-region value (over-release); the key slot was raw-overwritten without
  releasing the old key or retaining the new. Now: release old key, retain new
  key, and retain (not assign) into the free value slot. moon_next's
  no-more-elements path releases the owned key (popNArc) instead of a raw pop.

- moon_load (mapi.cpp): the _ENV upvalue of a freshly loaded chunk was set to
  the globals table with a raw assignment -- every load() under-counted the
  globals table by 1. Use slotAssign.

- moon_setupvalue (mapi.cpp): same raw upvalue write; now retains the new value
  / releases the old and releases the popped stack arg.

Default build is drain-off and unaffected. Verified: full testes/all.lua green
(drain-off; only the pre-existing main.lua rebrand env-var failure remains),
test_arc green, and all ARC repros + arc_smoke.lua clean under
MOON_ARC_DRAIN=1 MOON_ARC_CHECK=1. db.lua under drain-on now progresses past
the pairs/generic-for and next UAFs; one residual debug-hook-specific
under-count (debug.getinfo inside a hook + numeric-for register layout)
remains for a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n-on

A C function can leave stale, unowned references in its register window above
its results -- e.g. debug.getinfo transiently pushes a value into a high slot
while building the result table, then drops it with a raw stack pop. Those
slots become the caller's free region, and the VM's release-before-write
discipline (vmClearReg on the next LOADI/arith/etc.) then releases a value the
frame never owned, freeing a still-live object under drain-on.

This was the residual db.lua drain-on UAF: a debug hook calling
debug.getinfo + a numeric for-loop over its fields freed the getinfo result
table early (register-layout-sensitive, hence intermittent-looking). Distilled
repro now clean: hook doing `local ar=debug.getinfo(2,"ruS"); local a=ar.ftransfer;
local b=ar.ntransfer; for i=a,a+b-1 do end` under debug.sethook(...,"cr").

Fix: in preCallC, after the C function returns, raw-nil [top, ci->top) so the
free region is clean. Gated on moonC_drainEnabled() -- in the default
accounting-only build nothing is freed, so the spurious release is harmless and
the default build pays zero cost.

db.lua under drain-on now clears the entire debug-library and
parameter/return-inspection sections (previously aborted at the first hook).
One further over-release (a closure, later in db.lua) remains for follow-up.
Verified: testes/all.lua green under default drain-off (only the pre-existing
main.lua rebrand env-var failure), test_arc green, all ARC repros +
arc_smoke.lua clean under MOON_ARC_DRAIN=1 MOON_ARC_CHECK=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ocal

moon_getlocal pushed a copy of the inspected local into the free stack slot with
a raw assignment (no retain); when that local is collectable -- e.g. the
temporary holding a closure mid-construction, as a line hook sees during
`local A = function() ... end` -- the unowned copy was later released by the
caller's frame teardown, freeing a still-live function under drain-on. Use
pushSlot (retain into the free slot). moon_setlocal had the symmetric raw write
into the target frame slot: now slotAssign (retain new / release old) + popNArc.

With this, db.lua under drain-on now clears every ARC site and reaches the same
point as the default build: db.lua:895 (a bitwise-operator metamethod test)
fails identically with drain on or off -- a pre-existing non-ARC fork bug, same
category as nextvar.lua:611. So db.lua is ARC-clean end-to-end up to that
pre-existing failure.

Verified: testes/all.lua green under default drain-off (only the pre-existing
main.lua rebrand env-var failure), test_arc green, all ARC repros + arc_smoke
clean under MOON_ARC_DRAIN=1 MOON_ARC_CHECK=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ethod ops

Two more over-release sites surfaced by running errors.lua under drain-on with
MOON_ARC_CHECK:

- errorMsg() (message-handler path): raw-copied the error object and the handler
  function into the call slots without retaining. The handler call's postCall
  then released both, over-releasing the error object (table) and the handler.
  Fix: pushSlot the error object into the free slot, slotAssign the handler over
  the old slot (retain new / release old). Reproduced by xpcall(fn, handler) with
  error({}).

- OP_UNM / OP_BNOT / OP_LEN: these did vmClearReg(ra) (release ra's old value)
  unconditionally before dispatching, plus a trailing retain. On the metamethod
  path moonT_callTMres already does an ARC-correct store into ra (and retains the
  operand as an argument first, so it is safe even when ra == rb). The extra
  pre-clear released the operand early and, crucially, left it un-nil'd: an error
  raised by the absent metamethod (e.g. `-aaa` / `~aaa` / `#aaa` on a wrong type)
  then released that same slot again during the frame unwind, under-counting a
  live value (a global table in the errors.lua repro) until a later SETTABUP
  release drove it negative. The trailing retain also double-retained metamethod
  results (a latent leak). Fix: vmClearReg only in the scalar branches; let
  callTMres own the metamethod store. For OP_LEN, objlen now releases ra's old
  value itself in the primitive (table/string length) paths -- reading the length
  before the release so ra == rb is safe -- and moon_len nils its fresh API slot
  so that release is a no-op there.

Verified: testes/all.lua green under default drain-off (only pre-existing
main.lua rebrand failure); db.lua under drain-on still reaches the pre-existing
db.lua:895 with no ARC underflow; test_arc green; all ARC repros + arc_smoke
clean under MOON_ARC_DRAIN=1 MOON_ARC_CHECK=1. Remaining under drain-on:
errors.lua (now a later OP_GETFIELD site) and events.lua (OP_RETURN site).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etamethod path

The __index slow path double-accounted ra's old value and the result. The VM GET
opcodes (GETTABUP/GETTABLE/GETI/GETFIELD/SELF) did vmClearReg(ra) [release old] +
fastget(raw) + trailing retain, but finishGet's __index-FUNCTION path calls
moonT_callTMres which does a FULL slotAssign (release ra's old + retain result).
So on the __index-function slow path ra's old was released twice (UAF under
drain-on) and the result retained twice (leak). The C API get-family
(auxgetstr / moon_gettable / moon_geti) had the same double-retain (leak; it does
not pre-clear, so no UAF there). This was the events.lua chained-__index and
errors.lua underflows.

Fix: unify finishGet's contract -- it now owns its store (release val's old +
store new owned) on every path: the nil path releases old before setnil; the
__index-table path fetches into a temp and slotAssigns; the __index-function path
keeps moonT_callTMres (already slotAssign). Callers no longer pre-clear or
post-retain around finishGet; they apply that only on their own fast (fastget)
path. The VM opcodes save ra's old value, and on a fast hit retain the borrowed
result + release old (one 16-byte stack copy added to the hot path); on the slow
path they restore old and let finishGet store. The API callers retain only on the
fast path and pass a releasable old value (key / nil) into finishGet -- which also
fixes a latent placeholder-string leak on the API __index paths.

Verified: errors.lua under drain-on now reaches the pre-existing mdo.cpp:113
assert (same as drain-off); chained/function/table __index + SELF+string-metatable
repros clean; db.lua still reaches pre-existing db.lua:895; all.lua green under
default drain-off (only pre-existing main.lua rebrand failure); test_arc + all
repros/smokes clean under MOON_ARC_DRAIN=1 MOON_ARC_CHECK=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moonT_getvarargs stored each vararg into the destination registers with
slotAssign (retain new + release old). But OP_VARARG's destinations are always
fresh free-region registers (the compiler allocates new slots for a vararg
expansion, and the multret path raw-extends top into them), so the release-old
released stale free-slot garbage -- over-releasing whatever object's address
lingered there. Surfaced by events.lua's `function(...) cap={[0]=op,...}; return
(...) end` metamethods, where a table operand passed through `...` was freed
while still live under drain-on.

Fix: treat the destinations as a free region -- raw-copy each value and retain
it (no release of the prior garbage), and nil-fill raw. Mirrors the pushSlot
discipline used elsewhere for free-region writes. Only caller is OP_VARARG.

With this, events.lua under drain-on is ARC-clean (reaches the pre-existing
events.lua:186 comparison-metamethod failure, identical drain-off/on), and
errors.lua reaches the pre-existing mdo.cpp:113 setErrorObj assert (also
identical drain-off/on).

Verified: vararg + select repros clean; all ARC repros/smocks clean; db.lua
drain-on 0 underflows (still reaches pre-existing db.lua:895); all.lua green
under default drain-off (only pre-existing main.lua rebrand failure); test_arc
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Coroutine error handling relied on Lua's un-owned-duplicate trick (the error
object left in stale stack bits and read from multiple places), which the ARC
model cannot represent: the source-erase added to setErrorObj for ARC destroyed
the duplicate, so resetthread (during coroutine.close / coroutine.wrap re-raise)
found a nil error slot and tripped mdo.cpp:113. Confirmed ARC-introduced: the
pre-ARC base handles these cases; the regression is purely in the ARC error-path
changes.

Root-cause fix -- move the coroutine error exactly once, with single ownership:
- auxresume (mcorolib): no longer moves the error off 'co'. It classifies the
  failure via the coroutine's prior status: a *suspended* coroutine that errors
  is a genuine coroutine error left on 'co' (ERR_ON_CO); a dead/running one
  yields only a resume *setup* error, a transient consumed onto 'L' (ERR_ON_L).
- coresume: for ERR_ON_CO copies (retains) the error to 'L' while leaving 'co's
  copy in place, so a later coroutine.close(co) can still report it (Lua reports
  a dead coroutine's error from both resume and close). co==L (self-resume) and
  ERR_ON_L need no copy.
- auxwrap: for ERR_ON_CO closes 'co's TBC (a __close may replace the error) then
  moves it once to 'L'.
- setErrorObj (mdo): on a self-move (oldtop == top-1, e.g. resetthread
  repositioning an error already at the base) leave the error fully untouched --
  the previous code released oldtop and/or erased the source, destroying it.

coroutine.lua under drain-off now advances from line 69 to line 478 (a weak-table/
collectgarbage assertion inherent to the neutered-GC fork). Repros clean under
MOON_ARC_DRAIN=1 MOON_ARC_CHECK=1: coroutine.wrap+error, create+resume+error,
close-after-error, double-close idempotency, self-resume, dead-resume.

Verified: test_arc green; db.lua drain-on still 0 underflows (reaches pre-existing
db.lua:895); all.lua green under default drain-off (only pre-existing main.lua
rebrand failure). One further drain-on underflow remains later in coroutine.lua
(a closure over-release after the close tests) for follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…drain-on)

Under drain-on, moonC_drain freed objects that were registered for finalization.
Such objects live on the 'finobj' list (not 'allgc'), so arc_unlink -- which only
walks 'allgc' -- failed to unlink them, and freeobj then left a dangling pointer
in 'finobj'. At state close, GCFinalizer::separatetobefnz walked 'finobj' and
dereferenced the freed memory (assert tofinalize(curr) at gc_finalizer.cpp:198).
This crashed every drain-on program that registered a __gc finalizer, at exit
(e.g. strings.lua: all tests pass, then aborts during teardown).

Fix: in moonC_drain, skip objects with FINALIZEDBIT set (checkFinalizer sets it
when moving an object to 'finobj'). They are left intact for the finalizer
machinery, which runs __gc and reclaims them (and their children) at lua_gc /
state close. They stay at refcount 0 and are not re-queued. A finalizable object
is therefore not deterministically ARC-freed; deterministic __gc-on-last-release
is a later enhancement.

Verified: strings.lua now completes cleanly under MOON_ARC_DRAIN=1 (was aborting
at shutdown); a 100x __gc-table loop runs clean under drain-on+CHECK; test_arc
green; db.lua drain-on 0 underflows; all.lua green under default drain-off (only
pre-existing main.lua rebrand). Default build unaffected (drain-gated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ransfer

moon_xmove is a transfer, not a copy: the destination takes each reference
without a retain. But it only did half the job -- the source's vacated slots
were neither released nor nil'd, leaving stale (already-moved) owned pointers
in 'from's free region. When a coroutine later resumed and OP_MOVE reused one
of those slots, the slotAssign there would release a reference xmove had
already handed to the destination, double-counting it.

Symptom: a closure capturing an open upvalue, yielded across a thread boundary
and reused on later resumes, was over-released at teardown (ouv.lua aborted the
MOON_ARC_CHECK oracle on a type-70 closure underflow at OP_RETURN).

Fix: nil the vacated source slot after each transfer (raw setnil, no release,
since ownership has moved). This makes xmove a correct, balanced transfer.

ouv.lua + all coroutine error/close repros now clean under
MOON_ARC_DRAIN=1 MOON_ARC_CHECK=1; coroutine/errors/events/strings/math/
vararg/constructs/locals all 0 underflows. test_arc green; default drain-off
build unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moon_upvaluejoin rewired a closure's upvalue pointer with a bare
'*up1 = *up2', skipping ARC accounting entirely. A closure owns each upvalue
it points at (counted in pushClosure, or held as the birth ref from
initUpvals, and released in arc_decrefchildren's LCL case), so overwriting the
slot must release the old target and retain the new one.

Without that, the new target is under-counted (every join adds an uncounted
reference) and the old target leaks. debug.upvaluejoin joining many upvalues
onto a single target (calls.lua's "dump/undump with many upvalues" test, 200
joins) under-counted that target by ~200; when the closure was later freed,
arc_decrefchildren decref'd it that many times -> UPVAL released below 0
(MOON_ARC_CHECK oracle abort during execution).

Fix: treat '*up1' as an owned reference slot -- incref the new target (before
the barrier) then decref the old, which is safe even when up1 and up2 already
coincide.

calls.lua now 0 underflows under MOON_ARC_DRAIN=1 MOON_ARC_CHECK=1 (and now
also passes the default drain-off build). Oracle sweep across calls/coroutine/
errors/events/strings/math/vararg/constructs/locals/nextvar/db/gc/sort/
bitwise/pm/api: all 0 underflows. test_arc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…M path)

VirtualMachine::finishSet and Table::finishSet anchor the target table (resp.
the internalized string key) on the stack with a raw sethvalue2s/setsvalue2s +
push, then call into moonH_newkey, which can throw (OOM during rehash/resize).
The matching pop() never runs on the throw, leaving an un-retained raw
duplicate of the table/string in the stack's free region. The error-unwind
releaseRange then releases both the real owning register and that duplicate ->
object released below 0 (double free under drain-on).

Found via memerr.lua's allocation-injection (a numeric for-loop filling a
table, OOM during an array grow): the table 'a' was aliased into a scratch
register by finishSet's anchor and double-released by setErrorObj's setTopArc.

Fix: anchor as a properly owned slot -- pushSlot (retain) before the throwing
call, popNArc (release + nil) after. Balanced on both the normal and the throw
path. Affects any drain-on program that errors while a table store is creating
a new key.

Oracle sweep (calls/coroutine/errors/events/strings/math/vararg/constructs/
locals/nextvar/db/gc/sort/bitwise/pm/api) still 0 underflows; test_arc green;
default drain-off build unaffected. (memerr.lua still surfaces a later,
separate OOM-path string underflow -- see task #8.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moon_setiuservalue wrote the user-value slot with a raw copy (no retain-new /
release-old) and arc_decrefchildren's USERDATA case skipped user-values --
a matched gap: stored values were never owned by the userdata, so they leaked
under drain-on (and would have been a UAF had anything counted on them).

Wire the matched pair:
- moon_setiuservalue: slotAssign into the user-value slot (retain new, release
  old; created nil by moonS_newudata so the first assign releases nil), then
  popNArc the consumed argument (also covers the out-of-range failure path,
  which previously raw-popped a live value).
- arc_decrefchildren USERDATA: decref each user-value (mirrors the acquire).

Sole acquire site is moon_setiuservalue (getiuservalue already uses pushSlot;
creation nils; gc_marking/mtests are tracing/test only). Added a test_arc case:
a full userdata owning a table in user-value 1 -> dropping the userdata
reclaims both (2 objects). Oracle sweep still 0 underflows; default drain-off
build unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moon_pushthread pushed the running thread with a raw setthvalue + incr_top --
no retain. The stack slot is nonetheless released as an owned slot when it is
later popped or overwritten, debiting the thread a reference it never had.
Each coroutine.running() (or C-side moon_pushthread) round-trip inside a
coroutine thus leaked one release; after enough of them the thread's count
reached 0 while real owners (the script local holding the coroutine, the wrap
closure's upvalue) were still live, and drain freed it under them. The next
register write touching a slot with the dangling thread then retained freed
memory: heap-use-after-free in GCObject::retain (caught by ASan drain-on;
invisible to the MOON_ARC_CHECK oracle, which only flags counts going below
zero).

Root-caused with a temporary drain-time scanner that walks the live object
graph when a THREAD is freed: coroutine.lua's foo() (line 35) showed the dying
thread still referenced by the main chunk's local 'f' (main-thread stack slot
+8) and an open upvalue over it -- books short by exactly the un-retained
pushes.

Fix: moonC_slotRetain after the store, making the pushed slot a properly
owned reference like every other push (moon_pushvalue et al).

Entire core suite now ASan-clean under MOON_ARC_DRAIN=1 (calls, coroutine,
errors, events, strings, math, vararg, constructs, locals, nextvar, db, sort,
bitwise, pm, api). Oracle sweep still 0 underflows; test_arc green; default
drain-off build unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deterministic ARC freeing is now the default. MOON_ARC_DRAIN=0 opts out into
the old accounting-only mode (bookkeeping runs, nothing freed -- kept as a
bisection/debugging fallback). The flag is still latched on first use.

Gate evidence (steps 10-22 complete; no known drain-on UAF holes in real
programs):
- Pass/fail parity: every core test produces the identical result and failure
  line under the new default as under the opt-out (all remaining failures are
  pre-existing fork issues: neutered-GC weak tables, missing test modules,
  rebrand gaps).
- MOON_ARC_CHECK oracle: 0 underflows across the core suite under the default.
- ASan (Debug+ASAN+UBSAN): clean across the core suite under the default.
- test_arc green (pins MOON_ARC_DRAIN=1 explicitly against ambient env).
- Perf/memory (Release, 300k-iteration table+closure+string churn): default
  0.111s / 4.4 MB peak RSS vs opt-out 0.120s / 126 MB -- drain-on is slightly
  faster and uses 28x less memory.

Also updated docs/ARC_PHASE1_STATUS.md: flip banner, task-#8 completion
summary (steps 18-22 root causes), corrected the stale "still remaining"
list (userdata user-values and shutdown finalizers are done; Proto graphs
remain the one substantial leak-tightening item).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Protos and everything they pin were never freed: constants, nested protos,
and source were populated uncounted, arc_decrefchildren's PROTO case was a
no-op, and closures did not own their protos. Every load()/compile leaked its
whole proto graph. Wired in lockstep (retain at every population site + the
matching releases), per the ownership model:

Acquire:
- pushClosure: the closure retains its (existing, parent-owned) nested proto.
  The parser/undump *main* closures instead receive a fresh proto's birth
  reference -- a transfer, no retain.
- addk (parser) / loadConstants (undump): the proto retains each stored
  constant.
- open_func / undump loadFunction: the proto retains its source string
  (retain-new-before-release-old, so the main proto's same-string re-store
  stays count-neutral over its birth transfer from moonY_parser).
- addprototype / loadProtos: child protos are birth transfers into the
  parent's p[] slot.

Release (arc_decrefchildren):
- LCL: also releases cl->proto (ctor nulls it, so partially-constructed
  closures tear down safely).
- New PROTO case: releases constants (nil-prefilled on growth), nested protos
  (null-prefilled), and source. Debug-name strings stay uncounted borrows,
  deliberately not released (they live on their creation references).

Also fixed three raw anchor pops that leaked their tables and pinned
everything in them: close_func's kcache, moonY_parser's scanner table, and
moonU_undump's saved-strings table (pop -> popNArc).

Validation: core suite pass/fail and failure lines identical; oracle sweep 0
underflows; ASan sweep clean (incl. dump/undump paths in calls.lua); test_arc
green. New targeted tests: protolife.lua (escaped closures keep constants +
source alive after the chunk dies, plain + oracle) and load()-churn benches:
20k unique chunks now 10.6 MB / 0.08s vs 76.5 MB / 0.11s before (~16x lower
residual slope; remainder is the known lexer string-anchor convention, see
docs/ARC_PHASE1_STATUS.md). Binary-chunk churn flat at 7.6 MB. Standard bench
unchanged (0.111s / 4.4 MB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wn fixes

Two coupled change-sets: making the compiler/undump string anchors transfers
(so unique strings can reach zero), and fixing the frame-teardown bugs that
chasing the residual slope then exposed. Unique-chunk load() churn is now
FULLY bounded: live bytes exactly flat, 20k-chunk RSS 4.25 MB (was 10.6 MB
after the proto-graph commit, 76.5 MB before it).

String anchors become transfers (lockstep with counting debug names):
- LexState::anchorStr and undump loadString hand the caller-owned intern
  reference to their anchor table (scanner table / S->h) and return a borrow;
  the long-string dedupe path now actually frees the fresh duplicate.
- Debug-name fields become counted: retained in registerlocalvar / newupvalue
  / mainfunc's env upvalue and undump loadDebug/loadUpvalues; released in
  arc_decrefchildren's PROTO case. (Without this, the anchor transfer would
  leave getinfo names dangling -- the lockstep the plan called for.)

Frame teardown: the release bound is the frame window (ci->top), not L->top.
L->top legally sits below owned registers (results at returns; dead
temporaries of expired scopes between calls; assertion builds park top at the
frame base before every non-top-reading instruction, making top-bounded
teardown a no-op):
- New MoonStack::closeFrame(newtop, frameEnd) releases
  [newtop, max(top, frameEnd)). Used by moveResults/genMoveResults (frameEnd
  from the returning CallInfo -- recomputed after __close, which can move the
  stack; C frames keep top), the inline OP_RETURN0/OP_RETURN1 poscall, and
  preTailCall (which also nil-inits callee-window excess, since tail calls
  bypass preCall's nil-init). Previously every Lua return leaked all registers
  above its results -- in assertion builds, the entire frame.
- OP_CALL/OP_TAILCALL/OP_TFORCALL release the caller's dead temporaries
  [top, ci->top) before the call. preCall/preCallC's raw window nil-init was
  wiping those owned slots uncounted (each 'local f = load(...)' loop
  iteration leaked the previous chunk closure + proto graph). Caller-side is
  the correct place: the frame's own window at a clean opcode boundary, where
  nil-or-owned holds unconditionally -- callee-side releasing hit stale unwind
  residue in coroutine frames (oracle-caught).
- setErrorObj releases the destination's old value only when it is a live
  slot (oldtop < top). moon_resume passes oldtop == top to push the error onto
  a dead coroutine; that free-region slot's stale content is unowned, and
  releasing it debited a reference the slot never held (oracle underflow on
  the "stack overflow" message, exposed by the widened releases).

Validation: entire core suite -- identical pass/fail and failure lines; oracle
sweep 0 underflows; ASan sweep clean; test_arc green; protolife (constants,
source, and debug names survive chunk death) clean plain + oracle. Known
residual: metamethod calls bypass the dead-temp release (small bounded leak,
documented). Cost: ~7% on an allocation/call-heavy microbench (0.111s ->
0.119s, equal to the old accounting-only timing) for exact dead-temporary
reclamation. docs/ARC_PHASE1_STATUS.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Peter Neiss and others added 19 commits July 4, 2026 08:17
Metamethod invocations bypass OP_CALL's caller-side dead-temporary release.
On most VM paths that is harmless: protectCall raises top to ci->top before
the TM machinery runs, so the callee window starts above the caller's and
nothing overlaps. But paths that enter with top parked below the frame window
-- notably the concat TM, reached via protectCallNoTop with top lowered onto
the operands -- build the call at top, and the TM frame's raw window nil-init
then wipes the caller's owned dead-temporary slots uncounted, leaking them.

Fix at the two choke points every TM call funnels through (moonT_callTM /
moonT_callTMres): for a Lua frame, release+nil [top, ci->top) before pushing
the call. This is a no-op on the raised-top paths and exactly right on the
lowered-top ones; operand and result slots always sit below top there, outside
the released range. C frames are skipped (region above top is unowned
scratch), and the __close path (callclosemethod) is deliberately untouched --
it runs during unwind where the window can hold stale residue (the lesson
from the callee-side attempt).

A/B on a targeted probe (__concat TM with a unique-string dead temporary
parked above the call): 0.0765 KB/iter leaked before, exactly flat after.
Oracle sweep 0 underflows; ASan sweep clean; suite parity (identical failure
lines); test_arc green; bench unchanged (~0.120s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nlink twups

Investigation item from the drain work: arc_decrefchildren's THREAD case
carried an "open upvalues are intentionally left (they leak)" note. Auditing
the actual free path shows they are already handled correctly: when drain
frees a thread, freeobj -> moonE_freethread -> moonF_closeupval closes every
open upvalue -- each retains its stack value (revival-safe against the
just-released [stack, top) refs from decrefchildren) and drops its open-list
birth reference, leaving it owned exactly by its capturing closures. Verified
empirically: abandoned-suspended-coroutine churn (open upvalue + escaped
closure per iteration) is exactly flat; a closure OUTLIVING its drain-freed
coroutine still reads the correct closed-over value (no UAF, ASan clean);
closure-dies-first ordering also flat. Comment corrected.

One real latent bug found and fixed alongside: the global 'twups'
(threads-with-open-upvalues) chain was only ever pruned by the tracing GC's
remarkupvals, which never runs in the fork -- a thread freed while linked left
a dangling moon_State pointer in the chain (undereferenced today, but a
landmine for anything that walks twups). moonE_freethread now unlinks the
thread and self-marks it not-in-list.

Validation: new couv (bound/outlive/closurefirst) + twups interleaved-free
stress clean under plain, oracle, and ASan; full oracle sweep 0 underflows;
full ASan sweep clean; suite parity (identical failure lines); test_arc green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ablement

Getting testes/all.lua running end-to-end surfaced two real engine bugs and
motivated a real feature; test files are adapted only where they assert
removed-by-design tracing-GC semantics.

Engine fix 1 -- comparison metamethod results read as nil (pre-existing):
moonT_callTMres's epilogue assumed 'res' was a live slot distinct from the
call result. The comparison TMs (equalobj/callorderTM) and moon_len's __len
path pass res == pre-call top, which is exactly where the call lands its
result: the assign-then-nil erased the result before its tag was taken, so
every __eq/__lt/__le result became nil -> false (db.lua:895, events.lua:186).
New alias contract: the tag is captured first and the value is left owned in
the slot; value consumers (moon_len) raise top over it, tag-only consumers
(equalobj, callorderTM) release it via setNil.

Feature -- deterministic finalization (Swift-deinit semantics): drain now runs
an object's __gc at its last release instead of deferring to state close:
unlink from 'finobj', hand to the standard machinery (GCTM: relink to allgc,
clear FINALIZEDBIT, protected call), then reclaim -- resurrection-safe (rc>0
after __gc keeps the object; __gc runs once). Objects already owned by the
close machinery (on 'tobefnz') are left to it. This makes collection-driven
finalizer tests (db.lua's "repeat until finalizer") pass for the right reason.

Engine fix 2 -- stale frame cache after drain-run finalizers: __gc bodies can
reallocate the stack; correctPointers flags every Lua frame's trap, but the
VM's checkGC lambda ran drain after its trap refresh, so the next vmfetch
never resynced stackFrameBase (assert/corruption in files.lua). checkGC now
re-reads the trap after moonC_drain.

Suite adaptations (marked MOON):
- all.lua: add ./?.lua to package.path (moon modules use .mn; the suite's
  helper modules tracegc/bwcoercion are .lua); skip gc.lua/gengc.lua/
  gcmodes.lua (they test the removed tracing collector).
- closure.lua: replace the weak-table wait-for-GC loop (inert weak tables
  would spin forever) with bounded garbage churn.

Result: db.lua, events.lua, closure.lua, nextvar.lua now pass fully
(previously: assert failures at db.lua:895/events.lua:186/nextvar.lua:637 and
a closure.lua hang). Core sweep: 12 files OK, 0 oracle underflows, ASan clean
on all touched paths, test_arc green. all.lua proceeds deep into the suite;
remaining failures (files.lua handle-flush, api.lua abort, coroutine.lua:478
weak-table assert, attrib.lua) are the next iteration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…end-to-end

Root-cause fixes:
- moonC_condGC is now the ARC drain point (was: VM checkGC only), so
  C-side allocation checkpoints and collectgarbage()/moonC_fullgc reclaim
  deterministically; emergency fullgc and state close (GCSTPGC|GCSTPCLS)
  stand down. Fixes io-handle flush-by-__gc (files.lua) and all
  finalize-on-collectgarbage semantics.
- Multi-state safety for the shared pending queue: drain proves ownership
  by allgc/finobj membership and holds over other states' objects;
  moonC_purgePending + queue suppression at moonC_freeallobjects stop
  dangling entries after a state closes (testC newstate/doremote).
- Drain leaves objects re-marked for finalization by their own __gc on
  'finobj' instead of freeing them under it (testes/tracegc.lua pattern).
- anchorStr's scanner anchor is now a properly owned slot; the old raw
  copy + plain pop left a stale unowned copy above top that frame sweeps
  double-released (freed live interned identifiers; the layout-dependent
  files.lua/api.lua corruption family).
- finishOp (resume after yield in metamethods) now transfers TM results
  with release-old/vacate-source instead of raw moves.
- Eager table-entry deletion: writing nil over a live node releases the
  key and marks it dead (Swift-dict removal semantics; tracing GC did
  this lazily via clearkey during traversal); wrappers only retain keys
  for real insertions.
- setErrorObj retains the preregistered memory-error message (its slot
  debited an unowned ref -- freed the shared string under the state).
- Cross-thread error rethrow (doThrow to main thread) retains the copy.
- C API accounting: aux_rawset/rawseti release popped operands,
  moon_rawget releases the overwritten key, moon_setmetatable releases
  the popped metatable (and owns global per-type metatables),
  moon_closeslot releases the closed value, auxgetstr/auxsetstr drop the
  key's create-ref.

Test adaptations (MOON): api.lua GC-timing/cycle/order sections map to
deterministic ARC semantics; coroutine.lua inert weak table; goto.lua
probes 'global' reservedness instead of T; attrib.lua moonopen_ rebrand.

Validation: testes/all.lua -e '_U=true' => 'final OK' on plain, on
MOON_ARC_CHECK oracle, and on ASan builds; test_arc ALL OK; 23-file
standalone sweep green; constructs.lua 0.93s vs 1.23s baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compute into the nil'd free slot above top instead of straight over the
first operand: the raw-arith path stores raw, so a coerced string operand
("10"+"5") leaked, and the popped second operand / fake unary duplicate
were never released. The metamethod path's callTMres alias contract
(res == pre-call top) leaves the result owned one past the restored top,
matching the raw path's scalar write; both transfer into place.

Validated: api.lua arith tests (raw, coercion, metamethod variants) green
under MOON_ARC_CHECK with and without _U; all.lua 'final OK'; test_arc OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deterministic frees paid an O(live-objects) walk of 'allgc' per object,
which is quadratic for eviction patterns: freeing 2000 of the oldest
objects in a 200k-object heap took 0.95s (now 0.000s).

- GCObject grows a third word: 'prevnext' points at the slot that points
  at the object (list head or predecessor's 'next'); linkAt()/unlinkList()
  maintain it on allgc/finobj/tobefnz/fixedgc. Layout guard updated to
  3 words; the derived-type guards recheck themselves.
- Rewired mutators: moonC_newobjdt, drain (both unlink sites now O(1)),
  GCObject::fix, checkFinalizer (its own O(n) search removed too),
  udata2finalize, separatetobefnz, and moon_newstate's raw main-thread
  placement (back-link would have been garbage).
- Multi-state ownership walks are now gated on a live-state census
  (moonC_stateOpened/Closed from moon_newstate/close_state): single-state
  runs trust the back-link; only testC-style multi-state runs pay walks.
- fix() takes an owning reference: fixed objects (reserved-word strings,
  memErrMsg) can never reach refcount zero, so a stray release can no
  longer free a keyword string and reset its reserved-word 'extra' tag.

Validation: unlink bench 0.947s -> 0.000s; all.lua 'final OK' on plain,
MOON_ARC_CHECK, and ASan builds; test_arc ALL OK; key-file sweep green;
constructs.lua 0.99s and 3M-table churn 0.200s (no measurable header
cost).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
moonU_dump creates an auxiliary string-dedup table (D.h) and anchors it on
the stack via sethvalue2s + push, but never pops it -- relying on the caller
restoring 'top'. moon_dump restored 'top' with the raw setTopPtr, which under
ARC moves the stack pointer back over D.h's slot WITHOUT releasing the birth
reference the slot owns. So every string.dump leaked D.h and, transitively,
every string it referenced: measured ~614 bytes per dump (bisected to the
dump call; load/run/table/string churn were all clean).

Fix: restore with the ARC-aware setTopArc, which releases the abandoned
anchor (and its referenced strings). This is the same idiom already used at
mapi.cpp:144 ("release popped slots on shrink").

Impact: string.dump per-call leak 614 -> 0.03 bytes (residual is unrelated
string interning). This unblocks memerr.lua's dump/undump phase, which uses
testbytes' absolute-memory sweep and therefore never converged while the
leaked baseline outgrew the 7-byte/iter limit step; it now converges at
"minimum memory for dump/undump: 4375 bytes".

Validated: bisect/clean repros show the leak gone with no over-release under
MOON_ARC_CHECK; testes/all.lua 'final OK' on plain, MOON_ARC_CHECK, and ASan
(_U) builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…riers

The tracing collector was already neutered (Phase 0/1): moonC_step/fullgc/
changemode returned early and nothing marked or swept. This removes the dead
machinery wholesale.

- Write barriers: moonC_barrier/objbarrier/barrierback[back] are now inlined
  no-ops (ARC never marks objects black, so they could never fire); the impl
  functions moonC_barrier_/moonC_barrierback_ are deleted. Call sites remain
  as no-ops for now and will be removed in a follow-up.
- Deleted gc_marking.{cpp,h}, gc_weak.{cpp,h}, gc_collector.{cpp,h} (mark/
  sweep/generational/incremental driver) + their CMake entries.
- gc_sweeping reduced to just deletelist() (the close_state teardown walk);
  the incremental/generational sweep methods are gone. checkFinalizer drops
  its dead isSweepPhase() branch (no sweep phase exists under ARC).
- mgc.cpp: removed the mark/traverse sections (reallymarkobject dup,
  propagatemark/propagateall, traverseweakvalue) and every generational/
  incremental wrapper (minor2inc, youngcollection, setminordebt, entergen,
  fullgen, singlestep, incstep, fullinc). moonC_step/fullgc/changemode bodies
  reduced to their ARC behavior; moonC_runtilstate kept as a no-op so the
  T.gcstate test hook still links.
- mgc.h: dropped decls for propagateall, traverseweakvalue, traverseephemeron,
  getmode.

Kept (ARC-live): allocation (moonC_newobj*), refcount engine (incref/decref/
drain/release), object destruction (freeobj/freeupval), finalizer infra
(gc_finalizer, checkFinalizer, separatetobefnz, callallpendingfinalizers,
correctpointers), gc_core helpers. Object header color/age bits and
GlobalState gray/generational lists remain for now (unused storage) and will
be stripped in the next increment.

Net: -2177/+44 lines. Validated: testes/all.lua 'final OK' on plain,
MOON_ARC_CHECK, and ASan (_U) builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the tracing-GC deletion: removes the now-dead helper code that
survived because it was reachable only from other (also-dead) tracing code.

- mgc.cpp: deleted correctgraylist and the gray-list linking helpers
  (getgclist, linkgclist_, linkgclist, linkgclistTable, linkgclistThread,
  linkobjgclist), plus the dead GlobalState method bodies clearGrayLists,
  setPause, setMinorDebt, checkMinorMajor, correctGrayLists (all had zero
  callers once the collector was gone).
- gc_core: dropped getgclist, linkgclist_, clearkey (gray/weak machinery);
  GCCore now provides only objsize and freeupval, both ARC-live.

No behavior change (all removed code was unreachable). GlobalState still
declares these methods and holds the gray/generational list fields; those
declarations and fields are stripped in the next increment. testes/all.lua
'final OK' (_U, plain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The finalizer module is ARC-live (registers __gc via checkFinalizer, runs it
via drain/GCTM), but still carried generational/sweep branches that are all
no-ops under ARC (the generational lists stay nullptr, there is no sweep
phase). Simplified to the ARC behavior:

- separatetobefnz: traverse the whole 'finobj' list (finobjold1 is always
  nullptr); dropped the finobjsur fix-up.
- udata2finalize: dropped the isSweepPhase() "sweep" and the age==Old1
  setFirstOld1 branches.
- Deleted correctpointers + checkpointer (only corrected the generational
  survival/old1/reallyold/firstold1 pointers — all nullptr under ARC, so a
  no-op) and its call in GCObject::checkFinalizer.
- Deleted checkSizes (string-table shrink hook that only the deleted collector
  called).

The GlobalState generational/gray/weak/sweep FIELDS themselves remain as
unused storage: their only surviving references are in mtests.cpp's tracing-GC
consistency checker (checkmemory) and gc_query, which belong to the Phase 5
test-suite rework. The object header is already near-minimal (the 'marked'
byte still carries FINALIZEDBIT; next/prevnext drive allgc + O(1) unlink).

Validated: testes/all.lua 'final OK' on plain, MOON_ARC_CHECK, and ASan (_U).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removed the now-implementation-less declarations setPause, setMinorDebt,
checkMinorMajor, clearGrayLists, correctGrayLists from GlobalState (their
bodies were deleted with the tracing collector; zero callers). all.lua
'final OK' (_U).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@NiceAndPeter
NiceAndPeter merged commit 9b661da into main Jul 5, 2026
16 checks passed
@NiceAndPeter
NiceAndPeter deleted the phase1/arc-stack branch July 5, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant