コンテンツへスキップ

macOS でデスクトップアプリが再起動できない罠 ── LaunchServices と Flask ローカルサーバー型アプリの注意点

WP Maintenance Manager は ローカル Flask サーバー + ブラウザ UI という構造のデスクトップアプリです。アイコンをダブルクリックするとサーバーが立ち上がり、自動的にブラウザのタブが開いて、そこから複数の WordPress サイトを管理する形になっています。

この構造は WP 保守の現場と相性が良く、複雑な設定 UI を Web 技術で組めて、実装も配布も軽量にできるという利点があります。一方で、配布開始後に macOS 環境で 「タブを閉じてからアプリが再起動できない」 という再現性のある不具合がユーザーから報告され、原因を追ってみると コードの問題ではなく macOS 自体の仕様 に行き着きました。

本記事はその経緯と、最終的に採用した「ハートビート方式」、ついでに修正中に発覚した別系統の不具合をまとめた運用ノートです。Flask + ブラウザ UI 型のデスクトップアプリを macOS 向けに作る方の参考にもなるはずです。


1. 前提:このアプリの起動構造

WP Maintenance Manager は次のような流れで動きます。

1. ユーザーがアプリのアイコンをダブルクリック 2. 内部で Flask サーバーが起動(ローカルの空きポートを取って bind) 3. デフォルトブラウザを起動して http://127.0.0.1:<port>/ を開く 4. ブラウザのタブが UI として機能し、ユーザーはそこからサイト管理・更新実行・レポート表示を行う

この構造の利点は、UI を HTML/CSS/JS で書けること、Web 技術スタックの資産(テンプレート・CSS・JS ライブラリ)を再利用できること、そして将来的に LAN 内別マシンからのアクセスも視野に入れやすいことです。

ただし、この構造には次のセクションで詳しく見る通り、「アプリが起動している」という状態の定義が直感に反する という落とし穴があります。


2. 不具合:タブを閉じるとアプリが再起動できなくなる

ユーザー報告と再現手順はこうでした。

1. メンテナンスを実行する 2. 終わったのでブラウザのタブを閉じる 3. 後でまた使おうと思ってアプリのアイコンをダブルクリック 4. 何も起きない ── ブラウザは開かない・サーバーも応答しない 5. Mac を再起動すれば直る、しかし再びタブを閉じると同じ状態に

再現性 100%。コードの起動シーケンスを何度見直しても、特定のエラーは見当たりません。クリックイベントが届いていないかのようにも見えるのですが、Console.app には特に異常ログも出ていない。

直感的には「クリック → アプリが起動」だが、実際には何かが「クリックしても起動しない」状態を作っていることになります。


3. 原因:macOS LaunchServices の仕様

調査の末にたどり着いた原因は、macOS の LaunchServices フレームワークの仕様 でした。

Windows と macOS のアプリ起動はモデルが違う

| OS | 起動の挙動 | |—|—| | Windows | .exe をダブルクリック → 毎回新しいプロセスが立ち上がる | | macOS | .app をダブルクリック → 同名のプロセスが既に動いていれば、新規起動せず既存プロセスをアクティベート(前面化)するだけ |

macOS のこの挙動は、Finder からアプリを起動するとき LaunchServices が「既存インスタンスを再利用する」というポリシーを取っているためで、これは標準的な macOS アプリでは想定通りの動作です。例えば Safari や Mail を二重に開かないのも、この仕組みのおかげです。

この仕様と Flask サーバー型アプリの相性

ところが Flask + ブラウザ UI 型アプリの場合、「ブラウザのタブを閉じる ≠ アプリのプロセスを終了する」 という乖離が起きます。

ユーザーの感覚:

  • タブを閉じた → アプリは終わった

実際の状態:

  • タブを閉じた → ブラウザの 1 タブが消えただけ
  • 裏では Flask サーバープロセスがずっと生きている
  • アイコンをダブルクリックしても、LaunchServices は「もう動いてるね」と判断して新規起動しない
  • アクティベートしようとするが、可視ウィンドウは無いので 見た目には何も起きない

つまり、ユーザーには「アプリが壊れた」ようにしか見えません。これはコードのバグではなく、OS の仕様 × アプリ構造のミスマッチ です。


4. 解決策:ハートビート方式

この不整合を埋めるために採用したのがハートビート(heartbeat)方式です。心拍モニターを思い浮かべてください。

仕組み

  • ブラウザのタブが開いている間、JS が 30 秒ごとに /api/heartbeat のような軽量エンドポイントへ ping を投げる
  • サーバー側はその ping を受けたタイムスタンプを記録する
  • バックグラウンドで「最後の ping から 3 分以上経過していたら、自分(サーバー)を終了する」という監視ループを走らせる

これにより次の流れが成立します。

ユーザー操作:    アプリ起動 → 操作 → タブを閉じる → ... 何もしない時間 ... → 再起動
内部状態:        サーバー起動 → ping を受け続ける → ping 途絶 → 監視ループが
                自己終了 → プロセス消滅 → 次回ダブルクリックで新規起動可

実装上の注意

  • ハートビートのインターバルは 30 秒に 1 回、タイムアウト判定は 3 分(= 6 ping 分) 程度に取った
  • 短すぎるとブラウザのスリープ・モバイル接続切断で誤判定が増える
  • 長すぎると「再起動できない時間」が伸びてユーザー体験が悪化する
  • メンテナンス実行中は別フラグで保護(後述)

メンテナンス実行中の保護

WP Maintenance Manager は 長時間のメンテナンス処理中 がよくあります(数十サイトを順に更新するため)。この間にブラウザのタブを誤って閉じても、サーバーは仕事を続ける必要があります。そこで:

  • 実行中タスクが存在するかどうかを別途フラグで持つ
  • フラグが立っている間はハートビート途絶でも自己終了しない
  • タスク完了後にハートビートチェックを再開

これで「ブラウザを閉じてもメンテナンスは安全に走り切る」「メンテナンス完了後はタブが無ければ自然終了」が両立できる形になりました。


5. 修正中に発覚した別系統の不具合

ハートビート方式を入れて「これで解決」と思った後でも、ごく稀に再起動できないケースが残りました。追ってみるとまったく別の場所にバグが隠れていました。

起動時のロックファイル管理に問題

このアプリはプロセス管理用に小さなロックファイル(プロセス ID とポート番号を書いた JSON)を使っています。これは「いま動いているインスタンスを把握して、二重起動を防ぐ」ための仕組みです。

そこに、起動時のクリーンアップ処理が、稼働中のプロセスのロックファイルまで消してしまう バグがありました。本来は「過去に異常終了して残った stale なロックだけ消す」べきところを、ファイルの mtime や PID の生存確認をせずに一律削除していたためです。

結果として:

  • アプリ A が起動する → ロックファイルを作る
  • アプリ B が起動を試みる → 起動時クリーンアップで A のロックを消してしまう
  • A は「自分のロックが消えた」状態で動き続ける
  • 二重起動チェックが機能しなくなり、状態がさらに混乱する

修正

修正は次の 2 点です。

1. ロックファイルを消す前に、そこに書かれた PID が現在も生きているプロセスかどうかos.kill(pid, 0) で確認 2. 自分自身のロックファイルは絶対に消さない(自分の PID と一致するロックは保護) 3. 上記の判定を通った「明らかに stale なロック」だけを削除対象にする

これで二重起動チェックが正しく機能するようになり、ハートビート方式と組み合わせて、「タブを閉じてから 60 秒程度で再起動可能」という最終仕様に落ち着きました。

補足: 60 秒という時間は、ハートビートの間隔(30 秒)の 2 サイクル分 に相当します。最低 1 サイクルは様子を見る、確実を期して 2 サイクルでカット、という設計です。


6. 学んだこと

このバグの厄介な点は、コードの問題ではなく OS の仕様に起因していた ことでした。

  • アプリ側のコードを何度見直しても異常はない
  • ログにもエラーは出ない
  • でもユーザー側では再現性 100% で不具合が起きる

「コードは無罪、犯人は OS の仕様」というケースは、デスクトップアプリ開発では不定期に踏みます。クロスプラットフォームで配布する以上、Windows と macOS でプロセスのライフサイクル管理が違う ことは設計初期から織り込んで、ライフサイクルに関わるテストは OS ごとに別物として組む必要があります。

特に Flask(または同等の HTTP ローカルサーバー) + ブラウザ UI という構造を採るなら、以下の 3 点はリリース前に必ず潰しておくべきです。

1. タブを閉じても、アプリのプロセスが残らないこと(macOS LaunchServices 対策) 2. 長時間タスク実行中はサーバーが終了しないこと(中断保護) 3. ロックファイル管理が、自分自身のロックを消さないこと(自爆防止)


まとめ

  • macOS は LaunchServices の仕様で、同名プロセスが動いている間は新規起動せず既存をアクティベートする
  • Flask + ブラウザ UI 型アプリでは、「タブを閉じる ≠ プロセスを終わらせる」となり、アクティベート対象がない状態が発生する
  • ハートビート方式で「ブラウザ生存」を判定し、サーバー側で自己終了することで再起動可能にする
  • メンテナンス実行中は別フラグで保護し、ハートビート途絶でも仕事を継続させる
  • ロックファイル管理の自爆バグを併せて潰す

WP Maintenance Manager は WordPress 保守の現場ツールとして、こうした OS 側の癖まで吸収して安定運用できるよう作り込んでいます。詳細は 公式サイトご利用ガイド からどうぞ。