Node.js child_process の exit/error は spawn 直後に固定 Promise で購読し、停止処理側では後から .once しない
長生き子プロセス(録画ツールの子プロセス、常駐サブプロセス等)を Node.js の child_process.spawn で起動し、後から「停止させて終了を待つ」処理(graceful shutdown)を実装するときのハングパターンと、自己停止と自発終了(クラッシュ)を取り違えないための判定方法。
問題の型1: exit イベントの取りこぼしによるハング
proc.once("exit", callback) を「停止処理を行う関数の中で初めて登録する」実装は、その関数が呼ばれる前にプロセスが既に終了(早期クラッシュ、起動引数不正での即時終了、binary 不在など)していると、exit イベントが二度と発火しないため永久に解決しない Promise を作ってしまい、呼び出し元の finally/後向き処理全体がハングする。元のエラー(steps 失敗等)がこのハングに隠されるので、原因特定がさらに難しくなる。
対策: spawn 直後から exit/error リスナーを登録し、その結果(code/signal)を保持する固定の Promise(例: exited)を作成してハンドルと一緒に保持する。停止処理側は新規にリスナーを付けず、この共有 Promise を await するだけにする。これで「すでに発火済みなら即座に解決する」挙動が保証される。
問題の型2: 「自分で止めた」と「勝手に落ちた」の取り違え(レース)
停止処理の定石は「既に終了済みかチェック→未終了なら SIGINT を送る→タイムアウトしたら SIGKILL」だが、「既に終了済みかチェック」と「SIGINT を送る」の間のわずかな時間差でプロセスが自発的に終了することがある(録画中の権限失効・デバイス切断・ディスク枯渇等)。この場合 proc.kill() はシグナルを届けられないが、呼び出し自体は例外を投げないため、単純に「kill を呼んだ後だから自己停止」と判定すると、たまたま正常終了に見えるコードで終わった自発クラッシュを「正常に自分で止めた」と誤認識し、破損した成果物を成功として扱ってしまう。
対策: ChildProcess.prototype.kill() の戻り値を使う。Node のドキュメント通り、kill はシグナルが生存中のプロセスに実際に届いた場合は true、届かなかった場合(既にプロセスが終了していた等)は false を返す。戻り値が false なら「このシグナルは届いていない=この終了は自発的なもの」と判定でき、既知終了チェックと kill 呼び出しの間のレースウィンドウを正確に塞げる。SIGINT・SIGKILL のどちらの送信でもこの戻り値チェックを行う。
判断基準まとめ
- 起動直後から exit/error を固定 Promise に紐付け、停止処理側は新規リスナーを付けず共有 Promise を待つ。
- 停止処理内ではまず
proc.exitCode !== null || proc.signalCode !== nullで既に終了済みかをチェックし、発信不要なシグナル送信をスキップする。 - シグナル送信は
kill()の戻り値を検査し、false(届かなかった)なら「自己停止扱いにしない」。 - 先に穏やかなシグナル(SIGINT/SIGTERM)を送ってから、共有 exited Promise をタイムアウト付きで待ち、タイムアウトしたら SIGKILL に昇格させて確実に終了させる(graceful→forced の二段階)。SIGKILL への昇格そのものも kill() の戻り値チェックの対象にする(プロセスが SIGINT のタイムアウト待機中に自発終了していた場合、SIGKILL も届かない)。
- 「正常終了」の判定は、自己停止経路(kill() が true を返した経路)に限定して行う。自発終了(kill() が false、または最初のチェックで既に終了済み)は exit code の値によらず常に異常終了として扱う。強制終了(SIGKILL)による停止は、正常終了コードと同一視しない別カテゴリとして扱う(対象プロセスが出力ファイルの終端情報を書き切れていない可能性があるため)。
検証方法
早期クラッシュを意図的に起こす(存在しない引数・不正な入力を渡す等)と、修正前の実装では停止処理がハングするが、修正後は即座に戻ることを確認する。さらに意図的に SIGINT を無視する(例: signal handler を登録して無視するテスト用プロセス)ことで、タイムアウト→SIGKILL の昇格が実際に発火し終了まで届くことを確認する。レース検知は、既に終了させたプロセスに対して停止処理を呼び出し、kill() の戻り値が false になり「自己停止ではない」と判定されることを確認する。