A hoisted nested-type name leaks between two specializations of one template
The 21-line repro. fpc 3.2.2 compiles and runs it, printing a 1 1.
program dbl;
{$mode delphi}
type
TPtrs<T, P> = class
Q: P;
function Width: Integer;
end;
TOwner<T> = class
public type
PT = ^T;
public
function Ptrs: TPtrs<T, PT>;
end;
function TPtrs<T, P>.Width: Integer; begin Result := SizeOf(P); end;
function TOwner<T>.Ptrs: TPtrs<T, PT>; begin Result := nil; end;
var li: TOwner<LongInt>; by: TOwner<Byte>;
begin
li := TOwner<LongInt>.Create;
by := TOwner<Byte>.Create;
WriteLn('a ', Ord(li.Ptrs = nil), ' ', Ord(by.Ptrs = nil));
end.
Measured on c8ba1d666f79 (3a89c6184), PXXDBG=p.mint:*:
p.mint deferred alias=TPtrs$LongInt$TOwner$LongInt$PT tmpl=TPtrs args=LongInt TOwner$LongInt$PT
p.mint deferred alias=TPtrs$Byte$TOwner$Byte$PT tmpl=TPtrs args=Byte TOwner$Byte$PT
p.mint late alias=TPtrs$LongInt$TOwner$Byte$PT tmpl=TPtrs args=LongInt TOwner$Byte$PT
pascal26:15: error: unknown type: TPtrs$LongInt$TOwner$Byte$PT
near: ; function TOwner$LongInt . Ptrs : >>> TPtrs$LongInt$TOwner$Byte$PT ; begin
The first two rows are correct and the third is the defect. Both
specializations mint their own pair properly. Then line 15 — the METHOD
IMPLEMENTATION header, which is one token range in the stream shared by every
specialization — is scanned again and pairs LongInt with Byte's hoisted
PT.
The mechanism
HoistName/HoistFull/HoistUsed (pasparser_generic.inc:253) are a single
global table. CollectHoistCandidates resets it (HoistCount := 0) at the
top of every ParseSpecialization, filling HoistFull[k] := specName + '$' + nm for the specialization being parsed right now.
NestedSpecArg (:420) is the only reader — it calls HoistedNameFor(nm) and
writes the answer straight into NSpecArg, so the answer is baked in at SCAN
time and is correct for whatever the table held then. The bug is not the read;
it is when the scan happens. A method-implementation range is scanned after
a later specialization has already reset and refilled the table, so the name
that gets baked in belongs to the wrong specialization.
That is a real cross-specialization leak, not a naming cosmetic: the minted
alias names a type that is never declared, so the error is unknown type on a
name the compiler invented itself — which is why it reads like a mangler bug
and is not one.
Why nothing caught it
ONE instantiation is green. Change var by: TOwner<Byte> to
TOwner<LongInt> and the program compiles and runs. Every hoisting test in
test/ instantiates each template once, so the whole set passes while the
defect is live. The trigger is a SECOND specialization of the same template,
and a test asserting only that "a nested type works as a generic argument" can
never reach it. test_generic_nested_type_identity, ..._field_name and
test_a_class_nested_type_is_a_specialization_argument are all single-
instantiation and all green throughout.
Any fix here needs a two-instantiation row with different pointee types — give both the same argument and the two hoisted names coincide, and the test prints the right answer while reading the wrong row.
Provenance and what it blocks
Found while extending CollectHoistCandidates up the ANCESTOR chain for
bug-p-a-class-nested-type-as-a-specialization-argument-resolves-at-unit-scope
(door C, the rtl-generics rung). The extension is NOT the cause and that was
checked rather than assumed: the repro above fails identically on c8ba1d666f79,
which does not contain it, and on the door-C binary. It is a pre-existing
door-B defect that the ancestor walk merely makes reachable from more places.
It blocks a clean two-instantiation regression test for that ticket, and it is in the path of any real generic container corpus, where one template is specialized many times by construction.
THE SITE, MEASURED — EmitLateNestedSpecDecls sets the substitution and not the table
PXXDBG=p.nspec:* on the repro names it in three lines:
reg alias=TPtrs$LongInt$TOwner$LongInt$PT under=TOwner$LongInt subs=T->LongInt ts=12 head=class public type PT ...
reg alias=TPtrs$Byte$TOwner$Byte$PT under=TOwner$Byte subs=T->Byte ts=12 head=class public type PT ...
reg alias=TPtrs$LongInt$TOwner$Byte$PT under=TOwner$LongInt subs=T->LongInt ts=71 head=function TOwner Ptrs specialize TPtrs ...
The first two are the class-body scans and both are right. The third is the
METHOD IMPLEMENTATION header (ts=71, head=function TOwner . Ptrs : ...),
and note what it says: the SUBSTITUTION is correct — under=TOwner$LongInt,
subs=T->LongInt. Only the hoisted name is Byte's. Two pieces of one state,
one of them refreshed and one of them not.
EmitLateNestedSpecDecls (pasparser_generic.inc) loops every specialization:
ti := SpecTemplateIdx[si];
SpecializeTemplateName := Templates[ti].Name;
SetSpecSubs(ti, si); { <- refreshed }
ScanDelphiMethodImplsForNestedSpecs(ti, Specializations[si].Name);
SetSpecSubs is there. CollectHoistCandidates is not — so HoistName /
HoistFull hold whatever the last ParseSpecialization left, and
NestedSpecArg reads BOTH tables. Two later rows in the same log show the third
state: the table empty, and PT resolving to nothing at all
(alias=TPtrs$LongInt$PT tmpl=TPtrs args=LongInt PT).
This is the fourth instance in one day of one shape — a rule present on one
side of a pair and absent on the other, silent on arrival, failing by agreeing
and then building something else, with the diagnostic naming the materialisation
rather than the pair. The other three are frankS's static-method Self, its
ScanDelphiMethodImplsForNestedSpecs header test, and the two in
bug-p-a-class-nested-type-as-a-specialization-argument-resolves-at-unit-scope.
Playbook section: "A RULE THAT LIVES ON ONE SIDE OF A DECLARATION/IMPLEMENTATION
PAIR FAILS BY AGREEING".
And it may be the last rtl-generics wall too, which is NOT yet established.
PXXDBG=p.mint:TEnumerator over the Collections driver at 61a9463be shows the
hoisting working almost everywhere — TEnumerator$TCustomList$UInt32$PT,
TEnumerator$TEnumerable$UInt32$PT — and exactly ONE bare
alias=TEnumerator$PT tmpl=TEnumerator args=PT, which is the shape a scan with
an empty hoist table produces. That is a HYPOTHESIS from a matching signature,
not a measurement: the bad corpus mint is labelled deferred and this one is
labelled late, so they are not the same site and may not share a cause.
Re-measure after the fix rather than assuming.
THE ONE-LINE FIX IS WRONG, AND ITS FAILURE IS THE REAL FINDING
Attempted and REVERTED at 24f4fc4ec625 (HEAD = 61a9463be), both variants
measured on the repro:
SetSpecSubs(ti, si);
CollectHoistCandidates(ti, Specializations[si].Name); { <- added }
ScanDelphiMethodImplsForNestedSpecs(ti, Specializations[si].Name);
| variant | repro |
|---|---|
| HEAD | unknown type: TPtrs$LongInt$TOwner$Byte$PT — the leak |
+ CollectHoistCandidates |
unknown type: specialize at line 15 |
+ CollectHoistCandidates + EmitHoistedDecls |
expected 'begin' before 'TPtrs' at line 15 |
The name became correct and the program got WORSE, which is the finding. The three rows above are measured and they are the durable part; the causal story is not, and the version this ticket carried until 2026-09-09 was wrong in a way worth spelling out, because a peer had already built a playbook section on it.
The tell that travels, and it needs nothing about the hoist table: the
failure mode CHANGED SHAPE rather than improving. unknown type: <a wrong mangled name> → unknown type: specialize is not closer to working — it is a
DIFFERENT LAYER failing, which means the first layer stopped running. Read the
table that way and it is actionable without any mechanism at all.
WHAT THIS TICKET CLAIMED AND CANNOT SUPPORT. It said "collapsing the
specialize X<...> group in the stream is a SIDE EFFECT of registering it".
It is not. The collapse lives in SpecializeToBuffer, and its condition is
if CaseEqual(aliasNm, specName) or NestedSpecKnown(aliasNm) or
LateSpecEmittedName(aliasNm) then
— three predicates about whether the NAME resolves, and none of them asks
whether ScanRangeForNestedSpecs registered anything. That scan only fills
NSpec*. There is a real coupling — a name skipped at scan time never reaches
LateSpecEmitted, and EmitLateNestedSpecDecls only calls FlushPrereqs when
NSpecCount > 0 — but WHICH link fires here is unmeasured, and
pasparser_generic.inc:4899 documents a THIRD route to the identical
specialize-survives symptom (a method body never swept at all) that involves
neither. Three candidate routes to one symptom, one experiment run: that is
not enough to name a cause, and the confident sentence above was written from
one reading.
So what stands is narrower and still decides the shape of the work: this is
not a one-line fix. Refreshing the table alone is measured to break the
program, so whoever takes it must first establish which of the three routes the
specialize survival came through — PXXDBG=p.nspec:* on the repro under the
patched binary answers it directly, and that measurement was never run.
That also retires the hypothesis above. The single bare
alias=TEnumerator$PT in the Collections driver is NOT shown to be this defect
— the experiment that would have linked them made the corpus worse, not better,
so nothing was learned about the corpus from it. Measured for the record at
61a9463be: of the 8 TEnumerator mints, 7 resolve ($TCustomList$UInt32$PT
x4, $UInt32 x2, $TEnumerable$UInt32$PT x1) and exactly one is bare. Which
site produces it is still unidentified.
Where to start
The read is already resolved eagerly and correctly; the fix belongs at the
SCAN, not at HoistedNameFor. Either the hoist table must be keyed by
specialization rather than reset per parse, or every range belonging to a
specialization must be scanned inside that specialization's own window. The
second is the smaller claim and matches the existing comment at
ScanRangeForNestedSpecs about method bodies being buffered separately —
that arm was added because a range was being missed; this is the same arm
being scanned at the wrong time.
FIXED — THREE OF FOUR SITES SET HALF THE STATE (frankZ, 2026-09-09)
SetSpecSubs(ti, si) and CollectHoistCandidates(ti, specName) are two
halves of ONE per-specialization state, and only ParseSpecialization set
both. The census is four rows and it is the whole finding:
| site | SetSpecSubs |
CollectHoistCandidates |
|---|---|---|
ParseSpecialization |
yes | yes |
EmitLateNestedSpecDecls |
yes | no |
FlushPendingClassSpecializations |
yes | no |
BufferGenericMethod |
yes | no |
Three sites refreshed the substitution and left the hoist table holding
whichever specialization was collected LAST. The fix adds the missing half at
all three. The repro now prints a 1 1, matching fpc 3.2.2.
Why the one-line version made it worse, measured this time
The earlier attempt added the call at EmitLateNestedSpecDecls alone. With
PXXDBG=p.mint:*,p.nspec:* on the patched binary the whole chain is visible:
p.nspec reg alias=TPtrs$LongInt$TOwner$LongInt$PT under=TOwner$LongInt ts=12
p.nspec reg alias=TPtrs$Byte$TOwner$Byte$PT under=TOwner$Byte ts=12
pascal26:15: error: unknown type: specialize
No late registration at all — the two aliases the method-impl header would
mint are now NestedSpecKnown, ScanRangeForNestedSpecs skips them,
NSpecCount stays 0, and EmitLateNestedSpecDecls returns having emitted
nothing. That part is correct and desirable.
What broke is one layer down. The stream collapse in SpecializeToBuffer keys
on the alias resolving:
if CaseEqual(aliasNm, specName) or NestedSpecKnown(aliasNm) or
LateSpecEmittedName(aliasNm) then
and it computes aliasNm through the same hoist table. BufferGenericMethod
did not refresh it either, so when TOwner$LongInt's body streamed, the table
still held Byte's names and the group's alias came out
TPtrs$LongInt$TOwner$Byte$PT — not known, no collapse, and the literal word
specialize survived into the stream. pasparser_generic.inc:4899 documents
that exact symptom for a range that was never swept; this is the same symptom
reached a different way.
So the coupling is real and it is TWO steps, not a side effect: the late
scan REGISTERING the alias is what made NestedSpecKnown answer true, which is
what the collapse reads. With the wrong name it registered, so the collapse
fired on a wrong-but-declared name; with the right name it correctly did not
register, and the collapse then had nothing to fire on because the OTHER site
was still stale. Fixing one half of a two-site state converts a wrong answer
into no answer. This ticket asserted a one-step mechanism for a day and that
sentence has been cut; the measurement above is what replaces it.
The test
test/test_a_hoisted_nested_type_does_not_leak_between_specializations.pas,
wired as sweep_hoistleak26. Two owners with different pointees — Int64 and
Byte — because with the same argument the two hoisted names coincide and the
row passes with the defect live. Neither expected size is 4, since SizeOf of
an unrecorded type answers the int width and a row expecting 4 cannot tell a
correct answer from a blank one. Output is fpc 3.2.2's, byte for byte.
Positive control, run rather than assumed: at 2ccc85cef the same file
gives unknown type: TBox$Int64$TOwner$Byte$PT — Int64's substitution carrying
Byte's hoisted PT, the defect in one line.
And the deferred row is closed.
test_an_inherited_nested_type_is_a_specialization_argument was written with
ONE instantiation per template because of this ticket, which its own header
said in those words. It now carries two and ptr2, a second specialization of
every template on the chain with a different pointee, matching fpc.
What this does NOT establish
The corpus was not re-measured before this was written. The retired hypothesis
above — that the single bare alias=TEnumerator$PT in the Collections driver is
this defect — stays retired until somebody runs the seven minutes; a fix landing
is not evidence about a corpus nobody re-ran.
Log
- 2026-09-09 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 2b43c97d0.
CORPUS RE-MEASURED, AND THE ANSWER IS ZERO — WITH THE CONTROL THAT MAKES THAT SAYABLE
The section above said the corpus had not been re-run and that the retired
TEnumerator$PT hypothesis stayed retired until somebody spent the seven
minutes. Both runs are now in.
| run | binary | errors | wall |
|---|---|---|---|
| with this fix | 00ca0d61bbce |
14 | 6m59.3s |
| control — HEAD minus this fix ONLY | 68421d8ff193 |
14 | 7m19.1s |
The two error lists are byte-identical, same rows in the same order. This
fix moves NO row of the Generics.Collections corpus, in either direction, and
costs no measurable time — which was the one real risk in it, since
CollectHoistCandidates now runs per specialization inside
BufferGenericMethod's loop over every specialization.
THE CONTROL IS THE POINT, NOT THE NUMBER. The previous figure on record was
11 errors, so the naive reading of this run is "+3, and I am the one who just
landed a change here". That reading is available in both directions and both are
wrong: the range between the two figures spans many commits by three seats, and
the row set did not merely grow — duplicate definition of 'TComparer$UInt32.Construct' and cannot access private member "FComparison"
CLEARED while generic template TArray not found appeared. A count cannot see
that. The control binary differs from HEAD by exactly the 20 lines of this fix
and was verified to fail this ticket's own fixture before being run, so the
comparison is attributable and the delta is provably not mine.
CLAUDE.md, "a pull can improve your numbers" and its mirror added the same day: attribute a delta to a RANGE before attributing it to yourself, in the unflattering direction too — that is the direction where finding a culprit ends the search.
AND THIS CASE IS ONE STEP EARLIER THAN EITHER OF THOSE RULES, WHICH BOTH
ASSUME THE DELTA IS A DELTA. DIFF THE ROW SET, NEVER THE COUNT. 11 → 14 reads
as +3 and is actually −2 / +1: two rows cleared and one appeared, across a
range three seats were burning at once. No amount of careful reasoning about who
landed what recovers that from the totals, in either direction, because the
count does not merely misattribute the cause — it destroys the evidence that
there were several causes at all. A corpus under simultaneous work produces
COMPENSATING movements by construction, and the count is the one view in which
they cancel. Keep the row list, diff it, and quote the set difference; the
number is a summary of the thing you actually needed to look at.
(frankS's framing, from the same exchange — the rule as landed was still too
weak, and this is the correction.)
So the retirement stands and is now positive rather than merely unproven.
The single bare alias=TEnumerator$PT is not this defect: this defect is fixed
and that mint is unchanged. Where it does come from is recorded in
bug-p-a-class-nested-type-as-a-specialization-argument-resolves-at-unit-scope.