Tracking down a Zsh history data loss bug 🐞

Michael Stapelberg

追查 Zsh 歷史紀錄資料遺失臭蟲 🐞

多年來,我偶爾會發現自己明明執行過的指令,卻不在 Z shell 歷史紀錄檔(~/.zsh_history)裡了。在本文中,我將展示我是如何追查這個臭蟲的。先爆雷:最終奏效的策略,是替 Zsh 打上補丁讓它大聲當機,再分析當機產生的 core dump(核心傾印)!

先說好消息

Zsh 5.9.2(2026 年 7 月 12 日發布)已包含此問題的修正——請在讀完本次調查後打開,以免破壞樂趣。

爆雷:上游修正的連結

Zsh fix 53454

症狀

偶爾,我會發現自己明明前一天執行過的指令,卻在 shell 歷史紀錄中找不到,也就是說按下 Ctrl+R 進行反向歷史搜尋時毫無結果。每當我注意到這點時,我的 shell 歷史紀錄檔裡就只剩下非常舊的紀錄,好幾年來較新的紀錄全都不見了。

前幾次發生時,我只是從每日備份還原了 shell 歷史紀錄,並沒有進一步追究。但這個問題一再發生。

我注意到 .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

實際上,這代表我的各個 shell 都是獨立的工作階段,會將各自的指令串流寫入同一個共用的 ~/.zsh_history。歷史紀錄是有意不共用的,所以當我想存取另一個 shell 寫入的紀錄時,我會明確執行 exec zsh

追蹤行為

當我在 2024 年 12 月在 Mastodon 上求助時(主要是希望有人已經遇過並診斷過這個問題),有人建議我使用像是 inotify 或 fsevents 這類檔案系統變更監控機制,來找出會截斷(或異動?)Zsh 歷史紀錄檔的元凶。

接下來的幾個小節將介紹我在 Linux 上嘗試過的可用選項。

inotify

inotify(7) Linux 核心子系統是 Linux 上最古老的檔案系統變更監控 API 之一(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

檔案被開啟、存取(= 讀取),然後……被刪除了?!

透過監控所在的目錄,我們就能看到全貌:

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。

以下是用 fatrace(8) 觀察到的 Zsh 歷史紀錄重寫過程:

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,因此可以驗證是否有多個處理程序涉及 shell 歷史紀錄的毀損。但是,我們無法得知每個 Zsh PID 讀取/寫入了多少資料,所以即使有 fatrace 紀錄,仍然不清楚究竟發生了什麼事。

strace

當然,也可以利用 strace(1),特別是加上 -k 旗標,來更深入觀察 Zsh 的行為,但要為每個(互動式)Zsh 處理程序都安排對應的 strace 執行,似乎在後勤上是場惡夢,而且我也不確定一直用 strace 追蹤 shell 是否會以微妙的方式改變行為,所以我沒有走 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_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]/ {
     @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]/ {
     @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

有一天,我發現自己的 shell 歷史紀錄被截斷了,於是查看了紀錄。結果如下。請注意其中沒有 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),讓它在寫入少於 50000 行的 .zsh_history.new 之後、且在以這個被截斷的新檔替換我的 .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 上,要確保這類當機被收集到有用的地方,最簡單的方法是安裝 systemd-coredump(8),之後 systemd 就會自動收集 core dump。你可以使用 coredumpctl(1) 來列出並處理它們。請注意,這些 core dump 包含你的 shell 歷史紀錄,因此請勿將其上傳至第三方服務。Fedora 的 ABRT 似乎只會傳送微型報告(即不包含完整的 shell 歷史紀錄),而 Ubuntu 的 Apport 預設為停用,但仍值得再次確認。

我安裝了自己打過補丁的 Zsh 版本(已啟用除錯符號),並將進一步的調查延後,直到取得問題發生當下的 core dump。果然,幾天後當我用 coredumpctl 檢查時,就看到了一次當機!以下是回溯(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 <error: Cannot access memory at address 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 收到 signal(信號)時,讀取迴圈會透過 break; 中斷:

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

讓我們來看看在這次當機中,errflaglasthist.interrupted 的內容是什麼:

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

Bingo!所以一定有某個 signal 牽涉其中。

由於本文範圍之外的原因,我使用一個 mosh 工作階段,並在其中啟動一個長時間執行的 SSH 工作階段,再透過它多工處理更多工作階段。在每天工作結束時要收掉這個架構時,我會在多工的工作階段中按下 Ctrl+D(送出 EOF,離開工作階段),然後在長時間執行的 SSH 上按下 Ctrl+C,接著再按 Ctrl+D 離開 mosh 工作階段。

(如果沒有乾淨地離開 mosh 工作階段,它會留在伺服器上,之後登入時就會告訴你有這些孤立的工作階段。我想避免累積這類孤立的工作階段。)

所以實際上我會反覆按下 Ctrl+D、Ctrl+C、Ctrl+D、Ctrl+C 等,直到所有視窗都關閉。在這個序列中,很有可能我正在離開一個 Zsh 工作階段(Ctrl+D),然後在其 readhistfile 歷史重寫耗時夠久時,用 Ctrl+C 中斷它。

有了這些線索,我建立了一個獨立的 reproducer(重現案例),並於 2025 年 3 月向 zsh-workers 郵件論壇發送了錯誤報告。Bart Schaefer(巴特·謝弗)研究了這個問題,並於 2025 年 4 月發布了修正(感謝!)。

這個修正花了很長時間才真正發布,因為中間有很長一段時間沒有任何 Zsh 版本發布。然後,當 5.9.1 版發布時,結果巴特·謝弗的修正竟被發布工程師遺漏了!我指出了這個疏漏,而 Zsh 5.9.2 幸好已包含此修正。

我一直在執行套用了巴特·謝弗補丁的 Zsh 5.9,並會將此版本釘選,直到 5.9.2 在我的電腦上落地。如果你要在 Debian 上釘選 zsh,請同時釘選 zshzsh-common 兩個套件。否則,某天你可能會落得完全沒有 zsh 套件可用……

這個臭蟲是什麼?

離開時,zexit 會呼叫 savehistfile 來壓縮歷史紀錄:在工作階段期間,歷史紀錄會逐步附加,但在 shell 離開時,歷史紀錄檔會被壓縮(例如,若有設定大小限制就會套用),因此 savehistfile 會讀取整個歷史紀錄(readhistfile)並重新寫出。

當 signal 觸發時,readhistfile 可能會被中斷(它會檢查 errflag & ERRFLAG_INT 並短路其讀取迴圈),但 savehistfile 在離開時寫入 shell 歷史紀錄時並未檢查中斷。因此,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,且該檔案是可定址的,則底層開啟檔案描述(open file description)的檔案位移應設定為資料流(stream)之檔案位置,前提是該資料流是底層檔案描述的作用中控制代碼。

Zsh 使用 fopen() 來取得資料流,因此 glibc 會以 4096 位元組為單位分塊讀取,而在關閉資料流時,底層的檔案描述符需要往回 seek,這樣當前 4096 位元組區塊中已讀取的部分才能被下一個資料流正確地再次讀取。(Zsh 會立即關閉檔案,所以這個 seek 其實是多餘的,但 glibc 無從得知。)

結論

令人驚訝的是,像這樣會造成資料遺失的臭蟲,竟能在一個熱門的 shell 中長達 10 年未被修正(你知道嗎?Apple 在 2019 年將 macOS 的預設登入 shell 改為 Zsh)。

誠然,多數使用者大概不會像我一樣,以那種很可能送出 SIGINT 的方式來結束 shell 工作階段,但我不禁想像,一定有些使用者曾遺失過部分歷史紀錄。

我很高興這個問題現在已經修正了!如果你也遇到歷史紀錄檔被截斷的情況,且並非本文所描述的問題,或許你是意外地 export 了 HISTFILE?請參閱附錄 A,了解我幾年前遇到的 HISTFILE 額外地雷(footgun)。

在撰寫本文時浮現的另一個顯而易見的問題是:我在 LLM 在寫程式與解題方面變得令人驚艷之前,就追查到了這個問題。今天的 AI 寫程式代理能找到這個臭蟲嗎?詳見附錄 B,但答案是:可以,今天的前沿模型能夠找到這個臭蟲!

附錄 A:額外地雷:被 export 的 HISTFILE

當你使用 Emacs 的 TRAMP 模式時,預設會 export HISTFILE。例如,在啟動 emacs /ssh:keep:/srv/keep 後使用 M-x shell 時,我會在環境變數中看到 HISTFILE

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

這是一個 footgun,因為大多數 shell 設定並不會 unexport HISTFILE,而只是改變它的值。例如,在我的 ~/.zshrc 中,我設定了 HISTFILE=~/.zsh_history

當執行互動式 shell(輸入 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 ~ %

在其他 shell 以不同(預設)設定配置的機器上,export 特定 shell 的 HISTFILE 就是一個 footgun。在我的工作電腦上,Linux 安裝預設為 bash 設定了 HISTSIZE=64000HISTFILESIZE=64000,我曾不小心將自己的 ~/.zsh_history 檔案截斷為 64000 行。我懷疑那是因為執行了 M-x shell,接著執行 zsh(以載入我的設定),然後又執行 bash(暫時用來載入設定並啟動腳本)所致。

為了避免未來再發生這類問題,我決定~/.zshrc 中主動 unexport HISTFILE

附錄 B:額外問題:AI 能找到這個臭蟲嗎?

有一段時間以來,我一直覺得親手建立自己的 eval(評測)會很有用。如果你不熟悉「eval」這個詞,請參閱 Anthropic 的 〈Demystifying evals for AI agents(《揭開 AI 代理評測的神秘面紗》)〉

我從Simon Willison(西蒙·威利森)的 smevals 開始,但發現它過於精簡:若不採取額外措施,代理很快就會逃脫其 eval 任務並偷看解答,或利用網際網路發現 Zsh 的 git 版本已經修正了這個臭蟲。

我最後採用了由 UK AI Security Institute 與 Meridian Labs 開發的Inspect,一個開源的 eval 框架,效果更好,儘管其網頁介面非常精簡。

這個 eval 很快就變得非常昂貴!我為了這個 eval 大約嘗試了 3 次,就付出了超過 300 美元的 token 成本。以下的結果來自最近一次嘗試。當模型能正確解釋事件的正確序列:中斷設定了 errflag,進而中止 readhistfile 並導致歷史紀錄檔被截斷,即算及格。

評測設定:症狀 + bpftrace

完整的提示詞,包含正常/被截斷的 bpftrace

當我登出後,有時隔天回來會發現我的 .zsh_history 檔案莫名其妙被截斷了。這可能是為什麼?

我是在 Linux 上使用 zsh 5.9.1。只有 zsh 會寫入這個檔案。我有一個 bpftrace 程式,會記錄 zsh 對歷史紀錄檔所做的每一個 syscall。

一次正常的登出看起來像這樣:

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

一次會截斷檔案的登出看起來像這樣:

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

我的 zshrc 位於 ./zshrc——這是受影響機器上實際生效的設定,因此你可以看到哪些選項有(以及沒有)啟用。

完整的 zsh 5.9.1 原始碼樹位於 ./zsh-5.9.1——這正是我正在執行的版本。請盡量深入研究。

究竟發生了什麼事,而 zsh 原始碼中的哪部分會導致這個現象?

請僅根據所提供的 zsh 5.9.1 原始碼與上述證據進行分析。不要參考更新的 zsh 版本、上游提交、郵件論壇討論、變更紀錄或版本說明——重點是從此原始碼推導出原因,而不是去查找它後來是如何被修正的。

請在回覆的最後,以恰好為 ## Diagnosis 的標題作為一個章節,包含你的最終答案:根本原因,以及對應的具體程式碼。

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

評測變體:加入習慣提示

在這個版本中,我加入了關於反覆按下 Ctrl+C 與 Ctrl+D 的提示,這是對信號與中斷處理的暗示:

fwiw,我的登出習慣是:我會反覆按下 ctrl+c / ctrl+d,直到所有終端機視窗都關閉,然後看看剩下什麼。

這用來衡量模型理解此問題的難易程度——如果它們能理解的話。

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

AI 結論

像 Claude Opus 5 或 GPT 5.6 Sol 這類最新的前沿模型,僅憑症狀描述以及正常/失敗的 bpftrace 就能可靠地找到這個臭蟲。如果多試幾次,用 Gemini 模型也能做到。在 Open Weight 模型中,只有 Kimi K3 能在沒有提示的情況下找到這個臭蟲。

一旦在提示詞中加入 Ctrl+C + Ctrl+D 的習慣,更多前沿模型就能可靠地找到問題(包括 Claude Sonnet 5!)。在 Open Weight 模型中,GLM 5.2 與 Kimi K3 是首批能可靠釐清問題的模型!如果多試幾次,用 Gemini 或 DeepSeek 模型也能成功。我無法讓 Qwen 或 Minimax 模型通過。

這似乎是一個相當不錯的 eval,尤其適合用來追蹤哪個 Open Weight 模型真正能像 Opus 或 GPT 那樣運作(至少在這個特定層面上)。就目前而言,Kimi K3 似乎是能力最強的 Open Weight 模型,儘管它仍無法可靠地診斷此問題。GLM 5.2 則小得多——但在有提示的情況下,至少能理解這個問題。

有趣的是,幾乎所有模型都考慮過正確的假設,包括 Qwen 與 Minimax 模型。只有 Gemini 3.1 Flash Lite 從未闡述過正確的假設,推測是因為它是一個(相對而言)較小的模型。

那麼模型是在哪裡出錯的呢?在驗證/證偽理論的過程中!例如,GLM 5.2 假設 bpftrace 輸出中的 lseek 一定代表 SHAREHISTORY 已啟用(其實並沒有!):

glm-5.2 列舉了造成讀取過短的三個確切原因——毀損、HFILE_FAST 搜尋、errflag & ERRFLAG_INT——然後因為「選項 1 和 3 不涉及 lseek 到非零位移。但追蹤顯示 lseek(offset, SEEK_SET),這是 HFILE_FAST 的行為。所以 SHAREHISTORY 一定有啟用」而排除了中斷的可能性——為了維持排除法的成立,甚至推翻了你的 zshrcunsetopt SHARE_HISTORY 的設定。

我已驗證,透過讓 eval 採用更多協作(例如由一個子代理產生理論,另一個追蹤並證偽/驗證等),成功率會提升。同樣地,我預期透過改變提示詞與 harness,個別模型的表現也能大幅改善。

最常見的失敗模式似乎是模型選錯了理論,並卡在驗證該理論的過程中,再也沒有回到其他理論。或許表現較好的模型擁有更好的方法論,在於它們更能遵循科學方法?

原文由 Michael Stapelberg 發布

本文章由 muse-spark-1.2-contributor 進行翻譯