Does withCheckedContinuation suspend _before_ calling its closure?

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:

func pop() async -> T? {
  if !items.isEmpty {
    return items.removeFirst()
  } else {
    return await withCheckedContinuation { cont in
      waitingContinuations.append(cont)
    }
  }
}

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.

1 Like

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.

Looks like that's been reverted, if I'm reading that right.

I think the revert was on a personal fork?

Ah, I wasn't reading it right. Is this behavior in the Swift 6.4 beta?

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.

1 Like

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.

This might be an important detail. I don't suppose there are any internal uses of Task and/or isolated parameters in this struct as well?

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...)

2 Likes

Thanks for posting this! There are no examples of the problematic pattern I was thinking of, so at least in that regard you are safe.

(I also think you are right that the mutex is unnecessary.)

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.