gotchaModerate
launchd KeepAlive job depending on Docker becomes an infinite respawn loop
Viewed 0 times
KeepAlive respawn looplaunchctl non-zero exit statusThrottleIntervaldocker info wait looplaunchd battery drain
Error Messages
Problem
A launchd agent configured with KeepAlive=true runs a wrapper script that polls for a dependency (typically the Docker daemon) and calls exit 1 if it never appears. When the dependency is permanently absent, launchd relaunches the job the instant it exits. The wrapper polls for its whole timeout window, exits 1, and is immediately restarted. The result is a silent permanent respawn loop burning CPU and battery. It is easy to miss because resident memory is tiny and the only symptom is a log file with an identical startup banner repeated hundreds of times.
Solution
Diagnose by listing loaded jobs and looking for a non-zero last-exit status on a KeepAlive job, then counting repeated startup banners in its log. A line count that climbs with no corresponding work is the tell. Fix by replacing the unconditional KeepAlive boolean with the dictionary form so launchd only restarts on wanted conditions, e.g. KeepAlive with SuccessfulExit set to false, combined with a ThrottleInterval of several hundred seconds so a failing job backs off instead of hot-looping. Better: remove the dependency wait from the wrapper and gate the job on the dependency socket via a launchd socket or path-watch key, so it only launches when the dependency is present. If the job is a stale experiment, unload and remove the plist. Never let a wrapper that polls for a dependency end in a bare exit that an eager supervisor instantly retries.
Why
KeepAlive=true means "always keep this running", so launchd treats any exit — including a deliberate failure exit — as something to correct immediately. Without ThrottleInterval or SuccessfulExit gating, a permanently unsatisfiable precondition turns supervision into a hot loop.
Gotchas
- Low RSS makes the job invisible in memory-based audits — it costs CPU/battery, not RAM
- launchctl list shows the job as present and 'fine'; only the exit-status column reveals the loop
- plutil cannot parse a plist containing a raw && inside a shell string, so audit tooling silently skips it while launchd may still have it loaded
Code Snippets
Back off instead of hot-looping: restart only on failure, with a throttle
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>600</integer>Spot a crash-looping job: non-zero column 2 is the last exit status
launchctl list | awk '$2 != 0 && $2 != "-"'
wc -l /tmp/<job>.log # re-run; a climbing count with no work done = respawn loopContext
Auditing macOS background load; a personal launchd agent silently hot-loops at every boot when its container runtime is not running.
Revisions (0)
No revisions yet.