Architecture2026.09.07 · 5 min read

保存できたのに「保存失敗」になる — macOS Sandbox で atomic write 後の権限変更が落ちた

認証基盤・クラウドインフラ

macOS アプリに音声で思いつきを保存する受信箱を追加した。外部のタスク設定が無くても保存できる設計にしたのに、実機では「受信箱に保存できなかった」と表示され、もう一度を押しても同じ画面に戻った。保存先を確認すると JSON ファイルは存在している。書き込みそのものではなく、書き込みの後ろに置いた処理が失敗していた。

ファイルはあるのに保存失敗と表示された

最初に疑ったのは、タスクの送り先が設定されていないことだった。しかし受信箱への保存は、Google Tasks や Apple リマインダーへの登録より前に行う。PanelModel は音声の確定結果から原文を受信箱へ渡し、そこで成功してから外部登録の確認画面へ進む。送り先の一覧が空でも、受信箱だけなら保存できるはずだった。

実機の unified log と保存ファイルを同じ時刻で確認した。ログには保存失敗が複数回出ている一方、ファイルは残っていて、JSON の項目数も 1 だった。

unified log と保存ファイルの観測
2026-09-07 21:49:20.502 E  InboxStore: Inbox persistence failed: <private>
2026-09-07 21:50:57.794 E  InboxStore: Inbox persistence failed: <private>

inbox-v1.json  mode=0644  size=253 bytes
schemaVersion=1  projects=0  items=1

ここで「もう一度」が効かない理由も見えた。UI は保存処理が投げたエラーを失敗として扱っているので、再試行すると同じ保存処理をもう一度呼ぶ。その処理は同じ後段で再び失敗する。データが書けたかどうかと、保存処理が成功を返したかどうかが食い違っていた。

書き込みの後段だけが失敗していた

保存処理は、受信箱の JSON を一時ファイルへ atomic に書き、その後でファイルの mode を 0600 に変更する実装だった。保存に失敗したら actor 内の document を進めない、という設計自体は正しい。問題は、後から mode を変更する処理が同じ成功条件に入っていたことだ。

修正前の保存処理
try data.write(to: fileURL, options: .atomic)
try fileManager.setAttributes([.posixPermissions: 0o600],
                              ofItemAtPath: fileURL.path())

実機で観測したファイルは 0644 だった。つまり最初の Data.write はファイルを作れていたが、後段の FileManager.setAttributes が失敗し、InboxStore は全体を persistenceFailed として返していたと判断できる。アプリ側から見ると保存失敗、ファイルシステムから見ると本文だけ保存済み、という中途半端な状態である。

atomic write は「置き換えの途中状態を見せない」ための性質であって、「置き換え後に追加する権限変更まで成功する」ことは保証しない。保存の成功条件に後処理を足すなら、その後処理にも実機での検証が必要になる。

テストは通り、実機だけが落ちた

保存ストアのテストは、一時ディレクトリに保存して再起動後の復元と mode 0600 を確認していた。保存失敗時に一覧を成功状態へ進めないテストもあった。ローカルのテストホストでは、この経路は問題なく通る。

しかしテストしていたのは通常のテストホストであり、署名済みの macOS Sandbox アプリを実機で起動した環境ではなかった。ファイルの読み書きが許可されていても、atomic に置き換えた直後の権限変更まで同じように扱われるとは限らない。ファイル権限を検査するテストがあっても、実行環境の差までは検査できていなかった。

  • データの書き込みと、書き込み後の権限変更を別の失敗点として数えていなかった。
  • テストは一時ディレクトリで通ったが、署名済み Sandbox アプリの実機経路を通していなかった。
  • エラーを返す設計は守れていたが、エラーが返る前にファイルが置き換わる可能性を見落としていた。

権限を後から変えずに最初から固定した

FileManager.setAttributes を保存の最後に置く案は捨てた。代わりに、保存先と同じディレクトリへ 0600 の一時ファイルを POSIX の open で作り、全データを書き込んで fsync と close を通したあと、rename で保存先へ置き換える。

InboxStore の原子的な書き込みの中心部分
return Darwin.open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode_t(0o600))
try writeAll(data, to: descriptor)
guard Darwin.fsync(descriptor) == 0 else { throw posixError() }
guard Darwin.close(descriptor) == 0 else { throw posixError() }
guard Darwin.rename(source, destination) == 0 else { throw posixError() }

バックアップも同じ書き込み経路を使う。これで「データは書けたが mode の変更で失敗する」という二段階の成功条件を無くし、作成されたファイル自体が最初から 0600 になる。置き換えに失敗した場合は一時ファイルを片づけ、actor 内の document は更新しない。

修正後は受信箱保存、保存失敗からの再試行、既存パネルを含むアプリ全体のテストを macmini で通し、署名した成果物を実機へ直接配置した。実機で見つかった問題を、テストの期待値を変えるだけで終わらせず、失敗した境界そのものを狭めた。

まとめ

ローカル保存の成否は、write が戻ったかだけでは決まらない。バックアップ、権限、fsync、rename のような後続処理を成功条件に含めるなら、そこまでを一つの保存操作として扱う必要がある。

そして、テストでファイル権限を確認できていても、Sandbox された実機で同じ結果になる証明にはならない。書き込み後に権限を直すより、作成時に安全な mode を固定してから原子的に置き換えるほうが、失敗の境界も観測しやすい。

macOSSandboxSwiftPersistence