Flutter デスクトップアプリが Cmd+Q で SIGSEGV — Timer と FFI とエンジン終了処理の競合を解く

| 開発記録 | プラットフォーム

タグ: #Flutter #macOS #FFI #SQLite #デスクトップ

Cmd+Q で閉じるたびに、macOS のクラッシュレポートが溜まっていました。PC=0 の SIGSEGV です。Timer.periodic の同期処理が sqlite3 の FFI を呼んでいる最中に、エンジンの終了処理がネイティブライブラリを解放していました。完了を待てる stop() と AppLifecycleListener.onExitRequested の二段構えで直すまでの記録です。

Cmd+Q しただけで落ちる

個人用に、macOS のデスクトップツールを Flutter で作っています。チャットサービスのメッセージ履歴を、ローカルの SQLite に定期同期して溜めるツールです。機能は一通り動くようになりました。ところが使い終わって Cmd+Q で閉じると、macOS のクラッシュレポートがときどき積まれています。

レポートを開くと EXC_BAD_ACCESS (SIGSEGV)。しかもクラッシュ時のプログラムカウンタが 0 番地 を指していました。Dart 側には例外もスタックトレースも一切残っていません。

アプリはもう終了しているので、ユーザー視点では「閉じただけ」です。でもクラッシュレポートは確実に溜まっていく。気持ちの悪い状態でした。

PC=0 が意味すること

プログラムカウンタが 0。これは null 関数ポインタへジャンプした直後に落ちたということです。C/C++ の世界でこれが起きる典型は「解放済みの関数テーブル経由で呼び出した」ケースです。

PC=0 だけなら、戻りアドレスの破損など他の可能性も残ります。それでも Dart のロジックエラーではありません。ネイティブ層で不正なアドレスへ制御が飛んでいる。そこまでは確かです。

再現条件を絞り込みました。落ちるのは決まって、バックグラウンド同期が走っている最中に Cmd+Q したときです。このツールは Timer.periodic で数分おきに同期処理を起動します。その中で HTTP でメッセージを取得して SQLite に書き込む。DB アクセスは drift 経由ですが、実体は sqlite3 の FFI 呼び出しです。

原因: 3 者の競合

終了時に、次の 3 つが並走していました。

  1. Timer.periodic が起動した同期処理が、sqlite3 の FFI 呼び出しを実行している
  2. Cmd+Q により FlutterEngine の終了シーケンスが始まり、プラグインやネイティブライブラリ(dylib)を解放する
  3. Dart の isolate はまだ生きていて、同期処理の続きで次の FFI 呼び出しを発行する

3 番の呼び出しが、2 番で解放済みになった関数ポインタへのジャンプになる。それで PC=0 の SIGSEGV。これが「同期中の Cmd+Q でのみ再現する」という条件と突き合わせて立てた仮説です(後述の修正を入れてから一度も再現していません。そこが裏付けになりました)。

モバイルではまず顕在化しません。iOS / Android のアプリ終了は、OS がプロセスごと suspend / kill します。「エンジンだけ先に片付いて Dart コードが走り続ける」瞬間が基本的に無いからです。

一方、デスクトップの Cmd+Q は行儀のよい終了シーケンスです。エンジンの分解と Dart コードの実行が、短い時間だけ並走します。モバイルの感覚で書いたコードにとっては、この「行儀のよい終了」こそが一番危ない経路でした。ていねいに閉じてくれるほうが危ない。しばらくぴんと来ませんでした。

修正 1: 「完了を待てる stop()」を作る

まず押さえるべきことがあります。Timer.cancel()次の発火を止めるだけです。いま進行中のコールバックは止めてくれません(cancel すれば全部止まると思い込んでいました)。同期スケジューラの stop() が同期的に cancel して即 return する作りだと、進行中の FFI 呼び出しは置き去りになります。

そこで stop()Future 化しました。進行中の処理の完了を待ってから戻ります。

class SyncScheduler {
  static const _stopTimeout = Duration(seconds: 30);
  static const _pollInterval = Duration(milliseconds: 50);

  Timer? _timer;
  Timer? _initialTimer; // 初回遅延実行用の単発 Timer
  bool _isRunning = false;
  bool _stopped = false;

  /// 進行中の処理があれば、その完了を待ってから戻る。
  Future<void> stop() async {
    _stopped = true; // 以後の新しい tick をブロック(fail-closed)
    _initialTimer?.cancel();
    _initialTimer = null;
    _timer?.cancel();
    _timer = null;
    // 進行中の同期を待つ。ただし上限つき。
    final deadline = DateTime.now().add(_stopTimeout);
    while (_isRunning && DateTime.now().isBefore(deadline)) {
      await Future<void>.delayed(_pollInterval);
    }
  }

  Future<void> _tick() async {
    if (_stopped) return; // stop 後に発火しても何もしない
    if (_isRunning) return; // 前回の同期が長引いていたら重複起動しない
    _isRunning = true;
    try {
      // HTTP 取得 → SQLite(FFI) 書き込み
    } finally {
      _isRunning = false;
    }
  }
}

設計上のポイントは 3 つです。

  • _stopped フラグで入口を閉じる(fail-closed)cancel() すればその Timer の以後の発火は起きません。でも、すでに走り出している非同期の tick には効きません。Timer 以外の経路から _tick() が呼ばれるケースにも効きません。入口のフラグで「stop 後は何が来ても何もしない」を保証します
  • 初回遅延用の単発 Timer も別に保持して cancel するTimer.periodic だけ止めて満足しがちです。起動直後に Cmd+Q されると単発側が発火します
  • 待機には必ずタイムアウトを付ける。同期処理が何かの理由でハングしたとき、クラッシュを防ぐための待機がアプリを終了不能にしたら本末転倒です。最悪ケースは「30 秒待って諦めて終了」に倒します

修正 2: onExitRequested で OS の終了要求を保留する

stop() を待てるようにしても、誰かが終了前にそれを await してくれなければ意味がありません。デスクトップの Flutter には、そのためのフックが用意されています。AppLifecycleListeneronExitRequested です。

class _AppBootstrapState extends State<AppBootstrap> {
  AppLifecycleListener? _lifecycleListener;

  @override
  void initState() {
    super.initState();
    _lifecycleListener = AppLifecycleListener(
      // Cmd+Q 等で OS が「終了していいか」を問い合わせてきたとき
      onExitRequested: () async {
        await scheduler.stop(); // 進行中の FFI 呼び出しを見送ってから
        return AppExitResponse.exit; // 終了を許可する
      },
      // 最終セーフティネット(同期的にできる範囲の片付けだけ行う)
      onDetach: () => scheduler.stop(),
    );
  }

  @override
  void dispose() {
    _lifecycleListener?.dispose();
    super.dispose();
  }
}

onExitRequested のハンドラが AppExitResponse を返すまで、ネイティブ側は FlutterEngine の分解を始めません。つまりここで scheduler.stop() を await する。すると通常は「進行中の FFI 呼び出しが着地してから dylib が解放される」順序を作れます。

厳密には stop() は 30 秒のタイムアウトで諦めて戻ります。そこまで同期がハングしていた場合は競合が再発しえます。タイムアウト時に AppExitResponse.cancel を返して終了自体を取りやめる設計も、API としては可能です。ただこのツールでは「終了できないことのほうが害が大きい」と判断して exit に倒しました。

注意点があります。このフックが呼ばれるのは Cmd+Q のようなキャンセル可能な終了のときだけです。onDetach も併せて登録しました。ただ、API ドキュメント上 detached はモバイル中心のライフサイクル状態です。macOS の終了時に呼ばれる保証はありません。あくまで「呼ばれたら儲けもの」の保険で、本命は onExitRequested です。

kill などの強制終了なら、プロセスごと即座に落ちます。「エンジンの分解と Dart の実行が並走する」隙がそもそも生まれません。塞ぐべきは行儀のよい終了経路だけでした。

この 2 段構えを入れてから、Cmd+Q 後のクラッシュレポートは一切出なくなりました。

学び

PC=0 の SIGSEGV は「解放済みコードの呼び出し」をまず疑う。 Dart 側に痕跡が残らないネイティブクラッシュは、症状が出た側に原因が無いことが多いです。FFI 呼び出しではなく、コードの寿命を管理している側、つまりエンジンの終了シーケンスを見ます。

Timer.periodic を書いたら、対になる「完了を待てる stop」を最初から設計する。 cancel() は次の発火を止めるだけで、進行中の処理は止まりません。非同期の定期処理は「起動」「新規ブロック」「進行中の完了待ち」の 3 つが揃って、初めて安全に止まります。

デスクトップ Flutter は終了ライフサイクルを自分で面倒を見る。 モバイルは OS がプロセスごと面倒を見てくれていただけでした。デスクトップでは AppLifecycleListener.onExitRequested で「片付けが終わるまで終了を保留する」のがアプリ側の責務になります。

安全のための待機には必ず上限を付ける。 クラッシュを防ぐための同期待ちで終了不能を作ってしまったら、ユーザーにとってはクラッシュより悪い体験です。

僕しか使わないツールなので、とりあえず放っておこうかとも思いました。でもレポートが増えていくのは、やっぱり気分が悪い。直してよかったです。