時間のかかるプロセスを管理・強制終了する

shell プラグインの spawn() で起動したプロセスを Child で保持し、kill()(権限 shell:allow-kill)で止める。Rust の State での管理と、アプリ終了時に子プロセスを残さない後始末も示す。

外部プロセス 対象: Tauri 2.x 更新日: 読了目安: 約10分 shell-007
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 起動して Child を持っておく
  4. ページを再読み込みしてもプロセスは止まらない
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

ローカルの HTTP サーバー、動画の変換、フォルダーの同期のように、すぐには終わらない外部プロセスをアプリから起動し、必要なときに止めたい場面があります。終わるまで待つ execute() ではなく、起動した時点で制御が戻る spawn() を使い、返ってくる Child(PID と kill() を持つ)を保持しておきます。ここでは起動・終了の検知・強制終了と、再読み込みやアプリの終了でプロセスを取り残さない方法を説明します。出力を逐次画面に出す方法は コマンドの出力をリアルタイムで受け取る で扱います。

前提条件

shell プラグインを追加します。

npm run tauri add shell

shell:default に含まれるのは URL を開く open の権限だけです。spawn() には shell:allow-spawn、止めるには shell:allow-kill を追加します。name / cmd / args の書き方の基本は 外部コマンド(ls/dir等)を実行する を参照してください。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "shell:default",
    "shell:allow-kill",
    {
      "identifier": "shell:allow-spawn",
      "allow": [
        {
          "name": "py-server",
          "cmd": "python",
          "args": ["-u", "-m", "http.server", { "validator": "\\d{4,5}" }]
        }
      ]
    }
  ]
}

JS から変えられるのはポート番号だけです。validator は前後に ^ と $ を補って照合されるので、値全体が一致する必要があります。macOS / Linux では cmd を python3 にします(-u は出力をため込ませない Python のオプション)。

execute()spawn()
制御が戻るときプロセスの終了後起動した直後(PID が返る)
出力の受け取り終了後にまとめてdata イベントで 1 行ずつ
途中で止めるできないkill()
必要な権限shell:allow-executeshell:allow-spawn

終わるまで待って結果だけ欲しいなら execute() で十分です(コマンドの標準出力・エラー出力を取得する)。

1. フロントエンドから実装する (TypeScript)

起動して Child を持っておく

終了を確実に知る手段は close イベントだけなので、「動いているか」の状態はそこで更新します。kill() が解決した時点では close がまだ届いていないことがあるので、止める側では状態を消しません。また spawn() の完了を待つ間にもう一度呼ばれると 2 つ起動するので、起動中のフラグで防ぎます。

import { Command, type Child } from '@tauri-apps/plugin-shell';

const PID_KEY = 'py-server-pid';
let server: Child | null = null;
let starting = false;
let stopRequested = false;

export async function startServer(port: number): Promise<number | null> {
  if (server || starting) return server?.pid ?? null; // 二重起動しない(spawn の完了待ちの間も)
  starting = true;
  try {
    const cmd = Command.create('py-server', ['-u', '-m', 'http.server', String(port)]);
    let closed = false;
    // リスナーは spawn() の前に登録する
    cmd.on('close', ({ code, signal }) => {
      closed = true;
      console.log(stopRequested ? '停止しました' : `終了しました (code=${code}, signal=${signal})`);
      server = null;
      stopRequested = false;
      sessionStorage.removeItem(PID_KEY);
    });
    cmd.on('error', (message) => console.error('読み取りエラー:', message));
    cmd.stdout.on('data', (line) => console.log(line.trimEnd()));
    cmd.stderr.on('data', (line) => console.log(line.trimEnd())); // http.server のアクセスログは stderr に出る
    const child = await cmd.spawn();
    if (closed) return null; // 起動直後に終わった(ポートが使用中など)
    server = child;
    sessionStorage.setItem(PID_KEY, String(child.pid)); // 再読み込みへの備え(後述)
    return child.pid;
  } finally {
    starting = false;
  }
}

export async function stopServer(): Promise<void> {
  if (!server) return;
  stopRequested = true;
  await server.kill(); // shell:allow-kill が必要。close はこの後に届く
}

kill() は強制終了で、プロセス側の終了処理は動きません。ffmpeg のように標準入力で終了を受け付けるプログラムは、先に child.write('q')(権限 shell:allow-stdin-write)で伝え、数秒で close が来なければ kill() すると、出力ファイルを壊さずに済みます。

ページを再読み込みしてもプロセスは止まらない

開発中にページを再読み込みすると変数 server は消えますが、プロセスは動き続けます。止める手段が無くなり、次の起動ではポートが使用中になります。コンソールに「[TAURI] Couldn't find callback id …」で始まる警告が出続けるのは、前のページで起動したプロセスの出力が届いているためです。

kill() で止められるのは、このプラグインの spawn() で起動してまだ動いているプロセスだけで、それ以外の PID を渡しても何も起きません。そのため、控えておいた PID から Child を作り直して止めても安全です。

import { Child } from '@tauri-apps/plugin-shell';

// ページの読み込み時に 1 回呼ぶ。前のページが起動したサーバーが残っていれば止める
export async function cleanupLeftover(): Promise<void> {
  const saved = sessionStorage.getItem('py-server-pid');
  if (!saved) return;
  sessionStorage.removeItem('py-server-pid');
  await new Child(Number(saved)).kill(); // もう終わっていれば何も起きない
}

2. バックエンドから実装する (Rust)

Rust からはプラグインの app.shell().command() で起動します。capability の範囲には縛られないので、引数は型と値で絞ります(ここでは u16 のポート番号だけ)。spawn() は出力を受け取る Receiver と CommandChild を返し、kill() は CommandChild を消費するので、State に Mutex<Option<CommandChild>> として持たせます。

注意したいのは後始末です。JS の spawn() で起動したプロセスはアプリの正常終了時にプラグインが止めますが、Rust で起動したものは対象外です。run() のコールバックで RunEvent::Exit を受け取って自分で止めます(アプリ起動・終了時の処理を書く (ライフサイクル))。

use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, RunEvent, State};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;

/// 起動中のサーバー。None なら止まっている
#[derive(Default)]
struct ServerProcess(Mutex<Option<CommandChild>>);

#[tauri::command]
fn start_server(app: AppHandle, state: State<'_, ServerProcess>, port: u16) -> Result<u32, String> {
    let mut slot = state.0.lock().map_err(|e| e.to_string())?;
    if let Some(child) = slot.as_ref() {
        return Ok(child.pid()); // 起動済み
    }
    let port_arg = port.to_string();
    let (mut rx, child) = app
        .shell()
        .command("python")
        .args(["-u", "-m", "http.server", port_arg.as_str()])
        .spawn()
        .map_err(|e| e.to_string())?;
    let pid = child.pid();
    *slot = Some(child);

    let handle = app.clone();
    tauri::async_runtime::spawn(async move {
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stderr(line) => print!("{}", String::from_utf8_lossy(&line)),
                CommandEvent::Terminated(payload) => {
                    // 自然に終わったときも State を空にする(別のプロセスに替わっていたら触らない)
                    let state = handle.state::<ServerProcess>();
                    let mut slot = state.0.lock().unwrap();
                    if slot.as_ref().map(|c| c.pid()) == Some(pid) {
                        *slot = None;
                    }
                    drop(slot);
                    let _ = handle.emit("server-exited", payload.code);
                }
                _ => {}
            }
        }
    });
    Ok(pid)
}

#[tauri::command]
fn stop_server(state: State<'_, ServerProcess>) -> Result<bool, String> {
    let child = state.0.lock().map_err(|e| e.to_string())?.take();
    match child {
        Some(child) => child.kill().map(|_| true).map_err(|e| e.to_string()),
        None => Ok(false), // 起動していない
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .manage(ServerProcess::default())
        .invoke_handler(tauri::generate_handler![start_server, stop_server])
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app, event| {
            // Rust で起動したプロセスは自動では止まらないので、アプリの終了時に止める
            if let RunEvent::Exit = event {
                if let Some(child) = app.state::<ServerProcess>().0.lock().unwrap().take() {
                    let _ = child.kill();
                }
            }
        });
}

フロントエンドからは次のように呼びます。終わったこと(止めた場合も含む)は server-exited イベントで知らせています。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

await listen<number | null>('server-exited', ({ payload }) => console.log('exited, code =', payload));
const pid = await invoke<number>('start_server', { port: 8000 });
console.log('started', pid);
// 止めるとき(起動していなければ false が返る)
const stopped = await invoke<boolean>('stop_server');

動作確認

npm run tauri dev で起動し、ボタンなどから startServer(8000) を呼んでからブラウザーで http://localhost:8000/ を開きます。続けて stopServer() を呼ぶと、Windows では DevTools のコンソールに次のように出ます。

Serving HTTP on :: port 8000 (http://[::]:8000/) ...
::1 - - [12/Sep/2026 10:15:02] "GET / HTTP/1.1" 200 -
停止しました

タスクマネージャー(macOS / Linux なら ps aux | grep http.server)で python が消えたことを確かめます。動かしたままアプリを閉じた場合も同様に消えます。

よくあるエラーと対処法

  • 「shell.spawn not allowed. Permissions associated with this command: shell:allow-spawn」: 権限の追加漏れです。この文面はデバッグビルドのもので、リリースビルドでは「Command plugin:shell|spawn not allowed by ACL」だけになります。起動はできて止めるときだけ失敗するなら「shell.kill not allowed. …」で、shell:allow-kill が足りません。
  • 「program not allowed on the configured shell scope: py-server」: shell:allow-spawn の allow に名前が無い(spawn() には shell:allow-execute 側の許可は使われない)か、引数が args の定義と合っていません。どこで合わなかったかは、tauri dev を実行しているターミナルに「Scoped command argument at position 3 was found, but failed regex validation ^\d{4,5}$」のように出ます。
  • 再読み込みの後で起動に失敗する: 前のページが起動したプロセスが残っています。cleanupLeftover() を読み込み時に呼びます。
  • kill() したのに処理が止まらない: cmd /C や npm run、シェルスクリプトを間に挟むと、止まるのは間に入ったプロセスだけで、そこから起動された本体は残ります。本体を直接起動します。

OS ごとの違いと注意点

  • Windows: kill() で止めると close の code は 1、signal は null です。プロセスが自分で exit 1 した場合と区別できないので、自分で止めたかどうかは stopRequested のようなフラグで判断します。シェルプラグイン経由ならコンソールウィンドウは開きませんが、std::process::Command で起動するとリリースビルドで黒いウィンドウが一瞬開きます。
  • macOS / Linux: kill() は SIGKILL を送るので、code は null、signal は 9 になります。SIGKILL はプロセス側で捕まえられず、一時ファイルの削除などの後始末は動きません。
  • 共通: アプリ自体が強制終了やクラッシュで終わると、プラグインの後始末も RunEvent::Exit も動かず、子プロセスが残ります。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する