Automation doesn't break when you write it. It breaks in production, weeks later, quietly — and my Chrome concurrency limit broke three separate times before it settled.
Why This Mechanism Matters
Once you run automation seriously as a solo developer, you always hit the same wall: multiple jobs trying to use the same resource at the same time.
In the context of social media automation, that resource is Chrome. Liking, following, posting, note integration — browser-driven jobs run around the clock. In my setup, a launchd job called com.lily.autolike.ig-1 fires every two hours, 12 times a day (0:36, 2:36, 4:36 … 22:36), and drives automatic likes through Chrome. That alone is 12 processes a day, and jobs of the same kind exist across multiple accounts and multiple social networks.
With no control at all, overlapping start times mean nine Chrome instances launch simultaneously. It's not hard to imagine the Mac screaming.
The problem is how it breaks. If one of them crashed from memory exhaustion, that would at least be legible. What actually happens is that every process stays alive but becomes extremely slow, and timeouts pile up. The run looks like it completed, but effectively nothing happened. The logs fill with TIMEOUT: killed after 900s.
The idea of "control this with a cap on concurrent launches" is itself correct. What I got wrong was the basis for the cap value, and how I treated priority across job types.
~/.claude/scripts/browser-slot.sh carries all three mistakes and their fixes, etched into a single file. The comments serve as a change log, and tracing the numbers and dates reveals the structure of the failures.
The Real Problem: Intuitive Resource Blame
When automation gets heavy, your intuition says "something must be eating resources." Chrome sure looks heavy. My decision to set the cap to 3 was based on the hypothesis that Chrome was devouring memory.
When the hypothesis is wrong, the countermeasure is wrong. The cap of 3 was a number born from the misdiagnosis "throttle Chrome and things get lighter." Until I actually measured, I didn't notice that this misdiagnosis was producing 92 skips a day.
Put the other way around: measuring and recording it in a comment makes the misdiagnosis visible. The script's comments (lines 5–8) read as a postmortem report on their own.
# 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/
# ComfyUI(12GB)/iii(3.5GB)であってブラウザジョブではなかった。上限3は過剰に厳しく
# 1日で92回のskip(全体の30%)を出していたので5に緩める。
SLOT_MAX="${BROWSER_SLOT_MAX:-6}"
The comment says "relax it to 5," but the actual default value of SLOT_MAX is 6. At the time I wrote the comment I was assuming 5, but I ended up setting 6. That very "gap between comment and value" is the fingerprint of a fix made in one sitting.
Lock Files Live or Die by Atomicity
For the semaphore mechanism, browser-slot.sh chose the atomicity of the filesystem's mkdir.
GUARD_DIR="$SLOT_DIR/.guard"
acquire_guard() {
attempt=0
while [ "$attempt" -lt 100 ]; do
if mkdir "$GUARD_DIR" 2>/dev/null; then
printf '%s\n' "$$" > "$GUARD_DIR/pid"
GUARD_HELD=1
return 0
fi
# ...ゾンビガードの掃除...
attempt=$((attempt + 1))
sleep 0.05
done
return 1
}
mkdir is atomic under POSIX. If the directory doesn't exist it is created and succeeds; if it already exists it fails. This mutual exclusion, which doesn't consume a single byte, guarantees that even when multiple jobs call acquire_guard() simultaneously, exactly one gets through.
The process that acquires the guard then scans the *.lock files under ~/.cache/lily-browser-slots/ to count how many are currently running. The first line of a lock file is the PID, and liveness is checked with kill -0 $pid. Lock files belonging to dead processes get cleaned up.
for lock in "$SLOT_DIR"/*.lock; do
[ -e "$lock" ] || continue
lock_pid="$(sed -n '1p' "$lock" 2>/dev/null)"
# ...
if ! kill -0 "$lock_pid" 2>/dev/null; then
rm -f "$lock"
continue
fi
running=$((running + 1))
If the running count is below the limit, it writes a .lock file under its own label name, releases the guard, and runs the command. If the count is at or above the limit, it either skips or waits.
The advantage of this approach is that it self-heals even when a process dies abnormally. The lock file of a crashed process gets cleaned up by whoever comes next. Files written to /tmp/ disappear on reboot. And unlike flock, there's no kernel-side FD management involved. For something a shell script has to handle, this "create a file, and clean it up if it dies" approach is the most robust.
Don't Leave Timeouts to the Shell
The timeout for the executed command is controlled by a Perl subprocess rather than the timeout command (lines 277–327). The reason is that the timeout command can kill only the direct child while leaving grandchildren behind.
Perl's descendants() function builds a PID-to-PPID table for all processes via /bin/ps -axo pid=,ppid=, then recursively collects descendants from the root. On timeout it kills every descendant in the order SIGTERM → wait 1 second → SIGKILL.
local $SIG{ALRM} = sub { $timed_out = 1; stop_tree(); };
alarm $timeout;
The exit code on timeout is exit 124. The script side detects this and records it in the log as RESULT="timeout:${TIMEOUT_SEC}s".
com.lily.autolike.ig-1.plist passes AUTOLIKE_TIMEOUT_SEC=3700 (about 61 minutes) as an environment variable, but the TIMEOUT_SEC default in browser-slot.sh is 900 seconds (15 minutes). Unless each job overrides BROWSER_SLOT_TIMEOUT_SEC, it gets killed at 15 minutes.
The Overall Flow
Here's a diagram of how a single auto-like job "borrows one Chrome slot, does its work, and returns it."
launchd
com.lily.autolike.ig-1 ←── 0:36 / 2:36 / ... / 22:36(12回/日)
│
│ /bin/bash ~/dev/social-autolike/scripts/run-account.sh ig-1
▼
run-account.sh ig-1
│
│ browser-slot.sh ig-1 --group engage --group-max N -- <chrome-cmd>
▼
browser-slot.sh
│
├─① acquire_guard()
│ mkdir ~/.cache/lily-browser-slots/.guard ← アトミック排他
│
├─② *.lock を走査して running を数える
│ 死んだPIDのロックは掃除
│
├─③ 実効上限を決める
│ reserved group (post)?
│ YES → effective_max = SLOT_MAX (= 6)
│ NO → effective_max = SLOT_MAX - SLOT_RESERVE_COUNT (= 5)
│
├─④ running >= effective_max ?
│ YES + WAIT_SEC > 0 → 15〜45秒ランダム待機してリトライ
│ YES + 待機限界 → SKIP:global-limit をログに書いて exit 0
│ NO → .lock ファイルを書いてガード解放
│
├─⑤ Perl supervisor で <chrome-cmd> を起動
│ alarm(TIMEOUT_SEC=900) ← SIGALRM でタイムアウト
│ 子孫PIDを再帰収集して SIGTERM→SIGKILL
│
└─⑥ cleanup()
.lock 削除
slot.log へ result=exit:0 / timeout:900s 等を記録
Slot acquisition happens only inside acquire_guard. Even with multiple jobs running at once, only one process holds the guard at a time. Because counting the running total and writing the .lock file both complete inside the guard, you never get the race where "I counted and was under the limit, but another process passed through at the same time and pushed us over."
File Layout and Key Variables
SLOT_DIR="${BROWSER_SLOT_DIR:-$HOME/.cache/lily-browser-slots}"
SLOT_MAX="${BROWSER_SLOT_MAX:-6}"
SLOT_RESERVED_GROUPS="${BROWSER_SLOT_RESERVED_GROUPS:-post}"
SLOT_RESERVE_COUNT="${BROWSER_SLOT_RESERVE:-1}"
TIMEOUT_SEC="${BROWSER_SLOT_TIMEOUT_SEC:-900}"
WAIT_SEC="${BROWSER_SLOT_WAIT_SEC:-600}"
SLOT_LOG="${BROWSER_SLOT_LOG:-$HOME/.cache/lily-browser-slots/slot.log}"
Every variable is designed to be overridable via environment variables. Change BROWSER_SLOT_MAX and you change the cap without touching the script at all. It's the same mechanism by which com.lily.autolike.ig-1.plist passes AUTOLIKE_TIMEOUT_SEC=3700 — each job can have its own behavior through launchd's EnvironmentVariables block.
The slot label is the first command argument (e.g. ig-1). It becomes the filename ig-1.lock directly, and a duplicate launch under the same label is prevented with SKIP: slot already held. launchd starts jobs via StartCalendarInterval, but even if the previous run is still alive past 60 minutes, a second one won't run at the next start.
Group limits are the higher-level control. Pass --group engage --group-max 3 and you can layer on a "the engage group gets at most 3" restriction separate from the global cap. Put likes, follows, and note engagement in the same group and you prevent the state where slots are free but too many engage jobs run in parallel.
Logs are appended to slot.log in a one-line format.
2026-08-15T07:42:11+0900 label=xpilot.autopost group=post result=exit:0
2026-08-15T07:43:05+0900 label=ig-1 group=engage result=skip:global-limit:6/5:waited=600s
This format is the raw material for aggregation. Count the result=skip:global-limit lines and you know that day's slot contention. If result=timeout:900s starts increasing, that's a sign something is jammed.
How It's Actually Called
When launchd's plist starts run-account.sh ig-1, that script calls browser-slot.sh internally. The call looks like this.
browser-slot.sh "ig-1" \
--group engage \
--group-max 3 \
-- python3 ~/dev/social-autolike/src/like.py ig-1
ig-1 is the slot label. It fits within the engage group's 3-slot cap, and if it also hits the global cap (effectively 5 slots for engage), it waits. The maximum wait is WAIT_SEC=600 (10 minutes), retrying at random intervals of 15–45 seconds during that window. The randomization exists to avoid re-collision when multiple jobs time out simultaneously and start retrying simultaneously.
retry_delay=$((15 + RANDOM % 31))
If no slot frees up after 600 seconds, it skips and exits 0. It's exit 0 so that launchd doesn't record it as an error. A skip isn't a "failure" — it's "we passed this time," and it gets naturally retried at the next start (two hours later).
Implementation Details
The Crux of acquire_slot(): The Effective-Cap Branch
The heart of acquire_slot() is that it doesn't use the global cap as-is. The most important branch in the code is lines 177–188.
# 予約グループはSLOT_MAXまで使える。それ以外は予約分を差し引いた実効上限で止める。
effective_max="$SLOT_MAX"
if ! is_reserved_group "$GROUP"; then
effective_max=$((SLOT_MAX - SLOT_RESERVE_COUNT))
[ "$effective_max" -lt 1 ] && effective_max=1
fi
if [ "$running" -ge "$effective_max" ]; then
SLOT_BLOCK_MESSAGE="SKIP: global limit reached ($running/$effective_max)"
SLOT_BLOCK_RESULT="skip:global-limit:$running/$effective_max"
SLOT_BLOCK_WAITABLE=1
release_guard
return 1
fi
is_reserved_group() scans the whitespace-separated list in SLOT_RESERVED_GROUPS (default "post") and checks whether the given group name is in it.
is_reserved_group() {
_needle="${1:-}"
[ -n "$_needle" ] || return 1
for _g in $SLOT_RESERVED_GROUPS; do
[ "$_g" = "$_needle" ] && return 0
done
return 1
}
The unquoted $SLOT_RESERVED_GROUPS is expanded directly by for. This is a deliberate use of shell word splitting: if you want to list multiple groups whitespace-separated like "post engage", you just add them to the env variable.
The effective-cap computation is simple. With SLOT_MAX=6 and SLOT_RESERVE_COUNT=1, the engage group's cap becomes 6 - 1 = 5. Only the post group can use all 6. That one-slot difference creates the guarantee that "even with 5 engage jobs running, a post always gets in."
The guard [ "$effective_max" -lt 1 ] && effective_max=1 matters too. It prevents the accident where setting SLOT_RESERVE_COUNT to a value at or above SLOT_MAX drives the effective cap to zero or below and stops every job.
What the Lock File's Three Lines Mean
When a slot is acquired, three lines are written to the lock file named after the label (line 198).
printf '%s\n%s\n%s\n' "$$" "$GROUP" "$(date +%s)" > "$LOCK_FILE"
Line 1 is the PID, line 2 is the group name, line 3 is UNIX time (epoch seconds).
The PID on line 1 plays a dual role. One is the liveness check. When scanning lock files, kill -0 "$lock_pid" 2>/dev/null confirms whether the process is alive. If it's dead, the lock file is deleted and the scan moves on. The other is self-verification at cleanup time. When cleanup() runs, it checks that the PID read with sed -n '1p' matches its own $$ before deleting the file. This prevents the accident of mistakenly removing another process's lock file.
The group name on line 2 is for group-limit counting. During the scan, lock_group="$(sed -n '2p' "$lock")" reads it out, and if it matches your own group, group_running is incremented. Because the lock file itself carries the group information, there's no need to query the running processes.
The epoch seconds on line 3 are for debugging. When the logs alone don't tell you "when did this slot start," you can cat the lock file directly and see the start time.
Why "Random" Works in the Wait Loop
Retries after a failed slot acquisition use a random wait rather than a fixed interval (line 269).
retry_delay=$((15 + RANDOM % 31))
A random wait of 15–45 seconds.
Why not fixed? Suppose five engage jobs end up waiting for a slot at the same time. If they all retry on a fixed 20 seconds, then 20 seconds later all five call acquire_guard() simultaneously. Only one can take the guard with mkdir; the other four are rejected and wait another 20 seconds. Repeat that and you get a periodic scramble over the guard, and throughput drops. Randomizing spreads out the retry timing, producing a flow where slots fill up in sequence. It's the standard technique for avoiding the simultaneous-retry collision known as the "thundering herd."
There's also a comparison against the remaining wait time (lines 271–273).
remaining=$((WAIT_SEC - waited))
if [ "$retry_delay" -gt "$remaining" ]; then
retry_delay="$remaining"
fi
This prevents a "retry in 30 seconds" setting when only 5 seconds remain on the wait timeout. It's designed to keep making meaningful attempts right up to the edge of the remaining time.
Why Perl Wipes Out the Whole Process Tree
The reason timeout handling isn't left to the shell's timeout command comes down to how grandchild processes are handled.
The Perl supervisor's descendants() function (lines 287–303) builds a system-wide PID-to-PPID table via /bin/ps -axo pid=,ppid= and enumerates all descendants of the given PID with a BFS.
sub descendants {
my ($root) = @_;
my %children;
open my $ps, "-|", "/bin/ps", "-axo", "pid=,ppid=" or return ();
while (my $line = <$ps>) {
next unless $line =~ /^\s*(\d+)\s+(\d+)\s*$/;
push @{$children{$2}}, $1;
}
close $ps;
my @queue = ($root);
my @found;
while (@queue) {
my $parent = shift @queue;
for my $pid (@{$children{$parent} || []}) {
push @found, $pid;
push @queue, $pid;
}
}
return @found;
}
On timeout, stop_tree() sends SIGTERM to the descendants in reverse order (child → parent), waits 1 second, then sends SIGKILL to any process still alive (lines 306–312).
sub stop_tree {
return if $stopping++;
my @pids = descendants($child);
kill "TERM", reverse(@pids), $child;
select undef, undef, undef, 1;
kill "KILL", grep { kill 0, $_ } reverse(@pids), $child;
}
return if $stopping++ ensures idempotency. Even if SIGALRM and SIGTERM arrive at the same time and stop_tree() gets called twice, only the first call executes.
Chrome spawns many child processes — renderer, GPU, network service, and so on. Because the shell's timeout command only signals the direct child, grandchildren and below survive. Those leftover Chrome processes created a state where "Chrome is already running" at the next launch, causing a problem where the login session couldn't be obtained. Since switching to Perl killing all descendants together, this zombie-Chrome problem has stopped appearing.
Don't Let launchd See a Skip as an Error
When a slot can't be acquired, the script ends with exit 0 (lines 257–260).
if [ "$SLOT_BLOCK_WAITABLE" -ne 1 ] || [ "$WAIT_SEC" -eq 0 ]; then
echo "$SLOT_BLOCK_MESSAGE"
RESULT="$SLOT_BLOCK_RESULT"
exit 0
fi
com.lily.autolike.ig-1.plist uses StartCalendarInterval for fixed-time launches (0:36, 2:36 … 22:36). launchd treats a non-zero exit code as a "failed job" and in some cases imposes a ThrottleInterval penalty.
A skip is "passing this time," not a failure. It gets naturally retried at the next start two hours later. The log records result=skip:global-limit, so a human can tell the difference. To launchd, it looks like a normal exit. Reconciling both is the reason for exit 0.
Where I Got Stuck
Miscalculation 1: "Chrome Is Heavy" Was Just an Assumption
When I first built the slot management, I set the cap to 3. The basis was a feeling. Chrome sure looks heavy, so let's hold it to less than half — that was the judgment.
The symptoms started quietly. It felt like the number of auto-likes processed was dropping. Aggregating the logs showed 92 result=skip:global-limit entries a day: about 30% of all runs finished without even acquiring a slot.
But I had a confirmation bias — "even if it's skipped, the next run should cover it" — and left it alone for a while. It'll try again in two hours, so we're fine.
The confirmation bias collapsed when I actually measured with vm_stat and top. The result is preserved verbatim in the script's comments.
# 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/
# ComfyUI(12GB)/iii(3.5GB)であってブラウザジョブではなかった。上限3は過剰に厳しく
# 1日で92回のskip(全体の30%)を出していたので5に緩める。
SLOT_MAX="${BROWSER_SLOT_MAX:-6}"
Against 0.7GB total for nine Chrome-family jobs, dasd (Apple's log daemon) was using 47GB and ComfyUI (an image-generation AI server) 12GB. Chrome's 0.7GB was within the margin of error. The hypothesis that Chrome was the culprit was completely wrong.
The comment says "relax it to 5" while the variable's default is 6. That's the fingerprint of measuring, thinking "I figured 5 would do, but 6 is fine," changing my mind midway, fixing only the variable, and leaving the comment half-written. When you make a fix you're not confident about, you hesitate while writing the comment. Write "5," then reconsider with "6 would be fine too," change only the variable — and this gap is born.
The core of the fix was in the variable design. Because I'd built BROWSER_SLOT_MAX to be overridable via an environment variable, I could change the cap without touching the script at all. Just add one line, BROWSER_SLOT_MAX=5, to launchd's plist. If I'd hardcoded the number inside the script, every change would have meant editing and redeploying the file.
Miscalculation 2: I Raised the Cap Back to 6, and the Post Lane Jammed 9 Times
Six days after relaxing the cap, on 2026-08-15, a new problem appeared.
Checking the morning logs, I found 9 consecutive result=skip:global-limit:6/5 entries for the post job (xpilot.autopost). The slot cap was supposed to be 6, but the record said it jammed at "6/5" — at first I thought it was an aggregation bug.
The record is preserved verbatim in the script's comments (lines 9–14).
# 2026-08-15: 朝の7本同時timeoutで枠6が死んだrunに占有され、投稿レーン(xpilot.autopost
# 等)が global-limit で9回skipした。いいね/フォローは1回落ちても翌回で取り返せるが、
# 投稿はその時間帯の枠が消えると二度と埋まらない。そこで投稿用に枠を予約し、
# engage系(いいね/フォロー/note等)はSLOT_MAXより1つ少ない実効上限で動かす。
Here's the cause. Seven engage-group jobs started simultaneously in the morning window, and each process jammed at TIMEOUT_SEC=900 (15 minutes). For those 15 minutes until timeout, the slots were legitimately occupied. With all 6 slots full, the post job tried to enter, but running=6 is at or above effective_max=6, so it was rejected. This repeated 9 times.
The core of the problem is this: "a like can be recovered on the next run, but once a post's window in that time slot is gone, it never gets filled."
Auto-likes get 12 launch opportunities per day. One skip doesn't change the total processed count much. But a posting schedule is a time specification — "publish at 9 AM." Miss that window and the content becomes either "posted a day late" or "a missing number." Because posting in a specific time slot directly affects engagement rate, the cost of a skip is completely different from that of a like.
Even with the same skip:global-limit symptom, the severity differed entirely depending on job type.
The fix: I added two env variables to introduce a reserved-slot mechanism. I set up SLOT_RESERVED_GROUPS=post (the reserved group name) and SLOT_RESERVE_COUNT=1 (the reserve count), making the engage group's effective_max = 6 - 1 = 5. This guarantees "even with 5 engage jobs running, post always gets the 6th."
The change to the script was minimal. I only added is_reserved_group() and inserted a 4-line block into the cap check in acquire_slot(). It takes effect either by having the calling shell script pass --group post, or by setting BROWSER_SLOT_RESERVED_GROUPS=post in the plist's EnvironmentVariables block.
Miscalculation 3: Timeout Values Managed in Two Places
com.lily.autolike.ig-1.plist has the following environment variable set.
<key>EnvironmentVariables</key>
<dict>
<key>AUTOLIKE_TIMEOUT_SEC</key>
<string>3700</string>
</dict>
AUTOLIKE_TIMEOUT_SEC=3700 (about 61 minutes). Meanwhile, the variable browser-slot.sh uses for the Perl supervisor's timeout is BROWSER_SLOT_TIMEOUT_SEC, defaulting to 900 seconds (15 minutes). The variable names differ.
AUTOLIKE_TIMEOUT_SEC is the variable run-account.sh reads, controlling the processing timeout on the Python script side. TIMEOUT_SEC in browser-slot.sh is a separate-layer timeout for when the Perl supervisor force-terminates.
This is where I got stuck. The Python script is built on the premise that it "can take up to 61 minutes," but if the Perl supervisor is left set to "force-terminate at 15 minutes," anything that doesn't finish within that window times out. Conversely, if you forget to set BROWSER_SLOT_TIMEOUT_SEC and run at the 900-second default, account operations that would have completed get killed at 15 minutes. This misconfiguration was hard to notice because an exit-124 log only records "timeout" — it doesn't tell you "the timeout cap is too small."
The plist's Nice: 10 and LowPriorityIO: true settings are also involved.
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
A nice value of 10 lowers CPU scheduling priority, and launchd aggressively defers this job's work when the system is under load. It's the right setting for system stability, but it has the side effect that "low priority makes processing slower, which in turn makes it easier to hit the 900-second timeout." It's one of the reasons you need to set a timeout value with headroom.
The fix: I added one line inside run-account.sh that reads AUTOLIKE_TIMEOUT_SEC and passes it to browser-slot.sh as BROWSER_SLOT_TIMEOUT_SEC. Having "the timeout Python uses" and "the timeout the Perl supervisor uses" be separate variables is unavoidable, but writing the handoff from the former to the latter explicitly in code eliminated the state of "I configured it but it isn't taking effect." Env variables in general — not just timeouts — make it hard to trace the route of where they're passed and where they're used. Checking once with printenv which process a value written in launchd's plist actually reaches is the reliable move.
Pitfalls
Miscalculations 1–3 (the Chrome misdiagnosis, the post-lane jam, the duplicated timeout management) were covered in detail above. Here I'll list the other, finer-grained traps I actually stepped on.
A gap between comment and variable value is the fingerprint of mid-change hesitation
The comment at lines 5–8 of the script says "relax it to 5," but the actual variable is SLOT_MAX="${BROWSER_SLOT_MAX:-6}".
# 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/
# ComfyUI(12GB)/iii(3.5GB)であってブラウザジョブではなかった。上限3は過剰に厳しく
# 1日で92回のskip(全体の30%)を出していたので5に緩める。
SLOT_MAX="${BROWSER_SLOT_MAX:-6}"
This isn't a bug — it's the fingerprint of "wrote 5, reconsidered that 6 would be fine, changed only the variable, and left the comment half-written." The smaller the one-line change, the more likely you forget to update the comment. Reading this comment myself later, I spent 5 minutes debugging "it should be 5 but it's 6 — is this a bug?" You need the habit of fixing the comment first when you change a variable.
I underestimated the side effects of Nice: 10 and LowPriorityIO: true
The plist has these three set together.
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
It's the correct setting for a design that doesn't pressure the system, but it has side effects. When high-load processes like dasd or ComfyUI are running, macOS aggressively defers nice-10 jobs. You get a state where "work that normally finishes in 5–8 minutes takes 12–14 minutes," and the headroom on TIMEOUT_SEC=900 (15 minutes) effectively shrinks to 1–3 minutes. Timeout values need headroom that factors in nice-induced delay. This is often the cause behind the symptom "the settings are right but timeouts keep happening."
There's intent behind the zombie-guard cleanup timing
Inside acquire_guard(), when the guard directory exists but the contents of the PID file are invalid, cleanup waits until attempt reaches 20 or more (script lines 110–126).
case "$guard_pid" in
''|*[!0-9]*)
if [ "$attempt" -ge 20 ]; then
rm -f "$GUARD_DIR/pid"
rmdir "$GUARD_DIR" 2>/dev/null || true
fi
The reason it doesn't clean up immediately from attempt=0 is to protect "the tiny window between acquiring the guard with mkdir and writing with printf '%s\n' "$$" > "$GUARD_DIR/pid"." If another process peeks during that gap, the PID file looks empty or nonexistent. It waits about 1 second (0.05s × 20) before deciding it's "genuinely an orphaned zombie guard." Not knowing this intent, I started debugging "why can't I take the guard immediately?" and burned 30 minutes.
Specifying only one of --group and --group-max exits 1
The argument-parsing section of the script (lines 233–239) has the following interdependency check.
if [ -n "$GROUP" ] && [ -z "$GROUP_MAX" ]; then
echo "--group requires --group-max" >&2
exit 1
fi
if [ -z "$GROUP" ] && [ -n "$GROUP_MAX" ]; then
echo "--group-max requires --group" >&2
exit 1
fi
Write only --group engage and forget --group-max 3, and the script dies with exit 1. launchd records this as an error exit and may apply a ThrottleInterval penalty. The error message itself is clear, but you won't notice until you check ~/dev/social-autolike/logs/ig-1.launchd.log. Setting only BROWSER_SLOT_GROUP in the plist's EnvironmentVariables and forgetting BROWSER_SLOT_GROUP_MAX produces the same symptom.
SLOT_RESERVE_COUNT >= SLOT_MAX makes the effective cap 1
In the computation effective_max = SLOT_MAX - SLOT_RESERVE_COUNT, mistakenly setting SLOT_RESERVE_COUNT at or above SLOT_MAX drives the effective cap to zero or below. The script has the fallback [ "$effective_max" -lt 1 ] && effective_max=1, so it doesn't stop completely, but you end up in a state where "the entire engage group is limited to 1 slot." Skips spike, but since it returns exit 0 to launchd, you won't find out unless you aggregate slot.log. Always set SLOT_RESERVE_COUNT to a value smaller than SLOT_MAX.
The character set usable in slot labels is limited
In the script's argument validation (lines 207–209), a label containing anything other than alphanumerics, dots, underscores, colons, and hyphens dies with exit 1.
case "$SLOT_LABEL" in
*[!A-Za-z0-9._:-]*) echo "invalid slot label: $SLOT_LABEL" >&2; exit 1 ;;
esac
Because the lock filename becomes $SLOT_LABEL.lock, the design rejects characters that would cause filesystem trouble. I got stuck trying to use an account identifier containing an at sign, like @ig_account, directly as a label. You need to convert it to a simple identifier like ig-1 before passing it.
The morning "7 simultaneous timeouts" is hard to avoid by plist design
Looking at StartCalendarInterval in com.lily.autolike.ig-1.plist, this job launches daily at 6:36 (Hour=6, Minute=36). If multiple engage jobs are set to the same Minute=36, launchd tries to start them simultaneously. launchd does not guarantee start order.
If 7 engage-family jobs all start at 6:36 and each holds its slot until reaching the TIMEOUT_SEC=900 limit, then for the 75 minutes from 6:36 to 7:51, all 6 slots are occupied by processes that are "running but effectively doing nothing." You could also solve this with a design change that staggers start times by 2–5 minutes per job, but against the cost of rewriting every plist, the reserved slot — which takes just two env variables (BROWSER_SLOT_RESERVED_GROUPS=post and BROWSER_SLOT_RESERVE=1) — was the cheaper fix.
exit-0 skips are invisible without log aggregation
A skip from failing to get a slot exits 0. Nothing is left in launchd's error log. Unless you aggregate slot.log, you have no idea "how many were skipped today."
For the first week, a 30% skip rate was happening and I thought "no errors in launchd, so it's fine." I later added a cron that runs grep 'result=skip' ~/.cache/lily-browser-slots/slot.log | wc -l every morning, but I should have set that up from day one of the slot management. Automation without monitoring keeps you believing "it's not broken" while it's broken.
Best Practices
Here are guidelines, distilled from the implementation and the failures, for building a semaphore mechanism of this kind.
1. Make every variable overridable via environment variables
Write things in the form SLOT_MAX="${BROWSER_SLOT_MAX:-6}" and you can change behavior from launchd's EnvironmentVariables block without touching the script at all. No more editing and re-copying the file every time you change the cap. You can flexibly handle requirements like "I want the cap to be 3 only in this environment" or "I want a longer timeout for this one job." With the four variables BROWSER_SLOT_MAX, BROWSER_SLOT_TIMEOUT_SEC, BROWSER_SLOT_RESERVED_GROUPS, and BROWSER_SLOT_RESERVE in place, per-environment tuning is complete with zero changes to the script body.
2. Skips should exit 0 so launchd doesn't see an error
"We passed this time" is not a job failure. launchd records a non-zero exit code as a failure and may apply a ThrottleInterval penalty. Design skips to exit 0 and leave the human-facing record in slot.log. You need two layers of recording: "normal exit for launchd" and "details in slot.log."
3. Write the measurement date and concrete numbers in comments
# 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/ComfyUI(12GB)
SLOT_MAX="${BROWSER_SLOT_MAX:-6}"
Write the measured values and the date next to the variable and you can trace "why this number" later. The comment stands in for a change log, and it also creates motivation to update it at the next measurement. A "number decided by feel" has no update criterion, but a "number decided by measurement" automatically comes with the condition "measure again next time and change it if it exceeds this value."
4. Separate timeout layers explicitly by variable name
The application-side timeout (AUTOLIKE_TIMEOUT_SEC) and the Perl supervisor's timeout (BROWSER_SLOT_TIMEOUT_SEC) serve different roles. You need to write code in run-account.sh that explicitly connects the two.
export BROWSER_SLOT_TIMEOUT_SEC="${AUTOLIKE_TIMEOUT_SEC:-900}"
Without this one line, you stay in the state "I set 3700 in the plist but Perl kills at 900 seconds." For variable handoffs, checking once with printenv "which process does the value I wrote here actually reach" is the reliable move.
5. Build a two-tier structure of group limits and global limits
The global cap (SLOT_MAX=6) alone gives you coarse control. Pass --group engage --group-max 3 and you get fine-grained control: "engage gets at most 3 even when under the global cap." Effective when you want to prevent jobs of the same kind from over-paralleling and hitting rate limits. Because group limits are evaluated independently of the global limit, you can design it so that in a state of "2 engage, 1 note, 3 total, with global cap 6 to spare," only the individual group limit fires first.
6. Add reserved slots only when jobs of differing weight are mixed
SLOT_RESERVED_GROUPS=post and SLOT_RESERVE_COUNT=1 reserve one slot exclusively for the post group. Treat "a like that can be recovered the next day" and "a post that can never be filled once its time slot is gone" as equals, and posts get skipped repeatedly. If job weights are uniform, adding reserved slots only complicates the configuration. Introduce it only "when jobs that must not be skipped are mixed with jobs that can be covered on the next run."
7. Randomize wait intervals to avoid the thundering herd
retry_delay=$((15 + RANDOM % 31))
Retry on a fixed interval and, when multiple jobs are waiting simultaneously, they all fight over the guard at the same moment. Only one process can take the guard with mkdir, so the rest are rejected and retry again after the same fixed interval. This collision repeats periodically and throughput drops. Randomizing to 15–45 seconds spreads out retry timing and produces a flow where slots fill up in sequence.
8. Always include a comparison against the remaining wait time
remaining=$((WAIT_SEC - waited))
if [ "$retry_delay" -gt "$remaining" ]; then
retry_delay="$remaining"
fi
This prevents a "retry in 30 seconds" setting when only 5 seconds remain on the wait timeout. Without this comparison, you miss opportunities where "one more attempt was worth it" while the remaining time runs out.
9. Killing an entire process tree requires Perl or Python
The shell's timeout command only signals the direct child process. Chrome spawns many descendant processes, such as renderer and GPU processes. Kill only the parent with timeout and grandchildren and below survive, perpetuating the state "Chrome is already running at the next launch." You need an implementation that, like Perl's descendants() function, builds a PID-to-PPID table with /bin/ps -axo pid=,ppid=, enumerates all descendants with a BFS, and sends SIGTERM → SIGKILL. The reason for choosing Perl is that it ships with macOS by default.
10. Write three lines — PID, group name, epoch seconds — to the lock file
printf '%s\n%s\n%s\n' "$$" "$GROUP" "$(date +%s)" > "$LOCK_FILE"
The PID on line 1 lets you do liveness checks with kill -0. The group name on line 2 lets you count for group limits. The epoch seconds on line 3 let you debug "when did this slot start." Just cat the lock file and you know everything about that slot's state. Giving the lock file the group information removes the need to query other running processes.
11. Always include zombie-guard cleanup
If the process holding the guard dies abnormally, the guard directory sticks around forever. The next process can't take the guard, and all slot management stops. Without logic that checks the guard's PID with kill -0 and cleans it up with rmdir if dead, you get an implementation with zero fault tolerance where "the moment one process crashes, every job jams." Guard cleanup is the single most important part of self-healing.
12. Set up slot.log aggregation from day one of the automation
# cronで毎朝実行
skip_count=$(grep "result=skip:global-limit" ~/.cache/lily-browser-slots/slot.log | wc -l)
echo "today skip:global-limit count = $skip_count"
Look only at launchd's logs and everything appears "normal, all exit 0." The skip rate of slot management is only knowable by aggregating slot.log. To prevent the situation "92 skips a day were happening and I didn't notice for a week," automate the aggregation from the start. A skip rate above 15% is a sign to revisit the cap or the reserved slots.
13. Stagger start times across jobs by a few minutes each
If multiple jobs share the same Minute value in StartCalendarInterval, launchd starts them simultaneously. When simultaneously started jobs enter a timeout race, all 6 slots stay occupied for a long time. Just staggering each job's Minute by 2–5 minutes naturally distributes the contention over slots. Combined with reserved slots, post-lane jams drop further. If you have many plist entries there's a bulk-rewrite cost, but it's worth keeping in mind every time you add a new job.
Summary
There were three miscalculations before browser-slot.sh reached its current form.
The first was the hypothesis that "Chrome is heavy." It's preserved verbatim in the script's comments. A single vm_stat revealed the culprits were dasd (47GB) and ComfyUI (12GB), and the 0.7GB total across 9 Chrome instances was noise. The 92 skips a day the cap of 3 was producing was a problem solvable with 5 minutes of measurement.
The second was the post-lane jam after raising the cap back to 6. Because engage and post were treated as equals, simultaneous morning timeouts caused 9 consecutive post skips. "A like that can be recovered on the next run" and "a post that can't be recovered once its time slot is gone" should never have competed under the same slot cap. The reserved slot — two env variables — was the solution, and the script change was just adding is_reserved_group() and inserting 4 lines into acquire_slot().
The third was managing the timeout variable in two places. The AUTOLIKE_TIMEOUT_SEC=3700 written in the plist wasn't reaching the Perl supervisor's BROWSER_SLOT_TIMEOUT_SEC, and it ran that way for several days. A one-line handoff in run-account.sh solved it, but the symptom "I configured it but it isn't taking effect" is especially slow to discover because the logs don't honestly tell you the cause.
What all three share is the pattern "configure by intuition → no measurement → quiet malfunction." Chrome's weight, slot contention, the timeout connection — every one presented as "running but not actually functioning," and none surfaced until I aggregated the logs.
Automation is something that "breaks in production after the code works." The quieter the failure mode, the more measurement and log aggregation become the only diagnostic tools. Deciding up front "what to measure" and "what to aggregate" is a shorter path to automation that runs stably for a long time than the semaphore mechanism itself.
The full picture of the system, the breakdown behind ¥1.2M/month, and the 30-day walkthrough are collected in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)