Technology Sep 05, 2026 · 28 min read

24 Hours of Zero Posts: The 46GB Compressed-Memory Bomb That RSS Couldn't See

The day my social media automation posted exactly zero times in 24 hours was the day I stopped trusting Activity Monitor. RSS said 264MB. Reality was 47GB. Here is how I found the gap and built a launchd job that closes it while I sleep. Why This Setup Works The contradiction: "...

DE
DEV Community
by Lily
24 Hours of Zero Posts: The 46GB Compressed-Memory Bomb That RSS Couldn't See

The day my social media automation posted exactly zero times in 24 hours was the day I stopped trusting Activity Monitor. RSS said 264MB. Reality was 47GB. Here is how I found the gap and built a launchd job that closes it while I sleep.

Why This Setup Works

The contradiction: "RSS looked healthy, yet everything was dead"

On August 9, 2026, at hour 21 of uptime, something went wrong on my Mac. The automated social media posting agent driven by Claude Code stopped cold. Playwright's launchPersistentContext timed out at 180 seconds, and 24 hours went by with every lane posting nothing.

My first suspicion was memory pressure. I opened Activity Monitor and checked the memory pressure graph. I ran top and sorted by RSS. Nothing looked abnormal. dasd (Duet Activity Scheduler, macOS's background activity scheduler) showed an RSS of 264MB. Heavy, but within the "that's about normal" range.

The reality was different. When I ran top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs to show the MEM and CMPRS columns side by side, the full picture appeared.

PID    COMMAND  MEM    CMPRS
...
1234   dasd     264M   46G

MEM (actual physical RAM in use) was 264MB. But CMPRS (compressed pages still resident in memory) was 46GB. In total, the system had reserved roughly 47GB of memory for dasd. Swap had ballooned to 37GB.

RSS (Resident Set Size) is the total of pages a process currently has expanded in physical memory. Under memory pressure, macOS compresses rarely used pages and packs them into the same physical address space (Compressed Memory). Compressed pages are not counted in RSS. That is why a "healthy" RSS of 264MB was being displayed.

Activity Monitor's "Memory" column is the same trap. What it shows is an RSS-based value. The compressed region only blends faintly into the "Memory Pressure" graph, and per-process CMPRS values never appear.

How RSS hides the mechanism behind Chrome's timeout

When Playwright launches Chrome, the kernel allocates a heap and mmaps the profile directory. At that point macOS tries to bring pages back from swap into memory. But with dasd holding 46GB of compressed pages, there is no room to free up in physical RAM. Decompression, expansion, page-in, swap-in: this chain of operations cascades, Chrome's initialization stalls, and it blows past the 180-second wall time and times out.

The error log only contains Target closed and Navigation timeout. Not a single line mentions memory. That is why "nobody could notice."

The second leaker: iii (agentmemory)

dasd was not the only culprit. The iii process (agentmemory), which handles memory persistence for the agents, was a second leaker growing at a measured rate of roughly 4GB per hour. The script's comments record it like this:

# iii(agentmemory) は 2026-08-09 実測で時速約4GB増える second leaker。
LAUNCHD_RESTART_MAP="${MEM_HOG_LAUNCHD_MAP:-iii=com.shun.agentmemory:2048}"

Unlike dasd, iii is a job that launchd manages as part of the user session. Simply running killall iii makes launchd immediately restart the parent process (node), while iii itself survives as an orphan with ppid=1. The result was the worst possible pattern: the old process kept its memory while a new one was added on top. This was a trap I actually fell into in the first draft of the implementation, and it is solved by the kickstart strategy described later.

"Check periodically" isn't enough. It has to "take it down automatically"

Side-business automation is pointless unless it keeps running while you sleep. The best posting times on social media cluster around slots like 7 AM, noon, and 9 PM. If dasd runs wild at hour 21 of uptime, an explosion that happens quietly in the middle of the night gets discovered the next morning. Even though a manual sudo killall dasd fixes it instantly, the posts in between are zero.

"Let's check Activity Monitor regularly" is not a solution. The hours when no human is checking are exactly the most dangerous ones. Detect automatically, restart safely, and notify Discord. By building this mechanism into launchd, the environment heals itself even while the human is asleep.

"Safely" is the most important condition

You cannot just kill everything. Kill WindowServer and the desktop goes down. Try to kill launchd and the OS ignores you. Kill Finder and the desktop restarts. Kill an in-progress Claude Code session or a notes app and you lose the context you were writing.

So the script embeds a design principle: only auto-restart resident daemons that the OS itself auto-restarts. If any other process crosses the memory threshold, the script only sends a Discord notification and never touches the process.

# 絶対に触らないもの
NEVER_TOUCH="kernel_task WindowServer launchd loginwindow Finder"

This one line minimizes the automation's blast radius.

The Overall Flow

Architecture diagram

[launchd]
    │
    │ StartInterval = 600秒(10分ごと)
    │ Nice=10 / LowPriorityIO=true(バックグラウンド優先度)
    ▼
[~/.claude/scripts/mem-hog-guard.sh]
    │
    ├─① top -l 1 -n 20 -o mem        ← MEM列+CMPRS列をスキャン
    │     │
    │     ├─ dasd: MEM=47G/CMPRS=46G → 閾値3072MB超
    │     │         └─ sudo -n killall dasd → launchdが即再起動
    │     │
    │     ├─ iii: MEM≥4096MB超
    │     │         └─ SIGTERM → sleep2 → SIGKILL → kickstart -k
    │     │              └─ 孤児残存チェック後にログ記録
    │     │
    │     └─ その他: MEM≥6144MB → 通知のみ(触らない)
    │
    ├─② ps -Aco pid=,%cpu=,comm=     ← CPU軸を別スキャン(CMPRS爆弾は出ない)
    │     │
    │     └─ dasd: CPU≥50% を2回連続
    │               └─ sudo -n killall dasd
    │
    ├─③ クールダウン管理(プロセスごと・30分)
    │     └─ ~/.cache/lily-browser-slots/cooldown/<name>
    │
    └─④ Discord通知 (notify.sh)
              └─ 検知内容 + sysctl vm.swapusage の現在値

launchd fires the script every 10 minutes. The script itself runs with Nice=10 / LowPriorityIO=true, so the impact on normal work is essentially zero. Double launches are prevented with shlock.

LOCK_FILE="/tmp/com.lily.mem-hog-guard.lock"
if ! /usr/bin/shlock -f "$LOCK_FILE" -p "$$"; then
  exit 0
fi
trap '/bin/rm -f "$LOCK_FILE"' EXIT HUP INT TERM

shlock is a BSD-derived file lock utility that ships with macOS by default. The PID is written into the lock file, so if the process dies, the lock is released automatically.

Fetching MEM/CMPRS and converting units

The memory scan uses top. -o mem sorts the MEM column in descending order, and -stats pid,command,mem,cmprs fetches the CMPRS column at the same time.

TOP_RAW=$(top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs 2>/dev/null)

The values top returns are human-readable, like 264M, 46G, 37G. A to_mb() function normalizes them to integer MB.

to_mb() {
  awk -v v="$1" 'BEGIN {
    u = substr(v, length(v), 1); n = substr(v, 1, length(v) - 1) + 0
    if (u == "T") { printf "%.0f", n * 1024 * 1024 }
    else if (u == "G") { printf "%.0f", n * 1024 }
    else if (u == "M") { printf "%.0f", n }
    else if (u == "K") { printf "%.0f", n / 1024 }
    else { printf "%.0f", v / 1048576 }
  }'
}

46G becomes 46 × 1024 = 47104 MB. Since it can now be compared to the threshold as an integer, bash's [ "$mem_mb" -ge "$AUTO_RESTART_THRESHOLD_MB" ] does the check.

The design philosophy behind the two-axis scan

The MEM/CMPRS scan and the CPU scan run as two independent axes. There is a reason.

A CMPRS bomb (dasd's symptom this time) can be detected with top -o mem. But on a different day, dasd also ran away at 75% CPU (compressed memory was normal, yet the computation would not stop). That case never shows up on the MEM axis at all. A CPU-axis scan with ps -Aco pid=,%cpu=,comm= is required.

CPU_RAW=$(ps -Aco pid=,%cpu=,comm= 2>/dev/null)

The -c flag returns only the executable name. With -o comm, the column is truncated to 15 characters, so /usr/libexec/dasd becomes /usr/libexec/da and basename matching misses forever. This is another trap I actually hit.

To distinguish a momentary spike from a genuine runaway, the CPU check keeps a streak counter for "N consecutive checks over the threshold."

CPU_THRESHOLD_PCT="${MEM_HOG_CPU_THRESHOLD_PCT:-50}"
CPU_SUSTAIN_CHECKS="${MEM_HOG_CPU_SUSTAIN_CHECKS:-2}"

The default is "over 50% CPU, twice in a row." Since it runs at 10-minute intervals, a restart only happens after 20 continuous minutes of runaway. It is a buffer against false positives.

Flexible tuning through environment variables

Every threshold in the script can be overridden by environment variables. By injecting project-specific values through the launchd plist, behavior can be adjusted without touching the script itself.

These are the values set in the plist's EnvironmentVariables section.

<key>EnvironmentVariables</key>
<dict>
  <key>MEM_HOG_LAUNCHD_MAP</key>
  <string>iii=com.lily.agentmemory:4096</string>
  <key>MEM_HOG_THRESHOLD_MB</key>
  <string>3072</string>
</dict>

MEM_HOG_THRESHOLD_MB=3072 sets dasd's MEM threshold to 3GB. That is lower than the script's default (5120MB), so it intervenes earlier. The value comes from real measurements showing that swap surges the moment dasd exceeds 3GB.

MEM_HOG_LAUNCHD_MAP lists user-land launchd jobs in the format process-name=label:threshold-MB. iii (agentmemory) becomes a restart target via kickstart -k once it exceeds 4096MB.

The plist also sets StartInterval=600, and logs are written to the ~/.cache/lily-browser-slots/ directory. It shares the directory with browser slot management so that all logs from the social media automation system live in one place.

Per-process cooldown

The initial implementation had a serious bug. The cooldown (don't restart the same process for 30 minutes after a restart) was a single global one. In the real incident on 2026-08-09, during the 30 minutes after dasd was reclaimed at 16:42, iii swelled to 1.5 times its threshold (3.1GB) and was never reclaimed at all.

The current implementation manages cooldowns in independent files per process.

COOLDOWN_DIR="$STATE_DIR/cooldown"

cooldown_active() {
  local key="$1" file last
  file="$COOLDOWN_DIR/$(printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_')"
  [ -f "$file" ] || return 1
  last=$(cat "$file" 2>/dev/null)
  [ $(( $(date +%s) - last )) -lt "$COOLDOWN_SEC" ]
}

cooldown_mark() {
  local key="$1"
  date +%s >"$COOLDOWN_DIR/$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_')"
}

The cooldown files for dasd and iii are managed independently. Even right after reclaiming dasd, if iii crosses its threshold, it can be handled immediately.

Why restarting iii takes three steps

dasd is an OS daemon running with root privileges. Kill it with sudo -n killall dasd and launchd restarts it within seconds. Simple.

But iii (agentmemory) is a user-session launchd job. launchctl kickstart -k replaces the parent process (node), but iii itself is spawned beneath it as an independent process via detach(), so it survives as an orphan with ppid=1. Swap out the parent and the child remains.

The actual restart sequence is these three steps.

# まず本体を落とす
kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
# 親(launchdジョブ)を置き換え
launchctl kickstart -k "gui/$(id -u)/$label" >/dev/null 2>&1 || true
sleep 3
# 旧プロセスが本当に消えたか確認
if kill -0 "$pid" 2>/dev/null; then
  log "restart-failed $label pid=$pid が残存"
fi

SIGTERM → wait 2 seconds → SIGKILL (if still alive) → kickstart -k → check for survivors after 3 seconds. Skip this sequence and you get the worst outcome: the memory-hoarding old process survives and a new process is simply added.

Safe verification with dry-run mode

Before deploying to production, you can check the behavior with --dry-run.

~/.claude/scripts/mem-hog-guard.sh --dry-run

No actual kill or kickstart is performed. It prints "what it intends to do" to standard output.

WOULD RESTART: dasd (pid=1234) が MEM 47G/圧縮 46G を抱えている -> 再起動
swap: total = 37.00G  used = 37.00G  free = 0.00G
dry-run 終了

Running --dry-run locally once before registering with launchd is a mandatory verification step.

Implementation Details

The PATH problem under launchd and the full-path strategy

Lines 2 and 3 of the script are unglamorous, but they are the two most important lines for running under launchd.

set -uo pipefail
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH

set -uo pipefail is bash's three-piece safety set with -e removed. In a script called from launchd, adding -e terminates the whole thing the moment an unintended command returns non-zero. When kill -0 "$pid" finds the process already gone and there is no || true attached, -e takes the script down. So -e is dropped, and only the important operations are checked explicitly.

set -u turns references to undefined variables into errors. However, it also blows up on positional parameters like $1 when the variable doesn't exist. That is why argument handling is written as case "${1:-}" in. ${1:-} expands to an empty string if $1 is undefined, and set -u does not complain.

PATH is pinned explicitly to four paths because launchd does not read the user's shell configuration (.zshrc or .zprofile). In a terminal, nvm expands PATH for you, but a shell launched by launchd has no nvm. Calling shlock as /usr/bin/shlock with a full path is for the same reason, and specifying sudo -n /usr/bin/killall with a full path is so the PATH that sudo consults has no external dependency.

Parsing top output with awk

The most critical piece is the awk block that extracts pid/name/mem/cmprs from the raw top output.

awk '/^PID/ { seen = 1; next }
  seen && NF >= 4 {
    cmprs = $NF; mem = $(NF - 1); pid = $1
    name = ""
    for (i = 2; i <= NF - 2; i++) name = name (name == "" ? "" : " ") $i
    gsub(/ /, "", name)
    print pid, name, mem, cmprs
  }' <<<"$TOP_RAW"

/^PID/ detects the header line, and only data lines after it are processed. The key point is that columns are taken from the end, not from the front. MEM is one before the last column ($(NF-1)), CMPRS is the last column ($NF). The process name is the concatenation of column 2 through column NF-2.

Why take them from the end? When you output top -stats pid,command,mem,cmprs, the command column can contain multiple words (some macOS services display with spaces, like /usr/libexec/something agent). Taking fixed columns from the front shifts the name and the MEM column becomes agent. Anchoring from the end keeps the MEM/CMPRS positions stable.

The final gsub(/ /, "", name) strips spaces so the process name is a single word. Since bash's in_list() matches against a space-separated list, this prevents a name containing a space from being misread as two elements.

Why in_list() is a simple loop instead of an array

Bash arrays require declare -a and cannot be passed via environment variables. Values set in the launchd plist's EnvironmentVariables arrive as strings, so converting to an array adds a step. in_list() takes the space-separated string as-is and loops over it with for.

in_list() {
  local needle="$1" hay="$2" item
  for item in $hay; do [ "$item" = "$needle" ] && return 0; done
  return 1
}

Leaving $hay unquoted is intentional. Quoting it gives for item in "$hay", which treats the entire string as one element. It is deliberately unquoted to let the space-separated list expand. I chose the simple form that needs no IFS manipulation.

Parsing LAUNCHD_RESTART_MAP

Definitions for launchd jobs like iii use the format process-name=label:threshold-MB. The plist sets iii=com.shun.agentmemory:4096.

for m in $LAUNCHD_RESTART_MAP; do
  case "$m" in "$command="*) entry="${m#*=}"; break ;; esac
done

case "$m" in "$command="* is a pattern match checking "does the string start with $command=?" It is done entirely with bash's built-in case, no grep or awk. On a match, ${m#*=} extracts everything after the = (com.shun.agentmemory:4096), which is then split on : into label and threshold.

label="${entry%:*}"   # com.shun.agentmemory
limit="${entry##*:}"  # 4096

%:* is everything before the last :, and ##*: is everything after the last :. No external commands, and no dependency on PATH.

The CPU streak state machine

CPU runaway detection is implemented with a file-based streak counter.

streak=$(cpu_streak_get "$name")
if cpu_at_least_threshold "$cpu" "$CPU_THRESHOLD_PCT"; then
  streak=$((streak + 1))
else
  streak=0
fi
[ "$DRY_RUN" -eq 1 ] || cpu_streak_set "$name" "$streak"

cpu_at_least_threshold() does a floating-point comparison in awk. Bash's [ ] can only compare integers. Since the CPU value top returns is a decimal like 75.3, the check is awk -v cpu="$1" -v threshold="$2" 'BEGIN { exit !(cpu + 0 >= threshold + 0) }'.

A restart only happens when the streak reaches CPU_SUSTAIN_CHECKS (default 2), the threshold is still exceeded at this very moment, and no cooldown is active. The important part is that the threshold is re-checked right before the restart, so it does not misfire in the "streak accumulated, but it had already calmed down" case.

Where I Got Stuck

Stuck #1: ps -o comm truncated at 15 characters and matching never hit

When I first implemented the CPU-axis scan, I used ps -Ao comm to get process names. After several days of running, not a single dasd CPU runaway was detected. Nothing in the logs.

Investigating, I found that -o comm truncates the column to 15 characters. dasd is 4 characters, so no problem there. But when you actually run ps -Ao comm, processes registered with a full path come back as /usr/libexec/dasd, which gets cut to /usr/libexec/da. Trying to match with basename yields da, so in_list "da" "dasd" never matches.

It is recorded in a code comment as well.

# ps -Ao comm は列幅15文字で切られ /usr/libexec/dasd が /usr/libexec/da になる。
# それだと basename 照合が永久に外れるので -c(実行ファイル名のみ)+ヘッダ抑止を使う。
CPU_RAW=$(ps -Aco pid=,%cpu=,comm= 2>/dev/null)

-c is the flag that returns "executable name only," with no full path and no truncation. Combined with the = suffix that suppresses column headers, it is written as pid=,%cpu=,comm=. Changing the order of -Aco changes the meaning of the options, so it stays fixed exactly as is.

Stuck #2: The cost of a single global cooldown file

The first implementation used one file, COOLDOWN_FILE="$STATE_DIR/.mem-hog-last". The logic was "after restarting anything, do nothing for 30 minutes."

In the August 9, 2026 incident, dasd was reclaimed at 16:42. Immediately afterward, iii began swelling at 4GB per hour, and by 16:55 it had exceeded 1.5 times its threshold (3.1GB). But the script judged itself to be in the cooldown from reclaiming dasd and never touched iii.

iii swelling during dasd's cooldown is a scenario that can easily happen simultaneously. "Fix one thing and the rest gets ignored for 30 minutes" is a broken design, which I realized in the post-mortem.

After the fix, cooldowns are split into independent files per process. The comment preserves exactly what happened at the time.

# クールダウンは「プロセスごと」に持つ。
# 2026-08-09: 全体で1つにしていたため、16:42にdasdを回収した30分間は
# iii が閾値の1.5倍(3.1GB)まで膨らんでも一切回収されなかった。
# 1件直したら他が30分放置になるのは誤り。
COOLDOWN_DIR="$STATE_DIR/cooldown"

The filename is converted to a safe name with printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_'. This prevents labels containing : (com.shun.agentmemory) or the CPU key dasd:cpu from expanding into / and creating directories.

Stuck #3: kickstart alone left the old iii process alive

The first restart strategy for iii (agentmemory) was only launchctl kickstart -k "gui/$(id -u)/com.shun.agentmemory". The assumption was that for a launchd-managed job, this would replace the whole thing including the parent process (node).

In practice, the old iii process survived. Checking with ps aux | grep iii, it lived on as an orphan with ppid=1, still holding 4GB of memory. A new iii also started, so there were two processes and memory usage doubled.

The cause is how iii starts. agentmemory is spawned as an independent child process via detach() after node starts. What launchd manages is the parent (node). kickstart -k swaps out the parent, but the detached child's ppid has already been rewritten to 1 (launchd itself), so it is unaffected by the parent's restart.

The correct order is "take down iii itself first → then kickstart the parent."

kill -TERM "$pid" 2>/dev/null || true
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/$label" >/dev/null 2>&1 || true
sleep 3
if kill -0 "$pid" 2>/dev/null; then
  log "restart-failed $label pid=$pid が残存"
fi

Send SIGTERM, and if it is still alive after 2 seconds, SIGKILL. Then replace the parent with kickstart, wait 3 seconds, and verify the old pid is really gone. The survival check uses kill -0 because it can confirm liveness whenever you have read permission on the pid. If the process is gone, kill -0 returns non-zero and the if body is skipped.

Stuck #4: sudo -n didn't work under launchd

Restarting dasd is done with sudo -n /usr/bin/killall dasd. -n means "don't show a password prompt; fail immediately if not permitted."

Running it manually from a terminal during development worked. Running through launchd, it was skipped with sudo: a password is required.

There were two causes. One was the PATH problem (the sudoers secure_path and the environment's PATH disagree, so the full path /usr/bin/killall is required). The other was that NOPASSWD was not configured by default on my Mac.

The fix was to change the design so that a sudo -n failure is treated not as an error but as "notify and exit."

if sudo -n /usr/bin/killall "$command" 2>/dev/null; then
  log "restarted $command pid=$pid mem=$mem cmprs=$cmprs"
  findings="${findings}${msg}"$'\n'
  restarted=$((restarted + 1))
else
  log "restart-failed(no-sudo) $command pid=$pid mem=$mem"
  findings="${findings}⚠️ ${msg} — sudo権限が無く再起動できず。手動: sudo killall ${command}"$'\n'
fi

In an environment without NOPASSWD, a "please run this manually" notification goes to Discord. The design hands the failure to a human instead of silently swallowing it. To configure NOPASSWD, run sudo visudo and add %admin ALL=(ALL) NOPASSWD: /usr/bin/killall. On my Mac it is already added.

Stuck #5: The NF condition for top's header detection was too loose

The first awk set the seen flag on /^PID/ and then picked up every line with seen && NF > 0. top's output can contain blank lines. Blank lines have NF=0, so I assumed they were fine, but separator lines (like ---) slipped through with NF=1, $(NF-1) became the same as $0, and parsing broke.

The fix was adding the NF >= 4 condition. Lines lacking the minimum four fields of pid/name/mem/cmprs are ignored.

seen && NF >= 4 {
  cmprs = $NF; mem = $(NF - 1); ...
}

With this change, blank lines right after the header, separator lines, and format exceptions are all ignored. top's output format shifts subtly between macOS versions. Minimizing reliance on fixed column indexes and guarding with an NF condition is the safe approach.

The next section covers the steps for actually registering this script with launchd and how to read the logs once it is running.

Pitfalls

Here is a list of the traps I stepped on across the implementation, registration, and operation phases. The five explored in depth above (ps -c truncation, global cooldown, kickstart orphans, sudo -n failure, NF condition) are not repeated here. This focuses on the registration and long-term operation phases.

  • launchctl load is deprecated since Ventura and can fail silently
    launchctl load ~/Library/LaunchAgents/com.lily.mem-hog-guard.plist is the old API. From macOS Ventura (13) onward, use launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.lily.mem-hog-guard.plist. With load, it can appear registered without any error, and you won't notice until you verify with launchctl list | grep mem-hog.

  • If the directory that StandardErrorPath/StandardOutPath points to doesn't exist beforehand, it fails silently
    The plist sets ~/.cache/lily-browser-slots/mem-hog-guard.launchd.log as StandardErrorPath. If you bootstrap while this directory doesn't exist, launchd cannot open the log file and drops the job. Since it emits no error log either, you get stuck on "it never runs even after waiting 10 minutes." Run mkdir -p ~/.cache/lily-browser-slots first, then register.

  • Deploying to the production Mac with RunAtLoad=true killed dasd the instant I ran bootstrap
    If you forget to revert RunAtLoad=true from testing and deploy it, the script runs the moment you bootstrap. If dasd happens to be over the threshold (MEM_HOG_THRESHOLD_MB=3072 in the plist), sudo killall dasd executes immediately. Write <key>RunAtLoad</key><false/> explicitly in the plist, and trigger the first run manually with launchctl start com.lily.mem-hog-guard.

  • The StartInterval timer runs on calendar boundaries, not from load time
    Setting 600 seconds means "fire at the next 600-second boundary," not "600 seconds after load." Right after registering, you get confused by "why hasn't it run after waiting 10 minutes?" Trigger it manually right away with launchctl start com.lily.mem-hog-guard, verify the first run's behavior, and then leave it alone.

  • Omitting the ProcessType / Nice / LowPriorityIO trio shows up in battery warnings
    Without them, the few seconds top runs get recorded in macOS's energy diagnostics as an "app using significant CPU." Setting all three of ProcessType=Background, Nice=10, and LowPriorityIO=true in the plist classifies it correctly as background processing.

  • Writing NOPASSWD too broadly in sudo visudo invites a different risk
    Allowing NOPASSWD: ALL maximizes the damage if the script is compromised. Restricting it to just killall with NOPASSWD: /usr/bin/killall is least privilege. Specifying the full path sudo -n /usr/bin/killall dasd inside the script also ensures it matches the target command in sudoers.

  • The log file grows without bound
    The log() function only appends with >> and has no rotation mechanism. Unless you set up a separate weekly launchd job to truncate -s 0 it, or get in the habit of reading with tail -n 100, it reaches hundreds of MB after a few months.

  • launchd.log (the plist's StandardErrorPath) and the script's log() go to different files
    The plist's StandardErrorPath receives standard error (the shell's >&2). The script's log() writes directly to mem-hog-guard.log. A state of "it should be running but log() is empty" means the script crashed at the launchd level before it ever started (plist syntax error, permissions, directory not created). Check mem-hog-guard.launchd.log first.

  • The unquoted $hay in in_list got "fixed" by another engineer
    The unquoted for item in $hay is intentional. It relies on IFS word splitting to expand the space-separated list. If it gets "fixed" to "$hay", the whole string is treated as one element and matching misses forever. Without a one-line comment explaining why, correct code gets broken.

  • Trying to check dasd's survival with kill -0 gave a false negative from a permission error
    dasd is a root-owned process. Running kill -0 "$pid" as a regular user returns EPERM, which is non-zero. That non-zero means "no permission," not "the process is gone." The script skips the survival check for dasd and treats a successful sudo -n killall as a successful restart. The kill -0 survival check is reserved for launchd user jobs (iii).

  • vm_stat's column order changes between OS versions
    The swap information included in the Discord notification uses sysctl -n vm.swapusage. The vm_stat command returns raw per-page values like "Pages free" and "Pages wired down," which need computation and whose column names change with versions. If you want a human-readable current swap value on one line, sysctl -n vm.swapusage is the most robust choice.

Best Practices

1. Define the NEVER_TOUCH list first

NEVER_TOUCH="kernel_task WindowServer launchd loginwindow Finder"

Decide "what must never be touched" before "what may be touched." As long as this list exists, a WindowServer over the threshold will never be auto-killed and take the desktop down. Declaring the blast radius first is a design principle of automation.

2. Always run --dry-run once before production registration

~/.claude/scripts/mem-hog-guard.sh --dry-run

The output WOULD RESTART: dasd (pid=1234) が MEM 47G/圧縮 46G を抱えている -> 再起動 tells you in advance what will happen. These 5 seconds prevent an incident.

3. Write every command with a full path under launchd

launchd does not read .zshrc or .zprofile. Pin PATH to four paths at the top of the script with PATH="/usr/bin:/bin:/usr/sbin:/sbin", and write shlock as /usr/bin/shlock and killall as /usr/bin/killall. Most cases of "works in the terminal but not through launchd" come from this.

4. Use set -uo pipefail and drop set -e

-e misfires under launchd. When kill -0 "$pid" finds the process already gone (a normal state), it returns non-zero and -e takes down the whole script. Explicitly check only the important operations with if and || true, and don't rely on -e.

5. Make every threshold overridable via environment variables

AUTO_RESTART_THRESHOLD_MB="${MEM_HOG_THRESHOLD_MB:-5120}"

Design it so values can be injected from the plist's EnvironmentVariables without changing the script itself. The actual plist sets MEM_HOG_THRESHOLD_MB=3072. That value was chosen from real measurements showing swap surges once dasd exceeds 3GB. Injecting a value lower than the script default (5120MB) from outside lets it intervene earlier.

6. Keep cooldowns in independent files per process

COOLDOWN_DIR="$STATE_DIR/cooldown"

A single global file causes "for the 30 minutes after reclaiming dasd, iii is ignored even past 4GB." This was proven in the August 9, 2026 incident. Sanitize filenames with printf '%s' "$key" | tr -c 'A-Za-z0-9._-' '_' to prevent colons (compound keys like iii:cpu) from being interpreted as directories.

7. Run the MEM axis and CPU axis as two independent loops

A CMPRS bomb shows up in top -o mem, but a CPU-only runaway never appears in the MEM column. In the separate incident where dasd ran away at 75% CPU, the MEM axis alone detected nothing. Fully separating the top loop and the ps -Aco loop lets each symptom be handled independently.

8. Make the CPU check a two-stage streak (N consecutive)

Restarting on a single spike misfires on temporary processing peaks. Use a file-based streak counter with the condition "over 50% CPU twice in a row," and re-check the threshold right before restarting. It is a double check against misfiring in the "streak accumulated, but it had already calmed down" case.

if [ "$streak" -ge "$CPU_SUSTAIN_CHECKS" ] && cpu_at_least_threshold "$cpu" "$CPU_THRESHOLD_PCT" && ! cooldown_active "$name:cpu"; then

9. Handle sudo -n failure with graceful degradation

In an environment without NOPASSWD, the automatic kill won't go through. Instead of silently exiting, send a Discord notification saying "please run sudo killall dasd manually." Handing automation failures to a human is what builds long-term operational reliability.

if sudo -n /usr/bin/killall "$command" 2>/dev/null; then
  log "restarted $command ..."
else
  log "restart-failed(no-sudo) ..."
  findings="${findings}⚠️ ${msg} — sudo権限が無く再起動できず。手動: sudo killall ${command}"$'\n'
fi

10. Restart launchd user jobs in three steps: SIGTERM → SIGKILL → kickstart

A detached child process with ppid=1 does not go down with kickstart -k alone. First SIGTERM the process itself, SIGKILL it if still alive after 2 seconds, then replace the parent with kickstart. Stopping at kickstart alone gives the worst state: the memory-hoarding old process survives while a new one is added.

11. Use the -c flag with ps

CPU_RAW=$(ps -Aco pid=,%cpu=,comm= 2>/dev/null)

-o comm truncates process names to a 15-character column. /usr/libexec/dasd becomes /usr/libexec/da, and basename matching misses forever. -c returns only the executable name with no truncation. Use it together with the header-suppressing = suffix.

12. Parse top's output in awk from the end

cmprs = $NF; mem = $(NF - 1)

If a process name contains a space, taking fixed columns from the front shifts the MEM column. Anchor the last column (CMPRS) and the one before it (MEM), and concatenate the rest as the name. Guard blank and separator lines with NF >= 4.

13. Include sysctl vm.swapusage in notifications

swap=$(sysctl -n vm.swapusage | sed 's/vm.swapusage: //')
notify "🧠 mem-hog-guard\n${findings}\nswap: $swap"

"dasd was restarted" alone does not tell you whether swap had piled up to 37GB. Including the current swap value in the notification lets you decide from your phone whether the Mac itself needs a reboot.

14. Verify the old process is gone with kill -0 after restarting

sleep 3
if kill -0 "$pid" 2>/dev/null; then
  log "restart-failed $label pid=$pid が残存"
fi

Wait 3 seconds after kickstart and confirm the old pid is truly gone. If still alive, log and notify it as restart-failed. "Restarted" and "old process is gone" are separate checks.

15. Prevent double launches with shlock

LOCK_FILE="/tmp/com.lily.mem-hog-guard.lock"
if ! /usr/bin/shlock -f "$LOCK_FILE" -p "$$"; then
  exit 0
fi
trap '/bin/rm -f "$LOCK_FILE"' EXIT HUP INT TERM

launchd's StartInterval does not check whether the previous run has finished. If top and ps run concurrently, they end up trying to kill the same pid twice. shlock releases the lock automatically the instant the process dies, so it never deadlocks.

Summary

A CMPRS bomb invisible to RSS cannot be caught by Activity Monitor or by default top. Only after running top -l 1 -n 20 -o mem -stats pid,command,mem,cmprs does the reality of dasd's RSS 264MB / CMPRS 46GB appear. Miss that number, and even when Chrome's launchPersistentContext times out at 180 seconds and every social media lane goes to zero for an entire day, not one line about memory shows up in the error log.

Manual monitoring is not a solution. When dasd runs wild at 3 AM, all you can find the next morning is a log full of zeros. launchd fires the script every 10 minutes, the script detects the compressed-memory bomb across two loops on the MEM/CMPRS axis and the CPU axis, auto-restarts while the NEVER_TOUCH list guarantees safety, and reports to Discord with the current swap value attached. With this cycle running without my knowledge, the posting lanes no longer stop while I sleep.

Every stumble along the way to this implementation is preserved as a dated comment in the script itself. Code is knowledge. The next time it breaks, the comments will tell you what happened.

What about you: has a process on your machine ever looked healthy in Activity Monitor while quietly eating your entire swap?

The full picture of the system, the breakdown of the 1.2M yen/month, and the 30-day setup guide are compiled in a paid note.
📕 How to actually earn with a Claude Code autonomous environment: the system, real examples, getting started, and support

Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

DE
Source

This article was originally published by DEV Community and written by Lily.

Read original article on DEV Community
Back to Discover

Reading List