アトミック書き出しの死角:settings.json の「二重シンボリックリンク」が引き起こす保存失敗バグ
1. 概要
設定ファイルを保存する際、書き込み中のクラッシュによるファイル破損を防ぐため、一度一時ファイルに書き出してから rename(不可分な置換)を行う「アトミック書き込み(Atomic Write)」は、システム開発における必須のベストプラクティスである。 しかし、対象となる設定ファイルが、宣言型構成管理ツール(Nix home-manager 等)によって「シンボリックリンクのシンボリックリンク(二重シンボリックリンク)」として構築されている場合、アトミック書き込みのパス解決ロジックに死角が生まれる。 本稿では、Claude Codeで報告された、設定保存が EROFS/EACCES で失敗するバグ(anthropics/claude-code#78162)を題材に、ファイルI/Oの階層構造とアトミック処理におけるパス解決の罠について分析する。
2. インシデントの分析:Nix環境で設定保存が死ぬ謎のエラー
2.1. 現象:プラグイン追加や設定保存時のサイレント・アボート
Claude Code v2.1.211において、設定の永続化を伴う操作(プラグインのインストールやカタログ更新など)を実行した際、処理の途中でパーミッションエラー(EROFS: read-only file system や EACCES: permission denied)が発生し、保存処理が失敗するバグが発生した。
このバグが発生すると、エラー自体が明確に報告されないまま進行し、結果としてインストールしたはずのプラグインやスキルがロードされない(Unknown skill)という、デバッグの極めて困難な可用性障害に繋がった。
2.2. 原因:1ホップしか解決しない readlink の限界
原因は、Claude Codeの「アトミック書き出し(Atomic Writer)」が、シンボリックリンクを解決する際の「深度(Depth)」の甘さにあった。
アトミック書き出しでは、settings.json 自体がシンボリックリンクである可能性を考慮し、「実ファイルがあるフォルダ」に一時ファイルを作成しようとする。しかし、実装ではリンクを 「1ホップ(1階層)」 しか解決していなかった。
- 二重シンボリックリンクの構造(Nix等によるドットファイル管理):
~/.claude/settings.json(第1リンク) └── 🔗 指す先:/nix/store/.../settings.json(第2リンク:読み取り専用のストアパス) └── 🔗 指す先:/home/user/dotfiles/settings.json(実ファイル:書き込み可能)
この構成において、1ホップしか解決しない場合、プログラムは一時ファイル(settings.json.tmp.<pid>)を /nix/store/.../(読み取り専用のディレクトリ) に作成しようとしてしまう。 当然、書き込み権限がないため、OSから EROFS や EACCES を返され、保存処理が完全に破綻した。
実ファイル(dotfiles/settings.json)自体は書き込み可能であるにもかかわらず、「一時ファイルを作成するディレクトリの特定ロジック」の甘さによって、システム自身が自分を締め出す結果となった。
2.3. ソースコード不要の推理:strace によるブラックボックス解析の美学
このバグをGitHubに報告したエンジニア(GuillaumeLagrange 氏)は、Claude Codeの開発コアメンバー(コントリビューター)ではなく、Nix環境下でこのツールを使用し始めた一介のパワーユーザーである。 ソースコードの全貌を知らない外部 of ユーザーが、なぜ「一時ファイル作成のディレクトリ特定ロジックのバグ」という内部の設計ミスを完璧に看破できたのか。その鍵は、OSレイヤーのシステムコールを監視するデバッグツール(Linuxの strace 等)にある。
アプリケーションが正しく動かない際、OSのシステムコール(ファイルオープンや書き込み命令)を追跡することで、プログラムが背後でどのような処理を行っているかをブラックボックス状態からリバースエンジニアリングすることができる。
# strace を用いたファイルシステム操作の追跡イメージ
strace -f -e trace=file claude plugin add ...この監視ログの末尾に、以下のようなOSからのエラーが記録されていたはずである。
open(".../readonly/settings.json.tmp.12345", O_WRONLY|O_CREAT|O_TRUNC, 0666) = -1 EROFS (Read-only file system)この一行から、以下の事実が一本の線で繋がる。
- 更新しようとしているターゲットは
/writable/settings.jsonである。 - にもかかわらず、プログラムが
openしようとした一時ファイルのパスは/readonly/(読み取り専用のNixストア)である。 - すなわち、プログラムの内部ロジックは、実ファイルの場所ではなく、シンボリックリンクの解決途中のパス(1ホップ目のリンク先)を基準にして一時ファイルを作成しようとしている。
「プログラムのソースコードを1行も読まずとも、OSが吐き出したシステムコールのパスとエラーの相関から、内部アルゴリズムのバグを正確に特定する」というこのアプローチは、システムプログラミングやOSの挙動に対する深い理解がもたらす、極めて美しく合理的なデバッグの極致である。
3. 実体験:アトミック書き出しが「一時ディレクトリ」と衝突する罠
一時ファイルを使った安全な書き出し処理は、シンボリックリンク以外でも、異なるパーティションやファイルシステムをまたぐ際に、OSレベルの制限と衝突してハングする原因になる。
- 実体験エピソード: 大量のログデータやレポートを生成して
/var/log/app/output.jsonにアトミックに書き出すサーバーサイドのプログラムを開発した。 アトミック性を保証するため、プログラムはシステムのデフォルトの一時ディレクトリ(/tmp/)に一時ファイルを生成し、書き込み完了後に/var/log/app/output.jsonへrenameする仕様にした。 テスト環境では完璧に動いていたが、本番環境にデプロイした瞬間、すべてのファイル書き出しがEXDEV: cross-device link not permittedエラーでクラッシュした。 原因は、本番環境において/tmpは高速なRAMディスク(tmpfs)上にあり、/var/logは物理ハードディスク(ext4)上という、「異なるファイルシステム(デバイス)間でのrename」 が発生したことだった。 OSのrename(またはmv)は、同一デバイス間であればインデックス(i-node)の書き換えだけでアトミックに完了するが、デバイスをまたぐ場合は「全データの物理的コピーと削除」に化けるため、アトミック性が失われ、かつ単純なシステムコールではエラーになる。 「安全のために一時ファイルを経由する」というロジックは、常に「OSとファイルシステムの物理レイアウト(マウント情報やリンク構造)」を完璧に把握していなければ、容易に爆弾に変わる。
4. 解決策と「アトミックファイルI/O」の設計原則
この二重シンボリックリンクの罠を解決するためには、パスの解決において「1ホップの解決」ではなく、**「終端までの完全解決」**を強制しなければならない。
4.1. fs.realpath(readlink -f 相当)による完全解決(Full Resolve)
一時ファイルを作成するディレクトリを決定する前に、対象のパスに対して再帰的にシンボリックリンクを辿り、最終的な物理的実ファイルが属するディレクトリを特定する。
- Rust / Node.js での実装イメージ:javascript
// 誤り(1ホップ):dirname(fs.readlinkSync(path)) -> 読み取り専用ディレクトリを指すリスク // 正しい(完全解決):dirname(fs.realpathSync(path)) -> 最終的な実ファイルの書き込み可能ディレクトリを特定 const realPath = fs.realpathSync(settingsJsonPath); const tempFilePath = path.join(path.dirname(realPath), `settings.json.tmp.${process.pid}`);
この修正により、Nixや Chezmoi などの複雑な多重リンク環境であっても、一時ファイルは最終的な書き込み可能フォルダ(dotfiles/)に正しく作成され、アトミック書き込みが100%成功するようになる。
5. 結論
アトミック書き込みは堅牢なファイル保存のための優れた手法であるが、シンボリックリンクやデバイス境界(クロスデバイス)の存在を甘く見積もると、容易に「可用性を破壊するバグ」になる。 ファイルI/Oを設計する際は、「リンクは最後まで完全に解決(realpath)した上でディレクトリを決定する」、「一時ファイルはターゲットと物理的に同一のデバイス(ディレクトリ内)に作成する」 という、ファイルシステムとOSの物理的な仕様に準拠した厳密な実装が不可欠である。
6. 参考文献 / 一次情報
- Atomic write to settings.json fails with EROFS/EACCES when the file is a symlink-to-a-symlink: anthropics/claude-code#78162
- Chezmoi / Nix home-manager symlink issue: NixOS discourse & community threads