there's this strange bug (and related thread) that was reported some time ago in which a closure that is actor-isolated and involves an unowned capture of the actor instance can seemingly lead to an over-release of the actor itself. here's a reproduction distilled from the original report:
actor A {
func bug() {
let _: @isolated(any) () -> Void = {
[unowned self] in
_ = self
}
}
deinit {
print("deinit")
}
}
@main
enum App {
static func main() async {
let a = A()
await a.bug()
// "deinit" prints twice, but only one instance created
}
}
if i add a print() statement into the end of the bug() method, then deinit prints before it, so it seems something with the way the closure capture is handled is likely responsible. i've stared at the SIL for a long time and i'm still not entirely sure if there's something wrong at that level, or if it's further down the pipeline. the SIL for the bug() function in the above example looks like:
// A.bug(), loc "/app/example.swift":4:10, scope 6
// Isolation: actor_instance. name: 'self'
sil hidden @$s6output1AC3bugyyF : $@convention(method) (@sil_isolated @guaranteed A) -> () {
// %0 "self" // users: %7, %4, %3, %1
bb0(%0 : $A):
debug_value %0, let, name "self", argno 1, loc "/app/example.swift":4:10, scope 6 // id: %1
%2 = alloc_stack [lexical] [var_decl] $@sil_unowned A, loc "/app/example.swift":6:26, scope 8 // users: %8, %6, %19, %18
strong_retain %0, loc "/app/example.swift":6:26, scope 7 // id: %3
%4 = ref_to_unowned %0 to $@sil_unowned A, loc "/app/example.swift":6:26, scope 7 // users: %6, %5
unowned_retain %4, loc "/app/example.swift":6:26, scope 7 // id: %5
store %4 to %2, loc "/app/example.swift":6:26, scope 7 // id: %6
strong_release %0, loc "/app/example.swift":6:26, scope 7 // id: %7
%8 = load %2, loc * "/app/example.swift":5:44, scope 7 // user: %9
%9 = strong_copy_unowned_value %8, loc * "/app/example.swift":5:44, scope 7 // users: %12, %10
%10 = ref_to_unowned %9 to $@sil_unowned A, loc * "/app/example.swift":5:44, scope 7 // users: %17, %14, %13, %11
unowned_retain %10, loc * "/app/example.swift":5:44, scope 7 // id: %11
strong_release %9, loc * "/app/example.swift":5:44, scope 7 // id: %12
unowned_retain %10, loc "/app/example.swift":5:44, scope 7 // id: %13
%14 = init_existential_ref %10 : $@sil_unowned A : $@sil_unowned A, $any Actor, loc "/app/example.swift":5:44, scope 7 // user: %15
%15 = enum $Optional<any Actor>, #Optional.some!enumelt, %14, loc "/app/example.swift":5:44, scope 7 // user: %16
release_value %15, loc "/app/example.swift":6:26, scope 7 // id: %16
unowned_release %10, loc "/app/example.swift":6:26, scope 7 // id: %17
destroy_addr %2, loc "/app/example.swift":6:26, scope 7 // id: %18
dealloc_stack %2, loc "/app/example.swift":6:26, scope 7 // id: %19
%20 = tuple (), loc "/app/example.swift":9:5, scope 7 // user: %21
return %20, loc "/app/example.swift":9:5, scope 7 // id: %21
} // end sil function '$s6output1AC3bugyyF'
if we reduce it just to the instructions that i think affect strong reference counts (per the docs), we get:
strong_retain %0 // +1
strong_release %0 // -1
%9 = strong_copy_unowned_value %8 // +1
strong_release %9 // -1
release_value %15 // -1
// net change: -1?
looking at the lowered IR maybe makes it more clear something is off, since the strong retain/release counts don't seems to be balanced:
define hidden swiftcc void @"output.A.bug() -> ()"(ptr swiftself %0) #0 !dbg !59 {
entry:
%self.debug = alloca ptr, align 8
#dbg_declare(ptr %self.debug, !64, !DIExpression(), !65)
call void @llvm.memset.p0.i64(ptr align 8 %self.debug, i8 0, i64 8, i1 false)
%1 = alloca %swift.unowned, align 8
store ptr %0, ptr %self.debug, align 8, !dbg !66
call void @llvm.lifetime.start.p0(i64 8, ptr %1), !dbg !67
%2 = call ptr @swift_retain(ptr returned %0) #3, !dbg !71 ; strong +1
%3 = call ptr @swift_unownedRetain(ptr returned %0) #2, !dbg !71
store ptr %0, ptr %1, align 8, !dbg !71
call void @swift_release(ptr %0) #2, !dbg !71 ; strong -1
%4 = load ptr, ptr %1, align 8, !dbg !72
%5 = call ptr @swift_unownedRetainStrong(ptr returned %4) #2, !dbg !72 ; strong +1
%6 = call ptr @swift_unownedRetain(ptr returned %4) #2, !dbg !72
call void @swift_release(ptr %4) #2, !dbg !72 ; strong -1
%7 = call ptr @swift_unownedRetain(ptr returned %4) #2, !dbg !73
%8 = call ptr @"lazy protocol witness table accessor for type output.A and conformance output.A : Swift.Actor in output"() #10, !dbg !73
%9 = ptrtoint ptr %4 to i64, !dbg !73
%10 = ptrtoint ptr %8 to i64, !dbg !73
%11 = inttoptr i64 %9 to ptr, !dbg !74
%12 = inttoptr i64 %10 to ptr, !dbg !74
call void @swift_release(ptr %11) #2, !dbg !74 ; strong -1
call void @swift_unownedRelease(ptr %4) #2, !dbg !74
%toDestroy = load ptr, ptr %1, align 8, !dbg !76
call void @swift_unownedRelease(ptr %toDestroy) #2, !dbg !76
call void @llvm.lifetime.end.p0(i64 8, ptr %1), !dbg !76
ret void, !dbg !76
}
so, what's going wrong here exactly? my best guess so far is that somehow the combination of an unowned, optional, class existential capture is producing the wrong cleanups. the instruction that seems particularly unusual to me is this:
%14 = init_existential_ref %10 : $@sil_unowned A : $@sil_unowned A, $any Actor
i've yet to come up with another formulation that will produce an init_existential_ref that has @sil_unowned on the AST types. is such an instruction well-formed? can you create a class existential container that houses an unowned reference?
at any rate, would be interested if anyone has ideas about what could actually be going wrong here.
here's a godbolt sample i was working with that contains code that differs just in the ownership of the closure capture. you can add a 'Diff View' from the 'Add' dropdown menu to directly compare the intermediate representation outputs. note that it is sort of difficult to reproduce this with development compilers or those with assertions enabled due to the fact that the involved code trips various SIL verifier issues and assertions, so the frontend flag -sil-verify-none is used. however, the code does compile successfully (or, perhaps, unsuccessfully?) with at least some of the release compilers that exist (e.g. the 6.2.0 Xcode toolchain).