TL;DR
Casting collections of function types crashes at runtime. I've been working on a fix for ~10 months across three PRs:
- First PR was approved and merged, but it didn't handle some use cases and was reverted.
- Second PR follows the approach approved in the first PR also fixing identified gaps. But it got stuck in code review. There is a disagreement about the scope of the fix. Review feedback on the two PRs gives contradictory guidance about synthesizing
ClosureExpr. I've got no response from the reviewers since April. - Third PR provides alternative implementation - avoiding AST changes, as requested in the second PR feedback. But it does not address disagreement about the scope of the fix.
I would like to get some fresh eyes on this topic, to:
- clarify the scope of the fix
- make a decision about the best place to synthesize
ClosureExpror work out another strategy for loweringKeyConversion/ValueConversion.
The Bug
All casts of collections of function types crash at runtime, while the equivalent scalar casts work fine. This is independent of @preconcurrency or typealiases — here's a pure Swift 6 reproducer:
func upcastArray(_ x: [@Sendable () -> Void]) -> [@MainActor () -> Void] {
return x // no diagnostics, crashes at runtime
}
func upcastScalar(_ x: @escaping @Sendable () -> Void) -> @MainActor () -> Void {
return x // works fine
}
Or even one unrelated to concurrency:
func upcastArray(_ x: [(Base) -> Void]) -> [(Derived) -> Void] {
return x // no diagnostics, crashes at runtime
}
func upcastScalar(_ x: @escaping (Base) -> Void) -> (Derived) -> Void {
return x // works fine
}
Root Cause
There are two underlying problems:
-
Collection element casts and scalar casts use different machinery. For both cases
CSApplyemits code for coercion of function types. For scalars it ends up being used in theSILGen, but for collections it is recorded insideCollectionUpcastConversionExprand not used after that. Instead, collection casts are lowered using runtime functions (_arrayForceCast, etc.) that useas?/as!for casting elements. -
Dynamic casts of function types are not supported. The runtime always fails them.
Fixing #1 makes collection casts behave identically to scalar casts — coercions work, dynamic casts fail consistently. Fixing #2 would make dynamic casts work, but without #1, @preconcurrency coercions in collections would still crash, because @preconcurrency coercions are not valid dynamic casts.
I believe #1 is higher priority.
Why a Narrow Fix Doesn't Work
It was suggested in review that the original issue could be solved more narrowly by changing how isolation attributes are stripped from @preconcurrency typealias, so the problematic cast would never be emitted. However:
- The crash reproduces in cases that don't involve
@preconcurrency,typealiasor minimal concurrency checking. - Differences in stripping concurrency attributes seem to be intentional as per SE-0337.
The crash is in collection-cast runtime infrastructure, not in concurrency attribute handling. I'd like confirmation that fixing it at the collection-cast level is the right scope.
The Fix
Both open PRs solve problem (1) by introducing new runtime functions for collection casts that take a closure for casting elements. Type coercion code emitted by CSApply needs to be lowered into a closure passed to these runtime functions. This lets the compiler emit element-cast code identically to how it handles scalar function casts. These closures don't capture values, but might capture types from a generic context.
The disagreement is about where that closure is constructed.
Two Approaches
In the AST, CollectionUpcastConversionExpr has KeyConversion/ValueConversion fields currently modeled as ConversionPair. These were populated by CSApply but never consumed by SILGen since the original implementation by @John_McCall. SILGen currently has no infrastructure to lower a ConversionPair into a closure.
Approach A — Use ClosureExpr as representation for KeyConversion/ValueConversion (PR #87512):
- Replaces
ConversionPairwithClosureExprinCollectionUpcastConversionExpr CSApplydirectly creates the closure.- Sema assigns discriminator and capture info
- Covered by
ASTVerifier - SILGen lowers it like any other closure
Approach B — Synthesize closure in SILGen (PR #91638):
- Keeps
ConversionPairin the AST unchanged - SILGen creates a detached
ClosureExpron the fly from theConversionPair - Bypasses
ASTVerifier - Duplicates discriminator assignment and type-capture computation from Sema
- Includes a
ConversionClosureEmitterthat clones a limited subset ofExprsubtypes
I've implemented Approach B to address this feedback, but I believe Approach A is the better long-term solution — it's simpler, uses existing infrastructure, and has verifier coverage. I'm willing to land either one.
The last reviewer response was in April 2026. I'd appreciate @kavon and @xedin to resume the discussion, or get input from anyone else from the Core team (cc @John_McCall as the original designer of CollectionUpcastConversionExpr, @Douglas_Gregor and @beccadax as authors of SE-0337) on which direction to take.