コンテンツへスキップ

デスクトップアプリの「起動時に古いプロセスを検知して終了する」仕組み

デスクトップアプリを終了ボタンで閉じたはずなのに、タスクマネージャーやアクティビティモニタを見るとプロセスが残っている——という状態に遭遇したことがある人は多いはずだ。この記事では、起動時に「前回終わり損ねたプロセス」を検知し、安全に後始末してから新しく起動する、という仕組みの設計を整理する。

なぜプロセスが終了し損ねることがあるのか

Python で作られたデスクトップアプリ(Flask をバックエンドに、ブラウザを画面として使う構成)を PyInstaller で1本の実行ファイルに固めた場合、終了処理には os._exit(0) のような強制終了系の呼び出しがよく使われる。ところが、この呼び出しをバックグラウンドのデーモンスレッドから行うと、PyInstaller が生成した frozen exe の実行環境によってはプロセスが実際には終了しないことがある。ユーザーから見れば「終了ボタンを押した」のに、裏ではプロセスが生き続けている状態になる。

補足: デーモンスレッドとは、メインスレッドが終了すると強制的に道連れで終了するスレッドのこと。バックグラウンドで動かす処理に使われることが多いが、そのスレッドの中から os._exit() を呼んでもプロセス全体の終了が保証されるとは限らない。

こうして残ったプロセスは、次に同じアプリを起動しようとしたときに問題を起こす。ポート番号が既に使用中で新しいインスタンスが起動できない、あるいは古いインスタンスと新しいインスタンスが同時に動いて競合する、といった形で表面化する。

「生きているPID」と「応答するポート」を分けて考える

このアプリでは、起動中のインスタンスの情報をポートファイル(app_running.port)に ポート番号\nPID の形式で書き出している。次回起動時、このファイルを読み、記録された PID が本当にまだ動いているかを確認する。

def _is_pid_alive(pid: int) -> bool:
    """PIDが生きているか確認する(macOS/Windows 両対応)"""
    if sys.platform == 'win32':
        try:
            import ctypes
            kernel32 = ctypes.windll.kernel32
            SYNCHRONIZE = 0x00100000
            handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid)
            if handle:
                kernel32.CloseHandle(handle)
                return True
            return False
        except Exception:
            return False
    else:
        try:
            os.kill(pid, 0)
            return True
        except (OSError, ProcessLookupError):
            return False

ここで注目したいのは、Unix系とWindowsでプロセスの生死確認のやり方がまったく違う点だ。Unix系では os.kill(pid, 0) という「シグナル番号 0 を送る」呼び出しを使う。シグナル番号 0 は実際には何も送らず、権限チェックとプロセス存在確認だけを行うという、POSIX の仕様上の特別扱いになっている。存在しないPIDに対しては ProcessLookupErrorOSError のサブクラス)が返る。一方Windowsにはこの意味でのシグナル0は存在しないため、OpenProcess() でプロセスハンドルの取得を試み、取得できるかどうかで生死を判定する。

しかし「PIDが生きている」ことと「そのプロセスが正常に動作している」ことは同じではない。ここが今回の設計の核心になる。プロセスは生きていても、何らかの理由でメインループが固まっていたり、ソケットのリスニングが止まっていたりすれば、外部から見れば「応答しないゾンビ」でしかない。そこで、PIDの生死確認に加えて、記録されたポートに実際に接続できるかどうかも確認する。

def _kill_stale_process():
    port, pid = _read_port_file()
    if pid is None:
        return
    if not _is_pid_alive(pid):
        # プロセスは既に終了しているが、ポートファイルが残っている → クリーンアップのみ
        _cleanup_instance_files()
        return
    # プロセスは生きているが、ポートが応答しない場合はゾンビ → 強制終了
    is_listening = False
    if port is not None:
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.settimeout(1.0)
                is_listening = s.connect_ex(('127.0.0.1', port)) == 0
        except Exception:
            pass
    if not is_listening:
        _force_kill_pid(pid)
        _cleanup_instance_files()
    # is_listening=True(正常動作中)の場合はポートファイルを削除しない

この関数は、起動時のポートファイルの状態を3パターンに分けて扱っている。

  1. PIDが既に死んでいる: プロセスは正常に(あるいは異常に)終了済み。ポートファイルという残骸だけを片付ければよい
  2. PIDは生きているがポートが応答しない: プロセスは残っているのに仕事をしていない、いわゆるゾンビ状態。強制終了の対象
  3. PIDが生きていてポートも応答する: 正常に動作中の別インスタンス。この場合は何もせず、ポートファイルもそのまま残す

3番目のケースを誤って「古いプロセス」と判定して強制終了してしまうと、正常に動いているインスタンスをユーザーの意図に反して落とすことになる。PIDの生死だけで判定を打ち切らず、実際にサービスとして応答しているかまで確認するのは、この誤爆を避けるための二段構えのチェックだと言える。

強制終了もOSごとに手段が違う

生死確認と同様に、強制終了の手段もOSによって異なる。

def _force_kill_pid(pid: int):
    """PIDを強制終了する(macOS/Windows 両対応)"""
    if sys.platform == 'win32':
        try:
            subprocess.run(
                ['taskkill', '/F', '/PID', str(pid)],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
                creationflags=subprocess.CREATE_NO_WINDOW
            )
        except Exception:
            pass
    else:
        try:
            os.kill(pid, signal.SIGKILL)
        except Exception:
            pass

Unix系では SIGKILL をプロセスに送る。このシグナルはプロセス側でキャッチ・無視することができない、OSカーネルレベルでの強制終了であり、ゾンビプロセスのように通常のシグナルハンドラが機能していない可能性があるプロセスを確実に終わらせるにはこれが必要になる。Windowsには同等の概念としてのシグナルがないため、外部コマンド taskkill /F を呼び出す形になる。

macOSだけ事情が違う「再アクティベート問題」

ここまでは「前回終わり損ねたプロセスの後始末」の話だったが、macOSにはもう一つ別の事情が絡む。macOSのDockやFinderは、既に起動済みのアプリのアイコンをクリックしても新しいプロセスは起動せず、既存のプロセスを「アクティベート(前面に呼び出す)」するだけ、という挙動をする。これはこのアプリに限らずmacOSアプリ全般の仕様だが、Flask+ブラウザという構成のアプリにとっては単純には都合がよくない。

このアプリでは、frozen(配布用ビルド)かつmacOSで起動した際、既存インスタンスが動いていることを検知すると、まずグレースフルシャットダウン(実行中のメンテナンス処理があれば完了を待つ)を要求し、SIGTERM を送って最大15秒待ち、それでも終了しなければ SIGKILL で強制終了してから、新しいプロセスとして起動し直す、という手順を踏む。単純に既存プロセスをアクティベートするだけでは、ユーザーが「新しいバージョンとして起動し直したい」という意図を反映できないための対処だ。

ブラウザを閉じても残るプロセスへの別解

古いプロセスの検知・強制終了とは別に、このアプリには「ブラウザタブを閉じたのにサーバープロセスだけ生き残る」という、また違う種類の問題への対策もある。ブラウザは30秒ごとに /api/heartbeat へリクエストを送り、サーバー側は最後のハートビートから一定時間(60秒)応答がなければ、メンテナンス実行中でない限り自分自身にSIGKILLを送って終了する。

この仕組みは今回の「起動時の古いプロセス検知」とは目的も発火タイミングも異なる。ハートビート監視は「動作中のプロセスが、利用者に見捨てられたことをどう自覚するか」という問題であり、_kill_stale_process は「新しいプロセスが起動しようとしたときに、残骸となった古いプロセスをどう片付けるか」という問題である。似たような「プロセスの後始末」に見えて、検知の主体(サーバー自身か、次に起動するプロセスか)とタイミング(常時監視か、起動時の一度きりか)が異なる、別々の設計判断であることに注意が必要になる。

まとめ

論点 ポイント
PIDの生死確認 Unix系は os.kill(pid, 0)、Windowsは OpenProcess() とOSごとに手段が異なる
「生きている」と「動いている」の違い PIDの生死だけでなく実際にポートが応答するかまで見て、正常インスタンスの誤爆を防ぐ
強制終了の手段 Unix系は SIGKILL、Windowsは taskkill /F
macOSの再アクティベート Dock/Finderが新規プロセスを起動しない仕様への対処として、旧プロセスを明示的に終了してから起動し直す
ハートビート監視との違い 「起動時の後始末」と「動作中の見捨てられ検知」は目的もタイミングも別物

「プロセスを終了する」という一見単純な操作の裏には、OSごとの生死確認手段の違い、正常動作中のプロセスを誤って殺さないための多段階チェック、プラットフォーム固有の挙動への対処など、いくつもの判断が積み重なっている。