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/LaunchAgents runs in the user session, but the shell PATH is 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

  1. Run launchctl list | grep <label> — check exit code
  2. Run launchctl print gui/$(id -u)/<label> — inspect resolved path and error fields
  3. Resolve the binary path with readlink -f and compare to plist
  4. Test the binary with env -i to expose environment dependencies
  5. Stream logs with log stream while cycling the agent
  6. Validate plist with plutil -lint
  7. Reload with launchctl bootout + bootstrap, never the deprecated load/unload

Path mismatches are fixable in minutes once you stop trusting assumptions and start reading what launchd actually sees.

References

Write a comment