本文へ移動
Hikari 仕様

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:envargs() (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§2Output
  • Err(e)eProcError (§2.1) の 3 つの tag のいずれか:
    • 非 0 終了: Exited。exit code と捕捉した出力を運ぶ
    • 起動失敗: コマンドが見つからない・権限不足等。SpawnFailed
    • シグナル終了: シグナルで死んだ (kill 等)。Signaled。出力を運ぶ

キャンセルは固有の tag を持たない。 .cancel! / root 終了の一括キャンセルは子へシグナルを送って止めるので、呼び手が受け取るのはその終了状態、すなわち Signaled である (§4)。

失敗の型は Error ではなく ProcError である。 出力を持つのは実際に走った失敗だけなので、起動しなかったプロセスに終了コードを持たせないためである (§2.1)。

この設計により、Resultand_then / or_elsematchそのまま「成功時だけ次へ・失敗時に分岐」のタスク制御になる:

# 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 が要る場合も Err payload の 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.1Exited / Signaled が運ぶ出力も同様。

バイナリ出力が要る場合は将来 run_bytes を別 slot で足す (本書は出力を UTF-8 テキストに限る。std:fsread / 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" は親の標準入出力へ直結

cmdargv の両方指定・両方欠落、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 と同じ向き。求めているのは
    「無くなっていること」であって、既に無いのは失敗ではない)
  • 同じ名前を envenv_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 の stdoutstderr は空文字列。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 / 異常終了で孫プロセスを残さない」保証になる
  • キャンセルは子へシグナルを送って止めるので、停止した FutureErr(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:processimport は外部リソース (プロセス起動) に触れる能力宣言である (index.md の capability 可視化)。未知 slot の参照は実行前の静的検査で弾かれる (../static-analysis.md §1)。

7. 将来枠

  • stream(cmd, handlers) — stdout/stderr を行ごとにコールバックへ流す形 (捕捉と逐次表示の両立用)。背景フローから Hikari のコールバックを安全に駆動する設計 (単一論理フロー・データ競合非発生モデルとの整合) が要るため、初版では見送る。単に端末へ流すだけなら stdio := "inherit" (§3.4) で足りる。
  • run_bytes — バイナリ出力を Bytes で運ぶ形 (std:fsread / read_bytes と同じ二分法)。本版は出力を UTF-8 テキストに限る。
  • env_clear — 継承をやめて env だけを渡す、環境の全置換。落としたい名前が分かっているうちは env_remove (§3.3.1) で足りるので、全置換が要る用途が出てから足す。
  • stdio の per-stream 化 — stdout/stderr を個別に指定する形。必要になれば stdio の値を String からレコード ({ out := "inherit", err := "capture" |} 等) へ広げる互換拡張で足す。破棄 ("null") も同様。