← board

Zero-init contract — one library-owned managed-slot zeroing guarantee

Problem — the seam

The "managed slots must be nil before first use" invariant is currently reimplemented inline, per emit-path, per target, instead of living in one owned place. Two shapes of the same bug keep biting:

  1. not 15 → 14 (FIXED 606abec) — symptom of codegen making an assumption that didn't hold. Same class as below: behaviour split across sites.
  2. Stale non-nil'd slot → DecRef faults on a garbage handle. A managed local (AnsiString / dyn-array / record-with-managed-fields / variant) whose slot was not zeroed before the body runs. First SetLength / assignment / ARC-correct whole-record copy reads stale stack bytes as a live handle and release/retain dereferences garbage. Surfaced as e.g. the arm32 startup segfault — which is not in the Int64 work that exposed it; it is pre-existing missing zero-init.

The invariant lives half in codegen, half in builtinheap.pas, and neither side owns it fully:

The contract (library-first)

Push the invariant into one library-owned helper, called everywhere, not reimplemented inline per path/target:

One helper, one guarantee → the bump-vs-freelist split and the per-target ladder stop being places a bug can hide.

Why now (ordering)

Anti-pattern to avoid: patching DecRef to tolerate garbage handles. That hides the seam instead of closing it.

Acceptance

Log

RESOLVED 2026-07-12 (a3d3c9a6) — one owner, both halves

Compiler half — EmitZeroFrameSlot(frameOff, nBytes) in symtab.inc is now the single producer of the "managed slot is nil before first use" guarantee. parser.inc's prologue computes an extent and calls it; the per-target ladder is gone. Pointer-sized handle → one nil store. Larger extent (record with managed fields / variant / COM fat pointer / array of managed) → PXXMemZero, on EVERY target: xtensa and riscv32 no longer error with "managed aggregate locals not yet supported", and arm32's open-coded byte loop is gone. The array-of-dynarray-handles special case (per-element stores) folded into the ordinary extent — it only existed because the cross backends rejected the >pointer-sized path.

Deliberate deviation from the literal acceptance text: x86-64 keeps rep stosb inside EmitZeroFrameSlot. It is the hardware memset, it is chosen at the one owning decision point, and it is not a second implementation of the invariant — funnelling every managed aggregate on the hot host target through a helper call would be a real prologue cost for zero contract benefit.

RTL half — PXXAlloc now states and produces a zeroed payload on both paths. Free-list reuse already zeroed explicitly. The bump path relies on virgin arena memory, and HeapMmap now produces that instead of assuming it: MAP_ANONYMOUS gives it free on Linux, and the ESP static arena is zeroed once on hand-out rather than trusting that startup zeroed .bss — exactly the latent trap this ticket named ("any future bump-path change silently breaks the fresh-memory-is-zero assumption"). The invariant is written down at the bump path: anything that changes it must re-produce the guarantee there, not push it back onto callers.

Gate: self-host byte-identical; testmgr quick + limited green; make test-aarch64 and make test-riscv32 green; managed-local cross tests (dynarray, string, record, variant, nested SetLength, collections, interfaces, managed-aggregate locals) match the x86-64 oracle on i386 / arm32 / aarch64 / riscv32.

The i386 / arm32 suites currently stop at a pre-existing, unrelated red — the coroutine family (scheduler / scheduler-exc / reactor / timer / channel / asyncecho), auto-filed by Track T as regression-test-{i386,arm32}-test-scheduler et al. Verified not from this work: the affected binaries are byte-identical with this change stashed, and the pinned stable compiler builds a green test_scheduler while HEAD does not.

The arm32 startup segfault the ticket predicted would "disappear at source" was already gone before this landed; nothing here is a DecRef-side workaround.