use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::client::Client;
use super::workshop::WorkshopRendezvous;
use super::service::{self, Control, Record, Supervisor, System};
const START_WITHIN: Duration = Duration::from_secs(60);
const STOP_WITHIN: Duration = Duration::from_secs(20);
const POLL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Serving {
pub pid: u32,
pub version: String,
pub port: Option<u16>,
}
impl Serving {
fn named(&self) -> String {
format!("wsb-daemon {} (pid {})", self.version, self.pid)
}
}
pub(crate) trait Daemons {
fn serving(&mut self) -> Result<Option<Serving>, String>;
fn stop(&mut self, daemon: &Serving) -> Result<(), String>;
}
pub(crate) struct PublishedDaemons<'a> {
pub state_dir: &'a Path,
}
impl Daemons for PublishedDaemons<'_> {
fn serving(&mut self) -> Result<Option<Serving>, String> {
if let Some(published) = WorkshopRendezvous::read(self.state_dir)? {
if let Ok(info) = Client::from_rendezvous(&published)?.info() {
return Ok(Some(Serving { pid: info.pid, version: info.version, port: Some(info.http_port) }));
}
return Ok(super::launch::still_serving(&published).then(|| Serving {
pid: published.pid,
version: published.version.clone(),
port: Some(published.http_port),
}));
}
Ok(WorkshopRendezvous::published_by(self.state_dir)?
.filter(|published| crate::ops::process::is_alive(published.pid))
.map(|published| Serving { pid: published.pid, version: published.version, port: None }))
}
fn stop(&mut self, daemon: &Serving) -> Result<(), String> {
let asked = match WorkshopRendezvous::read(self.state_dir)? {
Some(published) if published.pid == daemon.pid => {
Client::from_rendezvous(&published).and_then(|client| client.shutdown()).is_ok()
}
_ => false,
};
if !asked {
if let Some(failed) = crate::ops::process::signal(&[daemon.pid], "TERM")
.into_iter()
.find(|signalled| signalled.outcome.is_failure())
{
return Err(format!("cannot stop {}: {:?}", daemon.named(), failed.outcome));
}
}
let deadline = Instant::now() + STOP_WITHIN;
while crate::ops::process::is_alive(daemon.pid) {
if Instant::now() >= deadline {
return Err(format!(
"{} was asked to stop and is still running {}s later; end it with `kill {}` and run this again",
daemon.named(),
STOP_WITHIN.as_secs(),
daemon.pid
));
}
std::thread::sleep(POLL);
}
WorkshopRendezvous::remove_if_published_by(self.state_dir, daemon.pid).map(|_| ())
}
}
#[derive(Debug, Clone)]
pub struct Installed {
pub record: Record,
pub supervisor: Supervisor,
pub definition: PathBuf,
}
impl Installed {
fn stale(&self) -> bool {
match std::fs::read_to_string(&self.definition) {
Ok(on_disk) => service::drifted(self.supervisor, &self.record, Some(&on_disk)),
Err(_) => true,
}
}
}
pub fn installed(state_dir: &Path) -> Result<Option<Installed>, String> {
let Some(record) = service::record(state_dir)? else {
return Ok(None);
};
let supervisor = service::supervisor()?;
let definition = service::definition_path(supervisor)?;
Ok(Some(Installed { record, supervisor, definition }))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Brought {
pub serving: Serving,
pub replaced: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn bring_up_with<C: Control, D: Daemons>(
control: &mut C,
daemons: &mut D,
supervisor: Supervisor,
definition: &Path,
version: &str,
restart: bool,
prepare: impl FnOnce(&mut C) -> Result<(), String>,
within: Duration,
poll: Duration,
) -> Result<Brought, String> {
let mut replaced = Vec::new();
let keeping = service::supervised_with(control, supervisor)?.keeping();
if let Some(daemon) = daemons.serving()? {
let owned = keeping == Some(daemon.pid);
let current = daemon.version == version;
if !owned || !current || restart {
daemons.stop(&daemon)?;
if !owned {
replaced.push(format!("stopped {}, which the service was not keeping", daemon.named()));
} else if !current {
replaced.push(format!("stopped the service's {}, which is not this wsb's {version}", daemon.named()));
}
}
}
prepare(control)?;
service::start_with(control, supervisor, definition)?;
let deadline = Instant::now() + within;
loop {
let state = service::supervised_with(control, supervisor)?;
if state.failing() {
return Err(format!("the service is {}", state.failure()));
}
if let Some(daemon) = daemons.serving()? {
match state.keeping() {
Some(pid) if pid == daemon.pid && daemon.version == version => {
return Ok(Brought { serving: daemon, replaced });
}
Some(pid) if pid == daemon.pid => {
return Err(format!(
"the service started {}, and this is wsb {version}: its definition names another \
build. Install it again with this wsb: wsb daemon install",
daemon.named()
));
}
_ => {
daemons.stop(&daemon)?;
replaced.push(format!("stopped {}, started while the service was starting", daemon.named()));
}
}
}
if Instant::now() >= deadline {
return Err(format!(
"the service's daemon has not answered in {}s; {} says it is {}",
within.as_secs(),
supervisor.describe(),
state.failure_or_phase()
));
}
std::thread::sleep(poll);
}
}
fn bring_up_here(
state_dir: &Path,
installed: &Installed,
restart: bool,
prepare: impl FnOnce(&mut System) -> Result<(), String>,
) -> Result<Brought, String> {
bring_up_with(
&mut System,
&mut PublishedDaemons { state_dir },
installed.supervisor,
&installed.definition,
crate::get_version(),
restart,
prepare,
START_WITHIN,
POLL,
)
.map_err(|error| with_last_error(state_dir, error))
}
fn with_last_error(state_dir: &Path, error: String) -> String {
match last_error(state_dir) {
Some(logged) => format!("{error}\n its log says: {logged}"),
None => error,
}
}
pub fn last_error(state_dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(super::log_path(state_dir)).ok()?;
let tail = &text[text.len().saturating_sub(64 * 1024)..];
tail.lines().rev().find(|line| line.contains("Error:")).map(|line| line.trim().to_string())
}
pub fn install(state_dir: &Path, record: &Record) -> Result<Brought, String> {
let installed = Installed {
record: record.clone(),
supervisor: service::supervisor()?,
definition: service::definition_path(service::supervisor()?)?,
};
bring_up_here(state_dir, &installed, false, |control| {
service::apply_with(control, installed.supervisor, record, &installed.definition, service::BOOTOUT_TIMEOUT)?;
service::write_record(state_dir, record)
})
}
pub fn enable(state_dir: &Path) -> Result<(Record, Brought), String> {
let Some(mut installed) = installed(state_dir)? else {
return Err("no service is installed on this host, so there is nothing to enable.\n \
`wsb daemon install` puts one on it."
.to_string());
};
installed.record.autostart = true;
let record = installed.record.clone();
let brought = bring_up_here(state_dir, &installed, false, |control| {
service::apply_with(control, installed.supervisor, &record, &installed.definition, service::BOOTOUT_TIMEOUT)?;
service::write_record(state_dir, &record)
})?;
Ok((record, brought))
}
pub fn bring_up(state_dir: &Path, installed: &Installed, restart: bool) -> Result<Brought, String> {
let stale = installed.stale();
bring_up_here(state_dir, installed, restart, |control| {
if stale {
service::apply_with(
control,
installed.supervisor,
&installed.record,
&installed.definition,
service::BOOTOUT_TIMEOUT,
)
} else {
Ok(())
}
})
}
pub fn stop(state_dir: &Path) -> Result<Vec<String>, String> {
stop_in(state_dir, &mut System, &mut PublishedDaemons { state_dir }, service::supervisor)
}
pub(crate) fn stop_in(
state_dir: &Path,
control: &mut impl service::Control,
daemons: &mut impl Daemons,
supervisor_of_this_host: impl FnOnce() -> Result<Supervisor, String>,
) -> Result<Vec<String>, String> {
let supervisor = match service::record_path(state_dir).exists() {
true => Some(supervisor_of_this_host()?),
false => None,
};
let mut stopped = Vec::new();
if let Some(supervisor) = supervisor {
let state = service::supervised_with(control, supervisor)?;
if state.phase != service::Phase::Stopped {
let was = daemons.serving()?.filter(|daemon| state.keeping() == Some(daemon.pid));
service::stop_with(control, supervisor)?;
stopped.push(match was {
Some(daemon) => format!("stopped the service's {}", daemon.named()),
None => format!("stopped the service ({})", state.failure_or_phase()),
});
}
}
if let Some(daemon) = daemons.serving()? {
daemons.stop(&daemon)?;
stopped.push(format!("stopped {}", daemon.named()));
}
Ok(stopped)
}
#[cfg(test)]
#[path = "lifecycle_tests.rs"]
mod tests;