use std::path::{Path, PathBuf};
use std::time::Duration;
use super::launch::Spawn;
pub const LABEL: &str = "com.machinefabric.wsb-daemon";
pub const UNIT: &str = "wsb-daemon.service";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Supervisor {
Systemd,
Launchd,
}
impl Supervisor {
pub fn describe(self) -> &'static str {
match self {
Self::Systemd => "systemd (--user)",
Self::Launchd => "launchd (user agent)",
}
}
}
pub fn supervisor() -> Result<Supervisor, String> {
if cfg!(target_os = "macos") {
return Ok(Supervisor::Launchd);
}
if cfg!(target_os = "linux") {
if user_manager_reachable() {
return Ok(Supervisor::Systemd);
}
return Err(
"this machine has no reachable systemd user manager, so there is nothing to \
install a service into.\n \
`systemctl --user` must answer; on a machine with no login session that \
usually means `loginctl enable-linger $USER`.\n \
Without a service the daemon still starts by itself with the next command."
.to_string(),
);
}
Err(format!(
"wsb does not know how to install a service on {}.\n \
The daemon still starts by itself with the next command that needs one.",
std::env::consts::OS
))
}
fn user_manager_reachable() -> bool {
crate::git_backend::command_without_repo_scope("systemctl")
.args(["--user", "is-system-running"])
.output()
.is_ok_and(|out| {
let said = String::from_utf8_lossy(&out.stdout);
let state = said.trim();
!state.is_empty() && state != "offline"
})
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Record {
pub executable: PathBuf,
pub arguments: Vec<String>,
pub worktree_root: PathBuf,
pub log: PathBuf,
pub path: String,
pub machine_dir: PathBuf,
pub prj: Option<PathBuf>,
pub autostart: bool,
}
pub fn record_path(state_dir: &Path) -> PathBuf {
state_dir.join("service.json")
}
pub fn record(state_dir: &Path) -> Result<Option<Record>, String> {
let path = record_path(state_dir);
match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).map(Some).map_err(|e| {
if serde_json::from_str::<serde_json::Value>(&text)
.is_ok_and(|value| value.get("workspace_root").is_some())
{
return format!(
"{} was written by an older wsb; run `wsb migrate` (with the daemon stopped: \
`wsb daemon stop`), then `wsb daemon install`",
path.display()
);
}
format!(
"{} is not a service record this wsb reads ({e}); `wsb daemon install` writes \
one, and keeps whether the service starts at login",
path.display()
)
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("cannot read {}: {e}", path.display())),
}
}
fn write_record(state_dir: &Path, record: &Record) -> Result<(), String> {
let path = record_path(state_dir);
let text = serde_json::to_string_pretty(record)
.map_err(|e| format!("cannot write the service record: {e}"))?;
std::fs::write(&path, format!("{text}\n"))
.map_err(|e| format!("cannot write {}: {e}", path.display()))
}
pub fn definition_path(supervisor: Supervisor) -> Result<PathBuf, String> {
let home = home()?;
Ok(match supervisor {
Supervisor::Systemd => config_home()?.join("systemd/user").join(UNIT),
Supervisor::Launchd => home
.join("Library/LaunchAgents")
.join(format!("{LABEL}.plist")),
})
}
fn home() -> Result<PathBuf, String> {
std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.ok_or_else(|| "HOME is not set, so there is no user to install a service for".to_string())
}
fn config_home() -> Result<PathBuf, String> {
match std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
Some(explicit) => Ok(PathBuf::from(explicit)),
None => Ok(home()?.join(".config")),
}
}
pub fn install(
state_dir: &Path,
worktree_root: &Path,
with: &Spawn,
) -> Result<Record, String> {
let supervisor = supervisor()?;
let executable = super::daemon_executable()?;
let arguments = super::daemon_arguments(worktree_root, with)?;
let existing = installed_autostart(state_dir)?;
let record = Record {
executable,
arguments,
worktree_root: worktree_root.to_path_buf(),
log: super::log_path(state_dir),
path: std::env::var("PATH").unwrap_or_default(),
machine_dir: state_dir.to_path_buf(),
prj: std::env::var_os("PRJ").filter(|v| !v.is_empty()).map(PathBuf::from),
autostart: existing,
};
apply(supervisor, &record)?;
write_record(state_dir, &record)?;
Ok(record)
}
fn installed_autostart(state_dir: &Path) -> Result<bool, String> {
let path = record_path(state_dir);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
};
let value: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("{} is not JSON: {e}", path.display()))?;
value
.get("autostart")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| format!("{} does not say whether the service starts at login", path.display()))
}
pub fn enable(state_dir: &Path) -> Result<Record, String> {
set_autostart(state_dir, true)
}
pub fn disable(state_dir: &Path) -> Result<Record, String> {
set_autostart(state_dir, false)
}
fn set_autostart(state_dir: &Path, autostart: bool) -> Result<Record, String> {
let supervisor = supervisor()?;
let Some(mut record) = record(state_dir)? else {
return Err(format!(
"no service is installed on this machine, so there is nothing to {}.\n \
`wsb daemon install` puts one on it.",
if autostart { "enable" } else { "disable" }
));
};
record.autostart = autostart;
apply(supervisor, &record)?;
write_record(state_dir, &record)?;
Ok(record)
}
pub fn uninstall(state_dir: &Path) -> Result<bool, String> {
let supervisor = supervisor()?;
let definition = definition_path(supervisor)?;
let had = record(state_dir)?.is_some() || definition.exists();
if !had {
return Ok(false);
}
match supervisor {
Supervisor::Systemd => {
let _ = systemctl(&["disable", UNIT]);
remove(&definition)?;
systemctl(&["daemon-reload"])?;
}
Supervisor::Launchd => {
let _ = launchctl(&["bootout".to_string(), service_target()?]);
remove(&definition)?;
}
}
let record = record_path(state_dir);
if record.exists() {
remove(&record)?;
}
Ok(true)
}
fn remove(path: &Path) -> Result<(), String> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("cannot remove {}: {e}", path.display())),
}
}
trait Control {
fn systemctl(&mut self, arguments: &[&str]) -> Result<String, String>;
fn launchctl(&mut self, arguments: &[String]) -> Result<String, String>;
fn launchd_loaded(&mut self, target: &str) -> Result<bool, String>;
}
struct System;
impl Control for System {
fn systemctl(&mut self, arguments: &[&str]) -> Result<String, String> {
systemctl(arguments)
}
fn launchctl(&mut self, arguments: &[String]) -> Result<String, String> {
launchctl(arguments)
}
fn launchd_loaded(&mut self, target: &str) -> Result<bool, String> {
let output = crate::git_backend::command_without_repo_scope("launchctl")
.args(["print", target])
.output()
.map_err(|e| format!("cannot run launchctl: {e}"))?;
match output.status.code() {
Some(0) => Ok(true),
Some(LAUNCHCTL_NO_SUCH_SERVICE) => Ok(false),
code => Err(format!(
"launchctl print {target} failed ({}): {}",
code.map_or("killed by a signal".to_string(), |code| format!("exit {code}")),
String::from_utf8_lossy(&output.stderr).trim()
)),
}
}
}
const LAUNCHCTL_NO_SUCH_SERVICE: i32 = 113;
const BOOTOUT_TIMEOUT: Duration = Duration::from_secs(30);
const BOOTOUT_POLL: Duration = Duration::from_millis(100);
fn apply(supervisor: Supervisor, record: &Record) -> Result<(), String> {
apply_with(&mut System, supervisor, record, &definition_path(supervisor)?, BOOTOUT_TIMEOUT)
}
fn apply_with(
control: &mut impl Control,
supervisor: Supervisor,
record: &Record,
definition: &Path,
bootout_within: Duration,
) -> Result<(), String> {
if let Some(parent) = definition.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
let rendered = render(supervisor, record);
let previous = match std::fs::read_to_string(definition) {
Ok(text) => Some(text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(format!("cannot read {}: {e}", definition.display())),
};
std::fs::write(definition, &rendered)
.map_err(|e| format!("cannot write {}: {e}", definition.display()))?;
match supervisor {
Supervisor::Systemd => {
control.systemctl(&["daemon-reload"])?;
if previous.as_deref() != Some(rendered.as_str()) {
control.systemctl(&["try-restart", UNIT])?;
}
if record.autostart {
control.systemctl(&["enable", "--now", UNIT])?;
} else {
control.systemctl(&["disable", UNIT])?;
}
}
Supervisor::Launchd => {
let target = service_target()?;
let _ = control.launchctl(&["bootout".to_string(), target.clone()]);
wait_until_booted_out(control, &target, bootout_within)?;
if record.autostart {
control.launchctl(&["bootstrap".to_string(), domain()?, definition_string(definition)])?;
control.launchctl(&["kickstart".to_string(), target])?;
}
}
}
Ok(())
}
fn wait_until_booted_out(control: &mut impl Control, target: &str, within: Duration) -> Result<(), String> {
let deadline = std::time::Instant::now() + within;
while control.launchd_loaded(target)? {
if std::time::Instant::now() >= deadline {
return Err(format!(
"launchd still holds {LABEL} {}s after it was booted out, so the new definition \
cannot be loaded in its place.\n \
`launchctl print {target}` shows what it is doing; once it is gone, run this again.",
within.as_secs()
));
}
std::thread::sleep(BOOTOUT_POLL);
}
Ok(())
}
fn definition_string(path: &Path) -> String {
path.display().to_string()
}
fn domain() -> Result<String, String> {
Ok(format!("gui/{}", uid()))
}
fn service_target() -> Result<String, String> {
Ok(format!("gui/{}/{LABEL}", uid()))
}
#[cfg(unix)]
fn uid() -> u32 {
unsafe { libc::getuid() }
}
#[cfg(not(unix))]
fn uid() -> u32 {
0
}
pub fn render(supervisor: Supervisor, record: &Record) -> String {
match supervisor {
Supervisor::Systemd => render_unit(record),
Supervisor::Launchd => render_plist(record),
}
}
fn render_unit(record: &Record) -> String {
let mut exec = vec![systemd_quote(&record.executable.display().to_string())];
exec.extend(record.arguments.iter().map(|argument| systemd_quote(argument)));
let log = record.log.display();
format!(
"# Written by `wsb daemon install`, rendered from the machine's service\n\
# record. Edits here are lost the next time that is written.\n\
[Unit]\n\
Description=wsb daemon for {worktree}\n\
After=default.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={exec}\n\
# Failures only. `wsb daemon stop` exits zero, and a supervisor that\n\
# started a new daemon on top of a deliberate stop would make the\n\
# command impossible to obey.\n\
Restart=on-failure\n\
RestartSec=5\n\
# A supervisor does not start a login shell. Without this the daemon\n\
# has the manager's PATH, and every operation that needs a toolchain\n\
# fails with 'command not found' on a machine where it is installed.\n\
Environment=PATH={path}\n\
# Where its state is and where the projects are, as the installing\n\
# command resolved them: a daemon that worked them out again without\n\
# PRJ kept a second, empty machine of its own.\n\
{environment}\
# The same file an on-demand daemon writes to, so `wsb daemon log`\n\
# has one answer whichever started it.\n\
StandardOutput=append:{log}\n\
StandardError=append:{log}\n\
# The daemon is holding every other surface open; a build it started\n\
# can be run again. systemd-oomd is asked to look elsewhere first.\n\
ManagedOOMPreference=avoid\n\
\n\
[Install]\n\
WantedBy=default.target\n",
worktree = record.worktree_root.display(),
exec = exec.join(" "),
path = record.path,
environment = state_environment(record)
.iter()
.map(|(name, value)| format!("Environment={}\n", systemd_quote(&format!("{name}={value}"))))
.collect::<String>(),
)
}
fn systemd_quote(word: &str) -> String {
let plain = !word.is_empty()
&& word
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./:=@+,".contains(c));
if plain {
return word.to_string();
}
let escaped = word.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn render_plist(record: &Record) -> String {
let mut arguments = vec![record.executable.display().to_string()];
arguments.extend(record.arguments.iter().cloned());
let program: String = arguments
.iter()
.map(|argument| format!(" <string>{}</string>\n", xml(argument)))
.collect();
let log = xml(&record.log.display().to_string());
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<!-- Written by `wsb daemon install`, rendered from the machine's\n\
service record. Edits here are lost the next time it is written. -->\n\
<plist version=\"1.0\">\n\
<dict>\n\
\x20 <key>Label</key>\n\
\x20 <string>{LABEL}</string>\n\
\x20 <key>ProgramArguments</key>\n\
\x20 <array>\n{program}\
\x20 </array>\n\
\x20 <key>RunAtLoad</key>\n\
\x20 <{run}/>\n\
\x20 <!-- Failures only: a deliberate `wsb daemon stop` exits zero. -->\n\
\x20 <key>KeepAlive</key>\n\
\x20 <dict>\n\
\x20 <key>SuccessfulExit</key>\n\
\x20 <false/>\n\
\x20 </dict>\n\
\x20 <!-- launchd does not start a login shell. Without this the daemon\n\
\x20 cannot find the toolchains every operation needs. -->\n\
\x20 <key>EnvironmentVariables</key>\n\
\x20 <dict>\n\
\x20 <key>PATH</key>\n\
\x20 <string>{path}</string>\n\
{environment}\
\x20 </dict>\n\
\x20 <key>StandardOutPath</key>\n\
\x20 <string>{log}</string>\n\
\x20 <key>StandardErrorPath</key>\n\
\x20 <string>{log}</string>\n\
\x20 <!-- Standard, not Background: launchd throttles a Background job's\n\
\x20 processor and disk, and the daemon then took minutes to start\n\
\x20 and seconds for every answer, while it serves a page somebody\n\
\x20 is waiting on and runs the builds they asked for. -->\n\
\x20 <key>ProcessType</key>\n\
\x20 <string>Standard</string>\n\
</dict>\n\
</plist>\n",
run = if record.autostart { "true" } else { "false" },
path = xml(&record.path),
environment = state_environment(record)
.iter()
.map(|(name, value)| {
format!(" <key>{name}</key>\n <string>{}</string>\n", xml(value))
})
.collect::<String>(),
)
}
fn state_environment(record: &Record) -> Vec<(&'static str, String)> {
let mut environment = vec![(super::machine::MACHINE_DIR_ENV, record.machine_dir.display().to_string())];
if let Some(prj) = &record.prj {
environment.push(("PRJ", prj.display().to_string()));
}
environment
}
fn xml(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Status {
pub supervisor: Supervisor,
pub definition: PathBuf,
pub installed: bool,
pub autostart: bool,
pub active: bool,
pub pid: Option<u32>,
pub drifted: bool,
pub linger: Option<bool>,
}
pub fn drifted(supervisor: Supervisor, record: &Record, on_disk: Option<&str>) -> bool {
match on_disk {
Some(text) => text != render(supervisor, record),
None => false,
}
}
pub fn status(state_dir: &Path) -> Result<Status, String> {
let supervisor = supervisor()?;
let definition = definition_path(supervisor)?;
let record = record(state_dir)?;
let installed = record.is_some() && definition.exists();
let drifted = match &record {
Some(record) => drifted(
supervisor,
record,
std::fs::read_to_string(&definition).ok().as_deref(),
),
None => false,
};
let (active, pid) = running(supervisor);
Ok(Status {
supervisor,
definition,
installed,
autostart: record.map(|record| record.autostart).unwrap_or(false),
active,
pid,
drifted,
linger: match supervisor {
Supervisor::Systemd => linger(),
Supervisor::Launchd => None,
},
})
}
fn running(supervisor: Supervisor) -> (bool, Option<u32>) {
match supervisor {
Supervisor::Systemd => {
let Ok(output) = crate::git_backend::command_without_repo_scope("systemctl")
.args(["--user", "show", UNIT, "--property=ActiveState,MainPID"])
.output()
else {
return (false, None);
};
let said = String::from_utf8_lossy(&output.stdout);
let mut active = false;
let mut pid = None;
for line in said.lines() {
if let Some(state) = line.strip_prefix("ActiveState=") {
active = state == "active" || state == "activating";
}
if let Some(number) = line.strip_prefix("MainPID=") {
pid = number.parse::<u32>().ok().filter(|pid| *pid != 0);
}
}
(active, pid)
}
Supervisor::Launchd => {
let Ok(target) = service_target() else {
return (false, None);
};
let Ok(output) = crate::git_backend::command_without_repo_scope("launchctl")
.args(["print", &target])
.output()
else {
return (false, None);
};
if !output.status.success() {
return (false, None);
}
let said = String::from_utf8_lossy(&output.stdout);
let pid = said.lines().find_map(|line| {
line.trim()
.strip_prefix("pid = ")
.and_then(|number| number.trim().parse::<u32>().ok())
});
let state_running = said
.lines()
.any(|line| line.trim() == "state = running");
(pid.is_some() || state_running, pid)
}
}
}
fn linger() -> Option<bool> {
let user = std::env::var("USER").ok()?;
let output = crate::git_backend::command_without_repo_scope("loginctl")
.args(["show-user", &user, "--property=Linger"])
.output()
.ok()?;
let said = String::from_utf8_lossy(&output.stdout);
said.lines()
.find_map(|line| line.strip_prefix("Linger="))
.map(|value| value.trim() == "yes")
}
pub fn restart() -> Result<(), String> {
match supervisor()? {
Supervisor::Systemd => systemctl(&["restart", UNIT]).map(|_| ()),
Supervisor::Launchd => {
launchctl(&["kickstart".to_string(), "-k".to_string(), service_target()?]).map(|_| ())
}
}
}
fn systemctl(arguments: &[&str]) -> Result<String, String> {
let mut full = vec!["--user"];
full.extend_from_slice(arguments);
run("systemctl", &full.iter().map(|a| a.to_string()).collect::<Vec<_>>())
}
fn launchctl(arguments: &[String]) -> Result<String, String> {
run("launchctl", arguments)
}
fn run(program: &str, arguments: &[String]) -> Result<String, String> {
let output = crate::git_backend::command_without_repo_scope(program)
.args(arguments)
.output()
.map_err(|e| format!("cannot run {program}: {e}"))?;
if !output.status.success() {
let said = String::from_utf8_lossy(&output.stderr);
let said = said.trim();
return Err(format!(
"{program} {} failed{}",
arguments.join(" "),
if said.is_empty() {
String::new()
} else {
format!(": {said}")
}
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn spawn_for_service(port: Option<u16>, peer_port: Option<u16>) -> Spawn {
Spawn {
listen: super::LOOPBACK,
http_port: match port {
Some(port) => super::Port::Exact(port),
None => super::Port::FirstFreeFrom(super::DEFAULT_HTTP_PORT),
},
parent_pid: None,
debug: false,
peer_port: peer_port.unwrap_or(crate::peer::DEFAULT_PEER_PORT),
}
}
pub const START_TIMEOUT: Duration = Duration::from_secs(20);
#[cfg(test)]
#[path = "service_tests.rs"]
mod tests;