追查 Zsh 歷史紀錄資料遺失臭蟲 🐞
多年來,我偶爾會發現自己明明執行過的指令,卻不在 Z shell 歷史紀錄檔(~/.zsh_history)裡了。在本文中,我將展示我是如何追查這個臭蟲的。先爆雷:最終奏效的策略,是替 Zsh 打上補丁讓它大聲當機,再分析當機產生的 core dump(核心傾印)!
先說好消息
Zsh 5.9.2(2026 年 7 月 12 日發布)已包含此問題的修正——請在讀完本次調查後再打開,以免破壞樂趣。
爆雷:上游修正的連結
症狀
偶爾,我會發現自己明明前一天執行過的指令,卻在 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,以下是我覺得有用的幾個資源:
- 上游 bpftrace 文件
- 部落格文章 〈First steps in system-wide Linux tracing〉,Martin Pitt(馬丁·皮特)著(2020)
- LSFMM 演講 〈BPF Observability〉,Brendan Gregg(布倫丹·葛瑞格)著(2019)
我建立了一個 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;
}
// …
讓我們來看看在這次當機中,errflag 與 lasthist.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,請同時釘選 zsh 與 zsh-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=64000 與 HISTFILESIZE=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 3 | openai/gpt-5.6-sol | 497,040 | 2m 29s |
| ✅ 3 of 3 | anthropic/claude-opus-5 | 5,440,676 | 26m 43s |
| ⚠️ 2 of 3 | openai/gpt-5.5 | 659,050 | 2m 43s |
| ⚠️ 1 of 3 | openai/gpt-5.6-terra | 673,336 | 1m 57s |
| ⚠️ 1 of 3 | google/gemini-3.1-pro-preview | 3,733,226 | 9m 31s |
| ⚠️ 1 of 3 | anthropic/claude-sonnet-5 | 9,323,989 | 29m 18s |
| ⚠️ 1 of 3 | google/gemini-3.5-flash | 6,746,305 | 12m 38s |
| ⚠️ 1 of 3 | moonshotai/kimi-k3 (open weight!) @ medium | 2,455,874 | 45m 6s |
| ⚠️ 1 of 3 | moonshotai/kimi-k3 (open weight!) @ high | 13,060,937 | 52m 2s |
| ⚠️ 1 of 3 | google/gemini-3-flash-preview | 19,370,711 | 30m 14s |
| ❌ | openai/gpt-5.1 | 118,948 | 1m 12s |
| ❌ | openai/gpt-5.4 | 286,288 | 1m 10s |
| ❌ | openai/gpt-5.6-luna | 549,724 | 1m 14s |
| ❌ | qwen/qwen3-coder | 623,110 | 5m 2s |
| ❌ | openai/gpt-5.2 | 1,306,586 | 1m 49s |
| ❌ | openai/gpt-5 | 2,858,548 | 7m 14s |
| ❌ | anthropic/claude-opus-4-8 | 3,402,954 | 9m 5s |
| ❌ | deepseek/deepseek-v4-flash-0731 (open weight!) | 5,570,798 | 25m 6s |
| ❌ | google/gemini-3.1-flash-lite | 6,945,359 | 2m 7s |
| ❌ | qwen/qwen3.8-max (open weight!) | 6,307,959 | 39m 32s |
| ❌ | deepseek/deepseek-v4-pro (open weight!) | 8,577,105 | 30m 7s |
| ❌ | anthropic/claude-haiku-4-5 | 10,646,235 | 7m 24s |
| ❌ | qwen/qwen3.6-max-preview | 19,689,360 | 27m 12s |
| ❌ | minimax/minimax-m3 (open weight!) | 19,937,971 | 46m 28s |
| ❌ | z-ai/glm-5.2 (open weight!) | 21,507,452 | 29m 30s |
評測變體:加入習慣提示
在這個版本中,我加入了關於反覆按下 Ctrl+C 與 Ctrl+D 的提示,這是對信號與中斷處理的暗示:
fwiw,我的登出習慣是:我會反覆按下 ctrl+c / ctrl+d,直到所有終端機視窗都關閉,然後看看剩下什麼。
這用來衡量模型理解此問題的難易程度——如果它們能理解的話。
| 分數 | 模型 | Token 數 | 耗時 |
|---|---|---|---|
| ✅ 3 of 3 | openai/gpt-5.6-sol | 393,895 | 1m 43s |
| ✅ 3 of 3 | openai/gpt-5.5 | 622,081 | 1m 31s |
| ✅ 3 of 3 | anthropic/claude-opus-5 | 1,583,601 | 7m 29s |
| ✅ 3 of 3 | anthropic/claude-opus-4-8 | 2,338,905 | 6m 4s |
| ✅ 3 of 3 | anthropic/claude-sonnet-5 | 3,132,322 | 14m 9s |
| ✅ 3 of 3 | moonshotai/kimi-k3 @ medium (open weight!) | 4,642,764 | 32m 25s |
| ✅ 3 of 3 | moonshotai/kimi-k3 @ high (open weight!) | 9,631,629 | 32m 17s |
| ✅ 3 of 3 | z-ai/glm-5.2 (open weight!) | 23,654,094 | 22m 37s |
| ⚠️ 2 of 3 | openai/gpt-5 | 2,345,526 | 3m 38s |
| ⚠️ 2 of 3 | google/gemini-3-flash-preview | 6,421,624 | 16m 41s |
| ⚠️ 2 of 3 | google/gemini-3.5-flash | 3,571,679 | 9m 7s |
| ⚠️ 2 of 3 | qwen/qwen3.8-max (open weight!) | 4,289,195 | 41m 49s |
| ⚠️ 1 of 3 | openai/gpt-5.6-luna | 426,974 | 1m 26s |
| ⚠️ 1 of 3 | openai/gpt-5.6-terra | 728,604 | 1m 31s |
| ⚠️ 1 of 3 | google/gemini-3.1-pro-preview | 3,525,585 | 6m 23s |
| ⚠️ 1 of 3 | deepseek/deepseek-v4-flash-0731 (open weight!) | 3,628,997 | 25m 37s |
| ⚠️ 1 of 3 | deepseek/deepseek-v4-pro (open weight!) | 7,751,190 | 30m 4s |
| ❌ | openai/gpt-5.4 | 266,719 | 45s |
| ❌ | openai/gpt-5.1 | 287,721 | 1m 9s |
| ❌ | openai/gpt-5.2 | 1,097,940 | 1m 30s |
| ❌ | qwen/qwen3-coder (open weight!) | 1,160,681 | 8m 31s |
| ❌ | anthropic/claude-haiku-4-5 | 5,663,995 | 5m 47s |
| ❌ | google/gemini-3.1-flash-lite | 5,839,696 | 1m 39s |
| ❌ | minimax/minimax-m3 (open weight!) | 10,733,126 | 27m 25s |
| ❌ | qwen/qwen3.6-max-preview | 13,322,764 | 30m 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一定有啟用」而排除了中斷的可能性——為了維持排除法的成立,甚至推翻了你的zshrc中unsetopt SHARE_HISTORY的設定。
我已驗證,透過讓 eval 採用更多協作(例如由一個子代理產生理論,另一個追蹤並證偽/驗證等),成功率會提升。同樣地,我預期透過改變提示詞與 harness,個別模型的表現也能大幅改善。
最常見的失敗模式似乎是模型選錯了理論,並卡在驗證該理論的過程中,再也沒有回到其他理論。或許表現較好的模型擁有更好的方法論,在於它們更能遵循科學方法?
隨機一篇部落格