I'd welcome feedback on the incremental approach outlined below, particularly whether the Inherited-stage unification is the right first step, and whether anyone sees cycle risks I've missed, or if this has been tried already or captured somewhere else.
Motivation: the what
ConformanceLookupTable is the central data structure for protocol conformance in the Swift compiler. It processes conformances through four imperative stages (RecordedExplicit, Inherited, ExpandedImplied, Resolved), each mutating shared state in a fixed order. Whether a conformance is visible to a query depends on which stages have run, which in turn depends on what other code has triggered table construction for the same or related types.
This ordering sensitivity causes real bugs:
- PR #90251: A class conforming to a protocol that refines
Sendablegets a falseNonSendableSuperclassdiagnostic when its superclass is a cross-module@MainActorObjC class. The superclass's implicit Sendable is derived lazily viaImplicitKnownProtocolConformanceRequest, but the table'sInheritedstage runs before that request is triggered, soinheritConformancesnever finds it. - PR #38921 (rdar://81700570): Crash due to inherited Sendable conformance being "missing" when forming the
InheritedProtocolConformance. The commit message notes: "The proper fix for this issue is likely to eliminate the modeling of inherited conformances within the conformance lookup table, which introduces a lot of redundance and, apparently, some bugs." - PR #79195 (rdar://137080876): Extension macros with both
memberandextensionroles had theirconformingTo:protocols suppressed depending on which role was expanded first. - PR #85828:
~Sendabledidn't properly account for inherited conformances, requiring special-case handling in the table.
The codebase acknowledges this fragility directly. FIXMEs in ConformanceLookupTable.cpp alone include:
- "This could be lazier" (line 185)
- "Avoid infinite loop by fixing root cause instead" with a hardcoded 16384-iteration backstop (line 541)
- "Conformance lookup should depend on source location for 100% correctness" (line 620)
- "Deterministic ordering" with a fallback to arbitrary resolution (line 670)
- "This is a hack because the inherited conformances aren't getting updated properly" (line 878)
Downstream code has accumulated its own workarounds. checkSendableConformance in TypeCheckConcurrency.cpp can't rely on the table for a straight answer about whether a superclass is Sendable, so it layers three independent checks:
isa<InheritedProtocolConformance>as a proxy for superclass SendabilityisNSObject()name-based fallback (special-cased from the SE)- a direct
checkConformancecall
The root issue: the why
The table mixes two concerns: discovery (finding all conformance sources: explicit, inherited, implied, synthesized) and resolution (choosing the winner when multiple sources exist). Both are implemented as side effects on shared mutable state. The stages cascade (Inherited calls updateLookupTable(Resolved) on the superclass, triggering all earlier stages recursively), but implicit conformance derivation lives outside the table entirely. ImplicitKnownProtocolConformanceRequest is a fallback in lookupConformance that runs after the table has been fully resolved and found nothing. This creates a window where the table is "resolved" but incomplete.
Every external entry point (NominalTypeDecl::lookupConformance(), getAllConformances(), getImplicitProtocols(), getSatisfiedProtocolRequirementsForMember(), and the global lookupConformance() / lookupConformances()) calls prepareConformanceTable() and updateLookupTable to whatever stage it needs. If two callers need different stages for overlapping types, the table may be in a partially-processed state when either one reads it.
Proposed direction: the how
The goal is that asking "does type T conform to protocol P?" always returns the same answer regardless of what else has been computed. The compiler's evaluator/request infrastructure already provides caching, dependency tracking, and cycle detection. Conformance resolution should use it rather than reimplementing ordering constraints imperatively in the table. This direction was discussed briefly in 2019 when @dan-zheng raised the idea of a unified conformance request; @Slava_Pestov noted that lookupConformance() and conformsToProtocol() differ only in incremental dependency recording and conditional requirement checking, and that "once the remaining distinction between the two is conditional conformance checking, it would be great to merge them into a single request or function." That merge never happened, but the fragmented API surface and the ordering bugs cataloged above suggest it's time to revisit.
This can be done incrementally, with each step delivering concrete value.
Start: unify implicit conformance into the table
Today, ImplicitKnownProtocolConformanceRequest runs as a fallback after the table fails. The first step is to move this derivation into the Inherited stage so that when the table resolves a superclass, implicit conformances (Sendable, BitwiseCopyable) are already present. This eliminates the window where the table is "resolved" but missing lazily-derived conformances, and fixes the implicit-derivation ordering class of bugs without restructuring the table itself.
Concretely: in ConformanceStage::Inherited, after calling updateLookupTable(superclass, Resolved), trigger ImplicitKnownProtocolConformanceRequest for any KnownProtocolKind that uses lazy derivation and add resulting conformances to the superclass's table before inheritConformances runs.
This introduces a new re-entrancy path to reason about. deriveImplicitSendableConformance calls checkConformance on the superclass, which calls lookupConformance, which can re-enter updateLookupTable for a different type whose table is partially constructed. The evaluator's existing active-request check (at ConformanceLookup.cpp:214-221) prevents re-entering ImplicitKnownProtocolConformanceRequest for the same type, and the VisitingSuperclass guard (ConformanceLookupTable.cpp:349) handles the direct-superclass cycle. We'd need to verify that these guards cover transitive cases or add a similar guard for the implicit derivation path.
With this in place, the PR #90251 point fix in getConformance() and the existing getConformingContext hack become unnecessary, and checkSendableConformance can drop its special-case logic in favor of a single checkConformance call.
Then: make resolution idempotent
resolveConformances currently runs once during the Resolved stage, operating on all protocols for a nominal type simultaneously. If new conformance sources appear later (extension macros, retroactive conformances loaded from other modules), the resolution may be stale.
Wrapping this in a ResolvedConformanceRequest{nominal, protocol} would make resolution cacheable and invalidatable through the evaluator. It would subsume the Resolved stage, the compareConformances ranking logic, and the getConformance materialization (which currently has its own fixup logic for Sendable).
The current resolveConformances iterates all protocols at once, and the diagnoseSuperseded logic in compareConformances considers interactions between entries. Splitting resolution per-protocol could change the order in which supersession diagnostics are emitted, so this needs careful validation against the existing test suite.
Eventually: split discovery from resolution
With resolution behind a Request, the natural next step is to factor out conformance source discovery the same way:
ExplicitConformancesRequest{nominal}-- scans inheritance clauses and extension macrosInheritedConformancesRequest{classDecl}-- walks the superclass chainImpliedConformancesRequest{nominal}-- expands protocol inheritance
ResolvedConformanceRequest would depend on all three, and the evaluator would handle ordering.
Endgame: retire ConformanceLookupTable
With discovery and resolution both driven by Requests, ConformanceLookupTable reduces to a cache owned by the evaluator. The mutable AllConformances map and the stage enum go away. NominalTypeDecl::lookupConformance() becomes a thin wrapper around ResolvedConformanceRequest, and the FIXMEs about deterministic ordering, source-location dependence, and incomplete circularity detection can be addressed in the individual Requests rather than the monolithic table.
This last step is aspirational. The table is deeply embedded in the compiler, and getting here would require significant refactoring, but each preceding step is independently useful and moves in this direction.
Practical considerations
Performance. The current table's forEachInStage with LastProcessedEntry tracking avoids reprocessing extensions; each stage only handles entries added since it last ran. A naive per-protocol request model could cause O(protocols x extensions) work where the current table does O(extensions) total. The evaluator's caching should compensate (each Request runs once), but the interaction with the evaluator's invalidation model needs measurement. The first step (unifying implicit conformance) is low-risk since it only adds work during the existing Inherited stage. Later steps should include before/after measurements on large projects to verify no regression.
Serialization. Deserialized conformances from .swiftmodule files bypass the staged pipeline entirely and are registered directly via takeConformanceLoader(). The request model needs to accommodate this path, likely by having the deserialization loader populate the same cache that the discovery Requests write to. This only matters for the later discovery-splitting step; the first two steps don't change the deserialization path.
Retroactive conformances. Conformances from other modules are loaded lazily through takeConformanceLoader() as well. The request model would need to define when cross-module conformances are discovered, likely as an input to ExplicitConformancesRequest that queries the module's conformance tables.
Alternatives considered
Fix each ordering bug individually. This is what we've been doing. PR #90251 fixes the Sendable case in getConformance. The FIXME hack fixes the Inherited case in getConformingContext. PR #38921 patches over a crash from the same root cause. Each fix works, but each is specific to one protocol and one entry kind. The next protocol with lazy derivation will hit the same pattern.
Eagerly derive all implicit conformances during module loading. This would eliminate the lazy derivation window but front-load work that may never be needed. For large ObjC modules with thousands of classes, eagerly deriving Sendable for every @MainActor class would be wasteful.
Related work
- PR #90251 -- point fix for Sendable ordering in
getConformance - PR #90186 --
isImplicitSendableCheckguard for SE-0434 source compatibility (the "mistake" case where globally-isolated classes with non-Sendable superclasses got implicit Sendable silently) - PR #38921 -- workaround for inherited Sendable crash (rdar://81700570)
- PR #79195 -- fix for macro conformance ordering (rdar://137080876)
- SE-0302, SE-0316, SE-0434 -- the Sendable and global actor specifications that created the implicit conformance path
- Evaluator.h -- the request infrastructure this pitch builds on
- Adding a "does X conform to Y" request -- 2019 forums discussion where Slava Pestov expressed interest in merging conformance APIs into a single request