Tracking down a Zsh history data loss bug 🐞

Michael Stapelberg

Zshの履歴データ消失バグを追う 🐞

長年にわたり、確かに実行したはずのコマンドが、Z shellの履歴ファイル(~/.zsh_history)から消えていることにときどき気づいていました。この記事では、そのバグをどのように追い詰めていったのかをお見せします。ネタバレしておくと、最終的に決め手となったのは、Zshにパッチを当てて派手にクラッシュさせ、そのコアダンプを解析するという戦略でした!

まずは良い知らせから

Zsh 5.9.2(2026年7月12日リリース)には、この問題に対する修正が含まれています。楽しみを損なわないよう、ぜひ本記事を読み終えてからリンクを開いてください。

ネタバレ: 上流の修正へのリンク

Zsh fix 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を実行しています。

挙動の追跡

2024年12月にMastodonで助けを求めたとき(誰かがすでにこの問題に遭遇し、診断していることを期待してのことでした)、Zshの履歴ファイルを切り詰めている(あるいは変更している?)犯人を突き止めるために、inotifyやfseventsのようなファイルシステム変更監視の仕組みを使ってみてはどうか、という提案をもらいました。

次の節では、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)を使っても同様で、こちらはPIDを提供するAPIであるfanotify(7)を使っているにもかかわらずです。調べてみると、カーネルはPIDをきちんと送っているのですが、fsnotifywaitがそれを表示していないのでした。

fatrace

幸い、プロセス名とPIDを表示してくれるfatrace(8)というツールがあります。

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をかけた状態でシェルを動かすことが微妙な挙動の変化を招かないかも確信が持てなかったため、この方法は見送りました。

(再現手順が手に入ってからは、straceは手軽に使えて非常に役立ちました。)

bpftrace

Zshの読み書きをより詳しく可視化するために、bpftrace(8)を使いました。

まずは、open(2)システムコールが呼ばれるたびに実行され、どのプロセスが.zsh_historyを開いたのかをユーザースタックトレース付きで記録する、次のようなbpftraceプログラムを作りました。

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

ある日、シェルの履歴が切り詰められていることに気づき、ログを確認しました。見つかったのは次の内容です。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が自動的にコアダンプを収集してくれます。coredumpctl(1)を使えば一覧表示や操作が可能です。なお、これらのコアダンプにはシェルの履歴が含まれるため、サードパーティのサービスにはアップロードしないでください。FedoraのABRTはマイクロレポートのみを送信するようです(つまり完全な履歴は含まれません)し、UbuntuのApportはデフォルトで無効になっていますが、念のため確認する価値はあります。

パッチを当てたZsh(デバッグシンボル付き)をインストールし、実際に問題が再現したコアダンプが手に入るまで、さらなる調査は保留にしました。果たして数日後にcoredumpctlで確認すると、クラッシュが発生していました!バックトレースは次のとおりです。

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の方が追いやすいです。関数を読み進めると、早期リターンする可能性が1つあります。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

ビンゴです!つまり、何らかのシグナルが関与しているはずです。

本記事の本筋からは外れますが、私はmoshセッションの中から長時間実行するSSHセッションを起動し、その上でさらにセッションを多重化するという使い方をしています。1日の終わりにこの環境を片付けるときは、多重化したセッションでCtrl+Dを押し(EOFを送ってセッションを終了)、長時間実行しているSSHに対してCtrl+Cを押し、さらにmoshセッションを抜けるためにCtrl+Dを押します。

(moshセッションをきちんと終了しないと、サーバー上に残留し、次回ログイン時に孤立したセッションについて通知されます。私はそうした取り残しを溜めたくなかったのです。)

つまり実際には、すべてのウィンドウが消えるまでCtrl+D、Ctrl+C、Ctrl+D、Ctrl+C……と繰り返し押していることになります。この一連の操作の中で、おそらく私はZshセッションを終了させ(Ctrl+D)、履歴の書き換えに十分時間がかかっている場合に、そのreadhistfileを割り込んで(Ctrl+C)しまっていたのでしょう。

これらの手がかりをもとに、単独で再現できる手順を作り、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を呼び出します。セッション中は履歴エントリが逐次追記されていきますが、シェル終了時には履歴ファイルがコンパクト化されるのです(たとえば設定されていればサイズ制限を適用するため)。そのため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に達しておらず、かつシーク可能なファイルである場合、ストリームが基盤となるファイル記述のアクティブなハンドルであれば、基盤となるオープン時のファイル記述のファイルオフセットは、ストリームのファイル位置に設定されなければなりません。

Zshはストリームを取得するためにfopen()を使っているため、glibcは4096バイト単位で読み込みます。ストリームを閉じる際には、現在の4096バイトチャンクのうちすでに読み込まれた部分を次のストリームが正しく再読できるよう、基盤となるファイルディスクリプタをシークして戻す必要があります。(Zshはすぐにファイルを閉じるので、このシークは無意味なのですが、glibcにはそれが分かりません。)

まとめ

データ消失を引き起こすようなバグが、人気のあるシェルで10年も修正されずに残っていたというのは驚くべきことです(ご存知でしたか? Appleは2019年にmacOSのデフォルトログインシェルをZshに切り替えました)。

確かに、SIGINTが送られるような形でシェルセッションを終了させるという私の習慣を、ほとんどのユーザーは共有していないでしょう。しかし、何人かのユーザーは確実に履歴の一部を失っていたはずだと想像しています。

この問題がようやく修正されたことを、本当に嬉しく思います!もしあなたも履歴ファイルの切り詰めに遭遇していて、それが本記事で説明した問題ではない場合、もしかすると誤ってHISTFILEをexportしてしまっているのかもしれません。数年前に私が遭遇したHISTFILEにまつわるおまけの落とし穴については、付録Aをご覧ください。

この記事を書いていて浮かんだもう一つの素朴な疑問があります。私がこの問題を追い詰めたのは、LLMがコーディングや問題解決で目覚ましく優秀になる前のことでした。今日のAIコーディングエージェントなら、このバグを見つけられるだろうか? 詳細は付録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/ #$

これは落とし穴です。多くのシェル設定はHISTFILEのexportを解除せず、単に値を書き換えるだけだからです。たとえば私の~/.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をexportすることは、他のシェルが別の(デフォルト)設定になっているマシンでは落とし穴になります。私の仕事用マシンでは、Linuxのインストール時にbash向けにデフォルトでHISTSIZE=64000HISTFILESIZE=64000が設定されているのですが、かつて誤って~/.zsh_historyを64000行に切り詰めてしまったことがあります。おそらくM-x shellを実行し、次にzsh(自分の設定を読み込むため)、さらに一時的にbash(設定を読み込んでスクリプトを起動するため)を実行した際に起きたのだと思います。

今後このような問題を防ぐため、私の~/.zshrcHISTFILEのexportを明示的に解除することにしました。

付録B: おまけの問い: AIはこのバグを見つけられるか?

しばらく前から、自分でevalを作って実際に手を動かしてみるのは有用だと感じていました。「eval」という用語に馴染みがない方は、Anthropicの「Demystifying evals for AI agents」をご覧ください。

最初はSimon Willison氏のsmevalsから始めたのですが、あまりにミニマルすぎると感じました。追加の対策を取らないと、エージェントはすぐにevalのタスクから逸脱して解答を覗き見たり、インターネットを使ってZshのgit版ではすでにこのバグが修正されていることを見つけてしまったりするのです。

最終的には、UK AI Security InstituteとMeridian LabsによるオープンソースのevalフレームワークであるInspectに落ち着きました。こちらはよりうまく機能しましたが、Web UIは非常にミニマルです。

このevalはすぐに非常に高額になりました。3回ほどの試行で、トークンコストだけで300米ドル以上かかっています。以下は最新の試行結果です。合格とされるのは、モデルが正しい一連の出来事を説明できた場合です。すなわち、割り込みがerrflagをセットし、それがreadhistfileを中断させ、結果として履歴ファイルが切り詰められるという流れです。

Eval設定: 症状 + bpftrace

完全なプロンプト(正常時と切り詰め時のbpftraceを含む)

ログアウトすると、翌日戻ってきたときに.zsh_historyファイルが謎に切り詰められていることがあります。なぜでしょうか?

私はLinux上のzsh 5.9.1を使っています。このファイルを書き込むのはzshだけです。履歴ファイルに対してzshが行うすべてのシステムコールを記録するbpftraceプログラムを用意しています。

正常なログアウトは次のように見えます。

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としたセクションを設け、その中に最終的な答えを記してください。根本原因と、原因となっている具体的なコードです。

スコアモデルトークン所要時間
✅ 3/3openai/gpt-5.6-sol497,0402m 29s
✅ 3/3anthropic/claude-opus-55,440,67626m 43s
⚠️ 2/3openai/gpt-5.5659,0502m 43s
⚠️ 1/3openai/gpt-5.6-terra673,3361m 57s
⚠️ 1/3google/gemini-3.1-pro-preview3,733,2269m 31s
⚠️ 1/3anthropic/claude-sonnet-59,323,98929m 18s
⚠️ 1/3google/gemini-3.5-flash6,746,30512m 38s
⚠️ 1/3moonshotai/kimi-k3 (open weight!) @ medium2,455,87445m 6s
⚠️ 1/3moonshotai/kimi-k3 (open weight!) @ high13,060,93752m 2s
⚠️ 1/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を繰り返し押し続け、そのあと何が残ったかを確認しています。

これは、モデルがどれだけ容易に問題を理解できるか(もしできるとすれば)を測るものです。

スコアモデルトークン所要時間
✅ 3/3openai/gpt-5.6-sol393,8951m 43s
✅ 3/3openai/gpt-5.5622,0811m 31s
✅ 3/3anthropic/claude-opus-51,583,6017m 29s
✅ 3/3anthropic/claude-opus-4-82,338,9056m 4s
✅ 3/3anthropic/claude-sonnet-53,132,32214m 9s
✅ 3/3moonshotai/kimi-k3 @ medium (open weight!)4,642,76432m 25s
✅ 3/3moonshotai/kimi-k3 @ high (open weight!)9,631,62932m 17s
✅ 3/3z-ai/glm-5.2 (open weight!)23,654,09422m 37s
⚠️ 2/3openai/gpt-52,345,5263m 38s
⚠️ 2/3google/gemini-3-flash-preview6,421,62416m 41s
⚠️ 2/3google/gemini-3.5-flash3,571,6799m 7s
⚠️ 2/3qwen/qwen3.8-max (open weight!)4,289,19541m 49s
⚠️ 1/3openai/gpt-5.6-luna426,9741m 26s
⚠️ 1/3openai/gpt-5.6-terra728,6041m 31s
⚠️ 1/3google/gemini-3.1-pro-preview3,525,5856m 23s
⚠️ 1/3deepseek/deepseek-v4-flash-0731 (open weight!)3,628,99725m 37s
⚠️ 1/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は読み込みが短くなる原因を正確に3つ挙げました――破損、HFILE_FASTによる探索、errflag & ERRFLAG_INTです――そして「選択肢1と3は非ゼロオフセットへのlseekを伴わない。しかしトレースはlseek(offset, SEEK_SET)を示しており、これはHFILE_FASTの挙動だ。だからSHAREHISTORYが有効に違いない」と割り込みの可能性を除外し、あなたのzshrcunsetopt SHARE_HISTORYを無視してまで消去法を維持したのです。

より多くのオーケストレーションをevalに持たせる(1つのサブエージェントに理論を生成させ、別のエージェントに追跡や反証/検証をさせるなど)ことで成功率が上がることを確認しました。同様に、プロンプトやハーネスを工夫することで、個々のモデルをもっとうまく機能させることも可能だと思います。

最も一般的な失敗パターンは、モデルが誤った理論を選び、その検証に固執して他の理論に戻らなくなることのようです。より性能の高いモデルは、より優れた方法論を持っているのかもしれません。つまり、科学的方法により忠実に従っているということでしょうか?

原文は Michael Stapelberg により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。