A five-second Swift Testing wait timed out after its loop had run five times
Five concurrency tests passed on their own in 0.086 seconds. In the full target, they failed together at about 6.8 seconds:
✘ Test fetch_threeSources_asksForEverySourceAtOnce() recorded an issue at FetcherConcurrencyTests.swift:21:30:
Expectation failed: ContinuousClock.now < deadline
✘ Test fetch_threeSources_asksForEverySourceAtOnce() failed after 6.816 seconds with 1 issue.
It looked like a deadlock in the code under test. It was a test helper measuring the wrong thing.
Setup #
- Swift Testing, an Xcode test target that mixes
@MainActorunit suites with snapshot suites. - Two test plans run the same target. One sets
"parallelizable": falsefor it, and the other doesn’t. Only the parallel plan failed. - A small polling helper that the concurrent-fetch tests use to wait for a gated fake service to see requests:
static func until(
sourceLocation: SourceLocation = #_sourceLocation,
_ condition: () -> Bool
) async throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition() {
try #require(ContinuousClock.now < deadline, "Timed out waiting for the condition to hold",
sourceLocation: sourceLocation)
await Task.yield()
}
}
The helper runs on the main actor. Each Task.yield() lets the queued work, in this case the fetcher opening its requests, run before the condition is checked again.
Reproduce before guessing #
Running the target alone under the parallel plan with -test-iterations and -run-tests-until-failure failed 3 out of 3 times, always in the same tests and always at the wall-clock deadline. So it wasn’t a rare flake. It failed every time under that plan.
To check whether the waits were stuck or just slow, I temporarily replaced the deadline with a counter and printed what each wait used:
let start = ContinuousClock.now
var turns = 0
while !condition() {
turns += 1
await Task.yield()
}
print("WAITPROBE turns=\(turns) elapsed=\(ContinuousClock.now - start) at \(sourceLocation.line)")
5 5.766 FetcherConcurrencyTests.swift:54
5 5.712 FetcherConcurrencyTests.swift:21
4 6.411 FetcherConcurrencyTests.swift:37
3 5.704 SyncLoggingTests.swift:25
3 5.338 InFlightTests.swift:20
Every wait finished, but the slowest needed only 3 to 5 turns and took more than 5 seconds of wall time. The condition was met on almost the first chance the fetcher got to run. It just didn’t get that chance for seconds.
The reason showed up when I sorted tests by duration. In the parallel plan, snapshot suites share the process and the main actor, and each render holds the actor synchronously, some of them for over 7 seconds. While a render ran, the waiting test’s next turn just sat in the queue, and the clock kept running.
Dead end: a longer timeout #
I tried a 60-second deadline as an experiment. Both runs passed, with the concurrency tests taking about 7.9 seconds each.
That shows the cause, but it isn’t a fix. 60 is no more principled than 5. A heavier snapshot suite brings the failure back, and a test that really hangs now takes a minute to report it. I reverted it.
Fix: spend both budgets before failing #
The wait should fail only once it has had enough chances to make progress and enough time has passed:
static func until(
sourceLocation: SourceLocation = #_sourceLocation,
timeBudget: Duration = .seconds(5),
_ condition: () -> Bool
) async throws {
let deadline = ContinuousClock.now.advanced(by: timeBudget)
var turns = 0
while !condition() {
let hasBudgetLeft = turns < Constants.turnBudget || ContinuousClock.now < deadline
try #require(hasBudgetLeft, "Timed out waiting for the condition to hold",
sourceLocation: sourceLocation)
turns += 1
await Task.yield()
}
}
enum Constants {
/// A wait under a fully loaded run of this target took at most 5 turns.
static let turnBudget = 100
}
On an idle main actor, 100 turns go by in milliseconds, so a condition that never holds still fails just after the time budget. On a starved actor, the wait keeps going until it has had real chances to run.
Pin it with a controlled starvation test #
The full target is a slow, noisy reproduction, so I wrote a test that intentionally causes starvation. It makes timeBudget a parameter, holds the main actor for longer than that budget, and makes the condition true only afterward:
@MainActor
@Suite
struct WaitTests {
@Test
func until_whenTheMainActorIsHeldPastTheTimeBudget_waitsForTheCondition() async throws {
let holds = LockIsolated(false)
Task { @MainActor in
Thread.sleep(forTimeInterval: 0.3) // stands in for a snapshot render
Task { @MainActor in holds.setValue(true) }
}
try await Wait.until(timeBudget: .milliseconds(100)) { holds.value }
}
@Test
func until_whenTheConditionNeverHolds_fails() async {
await withKnownIssue {
try await Wait.until(timeBudget: .zero) { false }
}
}
}
Before the fix, it failed after 0.335 seconds with the same ContinuousClock.now < deadline error. After the fix, it passed in 0.303 seconds. The second test makes sure the helper can still fail.
Verify both directions #
| Check | Result |
|---|---|
| Parallel plan, before the fix | 0 of 3 passed |
| Parallel plan, after the fix | 5 of 5 passed |
| Serial plan, whole target | 690 tests passed |
| Mutant: fetcher limited to one request at a time | all 5 concurrency tests failed, each at 5.0 s |
The last row matters most. A longer timeout would also have made the parallel runs pass. What it wouldn’t keep is fast failure for a real bug. I changed the fetcher’s concurrency limit from 4 to 1, so the “all three requests are open at once” condition could never be true. All five tests caught it, each at the 5-second time budget, and I reverted the mutation.
One caveat: that mutant ran under the serial plan. Under heavy parallel load, a real hang fails after 100 turns, which takes longer than 5 seconds. That’s the trade-off I chose, and it applies to this helper and this workload. It isn’t a general fairness guarantee for Task.yield().
A second guess hiding in the same tests #
Three tests waited for the first request and then gave the scheduler “five settling turns” before asserting that all three were open. That turn count was another guess, and it could fail under load the same way. The fix was to wait for the exact condition:
// before
try await Wait.until { service.pendingCount > 0 }
await Wait.untilSettled()
#expect(service.pendingCount == 3)
// after
try await Wait.until { service.pendingCount == 3 }
Takeaway #
A wall-clock timeout in a polling helper measures how busy the process is, not whether your code is stuck. When a wait times out only in the big parallel run, count its turns before you suspect a deadlock. If it got only a handful of turns, the actor was starved. Make the helper fail only after both a time budget and a turn budget run out. Then check the fix with a mutation that should hang and make sure it still fails quickly.
Try it on your own polling helper: add a turn counter and run the full target in parallel once. If the worst wait used single-digit turns but took seconds, the timeout is measuring main-actor contention.