ローカルの 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-execute | shell: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も動かず、子プロセスが残ります。
