クラウドMacで停止したXCTestを監視し実行時の証拠を保全する

クラウドMacで停止したXCTestを監視し実行時の証拠を保全する

本来なら十数分で終わる XCTest ジョブが、クラウド Mac 上で 40 分経っても終了しないことがあります。ログの最終行には特定のテストケースが開始されたことしか記録されておらず、CPU 使用率は低いままで、結果バンドルも完成していません。この時点でジョブを即座に停止しても「タイムアウト」という結果しか残らず、メインスレッドのデッドロックなのか、非同期コールバックが返ってこないのか、Simulator のサービスとの接続が切れたのか、それともテストプロセスが決して成立しない条件を待ち続けているのかを判断できません。

この種の問題に対処するうえで重要なのは、再試行を増やすことではなく、終了させる前に実行時の証拠を保全することです。プロセスの関係、連続したスレッドサンプル、Simulator の状態、生ログ、未完成の xcresult を、同じ実行記録としてまとめて残す必要があります。

遅いテストと実際のハングを切り分ける

全体タイムアウトを 1 つ設定するだけでは、誤判定が起きやすくなります。初回ビルド、Simulator の起動、テスト実行では時間がかかる原因が異なるため、まず工程を分けます。

  1. build-for-testing でビルドを完了させます。
  2. test-without-building でテストを実行します。
  3. ビルド時間、テスト時間、ログが最後に更新された時刻を個別に記録します。
  4. テストが過去の上限時間を超えたら、すぐに強制終了せず、先に実行時の証拠を収集します。

全体上限はジョブが無期限に動き続けるのを防ぎ、無更新時間の上限は「プロセスは生きているが進捗がない」状態を検出するために使います。ただし、無更新を標準出力がないことと単純に同一視してはいけません。長時間テストの中には、もともとほとんど出力しないものもあります。ログファイルの更新時刻、テストプロセスの生存状態、xcresult ディレクトリに変化が続いているかを同時に監視するほうが確実です。

タイムアウトは保護機構であり、根本原因ではありません。タイムアウト発生時に十分な証拠が残って初めて、診断に役立ちます。

再現可能な実行条件を固定する

最初に scheme、テストプラン、対象デバイス、DerivedData のパスを固定します。診断ジョブで同時に Xcode、Simulator ランタイム、依存関係のバージョンまで変更してはいけません。条件が変わると、2 回の実行結果を比較できなくなります。

set -euo pipefail

WORKSPACE="Demo.xcworkspace"
SCHEME="Demo"
PLAN="Regression"
DERIVED="$PWD/.build/DerivedData"
RESULT="$PWD/.artifacts/TestRun.xcresult"

rm -rf "$RESULT"
mkdir -p "$PWD/.artifacts"

xcodebuild build-for-testing \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -testPlan "$PLAN" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$DERIVED"

xcodebuild test-without-building \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -testPlan "$PLAN" \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$DERIVED" \
  -resultBundlePath "$RESULT"

問題が特定のテストクラスでのみ発生する場合は、まず -only-testing:TargetName/TestClass で対象を絞り込みます。最初からテストプラン全体を並列で再実行すると、プロセスや Simulator によるノイズが増えるため避けてください。

証拠を収集できるウォッチドッグをテストプロセスに追加する

macOS には、環境を問わず利用できる GNU timeout コマンドが標準では用意されていません。CI スクリプトでは、バックグラウンドのウォッチドッグプロセスで代替できます。正しい順序は、テストを開始し、上限時間まで待機し、証拠を収集してから TERM を送り、クリーンアップ時間を確保し、最後に KILL を送ることです。

set -euo pipefail

OUT="$PWD/.artifacts/hang-$(date +%Y%m%d-%H%M%S)"
LIMIT="${TEST_TIMEOUT_SECONDS:-1200}"
mkdir -p "$OUT"

xcodebuild test-without-building \
  -workspace Demo.xcworkspace \
  -scheme Demo \
  -testPlan Regression \
  -destination "platform=iOS Simulator,name=iPhone 16" \
  -derivedDataPath "$PWD/.build/DerivedData" \
  -resultBundlePath "$OUT/TestRun.xcresult" \
  >"$OUT/xcodebuild.log" 2>&1 &

test_pid=$!

(
  sleep "$LIMIT"
  if kill -0 "$test_pid" 2>/dev/null; then
    date -u >"$OUT/timeout-at.txt"
    ps -axo pid,ppid,state,%cpu,%mem,etime,command >"$OUT/host-processes.txt"
    xcrun simctl list devices >"$OUT/simulators.txt"
    xcrun simctl spawn booted ps -axo pid,ppid,state,etime,command \
      >"$OUT/simulator-processes.txt" 2>&1 || true

    for round in 1 2 3; do
      xcrun simctl spawn booted sample DemoTests-Runner 5 1 \
        >"$OUT/test-runner-sample-$round.txt" 2>&1 || true
      sleep 3
    done

    kill -TERM "$test_pid" 2>/dev/null || true
    sleep 10
    kill -KILL "$test_pid" 2>/dev/null || true
  fi
) &

watchdog_pid=$!

set +e
wait "$test_pid"
status=$?
set -e

kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
exit "$status"

テスト Runner の名前は、プロジェクトの成果物に合わせて変更する必要があります。名前が一定でない場合は、Simulator のプロセス一覧からテストバンドルのパスで PID を絞り込み、その PID を sample に渡します。1 回の長いサンプリングよりも 3 回の短いサンプリングのほうが、コールスタックの進行が本当に止まっているかを判断しやすくなります。

スレッドスタックから待機の種類を判定する

まず、3 つのサンプルでメインスレッドとテストスレッドが同じ位置に留まっているかを確認します。代表的なパターンは次のとおりです。

メインスレッドがテストスレッドを待っている

メインスレッドがセマフォ、条件変数、同期ディスパッチで停止し、テストスレッドはメインスレッドで実行される必要があるコールバックを待っている場合、非同期 API を同期的に待ったことでデッドロックしている可能性が高いと考えられます。XCTest expectation を使用するように修正し、コールバックを処理するキューを待機処理で占有しないようにします。

テストスレッドが外部条件を待っている

コールスタックが継続して socket、ファイル調停、データベースロック、プロセス間通信のいずれかで止まっている場合は、依存サービス、作業用ディレクトリの権限、先行テストが残したロックをさらに確認します。すべての待機をネットワークの問題と決めつけず、プロセスとファイルの状態から何を待っているのかを先に特定してください。

コールスタックが変化し続けている

3 回のサンプリングでホットスポットが変化し続けている場合、ジョブはハングしているのではなく、単に遅い可能性があります。この場合はテストケースごとの所要時間を記録し、データ量を減らすか、テスト自体に妥当な expectation のタイムアウトを設定します。全体上限を際限なく引き上げるべきではありません。

xcresult とシステム状態をまとめてアーカイブする

結果バンドルが正常に確定していなくても、そのまま保管し、失敗処理で先に削除しないでください。完全な結果は、次のコマンドで検索しやすい JSON にエクスポートできます。

xcrun xcresulttool get \
  --legacy \
  --path ".artifacts/TestRun.xcresult" \
  --format json \
  >".artifacts/xcresult.json"

タイムアウトごとのディレクトリには、少なくとも次の情報を含めます。

  • 完全な xcodebuild 出力と終了コード
  • scheme、テストプラン、destination、Xcode のバージョン
  • ホストと Simulator のプロセス一覧
  • 時間を空けて収集した 3 つのスレッドサンプル
  • Simulator デバイスの状態
  • 元の xcresult とエクスポートした構造化結果
  • 対象コミットの識別子とテスト実行時の引数

PingMini のクラウド Mac で実行する場合も、1 つのジョブでは明示的に指定した 1 つの Simulator デバイスセットだけを使用し、別の並行ジョブによってデバイス状態が変更されないようにします。構成とノードはコンソールで現在の選択肢を確認し、マシン差異を同じ障害再現の試行に持ち込まないでください。

修正を回帰テストに変える

根本原因を特定した後も、ウォッチドッグを削除するだけで終わらせてはいけません。タイムアウト時の収集処理は残しつつ、しきい値を過去のパーセンタイル値に基づく時間枠へ変更します。同時に、修正した事象に対する最小限の回帰テストを追加します。たとえば、デッドロックの問題ではコールバックが制限時間内に完了することを確認し、リソースロックの問題ではテスト終了後にロックファイルと子プロセスの両方がクリーンアップされることを確認します。

最後に、正常なジョブではサンプリングが発生しないこと、異常なジョブが有限時間内に終了すること、アーカイブされた情報から「どのプロセスが何を待っているか」を判断できることの 3 点を確認します。ここまで整備すれば、断続的な XCTest のハングは手動で再実行するしかない赤い記録ではなく、継続的に原因を絞り込めるエンジニアリング上の課題になります。

よくある質問

XCTestは無出力が何分続いたら停止と判断すべきですか?

通常実行の履歴から決めた全体上限と、より短い無進捗上限を併用します。プロセスが生存し、ログとテスト状態の両方が進まない場合にだけ採取を開始します。

タイムアウト直後に強制終了してはいけないのはなぜですか?

即時の強制終了ではスレッド状態と結果バンドルの終了情報が失われます。先にプロセスとスタックを採取し、TERMの猶予後も残る場合だけKILLを使います。

プロセスサンプルではどのスレッドを優先して確認しますか?

メインスレッド、テストスレッド、データベースキュー、独自の直列キューを確認します。複数回の採取で同じロック待ちやIPC待ちが続く箇所が有力な手掛かりです。

次回のビルドに向けて専用の物理Macを準備

開発、継続的インテグレーション、ローカルモデルのテストに適した3種類の固定構成からクラウドMacを選び、タスク期間に合わせてセットアップできます。

クラウドMacを構成する