← 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 halfEmitZeroFrameSlot(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 halfPXXAlloc 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.