zudo-tauri-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

プロセスライフサイクル

所有権を守るポート選択、世代管理付き起動、プロセスグループと Tauri v2 の終了処理

ポートのリスナーが自分のアプリのプロセスとは限らない。この起動で生成した子プロセスとグループを記録し、再起動・準備確認・終了を単一のコーディネーターで管理する。

ポート所有者を終了せずにポートを選ぶ

lsof で見つけた PID を、希望ポートの使用者という理由だけで終了してはいけない。strict モードでは使用中のポートを報告する。フォールバックを明示的に許可した場合だけ、カーネルに空いているループバックポートを選ばせる。

use std::{io, net::{Ipv4Addr, TcpListener}};

fn choose_port(preferred: u16, strict: bool) -> io::Result<u16> {
    let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, preferred)) {
        Ok(listener) => listener,
        Err(error) if error.kind() == io::ErrorKind::AddrInUse && !strict => {
            TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?
        }
        Err(error) => return Err(error),
    };
    Ok(listener.local_addr()?.port())
}

これは予約ではなく事前確認であり、戻り時にリスナーを閉じる。その後 sidecar が bind するまでに別プロセスが取得する可能性がある。実際のポートを子プロセスと準備確認に渡し、子がそのポートだけに bind するようにする。bind 競合と特定できた場合だけ回数を制限して再試行する。任意の起動・設定エラーを再試行したり、strict モードのポートを黙って変更したりしない。リスナーを継承できるサーバーならこの競合を避けられる。

子プロセスとウォッチャーを所有する

起動・再試行・終了を単一のコーディネーターで直列化する。起動ごとに世代を進める。ワーカーは世代を保持し、状態ロックの外でリソースを準備して結果を返す。子プロセス、ウォッチャー、実際の URL を登録する直前に、世代と終了フラグを同時に確認する。ナビゲーションの許可も同じ直列化された判断で行う。atomic の読み取り後に別途 navigate() するだけでは競合が残る。

登録できなければ候補のプロセスグループとウォッチャーを即座に破棄する。終了時は先にコーディネーターを終端状態にし、Option<OwnedResources> を take する。それ以降ワーカーを登録させない。take により終了要求を冪等にできる。 プロセスの終了を許可する前に、起動中のすべてのワーカーをキャンセルして join/drain し、spawn 済みで未登録の候補も回収する。登録済みスロットだけの所有ではこの空白を保護できない。sidecar だけでなくウォッチャーと準備確認も停止する。beforeDevCommand が生成したプロセスはこのスロットの所有物ではなく、ランタイム側で終了してはいけない。

クリーンシャットダウンのためのプロセスグループ

Unix では専用グループを作り、spawn 直後に PGID を記録する。

#[cfg(unix)]
fn spawn_owned(command: &mut std::process::Command) -> std::io::Result<std::process::Child> {
    use std::os::unix::process::CommandExt;
    command.process_group(0).spawn()
}

process_group(0) の初期 PGID は子の PID になる。子孫は通常継承するが、新しいセッションやグループを作れば対象外になる。Windows では Job Object など別のプロセスツリー管理が必要で、Child::kill() だけでは同等にならない。

すべての終了経路と補助ウィンドウ

Settings などの補助ウィンドウを閉じてもサーバーを停止しない。製品の仕様が close-to-hide なら、その CloseRequested を抑止して非表示にする。閉じるとアプリを終了するラッパーでは、メインウィンドウの Destroyed だけを終了要求に結び付ける。Dock に残す製品は別の方針が必要になる。

メインウィンドウの破棄、受理した RunEvent::ExitRequested、RunEvent::Exit を同じ冪等なクリーンアップ所有者へ渡す。ExitRequested ではバックグラウンド処理が終わるまで終了を抑止し、完了フラグ付きで再度終了を要求する。2回目は許可する。UI スレッド上でポーリングの sleep をしない。Exit にも時間制限付きの最終処理を用意するが、キャンセルした終了要求で実行中のアプリを壊さない。

macOS の ⌘Q、Dock Quit、Apple event はネイティブ環境で検証する。必ず先にウィンドウ破棄イベントが届くとは仮定しない。Unix の libc::atexit は通常のネイティブ終了への補助になるが、SIGKILL、_exit、すべてのクラッシュには対応しない。PID 探索、ロック、メモリ確保、Tauri API を避け、事前に所有した PGID だけを扱う。atomic の終端値だけでは不十分で、spawn・登録・終了を協調させる。終了後に登録を拒否されたワーカーは、新しいグループを直ちに停止する。グループ消滅後は保存した PGID を消し、PID 再利用で他プロセスを対象にしない。

sidecar の kill シーケンス

リーダーを回収しても子孫の終了は保証されない。所有グループへ SIGTERM を送り、try_wait() で直接の子を回収しながら、期限までグループの存在を確認する。必要なら child.kill() だけでなくグループに SIGKILL を送る。ESRCH 以外のエラーはログに残す。

#[cfg(unix)]
fn group_exists(pgid: i32) -> std::io::Result<bool> {
    if pgid <= 0 {
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid PGID"));
    }
    if unsafe { libc::kill(-pgid, 0) } == 0 { return Ok(true); }
    let error = std::io::Error::last_os_error();
    match error.raw_os_error() {
        Some(libc::ESRCH) => Ok(false),
        Some(libc::EPERM) => Ok(true),
        _ => Err(error),
    }
}

#[cfg(unix)]
fn signal_group(pgid: i32, signal: i32) -> std::io::Result<()> {
    if pgid <= 0 {
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid PGID"));
    }
    if unsafe { libc::kill(-pgid, signal) } == 0 { return Ok(()); }
    let error = std::io::Error::last_os_error();
    if error.raw_os_error() == Some(libc::ESRCH) { Ok(()) } else { Err(error) }
}

// Only accept a PGID saved immediately after this app's process_group(0) spawn.
#[cfg(unix)]
fn stop_owned_group(child: &mut std::process::Child, pgid: i32) -> std::io::Result<()> {
    use std::time::{Duration, Instant};
    for (signal, grace) in [(libc::SIGTERM, 500), (libc::SIGKILL, 500)] {
        signal_group(pgid, signal)?;
        let deadline = Instant::now() + Duration::from_millis(grace);
        loop {
            child.try_wait()?; // Reap our direct child; this is not group completion.
            if !group_exists(pgid)? { return Ok(()); }
            if Instant::now() >= deadline { break; }
            std::thread::sleep(Duration::from_millis(20));
        }
    }
    Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "process group still present"))
}

この時間制限付きヘルパーはワーカーで実行する。プロセスの探索・引き取り、グループから離脱した子孫の停止、応答しない子の無期限の回収は行わない。失敗した終了状態を診断・回収用に保持し、タイムアウトを成功扱いしない。signal 0 の存在確認は権限エラーも存在として扱い、子孫がゾンビ状態でも続くことがある。

完全なライフサイクルシーケンス

sequenceDiagram participant UI participant Coordinator participant Worker participant Sidecar UI->>Coordinator: Launch / Retry Coordinator->>Coordinator: Advance generation; take old resources Coordinator->>Worker: Stop old group; prepare new resources Worker->>Sidecar: Spawn owned process group on loopback Worker->>Worker: Probe route, marker, module entries Worker->>Coordinator: Publish generation + owned resources alt Current generation and not shutting down Coordinator->>UI: Navigate to effective URL else Superseded or shutdown Coordinator->>Worker: Dispose unpublished resources end

準備完了ループでの sidecar の死亡検出

ワーカーは各 tick でキャンセル・世代と Child::try_wait() を確認し、ネットワーク I/O 中は mutex を保持しない。所有する子がない場合や終了中は起動をキャンセルし、子が終了していれば終了コードを返す。起動結果と HTTP 確認結果を分ける。

pub enum ReadyResult {
    Ready,
    Timeout,
    Superseded,
    SidecarExited { code: Option<i32> },
}

pub enum ProbeState {
    HttpUnavailable,
    StaleContent,
    Ready,
}

実際の遷移先を取得し、HTTP 200、今回の起動マーカー、ページに必要な同一オリジンの module エントリーを確認する。古いマーカーは準備完了ではない。レシピの準備確認契約を参照。50 ms 間隔は出発点にすぎず、各リクエストと起動全体に期限を設ける。最後に「安定待ち」の sleep を足さない。

Ready でも世代確認付きの登録を経てから遷移する。Timeout と SidecarExited はログパスとともにローディング画面のエラー表示へ渡す。Superseded は遷移せず、新しい起動にエラーを上書きしない。

ロギング

アプリが書き込めるログディレクトリで、ライフサイクル判断と sidecar の stdout/stderr を分ける。log_file.try_clone()? で2つ目のハンドルを作り、それぞれ Stdio::from へ渡す。sidecar ログは起動ごとにローテーションまたは切り詰め、ライフサイクルログの保存量も制限する。世代、実際のポート、所有 PGID、終了コード、シグナルエラー、最後の準備確認分類を記録する。

検証と出典

別プロセスが使うポート、strict と fallback、連続 Retry、spawn 中の終了、補助ウィンドウの close、リーダーより長く生存する子孫、再起動を検証する。macOS では ⌘Q、Dock Quit、Apple event も試す。ドキュメントのビルドや Linux テストではこれらを検証できない。

Revision History

作成更新