xcodebuild が終了コード 65 で失敗

この見出しのフレーズは、あらゆる CI システム環境の iOS 開発者と macOS 開発者を震え上がらせる威力を持っています。Xcode そのものを使っているときに目にした方もいらっしゃると思いますが、こうしたエラーコードが Xcode に表示されず、気付かなかった方も同じくらいいらっしゃることでしょう。

xcodebuild から何のエラーメッセージもなく終了コード 65 だけが返される事態を回避するには、この終了コードが何を意味しているかを理解する必要があります。

任意のターミナル エミュレーターに「man xcodebuild」と入力して最下部あたりまでスクロールすると、使用例の手前に、終了コードに関する次のような小さな段落が含まれているはずです。

xcodebuild exit code 65

このわずかなヒントから、sysexits の man ページとの関連性が導き出されます。「200 OK」「404 Not Found」「418 I’m a teapot」などの HTTP ステータス コードと同様、共通の実行可能リターン コードは sysexits.h のヘッダーで定義されますが、この共通の終了コードが、不正なデータ入力を意味する「65」となっています。

man xcodebuild exit code 65

このことがわかれば、xcodebuild がなぜコード 65 で終了しようとするのか、その理由を究明できそうです。

xcodebuildmake(1) のステロイド増強版とも言えるような、間違いなく複雑なソフトウェアです。プロジェクトまたはワークスペースの構造を判定し、適切なフラグと共にすべてをコンパイラーに渡します。ログ内の 1 つのコマンドは、50 文字に収まる場合もあれば、27 インチ iMac でフルスクリーン モードで実行中の Safari ウィンドウ全体を埋め尽くすほどの長さになる場合もあります。

この xcodebuild が、何らかの想定外の動作 (よくあることです) を検出すると終了コード 65 を返します。ここで私たちを最も苦しませるのは、処理できなかった問題のコンテキストを xcodebuild がほとんど残してくれないことです。私たちはこうした状況と 1 年近くも格闘してきました。

xcodebuild がさまざまなアクション (クリア、ビルド、テスト、分析など) のいずれかの実行を終了すると、その直後に次のアクションが何の準備もなく実行され、同時に固定タイムアウト カウンターが開始されます。ハードウェアの処理速度が十分でないと、iOS シミュレーターが完全に起動する前に、このタイムアウトが 0 になってしまいます。

バックグラウンドで起動する iOS シミュレーターは、別スレッドの重要度が高いと優先度が下げられるため、設定によってはこの問題がさらに深刻になります。タイムアウトが 0 になると、xcodebuild は iOS シミュレーターへの接続を数回試行し、失敗します。これは、xcodebuild がコンパイル済みバイナリとシミュレーターの中間に配置されるため、基盤となるハードウェアの処理速度を認識できず、回復できるとは限らないためです。

ローカル マシン上では、一瞬がっかりしても気を取り直し、CMD+U をもう一度押すことで問題を修正できますが、CircleCI 上ではすべてがシャットダウンされ、最後のビルドが実行された VM がリサイクルされます。

通常の開発ワークフローでは、派生データ フォルダーや、その他 Xcode に共通のファイルは存在しません。作業ディレクトリは存在せず、iOS シミュレーターも実行されません。この場合、CircleCi で新しいビルドを実行すると同じ xcodebuild の問題が発生し、多くの場合は未知の状態から回復できないため、終了コード 65 が返されます。

この問題を緩和する最善の方法は、搭載されているコマンドライン ツールを使用して iOS シミュレーターを早期段階で起動することです。これによって iOS シミュレーターが起動され、すぐに戻されることで、xcodebuild がコードのコンパイルを開始できるようになります。通常は依存関係のステップとは別に実行することをお勧めします。これにより、このステップが (Carthage による依存関係のフェッチや fastlane の更新と同様に) 非常に重要であることを、今後の自分や同僚に注意喚起することができます。

dependencies:
  pre:
    - xcrun instruments -w 'iPhone 7 (10.3)' || sleep 15

sleep 15 には 2 つの重要な意味があります。iOS Simulator が完全に起動するための余裕を確保すること、そして終了コード 0 を返すことです。複数のコマンドを set -o pipefail を使用せずにチェーンすると、最後のコマンドの終了コードが返され、それ以前のコマンドのコードは無視されます。CircleCI は、ビルド ステップの終了コードを探して成功または失敗を判定します。sleep コマンドを除外した場合は、-t (テンプレート) フラグが渡されないため、xcrun instruments は 0 以外の終了コードを返すことになります。

あの悪名高き xcodebuild 終了コード 65 の説明は以上です。xcodebuild がなぜあのような意地悪をするのか、今回の記事が多少なりとも参考になれば幸いです。現状、このコードが表示された場合は常に xcodebuild が回復不能な問題に遭遇しているということになりますが、CircleCI は引き続き Apple の Developer Tools チームとの連携を崩すことなく、開発者ツールチェーンの高速化と安定化に取り組んでまいります。