Repro
program w; {$MODE OBJFPC}
type
TS = class
function Write(const Buffer; Count: Longint): Longint;
procedure P;
end;
function TS.Write(const Buffer; Count: Longint): Longint; begin Result := Count; end;
procedure TS.P;
var f: Text; s: AnsiString;
begin
Assign(f, 'w.txt'); Rewrite(f); Write(f, 'payload'); Close(f);
Assign(f, 'w.txt'); Reset(f); Readln(f, s); Close(f);
writeln('got=[', s, ']');
end;
var s: TS; begin s := TS.Create; s.P; end.
| compiler | result |
|---|---|
| pxx HEAD | compiles, runs, prints got=[], exit 0 — w.txt is created and empty |
| pxx pinned | identical — pre-existing, not a regression |
| fpc 3.2.2 | refuses: Wrong number of parameters specified for call to "Write" |
Why it happens
pasparser_stmt.inc's tkwriteln/tkwrite/tkReadln/tkRead arm binds an
unqualified call to a member of the enclosing class when
FindUMethOverloadAhead / FindUMethArityStrict accept it. Neither asks
whether the FIRST ARGUMENT IS A FILE HANDLE — the one fact that makes a call
unambiguously the intrinsic, since no ordinary member takes a Text or a
FileRec in that position by accident.
Write(f, 'payload') is two arguments and the member takes two, so the arity
gate passes and the type-directed FindUMethOverloadAhead is not decisive
enough to reject it (const Buffer is an untyped formal and accepts anything).
What makes this the bad kind of bug
We are neither compiler. FPC refuses; a fall-through-to-intrinsic design performs the write. We accept the program and perform a different operation, with the same exit code as success. Nothing in the output distinguishes it from a working program — the file exists, it is just empty. This is the shape CLAUDE.md calls out: "the expensive bugs here do not crash; they produce a plausible wrong value far from the cause."
The design fork, and a recommendation rather than a Track U ticket
FPC's rule is "member always wins, qualify with System.Write to reach the
intrinsic". We already diverge from that deliberately and should not adopt
it now: the arity fall-through was installed by
[[bug-p-a-write-call-inside-a-method-named-write-binds-to-the-member-whatever-its-arity]]
precisely so lib/rtl/configparser.pas — written in exactly this shape — would
build, and FPC rejects that file outright.
So the consistent completion of the existing choice is route on the first
argument: if it is a Text or a FileRec handle, it is the intrinsic,
whatever any member's arity says. That rule needs no new policy, it makes the
2-argument case behave like the 3-argument case already does, and the parser
already has both predicates — TextIOFileSym and FileIOFileSym — that answer
exactly this question by lookahead, which is how the file arms of
ParseReadArgsAST / ParsewriteArgsAST find their handle today.
Reaching a member that genuinely wants a file handle as its first parameter
stays possible by qualifying (Self.Write(f, n)), which is what FPC makes you
do for the intrinsic and is the same escape hatch pointed the other way.
Gate
The repro, asserting got=[payload], plus BOTH controls, because this sits
between two mechanisms that can each eat the other:
- the member must still win for a call with NO file handle —
Write(buf, 4)inside the same class must call the member and not print to the console; - the 3-argument fall-through must stay working —
Write(f, 'x', 'y', 'z')writesxyz, whichtest_read_write_as_method_name.pasalready pins.
test_read_write_as_method_name.pas documents this defect in the comment above
its TReaderOnly class, which exists only because TStreamish cannot host the
Text round-trip row while this bug is open. Fold that class back in when this
is fixed — its presence is a marker.
Log
- 2026-09-05 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 98eb6127f.
The oracle, added after landing — "writes something" and "writes the RIGHT BYTES" are different claims
Compiler 47618f77c240. The fix was landed on the strength of a round-trip
returning payload, which is content and not bytes, and this source has no
direct FPC oracle: fpc refuses to compile it, since it gives the member
absolute priority. So the oracle was CONSTRUCTED rather than skipped.
Three programs, one emission — a string, a formatted Write(f, ' ', 42, ' ', 3.5:0:2), a bare Writeln(f), and a three-iteration Writeln loop:
| program | built by | |
|---|---|---|
| A | class DECLARES function Write(const Buffer; Count: Longint) |
pxx only (fpc refuses) |
| B | identical emission, no member named Write |
pxx |
| C | same source as B | fpc 3.2.2 |
B == C the intrinsic itself is right
A == B shadowing changes nothing
A == C the claim: 34 bytes, IDENTICAL
payload 42 3.50\nrow 1\nrow 2\nrow 3\n. Positive control: appending one byte
to A makes it differ, so the comparison can fail.
Why the middle row is the load-bearing one. A == C alone would be satisfied
by a fix that routed to the intrinsic and got the FORMATTING wrong in some way
fpc happened to match. A == B says the shadowed and unshadowed paths produce the
same bytes, i.e. the routing decision is the ONLY thing that differs between
them — which is the actual claim this ticket makes. Checking only that the file
was non-empty would have passed against a fix that wrote payload and dropped
the formatted arguments.