Уже второе утро Mac зависал при загрузке. Индикатор доходил почти до конца и замирал. Я подумал, что закончилось место на диске. В режиме восстановления выяснилось обратное: свободны были 264 ГБ.

Причина оказалась во временном каталоге. За 16 часов ночные тесты оставили там 30 182 папки и 1 378 905 файлов. Сколько из них нужно, чтобы сломать загрузку macOS, я не знаю. Но с этим каталогом мой Mac не загружался, а после его очистки загрузился.

Где лежали эти файлы

На моём Mac os.tmpdir() указывал не на привычный /tmp, а на пользовательский каталог вида /private/var/folders/<xx>/<hash>/T. Документация Node.js описывает os.tmpdir() как путь к системному временному каталогу; на него также может влиять переменная TMPDIR. Поэтому путь, который возвращает функция, лучше проверять на конкретной машине.

В режиме восстановления я разблокировал том с данными, отвёл старые временные каталоги в сторону и после успешной загрузки посчитал содержимое папки T:

Источник

Папок

Файлов

Временные файлы моих тестов

25 299

1 189 858

Временные каталоги Vitest

4 603

151 679

Приложения и система

280

37 368

Всего

30 182

1 378 905

Все эти файлы появились между 23:00 воскресенья и 15:00 понедельника. Основную массу дали несколько типов тестовых каталогов: construct-detach-* — около 246 тысяч файлов, construct-attach-* — около 234 тысяч, ghosts-preflight-test-* — ещё около 113 тысяч.

Первая попытка исправления сделала хуже

Сначала я в режиме восстановления переименовал var/folders и tmp в каталоги с суффиксом .old, затем создал пустой tmp. Я хотел убрать накопившиеся файлы с пути загрузки, сохранив старые каталоги для разбора. После перезагрузки Mac попал в цикл и по-прежнему не запускался.

Вот команды той неудачной попытки; пути относятся к моему тому данных в Recovery:

mv /Volumes/Data/private/var/folders /Volumes/Data/private/var/folders.old
mv /Volumes/Data/private/tmp /Volumes/Data/private/tmp.old
mkdir -m 1777 /Volumes/Data/private/tmp

Я упустил родительский каталог /private/var/folders. В моём случае macOS создавала пользовательские папки внутри него, но не восстановила сам каталог после переименования. Пришлось снова зайти в режим восстановления, создать пустой /private/var/folders и вернуть ему владельца root:wheel и права 755. После этого система загрузилась.

mkdir /Volumes/Data/private/var/folders
chown root:wheel /Volumes/Data/private/var/folders
chmod 755 /Volumes/Data/private/var/folders

Это важная деталь именно этой истории: перенос системного каталога без восстановления его родительской структуры добавил мне вторую неисправность поверх первой.

Откуда взялось почти полтора миллиона файлов

Ночью агенты запускали тесты параллельно в разных git worktree. Каждый отдельный прогон выглядел нормально. Вместе они многократно повторили утечку временных файлов.

Часть тестов создавала каталог через mkdtempSync:

const dir = mkdtempSync(join(tmpdir(), 'construct-detach-'))

Сам вызов создаёт каталог и возвращает его путь. Удаление нужно организовать отдельно. В тестах его не было. Один каталог легко не заметить; тысячи прогонов оставили 25 299 каталогов только от наших тестовых фикстур.

Я уже внёс исправление: каждый запуск получал собственный корневой каталог вида mkr-<pid>-XXXXXX, а TMPDIR указывал внутрь него. В конце запуска корень удалялся. Изменение попало в main в 00:50 понедельника, но утечка прекратилась только около 04:00.

Причина задержки — старые worktree. Они были созданы до исправления и продолжали выполнять прежний код. Когда я проверил их, два ещё не были обновлены. Правка в main сама по себе не меняет уже запущенные ветки.

Оставался второй источник: Vitest 5.0.0. В моих запусках он создавал временную папку при старте, до того как обёртка переназначала TMPDIR. Если процесс успевал нормально завершиться, внутреннее удаление убирало папки проектов, но корневая папка оставалась. Я воспроизвёл это на одном тестовом файле: после запуска в пустом временном каталоге появилась новая папка. Таких папок накопилось 4 603; они продолжали появляться примерно по 40 в час.

Что я изменил после разбора

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

На случай прерванных запусков я расширяю процесс очистки: он должен находить осиротевшие каталоги и оставленные Vitest папки старше часа. Добавляю lint-правило, которое запретит прямое создание временных каталогов через mkdtempSync(join(tmpdir(), …)) в тестах: для них предусмотрен каталог конкретного запуска. Старые worktree нужно обновлять или удалять, иначе они снова запустят код с утечкой. Поведение Vitest я также планирую описать в issue проекта.

Все мои обычные индикаторы тогда были зелёными: свободное место и память оставались в норме. Я не считал количество файлов во временной папке. После этого случая для меня у каждого созданного файла должны быть понятны владелец, срок жизни и способ удаления, который можно проверить. Путь из tmpdir() сам по себе ничего из этого не гарантирует.

Источники