← board

An interface-dispatched call that omits a defaulted argument segfaults

{$mode objfpc}{$H+}
type
  IFoo = interface
    ['{11111111-2222-3333-4444-555555555555}']
    procedure D(d: Double = 2.5);
    procedure N(n: Integer = 7);
  end;
  TFoo = class(TInterfacedObject, IFoo)
    procedure D(d: Double = 2.5);
    procedure N(n: Integer = 7);
  end;
...
var i: IFoo;
begin
  i := TFoo.Create;
  i.D;      { <- segfault }
  i.N;      { <- segfault }
end.
row pxx fpc 3.2.2
i.D; / i.N; (defaults omitted) compiles, segfault D 2.50 / N 7
i.D(1.5); / i.N(3); (same file, args written) D 1.50 / N 3 same
f.D; f.N; on a TFoo variable (class dispatch) correct same

The middle row is why this is a finding and not a crash report. Same file, same interface reference, same object, same binary shape — the only thing that differs is whether the argument was written at the call. So the default FILL on an interface-dispatched call is the suspect, and interface dispatch itself, the default's declared shape, and object lifetime are all controlled for. Both a float and an ordinal default crash, so it is not the value's width.

Not investigated

I did not locate the fill. A plausible-but-unmeasured reading is the Self injection: an interface method's parameter row is shifted by one to make room for Self, and the shift loop in pasparser_decl.inc copies mPDefault, mPDefaultVal, mPDefaultIsStr, mPDefaultIsSet, mPDefaultSOff and mPDefaultSLen but not mPDefaultIsFloat. That omission is real and visible in the source; it does not explain the Integer row, so it is at most half of this and possibly none of it. Do not take it as the diagnosis.

Done when

i.D; and i.N; above print fpc's values, and a row exists asserting the omitted and written spellings in the same file — two files each printing a plausible number both pass; two numbers on adjacent lines of one output do not.

The sibling, and what "no shared cause" does and does not mean

[[bug-p-an-interface-dispatched-call-passing-a-named-dynamic-array-segfaults]] was found in the same sitting, in the same subsystem, at the same priority. Read that pairing carefully, because the honest statement is weaker than either "they are the same bug" or "they are independent."

What was measured is that each crashes under its own trigger with the other's trigger controlled out: this one with a scalar parameter and no array anywhere, that one with no default value anywhere. Neither is a special case of the other's repro.

What was NOT measured is whether one cause explains both. I did not locate either. Two crashes in one dispatch mechanism have every reason to share a cause, and a reader who fixes one should expect the other to fall out and check rather than assume it will not — the pair being listed here is not evidence that they are two. If they turn out to be one, close this and say so; that is a better outcome than two tickets held apart by a sentence nobody measured.

Closed 2026-09-06 (frankB) — two arms, and the fixture is the finding

CheckMethodCallArity exits quietly when parameter 1 carries a default, which is correct: the call IS legal. Nothing then filled the arguments. A guard whose entire justification is "the missing parameters have defaults" and which does not supply them is not a partial implementation — it is a guard that accepts a call it has not made valid.

The helper an author reaches for declines here, silently:

function CanFillDefaultsFrom(mpi, nextIdx: Integer): Boolean;
begin
  Result := (CurTok.Kind = tkRParen) and ParamsDefaultedFrom(mpi, nextIdx);
end;

Two questions under one name — the argument list ends here AND the rest can default. With no parentheses at all CurTok is ;. Now split, and ParamsDefaultedFrom is the half a parenless call needs. The instance-method arm had found this in August and written the test out by hand, so the three arms that still had the hole would have copied that line rather than found its reasoning; naming it is what stops the fourth copy.

The second arm, and how it was actually found

Fixing the plain interface reference left (i).M; still segfaulting. That arm is in the selector walker, where the paren test and its else are ~95 lines apart across a nested argument loop — a fill written in the else did not run for this shape, and reading the code did not show why. What settled it was tagging each candidate arm with a distinct Warn and one build: the builder fired, the else did not. The fill now sits immediately after the token is read:

Next; { consume method name }
if (CurTok.Kind <> tkLParen) and ParamsDefaultedFrom(mpi, 1) then
  FillDefaultArgs(mpi, 1, callN, lastA);
if CurTok.Kind = tkLParen then

Put the decision where the token is READ, not where the block happens to close. Four builds is what the alternative cost.

Eleven shapes, and ten of them were already right

free routine · instance method · class method on the class name · class method through a metaclass · record method · selector chain · implicit Self · grouped (o) · grouped cast (o as T) · interface reference · grouped interface reference — plus the explicit-argument control.

fpc 3.2.2 answers 7 for all of them. When this was filed, pxx answered 7 for ten and crashed on one. A fixture holding a single shape would have been green on any nine of the eleven, which is the argument for the file's shape: the shapes are the assertion, not the arrangement.

The explicit-argument control is what made the crash diagnosable in the first place — same reference, same object, same binary, differing only in whether the argument was written.

Also fixed, and honestly labelled

The CLASS-method Self-shift loop in pasparser_decl.inc copied five of the six default channels and omitted mPDefaultIsFloat; the INTERFACE-method shift loop in the same file copies all six. I could not construct a program that reads the wrong value — the defaults that reach a call come from the implementation row, so the declaration row's flag is not consulted on any path I could reach. Fixed anyway: the loop's contract is shift the row, a sibling loop shows what the row is, and an omission that is unreachable today is inherited by the next path that reads it.

The sibling

[[bug-p-an-interface-dispatched-call-passing-a-named-dynamic-array-segfaults]] is not closed by this and was not caused by it — it has no default values anywhere, and it still crashes at this commit. The two were filed as a pair with the independence explicitly NOT established; this close does not settle that question either way.

Log