雲端 Mac CI 殘留程序與連接埠衝突治理

雲端 Mac CI 殘留程序與連接埠衝突治理

同一台雲端 Mac 連續執行多次 CI 後,最難釐清的故障往往不是編譯錯誤,而是「上一輪明明已結束,下一輪卻無法啟動」:測試服務回報連接埠已被占用、模擬器數量持續增加、建置目錄無法刪除,甚至有多個同名背景程序同時寫入同一份記錄。直接重新啟動機器雖然能暫時恢復運作,卻會掩蓋資源歸屬問題。更可靠的方式是先保留診斷資訊,再將每次執行產生的資源限制在可識別、可回收的明確邊界內。

先確認殘留資源屬於誰

看到 Address already in use 時,不要立刻執行全域 killall。應先找出正在監聽的程序,再沿著父程序向上追查,確認它是否屬於已經結束的工作。

PORT=8080
lsof -nP -iTCP:"$PORT" -sTCP:LISTEN
ps -o pid=,ppid=,user=,lstart=,command= -p 41872
ps -o pid=,ppid=,user=,command= -p 41801

透過 lsof 取得 PID 後,至少要核對使用者、啟動時間、完整命令與 PPID。若父程序仍是目前的 Runner,該連接埠可能屬於仍在執行的平行工作;若父程序已經變成系統程序,也不能只憑這一點認定它可以刪除,因為背景工作可能主動脫離原本的工作階段。

建議為每一輪工作產生 RUN_ID,並將它寫入程序參數、記錄路徑或環境變數。疑難排解時,可用以下命令縮小查找範圍:

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
export XCODEVM_RUN_ID="$RUN_ID"
pgrep -afil "$RUN_ID"

執行清理的依據應是「資源識別資訊與已結束的工作一致」,而不是「程序名稱看起來很熟悉」。同一台機器上的平行工作可能執行完全相同的命令。

為每次執行配置獨立邊界

共用目錄與固定連接埠會把偶發中斷放大成持續性故障。建置目錄、結果套件、暫存檔案與服務連接埠都應依每次執行分開隔離。

資源 不穩定的做法 建議邊界
Derived Data 所有工作共用預設目錄 $WORK_ROOT/$RUN_ID/DerivedData
測試結果 固定使用 result.xcresult 以執行識別碼命名
暫存目錄 直接寫入 /tmp/build 使用 mktemp -d 建立
本機服務 所有工作固定使用 8080 從受控連接埠集區分配
模擬器 反覆依名稱查找 儲存本輪建立的 UDID

xcodebuild 可明確指定建置資料與結果路徑:

WORK_ROOT="${CI_WORK_ROOT:-$HOME/ci-work}"
RUN_ROOT="$WORK_ROOT/$RUN_ID"
DERIVED_DATA="$RUN_ROOT/DerivedData"
RESULT_PATH="$RUN_ROOT/Test.xcresult"

mkdir -p "$RUN_ROOT"
xcodebuild test \
  -scheme App \
  -derivedDataPath "$DERIVED_DATA" \
  -resultBundlePath "$RESULT_PATH"

不要讓兩個並行工作共用同一個 Derived Data。即使專案與分支相同,中間產物、索引資料庫與清理作業仍可能互相競爭。

使用結束陷阱涵蓋失敗與中斷

只在指令碼最後執行清理並不足夠。編譯失敗、逾時訊號與人工取消,都可能讓最後幾行永遠無法執行。Shell 的 trap 可以統一處理正常結束與常見的中斷路徑。

set -euo pipefail

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/xcodevm-${RUN_ID}.XXXXXX")"
SIM_UDID=""
SERVICE_PID=""

cleanup() {
  if [[ -n "$SERVICE_PID" ]] && kill -0 "$SERVICE_PID" 2>/dev/null; then
    kill -TERM "$SERVICE_PID" 2>/dev/null || true
    for _ in 1 2 3 4 5; do
      kill -0 "$SERVICE_PID" 2>/dev/null || break
      sleep 1
    done
    kill -KILL "$SERVICE_PID" 2>/dev/null || true
  fi

  if [[ -n "$SIM_UDID" ]]; then
    xcrun simctl shutdown "$SIM_UDID" 2>/dev/null || true
    xcrun simctl delete "$SIM_UDID" 2>/dev/null || true
  fi

  rm -rf "$RUN_ROOT"
}

trap cleanup EXIT INT TERM

這裡保存的是本輪建立的 PID 與 UDID,而不是掃描並刪除所有同名資源。TERM 會讓程序有機會釋放檔案控制代碼並完成記錄寫入;只有在等待後仍未結束時,才升級為 KILL

避免 PID 重複使用造成誤刪

在長時間執行的工作中,舊 PID 可能被系統重新分配。結束程序前,應再次讀取命令列並檢查其中的執行識別碼。更穩妥的做法是讓背景服務將 PID、啟動時間與命令摘要寫入 $RUN_ROOT/manifest,清理時同時比對這些欄位。

處理模擬器與服務管理程式

不應只依名稱管理模擬器,因為同名裝置可能來自其他工作。建立模擬器後,應立即保存 UDID:

SIM_UDID="$(xcrun simctl create "ci-$RUN_ID" \
  "iPhone 16" \
  "com.apple.CoreSimulator.SimRuntime.iOS-18-0")"
xcrun simctl boot "$SIM_UDID"

若執行環境尚未安裝對應的 Runtime,應先使用 xcrun simctl list runtimes 取得實際可用的識別碼,不要在共用指令碼中寫死版本。

若程序結束後又自動出現,應檢查它是否由使用者層級的服務管理。可先查看目前的使用者網域,不要直接卸載來源不明的服務:

USER_ID="$(id -u)"
launchctl print "gui/$USER_ID" | grep -B 3 -A 6 "$RUN_ID"

若工作確實建立了暫時性服務,應保存其 label,並在結束路徑中使用同一個 label 定向移除。只刪除程序 PID 通常無效,因為服務管理程式會再次將它啟動。

建立可重複執行的驗收清單

完成清理不代表問題已經解決。請連續執行兩輪相同工作,並在每輪結束後檢查資源是否回到基準狀態:

  1. 使用 lsof 確認測試連接埠已停止監聽。
  2. pgrep -afil "$RUN_ID" 不應傳回本輪的背景程序。
  3. xcrun simctl list devices 中不應再出現本輪建立的名稱與 UDID。
  4. 暫時執行目錄已刪除;需要封存的記錄與結果套件已事先複製到持久目錄。
  5. 第二輪使用新的執行識別碼,不依賴第一輪留下的快取或服務。
  6. 存在並行工作時,只清理目前工作清單中登記的資源。

如果連接埠仍被占用,應保留 lsof 的輸出、兩層父程序、啟動時間與命令列,再判斷是清理有所遺漏,還是存在合法的平行工作。若問題只在強制取消時出現,應優先檢查訊號是否傳遞至包裝指令碼,以及背景程序是否建立了獨立工作階段。將這些檢查固定納入 Runner 的收尾階段,比定時掃描並批次結束程序更安全,也更容易重現。

常見問題

發現連接埠被占用後,可以直接終止對應程序嗎?

不應只依連接埠號判斷。先以 lsof 取得 PID,再核對命令列、父程序、啟動時間與本次執行識別碼;確認屬於已結束工作後,先傳送 TERM,逾時未退出才考慮 KILL。

為什麼 CI 腳本已經退出,子程序仍繼續執行?

子程序可能被放到背景、建立新的工作階段,或由服務管理器接手。建立資源時應保存 PID 或唯一識別碼,並透過 trap 在 EXIT、INT、TERM 路徑定向清理。

XcodeVM 雲端 Mac

選擇適合目前工作負載的獨享實體主機

核對記憶體、儲存空間、節點與計費週期後,即可進入下單設定。所有節點全年正常運作,實際可用性以控制台即時回傳的結果為準。

選擇方案並租用