LaunchAgent Path Mismatch Debugging
LaunchAgent Path Mismatch Debugging: Tracing Silent Failures in macOS Service Orchestration
LaunchAgent path mismatches are among the quietest failure modes in macOS service infrastructure. The agent loads, launchd reports no errors, and yet the service never starts — or worse, it starts intermittently and leaves stale state behind. Understanding why this happens, and building a repeatable diagnostic workflow, is essential for anyone operating persistent background services on macOS.
What Is a Path Mismatch?
A LaunchAgent path mismatch occurs when the path declared in a .plist file’s ProgramArguments key does not resolve correctly at the time launchd attempts to execute it. This is distinct from a permission error or a missing file — the file may exist, be executable, and even pass static validation, but launchd still cannot launch it reliably.
Common root causes include:
- Symlink drift: the binary path points to a symlink that was updated by a package manager (Homebrew, nix, etc.) after the plist was written
- User-scoped vs. system-scoped path resolution:
~/Library/LaunchAgentsruns in the user session, but the shellPATHis not inherited from your interactive terminal - Relative paths: launchd does not honor relative paths in
ProgramArguments; every path must be absolute - Case sensitivity: macOS filesystems are case-insensitive by default, but paths sourced from external volumes or containers may not be
Initial Diagnosis
Start with launchctl to check whether the agent is loaded and what its last exit status was:
launchctl list | grep com.example.myagent
A healthy entry looks like:
- 0 com.example.myagent
The middle column is the exit code of the last run. A non-zero value — especially 78 (configuration error) or 127 (command not found) — confirms something is wrong at launch time.
To pull structured detail:
launchctl print gui/$(id -u)/com.example.myagent
This surfaces the last exit code, pid, path, and any error fields launchd recorded. Pay close attention to the path field — it shows what launchd actually resolved, which may differ from what you wrote in the plist.
Resolving the Actual Binary Path
Never assume the path in your plist is correct. Verify it explicitly:
# Check where the binary actually lives
which myservice
readlink -f $(which myservice)
# If installed via Homebrew
brew --prefix myservice
ls -la $(brew --prefix myservice)/bin/myservice
If the output of readlink -f differs from what you have in ProgramArguments, that is your mismatch. Update the plist to use the fully resolved, canonical path:
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/myservice/bin/myservice</string>
<string>--config</string>
<string>/Users/operator/.config/myservice/config.toml</string>
</array>
Avoid /usr/local/bin/myservice when the binary is actually at /opt/homebrew/bin/myservice — these are not interchangeable on Apple Silicon hosts.
The Environment Variable Trap
launchd agents do not inherit your shell environment. If the binary relies on PATH, DYLD_LIBRARY_PATH, or any other variable set in .zshrc or .bashrc, it will not have access to them unless you explicitly declare them in the plist:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>/Users/operator</string>
</dict>
A quick way to expose this class of failure is to run the binary directly the same way launchd would — without your shell environment:
env -i HOME=/Users/operator /opt/homebrew/bin/myservice --config ~/.config/myservice/config.toml
If this fails when running normally in your shell succeeds, you have an environment dependency that the plist needs to declare explicitly.
Watching Failures in Real Time
The unified logging system captures launchd output even when a service exits immediately:
log stream --predicate 'subsystem == "com.apple.launchd"' --level debug
In a separate terminal, unload and reload the agent to trigger the failure:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.example.myagent.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myagent.plist
Watch the log stream for entries referencing your agent label. A path resolution failure will produce a message similar to:
posix_spawn: /opt/homebrew/bin/myservice: No such file or directory
Even if the file exists at that path in your terminal session. Cross-check with:
ls -la /opt/homebrew/bin/myservice
file /opt/homebrew/bin/myservice
If file reports cannot open or the architecture is wrong (e.g., x86_64 binary on arm64 host without Rosetta configured for the agent), you have found a deeper mismatch.
Plist Validation Before Load
Before reloading, validate the plist structure:
plutil -lint ~/Library/LaunchAgents/com.example.myagent.plist
This catches XML syntax errors but not semantic issues. For semantic validation, use:
launchctl load -w ~/Library/LaunchAgents/com.example.myagent.plist 2>&1
The -w flag removes any disabled override. Errors printed to stderr here are authoritative — treat them as ground truth over any other diagnostic signal.
A Repeatable Debugging Checklist
- Run
launchctl list | grep <label>— check exit code - Run
launchctl print gui/$(id -u)/<label>— inspect resolved path and error fields - Resolve the binary path with
readlink -fand compare to plist - Test the binary with
env -ito expose environment dependencies - Stream logs with
log streamwhile cycling the agent - Validate plist with
plutil -lint - Reload with
launchctl bootout+bootstrap, never the deprecatedload/unload
Path mismatches are fixable in minutes once you stop trusting assumptions and start reading what launchd actually sees.
Write a comment