iOS / Swift / Concurrency and data boundaries / Lesson 10
Actor reentrancy and result freshness
Protect semantic ordering across suspension points even when actor isolation already prevents unsynchronized memory access.
On this page
Lesson goals
- Data-race safety is not a latest-result policy
- Capture request identity before the suspension
- Do not let obsolete errors replace current success
Data-race safety is not a latest-result policy
Actors protect their isolated state from unsynchronized access. An actor method can nevertheless suspend while awaiting another operation, allowing another actor message to change that state. When the original method resumes, assumptions made before await may no longer hold. A search started first can finish last and replace newer results unless the model defines ordering. This is a semantic race rather than necessarily a memory data race. Identify which operation currently owns the right to publish, and recheck that ownership after suspension instead of relying solely on an actor annotation.
Capture request identity before the suspension
The first fragment is a main-actor search model with an explicit ticket for each attempt. The repository is also declared main-actor isolated here to make the teaching boundary clear and avoid unspecified transfer assumptions. An application can design a separate actor or Sendable transport layer, but must then document that boundary. Each load captures the ticket before awaiting and compares it before either success or failure publication. Retaining previous results on a current failure is an intentional refresh policy. The model does not claim cancellation or server authorization.
@MainActor protocol SearchRepository {
func search(_ query: String) async throws -> [String]
}
@MainActor final class SearchModel {
private let repository: any SearchRepository
private var ticket = 0
private(set) var results: [String] = []
private(set) var failed = false
init(repository: any SearchRepository) { self.repository = repository }
func load(_ query: String) async {
ticket += 1
let mine = ticket
do {
let next = try await repository.search(query)
guard mine == ticket else { return }
results = next; failed = false
} catch {
guard mine == ticket else { return }
failed = true
}
}
}
Only the most recently started load can replace results or the failure flag.
This model is intentionally not an observable UI implementation. It isolates and illustrates the publication policy with an injected dependency.
Do not let obsolete errors replace current success
A common partial repair guards only the successful result. If the older request later throws, an unguarded catch can still turn a successful screen into an obsolete error. Apply the identity rule to every publication path, including finally-like cleanup that changes busy state. If several operations share one boolean, an old completion can clear busy while a new operation remains active. Model the owner of pending work or derive pending state from the current operation rather than assuming all completions have equal authority.
Include session ownership where accounts can change
The second fragment expresses a publication gate for both request and session generations. Increment the session generation when account ownership changes and invalidate private caches under the same scope. A screen can remain alive across logout and login, so view existence alone does not prove the result belongs to the current user. Keep safe identifiers in diagnostics instead of logging tokens or personal query text. This gate controls local publication only; the authoritative service still checks current authentication and permission whenever it evaluates a command.
struct PublicationIdentity: Equatable, Sendable {
let session: Int
let request: Int
}
func mayPublish(started: PublicationIdentity, current: PublicationIdentity) -> Bool {
started == current
}
let old = PublicationIdentity(session: 1, request: 7)
let current = PublicationIdentity(session: 2, request: 7)
print(mayPublish(started: old, current: current))
false
The same request number under a new session is a different owner. Pure comparison does not cancel work or enforce remote permission.
Cancellation and debouncing complement identity checks
Debouncing can reduce starts while a user types, and cancellation can reduce unnecessary work if the underlying operation cooperates. Neither alone proves that an obsolete completion cannot reach a model. A callback may already be queued, or a service may have completed before cancellation was observed. Keep the identity check at the commit boundary even when optimizing work upstream. For transactional commands, do not apply a latest-search policy blindly: an older save may already have changed server state and require reconciliation rather than silent abandonment.
Use deterministic completion-order tests
Provide a fake repository with separately controllable pending results. Start A, start B, complete B, then complete A and confirm B remains authoritative. Repeat with A failing after B succeeds and with session ownership changing before A completes. Also verify an obsolete completion cannot clear the active loading indicator. These checks should use explicit continuations or a carefully scoped fake rather than arbitrary wall-clock sleeps. Record the language mode and actor annotations in an executed test report. No Swift concurrency runtime test was performed during authoring.
Common errors and fixes
Older data overwrites the newest query
- Why it happens
- The model relied on actor isolation without a publication identity.
- How to fix it
- Compare current request identity after await and before commit.
- Prevent it next time
- Test reversed completion order.
An old failure clears a successful screen
- Why it happens
- Only the success branch checked freshness.
- How to fix it
- Apply the gate to errors and pending-state cleanup too.
- Prevent it next time
- Complete stale requests with both success and failure.
Account switching exposes previous-session content
- Why it happens
- Request identity omitted account ownership and cache scope.
- How to fix it
- Invalidate session-scoped work and compare session generation.
- Prevent it next time
- Test login changes during pending work.
Check your understanding
1. Can a main-actor load method still have a stale-result defect?
Show worked solution
Yes. It can suspend at await while another main-actor action starts a newer load. Isolation prevents unsynchronized access, not this semantic ordering error. Capture identity before suspension and revalidate before every state publication.
2. Why must stale completion not set busy false unconditionally?
Show worked solution
A newer operation may still be active. Clearing a shared flag would let duplicate controls activate or falsely announce completion. Tie the indicator to the current operation identity, and only that owner may finish its pending transition.
Key takeaways
- Data-race safety is not a latest-result policy
- Use deterministic completion-order tests