綴Photo の勉強ノートに戻る
開発記録2026.05.22

1件の失敗でフォルダ走査を止めない — symlink ループと『途中で投げる例外』の罠

FlutterDartファイルシステム堅牢性Edge Case綴Photo

綴Photo のデスクトップ版は、選んだフォルダの中の画像を再帰的に集めてスキャンする。年・月でネストしたフォルダ構成(2024/01/...)にも対応するため、サブフォルダの奥まで潜る。実装は AI に手伝ってもらいながら進めていて、この「再帰的にフォルダを歩く」コードにも見過ごしやすい穴が3つあった。それを見直して直した記録だ。

どれも「普段は踏まないが、踏むと大きく壊れる」種類の罠で、テーマは共通している——1件の異常で走査全体を巻き添えにしないこと。

この記事で扱う観察ポイント

  • dir.list(recursive: true) はデフォルトで symlink を辿ります → ループで無限走査
  • 走査ストリームのエラーは await for で投げられ、最初の1件で全体が止まります
  • stat() も投げうる → ファイル単位で握らないと全体が巻き添え
  • 「件数上限」をあえて付けなかった理由

走査はこうなっていた。

dart
await for (final entity in dir.list(recursive: true)) {
  // ... 画像ファイルを拾う ...
}

Directory.list の followLinks はデフォルトが true。つまりシンボリックリンクやジャンクションを「実体」として辿ります。ここに、あるフォルダが自分の祖先を指すようなリンク(循環)があると、再帰走査が同じ場所をぐるぐる回り続けて永遠に終わりません。

選んだフォルダの「外」へリンクで脱出してしまう問題もあります。ユーザーが「写真フォルダ」を選んだつもりが、リンク経由で全然関係ない場所まで走査が広がります。

対策は1行。

dart
dir.list(recursive: true, followLinks: false)

写真を集めるのに symlink を辿る必要はないので、最初から切っておくのが安全です。

穴②: 1件のエラーで走査全体が中断する

dir.list(recursive: true) は、途中で読めないサブフォルダ(権限がない等)に当たると、ストリームにエラーイベントを流します。これを await for で受けていると、最初のエラーで例外が投げられ、ループを抜けてしまいます。

dart
await for (final entity in dir.list(recursive: true)) {
  // 読めないフォルダに当たった瞬間、ここで throw されて
  // 残りの写真は1枚も拾われずスキャンが終わる
}

ユーザーから見ると「写真があるはずなのに途中までしか出てこない」「何も出てこない」のに、理由はどこにも表示されません。1つの権限エラーが、無関係な何千枚もの写真を巻き添えにします。

対策は handleError で、その種のエラーだけ受け止めて走査を続けます。ただし黙って消すのではなく、スキップした件数を数えておきます(あとでまとめて知らせるため)。

dart
var skipped = 0; // 読めず候補から落とした件数
 
final entities = dir
    .list(recursive: true, followLinks: false)
    .handleError((Object _) => skipped++, test: (e) => e is FileSystemException);
 
await for (final entity in entities) {
  // 読めないエントリはスキップされ、走査は続く
}

第1引数が「握ったらどうするか(ここでは件数を数える)」、test が「どのエラーを握るか」の絞り込みです。test で対象のエラー種別を FileSystemException だけに絞れるので、想定外の例外まで飲み込まないようにしています。

穴③: stat() もファイル単位で握る

ファイルを見つけたあと、並べ替え用に更新日時を取るため stat() を呼んでいます。これも投げうる。スキャン中にファイルがロックされたり削除されたりすると、stat() が例外を出し、握っていなければ②と同じく走査全体が止まります。

そこで、1ファイル単位で try/catch し、失敗した写真だけ候補から外して走査は続けます。

dart
final FileStat stat;
try {
  stat = await entity.stat();
} catch (_) {
  skipped++;
  continue; // この1枚は諦めて次へ。走査は止めない(②と同じ skipped に数える)
}
skipped も候補も走査のメモリ。DB にも prefs にも無い。ディスク上のフォルダ選んだフォルダの実ファイル。年/月のサブフォルダまで潜る。① リンクは辿らない。循環も、選んだフォルダの外へ出ることも無い。②③ 失敗読めた走査のメモリ skipped② 読めないフォルダ / ③ stat 失敗ローカル変数。保存しない。走査は止まらない。走査のメモリ 候補読めた写真この走査限り。DB にはまだ無い。完了後に画面へ載せる。②③は関所で、置き場所ではない。カウンタは走査のメモリにある。

フォルダを歩くコードは、外部環境(権限・リンク・他プロセスのロック)に左右されるので、「全部きれいに読める前提」で書くと簡単に折れます。

捨てた1件は、黙っては捨てない

「ダメな1件は捨てて続ける」と書いたが、捨てた数を黙って消してはいない。走査のあと、カウンタを文言に変えます。

dart
String? describeScanSkips(int skippedCount) {
  if (skippedCount <= 0) return null;          // 0件なら通知しない
  return '一部のファイル・フォルダを読み込めませんでした';
}
skipped は走査のメモリ。DB にも prefs にも書かない。走査のメモリ skippedフォルダ1つで +1(配下は何枚でも)ファイル1つで +1正確な枚数ではない。保存しない。画面の一言一部のファイル・フォルダを読み込めませんでした数字は出さない。保存しない。端末 SQLite にも prefs にも書かない。閉じたら消える。

ここであえて件数(「3件読めませんでした」など)を出していないのは意図的だ。正確な数字のつもりで出すと逆に実態とズレて不安を煽ります。だから「一部の」とぼかして、"何か読めなかったものがある" という事実だけを、操作を止めない形(SnackBar)で伝えます。

「ダメな1件は捨てる」と「捨てたことは黙らない」をセットにして、写真が理由もなく減っているのに何も知らされない状態——無言の天井——を作らないようにした。

あえて付けなかったもの: 件数上限

「巨大フォルダ対策に、見つける枚数の上限を付けるか?」も検討したが、付けなかった。

  • 上限を付けると、正当に大量の写真ライブラリを持っている人の写真を黙って打ち切ることになります。「全部の記憶に向き合う」というアプリの思想と相性が悪いです。
  • 走査の中断(ユーザーが画面を離れる)は、await for のループ内で「もう画面にいないなら抜ける」チェックを入れてあるので、ストリームの購読ごと止まります。暴走したまま裏で走り続けることはありません。

「終わらない」リスク(①の symlink ループ)は塞ぎます。でも「ただ多い」ことは正常系として遅くても通します。この線引きをはっきりさせた。

学びポイント

  • Directory.list(recursive: true) は followLinks がデフォルト true。写真集めなら false にして、リンク循環による無限走査とスコープ外への脱出を防ぎます
  • 走査ストリームのエラーは await for で投げられ、最初の1件で全体が止まります。handleError(種別を test で絞る)で「ダメな1件はスキップして続行」にします
  • stat() のような per-entity 操作もファイル単位で try/catch。1件の失敗を全体に波及させません
  • 捨てた1件は黙って捨てず skipped に数え、完了時にまとめて通知します。数字は出さず「一部の」とぼかす=無言の天井を作りません
  • 「終わらない」と「ただ遅い」は別物として扱います。前者は塞ぎ、後者は正常系として許容します(むやみに上限で打ち切りません)

おわりに

実際のユーザーの環境には、権限のないフォルダ、クラウド同期中のロック、過去に作った謎の symlink が普通にあります。recursive: true の一行に、それだけの外部リスクがぶら下がっています。AI に書いてもらう素直な実装は「全部きれいに読める前提」で出てきがちで、こういう外部環境の汚れは、自分が「実環境はもっと汚い」と補ってレビューしないと埋まらない。


この記事は2026年5月時点の自分の理解で書いています。