Test Swift timeout races, cancellation, and scope exit

Test Swift timeout races, cancellation, and scope exit

When teams hand off asynchronous code running on a cloud Mac across time zones, one of the hardest failures to reproduce is this: the interface already shows a timeout, but the test still will not finish. The cause is usually not a broken timer. It is an underlying operation that does not respond to cancellation. Putting the work and the timer in a task group creates a race; it does not forcibly interrupt arbitrary code.

The following examples use Swift structured concurrency to establish an executable baseline, then verify the result, cancellation, and actual scope exit separately. They require a toolchain that supports Duration and Task.sleep(for:), with macOS 13 or later for command-line execution. Record the actual compiler version when handing the work over to another team.

Define the timeout contract before writing the timer

Write the contract as three rules: if the work finishes first, return its result; if the timer branch is observed first, throw a timeout error; if the parent task is cancelled, propagate cancellation rather than converting it into an application-level timeout. When the two branches finish almost simultaneously, do not promise a particular winner.

Also distinguish a relative wait from a strict deadline. In the implementation below, timing starts when the timer branch begins its wait. It does not establish an absolute response-time limit measured from entry into the function. A very short wait cannot eliminate scheduling delays, either.

The timeout firing, the cancellation request being sent, and the underlying operation exiting are three separate events. Only after the last event can the task group finish.

If the application must return immediately at the deadline, you cannot also claim that all work remains scoped to this task group. You need to redesign the underlying cancellation interface, or move non-interruptible work into a separate process and manage its lifecycle.

Implement a race with injectable waiting logic

Save the following as Timeout.swift. The waiting closure can use a real clock or be replaced with a controllable gate in tests. The work’s result must meet the requirements for transfer between tasks.

import Foundation

enum TimeoutFailure: Error {
    case expired
}

func raceTimeout<Value: Sendable>(
    wait: @escaping @Sendable () async throws -> Void,
    operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
    try Task.checkCancellation()

    return try await withThrowingTaskGroup(of: Value.self) { group in
        defer { group.cancelAll() }

        group.addTask {
            try Task.checkCancellation()
            return try await operation()
        }

        group.addTask {
            try Task.checkCancellation()
            try await wait()
            try Task.checkCancellation()
            throw TimeoutFailure.expired
        }

        guard let value = try await group.next() else {
            throw CancellationError()
        }

        try Task.checkCancellation()
        return value
    }
}

First check the types and concurrency constraints with this compilation command, then integrate the code into the project’s tests:

xcrun swiftc -swift-version 6 -strict-concurrency=complete \
  -parse-as-library -emit-module \
  -module-name Timeout Timeout.swift \
  -emit-module-path /tmp/Timeout.swiftmodule

Here is a call using a real timer. The caller must be in an asynchronous context:

let result = try await raceTimeout(
    wait: { try await Task.sleep(for: .seconds(2)) },
    operation: {
        try await Task.sleep(for: .milliseconds(20))
        return "ready"
    }
)

The defer block ensures that cancellation is requested for the remaining branch on both success and error paths. Cancelling the timer causes it to throw. Do not swallow that error with try?, or the branch may continue running and incorrectly report a timeout.

Use gates to fix the order instead of relying on sleeps

Core unit tests should not follow the pattern “wait a little, then guess which branch ran first.” Create two test gates to control work completion and timer completion separately. Add startup signals so you can confirm that both branches have entered their waits before opening the intended gate.

Each gate should track cancellation state and release its waiter through a cancellation handler. If it uses a continuation internally, ensure that the continuation is resumed exactly once. Also cover the ordering in which cancellation arrives before the waiter is registered. Otherwise, the test fixture itself can cause a hang.

Keep at least these four sets of assertions:

For each set, check not only the call’s result but also wait for both branches’ completion markers. A correct result is not enough to pass if a child task has not exited.

Prove that uncooperative work blocks scope exit

Add a work gate that deliberately ignores cancellation but can be released by the test. Let the timer branch win first, then confirm that the work has received a cancellation request while the enclosing call is still incomplete. Finally, release the work gate and verify that only then does the enclosing call throw the timeout error.

This test demonstrates the waiting semantics of structured concurrency; it is not an endorsement of ignoring cancellation. The test must have a fallback release mechanism. Do not simulate a hang with an infinite loop, and do not leave behind a continuation that will never be resumed if an assertion fails.

In production code, inspect the entire call chain: do long-running loops regularly call Task.checkCancellation()? Do callback adapters invoke the underlying cancellation handle? Can blocking calls return? Checking once at the start of an operation does not cover the long-running work that follows.

Include exit evidence in the next team’s handoff

When running on a cloud Mac, include the commit identifier, compiler version, test case name, winning branch, cancellation observations, and both exit markers in the test attachments. Calculate elapsed time with a monotonic clock, not by subtracting wall-clock readings from different team members’ desktops.

Real-clock tests should only verify that the production waiting closure is wired correctly. Allow scheduling headroom rather than requiring millisecond-exact timing. The test process also needs an independent execution limit: a task group that never finishes cannot rescue itself with its own timer child task.

Finally, check that error handling does not rewrite every CancellationError as a timeout, that the waiting closure does not swallow errors, and that failure paths retain exit evidence. The handoff is complete not when “the timer fired,” but when the result is explainable, cancellation is observable, and the remaining work has exited.

Frequently asked questions

Does the function return as soon as the timer task throws?

Not necessarily. A task group waits for every child to finish before leaving its scope. cancelAll requests cancellation; it cannot forcibly stop work that ignores the request.

Must every timeout test wait several real seconds?

No. Inject a controlled wait and use test gates to determine completion order. Keep only a few real-clock integration tests and give them an independent execution limit.

Choose a cloud Mac for your next task

Choose a location near where most of your team is based, and use a dedicated physical Mac mini. Browser-based remote desktop requires a modern browser and a stable internet connection. Check the configuration, billing period, and availability before ordering.

Make your next connection from nearby

Check the configuration and billing period first, then choose a location close to where your team is based.