Files
element-x-ios/.agents/skills/swift-concurrency-pro/references/bug-patterns.md
T
Doug 33e7c1a819 Add Swift Concurrency Pro skill to the project. (#5662)
Installed for Copilot with `gh skill install` and manually created a symlink for Claude to access all Copilot skills.
2026-05-29 12:47:48 +01:00

4.1 KiB
Raw Blame History

Bug patterns

Real concurrency failure modes that LLMs produce frequently, with the preferred fix for each.

Actor reentrancy: check-then-act across await

Failure: Actor method checks state, awaits, then acts on the stale check. Other callers may have mutated state during the suspension.

// BUG: Two callers can both see nil and both download.
// The force unwrap can crash if a third caller clears the cache mid-flight.
actor Cache {
    var data: [String: Data] = [:]

    func load(_ key: String) async throws -> Data {
        if data[key] == nil {
            data[key] = try await download(key)
        }
        return data[key]!
    }
}

Fix: Capture the async result into a local before writing. For deduplication, store in-flight Task handles. See actors.md for the full pattern.

Continuation resumed zero times

Failure: A withCheckedThrowingContinuation callback never fires (object deallocated, network timeout with no callback, early return before registering the handler, etc). The caller hangs forever.

Fix: Audit every code path to confirm the continuation is resumed. If the underlying API can silently drop the callback, add a timeout or restructure so the caller isn't left waiting. Always use withCheckedThrowingContinuation (not the unsafe variant) so that missed resumes are easier to diagnose.

Continuation resumed twice

Failure: Two callbacks (e.g., a success handler and a cancellation handler) both resume the same continuation. CheckedContinuation traps at runtime; UnsafeContinuation causes undefined behavior.

Fix: Restructure the callback wiring so only one path can reach the continuation. If that isn't possible, guard with a Bool flag or use an actor to serialize access. Always default to CheckedContinuation so double resumes surface immediately during development and testing.

Unstructured tasks in a loop

Failure: for item in items { Task { await process(item) } } creates fire-and-forget tasks with no cancellation propagation, no error collection, and no way to await completion.

Fix: Use withTaskGroup or withThrowingTaskGroup. See structured.md.

Swallowed errors in Task closures

Failure: Task { try await riskyWork() } – if riskyWork throws, the error is silently lost. The user sees nothing; the operation just doesn't happen.

Fix: Handle the error inside the closure – show an alert, log to a visible surface, or propagate via a @State error property.

Task {
    do {
        try await riskyWork()
    } catch {
        self.errorMessage = error.localizedDescription
    }
}

Blocking the main actor with synchronous work

Failure: CPU-intensive work runs on @MainActor (or inside Task {} called from @MainActor), causing UI freezes. In Swift 6.2 this is more likely because nonisolated async functions now stay on the caller's executor by default.

Fix: Move the expensive work into an explicitly offloaded function using @concurrent, or use Task.detached as a last resort.

Unbounded AsyncStream buffer

Failure: A high-throughput producer yields values faster than the consumer processes them. With the default .unbounded buffering policy, memory grows without limit.

Fix: Specify .bufferingNewest(n) or .bufferingOldest(n). See async-streams.md.

Ignoring CancellationError in catch blocks

Failure: A catch block retries or shows an error alert for CancellationError, which is a normal lifecycle event (e.g., user navigated away).

Fix: Check for cancellation before handling other errors:

do {
    try await loadData()
} catch is CancellationError {
    // Normal – view disappeared or task was cancelled. Do nothing.
} catch {
    self.errorMessage = error.localizedDescription
}

@unchecked Sendable hiding real races

Failure: A class is marked @unchecked Sendable to suppress compiler errors, but its mutable var properties have no synchronization. The data race still exists at runtime.

Fix: Restructure to use value types, use an actor, or move state behind a lock. See bridging.md.