Zshの履歴消失バグを追う 🐞
原文は Michael Stapelberg により に公開されました。 このブログを購読する
長年にわたり、確かに実行したはずのコマンドが Z shell の履歴ファイル(~/.zsh_history)から消えていることに、ときどき気づいていました。この記事では、そのバグをどのように追い詰めたのかをお見せします。ネタバレすると、最終的に勝因となったのは、Zshにパッチを当ててわざと派手にクラッシュさせ、そのコアダンプを解析するという戦略でした!
まずは良いお知らせ
Zsh 5.9.2(2026年7月12日リリース)にはこの問題の修正が含まれています――調査の楽しみを損なわないよう、読み終えてから開いてください。
ネタバレ:上流の修正へのリンク
症状
ときどき、前日に実行したはずのコマンドがシェルの履歴から見つからないことに気づきました。つまり、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で助けを求めたとき(すでにこの問題に遭遇して診断した人がいないかという期待が大半でした)、もらった助言の一つが、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を提供するのですが。調べてみたところ、カーネルは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についてさらに深く知りたい方向けに、役立った資料をいくつか挙げておきます。
- 上流のbpftraceドキュメント
- ブログ記事 “First steps in system-wide Linux tracing” by Martin Pitt (2020)
- LSFMMでの発表 “BPF Observability” by 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
ある日、シェルの履歴が切り詰められていることに気づいてログを確認しました。これがそのときに見つかったものです。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 の制御フローは追いやすい方です。関数を読み進めていくと、早期リターンする可能性が一つあります。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
ビンゴ!ということは、何らかのシグナルが関与しているはずです。
この記事の本筋からは外れますが、私は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を固定する場合は、zsh と zsh-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=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 から始めましたが、最小限すぎると感じました。追加の対策を取らないと、エージェントはすぐにevalのタスクから逸脱して解答を覗き見たり、インターネットを使ってZshのgit版ではこのバグがすでに修正されていることを見つけてしまったりするのです。
最終的に、英国AIセキュリティ研究所とMeridian Labsによるオープンソースのevalフレームワークである Inspect に落ち着きました。こちらの方がうまくいきましたが、Web UIは非常にミニマルです。
このevalはすぐに非常に高額になりました!この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/3 | openai/gpt-5.6-sol | 497,040 | 2m 29s |
| ✅ 3/3 | anthropic/claude-opus-5 | 5,440,676 | 26m 43s |
| ⚠️ 2/3 | openai/gpt-5.5 | 659,050 | 2m 43s |
| ⚠️ 1/3 | openai/gpt-5.6-terra | 673,336 | 1m 57s |
| ⚠️ 1/3 | google/gemini-3.1-pro-preview | 3,733,226 | 9m 31s |
| ⚠️ 1/3 | anthropic/claude-sonnet-5 | 9,323,989 | 29m 18s |
| ⚠️ 1/3 | google/gemini-3.5-flash | 6,746,305 | 12m 38s |
| ⚠️ 1/3 | moonshotai/kimi-k3 (open weight!) @ medium | 2,455,874 | 45m 6s |
| ⚠️ 1/3 | moonshotai/kimi-k3 (open weight!) @ high | 13,060,937 | 52m 2s |
| ⚠️ 1/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 を繰り返し押して、それから何が残っているかを確認します。
これは、モデルがどれだけ容易に問題を理解できるかを測るものです。
| スコア | モデル | トークン数 | 所要時間 |
|---|---|---|---|
| ✅ 3/3 | openai/gpt-5.6-sol | 393,895 | 1m 43s |
| ✅ 3/3 | openai/gpt-5.5 | 622,081 | 1m 31s |
| ✅ 3/3 | anthropic/claude-opus-5 | 1,583,601 | 7m 29s |
| ✅ 3/3 | anthropic/claude-opus-4-8 | 2,338,905 | 6m 4s |
| ✅ 3/3 | anthropic/claude-sonnet-5 | 3,132,322 | 14m 9s |
| ✅ 3/3 | moonshotai/kimi-k3 @ medium (open weight!) | 4,642,764 | 32m 25s |
| ✅ 3/3 | moonshotai/kimi-k3 @ high (open weight!) | 9,631,629 | 32m 17s |
| ✅ 3/3 | z-ai/glm-5.2 (open weight!) | 23,654,094 | 22m 37s |
| ⚠️ 2/3 | openai/gpt-5 | 2,345,526 | 3m 38s |
| ⚠️ 2/3 | google/gemini-3-flash-preview | 6,421,624 | 16m 41s |
| ⚠️ 2/3 | google/gemini-3.5-flash | 3,571,679 | 9m 7s |
| ⚠️ 2/3 | qwen/qwen3.8-max (open weight!) | 4,289,195 | 41m 49s |
| ⚠️ 1/3 | openai/gpt-5.6-luna | 426,974 | 1m 26s |
| ⚠️ 1/3 | openai/gpt-5.6-terra | 728,604 | 1m 31s |
| ⚠️ 1/3 | google/gemini-3.1-pro-preview | 3,525,585 | 6m 23s |
| ⚠️ 1/3 | deepseek/deepseek-v4-flash-0731 (open weight!) | 3,628,997 | 25m 37s |
| ⚠️ 1/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のモデルでは合格させることができませんでした。
これは、どのオープンウェイトモデルがOpusやGPTと実際に同等に機能するかを追跡するのに、とても良いevalのようです(少なくともこの特定の観点において)。今のところ、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が設定されているはずだ」と割り込みの可能性を除外しました――あなたのzshrcのunsetopt SHARE_HISTORYを無視して、消去法を維持するために。
evalにより多くのオーケストレーションを持たせることで(あるサブエージェントに理論を生成させ、別のサブエージェントに追跡と反証/検証を担当させるなど)、成功率が上がることを確認しました。同様に、プロンプトやハーネスを変えることで、個々のモデルをもっとうまく機能させることもできると考えています。
最も一般的な失敗モードは、モデルが誤った理論を選んでその検証に固執し、他の理論に二度と戻ってこないことのようです。より性能の高いモデルは、より優れた方法論を持っているのかもしれません。すなわち、科学的手法により忠実に従っているということでしょうか?
記事をランダムに読む
コメント
ログインしてコメントする