← board

Mutually referencing generics are rejected as circular

Repro (22 lines; FPC prints 7, pxx refuses)

program cyc;
{$MODE OBJFPC}{$H+}
type
  generic TEq<T> = class
    class function Make: LongInt;
  end;

  generic TDel<T> = class(specialize TEq<T>)   // (1) genuine: inheritance
    class function Val: LongInt;
  end;

class function TEq.Make: LongInt;
begin
  Result := (specialize TDel<T>).Val;          // (2) inside a METHOD BODY
end;

class function TDel.Val: LongInt;
begin
  Result := 7;
end;

type TE1 = specialize TEq<LongInt>;
begin
  WriteLn(TE1.Make);
end.
pascal26:22: error: circular generic specialization: TDel$LongInt requires
             TEq$LongInt, which requires TDel$LongInt back

Reproduces identically in {$MODE DELPHI}.

The two references are not the same kind, and that is the whole bug

# reference when it must exist our treatment
1 TDel<T> inherits TEq<T> declaration time — you cannot declare the class without its parent blocking prerequisite — correct
2 TEq<T>'s method body constructs TDel<T> materialisation time — when that method is compiled, which is after both classes are declared blocking prerequisite — wrong

ParseSpecialization collects prerequisites from the class body and every method body, and treats them all as things that must be declared BEFORE this alias. For (2) that is stronger than the language requires, and it manufactures a cycle out of a program that has none. FPC has no trouble: it specializes on demand and memoises, so the back-reference resolves when the method is compiled.

The cycle detector itself is right and must stay. A genuine cycle exists — test/test_generic_cycle_fail.pas asserts it (TP$string$Integer requires TP$Integer$string requires back) and that test must keep failing. This ticket is not "delete the check"; it is "stop counting a materialisation-time reference as a declaration-time one".

Why the method-body scan cannot simply be dropped

The obvious move — go back to scanning only the class body — is wrong and would reopen a ticket that is already closed. Method-body prerequisites are needed: rtl-generics' dominant comparer idiom lives in a class-constructor body and nowhere else, and not scanning it is exactly what [[bug-p-a-generic-class-method-call-is-undefined-inside-another-generics-body]] fixed. So both are required; what is missing is the distinction between "must be declared first" and "must exist by the time this method is streamed".

Direction, not a diagnosis (unmeasured, do not treat as settled): a method-body prerequisite could be emitted without deferring the declaration that found it — the alias needs to exist before the method is materialised, and methods are materialised after the type section (FlushPendingClassSpecializations). If that holds, the deferral/cycle machinery would only ever see genuine declaration-time edges, and the cycle detector would go back to catching only real cycles. That needs measuring before it is believed.

Pre-existing, measured in both directions

Worth stating precisely, because this surfaced in the same session that changed the neighbouring code and the timing invites the wrong conclusion:

compiler result on the repro above
pinned undefined variable (specialize) at the method body — cannot get far enough to have an opinion (that is the older, already-fixed defect)
this tree before the wall-6 fix (rebuilt with it stashed) the identical circular error
this tree after the identical circular error

The wall-6 fix therefore did not introduce this: it made mode Delphi reach the same wall the objfpc path was already standing at, which is what that fix was for. The corpus's apparent regression is the same story — generics.defaults.pas now stops at :994 rather than :3231, which is EARLIER in the file but a truer error: before, a prerequisite was silently never discovered.

Corpus impact — this is rung 6's wall in both units now

generics.defaults.pas:994, reached via the real shape at :785 (TDelegatedEqualityComparerEvents<T> = class(TEqualityComparer<T>)) and :2644 (Result := TDelegatedEqualityComparerEvents<T>.Create(...), inside a method body). generics.collections.pas uses Generics.Defaults, so it dies at the same line without reaching any of its own — as it did behind the previous wall. Rung 6 is behind this one defect in both units.

Resolved 2026-08-28 (frankA)

The distinction needed no new state, and that is what kept this in Track P.

frank-coordinator's hypothesis was that the two edge kinds arrive from two different scans, so the kind is known at the moment the edge is recorded and can be tagged at insertion rather than recovered later. That is correct, and it goes one better: the scans already run class body first, method bodies after, so the kind is encoded in the insertion order and the boundary is a single index.

ScanRangeForNestedSpecs(Templates[ti].TokStart, Templates[ti].TokCount, specName);
nDeclEdges := NSpecCount;        { everything below this came from the CLASS BODY }
for gmScan := 0 to GenericMethodCount - 1 do ...

That mattered practically, not just aesthetically: the NSpec* arrays live in compiler/defs.inc, which is Track A's shared ground, and frank-optimize-b4 is live in that area. A per-edge flag would have needed a parallel array there and tripped the escape condition. Looking for the fact already present — the question the wall-6 fix had just rewarded — is what made this a P-only change.

The fix. A materialisation-time-only prerequisite set (nDeclEdges = 0) is emitted but not deferred: this specialization registers now, and the prerequisite declarations go in at TokPos, parsed straight after it and well before FlushPendingClassSpecializations streams any method that needs them.

Deferring was the actual bug. It re-emits this declaration behind its prerequisites, so on the re-parse the method-body reference is still undeclared — it now sits after this declaration in the stream — the scan finds it again, defers again, and the round counter eventually calls it a cycle. Two specializations each waiting for the other to be declared first, when only one of them ever needed to be.

The control, run before and after

test/test_generic_cycle_fail.pas asserts a GENUINE cycle. Confirmed failing before the change (a control is not a control until it has fired) and still failing after. Both edges of a real cycle are declaration-time, so nDeclEdges > 0 on at least one side and the deferral path still runs. The two tests are a pair: turning the new one green by disabling the detector turns that one red.

Verification

Two repros that were the wrong shape, recorded so they are not re-tried

My first two attempts at a minimal case failed for reasons unrelated to this defect, and both would have read as "the fix does not work":

The working shape is a var of the specialized type, which is one of the five positions the rewrite's fixed-point comment already names.

Still open behind this: the prerequisite is emitted before the referenced template exists

generics.defaults.pas now stops at :3250, and it is the same ordering family rather than a return of this one. Measured:

line
TGStringComparer<T, THashFactory> declared ~985
TGOrdinalStringComparer<T, THashFactory> declared 1002
TGStringComparer.Ordinal's body references it 3250

The rewrite emits a template's alias declaration right behind that template's own declaration, so TGStringComparer's alias — and therefore the materialisation-time prerequisite this fix now emits with it — lands at ~985, before the referenced template exists at 1002. undefined variable (specialize) follows.

Minimal case (Delphi, FPC prints 7, we do not):

type
  TEq<T> = class class function Make: LongInt; end;
  TDel<T> = class(TEq<T>) class function Val: LongInt; end;   // declared LATER
class function TEq<T>.Make: LongInt;
begin
  Result := TDel<T>.Val;        // referenced from a template declared EARLIER
end;

Direction (unmeasured): emit materialisation-time prerequisites at the END of the type section rather than immediately after the referencing template, since by then every template in the section is declared. FlushPendingClassSpecializations already runs at exactly that boundary and is the obvious place to look. Filed separately rather than folded in here — this ticket's defect (a false cycle) is fixed and tested, and that one is a different question about placement.

Log