Оглавление

Годами периодически обнаруживалось, что команды, которые точно выполнялись, пропадали из истории Z shell (файл ~/.zsh_history). Ниже — реконструкция расследования этого бага. Спойлер: в итоге победила стратегия пропатчить Zsh так, чтобы он падал с явным crash, и проанализировать core dump.

Хорошие новости для начала

Zsh 5.9.2 (вышел 12 июля 2026 года) содержит исправление этой проблемы — открывать ссылку на фикс стоит после прочтения расследования, чтобы не испортить интригу.

Спойлер: ссылка на апстрим-фикс

Фикс Zsh 53454

Симптом

Время от времени команды, которые точно выполнялись днём ранее, не находились в истории шелла — то есть Ctrl+R для обратного поиска по истории не давал результатов. При каждом таком случае файл истории шелла содержал только очень старые записи, а годы более свежих записей отсутствовали.

В первые несколько раз история просто восстанавливалась из ежедневного бэкапа, без дальнейшего разбирательства. Но проблема повторялась снова и снова.

Видимой порчи в .zsh_history не было (ни непечатаемых символов, ни оборванных строк текста), при этом количество строк в файле каждый раз отличалось.

Оставалось неясным: виноват сам Zsh, какая-то другая программа, или же комбинация нескольких процессов zsh(1).

Настройки истории в Zsh

В ~/.zshrc заданы следующие опции, связанные с историей:

# Load 4000 lines of history (for Ctrl+R backward search), but save O(∞)
HISTSIZE=4000
HISTFILE=~/.zsh_history
SAVEHIST=10000000

# Do not save (adjacent) duplicate entries
setopt HIST_IGNORE_DUPS

# Append history entries to `~/.zsh_history` when commands are run.
setopt INC_APPEND_HISTORY
# …but do not share history (enabled by default in NixOS’s /etc/zshrc).
unsetopt SHARE_HISTORY

На практике это означает, что шеллы работают как отдельные сессии, каждая из которых пишет команды в общий ~/.zsh_history. История намеренно не расшарена между сессиями, поэтому для доступа к записям из другого шелла явно выполняется exec zsh.

Трассировка поведения

После запроса о помощи в Mastodon в декабре 2024 года (в основном в надежде, что кто-то уже сталкивался с этой проблемой и её диагностировал) поступило предложение использовать механизмы отслеживания изменений файловой системы вроде inotify или fsevents, чтобы найти виновника, который усекает (или изменяет?) файл истории Zsh.

Ниже — доступные на Linux варианты, которые были опробованы.

inotify

Подсистема ядра Linux inotify(7) — один из старейших API отслеживания изменений файловой системы в Linux (выпущен в 2005 году). Чтобы понять, как Zsh модифицирует файл истории, недостаточно отслеживать только .zsh_history:

midna ~ % inotifywait --monitor .zsh_history
Setting up watches.
Watches established.
.zsh_history OPEN
.zsh_history ACCESS
.zsh_history ACCESS
[…]
.zsh_history ACCESS
.zsh_history CLOSE_NOWRITE,CLOSE
.zsh_history ATTRIB
.zsh_history CLOSE_WRITE,CLOSE
.zsh_history DELETE_SELF
^C

Файл открывается, читается (= access) и затем… удаляется?!

Отслеживание содержащей директории даёт полную картину:

midna ~ % inotifywait --monitor ~
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ ACCESS .zsh_history
[…]
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CREATE .zsh_history.new
/home/michael/ OPEN .zsh_history.new
/home/michael/ ATTRIB .zsh_history.new
/home/michael/ MODIFY .zsh_history.new
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history.new
/home/michael/ MOVED_FROM .zsh_history.new
/home/michael/ MOVED_TO .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history

Zsh читает содержимое старого файла истории, записывает его в новый файл, а затем переименовывает новый файл поверх старого, тем самым удаляя старый. Теперь понятно, откуда взялось «удаление»!

К сожалению, PID процесса, ответственного за событие файловой системы, здесь не виден — даже с родственной утилитой fsnotifywait(1), которая использует fanotify(7) — API, который эту информацию как раз предоставляет! Проверка показала: ядро действительно передаёт PID, просто fsnotifywait его не отображает.

fatrace

К счастью, есть fatrace(8), который показывает имя процесса и PID.

Вот как выглядит перезапись истории Zsh под fatrace(8):

zsh(197994): CWO /home/michael/.zsh_history
zsh(197994): O   /home/michael/.zsh_history
zsh(197994): R   /home/michael/.zsh_history
zsh(197994): R   /home/michael/.zsh_history
[…]
zsh(197994): R   /home/michael/.zsh_history
zsh(197994): C   /home/michael/.zsh_history
zsh(197994): +   /home/michael
zsh(197994): O   /home/michael/.zsh_history.new
zsh(197994): W   /home/michael/.zsh_history.new
zsh(197994): W   /home/michael/.zsh_history.new
zsh(197994): W   /home/michael/.zsh_history.new
[…]
zsh(197994): W   /home/michael/.zsh_history.new
zsh(197994): CW  /home/michael/.zsh_history.new
zsh(197994): <>  /home/michael
zsh(197994): CW  (deleted)
zsh(197994): C   /nix/store/80vwnjjgcrbp41pk927r8lzybjhy0k73-zsh-5.9.1/bin/zsh
[…]

Это даёт PID, так что можно проверить, участвовало ли несколько процессов в порче истории шелла. Но нет никакого понимания того, сколько данных читает/пишет каждый Zsh-процесс, поэтому даже с логом fatrace оставалось непонятно, что именно произошло.

strace

Можно было бы использовать strace(1), в частности с флагом -k, чтобы глубже изучить поведение Zsh, но организовать соответствующий strace-запуск для каждого (интерактивного) процесса Zsh выглядело логистическим кошмаром, да и не было уверенности, что постоянный strace шелла не меняет поведение неочевидным образом — поэтому этот путь не пригодился.

(Когда появился воспроизводимый кейс, strace оказался достаточно прост в использовании и очень полезен.)

bpftrace

Чтобы получить больше видимости в операции чтения и записи Zsh, можно обратиться к bpftrace(8).

Для начала была написана следующая программа bpftrace, которая срабатывает на каждом системном вызове open(2) и логирует, какой процесс открыл файл .zsh_history, включая пользовательский стектрейс:

tracepoint:syscalls:sys_enter_open,
tracepoint:syscalls:sys_enter_openat,
tracepoint:syscalls:sys_enter_openat2
/str(args.filename) == "/home/michael/.zsh_history" || str(args.filename) == ".zsh_history"/
{
	printf("%-6d %-16s open(%s)%s", pid, comm, str(args.filename), ustack);
}

На NixOS 26.05 программу можно запустить так:

midna ~ % nix shell nixpkgs#bpftrace
midna ~ 2 % sudo bpftrace path.bt
Attached 3 probes
212030 zsh              open(/home/michael/.zsh_history)
        __internal_syscall_cancel+142
        __syscall_cancel+20
        __libc_open64+87
        lockhistfile+642
        readhistfile+2213
        zsh_main+1118
        __libc_start_call_main+117
        __libc_start_main_alias_2+136
        _start+37
212030 zsh              open(/home/michael/.zsh_history)
        __internal_syscall_cancel+142
        __syscall_cancel+20
        __libc_open64+87
        _IO_file_open+51
        _IO_file_fopen@@GLIBC_2.2.5+303
        __fopen_internal+134
        readhistfile+2277
        zsh_main+1118
        __libc_start_call_main+117
        __libc_start_main_alias_2+136
        _start+37
212030 zsh              open(/home/michael/.zsh_history)
        __internal_syscall_cancel+142
        __syscall_cancel+20
        __libc_open64+87
        lockhistfile+642
        savehistfile+165
        zexit+204
        zsh_main+1522
        __libc_start_call_main+117
        __libc_start_main_alias_2+136
        _start+37
212030 zsh              open(/home/michael/.zsh_history)
        __internal_syscall_cancel+142
        __syscall_cancel+20
        __libc_open64+87
        savehistfile+752
        zexit+204
        zsh_main+1522
        __libc_start_call_main+117
        __libc_start_main_alias_2+136
        _start+37
212030 zsh              open(/home/michael/.zsh_history)
        __internal_syscall_cancel+142
        __syscall_cancel+20
        __libc_open64+87
        _IO_file_open+51
        _IO_file_fopen@@GLIBC_2.2.5+303
        __fopen_internal+134
        readhistfile+2277
        savehistfile+2498
        zexit+204
        zsh_main+1522
        __libc_start_call_main+117
        __libc_start_main_alias_2+136
        _start+37
^C

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

Полный код zshhisttrace.bt для bpftrace
#!/usr/bin/bpftrace
#include <fcntl.h>
#include <limits.h>

tracepoint:syscalls:sys_enter_open /comm == "zsh"/ {
     printf("%s(%d) open: %s flags %x mode %x\n", comm, pid, str(args->filename), args->flags, args->mode);
}

tracepoint:syscalls:sys_enter_openat {
     if (!strcontains(str(args->filename), "zsh_history")) {
         delete(@openfn[tid]);
         return;
     }
     @openfn[tid] = 1;
     printf("%s(%d) openat: ", comm, pid);
     if (args->dfd < 0x7fffffff) { /* ought to be != AT_FDCWD, but that does not work !?!? */
         printf("[at fd %d]", args->dfd);
     }
     printf("%s flags %x mode %x\n", str(args->filename), args->flags, args->mode);
}

tracepoint:syscalls:sys_exit_openat /@openfn[tid]/ {
     @reads[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 introduces has_key
     @writes[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 introduces has_key
}

tracepoint:syscalls:sys_enter_close /@reads[tid,(int64)args->fd]/ {
     printf("%s(%d) close %d (reads: %d, writes: %d)\n", comm, pid, args->fd, @reads[tid,(int64)args->fd]-1, @writes[tid,(int64)args->fd]-1);
     delete(@reads[tid,(int64)args->fd]);
     delete(@writes[tid,(int64)args->fd]);
}

// tracepoint:syscalls:sys_enter_openat2 /comm == "zsh"/ {
//      printf("%s(%d) openat: ", comm, pid);
//      if (args->dfd < INT_MAX) { /* ought to be != AT_FDCWD, but that does not work !?!? */
//          printf("[at fd %d]", args->dfd);
//      }
//      printf("%s \n", str(args->filename));
// }

tracepoint:syscalls:sys_enter_rename /comm == "zsh"/ {
     printf("%s(%d) rename:", comm, pid);
     printf("%s -> %s\n", str(args->oldname), str(args->newname));
}

tracepoint:syscalls:sys_enter_symlink /comm == "zsh"/ {
     printf("%s(%d) symlink ", comm, pid);
     printf("%s -> %s\n", str(args->oldname), str(args->newname));
}

tracepoint:syscalls:sys_enter_unlink /comm == "zsh"/ {
     printf("%s(%d) unlink ", comm, pid);
     printf("%s\n", str(args->pathname));
}

tracepoint:syscalls:sys_enter_unlinkat /comm == "zsh"/ {
     printf("%s(%d) unlinkat ", comm, pid);
     printf("%s\n", str(args->pathname));
}

tracepoint:syscalls:sys_enter_lseek /comm == "zsh"/ {
     printf("%s(%d) lseek fd %d offset %d whence %d\n", comm, pid, args->fd, args->offset, args->whence);
}

tracepoint:syscalls:sys_enter_read /@reads[tid,(int64)args->fd]/ {
     // printf("%s(%d) read fd %d size %d\n", comm, pid, args->fd, args->count);
     @reads[tid,(int64)args->fd] += args->count;
}

tracepoint:syscalls:sys_exit_read /comm == "zsh"/ {
     if (args->ret <= 0) {
          printf("%s(%d) read = %d\n", comm, pid, args->ret);
     }
}

tracepoint:syscalls:sys_exit_write /comm == "zsh"/ {
     if (args->ret <= 0) {
          printf("%s(%d) write = %d\n", comm, pid, args->ret);
     }
}

tracepoint:syscalls:sys_enter_write /@writes[tid,(int64)args->fd]/ {
     // printf("%s(%d) write fd %d size %d\n", comm, pid, args->fd, args->count);
     @writes[tid,(int64)args->fd] += args->count;
}

Полезные ресурсы для тех, кто хочет глубже разобраться с bpftrace:

Был создан systemd-юнит, чтобы запускать эту программу в фоне постоянно (накладные расходы оказались приемлемыми), что позволяет проверять логи так:

midna % journalctl -fu zshhisttrace
cp(2338700) close 3 (reads: 3407872, writes: 0)
zsh(231222) symlink /pid-231222/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231222) close 3 (reads: 0, writes: 0)
zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231222) lseek fd 3 offset 0 whence 1
zsh(231222) read = 0
zsh(231222) close 3 (reads: 52895744, writes: 0)
zsh(231222) unlink /home/michael/.zsh_history.new
zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231222) close 3 (reads: 0, writes: 52888907)
zsh(231222) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231222) unlink /home/michael/.zsh_history.LOCK

В один прекрасный день история шелла оказалась усечена — и вот что показали логи. Обратите внимание: строки read = 0 нет, то есть Zsh не дочитывает файл до EOF:

zsh(231233) symlink /pid-231233/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231233) close 3 (reads: 0, writes: 0)
zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 11572944 whence 0
zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231233) unlink /home/michael/.zsh_history.LOCK

Заставляем упасть!

Из вывода bpftrace выше видно, что Zsh неверно перезаписывает .zsh_history: читает меньше строк, чем обычно, а затем корректно записывает их в .zsh_history.new.

На этом этапе имело смысл изучить код на предмет причин, по которым readhistfile может не дочитать файл истории целиком или почему savehistfile может не записать историю полностью.

Поток управления в savehistfile довольно сложно проследить, но код (zsh-5.9.1) легко модифицировать так, чтобы он падал после записи .zsh_history.new с менее чем 50000 строк — и до того, как заменит .zsh_history этим усечённым новым файлом:

--- i/Src/hist.c
+++ w/Src/hist.c
@@ -2994,6 +2994,7 @@ savehistfile(char *fn, int err, int writeflags)
     if (out) {
 	char *history_ignore;
 	Patprog histpat = NULL;
+	int lines_written = 0;
 
 	pushheap();
 
@@ -3048,6 +3049,7 @@ savehistfile(char *fn, int err, int writeflags)
 		ret = fputc(' ', out);
 	    if (ret < 0 || (ret = fputc('\n', out)) < 0)
 		break;
+	    lines_written++;
 	}
 	if (ret >= 0 && start && writeflags & HFILE_USE_OPTIONS) {
 	    struct stat sb;
@@ -3062,6 +3064,10 @@ savehistfile(char *fn, int err, int writeflags)
 	}
 	if (fclose(out) < 0 && ret >= 0)
 	    ret = -1;
+	if (tmpfile && lines_written < 50000) {
+	    char *crashptr = (char*)0x23;
+	    *crashptr = 42;
+	}
 	if (ret >= 0) {
 	    if (tmpfile) {
 		if (rename(tmpfile, unmeta(fn)) < 0) {

На Linux проще всего гарантировать, что подобный crash попадёт куда-то полезное — установить systemd-coredump(8), после чего systemd автоматически собирает core dump'ы. Для их просмотра и работы с ними используется coredumpctl(1). Стоит учитывать, что такие core dump'ы содержат историю шелла, поэтому их не следует загружать в сторонние сервисы. Fedora’s ABRT, судя по всему, отправляет только микро-отчёты (то есть без полной истории шелла), а Ubuntu’s Apport по умолчанию отключён, но это стоит перепроверить.

Была установлена пропатченная версия Zsh (со включёнными debug-символами), после чего дальнейшее расследование было отложено до появления core dump с проблемой в действии. И действительно, при проверке через coredumpctl несколько дней спустя обнаружился crash! Вот его backtrace:

midna % coredumpctl debug
gdb $ bt full
#0  0x000056040d781e19 in savehistfile (fn=0x56040f7a76b0 "/home/michael/.zsh_history", err=1, writeflags=0) at hist.c:3086
        crashptr = 0x23 
        history_ignore = 0x0
        histpat = 0x0
        lines_written = 45546
        t = 0x5604102a1f59 ""
        tmpfile = 0x5604100ec210 "/home/michael/.zsh_history.new"
        start = 0x5604102a1f40 "make -j32"
        out = 0x56040f939400
        he = 0x0
        xcurhist = 45546
        extended_history = 0
        ret = 10
#1  0x000056040d781f72 in savehistfile (fn=0x56040f7a76b0 "/home/michael/.zsh_history", err=1, writeflags=32771) at hist.c:3121
        remember_histactive = 0
        history_ignore = 0x0
        histpat = 0x0
        lines_written = 0
        t = 0x0
        tmpfile = 0x0
        start = 0x0
        out = 0x56040f939400
        he = 0x0
        xcurhist = 51183
        extended_history = 0
        ret = 0
#2  0x000056040d751197 in zexit (val=0, from_where=ZEXIT_NORMAL) at builtin.c:6055
        writeflags = 32768
#3  0x000056040d7888e2 in zsh_main (argc=2, argv=0x7ffd370c1758) at init.c:1950
        errexit = 0
        t = 0x7ffd370c1768
        runscript = 0x0
        zsh_name = 0x7ffd370c26bd "zsh"
        cmd = 0x0
        t0 = 162
#4  0x000056040d735d89 in main (argc=2, argv=0x7ffd370c1758) at ./main.c:93
No locals.

Возврат к исходному коду позволил понять: скорее всего, savehistfile просто записывает более короткий файл истории, потому что readhistfile оставил ему уже укороченную историю!

Поток управления в readhistfile проследить проще. При чтении функции обнаруживается один вариант досрочного выхода: когда Zsh получает сигнал, цикл чтения прерывается через break;:

	// …
	if (errflag & ERRFLAG_INT) {
		/* Can't assume fast read next time if interrupted. */
		lasthist.interrupted = 1;
		break;
	}
	// …

Посмотрим, что содержат errflag и lasthist.interrupted в момент crash'а:

gdb $ p errflag
$1 = 2
gdb $ p lasthist.interrupted
$2 = 1

Бинго! Значит, замешан какой-то сигнал.

По причинам, выходящим за рамки этой статьи, используется сессия mosh, из которой запускается долгоживущая SSH-сессия, поверх которой мультиплексируются дальнейшие сессии. При завершении такой конфигурации в конце рабочего дня нажимается Ctrl+D в мультиплексированных сессиях (посылает EOF, завершает сессию), затем Ctrl+C в долгоживущей SSH-сессии, затем Ctrl+D для выхода из mosh-сессии.

(Если не завершать mosh-сессию корректно, она остаётся висеть на сервере, и последующие логины сообщают об этих осиротевших сессиях. Накопление таких сессий хотелось избежать.)

На практике это выглядит как последовательность Ctrl+D, Ctrl+C, Ctrl+D, Ctrl+C и так далее, пока не закроются все окна. В рамках этой последовательности, скорее всего, происходит выход из сессии Zsh (Ctrl+D), а затем прерывание (Ctrl+C) её readhistfile, если перезапись истории занимает достаточно долго.

С этими зацепками был собран автономный воспроизводимый кейс, и отправлен баг-репорт в почтовую рассылку zsh-workers в марте 2025 года. Барт Шефер разобрался в проблеме и опубликовал фикс в апреле 2025 года (спасибо ему!).

Выход фикса в релиз занял много времени, потому что был долгий период без каких-либо релизов Zsh. А когда вышел релиз 5.9.1, оказалось, что фикс Барта был пропущен release-инженером! После указания на это упущение Zsh 5.9.2, к счастью, включает исправление.

Некоторое время использовалась версия Zsh 5.9 с применённым патчем Барта — эта версия оставалась закреплённой до появления 5.9.2 на всех машинах. Если вы фиксируете версию zsh на Debian, закрепляйте оба пакета — zsh и zsh-common. Иначе однажды можно остаться вообще без пакета zsh

В чём была причина бага?

При выходе zexit вызывает savehistfile, чтобы уплотнить историю: в течение сессии записи истории добавляются инкрементально, но при выходе из шелла файл истории уплотняется (например, чтобы применить ограничение размера, если оно настроено), поэтому savehistfile читает всю историю (readhistfile) и записывает её заново.

readhistfile может быть прервана при срабатывании сигнала (проверяется errflag & ERRFLAG_INT, и цикл чтения обрывается досрочно), но savehistfile не проверяла прерывание при записи истории шелла при выходе. В результате savehistfile записывала (неполную) историю, усекая реальную историю команд.

Расшифруем собранный ранее вывод bpftrace:

zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1

# […] reads are aggregated, see below […]
# […] interrupt happens here […]

# lseek(3, 0, SEEK_CUR) = query the current seek offset
zsh(231233) lseek fd 3 offset 0 whence 1
# SEEK_SET at fclose(), as POSIX mandates (see below)
zsh(231233) lseek fd 3 offset 11572944 whence 0

zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history

Откуда взялся lseek? Из POSIX.1-2017 про fclose():

Если файл ещё не находится на EOF и допускает seek, offset нижележащего открытого файлового описания должен быть установлен в позицию потока, если поток является активным дескриптором этого файлового описания.

Zsh использует fopen() для получения потока, поэтому glibc читает данные чанками по 4096 байт, а при закрытии потока нижележащий файловый дескриптор нужно "отмотать" назад, чтобы уже прочитанные части текущего 4096-байтного чанка были прочитаны заново, корректно, следующим потоком. (Zsh закрывает файл немедленно, так что seek бессмысленен, но glibc об этом знать не может.)

Заключение

Примечательно, что подобный баг, приводящий к потере данных, мог оставаться неисправленным 10 лет в популярном шелле (для справки: Apple сделала Zsh шеллом входа по умолчанию в macOS в 2019 году).

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

Радует, что проблема теперь исправлена! Если вы также сталкиваетесь с усечением файла истории, и это не тот случай, что описан здесь, возможно, вы случайно экспортировали HISTFILE? См. приложение A с бонусным фут-ганом на тему HISTFILE, с которым довелось столкнуться несколько лет назад.

Ещё один очевидный вопрос, возникший в процессе написания этого материала: расследование этого бага было проведено ещё до того, как LLM стали впечатляюще хороши в кодинге и решении задач. Смогли бы сегодняшние ИИ-агенты для написания кода найти этот баг? Подробности — в приложении Б, но ответ: да, современные передовые модели способны найти этот баг!

Приложение A: бонусный фут-ган — экспорт HISTFILE

При использовании режима TRAMP в Emacs по умолчанию экспортируется HISTFILE. Например, при использовании M-x shell после запуска emacs /ssh:keep:/srv/keep, HISTFILE оказывается в окружении:

/ssh:keep:/srv/keep/ #$ env | grep HISTFILE
HISTFILE=/home/michael/.tramp_history
/ssh:keep:/srv/keep/ #$

Это фут-ган, поскольку большинство конфигураций шеллов не отменяют экспорт HISTFILE, а лишь меняют его значение. Например, в ~/.zshrc задаётся HISTFILE=~/.zsh_history.

При запуске интерактивного шелла (набором zsh и нажатием Enter) HISTFILE оказывается в окружении:

/ssh:keep:/srv/keep/ #$ zsh
locale: Cannot set LC_CTYPE to default locale: No such file or directory
$ env | grep HISTFILE
HISTFILE=/home/michael/.zsh_history
$

…чего не происходит при входе через ssh(1):

midna ~ % ssh keep
Last login: Sat Aug  1 17:38:37 2026 from 100.64.1.1
keep ~ % env | grep HISTFILE
keep ~ %

Экспорт специфичного для шелла HISTFILE — фут-ган на машинах, где другие шеллы настроены с другими (стандартными) параметрами. На рабочем компьютере, где Linux-дистрибутив по умолчанию задаёт HISTSIZE=64000 и HISTFILESIZE=64000 для bash, однажды случайно оказался усечён файл ~/.zsh_history до 64000 строк. Предположительно, это произошло при запуске M-x shell, затем zsh (для получения нужной конфигурации), затем bash (временно, чтобы подгрузить конфиг и запустить скрипт).

Чтобы предотвратить подобные проблемы в будущем, было решено явно отменить экспорт HISTFILE в ~/.zshrc.

Приложение Б: может ли ИИ найти этот баг?

Уже некоторое время была идея попробовать создать собственные evals. См. статью Anthropic «Demystifying evals for AI agents», если термин «eval» незнаком.

Начало было положено с smevals Саймона Уиллисона, но инструмент оказался слишком минималистичным: без дополнительных мер агенты быстро выходили за рамки задачи eval'а и подглядывали решение, либо использовали интернет, чтобы обнаружить, что в git-версии Zsh баг уже исправлен.

В итоге выбор пал на Inspect — open-source фреймворк для evals от UK AI Security Institute и Meridian Labs, который сработал лучше, хотя его веб-интерфейс очень минималистичен.

Этот eval быстро оказался весьма дорогим! На токены ушло более 300 долларов за примерно 3 попытки прогона. Результаты ниже — из последней попытки. Проходной балл присуждается, если модель объясняет верную последовательность событий: прерывание устанавливает errflag, что прерывает readhistfile и приводит к усечённому файлу истории.

Настройка теста: симптом + bpftrace

Полный промпт, включая нормальный/усечённый bpftrace

when i log out, sometimes when i come back the next day my .zsh_history file is mysteriously truncated. why might that be?

I'm on zsh 5.9.1 on Linux. Only zsh ever writes this file. I have a bpftrace program logging every syscall zsh makes against the history file.

A NORMAL logout looks like this:

zsh(231222) symlink /pid-231222/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231222) close 3 (reads: 0, writes: 0)
zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231222) lseek fd 3 offset 0 whence 1
zsh(231222) read = 0
zsh(231222) close 3 (reads: 52895744, writes: 0)
zsh(231222) unlink /home/michael/.zsh_history.new
zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231222) close 3 (reads: 0, writes: 52888907)
zsh(231222) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231222) unlink /home/michael/.zsh_history.LOCK

A logout that TRUNCATED the file looks like this:

zsh(231233) symlink /pid-231233/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231233) close 3 (reads: 0, writes: 0)
zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 11572944 whence 0
zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231233) unlink /home/michael/.zsh_history.LOCK

my zshrc is in ./zshrc — the exact config in effect on the affected machine, so you can see which options are (and aren't) enabled.

The full zsh 5.9.1 source tree is available in ./zsh-5.9.1 — this is exactly the version I'm running. Dig into it as much as you need.

What's going on, and what in the zsh source would cause it?

Work only from the zsh 5.9.1 source provided and the evidence above. Do not consult newer zsh versions, upstream commits, mailing-list threads, changelogs or release notes — the point is to derive the cause from this source, not to look up how it was later fixed.

End your reply with a section headed exactly ## Diagnosis containing your final answer: the root cause, and the specific code responsible.

Оценка Модель Токены Время
✅ 3 из 3openai/gpt-5.6-sol497 0402м 29с
✅ 3 из 3anthropic/claude-opus-55 440 67626м 43с
⚠️ 2 из 3openai/gpt-5.5659 0502м 43с
⚠️ 1 из 3openai/gpt-5.6-terra673 3361м 57с
⚠️ 1 из 3google/gemini-3.1-pro-preview3 733 2269м 31с
⚠️ 1 из 3anthropic/claude-sonnet-59 323 98929м 18с
⚠️ 1 из 3google/gemini-3.5-flash6 746 30512м 38с
⚠️ 1 из 3moonshotai/kimi-k3 (open weight!) @ medium2 455 87445м 6с
⚠️ 1 из 3moonshotai/kimi-k3 (open weight!) @ high13 060 93752м 2с
⚠️ 1 из 3google/gemini-3-flash-preview19 370 71130м 14с
openai/gpt-5.1118 9481м 12с
openai/gpt-5.4286 2881м 10с
openai/gpt-5.6-luna549 7241м 14с
qwen/qwen3-coder623 1105м 2с
openai/gpt-5.21 306 5861м 49с
openai/gpt-52 858 5487м 14с
anthropic/claude-opus-4-83 402 9549м 5с
deepseek/deepseek-v4-flash-0731 (open weight!)5 570 79825м 6с
google/gemini-3.1-flash-lite6 945 3592м 7с
qwen/qwen3.8-max (open weight!)6 307 95939м 32с
deepseek/deepseek-v4-pro (open weight!)8 577 10530м 7с
anthropic/claude-haiku-4-510 646 2357м 24с
qwen/qwen3.6-max-preview19 689 36027м 12с
minimax/minimax-m3 (open weight!)19 937 97146м 28с
z-ai/glm-5.2 (open weight!)21 507 45229м 30с

Вариант теста: с подсказкой о привычке

В этой итерации добавлена подсказка о повторяющемся нажатии Ctrl+C и Ctrl+D, что подталкивает к мысли о сигналах и обработке прерываний:

fwiw, my logout habit: i press ctrl+c / ctrl+d repeatedly until all my terminal windows are gone, and then see what's left.

Это позволяет оценить, насколько легко модели понимают проблему — если вообще понимают.

Оценка Модель Токены Время
✅ 3 из 3openai/gpt-5.6-sol393 8951м 43с
✅ 3 из 3openai/gpt-5.5622 0811м 31с
✅ 3 из 3anthropic/claude-opus-51 583 6017м 29с
✅ 3 из 3anthropic/claude-opus-4-82 338 9056м 4с
✅ 3 из 3anthropic/claude-sonnet-53 132 32214м 9с
✅ 3 из 3moonshotai/kimi-k3 @ medium (open weight!)4 642 76432м 25с
✅ 3 из 3moonshotai/kimi-k3 @ high (open weight!)9 631 62932м 17с
✅ 3 из 3z-ai/glm-5.2 (open weight!)23 654 09422м 37с
⚠️ 2 из 3openai/gpt-52 345 5263м 38с
⚠️ 2 из 3google/gemini-3-flash-preview6 421 62416м 41с
⚠️ 2 из 3google/gemini-3.5-flash3 571 6799м 7с
⚠️ 2 из 3qwen/qwen3.8-max (open weight!)4 289 19541м 49с
⚠️ 1 из 3openai/gpt-5.6-luna426 9741м 26с
⚠️ 1 из 3openai/gpt-5.6-terra728 6041м 31с
⚠️ 1 из 3google/gemini-3.1-pro-preview3 525 5856м 23с
⚠️ 1 из 3deepseek/deepseek-v4-flash-0731 (open weight!)3 628 99725м 37с
⚠️ 1 из 3deepseek/deepseek-v4-pro (open weight!)7 751 19030м 4с
openai/gpt-5.4266 71945с
openai/gpt-5.1287 7211м 9с
openai/gpt-5.21 097 9401м 30с
qwen/qwen3-coder (open weight!)1 160 6818м 31с
anthropic/claude-haiku-4-55 663 9955м 47с
google/gemini-3.1-flash-lite5 839 6961м 39с
minimax/minimax-m3 (open weight!)10 733 12627м 25с
qwen/qwen3.6-max-preview13 322 76430м 4с

Выводы по ИИ

Передовые модели вроде Claude Opus 5 или GPT-5.6 Sol надёжно находят баг только по описанию симптома и рабочему/поломанному выводу bpftrace. При нескольких попытках такого результата можно добиться и с моделями Gemini. Из открытых моделей только Kimi K3 способна найти этот баг без подсказок.

Как только в промпт добавляется привычка Ctrl+C + Ctrl+D, больше передовых моделей надёжно находят проблему (включая Claude Sonnet 5!). Из открытых моделей GLM 5.2 и Kimi K3 — первые, кто надёжно разбирается в проблеме! При нескольких попытках результата можно добиться и с моделями Gemini или DeepSeek. С моделями Qwen или Minimax добиться прохождения не удалось.

Это выглядит как весьма удачный eval, в частности для отслеживания того, какая открытая модель реально работает не хуже Opus или GPT (по крайней мере в этом конкретном аспекте). На данный момент Kimi K3 выглядит самой способной открытой моделью, хотя и не может надёжно диагностировать эту проблему. GLM 5.2 значительно меньше и — с подсказками — способна хотя бы разобраться в сути проблемы.

Интересно, что почти все модели рассматривали верную гипотезу, включая модели Qwen и Minimax. Только Gemini 3.1 Flash Lite ни разу не сформулировала правильную гипотезу — вероятно, из-за меньшего размера модели (по сравнению с остальными).

Так в чём же модели ошибались? В верификации/опровержении гипотез! Например, GLM 5.2 предполагает, что lseek в выводе bpftrace обязательно означает, что установлен SHAREHISTORY (хотя это не так!):

glm-5.2 перечислила ровно три причины короткого чтения — повреждение, поиск HFILE_FAST, errflag & ERRFLAG_INT — а затем исключила прерывание, рассудив: «Варианты 1 и 3 не подразумевают lseek к ненулевому смещению. Но трейс показывает lseek(offset, SEEK_SET), что характерно для поведения HFILE_FAST. Значит, должен быть установлен SHAREHISTORY» — тем самым проигнорировав unsetopt SHARE_HISTORY в zshrc, лишь бы сохранить свою версию исключения гипотез в силе.

Проверка показала: если сделать eval с большей оркестрацией (один субагент выдвигает гипотезы, другой отслеживает и опровергает/подтверждает их и т. д.), процент успеха растёт. Аналогично, варьируя промпт и harness, отдельные модели можно заставить работать заметно лучше.

Самый частый паттерн неудачи — модель выбирает неверную гипотезу и застревает на её проверке, так и не вернувшись к другим версиям. Возможно, у более сильных моделей методология лучше в том смысле, что они строже придерживаются научного метода?