После нескольких последовательных запусков 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 обычно недостаточно, поскольку менеджер сервисов запустит его снова.
Создайте воспроизводимый контрольный список проверки
Завершение очистки ещё не означает, что проблема устранена. Выполните одно и то же задание два раза подряд и после каждого запуска проверяйте, вернулись ли ресурсы к исходному состоянию:
- С помощью
lsofубедитесь, что тестовый порт больше не прослушивается. - Команда
pgrep -afil "$RUN_ID"не должна возвращать фоновые процессы текущего запуска. - В выводе
xcrun simctl list devicesне должны оставаться имя и UDID, созданные текущим запуском. - Временный каталог запуска должен быть удалён, а журналы и пакеты результатов, требующие архивации, — заранее скопированы в постоянный каталог.
- Второй запуск должен использовать новый идентификатор и не зависеть от кэша или сервисов, оставшихся после первого.
- При наличии параллельных заданий очищайте только ресурсы, зарегистрированные в списке текущего задания.
Если порт всё ещё занят, сохраните вывод lsof, сведения о двух уровнях родительских процессов, время запуска и командную строку. Затем определите, вызвана ли ситуация неполной очисткой или допустимым параллельным выполнением. Если проблема возникает только при принудительной отмене, прежде всего проверьте, передаётся ли сигнал обёрточному скрипту и создают ли фоновые процессы отдельный сеанс. Закрепить эти проверки в завершающем этапе Runner безопаснее, чем периодически сканировать процессы и массово завершать их; кроме того, такой подход упрощает воспроизведение проблемы.
Часто задаваемые вопросы
Можно ли сразу завершить процесс, который занимает нужный порт?
Нет. Сначала следует проверить PID, родительский процесс, время запуска, командную строку и идентификатор задания. Для подтвержденного остаточного процесса сначала отправляют TERM и только после контролируемого ожидания используют KILL.
Почему дочерний процесс остается после завершения сценария CI?
Фоновый процесс мог создать новый сеанс или перейти под управление системной службы. Созданные PID и идентификаторы ресурсов нужно сохранять, а точечную очистку запускать через trap для EXIT, INT и TERM.
XcodeVM облачные Mac
Выберите выделенный физический сервер под текущую нагрузку
Проверьте объём памяти, хранилище, узел и расчётный период, затем перейдите к настройке заказа. Все узлы работают 365 дней в году; фактическая доступность определяется в реальном времени через панель управления.