use crate::Result;
use crate::cli::logs;
use crate::daemon_id::DaemonId;
use crate::daemon_status::DaemonStatus;
use crate::env;
use crate::ipc::client::IpcClient;
use crate::pitchfork_toml::PitchforkToml;
use crate::procs::PROCS;
use crate::settings::settings;
use crate::state_file::StateFile;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::time;
#[cfg(windows)]
use tokio::signal;
#[cfg(unix)]
use tokio::signal::unix::{self, SignalKind};
#[derive(Debug, usage_rs::Args)]
#[usage(
verbatim_doc_comment,
long_about = "\
Wait for one or more daemons to stop, tailing the logs along the way
Blocks until every specified daemon stops running, while displaying its
log output in real-time. Already-finished daemons are evaluated without
waiting; their exit codes still count. With no daemon IDs and no
`--group`, shows an interactive picker of the currently running daemons.
A daemon the supervisor restarts (because a watched file changed, or
through `pitchfork restart` / `start --force`) shows as `restarting` in
between, and waiting follows it to the new process instead of returning.
With `--exit-on-restart`, waiting ends when the daemon is stopped for a
restart, and the restart counts as a clean stop (exit 0).
With `--kill`, an incoming signal (SIGINT/SIGTERM/SIGHUP/SIGQUIT, or Ctrl-C
on Windows) first stops the waited daemons via the supervisor (graceful
SIGTERM then SIGKILL, hooks fire, reverse dependency order), then the
command exits with 128 + the signal number like the shell, so Ctrl-C
yields 130.
Exit code: 0 when every waited daemon stopped cleanly. Otherwise the exit
code of the first failing daemon (in the order given) is propagated;
unknown exit codes, failed daemons, and missing statuses map to 1.
Useful in scripts that need to wait for daemons to complete.
Examples:
pitchfork wait api Wait for 'api' to stop, exit with its status
pitchfork wait api worker Wait for 'api' and 'worker' to stop
pitchfork wait --group backend Wait for the whole 'backend' group
pitchfork wait --kill api Stop 'api' gracefully when a signal arrives
pitchfork wait --exit-on-restart api
Return when 'api' is restarted
pitchfork w api Alias for 'wait'
pitchfork wait api && echo done Run command after the daemon stops"
)]
pub struct Wait {
id: Vec<String>,
#[usage(long, value_name = "GROUP")]
group: Option<String>,
#[usage(long)]
kill: bool,
#[usage(long)]
exit_on_restart: bool,
}
impl Wait {
pub async fn run(&self) -> Result<()> {
let no_target = self.id.is_empty() && self.group.is_none();
let ids: Vec<DaemonId> = if no_target {
super::interactive::require_interactive_terminal()?;
let ipc = Arc::new(IpcClient::connect(false).await?);
let candidates = ipc.get_running_daemons().await?;
super::interactive::select_daemons_interactively(&candidates, "wait")?
} else {
PitchforkToml::resolve_ids_and_group(&self.id, self.group.as_deref())?
};
let sf = StateFile::get();
let supervisor_live = supervisor_is_live(sf);
let mut watched_ids: Vec<DaemonId> = Vec::new();
let mut polled: Vec<Watched> = Vec::new();
let mut restarted: Vec<DaemonId> = Vec::new();
let exit_on_restart = self.exit_on_restart;
for id in &ids {
match sf.daemons.get(id) {
Some(daemon)
if !is_terminal_status(&daemon.status, exit_on_restart)
|| retry_pending(sf, daemon, supervisor_live) =>
{
watched_ids.push(id.clone());
polled.push(Watched::new(id.clone(), daemon.pid, exit_on_restart));
}
Some(daemon) => {
watched_ids.push(id.clone());
if daemon.status.is_restarting() {
restarted.push(id.clone());
}
}
None => {
warn!("{id} is not running");
}
}
}
if watched_ids.is_empty() {
return Ok(());
}
let ipc: Option<Arc<IpcClient>> = if self.kill {
Some(Arc::new(IpcClient::connect(false).await?))
} else {
None
};
let tail_names = watched_ids.clone();
tokio::spawn(async move {
logs::tail_logs(
&tail_names,
true,
false,
Vec::new(),
Vec::new(),
None,
settings().logs.timestamp,
false,
)
.await
.unwrap_or_default();
});
let mut signal_rx = if self.kill {
Some(register_signal_receiver()?)
} else {
None
};
if !polled.is_empty() {
let mut interval = time::interval(time::Duration::from_millis(100));
let mut remaining = polled;
loop {
tokio::select! {
signo = wait_for_signal(&mut signal_rx), if signal_rx.is_some() => {
match signo {
Some(signo) => {
let stop_ids: Vec<DaemonId> =
remaining.iter().map(|w| w.id.clone()).collect();
let ipc = ipc.as_ref().expect("--kill connects IPC upfront");
if let Err(e) = ipc.stop_daemons(&stop_ids).await {
warn!("failed to stop waited daemons on signal: {e}");
}
std::process::exit(128 + signo);
}
None => {
warn!("--kill signal handling is no longer active; continuing to wait");
signal_rx = None;
}
}
}
_ = interval.tick() => {
if exit_on_restart || remaining.iter().any(|w| !w.process_running()) {
let sf = StateFile::read(&*env::PITCHFORK_STATE_FILE).ok();
let supervisor_live = sf.as_ref().is_some_and(supervisor_is_live);
remaining.retain_mut(|w| {
let running = w.still_running(sf.as_ref(), supervisor_live);
if !running && w.restarted {
restarted.push(w.id.clone());
}
running
});
}
if remaining.is_empty() {
break;
}
}
}
}
}
let finished_ids: Vec<DaemonId> = watched_ids
.iter()
.filter(|id| !restarted.contains(id))
.cloned()
.collect();
let statuses = read_terminal_statuses(&finished_ids, exit_on_restart).await;
if let Some(exit_code) = watched_ids
.iter()
.map(|id| {
if restarted.contains(id) {
0
} else {
daemon_exit_code(id, &statuses)
}
})
.find(|code| *code != 0)
{
std::process::exit(exit_code);
}
Ok(())
}
}
#[cfg(unix)]
fn register_signal_receiver() -> Result<mpsc::Receiver<i32>> {
let (tx, rx) = mpsc::channel(4);
let mut registered = 0;
for (kind, signo) in [
(SignalKind::interrupt(), libc::SIGINT),
(SignalKind::terminate(), libc::SIGTERM),
(SignalKind::hangup(), libc::SIGHUP),
(SignalKind::quit(), libc::SIGQUIT),
] {
let stream = match unix::signal(kind) {
Ok(s) => s,
Err(e) => {
warn!("Failed to register signal handler for {kind:?}: {e}");
continue;
}
};
registered += 1;
let tx = tx.clone();
tokio::spawn(async move {
let mut stream = stream;
if stream.recv().await.is_some() {
let _ = tx.send(signo).await;
}
});
}
if registered == 0 {
return Err(miette::miette!(
"failed to register any signal handler for --kill"
));
}
Ok(rx)
}
#[cfg(windows)]
fn register_signal_receiver() -> Result<mpsc::Receiver<i32>> {
let (tx, rx) = mpsc::channel(4);
tokio::spawn(async move {
if signal::ctrl_c().await.is_ok() {
let _ = tx.send(2).await;
}
});
Ok(rx)
}
async fn wait_for_signal(signal_rx: &mut Option<mpsc::Receiver<i32>>) -> Option<i32> {
signal_rx.as_mut()?.recv().await
}
fn daemon_exit_code(id: &DaemonId, statuses: &[(DaemonId, DaemonStatus)]) -> i32 {
statuses
.iter()
.find(|(status_id, _)| status_id == id)
.map_or(1, |(_, status)| status_exit_code(status))
}
fn status_exit_code(status: &DaemonStatus) -> i32 {
match status {
DaemonStatus::Stopped => 0,
DaemonStatus::Completed => 0,
DaemonStatus::Errored(code) if *code != -1 => *code,
DaemonStatus::Errored(_) => 1,
DaemonStatus::Failed(_) => 1,
_ => 1,
}
}
const SETTLE_TIMEOUT: time::Duration = crate::supervisor::EXIT_OUTPUT_DRAIN_TIMEOUT
.saturating_add(crate::supervisor::STATE_FLUSH_INTERVAL)
.saturating_add(time::Duration::from_secs(2));
struct Watched {
id: DaemonId,
pid: Option<u32>,
gone_since: Option<time::Instant>,
exit_on_restart: bool,
restarted: bool,
pid_seen_live: bool,
}
impl Watched {
fn new(id: DaemonId, pid: Option<u32>, exit_on_restart: bool) -> Self {
Self {
id,
pid,
gone_since: None,
exit_on_restart,
restarted: false,
pid_seen_live: pid.is_some_and(|pid| PROCS.is_running(pid)),
}
}
fn process_running(&self) -> bool {
self.pid.is_some_and(|pid| PROCS.is_running(pid))
}
fn still_running(&mut self, sf: Option<&StateFile>, supervisor_live: bool) -> bool {
let Some(daemon) = sf.and_then(|sf| sf.daemons.get(&self.id)) else {
return self.process_running();
};
if self.exit_on_restart && daemon.status.is_restarting() {
self.restarted = true;
return false;
}
if self.process_running() {
self.pid_seen_live = true;
return true;
}
let Some(sf) = sf else { return false };
if let Some(pid) = daemon.pid
&& Some(pid) != self.pid
&& PROCS.is_running(pid)
{
if self.exit_on_restart && self.pid_seen_live && daemon.retry_count == 0 {
self.restarted = true;
return false;
}
self.pid = Some(pid);
self.pid_seen_live = true;
self.gone_since = None;
return true;
}
if is_terminal_status(&daemon.status, self.exit_on_restart) {
if retry_pending(sf, daemon, supervisor_live) {
self.pid = None;
self.gone_since = None;
return true;
}
self.restarted = daemon.status.is_restarting();
return false;
}
if daemon.status.is_restarting() && supervisor_live {
self.gone_since = None;
return true;
}
let gone_since = *self.gone_since.get_or_insert_with(time::Instant::now);
gone_since.elapsed() < SETTLE_TIMEOUT
}
}
fn retry_pending(sf: &StateFile, daemon: &crate::daemon::Daemon, supervisor_live: bool) -> bool {
supervisor_live
&& daemon.status.is_errored()
&& daemon.pid.is_none()
&& daemon.retry.count() > 0
&& daemon.retry_count < daemon.retry.count()
&& !sf.disabled.contains(&daemon.id)
}
fn supervisor_is_live(sf: &StateFile) -> bool {
sf.daemons
.get(&DaemonId::pitchfork())
.is_some_and(crate::supervisor::supervisor_record_is_live)
}
fn is_terminal_status(status: &DaemonStatus, exit_on_restart: bool) -> bool {
if status.is_restarting() {
return exit_on_restart;
}
!status.is_running() && !status.is_waiting() && !status.is_stopping()
}
fn fresh_statuses(ids: &[DaemonId]) -> Vec<(DaemonId, DaemonStatus)> {
StateFile::read(&*env::PITCHFORK_STATE_FILE)
.map(|sf| {
ids.iter()
.filter_map(|id| sf.daemons.get(id).map(|d| (id.clone(), d.status.clone())))
.collect()
})
.unwrap_or_default()
}
async fn read_terminal_statuses(
ids: &[DaemonId],
exit_on_restart: bool,
) -> Vec<(DaemonId, DaemonStatus)> {
for _ in 0..40 {
let statuses = fresh_statuses(ids);
if statuses.len() == ids.len()
&& statuses
.iter()
.all(|(_, status)| is_terminal_status(status, exit_on_restart))
{
return statuses;
}
time::sleep(time::Duration::from_millis(50)).await;
}
fresh_statuses(ids)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::Daemon;
fn failed(retry: u32, retry_count: u32) -> Daemon {
Daemon {
id: DaemonId::new("proj", "api"),
status: DaemonStatus::Errored(3),
retry: crate::config_types::Retry(retry),
retry_count,
..Default::default()
}
}
fn state_with(daemon: &Daemon) -> StateFile {
let mut sf = StateFile::new(std::path::PathBuf::from("state.toml"));
sf.daemons.insert(daemon.id.clone(), daemon.clone());
sf
}
#[test]
fn a_failed_attempt_with_retries_left_is_not_the_end() {
let daemon = failed(2, 0);
let sf = state_with(&daemon);
assert!(retry_pending(&sf, &daemon, true));
let mut watched = Watched::new(daemon.id.clone(), None, false);
assert!(watched.still_running(Some(&sf), true));
}
#[test]
fn the_last_attempt_failing_is_the_end() {
let daemon = failed(2, 2);
let sf = state_with(&daemon);
assert!(!retry_pending(&sf, &daemon, true));
let mut watched = Watched::new(daemon.id.clone(), None, false);
assert!(!watched.still_running(Some(&sf), true));
}
#[test]
fn no_retry_follows_without_a_supervisor_or_when_disabled() {
let daemon = failed(2, 0);
let mut sf = state_with(&daemon);
assert!(!retry_pending(&sf, &daemon, false));
sf.disabled.insert(daemon.id.clone());
assert!(!retry_pending(&sf, &daemon, true));
}
#[test]
fn an_exit_not_yet_recorded_is_waited_out() {
let daemon = Daemon {
id: DaemonId::new("proj", "api"),
status: DaemonStatus::Running,
..Default::default()
};
let sf = state_with(&daemon);
let mut watched = Watched::new(daemon.id.clone(), None, false);
assert!(watched.still_running(Some(&sf), true));
watched.gone_since = Some(time::Instant::now() - SETTLE_TIMEOUT);
assert!(!watched.still_running(Some(&sf), true));
}
fn restarting(pid: Option<u32>) -> Daemon {
Daemon {
id: DaemonId::new("proj", "api"),
status: DaemonStatus::Restarting,
pid,
..Default::default()
}
}
#[test]
fn a_restart_is_followed_by_default() {
let daemon = restarting(None);
let sf = state_with(&daemon);
let mut watched = Watched::new(daemon.id.clone(), Some(i32::MAX as u32), false);
assert!(watched.still_running(Some(&sf), true));
watched.gone_since = Some(time::Instant::now() - SETTLE_TIMEOUT);
assert!(watched.still_running(Some(&sf), true));
assert!(!is_terminal_status(&DaemonStatus::Restarting, false));
}
#[test]
fn the_new_process_of_a_restart_is_picked_up() {
let me = std::process::id();
let daemon = Daemon {
id: DaemonId::new("proj", "api"),
status: DaemonStatus::Running,
pid: Some(me),
..Default::default()
};
let sf = state_with(&daemon);
let mut watched = Watched::new(daemon.id.clone(), None, false);
assert!(watched.still_running(Some(&sf), true));
assert_eq!(watched.pid, Some(me));
}
#[test]
fn a_restart_left_behind_by_a_dead_supervisor_is_settled() {
let daemon = restarting(None);
let sf = state_with(&daemon);
let mut watched = Watched::new(daemon.id.clone(), None, false);
assert!(watched.still_running(Some(&sf), false));
watched.gone_since = Some(time::Instant::now() - SETTLE_TIMEOUT);
assert!(!watched.still_running(Some(&sf), false));
}
#[test]
fn exit_on_restart_ends_the_wait_cleanly() {
let daemon = restarting(None);
let sf = state_with(&daemon);
let mut watched = Watched::new(daemon.id.clone(), None, true);
assert!(!watched.still_running(Some(&sf), true));
assert!(watched.restarted);
assert!(is_terminal_status(&DaemonStatus::Restarting, true));
assert_eq!(status_exit_code(&DaemonStatus::Restarting), 1);
}
#[test]
fn exit_on_restart_ends_the_wait_when_the_restart_already_finished() {
let mut daemon = Daemon {
id: DaemonId::new("proj", "api"),
status: DaemonStatus::Running,
pid: Some(std::process::id()),
..Default::default()
};
let sf = state_with(&daemon);
let mut watched = Watched::new(daemon.id.clone(), Some(i32::MAX as u32), true);
watched.pid_seen_live = true;
assert!(!watched.still_running(Some(&sf), true));
assert!(watched.restarted);
let mut watched = Watched::new(daemon.id.clone(), Some(i32::MAX as u32), true);
assert!(watched.still_running(Some(&sf), true));
assert!(!watched.restarted);
assert_eq!(watched.pid, Some(std::process::id()));
daemon.retry = crate::config_types::Retry(2);
daemon.retry_count = 1;
let sf = state_with(&daemon);
let mut watched = Watched::new(daemon.id.clone(), Some(i32::MAX as u32), true);
watched.pid_seen_live = true;
assert!(watched.still_running(Some(&sf), true));
assert!(!watched.restarted);
}
#[test]
fn exit_on_restart_ends_the_wait_while_the_old_process_is_stopping() {
let daemon = restarting(Some(std::process::id()));
let sf = state_with(&daemon);
let mut watched = Watched::new(daemon.id.clone(), Some(std::process::id()), true);
assert!(!watched.still_running(Some(&sf), true));
assert!(watched.restarted);
let mut watched = Watched::new(daemon.id.clone(), Some(std::process::id()), false);
assert!(watched.still_running(Some(&sf), true));
}
}