← board

A var parameter accepts a narrower actual and is written past its end

Repro

program vp;
procedure TakesInt64(var x: Int64);
begin x := -1; end;
var a: LongInt; guard: LongInt;
begin
  a := 0; guard := 12345678;
  TakesInt64(a);
  WriteLn('a=', a, ' guard=', guard);
end.
compiler result
fpc 3.2.2 vp.pas(7,15) Error: Call by var for arg no. 1 has to match exactly: Got "LongInt" expected "Int64"
pxx (pin v410) compiles; prints a=-1 guard=-1

guard is never mentioned at the call. It is destroyed because TakesInt64 writes SizeOf(Int64) bytes at @a, and a is four bytes wide.

Why this is worse than an ordinary type-check gap

No instrument on the normal path can see it. The callee's own writes are correct for its declared type; the caller's variable holds the right value afterwards in the common case (the low half is what it wanted); and what breaks is a DIFFERENT variable, chosen by the stack frame layout. Measured the same day in lib/rtl: the same defect reached through BlockRead destroyed the caller's buffer-length variable in one arrangement and its sentinel in another, from the same source, because the two test procedures laid their frames out differently. A bug report from either one points at the wrong place.

It is also a silent negative for assertions: the out-parameter the caller actually reads comes back CORRECT, so a test that checks the returned count passes while memory is being corrupted beside it.

The two halves

  1. Type checking. A var/out actual must match the formal's type exactly. fpc's message names the rule directly. We accept any assignment-compatible actual, which for a var parameter is unsound in both directions -- a wider formal writes past the actual, a narrower formal leaves the actual's high bytes stale.
  2. Overload resolution, CONDITIONALLY. Because widening counts as compatible, several overloads differing only in a var parameter's width are all viable. The exact row nevertheless wins -- unless some OTHER argument converts, and then declaration order decides. See the matrix below; it is the part that was stated wrongly here when this ticket was filed. Fixing (1) makes this fall out, and makes the ordering in lib/rtl/textfile.pas unnecessary rather than load-bearing.

The masking rule, measured

All rows: procedure T(c: <type>; var a: Int64) and T(c: <type>; var a: <exact>) both declared, Int64 FIRST, a a narrow local with a sentinel beside it. Only the by-value argument c varies.

what the by-value argument does var binding
c: Int64 <- Int64 variable (no conversion) correct
c: Integer <- Integer variable (no conversion) correct
c: Integer <- literal 10 (no conversion) correct
c: SmallInt <- SmallInt variable (no conversion) correct
c: Int64 <- Integer variable (widening) corrupts
c: Int64 <- literal 10 (widening) corrupts
c: SmallInt <- Int64 variable (narrowing) corrupts
c: SmallInt <- literal 5 (narrowing) corrupts

Two things this settles that the first reading of it got wrong:

Why the obvious probe cannot see any of this: hold the call fixed at P(a) -- one argument, nothing to convert -- and vary the DECLARATION order, and every arrangement behaves correctly, because the single shape being varied is the one shape that cannot exhibit the effect. A peer reproduced exactly that and concluded order decides nothing. The route, not the isolation, is what needs varying here (CLAUDE.md, "isolation guards the RUN, not the ROUTE").

Blast radius

var-parameter surfaces where callers legitimately hold a narrower type are where this bites, and the RTL publishes several: BlockRead/BlockWrite (repaired), and every var ...: Int64 in an interface that real code may call with an Integer. This is not a search that was completed -- the repair above covered the one the FPC corpus walked into. A census of var parameters whose declared type is wider than 32 bits belongs with this fix, not before it.

Not a workaround note

Per CLAUDE.md the RTL change that went in beside this ticket is deliberately NOT a compiler-appeasement workaround: it publishes the overload set FPC itself publishes, so the platonic call BlockRead(f, buf, n, c) with c: Integer becomes correct rather than merely accepted. The narrowest-first ORDERING is the part that exists only because of this bug, and it is commented as such in lib/rtl/textfile.pas and pinned by test/lib_blockio.pas.