Zsh 5.9.2 исправляет баг, который 10 лет стирал историю команд
Михаэль Штапельберг годами замечал, что часть его истории команд в Zsh бесследно пропадает: команды, которые он точно выполнял накануне, не находились по Ctrl+R, а файл ~/.zsh_history вдруг содержал только старые записи, годы более новых записей отсутствовали. Первые несколько раз он просто восстанавливал историю из ежедневного бэкапа, но проблема повторялась. Видимой порчи файла не было, ни нечитаемых символов, ни оборванных строк, а число строк в файле каждый раз было разным. В его ~/.zshrc заданы HISTSIZE=4000 (сколько строк подгружать для поиска по Ctrl+R) и SAVEHIST=10000000 (сколько строк хранить на диске), включена опция HIST_IGNORE_DUPS и потоковая дозапись команд (INC_APPEND_HISTORY), а совместное использование истории между сессиями (SHARE_HISTORY) отключено, то есть каждая открытая оболочка ведёт свою историю независимо.
Попросив совета на Mastodon в декабре 2024 года, он начал трассировать файловую систему. inotify показал, что при выходе из сессии Zsh читает старую историю, пишет её в новый файл .zsh_history.new и переименовывает его поверх старого, штатный паттерн атомарной перезаписи, но без PID процесса. fatrace добавил PID, но не показывал, сколько байт читается и пишется. bpftrace (запущенный через NixOS) дал полные стек-трейсы вызовов open() и позволил различить, какой именно вызов, readhistfile (чтение) или savehistfile (запись), стоит за каждым обращением к файлу; развёрнутый вариант скрипта Штапельберг оформил как systemd-юнит и вёл его в фоне постоянно. Сравнив трейс обычного выхода из сессии с трейсом, где история обрезалась, он заметил: в «плохом» случае отсутствует финальное read = 0, то есть Zsh не дочитывает файл истории до конца.
Дальше вычитывать код руками оказалось малопродуктивно, и Штапельберг патчит Zsh 5.9.1 так, чтобы тот намеренно падал (обращением по невалидному адресу памяти), если savehistfile записывает временный файл .zsh_history.new короче 50 000 строк, то есть превращает молчаливое усечение в аварийный крах, который можно поймать и разобрать через дамп памяти. Он включил systemd-coredump (отдельно предупреждая: такие дампы содержат полную историю команд, их нельзя загружать в сторонние сервисы) и через несколько дней получил нужный крах. gdb показал, что savehistfile действительно записал заметно меньше строк, чем было в реальной истории. Разбор кода readhistfile выявил единственную точку раннего выхода: цикл чтения обрывается по break, если Zsh получает сигнал (проверка errflag & ERRFLAG_INT), и в дампе именно этот флаг и lasthist.interrupted оказались выставлены, то есть сигнал действительно прилетел. У самого Штапельберга есть привычка в конце рабочего дня закрывать стек из mosh-сессии, длинного SSH-сеанса и мультиплексированных внутри него сессий, поочерёдно нажимая Ctrl+D и Ctrl+C, и, по всей видимости, именно Ctrl+C иногда прерывает Zsh ровно в момент, когда та перечитывает свою историю при выходе. Итоговая причина: readhistfile корректно прерывается по сигналу, но savehistfile перед записью не проверяет, было ли чтение прервано, и молча сохраняет ту неполную историю, которая успела прочитаться, поверх настоящего файла.
Штапельберг собрал воспроизводимый пример и отправил баг-репорт в рассылку zsh-workers в марте 2025 года. Барт Шефер (Bart Schaefer) разобрался в проблеме и выложил патч в апреле 2025 года. Но релизов Zsh долго не было, а когда наконец вышла версия 5.9.1, патч Шефера, как выяснилось, просто пропустил релиз-инженер. Штапельберг указал на этот пропуск, и фикс наконец вошёл в Zsh 5.9.2, вышедшую 12 июля 2026 года. По собственной оценке автора, баг, приводящий к потере данных, оставался неисправленным в популярном шелле около 10 лет, притом что Zsh служит шеллом по умолчанию в macOS с 2019 года. Штапельберг оговаривается, что его привычку обрывать сессии сигналами разделяют не все пользователи, но допускает, что кто-то ещё за эти годы незаметно терял часть своей истории команд.
Отдельно, как самостоятельный и не связанный с основным багом «капкан», автор описывает другой сценарий потери истории: TRAMP-режим Emacs при подключении по SSH экспортирует переменную HISTFILE в окружение, и если после этого запустить обычный интерактивный шелл, тот унаследует чужой путь. На рабочей машине автора, где bash по умолчанию задаёт HISTSIZE=64000 и HISTFILESIZE=64000, это однажды обрезало его .zsh_history ровно до 64 000 строк. Защита, явно снять экспорт HISTFILE (unset вместо простого переопределения) в ~/.zshrc.
В приложении к посту Штапельберг проверяет отдельный вопрос: нашли бы этот баг сегодняшние ИИ-модели, если бы расследовали его с нуля? Первый вариант на основе smevals Саймона Уиллисона (Simon Willison) он счёл слишком простым, агенты подглядывали готовое решение или гуглили, что баг уже исправлен в git-версии Zsh, и перешёл на открытый фреймворк Inspect (от UK AI Security Institute и Meridian Labs). Моделям давали только описание симптома, сравнение bpftrace-логов обычного и «плохого» выхода из сессии, содержимое .zshrc и полный исходный код Zsh 5.9.1, явно запретив обращаться к более новым версиям Zsh, апстрим-коммитам, рассылкам или списку изменений; ответ требовалось завершить разделом с точным диагнозом и указанием ответственного кода. На настройку эксперимента (около трёх заходов, включая отброшенный вариант на smevals) Штапельберг потратил больше $300 в токенах; в итоговой таблице каждую модель прогоняли по три раза, отсюда оценки вида «X из 3». Без подсказки про привычку автора с Ctrl+C и Ctrl+D GPT-5.6 Sol и Claude Opus 5 нашли причину в 3 попытках из 3 (Opus 5, ценой примерно в 11 раз большего числа токенов, чем у GPT-5.6 Sol); Claude Sonnet 5 справился только с 1 попыткой из 3. Среди моделей с открытыми весами хоть какой-то успех без подсказки показала только Kimi K3 (1 из 3 и при среднем, и при высоком уровне рассуждений), GLM 5.2 не справилась ни разу, а Gemini 3.1 Flash Lite, единственная модель, которая не подобрала верную гипотезу вообще ни разу, ни с подсказкой, ни без неё; Штапельберг связывает это с тем, что модель сравнительно небольшая. Стоило добавить в промпт всего одну фразу, про привычку жать Ctrl+C и Ctrl+D по кругу при выходе, и результат почти всех моделей подскочил до 3 из 3, включая Claude Sonnet 5, а из открытых моделей, GLM 5.2 и Kimi K3. Штапельберг описывает и типовую причину провала: модель цепляется за неверную гипотезу и застревает, пытаясь её подтвердить, вместо того чтобы вернуться к другим вариантам, например, GLM 5.2 по одному lseek в трейсе ошибочно заключила, что должна быть включена опция SHAREHISTORY (на деле она была выключена), хотя этот lseek, только следствие того, как glibc обрабатывает fclose() у буферизованных потоков по стандарту POSIX, а не признак общей истории сессий.
Ключевые факты
- Zsh годами незаметно обрезал файл истории команд (~/.zsh_history) при выходе из сессии, иногда сразу на годы записей; видимой порчи файла не было, так что причину было почти невозможно заметить без трассировки.
- Причина, которую Михаэль Штапельберг нашёл через inotify → fatrace → bpftrace → намеренный краш с coredump: readhistfile прерывается сигналом (например, от Ctrl+C) и обрывает чтение истории, но savehistfile перед записью не проверяет это прерывание, и молча сохраняет усечённую историю поверх настоящей.
- Путь фикса: баг-репорт в zsh-workers в марте 2025 года → патч Барта Шефера в апреле 2025 года → патч случайно пропущен в релизе Zsh 5.9.1 → фикс вошёл в Zsh 5.9.2 от 12 июля 2026 года, то есть, по оценке автора, баг прожил в популярном шелле около 10 лет.
- В приложении к посту Штапельберг проверил, находят ли современные ИИ-модели причину бага по одним лишь bpftrace-логам: без подсказок GPT-5.6 Sol и Claude Opus 5 справились 3 из 3 попыток, Claude Sonnet 5, только 1 из 3, а Gemini 3.1 Flash Lite не подобрала верную гипотезу вообще ни разу.
- Одна лишняя фраза в промпте, про привычку автора жать Ctrl+C/Ctrl+D по кругу при выходе, подняла результат почти всех моделей до 3 из 3, включая открытые GLM 5.2 и Kimi K3; вся затея на Inspect обошлась автору больше чем в $300 токенов.
Почему это важно
Zsh, шелл по умолчанию в macOS с 2019 года и один из самых распространённых в Linux, а баг, из-за которого при обычном выходе из сессии может незаметно обрезаться многолетняя история команд, прожил в нём около 10 лет никем не найденным. Ценность поста не только в самом фиксе: автор пошагово показывает рабочую эскалацию инструментов трассировки, inotify, затем fatrace, затем bpftrace, а в финале намеренный краш с разбором coredump, как универсальный метод охоты за редкими, трудно воспроизводимыми багами порчи данных в любой долгоживущей программе. Отдельно пост даёт конкретные, проверяемые цифры о том, находят ли сегодняшние ИИ-модели причину такого бага по одним лишь сырым логам трассировки, без документации и без подсказок.
Кому это важно
Пользователям Zsh на macOS и Linux, которые полагаются на поиск по истории команд (Ctrl+R) и могли годами незаметно терять её часть. Системным инженерам и SRE, которые расследуют редкие баги порчи файлов и хотят готовый, воспроизводимый пример эскалации inotify → fatrace → bpftrace → намеренный краш с coredump. И тем, кто оценивает, насколько ИИ-агентам можно доверять самостоятельный поиск первопричины реальных багов, здесь есть конкретные цифры по нескольким моделям, а не абстрактная оценка.
Как это применить
Обновиться до Zsh 5.9.2 (или вручную наложить патч Барта Шефера, если апгрейд пока невозможен), фикс закрывает именно этот баг. Проверить ~/.zshrc и другие конфиги на предмет случайно экспортированной переменной HISTFILE (отдельный, не связанный с основным багом капкан из приложения A), обычно достаточно явно её unset. Как общий метод отладки, использовать ту же лестницу эскалации: inotify для первого понимания «что вообще происходит», fatrace для PID процесса, bpftrace (в виде systemd-юнита для редких и эпизодических случаев) для полной картины по системным вызовам и стек-трейсам, и, если код всё ещё непонятен, намеренно превратить тихую порчу в аварийный крах с coredump. Если тестируете ИИ-агента на самостоятельной диагностике бага, проверяйте результат и с подсказкой в духе «вот контекст, который может быть релевантен», и без неё: разница в проходимости, как показывает этот эксперимент, может быть огромной.
Можно ли доверять
Основную часть поста, расследование самого бага, подтверждают код-диффы, вывод bpftrace, бэктрейс gdb и ссылка на реальный апстрим-фикс; хронологию (баг-репорт в марте 2025-го, патч Шефера в апреле 2025-го, пропуск в релизе 5.9.1, включение в 5.9.2) можно независимо сверить по рассылке zsh-workers и релизным заметкам Zsh. А вот сравнение ИИ-моделей в приложении B, это личный, разовый эксперимент автора на фреймворке Inspect, а не публичный бенчмарк и не исследование с рецензированием: каждую модель прогнали по три раза, сам автор называет получившуюся картину интересной, но не претендует на то, что цифры обобщаются за пределы этого одного конкретного бага.
Риски и подводные камни
Не перепутайте два разных «капкана» с историей Zsh: разобранный в посте баг с SIGINT и отдельный, не связанный с ним капкан с экспортированной HISTFILE из приложения A, у них разные причины и разные исправления. Coredump, снятый при отладке такого бага, содержит полную историю команд в открытом виде, автор прямо предупреждает не загружать такие дампы в сторонние сервисы. Цифры ИИ-эксперимента, это три прогона на модель и сравнительно небольшая, дорогая (больше $300 в токенах) выборка, а не статистически надёжный бенчмарк; сам эксперимент к тому же показывает, насколько сильно на результат влияет единственная лишняя фраза в промпте, с этим стоит быть аккуратным при громких заявлениях «модель X справляется, а Y нет» по мотивам одного личного блог-поста. Наконец, типовой сбой моделей, зацикливание на неверной гипотезе без возврата к альтернативам, стоит держать в уме, если поручаете ИИ-агенту самостоятельный разбор первопричины в проде без присмотра.
«Поразительно, что баг вроде этого, который приводит к потере данных, может десять лет оставаться неисправленным в популярном шелле.»
— Михаэль Штапельберг, автор поста