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:
parent
9eede3b366
commit
57fe7ff53a
3 changed files with 58 additions and 10 deletions
|
|
@ -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(|_| ())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue