運営者がブログ運用のために作っているツール(Python製)の開発中に、回帰テストの完了を待つシェルが終わらずに残り続けたことがあります。このサイトの記事の作り方とツールの関係は運営者情報にまとめています。
起きていたこと
テストの完了を待つのに、次の1行を使っていました。
until ! pgrep -f "pytest tests -q" >/dev/null; do sleep 15; done
pgrep が何も見つけなくなったら抜ける、というつもりです。2026年8月30日に動いているプロセスを調べると、この待機シェルが13本残っていて、待っているはずの pytest は1本も動いていませんでした。
pgrep -f はプロセス名ではなく、引数を含めたコマンドライン全体を検索します。待機シェルのコマンドラインにも pytest tests -q という文字列が入っているので、検索に引っかかります。当時の記録には「待っているシェル自身に一致し続ける」と書きました。
macOSで確かめると、1本だけなら終わった
今回この記事のために、2026年10月19日にmacOS 26.6.2で確かめ直しました。pytestの代わりに無害な sleep 6.5 を対象にし、すべて /tmp で、短時間で終わる形にしています。
まず待機シェルを1本だけ動かしました。
-- pgrep -fl(対象が動いている間)
33621 sleep 6.5
33622 bash -c until ! pgrep -f "sleep 6.5" >/dev/null; do sleep 1; done; echo " waiter $$: 終わりました"
waiter 33622: 終わりました
-- 対象終了から2秒後: 待機シェル 33622 は 終了している
対象が動いている間は、pgrep -fl に対象(sleep 6.5)と待機シェル(bash -c until ...)の両方が出ます。それでも、対象が終わると待機シェルは抜けました。自分に一致し続けるなら、ここで終わらないはずです。
理由はmacOSの pgrep のマニュアルにありました。
-a Include process ancestors in the match list. By
default, the current pgrep or pkill process and all of
its ancestors are excluded (unless -v is used).
手元の pgrep は、自分と、自分を起動した祖先のプロセスを、既定で一致から外します。待機シェルは pgrep の親なので、自分自身には一致しません。待機シェルの中から、-a の有無で比べたのが次の出力です。
自分のPID: 33783
pgrep -f -> 33782
pgrep -af -> 33617 33782 33783 33787
対象のPID: 33782
-a なしでは対象(33782)だけですが、-a を付けると自分(33783)も入ります。ほかの番号は、$(...) のサブシェルと、この確認を起動した外側のシェルで、どちらもコマンドラインに同じ文字列を含んでいたものです。
2本以上あると、互いに見つけ合って終わらない
次に、同じ待ち方のシェルを2本並べました。
-- pgrep -fl(対象が動いている間)
33824 sleep 6.5
33825 bash -c until ! pgrep -f "sleep 6.5" >/dev/null; do sleep 1; done; echo " waiter $$: 終わりました"
33826 bash -c until ! pgrep -f "sleep 6.5" >/dev/null; do sleep 1; done; echo " waiter $$: 終わりました"
-- 対象終了から4秒後の pgrep -fl
33825 bash -c until ! pgrep -f "sleep 6.5" >/dev/null; do sleep 1; done; echo " waiter $$: 終わりました"
33826 bash -c until ! pgrep -f "sleep 6.5" >/dev/null; do sleep 1; done; echo " waiter $$: 終わりました"
待機シェル 33825: 残っている
待機シェル 33826: 残っている
対象が終わっても、2本とも残りました。それぞれの pgrep は自分の祖先を外しますが、もう1本の待機シェルは祖先ではないので一致します。互いに相手を見つけ続けるので、どちらも抜けられません。
13本残っていたのは、この形だったと考えています。テストの完了待ちを何度も始めているうちに待機シェルが重なり、一度2本以上になると、テストが終わっても誰も抜けられなくなります。記録の「自分自身に一致」は正確ではなく、同じ文字列で待つほかの待機シェルに一致していたと書くのが正しいところでした。
確かめたのはmacOSの pgrep だけです。ほかのOSでは祖先を除外しない実装もありうるので、使っている環境の man で -a の説明を確認してください。除外しない実装なら、1本でも自分に一致します。
直し方:PIDで待つ
完了を待つ相手が分かっているなら、文字列で探さずにPIDで指定します。自分が起動した子プロセスなら wait が使えます。
pytest tests -q &
PID=$!
wait "$PID"
別のシェルから待つなど、子プロセスでない場合は kill -0 で生きているかを確かめます。シグナルは送らず、存在の確認だけをします。
while kill -0 "$PID" 2>/dev/null; do sleep 5; done
どうしても文字列で探すなら、コマンドライン全体の完全一致にする -x か、角かっこで自分の文字列に一致しないようにする書き方があります。[s]leep という正規表現は sleep に一致しますが、待機シェルのコマンドラインに入っているのは [s]leep という文字なので一致しません。PIDで待つものも含めて3つの待ち方を同時に動かし(同じ対象を待つシェルが3本ある状態)、どれも対象の終了で抜けることを確かめました。
対象 34139 終了
kill -0 の待機 34142: 終わりました
-xf の待機 34140: 終わりました
[s]leep の待機 34141: 終わりました
待機シェル 34140: 終了している
待機シェル 34141: 終了している
待機シェル 34142: 終了している
(sleep 6.5 に一致するプロセスなし)
最後に、起動したものが残っていないことも確認しています。
抜けた順番にも意味があります。[s]leep の待機は、-xf の待機シェル(コマンドラインに sleep 6.5 をそのまま含む)にも一致するので、そちらが抜けたあとで抜けています。
ただし -x も角かっこも、同じコマンドラインのプロセスがほかにあれば一致します。PIDが分かるならPIDで待つほうが確実です。
ツール側で直したこと
ツールには、PIDの終了を待つ関数を1つ用意しました。上限の時間を過ぎたら待つのをやめますが、待っている相手は止めません。
作っている途中で、次の2つにも当たりました。
- 終了した子プロセスは、親が回収するまで
psにゾンビとして残ります。存在するかどうかだけで判定すると、テストが終わっても待ち続けました。状態がZなら終了として扱います - 残った待機シェルを一覧にする処理のコマンドラインにも同じ文字列が入り、自分を残骸として数えました。自分と親のPIDは除くようにしました
どちらも、文字列が含まれているかどうかだけで、そのプロセスの役割を決めていたのが原因です。最初の待機の問題と同じ形でした。
自分のコードで確かめること
pgrep -fやps | grepで完了を待っている箇所がないか- 同じ待ち方を並べて動かすことがないか(並べると互いに一致する)
- 待つ相手のPIDを
$!などで取っておけないか - ゾンビ(状態 Z)を「動いている」と数えていないか
- 待機に上限があり、上限で対象まで止めていないか
検証した環境
この記事の調査・再現・執筆は、運営者の指示のもとでAIエージェント(AnthropicのClaude)が行い、公開するかどうかは運営者が決めています。載せた出力は、そのAIエージェントがこのMacで実際にコマンドを実行して得たものです。
- macOS 26.6.2(標準の pgrep、/bin/bash)
- 再現は2026年10月19日。対象は /tmp で動かした sleep のみで、終了後に残っていないことを確認