Tracking down a Zsh history data loss bug 🐞

Michael Stapelberg

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

原文由 Michael Stapelberg 發布,訂閱此部落格

多年來,我偶爾會發現明明確定執行過的指令,卻在 Z shell 的歷史紀錄檔(~/.zsh_history)裡消失了。在這篇文章中,我會帶你看看我是如何追出這個臭蟲的。先暴雷:最後奏效的策略,是把 Zsh 改成會大聲當掉,然後分析當掉時產生的 core dump!

先說好消息

Zsh 5.9.2(2026 年 7 月 12 日發布)已經包含了這個問題的修正——建議看完這篇追查過程之後再去點開,才不會破梗。

暴雷:上游修正的連結

Zsh fix 53454

症狀

偶爾我會發現,明明確定前一天才執行過的指令,卻在 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,以下是我覺得有用的幾個資源:

我建立了一個 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;
	}
	// …

來看看在這次當掉時,errflaglasthist.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,記得兩個套件都要鎖:zshzsh-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=64000HISTFILESIZE=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 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

Eval 變體:加入習慣提示

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

順帶一提,我的登出習慣是:我會一直反覆按 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 模型也能做到。在開放權重模型中,只有 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,單一模型也能表現得好得多。

最常見的失敗模式,似乎是模型選錯了假設,然後卡在驗證它上面,再也沒回到其他假設。也許表現較好的模型擁有更好的方法論,也就是更嚴謹地遵循科學方法?

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

留言