Hängende XCTest-Läufe auf dem Cloud Mac mit Watchdog und Prozessproben untersuchen

Hängende XCTest-Läufe auf dem Cloud Mac mit Watchdog und Prozessproben untersuchen

Ein XCTest-Job, der normalerweise nach gut zehn Minuten abgeschlossen ist, läuft auf einem Cloud Mac auch nach vierzig Minuten noch weiter. Die letzte Protokollzeile zeigt lediglich den Start eines Testfalls, die CPU-Auslastung ist niedrig und das Ergebnispaket wurde nicht fertiggestellt. Wird der Job jetzt einfach abgebrochen, bleibt als Ergebnis nur „Zeitüberschreitung“. Ob sich der Hauptthread in einem Deadlock befindet, ein asynchroner Callback nicht zurückkehrt, der Simulator-Dienst nicht mehr erreichbar ist oder der Testprozess auf eine Bedingung wartet, die niemals erfüllt wird, lässt sich so nicht feststellen.

Der entscheidende Schritt bei solchen Problemen besteht nicht darin, weitere Wiederholungsversuche einzubauen, sondern den Zustand vor dem Abbruch zu sichern. Prozessbeziehungen, fortlaufende Thread-Proben, Simulatorstatus, Rohprotokolle und das unvollständige xcresult sollten gemeinsam als Datensatz desselben Laufs archiviert werden.

Langsame Tests von tatsächlich hängenden Tests unterscheiden

Ein einziges Gesamtzeitlimit führt leicht zu Fehleinschätzungen. Erstkompilierung, Simulatorstart und Testausführung haben unterschiedliche Zeitprofile und sollten daher getrennt betrachtet werden:

  1. Die Kompilierung mit build-for-testing abschließen.
  2. Die Tests mit test-without-building ausführen.
  3. Build-Dauer, Testdauer und Zeitpunkt der letzten Protokolländerung separat erfassen.
  4. Beim Überschreiten des historischen Testlimits zunächst Diagnosedaten sammeln, statt den Prozess sofort zwangsweise zu beenden.

Das globale Limit verhindert, dass ein Job unbegrenzt weiterläuft. Ein Inaktivitätslimit erkennt dagegen Situationen, in denen der Prozess noch existiert, aber keinen Fortschritt mehr macht. Inaktivität darf nicht einfach mit fehlender Standardausgabe gleichgesetzt werden, da manche lang laufenden Tests von sich aus nur wenig protokollieren. Zuverlässiger ist es, gleichzeitig den Änderungszeitpunkt der Protokolldatei, den Status des Testprozesses und weitere Änderungen am xcresult-Verzeichnis zu beobachten.

Ein Zeitlimit ist ein Schutzmechanismus, keine Ursache. Diagnostisch wertvoll wird es erst, wenn beim Überschreiten genügend Beweismaterial erhalten bleibt.

Einen stabilen, reproduzierbaren Testeinstieg festlegen

Scheme, Testplan, Zielgerät und DerivedData-Pfad sollten zunächst fest vorgegeben werden. Ein Diagnoselauf darf nicht nebenbei die Xcode-Version, die Simulator-Runtime oder Abhängigkeitsversionen wechseln, da sich die erfassten Zustände sonst nicht sinnvoll vergleichen lassen.

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"

Tritt das Problem nur in einer bestimmten Testklasse auf, sollte der Umfang zunächst mit -only-testing:TargetName/TestClass eingeschränkt werden. Den gesamten Testplan sofort parallel erneut auszuführen, erzeugt nur zusätzliches Prozess- und Simulatorrauschen.

Den Testprozess mit einem forensischen Watchdog überwachen

macOS stellt standardmäßig keinen einheitlich verfügbaren GNU-Befehl timeout bereit. In CI-Skripten lässt sich diese Aufgabe stattdessen mit einem Hintergrund-Watchdog lösen. Die richtige Reihenfolge lautet: Test starten, bis zum Limit warten, Diagnosedaten erfassen, TERM senden, Zeit zum Aufräumen lassen und erst danach KILL senden.

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"

Der Name des Test-Runners muss an das jeweilige Projektartefakt angepasst werden. Ist der Name nicht stabil, kann die PID anhand des Test-Bundle-Pfads aus der Prozessliste des Simulators ermittelt und anschließend an sample übergeben werden. Drei kurze Proben zeigen zuverlässiger als eine einzelne lange Probe, ob sich der Aufruf-Stack tatsächlich nicht mehr weiterbewegt.

Die Art des Wartens aus den Thread-Stacks ablesen

Zunächst ist zu prüfen, ob Haupt- und Testthread in allen drei Proben an derselben Stelle stehen bleiben. Zu den typischen Mustern gehören:

Hauptthread wartet auf den Testthread

Wenn der Hauptthread an einem Semaphor, einer Bedingungsvariable oder einem synchronen Dispatch-Aufruf festhängt, während der Testthread seinerseits auf einen Callback wartet, der auf dem Hauptthread ausgeführt werden muss, liegt meist ein Deadlock durch synchrones Warten auf eine asynchrone API vor. Zur Behebung sollte eine XCTest-Expectation verwendet und sichergestellt werden, dass der Wartevorgang nicht die für den Callback zuständige Queue blockiert.

Testthread wartet auf eine externe Bedingung

Bleibt der Aufruf-Stack dauerhaft bei einem Socket, der Dateikoordination, einer Datenbanksperre oder der Interprozesskommunikation stehen, sollten abhängige Dienste, Berechtigungen temporärer Verzeichnisse und von vorherigen Tests hinterlassene Sperren überprüft werden. Nicht jeder Wartezustand ist ein Netzwerkproblem. Prozess- und Dateistatus sollten zunächst belegen, auf welches Objekt tatsächlich gewartet wird.

Der Aufruf-Stack verändert sich weiterhin

Wenn sich die Hotspots zwischen den drei Proben fortlaufend verändern, ist der Job möglicherweise nur langsam. In diesem Fall sollten die Laufzeiten einzelner Testfälle erfasst, die Datenmenge reduziert oder ein angemessenes Expectation-Zeitlimit für den Test selbst festgelegt werden, statt das globale Limit unbegrenzt zu erhöhen.

xcresult zusammen mit dem Systemzustand archivieren

Auch wenn das Ergebnispaket nicht ordnungsgemäß abgeschlossen wurde, sollte es unverändert aufbewahrt und nicht bereits im Fehlerzweig gelöscht werden. Ein vollständiges Ergebnis lässt sich mit dem folgenden Befehl als durchsuchbares JSON exportieren:

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

Jedes Verzeichnis eines abgelaufenen Tests sollte mindestens Folgendes enthalten:

  • die vollständige xcodebuild-Ausgabe und den Exit-Code;
  • Scheme, Testplan, Destination und Xcode-Version;
  • Prozesslisten von Host und Simulator;
  • drei in Intervallen erfasste Thread-Proben;
  • den Gerätestatus des Simulators;
  • das ursprüngliche xcresult und das exportierte strukturierte Ergebnis;
  • die Kennung des aktuellen Commits und die Parameter des Testeinstiegs.

Bei der Ausführung auf einem Cloud Mac von PingMini sollte jeder Job außerdem ausschließlich einen eindeutig festgelegten Simulator-Gerätesatz verwenden, damit kein parallel laufender Job dessen Zustand verändert. Die aktuell verfügbaren Konfigurationen und Nodes sind in der Konsole zu prüfen. Maschinenunterschiede dürfen nicht in denselben Reproduktionslauf einfließen.

Die Fehlerbehebung in eine Regressionsprüfung überführen

Nachdem die Ursache gefunden wurde, sollte der Watchdog nicht einfach entfernt werden. Die Erfassung bei Zeitüberschreitung bleibt bestehen, während der Schwellenwert auf ein Budget umgestellt wird, das auf historischen Perzentilen basiert. Zusätzlich sollte für das behobene Szenario ein minimaler Regressionstest angelegt werden. Bei einem Deadlock muss beispielsweise geprüft werden, ob der Callback innerhalb des vorgegebenen Zeitraums abgeschlossen wird. Bei Ressourcensperren ist zu verifizieren, dass Sperrdateien und untergeordnete Prozesse nach Testende vollständig bereinigt wurden.

Abschließend sind drei Punkte zu kontrollieren: Normale Jobs lösen keine Probenahme aus, fehlerhafte Jobs werden innerhalb begrenzter Zeit beendet und das Archiv beantwortet die Frage, welcher Prozess worauf wartet. Erst dann ist ein sporadisch hängender XCTest-Lauf nicht mehr nur ein roter Eintrag, der manuell neu gestartet werden muss, sondern ein technisches Problem, das sich systematisch eingrenzen und dauerhaft beheben lässt.

Häufig gestellte Fragen

Wann gilt ein XCTest-Lauf tatsächlich als blockiert?

Nicht allein nach der Gesamtdauer. Maßgeblich sind ein überschrittenes historisches Zeitbudget, ein weiterhin lebender Prozess und ausbleibender Fortschritt in Log und Testergebnis.

Warum sollte der Watchdog nicht sofort SIGKILL senden?

SIGKILL verhindert Aufräumarbeiten und vernichtet wichtige Zustandsdaten. Zuerst werden Prozessliste und Thread-Proben gesichert, danach folgt SIGTERM und erst nach einer kurzen Frist SIGKILL.

Welche Stellen sind in einer Prozessprobe besonders wichtig?

Zuerst werden Hauptthread, Testthread und serielle Warteschlangen auf Locks, IPC-Wartezustände und blockierende Netzwerkaufrufe geprüft. Wiederholte identische Stacks sind besonders aussagekräftig.

Exklusiven physischen Mac für den nächsten Build vorbereiten

Wählen Sie aus drei festen Konfigurationen den passenden Cloud Mac für Entwicklung, Continuous Integration oder lokale Modelltests und richten Sie ihn passend zur Dauer Ihres Projekts ein.

Cloud Mac konfigurieren