追查 Zsh 歷史紀錄資料遺失的臭蟲 🐞
原文由 Michael Stapelberg 于 發布,訂閱此部落格
多年來,我偶爾會發現明明確定執行過的指令,卻在 Z shell 的歷史紀錄檔(~/.zsh_history)裡消失了。在這篇文章中,我會帶你看看我是如何追出這個臭蟲的。先暴雷:最後奏效的策略,是把 Zsh 改成會大聲當掉,然後分析當掉時產生的 core dump!
先說好消息
Zsh 5.9.2(2026 年 7 月 12 日發布)已經包含了這個問題的修正——建議看完這篇追查過程之後再去點開,才不會破梗。
暴雷:上游修正的連結
症狀
偶爾我會發現,明明確定前一天才執行過的指令,卻在 shell 歷史紀錄裡怎麼找都找不到,也就是按 Ctrl+R 向後搜尋完全沒有結果。每次發現這種情況,我的 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 核心中最早的檔案系統變更監控 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 是先讀取舊的歷史紀錄檔內容,寫到一個新檔案,然後把新檔案重新命名覆蓋掉舊檔,藉此把舊檔刪掉。這樣就說得通了!
可惜的是,我們看不到觸發檔案系統事件的行程 ID(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,來驗證是否有多個行程參與了歷史紀錄的毀損。不過,我們還是看不出每個 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” by Martin Pitt (2020)
- LSFMM 演講 “BPF Observability” by Brendan Gregg (2019)
我建立了一個 systemd unit,讓這個程式在背景持續執行(開銷似乎還算小),這樣我就能像這樣查看紀錄:
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 預設是停用的,不過還是值得再三確認。
我安裝了自己 patch 過的 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 收到訊號時,讀取迴圈會透過 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!所以一定有某個訊號介入。
基於本文範圍之外的原因,我是透過一個 mosh 工作階段去啟動一個長時間執行的 SSH 連線,再在上面多工其他工作階段。每天工作結束要收掉這個環境時,我會在多工的工作階段裡按 Ctrl+D(送出 EOF、離開工作階段),然後在長時間執行的 SSH 上按 Ctrl+C,再按 Ctrl+D 離開 mosh 工作階段。
(如果沒有乾淨地離開 mosh 工作階段,它會留在伺服器上,之後登入時會提示你有這些孤立的工作階段。我想避免累積這種孤立的工作階段。)
所以實際上我會一直按 Ctrl+D、Ctrl+C、Ctrl+D、Ctrl+C……直到所有視窗都關掉。在這個過程中,很可能我正要離開一個 Zsh 工作階段(Ctrl+D),接著如果歷史紀錄重寫花得夠久,就用 Ctrl+C 中斷了它的 readhistfile。
有了這些線索,我做了一個獨立可重現的案例,並在 2025 年 3 月向 zsh-workers 郵件論壇送出了錯誤回報。Bart Schaefer 著手研究,並在 2025 年 4 月發布了修正(感謝他!)。
這個修正花了很久才真正發布,因為中間很長一段時間都沒有 Zsh 的新版本。然後,到了 5.9.1 發布時,結果 Bart 的修正被發布工程師遺漏了!我指出了這個疏漏,還好 Zsh 5.9.2 有把修正納入。
我一直跑著套用 Bart 補丁的 Zsh 5.9,並會持續鎖定這個版本,直到 5.9.2 在我的電腦上落地。如果你要在 Debian 上鎖定 zsh,記得兩個套件都要鎖:zsh 和 zsh-common 套件。否則,某天你可能會發現 zsh 套件整個不見了……
這個臭蟲到底是什麼?
離開時,zexit 會呼叫 savehistfile 來壓縮歷史紀錄:在工作階段期間,歷史紀錄是逐筆附加的,但在 shell 離開時,歷史紀錄檔會被壓縮(例如為了套用大小限制,如果有設定的話),所以 savehistfile 會把整個歷史紀錄讀進來(readhistfile)再重新寫出去。
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,且檔案是可 seek 的,則底層開啟檔案描述的檔案位移應被設為資料流的檔案位置,如果該資料流是底層檔案描述的作用中 handle。
Zsh 使用 fopen() 來取得資料流,所以 glibc 會以 4096 位元組為單位分塊讀取,而在關閉資料流時,需要把底層的檔案描述符 seek 回去,讓目前這個 4096 位元組區塊中已經讀過的部分,能在下一個資料流正確地再次被讀取。(Zsh 隨即就把檔案關掉了,所以這個 seek 其實是多餘的,但 glibc 無從得知。)
結論
令人驚訝的是,像這樣會造成資料遺失的臭蟲,竟然能在一個熱門的 shell 中 10 年都未被修正(你知道嗎?Apple 在 2019 年把 macOS 的預設登入 shell 換成了 Zsh)。
話說回來,大多數使用者大概不會像我這樣用容易送出 SIGINT 的方式來關掉 shell 工作階段,但我還是得想,一定有些使用者已經遺失了部分的歷史紀錄。
我很高興這個問題現在已經修好了!如果你也遇到歷史紀錄檔被截斷的情況,但不是本文描述的這個問題,或許你是不小心把 HISTFILE export 出去了?請見附錄 A,那是我幾年前踩到的 HISTFILE 額外地雷。
在寫這篇文章時,另一個很自然浮現的問題是:我是在 LLM 在寫程式和解決問題上變得非常厲害之前追出這個問題的。今天的 AI coding agent 能找到這個臭蟲嗎?詳情請見附錄 B,但答案是:可以,今天最前沿的模型找得到這個臭蟲!
附錄 A:額外地雷:被 export 的 HISTFILE
當你使用 Emacs 的 TRAMP 模式時,預設會把 HISTFILE export 出去。例如,在執行 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/ #$
這是個地雷,因為大多數 shell 設定不會把 HISTFILE 取消 export,只是改掉它的值。例如,我在 ~/.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 專屬的 HISTFILE export 出去,在那些對其他 shell 設有不同(預設)設定的機器上就是個地雷。在我的工作電腦上,Linux 安裝預設幫 bash 設定了 HISTSIZE=64000 和 HISTFILESIZE=64000,我就有一次不小心把 ~/.zsh_history 截斷成 64000 行。我懷疑就是因為執行了 M-x shell,接著跑 zsh(為了載入我的設定),然後又暫時跑 bash(為了載入某個設定並執行腳本)所造成的。
為了避免日後再發生這類問題,我決定在我的 ~/.zshrc 中主動把 HISTFILE 取消 export。
附錄 B:額外問題:AI 能找到這個臭蟲嗎?
有一陣子,我覺得親手做做看自己的 eval 會很有用。如果你不熟悉「eval」這個詞,可以看看 Anthropic 的「Demystifying evals for AI agents」。
我一開始是從 Simon Willison 的 smevals 入手,但發現它太精簡了:如果沒有額外措施,agent 很快就會跳出 eval 任務去偷看解答,或直接上網發現 Zsh 的 git 版本已經修好這個臭蟲了。
最後我改用 由英國 AI 安全研究所與 Meridian Labs 打造的開源 eval 框架 Inspect,效果比較好,雖然它的網頁介面非常陽春。
這個 eval 很快就變得非常昂貴!光是做大概 3 次嘗試,我就付了超過 300 美元的 token 費用。以下的結果來自最近一次的嘗試。當模型能正確說明事件的先後順序:中斷設定了 errflag,導致 readhistfile 中止,進而產生被截斷的歷史紀錄檔,就算通過。
Eval 設定:症狀 + 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 版本、上游 commit、郵件論壇討論串、changelog 或 release note——重點是從這些來源推導出原因,而不是去查它後來是怎麼被修掉的。
請在回覆的最後加上一個標題完全為
## 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 |
Eval 變體:加入習慣提示
在這個版本中,我加入了關於反覆按 Ctrl+C 和 Ctrl+D 的提示,這是對訊號與中斷處理的暗示:
順帶一提,我的登出習慣是:我會一直反覆按 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 模型也能做到。在開放權重模型中,只有 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 假設 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 加入更多協作(例如讓一個子 agent 負責提出假設,另一個負責追蹤並推翻/驗證等),成功率就會提升。同樣地,我預期只要調整提示和 harness,單一模型也能表現得好得多。
最常見的失敗模式,似乎是模型選錯了假設,然後卡在驗證它上面,再也沒回到其他假設。也許表現較好的模型擁有更好的方法論,也就是更嚴謹地遵循科學方法?
隨機一篇部落格
留言
登入後參與討論