Hi! I've came across an unexpected behavior when explicitly capturing an isolated parameter in TaskGroup.addTask closure and I'm trying to figure out whether this is intended behavior or a bug:
actor MyActor {}
func test(isolation: isolated MyActor) async {
isolation.assertIsolated() // OK
await withTaskGroup { group in
isolation.assertIsolated() // OK
await Task {
isolation.assertIsolated() // OK
}.value
group.addTask {
isolation.assertIsolated() // Crash: Incorrect actor executor assumption
}
await group.waitForAll()
}
}
await test(isolation: MyActor())
I've confirmed the difference comes from the @_inheritActorContext attribute, which is used by Task.init but not by TaskGroup.addTask. Nevertheless, since the isolated parameter is captured explicitly in both cases I would assume that assertions should pass as the closure should become isolated to the capture.
I've made a couple more examples which illustrate this behavior in more detail:
func callWithInheritedActorContext(
@_inheritActorContext closure: @Sendable @isolated(any) () -> Void
) async {
await closure()
}
func test2(isolation: isolated MyActor) async {
await callWithInheritedActorContext {
isolation.assertIsolated() // OK
}
}
func callWithoutInheritedActorContext(
closure: @Sendable @isolated(any) () -> Void
) async {
await closure()
}
func test3(isolation: isolated MyActor) async {
await callWithoutInheritedActorContext {
isolation.assertIsolated() // Crash: Incorrect actor executor assumption
}
}
func test4(isolation: isolated MyActor) async {
let closure: @Sendable @isolated(any) () -> Void = {
isolation.assertIsolated() // Crash: Incorrect actor executor assumption
}
await closure()
}
extension MyActor {
func test5() async {
let closure: @Sendable @isolated(any) () -> Void = {
self.assertIsolated() // Crash: Incorrect actor executor assumption
}
await closure()
}
}
When using sending instead of @Sendable or making closures explicitly async rather than @isolated(any) the examples crash in the same way.
Recently accepted SE-461 seems to explain that this is not a new behavior:
If the type of the closure is
@Sendableor if the closure is passed to asendingparameter, the closure is inferred to benonisolated.The closure is also inferred to be
nonisolatedif the enclosing context has an isolated parameter (includingselfin actor-isolated methods), and the closure does not explicitly capture the isolated parameter. This is done to avoid implicitly capturing values that are invisible to the programmer, because this can lead to reference cycles.
This would indicate that the @Sendable/sending closures are unconditionally nonisolated (unless annotated with @_inheritActorContext as seen above).
However, if this is indeed true and even explicit capture of isolated parameter should not change isolation of @Sendable/sending closures then closures of type like sending @isolated(any), used by TaskGroup.addTask, have rather unfortunate semantics of being nonisolated and @isolated(any) at the same time.
I would appreciate if someone could take a look and say whether this is intended behavior. Thanks!