std:process — サブプロセス実行
標準ライブラリモジュール (index.md)。本書中の裸の §N は本書の節を指す。言語の意味論は ../language-spec.md を参照。
ローカル並行タスクランナーのための心臓部。std:fs/std:http/clientと同じく ブロッキング I/O をFuture(Result)で表す 規約 (../prelude.md §9.4) に乗り、fork/all/wait_any/.timeout/.cancel!といった既存の非同期機構をそのまま再利用する。
ホストが負うのはこの能力だけ。 タスクランナー自体は処理系のサブコマンドではなく 純 Hikari プログラム として書ける — タスクの探索・dispatch・parallel/retry等のコンビネーターは Hikari の領域であり、外部入力 (どのタスクを実行するか) はstd:envのargs()(env.md §3) で、タスク一覧の列挙はnames(../prelude.md §6.1) で書ける。hikari Taskfile.hika <task>(または shebang で./Taskfile.hika <task>) で素の処理系から動く。サブプロセス起動という Hikari では書けない能力に限ってホストが足す のが本モジュールの役割で、これは「ユーザー定義と組込の境界を消す」「予約語/組込を極小化する」という言語哲学 (../philosophy.md §4.2) と整合する。
import p := "std:process" は run / exec / run_opts の 3 slot と、失敗の型 ProcError (§2.1) を型メンバーとして持つ namespace object を p に束縛する。名前 destructure (../language-spec.md §6.3) で個別に取り出すか、namespace のまま slot アクセスする:
# namespace のまま使う import p := "std:process" out := p.run("go test ./...")! # 個別に引く import { run, exec } := "std:process" out := run("go build ./...")!
| 名前 | 形 | 効果 | 意味 |
|---|---|---|---|
run |
run(cmd) |
Proc |
cmd (String) を shell 経由 (sh -c <cmd>) で実行し、完了まで待って Future (Result(Output, ProcError)) を返す |
exec |
exec(argv) |
Proc |
argv (List(String)) を shell を介さず 直接起動し、Future (Result(Output, ProcError)) を返す (引数注入を避ける安全形) |
run_opts |
run_opts(opts) |
Proc |
opts ({cmd or argv, cwd, env, env_remove, stdin, stdio} 等) で作業ディレクトリ・環境変数等を指定して実行、Future (Result(Output, ProcError)) を返す |
全関数は Future を返し、! で resolve 値 (Result) を待つ。これにより ../prelude.md §9.4 のブロッキング I/O 規則に乗り、fork で並行起動・! で合流・未完了 Future への ! が scheduler の中断点になる (../language-spec.md §10.1)。
1. 成否のモデル — exit code を Result に写す
タスクランナー用途では「コマンドが走ったか」より「コマンドが成功したか」で分岐したい。そこで本モジュールは exit code を Result にまとめる:
Ok(out)— プロセスが起動し、exit code 0 で終了した。outは §2 のOutputErr(e)—eはProcError(§2.1) の 3 つの tag のいずれか:- 非 0 終了:
Exited。exit code と捕捉した出力を運ぶ - 起動失敗: コマンドが見つからない・権限不足等。
SpawnFailed - シグナル終了: シグナルで死んだ (kill 等)。
Signaled。出力を運ぶ
キャンセルは固有の tag を持たない。 .cancel! / root 終了の一括キャンセルは子へシグナルを送って止めるので、呼び手が受け取るのはその終了状態、すなわち Signaled である (§4)。
失敗の型は Error ではなく ProcError である。 出力を持つのは実際に走った失敗だけなので、起動しなかったプロセスに終了コードを持たせないためである (§2.1)。
この設計により、Result の and_then / or_else や match がそのまま「成功時だけ次へ・失敗時に分岐」のタスク制御になる:
# test 緑のときだけ build (subject に block を含むため一旦束縛してから match する) result := p.run("go test ./...").and_then { _ | fork { p.run("go build ./...") }! } match result! { Ok(out) => print("all green") Err(e) => print(e.message) }
設計上の対比:std:fs/std:http/clientは「I/O が完遂したか」をResultの境界にする (HTTP は 404 でもOk(response))。std:processは用途特化として「exit 0 か」を境界にする。生の exit code が要る場合もErrpayload のout.codeから取れる (§2.1)。
2. Output — 完了したプロセスの結果
成功 (Ok) が運ぶ値。
| slot | 型 | 意味 |
|---|---|---|
code |
Int | exit code (Ok では常に 0) |
stdout |
String | 標準出力全体 (UTF-8 デコード) |
stderr |
String | 標準エラー出力全体 (UTF-8 デコード) |
stdio := "inherit" (§3.4) のときは出力を捕捉しないため、stdout / stderr は空文字列になる (slot の形状自体は変えない)。§2.1 の Exited / Signaled が運ぶ出力も同様。
バイナリ出力が要る場合は将来 run_bytes を別 slot で足す (本書は出力を UTF-8 テキストに限る。std:fs の read / read_bytes と同じ二分法)。
2.1 ProcError — 失敗の型
本モジュールの失敗は ProcError で表す。型メンバーとして export されるので、import p := "std:process" のもとで p.ProcError と修飾参照するか、import { run, ProcError } := "std:process" で名前を取り出して型注釈・match に使う。tag は enum の名前空間の下に置かれるので、裸で使うには { SpawnFailed, Exited, Signaled } := ProcError で取り出す (../language-spec.md §17.4)。
enum ProcError := OneOf( SpawnFailed(Error) # 起動できなかった Exited(Error, Int, String, String) # 非 0 終了。code / stdout / stderr Signaled(Error, Int, String, String) )
出力を運ぶのは実際に走った失敗だけである。 起動しなかったプロセスには終了コードも出力も無いので、SpawnFailed からは引けない。"exit" のときだけ意味を持つ slot を全ての失敗に持たせると、型が実際より多くを述べることになる (../language-spec.md §17.2)。
どの tag も Error を運ぶ (../prelude.md §12 の {kind, message})。失敗の中身を見ないコード (ログに出す・そのまま伝播する) は tag に分岐せず Error だけを取り出せる。kind は "spawn" / "exit" / "signal" で、tag と 1 対 1 に対応する。
match (p.run "exit 3")! { Ok(out) => out.stdout Err(Exited(e, code, out, err)) => "exited ${code}: ${err}" Err(SpawnFailed(e)) => e.message Err(Signaled(e, _, _, _)) => e.message }
エラーメッセージにメタ参照 (章番号・バージョン) を埋め込まない (CLAUDE.md 規約)。
3. 各関数
3.1 run
- 引数:
cmd(String) 1 個。非 String を渡すと呼び出し位置でError(panic 型, ../language-spec.md §16) sh -c <cmd>相当で実行する (パイプ・リダイレクト・glob が使える ergonomic 形)- 戻り値:
Future(Result(Output, ProcError))。成否モデルは §1
p.run("echo hello")!.unwrap!.stdout #> "hello\n" match p.run("exit 3")! { Ok(_) => print("ok") Err(Exited(e, code, _, _)) => print("${e.kind} ${code}") #> "exit 3" Err(_) => print("failed") }
3.2 exec
- 引数:
argv(List(String)、要素は全て String) 1 個。空 List・非 String 要素は呼び出し位置でError - shell を介さず
argv.0を実行ファイルとして起動し、argv.1..を引数に渡す。shell メタ文字の解釈が無いので外部入力を引数に混ぜても安全 - 戻り値:
Future(Result(Output, ProcError))
p.exec(["git", "commit", "-m", user_message])! # user_message に空白や ; があっても安全
3.3 run_opts
- 引数:
opts(object) 1 個。slot:
| slot | 型 | 既定 | 意味 |
|---|---|---|---|
cmd |
String | — | shell 経由で実行 (run 相当)。argv と排他 |
argv |
List(String) | — | shell 非経由で実行 (exec 相当)。cmd と排他 |
cwd |
String | 現在のディレクトリ | 作業ディレクトリ |
env |
object | 親プロセスの環境を継承 | 追加/上書きする環境変数 (slot 名→値 String) |
env_remove |
List(String) | 空 | 親から継承した環境変数のうち落とす名前 (§3.3.1) |
stdin |
String | 空 | 標準入力に流す文字列 |
stdio |
String | "capture" |
標準入出力の接続先 (§3.4)。"capture" は stdout/stderr を捕捉、"inherit" は親の標準入出力へ直結 |
cmd と argv の両方指定・両方欠落、stdio への "capture" / "inherit" 以外の値は呼び出し位置で Error。
p.run_opts({
argv := ["npm", "test"],
cwd := "packages/web",
env := { CI := "true" |}
})!
3.3.1 env_remove — 継承した変数を落とす
env は足す/上書きするだけで、親から継承した変数を消せない。子の振る舞いが「その変数が
あるかどうか」で変わる場合、これでは足りない — 空文字を入れても変数はあるままである。
env_removeは名前の List で、継承した環境からその名前を落とす- 冪等 — 親が持っていない名前を並べても失敗しない (
fs.remove_allと同じ向き。求めているのは
「無くなっていること」であって、既に無いのは失敗ではない) - 同じ名前を
envとenv_removeの両方に書いたら呼び出し位置でError。立てるのか落とすのかを
決めていないということなので、どちらかを黙って選ばない env_removeの要素が String でない・List でないときも呼び出し位置でError
# 検証モードのフラグを落として、環境に左右されない数を採る p.run_opts({ argv := [bin], env := { HIKARI_RT_STATS := "1" |}, env_remove := ["HIKARI_VERIFY_GC"] })!
なぜ env の値で表さないのか。 env := { X := false |} のように「値が偽なら落とす」形も
考えられるが、env の値は String と定めてある (上表)。そこへ Bool を混ぜると、変数の値として
"false" を渡したいときと区別が付かない。名前の List を別 slot にすれば、型はどちらも素直なままで、
「落とす」ことが読んで分かる。
3.4 stdio := "inherit" — ライブ出力と対話
stdio は子プロセスの標準入出力の接続先を選ぶ。標準入力の内容を運ぶ stdin オプションとは独立の軸 (内容と接続先の分離):
"capture"(既定) — stdout/stderr をパイプで捕捉し、完了後にOutput(§2) で返す (従来動作)"inherit"— 子の stdin/stdout/stderr を親 (Hikari プロセス) のものへ直結する。出力はリアルタイムに端末へ流れ、捕捉しない (Output/ エラー payload のstdout・stderrは空文字列。exit code →Resultの畳み込み (§1) はそのまま機能する)。標準入力は、stdinオプションが与えられていればその文字列を流し、無ければ親の標準入力を継承する (対話プロンプトが機能する)
長時間ジョブの進捗表示・対話コマンド・常駐サーバー (タスクランナーの serve 系タスク) のための形。行単位のコールバックで捕捉と表示を両立する stream は将来枠 (§7) のまま。
# go test の進捗をリアルタイムに端末へ流す (exit code での成否分岐は従来どおり) match p.run_opts({ cmd := "go test ./...", stdio := "inherit" |})! { Ok(_) => print("green") Err(Exited(e, _, _, _)) => print(e.kind) Err(_) => print("failed") }
4. キャンセルとプロセスの後始末
run / exec / run_opts が返す Future は、他の fork 由来 Future と同じくキャンセル対象になる (../language-spec.md §10.3 / §13.3)。
f.cancel!(../prelude.md §9.8) を呼ぶと、走行中の子プロセスへ SIGTERM を送る。猶予 (実装定義の短時間) 内に終了しなければ SIGKILL で強制終了する- root リテラル本体の評価が完了した時点で未解決の
Futureは §13.3 により一括キャンセルされる → 取り残した子プロセスも上記の手順で停止する。これがタスクランナーの「Ctrl-C / 異常終了で孫プロセスを残さない」保証になる - キャンセルは子へシグナルを送って止めるので、停止した
FutureはErr(Signaled(…))に解決する。意図的停止でありバグではないため、§10.2 の「未 await の panic」診断の対象にしない。キャンセル固有の tag は持たない — 「自分が止めた」のか「外から殺された」のかを区別したいなら、それはFutureの側 (.cancel!の結果) が表す事柄である - 子プロセスは プロセスグループとして起動し、シグナルはグループへ送る (子がさらに孫を産んでも巻き込んで止める)
- 例外:
stdio := "inherit"(§3.4) のときは新しいプロセスグループを作らない (子は親と同じフォアグラウンドグループに残る)。端末の Ctrl-C (SIGINT) が子へ直接届く —make配下でコマンドを走らせたときと同じ挙動である。この形ではキャンセルのシグナルは子プロセス単体へ送る (グループ送信は親自身を巻き込むため)。孫プロセスまで巻き込む停止保証は"capture"のときのみ
5. 並行実行とスケジューラー
std:process 固有の並行 API は持たない。並行は既存の非同期機構 (../prelude.md §9) の再利用で書く:
# 3 コマンドを並行起動し全完了を待つ (最遅 1 本の時間で済む)。 # run はその場で起動済みの Future を返すため、fork で包まず直接 all に渡せる。 results := all([ p.run("golangci-lint run"), p.run("go vet ./..."), p.run("go test ./...") ])! # タイムアウト付き (超過したら None。子プロセスは別途 cancel で止める) slow := p.run("./long-job.sh") slow.timeout(30000)!.unwrap_or_else { slow.cancel! Err({ kind := "timeout", message := "job timed out" |}) }
子プロセスの完了待ちは中断点 (../language-spec.md §10.1 規則 a) なので、並行起動した複数プロセスは実時間で重なって走る (協調スケジューラーが各待ちで他フローへ譲る)。Hikari 側のフロー切り替えは中断点でのみ起こるため、結果集約を行う Hikari コードにデータ競合は生じない (§10.1)。なお遅延タスク ({ ... } で包んだ thunk) を並行化する場合は fork { task! } で起動する (taskrunner の parallel 参照)。
6. 静的検査
std:process の import は外部リソース (プロセス起動) に触れる能力宣言である (index.md の capability 可視化)。未知 slot の参照は実行前の静的検査で弾かれる (../static-analysis.md §1)。
7. 将来枠
stream(cmd, handlers)— stdout/stderr を行ごとにコールバックへ流す形 (捕捉と逐次表示の両立用)。背景フローから Hikari のコールバックを安全に駆動する設計 (単一論理フロー・データ競合非発生モデルとの整合) が要るため、初版では見送る。単に端末へ流すだけならstdio := "inherit"(§3.4) で足りる。run_bytes— バイナリ出力をBytesで運ぶ形 (std:fsのread/read_bytesと同じ二分法)。本版は出力を UTF-8 テキストに限る。env_clear— 継承をやめてenvだけを渡す、環境の全置換。落としたい名前が分かっているうちはenv_remove(§3.3.1) で足りるので、全置換が要る用途が出てから足す。stdioの per-stream 化 — stdout/stderr を個別に指定する形。必要になればstdioの値を String からレコード ({ out := "inherit", err := "capture" |}等) へ広げる互換拡張で足す。破棄 ("null") も同様。