雲端 Mac CI 任務優雅取消與子程序回收

雲端 Mac CI 任務優雅取消與子程序回收

遠端 CI 佇列中最難處理的往往不是失敗,而是「取消」。開發者在介面中停止任務後,執行器通常會先向指令碼傳送 TERM;如果指令碼未捕捉該訊號,正在執行的 xcodebuild、測試程序或日誌收集器可能會繼續占用工作區。下一個任務隨後便會遇到鎖定檔、遭占用的模擬器或不完整的結果套件。表面上看似隨機故障,根本原因卻是前一個任務未完整結束。

先定義取消完成的標準

任務從佇列中消失,不代表資源已經回收。可實際驗證的完成標準至少包含四項:

  1. 主要建置程序收到終止訊號並退出。
  2. 指令碼記錄真實的退出原因,而不是一律標記為建置失敗。
  3. 已產生的日誌與 xcresult 獲得保留。
  4. 工作區中不再有屬於該任務的背景程序與暫時掛載。

取消路徑也是管線的正常執行路徑。若只在異常退出時測試清理邏輯,通常會在系統最忙碌時才發現它根本無法運作。

首先確認執行器實際會傳送哪一種信號。可以在測試任務中加入一個只記錄訊號、不包含敏感環境變數的探針。不要依賴預設推測,也不要把立即傳送 KILL 當成取消策略,因為這不會給程序關閉資料庫、刷新日誌或整理結果套件的機會。

使用陷阱捕捉 TERM 並追蹤主要程序

建置指令碼應明確保存 xcodebuild 的 PID。收到 TERMINT 後,只終止目前任務所建立的程序,不要使用沒有範圍限制的 killall。以下 Bash 骨架會將日誌與結果寫入獨立的執行目錄:

#!/bin/bash
set -u

run_id="${CI_RUN_ID:-manual-$(date +%s)}"
run_dir="$PWD/.ci-runs/$run_id"
result_path="$run_dir/TestResults.xcresult"
log_path="$run_dir/xcodebuild.log"
status_path="$run_dir/status.txt"
child_pid=""
cancelled=0

mkdir -p "$run_dir"

on_cancel() {
  cancelled=1
  if [[ -n "$child_pid" ]] && kill -0 "$child_pid" 2>/dev/null; then
    kill -TERM "$child_pid" 2>/dev/null || true
  fi
}

trap on_cancel TERM INT

xcodebuild \
  -workspace Example.xcworkspace \
  -scheme Example \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -resultBundlePath "$result_path" \
  test >"$log_path" 2>&1 &

child_pid=$!
wait "$child_pid"
build_status=$?

if [[ "$cancelled" -eq 1 ]]; then
  printf 'cancelled\n' >"$status_path"
  exit 130
fi

printf 'finished:%s\n' "$build_status" >"$status_path"
exit "$build_status"

這裡沒有直接在陷阱中刪除目錄。陷阱只負責轉送訊號,主要流程仍會執行 wait,因此能區分一般失敗與人工取消。退出碼 130 常用來表示程序遭到中斷,但應先確認你的 CI 系統如何對應取消狀態。

為程序設定有限的退出時間

有些測試可能不會回應 TERM。正式環境的指令碼可以在送出訊號後每秒檢查一次 PID,例如等待 20 秒;逾時後再傳送 KILL。寬限期必須有限,否則取消操作可能永久占用執行器。反過來,也不要把等待時間壓縮到一兩秒,因為 xcodebuild 可能仍在寫入結果套件。

將證據保留與快取清理分開

退出清理最常見的錯誤,是在 trap cleanup EXIT 中直接執行 rm -rf "$run_dir"。這樣雖然能讓目錄保持乾淨,卻也會刪除判斷原因所需的日誌。更穩妥的做法是將內容分成兩類:

內容 取消後處理
xcodebuild.logxcresult、狀態檔 保留並封存
此任務建立的暫存目錄 確認路徑後刪除
共用相依套件快取 不在取消陷阱中刪除
模擬器與衍生資料 依任務隔離策略處理

封存日誌時,也要容許檔案尚未產生。應使用 [[ -e "$result_path" ]] 判斷,而不是讓缺少結果套件變成第二個錯誤。若上傳動作可能耗時,應為上傳步驟另外設定逾時,並確保上傳失敗不會覆寫原始建置退出碼。

建議將暫存目錄放在包含執行編號的固定父目錄下。刪除前同時檢查前綴與實際路徑,避免變數為空時意外擴大刪除範圍:

safe_remove_run_dir() {
  local target="$1"
  local root="$PWD/.ci-tmp"

  [[ -n "$target" ]] || return 1
  [[ "$target" == "$root/"* ]] || return 1
  [[ -d "$target" ]] || return 0

  rm -rf -- "$target"
}

檢查殘留程序,而不是全域清場

任務退出後,可以先記錄程序快照:

ps -axo pid,ppid,state,etime,command >"$run_dir/processes-after.txt"
pgrep -P "$$" >"$run_dir/direct-children.txt" 2>/dev/null || true

pgrep -P 只會查看直接子程序,無法涵蓋所有後代程序。因此,更可靠的做法是讓每個輔助程序都由指令碼啟動並登記 PID。例如,分別將日誌轉送器、測試代理程式與連接埠轉送程序寫入陣列,清理時再逐一檢查。不要依程序名稱終止整台機器上的所有同名任務;即使是獨享的實體 Mac mini,也可能同時執行開發者主動啟動的長期程序。

若管線允許平行執行,工作區、結果目錄、模擬器裝置集與暫存目錄都應包含任務編號。優雅取消只能處理程序生命週期,無法彌補多個任務共用同一個可變目錄所造成的覆寫問題。

使用三類演練驗收取消路徑

上線前至少要執行三次演練。第一類是在解析相依套件期間取消,檢查套件管理器與日誌程序是否退出。第二類是在編譯期間取消,確認建置資料庫不會被下一個任務誤用。第三類是在測試執行期間取消,確認結果套件可以讀取,且模擬器相關程序不會持續占用此任務的裝置集。

每次演練都要核對以下項目:

在 RentMini 的雲端 Mac 上執行長期 CI 時,也應將取消演練納入執行器更新後的驗收清單。節點與設定選擇只需在控制台確認目前可用的設定;指令碼本身不應依賴城市、機器名稱或固定工作區路徑。最終目標不是讓目錄看起來最乾淨,而是讓每次退出都有明確邊界、保留足夠證據,並讓下一次建置能從明確狀態開始。

常見問題

CI 任務取消後可以立刻使用 kill -9 嗎?

不建議。應先對主要建置程序送出 TERM,給予有限的退出時間,讓它關閉檔案並寫完紀錄;超過期限仍未退出時,才將 KILL 作為最後手段。

取消建置時應保留哪些診斷資料?

至少保留 xcodebuild 紀錄、已產生的 xcresult、任務狀態檔與程序快照。不要在退出陷阱中無條件刪除這些資料。

按需使用雲端 Mac

為下一次建置準備獨享實體 Mac mini

選擇 M4 配置、租期與節點。每筆訂單對應一台獨享實體設備,實際可用狀態以控制台即時回傳為準。

選擇配置並租用