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 errorErrno 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:
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 post | This post | |
|---|---|---|
| Command | launchctl unload / bootout | launchctl bootout |
| Returned | 0 | 0 |
| Reality | job unloaded, process still running | request accepted, label still loaded |
| Consequence | orphaned daemon holding a port | nothing running at all |
| Cause | KeepAlive respawn racing the teardown | teardown 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 bootoutreturns 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 questionbootoutwill 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#
launchctl(1)andlaunchd.plist(5)man pages. Accurate about verbs, silent about lifecycle ordering, which is the part that bites- The sibling failure on the same API: launchctl unload returned 0. The daemon was still running. KeepAlive raced.
- The project this installer belongs to: I deleted six subsystems by swapping one dependency.
- HerdDeck installer, the bootout wait and bootstrap retry:
packages/cli/src/herddeck.ts
