Test concurrent iOS token refresh on a cloud Mac
The list, avatar, and message count all call the API at the same time. Once the old token becomes invalid, all three requests fail authentication. If each request refreshes independently, server-side rotation of refresh credentials can cause the updates to overwrite one another. If the user logs out in the meantime, a late response may even restore the logged-in state. A cloud Mac is a good place to turn this race condition into a repeatable test: no production API calls, no reliance on the network happening to slow down, just verification of the authentication coordinator’s behavior.
Define three acceptance criteria first
First, concurrent refresh attempts within the same session must run only one refresh operation. Second, after logout, an old refresh result must not update the current credentials. Third, a business request may refresh and retry at most once. Whether an authentication failure permits a refresh must follow the API contract; not every permission error means the token has expired.
The coordinator receives the token that the request actually used. If the current token is already different, another request has completed the update, so the coordinator returns the new value without refreshing again. Otherwise, a late failure response would trigger a second, unnecessary refresh.
An actor only guarantees isolated access to its state, not that an entire asynchronous method runs without interleaving. At an await, logout and other requests can still enter the actor.
The example represents tokens as strings and injects the refresh transport as a closure. A real application must also handle expiration, refresh credential rotation, and secure persistence. Do not write complete credentials into test reports.
Coalesce refreshes with a shared Task
Add the following coordinator to the project’s test target. It keeps only in-memory state and does not send business requests. The refresh closure must throw when the server returns an explicit failure or a timeout occurs; it must not wait indefinitely.
import Foundation
actor RefreshGate {
enum Failure: Error { case signedOut }
private var token: String?
private var generation = 0
private var flight: (id: UUID, task: Task<String, Error>)?
private let refresh: @Sendable () async throws -> String
init(token: String, refresh: @escaping @Sendable () async throws -> String) {
self.token = token
self.refresh = refresh
}
func recover(rejected: String) async throws -> String {
guard let current = token else { throw Failure.signedOut }
if current != rejected {
try Task.checkCancellation()
return current
}
let epoch = generation
let active: (id: UUID, task: Task<String, Error>)
if let existing = flight {
active = existing
} else {
let operation = refresh
active = (UUID(), Task { try await operation() })
flight = active
}
do {
let updated = try await active.task.value
guard generation == epoch else { throw Failure.signedOut }
if flight?.id == active.id {
token = updated
flight = nil
}
try Task.checkCancellation()
return updated
} catch {
if flight?.id == active.id {
flight = nil
}
throw error
}
}
func logout() {
generation += 1
token = nil
flight?.task.cancel()
flight = nil
}
}
Store the Task before awaiting its result so that other calls can reuse it. During cleanup, compare task identifiers to prevent an older call from resuming and accidentally removing a newer task. This unstructured Task is not automatically canceled when one waiter is canceled. That shared behavior is intentional; do not add a cancellation handler to terminate it.
On success, update the shared state before checking whether the caller was canceled. This prevents a closed page from continuing to consume the result while allowing requests that are still waiting to receive the new token. The cancellation check runs only after the wait ends, so this interface does not return immediately on cancellation.
Run local concurrency tests on a cloud Mac
Replace the refresh API with a counter and use only fixed dummy values for test credentials. Put the following method in an XCTestCase subclass; the coordinator and test only need to be in the same test target.
import XCTest
actor RefreshProbe {
private(set) var calls = 0
func run() async throws -> String {
calls += 1
try await Task.sleep(nanoseconds: 100_000_000)
return "fresh-token"
}
}
func testRefreshIsShared() async throws {
let probe = RefreshProbe()
let gate = RefreshGate(token: "old-token") {
try await probe.run()
}
let values = try await withThrowingTaskGroup(of: String.self) { group in
for _ in 0..<20 {
group.addTask {
try await gate.recover(rejected: "old-token")
}
}
var output: [String] = []
for try await value in group {
output.append(value)
}
return output
}
let calls = await probe.calls
XCTAssertEqual(calls, 1)
XCTAssertEqual(values.count, 20)
XCTAssertTrue(values.allSatisfy { $0 == "fresh-token" })
}
The short pause increases the chance of overlap; it is not a performance measurement. Add a case that submits a failure for the old token after the refresh has already completed, and assert that the call count remains one. This catches implementations that coalesce waiters but overlook late responses.
Do not rely on a fixed sleep to test the logout race. Use a controlled continuation: have the fake API notify the test that refresh has started, then call logout before allowing the result to return. Even if the fake API ignores cancellation and returns successfully, the old waiters must throw. New calls made after logout must fail immediately.
Handle failure, cancellation, and retries separately
Do not turn a shared failure into a retry storm
When refresh throws, the existing waiters receive the same failure, and the coordinator clears the task. Whether a later attempt is allowed is an upper-layer policy decision: temporary network failures can use backoff, while explicitly invalid credentials should close the session. Do not let every waiter immediately call recover again from its catch block.
Before retrying a business request, also confirm that its body can be sent again. File streams, one-shot data sources, and operations that perform writes require either rebuilding the request or using a server-side idempotency protocol. A successful token refresh does not mean every request is safe to replay.
Logout is a state boundary
Task cancellation is cooperative. The transport layer may already have finished, or it may not respond to cancellation in time. Logout must therefore both clear the credentials and increment the generation. Production persistence must be protected by the same check. Do not let the transport closure write new credentials itself, because that would bypass the coordinator’s logout boundary.
Make races part of the regression suite
Before submitting changes, cover at least these cases:
- Multiple requests using the old token fail concurrently: the refresh count is one.
- The new token is already active when an old failure arrives: no additional refresh occurs.
- Refresh fails: all waiters fail, and the shared task is cleared.
- One waiter is canceled: the other waiters still succeed.
- Refresh returns only after logout: credentials are not restored, and no business request is retried.
- The retry still fails authentication: return the error directly, without recursively refreshing.
Record only the test name, refresh count, and final state—not token contents. When running this test suite repeatedly on a cloud Mac, the goal is not to prove that races can never occur. It is to confirm that when they do occur, the outcome still respects the session boundary.
Frequently asked questions
Should cancelling one screen cancel the shared refresh task?
No. Other requests may still need that refresh. Cancel the screen’s own processing; logout cancels the shared task and advances the session generation to reject late results.
Should a second authentication failure trigger another refresh?
No. Each business request gets at most one refresh and one retry. Return the second authentication error and use the server protocol to decide whether the session is invalid.
Choose a Dedicated Cloud Mac for Your Development Work
Apple Silicon physical machines, not virtual machines. Rent by the day, week, month, or quarter. Models and prices are published transparently; check real-time availability in the console.