A shared Swift timeout left an earlier task without a deadline

·3 mins

A Swift service had a timeout, but waiting for its in-flight work could still hang. A held-fetch regression test reached Swift Testing’s outer limit:

Time limit was exceeded: 60.000 seconds

Starting another check replaced the shared timer while the earlier fetch kept running. The fix was to give each tracked operation its own cancellation deadline and cancel that deadline when the operation ended. That still depends on the fetch cooperating with cancellation.

One timer could not cover overlapping fetches

The service could start another asynchronous check before the previous one finished. A separate tracker retained the in-flight tasks so a caller could wait for their completion. Its waiter took a snapshot of those tasks and awaited their results.

The mistaken assumption was that the service’s existing timeout covered everything in that snapshot. Review found that starting a check reset the shared timer without canceling the previous fetch. Finishing a check also reset the timer so that an older completion could interfere with a newer check’s deadline.

An illustrative sequence makes the ownership mismatch visible:

start A       tracked: A       shared timer: A
start B       tracked: A, B    shared timer: B; A's timer reset
finish B      tracked: A       shared timer: reset
wait for A    A still running  no cancellation deadline for A

The tracker remembered more work than the timer covered. Making that timer longer would leave the same mismatch.

Put the deadline beside the operation

The correction belonged where the tracker created each fetch task. That scope already knew which task it owned and when it finished.

For each operation, create a separate deadline task that sleeps for the configured duration, then calls cancel() on that operation’s task handle. Keep the deadline handle local. On exit, cancel the deadline and remove the operation from tracking. Forward cancellation from the caller to the fetch as well.

The resulting ownership is:

operation A owns deadline A; completion A cancels deadline A
operation B owns deadline B; completion B cancels deadline B

Canceling a deadline’s sleep must end the deadline task. Swallowing the sleep’s cancellation error and continuing to cancel() would turn normal cleanup into an unintended cancellation request.

Keeping the handles local also means A’s cleanup cannot accidentally cancel B’s deadline through a shared slot.

Prove that the waiter needs this deadline

The regression test used a fetch held until cancellation, a short injected operation timeout, and a signal confirming that the fetch had started. Nothing outside the tracker canceled it. The test then awaited completion and checked that the fetch ended before the waiting caller continued.

Before the implementation used the injected timeout, the test failed at the one-minute limit. After adding the operation-local deadline, the archived test output reports the held-fetch test passing and the focused test target exiting successfully.

That establishes the missing-deadline behavior for a cooperative fake. It does not reproduce the entire overlapping-check sequence live. For your own tracker, also hold A open while B starts and finishes, then confirm that A still receives its own cancellation request.

A cancellation deadline is not a hard timeout

Swift’s Task.cancel() documentation explicitly says cancellation is cooperative. Calling it flags the task and runs cancellation handlers; arbitrary work does not automatically stop. A fetch that ignores cancellation can outlive this deadline and keep a completion waiter suspended.

Later work therefore separated the operation’s cancellation deadline from the caller’s user-facing wait budget. It also added recovery after an unsuccessful check. Giving every operation a deadline did not settle how long a caller should wait or whether continuing after failure was safe.

Before relying on a service timeout, start two operations and finish the newer one first. Check that the older operation still has its own cancellation deadline.