From 57fe7ff53addaac6e7170aeb95ec5d5d091971f9 Mon Sep 17 00:00:00 2001 From: James Coleman Date: Sun, 4 Oct 2026 08:43:28 -0500 Subject: [PATCH] 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. --- crates/service/src/launchd.rs | 41 +++++++++++++++++++++++++--- specs/019-daemon-updates/research.md | 12 ++++++++ tests/service_lifecycle.rs | 15 ++++++---- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/crates/service/src/launchd.rs b/crates/service/src/launchd.rs index 6ee5baf..3306055 100644 --- a/crates/service/src/launchd.rs +++ b/crates/service/src/launchd.rs @@ -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(|_| ()) } diff --git a/specs/019-daemon-updates/research.md b/specs/019-daemon-updates/research.md index 0c4db1e..6c0375b 100644 --- a/specs/019-daemon-updates/research.md +++ b/specs/019-daemon-updates/research.md @@ -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. diff --git a/tests/service_lifecycle.rs b/tests/service_lifecycle.rs index 98c32da..318e356 100644 --- a/tests/service_lifecycle.rs +++ b/tests/service_lifecycle.rs @@ -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 &