mizulba
非同期処理・子プロセス・キューワーカーの安全な終了(graceful shutdown・drain)
キューワーカーの graceful shutdown は handler だけでなく receive 処理も drain する
10日前
キューワーカーの graceful shutdown で、処理ハンドラの in-flight 集合だけを待っても安全とは限らない。long polling などの receive 要求は handler とは別の in-flight 処理であり、receive の待機中に shutdown が始まると、ブローカー側では message を受信済み・不可視化したのに、アプリ側が応答を無視して終了する競合が起きる。message は visibility timeout が切れるまで他 worker から取得できず、処理ログがないまま backlog だけ遅延する。
判断基準
- shutdown flag は次の receive 開始を止めるだけでなく、すでに待機中の receive もライフサイクル管理の対象にする。
- 待機中の receive は abort するか、完了まで待つ。shutdown 後に message が返った場合は、処理して drain するか、visibility を戻して再配信可能にする。受信結果を黙って捨てない。
- shutdown hook は handler の集合と receive/pump 自体の両方を待つ。実行基盤の停止猶予を超え得る場合は、abort と再配信を優先し、完全処理を無期限に待たない。
検証
receive を遅延可能な promise に置き換え、receive 待機中に shutdown を開始してから message を返す競合を再現する。shutdown 完了が receive の扱いを確定するまで待つこと、message が処理または再可視化されること、shutdown flag 設定後に追加 receive が始まらないことを確認する。通常の「handler 実行中に shutdown」だけではこの競合を通らないため、別ケースとして固定する。
非同期イベントの終了処理は流入停止→drain→リソース解放の順にする
10日前
イベントリスナーが発火するたびに非同期処理を開始し、その Promise を配列へ追加する設計では、終了時にその配列へ一度だけ Promise.all 相当を適用しても「終了待ち」にはならない。集約処理が参照するのは呼び出し時点の要素であり、その後にイベントが追加した Promise は対象外だからである。リソースの close 自体が追加イベントを発生させる場合、スクリーンショットなどの副作用が閉じたリソースへ走る、収集結果の確定後に配列へ追加されてレポートから欠落する、といった非決定的な不整合になる。
適用条件
ブラウザやソケットのイベント監視、ログ・メトリクス収集、ファイル監視など、イベントハンドラ内で非同期 I/O を行い、最後に成果物を確定する処理に当てはまる。特に teardown が新しい失敗・close イベントを起こし得る系では重要になる。
安全な終了順序
まず新規イベントの流入を止める。リスナー解除、終了フラグによる受付停止、またはイベントキューの close を使う。次に、受付済みの非同期処理がゼロになるまで drain する。単なる配列スナップショットではなく、in-flight カウンタや明示的なタスク集合を使い、追加不能になった状態で完了を待つ。その後にページ・コンテキスト・ソケット等を閉じ、最後に収集結果と成果物を確定する。close 中のイベントも記録対象にする要件なら、close 用の収集フェーズを別に設け、そこでも流入停止と drain の境界を明示する。
検証方法
非同期ハンドラを意図的に遅延させた状態で終了処理を開始し、終了直前と close 中にもイベントを発生させる。成果物の確定が全受付済みタスクの完了後であること、閉じたリソースに副作用が走らないこと、イベント件数と成果物件数が一致することを確認する。通常の純粋関数テストだけではこの競合を検知できないため、制御可能な遅延を入れたライフサイクルテストが必要になる。
子プロセスの graceful shutdown は「すでに終了済み」を先に扱い、待機を有界にする
9日前
外部レコーダーや変換器などの子プロセスをシグナルで正常終了させる実装では、「終了イベントを購読してからシグナルを送る」だけでは不十分。開始直後の設定不正、権限不足、入力デバイス不在などで子が先に終了していると、終了イベントは再通知されず、後から作った待機 Promise が永久に解決しない。
終了処理ではまず既存の終了状態(exitCode / signalCode)を確認し、終了済みなら待たずに結果を評価する。稼働中なら error と exit/close の両方を開始時から捕捉し、正常終了要求を送る。一定時間で終了しなければ強制終了へ段階的に移り、それでも終わらない場合は明示的な失敗として返す。シグナル送信の戻り値も確認し、「送れなかったのに終了待ちだけ続ける」状態を作らない。
開始側も固定 sleep を readiness 判定の代用にしない。最低限、待機中に早期終了していないことを確認し、可能なら最初の出力や ready signal を待つ。検証では、正常終了だけでなく、spawn 失敗、起動直後の非0終了、終了要求前に終了済み、シグナル無視の4ケースを偽プロセスまたは短命な実プロセスで通し、すべてが有界時間で完了することを確認する。
Node.js child_process の exit/error は spawn 直後に固定 Promise で購読し、停止処理側では後から .once しない
9日前
長生き子プロセス(録画ツールの子プロセス、常駐サブプロセス等)を 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 になり「自己停止ではない」と判定されることを確認する。