#![cfg(target_os = "macos")]
use anyhow::{Context, Result};
use crate::launchd::{LaunchdConfig, current_uid};
use crate::launchd_labels::{EvictionOutcome, LabelEviction};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Activation {
AlreadyCurrent {
evicted: Vec<String>,
},
Activated {
evicted: Vec<String>,
replaced: bool,
},
}
impl Activation {
#[must_use]
pub fn evicted(&self) -> &[String] {
match self {
Activation::AlreadyCurrent { evicted } | Activation::Activated { evicted, .. } => {
evicted
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rollback {
Restored,
NothingToRestore,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RollbackPlan {
RestorePrevious,
ReviveWritten,
RemoveAndBootout,
}
#[must_use]
pub fn rollback_plan(has_previous: bool, was_loaded: bool) -> RollbackPlan {
if has_previous {
RollbackPlan::RestorePrevious
} else if was_loaded {
RollbackPlan::ReviveWritten
} else {
RollbackPlan::RemoveAndBootout
}
}
pub fn execute_rollback(
plan: RollbackPlan,
write_previous: impl FnOnce() -> bool,
remove_plist: impl FnOnce(),
bootstrap: impl FnOnce() -> bool,
) -> Rollback {
match plan {
RollbackPlan::RestorePrevious => {
if !write_previous() {
return Rollback::Failed;
}
if bootstrap() {
Rollback::Restored
} else {
Rollback::Failed
}
}
RollbackPlan::ReviveWritten => {
if bootstrap() {
Rollback::Restored
} else {
Rollback::Failed
}
}
RollbackPlan::RemoveAndBootout => {
remove_plist();
Rollback::NothingToRestore
}
}
}
#[must_use]
pub fn reload_needed(
rendered: &str,
installed: Option<&str>,
is_loaded: bool,
force: bool,
) -> bool {
force || !is_loaded || installed != Some(rendered)
}
impl LaunchdConfig {
pub fn install_and_activate(&self, legacy_labels: &[&str]) -> Result<Activation> {
self.install_and_activate_forced(legacy_labels, false)
}
pub fn install_and_activate_forced(
&self,
legacy_labels: &[&str],
force: bool,
) -> Result<Activation> {
debug_assert!(
!legacy_labels.contains(&self.label.as_str()),
"a service's own label must never be listed as its legacy alias — \
evicting it would boot out the unit being installed"
);
let plist_path = self.plist_path()?;
let previous = std::fs::read_to_string(&plist_path).ok();
let rendered = self.render_plist()?;
let evicted = self.evict_legacy(legacy_labels);
let was_loaded = self.is_loaded();
if !reload_needed(&rendered, previous.as_deref(), was_loaded, force) {
return Ok(Activation::AlreadyCurrent { evicted });
}
let replaced = previous.is_some();
self.install()?;
self.guard_short_grace(crate::shutdown::TERMINATION_GRACE_SECS);
match self.bootstrap_and_verify() {
Ok(()) => Ok(Activation::Activated { evicted, replaced }),
Err(e) => {
let restored = self.roll_back(&plist_path, previous.as_deref(), was_loaded);
Err(e).context(match restored {
Rollback::Restored => {
"activating the new LaunchAgent failed; the previously \
installed unit was restored and reloaded, so the \
service is not left down"
}
Rollback::NothingToRestore => {
"activating the new LaunchAgent failed; no unit was \
installed beforehand, so nothing was taken down"
}
Rollback::Failed => {
"activating the new LaunchAgent failed AND restoring \
the previous unit also failed — THE SERVICE IS DOWN. \
Re-run the install, or bootstrap the plist by hand"
}
})
}
}
}
pub fn evict_legacy(&self, legacy_labels: &[&str]) -> Vec<String> {
self.evict_legacy_detailed(legacy_labels)
.into_iter()
.filter(|e| e.outcome == EvictionOutcome::Evicted)
.map(|e| e.label)
.collect()
}
pub fn evict_legacy_detailed(&self, legacy_labels: &[&str]) -> Vec<LabelEviction> {
legacy_labels
.iter()
.map(|legacy| {
let mut alias = self.clone();
alias.label = (*legacy).to_string();
LabelEviction::new(*legacy, alias.evict_one())
})
.collect()
}
fn evict_one(&self) -> EvictionOutcome {
let was_loaded = self.is_loaded();
let mut failures: Vec<String> = Vec::new();
if was_loaded {
if let Err(e) = self.bootout() {
failures.push(format!("`launchctl bootout` failed: {e}"));
} else if self.is_loaded() {
failures
.push("still loaded after `launchctl bootout` reported success".to_string());
}
}
let removed = match self.plist_path() {
Ok(path) if path.exists() => match std::fs::remove_file(&path) {
Ok(()) => true,
Err(e) => {
failures.push(format!("could not delete {}: {e}", path.display()));
false
}
},
Ok(_) => false,
Err(e) => {
failures.push(format!("could not resolve the plist path: {e}"));
false
}
};
if !failures.is_empty() {
EvictionOutcome::Failed(failures.join("; "))
} else if was_loaded || removed {
EvictionOutcome::Evicted
} else {
EvictionOutcome::Absent
}
}
pub(crate) fn guard_short_grace(&self, required_secs: u64) {
use crate::launchd_grace::{GraceVerdict, Quiesce};
let GraceVerdict::TooShort {
active_secs,
required_secs,
} = self.active_grace_verdict(required_secs)
else {
return;
};
tracing::warn!(
label = %self.label,
active_secs,
required_secs,
"the loaded launchd unit grants less shutdown grace than the daemon \
needs; stopping it directly before bootout so its flush is not \
SIGKILLed (#6590)"
);
match self.quiesce_before_bootout(required_secs) {
Quiesce::NotRunning => {}
Quiesce::Exited { waited_secs } => tracing::info!(
label = %self.label,
waited_secs,
"daemon exited cleanly before bootout"
),
Quiesce::StillRunning => tracing::warn!(
label = %self.label,
required_secs,
"daemon did not exit within its own grace window; the bootout \
that follows may still be cut short by launchd"
),
}
}
fn bootstrap_and_verify(&self) -> Result<()> {
self.bootstrap()?;
if !self.is_loaded() {
anyhow::bail!(
"launchctl bootstrap reported success but gui/{}/{} is not \
loaded (#2498)",
current_uid(),
self.label
);
}
Ok(())
}
fn roll_back(
&self,
plist_path: &std::path::Path,
previous: Option<&str>,
was_loaded: bool,
) -> Rollback {
let plan = rollback_plan(previous.is_some(), was_loaded);
let outcome = execute_rollback(
plan,
|| previous.is_some_and(|bytes| std::fs::write(plist_path, bytes).is_ok()),
|| {
let _ = std::fs::remove_file(plist_path);
},
|| self.bootstrap().is_ok() && self.is_loaded(),
);
if plan == RollbackPlan::RemoveAndBootout {
let _ = self.bootout();
}
outcome
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reload_needed_is_false_when_nothing_changed() {
assert!(!reload_needed("<plist/>", Some("<plist/>"), true, false));
}
#[test]
fn rollback_plan_restores_a_previous_unit() {
assert_eq!(
rollback_plan(true, true),
RollbackPlan::RestorePrevious,
"a unit that was running must be put back"
);
assert_eq!(rollback_plan(true, false), RollbackPlan::RestorePrevious);
}
#[test]
fn rollback_plan_revives_the_written_unit_when_a_live_job_was_displaced() {
assert_eq!(
rollback_plan(false, true),
RollbackPlan::ReviveWritten,
"the displaced job cannot be reconstructed, so the written unit is \
the only thing left that can restore service"
);
}
#[test]
fn rollback_execution_reports_failure_when_revival_fails() {
let mut removed = false;
let outcome = execute_rollback(
RollbackPlan::ReviveWritten,
|| unreachable!("no previous plist on this path"),
|| removed = true,
|| false,
);
assert_eq!(
outcome,
Rollback::Failed,
"a failed revival leaves the service down and must say so"
);
assert!(
!removed,
"the written plist is the only unit naming this label — deleting it \
removes the last chance of recovery"
);
}
#[test]
fn rollback_execution_keeps_the_written_plist_when_reviving() {
let mut removed = false;
let outcome = execute_rollback(
RollbackPlan::ReviveWritten,
|| unreachable!("no previous plist on this path"),
|| removed = true,
|| true,
);
assert_eq!(outcome, Rollback::Restored);
assert!(!removed);
}
#[test]
fn rollback_execution_reports_restored_only_when_bootstrap_succeeds() {
assert_eq!(
execute_rollback(RollbackPlan::RestorePrevious, || true, || {}, || true),
Rollback::Restored
);
assert_eq!(
execute_rollback(RollbackPlan::RestorePrevious, || true, || {}, || false),
Rollback::Failed,
"a restored file that will not load is still a down service"
);
assert_eq!(
execute_rollback(
RollbackPlan::RestorePrevious,
|| false,
|| {},
|| { unreachable!("must not bootstrap when the write failed") }
),
Rollback::Failed
);
}
#[test]
fn rollback_plan_boots_out_a_label_it_started_itself() {
assert_eq!(rollback_plan(false, false), RollbackPlan::RemoveAndBootout);
let mut removed = false;
let outcome = execute_rollback(
RollbackPlan::RemoveAndBootout,
|| unreachable!("no previous plist on this path"),
|| removed = true,
|| unreachable!("nothing to revive when nothing was running"),
);
assert_eq!(outcome, Rollback::NothingToRestore);
assert!(removed, "a unit that never came up must not be left behind");
}
#[test]
fn reload_needed_is_true_when_forced() {
assert!(
reload_needed("<plist/>", Some("<plist/>"), true, true),
"a forced install must activate the binary it just built"
);
}
#[test]
fn reload_needed_is_true_when_the_plist_changed() {
assert!(
reload_needed(
"<plist>new</plist>",
Some("<plist>old</plist>"),
true,
false
),
"a changed plist must be activated or its fixes never reach launchd"
);
}
#[test]
fn reload_needed_is_true_when_the_label_is_not_loaded() {
assert!(reload_needed("<plist/>", Some("<plist/>"), false, false));
}
#[test]
fn reload_needed_is_true_on_first_install() {
assert!(reload_needed("<plist/>", None, false, false));
assert!(reload_needed("<plist/>", None, true, false));
}
}