← board

ManagedLocalZeroBytes answers per kind, and has been wrong twice the same way

compiler/pasparser_expr.inc:35 is the single table behind "how many bytes must this managed local start zeroed". Getting it wrong does not produce a missing zero — it produces a use-after-free, because every managed kind's first store RELEASES the slot's previous contents, and an unzeroed slot's previous contents are stack garbage that sometimes looks like a live handle.

The table's shape is a chain of else if arms, one per kind, and each arm is independently responsible for remembering that the local might be an ARRAY:

arm asks IsArray?
element-is-dyn-array yes (that IS the arm)
dyn-array handle yes
tyAnsiString yesArrLen * PTR_SIZE
tyVariant added 2026-08-27; shipped without it
tyRecord yes, as a separate arm
COM interface added earlier; shipped without it
static array of COM interfaces yes, as a separate arm
NilPy tyClass says not IsArray
promo int says not IsArray

Two of those arms have already been fixed after shipping, each found the same way — a bug that appears and disappears when an unrelated routine changes the frame in front of it:

The two open questions

1. Are the remaining not IsArray guards decisions or omissions? Neither carries a note saying which. The NilPy tyClass arm is guarded by NilPyUserCode, and NilPy user code has no array locals — so it may be unreachable rather than wrong. The promo-int arm has the same smell. I declined to widen either while fixing the variant one, because I could not construct a reachable case and a speculative widening would be a guess wearing a fix's clothes. That decision needs to be measured and then either recorded as deliberate (with the reason, in the arm) or fixed.

2. Should the array question be asked ONCE instead of nine times? The whole table is "bytes per element x element count", and every arm that gets it right computes exactly that. A structure that asks IsArray at the top and multiplies the per-element answer would make the omission unstateable rather than merely unlikely — which is the argument devdocs/dev/normalise-dont-special-case.md makes, and this table is now its best worked example: the second path is the one that stays broken, and here there are nine.

Care needed: the arms are not all "size x count". The dyn-array-element arm zeros POINTERS inside a fixed array, and the record arms use RecSize of an element record. A restructure has to keep those distinctions, so this is a real design task, not a mechanical rewrite. Weigh it against leaving the chain and adding a test per kind instead — root-cause-over-microfix says measure tickets-closed-per-change, and the answer here may honestly be the chain plus coverage.

What would make either answer cheap

There is no test that enumerates the kinds. test_interface_local_array_zero_init.pas and test_variant_local_array_zero_init.pas each cover one arm, both by dirtying their own stack so the failure is deterministic. A single table-driven Pascal test — one local of every managed kind, scalar and array, all asserted to start zeroed — would answer question 1 by running it and would guard any restructure done for question 2.


Both questions answered, 2026-08-30 — and one of the two guards was a bug

Q1: decision or omission? — one of each, measured not argued

The ticket asked whether the two remaining not IsArray guards were deliberate. They were treated as one question; they are two, with opposite answers.

The promo-int guard was an OMISSION, and reachable. promoint, promoint32 and promoint64 are spellable Pascal type names (pasparser_decl.inc:543), so a: array[0..3] of promoint64 is an ordinary Pascal local. Measured with a new PXXDBG=a.mlzero channel (below):

sym=zzscalar tk=28 isarray=FALSE          -> 16     { correct }
sym=zzarr    tk=28 ARRAY len=4 promoint   -> 0      { the hole — want 64 }
sym=zzstr    tk=23 isarray=TRUE arrlen=4  -> 32     { control: AnsiString asks }

Not one element's worth, as the Variant bug had — nothing at all. And it is worse than a missing zero, because EmitManagedLocalCleanup's promo arm (symtab.inc:10792) has the same missing IsArray and still calls PXXPromoClear on the slot address at scope exit. That routine releases the payload as a managed string whenever the tag reads PROMO_TAG_HEAP, and its own header says it "cannot be used on uninitialised memory". So element 0 was cleared from stack garbage: a frame carrying {1, <pointer-shaped bytes>} freed a block the slot never owned. test_promoint_local_array_zero_init segfaults on the pre-fix compiler.

Third arm, same shape, same cause as interfaces and Variants.

The NilPy tyClass guard is a genuine DECISION, and now recorded as one. NilPyUserCode is true only for NilPy source, and NilPy has no static-array syntax — a NilPy list is a dynamic array (ArrLen = -1), which the dyn-array-handle arm claims long before the class arm is reached. Measured on a NilPy compile carrying classes, lists and int arithmetic: 19 dyn-array locals, all arrlen=-1 -> 8; zero static arrays of tyClass under NilPyUserCode; and every static array that answered 0 was of an unmanaged kind (tyUInt8, tyInt64). The guard is unreachable rather than wrong. It is dropped anyway by the restructure below, which costs nothing and removes the question.

Q2: restructure or chain-plus-coverage? — restructure, and it verified clean

Three arms shipping the same omission is the argument. The count is now computed once and applied at the bottom, so every per-element arm supplies only a per-element size and a new arm is correct for arrays by construction rather than by remembering.

The ticket's caveat was right and is preserved: the arms are not uniformly "size x count".

Verified by equivalence, not by inspection. The new a.mlzero channel prints the table's answer for every managed local it sees, so the whole decision set can be diffed:

The instrument, which is the durable part

PXXDBG=a.mlzero (compiler/pasparser_expr.inc) reports every local the table looked at, what it decided, and — the line that matters — MISS for a local of a kind the chain handles that still came out zeroBytes = 0. The ticket observed that both prior arms were found from the outside, as a use-after-free three layers from the cause, because a missed zero is invisible on a clean stack. A fourth omission is now one grep of this channel away instead.

It is also what made the restructure safe: an equivalence oracle over ~9,000 real decisions is a stronger check than reading ten arms.

Split out, not fixed here

[[bug-a-a-static-array-of-promo-ints-releases-only-element-zero]] — the release half of the same missing IsArray. With init fixed, every element starts {0,0}, so clearing element 0 is harmless and what remains is bounded: elements 1..N leak their heap-tier payload. Safety-critical half closed here, correctness half filed at prio 45. Its remedy is known — it is bug-a-local-static-array-of-string-never-released-at-scope-exit one type over, and that fix's PXXArrayReleaseImmediate arm already sits earlier in the cleanup chain — but it needs a runtime change in promocore.pas and a new base-kind number, so it is not folded in.

Log

2026-08-31 (frankA) — the family has a SECOND AXIS, and this ticket's census cannot see it

A third instance of "a managed local started from stack garbage" landed today, and it is not a missing IsArray in this table. ManagedLocalZeroBytes answered correctly — the byte COUNT was right. What was wrong was the width of each store the backend then emitted: EmitStoreVar on arm32, riscv32 and xtensa sized the access with TypeSize(Syms[idx].TypeKind), the ELEMENT type, so a dynamic array with a byte-sized element got strb into a pointer slot — low byte zeroed, three stale bytes surviving. Same outcome as a missed zero (a release of a handle made of stack garbage, found three layers away as a write-after-free), reached through a different mechanism.

So the PXXDBG a.mlzero channel this ticket built is sound and would have reported zeroBytes correctly here, and been no help: it answers HOW MANY bytes, never HOW WIDE the stores are. If the census is ever extended, that is the second column to add. Fixed in [[bug-a-no-cross-target-can-build-the-compiler-itself]]; aarch64 had the guard already, the three siblings did not.