クラウド Mac で検証する Swift タイムアウトとキャンセル

クラウド Mac で検証する Swift タイムアウトとキャンセル

クラウド Mac で検証する Swift タイムアウトとキャンセル

クラウド Mac 上の非同期コードをタイムゾーンの異なるチームに引き継ぐ際、特に再現が難しい不具合の一つが、画面にはタイムアウトと表示されているのに、テストがいつまでも終わらないというものです。原因は多くの場合、タイマーの不具合ではなく、下位の処理がキャンセルに応答していないことにあります。処理とタイマーをタスクグループに入れれば競争させることはできますが、任意のコードを強制的に中断できるわけではありません。

ここでは Swift の構造化並行処理で実行可能な基準実装を作り、結果、キャンセル、実際の終了を分けて検証します。サンプルには Duration と Task.sleep(for:) をサポートするツールチェーンが必要で、コマンドラインでの実行環境は macOS 13 以降を前提とします。チーム間の引き継ぎでは、実際に使用したコンパイラのバージョンも記録してください。

タイマーを書く前にタイムアウトの契約を定める

まず、契約を三つに整理します。処理が先に完了したら結果を返す。タイマー側の完了を先に観測したらタイムアウトエラーをスローする。親タスクがキャンセルされたら、そのキャンセルを伝播させ、業務上のタイムアウトには置き換えない。両方の分岐がほぼ同時に完了した場合、どちらが勝つかは保証しません。

相対的な待機時間と厳密な期限も区別する必要があります。以下の実装では、タイマー側の分岐が待機を開始した時点から時間を計ります。呼び出し開始からの応答時間に絶対的な上限を設けるものではありません。また、待機時間を極端に短くしても、スケジューリングの遅延はなくせません。

タイムアウトの発生、キャンセル要求の送信、下位の処理の終了は、それぞれ別のイベントです。最後のイベントが発生して初めて、タスクグループは終了できる状態になります。

期限に達したら即座に呼び出し元へ戻ることが業務要件なら、同時にすべての処理がこのタスクグループの管理下にあるとは主張できません。下位のキャンセルインターフェースを設計し直すか、中断できない処理を別プロセスに分離し、そのライフサイクルを管理する必要があります。

待機ロジックを注入できる競争処理を実装する

以下を Timeout.swift として保存します。待機クロージャには実際の時計を使うことも、テスト用の制御可能なゲートを使うこともできます。処理結果は、タスク間で受け渡すための要件を満たす必要があります。

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
    }
}

まずコンパイルコマンドで型と並行処理の制約を確認し、その後プロジェクトのテストに組み込みます。

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

実際に時間を計る呼び出し例は次のとおりです。呼び出し元は非同期コンテキストである必要があります。

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

defer により、成功時もエラー発生時も、残った分岐に必ずキャンセルを要求します。タイマーはキャンセルされるとエラーをスローします。このエラーを try? で握りつぶしてはいけません。そのまま実行を続け、タイムアウトと誤って報告するおそれがあります。

sleep 任せにせずゲートで実行順序を固定する

中核となるユニットテストを「少し待って、どちらの分岐が先に動いたかを推測する」形にしてはいけません。処理の完了とタイマーの完了をそれぞれ制御する、二つのテスト用ゲートを用意します。さらに開始シグナルを設け、両方の分岐が待機に入ったことを確認してから、対象のゲートを開放します。

ゲートはキャンセル状態を記録し、キャンセルハンドラを通じて待機中のタスクを解放する必要があります。内部で continuation を使う場合は、再開が一度だけ行われることを保証してください。キャンセルが先に届き、後から待機側が登録される順序も扱う必要があります。そうしないと、テスト用の仕組み自体がハングの原因になります。

少なくとも、次の四組のアサーションを用意します。

各組では呼び出し結果を確認するだけでなく、両方の分岐の終了マーカーを待つ必要があります。結果が正しくても、子タスクが終了していなければ検証に合格したとはいえません。

キャンセルに応答しない処理が終了を妨げることを検証する

意図的にキャンセルを無視しつつ、テスト側からは開放できる処理用ゲートを追加します。まずタイマー側を勝たせ、処理がキャンセル要求を受け取ったにもかかわらず、外側の呼び出しがまだ完了していないことを確認します。最後に処理用ゲートを開放し、その時点で初めて外側がタイムアウトをスローすることを確かめます。

このテストが示すのは、構造化並行処理における待機のセマンティクスです。キャンセルの無視を推奨するものではありません。テストには確実にゲートを開放するための後始末を用意してください。無限ループでハングを再現したり、アサーションの失敗後に、二度と再開されない continuation を残したりしてはいけません。

本番コードでは、呼び出しチェーンに沿って確認します。長いループで Task.checkCancellation() を定期的に実行しているか、コールバックを適合させる層で下位のキャンセルハンドルを呼び出しているか、ブロッキング呼び出しから戻れるかを点検してください。処理の入口で一度確認するだけでは、その後に続く長時間の処理には対応できません。

終了の証跡も次の担当者へ引き継ぐ

クラウド Mac 上で実行するときは、コミット識別子、コンパイラのバージョン、テストケース名、勝った分岐、キャンセルの観測記録、二つの終了マーカーをテストの添付資料に残します。経過時間は単調時計で計算し、メンバーごとのデスクトップに表示される時刻を引き算してはいけません。

実際の時計を使うテストでは、本番用の待機クロージャが正しく接続されていることだけを検証します。スケジューリングに余裕を持たせ、特定のミリ秒での動作までは要求しません。また、テストプロセスには独立した実行時間の上限が必要です。終了しないタスクグループは、自身のタイマー子タスクでは停止させられないためです。

最後に、エラー処理ですべての CancellationError をタイムアウトに書き換えていないこと、待機クロージャがエラーを握りつぶしていないこと、失敗経路でも終了の証跡が欠けていないことを確認します。引き継ぎ完了の基準は「タイマーが発火した」ことではありません。結果を説明でき、キャンセルを観測でき、残りの処理が終了していることです。

よくある質問

タイマー側がエラーを投げると、関数はすぐ戻りますか?

必ずしも戻りません。タスクグループはすべての子タスクが終了するまで待ちます。cancelAll はキャンセル要求であり、処理の強制終了ではありません。

テストでは数秒待つ必要がありますか?

主要な契約テストでは不要です。待機処理を差し替え、ゲートで完了順を制御します。実時間を使うテストは少数に絞り、別途実行時間の上限を設けます。

次のタスクに最適なクラウドMacを選ぶ

チームの主な所在地に近いリージョンを選び、Mac miniを専有で利用できます。ブラウザーからのリモートデスクトップには、最新のブラウザーと安定したネットワークが必要です。構成、課金期間、実際の空き状況は注文前にご確認ください。

次の接続先は近くのリージョンに

まず構成と利用期間を確認し、チームが主に利用する地域に近いリージョンを選びましょう。