I've just spent the morning diagnosing a deadlock which turned out to be a race condition with continuations in an actor. I think I may have been misled by the documentation of withCheckedContinuation(), which says:
The body of the closure executes synchronously on the calling task, and once it returns the calling task is suspended.
I took this sentence to mean that the calling task is not suspended before running the closure. But apparently this is not true -- it's the only explanation I can find for the deadlock, and once I updated my code to stop making that assumption, the bug went away. Can anyone confirm this?
My code is basically an async producer/consumer queue; it's called with actor isolation. Pseudocode for the buggy method:
The bug in the code was that before the body of withCheckedContinuation ran, another task could have called the matching push() method that adds an item to the queue. So the continuation would end up waiting when it should have immediately resumed with the item, which usually meant it waited forever. I didn't think this case was possible.
I'd be curious to see more of the specifics regarding the isolation and/or executors involved in your example, but the issue you describe looks like this problem to me. IIUC this may have been fixed by this change which has not yet shipped in a swift release.
Please verify the behavior just released betas (I.e. Swift 6.4 associated SDKs), it is expected to be fixed by moving to nonisolated(nonsending) as linked by folks in this thread.
The fix is in the signature of the func, which is emitted into client code, so as long as you compile against latest SDK it should work correctly even on older deployment targets.
I actually first found this race condition while using Swift 6.4. I went back to 6.3.1 to see if it was a regression, but it persisted.
To clarify: the code above isn’t a method of an actor. It’s in a non-sendable struct that’s a private property of an actor, so it’s implicitly isolated.
Here's the entire struct; it's self-contained. As I said, it's a private property of an actor, so it's always called isolated to that actor. (In hindsight I don't think it actually needs to use a Mutex; IIRC I was initially trying to make this struct Sendable but gave up on that.)
internal import Synchronization
/** An asynchronous LIFO producer/consumer channel with unlimited capacity.*/
struct AsyncStack<T: Sendable> : ~Copyable {
init() { }
init(_ items: [T]) {
_mutex.withLock { state in
state.items = items
}
}
var isEmpty: Bool {
_mutex.withLock { state in
state.items.isEmpty
}
}
/// Pushes an item onto the stack.
/// Has no effect if the stack is closed.
func push(_ item: T) {
_mutex.withLock { state in
guard !state.closed else { return }
if let waiter = state.waiters.first {
state.waiters.removeFirst()
waiter(item)
} else {
state.items.append(item)
}
}
}
/// Removes and returns the latest item added to the stack.
/// If the stack is empty, blocks until an item is added.
/// If the stack is closed, or closes, returns nil.
func pop() async -> T? {
// Get the item if it's available:
let (item, wait) = _mutex.withLock { $0.take() }
if !wait {
return item
} else {
// If not available, wait for one to be pushed:
return await withCheckedContinuation { cont in
_mutex.withLock { state in
// (Task may have been suspended, altering state, so retry the `take`)
let (item, wait) = state.take()
if !wait {
cont.resume(returning: item)
} else {
// OK, still no item, so put myself on the waiting list:
state.waiters.append { item in
cont.resume(returning: item)
}
}
}
}
}
}
/// Removes and returns the latest item added to the stack, without blocking.
/// If there is nothing in the stack right now, or the stack is closed, returns nil.
func tryPop() -> T? {
return _mutex.withLock { state in
if !state.items.isEmpty {
return state.items.removeLast()
} else {
return nil
}
}
}
/// Closes the stack. All items are removed, and any blocked pop() calls will resume, returning nil.
func close() {
_mutex.withLock { state in
state.closed = true
state.items.removeAll()
for waiter in state.waiters {
waiter(nil)
}
state.waiters.removeAll()
}
}
private let _mutex = Mutex<State>(State())
// Making this private crashes the compiler: https://github.com/swiftlang/swift/issues/87573
/*private*/internal struct State {
typealias Callback = (_ item: consuming T?) -> Void
var items = [T]()
var waiters = [Callback]()
var closed = false;
mutating func take() -> (item: T?, wait: Bool) {
if closed || items.isEmpty {
return (nil, !closed)
} else {
return (items.removeLast(), false)
}
}
}
}
(Ironically, as I pasted this I noticed the comment at the end reminding me that that I've already had to work around a compiler crash while implementing this little struct...)
Thanks for the update on this bug. Will you clarify the specific situations that are affected by this when using Xcode 26.5? For example, are we safe from this bug if no actor isolation is involved?
More candidly, the Swift team and Apple should release fixed toolchains now; this bug is very serious, and we shouldn't wait three more months to release a fix.