use super::hooks::{self, HookType, fire_hook};
use super::{SUPERVISOR, Supervisor};
use crate::config_types::OneshotWait;
use crate::daemon::RunOptions;
use crate::daemon_id::DaemonId;
use crate::daemon_status::DaemonStatus;
use crate::error::PortError;
use crate::inherited_fds::CloseInheritedFds;
use crate::ipc::IpcResponse;
use crate::log_store::LogStore;
use crate::log_store::sqlite::LOG_STORE;
use crate::pitchfork_toml::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, RunCommand};
use crate::procs::PROCS;
use crate::settings::{resolve_shell, settings};
use crate::shell::{HideConsoleWindow, Shell, ShellScript, powershell_script};
use crate::supervisor::state::UpsertDaemonOpts;
use crate::{Result, env};
use indexmap::IndexMap;
use miette::IntoDiagnostic;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
#[cfg(unix)]
use std::ffi::CString;
use std::sync::{Arc, atomic};
use std::time::Duration;
use tokio::select;
use tokio::sync::oneshot;
use tokio::time;
static REGEX_CACHE: Lazy<std::sync::Mutex<HashMap<String, Regex>>> =
Lazy::new(|| std::sync::Mutex::new(HashMap::new()));
fn resolve_configured_ready_port(
configured_port: u16,
expected_ports: &[u16],
resolved_ports: &[u16],
) -> u16 {
let bump_offset = resolved_ports
.first()
.unwrap_or(&0)
.saturating_sub(*expected_ports.first().unwrap_or(&0));
if expected_ports.contains(&configured_port) && bump_offset > 0 {
configured_port
.checked_add(bump_offset)
.unwrap_or(configured_port)
} else {
configured_port
}
}
fn active_port_from_ready_port(ready_port: u16, resolved_ports: &[u16]) -> Option<u16> {
resolved_ports
.first()
.copied()
.filter(|&primary_port| primary_port == ready_port)
}
#[cfg(unix)]
#[derive(Clone, Debug, PartialEq, Eq)]
enum RunIdentity {
Inherit,
Switch {
uid: nix::unistd::Uid,
gid: nix::unistd::Gid,
username: Option<CString>,
home: Option<std::path::PathBuf>,
},
}
pub(crate) fn get_or_compile_regex(pattern: &str) -> Option<Regex> {
let mut cache = REGEX_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(re) = cache.get(pattern) {
return Some(re.clone());
}
match Regex::new(pattern) {
Ok(re) => {
cache.insert(pattern.to_string(), re.clone());
Some(re)
}
Err(e) => {
error!("invalid regex pattern '{pattern}': {e}");
None
}
}
}
pub(crate) struct CmdProbe {
pub(crate) cancel_tx: tokio::sync::oneshot::Sender<()>,
pub(crate) result_rx: tokio::sync::oneshot::Receiver<std::io::Result<std::process::ExitStatus>>,
}
fn apply_runtime_env(
command: &mut tokio::process::Command,
id: &DaemonId,
retry_count: u32,
daemon_env: Option<&IndexMap<String, String>>,
resolved_ports: &[u16],
) {
if let Some(ref path) = *env::ORIGINAL_PATH {
command.env("PATH", path);
}
if let Some(env_vars) = daemon_env {
command.envs(env_vars);
}
command
.env("PITCHFORK_DAEMON_ID", id.qualified())
.env("PITCHFORK_DAEMON_NAMESPACE", id.namespace())
.env("PITCHFORK_RETRY_COUNT", retry_count.to_string());
if let Some(port) = resolved_ports.first() {
command.env("PORT", port.to_string());
for (index, port) in resolved_ports.iter().enumerate() {
command.env(format!("PORT{index}"), port.to_string());
}
}
}
fn closed_cmd_probe() -> CmdProbe {
let (cancel_tx, _) = tokio::sync::oneshot::channel();
let (_, result_rx) = tokio::sync::oneshot::channel();
CmdProbe {
cancel_tx,
result_rx,
}
}
pub(crate) fn spawn_cmd_probe(
id: &DaemonId,
cmd: &RunCommand,
dir: &std::path::Path,
retry_count: u32,
daemon_env: Option<&IndexMap<String, String>>,
resolved_ports: &[u16],
) -> CmdProbe {
let mut command = match cmd {
RunCommand::Argv(argv) => {
let Some((program, args)) = argv.split_first().filter(|(p, _)| !p.is_empty()) else {
warn!("daemon {id}: ready_cmd array has no program to run");
return closed_cmd_probe();
};
let mut c = tokio::process::Command::new(program);
c.args(args);
c
}
RunCommand::Shell(cmd) => match resolve_shell() {
Ok(parts) => {
let (program, args) = parts.split_first().unwrap();
let mut c = tokio::process::Command::new(program);
c.shell_script(program, args, cmd);
c
}
Err(e) => {
warn!("daemon {id}: {e}; using the platform shell for this probe");
Shell::default_for_platform().command(cmd)
}
},
};
command
.current_dir(dir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.hide_console_window()
.close_inherited_fds();
apply_runtime_env(&mut command, id, retry_count, daemon_env, resolved_ports);
let mut child = match command.spawn() {
Ok(child) => child,
Err(e) => {
warn!("daemon {id}: failed to spawn command probe: {e}");
return closed_cmd_probe();
}
};
let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel();
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let status = tokio::select! {
status = child.wait() => status,
_ = &mut cancel_rx => {
let mut child = child;
let _ = child.kill().await;
child.wait().await
}
};
let _ = result_tx.send(status);
});
CmdProbe {
cancel_tx,
result_rx,
}
}
fn stop_cmd_probe_state(probe: &mut Option<CmdProbe>) {
if let Some(p) = probe.take() {
let _ = p.cancel_tx.send(());
}
}
fn spawn_ready_fail_kill(
id: DaemonId,
pid: u32,
stop_cfg: crate::config_types::StopConfig,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = PROCS
.kill_process_group_async(pid, stop_cfg.signal.into(), stop_cfg.timeout)
.await
{
error!("daemon {id}: failed to kill pid {pid} after readiness failure: {e}");
}
})
}
#[allow(clippy::too_many_arguments)]
fn any_ready_check_remaining(
ready_output: Option<&ReadyOutput>,
output_exhausted: bool,
ready_port: Option<&ReadyPort>,
port_exhausted: bool,
ready_http: Option<&ReadyHttp>,
http_exhausted: bool,
ready_cmd: Option<&ReadyCmd>,
cmd_exhausted: bool,
) -> bool {
ready_output.is_some_and(|o| o.timeout.is_none() || !output_exhausted)
|| ready_port.is_some_and(|p| p.timeout.is_none() || !port_exhausted)
|| ready_http.is_some_and(|h| h.timeout.is_none() || !http_exhausted)
|| ready_cmd.is_some_and(|c| c.timeout.is_none() || !cmd_exhausted)
}
fn delay_readiness_succeeded(
ready_notified: bool,
has_other_ready_check: bool,
process_exited: bool,
process_running: bool,
) -> bool {
!ready_notified && !has_other_ready_check && !process_exited && process_running
}
fn terminal_exit_state(
exit_reason: &str,
oneshot: bool,
exit_code: i32,
exited_cleanly: bool,
) -> (DaemonStatus, bool) {
match exit_reason {
"exit" if oneshot => (DaemonStatus::Completed, true),
"stop" | "exit" => (DaemonStatus::Stopped, exited_cleanly),
_ => (DaemonStatus::Errored(exit_code), false),
}
}
fn invalid_probe_program(id: &DaemonId, field: &str, run: Option<&RunCommand>) -> Option<String> {
let RunCommand::Argv(argv) = run? else {
return None;
};
match argv.first().map(String::as_str) {
None | Some("") => Some(format!(
"daemon {id} has no program to run in its {field} array"
)),
Some("exec") => Some(format!(
"daemon {id} starts its {field} array with \"exec\"; a {field} array runs the program directly, so remove \"exec\""
)),
Some(_) => None,
}
}
fn invalid_argv_program(id: &DaemonId, argv: &[String]) -> Option<String> {
match argv.first().map(String::as_str) {
None | Some("") => Some(format!(
"daemon {id} has no program to run: its run array starts with an empty value"
)),
Some("exec") => Some(format!(
"daemon {id} starts its run array with \"exec\"; a run array starts the program directly, so remove \"exec\""
)),
Some(_) => None,
}
}
fn launch_command(words: Vec<String>, mise_bin: Option<&std::path::Path>) -> (String, Vec<String>) {
match mise_bin {
Some(mise_bin) => {
let mut args = vec!["x".to_string(), "--".to_string()];
args.extend(words);
(mise_bin.to_string_lossy().to_string(), args)
}
None => {
let mut words = words.into_iter();
let program = words.next().unwrap_or_default();
(program, words.collect())
}
}
}
fn output_line_text(line: &[u8]) -> String {
let line = line.strip_suffix(b"\n").unwrap_or(line);
crate::cli::log_sink::decode_line(line)
.trim_end_matches('\r')
.to_string()
}
async fn forward_output_lines<R>(mut reader: R, tx: tokio::sync::mpsc::Sender<super::OutputLine>)
where
R: tokio::io::AsyncBufRead + Unpin,
{
use tokio::io::AsyncBufReadExt;
let mut line = Vec::new();
loop {
line.clear();
let last = match reader.read_until(b'\n', &mut line).await {
Ok(0) => break,
Ok(_) => false,
Err(_) if line.is_empty() => break,
Err(_) => true,
};
let sent = tx
.send(super::OutputLine {
text: output_line_text(&line),
source: super::OutputSource::Local,
})
.await;
if sent.is_err() || last {
break;
}
}
}
fn stop_keeps_finalized_status(status: &DaemonStatus) -> bool {
status.is_completed()
}
const SINK_OUTPUT_TIMEOUT: Duration = Duration::from_millis(400);
struct StopEpochGuard(DaemonId);
impl Drop for StopEpochGuard {
fn drop(&mut self) {
SUPERVISOR.bump_stop_epoch(&self.0);
}
}
pub(crate) struct RetryingGuard {
id: DaemonId,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl RetryingGuard {
fn is_cancelled(&self) -> bool {
self.cancel.load(std::sync::atomic::Ordering::Acquire)
}
}
impl Drop for RetryingGuard {
fn drop(&mut self) {
let mut retrying = SUPERVISOR
.retrying
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(claims) = retrying.get_mut(&self.id) {
claims.retain(|flag| !std::sync::Arc::ptr_eq(flag, &self.cancel));
if claims.is_empty() {
retrying.remove(&self.id);
}
}
}
}
impl Supervisor {
pub(crate) fn is_retrying(&self, id: &DaemonId) -> bool {
self.retrying
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(id)
.is_some_and(|claims| !claims.is_empty())
}
pub(crate) fn stop_epoch(&self, id: &DaemonId) -> u64 {
self.stop_epochs
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(id)
.copied()
.unwrap_or(0)
}
fn bump_stop_epoch(&self, id: &DaemonId) {
*self
.stop_epochs
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(id.clone())
.or_default() += 1;
}
fn mark_retrying(&self, id: &DaemonId) -> RetryingGuard {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.retrying
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(id.clone())
.or_default()
.push(cancel.clone());
RetryingGuard {
id: id.clone(),
cancel,
}
}
pub(crate) fn cancel_retrying(&self, id: &DaemonId) {
if let Some(claims) = self
.retrying
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(id)
{
for cancel in claims {
cancel.store(true, std::sync::atomic::Ordering::Release);
}
}
}
pub async fn run(&self, opts: RunOptions) -> Result<IpcResponse> {
let id = opts.id.clone();
let mut restart_mark = None;
let result = self.run_inner(opts, None, &mut restart_mark).await;
if let Some(mark) = restart_mark {
self.settle_restarting(&id, mark).await;
}
result
}
pub(crate) async fn settle_restarting(&self, id: &DaemonId, mark: u64) {
let lock = self.stop_lock(id).await;
let _guard = lock.lock().await;
if self.stop_epoch(id) != mark {
return;
}
let mut state_file = self.state_file.lock().await;
if state_file
.daemons
.get(id)
.is_some_and(|d| d.pid.is_none() && d.status.is_restarting())
{
debug!("daemon {id} was not started again after its restart; recording it as stopped");
state_file.set_status(id, DaemonStatus::Stopped);
}
}
pub(crate) async fn run_retry(
&self,
opts: RunOptions,
approved_at: u64,
restart_mark: &mut Option<u64>,
) -> Result<IpcResponse> {
self.run_inner(opts, Some(approved_at), restart_mark).await
}
async fn run_inner(
&self,
opts: RunOptions,
approved_at: Option<u64>,
restart_mark: &mut Option<u64>,
) -> Result<IpcResponse> {
let id = &opts.id;
let cmd = opts.cmd.clone();
{
let mut pending = self.pending_autostops.lock().await;
if pending.remove(id).is_some() {
info!("cleared pending autostop for {id} (daemon starting)");
}
}
let mut stop_guard = Some(self.stop_lock(id).await.lock_owned().await);
if let Some(approved_at) = approved_at
&& self.stop_epoch(id) != approved_at
{
info!("daemon {id} was stopped after this retry was decided on; not starting it");
return Ok(IpcResponse::DaemonNotRunning);
}
if (approved_at.is_some() || !opts.requested_by_client)
&& self.state_file.lock().await.disabled.contains(id)
{
info!("daemon {id} was disabled before this start; not starting it");
return Ok(IpcResponse::DaemonNotRunning);
}
if let Some(response) = self
.claim_or_defer(&opts, &mut stop_guard, restart_mark)
.await?
{
return Ok(response);
}
if opts.wait_ready && opts.retry.count() > 0 {
let retrying_claim = self.mark_retrying(id);
let max_attempts = opts.retry.count().saturating_add(1);
for attempt in 0..max_attempts {
let mut retry_opts = opts.clone();
retry_opts.retry_count = attempt;
retry_opts.cmd = cmd.clone();
let mut guard = Some(match stop_guard.take() {
Some(guard) => guard,
None => self.stop_lock(id).await.lock_owned().await,
});
if let Some(response) = self
.claim_or_defer(&retry_opts, &mut guard, restart_mark)
.await?
{
return Ok(response);
}
if attempt > 0
&& let Some(daemon) = self.get_daemon(id).await
&& daemon.status.is_completed()
{
info!("daemon {id} completed while waiting to retry; not running it again");
return Ok(IpcResponse::DaemonReady { daemon });
}
if attempt > 0 && self.state_file.lock().await.disabled.contains(id) {
info!(
"daemon {id} was disabled while waiting to retry; abandoning its retries"
);
return Ok(IpcResponse::DaemonFailed {
error: "disabled while retrying".to_string(),
});
}
if retrying_claim.is_cancelled() {
info!("daemon {id} was stopped while waiting to retry; abandoning its retries");
return Ok(IpcResponse::DaemonFailed {
error: "stopped while retrying".to_string(),
});
}
let Some(guard) = guard else {
return Ok(IpcResponse::DaemonAlreadyRunning);
};
let result = self.run_once(retry_opts, guard).await?;
match result {
IpcResponse::DaemonReady { daemon } => {
return Ok(IpcResponse::DaemonReady { daemon });
}
IpcResponse::DaemonFailedWithCode {
exit_code,
resolved_ports,
} => {
if attempt < opts.retry.count() {
let attempt_pid = self.get_daemon(id).await.and_then(|d| d.pid);
self.wait_for_exit_finalized(id, attempt_pid).await;
let backoff_secs = 2u64.saturating_pow(attempt).min(3600);
info!(
"daemon {id} failed (attempt {}/{}), retrying in {}s",
attempt + 1,
max_attempts,
backoff_secs
);
fire_hook(
HookType::OnRetry,
id.clone(),
super::hooks::HookDirs::new(
opts.dir.0.clone(),
opts.watch_base_dir.clone(),
),
attempt + 1,
opts.env.clone(),
resolved_ports,
vec![],
)
.await;
let backoff_deadline =
tokio::time::Instant::now() + Duration::from_secs(backoff_secs);
while tokio::time::Instant::now() < backoff_deadline
&& !retrying_claim.is_cancelled()
{
let remaining = backoff_deadline - tokio::time::Instant::now();
time::sleep(remaining.min(Duration::from_millis(200))).await;
}
continue;
} else {
info!("daemon {id} failed after {max_attempts} attempts");
return Ok(IpcResponse::DaemonFailedWithCode {
exit_code,
resolved_ports,
});
}
}
other => return Ok(other),
}
}
}
let guard = match stop_guard.take() {
Some(guard) => guard,
None => self.stop_lock(id).await.lock_owned().await,
};
self.run_once(opts, guard).await
}
async fn wait_for_exit_finalized(&self, id: &DaemonId, pid: Option<u32>) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
loop {
match self.get_daemon(id).await {
Some(daemon) if pid.map_or(daemon.pid.is_some(), |pid| daemon.pid == Some(pid)) => {
}
_ => return,
}
if tokio::time::Instant::now() >= deadline {
debug!("daemon {id}: previous attempt has not finalized yet; continuing anyway");
return;
}
time::sleep(Duration::from_millis(50)).await;
}
}
async fn claim_or_defer(
&self,
opts: &RunOptions,
stop_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
restart_mark: &mut Option<u64>,
) -> Result<Option<IpcResponse>> {
let id = &opts.id;
let Some(daemon) = self.get_daemon(id).await else {
return Ok(None);
};
if opts.on_directory_enter && opts.oneshot && daemon.status.is_completed() {
debug!("daemon {id} already completed; directory entry leaves it alone");
return Ok(Some(IpcResponse::DaemonReady { daemon }));
}
if daemon.status.is_stopping_or_restarting()
|| daemon.status.is_stopped()
|| daemon.status.is_completed()
{
return Ok(None);
}
let Some(pid) = daemon.pid else {
return Ok(None);
};
if opts.force {
self.stop_locked_with(id, true).await?;
*restart_mark = Some(self.stop_epoch(id));
info!("run: stop completed for daemon {id}");
return Ok(None);
}
if daemon.oneshot && opts.wait_ready {
info!("daemon {id} is an in-flight oneshot (pid {pid}); waiting for it to finish");
drop(stop_guard.take());
return Ok(Some(
self.await_running_oneshot(id, opts.oneshot_wait, pid).await,
));
}
PROCS.refresh_pids(&[pid]);
if !PROCS.is_running(pid) {
debug!(
"daemon {id}: record still names pid {pid}, which has exited; its monitor has not finalized yet"
);
return Ok(None);
}
warn!("daemon {id} already running with pid {pid}");
Ok(Some(IpcResponse::DaemonAlreadyRunning))
}
async fn await_running_oneshot(
&self,
id: &DaemonId,
wait: Option<OneshotWait>,
watched_pid: u32,
) -> IpcResponse {
let interval = settings().supervisor_ready_check_interval();
let deadline = wait
.unwrap_or_else(|| settings().supervisor_oneshot_wait())
.duration()
.map(|d| tokio::time::Instant::now() + d);
let mut watched_pid = watched_pid;
loop {
let Some(daemon) = self.get_daemon(id).await else {
return IpcResponse::DaemonNotFound;
};
if let Some(current) = daemon.pid
&& current != watched_pid
{
info!(
"daemon {id}: the run being waited on (pid {watched_pid}) was replaced by pid {current}; following it"
);
watched_pid = current;
}
match &daemon.status {
DaemonStatus::Completed => {
info!("daemon {id}: the in-flight oneshot completed");
return IpcResponse::DaemonReady { daemon };
}
DaemonStatus::Errored(code) => {
if daemon.retry.count() > 0
&& daemon.retry_count < daemon.retry.count()
&& !self.state_file.lock().await.disabled.contains(id)
{
debug!(
"daemon {id}: in-flight oneshot failed attempt {} of {}; still waiting",
daemon.retry_count + 1,
daemon.retry.count() + 1
);
} else {
let exit_code = Some(*code).filter(|c| *c != -1);
return IpcResponse::DaemonFailedWithCode {
exit_code,
resolved_ports: daemon.resolved_port.clone(),
};
}
}
DaemonStatus::Failed(error) => {
return IpcResponse::DaemonFailed {
error: error.clone(),
};
}
DaemonStatus::Stopped => {
warn!("daemon {id}: the in-flight oneshot was stopped before completing");
return IpcResponse::DaemonFailedWithCode {
exit_code: None,
resolved_ports: daemon.resolved_port.clone(),
};
}
DaemonStatus::Running
| DaemonStatus::Waiting
| DaemonStatus::Stopping
| DaemonStatus::Restarting => {}
}
if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
warn!("daemon {id}: gave up waiting for the in-flight oneshot to finish");
return IpcResponse::DaemonFailedWithCode {
exit_code: Some(124),
resolved_ports: Vec::new(),
};
}
time::sleep(interval).await;
}
}
pub(crate) async fn run_once(
&self,
opts: RunOptions,
stop_guard: tokio::sync::OwnedMutexGuard<()>,
) -> Result<IpcResponse> {
let id = &opts.id;
if self
.shutting_down
.load(std::sync::atomic::Ordering::Acquire)
{
info!("not starting daemon {id}: the supervisor is shutting down");
return Ok(IpcResponse::DaemonFailed {
error: "the supervisor is shutting down".to_string(),
});
}
let original_cmd = opts.cmd.clone();
let (ready_tx, ready_rx) = if opts.wait_ready {
let (tx, rx) = oneshot::channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
let expected_ports = opts
.port
.as_ref()
.map(|p| p.expect.clone())
.unwrap_or_default();
let (resolved_ports, effective_ready_port) = if !expected_ports.is_empty() {
let port_cfg = opts.port.as_ref().unwrap();
match check_ports_available(
&expected_ports,
port_cfg.auto_bump(),
port_cfg.max_bump_attempts(),
)
.await
{
Ok(resolved) => {
let ready_port = if let Some(configured_port) =
opts.ready_port.as_ref().and_then(|p| p.as_port())
{
Some(resolve_configured_ready_port(
configured_port,
&expected_ports,
&resolved,
))
} else if opts.ready_output.is_none()
&& opts.ready_http.is_none()
&& opts.ready_cmd.is_none()
&& opts.ready_delay.is_none()
{
resolved.first().copied().filter(|&p| p != 0)
} else {
None
};
info!("daemon {id}: ports {expected_ports:?} resolved to {resolved:?}");
(resolved, ready_port)
}
Err(e) => {
error!("daemon {id}: port check failed: {e}");
if let Some(port_error) = e.downcast_ref::<PortError>() {
match port_error {
PortError::InUse {
port,
address,
process,
pid,
} => {
return Ok(IpcResponse::PortConflict {
port: *port,
process: process.clone(),
pid: *pid,
address: address.clone(),
});
}
PortError::NoAvailablePort {
start_port,
attempts,
} => {
return Ok(IpcResponse::NoAvailablePort {
start_port: *start_port,
attempts: *attempts,
});
}
}
}
return Ok(IpcResponse::DaemonFailed {
error: e.to_string(),
});
}
}
} else {
if let Some(port) = opts.ready_port.as_ref().and_then(|p| p.as_port())
&& port > 0
&& let Some((address, pid, process)) = detect_port_conflict(port).await
{
return Ok(IpcResponse::PortConflict {
port,
process,
pid,
address: Some(address),
});
}
(
Vec::new(),
opts.ready_port.as_ref().and_then(|p| p.as_port()),
)
};
if let Some(error) = invalid_probe_program(
id,
"ready_cmd",
opts.ready_cmd.as_ref().map(|c| &c.run),
)
.or_else(|| {
invalid_probe_program(id, "health_cmd", opts.health_cmd.as_ref().map(|c| &c.run))
}) {
return Ok(IpcResponse::DaemonFailed { error });
}
let (mut words, script) = if opts.no_shell {
if let Some(error) = invalid_argv_program(id, &original_cmd) {
return Ok(IpcResponse::DaemonFailed { error });
}
(original_cmd.clone(), None)
} else {
let words = match resolve_shell() {
Ok(parts) => parts,
Err(error) => return Ok(IpcResponse::DaemonFailed { error }),
};
let script = opts
.run
.clone()
.unwrap_or_else(|| shell_words::join(&original_cmd));
(words, Some(script))
};
let mise_bin = if opts.mise.unwrap_or(settings().general.mise) {
let mise_bin = settings().resolve_daemon_mise_bin(opts.mise_bin.as_deref());
if mise_bin.is_none() {
warn!("daemon {id}: mise=true but mise binary not found, running without mise");
}
mise_bin
} else {
None
};
if mise_bin.is_none() && opts.deferred_template_context.is_some() {
return Ok(IpcResponse::DaemonFailed {
error: format!(
"daemon {id}: the command uses template variables only mise can render, but mise is not enabled or its binary was not found"
),
});
}
let script = match &mise_bin {
Some(mise_bin) => {
info!(
"daemon {id}: wrapping command with mise ({})",
mise_bin.display()
);
if let Some(script) = script {
let script = match words.split_first() {
Some((program, options)) => {
powershell_script(program, options, &script).into_owned()
}
None => script,
};
words.push(script);
}
None
}
None => script,
};
let (program, args) = launch_command(words, mise_bin.as_deref());
#[cfg(unix)]
let run_identity = match resolve_effective_run_identity(opts.user.as_deref()) {
Ok(identity) => identity,
Err(e) => {
return Ok(IpcResponse::DaemonFailed {
error: e.to_string(),
});
}
};
info!("run: spawning daemon {id} with {program} {args:?} {script:?}");
#[cfg(unix)]
let pty_pair = if opts.pty.unwrap_or(false) {
match super::pty::openpty() {
Ok(pair) => {
info!("daemon {id}: allocated PTY (pty = true)");
Some(pair)
}
Err(e) => {
warn!("daemon {id}: failed to allocate PTY, falling back to pipes: {e}");
None
}
}
} else {
None
};
let (output_tx, output_rx) = tokio::sync::mpsc::channel::<super::OutputLine>(256);
let mut output_relay = None;
let mut sink_pipe = None;
let mut sink_writer = None;
let mut sink_child = None;
if super::log_sink::is_supported(&opts) {
let log_format = opts
.log_format
.clone()
.unwrap_or_else(|| settings().logs.log_format.clone());
let watch_for = super::log_sink::WatchFor::from_opts(id, &opts);
let relay_token = if watch_for.is_empty() {
0
} else {
let relay = super::log_sink::OutputRelay::register(id, output_tx.clone());
let token = relay.token();
output_relay = Some(relay);
token
};
match super::log_sink::SinkPipe::new(log_format, watch_for, relay_token) {
Ok((pipe, writer)) => match pipe.start(id) {
Ok(child) => {
sink_child = Some(super::log_sink::PendingSink::new(child));
sink_pipe = Some(pipe);
sink_writer = Some(writer);
}
Err(e) => {
warn!("could not start log sink for {id}, capturing in-process: {e}");
}
},
Err(e) => {
warn!("could not create log pipe for {id}, capturing in-process: {e}");
}
}
}
let mut cmd = tokio::process::Command::new(&program);
#[cfg(unix)]
if let Some(ref pair) = pty_pair {
let slave_file = std::fs::File::from(
pair.slave
.try_clone()
.map_err(|e| miette::miette!("failed to dup slave PTY fd: {e}"))?,
);
cmd.stdin(std::process::Stdio::from(slave_file.try_clone().map_err(
|e| miette::miette!("failed to clone slave PTY fd for stdin: {e}"),
)?));
cmd.stdout(std::process::Stdio::from(slave_file.try_clone().map_err(
|e| miette::miette!("failed to clone slave PTY fd for stdout: {e}"),
)?));
cmd.stderr(std::process::Stdio::from(slave_file));
} else if let Some(writer) = sink_writer.take() {
let dup = writer
.try_clone()
.map_err(|e| miette::miette!("failed to dup log pipe for stderr: {e}"))?;
cmd.stdout(std::process::Stdio::from(writer))
.stderr(std::process::Stdio::from(dup));
} else {
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
}
#[cfg(not(unix))]
if let Some(writer) = sink_writer.take() {
let dup = writer
.try_clone()
.map_err(|e| miette::miette!("failed to dup log pipe for stderr: {e}"))?;
cmd.stdout(std::process::Stdio::from(writer))
.stderr(std::process::Stdio::from(dup));
} else {
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
}
match &script {
Some(script) => cmd.shell_script(&program, &args, script),
None => cmd.args(&args),
};
cmd.current_dir(&opts.dir)
.hide_console_window()
.close_inherited_fds();
#[cfg(unix)]
if pty_pair.is_none() {
cmd.stdin(std::process::Stdio::null());
}
#[cfg(not(unix))]
cmd.stdin(std::process::Stdio::null());
#[cfg(unix)]
apply_identity_env(&mut cmd, &run_identity);
apply_runtime_env(
&mut cmd,
id,
opts.retry_count,
opts.env.as_ref(),
&resolved_ports,
);
if let Some(context) = &opts.deferred_template_context {
cmd.env(crate::template::TEMPLATE_CONTEXT_ENV, context);
}
inject_proxy_env(&mut cmd, &daemon_proxy_host(&opts).await);
#[cfg(unix)]
{
let run_identity = run_identity.clone();
let use_pty = pty_pair.is_some();
unsafe {
cmd.pre_exec(move || {
nix::unistd::setsid().map_err(nix_to_io_error)?;
if use_pty {
let ret = libc::ioctl(0, libc::TIOCSCTTY as _, 0);
if ret < 0 {
#[cfg(target_os = "linux")]
eprintln!(
"pitchfork: TIOCSCTTY failed: {}",
std::io::Error::last_os_error()
);
}
}
apply_run_identity(&run_identity)?;
Ok(())
});
}
}
let spawn_time = chrono::Local::now();
let mut child = cmd.spawn().into_diagnostic()?;
#[cfg(windows)]
if let (Some(pid), Some(process)) = (child.id(), child.raw_handle()) {
crate::win_job::contain_spawned(process as _, pid);
}
let spawned_pid = child.id();
let monitored_guard =
spawned_pid.map(|pid| super::adopt::MonitoredGuard::register(id.clone(), pid));
if opts.cron_started {
let mut state_file = self.state_file.lock().await;
if state_file.set_last_cron_run(id, spawn_time)
&& let Err(e) = state_file.write()
{
error!("failed to persist last_cron_run for daemon {id}: {e}");
}
}
let (pid, monitored_guard) = match (spawned_pid, monitored_guard) {
(Some(p), Some(guard)) => (p, guard),
_ => {
warn!("Daemon {id} exited before PID could be captured");
if sink_child.is_some() {
super::log_sink::wait_for_output(id, spawn_time, SINK_OUTPUT_TIMEOUT).await;
}
return Ok(IpcResponse::DaemonFailed {
error: "Process exited immediately".to_string(),
});
}
};
info!("started daemon {id} with pid {pid}");
PROCS.refresh_pids(&[pid]);
let monitor_token = monitored_guard.token();
let using_sink = sink_pipe.is_some();
if let Some(pipe) = sink_pipe.take()
&& let Some(child) = sink_child.as_mut().and_then(|pending| pending.take())
{
pipe.supervise(id.clone(), monitor_token, child);
}
let attempt_resolved_ports = resolved_ports.clone();
let daemon = self
.upsert_daemon(
UpsertDaemonOpts::from_run_options(&opts, DaemonStatus::Running)
.set(|o| {
o.pid = Some(pid);
o.cmd = Some(original_cmd);
o.ready_port = effective_ready_port.map(|p| ReadyPort {
port: Some(p),
template: None,
timeout: opts.ready_port.as_ref().and_then(|rp| rp.timeout),
});
o.port = crate::config_types::PortConfig::from_parts(
expected_ports,
opts.port.as_ref().map(|p| p.bump).unwrap_or_default(),
);
o.resolved_port = Some(resolved_ports);
})
.build(),
)
.await?;
drop(stop_guard);
let id_clone = id.clone();
let ready_delay = (!opts.oneshot).then_some(opts.ready_delay).flatten();
let ready_output = (!opts.oneshot).then(|| opts.ready_output.clone()).flatten();
let ready_http = (!opts.oneshot).then(|| opts.ready_http.clone()).flatten();
let ready_port = (!opts.oneshot).then_some(effective_ready_port).flatten();
let implicit_ready_port = ready_port.map(|p| ReadyPort {
port: Some(p),
template: None,
timeout: None,
});
let ready_port_config = (!opts.oneshot)
.then(|| opts.ready_port.clone())
.flatten()
.or(implicit_ready_port);
let ready_cmd = (!opts.oneshot).then(|| opts.ready_cmd.clone()).flatten();
let daemon_dir = opts.dir.0.clone();
let hook_dirs =
super::hooks::HookDirs::new(daemon_dir.clone(), opts.watch_base_dir.clone());
let hook_retry_count = opts.retry_count;
let hook_retry = opts.retry;
let hook_daemon_env = opts.env.clone();
let hook_resolved_ports = attempt_resolved_ports.clone();
let readiness_daemon_env = opts.env.clone();
let readiness_resolved_ports = attempt_resolved_ports.clone();
let on_output_hook = opts.on_output_hook.clone();
let has_port_config = opts.port.as_ref().is_some_and(|p| !p.expect.is_empty())
|| (settings().proxy.enable && is_daemon_slug_target(id));
let daemon_pid = pid;
#[cfg(unix)]
let pty_reader = pty_pair.map(|p| {
tokio::io::BufReader::new(tokio::fs::File::from_std(std::fs::File::from(p.master)))
});
#[cfg(not(unix))]
let pty_reader: Option<tokio::io::BufReader<tokio::fs::File>> = None;
let stdout_reader = if pty_reader.is_none() {
child.stdout.take().map(tokio::io::BufReader::new)
} else {
None
};
let stderr_reader = if pty_reader.is_none() {
child.stderr.take().map(tokio::io::BufReader::new)
} else {
None
};
if !using_sink
&& pty_reader.is_none()
&& (stdout_reader.is_none() || stderr_reader.is_none())
{
error!("Failed to capture stdout/stderr for daemon {id}");
}
tokio::spawn(async move {
let id = id_clone;
let _monitored_guard = monitored_guard;
let output_relay = output_relay;
let mut output_rx = output_rx;
if let Some(reader) = pty_reader {
tokio::spawn(forward_output_lines(reader, output_tx));
} else {
if let Some(stdout) = stdout_reader {
tokio::spawn(forward_output_lines(stdout, output_tx.clone()));
}
if let Some(stderr) = stderr_reader {
tokio::spawn(forward_output_lines(stderr, output_tx.clone()));
}
drop(output_tx);
}
let log_store = Arc::clone(&LOG_STORE);
let log_format = opts
.log_format
.clone()
.unwrap_or_else(|| crate::settings::settings().logs.log_format.clone());
let parse_line = move |line: &str| crate::log_parse::parse(line, &log_format);
const LOG_BATCH_SIZE: usize = 100;
const LOG_FLUSH_INTERVAL: Duration = Duration::from_millis(100);
let mut log_buffer: Vec<crate::log_parse::ParsedLog> =
Vec::with_capacity(LOG_BATCH_SIZE);
let mut log_flush_interval = tokio::time::interval(LOG_FLUSH_INTERVAL);
log_flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let flush_logs =
|buffer: &mut Vec<crate::log_parse::ParsedLog>| -> Option<tokio::task::JoinHandle<()>> {
if buffer.is_empty() {
return None;
}
let store = Arc::clone(&log_store);
let id = id.clone();
let batch = std::mem::take(buffer);
Some(tokio::task::spawn_blocking(move || {
if let Err(e) = store.append_structured_batch(&id, &batch) {
error!("Failed to write batch to log for daemon {id}: {e}");
}
}))
};
let mut ready_notified = false;
let mut oneshot_completion_pending = false;
let mut ready_tx = ready_tx;
let ready_pattern = ready_output
.as_ref()
.and_then(|o| get_or_compile_regex(&o.pattern));
let mut active_port_spawned = false;
let on_output_hook = match on_output_hook {
Some(ref hook) => match hook.validate(id.name()) {
Ok(()) => on_output_hook,
Err(e) => {
error!("{e}");
None
}
},
None => None,
};
let on_output_pattern: Option<regex::Regex> = on_output_hook
.as_ref()
.and_then(|h| h.regex.as_deref().and_then(get_or_compile_regex));
let on_output_debounce = on_output_hook
.as_ref()
.map(|h| h.debounce_duration())
.unwrap_or(Duration::from_millis(1000));
let mut on_output_last_fired: Option<std::time::Instant> = None;
let mut delay_timer =
ready_delay.map(|secs| Box::pin(time::sleep(Duration::from_secs(secs))));
let mut http_exhausted = false;
let mut cmd_exhausted = false;
let mut port_exhausted = false;
let mut output_exhausted = false;
let s = settings();
let ready_check_interval = s.supervisor_ready_check_interval();
let http_client_timeout = s.supervisor_http_client_timeout();
let mut output_deadline = ready_output
.as_ref()
.and_then(|o| o.timeout)
.map(|d| Box::pin(time::sleep(d)));
let mut http_check_interval = ready_http
.as_ref()
.map(|_| tokio::time::interval(ready_check_interval));
let mut http_deadline = ready_http
.as_ref()
.and_then(|h| h.timeout)
.map(|d| Box::pin(time::sleep(d)));
let http_client = if ready_http.is_some() {
Some(crate::supervisor::health::supervisor_http_client().await)
} else {
None
};
let mut port_check_interval =
ready_port.map(|_| tokio::time::interval(ready_check_interval));
let mut port_deadline = ready_port_config
.as_ref()
.and_then(|p| p.timeout)
.map(|d| Box::pin(time::sleep(d)));
let mut cmd_probe: Option<CmdProbe> = None;
let mut cmd_respawn_delay: Option<_> = None;
let mut cmd_deadline = ready_cmd
.as_ref()
.and_then(|c| c.timeout)
.map(|d| Box::pin(time::sleep(d)));
if let Some(ref cmd) = ready_cmd {
cmd_probe = Some(spawn_cmd_probe(
&id,
&cmd.run,
daemon_dir.as_path(),
hook_retry_count,
readiness_daemon_env.as_ref(),
&readiness_resolved_ports,
));
}
let (exit_tx, mut exit_rx) =
tokio::sync::mpsc::channel::<std::io::Result<std::process::ExitStatus>>(1);
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
let result = child.wait().await;
#[cfg(all(unix, not(target_os = "linux")))]
let result = match &result {
Err(e) if e.raw_os_error() == Some(nix::libc::ECHILD) => {
if let Some(code) = super::REAPED_STATUSES.lock().await.remove(&child_pid) {
warn!(
"daemon pid {child_pid} wait() got ECHILD; \
recovered exit code {code} from zombie reaper"
);
use std::os::unix::process::ExitStatusExt;
if code >= 0 {
Ok(std::process::ExitStatus::from_raw(code << 8))
} else {
Ok(std::process::ExitStatus::from_raw((-code) & 0x7f))
}
} else {
warn!(
"daemon pid {child_pid} wait() got ECHILD but no \
stashed status found; reporting as error"
);
result
}
}
_ => result,
};
debug!("daemon pid {child_pid} wait() completed with result: {result:?}");
let _ = exit_tx.send(result).await;
});
#[allow(unused_assignments)]
let mut exit_status = None;
if has_port_config
&& ready_pattern.is_none()
&& ready_http.is_none()
&& ready_port.is_none()
&& ready_cmd.is_none()
&& delay_timer.is_none()
{
active_port_spawned = true;
detect_and_store_active_port(id.clone(), daemon_pid);
}
let mut ready_fail_kill: Option<tokio::task::JoinHandle<()>> = None;
loop {
select! {
biased;
Some(result) = exit_rx.recv() => {
exit_status = Some(result);
debug!("daemon {id} process exited, exit_status: {exit_status:?}");
if !ready_notified {
let is_success = exit_status.as_ref()
.and_then(|r| r.as_ref().ok())
.map(|s| s.success())
.unwrap_or(false);
if is_success && opts.oneshot {
debug!("daemon {id} completed, deferring success notification until the completed state is persisted");
oneshot_completion_pending = true;
} else if let Some(tx) = ready_tx.take() {
if is_success {
debug!("daemon {id} exited successfully before ready check, sending success notification");
let _ = tx.send(Ok(()));
} else {
let exit_code = exit_status.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|s| s.code());
debug!("daemon {id} exited with failure before ready check, sending failure notification with exit_code: {exit_code:?}");
let _ = tx.send(Err(exit_code));
}
}
} else {
debug!("daemon {id} was already marked ready, not sending notification");
}
break;
},
Some(super::OutputLine { text: line, source }) = output_rx.recv() => {
if matches!(source, super::OutputSource::Local) {
let parsed = parse_line(&line);
log_buffer.push(parsed);
if log_buffer.len() >= LOG_BATCH_SIZE {
let _ = flush_logs(&mut log_buffer);
}
}
trace!("output: {id} {line}");
let line_clean = console::strip_ansi_codes(&line).to_string();
if !ready_notified
&& !output_exhausted
&& let Some(ref pattern) = ready_pattern
&& pattern.is_match(&line_clean)
{
if let Some(handle) = flush_logs(&mut log_buffer) {
let _ = handle.await;
}
info!("daemon {id} ready: output matched pattern");
ready_notified = true;
if let Some(tx) = ready_tx.take() {
let _ = tx.send(Ok(()));
}
fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
stop_cmd_probe_state(&mut cmd_probe);
http_deadline = None;
cmd_deadline = None;
port_deadline = None;
output_deadline = None;
if !active_port_spawned && has_port_config {
active_port_spawned = true;
detect_and_store_active_port(id.clone(), daemon_pid);
}
}
if let Some(ref hook) = on_output_hook {
let matched = match source {
super::OutputSource::Sink { fires_hook } => fires_hook,
super::OutputSource::Local => match (&hook.filter, &on_output_pattern) {
(Some(substr), _) => line_clean.contains(substr.as_str()),
(None, Some(re)) => re.is_match(&line_clean),
(None, None) => true,
},
};
if matched {
let now = std::time::Instant::now();
let elapsed = on_output_last_fired.map(|t| now.duration_since(t));
if elapsed.is_none_or(|e| e >= on_output_debounce) {
on_output_last_fired = Some(now);
hooks::fire_output_hook(id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), hook.run.clone(), line_clean.clone()).await;
}
}
}
tokio::task::yield_now().await;
}
_ = async {
if let Some(ref mut deadline) = http_deadline {
deadline.await;
} else {
std::future::pending::<()>().await;
}
}, if !ready_notified && ready_http.is_some() => {
http_exhausted = true;
http_deadline = None;
http_check_interval = None;
warn!("daemon {id}: HTTP readiness check timed out");
let any_remaining = any_ready_check_remaining(
ready_output.as_ref(),
output_exhausted,
ready_port_config.as_ref(),
port_exhausted,
ready_http.as_ref(),
http_exhausted,
ready_cmd.as_ref(),
cmd_exhausted,
);
if !any_remaining {
error!("daemon {id}: all readiness checks exhausted, failing");
stop_cmd_probe_state(&mut cmd_probe);
ready_fail_kill = Some(spawn_ready_fail_kill(
id.clone(),
daemon_pid,
opts.stop_signal.unwrap_or_default(),
));
break;
}
}
_ = async {
if let Some(ref mut deadline) = output_deadline {
deadline.await;
} else {
std::future::pending::<()>().await;
}
}, if !ready_notified && ready_output.is_some() => {
output_exhausted = true;
output_deadline = None;
warn!("daemon {id}: output readiness check timed out");
let any_remaining = any_ready_check_remaining(
ready_output.as_ref(),
output_exhausted,
ready_port_config.as_ref(),
port_exhausted,
ready_http.as_ref(),
http_exhausted,
ready_cmd.as_ref(),
cmd_exhausted,
);
if !any_remaining {
error!("daemon {id}: all readiness checks exhausted, failing");
stop_cmd_probe_state(&mut cmd_probe);
ready_fail_kill = Some(spawn_ready_fail_kill(
id.clone(),
daemon_pid,
opts.stop_signal.unwrap_or_default(),
));
break;
}
}
_ = async {
if let Some(ref mut interval) = http_check_interval {
interval.tick().await;
} else {
std::future::pending::<()>().await;
}
}, if !ready_notified && ready_http.is_some() && !http_exhausted => {
if let (Some(http), Some(client)) = (&ready_http, &http_client) {
match client.get(&http.url).timeout(http_client_timeout).send().await {
Ok(response) if http.accepts_status(response.status().as_u16()) => {
info!("daemon {id} ready: HTTP check passed (status {})", response.status());
ready_notified = true;
if let Some(tx) = ready_tx.take() {
let _ = tx.send(Ok(()));
}
fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
http_check_interval = None;
http_deadline = None;
stop_cmd_probe_state(&mut cmd_probe);
cmd_deadline = None;
port_deadline = None;
output_deadline = None;
if !active_port_spawned && has_port_config {
active_port_spawned = true;
detect_and_store_active_port(id.clone(), daemon_pid);
}
}
Ok(response) => {
trace!("daemon {id} HTTP check: status {} (not ready)", response.status());
}
Err(e) => {
trace!("daemon {id} HTTP check failed: {e}");
}
}
}
}
_ = async {
if let Some(ref mut deadline) = port_deadline {
deadline.await;
} else {
std::future::pending::<()>().await;
}
}, if !ready_notified && ready_port.is_some() => {
port_exhausted = true;
port_deadline = None;
port_check_interval = None;
warn!("daemon {id}: TCP port readiness check timed out");
let any_remaining = any_ready_check_remaining(
ready_output.as_ref(),
output_exhausted,
ready_port_config.as_ref(),
port_exhausted,
ready_http.as_ref(),
http_exhausted,
ready_cmd.as_ref(),
cmd_exhausted,
);
if !any_remaining {
error!("daemon {id}: all readiness checks exhausted, failing");
stop_cmd_probe_state(&mut cmd_probe);
ready_fail_kill = Some(spawn_ready_fail_kill(
id.clone(),
daemon_pid,
opts.stop_signal.unwrap_or_default(),
));
break;
}
}
_ = async {
if let Some(ref mut interval) = port_check_interval {
interval.tick().await;
} else {
std::future::pending::<()>().await;
}
}, if !ready_notified && ready_port.is_some() && !port_exhausted => {
if let Some(port) = ready_port {
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
Ok(_) => {
info!("daemon {id} ready: TCP port {port} is listening");
ready_notified = true;
if let Some(tx) = ready_tx.take() {
let _ = tx.send(Ok(()));
}
fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
port_check_interval = None;
port_deadline = None;
stop_cmd_probe_state(&mut cmd_probe);
http_deadline = None;
cmd_deadline = None;
output_deadline = None;
if !active_port_spawned && has_port_config {
active_port_spawned = true;
if let Some(active_port) = active_port_from_ready_port(
port,
&readiness_resolved_ports,
) {
let mut state_file =
SUPERVISOR.state_file.lock().await;
if let Some(d) = state_file.daemons.get(&id)
&& d.pid == Some(daemon_pid)
{
state_file.set_active_port(&id, active_port);
}
} else {
detect_and_store_active_port(
id.clone(),
daemon_pid,
);
}
}
}
Err(_) => {
trace!("daemon {id} port check: port {port} not listening yet");
}
}
}
}
_ = async {
if let Some(ref mut delay) = cmd_respawn_delay {
delay.await;
} else {
std::future::pending::<()>().await;
}
}, if !ready_notified && ready_cmd.is_some() && !cmd_exhausted && cmd_probe.is_none() => {
if let Some(ref cmd) = ready_cmd {
cmd_probe = Some(spawn_cmd_probe(
&id,
&cmd.run,
daemon_dir.as_path(),
hook_retry_count,
readiness_daemon_env.as_ref(),
&readiness_resolved_ports,
));
}
cmd_respawn_delay = None;
}
result = async {
if let Some(probe) = cmd_probe.as_mut() {
std::pin::Pin::new(&mut probe.result_rx).await
} else {
std::future::pending::<Result<Result<std::process::ExitStatus, std::io::Error>, tokio::sync::oneshot::error::RecvError>>().await
}
}, if !ready_notified && ready_cmd.is_some() && !cmd_exhausted => {
let _ = cmd_probe.take();
match result {
Ok(Ok(status)) if status.success() => {
info!("daemon {id} ready: readiness command succeeded");
ready_notified = true;
if let Some(tx) = ready_tx.take() {
let _ = tx.send(Ok(()));
}
fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
cmd_respawn_delay = None;
cmd_deadline = None;
http_deadline = None;
port_deadline = None;
output_deadline = None;
if !active_port_spawned && has_port_config {
active_port_spawned = true;
detect_and_store_active_port(id.clone(), daemon_pid);
}
}
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {
trace!("daemon {id} cmd check: command not ready, will respawn");
cmd_respawn_delay = Some(Box::pin(time::sleep(ready_check_interval)));
}
}
}
_ = async {
if let Some(ref mut deadline) = cmd_deadline {
deadline.await;
} else {
std::future::pending::<()>().await;
}
}, if !ready_notified && ready_cmd.is_some() => {
cmd_exhausted = true;
cmd_deadline = None;
stop_cmd_probe_state(&mut cmd_probe);
cmd_respawn_delay = None;
warn!("daemon {id}: command readiness check timed out");
let any_remaining = any_ready_check_remaining(
ready_output.as_ref(),
output_exhausted,
ready_port_config.as_ref(),
port_exhausted,
ready_http.as_ref(),
http_exhausted,
ready_cmd.as_ref(),
cmd_exhausted,
);
if !any_remaining {
error!("daemon {id}: all readiness checks exhausted, failing");
ready_fail_kill = Some(spawn_ready_fail_kill(
id.clone(),
daemon_pid,
opts.stop_signal.unwrap_or_default(),
));
break;
}
}
_ = async {
if let Some(ref mut timer) = delay_timer {
timer.await;
} else {
std::future::pending::<()>().await;
}
} => {
let has_other_ready_check = ready_pattern.is_some()
|| ready_http.is_some()
|| ready_port.is_some()
|| ready_cmd.is_some();
let delay_is_only_readiness = !ready_notified && !has_other_ready_check;
let process_exited = exit_status.is_some();
let process_running = if delay_is_only_readiness && !process_exited {
PROCS.refresh_pids(&[daemon_pid]);
PROCS.is_running(daemon_pid)
} else {
false
};
if delay_readiness_succeeded(
ready_notified,
has_other_ready_check,
process_exited,
process_running,
) {
info!("daemon {id} ready: delay elapsed");
ready_notified = true;
if let Some(tx) = ready_tx.take() {
let _ = tx.send(Ok(()));
}
fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
if !active_port_spawned && has_port_config {
active_port_spawned = true;
detect_and_store_active_port(id.clone(), daemon_pid);
}
} else if delay_is_only_readiness {
if process_exited {
debug!("daemon {id} exited during ready_delay, not marking as ready");
} else {
debug!("daemon {id} pid {daemon_pid} not running during ready_delay, deferring to exit handler");
}
}
if delay_is_only_readiness {
output_deadline = None;
http_deadline = None;
cmd_deadline = None;
port_deadline = None;
stop_cmd_probe_state(&mut cmd_probe);
}
delay_timer = None;
}
_ = log_flush_interval.tick() => {
let _ = flush_logs(&mut log_buffer);
}
}
}
let pre_drain_daemon = SUPERVISOR.get_daemon(&id).await;
let pre_drain_is_stopping = pre_drain_daemon
.as_ref()
.is_some_and(|d| d.status.is_stopped() || d.status.is_stopping_or_restarting());
drop(output_relay);
let drain_deadline = tokio::time::Instant::now() + super::EXIT_OUTPUT_DRAIN_TIMEOUT;
loop {
let now = tokio::time::Instant::now();
if now >= drain_deadline {
break;
}
let Ok(Some(line)) =
tokio::time::timeout(drain_deadline - now, output_rx.recv()).await
else {
break;
};
if matches!(line.source, super::OutputSource::Local) {
log_buffer.push(parse_line(&line.text));
}
}
if let Some(handle) = flush_logs(&mut log_buffer) {
let _ = handle.await;
}
{
let mut state_file = SUPERVISOR.state_file.lock().await;
state_file.clear_active_port(&id);
}
let exit_status = if let Some(status) = exit_status {
status
} else {
match exit_rx.recv().await {
Some(status) => status,
None => {
warn!("daemon {id} exit channel closed without receiving status");
Err(std::io::Error::other("exit channel closed"))
}
}
};
if let Some(kill) = ready_fail_kill {
let _ = kill.await;
if let Some(tx) = ready_tx.take() {
let _ = tx.send(Err(Some(124)));
}
}
let current_daemon = SUPERVISOR.get_daemon(&id).await;
SUPERVISOR
.active_monitors
.fetch_add(1, atomic::Ordering::Release);
struct MonitorGuard;
impl Drop for MonitorGuard {
fn drop(&mut self) {
SUPERVISOR
.active_monitors
.fetch_sub(1, atomic::Ordering::Release);
SUPERVISOR.monitor_done.notify_waiters();
}
}
let _monitor_guard = MonitorGuard;
if !pre_drain_is_stopping
&& (current_daemon.is_none()
|| current_daemon.as_ref().is_some_and(|d| {
d.pid != Some(pid)
&& !d.status.is_stopped()
&& !d.status.is_stopping_or_restarting()
}))
{
if oneshot_completion_pending && let Some(tx) = ready_tx.take() {
let _ = tx.send(Ok(()));
}
return;
}
let already_stopped = current_daemon
.as_ref()
.is_some_and(|d| d.status.is_stopped());
let is_stopping = already_stopped
|| pre_drain_is_stopping
|| current_daemon
.as_ref()
.is_some_and(|d| d.status.is_stopping_or_restarting());
let (exit_code, exit_reason) = match (&exit_status, is_stopping) {
(Ok(status), true) => {
(status.code().unwrap_or(-1), "stop")
}
(Ok(status), false) if status.success() => (status.code().unwrap_or(-1), "exit"),
(Ok(status), false) => (status.code().unwrap_or(-1), "fail"),
(Err(_), true) => {
(-1, "stop")
}
(Err(_), false) => (-1, "fail"),
};
if !already_stopped && !pre_drain_is_stopping {
if let Ok(status) = &exit_status {
info!("daemon {id} exited with status {status}");
}
let (new_status, last_exit_success) = terminal_exit_state(
exit_reason,
opts.oneshot,
exit_code,
exit_status.as_ref().map(|s| s.success()).unwrap_or(true),
);
if !SUPERVISOR
.finalize_monitored_exit(
&id,
pid,
monitor_token,
new_status,
Some(last_exit_success),
)
.await
{
debug!("daemon {id} exit state was not written; a successor owns the record");
}
}
if oneshot_completion_pending && let Some(tx) = ready_tx.take() {
if exit_reason == "exit" {
let _ = tx.send(Ok(()));
} else {
warn!("daemon {id}: oneshot was stopped before completing");
let _ = tx.send(Err(None));
}
}
let hook_extra_env = vec![
("PITCHFORK_EXIT_CODE".to_string(), exit_code.to_string()),
("PITCHFORK_EXIT_REASON".to_string(), exit_reason.to_string()),
];
let hooks_to_fire: Vec<HookType> = match exit_reason {
"stop" => vec![HookType::OnStop, HookType::OnExit],
"exit" => vec![HookType::OnExit],
_ if hook_retry_count >= hook_retry.count() => {
vec![HookType::OnFail, HookType::OnExit]
}
_ => vec![],
};
for hook_type in hooks_to_fire {
fire_hook(
hook_type,
id.clone(),
hook_dirs.clone(),
hook_retry_count,
hook_daemon_env.clone(),
hook_resolved_ports.clone(),
hook_extra_env.clone(),
)
.await;
}
});
if let Some(ready_rx) = ready_rx {
match ready_rx.await {
Ok(Ok(())) => {
info!("daemon {id} is ready");
let daemon = match self.get_daemon(id).await {
Some(current) if current.pid.is_none() || current.pid == Some(pid) => {
current
}
_ if opts.oneshot => crate::daemon::Daemon {
status: DaemonStatus::Completed,
pid: None,
start_time: None,
boot_time: None,
last_exit_success: Some(true),
..daemon
},
_ => daemon,
};
Ok(IpcResponse::DaemonReady { daemon })
}
Ok(Err(exit_code)) => {
error!("daemon {id} failed before becoming ready");
let last_attempt = opts.retry_count >= opts.retry.count();
if using_sink && last_attempt {
super::log_sink::wait_for_output(id, spawn_time, SINK_OUTPUT_TIMEOUT).await;
}
Ok(IpcResponse::DaemonFailedWithCode {
exit_code,
resolved_ports: attempt_resolved_ports,
})
}
Err(_) => {
error!("readiness channel closed unexpectedly for daemon {id}");
Ok(IpcResponse::DaemonStart { daemon })
}
}
} else {
Ok(IpcResponse::DaemonStart { daemon })
}
}
pub async fn stop(&self, id: &DaemonId) -> Result<IpcResponse> {
let lock = self.stop_lock(id).await;
let _guard = lock.lock().await;
self.stop_locked(id).await
}
pub(crate) async fn stop_for_restart(&self, id: &DaemonId) -> Result<IpcResponse> {
let lock = self.stop_lock(id).await;
let _guard = lock.lock().await;
self.stop_locked_with(id, true).await
}
pub(super) async fn stop_locked(&self, id: &DaemonId) -> Result<IpcResponse> {
self.stop_locked_with(id, false).await
}
pub(super) async fn stop_locked_with(
&self,
id: &DaemonId,
restarting: bool,
) -> Result<IpcResponse> {
let (stopping_status, stopped_status) = if restarting {
(DaemonStatus::Restarting, DaemonStatus::Restarting)
} else {
(DaemonStatus::Stopping, DaemonStatus::Stopped)
};
let pitchfork_id = DaemonId::pitchfork();
if *id == pitchfork_id {
return Ok(IpcResponse::Error(
"Cannot stop supervisor via stop command".into(),
));
}
info!("stopping daemon: {id}");
self.cancel_retrying(id);
let _stop_epoch_bump = StopEpochGuard(id.clone());
if let Some(daemon) = self.get_daemon(id).await {
trace!("daemon to stop: {daemon}");
if let Some(pid) = daemon.pid {
trace!("killing pid: {pid}");
if PROCS.is_running(pid) {
if !super::signalling_pid_is_authorized(
daemon.start_time,
PROCS.start_time(pid),
) {
warn!(
"pid {pid} recorded for daemon {id} belongs to another process now; not signalling it"
);
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = None;
o.status = stopped_status.clone();
})
.build(),
)
.await?;
return Ok(IpcResponse::DaemonWasNotRunning);
}
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = Some(pid);
o.status = stopping_status.clone();
})
.build(),
)
.await?;
let stop_cfg = daemon.stop_signal.unwrap_or_default();
let stop_signal: i32 = stop_cfg.signal.into();
if let Err(e) = PROCS
.kill_process_group_async(pid, stop_signal, stop_cfg.timeout)
.await
{
debug!("failed to kill pid {pid}: {e}");
if PROCS.process_group_alive(pid) {
debug!(
"failed to stop pid {pid}: process group still alive after kill"
);
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = Some(pid); o.status = DaemonStatus::Running;
})
.build(),
)
.await?;
return Ok(IpcResponse::DaemonStopFailed {
error: format!(
"process group of {pid} still alive after kill attempt: {e}"
),
});
}
#[cfg(windows)]
{
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = None;
o.status = stopped_status.clone();
o.last_exit_success = Some(true);
})
.build(),
)
.await?;
return Ok(IpcResponse::DaemonStopFailed {
error: format!("{e}"),
});
}
}
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = None;
o.status = stopped_status.clone();
o.last_exit_success = Some(true);
})
.build(),
)
.await?;
} else if daemon.oneshot && self.is_monitored(id, pid) {
debug!(
"pid {pid} not running but daemon {id} is still monitored; waiting for its monitor to settle the outcome"
);
self.wait_for_exit_finalized(id, Some(pid)).await;
let finished = self.get_daemon(id).await;
if finished
.as_ref()
.is_some_and(|d| stop_keeps_finalized_status(&d.status))
{
return Ok(IpcResponse::DaemonWasNotRunning);
}
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = None;
o.status = stopped_status.clone();
})
.build(),
)
.await?;
return Ok(IpcResponse::DaemonWasNotRunning);
} else {
debug!("pid {pid} not running, process may have exited unexpectedly");
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = None;
o.status = stopped_status.clone();
})
.build(),
)
.await?;
return Ok(IpcResponse::DaemonWasNotRunning);
}
Ok(IpcResponse::Ok)
} else {
debug!("daemon {id} not running");
if !restarting && daemon.status.is_restarting() {
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = None;
o.status = DaemonStatus::Stopped;
})
.build(),
)
.await?;
return Ok(IpcResponse::Ok);
}
if daemon.status.is_errored() && daemon.retry_count < daemon.retry.count() {
self.upsert_daemon(
UpsertDaemonOpts::builder(id.clone())
.set(|o| {
o.pid = None;
o.status = stopped_status.clone();
})
.build(),
)
.await?;
return Ok(IpcResponse::DaemonWasNotRunning);
}
Ok(IpcResponse::DaemonNotRunning)
}
} else {
debug!("daemon {id} not found");
Ok(IpcResponse::DaemonNotFound)
}
}
}
#[cfg(unix)]
fn resolve_effective_run_identity(daemon_user: Option<&str>) -> Result<RunIdentity> {
let s = settings();
let settings_user = s.supervisor.user.trim();
let daemon_user = daemon_user.map(str::trim).filter(|user| !user.is_empty());
let settings_user = (!settings_user.is_empty()).then_some(settings_user);
let configured = daemon_user.or(settings_user);
let current_uid = nix::unistd::Uid::effective().as_raw();
let current_gid = nix::unistd::Gid::effective().as_raw();
let invoking = env::invoking_user_ids().map(|(uid, gid)| (uid.to_string(), gid.to_string()));
resolve_run_identity(
configured,
current_uid,
current_gid,
invoking.as_ref().map(|(uid, _)| uid.as_str()),
invoking.as_ref().map(|(_, gid)| gid.as_str()),
)
}
#[cfg(unix)]
fn resolve_run_identity(
configured: Option<&str>,
current_uid: u32,
current_gid: u32,
sudo_uid: Option<&str>,
sudo_gid: Option<&str>,
) -> Result<RunIdentity> {
let current_uid = nix::unistd::Uid::from_raw(current_uid);
let current_gid = nix::unistd::Gid::from_raw(current_gid);
if let Some(user) = configured {
let identity = resolve_configured_user(user)?;
ensure_can_use_identity(user, &identity, current_uid, current_gid)?;
if identity.matches(current_uid, current_gid) {
return Ok(RunIdentity::Inherit);
}
return Ok(identity);
}
if current_uid.is_root()
&& let Some(identity) = resolve_sudo_identity(sudo_uid, sudo_gid)
{
if identity.matches(current_uid, current_gid) {
return Ok(RunIdentity::Inherit);
}
return Ok(identity);
}
Ok(RunIdentity::Inherit)
}
#[cfg(unix)]
fn resolve_configured_user(user: &str) -> Result<RunIdentity> {
if user.chars().all(|c| c.is_ascii_digit()) {
let uid = user
.parse::<u32>()
.map_err(|e| miette::miette!("invalid run user UID '{}': {}", user, e))?;
let user_record = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
.into_diagnostic()?
.ok_or_else(|| miette::miette!("run user UID '{}' does not exist", user))?;
return run_identity_from_user_record(user_record);
}
let user_record = nix::unistd::User::from_name(user)
.into_diagnostic()?
.ok_or_else(|| miette::miette!("run user '{}' does not exist", user))?;
run_identity_from_user_record(user_record)
}
#[cfg(unix)]
fn run_identity_from_user_record(user: nix::unistd::User) -> Result<RunIdentity> {
let username = CString::new(user.name)
.map_err(|e| miette::miette!("run user name contains an interior nul byte: {}", e))?;
Ok(RunIdentity::Switch {
uid: user.uid,
gid: user.gid,
username: Some(username),
home: Some(user.dir),
})
}
#[cfg(unix)]
fn resolve_sudo_identity(sudo_uid: Option<&str>, sudo_gid: Option<&str>) -> Option<RunIdentity> {
let uid = sudo_uid?.parse::<u32>().ok()?;
let gid = sudo_gid?.parse::<u32>().ok()?;
let user = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
.ok()
.flatten();
let (username, home) = match user {
Some(user) => (CString::new(user.name).ok(), Some(user.dir)),
None => (None, None),
};
Some(RunIdentity::Switch {
uid: nix::unistd::Uid::from_raw(uid),
gid: nix::unistd::Gid::from_raw(gid),
username,
home,
})
}
#[cfg(unix)]
fn ensure_can_use_identity(
configured_user: &str,
identity: &RunIdentity,
current_uid: nix::unistd::Uid,
current_gid: nix::unistd::Gid,
) -> Result<()> {
let RunIdentity::Switch { uid, gid, .. } = identity else {
return Ok(());
};
if *uid == current_uid && *gid == current_gid {
return Ok(());
}
if current_uid.is_root() {
return Ok(());
}
Err(miette::miette!(
"daemon is configured to run as '{}', but the supervisor is running as uid={} gid={}. Restart the supervisor with sudo to switch to uid={} gid={}, or choose a user matching the supervisor.",
configured_user,
current_uid.as_raw(),
current_gid.as_raw(),
uid.as_raw(),
gid.as_raw()
))
}
#[cfg(unix)]
fn apply_identity_env(command: &mut tokio::process::Command, identity: &RunIdentity) {
let RunIdentity::Switch { username, home, .. } = identity else {
return;
};
match home.as_ref().filter(|home| !home.as_os_str().is_empty()) {
Some(home) => command.env("HOME", home),
None => command.env_remove("HOME"),
};
match username.as_ref().and_then(|name| name.to_str().ok()) {
Some(name) => command.env("USER", name).env("LOGNAME", name),
None => command.env_remove("USER").env_remove("LOGNAME"),
};
}
#[cfg(unix)]
fn apply_run_identity(identity: &RunIdentity) -> std::io::Result<()> {
let RunIdentity::Switch {
uid, gid, username, ..
} = identity
else {
return Ok(());
};
if let Some(username) = username {
initgroups_for_user(username, *gid)?;
} else {
setgroups_to_primary(*gid)?;
}
nix::unistd::setgid(*gid).map_err(nix_to_io_error)?;
nix::unistd::setuid(*uid).map_err(nix_to_io_error)?;
Ok(())
}
#[cfg(unix)]
impl RunIdentity {
fn matches(&self, uid: nix::unistd::Uid, gid: nix::unistd::Gid) -> bool {
matches!(self, RunIdentity::Switch { uid: u, gid: g, .. } if *u == uid && *g == gid)
}
}
#[cfg(unix)]
fn setgroups_to_primary(gid: nix::unistd::Gid) -> std::io::Result<()> {
let groups = [gid.as_raw() as libc::gid_t];
#[cfg(any(target_os = "linux", target_os = "android"))]
let group_count = groups.len();
#[cfg(not(any(target_os = "linux", target_os = "android")))]
let group_count = groups.len() as libc::c_int;
let rc = unsafe { libc::setgroups(group_count, groups.as_ptr()) };
if rc == -1 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(unix)]
fn initgroups_for_user(username: &CString, gid: nix::unistd::Gid) -> std::io::Result<()> {
let gid = gid.as_raw();
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "tvos",
target_os = "watchos"
))]
let base_gid = i32::try_from(gid)
.map_err(|_| std::io::Error::other(format!("gid {gid} is out of range")))?;
#[cfg(not(any(
target_os = "macos",
target_os = "ios",
target_os = "tvos",
target_os = "watchos"
)))]
let base_gid = gid as libc::gid_t;
let rc = unsafe { libc::initgroups(username.as_ptr(), base_gid) };
if rc == -1 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(unix)]
fn nix_to_io_error(err: nix::errno::Errno) -> std::io::Error {
std::io::Error::from_raw_os_error(err as i32)
}
async fn check_ports_available(
expected_ports: &[u16],
auto_bump: bool,
max_attempts: u32,
) -> Result<Vec<u16>> {
if expected_ports.is_empty() {
return Ok(Vec::new());
}
for bump_offset in 0..=max_attempts {
let candidate_ports: Vec<u16> = expected_ports
.iter()
.map(|&p| p.wrapping_add(bump_offset as u16))
.collect();
let mut all_available = true;
let mut conflicting_port = None;
for &port in &candidate_ports {
if port == 0 {
continue;
}
if let Some(address) = port_bind_conflict(port).await {
all_available = false;
conflicting_port = Some((port, address));
break;
}
}
if all_available {
if candidate_ports.contains(&0) && !expected_ports.contains(&0) {
return Err(PortError::NoAvailablePort {
start_port: expected_ports[0],
attempts: bump_offset + 1,
}
.into());
}
if bump_offset > 0 {
info!("ports {expected_ports:?} bumped by {bump_offset} to {candidate_ports:?}");
}
return Ok(candidate_ports);
}
if bump_offset == 0
&& !auto_bump
&& let Some((port, address)) = conflicting_port
{
let (pid, process) = identify_port_owner(port, address).await;
return Err(PortError::InUse {
port,
address: Some(shown_address(address)),
process,
pid,
}
.into());
}
}
Err(PortError::NoAvailablePort {
start_port: expected_ports[0],
attempts: max_attempts + 1,
}
.into())
}
async fn port_bind_conflict(port: u16) -> Option<std::net::IpAddr> {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
tokio::task::spawn_blocking(move || {
for addr in [
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
IpAddr::V4(Ipv4Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::LOCALHOST),
] {
match std::net::TcpListener::bind((addr, port)) {
Ok(listener) => drop(listener),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => return Some(addr),
Err(_) => continue,
}
}
None
})
.await
.unwrap_or(None)
}
fn shown_address(addr: std::net::IpAddr) -> String {
match addr {
std::net::IpAddr::V4(v4) => v4.to_string(),
std::net::IpAddr::V6(v6) => format!("[{v6}]"),
}
}
fn listener_match(listening: std::net::IpAddr, conflict: std::net::IpAddr) -> Option<u8> {
use std::net::IpAddr;
let listening = listening.to_canonical();
if listening == conflict {
return Some(0);
}
match (listening, conflict) {
(IpAddr::V4(_), IpAddr::V4(_)) | (IpAddr::V6(_), IpAddr::V6(_))
if listening.is_unspecified() || conflict.is_unspecified() =>
{
Some(1)
}
(IpAddr::V6(v6), IpAddr::V4(_)) if v6.is_unspecified() => Some(2),
_ => None,
}
}
struct TcpSocket {
addr: std::net::SocketAddr,
listening: bool,
pid: u32,
process: String,
}
fn pick_port_owner(
sockets: impl IntoIterator<Item = TcpSocket>,
port: u16,
conflict: std::net::IpAddr,
) -> Option<(u32, String)> {
sockets
.into_iter()
.filter(|s| s.addr.port() == port)
.filter_map(|s| {
let matched = listener_match(s.addr.ip(), conflict)?;
let rank = (matched == 2, !s.listening, matched);
Some((rank, s.pid, s.process))
})
.min_by_key(|(rank, _, _)| *rank)
.map(|(_, pid, process)| (pid, process))
}
async fn identify_port_owner(port: u16, conflict: std::net::IpAddr) -> (u32, String) {
tokio::task::spawn_blocking(move || {
listeners::get_all()
.ok()
.and_then(|list| {
let tcp = list
.into_iter()
.filter(|l| l.protocol == listeners::Protocol::TCP)
.map(|l| TcpSocket {
addr: l.socket,
listening: l.state == listeners::SocketState::Listen,
pid: l.process.pid,
process: l.process.name,
});
pick_port_owner(tcp, port, conflict)
})
.unwrap_or((0, "unknown".to_string()))
})
.await
.unwrap_or((0, "unknown".to_string()))
}
async fn detect_port_conflict(port: u16) -> Option<(String, u32, String)> {
let address = port_bind_conflict(port).await?;
let (pid, process) = identify_port_owner(port, address).await;
Some((shown_address(address), pid, process))
}
#[derive(Debug, PartialEq, Eq)]
enum ActivePortSelection {
NoCandidates,
Selected(u16),
Ambiguous(Vec<u16>),
}
fn discovery_preferred_port(daemon: &crate::daemon::Daemon) -> Option<u16> {
daemon
.resolved_port
.first()
.copied()
.or_else(|| {
daemon
.port
.as_ref()
.and_then(|port| port.expect.first().copied())
})
.filter(|&port| port > 0)
}
fn select_active_port(
listeners: impl IntoIterator<Item = listeners::Listener>,
descendant_pids: &std::collections::HashSet<u32>,
preferred_port: Option<u16>,
) -> ActivePortSelection {
let process_ports: std::collections::BTreeSet<u16> = listeners
.into_iter()
.filter(|listener| {
listener.protocol == listeners::Protocol::TCP
&& listener.state == listeners::SocketState::Listen
&& descendant_pids.contains(&listener.process.pid)
})
.map(|listener| listener.socket.port())
.filter(|&port| port > 0)
.collect();
if let Some(port) = preferred_port
&& process_ports.contains(&port)
{
return ActivePortSelection::Selected(port);
}
match process_ports.len() {
0 => ActivePortSelection::NoCandidates,
1 => ActivePortSelection::Selected(*process_ports.first().unwrap()),
_ => ActivePortSelection::Ambiguous(process_ports.into_iter().collect()),
}
}
fn detect_and_store_active_port(id: DaemonId, pid: u32) {
tokio::spawn(async move {
for delay_ms in [500u64, 1000, 2000, 4000] {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
let preferred_port: Option<u16> = {
let state_file = SUPERVISOR.state_file.lock().await;
match state_file.daemons.get(&id) {
Some(d) if d.pid.is_none() => {
debug!("daemon {id}: aborting active_port detection — process exited");
return;
}
Some(d) => discovery_preferred_port(d),
None => None,
}
};
let selection = tokio::task::spawn_blocking(move || {
let listeners = listeners::get_all().ok()?;
PROCS.refresh_processes();
let descendant_pids: std::collections::HashSet<u32> = PROCS
.all_children(pid)
.into_iter()
.chain(std::iter::once(pid))
.collect();
Some(select_active_port(
listeners,
&descendant_pids,
preferred_port,
))
})
.await
.ok()
.flatten()
.unwrap_or(ActivePortSelection::NoCandidates);
let port = match selection {
ActivePortSelection::Selected(port) => port,
ActivePortSelection::Ambiguous(ports) => {
debug!(
"daemon {id}: ambiguous active_port candidates {ports:?} for pid {pid} \
and its descendants; leaving active_port unset (will retry)"
);
continue;
}
ActivePortSelection::NoCandidates => {
debug!(
"daemon {id}: no active port detected for pid {pid} or its descendants \
(will retry)"
);
continue;
}
};
debug!("daemon {id} active_port detected: {port}");
let mut state_file = SUPERVISOR.state_file.lock().await;
if let Some(d) = state_file.daemons.get(&id) {
if d.pid == Some(pid) {
state_file.set_active_port(&id, port);
} else {
debug!(
"daemon {id}: skipping active_port write — PID mismatch \
(expected {pid}, current {:?})",
d.pid
);
}
}
return;
}
debug!(
"daemon {id}: active port detection exhausted all retries for pid {pid} and its descendants"
);
});
}
#[cfg(test)]
mod port_conflict_tests {
use super::{
TcpSocket, check_ports_available, listener_match, pick_port_owner, port_bind_conflict,
shown_address,
};
use crate::error::PortError;
use std::net::{IpAddr, SocketAddr};
#[tokio::test]
async fn a_taken_port_is_reported_with_its_address_and_holder() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let address = shown_address(port_bind_conflict(port).await.unwrap());
assert!(
["0.0.0.0", "127.0.0.1"].contains(&address.as_str()),
"{address}"
);
let err = check_ports_available(&[port], false, 0).await.unwrap_err();
match err.downcast_ref::<PortError>() {
Some(PortError::InUse {
port: p,
address: Some(a),
pid,
..
}) => {
assert_eq!(*p, port);
assert_eq!(*a, address);
assert_eq!(*pid, std::process::id());
}
other => panic!("unexpected error: {other:?}"),
}
drop(listener);
assert_eq!(port_bind_conflict(port).await, None);
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn socket(addr: &str, listening: bool, pid: u32) -> TcpSocket {
TcpSocket {
addr: addr.parse::<SocketAddr>().unwrap(),
listening,
pid,
process: format!("p{pid}"),
}
}
#[test]
fn only_a_listener_whose_address_conflicts_is_named() {
let all = || {
[
socket("192.0.2.1:3000", true, 1),
socket("127.0.0.1:3000", true, 2),
socket("[::1]:3000", true, 3),
socket("127.0.0.1:4000", true, 4),
]
};
assert_eq!(
pick_port_owner(all(), 3000, ip("127.0.0.1")),
Some((2, "p2".into()))
);
assert_eq!(
pick_port_owner(all(), 3000, ip("::1")),
Some((3, "p3".into()))
);
let v4 = pick_port_owner(all(), 3000, ip("0.0.0.0")).unwrap().0;
assert!([1, 2].contains(&v4));
assert_eq!(
pick_port_owner([socket("[::1]:3000", true, 3)], 3000, ip("127.0.0.1")),
None
);
assert_eq!(pick_port_owner(all(), 5000, ip("127.0.0.1")), None);
let both = [
socket("127.0.0.1:3000", false, 5),
socket("0.0.0.0:3000", true, 6),
];
assert_eq!(
pick_port_owner(both, 3000, ip("127.0.0.1")),
Some((6, "p6".into()))
);
assert_eq!(
pick_port_owner([socket("127.0.0.1:3000", false, 5)], 3000, ip("127.0.0.1")),
Some((5, "p5".into()))
);
let v6_wildcard = || {
[
socket("[::]:3000", true, 7),
socket("127.0.0.1:3000", false, 8),
]
};
assert_eq!(
pick_port_owner(v6_wildcard(), 3000, ip("127.0.0.1")),
Some((8, "p8".into()))
);
assert_eq!(
pick_port_owner(v6_wildcard(), 3000, ip("0.0.0.0")),
Some((8, "p8".into()))
);
assert_eq!(
pick_port_owner([socket("[::]:3000", true, 7)], 3000, ip("127.0.0.1")),
Some((7, "p7".into()))
);
assert_eq!(
pick_port_owner(
[socket("[::ffff:127.0.0.1]:3000", false, 9)],
3000,
ip("127.0.0.1")
),
Some((9, "p9".into()))
);
assert_eq!(listener_match(ip("0.0.0.0"), ip("127.0.0.1")), Some(1));
assert_eq!(listener_match(ip("::"), ip("127.0.0.1")), Some(2));
assert_eq!(listener_match(ip("::1"), ip("127.0.0.1")), None);
assert_eq!(listener_match(ip("192.0.2.1"), ip("127.0.0.1")), None);
assert_eq!(shown_address(ip("::1")), "[::1]");
}
}
#[cfg(test)]
mod active_port_tests {
use super::*;
use crate::config_types::PortConfig;
use listeners::{Listener, Process, Protocol, SocketState};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
fn listener(pid: u32, port: u16, protocol: Protocol, state: SocketState) -> Listener {
Listener {
process: Process {
pid,
name: "test".to_string(),
path: "/test".to_string(),
},
socket: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
protocol,
state,
}
}
#[test]
fn active_port_candidates_exclude_outbound_tcp_and_udp_sockets() {
let daemon_pid = 100;
let child_pid = 101;
let descendant_pids = [daemon_pid, child_pid].into_iter().collect();
let listeners = vec![
listener(child_pid, 3004, Protocol::TCP, SocketState::Listen),
listener(daemon_pid, 47082, Protocol::TCP, SocketState::Established),
listener(daemon_pid, 5353, Protocol::UDP, SocketState::Unknown),
listener(999, 9000, Protocol::TCP, SocketState::Listen),
];
assert_eq!(
select_active_port(listeners, &descendant_pids, None),
ActivePortSelection::Selected(3004)
);
}
#[test]
fn bumped_cmd_readiness_prefers_resolved_primary_port() {
let daemon = crate::daemon::Daemon {
resolved_port: vec![3004],
port: Some(PortConfig {
expect: vec![3000],
..PortConfig::default()
}),
..crate::daemon::Daemon::default()
};
let descendant_pids = [100].into_iter().collect();
let listeners = vec![
listener(100, 9000, Protocol::TCP, SocketState::Listen),
listener(100, 3004, Protocol::TCP, SocketState::Listen),
];
assert_eq!(discovery_preferred_port(&daemon), Some(3004));
assert_eq!(
select_active_port(
listeners,
&descendant_pids,
discovery_preferred_port(&daemon),
),
ActivePortSelection::Selected(3004)
);
}
#[test]
fn legacy_state_uses_expected_primary_port_for_discovery() {
let daemon = crate::daemon::Daemon {
port: Some(PortConfig {
expect: vec![3000],
..PortConfig::default()
}),
..crate::daemon::Daemon::default()
};
assert_eq!(discovery_preferred_port(&daemon), Some(3000));
}
#[test]
fn ambiguous_candidates_leave_active_port_unset() {
let descendant_pids = [100].into_iter().collect();
let listeners = vec![
listener(100, 9000, Protocol::TCP, SocketState::Listen),
listener(100, 3000, Protocol::TCP, SocketState::Listen),
];
assert_eq!(
select_active_port(listeners, &descendant_pids, None),
ActivePortSelection::Ambiguous(vec![3000, 9000])
);
}
#[test]
fn bumped_ready_port_sets_only_the_resolved_primary_without_scanning() {
assert_eq!(active_port_from_ready_port(3004, &[3004]), Some(3004));
assert_eq!(active_port_from_ready_port(4003, &[3003, 4003]), None);
}
#[test]
fn delay_readiness_requires_delay_only_and_a_running_process() {
assert!(delay_readiness_succeeded(false, false, false, true));
assert!(!delay_readiness_succeeded(false, true, false, true));
assert!(!delay_readiness_succeeded(false, false, true, false));
assert!(!delay_readiness_succeeded(false, false, false, false));
assert!(!delay_readiness_succeeded(true, false, false, true));
}
}
fn is_daemon_slug_target(id: &DaemonId) -> bool {
let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
slugs.iter().any(|(slug, entry)| {
let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
id.name() == daemon_name
})
}
#[cfg(test)]
mod oneshot_tests {
use super::*;
#[test]
fn oneshot_clean_exit_is_completed() {
let (status, success) = terminal_exit_state("exit", true, 0, true);
assert!(matches!(status, DaemonStatus::Completed));
assert!(success);
}
#[test]
fn service_clean_exit_is_still_stopped() {
let (status, success) = terminal_exit_state("exit", false, 0, true);
assert!(matches!(status, DaemonStatus::Stopped));
assert!(success);
}
#[test]
fn oneshot_failure_is_errored_so_retry_applies() {
let (status, success) = terminal_exit_state("fail", true, 3, false);
assert!(matches!(status, DaemonStatus::Errored(3)));
assert!(!success);
}
#[test]
fn a_stop_cancels_the_retry_sequence_it_finds() {
let id = DaemonId::new("retry-cancel-test", "task");
let claim = SUPERVISOR.mark_retrying(&id);
assert!(!claim.is_cancelled());
assert!(SUPERVISOR.is_retrying(&id));
SUPERVISOR.cancel_retrying(&id);
assert!(claim.is_cancelled());
drop(claim);
assert!(!SUPERVISOR.is_retrying(&id));
}
#[test]
fn a_stop_cancels_every_sequence_for_the_daemon() {
let id = DaemonId::new("retry-cancel-test", "concurrent");
let first = SUPERVISOR.mark_retrying(&id);
let second = SUPERVISOR.mark_retrying(&id);
SUPERVISOR.cancel_retrying(&id);
assert!(first.is_cancelled());
assert!(second.is_cancelled());
drop(second);
assert!(SUPERVISOR.is_retrying(&id));
drop(first);
assert!(!SUPERVISOR.is_retrying(&id));
}
#[test]
fn a_stop_invalidates_an_attempt_decided_on_before_it() {
let id = DaemonId::new("stop-epoch-test", "task");
let approved_at = SUPERVISOR.stop_epoch(&id);
assert_eq!(SUPERVISOR.stop_epoch(&id), approved_at);
SUPERVISOR.bump_stop_epoch(&id);
assert_ne!(SUPERVISOR.stop_epoch(&id), approved_at);
let approved_after = SUPERVISOR.stop_epoch(&id);
assert_eq!(SUPERVISOR.stop_epoch(&id), approved_after);
}
#[test]
fn stop_epochs_are_tracked_per_daemon() {
let stopped = DaemonId::new("stop-epoch-test", "stopped");
let untouched = DaemonId::new("stop-epoch-test", "untouched");
let approved_at = SUPERVISOR.stop_epoch(&untouched);
SUPERVISOR.bump_stop_epoch(&stopped);
assert_eq!(SUPERVISOR.stop_epoch(&untouched), approved_at);
}
#[test]
fn a_stop_leaves_a_completed_task_alone() {
assert!(stop_keeps_finalized_status(&DaemonStatus::Completed));
}
#[test]
fn a_stop_replaces_a_failure_so_retries_do_not_resume() {
assert!(!stop_keeps_finalized_status(&DaemonStatus::Errored(1)));
assert!(!stop_keeps_finalized_status(&DaemonStatus::Running));
assert!(!stop_keeps_finalized_status(&DaemonStatus::Stopped));
}
#[test]
fn stopped_oneshot_did_not_complete() {
let (status, _) = terminal_exit_state("stop", true, 0, true);
assert!(matches!(status, DaemonStatus::Stopped));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn test_resolve_run_identity_empty_without_sudo() {
let identity = resolve_run_identity(None, 501, 20, None, None).unwrap();
assert_eq!(identity, RunIdentity::Inherit);
}
#[test]
fn test_resolve_run_identity_sudo_fallback() {
let identity = resolve_run_identity(None, 0, 0, Some("501"), Some("20")).unwrap();
let RunIdentity::Switch { uid, gid, .. } = identity else {
panic!("expected identity switch");
};
assert_eq!(uid.as_raw(), 501);
assert_eq!(gid.as_raw(), 20);
}
#[test]
fn test_resolve_run_identity_ignores_stale_sudo_when_not_root() {
let identity = resolve_run_identity(None, 501, 20, Some("0"), Some("0")).unwrap();
assert_eq!(identity, RunIdentity::Inherit);
}
#[test]
fn test_resolve_configured_user_root_name() {
let identity = resolve_configured_user("root").unwrap();
let RunIdentity::Switch { uid, username, .. } = identity else {
panic!("expected identity switch");
};
assert_eq!(uid.as_raw(), 0);
assert_eq!(
username.as_deref().and_then(|s| s.to_str().ok()),
Some("root")
);
}
#[test]
fn test_resolve_configured_user_root_uid() {
let identity = resolve_configured_user("0").unwrap();
let RunIdentity::Switch { uid, username, .. } = identity else {
panic!("expected identity switch");
};
assert_eq!(uid.as_raw(), 0);
assert_eq!(
username.as_deref().and_then(|s| s.to_str().ok()),
Some("root")
);
}
#[test]
fn test_resolve_configured_user_missing_user_fails() {
let err = resolve_configured_user("pitchfork-user-that-should-not-exist")
.unwrap_err()
.to_string();
assert!(err.contains("does not exist"));
}
#[test]
fn test_resolve_run_identity_requires_root_for_user_switch() {
let err = resolve_run_identity(Some("root"), 501, 20, None, None)
.unwrap_err()
.to_string();
assert!(err.contains("Restart the supervisor with sudo"));
}
#[test]
fn test_resolve_run_identity_same_user_is_noop() {
let identity = resolve_run_identity(Some("root"), 0, 0, Some("501"), Some("20")).unwrap();
assert_eq!(identity, RunIdentity::Inherit);
}
#[test]
fn test_resolve_configured_user_records_home() {
let identity = resolve_configured_user("root").unwrap();
let RunIdentity::Switch { home, .. } = identity else {
panic!("expected identity switch");
};
let expected = nix::unistd::User::from_name("root").unwrap().unwrap().dir;
assert_eq!(home, Some(expected));
}
fn switch_to(name: Option<&str>, home: Option<&str>) -> RunIdentity {
RunIdentity::Switch {
uid: nix::unistd::Uid::from_raw(501),
gid: nix::unistd::Gid::from_raw(20),
username: name.map(|n| CString::new(n).unwrap()),
home: home.map(std::path::PathBuf::from),
}
}
fn command_env(
command: &tokio::process::Command,
key: &str,
) -> Option<Option<std::ffi::OsString>> {
command
.as_std()
.get_envs()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.map(ToOwned::to_owned))
}
fn daemon_command(
identity: &RunIdentity,
daemon_env: Option<&IndexMap<String, String>>,
) -> tokio::process::Command {
let mut command = tokio::process::Command::new("true");
apply_identity_env(&mut command, identity);
apply_runtime_env(
&mut command,
&DaemonId::new("identity-env-test", "api"),
0,
daemon_env,
&[],
);
command
}
#[test]
fn test_identity_env_describes_the_switched_user() {
let command = daemon_command(&switch_to(Some("alice"), Some("/home/alice")), None);
assert_eq!(
command_env(&command, "HOME"),
Some(Some("/home/alice".into()))
);
assert_eq!(command_env(&command, "USER"), Some(Some("alice".into())));
assert_eq!(command_env(&command, "LOGNAME"), Some(Some("alice".into())));
}
#[test]
fn test_identity_env_leaves_inherited_env_alone() {
let command = daemon_command(&RunIdentity::Inherit, None);
assert_eq!(command_env(&command, "HOME"), None);
assert_eq!(command_env(&command, "USER"), None);
assert_eq!(command_env(&command, "LOGNAME"), None);
}
#[test]
fn test_root_sudo_to_root_preserves_environment() {
let identity = resolve_run_identity(None, 0, 0, Some("0"), Some("0")).unwrap();
assert_eq!(identity, RunIdentity::Inherit);
let command = daemon_command(&identity, None);
for key in ["HOME", "USER", "LOGNAME"] {
assert_eq!(command_env(&command, key), None);
}
}
#[test]
fn test_daemon_env_overrides_identity_env() {
let mut env = IndexMap::new();
env.insert("HOME".to_string(), "/srv/app".to_string());
env.insert("USER".to_string(), "app".to_string());
let command = daemon_command(&switch_to(Some("alice"), Some("/home/alice")), Some(&env));
assert_eq!(command_env(&command, "HOME"), Some(Some("/srv/app".into())));
assert_eq!(command_env(&command, "USER"), Some(Some("app".into())));
assert_eq!(command_env(&command, "LOGNAME"), Some(Some("alice".into())));
}
#[test]
fn test_identity_env_drops_values_without_a_passwd_entry() {
let command = daemon_command(&switch_to(None, None), None);
assert_eq!(command_env(&command, "HOME"), Some(None));
assert_eq!(command_env(&command, "USER"), Some(None));
assert_eq!(command_env(&command, "LOGNAME"), Some(None));
}
#[test]
fn test_daemon_env_restores_values_without_a_passwd_entry() {
let mut env = IndexMap::new();
env.insert("HOME".to_string(), "/srv/app".to_string());
let command = daemon_command(&switch_to(None, None), Some(&env));
assert_eq!(command_env(&command, "HOME"), Some(Some("/srv/app".into())));
}
}
fn inject_proxy_env(cmd: &mut tokio::process::Command, host: &Option<String>) {
let s = crate::settings::settings();
let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
if s.proxy.enable && host.is_some() && !lan_enabled {
cmd.env("HOST", "127.0.0.1");
}
if let Some(url) = build_pitchfork_url(host, &s) {
cmd.env("PITCHFORK_URL", &url);
}
if s.proxy.enable && s.proxy.https {
let ca_path = if s.proxy.tls_cert.is_empty() {
crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
} else {
std::path::PathBuf::from(&s.proxy.tls_cert)
};
if ca_path.exists() {
let ca_path = ca_path.to_string_lossy().to_string();
cmd.env("PITCHFORK_CA_FILE", &ca_path);
cmd.env("NODE_EXTRA_CA_CERTS", &ca_path);
}
}
if s.proxy.enable {
let tld = if lan_enabled { "local" } else { &s.proxy.tld };
cmd.env("__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS", format!(".{tld}"));
}
if lan_enabled {
cmd.env("PITCHFORK_LAN", "1");
}
}
async fn daemon_proxy_host(opts: &RunOptions) -> Option<String> {
if !crate::settings::settings().proxy.enable {
return None;
}
if let Some(slug) = opts.slug.as_deref()
&& crate::proxy::hostname::hostname_fits(slug)
{
return opts.slug.clone();
}
let config_dir = opts
.watch_base_dir
.clone()
.unwrap_or_else(|| opts.dir.0.clone());
let id = opts.id.clone();
tokio::task::spawn_blocking(move || {
let pt = crate::pitchfork_toml::PitchforkToml::all_merged_from(&config_dir).ok()?;
let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
crate::proxy::hostname::host_for_daemon(&id, pt.daemons.get(&id), &slugs)
})
.await
.unwrap_or_default()
}
fn build_pitchfork_url(host: &Option<String>, s: &crate::settings::Settings) -> Option<String> {
crate::proxy::build_proxy_url(host.as_deref(), s)
}
#[cfg(test)]
mod ready_check_tests {
use super::*;
use std::time::Duration;
#[test]
fn any_ready_check_remaining_prefers_unbounded_checks() {
let http = ReadyHttp::new("http://localhost/health");
let cmd = ReadyCmd::new("true");
assert!(any_ready_check_remaining(
None,
false,
None,
false,
Some(&http),
false,
None,
false
));
assert!(any_ready_check_remaining(
None,
false,
None,
false,
None,
false,
Some(&cmd),
false
));
assert!(any_ready_check_remaining(
None,
false,
Some(&ReadyPort::new(8080)),
false,
Some(&http),
true,
Some(&cmd),
true
));
}
#[test]
fn any_ready_check_remaining_exhausted_timed_checks() {
let http = ReadyHttp {
url: "http://localhost/health".to_string(),
status: vec![],
timeout: Some(Duration::from_secs(5)),
};
let cmd = ReadyCmd {
run: "true".into(),
timeout: Some(Duration::from_secs(5)),
};
assert!(any_ready_check_remaining(
None,
false,
None,
false,
Some(&http),
false,
Some(&cmd),
false
));
assert!(!any_ready_check_remaining(
None,
false,
None,
false,
Some(&http),
true,
Some(&cmd),
true
));
}
#[tokio::test]
async fn spawn_cmd_probe_reports_success() {
let id = DaemonId::new("global", "probe-test");
let probe = spawn_cmd_probe(&id, &"true".into(), &std::env::temp_dir(), 0, None, &[]);
let status = probe.result_rx.await.unwrap().unwrap();
assert!(status.success());
}
#[cfg(unix)]
#[tokio::test]
async fn spawn_cmd_probe_runs_an_argv_without_a_shell() {
let id = DaemonId::new("global", "probe-test");
let argv = |arg: &str| -> RunCommand {
RunCommand::Argv(
["test", arg, "=", "a b $HOME %PATH% &"]
.map(str::to_string)
.to_vec(),
)
};
let probe = spawn_cmd_probe(
&id,
&argv("a b $HOME %PATH% &"),
&std::env::temp_dir(),
0,
None,
&[],
);
assert!(probe.result_rx.await.unwrap().unwrap().success());
let probe = spawn_cmd_probe(
&id,
&"test a b $HOME %PATH% & = 'a b $HOME %PATH% &'".into(),
&std::env::temp_dir(),
0,
None,
&[],
);
assert!(!probe.result_rx.await.unwrap().unwrap().success());
let probe = spawn_cmd_probe(&id, &argv("different"), &std::env::temp_dir(), 0, None, &[]);
assert!(!probe.result_rx.await.unwrap().unwrap().success());
}
#[tokio::test]
async fn spawn_cmd_probe_stops_on_request() {
let id = DaemonId::new("global", "probe-test");
let probe = spawn_cmd_probe(&id, &"sleep 30".into(), &std::env::temp_dir(), 0, None, &[]);
let CmdProbe {
cancel_tx,
result_rx,
} = probe;
let _ = cancel_tx.send(());
let status = result_rx.await.unwrap().unwrap();
assert!(!status.success());
}
#[tokio::test]
async fn spawn_cmd_probe_receives_daemon_and_resolved_port_environment() {
let id = DaemonId::new("worktree", "api");
let daemon_env = IndexMap::from([("CUSTOM_VALUE".to_string(), "yes".to_string())]);
let check = if cfg!(windows) {
r#"(if "%CUSTOM_VALUE%"=="yes" if "%PORT%"=="4100" if "%PORT0%"=="4100" if "%PORT1%"=="5100" if "%PITCHFORK_DAEMON_ID%"=="worktree/api" if "%PITCHFORK_RETRY_COUNT%"=="2" exit 0) & exit 1"#
} else {
r#"test "$CUSTOM_VALUE" = yes && test "$PORT" = 4100 && test "$PORT0" = 4100 && test "$PORT1" = 5100 && test "$PITCHFORK_DAEMON_ID" = worktree/api && test "$PITCHFORK_RETRY_COUNT" = 2"#
};
let probe = spawn_cmd_probe(
&id,
&check.into(),
&std::env::temp_dir(),
2,
Some(&daemon_env),
&[4100, 5100],
);
let status = probe.result_rx.await.unwrap().unwrap();
assert!(status.success());
}
#[test]
fn configured_ready_port_follows_expected_port_bump() {
assert_eq!(resolve_configured_ready_port(3000, &[3000], &[3004]), 3004);
assert_eq!(
resolve_configured_ready_port(4000, &[3000, 4000], &[3003, 4003]),
4003
);
assert_eq!(resolve_configured_ready_port(8080, &[3000], &[3004]), 8080);
}
}
#[cfg(test)]
mod launch_command_tests {
use super::{invalid_argv_program, launch_command};
use crate::daemon_id::DaemonId;
#[test]
fn refuses_an_argv_whose_program_rendered_empty_or_to_exec() {
let id = DaemonId::new("proj", "api");
assert!(invalid_argv_program(&id, &[]).is_some());
assert!(invalid_argv_program(&id, &words(&["", "server.js"])).is_some());
assert!(invalid_argv_program(&id, &words(&["exec", "node"])).is_some());
assert_eq!(invalid_argv_program(&id, &words(&["node", ""])), None);
}
#[test]
fn refuses_a_probe_array_whose_program_rendered_empty_or_to_exec() {
use super::invalid_probe_program;
use crate::pitchfork_toml::RunCommand;
let id = DaemonId::new("proj", "api");
for field in ["ready_cmd", "health_cmd"] {
let check =
|run: Vec<String>| invalid_probe_program(&id, field, Some(&RunCommand::Argv(run)));
assert!(check(vec![]).unwrap().contains(field));
assert!(check(words(&["", "-h"])).is_some());
assert!(check(words(&["exec", "pg_isready"])).is_some());
assert_eq!(check(words(&["pg_isready", ""])), None);
}
assert_eq!(
invalid_probe_program(&id, "health_cmd", Some(&RunCommand::Shell(String::new()))),
None
);
assert_eq!(invalid_probe_program(&id, "health_cmd", None), None);
}
fn words(words: &[&str]) -> Vec<String> {
words.iter().map(|w| w.to_string()).collect()
}
#[test]
fn starts_the_first_word_with_the_rest_as_arguments() {
let argv = words(&["node", "my server.js", "--name=\"a b\"", "&", "%PATH%"]);
assert_eq!(
launch_command(argv, None),
(
"node".to_string(),
words(&["my server.js", "--name=\"a b\"", "&", "%PATH%"])
)
);
}
#[test]
fn mise_receives_every_word_after_the_separator() {
let argv = words(&["node", "my server.js", "'single'"]);
let mise = std::path::Path::new("/opt/mise/bin/mise");
let (program, args) = launch_command(argv, Some(mise));
assert_eq!(program, mise.to_string_lossy());
assert_eq!(
args,
words(&["x", "--", "node", "my server.js", "'single'"])
);
}
}
#[cfg(test)]
mod output_reader_tests {
use super::{forward_output_lines, output_line_text};
#[test]
fn line_endings_are_removed() {
assert_eq!(output_line_text(b"ready\n"), "ready");
assert_eq!(output_line_text(b"ready\r\n"), "ready");
assert_eq!(output_line_text(b"ready\r\r\n"), "ready");
assert_eq!(output_line_text(b"ready"), "ready");
}
#[tokio::test]
async fn a_line_that_is_not_utf8_does_not_end_the_read() {
let output: &[u8] = b"before\nbad \xff\xfe bytes\nafter 1\nafter 2\nlast";
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
forward_output_lines(tokio::io::BufReader::new(output), tx).await;
let mut lines = Vec::new();
while let Some(line) = rx.recv().await {
lines.push(line.text);
}
assert_eq!(lines.len(), 5, "{lines:?}");
assert_eq!(lines[0], "before");
assert!(lines[1].starts_with("bad "), "{:?}", lines[1]);
assert_eq!(&lines[2..], ["after 1", "after 2", "last"]);
}
}
#[cfg(test)]
mod output_reader_eio_tests {
use super::forward_output_lines;
use std::pin::Pin;
use std::task::{Context, Poll};
struct EndsWithEio(Option<&'static [u8]>);
impl tokio::io::AsyncRead for EndsWithEio {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
match self.0.take() {
Some(bytes) => {
buf.put_slice(bytes);
Poll::Ready(Ok(()))
}
None => Poll::Ready(Err(std::io::Error::from_raw_os_error(5))),
}
}
}
#[tokio::test]
async fn a_last_line_cut_off_by_eio_is_still_forwarded() {
let reader = tokio::io::BufReader::new(EndsWithEio(Some(b"first\nlast without newline")));
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
forward_output_lines(reader, tx).await;
let mut lines = Vec::new();
while let Some(line) = rx.recv().await {
lines.push(line.text);
}
assert_eq!(lines, ["first", "last without newline"]);
}
}