Skip to main content
  1. Posts/

launchctl bootout returned 0. The label was still there. Bootstrap failed 5.

··1155 words·6 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
`launchctl bootout` exited 0. `launchctl bootstrap`, on the very next line, failed with `Bootstrap failed: 5: Input/output error` because the label was still loaded. Then the bootout finished on its own schedule and the label went away, leaving a machine with no daemon at all and an installer that had just told me it was done. This one is mine: I caused it while fixing something else.
Tested with launchd · macOS

I have written before about launchctl unload returning 0 while the daemon kept running, which turned out to be KeepAlive’s supervisor racing the teardown. This is the sibling. Same API, opposite failure, and where that one was launchd surprising me, this one I built myself.

The idempotency fix that caused it
#

herddeck install should be re-runnable. You edit a config, you run install again, you expect it to work. That is table stakes for an installer.

launchd disagrees. Bootstrapping a label that is already loaded is an error, so a second install would print something like this and stop:

Bootstrap failed: 5: Input/output error

Errno 5 is EIO, which launchd reuses as a catch-all for “I refuse”. In this case it means “already loaded”, which is a perfectly harmless condition: the daemon you wanted running is running.

So I did the obvious thing. Boot it out first, then bootstrap. Now install is idempotent.

What bootout actually returns
#

launchctl bootout returns as soon as launchd has accepted the request. It does not wait for the job to be torn down, the processes to exit, or the label to leave launchd’s tables.

The sequence I created:

launchdherddeck installlaunchdherddeck installbootout completesnothing is loadedbootout gui/501/…herddeck.daemonexit 0 (accepted, not finished)bootstrap gui/501 …plistBootstrap failed: 5 (label still present)
launchdherddeck installlaunchdherddeck installbootout completesnothing is loadedbootout gui/501/…herddeck.daemonexit 0 (accepted, not finished)bootstrap gui/501 …plistBootstrap failed: 5 (label still present)

The bootstrap fails against a label that is on its way out. Then the bootout completes. The net result is that nothing is loaded, which is the one outcome neither command asked for.

Why the failure is silent
#

The old behaviour was a loud error in a harmless situation. The new behaviour is a quiet error in a broken one, and the asymmetry is the whole lesson.

If you run install twice in a row, the second run prints its bootstrap failure and you have a running daemon anyway, because the bootout raced and lost. If you run it once after an upgrade, you may get a bootout that wins, a bootstrap that fails, and no daemon. The daemon does not crash. It was never started. There is no crash log, no KeepAlive respawn, and nothing in launchctl list to look at, because the label is genuinely gone.

The symptom is that HerdDeck simply stops existing until you run install a second time, which of course works, because now there is nothing to boot out. A bug that is fixed by doing the thing again is a bug that hides for a long time.

The fix: poll, then retry, both bounded
#

The correct move is to stop trusting the return code and ask launchd directly whether the label is still there.

/** Poll interval and cap while waiting for `launchctl bootout` to
 * actually finish, plus how many times to retry a bootstrap that fails
 * anyway. 20 x 100ms is far beyond the observed settle time and still
 * bounded at 2s. */
const UNLOAD_WAIT_MS = 100;
const UNLOAD_WAIT_ATTEMPTS = 20;
const BOOTSTRAP_RETRIES = 2;

launchctl print gui/<uid>/<label> exits non-zero once the label is really gone, which makes it a usable predicate:

const isLoaded = async (): Promise<boolean> =>
  (await opts.exec("launchctl", ["print", `gui/${opts.uid}/${LAUNCHD_LABEL}`])).exitCode === 0;

const alreadyLoaded = await isLoaded();
if (alreadyLoaded) {
  await opts.exec("launchctl", ["bootout", `gui/${opts.uid}/${LAUNCHD_LABEL}`]);
  for (let i = 0; i < UNLOAD_WAIT_ATTEMPTS && (await isLoaded()); i++) {
    await sleep(UNLOAD_WAIT_MS);
  }
}

let res = await opts.exec("launchctl", ["bootstrap", `gui/${opts.uid}`, plistPath]);
for (let i = 0; i < BOOTSTRAP_RETRIES && res.exitCode !== 0; i++) {
  await sleep(UNLOAD_WAIT_MS);
  res = await opts.exec("launchctl", ["bootstrap", `gui/${opts.uid}`, plistPath]);
}

Both loops are bounded, and that matters more than the polling. The wait caps at two seconds, far past anything I measured. The bootstrap retries twice and then surfaces its error like it always did. A genuinely broken launchd, a malformed plist, a permissions problem, all still fail loudly. What the bounds buy is that a slow unload no longer looks like a broken install, and a broken install still looks broken.

The test that pins it is named for the outcome rather than the mechanism: “waits out an async bootout instead of leaving nothing loaded”. If someone later decides the polling loop is superstition, the test name tells them what breaks.

The pattern, and its sibling
#

Put the two launchd posts next to each other and they are the same shape from opposite sides.

Earlier postThis post
Commandlaunchctl unload / bootoutlaunchctl bootout
Returned00
Realityjob unloaded, process still runningrequest accepted, label still loaded
Consequenceorphaned daemon holding a portnothing running at all
CauseKeepAlive respawn racing the teardownteardown not finished when the call returns

In both cases the exit code is honest about what the command did and silent about what launchd had not finished doing yet. launchctl reports on the request, not on the world.

And in both cases the fix is the same in outline: after asking launchd to change something, verify the change independently, with a bound. pgrep in one direction, launchctl print in the other.

Lessons
#

  • launchctl bootout returns when launchd accepts the request, not when the job is gone. The exit code describes the request, not the world.
  • Adding a safety can convert a loud harmless failure into a silent harmful one. “Bootstrap refused because it is already loaded” was noisy and fine. “Nothing is loaded” is quiet and broken.
  • Suspect any bug that goes away when you run the command a second time. That shape is a race, and running it twice is not a workaround, it is the reproduction.
  • launchctl print gui/<uid>/<label> is the predicate you want. Its exit code answers “is this label loaded right now”, which is the question bootout will not answer for you.
  • Bound the poll and bound the retry. Unbounded waiting turns a broken plist into a hang, and the whole value of the fix is that real failures still fail.
  • Name the test after the outcome, not the mechanism. “Waits out an async bootout instead of leaving nothing loaded” survives a refactor that deletes the loop.

References
#

Related

launchctl unload returned 0. The daemon was still running. KeepAlive raced.

··1797 words·9 mins
`launchctl unload ~/Library/LaunchAgents/com.nickboy.claudedeck.plist` exited 0. Then `pgrep -f claudedeck-daemon` printed a fresh PID. Three seconds after the "unload succeeded" line. Spoiler: KeepAlive is a polling supervisor, not an event-driven one, and when you tell launchd to tear a job down, there is a window where the supervisor has already noticed the previous PID is gone and started a replacement. Building ClaudeDeck · Part 10 of 10 1 2 3 4 5 6 7 8 9 10 (One-paragraph grounding if launchd isn’t your daily driver: launchd is macOS’s init system, the equivalent of systemd on Linux or Windows Services on Windows. It boots PID 1, brings up daemons, restarts them when they crash. A LaunchAgent is a per-user launchd job, defined by an XML plist (property list) at ~/Library/LaunchAgents/<name>.plist. KeepAlive is one of the plist keys; set it to true and launchd will respawn the job whenever it exits. launchctl is the CLI you use to load, unload, and inspect those jobs. The Linux mental model: think systemctl driving systemd unit files. The Stream Deck plugin and its daemon are described in the TCC cdhash trap post if you want the project context.)

The tunnel was up. The socket existed. Nothing was on the other end.

··1851 words·9 mins
The daemon reported the tunnel as established. The local socket file was right there on disk with the right permissions. The target sat at `offline` forever. Meanwhile `connect to /home/nick/.config/herdr/sessions/main/herdr.sock port 0 failed` was being written to a stderr stream that nothing in my process ever read, several times a second, for as long as I left it running. Deleting ClaudeDeck · Part 5 of 5 1 2 3 4 5 🧪 Tested with herdr 0.8.0 · macOS HerdDeck drives agents on a second machine. My laptop has the Stream Deck and the daemon; the desktop runs herdr and the agents actually doing work. They are joined by ssh -N -L <local.sock>:<remote.sock> host, a Unix-domain socket forward, and the daemon speaks the same protocol to a forwarded socket as it does to a local one.

The ring was 1.00:1 against its background. The key read 35% at 65%.

··1541 words·8 mins
A key on my Stream Deck showed an agent with a context window 65% full. The ring around it displayed a little over a third of a circle, which anyone would read as 35%. Not blank, not obviously broken, no missing-data placeholder. Confidently wrong, by exactly the amount that inverts the decision you are making when you look at it. Deleting ClaudeDeck · Part 4 of 5 1 2 3 4 5 🧪 Tested with herdr 0.8.0 · macOS That is the hardware, live against five agents on a remote machine. The top row is one key per agent. The tint is that session’s lifecycle state, and the ring is how full its context window is. Two independent signals, one key.