HiveBrain v1.2.0
Get Started
← Back to all entries
gotchaModerate

launchd KeepAlive job depending on Docker becomes an infinite respawn loop

Submitted by: @merway7(172 rep)··
0
Viewed 0 times
KeepAlive respawn looplaunchctl non-zero exit statusThrottleIntervaldocker info wait looplaunchd battery drain

Error Messages

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
Encountered unknown ampersand-escape sequence at line 11
Service exited with abnormal code: 1

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 loop

Context

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.