These are my notes on the modern launchctl subcommands, as used on a user
LaunchAgent. launchctl(1) documents them but gives no examples, so this page
adds a few.
Every command on this page refers to one example agent through these four variables:
LABEL=dk.thrysoee.my-launch-agent
PLIST=~/Library/LaunchAgents/$LABEL.plist
DOMAIN=gui/$(id -u)
TARGET=$DOMAIN/$LABEL
The service target uses the Label string from inside the plist, not
the filename. By convention they match, but only the label counts. Check with
plutil -p "$PLIST".
Domains
Modern subcommands take an explicit target. bootstrap,
print-disabled and a whole-domain print take a domain
target. Everything else takes a service target, which is a domain target with
the label appended.
| Domain target | What it is | Where its jobs live |
|---|---|---|
system/ |
The root Mach bootstrap. Anyone can read it; changing it needs root. | /Library/LaunchDaemons |
user/<uid>/ |
A user's domain. It exists whether or not that user has logged in at the GUI. | Agents that opt out of the GUI session with LimitLoadToSessionType |
gui/<uid>/ |
The user's GUI login domain. A convenient spelling of
login/<asid>, and the one for a normal LaunchAgent. |
~/Library/LaunchAgents |
login/<asid>/ |
The same login domain, addressed by audit session ID instead of by user. | n/a |
pid/<pid>/ |
The XPC services one process can reach through
xpc_connection_create(3). |
n/a |
For UID 501, gui/501 and gui/$(id -u) name the same domain.
Hardcode the number in a one-off; use the substitution in anything you save.
launchctl print gui/501 # fine interactively
launchctl print "gui/$(id -u)" # portable, use this in scripts
That command is also the readable dump of a whole domain: type, ASID, service counts,
inherited environment, and every service in it. The asid field is the number
the login/ form takes.
gui/<uid> and user/<uid> are not interchangeable.
The man page says the two share a flat Mach bootstrap namespace but hold discrete sets of
services, so an agent in gui/501 is absent from user/501.
$ launchctl print user/501/dk.thrysoee.my-launch-agent
Could not find service "dk.thrysoee.my-launch-agent" in domain for uid: 501
Easy to misread, because printing the user domain still lists Mach names registered by GUI-domain services. The names are there. The services are not.
Command reference
The commands below use the four variables from the top of this page.
LABEL- The
Labelstring from inside the plist. launchd identifies the job by this string alone. PLIST- Filesystem path to the property list. Only
bootstraptakes a path. Everything else works from the label. DOMAIN- The domain target,
gui/$(id -u)for a user agent orsystemfor a daemon. The man page adds a trailing slash. Both forms work, and leaving it off keeps$DOMAIN/$LABELreadable. TARGET- The service target,
$DOMAIN/$LABEL. One job in one domain, and what every subcommand exceptbootstrapexpects.
| Intent | Legacy | Modern |
|---|---|---|
| load | launchctl load "$PLIST" |
launchctl bootstrap "$DOMAIN" "$PLIST" |
| unload | launchctl unload "$PLIST" |
launchctl bootout "$TARGET" |
| start (run now) | launchctl start "$LABEL" |
launchctl kickstart "$TARGET" |
| stop (kill running instance) | launchctl stop "$LABEL" |
launchctl kill TERM "$TARGET" |
| restart | n/a | launchctl kickstart -k "$TARGET" |
| enable | launchctl load -w "$PLIST" |
launchctl enable "$TARGET" |
| disable | launchctl unload -w "$PLIST" |
launchctl disable "$TARGET" |
| status | launchctl list "$LABEL" |
launchctl print "$TARGET" |
| list disabled | n/a | launchctl print-disabled "$DOMAIN" |
| reload after editing the plist | launchctl unload "$PLIST" && launchctl load "$PLIST" |
launchctl bootout "$TARGET"; launchctl bootstrap "$DOMAIN" "$PLIST" |
Gotchas
enableanddisableare independent ofbootstrapandbootout.disablewrites persistent state under/var/db/com.apple.xpc.launchd/and stops the job bootstrapping at login, but it does not unload what is loaded now. To unload a job and keep it from bootstrapping at login, run both.bootoutis not permanent. launchd bootstraps a plist in~/Library/LaunchAgentsagain at next login.- For an on-demand agent, driven by
WatchPaths,StartOnMountor a socket,not runningis the normal state.kickstarttriggers it by hand.killonly has an effect while an instance is running. - Legacy
stopon an on-demand job is unreliable. The man page warns that launchd may restart the job at once if its trigger criteria are still satisfied.killsends the signal you named and nothing else. - System daemons use
DOMAIN=system, a plist in/Library/LaunchDaemons, andsudo.
Error reporting in legacy and modern subcommands
Error reporting is the practical difference. Legacy subcommands pick their domain from
context. Root targets system, everyone else the domain of their current
session, and the command does not say which. They also report almost nothing. Compare
stop on an agent that is loaded but idle with stop on a label that
does not exist.
$ launchctl stop dk.thrysoee.my-launch-agent # exit 0, silent
$ launchctl stop no.such.job.xyz # exit 3, silent
$ launchctl kill TERM "$TARGET"
No process to signal. # exit 3
$ launchctl kill TERM "$DOMAIN/no.such.job.xyz"
Could not find service "no.such.job.xyz" in domain for user gui: 501 # exit 113
kill separates "the service exists, nothing is running" from "no such
service" and prints which. stop returns 0 for the first and 3 for the
second with no output, so a typo in a label looks the same as success.
Example: install an agent that loads at every login
Load a new plist from ~/Library/LaunchAgents into the current session, and
have launchd load it again at every login.
LABEL=dk.thrysoee.my-launch-agent
PLIST=~/Library/LaunchAgents/$LABEL.plist
DOMAIN=gui/$(id -u)
TARGET=$DOMAIN/$LABEL
plutil -lint "$PLIST" # well-formed plist?
plutil -p "$PLIST" | grep Label # does the Label match $LABEL?
chmod 644 "$PLIST"
launchctl enable "$TARGET" # reset any stale disabled record
launchctl bootstrap "$DOMAIN" "$PLIST" # load into the current login session
launchctl kickstart "$TARGET" # run it now, skip if RunAtLoad is set
launchctl print "$TARGET" # verify
Persistence across logout and reboot comes from the file's location, not from any
command. launchd scans ~/Library/LaunchAgents at every GUI login and bootstraps
what it finds there. bootstrap only affects the current session, which is why
you run it once, at install time.
The enable line looks redundant on a fresh install. It is not. Disabled
state is keyed by label, lives under
/var/db/com.apple.xpc.launchd/, and outlives the plist. If the label was ever
disabled, even under a plist deleted months ago, bootstrap fails like this:
$ launchctl bootstrap "$DOMAIN" "$PLIST"
Bootstrap failed: 5: Input/output error
Try re-running the command as root for richer errors.
Nothing in that message mentions the disabled flag. Check with launchctl
print-disabled "$DOMAIN" | grep "$LABEL", run launchctl enable
"$TARGET", and bootstrap again. Keeping enable in the recipe costs
nothing and skips that diagnosis.
For a daemon rather than an agent, the same sequence uses
DOMAIN=system, a plist in /Library/LaunchDaemons owned by
root:wheel, and sudo on every launchctl line.
Example: stop, disable and remove an agent completely
Stop the agent, block launchd from bootstrapping it at future logins, and delete the plist.
LABEL=dk.thrysoee.my-launch-agent
PLIST=~/Library/LaunchAgents/$LABEL.plist
DOMAIN=gui/$(id -u)
TARGET=$DOMAIN/$LABEL
launchctl kill TERM "$TARGET" 2>/dev/null # stop a running instance
launchctl disable "$TARGET" # persistent, survives reboot
launchctl bootout "$TARGET" # unload from the current session
rm "$PLIST" # remove from disk
launchctl print "$TARGET" # expect: Could not find service
launchctl print-disabled "$DOMAIN" | grep "$LABEL"
The four steps do not overlap. kill ends the current run.
disable records, across reboots, that this label must not be bootstrapped.
bootout removes the job from this session. rm stops launchd
bootstrapping it at next login. Without bootout the job stays loaded until
logout, plist or no plist.
The disabled record survives the rm and blocks any future plist with the
same label, which is useful when an installer might reinstall the agent. To clear it
instead:
launchctl enable "$TARGET"
That sets the record to enabled rather than deleting it, so the label stays
listed in print-disabled. An => enabled entry is inert; enabled
is the default for any label without a record.
Example: the full stop, unload, load and start cycle
Use this after editing the plist, or when kickstart -k is not enough and
the job has to be unloaded and loaded again. Every step takes a service target except
bootstrap, which takes the path.
LABEL=dk.thrysoee.my-launch-agent
PLIST=~/Library/LaunchAgents/$LABEL.plist
DOMAIN=gui/$(id -u)
TARGET=$DOMAIN/$LABEL
launchctl kill TERM "$TARGET" 2>/dev/null # stop, no-op if not running
launchctl bootout "$TARGET" 2>/dev/null # unload, no-op if not loaded
launchctl bootstrap "$DOMAIN" "$PLIST" # load
launchctl kickstart "$TARGET" # start
Shorter, since bootstrap is synchronous and kickstart -k covers
stop-then-start by itself:
launchctl bootout "$TARGET" 2>/dev/null
launchctl bootstrap "$DOMAIN" "$PLIST" && launchctl kickstart -k "$TARGET"
bootout fails when the service is not loaded, where legacy
unload did not. The 2>/dev/null hides the message only;
the exit status is still non-zero, so under set -e that line also needs
|| true. If bootstrap returns Bootstrap failed: 37:
Operation already in progress, the bootout has not completed yet.
Retry:
until launchctl bootstrap "$DOMAIN" "$PLIST"; do sleep 1; done