An array constructor in argument position leaks its dynamic array
- Type: bug (codegen / managed heap) — Track A.
- Filed: 2026-08-29 by the wasm32 lane, on
origin/masterat7aba316be. Target-independent; measured on native x86-64. - Impact:
Formatleaks 64 bytes per call, which is what raises this above the usual temp-lifetime ticket.Format(fmt, [args])is the single most common way an array constructor appears in real Pascal, and every one of them leaks.
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.
- For the headline
Formatcase the ticket is exactly right: pure dyn-array temp, no element leak, and the frame-buffer swap fixes it completely. That is the highest-value half and it is the straightforward one. - For
AN_ARRAY_CTORwith managed elements the sibling's justification does not carry over, and the ticket was right to flag it.bug-open-array-copy-temp-leakcould say "managed handles are borrowed bytes (no per-element ARC)" because itIR_COPY_REC'd raw bytes out of a caller-owned fixed array — the caller kept ownership. Here the temp creates the handles. A frame buffer that reuses storage without releasing the previous contents keeps leaking the elements:Take(['x'])would go from 80 B/call to roughly 40, not to 0.
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
make compiler/pascal26— self-host fixedpoint, converged in 2 rounds (2 because the compiler's own codegen changed; expected).- 8-row differential vs
fpc 3.2.2 -Mobjfpc -O2, all identical. Chosen for what reuse could break now that the array persists across calls: contents changing per iteration; differing element counts at two sites in one loop; a constructor in a not-taken branch then taken (the stale-slot concern the old comment cited as the reason for the inline zero); recursion through aFormatwith a constructor; two constructors live in one call;Formatwith mixed types; long strings into a reused slot. - A/B across
examples/: 11 byte-identical, 22 changed codegen — the 22 are exactly the array-constructor users — and all 22 behave identically. Three apparent output differences (mandelbrot_parallel,mandelzoom,parallel/pow) were checked against the before binary run twice and differ from themselves: nondeterministic programs, not this change. tools/gate.sh quick.
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
- 2026-08-30 — resolved, commit 204fec49a.