← board

Bug: passing a FIXED-size array to an array of T open-array parameter stack-copies into the caller's frame

Repro (minimal, isolated)

program ReproStackFrame;
const MAX_BUF = 8388608;
var
  BigBuf: array[0..MAX_BUF-1] of Byte;

function Touch(const buf: array of Byte; idx: Integer): Integer;
begin
  Result := buf[idx];
end;

procedure Caller;
var i: Integer;
begin
  i := Touch(BigBuf, 5);
  writeln(i);
end;

begin
  BigBuf[5] := 42;
  Caller;
end.

Compiling this with pascal26 --debug prints the compiler's own oversized- stack-frame warning (feature added for exactly this failure class, see project_warn_oversized_stack_frame_done memory / bug-fpc-seed-segfault):

warning: routine 'Caller' uses 8388620 bytes of stack frame (> 1048576);
large frames risk stack overflow — move big buffers to the heap (GetMem)
or a global

Caller itself declares no large locals — it only passes BigBuf (a global) to Touch as a const array of Byte parameter. The frame-size warning firing on Caller, not Touch, is the tell: the caller is reserving stack space sized to the array, meaning the open-array argument is being staged (copied) onto the caller's stack before the call, rather than passed as the standard Pascal open-array convention (a pointer + length pair, O(1) regardless of array size).

Counter-repro — identical shape, BigBuf as a dynamic array instead:

program ReproDynArrParam;
const MAX_BUF = 8388608;
var
  BigBuf: array of Byte;

function Touch(const buf: array of Byte; idx: Integer): Integer;
begin
  Result := buf[idx];
end;

procedure Caller;
var i: Integer;
begin
  i := Touch(BigBuf, 5);
  writeln(i);
end;

begin
  SetLength(BigBuf, MAX_BUF);
  BigBuf[5] := 42;
  Caller;
end.

Same Touch signature, same call, same 8 MB. pascal26 --debug prints no warning — not even at --max-stack-frame=64 (where RTL routines with 70-180 byte frames do show up; Caller isn't in that list). Confirms the bug is specific to the fixed-array-to-open-array conversion at the call site, not open-array parameter handling in general.

Impact

Silent, no compile error — just a stack-frame-size warning (easy to miss or ignore, especially since it names the caller, not the array or the callee, as the offender) that becomes a real SIGSEGV at runtime once the array is large enough relative to the platform's stack limit. Any code passing a multi-MB fixed array as a const/plain array of T parameter is at risk — this is a correctness/safety bug, not just a performance one.

Workaround used

compiler/asmdisasm_x64.inc's disassembler functions (DisOneReal, DisOne, DisParseModRM, DisRead32, DisReadI32) originally took code: array of Byte so they could be unit-tested standalone against a small local buffer. Since production use only ever passes the global Code[] array, removed the parameter entirely and made them reference Code[] directly — sidesteps the bug completely (no open-array argument, no stack copy) and is arguably better style anyway (matches this codebase's pervasive direct-global-access convention over parameter-threading). Verified with a full self-compile: pascal26 -S compiler/compiler.pas (a ~3.5 MB Code[]) now runs cleanly to completion (previously segfaulted) and the oversized-stack-frame warning is gone.

Suggested fix

A fixed-size array argument to an array of T formal parameter (with or without const) should compile to the standard open-array ABI (address of the array's first element + length, passed as two words), not a value copy — the dynamic-array case above proves the callee-side open-array handling itself is fine (it already accepts a handle-shaped argument correctly); the bug is specifically in how a fixed array's argument gets converted into that shape at the call site. Needs an IR/codegen-level look at caller-side argument marshalling for this one case (likely ir.inc/ir_codegen.inc's call-argument staging, wherever a fixed-array actual gets matched against an array of T formal) — probably the same code path for const array of T and plain array of T (no const), worth checking both. Comparing that code path against whatever the (working) dynamic-array argument path does differently is probably the fastest way in.

Also worth checking while in there

Fixed (2026-07-01, Track A, commit 730b6a75, pinned v113)

Size-gated at a new MAX_OPEN_ARRAY_STACK_TEMP (64 KB, defs.inc): arrays at or under the threshold keep the original frame-local [len:8][data] buffer path in IRLowerCallArg (ir.inc) byte-for-byte unchanged — zero risk to the overwhelmingly common small-array case, and the compiler's own source never crosses this threshold, so self-host/cross-bootstrap never exercise the new path at all (verified: bootstrap byte-identical). Above the threshold, both the const/value and var/out branches now manufacture a genuine managed dyn-array-of-byte temp instead (AllocDynArray + synthesized SetLength intrinsic call, mirroring AN_VARREC_ARRAY's existing TVarRec-temp pattern for array of const) — heap-backed, bounded regardless of the source array's size, and released automatically by the routine's normal managed-local cleanup at scope exit. Its handle already has the [len:8][data] layout the open-array parameter expects, so the callee side (Length/High/indexing) needed zero changes. The var/out writeback flush sites (two call sites in the AN_CALL IR lowering) gained a small shared helper (PendOAWBSrcAddr) that branches on Syms[temp].ArrLen < 0 (the existing dynamic-array sentinel) to compute the copy-back source correctly for either temp kind.

Landmine caught before landing: the first attempt copied AN_VARREC_ARRAY's inline IR_DEFAULT_MEM (zero the handle) immediately before the synthesized SetLength call, on the assumption this mirrored a proven-safe pattern. It doesn't generalize to a call site inside a loop: the inline zero orphans the previous iteration's already-allocated handle before SetLength gets a chance to see/reuse/release it, leaking on every iteration (~2 MB/call in the repro that caught it — RSS went from 4 MB to 102 MB over 50 iterations of a 2 MB array). Fixed by removing the inline zero entirely and relying solely on the prologue-level SymIsHiddenArgTemp nil-init (runs once at function entry, correctly leaves a non-nil handle alone on later loop iterations) plus SetLength's own already-correct resize-of-an-existing-handle semantics — the same behavior any ordinary array-growing user code already depends on. AN_VARREC_ARRAY itself was not touched or audited for the same class of bug (out of scope here; worth a look if anyone hits an array-of-const-inside-a-loop leak later).

Verified: full make test + all four cross targets (i386/aarch64/arm32/ riscv32) green; self-host bootstrap byte-identical; the original crash repro and a var/writeback repro both correct with zero stack-frame warning at --debug; new test/test_big_static_array_open_param.pas (small+large arrays, const+var paths, writeback correctness, 50-iteration RSS leak guard) wired into make test-core.

Log