A defaulted trailing parameter disables argument type checking
Repro
program w1;
type TR = record a: Integer; end;
function P(const c: AnsiString; k: Integer = 0): Integer;
begin P := Length(c); end;
var r: TR;
begin r.a := 1; WriteLn(P(r)); end.
| compiler | result |
|---|---|
| fpc 3.2.2 | Error: Incompatible type for arg no. 1: Got "TR", expected "AnsiString" |
pxx, pin v410 (c599e8546121) |
compiles, rc=0, binary segfaults |
| pxx, fixed | no overload of P matches these arguments / argument types: (record) |
Nothing here is exotic. There is no overload, no generic, no set literal, no cross-target width question -- a single routine, one defaulted parameter, and an argument of the wrong type.
Why it survived
The check was never missing. One of the two spellings of the same call was
not wired to it. Written out, P(r, 0) is refused by pxx today and always
was; only the spelling that OMITS the defaulted argument reaches
TryFillTrailingDefaults, and that function's candidate scan asks two
questions -- does the name match, and does the arity fit once the tail is
defaulted -- and no third. This is
normalise-dont-special-case.md's sibling rule in its stated form: grep for
the OTHER SPELLING'S HANDLER, not for the feature. Both spellings mean the
same thing to whoever wrote the source, so no test corpus separates them.
It is also structurally invisible to the instrument that would catch it: the fallback runs only where the ordinary match has already refused, so the population it rescues is exactly the population of calls with a type error in them. A suite of correct programs cannot reach it.
Measured boundary
All rows fpc 3.2.2 -Mobjfpc against pxx at the commit before the fix.
| argument | parameter | trailing default | pxx before | pxx after | fpc |
|---|---|---|---|---|---|
TR record |
AnsiString |
yes | compiled, 1 |
REFUSED | refused |
TR record |
AnsiString |
no | REFUSED | REFUSED | refused |
AnsiString |
Integer |
yes | compiled, 4265208 (an address) |
REFUSED | refused |
['x'] set literal |
AnsiString |
yes | compiled, Length = 17297991344808736 |
REFUSED | refused |
['x'] set literal |
AnsiString |
no | REFUSED | REFUSED | refused |
| record, in position 2 of 3 | AnsiString |
yes | compiled | REFUSED | refused |
AnsiString, in position 3 of 4 |
Integer |
yes | compiled | REFUSED | refused |
The two no rows are the control: same argument, same parameter, same
compiler, and the only thing that varies is whether the trailing default was
written out.
Log
- 2026-09-17 | frankS | fixed and closed, commit
2de677672. INERT UNTIL THE NEXT PIN: the change iscompiler/**, so pin v410 (c599e8546121) still compiles every row in the table above.
The fix
TrailingDefaultArgsAcceptable (pasparser_call.inc, beside
FillMatchArgChannelsAt), asked from the candidate scan. Three properties are
deliberate:
- It asks
MatchParamAccepted, the UNION of whatMatchProcCall's phases accept -- not a stricter rule of its own. Anything this fallback refuses becomes "no overload matches", so a narrower predicate would convert working calls into diagnostics. - It fills the argument side channels first.
MatchParamCompatiblereachesMatchArgNilOkandMatchArgProcAddrOk, and with the channels invalid both answer False -- which would refuseCallsIt(@Sub)against a procedural parameter andTakesPtr(nil)against a pointer one. Those two rows are in the positive test for exactly this reason and they fail differently from the rest of it. Unlike the method probe there is no rewind to schedule around: the argument list is fully parsed when this runs. - It abstains on
tyUnknownrather than refusing -- the sentinel for a type that was never settled, which is ordinary under NilPy and for a node downstream of an earlier error. Refusing there reports a type mismatch as the second diagnostic of one mistake.
One gate, three callers: the expression site (pasparser_expr.inc), the
statement site (pasparser_stmt.inc) and NilPy's (pyparser.inc). A check at
the call sites would have been three spellings of one rule.
Tests
test/test_default_arg_typecheck_fail.pas-- four shapes, refused at fpc's own four line numbers, rc=1, no binary written. Positive control fires: pin v410 compiles it and the binary segfaults.test/test_default_arg_typecheck_positive.pas-- 10/10 under fpc and pxx. Its job is the over-refusal direction: the procedural-address andnilrows are refused by a version of this fix that forgets the channel fill, and the two- and three-argument rows are passed by a gate that checks only argument 0.
A REDUCTION OF THIS CAN LOOK COMPLETELY HEALTHY — the record shape is load-bearing
Measured 2026-09-17 by frankuser while verifying the control, and worth more
than the verification. Reconstructing the repro from a description — a record of
three Int64s rather than this ticket's one-Integer TR — compiles under the
pin, runs cleanly, and prints 3. A small, sensible-looking integer. Reported
from that run alone it reads as does not reproduce, with a clean run to point
at.
It was never a false instance: HEAD refuses that program too, by the same
fallback. Only the CONSEQUENCE differed. Length(c) reads whatever bytes sit at
the argument, and a wide record happens to put a plausible small number there
where a narrow one puts a crash. The observable is a function of the victim's
memory layout, so the severity of any given reduction is luck. Use
test/test_default_arg_typecheck_fail.pas rather than a reconstruction — it is
in the tree, its shapes are chosen, and a reader who reduces this to "some
record, some string parameter" can get a healthy run and close the ticket on it.
Corpus: no cost (expectation stated before the run)
This fix makes the compiler STRICTER, which on a corpus can only lose units and
never gain them — and gate.sh quick cannot see that. Expectation recorded
before the sweep: 21 / 10 / 176 unchanged; any movement in BOTH-OK is a
regression, not a win.
Measured at 2de677672 over FPC's own compiler, 207 units, run as three
foreground chunks of a partition asserted to union exactly to the glob (the
probe's own header records that a backgrounded full sweep has been lost twice):
21 BOTH-OK / 10 ORACLE-NO / 176 PXX-FAIL, 207 rows, 207 distinct units.
Unchanged. The null row is reportable only because the expectation was written
down first.
What this does NOT fix
bug-p-an-array-constructor-in-argument-position-is-typed-as-a-set [55] is a
separate and still-open defect that this one was found underneath. With the
fallback no longer rescuing it, P(['x']) against an AnsiString /
array of AnsiString overload pair now selects fpc's candidate (the array
one) instead of the string one -- but the argument is still presented as a SET,
so the callee reads a garbage length. Selecting right and lowering wrong is one
defect where there were two; it is not a fix. See that ticket.