プログラミング
Zshの履歴データ消失バグの追跡
Tracking down a Zsh history data loss bug (michael.stapelberg.ch)
要約
この記事は、Zshの履歴ファイルからコマンドが時折消失する問題の原因を追跡した筆者の体験談です。ファイルシステム監視ツールやbpftraceなどを駆使して調査を進めた結果、Zsh自体が履歴ファイルを書き換える際の挙動に問題があることを突き止めました。最終的に、Zshのバグを修正したバージョン5.9.2がリリースされ、この問題は解決されました。
全文翻訳
Zshの履歴データ消失バグの追跡 🐞 2026年8月9日公開 デバッグ
目次
長年、実行したと確信していたコマンドがZシェル履歴ファイル(~/.zsh_history)に存在しなくなることが時折ありました。この記事では、そのバグをどのように追跡したかを紹介します。ネタバレ:最終的に、Zshをクラッシュさせて詳細な情報を得るようにパッチを当て、そのクラッシュのコアダンプを分析することが勝利の戦略でした!
まず良いニュースから
Zsh 5.9.2(2026年7月12日リリース)には、この問題の修正が含まれています。この調査結果を読んだ後に確認して、楽しみを損なわないようにしてください。ネタバレ:アップストリームの修正へのリンク Zsh fix 53454
症状
時折、前日に実行したと知っているコマンドがシェル履歴で見つからないことに気づきました。つまり、Ctrl+Rで履歴を逆検索しても結果が得られませんでした。これが起こるたびに、私のシェル履歴ファイルには古いエントリしかなく、数年分の新しいエントリが欠落していました。この問題が最初に発生した数回は、単に日次バックアップからシェル履歴を復元し、それ以上の調査はしませんでした。しかし、問題は発生し続けました。
.zsh_historyに目に見える破損(非表示文字や不完全なテキスト行など)はなく、ファイル内の行数も常に一定ではないことに気づきました。私には、この問題を引き起こしたのがZsh自体なのか、他のプログラムなのか、あるいは複数のzsh(1)プロセスが組み合わさったものなのかは明らかではありませんでした。
私のZsh履歴設定
私は ~/.zshrc で以下の履歴関連オプションを設定しています。
# 4000行の履歴をロードする(Ctrl+R逆検索用)、しかしO(∞)を保存する
HISTSIZE=4000
HISTFILE=~/.zsh_history
SAVEHIST=10000000
# (隣接する)重複エントリを保存しない
setopt HIST_IGNORE_DUPS
# コマンド実行時に履歴エントリを `~/.zsh_history` に追記する
setopt INC_APPEND_HISTORY
# …しかし履歴を共有しない(NixOSの/etc/zshrcではデフォルトで有効)
unsetopt SHARE_HISTORY
実際には、これは私のシェルが独立したセッションであり、すべてが共有の~/.zsh_historyにコマンドをストリーミングすることを意味します。履歴は意図的に共有されないため、他のシェルが書き込んだエントリにアクセスしたい場合は、明示的に exec zsh を実行します。
挙動のトレース
2024年12月にMastodonで助けを求めたとき(主に誰かがこの問題をすでに遭遇して診断していることを期待して)、得られた提案の1つは、inotifyやfseventsのようなファイルシステム変更監視メカニズムを使用して、Zsh履歴ファイルを切り詰める(または変更する?)犯人を見つけることでした。次のセクションでは、私が試したLinuxで利用可能なオプションを順を追って説明します。
inotify
inotify(7) Linuxカーネルサブシステムは、Linuxで利用可能な最も古いファイルシステム変更監視APIの1つです(2005年リリース)。Zshが履歴ファイルをどのように変更するかをよく理解するためには、.zsh_historyだけを監視するだけでは不十分です。
midna ~ % inotifywait --monitor .zsh_history
Setting up watches.
Watches established.
.zsh_history OPEN
.zsh_history ACCESS
.zsh_history ACCESS
[...]
.zsh_history ACCESS
.zsh_history CLOSE_NOWRITE,CLOSE
.zsh_history ATTRIB
.zsh_history CLOSE_WRITE,CLOSE
.zsh_history DELETE_SELF
^C
ファイルが開かれ、アクセス(=読み取り)され、そして…削除されました?!
含まれるディレクトリを監視することで、全体像が見えてきます。
midna ~ % inotifywait --monitor ~
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ ACCESS .zsh_history
[...]
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CREATE .zsh_history.new
/home/michael/ OPEN .zsh_history.new
/home/michael/ ATTRIB .zsh_history.new
/home/michael/ MODIFY .zsh_history.new
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history.new
/home/michael/ MOVED_FROM .zsh_history.new
/home/michael/ MOVED_TO .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
つまり、Zshは古い履歴ファイルの内容を読み込み、それを新しいファイルに書き込み、その後、新しいファイルを古いファイルの上にリネームすることで、古いファイルを削除しています。これで納得がいきました!
残念ながら、ファイルシステムイベントの責任プロセス(PID)は表示されません。PID情報を提供するAPIであるfanotify(7)を使用する兄弟ユーティリティfsnotifywait(1)を使っても表示されません。確認したところ、カーネルはPIDを送信していますが、fsnotifywaitはPIDを表示しません。
fatrace
幸いなことに、プロセス名とPIDを表示できるfatrace(8)があります。Zshの履歴書き換えがfatrace(8)でどのように見えるかを示します。
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のルートは追求しませんでした。(再現器が手に入ってからは、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