Actor Reentrancy in Swift - Why Actors Alone Do Not Prevent State Corruption

M
Matthew Diakonov

Actor Reentrancy in Swift - Why Actors Alone Do Not Prevent State Corruption

Swift actors eliminate data races. They do not eliminate reentrancy bugs. If you have worked with actors in a production macOS app, you have probably hit this problem without recognizing it - because the symptoms look like random data inconsistency rather than a concurrency bug.

What Reentrancy Actually Means

When an actor method reaches an await point, the actor is free to run other work while waiting. This means two calls to the same method can interleave at every suspension point. The second call does not wait for the first to finish - it starts executing on the actor as soon as the first hits an await.

The canonical example is a bank account:

actor BankAccount {
    var balance: Int = 10_000

    func withdraw(_ amount: Int) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        // The await here is the danger zone
        try await processPayment(amount)
        balance -= amount  // Two calls can both pass the guard above
    }
}

Two tasks calling withdraw(10_000) simultaneously will both pass the guard check before either subtracts from the balance. Result: balance = -10_000. The actor did not protect you because the read and write are separated by an await.

A real-world case from Fazm's development: token refresh. When multiple requests fire simultaneously, all of them check isRefreshing == false, all start refreshing, and you end up with multiple simultaneous refresh requests - which the server rejects as duplicate, causing cascading auth failures.

Why Swift Chose This Design

Swift actors guarantee mutual exclusion at synchronization boundaries but deliberately do not guarantee that a method runs atomically from start to finish. The alternative - holding the actor lock across the duration of any async work - would cause deadlocks and kill performance. Long network calls or disk operations would block the actor for seconds at a time.

The Swift proposal SE-0306 states the design intent explicitly: actors prevent data races but leave reentrancy as the developer's responsibility. The principle is that actor state should be valid at every yield point, and code should be able to resume correctly regardless of what happened in between.

This is correct as a language design choice. It means you need a pattern for protecting critical sections that span await points.

The TaskGate Pattern

A TaskGate serializes access to a critical section within an actor. The idea is simple: maintain a queue of continuations and process them one at a time. Before entering a critical section, you enqueue and wait. When you finish, you dequeue the next waiter.

actor TaskGate {
    private var isLocked = false
    private var waiters: [CheckedContinuation<Void, Never>] = []

    func acquire() async {
        if !isLocked {
            isLocked = true
            return
        }
        await withCheckedContinuation { continuation in
            waiters.append(continuation)
        }
    }

    func release() {
        if let next = waiters.first {
            waiters.removeFirst()
            next.resume()
        } else {
            isLocked = false
        }
    }
}

Using it in the token refresh case:

actor AuthManager {
    private var token: String?
    private let gate = TaskGate()

    func validToken() async throws -> String {
        await gate.acquire()
        defer { Task { await gate.release() } }

        if let existing = token, !isExpired(existing) {
            return existing
        }
        let fresh = try await fetchNewToken()
        token = fresh
        return fresh
    }
}

Now exactly one call executes fetchNewToken() at a time. All concurrent callers wait at gate.acquire() and get the fresh token when the first call completes - no duplicate requests, no wasted tokens.

When You Need TaskGate vs. When You Do Not

Not every actor method needs gating. The rule of thumb has two parts:

Gate it if: the method reads state, awaits something external (network, disk, another actor), and then writes state based on what it read. The read-await-write pattern is the reentrancy danger zone.

Skip the gate if: the method only reads without awaiting, only writes without awaiting, or its outcome does not depend on a state check made before an await.

Common cases requiring a gate:

  • Syncing local state with a remote API (check-fetch-update)
  • Processing a queue one item at a time (dequeue-process-update)
  • Token refresh and credential management (check-fetch-store)
  • Any idempotency check that spans an async operation (check-do-mark-done)

Debugging Reentrancy Bugs

Reentrancy bugs are hard to reproduce because they require specific timing. In testing, operations usually complete fast enough that interleaving does not happen. Under production load or with slower network conditions, they surface as intermittent data corruption.

Useful debugging approach: add logging around your read and write operations with task identifiers. When two task IDs show up interleaved around the same state variable, you have found a reentrancy window. Swift's Task.currentPriority and actor isolation contexts can help identify which call is which.

The Bigger Picture

The reentrancy problem is a good example of a pattern where the language feature (actors) solves most of the problem but not all of it. Swift actors are a genuine improvement over manually managed locks - they eliminate entire classes of data races. But they require developers to understand what they do not protect.

In Fazm's agent runtime, we gate any operation that involves checking shared state before making an external call. The cost is minimal - actors are already serialized, so a TaskGate just extends that serialization to cover the full operation duration. The benefit is eliminating an entire class of bugs that would be nearly impossible to reproduce and diagnose in production.

Fazm is an open source macOS AI agent. Open source on GitHub.

More on This Topic

Related Posts