← board

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

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.