Диагностика зависшего XCTest на облачном Mac с watchdog и снимками процессов

Диагностика зависшего XCTest на облачном Mac с watchdog и снимками процессов

Задача XCTest, которая обычно завершалась за десять с лишним минут, продолжает выполняться на облачном Mac уже сорок минут. Последняя строка журнала сообщает лишь о запуске очередного теста, загрузка CPU невысока, а пакет результатов так и не сформирован. Если просто остановить задачу, останется только отметка о тайм-ауте — без возможности понять, возникла ли взаимная блокировка главного потока, не вернулся ли асинхронный callback, потеряна ли связь со службой симулятора или тестовый процесс всё ещё ожидает условия, которое никогда не будет выполнено.

При диагностике таких сбоев важно не увеличивать число повторных запусков, а сохранить состояние системы до завершения процесса. Дерево процессов, последовательные снимки потоков, состояние симулятора, исходные журналы и незавершённый xcresult должны относиться к одной и той же попытке запуска.

Сначала отличите медленный тест от реального зависания

Один общий тайм-аут легко приводит к ошибочным выводам. Первая компиляция, запуск симулятора и выполнение тестов занимают время по разным причинам, поэтому эти этапы следует разделить:

  1. Выполните компиляцию с помощью build-for-testing.
  2. Запустите тесты с помощью test-without-building.
  3. Отдельно фиксируйте длительность сборки, длительность тестирования и время последнего изменения журнала.
  4. Если тест превысил исторический предел, сначала соберите диагностические данные, а не завершайте его принудительно.

Глобальный лимит не позволяет задаче выполняться бесконечно, а лимит бездействия помогает обнаружить ситуацию, когда процесс ещё работает, но прогресса уже нет. При этом отсутствие новых данных в стандартном выводе само по себе не означает бездействие: некоторые продолжительные тесты изначально выводят очень мало информации. Надёжнее одновременно отслеживать время изменения файла журнала, состояние тестового процесса и изменения в каталоге xcresult.

Тайм-аут — это защитный механизм, а не первопричина. Диагностическую ценность он приобретает только тогда, когда при его срабатывании сохраняется достаточно доказательств.

Зафиксируйте воспроизводимую точку запуска

Сначала зафиксируйте scheme, план тестирования, целевое устройство и путь к DerivedData. Диагностический запуск не должен попутно менять версию Xcode, среду выполнения симулятора или версии зависимостей, иначе собранные данные невозможно будет корректно сравнить.

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. Не стоит сразу параллельно перезапускать весь план тестирования: это создаст дополнительный шум со стороны процессов и симуляторов.

Добавьте к тестовому процессу watchdog со сбором доказательств

В macOS нет единой повсеместно доступной команды GNU timeout, поэтому в CI-скрипте можно реализовать watchdog как фоновый процесс. Правильная последовательность действий такова: запустить тест, дождаться достижения лимита, собрать данные, отправить 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 необходимо изменить в соответствии с артефактами проекта. Если имя нестабильно, PID можно найти в списке процессов симулятора по пути к тестовому пакету, а затем передать его команде sample. Три коротких снимка лучше одного продолжительного показывают, действительно ли стек вызовов перестал меняться.

Определите тип ожидания по стекам потоков

Сначала проверьте, находятся ли главный и тестовый потоки в одном и том же месте во всех трёх снимках. Обычно встречаются следующие сценарии:

Главный поток ожидает тестовый поток

Если главный поток остановился на семафоре, условной переменной или синхронной диспетчеризации, а тестовый поток при этом ожидает callback, который должен выполняться в главном потоке, вероятной причиной будет взаимная блокировка из-за синхронного ожидания асинхронного API. Для исправления следует использовать expectation в XCTest и убедиться, что операция ожидания не блокирует очередь, отвечающую за выполнение callback.

Тестовый поток ожидает внешнее условие

Если стек вызовов постоянно остаётся внутри операций с сокетом, координации файлов, блокировки базы данных или межпроцессного взаимодействия, дополнительно проверьте зависимые службы, права доступа к временному каталогу и блокировки, оставшиеся после предыдущих тестов. Не следует объяснять любое ожидание проблемами с сетью: сначала по состоянию процессов и файлов установите, какой именно объект ожидает поток.

Стек вызовов продолжает меняться

Если горячие участки меняются во всех трёх снимках, задача, вероятно, просто выполняется медленно. В этом случае следует фиксировать длительность отдельных тестов, уменьшить объём данных или задать разумный тайм-аут expectation в самом тесте, а не бесконечно увеличивать глобальный лимит.

Архивируйте xcresult вместе с состоянием системы

Даже если пакет результатов не был корректно завершён, сохраните его в исходном виде и не удаляйте заранее в ветке обработки ошибки. Полный результат можно экспортировать в удобный для поиска JSON следующей командой:

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

Каталог каждого запуска, завершившегося по тайм-ауту, должен содержать как минимум:

  • полный вывод xcodebuild и код завершения;
  • scheme, план тестирования, destination и версию Xcode;
  • списки процессов хоста и симулятора;
  • три снимка потоков, собранные через заданные интервалы;
  • состояние устройств симулятора;
  • исходный xcresult и экспортированный структурированный результат;
  • идентификатор текущего коммита и параметры запуска тестов.

При выполнении задачи на облачном Mac в PingMini каждому запуску также следует назначать один явно определённый набор устройств симулятора, чтобы другая параллельная задача не могла изменить их состояние. Текущие доступные варианты конфигурации и узлов необходимо проверять в панели управления, не смешивая различия между машинами с условиями одного цикла воспроизведения сбоя.

Превратите исправление в регрессионную проверку

После обнаружения первопричины не удаляйте watchdog. Сохраните сбор данных по тайм-ауту, но рассчитывайте порог как бюджет на основе исторических перцентилей. Одновременно добавьте минимальный регрессионный тест для исправленного сценария. Например, при взаимной блокировке тест должен подтверждать выполнение callback за заданное время, а при блокировке ресурсов — удаление файла блокировки и завершение дочерних процессов после окончания теста.

В завершение проверьте три условия: штатная задача не запускает сбор снимков; аварийная задача завершается за ограниченное время; архив позволяет ответить на вопрос «какой процесс чего ожидает». После этого периодическое зависание XCTest перестаёт быть красной записью, которую можно лишь вручную перезапустить, и становится инженерной проблемой, которую можно последовательно локализовать и устранять.

Часто задаваемые вопросы

Через какое время без вывода XCTest следует считать зависшим?

Используйте общий лимит на основе обычной длительности и более короткий лимит отсутствия прогресса. Сбор диагностики нужен, только если процесс жив, а журнал и состояние тестов не меняются.

Почему после тайм-аута нельзя сразу отправлять SIGKILL?

Мгновенная остановка уничтожает состояние потоков и мешает завершению пакета результатов. Сначала сохраните процессы и снимки, затем отправьте SIGTERM и применяйте SIGKILL лишь после периода ожидания.

Что в первую очередь искать в снимке процесса?

Проверьте главный и тестовый потоки, очереди базы данных и пользовательские последовательные очереди. Повторяющиеся ожидания блокировки, IPC или синхронного вызова обычно дают лучший ориентир.

Выберите выделенный физический Mac для следующего проекта

Выберите облачный Mac с одной из трёх фиксированных конфигураций для разработки, непрерывной интеграции или тестирования локальных моделей и настройте его на нужный срок.

Настроить облачный Mac