綴Photo の勉強ノートに戻る
技術ブログ2026.08.11

1箇所に閉じ込めて検査まで置いたのに、裏口がもう1つ開いていた — Flutter の投げっぱなし Future

Flutter非同期ログプライバシーテスト綴Photo

前に書いた「名前を信じて、日記の本文を端末のログに流していた — debugPrint はリリース版でも喋る」の続きです。あのとき置いた検査が、何を見ていて何を見ていないかの話になりました。

7月に、保存に失敗したときの例外をそのまま debugPrint へ流していた穴を塞いだ。出ていたのは綴った日記の本文と写真の絶対パスで、debugPrint は名前に反してリリースビルドでも出力される。

そのとき、囲み忘れを検査するのをやめて、呼べる場所を1つに絞るほうを選んだ。kDebugMode の中でだけ debugPrint を呼ぶ debugLog を作り、lib/ からの出力は全部そこを通す形にした。引数は文字列ではなくクロージャ(文字列を作る手順)で受けているので、リリースでは組み立てそのものが走らない。定義と、そう決めるまでの経緯は前の記事にある。

検査も置いた。lib/ を歩いて debugPrint( という文字列を探し、debug_log.dart 以外で見つかったら落ちる。次に debugPrint( と打った瞬間に赤になる。

自分では、これで閉じたと思っていた。

同じ本文が、別の出口から出ていた

今回の観点別 AI レビューで、セキュリティ・プライバシー・UX の3つが別々の症状として同じ場所を挙げてきた。「本文が漏れる」「絶対パスが漏れる」「失敗しても画面が何も言わない」。追いかけたら同じ根だった。

綴る画面には、こういう呼び出しがいくつかある。

dart
// 呼び出し側はボタン: onPressed: () => このメソッド(source)
//   ← このメソッドは Future を返すが、誰も受け取らない=ここが「投げっぱなし」
 
// 「また今度」を外してから、綴り済みとして次へ進む
await ref.read(tsuzuruDecisionServiceProvider).unpassPhoto(source);
if (!mounted) return;
setState(() {
  _cursor = _cursor.markWritten(currentId).advance();
  _currentMoodKey = '';
});

try が無い。そしてこのメソッド自体を呼んでいるのはボタンで、返ってくる Future を受け取らずに捨てている。

この形だと、unpassPhoto が投げた例外は誰にも捕まらない。捕まらない例外は「未処理の非同期エラー」として Dart のランタイムに戻る。

Flutter の既定の受け皿は、例外を toString() して出す。しかもその書き出しに使われているのが debugPrint そのもので、7月に塞いだのと同じ「リリースビルドでも喋る」1本だ。

データベース層が投げる例外は、失敗した SQL 文と渡したパラメータを文字列に含む。そこに写真の絶対パスが入っている。Windows なら C:\Users\<本名>\...\写真\<家族の呼び名>\... の形で。

例外文字列はメモリ。保存しない。失敗した SQL の parameters。端末 SQLite の passed_photos には乗らない。メモリ(保存しない)例外の toString。写真の絶対パス7月に閉じた窓口lib/ の debugPrint(debug_log.dart だけ残すrelease では組み立てごと無い検査がソースの文字を数える今回の裏口投げっぱなし FutureFlutter 既定の受け皿OS の端末ログへ debugPrintlib/ に文字列が無い。release でも残る

読める相手は、7月の検査ファイルに自分で書いた集合とまったく同じだった。Android なら adb(PC から端末の中を覗く開発用ツール)で繋いだ相手とバグレポートの提出先、iOS は Console.app と sysdiagnose、macOS は同じユーザーで動く任意のプロセス。

検査に映らなかった理由

検査はこう書いてある。

dart
for (final file in Directory('lib').listSync(recursive: true) ...) {
  ...
  if (!line.contains('debugPrint(')) continue;   // 自分のコードに現れる文字を探している
  offenders.add('$path:${i + 1}  ${line.trim()}');
}
検査はリポのファイルを文字として読む。実行中のメモリも OS のログも見ない。保存しない。lib/ の .dart検査が歩く範囲debugPrint( を数えるソースの文字アプリは動かさない今回はゼロ件debug_log.dart窓口。ここだけ許すdebugPrint を呼ぶ検査は対象外にするリポのファイルメモリではないFlutter SDK既定の受け皿debugPrint はある検査は開かないlib/ の外数えても映らない

数えても永遠にゼロだ。

面白かったのは、この検査ファイルが「自分が見ていない範囲」を一度は考えていたことだ。テストが2本ある。

dart
test('唯一の窓口である debug_log.dart が実在する', () {
  // 上のテストは「debugPrint がゼロ」でも緑になる=窓口ごと消えた時に
  // 素通りしてしまう。存在をここで別に固定する
  //(一方向ラチェット=ゆるむ方向には戻らない仕掛け)。
  expect(File(_allowedFile).existsSync(), isTrue, ...);  // expect(値, 期待)の形。debug_log.dart が実在するか
});

「テンプレートから作り直して窓口ごと消えたら、ゼロ件で緑になってしまう」という抜け道は、ちゃんと想像して塞いである。

塞げていなかったのは、その1つ手前の前提のほうだった。「ログに出るのは、自分が出したものだけ」。

直した形

main() に受け皿を2つ置いた。フレームワークが描画中に拾う例外と、行き場のない非同期エラーの2系統がある。

dart
// 描画中などにフレームワークが拾った例外を、こちらへ回してもらう
FlutterError.onError = (details) => debugLog(() => '$details');
// 誰も待っていない非同期エラーの「最後の受け皿」を、既定のものから差し替える
PlatformDispatcher.instance.onError = (error, stack) {
  debugLog(() => '$error\n$stack');
  return true;  // 「ここで処理した」の意味。既定のコンソール出力へは流さない
};
例外はメモリ。保存しない。受け皿は main.dart。release では debug_log.dart の中身ごと無く、OS のログにも乗らない。描画中の例外FlutterError.onError投げっぱなし FuturePlatformDispatcher.onErrordebug_log.dartrelease では中身ごと無い。既定へ流さない

そのうえで、投げっぱなしになっていた呼び出しを catch 付きへ寄せた。ここは新しい形を作らずに済んだ。同じファイルの気分ボタンだけはすでに正しく書けていたので、それに揃えた。

dart
// 既存: 失敗したら楽観更新(先に画面を進めてしまう作り)を巻き戻す。画面には何も出さない
} catch (_) {
  if (mounted) {              // await を挟んだので、画面が消えていたら setState しない
    setState(() {
      _cursor = prevCursor;   // prevCursor = 進める前に控えておいた位置
      _currentMoodKey = prevMoodKey;
    });
  }
}

同じ画面の中で、片方だけ作法が守られている状態だった。片方が抜けたこと自体は、注意力の問題ではないと思う。onPressed: () => 保存する() と書いても、Future を捨てたことについて誰も何も言わない。捨て忘れを警告する lint は Dart 側に用意されているが、このリポでは有効にしていない。だから書けているかどうかが、書いた人の記憶の中にしか残らない。7月に debugPrint で見たのと同じ形が、もう一段手前で起きていた。

検査のほうは「main.dart に受け皿2つが置いてあること」を固定した。消したら赤になる形にして、debugPrint を数えるテストの隣に並べた。

ここで1つ、直したら起きることを見込んでおく。受け皿を置くと、いままで黙って消えていた例外が開発中に出るようになる。うるさく感じるはずだが、それは目的そのものだ。潰す前に「これは本当に想定内の例外か」を1回見る。

学び

「窓口を1つに絞って検査で守る」は正しかったが、守れるのは窓口を通るものだけだった。 規律を人の記憶ではなく置き場所に移す、という考え方自体は今回も効いている。効かなかったのは範囲の見積もりで、「ログに出るもの」の集合を「自分が debugPrint と打ったもの」と等しいと置いていた。

だから今回、検査を足すときにもう1行 doc を書いた。その検査が見ていない範囲を、検査ファイル自身に書く。 「lib/ の文字列しか見ない=フレームワークが吐く経路は映らない」と書いてあれば、緑を見た後日の自分がそこで止まれる。

この記事は2026年8月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。