AI コーディングエージェントを 4 つ 5 つと同じマシンで走らせていると、プロセス一覧が自分の記憶と合わなくなります。それぞれが別のリポジトリで作業し、テストランナーや開発サーバーを勝手に起動し、終わったものと終わっていないものが混ざります。コマンドを順実行してデバッグしてゆくこういう話は、はてなダイアリー全盛期に「コマン道」タグを付けて書いていたことを思い出しますが、しばらく経ってから CPU 使用率を眺めると、いつ何のために起動したのか思い出せないプロセスが上位に並びます。
そこに next-server が 93.5% で並んでいました。今の作業では Next.js を触っていません。
止めればいいだけの話に見えますが、そう単純でもありません。別のエージェントが今まさに使っている可能性があり、その途中で落とせば作業をやり直させることになります。かといって放置すれば、こちらの作業が CPU を奪い合ったまま進みます。
この記事は、そのプロセスの起動元と状態を特定するまでを、使ったコマンドの順に追います。結論を先に書くと、そのプロセスは既に応答を返せない状態でした。重かったのではありません。ログを 1 行書こうとして失敗し、その失敗を報告する処理が同じ失敗を起こし、自分自身を呼び続けたまま戻らなくなっています。TCP のポートは開いたままなので、外からは正常に見えます。
記事の後半は、その先です。同じ状態を二度と起こさないための実装と、それが機能していることを確かめるはずだったテストが、二度にわたって別のものを測っていた話をします。二度目の原因はシェルの引用符でした。対策コードを丸ごと削除しても同じ結果を出す検査になっていたので、テストとしては存在しないのと同じでした。
調査全体に共通する原則がひとつあります。
プロセスは、自分で設定した名前ではなく、親・作業ディレクトリ・ファイルディスクリプタ・ソケット・スタックで見る。
名前は書き換えられます。CPU 使用率は平均値のことがあります。ポートが開いていても応答するとは限りません。どれも単独では判断材料にならず、組み合わせて初めて判断できます。Unix の小さな道具はそのために揃っています。
実測環境は macOS 14.2.1 (Apple Silicon) です。ps・lsof・curl はどちらの環境でも同じように使えます。macOS 固有のコマンドについては、Linux で対応する道具の名前だけを挙げます。使い方まで踏み込むと調査の流れから離れるので、キーワードを渡すところまでにとどめます。
AI エージェントが残した心当たりのないプロセス
まず全体を見ます。この時点でわかっていたのは、次の内訳だけでした。
| プロセス | CPU | 起動元 |
|---|---|---|
| next-server | 93.5% | 心当たりなし |
| cmux | 54.6% | 別アプリ |
| mori-dev-loop の python | 41.7% | 別リポジトリで走らせている自動実装ループ |
| Chrome Canary | 30.5% | ブラウザー |
| 現在の作業 | 15.8% | 優先度を最低へ下げ済み |
下の 4 つは起動元が分かっています。分からないのは先頭だけです。取れる選択肢は放置する、優先度を下げる、停止するの 3 つで、どれを選ぶかは 2 つの情報で決まります。そのプロセスがどのリポジトリのどの作業で起動されたものか。そして、正常に動作していて重いのか、壊れて回り続けているだけなのか。
ひとつ断っておくと、開発サーバーが CPU を使い続けること自体に異常はありません。ファイル変更の監視、依存関係の解決、JIT コンパイルによる最適化は、どれも CPU を使う正当な処理です。使用率が高いことと壊れていることは別で、この数字だけでは区別が付きません。
観測したこと: CPU 上位に
next-server(93.5%)。現在の作業とは無関係
判断したこと: 起動元と状態の 2 つが分かるまで、止めるか放置するかを決められない
書き換えられるプロセス名
名前で探すところから始めます。pgrep の -f はコマンド名だけでなくコマンドライン全体を検索対象にし、-l は結果にコマンド名を添えます。
pgrep -fl next
見つかった PID に対して、ps で必要な列だけを引きます。-o で出力する列を選べるので、PID、親プロセス ID、CPU 使用率、累積 CPU 時間、コマンドラインを並べました。
ps -o pid,ppid,%cpu,time,command -p 77982
PID PPID %CPU TIME COMMAND
77982 77696 172.6 106:20.05 next-server (v15.5.22)
ここで情報が足りなくなります。コマンドラインが next-server (v15.5.22) としか出ていません。実行ファイルのパスも、どのディレクトリで動いているかも読めません。Next.js が自分のプロセスタイトルを書き換えているためです。
プロセスタイトルの書き換えは、多くのランタイムやサーバーが行っています。ps に出ている文字列は、そのプロセスが自分で設定した値でしかありません。起動元を判断する根拠にはできません。
観測したこと: コマンドラインは
next-server (v15.5.22)のみ。パスも作業ディレクトリも出ない
判断したこと: プロセス名は当てにならない。別の経路で起動元を確かめる
起動元を示す親プロセスと作業ディレクトリ
プロセス自身から取れないなら、周囲の情報から特定します。まず親をたどります。PPID の 77696 を起点に、PPID が 1 になるまで遡ります。
pid=77696
while [ "$pid" != "1" ]; do
line=$(ps -o pid,ppid,command -p "$pid" | tail -1)
echo "$line"
pid=$(echo "$line" | awk '{print $2}')
done
77696 77542 node /Users/me/repos/DummyEdge/apps/dashboard/node_modules/.../next/dist/bin/next dev
77542 1 node /opt/homebrew/bin/pnpm dev
親には書き換えられていないコマンドラインが残っていました。pnpm dev が next dev を起動し、next dev がサーバー本体を別プロセスとして起動する三層構造です。これで起動元を特定できました。DummyEdge リポジトリの apps/dashboard です。
この出力にはもうひとつ情報があります。最上位の pnpm dev の PPID が 1 です。起動元のシェルが既に終了し、このプロセスが孤児プロセスとして launchd (Linux では init にあたる PID 1 のプロセス) に引き取られた、ということです。
この時点では、単に「起動したターミナルはもう閉じられている」という状況説明として扱っていました。後でこの 1 行が原因そのものだったとわかります。
親経由でなくても、プロセス自身のディレクトリを直接読む手もあります。lsof は開いているファイルを一覧するコマンドで、-d cwd を指定するとカレントワーキングディレクトリだけが出ます。
lsof -a -p 77982 -d cwd
COMMAND PID FD TYPE DEVICE NODE NAME
node 77982 cwd DIR 1,18 ... /Users/me/repos/DummyEdge/apps/dashboard
-a を忘れると結果が変わります。lsof は複数の条件を既定で OR として扱うため、-p と -d を並べただけでは「このプロセスの全ファイル、または全プロセスの cwd」になります。-a は条件を AND で結合する指定です。
Linux なら procfs の /proc/<pid>/cwd・exe・cmdline を読むと同じ情報が取れます。exe は実行ファイルの実体を指すので、プロセスタイトルが書き換えられていても起動元を追えます。
観測したこと: 親は
next dev、その親はpnpm devでPPIDは 1。cwd はDummyEdgeのapps/dashboard
判断したこと: 起動元は特定できた。起動元のシェルは既に終了している
LISTEN と ESTABLISHED の違い
起動元がわかったので、次は現在利用されているかどうかです。利用中なら、停止前に影響範囲を確認する必要があります。
lsof に -i を付けるとネットワーク関連のファイルディスクリプタだけに絞れます。
lsof -nP -p 77982 -a -i
-n はホスト名の逆引きを止め、-P はポート番号をサービス名に変換せずそのまま出します。どちらも無くても動きますが、逆引きが詰まると出力が返ってこなくなるので、調査中は付けておくほうが安全です。
COMMAND PID FD TYPE DEVICE NODE NAME
node 77982 14u IPv6 ... TCP *:3000 (LISTEN)
出てきたのは LISTEN の 1 行だけでした。ESTABLISHED がありません。ブラウザーのタブが開きっぱなしなら、Hot Module Replacement の WebSocket 接続が残るはずです。それも無いので、少なくとも今この瞬間、繋いでいるクライアントはいません。
Linux で同じことを見るなら ss です。
ここで注意したいのは、LISTEN があることと、サーバーが機能していることは別だという点です。この違いは後半で決定的になります。今の段階で言えるのは「今つないでいる人はいない」までで、それ以上ではありません。
観測したこと: ポート 3000 は
LISTEN。ESTABLISHEDはゼロ
判断したこと: 今この瞬間の利用者はいない。ただし次に使うため待機しているだけの可能性が残る
ここまでで利用中のクライアントが見当たらないことは確認できました。
ただし、待ち受け中のプロセスが正常に動作しているのか、応答不能のまま残っているのかはまだ判定できません。
平均値としての %CPU
一度戻って、最初に見た %CPU の数字を疑います。
多くの実装で ps の %CPU は、プロセスの生存期間の CPU 時間を経過時間で割った平均です。起動直後に重い処理をして、その後は静かにしているプロセスでも、この値は高いままになります。一方 top の %CPU は直前の更新間隔における瞬間値です。列の名前は同じでも、意味が違います。
そこで累積値と経過時間を並べます。time が累積 CPU 時間、etime が経過時間です。
ps -o pid,%cpu,time,etime -p 77982
PID %CPU TIME ELAPSED
77982 120.9 106:20.05 02:07:50
経過 2 時間 7 分に対して CPU 時間が 106 分。比はおよそ 0.83 です。起動直後だけ重かったのなら、この比はもっと小さくなります。起動してからほぼずっと使い続けている数字です。
平均ではなく今この瞬間を知りたければ、時間をおいて 2 回測って差を取ります。
ps -o time= -p 77982; sleep 5; ps -o time= -p 77982
108:07.37
108:13.34
実時間 5 秒に対して CPU 時間が 6 秒増えました。観測中も 120% 前後を使い続けています。100% を超えるのは、複数のスレッドが別々のコアで同時に走っているからです。この値の上限は、おおむね論理コア数 × 100% になります。
スレッドが何本あるかも見ておきます。macOS では top の #TH 列に出ます。-l 1 は 1 回だけ表示して終了する指定、-stats は列の指定です。
top -l 1 -pid 77982 -stats pid,cpu,th,mem,state
PID %CPU #TH MEM STATE
77982 0.0 23/1 1782M running
23 スレッド、常駐メモリ 1782 MB。この時点で、一時的な高負荷ではない可能性が高くなります。ただ、大きなプロジェクトの開発サーバーがメモリを 1.7 GB 使うこと自体は起こり得るので、これも決め手にはなりません。
Linux にはスレッドを行として出す ps -L と top -H があり、どのスレッドが CPU を消費しているかまでその場で分かります。macOS の top にこの機能はありません。
観測したこと: CPU 時間 106 分 / 経過 2 時間 7 分。5 秒の測定で 6 秒増。23 スレッド、1782 MB
判断したこと: 一時的な負荷ではなく継続的。複数スレッドが同時に動いている
ここまでで、単発の高負荷ではなく継続的な CPU 消費であることは確認できました。
ただし、この段階ではまだ、正常な処理負荷と異常なループを区別できません。
外から見たコールスタック
ここまでの材料では「重い」と「壊れている」を分けられません。分けるには、プロセスが今まさに何を実行しているかを見るしかありません。
使うのはサンプリングプロファイラです。一定間隔でプロセスのコールスタックを繰り返し記録し、どの関数に時間が集中しているかを統計的に求めます。macOS には sample が標準で入っています。引数は対象 PID と収集時間で、-file で出力先を指定します。
sample 77982 5 -file /tmp/next-sample.txt
このコマンドを実行する前は、ファイル監視かコンパイルのループが上位に出てくると予想していました。監視対象が多すぎて回り続けているのなら、そういうスタックが見えるはずです。
実際に出てきたのは別のものでした。
2483 Thread_30950743 DispatchQueue_1: com.apple.main-thread (serial)
2483 node::SpinEventLoopInternal(node::Environment*)
2483 uv_run
...
2482 v8::internal::Isolate::ReportPendingMessages(bool)
2482 v8::internal::MessageHandler::ReportMessage(...)
2481 node::errors::TriggerUncaughtException(...)
...
934 node::inspector::InspectorConsoleCall(...)
...
(以降、同一アドレスの自己再帰が延々と続く)
行頭の数字は、そのフレームが何回のサンプルに現れたかです。5 秒間に 2483 回サンプルを採り、そのうち 2481 回が同じ経路を通っています。サンプリングは時間的にランダムな瞬間を切り取るので、ほぼすべてが一致するということは、その間ずっと同じ処理から抜けていないことを意味します。ループを回っているのではなく、1 箇所から戻っていません。
経路の中身のほうが問題でした。TriggerUncaughtException は、捕捉されなかった例外を報告するために呼ばれる関数です。その内側で InspectorConsoleCall、つまりコンソールへの出力が呼ばれています。そしてその先で、同じアドレスの JIT コンパイル済み関数が自分自身を呼び続けています。出現回数を数えてみました。
grep -c "0x10bdb7720" /tmp/next-sample.txt
4687 回。例外を報告する処理そのものが、終わらない再帰に入っています。
ここで判定が変わりました。重いのではありません。壊れています。
ひとつ補足しておくと、sample の出力では JIT でその場で生成された関数が ??? (in <unknown binary>) と表示されます。あらかじめコンパイルされたバイナリと違い、実行時に生成された機械語には対応するシンボル情報が無いためです。関数名はわかりませんが、アドレスが一致していれば「同じ関数が繰り返し現れている」ことは判定できます。名前が読めないからといって、読み取れる情報がゼロになるわけではありません。
ついでに残り 22 本のスレッドも見ておきます。sample の出力にはスレッド名が付くので、探すのは簡単です。
grep -n "Thread_" /tmp/next-sample.txt
Thread_30950757: V8Worker
Thread_30950907: libuv-worker
Thread_30950933: tokio-runtime-worker
Thread_30950762: SignalInspector
V8Worker のスタックには ScavengerCollector が繰り返し現れていました。Scavenger は V8 の世代別ガベージコレクションにおける新世代領域の回収器で、生成されて間もないオブジェクトを短い周期で回収します。これが回り続けている理由は、メインスレッドの再帰にあると考えるのが自然です。再帰のたびにスタックフレームやエラーオブジェクトが生成され、回収対象が増え続けます。%CPU が 172% まで上がっていたのも、常駐メモリが 1782 MB まで膨らんでいたのも、これで説明が付きます。別々に見えていた 3 つの観測が、ここで 1 つの説明にまとまりました。
tokio-runtime-worker は Rust の非同期ランタイム Tokio のワーカースレッドです。Next.js は SWC というコンパイラーをネイティブモジュールとして読み込んでおり、そのスレッドがこの名前で出ます。ひとつのプロセスの中に JavaScript ランタイムのスレッドと Rust ランタイムのスレッドが同居しています。スレッド名から、ここまで内部構造を追えます。
sample は macOS 固有です。Linux で外からスタックを取るなら elfutils の eu-stack、GDB の thread apply all bt、統計的なプロファイルなら perf の perf record -g が対応します。
Node.js には SIGUSR1 でインスペクタを起動する手もありますが、今回は使えません。イベントループが動いていることが前提の機能だからです。プロセスの内側の仕組みに頼る観測手段は、そのプロセスが壊れたとき一緒に使えなくなります。
観測したこと: 5 秒 2483 サンプルのうち 2481 が同一スタック。例外報告処理の中で自己再帰 4687 フレーム。GC ワーカーが並走
判断したこと: 重いのではなく壊れている。停止してよい
ここで、高負荷の原因が通常の処理ではなく、例外報告経路の自己再帰だと判定できます。
以降は停止可否の判断ではなく、原因の説明と再発防止の整理に進みます。
接続の成立と応答の不在
スタックで結論は出ましたが、外から見た挙動も確かめておきます。curl で応答コードと所要時間だけを取り出しました。
curl -s -o /dev/null -w "http_code=%{http_code} time=%{time_total}\n" --max-time 5 http://localhost:3000/
-s は進捗表示の抑制、-o /dev/null は本文の破棄、-w は指定した変数だけを出力する指定です。--max-time は全体の制限時間で、応答が返らない相手に対しては必須です。付け忘れると、今度はこちら側が待ち続けることになります。
http_code=000 time=5.005260
http_code=000 は「HTTP のステータス行を受け取れなかった」ことを示す curl の表現です。5 秒間、1 バイトも返ってきませんでした。
TCP の接続が成立することと、アプリケーションが応答することは別です。listen() を呼んだ時点で、以降の接続要求はカーネルが accept キューに積みます。アプリケーションのスレッドが止まったままでも、キューに空きがある限り TCP のハンドシェイクは成立します。クライアントから見れば接続は成功し、その後の応答をひたすら待つことになります。
つまり、nc -z や telnet のような接続可否だけの確認では、このサーバーは正常と判定されます。
# 接続可否だけの確認 (この用途には不十分)
nc -z localhost 3000 && echo "ポートは開いている"
# アプリケーション層まで到達させる確認
curl -sS --max-time 5 -o /dev/null -w '%{http_code}\n' http://localhost:3000/
死活監視やヘルスチェックを書くときも同じです。ポートの開閉だけを見る監視は、この状態のプロセスを正常と報告します。
観測したこと: TCP 接続は成立。5 秒待って HTTP レスポンスは 1 バイトも返らない (
http_code=000)
判断したこと: ポートはLISTENのままだが、アプリケーションは応答していない
壊れた出力先と EPIPE
停止してよいことはわかりました。ただ、なぜ例外が発生したのかはまだわかりません。ここで、最初に確認していた pnpm dev の PPID が 1 になっていた点に戻ります。つまり、起動元のシェルが終了しているということです。
プロセスが開いているファイルディスクリプタのうち、0 番、1 番、2 番はそれぞれ標準入力、標準出力、標準エラー出力です。lsof の -d にこの 3 つを渡して、接続先を見ます。
lsof -p 77982 -a -d 0,1,2
COMMAND PID FD TYPE DEVICE NAME
node 77982 0u unix 0x27a5... ->(none)
node 77982 1u unix 0x27a5... ->(none)
node 77982 2u unix 0x27a5... ->(none)
3 つとも Unix ドメインソケットで、接続先が ->(none) です。ソケットのもう一方の端を持っていたプロセスが、既に終了しています。開発サーバーの出力を受け取っていたターミナルのペインが閉じられ、読み出し側が存在しない状態です。Linux なら /proc/<pid>/fd/ を見ると同じことが分かります。
読み出し側が消えたパイプやソケットに書き込むと、カーネルは SIGPIPE を送り、write() は EPIPE を返します。Node.js は SIGPIPE を無視する設定で動くため、この状況は EPIPE エラーとして JavaScript 側に現れます。
原因の性質として厄介なのは、これがコードを読んでも見つからないことです。プロセスを起動した時点では出力先は正常で、起動時のログにも何も残りません。ターミナルを閉じるという、コードの外側で起きた操作が原因になっています。リポジトリを見直すだけでは、この不具合は見つかりません。
EPIPE 自体は珍しいエラーではありません。問題は、それが未捕捉例外になった後の経路にありました。そこから先は Node.js の仕様の話になるので、節を分けます。
観測したこと: 標準入出力の 3 つとも接続先が
->(none)。PPIDは 1
判断したこと: 起動元のターミナルが閉じられ、ログの書き先が消えた。そこから例外の連鎖が始まった
ここで、最初の障害がアプリケーション本体ではなく、出力先の喪失にあったことがわかります。
したがって、再発防止ではコード修正だけでなく、起動方法と出力先の扱いも対象になります。
リスナーのない error イベント
そこから先を決めるのは、EventEmitter の error イベントの規定です。
process.stdout はストリームで、書き込みの失敗を error イベントで通知します。そして EventEmitter は、error イベントが起きたときにリスナーが 1 つも登録されていないと、渡されたエラーをその場で投げます。リスナーが無ければ例外になる、という仕様です。
標準出力の error にリスナーが 1 つも登録されていなければ、EPIPE はそのまま未捕捉例外になります。ここまでは、よくある未捕捉例外です。問題はその後でした。
- 起動元のターミナルが閉じられ、標準出力と標準エラー出力の読み出し側が消えた
- 開発サーバーがログを 1 行書こうとして
EPIPEが発生した -
process.stdoutのerrorにリスナーが無いため、EventEmitter がエラーを投げた - Node.js が未捕捉例外を報告するため、エラー内容をコンソールへ出力しようとした
- その出力先も同じ壊れたソケットなので、再び
EPIPEが起きた - 4 と 5 が相互に発生し、抜けられなくなった
未捕捉例外の報告先が、まさに壊れている出力先です。報告しようとするたびに新しい EPIPE が発生します。
先ほど sample で取ったスタックが、この説明と整合しています。
2481 node::errors::TriggerUncaughtException(...)
...
934 node::inspector::InspectorConsoleCall(...)
...
(以降、同一アドレスの自己再帰が 4687 フレーム)
ただし、この時点で断定はできません。JIT された関数のシンボルが取れていない以上、再帰しているのがどの関数かまでは特定できていないからです。言えるのは、->(none) という接続先、例外報告経路への集中、GC の連動という 3 つを同時に説明できる仮説が他に見当たらない、というところまでです。この仮説は後で再現実験にかけます。
SIGTERM とプロセスグループ
原因の見立てが立ったので、止めます。kill はプロセスにシグナルを送るコマンドで、名前に反して終了以外のシグナルも送れます。
最初に送るのは SIGTERM です。終了要求であり、プロセス側がハンドラを登録していれば後始末をしてから終了できます。
kill -TERM 77982 77696 77542
sleep 3
ps -o pid,%cpu,command -p 77542 77696 77982
3 秒後、3 つとも消えていました。SIGKILL を使うまでもありませんでした。
一見すると不自然ですが、メインスレッドのイベントループは再帰から戻っていないにもかかわらず、SIGTERM は到達していました。
理由は、Node.js のシグナル処理の作りにあります。process.on('SIGTERM', ...) のようなリスナーを登録している場合、その処理はイベントループを経由します。イベントループが回っていなければハンドラは実行されません。一方、リスナーを登録していなければ既定の動作が適用され、プロセスの終了はカーネルが行います。ユーザー空間のコードが動いている必要はありません。
一方で、独自の終了処理を持たないプロセスは応答不能でも SIGTERM で止まり、後始末を明示的に書いてあるプロセスほど、応答不能のときは止まりにくくなります。止まらない場合は、捕捉と無視のどちらもできない SIGKILL を使います。
kill -KILL 77982
順序にも一言あります。今回は子から親の順で送りましたが、親の管理プロセスが子の終了を検知して再起動する構成では、親から止めないと再生成されます。プロセスの木ごと止めたいときは、プロセスグループに送るのが確実です。PID の前にマイナス記号を付けると、その番号のプロセスグループ全体が対象になります。
ps -o pid,pgid,command -p 77542 # プロセスグループ ID を確認
kill -TERM -77542 # プロセスグループ全体へ送る
名前で指定する pkill も使えますが、パターンが広すぎると無関係なプロセスまで対象になります。実行前に pgrep -fl で対象を確認する癖をつけておくほうが安全です。
pgrep -fl "next dev" # まず対象を目視で確認する
pkill -f "next dev" # 確認したうえで送る
最後にポートの解放まで見ておきます。
lsof -nP -iTCP:3000 -sTCP:LISTEN || echo "LISTEN なし"
-i に TCP:3000 を渡すとポートで絞り込め、-sTCP:LISTEN で状態を待ち受け中に限定できます。ここまで確認してから、元の作業に戻ります。
E2E テストへの波及
このプロセスを放置していた場合の影響は、CPU の奪い合いだけでは済みませんでした。
Playwright の webServer 設定には reuseExistingServer があり、ローカル実行時には有効にする書き方が広く使われています。
// playwright.config.ts
export default defineConfig({
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
この設定は、指定ポートが既に使用中ならサーバーを起動せず、動いているものを使うという意味です。ところが応答不能なプロセスでも、ポートは LISTEN のままです。listen() を呼んだ時点で以降の接続要求はカーネルが accept キューに積むため、アプリケーションのスレッドが止まっていても TCP のハンドシェイクは成立します。
テストランナーは壊れたサーバーを既存サーバーとして採用し、その後のリクエストがすべてタイムアウトします。そのとき画面に出るのはテストの失敗であって、開発サーバーの異常ではありません。テストコードやセレクタを疑って調べ始めると、原因からどんどん離れていきます。
対策として、次の 4 つを順に入れました。運用の約束事から始めて、機械的に強制される順に並んでいます。
- 起動時に出力先をファイルへ向ける
-
EPIPEを捨てる preload をプロセスに読み込ませる - 応答不能なサーバーが残っている場合に、E2E の実行前へ検査を差し込む
- 1 から 3 の仕組みをテストで固定する
順に見ていきます。
出力先を失わない起動方法
まず起動方法です。今回の直接の引き金は、バックグラウンドで起動した開発サーバーの出力先が後から失われたことでした。これを避けるには、最初から出力をファイルへ向けておきます。
pnpm dev > /tmp/dev.log 2>&1 &
2>&1 は標準エラー出力を標準出力と同じ先へ向ける指定で、> file の後ろに置きます。順序を逆にすると、標準エラー出力の向き先はリダイレクト前の標準出力、つまり端末のままになります。
端末が閉じられてもプロセスを残したいなら nohup、より確実に端末から切り離すなら setsid を使います。
nohup pnpm dev > /tmp/dev.log 2>&1 &
setsid pnpm dev > /tmp/dev.log 2>&1 < /dev/null &
setsid は新しいセッションを作るため、制御端末を持たないプロセスになります。端末の切断そのものが届かなくなります。
プロセス側で EPIPE を捨てる preload
出力先をファイルへ向ける運用は、守られる保証がありません。誰かがそのまま pnpm dev を実行してターミナルを閉じれば、同じことが起きます。人が守る前提の対策は、いつか守られません。
プロセスの側で EPIPE を無害化しておくほうが確実です。必要な処理は、先ほど見た EventEmitter の仕様から決まります。error にリスナーが無いから例外になるのであれば、リスナーを 1 つ登録すれば例外にはなりません。
// scripts/tolerate-broken-stdout.cjs
'use strict';
/** @param {NodeJS.WriteStream} stream @param {string} name */
function tolerateBrokenPipe(stream, name) {
stream.on('error', (err) => {
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
// EPIPE: 読み出し側が消えた。ERR_STREAM_DESTROYED: 既に破棄済みのストリームへの書き込み。
// どちらも「出力先が無い」という同じ状態で、プロセスを落とす理由にはならない。
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
try {
process.stderr.write(`[tolerate-broken-stdout] ${name}: ${String(err)}\n`);
} catch {
// stderr にも書けない状態。ここで投げると同じ再帰に入るため何もしない。
}
});
}
tolerateBrokenPipe(process.stdout, 'stdout');
tolerateBrokenPipe(process.stderr, 'stderr');
捨てるエラーを 2 種類に限定しています。出力先が無いことに由来する EPIPE と ERR_STREAM_DESTROYED だけで、それ以外は標準エラー出力へ書きます。すべて捨てる実装にすると、書き込み失敗のあらゆる種類が見えなくなります。直したかったのは特定の状態であって、エラーの可視性ではありません。
catch の中が空なのも意図的です。ここで再度エラーを投げると、まさに今避けようとしている再帰に入ります。エラー処理の失敗を報告する手段が無い状況では、報告できません。
読み込ませ方には制約がありました。
{
"scripts": {
"dev": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --require ./scripts/tolerate-broken-stdout.cjs\" next dev"
}
}
next dev に直接オプションを渡すのではなく、NODE_OPTIONS 環境変数を経由しています。CPU を消費していたのが next dev 自身ではなく、それが起動する next-server の子プロセスだったためです。調査の最初に ps で親をたどって見つけた三層構造が、ここで関係してきます。コマンドライン引数は子プロセスに引き継がれませんが、環境変数は引き継がれます。子プロセスに読み込ませたい preload は、環境変数で渡すしかありません。
${NODE_OPTIONS:-} は、既に NODE_OPTIONS が設定されている場合にその内容を残す書き方です。未設定なら空文字列に展開されます。単に上書きすると、メモリ上限の指定など既存の設定を消してしまいます。
ファイルが CommonJS (.cjs) なのは、--require が CommonJS を前提とするためです。ES モジュールを読み込む --import は Node.js 20.6 以降でしか使えず、このプロジェクトの engines は 20.0.0 以上を許しています。対応する最低バージョンで動かないオプションは選べません。
再現実験による仮説の確認
対策を入れたので、仮説の確認と対策の検証を同時にできます。読み出し側が消えたパイプへ書き込み続けるプロセスを用意し、preload の有無で終了コードを比べました。
--- preload なし ---
exit=1
--- preload あり ---
exit=0
preload が無ければ終了コード 1 で落ち、あればそのまま動き続けます。「EPIPE の未捕捉が起点である」という仮説の前半が、ここで確認できました。
確認できた範囲は正確に線を引いておきます。この実験が直接示したのは、EPIPE が未捕捉例外になってプロセスを落とす、というところまでです。その先で観測された自己再帰と CPU の消費は、sample の出力と突き合わせた推論であり、この実験が示しているわけではありません。起点を塞げばその先の挙動にも到達しないので、対策としてはこれで足ります。足りているのと、証明できているのは別です。
固まったサーバーを再利用させない事前検査
再発を防いでも、既に固まっているプロセスが残っている場面は起こり得ます。Playwright が壊れたサーバーを再利用してしまう経路は、まだ塞がっていません。
reuseExistingServer は、指定 URL が応答するまで待つ形で生存を判定します。応答不能なサーバーが残っていると、webServer.timeout の 60 秒を丸ごと使ったうえで Timed out waiting from config.webServer としか出ません。この文面から「固まったプロセスが残っている」を読み取るのは、事情を知らなければ無理です。
そこで、設定ファイルの読み込み時点で応答性を判定することにしました。判定は独立したプロセスとして実行し、終了コードで結果を返します。
// e2e/probe-dev-server.mjs
import { connect } from 'node:net';
const HOST = '127.0.0.1';
const PORT = Number(process.argv[2] ?? '3000');
/** 応答を待つ上限。正常な dev サーバーなら最初のバイトはこの範囲で返る。 */
const RESPONSE_TIMEOUT_MS = Number(process.argv[3] ?? '4000');
const socket = connect({ host: HOST, port: PORT });
socket.setTimeout(RESPONSE_TIMEOUT_MS);
let settled = false;
function settle(code) {
if (settled) return;
settled = true;
socket.destroy();
process.exit(code);
}
socket.on('connect', () => {
socket.write(`GET / HTTP/1.1\r\nHost: ${HOST}:${PORT}\r\nConnection: close\r\n\r\n`);
});
// 1 バイトでも返ってくれば HTTP として応答できている。中身は問わない。
socket.on('data', () => settle(0));
// 接続できない = 誰も LISTEN していない。Playwright が自分で起動するので問題ない。
socket.on('error', () => settle(0));
socket.on('timeout', () => settle(3));
// 応答なしで相手から閉じられた場合も、再利用できる状態ではない。
socket.on('close', () => settle(settled ? 0 : 3));
判定すべき状態は 3 つあり、そのうち 2 つは問題なしです。待ち受けているプロセスが無ければ Playwright が自分で起動するので構いません。HTTP が返るなら再利用してよい状態です。止めたいのは、接続はできるのに応答が返らない場合だけです。だから終了コードは、問題なしを 0、応答不能を 3 に割り当てました。
data イベントで内容を検査せず 1 バイトでも受け取れば合格にしているのは、ここで確かめたいのがアプリケーションの正しさではなく、応答を返せる状態にあるかどうかだからです。ステータスコードやページ内容の検査は E2E テスト本体の仕事で、こちらが手を出す範囲ではありません。
この判定を設定ファイルの先頭で同期的に呼びます。
function assertNoHungDevServer(): void {
if (process.env['CI']) return;
const here = dirname(fileURLToPath(import.meta.url));
const result = spawnSync(process.execPath, [join(here, 'e2e/probe-dev-server.mjs'), '3000'], {
// 検査自体が固まっては本末転倒なので、probe の待ち時間より長い上限を置く。
timeout: 10_000,
stdio: 'ignore',
});
if (result.status !== HUNG_DEV_SERVER_EXIT_CODE) return;
throw new Error(
[
'localhost:3000 で待ち受けているプロセスがあるものの、HTTP レスポンスを返しません。',
'reuseExistingServer がこれを既存サーバーとして採用するため、E2E は全ケースがタイムアウトします。',
'',
'対処: 残っているプロセスを落としてから再実行してください。',
' lsof -nP -iTCP:3000 -sTCP:LISTEN',
' kill <pid>',
'',
'再起動するときは出力先をファイルへ向けてください (pnpm dev > /tmp/dev.log 2>&1)。',
].join('\n'),
);
}
assertNoHungDevServer();
エラーメッセージに実行するコマンドをそのまま埋めています。この検査が止めるのは、事情を知らない人が E2E を実行した場面です。そのとき必要なのは状態の説明よりも、次に実行する 2 行です。ここまで調べたことを、次に同じ場面に出会った人が調べ直さずに済む形で残しておく、という意図です。
継続的インテグレーション環境では reuseExistingServer が false で必ず新規起動するため、検査を飛ばしています。起こり得ない状態を毎回検査しても、実行時間を使うだけです。
3 つの状態それぞれで、期待どおりの終了コードが返ることを確認しました。
none_listening_exit=0
hung_exit=3
healthy_exit=0
ここまでは想定どおりでした。
イベントループを止めた spawnSync
ここまでの仕組みをテストで固定します。対策コードは、時間が経つと理由を忘れられて削除される対象になりやすいためです。テストは Vitest で書きました。
検査用のサーバーは、テストプロセス自身の中に立てます。応答するサーバーと、接続を受け付けたまま何も書かないサーバーの 2 種類です。後者が、応答不能な開発サーバーの再現になります。
function listenOnFreePort(handler: 'hang' | 'http'): Promise<{ port: number; close: () => void }> {
return new Promise((resolve) => {
const server = createServer((socket) => {
if (handler === 'http') socket.end('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok');
// 'hang' は accept したまま何も書かない (応答不能な dev サーバーの再現)。
});
server.listen(0, '127.0.0.1', () => { /* 割り当てられたポートを返す */ });
});
}
listen(0, ...) はポート番号に 0 を指定する書き方で、空いているポートをカーネルに選ばせます。固定のポート番号を使うと、別の実行と衝突します。
判定プロセスの起動には、設定ファイルと同じく spawnSync を使いました。同期で書くほうがテストコードは短くなります。
結果はこうなりました。
× dev サーバーの生存判定 > HTTP を返すサーバーは再利用してよい 1546ms
応答するはずのサーバーが、応答不能と判定されています。所要時間の 1546 ミリ秒は、判定側が待ち時間を使い切った値です。
最初は判定スクリプト側を疑いました。しかし、そちらは直前に 3 状態すべてで正しく動いています。おかしいのはテストの側です。
原因は、検査用サーバーと判定プロセスの位置関係にありました。spawnSync は子プロセスの終了までテストプロセスをブロックします。ところが検査用サーバーは、そのテストプロセスのイベントループの上で動いています。イベントループが止まっている間、サーバーは接続を受け付けることも応答を書くこともできません。判定プロセスから見れば、接続はカーネルの accept キューに積まれたまま応答が返らない状態です。
これは、調査の途中で見た「listen() 済みのポートはアプリケーションが止まっていても接続が成立する」という性質そのものです。同じ性質が、今度は自分の書いたテストの欠陥として現れました。実際の不具合と、それを検査するテストの欠陥が、同じ仕組みに由来しています。
非同期の spawn に変えて、終了を Promise で待つ形にすれば解決します。テストプロセスのイベントループが回り続けるので、検査用サーバーが応答できます。
function runProbe(port: number): Promise<number> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROBE, String(port), '1500'], { stdio: 'ignore' });
const timer = setTimeout(() => child.kill('SIGKILL'), 15_000);
child.on('exit', (code) => {
clearTimeout(timer);
resolve(code ?? -1);
});
});
}
なお、設定ファイル側が spawnSync のままなのは問題ありません。あちらが検査する相手は別プロセスの開発サーバーで、設定ファイルのイベントループとは無関係です。同期 API が壊すのは、検査対象が自分と同じイベントループに乗っている場合だけです。
ここで問題だったのは、検査対象ではなく検査方法の側でした。
同じ spawnSync でも、同一イベントループ上の疑似サーバーを相手にしたときだけ判定が崩れます。
以降は実装だけでなく、検査方法が対象を正しく測れているかも確認対象になります。
シェルの引用符が変えた測定対象
もうひとつのテスト、EPIPE に関する 2 件は最初から通っていました。preload 無しでは落ち、preload ありでは落ちない。期待どおりです。
そのまま先へ進んでいたら、この記事の後半は存在しませんでした。実際には、この 2 件は何も測っていませんでした。
当初の実装は、シェル経由でプロセスを起動していました。読み出し側が消えた状態を作るために出力を head -c 1 に繋ぎ、1 バイト読んだ時点で読み出し側が終了するようにしています。
const script = "setInterval(() => console.log('x'.repeat(64)), 1); ...";
const args = ['-e', script];
const command = `${node} ${args.map((a) => `'${a}'`).join(' ')} | head -c 1; exit ${PIPESTATUS[0]}`;
spawnSync('bash', ['-c', command]);
問題は、スクリプト本体に console.log('x'.repeat(64)) というシングルクォートが含まれていることです。POSIX のシェルでは、シングルクォートで囲まれた文字列の中にシングルクォートを含める手段がありません。バックスラッシュによるエスケープも使えず、囲みはそこで終わります。
外側から '...' で囲むと、こう解釈されます。
'setInterval(() => console.log(' x '.repeat(64)), 1); ...'
└─ 引用文字列 1 ─────────────┘ └┘ └─ 引用文字列 2 ──────┘
素の x
3 つの部分の間に引用されていない空白がないため、シェルはこれらを連結して 1 語として扱います。Node.js が受け取るのは、文字列リテラルだったはずの 'x' が裸の識別子 x に変わったスクリプトです。
setInterval(() => console.log(x.repeat(64)), 1); // x は未定義
1 ミリ秒後の最初のコールバックで ReferenceError: x is not defined が投げられ、未捕捉のまま終了コード 1 になります。EPIPE には一度も到達していません。
この状態で、2 件のテストがそれぞれ何を測っていたのかを並べます。
- preload 無し: 終了コード 1。アサーションは
not.toBe(0)なので通る。ただし理由はEPIPEではなくReferenceError - preload あり: 同じく
ReferenceErrorで 1。preload はerrorイベントしか扱わないのでReferenceErrorには無関係。toBe(0)が落ちる
一方だけが落ちていた理由は、preload の効果と無関係でした。preload を削除しても、この 2 件は同じ結果を出します。テストとしては存在していないのと同じです。
さらに厄介なことに、その前段で入れた修正も同じ理由で無意味になっていました。sh -c 'cmd | head' の終了コードは、パイプラインの最後のコマンドである head のものです。Node.js のプロセスが落ちても 0 が返ります。これを避けるために PIPESTATUS でパイプラインの先頭の終了コードを取り出す修正を入れていました。PIPESTATUS は bash 固有の配列変数で、直前のパイプラインの各コマンドの終了ステータスを保持します。修正そのものは正しかったのですが、引用符が壊れている以上、正しい終了コードを取り出しても測っている対象は ReferenceError のままです。正しい修正を重ねても、測定対象が違えば結果は変わりません。
直し方は、シェルを経由するのをやめることでした。読み出し側が消えた状態は、親プロセス側で読み取り側を破棄すれば直接作れます。
function writeToBrokenPipe(withPreload: boolean): Promise<number> {
// 落ちなかった場合に終了コード 0 で自ら終了する。終了しないとタイムアウトによる
// 強制終了になり、EPIPE で落ちた場合と区別できない。
const script =
"setInterval(() => console.log('x'.repeat(64)), 1); setTimeout(() => process.exit(0), 1500);";
const args = withPreload ? ['--require', PRELOAD, '-e', script] : ['-e', script];
return new Promise((resolve) => {
const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'ignore'] });
// 読み口を落として「相手側が消えたパイプ」を作る。
child.stdout.destroy();
const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
child.on('exit', (code) => { clearTimeout(timer); resolve(code ?? -1); });
});
}
引数を配列で渡せば、シェルの構文解析を通りません。引用符とエスケープが不要になります。head を挟む必要が消えるため、PIPESTATUS の考慮もなくなります。壊れたパイプは child.stdout.destroy() で直接作れます。
setTimeout(() => process.exit(0), 1500) を入れているのは、落ちなかったケースを終了コードで表現するためです。これが無いと、動き続けたプロセスは 10 秒後に SIGKILL で強制終了され、EPIPE で落ちた場合と区別が付きません。落ちなかったことを検査するには、落ちなかったプロセスを自分から正常終了させる必要があります。
この形にして、8 件すべてが正しい理由で通るようになりました。
Tests 8 passed (8)
検査を信用してよい条件
2 回の誤判定は、原因が別々なのに同じ形をしています。テストは結果を返していたのに、その結果は検査対象と因果関係を持っていませんでした。
前者は、検査対象を応答不能に見せる仕組みが検査自身の中にありました。後者は、検査対象へ届く前に別の失敗が起きていました。どちらも、実行結果を眺めているだけでは気づけません。成功と失敗が期待どおりに出ていたからです。
同じ誤りを繰り返さないよう、以降は次の 3 点を確認しています。
失敗の理由を見る。 失敗するはずのケースが失敗した、というだけでは検査の正しさの根拠になりません。想定した理由で失敗しているかを、少なくとも一度は目で見ます。今回は終了コードだけを見て標準エラー出力を捨てていました (stdio: 'ignore')。一度でも出力を確認していれば、ReferenceError の行が目に入ったはずです。
対策を無効化して、検査が落ちるか見る。 preload を一時的に外し、toBe(0) の検査が落ちるかを確認します。落ちなければ、その検査は preload の有無を測っていません。1 行コメントアウトするだけで、対象を測っていない検査をそのまま検出できます。
外部プロセスを起動する検査では、シェルを経由しない。 シェルは、測りたい対象に届く前の失敗経路を増やします。引用符、変数展開、パイプラインの終了コード、シェルの種類による差異。どれも検査したい対象ではないのに、検査の結果を左右します。引数を配列で渡せば、これらはすべて無関係になります。
3 つ目は、シェルスクリプト全般に当てはまります。文字列を組み立ててシェルに解釈させる方式は、引用の必要な文字が入った時点で壊れます。そして壊れ方は、構文エラーで止まるとは限りません。今回のように、別の意味を持つ有効なプログラムに変わることがあります。構文エラーで停止する壊れ方のほうが、検出はまだ容易です。
この記事の前半で使った ps や lsof は、壊れたプロセスの状態を外から観測する道具でした。ここで問われていたのは、自分が書いた検査そのものを外から観測できているか、という点です。CPU 93.5% のプロセスを突き止めるより、自分の書いたテストが何も測っていないと気づくまでのほうが、結局は長くかかりました。
次に同じことが起きたときのチェックリスト
たどった順序をそのまま手順にすると、次のようになります。
-
pgrep -fl <pattern>とps -o pid,ppid,%cpu,time,command -p <pid>で当たりを付ける - 親を
PPIDが 1 になるまで遡る。プロセス名が書き換えられていても、親には残っていることが多い -
lsof -a -p <pid> -d cwdで作業ディレクトリを見る -
lsof -nP -p <pid> -a -iでLISTENとESTABLISHEDを分けて見る -
ps -o time,etimeの比と、時間をおいた 2 回の差分で、負荷が継続的かを判定する -
curl --max-timeでアプリケーション層まで到達させる。ポートの開閉だけでは判定できない -
sampleでスタックを取り、同一経路にサンプルが集中していないかを見る -
lsof -p <pid> -a -d 0,1,2で出力先が有効かを確認する -
kill -TERMを送り、残るようならkill -KILL。停止後にポートの解放まで確認する
1 から 5 までで起動元と負荷の性質が分かり、6 から 8 で「重い」と「壊れている」が分かれます。CPU 使用率の数字だけで判断すると、この分岐を通らないまま放置か強制終了かを選ぶことになります。
調査の記録は、次の形にまとめておくと後から読み返せます。AI エージェントに状況を渡すときにも、そのまま貼れます。
観測:
- PID 77982 は ps 上 next-server としか出ず、パスも cwd も分からない
- 親は next dev、その親は pnpm dev。pnpm dev の PPID は 1
- cwd は DummyEdge の apps/dashboard
- ポート 3000 は LISTEN、ESTABLISHED はゼロ
- CPU 時間 106 分 / 経過 2 時間 7 分。5 秒の測定で 6 秒増
- 23 スレッド、常駐メモリ 1782 MB
- curl は 5 秒待って http_code=000
- sample の 2483 サンプル中 2481 が例外報告処理の自己再帰
- 標準入出力 3 つとも接続先が ->(none)
判断:
ポートは開いているが、アプリケーションは応答していない。
起動元のターミナルが閉じてログの書き先が消え、例外の連鎖でメインスレッドが戻らなくなっている。
利用者はいないため停止してよい。
冒頭に書いた原則を、もう一度置いておきます。プロセスは、自分で設定した名前ではなく、親・作業ディレクトリ・ファイルディスクリプタ・ソケット・スタックで見る。今回は 5 つのうち 4 つを使いました。どれかひとつで答えが出たわけではなく、名前が当てにならないと分かってから、周囲の情報を順に足していった結果です。
macOS と Linux のコマンド対応
Linux で対応する道具の名前だけ挙げておきます。オプションの意味と使い分けは、それぞれの man ページが正典です。
| 調べたいこと | macOS | Linux |
|---|---|---|
| プロセスの検索 | pgrep -fl <pattern> |
pgrep -fl <pattern> |
| コマンドラインの確認 | ps -o command= -p PID |
tr '\0' ' ' < /proc/PID/cmdline |
| 実行ファイルの実体 | lsof -a -p PID -d txt |
ls -l /proc/PID/exe |
| 作業ディレクトリ | lsof -a -p PID -d cwd |
readlink /proc/PID/cwd |
| 標準入出力の接続先 | lsof -p PID -a -d 0,1,2 |
ls -l /proc/PID/fd/ |
| 待ち受けポート | lsof -nP -p PID -a -i |
ss -tnlp |
| 確立済み接続 | lsof -nP -iTCP -sTCP:ESTABLISHED |
ss -tnp state established |
| スレッド一覧 | top -l 1 -pid PID -stats pid,th |
ps -L -p PID / top -H -p PID
|
| スタックの取得 | sample PID 5 |
eu-stack -p PID / gdb -p PID -batch -ex "thread apply all bt"
|
| CPU プロファイル |
sample / Instruments |
perf record -F 99 -g -p PID |
| 累積 CPU 時間 | ps -o time,etime -p PID |
ps -o time,etime -p PID |
| 優先度の変更 | renice -n 19 -p PID |
renice -n 19 -p PID |
| リソース制限 | ulimit |
systemd-run -p CPUQuota=… / cgroups |
プロセス優先度とリソース制限
ここからは発展です。1 つのプロセスがマシン全体を圧迫しない構成にしておくと、今回のような調査の緊急度そのものが下がります。
実行中のプロセスの優先度は renice で変えられます。nice 値は -20 から 19 の範囲で、大きいほど優先度が低くなります。値を下げるには特権が要りますが、上げるのは一般ユーザーでもできます。
renice -n 19 -p 77982 # 対象プロセスの優先度を最低にする
nice -n 19 pnpm test # これから起動するコマンドに適用する
nice 値が影響するのは CPU の取り合いだけで、メモリやディスク I/O には効果がありません。メモリを大量に使うプロセスを優先度だけで抑えることはできません。macOS で使えるのは nice と renice、そして ulimit までです。
CPU とメモリの両方に上限を設けたい場合、Linux には cgroups があり、systemd-run の CPUQuota / MemoryMax や taskset による CPU アフィニティの指定が使えます。macOS に相当する仕組みはありません。
参考情報
プロセスとシグナル
-
ps(1) — 出力フォーマット指定子と
%CPUの定義 -
top(1) — スレッド単位表示 (
-H) を含む対話的なプロセス監視 - pgrep(1) / pkill(1) — パターンによるプロセスの検索と、シグナル送出
- kill(1) — シグナル送出の仕様。負の PID によるプロセスグループ指定を含みます
- signal(7) — シグナルの一覧と既定の動作
- POSIX.1-2024 XBD 3 Definitions — プロセスグループ・セッション・孤児プロセスの定義
- nice(1) / renice(1) — nice 値による CPU 優先度の制御
- nohup(1) / setsid(1) — 端末から切り離してプロセスを起動する手段
ファイルディスクリプタとプロセス情報
-
lsof — 開いているファイルの一覧。
-aの AND 結合、-dの指定子、-iのネットワーク絞り込み -
proc(5) —
/proc/[pid]/cwd・exe・cmdline・fd・taskの意味 - open(2) — ファイルディスクリプタの割り当て規則
-
write(2) — 読み出し側が消えた場合に返る
EPIPEの規定 -
pipe(7) — パイプの読み出し側が閉じたときの
SIGPIPEの規定 - unix(7) — Unix ドメインソケットの仕様
ネットワーク
- ss(8) — ソケットの状態表示
- listen(2) — accept キューの仕様。アプリケーションが停止していても接続が成立する理由
-
RFC 9293 (TCP) — TCP の状態遷移。
LISTENとESTABLISHEDの定義 -
curl(1) —
--max-timeと-wの書式変数
プロファイリング
- perf — Linux のサンプリングプロファイラ
-
GDB Documentation —
thread apply all btによるバックトレース取得 -
elfutils —
eu-stackを含むユーティリティ群 - macOS の
sampleはman sampleが正典です。Instruments を含む計測全般は Improving your app's performance にまとまっています
Node.js と V8
- EventEmitter の error イベント — リスナーが無い場合に例外を投げる規定。今回の連鎖の起点
- uncaughtException イベント — 未捕捉例外の扱い
-
process のシグナルイベント —
SIGUSR1によるインスペクタ起動と、リスナー登録時の挙動 - Node.js デバッガ — インスペクタプロトコルへの接続手順
-
Node.js エラーコード一覧 —
ERR_STREAM_DESTROYEDの定義 - NODE_OPTIONS — 子プロセスへ引き継がれる形でオプションを渡す方法
-
--require / --import — preload の読み込み。
--importは Node.js 20.6 以降 -
CommonJS モジュール —
--requireが前提とするモジュール形式 -
child_process —
spawnとspawnSyncの違い。引数配列を渡す形式 -
net.Server —
listen(0)によるポートの自動割り当て - Trash talk: the Orinoco garbage collector — Scavenger を含む V8 の世代別 GC
- V8 Documentation — JIT コンパイルを含む実行時の構成
シェルとテスト
- POSIX.1-2024 XCU Shell Command Language: Quoting — シングルクォート内にシングルクォートを含められない規定
- Bash Variables: PIPESTATUS — パイプラインの各コマンドの終了ステータス
- Vitest — 対策の配線を固定するテストの実行環境
-
Playwright: Web server —
webServerとreuseExistingServerの仕様
リソース制限
- cgroups(7) / taskset(1) — CPU とメモリの上限設定、CPU アフィニティ
- systemd-run — 一時スコープによるリソース制限
-
POSIX.1-2024
ulimit— リソース上限の設定