🌲 『孤児Chrome』だけを見分けて刈る ― allowlist正規表現とps注入テストの話 — リーダー×
🌲

『孤児Chrome』だけを見分けて刈る ― allowlist正規表現とps注入テストの話

#claudecode#bash#automation#chrome#macos2026-09-09 · 約9

前回、note記事の画像リンク切れをフォールバック処理した話を書きました。今回は毛色を変えて、Claude Codeの自動化まわりで地味に事故りやすい**「定期ジョブでChromeを殺す」**話です。

agent-browserスキルやRemotion動画レンダリング、Playwright/Puppeteerのテストは、裏でヘッドレスや専用プロファイルのChromeを立ち上げます。親プロセスが異常終了すると、この子Chromeだけ生き残ってlaunchd(pid 1)に引き取られ、孤児プロセスとしてメモリを食い続けることがあります。これをlaunchdから30分おきに刈り取るchrome-reaper.shというスクリプトを運用しているのですが、狙いを外すと今まさにブラウジングしているChromeまで巻き添えにするのが怖いところです。この記事はその誤爆対策の設計と、実際に踏んだbashのバグの話です。

困りごと:「孤児」と「稼働中」をpsの1行だけで見分ける

chrome-reaper.shがやることはシンプルです。psで全プロセスを列挙し、条件に合うものをkillする。

threshold_seconds=$((MAX_AGE_MIN * 60))
while read -r pid ppid elapsed command_line; do
  [ "$ppid" = "1" ] || continue
  is_target_process "$command_line" || continue
  elapsed_seconds="$(elapsed_to_seconds "$elapsed")"
  [ "$elapsed_seconds" -gt "$threshold_seconds" ] || continue
  PIDS[((${#PIDS[@]}))]="$pid"
  DETAILS[((${#DETAILS[@]}))]="pid=$pid age=$elapsed command=$command_line"
done < <("$PS_BIN" -axo pid=,ppid=,etime=,command=)

条件は3つのAND ―― ①親がlaunchdppid=1。異常終了で拾われた証拠)、②is_target_processが「自動化用Chromeだ」と判定する、③既定30分(CHROME_REAPER_MAX_AGE_MIN)を超えて生きている。この3つのうち②を雑にやると、普段使いのGoogle Chromeまで巻き込む可能性があります。

対策:pathとuser-data-dirの両方を縛るallowlist

is_target_processは「殺していいものだけ」を積み上げる形のallowlistです。

is_target_process() {
  command_line="$1"
  if [[ "$command_line" == *"/Google Chrome.app/Contents/MacOS/Google Chrome"* ]] && \
     [[ "$command_line" =~ --user-data-dir(=|[[:space:]])~/dev/ ]]; then
    return 0
  fi
  # scent-media の常駐CDP Chrome(ensure_chrome.sh が port 9223 で意図して常駐) は対象外
  [[ "$command_line" == *"user-data-dir=~/dev/scent-media/.profiles/chrome-ig"* ]] && return 1
  if [[ "$command_line" == *"/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"* ]] && \
     [[ "$command_line" =~ --user-data-dir(=|[[:space:]])~/dev/ ]]; then
    return 0
  fi
  if [[ "$command_line" == *"/ms-playwright/"* ]] && \
     { [[ "$command_line" == *"/Chromium.app/Contents/MacOS/Chromium"* ]] || [[ "$command_line" == *"/chrome-headless-shell"* ]]; }; then
    return 0
  fi
  if [[ "$command_line" == *"/chrome-headless-shell"* ]] && \
     [[ "$command_line" == *"/node_modules/.remotion/"* ]]; then
    return 0
  fi
  if [[ "$command_line" == *"/chrome-headless-shell"* ]] && \
     [[ "$command_line" =~ --user-data-dir(=|[[:space:]])[^[:space:]]*puppeteer_dev_chrome_profile- ]]; then
    return 0
  fi
  return 1
}

ポイントは実行パス単体では判定しないこと。普通のGoogle Chromeは実行パスだけなら誰でも一致してしまうので、必ず--user-data-dir~/dev/配下(=自動化用に切ったプロファイル)であることを正規表現でAND条件にしています。逆にscent-mediaのCDP常駐Chrome(ensure_chrome.shがport 9223で意図的に立てっぱなしにしているもの)は、~/dev/配下のパスに一致してしまうので、明示的なreturn 1で先に弾いています。allowlistの中に狭いdenyを混ぜる形です。

テスト:psの出力をfixtureで丸ごと差し替える

このallowlistが「狙った孤児だけ刈れているか」を、本物のプロセスを立てずに検証したい。スクリプト側はpsの呼び出しを変数越しにしています。

PS_BIN="${CHROME_REAPER_PS_BIN:-/bin/ps}"
...
done < <("$PS_BIN" -axo pid=,ppid=,etime=,command=)

テスト側はCHROME_REAPER_PS_BINに本物のpsではなくfixtureスクリプトを渡すだけで、本体コードは一切変えずに任意のプロセス一覧を注入できます。fixtureはこれだけです。

#!/bin/bash
cat <<'EOF'
101 1 02:00:00 ~/node_modules/.remotion/chrome-headless-shell/.../chrome-headless-shell about:blank --headless=old --no-sandbox --user-data-dir=/var/folders/.../puppeteer_dev_chrome_profile-HJ9hz3
102 1 02:00:00 /opt/other-tool/chrome-headless-shell about:blank --user-data-dir=/tmp/puppeteer_dev_chrome_profile-test
103 1 02:00:00 /tmp/ms-playwright/chromium-123/chrome-headless-shell about:blank
104 1 02:00:00 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=~/dev/browser-profile
105 1 02:00:00 /opt/other-tool/chrome-headless-shell about:blank --user-data-dir=/tmp/ordinary-profile
106 1 02:00:00 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/tmp/ordinary-profile
107 1 00:10:00 ~/node_modules/.remotion/chrome-headless-shell/chrome-headless-shell about:blank
108 2 02:00:00 ~/node_modules/.remotion/chrome-headless-shell/chrome-headless-shell about:blank
EOF

8行それぞれに役割があります。101〜104は「刈っていい」パターン(Remotion、汎用puppeteerプロファイル、ms-playwright、devプロファイルのGoogle Chrome)。105・106は同じ実行ファイルだがuser-data-dir/tmp/ordinary-profile=allowlist対象外。107・108が地味に効いていて、107はallowlistには一致するのに経過時間が10分しかない(閾値30分未満なので見送るべき)、108は実行パスは101と同じだがppidが2(launchdの子ではない=孤児ではない)。つまりこのfixtureは正規表現だけでなく、年齢フィルタとppidフィルタも同時に検証しています。

テスト本体はこの出力を使って、狙った4件だけが混ざっているかを確認します。

output="$(CHROME_REAPER_PS_BIN="$FIXTURE" CHROME_REAPER_MAX_AGE_MIN=30 "$SCRIPT" --dry-run)"
for pid in 101 102 103 104; do
  printf '%s\n' "$output" | grep -Fq "pid=$pid " || fail "dry-run missed pid $pid"
done
for pid in 105 106 107 108; do
  if printf '%s\n' "$output" | grep -Fq "pid=$pid "; then
    fail "dry-run incorrectly included pid $pid"
  fi
done

--dry-runで実際にはkillせず、候補一覧だけをログ出力するモードがあるおかげで、本物のスクリプトをそのまま起動して結果を突き合わせられます。

このテストファイルはis_target_processのコピーを自分でも持っていて(本体スクリプトからsourceしていない)、まず単体でTrue/False判定を確認したあとに、実スクリプトを--dry-runで丸ごと起動する二段構えです。ただしテスト内のコピーには本体にある「Google Chrome for Testing」対応と「scent-media除外」の2分岐が存在せず、fixtureの8行にもこの2パターンは含まれていません。つまりこの2分岐は現状どちらのテストでもカバーされていない穴です。allowlistに分岐を足すたびに、fixtureとテストコピーの両方を同時に更新しないと、静かに検証対象から漏れます。

踏んだ落とし穴:bash 3.2のset -uが「孤児0件」を毎回異常終了に見せていた

chrome-reaper.sh本体には、こういうコメント付きのガードがあります。

# bash 3.2 (macOS標準) は set -u 下で空配列の "${arr[@]}" 展開が unbound variable になる。
# 孤児が0本の回は毎回ここで落ちて exit 1 になり、launchd 側からは常時異常に見えていた。
if [ "${#PIDS[@]}" -gt 0 ]; then
  for pid in "${PIDS[@]}"; do
    kill -TERM "$pid" 2>/dev/null || true
  done
  ...
fi

chrome-reaper.shの1行目はset -Eeuo pipefail。macOS標準の/bin/bashはいまだにバージョン3.2(GPLv3を避けて更新が止まっている)で、このbash 3.2には空配列に"${arr[@]}"で触れると、set -u下では要素が0個ある配列ではなく"未定義変数"として扱われunbound variableエラーになるという古い癖があります。PIDS=()で宣言した配列を、ガードなしにいきなりfor pid in "${PIDS[@]}"で回すと、孤児が1件も見つからなかった――つまり一番平和で正常なはずの回――に限って即死し、set -eで異常終了する。launchdのログには「実行はされたが毎回異常終了」という状態が積み上がり、実際には何も壊れていないのに監視上はずっと赤信号でした。直し方は単純で、配列を展開する前に必ず${#PIDS[@]}で要素数をチェックしてから触る。allowlistの正確さをどれだけ詰めても、こういう言語仕様側の地雷は別枠で踏みます。

その他、細かく効いた点を並べておきます。

  • MAX_AGE_MINのバリデーションを先頭でやる ―― case "$MAX_AGE_MIN" in ''|*[!0-9]*|0) で空文字・非数字・0を弾く。環境変数の型崩れは実行時ではなく起動直後に落とすのが安全
  • 経過時間のパースはpsetime書式ゆれを想定する ―― elapsed_to_secondsD-HH:MM:SS / MM:SS / SSの3パターンをheredoc経由のIFS=: readで分岐している。日数付き経過時間(1-02:00:00のような長時間孤児)を見落とすと閾値判定が壊れる
  • launchdStartCalendarIntervalは毎時5分・35分の2回(plistのMinute: 535)。既定の閾値30分とほぼ噛み合わせてあり、孤児が生まれてから最大1サイクル分の猶予で次の巡回に捕まる設計
  • LowPriorityIONice: 10ProcessType: Backgroundをplistに明示 ―― 定期ジョブがフォアグラウンド作業のCPU/IOを奪わないようにする、地味だが効くマナー設定

まとめ

  • 定期killジョブの誤爆対策は、実行パス単体ではなくpath × --user-data-dirのAND条件でallowlistを組む。狭いdenyを先に挟むのも有効
  • ps呼び出しを環境変数(CHROME_REAPER_PS_BIN)越しにすることで、本体コードを一切変えずにfixtureへ差し替えてallowlist・年齢フィルタ・ppidフィルタをまとめて検証できる
  • allowlistに分岐を足すときは、fixtureとテスト側のコピーも同時に更新しないとカバレッジの穴になる
  • macOS標準bash 3.2のset -u空配列展開でunbound variableになる。「0件で正常終了」のパスこそ明示的にガードしないと、launchdからは常時異常に見える

次は、この~/dev/配下の自動化群がどうuser-data-dirを払い出してポート衝突を避けているか、その割り当て側の話を書く予定です。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!