fix(service): wait for launchd to drop the old job before loading the new

- Update now stopped the daemon and failed with "Bootstrap failed: 5: Input/output error" on macOS, leaving the agent's plist written and no job loaded. `launchctl bootout` returns once the daemon has been sent SIGTERM, and launchd refuses `bootstrap` until the daemon has exited and the job has left the domain.
- Installing or replacing the launchd agent now waits for the booted-out job to leave, for up to 25 seconds, which covers the 20 seconds launchd allows before SIGKILL.
- Starting the service now loads the agent's plist first when launchd does not hold the job, so the window's Start button and `service start` recover a registration left unloaded instead of failing in `kickstart`.
- The stand-in launchctl in the service lifecycle test returns from `bootout` while the daemon is still stopping and refuses `bootstrap` until it has gone, as launchd does, so `service install --start` over a running daemon pins the regression.
This commit is contained in:
James Coleman 2026-10-04 08:43:28 -05:00
parent 9eede3b366
commit 57fe7ff53a
3 changed files with 58 additions and 10 deletions

View file

@ -5,6 +5,16 @@ use crate::{
write_definition,
};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// BOOTOUT_WAIT is how long a booted-out job is given to leave its domain.
///
/// launchd sends the daemon SIGTERM and, with no `ExitTimeOut` in the plist, SIGKILL 20 seconds
/// later, so the job is gone within that plus the time launchd takes to reap it.
const BOOTOUT_WAIT: Duration = Duration::from_secs(25);
/// BOOTOUT_POLL is how often launchd is asked whether a booted-out job has left.
const BOOTOUT_POLL: Duration = Duration::from_millis(100);
/// Escapes text for inclusion in a plist string element.
///
@ -119,9 +129,29 @@ impl Launchd {
format!("{}/{}", self.domain, SERVICE_LABEL)
}
/// Removes any existing registration, ignoring the error when none exists.
/// Reports whether launchd holds the job, running or not.
fn loaded(&self) -> bool {
run("launchctl", &["print", &self.target()]).is_ok()
}
/// Loads the job from the plist on disk.
fn bootstrap(&self) -> Result<(), ServiceError> {
let plist = self.plist_path.display().to_string();
run("launchctl", &["bootstrap", &self.domain, &plist]).map(|_| ())
}
/// Removes any existing registration, ignoring the error when none exists, and waits for
/// the job to leave.
///
/// `launchctl bootout` returns as soon as the daemon has been sent SIGTERM. The job stays in
/// the domain until the daemon exits, and until then `bootstrap` fails with "5: Input/output
/// error", which left an updated daemon stopped and unregistered.
fn bootout(&self) {
let _ = run("launchctl", &["bootout", &self.target()]);
let deadline = Instant::now() + BOOTOUT_WAIT;
while self.loaded() && Instant::now() < deadline {
std::thread::sleep(BOOTOUT_POLL);
}
}
}
@ -132,9 +162,7 @@ impl ServiceManager for Launchd {
self.bootout();
let spec = with_log_file(spec, &self.log_file);
write_definition(&self.plist_path, &render_plist(&spec))?;
let plist = self.plist_path.display().to_string();
run("launchctl", &["bootstrap", &self.domain, &plist])?;
self.bootstrap()?;
Ok(self.plist_path.clone())
}
@ -160,6 +188,11 @@ impl ServiceManager for Launchd {
}
fn start(&self) -> Result<(), ServiceError> {
// A plist launchd does not hold, after a bootstrap that failed or a bootout by hand,
// has nothing to kickstart until it is loaded.
if !self.loaded() {
self.bootstrap()?;
}
run("launchctl", &["kickstart", &self.target()]).map(|_| ())
}

View file

@ -64,6 +64,18 @@ the registration and starts it. Stopping comes first so the service manager stop
started. launchd is the exception: installing boots the old job out, which stops its daemon, and
stopping it beforehand only has launchd start the old one again in between.
**launchd, found in use (2026-10-04).** Update now on two Macs running macOS 15.6.1 stopped the
daemon and failed with `Bootstrap failed: 5: Input/output error`, leaving the plist written and
no job loaded. `launchctl bootout` returns once the daemon has been sent SIGTERM, and the job
stays in the domain until the daemon exits; `bootstrap` is refused until then. Reproduced on
macOS 27.0.1 with a scratch job that takes 3 s to exit: `bootout` returned in 0.01 s, `print`
went on answering with `state = SIGTERMed` and `bootstrap` failed with error 5 for 3.2 s, then
`print` exited 113 and `bootstrap` succeeded. The backend now polls `print` after `bootout` until
the job has left, for up to 25 s, which covers launchd's 20 s before SIGKILL. The Start button
then ran `kickstart` on a job that was not loaded, so `start` now bootstraps the plist first when
launchd does not hold the job. The stand-in `launchctl` in `tests/service_lifecycle.rs` behaves
the same way, and the test failed against the old backend.
**The App Store build** has no service. The app starts its helper, or attaches to one already
answering. It now asks an attached daemon for its build and, when it is another, asks it to stop
and starts its own.

View file

@ -2,10 +2,12 @@
//!
//! The binary runs with `PATH` holding nothing but a stand-in for `launchctl` or `systemctl`, so
//! the real one cannot be reached and nothing is registered with the machine. The stand-in keeps
//! its state in files and starts the daemon itself, as the real one would, so `service start`
//! has a daemon to wait for. Starting a service that is running does nothing, as with the real
//! ones. Everything else a user would have is the real code: the definition
//! written under a scratch home, the status read back from it, and the configuration beside it.
//! its state in files and starts the daemon itself, as the real one would, so `service start` has a
//! daemon to wait for. Starting a service that is running does nothing, as with the real ones. The
//! stand-in `launchctl` returns from `bootout` while the daemon is still stopping and refuses
//! `bootstrap` until it has gone, as launchd does (seen on macOS 15.6.1 and 27.0.1). Everything
//! else a user would have is the real code: the definition written under a scratch home, the status
//! read back from it, and the configuration beside it.
#![cfg(any(target_os = "macos", target_os = "linux"))]
#![allow(
@ -33,9 +35,10 @@ halt() {
/bin/rm -f "$FAKE_STATE/pid"
}
case "$1" in
bootstrap) : > "$FAKE_STATE/loaded" ;;
bootstrap) [ -f "$FAKE_STATE/loaded" ] && { echo "Bootstrap failed: 5: Input/output error" >&2; exit 5; }
: > "$FAKE_STATE/loaded" ;;
bootout) [ -f "$FAKE_STATE/loaded" ] || { echo "Boot-out failed: 3: No such process" >&2; exit 3; }
halt; /bin/rm -f "$FAKE_STATE/loaded" ;;
(halt; /bin/rm -f "$FAKE_STATE/loaded") > /dev/null 2>&1 & ;;
kickstart) [ -f "$FAKE_STATE/loaded" ] || exit 113
running && exit 0
"$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 &