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,
|
write_definition,
|
||||||
};
|
};
|
||||||
use std::path::{Path, PathBuf};
|
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.
|
/// Escapes text for inclusion in a plist string element.
|
||||||
///
|
///
|
||||||
|
|
@ -119,9 +129,29 @@ impl Launchd {
|
||||||
format!("{}/{}", self.domain, SERVICE_LABEL)
|
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) {
|
fn bootout(&self) {
|
||||||
let _ = run("launchctl", &["bootout", &self.target()]);
|
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();
|
self.bootout();
|
||||||
let spec = with_log_file(spec, &self.log_file);
|
let spec = with_log_file(spec, &self.log_file);
|
||||||
write_definition(&self.plist_path, &render_plist(&spec))?;
|
write_definition(&self.plist_path, &render_plist(&spec))?;
|
||||||
|
self.bootstrap()?;
|
||||||
let plist = self.plist_path.display().to_string();
|
|
||||||
run("launchctl", &["bootstrap", &self.domain, &plist])?;
|
|
||||||
Ok(self.plist_path.clone())
|
Ok(self.plist_path.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,6 +188,11 @@ impl ServiceManager for Launchd {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start(&self) -> Result<(), ServiceError> {
|
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(|_| ())
|
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
|
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.
|
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
|
**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
|
answering. It now asks an attached daemon for its build and, when it is another, asks it to stop
|
||||||
and starts its own.
|
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 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
|
//! 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`
|
//! its state in files and starts the daemon itself, as the real one would, so `service start` has a
|
||||||
//! has a daemon to wait for. Starting a service that is running does nothing, as with the real
|
//! daemon to wait for. Starting a service that is running does nothing, as with the real ones. The
|
||||||
//! ones. Everything else a user would have is the real code: the definition
|
//! stand-in `launchctl` returns from `bootout` while the daemon is still stopping and refuses
|
||||||
//! written under a scratch home, the status read back from it, and the configuration beside it.
|
//! `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"))]
|
#![cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
#![allow(
|
#![allow(
|
||||||
|
|
@ -33,9 +35,10 @@ halt() {
|
||||||
/bin/rm -f "$FAKE_STATE/pid"
|
/bin/rm -f "$FAKE_STATE/pid"
|
||||||
}
|
}
|
||||||
case "$1" in
|
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; }
|
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
|
kickstart) [ -f "$FAKE_STATE/loaded" ] || exit 113
|
||||||
running && exit 0
|
running && exit 0
|
||||||
"$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 &
|
"$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 &
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue