← board

An array constructor in argument position leaks its dynamic array

Symptom

program FmtRss;
uses SysUtils;
var i: Integer; s: string;
begin
  for i := 1 to 2000000 do s := Format('%d-%s', [i, 'x']);
  writeln('done ', Length(s));
end.
done 9
Maximum resident set size (kbytes): 125232

Measurement

The instrument is arena advance between two PXXAlloc(1024, 8) probes, at two iteration counts. 1024 is a size the loop never allocates and therefore never frees, so every probe bumps the arena rather than popping a free list; a build that releases everything gives the SAME number at both counts.

procedure Take(const a: array of string);  begin if Length(a) = 0 then writeln('empty'); end;
procedure TakeI(const a: array of Integer); begin if Length(a) = 0 then writeln('empty'); end;
loop body n=1000 n=9000 per call
Take(['x', 'y']) 129032 1153032 128 B
TakeI([1, 2]) 41032 361032 40 B
Format('%d-%s', [i, 'x']) 65072 577072 64 B
Take(av) — a named array of string variable 1032 1032 0 B

The last row is the control and it is what makes the rest a leak rather than an artefact of a bump allocator: the same parameter, the same callee, an argument that is a variable instead of a constructor, and the advance is flat. The release machinery works; the constructor path does not reach it.

Element type is irrelevant (strings and integers both leak, differing only by element width), so this is the dyn-array temp itself, not per-element ARC.

The sibling that was already fixed

bug-open-array-copy-temp-leak (done, 2026-06-23, 692db33) is the same defect on the other arm: passing a FIXED ARRAY to an open-array parameter heap-allocated a dyn-array temp whose slot was re-nil'd per call, orphaning the previous block — "~40-48 bytes PER CALL, a 2M-call loop reached ~78-94 MB RSS". It was fixed by replacing the heap temp with a frame/BSS-local [len:8][data] buffer, which is reused per call site and auto-frees.

The constructor arm was not covered by that change and still allocates. This is normalise-dont-special-case's stated failure mode — "if you fix a bug on one arm of a double case, grep for the sibling before closing the ticket" — and the numbers line up almost exactly (40-48 B/call then, 40-128 B/call now; ~78-94 MB then, 125 MB now), which is the tell that it is one concept and not two bugs.

So the likely fix is not new work: apply the same frame-buffer treatment to the constructor path. Whether the buffer can be shared with the fixed-array arm, or whether a constructor whose elements are managed needs per-element release before reuse, is the part that needs deciding — a constructor of string elements owns its element handles in a way a borrowed fixed array does not, so the "managed element handles are borrowed bytes, no per-element ARC" justification in the 2026-06 ticket does NOT carry over unexamined. That is the one real design question here and it should be settled before coding.

Not to be confused with

bug-a-open-array-of-string-arg-spilled-through-a-managed-string-temp (same lane, same day, adjacent code). That one is a mistyped hidden temp and was measured to leak NOTHING — its arena advance is identical before and after the fix. This ticket is the leak that probe kept finding underneath it. Two defects, one call shape; fixing the first does not touch this.

Gate

Track A's: make compiler/pascal26 (the byte-identical self-host fixedpoint) plus the arena-advance table above reproduced flat, plus tools/gate.sh quick. The control row must stay flat too — a "fix" that made every row equal by making the variable case allocate would pass a slope test on the three leaking rows alone.


Diagnosis, frankA 2026-08-30 — one root cause, TWO leaking consequences

Reproduced first: FmtRss gives 124,720 KB here against the ticket's 125,232 KB. Confirmed at origin/master, native x86-64.

Both lowerings are in compiler/ir.inc: AN_ARRAY_CTOR at :5921 and AN_VARREC_ARRAY at :6000. Each does exactly what the sibling ticket described — AllocDynArray(...), then IR_DEFAULT_MEM re-nils the handle slot per call, then SetLength. Re-nilling without releasing orphans the previous block. Same mechanism as bug-open-array-copy-temp-leak, on the other arm, as filed.

Correction: "element type is irrelevant" is wrong, and it changes the fix

The ticket concludes:

Element type is irrelevant (strings and integers both leak, differing only by element width), so this is the dyn-array temp itself, not per-element ARC.

Two numbers (128 B for two strings, 40 B for two integers) are consistent with that reading, but they are also consistent with a second leak, so they do not separate the hypotheses. Varying element count and string length does.

Instrument: RSS over a 2M-call loop. It is validated against the ticket's own arena-probe instrument on all three overlapping rows — Take(['x','y']) 128 B, TakeI([1, 2]) 40 B, Format('%d-%s', [i,'x']) 64 B — so the two independent methods agree where they overlap, and the new rows below can be trusted.

loop body RSS (KB) B/call
Take(['x']) — 1 string 156 160 80.0
Take(['x','y']) — 2 strings 249 984 128.0
Take(['x','y','z']) — 3 strings 343 552 175.9
TakeI([1]) 77 952 39.9
TakeI([1,2]) 78 080 40.0
TakeI([1,2,3]) 93 696 48.0

Strings scale linearly with element count at ~48 B per element per call. Integers do not — 1 and 2 elements cost the same 40 B, and the step at 3 is an allocator size class, not a slope. So element type is not irrelevant; it selects whether a second leak exists at all.

Second axis, which settles what that per-element cost is — string length, one element throughout:

element RSS (KB) B/call
['x'] (1 char) 156 160 80.0
['x'*32] 218 752 112.0
['x'*128] 406 016 207.9

~1 byte per character. The string DATA is leaking, not merely a handle. Each call allocates a fresh copy of every string element and never releases it.

So there are two leaking things, from one root cause

The orphaned block is the single cause; it has two consequences, because the block owns what it points at:

path node array block leaks element data leaks
Format(f, [i,'x'])array of const AN_VARREC_ARRAY yes, ~48–64 B/call no
Take(['x','y'])array of string AN_ARRAY_CTOR yes, ~40 B/call yes, ~(len+40)/element
TakeI([1,2])array of Integer AN_ARRAY_CTOR yes, ~40 B/call n/a (unmanaged)
Take(av) — a named variable (control) no no

Format is confirmed clean on the second axis: %s with a 1-char argument and with a 128-char argument both cost 63.9 B/call, flat. An array of const element is a TVarRec holding a reference; it never copies the string. The AN_ARRAY_CTOR path is different because it stores each element through the normal element-assign path, with managed-string ARC (its own comment says so) — those handles are freshly owned by the temp, and die with it.

What this does to the proposed fix

The ticket proposes applying the sibling's frame-buffer remedy, and asks whether a constructor of string elements needs per-element release. Measurement answers it: yes, and the element leak is already there today, independent of any change.

So the constructor arm needs the buffer plus a release of the previous contents' managed elements before reuse (or the elements stored as borrowed rather than retained). That is a real design decision and it is now made against numbers rather than against the sibling's precedent.

A slope test on the three leaking rows alone would not catch this — the same warning the ticket's own Gate section makes about the control row. A fix that addressed only the block would move every headline number and still leak proportionally to string length, which no fixed-size probe sees.

Status: blocked on file ownership, not on understanding

Both lowerings are in compiler/ir.inc, held by frankC (C-side array-shape census). Diagnosis, instrument and the element-release decision are done and recorded here; the edit itself is short once the file is free. Nothing else in the Pascal frontend or codegen is involved — the parser side (pasparser_lval.inc:3285/3407) only builds the node and needs no change.

The regression test

test/test_array_ctor_no_leak.pas, now wired into the Makefile beside its sibling's guard (it was written before the fix and held back so as not to land a red gate).

It is not the sibling's test with a different argument. Row 3 is the point: a 128-character element, because the element leak is proportional to string length and a probe built from short literals cannot see it. A fix that released the block and not the elements would take row 1 from 80 to ~40 B/call and look like a success on every fixed-size row.

Verified to demonstrate the defect at the pre-fix compiler: 367,104 KB RSS over 1M iterations of its four rows (ok 4000000). Post-fix it should be a few hundred KB, like its sibling's 264 KB.

Row 4 is the control — the same callee and parameter with a named variable argument — and must stay flat, per this ticket's own Gate note.

When the fix lands, paste this next to the sibling's block in the Makefile (the guard shape is copied from test_open_array_no_leak):

	./$(COMPILER) test/test_array_ctor_no_leak.pas $(TESTTMP)/test_array_ctor_no_leak26
	test "$$($(TESTTMP)/test_array_ctor_no_leak26)" = "ok 4000000"
	@if [ -x /usr/bin/time ]; then \
	  /usr/bin/time -v $(TESTTMP)/test_array_ctor_no_leak26 2>$(TESTTMP)/acnl.time >/dev/null; \
	  rss=$$(grep -oE 'Maximum resident set size .kbytes.: [0-9]+' $(TESTTMP)/acnl.time | grep -oE '[0-9]+$$'); \
	  if [ -n "$$rss" ] && [ "$$rss" -gt 10000 ]; then echo "array-ctor temp leak regressed: RSS $${rss}KB (>10MB over 4M calls)"; exit 1; else echo "array-ctor-no-leak: OK (RSS $${rss}KB)"; fi; \
	else echo "/usr/bin/time absent; array-ctor RSS leak guard skipped"; fi

FIXED, 2026-08-30 — and the remedy is the THIRD arm's, not the second's

The fix

Delete the per-reach IR_DEFAULT_MEM at both constructor lowerings — AN_ARRAY_CTOR (compiler/ir.inc:5921) and AN_VARREC_ARRAY (:6000). That is the whole change: two emissions removed, nothing added.

SymIsHiddenArgTemp already causes the slot to be nil-init'd once at body head (BSS for a main-body skGlobal; the codegen-prologue pass at ir_codegen.inc:9461 for an in-proc skLocal). So the inline zero was redundant on first reach, and on every later reach it was the defect: it discarded the live handle before SetLength could resize the existing array, orphaning the block and everything the block owned.

With it gone, the slot still holds the previous trip's array, SetLength resizes in place, and each element store's release-of-old frees the previous trip's element. Both halves of the leak close together, which is what confirms they were one root cause.

This is not the sibling's remedy, and that matters

The ticket proposed the frame-buffer treatment from bug-open-array-copy-temp-leak. There is a closer sibling, in this same file: bug-a-managed-string-arg-temp-leaks-on-loop-reuse, whose fix is a few hundred lines below at ir.inc:11249 and :11601, and whose comment states this defect exactly — "A per-store IR_DEFAULT_MEM here re-zeroed the slot before the STORE, dropping (leaking) that handle every iteration." Same mechanism, same remedy, applied to managed-string arg temps and never extended to the array-ctor temps.

So there are three arms of one concept, not two:

arm temp remedy when
1 materialised managed-string argument drop the per-reach zero fixed
2 fixed-array → open-array copy frame [len][data] buffer fixed 2026-06
3 array constructor in argument position arm 1's this ticket

Arm 2's frame buffer is right for arm 2 and wrong here, for the reason the diagnosis above measured: arm 2 could call managed handles "borrowed bytes, no per-element ARC" because it IR_COPY_REC'd bytes out of a caller-owned array. The constructor creates the handles through the element-assign path with ARC. A frame buffer reusing storage without releasing the previous contents would have taken Take(['x']) from 80 to ~40 B/call — halving every headline number, passing a slope test on short literals, and still leaking one element per call in proportion to string length.

Result — every row flat, including the length-proportional ones

Binary d4b25a8a72e4 (self-host fixedpoint, 2 rounds). Each program's output was checked, not only its RSS.

loop body before after
Format('%d-%s', [i,'x']) × 2M 124 720 KB 392 KB
Take(['x']) × 2M 156 160 KB 392 KB
Take(['x','y']) × 2M 249 984 KB 392 KB
Take(['x'*128]) × 2M 406 016 KB 392 KB
TakeI([1,2]) × 2M 78 080 KB 392 KB
the 4-row regression test × 1M 367 104 KB 392 KB

The length axis is the one that proves the element half closed: ['x'*128] was the worst row and lands on the same 392 KB as the rest.

Correctness

A measurement error worth recording, since it nearly became a false report

The first post-fix sweep read 392 KB on all eleven probes, and eleven identical numbers is one measurement, not eleven. It was in fact correct — but the next reading, of the regression test, said 367 MB and appeared to refute it. Neither number was wrong: compiler/pascal26 on disk had been rebuilt from the stash as the A/B baseline and never rebuilt after git stash pop, so the second measurement used the unfixed compiler. The tell was the timestamps — probes 01:19, compiler 01:22 — not either number.

This is the provenance trap CLAUDE.md names ("the compiler binary on disk drifts... any result you report must name the sha of the binary it came from"), reached through git stash rather than through a gate reseed. An A/B that rebuilds under a stash leaves the wrong binary installed; rebuild before the next measurement, and check the program's output and not only its RSS — a program that fails to run reports a beautifully flat number.

Log