Last time I wrote about falling back gracefully when note.com breaks image links in tables. This one is a different flavor: the quietly dangerous business of running a scheduled job whose entire purpose is to kill Chrome.
The agent-browser skill, Remotion video renders, and Playwright/Puppeteer tests all spin up headless or dedicated-profile Chrome instances behind the scenes. When the parent process dies abnormally, that child Chrome can survive on its own, get adopted by launchd (pid 1), and sit there as an orphan eating memory. I run a script called chrome-reaper.sh from launchd every 30 minutes to cull them. The scary part is that if the targeting is off, it will also take out the Chrome window I'm browsing in right now. This article covers the design that prevents that misfire, plus a bash bug I actually hit along the way.
The problem: telling "orphan" from "in use" with a single line of ps output
What chrome-reaper.sh does is simple. List every process with ps, and kill the ones that match the criteria.
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=)
The criteria are three conditions ANDed together: (1) the parent is launchd (ppid=1, which is the evidence it was adopted after an abnormal exit), (2) is_target_process decides "this is an automation Chrome," and (3) it has been alive longer than the default 30 minutes (CHROME_REAPER_MAX_AGE_MIN). If you're sloppy about condition (2), your everyday Google Chrome can get caught in the blast.
The fix: an allowlist that constrains both the path and the user-data-dir
is_target_process is an allowlist built by stacking up only the things that are safe to kill.
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
}
The key point is to never decide based on the executable path alone. Any regular Google Chrome would match on the executable path, so the regex adds an AND condition requiring --user-data-dir to live under ~/dev/ (that is, a profile carved out specifically for automation). Conversely, the scent-media CDP Chrome that stays resident on purpose (ensure_chrome.sh deliberately keeps it up on port 9223) would match the ~/dev/ path, so it gets rejected up front with an explicit return 1. It's a narrow deny embedded inside the allowlist.
Testing: swap out the entire ps output with a fixture
I want to verify that this allowlist reaps only the intended orphans, without launching real processes. On the script side, the ps call goes through a variable.
PS_BIN="${CHROME_REAPER_PS_BIN:-/bin/ps}"
...
done < <("$PS_BIN" -axo pid=,ppid=,etime=,command=)
On the test side, you just point CHROME_REAPER_PS_BIN at a fixture script instead of the real ps, and you can inject any process list you like without touching the main code at all. The fixture is nothing more than this.
#!/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
Each of the 8 lines has a job. 101 through 104 are the "safe to reap" patterns (Remotion, a generic puppeteer profile, ms-playwright, and Google Chrome with a dev profile). 105 and 106 use the same executables, but their user-data-dir is /tmp/ordinary-profile, which is outside the allowlist. 107 and 108 are the quietly important ones. 107 matches the allowlist but has only been running for 10 minutes (under the 30-minute threshold, so it should be skipped). 108 has the same executable path as 101 but its ppid is 2 (not a child of launchd, so not an orphan). In other words, this fixture exercises not just the regex but the age filter and the ppid filter at the same time.
The test body uses that output to confirm that exactly the intended 4 entries show up.
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
Because there's a --dry-run mode that logs the candidate list without actually killing anything, I can launch the real script as-is and compare its results.
Note: This test file carries its own copy of
is_target_process(it doesn't source the main script). It's a two-stage setup: first it checks the True/False verdicts in isolation, then it launches the real script end to end with--dry-run. However, the copy inside the test lacks two branches that exist in the main script, the "Google Chrome for Testing" handling and the "scent-media exclusion," and the 8 fixture lines don't include those two patterns either. So right now those two branches are a gap that neither test covers. Every time you add a branch to the allowlist, you have to update both the fixture and the test copy in the same change, or it silently drops out of verification.
The trap I stepped in: bash 3.2's set -u made "zero orphans" look like a failure every single time
The main chrome-reaper.sh has a guard with a comment like this.
# 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
The first line of chrome-reaper.sh is set -Eeuo pipefail. The stock /bin/bash on macOS is still version 3.2 (updates stopped to avoid GPLv3), and this bash 3.2 has an old quirk: touching an empty array with "${arr[@]}" under set -u treats it not as an array with zero elements but as an "undefined variable," producing an unbound variable error. If you declare an array with PIDS=() and then loop over it directly with for pid in "${PIDS[@]}" without a guard, the script dies instantly precisely on the runs where no orphans were found, which is to say the most peaceful, most normal runs of all, and set -e turns that into an abnormal exit. The launchd logs piled up a state of "it ran, but it exited abnormally every time." Nothing was actually broken, yet monitoring showed a permanent red light. The fix is simple: always check the element count with ${#PIDS[@]} before expanding the array. No matter how carefully you tune the allowlist, landmines in the language spec get stepped on in a separate category.
A few other details that quietly mattered:
-
Validate
MAX_AGE_MINat the top.case "$MAX_AGE_MIN" in ''|*[!0-9]*|0)rejects empty strings, non-digits, and zero. A malformed environment variable should fail right at startup, not at runtime. -
Parsing elapsed time has to account for
ps's varyingetimeformats.elapsed_to_secondsbranches on three patterns,D-HH:MM:SS/MM:SS/SS, usingIFS=: readover a heredoc. If you miss the day-prefixed form (a long-lived orphan like1-02:00:00), the threshold comparison breaks. -
launchd'sStartCalendarIntervalfires twice an hour, at minute 5 and minute 35 (Minute: 5and35in the plist). That roughly lines up with the default 30-minute threshold, so an orphan gets caught on the next sweep with at most about one cycle of grace after it's born. -
LowPriorityIO,Nice: 10, andProcessType: Backgroundare set explicitly in the plist. This keeps the scheduled job from stealing CPU/IO from foreground work. Unglamorous, but it's the kind of etiquette that pays off.
Summary
- To keep a scheduled kill job from misfiring, build the allowlist on an AND of path ×
--user-data-dir, not the executable path alone. Slotting a narrow deny in first also works well. - Routing the
pscall through an environment variable (CHROME_REAPER_PS_BIN) lets you swap in a fixture without changing a single line of the main code and verify the allowlist, the age filter, and the ppid filter all at once. - When you add a branch to the allowlist, update the fixture and the test-side copy at the same time, or you get a coverage hole.
- macOS's stock bash 3.2 under
set -uthrows unbound variable on empty array expansion. The "zero results, exit normally" path is exactly the one you have to guard explicitly, orlaunchdwill see a permanent failure.
Next, I plan to write about the allocation side: how the automation tools under ~/dev/ hand out their user-data-dir values and avoid port collisions.
Do you have a scheduled job that has been reporting failure for weeks while actually doing its job fine?
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)