Очистка оставшихся процессов и конфликтующих портов в Cloud Mac CI

Очистка оставшихся процессов и конфликтующих портов в Cloud Mac CI

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

Сначала определите, какому запуску принадлежат оставшиеся ресурсы

При появлении ошибки 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

Получив PID с помощью lsof, как минимум проверьте пользователя, время запуска, полную команду и 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 безопаснее, чем периодически сканировать процессы и массово завершать их; кроме того, такой подход упрощает воспроизведение проблемы.

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

Можно ли сразу завершить процесс, который занимает нужный порт?

Нет. Сначала следует проверить PID, родительский процесс, время запуска, командную строку и идентификатор задания. Для подтвержденного остаточного процесса сначала отправляют TERM и только после контролируемого ожидания используют KILL.

Почему дочерний процесс остается после завершения сценария CI?

Фоновый процесс мог создать новый сеанс или перейти под управление системной службы. Созданные PID и идентификаторы ресурсов нужно сохранять, а точечную очистку запускать через trap для EXIT, INT и TERM.

XcodeVM облачные Mac

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

Проверьте объём памяти, хранилище, узел и расчётный период, затем перейдите к настройке заказа. Все узлы работают 365 дней в году; фактическая доступность определяется в реальном времени через панель управления.

Выбрать план и арендовать