Why does my Swift actor deadlock when I call it from a Task?

Claude Chat anonymous·1d ago #swift #concurrency
Open in Claude Open in ChatGPT
U
You

I have an actor that owns a WebRTC connection. When I call await conn.send(frame) from inside a Task { } started in the actor itself, the app hangs. No crash, just nothing happens.

actor Connection {
    private var queue: [Frame] = []
    func enqueue(_ f: Frame) {
        queue.append(f)
        Task { await self.flush() }
    }
    func flush() async {
        while let f = queue.first {
            queue.removeFirst()
            try? await transport.send(f)   // suspends
            semaphore.wait()               // waits for ack
        }
    }
}
C
Claude

The hang is semaphore.wait(). It blocks the thread, and the thread it blocks is one of the cooperative pool threads that Swift concurrency needs to deliver the ack that would signal the semaphore. You’ve built a one-line deadlock: the ack can’t arrive because the thread that would deliver it is parked waiting for the ack.

Rules that get you out of this class of bug:

  1. Never block inside an actor or a Task. No DispatchSemaphore.wait(), no sleep(), no .wait() on a group. Blocking a cooperative thread starves everything.
  2. Turn the ack into something awaitable: a CheckedContinuation resumed by the ack callback, or an AsyncStream of acks you for await on.

Minimal fix:

func flush() async {
    while let f = queue.first {
        queue.removeFirst()
        try? await transport.send(f)
        await withCheckedContinuation { cont in
            transport.onAck = { cont.resume() }
        }
    }
}

Two more things in your snippet:

  • Task { await self.flush() } from inside enqueue can start several concurrent flush loops if enqueue is called quickly; guard it with an isFlushing flag so only one loop runs.
  • The while let f = queue.first + removeFirst() pair is fine inside an actor, but make sure transport.send isn’t itself hopping back to the main actor for UI reasons; that’s the second most common “it just hangs”.
U
You

How do I see which thread is blocked?

C
Claude

Pause in Xcode when it hangs and look at the thread list: you’ll see a thread named like com.apple.root.default-qos.cooperative sitting in __ulock_wait under DispatchSemaphore.wait. That thread name is the giveaway — anything blocking on a “cooperative” thread is a bug.

Also turn on the runtime warning: set LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 in the scheme’s environment. It makes the cooperative pool a single thread, so this class of deadlock shows up immediately instead of “sometimes on the customer’s machine”.

Comments (0)

No comments yet.

Sign in to comment.

Report this post