Test Swift actor reentrancy and quota reservations across await
Two export requests on a cloud Mac both pass through the same actor check, yet both claim the last available slot. The problem is not necessarily a data race: after the first request checks capacity, it waits for asynchronous preparation, letting the second request enter during that gap. What you need to verify is the business invariant across await, not whether the actor serializes reads and writes to its state.
Define the capacity invariant first
Suppose an export service allows at most one task to occupy a temporary workspace. Capacity must include tasks that are preparing as well as those that are executing—not just tasks that have finished preparing. Otherwise, slow preparation becomes a way to bypass the limit.
The dangerous sequence is “check availability, await preparation, increment usage.” While the method is suspended, the actor can handle other calls, so the check no longer reflects the state when execution resumes. The correct sequence is to check and reserve capacity in a section with no suspension points, then perform the asynchronous operation.
Actor isolation protects access to state; it does not turn an entire asynchronous method into an uninterrupted transaction. At every
await, ask: will the conditions I rely on still hold when execution resumes?
This example rejects requests immediately when capacity is full; it does not maintain a waiting queue. Queuing, fairness, and timeouts require a separate contract. They cannot be supplied by having callers retry indefinitely.
Use tokens to represent reservation ownership
On the cloud Mac, create a macOS project named ActorQuota and add an XCTest unit test target. Replace the default example in the test file with the following code. These tests exercise shared Swift concurrency logic, so there is no need to launch an iOS simulator.
import Foundation
import XCTest
enum QuotaError: Error {
case full
}
actor ReservationPool {
private let capacity: Int
private var active: Set<UUID> = []
init(capacity: Int) {
precondition(capacity > 0)
self.capacity = capacity
}
var count: Int { active.count }
func run(
_ operation: @Sendable () async throws -> Void
) async throws {
try Task.checkCancellation()
guard active.count < capacity else {
throw QuotaError.full
}
let token = UUID()
active.insert(token)
defer { active.remove(token) }
try await operation()
try Task.checkCancellation()
}
}
There is no await between the check and the insertion, so another call cannot interleave between them. Each call holds its own token and releases only its own reservation when it exits. This prevents a failure path from accidentally decrementing another call’s usage.
defer handles both normal returns and thrown errors, but it is not a universal substitute for resource management. The supplied operation must not return until the actual work has finished. If it starts a task whose lifetime extends beyond the call and then returns immediately, the slot will still be released too early. Review the calling contract alongside the implementation.
Control the reentrant interleaving instead of relying on sleeps
Give the first operation a controlled pause point. The second call must arrive after the reservation has been made but before it is released. A fixed sleep only guesses how far execution has progressed, making the test prone to passing by chance as machine load changes.
Add this one-shot fixture and test class to the same file. The fixture allows the gate to be opened before anyone waits, preventing an early signal from leaving the waiter suspended forever.
actor PauseGate {
private var opened = false
private var waiter: CheckedContinuation<Void, Never>?
func pause() async {
if opened { return }
await withCheckedContinuation { continuation in
waiter = continuation
}
}
func open() {
opened = true
waiter?.resume()
waiter = nil
}
}
final class ReservationTests: XCTestCase, @unchecked Sendable {
func testRejectsOverlapAndReleases() async throws {
let pool = ReservationPool(capacity: 1)
let gate = PauseGate()
let entered = expectation(description: "Operation entered")
let first = Task {
try await pool.run {
entered.fulfill()
await gate.pause()
}
}
await fulfillment(of: [entered], timeout: 2.0)
let during = await pool.count
XCTAssertEqual(during, 1)
do {
try await pool.run {}
XCTFail("Overlapping operation was accepted")
} catch QuotaError.full {
}
await gate.open()
try await first.value
let after = await pool.count
XCTAssertEqual(after, 0)
try await pool.run {}
}
}
When the expectation is fulfilled, the first call has entered the operation body, so its reservation must already exist. The two-second timeout limits how long the test waits to reach that point; it is not a performance requirement. The fixture supports only one waiter. Do not use it directly as a production broadcast mechanism.
Verify failure and cancellation separately
Add a throwing test case: have the operation immediately throw a test-specific error, catch it and verify its type, then read count. The count must be zero, and the next call must also succeed. Merely asserting that an error occurred would miss a reservation leak after a business operation fails.
For the cancellation case, reuse the pause point. Wait until the first operation has entered, then call first.cancel(). Read the usage at that point; it should still be one. Next, open the gate and await the task’s result. It should throw CancellationError, and the final usage should be zero.
These semantics are deliberately conservative. Cancellation only sends a cooperative signal; the pause fixture in this example does not automatically resume when cancelled. Releasing capacity before the underlying operation exits would allow a new task to overlap with the old one.
If the real export API supports cancellation, pass the cancellation signal through and wait for confirmation that the operation has ended. If the operation has already produced irreversible side effects, the final cancellation check cannot undo them. Define the commit point and a way to query the result separately; do not interpret “threw a cancellation error” as “nothing happened.”
Add the tests to the build and keep a failing counterexample
Run the following command from the project root. Keep the project name and shared scheme consistent with the setup above, and place the test class in the ActorQuotaTests target.
set -euo pipefail
xcodebuild \
-project ActorQuota.xcodeproj \
-scheme ActorQuota \
-destination 'platform=macOS' \
-only-testing:ActorQuotaTests/ReservationTests \
test
First, retain the passing result for the correct implementation. Then, on a temporary branch, deliberately move the reservation until after the asynchronous operation. The overlap test should fail. If it still passes, the test is not actually covering the gap between checking capacity and claiming a slot.
Finish by checking four things: a reservation exists before suspension, rejected calls do not change usage, error paths release only their own tokens, and cancellation returns capacity only after the actual operation exits. Then change the capacity to two, pause two operations, and reject a third to verify that the implementation has not hard-coded the limit as a Boolean switch.
Commit these tests alongside the resource pool implementation. When the asynchronous preparation flow is refactored later, they will do more to protect the real capacity boundary than a comment saying, “This uses an actor, so it is safe.”
Frequently asked questions
Why can an actor exceed its quota without a data race?
An await can allow another call to enter. If capacity was checked but not reserved, both calls can observe the same available slot and proceed.
Should cancellation release a reservation immediately?
Only once the underlying operation stops using the resource. Cancellation is cooperative, so releasing a slot while the operation still runs can cause double allocation.
Add a dedicated Mac to your workflow
Build, test, and experiment on native macOS. Compare configurations and billing periods, then choose the right physical node.