Skip to main content

pitchfork_cli/supervisor/
lifecycle.rs

1//! Daemon lifecycle management - start/stop operations
2//!
3//! Contains the core `run()`, `run_once()`, and `stop()` methods for daemon process management.
4
5use super::hooks::{self, HookType, fire_hook};
6use super::{SUPERVISOR, Supervisor};
7use crate::config_types::OneshotWait;
8use crate::daemon::RunOptions;
9use crate::daemon_id::DaemonId;
10use crate::daemon_status::DaemonStatus;
11use crate::error::PortError;
12use crate::ipc::IpcResponse;
13use crate::log_store::LogStore;
14use crate::log_store::sqlite::LOG_STORE;
15use crate::pitchfork_toml::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, RunCommand};
16use crate::procs::PROCS;
17use crate::settings::{resolve_shell, settings};
18use crate::shell::{HideConsoleWindow, Shell, ShellScript, powershell_script};
19use crate::supervisor::state::UpsertDaemonOpts;
20use crate::{Result, env};
21use indexmap::IndexMap;
22use miette::IntoDiagnostic;
23use once_cell::sync::Lazy;
24use regex::Regex;
25use std::collections::HashMap;
26#[cfg(unix)]
27use std::ffi::CString;
28use std::sync::{Arc, atomic};
29use std::time::Duration;
30use tokio::select;
31use tokio::sync::oneshot;
32use tokio::time;
33
34/// Cache for compiled regex patterns to avoid recompilation on daemon restarts
35static REGEX_CACHE: Lazy<std::sync::Mutex<HashMap<String, Regex>>> =
36    Lazy::new(|| std::sync::Mutex::new(HashMap::new()));
37
38fn resolve_configured_ready_port(
39    configured_port: u16,
40    expected_ports: &[u16],
41    resolved_ports: &[u16],
42) -> u16 {
43    let bump_offset = resolved_ports
44        .first()
45        .unwrap_or(&0)
46        .saturating_sub(*expected_ports.first().unwrap_or(&0));
47    if expected_ports.contains(&configured_port) && bump_offset > 0 {
48        configured_port
49            .checked_add(bump_offset)
50            .unwrap_or(configured_port)
51    } else {
52        configured_port
53    }
54}
55
56fn active_port_from_ready_port(ready_port: u16, resolved_ports: &[u16]) -> Option<u16> {
57    resolved_ports
58        .first()
59        .copied()
60        .filter(|&primary_port| primary_port == ready_port)
61}
62
63#[cfg(unix)]
64#[derive(Clone, Debug, PartialEq, Eq)]
65enum RunIdentity {
66    Inherit,
67    Switch {
68        uid: nix::unistd::Uid,
69        gid: nix::unistd::Gid,
70        username: Option<CString>,
71        /// Home directory from the user's passwd entry.
72        home: Option<std::path::PathBuf>,
73    },
74}
75
76/// Get or compile a regex pattern, caching the result for future use
77pub(crate) fn get_or_compile_regex(pattern: &str) -> Option<Regex> {
78    let mut cache = REGEX_CACHE.lock().unwrap_or_else(|e| e.into_inner());
79    if let Some(re) = cache.get(pattern) {
80        return Some(re.clone());
81    }
82    match Regex::new(pattern) {
83        Ok(re) => {
84            cache.insert(pattern.to_string(), re.clone());
85            Some(re)
86        }
87        Err(e) => {
88            error!("invalid regex pattern '{pattern}': {e}");
89            None
90        }
91    }
92}
93
94/// Handle for an in-flight readiness command probe.
95///
96/// The spawned task owns the `tokio::process::Child` and waits for either the
97/// process to exit or the cancel signal. Dropping the handle without cancelling
98/// leaves the task running, but the child is started with `kill_on_drop(true)`
99/// so it will still be terminated when the task ends.
100pub(crate) struct CmdProbe {
101    pub(crate) cancel_tx: tokio::sync::oneshot::Sender<()>,
102    pub(crate) result_rx: tokio::sync::oneshot::Receiver<std::io::Result<std::process::ExitStatus>>,
103}
104
105/// Spawn a readiness command probe and return a handle that can be used to wait
106/// for the exit status or cancel the probe.
107///
108/// The probe is started with `kill_on_drop(true)` as a cancellation fallback. The
109/// spawned task waits for the process to exit; if cancellation is requested, it
110/// kills the child and waits for it to reap before reporting the result.
111fn apply_runtime_env(
112    command: &mut tokio::process::Command,
113    id: &DaemonId,
114    retry_count: u32,
115    daemon_env: Option<&IndexMap<String, String>>,
116    resolved_ports: &[u16],
117) {
118    if let Some(ref path) = *env::ORIGINAL_PATH {
119        command.env("PATH", path);
120    }
121    if let Some(env_vars) = daemon_env {
122        command.envs(env_vars);
123    }
124    command
125        .env("PITCHFORK_DAEMON_ID", id.qualified())
126        .env("PITCHFORK_DAEMON_NAMESPACE", id.namespace())
127        .env("PITCHFORK_RETRY_COUNT", retry_count.to_string());
128    if let Some(port) = resolved_ports.first() {
129        command.env("PORT", port.to_string());
130        for (index, port) in resolved_ports.iter().enumerate() {
131            command.env(format!("PORT{index}"), port.to_string());
132        }
133    }
134}
135
136/// A probe whose result channel is already closed. The caller treats it the
137/// same as a probe that exited non-zero and respawns it after the
138/// ready_check_interval, preserving the existing retry behaviour.
139fn closed_cmd_probe() -> CmdProbe {
140    let (cancel_tx, _) = tokio::sync::oneshot::channel();
141    let (_, result_rx) = tokio::sync::oneshot::channel();
142    CmdProbe {
143        cancel_tx,
144        result_rx,
145    }
146}
147
148pub(crate) fn spawn_cmd_probe(
149    id: &DaemonId,
150    cmd: &RunCommand,
151    dir: &std::path::Path,
152    retry_count: u32,
153    daemon_env: Option<&IndexMap<String, String>>,
154    resolved_ports: &[u16],
155) -> CmdProbe {
156    let mut command = match cmd {
157        // The argv form: the program and its arguments as written, with no
158        // shell to reinterpret them, as for a `run` array.
159        RunCommand::Argv(argv) => {
160            let Some((program, args)) = argv.split_first().filter(|(p, _)| !p.is_empty()) else {
161                warn!("daemon {id}: ready_cmd array has no program to run");
162                return closed_cmd_probe();
163            };
164            let mut c = tokio::process::Command::new(program);
165            c.args(args);
166            c
167        }
168        // Use the same shell as daemon run and hooks. A probe is not worth
169        // failing the daemon over, so an unparseable setting degrades to the
170        // platform's own shell here rather than propagating; run_once has
171        // already rejected the start by then, so this only fires for a daemon
172        // whose settings changed under it.
173        RunCommand::Shell(cmd) => match resolve_shell() {
174            Ok(parts) => {
175                let (program, args) = parts.split_first().unwrap();
176                let mut c = tokio::process::Command::new(program);
177                c.shell_script(program, args, cmd);
178                c
179            }
180            Err(e) => {
181                warn!("daemon {id}: {e}; using the platform shell for this probe");
182                Shell::default_for_platform().command(cmd)
183            }
184        },
185    };
186    command
187        .current_dir(dir)
188        .stdout(std::process::Stdio::null())
189        .stderr(std::process::Stdio::null())
190        .kill_on_drop(true)
191        .hide_console_window();
192    apply_runtime_env(&mut command, id, retry_count, daemon_env, resolved_ports);
193    let mut child = match command.spawn() {
194        Ok(child) => child,
195        Err(e) => {
196            warn!("daemon {id}: failed to spawn command probe: {e}");
197            return closed_cmd_probe();
198        }
199    };
200
201    let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel();
202    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
203
204    tokio::spawn(async move {
205        let status = tokio::select! {
206            status = child.wait() => status,
207            _ = &mut cancel_rx => {
208                let mut child = child;
209                let _ = child.kill().await;
210                child.wait().await
211            }
212        };
213        let _ = result_tx.send(status);
214    });
215
216    CmdProbe {
217        cancel_tx,
218        result_rx,
219    }
220}
221
222/// Cancel an active command probe and clear its handle.
223fn stop_cmd_probe_state(probe: &mut Option<CmdProbe>) {
224    if let Some(p) = probe.take() {
225        let _ = p.cancel_tx.send(());
226    }
227}
228
229/// Spawn a detached task that kills a daemon's process group after its
230/// readiness checks are exhausted, logging a failed kill instead of
231/// discarding it. The returned handle is awaited before the readiness
232/// failure is reported so the process group is down by then.
233fn spawn_ready_fail_kill(
234    id: DaemonId,
235    pid: u32,
236    stop_cfg: crate::config_types::StopConfig,
237) -> tokio::task::JoinHandle<()> {
238    tokio::spawn(async move {
239        if let Err(e) = PROCS
240            .kill_process_group_async(pid, stop_cfg.signal.into(), stop_cfg.timeout)
241            .await
242        {
243            error!("daemon {id}: failed to kill pid {pid} after readiness failure: {e}");
244        }
245    })
246}
247
248/// Returns true if any configured readiness check can still succeed.
249/// A check with no timeout is unbounded; a timed check can still succeed until its
250/// deadline fires. `ready_delay` is only used as a fallback when no other check is
251/// configured, so it is not counted here.
252#[allow(clippy::too_many_arguments)]
253fn any_ready_check_remaining(
254    ready_output: Option<&ReadyOutput>,
255    output_exhausted: bool,
256    ready_port: Option<&ReadyPort>,
257    port_exhausted: bool,
258    ready_http: Option<&ReadyHttp>,
259    http_exhausted: bool,
260    ready_cmd: Option<&ReadyCmd>,
261    cmd_exhausted: bool,
262) -> bool {
263    ready_output.is_some_and(|o| o.timeout.is_none() || !output_exhausted)
264        || ready_port.is_some_and(|p| p.timeout.is_none() || !port_exhausted)
265        || ready_http.is_some_and(|h| h.timeout.is_none() || !http_exhausted)
266        || ready_cmd.is_some_and(|c| c.timeout.is_none() || !cmd_exhausted)
267}
268
269fn delay_readiness_succeeded(
270    ready_notified: bool,
271    has_other_ready_check: bool,
272    process_exited: bool,
273    process_running: bool,
274) -> bool {
275    !ready_notified && !has_other_ready_check && !process_exited && process_running
276}
277
278/// Terminal state recorded for a daemon run that has ended, and whether that
279/// ending counts as a successful exit.
280///
281/// A `oneshot` daemon's whole job is to finish, so a clean exit of its own
282/// accord is `Completed` rather than `Stopped` — that is what makes it
283/// distinguishable from a service that is merely not running, and what lets
284/// `depends` treat it as satisfied. An explicit stop is still a stop: the task
285/// was interrupted, not completed.
286fn terminal_exit_state(
287    exit_reason: &str,
288    oneshot: bool,
289    exit_code: i32,
290    exited_cleanly: bool,
291) -> (DaemonStatus, bool) {
292    match exit_reason {
293        "exit" if oneshot => (DaemonStatus::Completed, true),
294        "stop" | "exit" => (DaemonStatus::Stopped, exited_cleanly),
295        _ => (DaemonStatus::Errored(exit_code), false),
296    }
297}
298
299/// Whether a stop that arrived after a run's process was already gone should
300/// leave the status its monitor settled on alone.
301///
302/// Only a completed task is left alone: it had already done its work, so there
303/// was nothing for the stop to interrupt, and overwriting it would report a
304/// failure to anyone waiting on it. Anything else — a failure above all — is
305/// replaced by the stop, so the retry checker does not carry on with a task
306/// the user has stopped.
307/// Why the argv form of `run` cannot be started, if it cannot.
308///
309/// Config load checks the array as written, but a template can still render
310/// the program to nothing, or to `exec`.
311/// Why a daemon's `ready_cmd` or `health_cmd` array (named by `field`)
312/// cannot be run, if it cannot: its program, after templates are rendered, is
313/// empty or `exec`. Config load rejects both as written, but a template can
314/// still render to them, and a probe that can never run would keep the start
315/// waiting for readiness, or fail every health check.
316fn invalid_probe_program(id: &DaemonId, field: &str, run: Option<&RunCommand>) -> Option<String> {
317    let RunCommand::Argv(argv) = run? else {
318        return None;
319    };
320    match argv.first().map(String::as_str) {
321        None | Some("") => Some(format!(
322            "daemon {id} has no program to run in its {field} array"
323        )),
324        Some("exec") => Some(format!(
325            "daemon {id} starts its {field} array with \"exec\"; a {field} array runs the program directly, so remove \"exec\""
326        )),
327        Some(_) => None,
328    }
329}
330
331fn invalid_argv_program(id: &DaemonId, argv: &[String]) -> Option<String> {
332    match argv.first().map(String::as_str) {
333        None | Some("") => Some(format!(
334            "daemon {id} has no program to run: its run array starts with an empty value"
335        )),
336        Some("exec") => Some(format!(
337            "daemon {id} starts its run array with \"exec\"; a run array starts the program directly, so remove \"exec\""
338        )),
339        Some(_) => None,
340    }
341}
342
343/// Program and arguments to spawn for `words` — the daemon's program followed
344/// by its arguments — run through `mise x` when `mise_bin` is given.
345///
346/// Each word stays one argument: mise hands everything after `--` to the
347/// program as it received it.
348fn launch_command(words: Vec<String>, mise_bin: Option<&std::path::Path>) -> (String, Vec<String>) {
349    match mise_bin {
350        Some(mise_bin) => {
351            let mut args = vec!["x".to_string(), "--".to_string()];
352            args.extend(words);
353            (mise_bin.to_string_lossy().to_string(), args)
354        }
355        None => {
356            let mut words = words.into_iter();
357            // Never empty: a shell resolves to at least its program, and an
358            // empty argv is refused before this is reached.
359            let program = words.next().unwrap_or_default();
360            (program, words.collect())
361        }
362    }
363}
364
365/// Text of one line read from a daemon's output, its line ending removed.
366///
367/// Decoded as the log sink decodes lines, so output that is not UTF-8 is
368/// logged rather than ending the read. A PTY slave's ONLCR turns `\n` into
369/// `\r\n`, so every trailing `\r` goes, as the sink also strips them.
370fn output_line_text(line: &[u8]) -> String {
371    let line = line.strip_suffix(b"\n").unwrap_or(line);
372    crate::cli::log_sink::decode_line(line)
373        .trim_end_matches('\r')
374        .to_string()
375}
376
377/// Send each line of `reader` to `tx` until the output ends or nobody is
378/// listening.
379///
380/// Reading stops only there, never on a line's content: a reader that gave up
381/// would stop draining the daemon's pipe or PTY, and the daemon would block,
382/// or fail, on its next write.
383async fn forward_output_lines<R>(mut reader: R, tx: tokio::sync::mpsc::Sender<super::OutputLine>)
384where
385    R: tokio::io::AsyncBufRead + Unpin,
386{
387    use tokio::io::AsyncBufReadExt;
388    let mut line = Vec::new();
389    loop {
390        line.clear();
391        // A read error ends the output too: a Linux PTY master reports the
392        // slave closing, once the daemon is gone, as EIO rather than end of
393        // file. Whatever of a last, unterminated line arrived before it is
394        // still sent.
395        let last = match reader.read_until(b'\n', &mut line).await {
396            Ok(0) => break,
397            Ok(_) => false,
398            Err(_) if line.is_empty() => break,
399            Err(_) => true,
400        };
401        let sent = tx
402            .send(super::OutputLine {
403                text: output_line_text(&line),
404                source: super::OutputSource::Local,
405            })
406            .await;
407        if sent.is_err() || last {
408            break;
409        }
410    }
411}
412
413fn stop_keeps_finalized_status(status: &DaemonStatus) -> bool {
414    status.is_completed()
415}
416
417/// How long a failed start waits for the daemon's output to become queryable
418/// before reporting. Typically satisfied in a few dozen milliseconds; a daemon
419/// that failed without printing anything waits the whole of it, so keep it
420/// short.
421const SINK_OUTPUT_TIMEOUT: Duration = Duration::from_millis(400);
422
423/// Marks a daemon as having its retries managed by a foreground `run` for as
424/// long as this value lives, so the background checker does not start an
425/// attempt out from under it. Released on every exit from the retry loop,
426/// including the early returns.
427/// Counts a stop of this daemon once the stop is done, while its lock is
428/// still held. See `Supervisor::stop_epochs`.
429struct StopEpochGuard(DaemonId);
430
431impl Drop for StopEpochGuard {
432    fn drop(&mut self) {
433        SUPERVISOR.bump_stop_epoch(&self.0);
434    }
435}
436
437pub(crate) struct RetryingGuard {
438    id: DaemonId,
439    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
440}
441
442impl RetryingGuard {
443    /// Whether a `stop` or a `disable` has asked this retry sequence to end.
444    fn is_cancelled(&self) -> bool {
445        self.cancel.load(std::sync::atomic::Ordering::Acquire)
446    }
447}
448
449impl Drop for RetryingGuard {
450    fn drop(&mut self) {
451        let mut retrying = SUPERVISOR
452            .retrying
453            .lock()
454            .unwrap_or_else(|e| e.into_inner());
455        // Drop this claim's flag only. Another sequence for the same daemon
456        // may still be running, and it has to stay both protected from the
457        // retry checker and reachable by a stop.
458        if let Some(claims) = retrying.get_mut(&self.id) {
459            claims.retain(|flag| !std::sync::Arc::ptr_eq(flag, &self.cancel));
460            if claims.is_empty() {
461                retrying.remove(&self.id);
462            }
463        }
464    }
465}
466
467impl Supervisor {
468    /// Whether a foreground `run` is already working through this daemon's
469    /// retries.
470    pub(crate) fn is_retrying(&self, id: &DaemonId) -> bool {
471        self.retrying
472            .lock()
473            .unwrap_or_else(|e| e.into_inner())
474            .get(id)
475            .is_some_and(|claims| !claims.is_empty())
476    }
477
478    /// How many times this daemon has been stopped so far.
479    pub(crate) fn stop_epoch(&self, id: &DaemonId) -> u64 {
480        self.stop_epochs
481            .lock()
482            .unwrap_or_else(|e| e.into_inner())
483            .get(id)
484            .copied()
485            .unwrap_or(0)
486    }
487
488    fn bump_stop_epoch(&self, id: &DaemonId) {
489        *self
490            .stop_epochs
491            .lock()
492            .unwrap_or_else(|e| e.into_inner())
493            .entry(id.clone())
494            .or_default() += 1;
495    }
496
497    fn mark_retrying(&self, id: &DaemonId) -> RetryingGuard {
498        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
499        self.retrying
500            .lock()
501            .unwrap_or_else(|e| e.into_inner())
502            .entry(id.clone())
503            .or_default()
504            .push(cancel.clone());
505        RetryingGuard {
506            id: id.clone(),
507            cancel,
508        }
509    }
510
511    /// Ask a foreground retry sequence for this daemon, if there is one, to
512    /// end. A stop is a decision about the daemon, not about one of its
513    /// attempts, so the attempts left must not go ahead behind it.
514    pub(crate) fn cancel_retrying(&self, id: &DaemonId) {
515        if let Some(claims) = self
516            .retrying
517            .lock()
518            .unwrap_or_else(|e| e.into_inner())
519            .get(id)
520        {
521            // Every claim, not just the newest: a start that is sleeping out a
522            // backoff is as much a sequence the stop has to end as the one that
523            // claimed the daemon last.
524            for cancel in claims {
525                cancel.store(true, std::sync::atomic::Ordering::Release);
526            }
527        }
528    }
529
530    /// Run a daemon, handling retries if configured
531    pub async fn run(&self, opts: RunOptions) -> Result<IpcResponse> {
532        let id = opts.id.clone();
533        let mut restart_mark = None;
534        let result = self.run_inner(opts, None, &mut restart_mark).await;
535        // A forced start records the daemon it replaces as restarting. If no
536        // new process took the record over, the restart did not happen.
537        if let Some(mark) = restart_mark {
538            self.settle_restarting(&id, mark).await;
539        }
540        result
541    }
542
543    /// Record a daemon left `restarting` with no process as `stopped`: its
544    /// restart did not start a new process, and leaving the mark in place
545    /// would have `pitchfork wait` follow a restart that is never coming.
546    ///
547    /// `mark` is the daemon's stop epoch right after the restart's own stop.
548    /// Any stop since (a `pitchfork stop`, or another restart's stop that
549    /// left its own mark) has the last word, so the record is left alone, as
550    /// is one that has moved on (a new process, another status).
551    pub(crate) async fn settle_restarting(&self, id: &DaemonId, mark: u64) {
552        let lock = self.stop_lock(id).await;
553        let _guard = lock.lock().await;
554        if self.stop_epoch(id) != mark {
555            return;
556        }
557        let mut state_file = self.state_file.lock().await;
558        if state_file
559            .daemons
560            .get(id)
561            .is_some_and(|d| d.pid.is_none() && d.status.is_restarting())
562        {
563            debug!("daemon {id} was not started again after its restart; recording it as stopped");
564            state_file.set_status(id, DaemonStatus::Stopped);
565        }
566    }
567
568    /// Run an attempt the retry checker decided on while `stop_epoch` read
569    /// `approved_at`. If the daemon has been stopped since, the attempt is
570    /// abandoned instead of started.
571    ///
572    /// `restart_mark` is set as in [`Self::run_inner`], if a forced attempt
573    /// stopped a running instance.
574    pub(crate) async fn run_retry(
575        &self,
576        opts: RunOptions,
577        approved_at: u64,
578        restart_mark: &mut Option<u64>,
579    ) -> Result<IpcResponse> {
580        self.run_inner(opts, Some(approved_at), restart_mark).await
581    }
582
583    /// `restart_mark` is set to the stop epoch a forced start's own stop of
584    /// the running instance left, if it made one (see
585    /// [`Self::settle_restarting`]).
586    async fn run_inner(
587        &self,
588        opts: RunOptions,
589        approved_at: Option<u64>,
590        restart_mark: &mut Option<u64>,
591    ) -> Result<IpcResponse> {
592        let id = &opts.id;
593        let cmd = opts.cmd.clone();
594
595        // Clear any pending autostop for this daemon since it's being started
596        {
597            let mut pending = self.pending_autostops.lock().await;
598            if pending.remove(id).is_some() {
599                info!("cleared pending autostop for {id} (daemon starting)");
600            }
601        }
602
603        // Serialize against any in-flight stop of this daemon: a stop now
604        // waits for the whole process group to exit, so the Stopping window
605        // can last seconds instead of milliseconds. Starting through that
606        // window would collide with the dying instance (duplicate processes,
607        // port conflicts). Acquiring the stop lock waits the stop out; the
608        // state is re-read afterwards. The guard is owned and handed to
609        // run_once, which holds it until the new daemon's Running state and
610        // PID are persisted — releasing it before that point would let a
611        // concurrent run pass this same check (duplicate processes) or let a
612        // concurrent stop see no PID and return without stopping anything.
613        let mut stop_guard = Some(self.stop_lock(id).await.lock_owned().await);
614        // Checked here, under the daemon's lock, because that is what a stop
615        // takes too: an approval from before the stop cannot slip past it.
616        // Writing `stopped` over the record is not enough on its own, since an
617        // attempt already approved would read that as a daemon free to start.
618        if let Some(approved_at) = approved_at
619            && self.stop_epoch(id) != approved_at
620        {
621            info!("daemon {id} was stopped after this retry was decided on; not starting it");
622            return Ok(IpcResponse::DaemonNotRunning);
623        }
624        // Likewise a disable, for every start the supervisor makes on its own
625        // (retries, the schedule, boot): each read the disabled set before
626        // waiting for this lock, and `disable` takes it too.
627        if (approved_at.is_some() || !opts.requested_by_client)
628            && self.state_file.lock().await.disabled.contains(id)
629        {
630            info!("daemon {id} was disabled before this start; not starting it");
631            return Ok(IpcResponse::DaemonNotRunning);
632        }
633        if let Some(response) = self
634            .claim_or_defer(&opts, &mut stop_guard, restart_mark)
635            .await?
636        {
637            return Ok(response);
638        }
639
640        // If wait_ready is true and retry is configured, implement retry loop
641        if opts.wait_ready && opts.retry.count() > 0 {
642            // Claim this daemon's retries for the duration of the loop. The
643            // backoff between attempts leaves the record errored with no PID,
644            // which is what `check_retry` scans for, and an attempt started
645            // there would leave this call reporting on a run it does not own.
646            let retrying_claim = self.mark_retrying(id);
647            // Use saturating_add to avoid overflow when retry = u32::MAX (infinite)
648            let max_attempts = opts.retry.count().saturating_add(1);
649            for attempt in 0..max_attempts {
650                let mut retry_opts = opts.clone();
651                retry_opts.retry_count = attempt;
652                retry_opts.cmd = cmd.clone();
653
654                // The first attempt starts under the guard held since the
655                // running check above; later attempts re-acquire it so stops
656                // are not locked out during the backoff sleeps.
657                let mut guard = Some(match stop_guard.take() {
658                    Some(guard) => guard,
659                    None => self.stop_lock(id).await.lock_owned().await,
660                });
661                // Ownership has to be re-checked on every attempt, not just
662                // the first. The backoff leaves the daemon errored with no PID,
663                // which is exactly what `check_retry` looks for, so the
664                // background checker can start the next attempt during the
665                // sleep. Spawning another process here would replace that
666                // attempt's monitor registration and leave its process running
667                // unmonitored.
668                if let Some(response) = self
669                    .claim_or_defer(&retry_opts, &mut guard, restart_mark)
670                    .await?
671                {
672                    return Ok(response);
673                }
674                // The background retry checker may have run this attempt for
675                // us and seen it succeed while we slept. Starting again would
676                // repeat a task that has already done its work — for a
677                // migration or a seed, repeating its side effects.
678                //
679                // Only after a backoff, though. A completed record on the first
680                // attempt is the previous run's, and a start is defined to
681                // re-run a completed oneshot; short-circuiting here would make
682                // that true only for oneshots without `retry`.
683                if attempt > 0
684                    && let Some(daemon) = self.get_daemon(id).await
685                    && daemon.status.is_completed()
686                {
687                    info!("daemon {id} completed while waiting to retry; not running it again");
688                    return Ok(IpcResponse::DaemonReady { daemon });
689                }
690                // A disable during the backoff ends the sequence, checked under
691                // the guard `disable` takes. It wakes the backoff the way a stop
692                // does, so it is checked first to report what happened.
693                if attempt > 0 && self.state_file.lock().await.disabled.contains(id) {
694                    info!(
695                        "daemon {id} was disabled while waiting to retry; abandoning its retries"
696                    );
697                    return Ok(IpcResponse::DaemonFailed {
698                        error: "disabled while retrying".to_string(),
699                    });
700                }
701                // A stop that arrived during the backoff ends the sequence.
702                // Without this the loop would start the next attempt on a
703                // daemon the user has just stopped, and the stop would look
704                // like it had done nothing.
705                if retrying_claim.is_cancelled() {
706                    info!("daemon {id} was stopped while waiting to retry; abandoning its retries");
707                    return Ok(IpcResponse::DaemonFailed {
708                        error: "stopped while retrying".to_string(),
709                    });
710                }
711                let Some(guard) = guard else {
712                    // Only the deferring paths take the guard, and each of
713                    // those returned above.
714                    return Ok(IpcResponse::DaemonAlreadyRunning);
715                };
716                let result = self.run_once(retry_opts, guard).await?;
717
718                match result {
719                    IpcResponse::DaemonReady { daemon } => {
720                        return Ok(IpcResponse::DaemonReady { daemon });
721                    }
722                    IpcResponse::DaemonFailedWithCode {
723                        exit_code,
724                        resolved_ports,
725                    } => {
726                        if attempt < opts.retry.count() {
727                            // `run_once` reports failure the moment the process
728                            // exits, but its monitor finalizes the record only
729                            // after draining the process's remaining output.
730                            // Until then the record still names this attempt's
731                            // PID, and the next attempt's ownership check would
732                            // read its own dead predecessor as a competing run
733                            // and abandon the retries that are left.
734                            let attempt_pid = self.get_daemon(id).await.and_then(|d| d.pid);
735                            self.wait_for_exit_finalized(id, attempt_pid).await;
736                            let backoff_secs = 2u64.saturating_pow(attempt).min(3600);
737                            info!(
738                                "daemon {id} failed (attempt {}/{}), retrying in {}s",
739                                attempt + 1,
740                                max_attempts,
741                                backoff_secs
742                            );
743                            fire_hook(
744                                HookType::OnRetry,
745                                id.clone(),
746                                super::hooks::HookDirs::new(
747                                    opts.dir.0.clone(),
748                                    opts.watch_base_dir.clone(),
749                                ),
750                                attempt + 1,
751                                opts.env.clone(),
752                                resolved_ports,
753                                vec![],
754                            )
755                            .await;
756                            // Slept in slices so a stop arriving during a
757                            // long backoff — they grow to an hour — is acted
758                            // on when it arrives rather than when the sleep
759                            // happens to end.
760                            let backoff_deadline =
761                                tokio::time::Instant::now() + Duration::from_secs(backoff_secs);
762                            while tokio::time::Instant::now() < backoff_deadline
763                                && !retrying_claim.is_cancelled()
764                            {
765                                let remaining = backoff_deadline - tokio::time::Instant::now();
766                                time::sleep(remaining.min(Duration::from_millis(200))).await;
767                            }
768                            continue;
769                        } else {
770                            info!("daemon {id} failed after {max_attempts} attempts");
771                            return Ok(IpcResponse::DaemonFailedWithCode {
772                                exit_code,
773                                resolved_ports,
774                            });
775                        }
776                    }
777                    other => return Ok(other),
778                }
779            }
780        }
781
782        // No retry or wait_ready is false
783        let guard = match stop_guard.take() {
784            Some(guard) => guard,
785            None => self.stop_lock(id).await.lock_owned().await,
786        };
787        self.run_once(opts, guard).await
788    }
789
790    /// Wait for a just-failed attempt's monitor to write its terminal state,
791    /// clearing the PID from the record.
792    ///
793    /// Bounded a little beyond the monitor's own five-second output drain, the
794    /// longest it can hold the record after the process has gone. Giving up
795    /// early is safe: the ownership check that follows simply sees a PID and
796    /// defers, which is what it would have done anyway.
797    ///
798    /// `pid` names the run being waited for, so a record that has moved on to
799    /// another run is not mistaken for this one still finishing.
800    async fn wait_for_exit_finalized(&self, id: &DaemonId, pid: Option<u32>) {
801        let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
802        loop {
803            match self.get_daemon(id).await {
804                Some(daemon) if pid.map_or(daemon.pid.is_some(), |pid| daemon.pid == Some(pid)) => {
805                }
806                _ => return,
807            }
808            if tokio::time::Instant::now() >= deadline {
809                debug!("daemon {id}: previous attempt has not finalized yet; continuing anyway");
810                return;
811            }
812            time::sleep(Duration::from_millis(50)).await;
813        }
814    }
815
816    /// Decide whether this start may take the daemon's record, or must stand
817    /// down because a live run already owns it.
818    ///
819    /// Returns `Some(response)` when the caller must report that run's outcome
820    /// instead of spawning a second process, and `None` when the record is free
821    /// (including after a forced stop of the previous instance).
822    ///
823    /// `stop_guard` is released before an in-flight oneshot is awaited: that
824    /// wait lasts as long as the task does, and holding the lock would block a
825    /// stop of the very run being waited on.
826    async fn claim_or_defer(
827        &self,
828        opts: &RunOptions,
829        stop_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
830        restart_mark: &mut Option<u64>,
831    ) -> Result<Option<IpcResponse>> {
832        let id = &opts.id;
833        let Some(daemon) = self.get_daemon(id).await else {
834            return Ok(None);
835        };
836        // Entering a directory does not re-run a finished task, at any level of
837        // the dependency graph — the layout this exists for reaches the task
838        // through a service's `depends`, not by naming it. Decided here rather
839        // than in the client because this is the authoritative state: the state
840        // file lags it by up to the flush interval, which is exactly the window
841        // a second entry lands in after the task completes.
842        // `opts.oneshot` rather than the record's: the request carries what
843        // config says now, while the stored flag is only refreshed by a run, so
844        // a daemon that used to be a task would otherwise stay skipped forever
845        // after being turned into a service.
846        if opts.on_directory_enter && opts.oneshot && daemon.status.is_completed() {
847            debug!("daemon {id} already completed; directory entry leaves it alone");
848            return Ok(Some(IpcResponse::DaemonReady { daemon }));
849        }
850        // Stopping (or restarting) is treated as "not running": the
851        // monitoring task will clean it up. Only a live PID under a
852        // non-terminal status blocks a start.
853        if daemon.status.is_stopping_or_restarting()
854            || daemon.status.is_stopped()
855            || daemon.status.is_completed()
856        {
857            return Ok(None);
858        }
859        let Some(pid) = daemon.pid else {
860            return Ok(None);
861        };
862        if opts.force {
863            // A forced start replaces the running instance, so the daemon is
864            // recorded as restarting rather than stopped until the new
865            // process is up; `run` settles it if the start does not happen.
866            self.stop_locked_with(id, true).await?;
867            // Read under the daemon's lock, which this start still holds, so
868            // the epoch is the one this stop left.
869            *restart_mark = Some(self.stop_epoch(id));
870            info!("run: stop completed for daemon {id}");
871            return Ok(None);
872        }
873        if daemon.oneshot && opts.wait_ready {
874            // An in-flight oneshot has not done its work yet, so reporting
875            // "already running" would let dependents start against the state
876            // the task is still establishing. Wait for the run already under
877            // way instead.
878            info!("daemon {id} is an in-flight oneshot (pid {pid}); waiting for it to finish");
879            drop(stop_guard.take());
880            return Ok(Some(
881                self.await_running_oneshot(id, opts.oneshot_wait, pid).await,
882            ));
883        }
884        // A record can name a PID that has already exited: `stop` leaves the
885        // terminal state to a monitor that still owns the daemon, and that
886        // monitor writes it only after draining the process's output. Rejecting
887        // a start against a dead PID would fail an ordinary stop-then-start for
888        // the length of that drain, so confirm the process is really there
889        // before refusing. The oneshot branch above deliberately comes first: a
890        // task whose process has exited is about to be recorded as completed,
891        // and starting a second copy of it is exactly what waiting prevents.
892        PROCS.refresh_pids(&[pid]);
893        if !PROCS.is_running(pid) {
894            debug!(
895                "daemon {id}: record still names pid {pid}, which has exited; its monitor has not finalized yet"
896            );
897            return Ok(None);
898        }
899        warn!("daemon {id} already running with pid {pid}");
900        Ok(Some(IpcResponse::DaemonAlreadyRunning))
901    }
902
903    /// Wait for a oneshot that is already running to reach a terminal state,
904    /// and report it as if this call had started the task itself.
905    ///
906    /// Polls the state file because the terminal state is written by the
907    /// monitoring task of the *other* run; this call has no readiness channel
908    /// of its own to await.
909    async fn await_running_oneshot(
910        &self,
911        id: &DaemonId,
912        wait: Option<OneshotWait>,
913        watched_pid: u32,
914    ) -> IpcResponse {
915        let interval = settings().supervisor_ready_check_interval();
916        // The caller resolved this from the project's settings and sent it, so
917        // both processes wait exactly as long. Falling back to this process's
918        // own settings would read the directory the supervisor happens to have
919        // started in, where a project's `oneshot_timeout` is not visible — and
920        // the shorter of the two deadlines would silently win, releasing
921        // dependents while the task was still running.
922        //
923        // `None` here means the setting asked for no limit, so there is no
924        // deadline to reach rather than a distant one.
925        // One deadline for the whole wait, retries and backoffs included.
926        // `oneshot_timeout` is documented as the longest `pitchfork start` will
927        // wait, and the client bounds its own request by the same value without
928        // restarting it, so a per-attempt budget here would both break that
929        // promise — unboundedly, with infinite retries — and put the two sides
930        // back to disagreeing about when one task has gone on too long.
931        let deadline = wait
932            .unwrap_or_else(|| settings().supervisor_oneshot_wait())
933            .duration()
934            .map(|d| tokio::time::Instant::now() + d);
935        // Which run this wait is reporting on. A terminal state is only that
936        // run's while the record still names its PID or names none at all; once
937        // another PID appears, something else has started the task and the
938        // outcome that follows belongs to that run, not this one. Following the
939        // handoff keeps the answer useful to a dependent — it still learns
940        // whether the task succeeded — without quietly attributing an unrelated
941        // run's failure to the one it asked about.
942        let mut watched_pid = watched_pid;
943        loop {
944            let Some(daemon) = self.get_daemon(id).await else {
945                return IpcResponse::DaemonNotFound;
946            };
947            if let Some(current) = daemon.pid
948                && current != watched_pid
949            {
950                info!(
951                    "daemon {id}: the run being waited on (pid {watched_pid}) was replaced by pid {current}; following it"
952                );
953                watched_pid = current;
954            }
955            match &daemon.status {
956                DaemonStatus::Completed => {
957                    info!("daemon {id}: the in-flight oneshot completed");
958                    return IpcResponse::DaemonReady { daemon };
959                }
960                DaemonStatus::Errored(code) => {
961                    // A failed attempt is persisted before the in-flight `run`
962                    // sleeps out its backoff, so an errored record with
963                    // attempts left is a gap between tries rather than the
964                    // result. Same condition `check_retry` uses to decide
965                    // whether another attempt is still owed. A disabled daemon
966                    // is owed none: every retry path checks the disabled set,
967                    // under the lock `disable` takes, before starting one.
968                    if daemon.retry.count() > 0
969                        && daemon.retry_count < daemon.retry.count()
970                        && !self.state_file.lock().await.disabled.contains(id)
971                    {
972                        debug!(
973                            "daemon {id}: in-flight oneshot failed attempt {} of {}; still waiting",
974                            daemon.retry_count + 1,
975                            daemon.retry.count() + 1
976                        );
977                    } else {
978                        // -1 records an unobservable exit code; the caller
979                        // renders `None` as a plain failure rather than
980                        // "exit code -1".
981                        let exit_code = Some(*code).filter(|c| *c != -1);
982                        return IpcResponse::DaemonFailedWithCode {
983                            exit_code,
984                            resolved_ports: daemon.resolved_port.clone(),
985                        };
986                    }
987                }
988                DaemonStatus::Failed(error) => {
989                    return IpcResponse::DaemonFailed {
990                        error: error.clone(),
991                    };
992                }
993                DaemonStatus::Stopped => {
994                    // Stopped, not completed: the task was interrupted, so it
995                    // never established what its dependents are waiting for.
996                    warn!("daemon {id}: the in-flight oneshot was stopped before completing");
997                    return IpcResponse::DaemonFailedWithCode {
998                        exit_code: None,
999                        resolved_ports: daemon.resolved_port.clone(),
1000                    };
1001                }
1002                DaemonStatus::Running
1003                | DaemonStatus::Waiting
1004                | DaemonStatus::Stopping
1005                | DaemonStatus::Restarting => {}
1006            }
1007            if deadline.is_some_and(|deadline| tokio::time::Instant::now() >= deadline) {
1008                warn!("daemon {id}: gave up waiting for the in-flight oneshot to finish");
1009                // Reported as a failure rather than as "already running": the
1010                // batch start path only counts a result carrying an exit code
1011                // as failed, so anything else would let dependents start
1012                // against a task that never finished. 124 is the code a
1013                // readiness timeout already uses.
1014                return IpcResponse::DaemonFailedWithCode {
1015                    exit_code: Some(124),
1016                    resolved_ports: Vec::new(),
1017                };
1018            }
1019            time::sleep(interval).await;
1020        }
1021    }
1022
1023    /// Run a daemon once (single attempt).
1024    ///
1025    /// `stop_guard` is this daemon's stop lock, acquired by `run` before the
1026    /// already-running check. It is held through spawning until the Running
1027    /// state and PID are persisted (or an early failure returns), then dropped
1028    /// before the potentially unbounded readiness wait.
1029    pub(crate) async fn run_once(
1030        &self,
1031        opts: RunOptions,
1032        stop_guard: tokio::sync::OwnedMutexGuard<()>,
1033    ) -> Result<IpcResponse> {
1034        let id = &opts.id;
1035        // Checked under the daemon's stop lock, which `close()` waits out
1036        // after setting the flag: a start that took the lock first finishes
1037        // before `close()` lists the daemons to stop, and one that takes it
1038        // later stops here. Otherwise a daemon started during shutdown (an IPC
1039        // start, a cron run, a retry) would be left running once the
1040        // supervisor has gone.
1041        if self
1042            .shutting_down
1043            .load(std::sync::atomic::Ordering::Acquire)
1044        {
1045            info!("not starting daemon {id}: the supervisor is shutting down");
1046            return Ok(IpcResponse::DaemonFailed {
1047                error: "the supervisor is shutting down".to_string(),
1048            });
1049        }
1050        let original_cmd = opts.cmd.clone(); // Save original command for persistence
1051
1052        // Create channel for readiness notification if wait_ready is true
1053        let (ready_tx, ready_rx) = if opts.wait_ready {
1054            let (tx, rx) = oneshot::channel();
1055            (Some(tx), Some(rx))
1056        } else {
1057            (None, None)
1058        };
1059
1060        // Check port availability and apply auto-bump if configured
1061        let expected_ports = opts
1062            .port
1063            .as_ref()
1064            .map(|p| p.expect.clone())
1065            .unwrap_or_default();
1066        let (resolved_ports, effective_ready_port) = if !expected_ports.is_empty() {
1067            let port_cfg = opts.port.as_ref().unwrap();
1068            match check_ports_available(
1069                &expected_ports,
1070                port_cfg.auto_bump(),
1071                port_cfg.max_bump_attempts(),
1072            )
1073            .await
1074            {
1075                Ok(resolved) => {
1076                    let ready_port = if let Some(configured_port) =
1077                        opts.ready_port.as_ref().and_then(|p| p.as_port())
1078                    {
1079                        Some(resolve_configured_ready_port(
1080                            configured_port,
1081                            &expected_ports,
1082                            &resolved,
1083                        ))
1084                    } else if opts.ready_output.is_none()
1085                        && opts.ready_http.is_none()
1086                        && opts.ready_cmd.is_none()
1087                        && opts.ready_delay.is_none()
1088                    {
1089                        // No other ready check configured — use the first expected port as a
1090                        // TCP port readiness check so the daemon is considered ready once it
1091                        // starts listening.  Skip port 0 (ephemeral port request).
1092                        resolved.first().copied().filter(|&p| p != 0)
1093                    } else {
1094                        // Another ready check is configured (output/http/cmd/delay).
1095                        // Don't add an implicit TCP port check — it could race and fire
1096                        // before the daemon has produced any output.
1097                        None
1098                    };
1099                    info!("daemon {id}: ports {expected_ports:?} resolved to {resolved:?}");
1100                    (resolved, ready_port)
1101                }
1102                Err(e) => {
1103                    error!("daemon {id}: port check failed: {e}");
1104                    // Convert PortError to structured IPC response
1105                    if let Some(port_error) = e.downcast_ref::<PortError>() {
1106                        match port_error {
1107                            PortError::InUse {
1108                                port,
1109                                address,
1110                                process,
1111                                pid,
1112                            } => {
1113                                return Ok(IpcResponse::PortConflict {
1114                                    port: *port,
1115                                    process: process.clone(),
1116                                    pid: *pid,
1117                                    address: address.clone(),
1118                                });
1119                            }
1120                            PortError::NoAvailablePort {
1121                                start_port,
1122                                attempts,
1123                            } => {
1124                                return Ok(IpcResponse::NoAvailablePort {
1125                                    start_port: *start_port,
1126                                    attempts: *attempts,
1127                                });
1128                            }
1129                        }
1130                    }
1131                    return Ok(IpcResponse::DaemonFailed {
1132                        error: e.to_string(),
1133                    });
1134                }
1135            }
1136        } else {
1137            // When ready_port is set without expected_port, check that the port
1138            // is not already occupied.  If another process is listening on it,
1139            // the TCP readiness probe would immediately succeed and pitchfork
1140            // would falsely consider the daemon ready — routing proxy traffic to
1141            // the wrong process.
1142            if let Some(port) = opts.ready_port.as_ref().and_then(|p| p.as_port())
1143                && port > 0
1144                && let Some((address, pid, process)) = detect_port_conflict(port).await
1145            {
1146                return Ok(IpcResponse::PortConflict {
1147                    port,
1148                    process,
1149                    pid,
1150                    address: Some(address),
1151                });
1152            }
1153            (
1154                Vec::new(),
1155                opts.ready_port.as_ref().and_then(|p| p.as_port()),
1156            )
1157        };
1158
1159        // The program and arguments that start the daemon, before any mise
1160        // wrapping.
1161        // The program and arguments that start the daemon, before any mise
1162        // wrapping, and the script for the shell when `run` is a string.
1163        if let Some(error) = invalid_probe_program(
1164            id,
1165            "ready_cmd",
1166            opts.ready_cmd.as_ref().map(|c| &c.run),
1167        )
1168        .or_else(|| {
1169            invalid_probe_program(id, "health_cmd", opts.health_cmd.as_ref().map(|c| &c.run))
1170        }) {
1171            return Ok(IpcResponse::DaemonFailed { error });
1172        }
1173
1174        let (mut words, script) = if opts.no_shell {
1175            // The argv form of `run`: started as written, with no shell to
1176            // reinterpret quotes, `%`, `&` or anything else in the arguments.
1177            if let Some(error) = invalid_argv_program(id, &original_cmd) {
1178                return Ok(IpcResponse::DaemonFailed { error });
1179            }
1180            (original_cmd.clone(), None)
1181        } else {
1182            // Resolve the shell for this platform into program + args. The run
1183            // script is passed verbatim as the final argument, avoiding the lossy
1184            // split->join round-trip that previously mangled $VAR/glob expansion.
1185            let words = match resolve_shell() {
1186                Ok(parts) => parts,
1187                Err(error) => return Ok(IpcResponse::DaemonFailed { error }),
1188            };
1189            // Use the original run string verbatim; fall back to joining cmd for
1190            // ad-hoc commands (e.g. `pitchfork run -- cmd args`) that have no run string.
1191            // We don't prepend `exec` because it breaks compound commands (e.g. `exec a && b`
1192            // silently drops `b`). Users can add `exec` themselves in the run string.
1193            let script = opts
1194                .run
1195                .clone()
1196                .unwrap_or_else(|| shell_words::join(&original_cmd));
1197            (words, Some(script))
1198        };
1199
1200        let mise_bin = if opts.mise.unwrap_or(settings().general.mise) {
1201            let mise_bin = settings().resolve_daemon_mise_bin(opts.mise_bin.as_deref());
1202            if mise_bin.is_none() {
1203                warn!("daemon {id}: mise=true but mise binary not found, running without mise");
1204            }
1205            mise_bin
1206        } else {
1207            None
1208        };
1209        // A command left unrendered for `mise x` is not runnable without it: the
1210        // shell would see the template tags. Fail here, where the binary is
1211        // actually looked up, rather than guessing at render time.
1212        if mise_bin.is_none() && opts.deferred_template_context.is_some() {
1213            return Ok(IpcResponse::DaemonFailed {
1214                error: format!(
1215                    "daemon {id}: the command uses template variables only mise can render, but mise is not enabled or its binary was not found"
1216                ),
1217            });
1218        }
1219        // Started directly, the shell gets its script from `shell_script`.
1220        // Under mise it goes in as an ordinary argument: mise starts the shell
1221        // itself, re-quoting each argument, so the raw command line cmd.exe
1222        // needs for a script with `"` cannot reach it that way.
1223        let script = match &mise_bin {
1224            Some(mise_bin) => {
1225                info!(
1226                    "daemon {id}: wrapping command with mise ({})",
1227                    mise_bin.display()
1228                );
1229                if let Some(script) = script {
1230                    let script = match words.split_first() {
1231                        Some((program, options)) => {
1232                            powershell_script(program, options, &script).into_owned()
1233                        }
1234                        None => script,
1235                    };
1236                    words.push(script);
1237                }
1238                None
1239            }
1240            None => script,
1241        };
1242        let (program, args) = launch_command(words, mise_bin.as_deref());
1243        #[cfg(unix)]
1244        let run_identity = match resolve_effective_run_identity(opts.user.as_deref()) {
1245            Ok(identity) => identity,
1246            Err(e) => {
1247                return Ok(IpcResponse::DaemonFailed {
1248                    error: e.to_string(),
1249                });
1250            }
1251        };
1252        info!("run: spawning daemon {id} with {program} {args:?} {script:?}");
1253
1254        // Allocate PTY if configured
1255        #[cfg(unix)]
1256        let pty_pair = if opts.pty.unwrap_or(false) {
1257            match super::pty::openpty() {
1258                Ok(pair) => {
1259                    info!("daemon {id}: allocated PTY (pty = true)");
1260                    Some(pair)
1261                }
1262                Err(e) => {
1263                    warn!("daemon {id}: failed to allocate PTY, falling back to pipes: {e}");
1264                    None
1265                }
1266            }
1267        } else {
1268            None
1269        };
1270
1271        // Output reaches the monitoring task either from readers this process
1272        // owns or, when a sink owns the stream, relayed over IPC. The channel is
1273        // created here rather than in that task so it exists before the sink
1274        // starts: a daemon whose very first line matches its readiness pattern
1275        // would otherwise have the match reported with nowhere to deliver it.
1276        let (output_tx, output_rx) = tokio::sync::mpsc::channel::<super::OutputLine>(256);
1277        let mut output_relay = None;
1278
1279        // Set up out-of-process capture before building the command, so the
1280        // daemon can be handed the pipe's write end directly.
1281        let mut sink_pipe = None;
1282        let mut sink_writer = None;
1283        let mut sink_child = None;
1284        if super::log_sink::is_supported(&opts) {
1285            let log_format = opts
1286                .log_format
1287                .clone()
1288                .unwrap_or_else(|| settings().logs.log_format.clone());
1289            let watch_for = super::log_sink::WatchFor::from_opts(id, &opts);
1290            // The token ties this attempt's sink to this attempt's channel, so
1291            // a sink still draining a previous attempt cannot report into it.
1292            let relay_token = if watch_for.is_empty() {
1293                0
1294            } else {
1295                let relay = super::log_sink::OutputRelay::register(id, output_tx.clone());
1296                let token = relay.token();
1297                output_relay = Some(relay);
1298                token
1299            };
1300            match super::log_sink::SinkPipe::new(log_format, watch_for, relay_token) {
1301                Ok((pipe, writer)) => match pipe.start(id) {
1302                    Ok(child) => {
1303                        sink_child = Some(super::log_sink::PendingSink::new(child));
1304                        sink_pipe = Some(pipe);
1305                        sink_writer = Some(writer);
1306                    }
1307                    Err(e) => {
1308                        warn!("could not start log sink for {id}, capturing in-process: {e}");
1309                    }
1310                },
1311                Err(e) => {
1312                    // Fall back to in-process capture rather than refusing to
1313                    // start the daemon.
1314                    warn!("could not create log pipe for {id}, capturing in-process: {e}");
1315                }
1316            }
1317        }
1318
1319        let mut cmd = tokio::process::Command::new(&program);
1320
1321        #[cfg(unix)]
1322        if let Some(ref pair) = pty_pair {
1323            // PTY mode: connect both stdout and stderr to the slave PTY.
1324            // The child uses the slave for stdin/stdout/stderr, and we read
1325            // output from the master.
1326            let slave_file = std::fs::File::from(
1327                pair.slave
1328                    .try_clone()
1329                    .map_err(|e| miette::miette!("failed to dup slave PTY fd: {e}"))?,
1330            );
1331            cmd.stdin(std::process::Stdio::from(slave_file.try_clone().map_err(
1332                |e| miette::miette!("failed to clone slave PTY fd for stdin: {e}"),
1333            )?));
1334            cmd.stdout(std::process::Stdio::from(slave_file.try_clone().map_err(
1335                |e| miette::miette!("failed to clone slave PTY fd for stdout: {e}"),
1336            )?));
1337            cmd.stderr(std::process::Stdio::from(slave_file));
1338        } else if let Some(writer) = sink_writer.take() {
1339            // Capture belongs to a sibling sink process, so the daemon writes
1340            // to a pipe this process does not read. See supervisor::log_sink.
1341            let dup = writer
1342                .try_clone()
1343                .map_err(|e| miette::miette!("failed to dup log pipe for stderr: {e}"))?;
1344            cmd.stdout(std::process::Stdio::from(writer))
1345                .stderr(std::process::Stdio::from(dup));
1346        } else {
1347            cmd.stdout(std::process::Stdio::piped())
1348                .stderr(std::process::Stdio::piped());
1349        }
1350
1351        #[cfg(not(unix))]
1352        if let Some(writer) = sink_writer.take() {
1353            let dup = writer
1354                .try_clone()
1355                .map_err(|e| miette::miette!("failed to dup log pipe for stderr: {e}"))?;
1356            cmd.stdout(std::process::Stdio::from(writer))
1357                .stderr(std::process::Stdio::from(dup));
1358        } else {
1359            cmd.stdout(std::process::Stdio::piped())
1360                .stderr(std::process::Stdio::piped());
1361        }
1362
1363        match &script {
1364            Some(script) => cmd.shell_script(&program, &args, script),
1365            None => cmd.args(&args),
1366        };
1367        cmd.current_dir(&opts.dir).hide_console_window();
1368
1369        #[cfg(unix)]
1370        if pty_pair.is_none() {
1371            cmd.stdin(std::process::Stdio::null());
1372        }
1373
1374        #[cfg(not(unix))]
1375        cmd.stdin(std::process::Stdio::null());
1376
1377        // Before the runtime env, so a daemon's own `env` entries win.
1378        #[cfg(unix)]
1379        apply_identity_env(&mut cmd, &run_identity);
1380        apply_runtime_env(
1381            &mut cmd,
1382            id,
1383            opts.retry_count,
1384            opts.env.as_ref(),
1385            &resolved_ports,
1386        );
1387        // After the daemon's own env, since `mise x` needs exactly this value.
1388        if let Some(context) = &opts.deferred_template_context {
1389            cmd.env(crate::template::TEMPLATE_CONTEXT_ENV, context);
1390        }
1391
1392        // Inject proxy-related environment variables
1393        inject_proxy_env(&mut cmd, &daemon_proxy_host(&opts).await);
1394
1395        #[cfg(unix)]
1396        {
1397            let run_identity = run_identity.clone();
1398            let use_pty = pty_pair.is_some();
1399            unsafe {
1400                cmd.pre_exec(move || {
1401                    nix::unistd::setsid().map_err(nix_to_io_error)?;
1402
1403                    // When using a PTY, set the slave as the controlling terminal.
1404                    // The slave FD has already been dup'd onto stdin/stdout/stderr
1405                    // by tokio, so we can use stdin (fd 0) for TIOCSCTTY.
1406                    if use_pty {
1407                        let ret = libc::ioctl(0, libc::TIOCSCTTY as _, 0);
1408                        if ret < 0 {
1409                            // Non-fatal: the process can still run without
1410                            // a controlling terminal.
1411                            #[cfg(target_os = "linux")]
1412                            eprintln!(
1413                                "pitchfork: TIOCSCTTY failed: {}",
1414                                std::io::Error::last_os_error()
1415                            );
1416                        }
1417                    }
1418
1419                    apply_run_identity(&run_identity)?;
1420                    Ok(())
1421                });
1422            }
1423        }
1424
1425        // Timestamp the run so a failed start can wait for this attempt's output
1426        // specifically, rather than seeing an earlier attempt's.
1427        let spawn_time = chrono::Local::now();
1428        // A sink is already running at this point. Both bail-outs below have to
1429        // reap it explicitly: dropping the handle only reaps on a best-effort
1430        // basis, and run_once runs once per retry attempt, so a daemon that
1431        // consistently fails to spawn would otherwise accumulate sinks.
1432        // A failed spawn returns here; the sink is terminated by PendingSink.
1433        let mut child = cmd.spawn().into_diagnostic()?;
1434        // In a job of its own, so its whole process tree can be stopped; see
1435        // `win_job`.
1436        #[cfg(windows)]
1437        if let (Some(pid), Some(process)) = (child.id(), child.raw_handle()) {
1438            crate::win_job::contain_spawned(process as _, pid);
1439        }
1440        let spawned_pid = child.id();
1441        // Register the daemon as monitored BEFORE persisting the Running
1442        // state. The orphan reconciler treats any running, unmonitored PID
1443        // as an orphan; if the state became visible first, a concurrent
1444        // reconciliation pass could adopt — or under the kill policy,
1445        // terminate — a daemon that was just legitimately started. The RAII
1446        // guard unregisters on any early-error path below and is otherwise
1447        // handed to the monitoring task. Registered as soon as the process
1448        // exists, so a supervisor forced to exit while this start is still
1449        // on its way to recording it kills it (see `force_exit`).
1450        let monitored_guard =
1451            spawned_pid.map(|pid| super::adopt::MonitoredGuard::register(id.clone(), pid));
1452        // A process now exists, which is exactly what `last_cron_run` records.
1453        // Written here rather than from the watcher's view of the response
1454        // because that view cannot tell a start that failed before spawning
1455        // from one that spawned and exited before its PID could be read: a
1456        // port conflict, an unresolvable shell and an instant exit all report
1457        // `DaemonFailed`. Only the last of those ran, and a cron job that
1458        // fails that fast is precisely the one whose timing a user needs.
1459        // Ordered before the `Running` upsert below, which inherits it.
1460        //
1461        // Written synchronously rather than left to the background flush, for
1462        // the same reason `last_cron_triggered` is: a supervisor that dies in
1463        // the window between the two would come back with no record that this
1464        // run happened, and for a short-lived job that window is as long as
1465        // the job itself. A scheduled spawn is rare enough -- at most one per
1466        // `cron_check_interval` -- for the extra write to cost nothing.
1467        if opts.cron_started {
1468            let mut state_file = self.state_file.lock().await;
1469            if state_file.set_last_cron_run(id, spawn_time)
1470                && let Err(e) = state_file.write()
1471            {
1472                error!("failed to persist last_cron_run for daemon {id}: {e}");
1473            }
1474        }
1475        let (pid, monitored_guard) = match (spawned_pid, monitored_guard) {
1476            (Some(p), Some(guard)) => (p, guard),
1477            _ => {
1478                warn!("Daemon {id} exited before PID could be captured");
1479                // Unlike a daemon that never started, this one ran and may have
1480                // said why it gave up, and its output is the only diagnosis
1481                // available. Its write end is already closed, so the sink is on
1482                // its way to end of file: let it finish writing before reporting,
1483                // then reap whatever is left of it.
1484                if sink_child.is_some() {
1485                    super::log_sink::wait_for_output(id, spawn_time, SINK_OUTPUT_TIMEOUT).await;
1486                }
1487                return Ok(IpcResponse::DaemonFailed {
1488                    error: "Process exited immediately".to_string(),
1489                });
1490            }
1491        };
1492        info!("started daemon {id} with pid {pid}");
1493        PROCS.refresh_pids(&[pid]);
1494        let monitor_token = monitored_guard.token();
1495
1496        // Hand the retained read end to a sink and keep one running for as long
1497        // as this daemon is monitored.
1498        let using_sink = sink_pipe.is_some();
1499        // Take the sink out of the guard only once there is a pipe to supervise
1500        // it with, so it is never left running unsupervised.
1501        if let Some(pipe) = sink_pipe.take()
1502            && let Some(child) = sink_child.as_mut().and_then(|pending| pending.take())
1503        {
1504            pipe.supervise(id.clone(), monitor_token, child);
1505        }
1506        // The attempt's actual resolved ports, captured before the upsert
1507        // moves them into state. Hooks, readiness probes, and the failure
1508        // response must reflect this attempt: the state merge keeps the
1509        // existing resolved_port when an update is empty, so a no-port
1510        // attempt would otherwise inherit a previous run's stale ports
1511        // through the upserted record.
1512        let attempt_resolved_ports = resolved_ports.clone();
1513        let daemon = self
1514            .upsert_daemon(
1515                UpsertDaemonOpts::from_run_options(&opts, DaemonStatus::Running)
1516                    .set(|o| {
1517                        o.pid = Some(pid);
1518                        o.cmd = Some(original_cmd);
1519                        o.ready_port = effective_ready_port.map(|p| ReadyPort {
1520                            port: Some(p),
1521                            template: None,
1522                            timeout: opts.ready_port.as_ref().and_then(|rp| rp.timeout),
1523                        });
1524                        o.port = crate::config_types::PortConfig::from_parts(
1525                            expected_ports,
1526                            opts.port.as_ref().map(|p| p.bump).unwrap_or_default(),
1527                        );
1528                        o.resolved_port = Some(resolved_ports);
1529                    })
1530                    .build(),
1531            )
1532            .await?;
1533
1534        // Running state and PID are now persisted: concurrent run/stop calls
1535        // observe a running daemon and behave correctly, so release the stop
1536        // lock rather than holding it through the readiness wait below, which
1537        // can take arbitrarily long.
1538        drop(stop_guard);
1539
1540        let id_clone = id.clone();
1541        // A oneshot is ready only when its process exits 0, so no readiness
1542        // check may run alongside it — one that fired first would report the
1543        // task ready before it had done its work, and would suppress the
1544        // completion notification entirely. Config load rejects explicit
1545        // `ready_*` fields and the client clears CLI overrides, but the
1546        // implicit port check is derived here from `port.expect`, so the
1547        // suppression has to happen here rather than being trusted to callers.
1548        let ready_delay = (!opts.oneshot).then_some(opts.ready_delay).flatten();
1549        let ready_output = (!opts.oneshot).then(|| opts.ready_output.clone()).flatten();
1550        let ready_http = (!opts.oneshot).then(|| opts.ready_http.clone()).flatten();
1551        let ready_port = (!opts.oneshot).then_some(effective_ready_port).flatten();
1552        let implicit_ready_port = ready_port.map(|p| ReadyPort {
1553            port: Some(p),
1554            template: None,
1555            timeout: None,
1556        });
1557        let ready_port_config = (!opts.oneshot)
1558            .then(|| opts.ready_port.clone())
1559            .flatten()
1560            .or(implicit_ready_port);
1561        let ready_cmd = (!opts.oneshot).then(|| opts.ready_cmd.clone()).flatten();
1562        let daemon_dir = opts.dir.0.clone();
1563        let hook_dirs =
1564            super::hooks::HookDirs::new(daemon_dir.clone(), opts.watch_base_dir.clone());
1565        let hook_retry_count = opts.retry_count;
1566        let hook_retry = opts.retry;
1567        let hook_daemon_env = opts.env.clone();
1568        // Ports of THIS attempt, snapshotted before the monitor starts: a retry
1569        // or restart may replace state.resolved_port before a hook task runs.
1570        // Sourced from the attempt's local value, not the upserted record —
1571        // the state merge inherits stale ports for a no-port attempt.
1572        let hook_resolved_ports = attempt_resolved_ports.clone();
1573        let readiness_daemon_env = opts.env.clone();
1574        let readiness_resolved_ports = attempt_resolved_ports.clone();
1575        let on_output_hook = opts.on_output_hook.clone();
1576        // Whether this daemon has any port-related config — used to skip the
1577        // active_port detection task for daemons that never bind a port (e.g. `sleep 60`).
1578        // When the proxy is enabled, only detect active_port for daemons that are
1579        // actually referenced by a registered slug, rather than blanket-polling every
1580        // daemon (which wastes ~7.5 s of listeners::get_all() calls per port-less daemon).
1581        let has_port_config = opts.port.as_ref().is_some_and(|p| !p.expect.is_empty())
1582            || (settings().proxy.enable && is_daemon_slug_target(id));
1583        // When the ready_port check succeeds on the first resolved port we can
1584        // set active_port directly instead
1585        // of spawning detect_and_store_active_port (which relies on
1586        // listeners::get_all() + process-tree traversal and is unreliable on
1587        // Windows where Git Bash PID mapping can break descendant lookups).
1588        let daemon_pid = pid;
1589
1590        // Prepare output readers before spawning the monitoring task.
1591        // In PTY mode, we read from the PTY master FD.
1592        // In pipe mode, we read from separate stdout/stderr pipes.
1593        #[cfg(unix)]
1594        let pty_reader = pty_pair.map(|p| {
1595            tokio::io::BufReader::new(tokio::fs::File::from_std(std::fs::File::from(p.master)))
1596        });
1597        #[cfg(not(unix))]
1598        let pty_reader: Option<tokio::io::BufReader<tokio::fs::File>> = None;
1599        let stdout_reader = if pty_reader.is_none() {
1600            child.stdout.take().map(tokio::io::BufReader::new)
1601        } else {
1602            None
1603        };
1604        let stderr_reader = if pty_reader.is_none() {
1605            child.stderr.take().map(tokio::io::BufReader::new)
1606        } else {
1607            None
1608        };
1609
1610        if !using_sink
1611            && pty_reader.is_none()
1612            && (stdout_reader.is_none() || stderr_reader.is_none())
1613        {
1614            error!("Failed to capture stdout/stderr for daemon {id}");
1615        }
1616
1617        tokio::spawn(async move {
1618            let id = id_clone;
1619            // Registered before the Running upsert above; unregisters when
1620            // this monitoring task ends.
1621            let _monitored_guard = monitored_guard;
1622            // Likewise for sink-relayed output: dropping this stops the
1623            // supervisor delivering into a channel nobody is reading. Dropped
1624            // explicitly once the daemon exits, before the drain below.
1625            let output_relay = output_relay;
1626
1627            // Merge all output sources (PTY master OR stdout+stderr, or a
1628            // sink's IPC reports) into a single channel.
1629            let mut output_rx = output_rx;
1630
1631            if let Some(reader) = pty_reader {
1632                // PTY mode: single merged stream from the master.
1633                // output_tx is moved into the spawn; when the reader ends the
1634                // channel closes automatically.
1635                tokio::spawn(forward_output_lines(reader, output_tx));
1636            } else {
1637                // Pipe mode: stdout and stderr are merged into the same channel.
1638                // Both `ready_output` and `on_output_hook` patterns match against
1639                // lines from either stream, which is the expected behavior (a
1640                // "server ready" message may appear on stderr in some tools).
1641                if let Some(stdout) = stdout_reader {
1642                    tokio::spawn(forward_output_lines(stdout, output_tx.clone()));
1643                }
1644                if let Some(stderr) = stderr_reader {
1645                    tokio::spawn(forward_output_lines(stderr, output_tx.clone()));
1646                }
1647                // Drop the last sender so the channel closes when all readers
1648                // finish. The relay holds its own clone, so a sink's reports
1649                // still have somewhere to go after these end.
1650                drop(output_tx);
1651            }
1652            let log_store = Arc::clone(&LOG_STORE);
1653            let log_format = opts
1654                .log_format
1655                .clone()
1656                .unwrap_or_else(|| crate::settings::settings().logs.log_format.clone());
1657            let parse_line = move |line: &str| crate::log_parse::parse(line, &log_format);
1658
1659            const LOG_BATCH_SIZE: usize = 100;
1660            const LOG_FLUSH_INTERVAL: Duration = Duration::from_millis(100);
1661            let mut log_buffer: Vec<crate::log_parse::ParsedLog> =
1662                Vec::with_capacity(LOG_BATCH_SIZE);
1663            let mut log_flush_interval = tokio::time::interval(LOG_FLUSH_INTERVAL);
1664            log_flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1665
1666            let flush_logs =
1667                |buffer: &mut Vec<crate::log_parse::ParsedLog>| -> Option<tokio::task::JoinHandle<()>> {
1668                    if buffer.is_empty() {
1669                        return None;
1670                }
1671                let store = Arc::clone(&log_store);
1672                let id = id.clone();
1673                let batch = std::mem::take(buffer);
1674                Some(tokio::task::spawn_blocking(move || {
1675                    if let Err(e) = store.append_structured_batch(&id, &batch) {
1676                        error!("Failed to write batch to log for daemon {id}: {e}");
1677                    }
1678                }))
1679            };
1680
1681            // SQLite WAL mode provides automatic durability; no explicit flush needed.
1682
1683            // Setup readiness checking
1684            let mut ready_notified = false;
1685            // Set when a oneshot's process exits 0. Its readiness *is* its
1686            // completion, so the notification is held back until the
1687            // `completed` state has been persisted — a caller that returns
1688            // from `pitchfork start` must not still see the daemon running.
1689            let mut oneshot_completion_pending = false;
1690            let mut ready_tx = ready_tx;
1691            let ready_pattern = ready_output
1692                .as_ref()
1693                .and_then(|o| get_or_compile_regex(&o.pattern));
1694            // Track whether we've already spawned the active_port detection task
1695            let mut active_port_spawned = false;
1696
1697            // Validate on_output config early; discard the hook on any error so
1698            // a bad regex does not silently fall through to the (None, None) => true
1699            // match arm and fire on every line.
1700            let on_output_hook = match on_output_hook {
1701                Some(ref hook) => match hook.validate(id.name()) {
1702                    Ok(()) => on_output_hook,
1703                    Err(e) => {
1704                        error!("{e}");
1705                        None
1706                    }
1707                },
1708                None => None,
1709            };
1710
1711            // Compile the regex pattern after validation so we only attempt this
1712            // when the hook is known-good (validate() already checked the syntax).
1713            let on_output_pattern: Option<regex::Regex> = on_output_hook
1714                .as_ref()
1715                .and_then(|h| h.regex.as_deref().and_then(get_or_compile_regex));
1716            let on_output_debounce = on_output_hook
1717                .as_ref()
1718                .map(|h| h.debounce_duration())
1719                .unwrap_or(Duration::from_millis(1000));
1720            // Last time the on_output hook fired; None means it has never fired.
1721            let mut on_output_last_fired: Option<std::time::Instant> = None;
1722
1723            let mut delay_timer =
1724                ready_delay.map(|secs| Box::pin(time::sleep(Duration::from_secs(secs))));
1725
1726            // Track exhaustion of timed checks
1727            let mut http_exhausted = false;
1728            let mut cmd_exhausted = false;
1729            let mut port_exhausted = false;
1730            let mut output_exhausted = false;
1731
1732            // Get settings for intervals
1733            let s = settings();
1734            let ready_check_interval = s.supervisor_ready_check_interval();
1735            let http_client_timeout = s.supervisor_http_client_timeout();
1736
1737            // Setup output readiness check deadline
1738            let mut output_deadline = ready_output
1739                .as_ref()
1740                .and_then(|o| o.timeout)
1741                .map(|d| Box::pin(time::sleep(d)));
1742
1743            // Setup HTTP readiness check interval and deadline
1744            let mut http_check_interval = ready_http
1745                .as_ref()
1746                .map(|_| tokio::time::interval(ready_check_interval));
1747            let mut http_deadline = ready_http
1748                .as_ref()
1749                .and_then(|h| h.timeout)
1750                .map(|d| Box::pin(time::sleep(d)));
1751            let http_client = if ready_http.is_some() {
1752                Some(crate::supervisor::health::supervisor_http_client().await)
1753            } else {
1754                None
1755            };
1756
1757            // Setup TCP port readiness check interval and deadline
1758            let mut port_check_interval =
1759                ready_port.map(|_| tokio::time::interval(ready_check_interval));
1760            let mut port_deadline = ready_port_config
1761                .as_ref()
1762                .and_then(|p| p.timeout)
1763                .map(|d| Box::pin(time::sleep(d)));
1764
1765            // Setup command readiness check state. Probes are spawned one at a time;
1766            // a non-zero result triggers a respawn delay, and a timeout stops the probe.
1767            let mut cmd_probe: Option<CmdProbe> = None;
1768            let mut cmd_respawn_delay: Option<_> = None;
1769            let mut cmd_deadline = ready_cmd
1770                .as_ref()
1771                .and_then(|c| c.timeout)
1772                .map(|d| Box::pin(time::sleep(d)));
1773            if let Some(ref cmd) = ready_cmd {
1774                cmd_probe = Some(spawn_cmd_probe(
1775                    &id,
1776                    &cmd.run,
1777                    daemon_dir.as_path(),
1778                    hook_retry_count,
1779                    readiness_daemon_env.as_ref(),
1780                    &readiness_resolved_ports,
1781                ));
1782            }
1783
1784            // Use a channel to communicate process exit status
1785            let (exit_tx, mut exit_rx) =
1786                tokio::sync::mpsc::channel::<std::io::Result<std::process::ExitStatus>>(1);
1787
1788            // Spawn a task to wait for process exit
1789            let child_pid = child.id().unwrap_or(0);
1790            tokio::spawn(async move {
1791                let result = child.wait().await;
1792                // On non-Linux Unix (e.g. macOS) the zombie reaper may win the
1793                // race and consume the exit status via waitpid(None, WNOHANG)
1794                // before Tokio's child.wait() gets to it. When that happens,
1795                // Tokio returns an ECHILD io::Error. We recover by checking
1796                // REAPED_STATUSES for the stashed exit code.
1797                //
1798                // On Linux this is unnecessary because the reaper uses
1799                // waitid(WNOWAIT) to peek before reaping, which avoids the
1800                // race entirely.
1801                #[cfg(all(unix, not(target_os = "linux")))]
1802                let result = match &result {
1803                    Err(e) if e.raw_os_error() == Some(nix::libc::ECHILD) => {
1804                        if let Some(code) = super::REAPED_STATUSES.lock().await.remove(&child_pid) {
1805                            warn!(
1806                                "daemon pid {child_pid} wait() got ECHILD; \
1807                                 recovered exit code {code} from zombie reaper"
1808                            );
1809                            // Synthesize an ExitStatus from the stashed code.
1810                            // On Unix we can use `ExitStatus::from_raw()` with
1811                            // a wait-style status word (code << 8 for normal
1812                            // exit, or raw signal number for signal death).
1813                            use std::os::unix::process::ExitStatusExt;
1814                            if code >= 0 {
1815                                Ok(std::process::ExitStatus::from_raw(code << 8))
1816                            } else {
1817                                // Negative code means killed by signal (-sig)
1818                                Ok(std::process::ExitStatus::from_raw((-code) & 0x7f))
1819                            }
1820                        } else {
1821                            warn!(
1822                                "daemon pid {child_pid} wait() got ECHILD but no \
1823                                 stashed status found; reporting as error"
1824                            );
1825                            result
1826                        }
1827                    }
1828                    _ => result,
1829                };
1830                debug!("daemon pid {child_pid} wait() completed with result: {result:?}");
1831                let _ = exit_tx.send(result).await;
1832            });
1833
1834            #[allow(unused_assignments)]
1835            // Initial None is a safety net; loop only exits via exit_rx.recv() which sets it
1836            let mut exit_status = None;
1837
1838            // If there is no ready check of any kind and no delay, the daemon is
1839            // considered immediately ready and the active_port detection task would
1840            // never be triggered inside the select loop.  Kick it off right away so
1841            // that daemons without any readiness configuration still get their
1842            // active_port populated (needed for proxy routing).
1843            if has_port_config
1844                && ready_pattern.is_none()
1845                && ready_http.is_none()
1846                && ready_port.is_none()
1847                && ready_cmd.is_none()
1848                && delay_timer.is_none()
1849            {
1850                active_port_spawned = true;
1851                detect_and_store_active_port(id.clone(), daemon_pid);
1852            }
1853
1854            // Set when readiness checks exhaust. The group kill runs as a
1855            // separate task so this loop can exit and the post-loop drain
1856            // keeps consuming output — children logging during SIGTERM
1857            // cleanup would otherwise block on a full pipe and never exit.
1858            // The ready failure is only sent once the kill task completes,
1859            // so the retry loop cannot respawn into the dying group.
1860            let mut ready_fail_kill: Option<tokio::task::JoinHandle<()>> = None;
1861
1862            loop {
1863                // biased: evaluate in exit → output → delay order so that
1864                // process exit pre-empts both buffered output and the delay
1865                // timer, preventing a dead daemon from being marked ready.
1866                select! {
1867                    biased;
1868                    Some(result) = exit_rx.recv() => {
1869                        // Process exited - save exit status and notify if not ready yet
1870                        exit_status = Some(result);
1871                        debug!("daemon {id} process exited, exit_status: {exit_status:?}");
1872                        if !ready_notified {
1873                            // Check if process exited successfully
1874                            let is_success = exit_status.as_ref()
1875                                .and_then(|r| r.as_ref().ok())
1876                                .map(|s| s.success())
1877                                .unwrap_or(false);
1878                            if is_success && opts.oneshot {
1879                                debug!("daemon {id} completed, deferring success notification until the completed state is persisted");
1880                                oneshot_completion_pending = true;
1881                            } else if let Some(tx) = ready_tx.take() {
1882                                if is_success {
1883                                    debug!("daemon {id} exited successfully before ready check, sending success notification");
1884                                    let _ = tx.send(Ok(()));
1885                                } else {
1886                                    let exit_code = exit_status.as_ref()
1887                                        .and_then(|r| r.as_ref().ok())
1888                                        .and_then(|s| s.code());
1889                                    debug!("daemon {id} exited with failure before ready check, sending failure notification with exit_code: {exit_code:?}");
1890                                    let _ = tx.send(Err(exit_code));
1891                                }
1892                            }
1893                        } else {
1894                            debug!("daemon {id} was already marked ready, not sending notification");
1895                        }
1896                        break;
1897                    },
1898                    Some(super::OutputLine { text: line, source }) = output_rx.recv() => {
1899                        // A line relayed by a sink is already in the store —
1900                        // the sink wrote and flushed it before reporting it —
1901                        // so it arrives here only to be acted on.
1902                        if matches!(source, super::OutputSource::Local) {
1903                            let parsed = parse_line(&line);
1904                            log_buffer.push(parsed);
1905                            if log_buffer.len() >= LOG_BATCH_SIZE {
1906                                let _ = flush_logs(&mut log_buffer);
1907                            }
1908                        }
1909                        trace!("output: {id} {line}");
1910
1911                        // Strip ANSI for pattern matching so user-written patterns
1912                        // work regardless of whether the process emits color codes.
1913                        let line_clean = console::strip_ansi_codes(&line).to_string();
1914
1915                        // Check if output matches ready pattern
1916                        if !ready_notified
1917                            && !output_exhausted
1918                            && let Some(ref pattern) = ready_pattern
1919                            && pattern.is_match(&line_clean)
1920                        {
1921                            // Flush buffered logs synchronously before signalling
1922                            // readiness, so collect_startup_logs sees the line
1923                            // that triggered the match (and any co-buffered lines)
1924                            // in SQLite.
1925                            if let Some(handle) = flush_logs(&mut log_buffer) {
1926                                let _ = handle.await;
1927                            }
1928                            info!("daemon {id} ready: output matched pattern");
1929                            ready_notified = true;
1930                            if let Some(tx) = ready_tx.take() {
1931                                let _ = tx.send(Ok(()));
1932                            }
1933                            fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
1934                            stop_cmd_probe_state(&mut cmd_probe);
1935                            http_deadline = None;
1936                            cmd_deadline = None;
1937                            port_deadline = None;
1938                            output_deadline = None;
1939                            if !active_port_spawned && has_port_config {
1940                                active_port_spawned = true;
1941                                detect_and_store_active_port(id.clone(), daemon_pid);
1942                            }
1943                        }
1944
1945                        // Check on_output hook. A sink has already applied the
1946                        // filter, and says so per line: a line reported only
1947                        // because it announced readiness must not fire a hook
1948                        // that filters for something else.
1949                        if let Some(ref hook) = on_output_hook {
1950                            let matched = match source {
1951                                super::OutputSource::Sink { fires_hook } => fires_hook,
1952                                super::OutputSource::Local => match (&hook.filter, &on_output_pattern) {
1953                                    (Some(substr), _) => line_clean.contains(substr.as_str()),
1954                                    (None, Some(re)) => re.is_match(&line_clean),
1955                                    (None, None) => true,
1956                                },
1957                            };
1958                            if matched {
1959                                // The debounce is applied here as well as in the
1960                                // sink. A replacement sink starts with a fresh
1961                                // clock, and would otherwise let the hook fire
1962                                // twice inside one configured window.
1963                                let now = std::time::Instant::now();
1964                                let elapsed = on_output_last_fired.map(|t| now.duration_since(t));
1965                                if elapsed.is_none_or(|e| e >= on_output_debounce) {
1966                                    on_output_last_fired = Some(now);
1967                                    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;
1968                                }
1969                            }
1970                        }
1971                        // Yield briefly so that the output readiness deadline can be
1972                        // evaluated even when output is produced continuously.
1973                        tokio::task::yield_now().await;
1974                    }
1975                    _ = async {
1976                        if let Some(ref mut deadline) = http_deadline {
1977                            deadline.await;
1978                        } else {
1979                            std::future::pending::<()>().await;
1980                        }
1981                    }, if !ready_notified && ready_http.is_some() => {
1982                        http_exhausted = true;
1983                        http_deadline = None;
1984                        http_check_interval = None;
1985                        warn!("daemon {id}: HTTP readiness check timed out");
1986                        let any_remaining = any_ready_check_remaining(
1987                            ready_output.as_ref(),
1988                            output_exhausted,
1989                            ready_port_config.as_ref(),
1990                            port_exhausted,
1991                            ready_http.as_ref(),
1992                            http_exhausted,
1993                            ready_cmd.as_ref(),
1994                            cmd_exhausted,
1995                        );
1996                        if !any_remaining {
1997                            error!("daemon {id}: all readiness checks exhausted, failing");
1998                            stop_cmd_probe_state(&mut cmd_probe);
1999                            ready_fail_kill = Some(spawn_ready_fail_kill(
2000                                id.clone(),
2001                                daemon_pid,
2002                                opts.stop_signal.unwrap_or_default(),
2003                            ));
2004                            break;
2005                        }
2006                    }
2007                    _ = async {
2008                        if let Some(ref mut deadline) = output_deadline {
2009                            deadline.await;
2010                        } else {
2011                            std::future::pending::<()>().await;
2012                        }
2013                    }, if !ready_notified && ready_output.is_some() => {
2014                        output_exhausted = true;
2015                        output_deadline = None;
2016                        warn!("daemon {id}: output readiness check timed out");
2017                        let any_remaining = any_ready_check_remaining(
2018                            ready_output.as_ref(),
2019                            output_exhausted,
2020                            ready_port_config.as_ref(),
2021                            port_exhausted,
2022                            ready_http.as_ref(),
2023                            http_exhausted,
2024                            ready_cmd.as_ref(),
2025                            cmd_exhausted,
2026                        );
2027                        if !any_remaining {
2028                            error!("daemon {id}: all readiness checks exhausted, failing");
2029                            stop_cmd_probe_state(&mut cmd_probe);
2030                            ready_fail_kill = Some(spawn_ready_fail_kill(
2031                                id.clone(),
2032                                daemon_pid,
2033                                opts.stop_signal.unwrap_or_default(),
2034                            ));
2035                            break;
2036                        }
2037                    }
2038                    _ = async {
2039                        if let Some(ref mut interval) = http_check_interval {
2040                            interval.tick().await;
2041                        } else {
2042                            std::future::pending::<()>().await;
2043                        }
2044                    }, if !ready_notified && ready_http.is_some() && !http_exhausted => {
2045                        if let (Some(http), Some(client)) = (&ready_http, &http_client) {
2046                            match client.get(&http.url).timeout(http_client_timeout).send().await {
2047                                Ok(response) if http.accepts_status(response.status().as_u16()) => {
2048                                    info!("daemon {id} ready: HTTP check passed (status {})", response.status());
2049                                    ready_notified = true;
2050                                    if let Some(tx) = ready_tx.take() {
2051                                        let _ = tx.send(Ok(()));
2052                                    }
2053                                    fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
2054                                    http_check_interval = None;
2055                                    http_deadline = None;
2056                                    stop_cmd_probe_state(&mut cmd_probe);
2057                                    cmd_deadline = None;
2058                                    port_deadline = None;
2059                                    output_deadline = None;
2060                                    if !active_port_spawned && has_port_config {
2061                                        active_port_spawned = true;
2062                                        detect_and_store_active_port(id.clone(), daemon_pid);
2063                                    }
2064                                }
2065                                Ok(response) => {
2066                                    trace!("daemon {id} HTTP check: status {} (not ready)", response.status());
2067                                }
2068                                Err(e) => {
2069                                    trace!("daemon {id} HTTP check failed: {e}");
2070                                }
2071                            }
2072                        }
2073                    }
2074                    _ = async {
2075                        if let Some(ref mut deadline) = port_deadline {
2076                            deadline.await;
2077                        } else {
2078                            std::future::pending::<()>().await;
2079                        }
2080                    }, if !ready_notified && ready_port.is_some() => {
2081                        port_exhausted = true;
2082                        port_deadline = None;
2083                        port_check_interval = None;
2084                        warn!("daemon {id}: TCP port readiness check timed out");
2085                        let any_remaining = any_ready_check_remaining(
2086                            ready_output.as_ref(),
2087                            output_exhausted,
2088                            ready_port_config.as_ref(),
2089                            port_exhausted,
2090                            ready_http.as_ref(),
2091                            http_exhausted,
2092                            ready_cmd.as_ref(),
2093                            cmd_exhausted,
2094                        );
2095                        if !any_remaining {
2096                            error!("daemon {id}: all readiness checks exhausted, failing");
2097                            stop_cmd_probe_state(&mut cmd_probe);
2098                            ready_fail_kill = Some(spawn_ready_fail_kill(
2099                                id.clone(),
2100                                daemon_pid,
2101                                opts.stop_signal.unwrap_or_default(),
2102                            ));
2103                            break;
2104                        }
2105                    }
2106                    _ = async {
2107                        if let Some(ref mut interval) = port_check_interval {
2108                            interval.tick().await;
2109                        } else {
2110                            std::future::pending::<()>().await;
2111                        }
2112                    }, if !ready_notified && ready_port.is_some() && !port_exhausted => {
2113                        if let Some(port) = ready_port {
2114                            match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
2115                                Ok(_) => {
2116                                    info!("daemon {id} ready: TCP port {port} is listening");
2117                                    ready_notified = true;
2118                                    if let Some(tx) = ready_tx.take() {
2119                                        let _ = tx.send(Ok(()));
2120                                    }
2121                                    fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
2122                                    // Stop checking once ready
2123                                    port_check_interval = None;
2124                                    port_deadline = None;
2125                                    stop_cmd_probe_state(&mut cmd_probe);
2126                                    http_deadline = None;
2127                                    cmd_deadline = None;
2128                                    output_deadline = None;
2129                                    if !active_port_spawned && has_port_config {
2130                                        active_port_spawned = true;
2131                                        // ready_port check just TCP-connected to this
2132                                        // port, so it is definitely listening. If it
2133                                        // matches the first resolved port, write
2134                                        // active_port directly instead of spawning
2135                                        // detect_and_store_active_port, which sleeps
2136                                        // 500 ms then relies on listeners::get_all()
2137                                        // + process-tree traversal — unreliable on
2138                                        // Windows where Git Bash PID mapping can
2139                                        // break descendant lookups.
2140                                        if let Some(active_port) = active_port_from_ready_port(
2141                                            port,
2142                                            &readiness_resolved_ports,
2143                                        ) {
2144                                            let mut state_file =
2145                                                SUPERVISOR.state_file.lock().await;
2146                                            if let Some(d) = state_file.daemons.get(&id)
2147                                                && d.pid == Some(daemon_pid)
2148                                            {
2149                                                state_file.set_active_port(&id, active_port);
2150                                            }
2151                                        } else {
2152                                            detect_and_store_active_port(
2153                                                id.clone(),
2154                                                daemon_pid,
2155                                            );
2156                                        }
2157                                    }
2158                                }
2159                                Err(_) => {
2160                                    trace!("daemon {id} port check: port {port} not listening yet");
2161                                }
2162                            }
2163                        }
2164                    }
2165                    _ = async {
2166                        if let Some(ref mut delay) = cmd_respawn_delay {
2167                            delay.await;
2168                        } else {
2169                            std::future::pending::<()>().await;
2170                        }
2171                    }, if !ready_notified && ready_cmd.is_some() && !cmd_exhausted && cmd_probe.is_none() => {
2172                        if let Some(ref cmd) = ready_cmd {
2173                            cmd_probe = Some(spawn_cmd_probe(
2174                                &id,
2175                                &cmd.run,
2176                                daemon_dir.as_path(),
2177                                hook_retry_count,
2178                                readiness_daemon_env.as_ref(),
2179                                &readiness_resolved_ports,
2180                            ));
2181                        }
2182                        cmd_respawn_delay = None;
2183                    }
2184                    result = async {
2185                        if let Some(probe) = cmd_probe.as_mut() {
2186                            std::pin::Pin::new(&mut probe.result_rx).await
2187                        } else {
2188                            std::future::pending::<Result<Result<std::process::ExitStatus, std::io::Error>, tokio::sync::oneshot::error::RecvError>>().await
2189                        }
2190                    }, if !ready_notified && ready_cmd.is_some() && !cmd_exhausted => {
2191                        // The probe task has finished; remove the handle so it is not
2192                        // cancelled or reused. This must happen only after this branch
2193                        // actually wins the select, not while constructing the future.
2194                        let _ = cmd_probe.take();
2195                        match result {
2196                            Ok(Ok(status)) if status.success() => {
2197                                info!("daemon {id} ready: readiness command succeeded");
2198                                ready_notified = true;
2199                                if let Some(tx) = ready_tx.take() {
2200                                    let _ = tx.send(Ok(()));
2201                                }
2202                                fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
2203                                cmd_respawn_delay = None;
2204                                cmd_deadline = None;
2205                                http_deadline = None;
2206                                port_deadline = None;
2207                                output_deadline = None;
2208                                if !active_port_spawned && has_port_config {
2209                                    active_port_spawned = true;
2210                                    detect_and_store_active_port(id.clone(), daemon_pid);
2211                                }
2212                            }
2213                            Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {
2214                                trace!("daemon {id} cmd check: command not ready, will respawn");
2215                                cmd_respawn_delay = Some(Box::pin(time::sleep(ready_check_interval)));
2216                            }
2217                        }
2218                    }
2219                    _ = async {
2220                        if let Some(ref mut deadline) = cmd_deadline {
2221                            deadline.await;
2222                        } else {
2223                            std::future::pending::<()>().await;
2224                        }
2225                    }, if !ready_notified && ready_cmd.is_some() => {
2226                        cmd_exhausted = true;
2227                        cmd_deadline = None;
2228                        stop_cmd_probe_state(&mut cmd_probe);
2229                        cmd_respawn_delay = None;
2230                        warn!("daemon {id}: command readiness check timed out");
2231                        let any_remaining = any_ready_check_remaining(
2232                            ready_output.as_ref(),
2233                            output_exhausted,
2234                            ready_port_config.as_ref(),
2235                            port_exhausted,
2236                            ready_http.as_ref(),
2237                            http_exhausted,
2238                            ready_cmd.as_ref(),
2239                            cmd_exhausted,
2240                        );
2241                        if !any_remaining {
2242                            error!("daemon {id}: all readiness checks exhausted, failing");
2243                            ready_fail_kill = Some(spawn_ready_fail_kill(
2244                                id.clone(),
2245                                daemon_pid,
2246                                opts.stop_signal.unwrap_or_default(),
2247                            ));
2248                            break;
2249                        }
2250                    }
2251                    _ = async {
2252                        if let Some(ref mut timer) = delay_timer {
2253                            timer.await;
2254                        } else {
2255                            std::future::pending::<()>().await;
2256                        }
2257                    } => {
2258                        let has_other_ready_check = ready_pattern.is_some()
2259                            || ready_http.is_some()
2260                            || ready_port.is_some()
2261                            || ready_cmd.is_some();
2262                        let delay_is_only_readiness = !ready_notified && !has_other_ready_check;
2263                        let process_exited = exit_status.is_some();
2264                        let process_running = if delay_is_only_readiness && !process_exited {
2265                            // Force-refresh sysinfo for this PID before checking.
2266                            // On Windows, the cached process list may be stale.
2267                            PROCS.refresh_pids(&[daemon_pid]);
2268                            PROCS.is_running(daemon_pid)
2269                        } else {
2270                            false
2271                        };
2272
2273                        if delay_readiness_succeeded(
2274                            ready_notified,
2275                            has_other_ready_check,
2276                            process_exited,
2277                            process_running,
2278                        ) {
2279                            info!("daemon {id} ready: delay elapsed");
2280                            ready_notified = true;
2281                            if let Some(tx) = ready_tx.take() {
2282                                let _ = tx.send(Ok(()));
2283                            }
2284                            fire_hook(HookType::OnReady, id.clone(), hook_dirs.clone(), hook_retry_count, hook_daemon_env.clone(), hook_resolved_ports.clone(), vec![]).await;
2285                            if !active_port_spawned && has_port_config {
2286                                active_port_spawned = true;
2287                                detect_and_store_active_port(id.clone(), daemon_pid);
2288                            }
2289                        } else if delay_is_only_readiness {
2290                            if process_exited {
2291                                debug!("daemon {id} exited during ready_delay, not marking as ready");
2292                            } else {
2293                                debug!("daemon {id} pid {daemon_pid} not running during ready_delay, deferring to exit handler");
2294                            }
2295                        }
2296
2297                        if delay_is_only_readiness {
2298                            // Clear all deadlines — no other checks are configured
2299                            // when delay fires as readiness, but clear defensively.
2300                            output_deadline = None;
2301                            http_deadline = None;
2302                            cmd_deadline = None;
2303                            port_deadline = None;
2304                            stop_cmd_probe_state(&mut cmd_probe);
2305                        }
2306                        // Disable timer after it fires
2307                        delay_timer = None;
2308                    }
2309                    _ = log_flush_interval.tick() => {
2310                        let _ = flush_logs(&mut log_buffer);
2311                    }
2312                }
2313            }
2314
2315            // Snapshot the daemon state BEFORE draining output.
2316            //
2317            // The drain can take up to 5s (e.g. when child processes keep the
2318            // stdout pipe open). During that time, a subsequent start() call
2319            // (e.g. from `pitchfork restart`) can upsert the daemon with a new
2320            // PID and Running status. If we only checked state AFTER the drain,
2321            // the monitoring task would see d.pid != Some(old_pid) && !is_stopped()
2322            // && !is_stopping() and return early without firing on_stop/on_exit
2323            // hooks.
2324            //
2325            // By snapshotting is_stopping before the drain, we preserve the
2326            // knowledge that stop() was called, so hooks fire correctly even
2327            // if start() has since changed the state.
2328            let pre_drain_daemon = SUPERVISOR.get_daemon(&id).await;
2329            let pre_drain_is_stopping = pre_drain_daemon
2330                .as_ref()
2331                .is_some_and(|d| d.status.is_stopped() || d.status.is_stopping_or_restarting());
2332
2333            // Drain any in-flight output lines that were still in the mpsc
2334            // channel or the OS pipe buffer when the child exited. Without
2335            // this, trailing log lines from short-lived daemons get dropped.
2336            // The reader tasks drop their senders on EOF, so recv() returns
2337            // None when all data has been consumed. A total deadline of 5 s
2338            // guards against a stuck reader (e.g. PTY master FD not closing)
2339            // while ensuring drain doesn't block post-exit cleanup indefinitely.
2340            //
2341            // Stop accepting relayed output first: the relay holds a sender of
2342            // its own, so leaving it registered would keep the channel open and
2343            // make every drain wait out the whole deadline. Readiness is moot
2344            // now anyway — the process has exited.
2345            drop(output_relay);
2346            let drain_deadline = tokio::time::Instant::now() + super::EXIT_OUTPUT_DRAIN_TIMEOUT;
2347            loop {
2348                let now = tokio::time::Instant::now();
2349                if now >= drain_deadline {
2350                    break;
2351                }
2352                let Ok(Some(line)) =
2353                    tokio::time::timeout(drain_deadline - now, output_rx.recv()).await
2354                else {
2355                    break;
2356                };
2357                // Sink-relayed lines are already stored; see the select loop.
2358                if matches!(line.source, super::OutputSource::Local) {
2359                    log_buffer.push(parse_line(&line.text));
2360                }
2361            }
2362            // Flush any remaining log lines (including drained) before the process exits.
2363            // Await the flush to guarantee all buffered logs are persisted before cleanup.
2364            if let Some(handle) = flush_logs(&mut log_buffer) {
2365                let _ = handle.await;
2366            }
2367
2368            // Clear active_port since the process is no longer running
2369            {
2370                let mut state_file = SUPERVISOR.state_file.lock().await;
2371                state_file.clear_active_port(&id);
2372            }
2373
2374            // Get the final exit status
2375            let exit_status = if let Some(status) = exit_status {
2376                status
2377            } else {
2378                // Streams closed but process hasn't exited yet, wait for it
2379                match exit_rx.recv().await {
2380                    Some(status) => status,
2381                    None => {
2382                        warn!("daemon {id} exit channel closed without receiving status");
2383                        Err(std::io::Error::other("exit channel closed"))
2384                    }
2385                }
2386            };
2387
2388            // If the loop exited via readiness exhaustion, wait for the group
2389            // kill to finish before reporting the failure so the retry loop
2390            // (or a waiting client) cannot start a replacement while the old
2391            // process group is still terminating.
2392            if let Some(kill) = ready_fail_kill {
2393                let _ = kill.await;
2394                if let Some(tx) = ready_tx.take() {
2395                    let _ = tx.send(Err(Some(124)));
2396                }
2397            }
2398
2399            let current_daemon = SUPERVISOR.get_daemon(&id).await;
2400
2401            // Signal that this monitoring task is processing its exit path.
2402            // The RAII guard will decrement the counter and notify close()
2403            // when the task finishes (including all fire_hook registrations),
2404            // regardless of which return path is taken.
2405            SUPERVISOR
2406                .active_monitors
2407                .fetch_add(1, atomic::Ordering::Release);
2408            struct MonitorGuard;
2409            impl Drop for MonitorGuard {
2410                fn drop(&mut self) {
2411                    SUPERVISOR
2412                        .active_monitors
2413                        .fetch_sub(1, atomic::Ordering::Release);
2414                    SUPERVISOR.monitor_done.notify_waiters();
2415                }
2416            }
2417            let _monitor_guard = MonitorGuard;
2418            // Check if this monitoring task is for the current daemon process.
2419            // If the daemon was intentionally stopped (pre_drain_is_stopping),
2420            // skip this check — we must still fire on_stop/on_exit hooks even
2421            // if start() has since changed the PID and status.
2422            if !pre_drain_is_stopping
2423                && (current_daemon.is_none()
2424                    || current_daemon.as_ref().is_some_and(|d| {
2425                        d.pid != Some(pid)
2426                            && !d.status.is_stopped()
2427                            && !d.status.is_stopping_or_restarting()
2428                    }))
2429            {
2430                // Another process has taken over, don't update status. The
2431                // task itself did finish, so a caller waiting on it is still
2432                // told so rather than left to time out.
2433                if oneshot_completion_pending && let Some(tx) = ready_tx.take() {
2434                    let _ = tx.send(Ok(()));
2435                }
2436                return;
2437            }
2438            // Capture the intentional-stop flag. Combine pre-drain and
2439            // post-drain state to handle both race orders:
2440            //  - stop() set Stopping before drain → pre_drain_is_stopping
2441            //  - stop() set Stopped during drain → current_daemon.is_stopped()
2442            let already_stopped = current_daemon
2443                .as_ref()
2444                .is_some_and(|d| d.status.is_stopped());
2445            let is_stopping = already_stopped
2446                || pre_drain_is_stopping
2447                || current_daemon
2448                    .as_ref()
2449                    .is_some_and(|d| d.status.is_stopping_or_restarting());
2450
2451            // --- Phase 1: Determine exit_code, exit_reason, and update daemon state ---
2452            let (exit_code, exit_reason) = match (&exit_status, is_stopping) {
2453                (Ok(status), true) => {
2454                    // Intentional stop (by pitchfork). status.code() returns None
2455                    // on Unix when killed by signal (e.g. SIGTERM); use -1 to
2456                    // distinguish from a clean exit code 0.
2457                    (status.code().unwrap_or(-1), "stop")
2458                }
2459                (Ok(status), false) if status.success() => (status.code().unwrap_or(-1), "exit"),
2460                (Ok(status), false) => (status.code().unwrap_or(-1), "fail"),
2461                (Err(_), true) => {
2462                    // child.wait() error while stopping (e.g. sysinfo reaped the process)
2463                    (-1, "stop")
2464                }
2465                (Err(_), false) => (-1, "fail"),
2466            };
2467            // A stop that arrived while this monitor was draining did not get
2468            // to write anything, so it is applied here. A run that had already
2469            // succeeded keeps that outcome — there was nothing left to
2470            // interrupt — but a failed one is recorded as stopped, so the
2471            // retry checker leaves it alone.
2472
2473            // Update daemon state unless stop() already did it (won the race),
2474            // OR the daemon was intentionally stopped before the drain
2475            // (pre_drain_is_stopping). In the latter case, start() may have
2476            // upserted Running during the 5s drain, and we must NOT overwrite
2477            // it with Stopped — that would undo the restart.
2478            if !already_stopped && !pre_drain_is_stopping {
2479                if let Ok(status) = &exit_status {
2480                    info!("daemon {id} exited with status {status}");
2481                }
2482                let (new_status, last_exit_success) = terminal_exit_state(
2483                    exit_reason,
2484                    opts.oneshot,
2485                    exit_code,
2486                    exit_status.as_ref().map(|s| s.success()).unwrap_or(true),
2487                );
2488                // Revalidate ownership inside the same state-lock section that
2489                // performs the write. The snapshot above was taken without
2490                // holding the lock, so a restart running on another thread can
2491                // install a successor in between; overwriting its record would
2492                // clear a live daemon's PID and undo the restart.
2493                if !SUPERVISOR
2494                    .finalize_monitored_exit(
2495                        &id,
2496                        pid,
2497                        monitor_token,
2498                        new_status,
2499                        Some(last_exit_success),
2500                    )
2501                    .await
2502                {
2503                    debug!("daemon {id} exit state was not written; a successor owns the record");
2504                }
2505            }
2506
2507            // The terminal state is now visible, so a caller waiting on this
2508            // oneshot can return and see it. A task that was stopped partway
2509            // never did its work, so it does not satisfy anything waiting on
2510            // it — even when the process caught the signal and exited 0.
2511            if oneshot_completion_pending && let Some(tx) = ready_tx.take() {
2512                if exit_reason == "exit" {
2513                    let _ = tx.send(Ok(()));
2514                } else {
2515                    warn!("daemon {id}: oneshot was stopped before completing");
2516                    let _ = tx.send(Err(None));
2517                }
2518            }
2519
2520            // --- Phase 2: Fire hooks ---
2521            let hook_extra_env = vec![
2522                ("PITCHFORK_EXIT_CODE".to_string(), exit_code.to_string()),
2523                ("PITCHFORK_EXIT_REASON".to_string(), exit_reason.to_string()),
2524            ];
2525
2526            // Determine which hooks to fire based on exit reason
2527            let hooks_to_fire: Vec<HookType> = match exit_reason {
2528                "stop" => vec![HookType::OnStop, HookType::OnExit],
2529                "exit" => vec![HookType::OnExit],
2530                // "fail": fire on_fail + on_exit only when retries are exhausted
2531                _ if hook_retry_count >= hook_retry.count() => {
2532                    vec![HookType::OnFail, HookType::OnExit]
2533                }
2534                _ => vec![],
2535            };
2536
2537            for hook_type in hooks_to_fire {
2538                fire_hook(
2539                    hook_type,
2540                    id.clone(),
2541                    hook_dirs.clone(),
2542                    hook_retry_count,
2543                    hook_daemon_env.clone(),
2544                    hook_resolved_ports.clone(),
2545                    hook_extra_env.clone(),
2546                )
2547                .await;
2548            }
2549        });
2550
2551        // If wait_ready is true, wait for readiness notification
2552        if let Some(ready_rx) = ready_rx {
2553            match ready_rx.await {
2554                Ok(Ok(())) => {
2555                    info!("daemon {id} is ready");
2556                    // Re-read rather than returning the snapshot taken at
2557                    // spawn: a completed oneshot has since been finalized, and
2558                    // the snapshot would tell the caller it is still running
2559                    // under a PID that has exited.
2560                    //
2561                    // Only when the record still describes this run, though. A
2562                    // successor that claimed it carries its own PID and start
2563                    // time, and reporting those as the outcome of the process
2564                    // this call spawned would misattribute them.
2565                    let daemon = match self.get_daemon(id).await {
2566                        Some(current) if current.pid.is_none() || current.pid == Some(pid) => {
2567                            current
2568                        }
2569                        // A successor owns the record, so neither it nor the
2570                        // spawn snapshot describes this run: one carries
2571                        // another process's identity, the other still says
2572                        // running under a PID that has exited. A oneshot that
2573                        // reported ready did finish, so report that outcome
2574                        // directly rather than either misleading record.
2575                        _ if opts.oneshot => crate::daemon::Daemon {
2576                            status: DaemonStatus::Completed,
2577                            pid: None,
2578                            start_time: None,
2579                            boot_time: None,
2580                            last_exit_success: Some(true),
2581                            ..daemon
2582                        },
2583                        _ => daemon,
2584                    };
2585                    Ok(IpcResponse::DaemonReady { daemon })
2586                }
2587                Ok(Err(exit_code)) => {
2588                    error!("daemon {id} failed before becoming ready");
2589                    // The caller reports this by querying the log store for
2590                    // what the daemon printed, so wait for the sink's final
2591                    // write first. The in-process path got this ordering by
2592                    // flushing synchronously before signalling.
2593                    //
2594                    // Only on the attempt that gives up: `run` retries inline,
2595                    // and waiting after every attempt would both delay the
2596                    // backoff and widen the window in which the daemon looks
2597                    // errored and idle — long enough for the background retry
2598                    // checker to start an attempt of its own alongside it.
2599                    let last_attempt = opts.retry_count >= opts.retry.count();
2600                    if using_sink && last_attempt {
2601                        super::log_sink::wait_for_output(id, spawn_time, SINK_OUTPUT_TIMEOUT).await;
2602                    }
2603                    Ok(IpcResponse::DaemonFailedWithCode {
2604                        exit_code,
2605                        resolved_ports: attempt_resolved_ports,
2606                    })
2607                }
2608                Err(_) => {
2609                    error!("readiness channel closed unexpectedly for daemon {id}");
2610                    Ok(IpcResponse::DaemonStart { daemon })
2611                }
2612            }
2613        } else {
2614            Ok(IpcResponse::DaemonStart { daemon })
2615        }
2616    }
2617
2618    /// Stop a running daemon
2619    pub async fn stop(&self, id: &DaemonId) -> Result<IpcResponse> {
2620        // Hold the daemon's stop lock for the whole stop (including the
2621        // whole-group termination wait) so starts and concurrent stops of the
2622        // same daemon serialize against it instead of racing the Stopping window.
2623        let lock = self.stop_lock(id).await;
2624        let _guard = lock.lock().await;
2625        self.stop_locked(id).await
2626    }
2627
2628    /// Stop a running daemon that is about to be started again (a file-watch
2629    /// restart). Same as [`Self::stop`], except the daemon is recorded as
2630    /// `restarting` rather than `stopping`/`stopped`, so anything following it
2631    /// (`pitchfork wait`) does not mistake the stop for the end of its run.
2632    /// The caller must start the daemon again, or settle the record with
2633    /// [`Self::settle_restarting`] if it cannot.
2634    pub(crate) async fn stop_for_restart(&self, id: &DaemonId) -> Result<IpcResponse> {
2635        let lock = self.stop_lock(id).await;
2636        let _guard = lock.lock().await;
2637        self.stop_locked_with(id, true).await
2638    }
2639
2640    /// Stop implementation. Caller must hold the daemon's stop lock.
2641    pub(super) async fn stop_locked(&self, id: &DaemonId) -> Result<IpcResponse> {
2642        self.stop_locked_with(id, false).await
2643    }
2644
2645    /// Stop implementation. Caller must hold the daemon's stop lock.
2646    ///
2647    /// With `restarting`, every status this would record as `stopping` or
2648    /// `stopped` is recorded as `restarting` instead: the daemon is on its way
2649    /// back, and recording it as stopped, even briefly, would tell anything
2650    /// watching the record that its run is over.
2651    pub(super) async fn stop_locked_with(
2652        &self,
2653        id: &DaemonId,
2654        restarting: bool,
2655    ) -> Result<IpcResponse> {
2656        let (stopping_status, stopped_status) = if restarting {
2657            (DaemonStatus::Restarting, DaemonStatus::Restarting)
2658        } else {
2659            (DaemonStatus::Stopping, DaemonStatus::Stopped)
2660        };
2661        let pitchfork_id = DaemonId::pitchfork();
2662        if *id == pitchfork_id {
2663            return Ok(IpcResponse::Error(
2664                "Cannot stop supervisor via stop command".into(),
2665            ));
2666        }
2667        info!("stopping daemon: {id}");
2668        // A foreground `start` may be working through this daemon's retries,
2669        // sleeping out a backoff with no process of its own to kill. Tell it to
2670        // give up, or it would start the next attempt once the stop has
2671        // returned.
2672        self.cancel_retrying(id);
2673        // ...and the retry checker may already have decided on an attempt it
2674        // has not started yet. The count is raised when this stop is done
2675        // rather than now, and while its lock is still held, so a checker that
2676        // reads the count while the stop is still recording itself reads the
2677        // old value and stands down when it reaches the lock. Raising it up
2678        // front would hand that reader a value that still matches once the
2679        // stop has finished.
2680        let _stop_epoch_bump = StopEpochGuard(id.clone());
2681        if let Some(daemon) = self.get_daemon(id).await {
2682            trace!("daemon to stop: {daemon}");
2683            if let Some(pid) = daemon.pid {
2684                trace!("killing pid: {pid}");
2685                if PROCS.is_running(pid) {
2686                    // Something is alive on that PID, but the kill below signals
2687                    // the entire process group: if the PID was recycled while
2688                    // this record sat unsupervised, that group belongs to an
2689                    // unrelated process tree. The daemon itself is gone either
2690                    // way, so report it as not running and clear the record.
2691                    if !super::signalling_pid_is_authorized(
2692                        daemon.start_time,
2693                        PROCS.start_time(pid),
2694                    ) {
2695                        warn!(
2696                            "pid {pid} recorded for daemon {id} belongs to another process now; not signalling it"
2697                        );
2698                        self.upsert_daemon(
2699                            UpsertDaemonOpts::builder(id.clone())
2700                                .set(|o| {
2701                                    o.pid = None;
2702                                    o.status = stopped_status.clone();
2703                                })
2704                                .build(),
2705                        )
2706                        .await?;
2707                        return Ok(IpcResponse::DaemonWasNotRunning);
2708                    }
2709
2710                    // First set status to Stopping (preserve PID for monitoring task)
2711                    self.upsert_daemon(
2712                        UpsertDaemonOpts::builder(id.clone())
2713                            .set(|o| {
2714                                o.pid = Some(pid);
2715                                o.status = stopping_status.clone();
2716                            })
2717                            .build(),
2718                    )
2719                    .await?;
2720
2721                    // Kill the entire process group atomically (daemon PID == PGID
2722                    // because we called setsid() at spawn time)
2723                    let stop_cfg = daemon.stop_signal.unwrap_or_default();
2724                    let stop_signal: i32 = stop_cfg.signal.into();
2725                    if let Err(e) = PROCS
2726                        .kill_process_group_async(pid, stop_signal, stop_cfg.timeout)
2727                        .await
2728                    {
2729                        debug!("failed to kill pid {pid}: {e}");
2730                        // Check if the process group is actually gone despite the
2731                        // error. Checking only the leader here would mark the daemon
2732                        // Stopped while surviving group members (e.g. one stuck in
2733                        // uninterruptible sleep) are still alive — letting a restart
2734                        // collide with them.
2735                        if PROCS.process_group_alive(pid) {
2736                            // Group still has live members - set back to Running
2737                            debug!(
2738                                "failed to stop pid {pid}: process group still alive after kill"
2739                            );
2740                            self.upsert_daemon(
2741                                UpsertDaemonOpts::builder(id.clone())
2742                                    .set(|o| {
2743                                        o.pid = Some(pid); // Preserve PID to avoid orphaning the process
2744                                        o.status = DaemonStatus::Running;
2745                                    })
2746                                    .build(),
2747                            )
2748                            .await?;
2749                            return Ok(IpcResponse::DaemonStopFailed {
2750                                error: format!(
2751                                    "process group of {pid} still alive after kill attempt: {e}"
2752                                ),
2753                            });
2754                        }
2755                        // On Windows the daemon can be gone while processes of
2756                        // its job remain (see `procs`), which the PID check above
2757                        // cannot see. The daemon is recorded as stopped, since
2758                        // it is, but the stop is reported as failed.
2759                        #[cfg(windows)]
2760                        {
2761                            self.upsert_daemon(
2762                                UpsertDaemonOpts::builder(id.clone())
2763                                    .set(|o| {
2764                                        o.pid = None;
2765                                        o.status = stopped_status.clone();
2766                                        o.last_exit_success = Some(true);
2767                                    })
2768                                    .build(),
2769                            )
2770                            .await?;
2771                            return Ok(IpcResponse::DaemonStopFailed {
2772                                error: format!("{e}"),
2773                            });
2774                        }
2775                    }
2776
2777                    // Process successfully stopped
2778                    // Note: kill_process_group_async waits for the ENTIRE process
2779                    // group to exit (stop signal -> stop_timeout -> SIGKILL, then a
2780                    // bounded verification), so a replacement daemon can be started
2781                    // without colliding with a still-terminating instance. The only
2782                    // exception is a member stuck in uninterruptible sleep, which is
2783                    // logged with a warning.
2784                    self.upsert_daemon(
2785                        UpsertDaemonOpts::builder(id.clone())
2786                            .set(|o| {
2787                                o.pid = None;
2788                                o.status = stopped_status.clone();
2789                                o.last_exit_success = Some(true);
2790                            })
2791                            .build(),
2792                    )
2793                    .await?;
2794                } else if daemon.oneshot && self.is_monitored(id, pid) {
2795                    // The task's process is gone but its monitor is still
2796                    // running, so the run's real outcome has not been written
2797                    // yet — and for a task that finished on its own that
2798                    // outcome is `completed`. Writing `stopped` straight over
2799                    // it would discard a success the task actually achieved
2800                    // and report failure to anyone waiting on it, purely
2801                    // because a stop arrived a moment late.
2802                    //
2803                    // So wait for whoever is monitoring this run — the native
2804                    // monitor or an adopted one — to finish, then decide from
2805                    // what it wrote. Waiting rather than leaving a note for
2806                    // the monitor to find means there is no window in which
2807                    // the note lands too late to be read, and nothing left
2808                    // behind if it is never read at all. The wait is bounded,
2809                    // as is the monitor's own five-second output drain.
2810                    //
2811                    // Only oneshots take this path. A service has no
2812                    // successful exit to preserve, so it falls through to the
2813                    // arm below, which records the stop immediately.
2814                    debug!(
2815                        "pid {pid} not running but daemon {id} is still monitored; waiting for its monitor to settle the outcome"
2816                    );
2817                    self.wait_for_exit_finalized(id, Some(pid)).await;
2818                    let finished = self.get_daemon(id).await;
2819                    if finished
2820                        .as_ref()
2821                        .is_some_and(|d| stop_keeps_finalized_status(&d.status))
2822                    {
2823                        return Ok(IpcResponse::DaemonWasNotRunning);
2824                    }
2825                    // The run did not finish its work, so record the stop. A
2826                    // failure left in place would be picked up by the retry
2827                    // checker, which would start a task the user just stopped.
2828                    self.upsert_daemon(
2829                        UpsertDaemonOpts::builder(id.clone())
2830                            .set(|o| {
2831                                o.pid = None;
2832                                o.status = stopped_status.clone();
2833                            })
2834                            .build(),
2835                    )
2836                    .await?;
2837                    return Ok(IpcResponse::DaemonWasNotRunning);
2838                } else {
2839                    debug!("pid {pid} not running, process may have exited unexpectedly");
2840                    // Process already dead and unmonitored, so nothing else
2841                    // will record an outcome — transition to Stopped so the
2842                    // retry checker sees a terminal state and stops
2843                    // scheduling new attempts. This is important for an
2844                    // explicit `pitchfork stop` on an Errored daemon: the
2845                    // user wants to abort retries.
2846                    self.upsert_daemon(
2847                        UpsertDaemonOpts::builder(id.clone())
2848                            .set(|o| {
2849                                o.pid = None;
2850                                o.status = stopped_status.clone();
2851                            })
2852                            .build(),
2853                    )
2854                    .await?;
2855                    return Ok(IpcResponse::DaemonWasNotRunning);
2856                }
2857                Ok(IpcResponse::Ok)
2858            } else {
2859                debug!("daemon {id} not running");
2860                // Between a restart's stop and its start there is no process,
2861                // but the daemon is on its way back. Stopping it now cancels
2862                // the restart: the restart sees the record has moved on and
2863                // does not start it again.
2864                if !restarting && daemon.status.is_restarting() {
2865                    self.upsert_daemon(
2866                        UpsertDaemonOpts::builder(id.clone())
2867                            .set(|o| {
2868                                o.pid = None;
2869                                o.status = DaemonStatus::Stopped;
2870                            })
2871                            .build(),
2872                    )
2873                    .await?;
2874                    return Ok(IpcResponse::Ok);
2875                }
2876                // No process to signal, but a failed record with retries left
2877                // is not inert: `check_retry` starts the next attempt from it,
2878                // whether or not a foreground start is also working through
2879                // them. Record the stop so nothing picks the daemon back up.
2880                if daemon.status.is_errored() && daemon.retry_count < daemon.retry.count() {
2881                    self.upsert_daemon(
2882                        UpsertDaemonOpts::builder(id.clone())
2883                            .set(|o| {
2884                                o.pid = None;
2885                                o.status = stopped_status.clone();
2886                            })
2887                            .build(),
2888                    )
2889                    .await?;
2890                    return Ok(IpcResponse::DaemonWasNotRunning);
2891                }
2892                Ok(IpcResponse::DaemonNotRunning)
2893            }
2894        } else {
2895            debug!("daemon {id} not found");
2896            Ok(IpcResponse::DaemonNotFound)
2897        }
2898    }
2899}
2900
2901#[cfg(unix)]
2902fn resolve_effective_run_identity(daemon_user: Option<&str>) -> Result<RunIdentity> {
2903    let s = settings();
2904    let settings_user = s.supervisor.user.trim();
2905    let daemon_user = daemon_user.map(str::trim).filter(|user| !user.is_empty());
2906    let settings_user = (!settings_user.is_empty()).then_some(settings_user);
2907    let configured = daemon_user.or(settings_user);
2908    let current_uid = nix::unistd::Uid::effective().as_raw();
2909    let current_gid = nix::unistd::Gid::effective().as_raw();
2910    // The recorded invoking user of a boot service stands in for the sudo
2911    // environment that launchd and systemd do not provide.
2912    let invoking = env::invoking_user_ids().map(|(uid, gid)| (uid.to_string(), gid.to_string()));
2913    resolve_run_identity(
2914        configured,
2915        current_uid,
2916        current_gid,
2917        invoking.as_ref().map(|(uid, _)| uid.as_str()),
2918        invoking.as_ref().map(|(_, gid)| gid.as_str()),
2919    )
2920}
2921
2922#[cfg(unix)]
2923fn resolve_run_identity(
2924    configured: Option<&str>,
2925    current_uid: u32,
2926    current_gid: u32,
2927    sudo_uid: Option<&str>,
2928    sudo_gid: Option<&str>,
2929) -> Result<RunIdentity> {
2930    let current_uid = nix::unistd::Uid::from_raw(current_uid);
2931    let current_gid = nix::unistd::Gid::from_raw(current_gid);
2932    if let Some(user) = configured {
2933        let identity = resolve_configured_user(user)?;
2934        ensure_can_use_identity(user, &identity, current_uid, current_gid)?;
2935        if identity.matches(current_uid, current_gid) {
2936            return Ok(RunIdentity::Inherit);
2937        }
2938        return Ok(identity);
2939    }
2940
2941    if current_uid.is_root()
2942        && let Some(identity) = resolve_sudo_identity(sudo_uid, sudo_gid)
2943    {
2944        if identity.matches(current_uid, current_gid) {
2945            return Ok(RunIdentity::Inherit);
2946        }
2947        return Ok(identity);
2948    }
2949
2950    Ok(RunIdentity::Inherit)
2951}
2952
2953#[cfg(unix)]
2954fn resolve_configured_user(user: &str) -> Result<RunIdentity> {
2955    if user.chars().all(|c| c.is_ascii_digit()) {
2956        let uid = user
2957            .parse::<u32>()
2958            .map_err(|e| miette::miette!("invalid run user UID '{}': {}", user, e))?;
2959        let user_record = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
2960            .into_diagnostic()?
2961            .ok_or_else(|| miette::miette!("run user UID '{}' does not exist", user))?;
2962        return run_identity_from_user_record(user_record);
2963    }
2964
2965    let user_record = nix::unistd::User::from_name(user)
2966        .into_diagnostic()?
2967        .ok_or_else(|| miette::miette!("run user '{}' does not exist", user))?;
2968    run_identity_from_user_record(user_record)
2969}
2970
2971#[cfg(unix)]
2972fn run_identity_from_user_record(user: nix::unistd::User) -> Result<RunIdentity> {
2973    let username = CString::new(user.name)
2974        .map_err(|e| miette::miette!("run user name contains an interior nul byte: {}", e))?;
2975    Ok(RunIdentity::Switch {
2976        uid: user.uid,
2977        gid: user.gid,
2978        username: Some(username),
2979        home: Some(user.dir),
2980    })
2981}
2982
2983#[cfg(unix)]
2984fn resolve_sudo_identity(sudo_uid: Option<&str>, sudo_gid: Option<&str>) -> Option<RunIdentity> {
2985    let uid = sudo_uid?.parse::<u32>().ok()?;
2986    let gid = sudo_gid?.parse::<u32>().ok()?;
2987    let user = nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
2988        .ok()
2989        .flatten();
2990    let (username, home) = match user {
2991        Some(user) => (CString::new(user.name).ok(), Some(user.dir)),
2992        None => (None, None),
2993    };
2994    Some(RunIdentity::Switch {
2995        uid: nix::unistd::Uid::from_raw(uid),
2996        gid: nix::unistd::Gid::from_raw(gid),
2997        username,
2998        home,
2999    })
3000}
3001
3002#[cfg(unix)]
3003fn ensure_can_use_identity(
3004    configured_user: &str,
3005    identity: &RunIdentity,
3006    current_uid: nix::unistd::Uid,
3007    current_gid: nix::unistd::Gid,
3008) -> Result<()> {
3009    let RunIdentity::Switch { uid, gid, .. } = identity else {
3010        return Ok(());
3011    };
3012    if *uid == current_uid && *gid == current_gid {
3013        return Ok(());
3014    }
3015    if current_uid.is_root() {
3016        return Ok(());
3017    }
3018    Err(miette::miette!(
3019        "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.",
3020        configured_user,
3021        current_uid.as_raw(),
3022        current_gid.as_raw(),
3023        uid.as_raw(),
3024        gid.as_raw()
3025    ))
3026}
3027
3028/// Point HOME, USER and LOGNAME at the user a daemon switches to.
3029///
3030/// The supervisor's environment describes the supervisor's own user (usually
3031/// root), so without this a daemon run as another user would read and write
3032/// root's home. A value the passwd entry cannot supply is removed rather than
3033/// left describing root.
3034#[cfg(unix)]
3035fn apply_identity_env(command: &mut tokio::process::Command, identity: &RunIdentity) {
3036    let RunIdentity::Switch { username, home, .. } = identity else {
3037        return;
3038    };
3039    match home.as_ref().filter(|home| !home.as_os_str().is_empty()) {
3040        Some(home) => command.env("HOME", home),
3041        None => command.env_remove("HOME"),
3042    };
3043    match username.as_ref().and_then(|name| name.to_str().ok()) {
3044        Some(name) => command.env("USER", name).env("LOGNAME", name),
3045        None => command.env_remove("USER").env_remove("LOGNAME"),
3046    };
3047}
3048
3049#[cfg(unix)]
3050fn apply_run_identity(identity: &RunIdentity) -> std::io::Result<()> {
3051    let RunIdentity::Switch {
3052        uid, gid, username, ..
3053    } = identity
3054    else {
3055        return Ok(());
3056    };
3057    if let Some(username) = username {
3058        initgroups_for_user(username, *gid)?;
3059    } else {
3060        setgroups_to_primary(*gid)?;
3061    }
3062    nix::unistd::setgid(*gid).map_err(nix_to_io_error)?;
3063    nix::unistd::setuid(*uid).map_err(nix_to_io_error)?;
3064    Ok(())
3065}
3066
3067#[cfg(unix)]
3068impl RunIdentity {
3069    fn matches(&self, uid: nix::unistd::Uid, gid: nix::unistd::Gid) -> bool {
3070        matches!(self, RunIdentity::Switch { uid: u, gid: g, .. } if *u == uid && *g == gid)
3071    }
3072}
3073
3074#[cfg(unix)]
3075fn setgroups_to_primary(gid: nix::unistd::Gid) -> std::io::Result<()> {
3076    let groups = [gid.as_raw() as libc::gid_t];
3077    #[cfg(any(target_os = "linux", target_os = "android"))]
3078    let group_count = groups.len();
3079    #[cfg(not(any(target_os = "linux", target_os = "android")))]
3080    let group_count = groups.len() as libc::c_int;
3081    let rc = unsafe { libc::setgroups(group_count, groups.as_ptr()) };
3082    if rc == -1 {
3083        Err(std::io::Error::last_os_error())
3084    } else {
3085        Ok(())
3086    }
3087}
3088
3089#[cfg(unix)]
3090fn initgroups_for_user(username: &CString, gid: nix::unistd::Gid) -> std::io::Result<()> {
3091    let gid = gid.as_raw();
3092    #[cfg(any(
3093        target_os = "macos",
3094        target_os = "ios",
3095        target_os = "tvos",
3096        target_os = "watchos"
3097    ))]
3098    let base_gid = i32::try_from(gid)
3099        .map_err(|_| std::io::Error::other(format!("gid {gid} is out of range")))?;
3100
3101    #[cfg(not(any(
3102        target_os = "macos",
3103        target_os = "ios",
3104        target_os = "tvos",
3105        target_os = "watchos"
3106    )))]
3107    let base_gid = gid as libc::gid_t;
3108
3109    // SAFETY: `username` is a valid nul-terminated C string and `base_gid`
3110    // is derived from a resolved system account or sudo-provided gid.
3111    let rc = unsafe { libc::initgroups(username.as_ptr(), base_gid) };
3112    if rc == -1 {
3113        Err(std::io::Error::last_os_error())
3114    } else {
3115        Ok(())
3116    }
3117}
3118
3119#[cfg(unix)]
3120fn nix_to_io_error(err: nix::errno::Errno) -> std::io::Error {
3121    std::io::Error::from_raw_os_error(err as i32)
3122}
3123
3124/// Check if multiple ports are available and optionally auto-bump to find available ports.
3125///
3126/// All ports are bumped by the same offset to maintain relative port spacing.
3127/// Returns the resolved ports (either the original or bumped ones).
3128/// Returns an error if any port is in use and auto_bump is disabled,
3129/// or if no available ports can be found after max attempts.
3130async fn check_ports_available(
3131    expected_ports: &[u16],
3132    auto_bump: bool,
3133    max_attempts: u32,
3134) -> Result<Vec<u16>> {
3135    if expected_ports.is_empty() {
3136        return Ok(Vec::new());
3137    }
3138
3139    for bump_offset in 0..=max_attempts {
3140        // Use wrapping_add to handle overflow correctly - ports wrap around at 65535
3141        let candidate_ports: Vec<u16> = expected_ports
3142            .iter()
3143            .map(|&p| p.wrapping_add(bump_offset as u16))
3144            .collect();
3145
3146        // Check if all ports in this set are available
3147        let mut all_available = true;
3148        let mut conflicting_port = None;
3149
3150        for &port in &candidate_ports {
3151            // Port 0 is a special case - it requests an ephemeral port from the OS.
3152            // Skip the availability check for port 0 since binding to it always succeeds.
3153            if port == 0 {
3154                continue;
3155            }
3156
3157            // Use spawn_blocking to avoid blocking the async runtime during TCP bind checks.
3158            //
3159            // We check multiple addresses to avoid false-negatives caused by SO_REUSEADDR.
3160            // On macOS/BSD, Rust's TcpListener::bind sets SO_REUSEADDR by default, which
3161            // allows binding 0.0.0.0:port even when 127.0.0.1:port is already in use
3162            // (because 0.0.0.0 is technically a different address).  Most daemons bind
3163            // to localhost, so checking 127.0.0.1 is essential to detect real conflicts.
3164            // We also check [::1] to cover IPv6 loopback listeners.
3165            //
3166            // NOTE: This check has a time-of-check-to-time-of-use (TOCTOU) race condition.
3167            // Another process could grab the port between our check and the daemon actually
3168            // binding. This is inherent to the approach and acceptable for our use case
3169            // since we're primarily detecting conflicts with already-running daemons.
3170            if let Some(address) = port_bind_conflict(port).await {
3171                all_available = false;
3172                conflicting_port = Some((port, address));
3173                break;
3174            }
3175        }
3176
3177        if all_available {
3178            // Check for overflow (port wrapped around to 0 due to wrapping_add)
3179            // If any candidate port is 0 but the original expected port wasn't 0,
3180            // it means we've wrapped around and should stop
3181            if candidate_ports.contains(&0) && !expected_ports.contains(&0) {
3182                return Err(PortError::NoAvailablePort {
3183                    start_port: expected_ports[0],
3184                    attempts: bump_offset + 1,
3185                }
3186                .into());
3187            }
3188            if bump_offset > 0 {
3189                info!("ports {expected_ports:?} bumped by {bump_offset} to {candidate_ports:?}");
3190            }
3191            return Ok(candidate_ports);
3192        }
3193
3194        // Port is in use
3195        if bump_offset == 0
3196            && !auto_bump
3197            && let Some((port, address)) = conflicting_port
3198        {
3199            let (pid, process) = identify_port_owner(port, address).await;
3200            return Err(PortError::InUse {
3201                port,
3202                address: Some(shown_address(address)),
3203                process,
3204                pid,
3205            }
3206            .into());
3207        }
3208    }
3209
3210    // No available ports found after max attempts
3211    Err(PortError::NoAvailablePort {
3212        start_port: expected_ports[0],
3213        attempts: max_attempts + 1,
3214    }
3215    .into())
3216}
3217
3218/// Check whether a port is currently in use by attempting to bind on multiple addresses.
3219///
3220/// Returns the address of the first bind that gets `AddrInUse`, meaning another socket
3221/// holds the port there. Other errors (e.g. `AddrNotAvailable` on an address family
3222/// the OS doesn't support) are ignored so they don't produce false positives.
3223async fn port_bind_conflict(port: u16) -> Option<std::net::IpAddr> {
3224    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
3225    tokio::task::spawn_blocking(move || {
3226        for addr in [
3227            IpAddr::V4(Ipv4Addr::UNSPECIFIED),
3228            IpAddr::V4(Ipv4Addr::LOCALHOST),
3229            IpAddr::V6(Ipv6Addr::LOCALHOST),
3230        ] {
3231            match std::net::TcpListener::bind((addr, port)) {
3232                Ok(listener) => drop(listener),
3233                Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => return Some(addr),
3234                Err(_) => continue,
3235            }
3236        }
3237        None
3238    })
3239    .await
3240    .unwrap_or(None)
3241}
3242
3243/// An address as a message shows it: IPv6 in brackets, as in `[::1]`.
3244fn shown_address(addr: std::net::IpAddr) -> String {
3245    match addr {
3246        std::net::IpAddr::V4(v4) => v4.to_string(),
3247        std::net::IpAddr::V6(v6) => format!("[{v6}]"),
3248    }
3249}
3250
3251/// How well a socket listening on `listening` accounts for a bind to
3252/// `conflict` failing on the same port, best first, or `None` when it cannot:
3253/// the same address; a wildcard and a specific address of one family, which
3254/// conflict with each other; then `[::]`, which also takes IPv4 unless it is
3255/// IPv6-only. An IPv4-mapped IPv6 address, as a dual-stack socket shows an
3256/// IPv4 connection, counts as the IPv4 address.
3257fn listener_match(listening: std::net::IpAddr, conflict: std::net::IpAddr) -> Option<u8> {
3258    use std::net::IpAddr;
3259    let listening = listening.to_canonical();
3260    if listening == conflict {
3261        return Some(0);
3262    }
3263    match (listening, conflict) {
3264        (IpAddr::V4(_), IpAddr::V4(_)) | (IpAddr::V6(_), IpAddr::V6(_))
3265            if listening.is_unspecified() || conflict.is_unspecified() =>
3266        {
3267            Some(1)
3268        }
3269        (IpAddr::V6(v6), IpAddr::V4(_)) if v6.is_unspecified() => Some(2),
3270        _ => None,
3271    }
3272}
3273
3274/// A TCP socket on this machine, as the port owner lookup sees it.
3275struct TcpSocket {
3276    addr: std::net::SocketAddr,
3277    listening: bool,
3278    pid: u32,
3279    process: String,
3280}
3281
3282/// The process whose TCP socket on `port` best accounts for the bind to
3283/// `conflict` failing (see [`listener_match`]), among `sockets`. A listening
3284/// socket comes first; one that is not listening but is bound to the port,
3285/// such as a connection a listener accepted, can hold it too. `None` when
3286/// none can.
3287///
3288/// A `[::]` socket holds an IPv4 port only if it is not IPv6-only, which the
3289/// socket list does not tell, so it is named for an IPv4 conflict only when no
3290/// IPv4 socket, listening or not, could account for it.
3291fn pick_port_owner(
3292    sockets: impl IntoIterator<Item = TcpSocket>,
3293    port: u16,
3294    conflict: std::net::IpAddr,
3295) -> Option<(u32, String)> {
3296    sockets
3297        .into_iter()
3298        .filter(|s| s.addr.port() == port)
3299        .filter_map(|s| {
3300            let matched = listener_match(s.addr.ip(), conflict)?;
3301            let rank = (matched == 2, !s.listening, matched);
3302            Some((rank, s.pid, s.process))
3303        })
3304        .min_by_key(|(rank, _, _)| *rank)
3305        .map(|(_, pid, process)| (pid, process))
3306}
3307
3308/// Best-effort lookup of the process occupying a port via `listeners::get_all()`.
3309///
3310/// Only a TCP socket on an address that conflicts with `conflict`, the address
3311/// whose bind found the port taken, is taken as the holder: another process
3312/// may listen on the same port on an address that does not. A listening
3313/// socket is preferred (see [`pick_port_owner`]).
3314///
3315/// Returns `(pid, process_name)`.  Falls back to `(0, "unknown")` when no such
3316/// socket is found or the system call fails (permission error, unsupported OS, etc.).
3317async fn identify_port_owner(port: u16, conflict: std::net::IpAddr) -> (u32, String) {
3318    tokio::task::spawn_blocking(move || {
3319        listeners::get_all()
3320            .ok()
3321            .and_then(|list| {
3322                // UDP sockets share port numbers with TCP but never block a
3323                // TCP bind.
3324                let tcp = list
3325                    .into_iter()
3326                    .filter(|l| l.protocol == listeners::Protocol::TCP)
3327                    .map(|l| TcpSocket {
3328                        addr: l.socket,
3329                        listening: l.state == listeners::SocketState::Listen,
3330                        pid: l.process.pid,
3331                        process: l.process.name,
3332                    });
3333                pick_port_owner(tcp, port, conflict)
3334            })
3335            .unwrap_or((0, "unknown".to_string()))
3336    })
3337    .await
3338    .unwrap_or((0, "unknown".to_string()))
3339}
3340
3341/// Detect whether a port is in use, and if so, where and by which process.
3342///
3343/// Combines `port_bind_conflict` (reliable bind probe) with `identify_port_owner`
3344/// (best-effort process lookup).  Returns `None` when the port is free.
3345async fn detect_port_conflict(port: u16) -> Option<(String, u32, String)> {
3346    let address = port_bind_conflict(port).await?;
3347    let (pid, process) = identify_port_owner(port, address).await;
3348    Some((shown_address(address), pid, process))
3349}
3350
3351#[derive(Debug, PartialEq, Eq)]
3352enum ActivePortSelection {
3353    NoCandidates,
3354    Selected(u16),
3355    Ambiguous(Vec<u16>),
3356}
3357
3358fn discovery_preferred_port(daemon: &crate::daemon::Daemon) -> Option<u16> {
3359    daemon
3360        .resolved_port
3361        .first()
3362        .copied()
3363        .or_else(|| {
3364            daemon
3365                .port
3366                .as_ref()
3367                .and_then(|port| port.expect.first().copied())
3368        })
3369        .filter(|&port| port > 0)
3370}
3371
3372fn select_active_port(
3373    listeners: impl IntoIterator<Item = listeners::Listener>,
3374    descendant_pids: &std::collections::HashSet<u32>,
3375    preferred_port: Option<u16>,
3376) -> ActivePortSelection {
3377    let process_ports: std::collections::BTreeSet<u16> = listeners
3378        .into_iter()
3379        .filter(|listener| {
3380            listener.protocol == listeners::Protocol::TCP
3381                && listener.state == listeners::SocketState::Listen
3382                && descendant_pids.contains(&listener.process.pid)
3383        })
3384        .map(|listener| listener.socket.port())
3385        .filter(|&port| port > 0)
3386        .collect();
3387
3388    if let Some(port) = preferred_port
3389        && process_ports.contains(&port)
3390    {
3391        return ActivePortSelection::Selected(port);
3392    }
3393
3394    match process_ports.len() {
3395        0 => ActivePortSelection::NoCandidates,
3396        1 => ActivePortSelection::Selected(*process_ports.first().unwrap()),
3397        _ => ActivePortSelection::Ambiguous(process_ports.into_iter().collect()),
3398    }
3399}
3400
3401/// Spawn a background task that detects the daemon process's active listening port
3402/// and stores it in the state file as `active_port`.
3403///
3404/// This is called once when the daemon becomes ready. The port is cleared when the daemon stops.
3405///
3406/// Port selection strategy:
3407/// 1. Consider only TCP listening sockets owned by the daemon or its descendants.
3408/// 2. Prefer the first resolved port, falling back to the first expected port for
3409///    legacy state without resolved ports.
3410/// 3. Select a sole distinct candidate; leave `active_port` unset when ambiguous.
3411fn detect_and_store_active_port(id: DaemonId, pid: u32) {
3412    tokio::spawn(async move {
3413        // Retry with exponential backoff so that slow-starting daemons (JVM,
3414        // Node.js, Python, etc.) that take more than 500 ms to bind their port
3415        // are still detected.  Total wait budget: 500+1000+2000+4000 = 7.5 s.
3416        for delay_ms in [500u64, 1000, 2000, 4000] {
3417            tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
3418
3419            // Read daemon state atomically: check if still alive and get the preferred port
3420            // in a single lock acquisition to avoid TOCTOU and unnecessary lock overhead.
3421            let preferred_port: Option<u16> = {
3422                let state_file = SUPERVISOR.state_file.lock().await;
3423                match state_file.daemons.get(&id) {
3424                    Some(d) if d.pid.is_none() => {
3425                        debug!("daemon {id}: aborting active_port detection — process exited");
3426                        return;
3427                    }
3428                    Some(d) => discovery_preferred_port(d),
3429                    None => None,
3430                }
3431            };
3432
3433            let selection = tokio::task::spawn_blocking(move || {
3434                let listeners = listeners::get_all().ok()?;
3435
3436                // Refresh process tree so all_children sees current descendants.
3437                PROCS.refresh_processes();
3438
3439                let descendant_pids: std::collections::HashSet<u32> = PROCS
3440                    .all_children(pid)
3441                    .into_iter()
3442                    .chain(std::iter::once(pid))
3443                    .collect();
3444
3445                Some(select_active_port(
3446                    listeners,
3447                    &descendant_pids,
3448                    preferred_port,
3449                ))
3450            })
3451            .await
3452            .ok()
3453            .flatten()
3454            .unwrap_or(ActivePortSelection::NoCandidates);
3455
3456            let port = match selection {
3457                ActivePortSelection::Selected(port) => port,
3458                ActivePortSelection::Ambiguous(ports) => {
3459                    debug!(
3460                        "daemon {id}: ambiguous active_port candidates {ports:?} for pid {pid} \
3461                         and its descendants; leaving active_port unset (will retry)"
3462                    );
3463                    continue;
3464                }
3465                ActivePortSelection::NoCandidates => {
3466                    debug!(
3467                        "daemon {id}: no active port detected for pid {pid} or its descendants \
3468                         (will retry)"
3469                    );
3470                    continue;
3471                }
3472            };
3473
3474            debug!("daemon {id} active_port detected: {port}");
3475            let mut state_file = SUPERVISOR.state_file.lock().await;
3476            if let Some(d) = state_file.daemons.get(&id) {
3477                // Guard against PID reuse: if the original process exited and the OS
3478                // assigned the same PID to an unrelated process that happens to bind
3479                // a port, we must not route proxy traffic to that unrelated service.
3480                if d.pid == Some(pid) {
3481                    state_file.set_active_port(&id, port);
3482                } else {
3483                    debug!(
3484                        "daemon {id}: skipping active_port write — PID mismatch \
3485                         (expected {pid}, current {:?})",
3486                        d.pid
3487                    );
3488                }
3489            }
3490            return;
3491        }
3492
3493        debug!(
3494            "daemon {id}: active port detection exhausted all retries for pid {pid} and its descendants"
3495        );
3496    });
3497}
3498
3499#[cfg(test)]
3500mod port_conflict_tests {
3501    use super::{
3502        TcpSocket, check_ports_available, listener_match, pick_port_owner, port_bind_conflict,
3503        shown_address,
3504    };
3505    use crate::error::PortError;
3506    use std::net::{IpAddr, SocketAddr};
3507
3508    #[tokio::test]
3509    async fn a_taken_port_is_reported_with_its_address_and_holder() {
3510        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3511        let port = listener.local_addr().unwrap().port();
3512
3513        // Which bind finds it taken depends on the OS: Linux refuses the
3514        // wildcard bind as well, macOS only the 127.0.0.1 one.
3515        let address = shown_address(port_bind_conflict(port).await.unwrap());
3516        assert!(
3517            ["0.0.0.0", "127.0.0.1"].contains(&address.as_str()),
3518            "{address}"
3519        );
3520
3521        let err = check_ports_available(&[port], false, 0).await.unwrap_err();
3522        match err.downcast_ref::<PortError>() {
3523            Some(PortError::InUse {
3524                port: p,
3525                address: Some(a),
3526                pid,
3527                ..
3528            }) => {
3529                assert_eq!(*p, port);
3530                assert_eq!(*a, address);
3531                // The holder is this test process, which lists its own sockets.
3532                assert_eq!(*pid, std::process::id());
3533            }
3534            other => panic!("unexpected error: {other:?}"),
3535        }
3536        drop(listener);
3537        assert_eq!(port_bind_conflict(port).await, None);
3538    }
3539
3540    fn ip(s: &str) -> IpAddr {
3541        s.parse().unwrap()
3542    }
3543
3544    fn socket(addr: &str, listening: bool, pid: u32) -> TcpSocket {
3545        TcpSocket {
3546            addr: addr.parse::<SocketAddr>().unwrap(),
3547            listening,
3548            pid,
3549            process: format!("p{pid}"),
3550        }
3551    }
3552
3553    #[test]
3554    fn only_a_listener_whose_address_conflicts_is_named() {
3555        let all = || {
3556            [
3557                socket("192.0.2.1:3000", true, 1),
3558                socket("127.0.0.1:3000", true, 2),
3559                socket("[::1]:3000", true, 3),
3560                socket("127.0.0.1:4000", true, 4),
3561            ]
3562        };
3563        // The exact address wins over a wildcard or another address.
3564        assert_eq!(
3565            pick_port_owner(all(), 3000, ip("127.0.0.1")),
3566            Some((2, "p2".into()))
3567        );
3568        assert_eq!(
3569            pick_port_owner(all(), 3000, ip("::1")),
3570            Some((3, "p3".into()))
3571        );
3572        // A wildcard bind conflicts with any listener of its family.
3573        let v4 = pick_port_owner(all(), 3000, ip("0.0.0.0")).unwrap().0;
3574        assert!([1, 2].contains(&v4));
3575        // No socket on a conflicting address: nobody is named.
3576        assert_eq!(
3577            pick_port_owner([socket("[::1]:3000", true, 3)], 3000, ip("127.0.0.1")),
3578            None
3579        );
3580        assert_eq!(pick_port_owner(all(), 5000, ip("127.0.0.1")), None);
3581
3582        // A listener comes before a socket that is not listening, which is
3583        // named when it is all there is.
3584        let both = [
3585            socket("127.0.0.1:3000", false, 5),
3586            socket("0.0.0.0:3000", true, 6),
3587        ];
3588        assert_eq!(
3589            pick_port_owner(both, 3000, ip("127.0.0.1")),
3590            Some((6, "p6".into()))
3591        );
3592        assert_eq!(
3593            pick_port_owner([socket("127.0.0.1:3000", false, 5)], 3000, ip("127.0.0.1")),
3594            Some((5, "p5".into()))
3595        );
3596
3597        // `[::]` may be IPv6-only, so an IPv4 socket, even one that is not
3598        // listening, is named before it for an IPv4 conflict.
3599        let v6_wildcard = || {
3600            [
3601                socket("[::]:3000", true, 7),
3602                socket("127.0.0.1:3000", false, 8),
3603            ]
3604        };
3605        assert_eq!(
3606            pick_port_owner(v6_wildcard(), 3000, ip("127.0.0.1")),
3607            Some((8, "p8".into()))
3608        );
3609        assert_eq!(
3610            pick_port_owner(v6_wildcard(), 3000, ip("0.0.0.0")),
3611            Some((8, "p8".into()))
3612        );
3613        // With no IPv4 socket, it is the only one that can hold the port.
3614        assert_eq!(
3615            pick_port_owner([socket("[::]:3000", true, 7)], 3000, ip("127.0.0.1")),
3616            Some((7, "p7".into()))
3617        );
3618        // A dual-stack socket shows an IPv4 connection as IPv4-mapped.
3619        assert_eq!(
3620            pick_port_owner(
3621                [socket("[::ffff:127.0.0.1]:3000", false, 9)],
3622                3000,
3623                ip("127.0.0.1")
3624            ),
3625            Some((9, "p9".into()))
3626        );
3627
3628        assert_eq!(listener_match(ip("0.0.0.0"), ip("127.0.0.1")), Some(1));
3629        assert_eq!(listener_match(ip("::"), ip("127.0.0.1")), Some(2));
3630        assert_eq!(listener_match(ip("::1"), ip("127.0.0.1")), None);
3631        assert_eq!(listener_match(ip("192.0.2.1"), ip("127.0.0.1")), None);
3632        assert_eq!(shown_address(ip("::1")), "[::1]");
3633    }
3634}
3635
3636#[cfg(test)]
3637mod active_port_tests {
3638    use super::*;
3639    use crate::config_types::PortConfig;
3640    use listeners::{Listener, Process, Protocol, SocketState};
3641    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
3642
3643    fn listener(pid: u32, port: u16, protocol: Protocol, state: SocketState) -> Listener {
3644        Listener {
3645            process: Process {
3646                pid,
3647                name: "test".to_string(),
3648                path: "/test".to_string(),
3649            },
3650            socket: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
3651            protocol,
3652            state,
3653        }
3654    }
3655
3656    #[test]
3657    fn active_port_candidates_exclude_outbound_tcp_and_udp_sockets() {
3658        let daemon_pid = 100;
3659        let child_pid = 101;
3660        let descendant_pids = [daemon_pid, child_pid].into_iter().collect();
3661        let listeners = vec![
3662            listener(child_pid, 3004, Protocol::TCP, SocketState::Listen),
3663            listener(daemon_pid, 47082, Protocol::TCP, SocketState::Established),
3664            listener(daemon_pid, 5353, Protocol::UDP, SocketState::Unknown),
3665            listener(999, 9000, Protocol::TCP, SocketState::Listen),
3666        ];
3667
3668        assert_eq!(
3669            select_active_port(listeners, &descendant_pids, None),
3670            ActivePortSelection::Selected(3004)
3671        );
3672    }
3673
3674    #[test]
3675    fn bumped_cmd_readiness_prefers_resolved_primary_port() {
3676        let daemon = crate::daemon::Daemon {
3677            resolved_port: vec![3004],
3678            port: Some(PortConfig {
3679                expect: vec![3000],
3680                ..PortConfig::default()
3681            }),
3682            ..crate::daemon::Daemon::default()
3683        };
3684        let descendant_pids = [100].into_iter().collect();
3685        let listeners = vec![
3686            listener(100, 9000, Protocol::TCP, SocketState::Listen),
3687            listener(100, 3004, Protocol::TCP, SocketState::Listen),
3688        ];
3689
3690        assert_eq!(discovery_preferred_port(&daemon), Some(3004));
3691        assert_eq!(
3692            select_active_port(
3693                listeners,
3694                &descendant_pids,
3695                discovery_preferred_port(&daemon),
3696            ),
3697            ActivePortSelection::Selected(3004)
3698        );
3699    }
3700
3701    #[test]
3702    fn legacy_state_uses_expected_primary_port_for_discovery() {
3703        let daemon = crate::daemon::Daemon {
3704            port: Some(PortConfig {
3705                expect: vec![3000],
3706                ..PortConfig::default()
3707            }),
3708            ..crate::daemon::Daemon::default()
3709        };
3710
3711        assert_eq!(discovery_preferred_port(&daemon), Some(3000));
3712    }
3713
3714    #[test]
3715    fn ambiguous_candidates_leave_active_port_unset() {
3716        let descendant_pids = [100].into_iter().collect();
3717        let listeners = vec![
3718            listener(100, 9000, Protocol::TCP, SocketState::Listen),
3719            listener(100, 3000, Protocol::TCP, SocketState::Listen),
3720        ];
3721
3722        assert_eq!(
3723            select_active_port(listeners, &descendant_pids, None),
3724            ActivePortSelection::Ambiguous(vec![3000, 9000])
3725        );
3726    }
3727
3728    #[test]
3729    fn bumped_ready_port_sets_only_the_resolved_primary_without_scanning() {
3730        assert_eq!(active_port_from_ready_port(3004, &[3004]), Some(3004));
3731        assert_eq!(active_port_from_ready_port(4003, &[3003, 4003]), None);
3732    }
3733
3734    #[test]
3735    fn delay_readiness_requires_delay_only_and_a_running_process() {
3736        assert!(delay_readiness_succeeded(false, false, false, true));
3737        assert!(!delay_readiness_succeeded(false, true, false, true));
3738        assert!(!delay_readiness_succeeded(false, false, true, false));
3739        assert!(!delay_readiness_succeeded(false, false, false, false));
3740        assert!(!delay_readiness_succeeded(true, false, false, true));
3741    }
3742}
3743
3744/// Check whether a daemon (by its qualified ID) is the target of any registered
3745/// slug in the global config.  This is used to decide whether to run the
3746/// `detect_and_store_active_port` polling task — only slug-targeted daemons need
3747/// it, avoiding wasted `listeners::get_all()` calls for port-less daemons.
3748///
3749/// Delegates to `proxy::server::is_slug_target()` which uses the same in-memory
3750/// slug cache as the proxy hot path, so this check is cheap.
3751fn is_daemon_slug_target(id: &DaemonId) -> bool {
3752    // read_global_slugs is called once per daemon start — acceptable cost.
3753    // We intentionally avoid making this async to keep has_port_config evaluation
3754    // simple and synchronous in run_once().
3755    let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
3756    slugs.iter().any(|(slug, entry)| {
3757        let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
3758        id.name() == daemon_name
3759    })
3760}
3761
3762#[cfg(test)]
3763mod oneshot_tests {
3764    use super::*;
3765
3766    #[test]
3767    fn oneshot_clean_exit_is_completed() {
3768        let (status, success) = terminal_exit_state("exit", true, 0, true);
3769        assert!(matches!(status, DaemonStatus::Completed));
3770        assert!(success);
3771    }
3772
3773    #[test]
3774    fn service_clean_exit_is_still_stopped() {
3775        let (status, success) = terminal_exit_state("exit", false, 0, true);
3776        assert!(matches!(status, DaemonStatus::Stopped));
3777        assert!(success);
3778    }
3779
3780    #[test]
3781    fn oneshot_failure_is_errored_so_retry_applies() {
3782        // check_retry() only picks up errored daemons, so a non-zero exit must
3783        // not be recorded as completed.
3784        let (status, success) = terminal_exit_state("fail", true, 3, false);
3785        assert!(matches!(status, DaemonStatus::Errored(3)));
3786        assert!(!success);
3787    }
3788
3789    #[test]
3790    fn a_stop_cancels_the_retry_sequence_it_finds() {
3791        let id = DaemonId::new("retry-cancel-test", "task");
3792        let claim = SUPERVISOR.mark_retrying(&id);
3793        assert!(!claim.is_cancelled());
3794        assert!(SUPERVISOR.is_retrying(&id));
3795        SUPERVISOR.cancel_retrying(&id);
3796        assert!(claim.is_cancelled());
3797        drop(claim);
3798        assert!(!SUPERVISOR.is_retrying(&id));
3799    }
3800
3801    #[test]
3802    fn a_stop_cancels_every_sequence_for_the_daemon() {
3803        // Two starts can be working through the same daemon's retries: the
3804        // first releases the daemon's lock while it sleeps out a backoff. A
3805        // stop has to end both, not just whichever claimed it last.
3806        let id = DaemonId::new("retry-cancel-test", "concurrent");
3807        let first = SUPERVISOR.mark_retrying(&id);
3808        let second = SUPERVISOR.mark_retrying(&id);
3809        SUPERVISOR.cancel_retrying(&id);
3810        assert!(first.is_cancelled());
3811        assert!(second.is_cancelled());
3812        drop(second);
3813        // The first is still going, so the retry checker must still stand off.
3814        assert!(SUPERVISOR.is_retrying(&id));
3815        drop(first);
3816        assert!(!SUPERVISOR.is_retrying(&id));
3817    }
3818
3819    #[test]
3820    fn a_stop_invalidates_an_attempt_decided_on_before_it() {
3821        // The retry checker reads the epoch when it decides on an attempt and
3822        // `run_retry` compares it under the daemon's lock, so an approval from
3823        // before a stop cannot slip past that stop.
3824        let id = DaemonId::new("stop-epoch-test", "task");
3825        let approved_at = SUPERVISOR.stop_epoch(&id);
3826        assert_eq!(SUPERVISOR.stop_epoch(&id), approved_at);
3827        SUPERVISOR.bump_stop_epoch(&id);
3828        assert_ne!(SUPERVISOR.stop_epoch(&id), approved_at);
3829        // An attempt decided on after the stop is still fine to start.
3830        let approved_after = SUPERVISOR.stop_epoch(&id);
3831        assert_eq!(SUPERVISOR.stop_epoch(&id), approved_after);
3832    }
3833
3834    #[test]
3835    fn stop_epochs_are_tracked_per_daemon() {
3836        let stopped = DaemonId::new("stop-epoch-test", "stopped");
3837        let untouched = DaemonId::new("stop-epoch-test", "untouched");
3838        let approved_at = SUPERVISOR.stop_epoch(&untouched);
3839        SUPERVISOR.bump_stop_epoch(&stopped);
3840        assert_eq!(SUPERVISOR.stop_epoch(&untouched), approved_at);
3841    }
3842
3843    #[test]
3844    fn a_stop_leaves_a_completed_task_alone() {
3845        // It had already done its work, so the stop had nothing to interrupt.
3846        assert!(stop_keeps_finalized_status(&DaemonStatus::Completed));
3847    }
3848
3849    #[test]
3850    fn a_stop_replaces_a_failure_so_retries_do_not_resume() {
3851        // check_retry() picks up errored daemons, so a stop has to overwrite
3852        // one or it will start the task again.
3853        assert!(!stop_keeps_finalized_status(&DaemonStatus::Errored(1)));
3854        assert!(!stop_keeps_finalized_status(&DaemonStatus::Running));
3855        assert!(!stop_keeps_finalized_status(&DaemonStatus::Stopped));
3856    }
3857
3858    #[test]
3859    fn stopped_oneshot_did_not_complete() {
3860        let (status, _) = terminal_exit_state("stop", true, 0, true);
3861        assert!(matches!(status, DaemonStatus::Stopped));
3862    }
3863}
3864
3865#[cfg(all(test, unix))]
3866mod tests {
3867    use super::*;
3868
3869    #[test]
3870    fn test_resolve_run_identity_empty_without_sudo() {
3871        let identity = resolve_run_identity(None, 501, 20, None, None).unwrap();
3872        assert_eq!(identity, RunIdentity::Inherit);
3873    }
3874
3875    #[test]
3876    fn test_resolve_run_identity_sudo_fallback() {
3877        let identity = resolve_run_identity(None, 0, 0, Some("501"), Some("20")).unwrap();
3878        let RunIdentity::Switch { uid, gid, .. } = identity else {
3879            panic!("expected identity switch");
3880        };
3881        assert_eq!(uid.as_raw(), 501);
3882        assert_eq!(gid.as_raw(), 20);
3883    }
3884
3885    #[test]
3886    fn test_resolve_run_identity_ignores_stale_sudo_when_not_root() {
3887        let identity = resolve_run_identity(None, 501, 20, Some("0"), Some("0")).unwrap();
3888        assert_eq!(identity, RunIdentity::Inherit);
3889    }
3890
3891    #[test]
3892    fn test_resolve_configured_user_root_name() {
3893        let identity = resolve_configured_user("root").unwrap();
3894        let RunIdentity::Switch { uid, username, .. } = identity else {
3895            panic!("expected identity switch");
3896        };
3897        assert_eq!(uid.as_raw(), 0);
3898        assert_eq!(
3899            username.as_deref().and_then(|s| s.to_str().ok()),
3900            Some("root")
3901        );
3902    }
3903
3904    #[test]
3905    fn test_resolve_configured_user_root_uid() {
3906        let identity = resolve_configured_user("0").unwrap();
3907        let RunIdentity::Switch { uid, username, .. } = identity else {
3908            panic!("expected identity switch");
3909        };
3910        assert_eq!(uid.as_raw(), 0);
3911        assert_eq!(
3912            username.as_deref().and_then(|s| s.to_str().ok()),
3913            Some("root")
3914        );
3915    }
3916
3917    #[test]
3918    fn test_resolve_configured_user_missing_user_fails() {
3919        let err = resolve_configured_user("pitchfork-user-that-should-not-exist")
3920            .unwrap_err()
3921            .to_string();
3922        assert!(err.contains("does not exist"));
3923    }
3924
3925    #[test]
3926    fn test_resolve_run_identity_requires_root_for_user_switch() {
3927        let err = resolve_run_identity(Some("root"), 501, 20, None, None)
3928            .unwrap_err()
3929            .to_string();
3930        assert!(err.contains("Restart the supervisor with sudo"));
3931    }
3932
3933    #[test]
3934    fn test_resolve_run_identity_same_user_is_noop() {
3935        let identity = resolve_run_identity(Some("root"), 0, 0, Some("501"), Some("20")).unwrap();
3936        assert_eq!(identity, RunIdentity::Inherit);
3937    }
3938
3939    #[test]
3940    fn test_resolve_configured_user_records_home() {
3941        let identity = resolve_configured_user("root").unwrap();
3942        let RunIdentity::Switch { home, .. } = identity else {
3943            panic!("expected identity switch");
3944        };
3945        let expected = nix::unistd::User::from_name("root").unwrap().unwrap().dir;
3946        assert_eq!(home, Some(expected));
3947    }
3948
3949    fn switch_to(name: Option<&str>, home: Option<&str>) -> RunIdentity {
3950        RunIdentity::Switch {
3951            uid: nix::unistd::Uid::from_raw(501),
3952            gid: nix::unistd::Gid::from_raw(20),
3953            username: name.map(|n| CString::new(n).unwrap()),
3954            home: home.map(std::path::PathBuf::from),
3955        }
3956    }
3957
3958    /// The env a command will run with, as set on the command itself:
3959    /// `Some(None)` is an explicit removal, `None` means inherited.
3960    fn command_env(
3961        command: &tokio::process::Command,
3962        key: &str,
3963    ) -> Option<Option<std::ffi::OsString>> {
3964        command
3965            .as_std()
3966            .get_envs()
3967            .find(|(k, _)| *k == key)
3968            .map(|(_, v)| v.map(ToOwned::to_owned))
3969    }
3970
3971    /// Mirrors the order run_once applies them in.
3972    fn daemon_command(
3973        identity: &RunIdentity,
3974        daemon_env: Option<&IndexMap<String, String>>,
3975    ) -> tokio::process::Command {
3976        let mut command = tokio::process::Command::new("true");
3977        apply_identity_env(&mut command, identity);
3978        apply_runtime_env(
3979            &mut command,
3980            &DaemonId::new("identity-env-test", "api"),
3981            0,
3982            daemon_env,
3983            &[],
3984        );
3985        command
3986    }
3987
3988    #[test]
3989    fn test_identity_env_describes_the_switched_user() {
3990        let command = daemon_command(&switch_to(Some("alice"), Some("/home/alice")), None);
3991        assert_eq!(
3992            command_env(&command, "HOME"),
3993            Some(Some("/home/alice".into()))
3994        );
3995        assert_eq!(command_env(&command, "USER"), Some(Some("alice".into())));
3996        assert_eq!(command_env(&command, "LOGNAME"), Some(Some("alice".into())));
3997    }
3998
3999    #[test]
4000    fn test_identity_env_leaves_inherited_env_alone() {
4001        let command = daemon_command(&RunIdentity::Inherit, None);
4002        assert_eq!(command_env(&command, "HOME"), None);
4003        assert_eq!(command_env(&command, "USER"), None);
4004        assert_eq!(command_env(&command, "LOGNAME"), None);
4005    }
4006
4007    #[test]
4008    fn test_root_sudo_to_root_preserves_environment() {
4009        let identity = resolve_run_identity(None, 0, 0, Some("0"), Some("0")).unwrap();
4010        assert_eq!(identity, RunIdentity::Inherit);
4011        let command = daemon_command(&identity, None);
4012        for key in ["HOME", "USER", "LOGNAME"] {
4013            assert_eq!(command_env(&command, key), None);
4014        }
4015    }
4016
4017    #[test]
4018    fn test_daemon_env_overrides_identity_env() {
4019        let mut env = IndexMap::new();
4020        env.insert("HOME".to_string(), "/srv/app".to_string());
4021        env.insert("USER".to_string(), "app".to_string());
4022        let command = daemon_command(&switch_to(Some("alice"), Some("/home/alice")), Some(&env));
4023        assert_eq!(command_env(&command, "HOME"), Some(Some("/srv/app".into())));
4024        assert_eq!(command_env(&command, "USER"), Some(Some("app".into())));
4025        assert_eq!(command_env(&command, "LOGNAME"), Some(Some("alice".into())));
4026    }
4027
4028    #[test]
4029    fn test_identity_env_drops_values_without_a_passwd_entry() {
4030        // A sudo uid with no passwd entry: the supervisor's values would still
4031        // describe root, so they are removed instead.
4032        let command = daemon_command(&switch_to(None, None), None);
4033        assert_eq!(command_env(&command, "HOME"), Some(None));
4034        assert_eq!(command_env(&command, "USER"), Some(None));
4035        assert_eq!(command_env(&command, "LOGNAME"), Some(None));
4036    }
4037
4038    #[test]
4039    fn test_daemon_env_restores_values_without_a_passwd_entry() {
4040        let mut env = IndexMap::new();
4041        env.insert("HOME".to_string(), "/srv/app".to_string());
4042        let command = daemon_command(&switch_to(None, None), Some(&env));
4043        assert_eq!(command_env(&command, "HOME"), Some(Some("/srv/app".into())));
4044    }
4045}
4046
4047/// Inject proxy-related environment variables into a daemon's command.
4048///
4049/// Adds:
4050/// - `HOST` — the address the daemon should bind to (`127.0.0.1`, omitted in LAN mode)
4051/// - `PITCHFORK_URL` — the public proxy URL for this daemon (if it has a slug)
4052/// - `PITCHFORK_CA_FILE` / `NODE_EXTRA_CA_CERTS` — path to the pitchfork CA cert (if HTTPS enabled)
4053/// - `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` — `.<tld>` for Vite host allowlisting
4054/// - `PITCHFORK_LAN` — set to `"1"` when LAN mode is active
4055fn inject_proxy_env(cmd: &mut tokio::process::Command, host: &Option<String>) {
4056    let s = crate::settings::settings();
4057    let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
4058
4059    if s.proxy.enable && host.is_some() && !lan_enabled {
4060        // Only force loopback binding for daemons the proxy actually routes to.
4061        // In LAN mode, daemons need to bind to 0.0.0.0 to be reachable from the network.
4062        cmd.env("HOST", "127.0.0.1");
4063    }
4064
4065    // PITCHFORK_URL: the daemon's public proxy URL (only if it is routed and proxy is enabled)
4066    if let Some(url) = build_pitchfork_url(host, &s) {
4067        cmd.env("PITCHFORK_URL", &url);
4068    }
4069
4070    // PITCHFORK_CA_FILE / NODE_EXTRA_CA_CERTS: let daemons verify TLS to each
4071    // other through the proxy.  `PITCHFORK_CA_FILE` is the runtime-agnostic
4072    // name; most TLS libraries take a CA bundle path from configuration, and
4073    // several read one straight out of the environment (for example
4074    // `SSL_CERT_FILE` for OpenSSL or `REQUESTS_CA_BUNDLE` for Python).
4075    if s.proxy.enable && s.proxy.https {
4076        let ca_path = if s.proxy.tls_cert.is_empty() {
4077            crate::env::PITCHFORK_STATE_DIR.join("proxy").join("ca.pem")
4078        } else {
4079            std::path::PathBuf::from(&s.proxy.tls_cert)
4080        };
4081        if ca_path.exists() {
4082            let ca_path = ca_path.to_string_lossy().to_string();
4083            cmd.env("PITCHFORK_CA_FILE", &ca_path);
4084            cmd.env("NODE_EXTRA_CA_CERTS", &ca_path);
4085        }
4086    }
4087
4088    // __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: Vite host allowlisting
4089    if s.proxy.enable {
4090        let tld = if lan_enabled { "local" } else { &s.proxy.tld };
4091        cmd.env("__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS", format!(".{tld}"));
4092    }
4093
4094    // PITCHFORK_LAN: signal to daemons that LAN mode is active
4095    if lan_enabled {
4096        cmd.env("PITCHFORK_LAN", "1");
4097    }
4098}
4099
4100/// The hostname the proxy routes to this daemon, without the TLD.
4101///
4102/// A daemon registered under a legacy `[slugs]` entry keeps that spelling,
4103/// because the proxy resolves slugs first. Otherwise the hostname is derived
4104/// from where the daemon's configuration lives.
4105async fn daemon_proxy_host(opts: &RunOptions) -> Option<String> {
4106    // Nothing consumes a hostname while the proxy is off, and deriving one
4107    // reads configuration and walks the project, so daemon starts skip it.
4108    if !crate::settings::settings().proxy.enable {
4109        return None;
4110    }
4111    // A slug carried on the run options skips the lookup below, so it needs the
4112    // same length check that lookup applies; otherwise the daemon is told a URL
4113    // the proxy refuses to route.
4114    if let Some(slug) = opts.slug.as_deref()
4115        && crate::proxy::hostname::hostname_fits(slug)
4116    {
4117        return opts.slug.clone();
4118    }
4119    // The daemon's own `dir` can point outside its project, so look the config
4120    // up from where it was defined.
4121    let config_dir = opts
4122        .watch_base_dir
4123        .clone()
4124        .unwrap_or_else(|| opts.dir.0.clone());
4125    let id = opts.id.clone();
4126    // Reading the config, the slug registry and the project's checkouts is all
4127    // file I/O, so it happens together on a blocking worker rather than on the
4128    // supervisor's executor. The lookup is the one the CLI and the proxy use,
4129    // so the daemon is told the address they advertise for it — a registered
4130    // slug when it has one, otherwise its automatic hostname.
4131    tokio::task::spawn_blocking(move || {
4132        let pt = crate::pitchfork_toml::PitchforkToml::all_merged_from(&config_dir).ok()?;
4133        let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
4134        crate::proxy::hostname::host_for_daemon(&id, pt.daemons.get(&id), &slugs)
4135    })
4136    .await
4137    .unwrap_or_default()
4138}
4139
4140/// Compute the public proxy URL for a daemon.
4141///
4142/// Returns `None` if the daemon has no hostname or the proxy is not enabled.
4143fn build_pitchfork_url(host: &Option<String>, s: &crate::settings::Settings) -> Option<String> {
4144    crate::proxy::build_proxy_url(host.as_deref(), s)
4145}
4146
4147#[cfg(test)]
4148mod ready_check_tests {
4149    use super::*;
4150    use std::time::Duration;
4151
4152    #[test]
4153    fn any_ready_check_remaining_prefers_unbounded_checks() {
4154        let http = ReadyHttp::new("http://localhost/health");
4155        let cmd = ReadyCmd::new("true");
4156
4157        assert!(any_ready_check_remaining(
4158            None,
4159            false,
4160            None,
4161            false,
4162            Some(&http),
4163            false,
4164            None,
4165            false
4166        ));
4167        assert!(any_ready_check_remaining(
4168            None,
4169            false,
4170            None,
4171            false,
4172            None,
4173            false,
4174            Some(&cmd),
4175            false
4176        ));
4177        assert!(any_ready_check_remaining(
4178            None,
4179            false,
4180            Some(&ReadyPort::new(8080)),
4181            false,
4182            Some(&http),
4183            true,
4184            Some(&cmd),
4185            true
4186        ));
4187    }
4188
4189    #[test]
4190    fn any_ready_check_remaining_exhausted_timed_checks() {
4191        let http = ReadyHttp {
4192            url: "http://localhost/health".to_string(),
4193            status: vec![],
4194            timeout: Some(Duration::from_secs(5)),
4195        };
4196        let cmd = ReadyCmd {
4197            run: "true".into(),
4198            timeout: Some(Duration::from_secs(5)),
4199        };
4200
4201        assert!(any_ready_check_remaining(
4202            None,
4203            false,
4204            None,
4205            false,
4206            Some(&http),
4207            false,
4208            Some(&cmd),
4209            false
4210        ));
4211        assert!(!any_ready_check_remaining(
4212            None,
4213            false,
4214            None,
4215            false,
4216            Some(&http),
4217            true,
4218            Some(&cmd),
4219            true
4220        ));
4221    }
4222
4223    #[tokio::test]
4224    async fn spawn_cmd_probe_reports_success() {
4225        let id = DaemonId::new("global", "probe-test");
4226        let probe = spawn_cmd_probe(&id, &"true".into(), &std::env::temp_dir(), 0, None, &[]);
4227        let status = probe.result_rx.await.unwrap().unwrap();
4228        assert!(status.success());
4229    }
4230
4231    /// The argv form runs the program with each argument exactly as written:
4232    /// the probe passes only if the program sees a single argument containing
4233    /// a space and characters a shell would expand.
4234    #[cfg(unix)]
4235    #[tokio::test]
4236    async fn spawn_cmd_probe_runs_an_argv_without_a_shell() {
4237        let id = DaemonId::new("global", "probe-test");
4238        let argv = |arg: &str| -> RunCommand {
4239            RunCommand::Argv(
4240                ["test", arg, "=", "a b $HOME %PATH% &"]
4241                    .map(str::to_string)
4242                    .to_vec(),
4243            )
4244        };
4245        let probe = spawn_cmd_probe(
4246            &id,
4247            &argv("a b $HOME %PATH% &"),
4248            &std::env::temp_dir(),
4249            0,
4250            None,
4251            &[],
4252        );
4253        assert!(probe.result_rx.await.unwrap().unwrap().success());
4254        // Through a shell, the unquoted string would split and expand, and fail.
4255        let probe = spawn_cmd_probe(
4256            &id,
4257            &"test a b $HOME %PATH% & = 'a b $HOME %PATH% &'".into(),
4258            &std::env::temp_dir(),
4259            0,
4260            None,
4261            &[],
4262        );
4263        assert!(!probe.result_rx.await.unwrap().unwrap().success());
4264        let probe = spawn_cmd_probe(&id, &argv("different"), &std::env::temp_dir(), 0, None, &[]);
4265        assert!(!probe.result_rx.await.unwrap().unwrap().success());
4266    }
4267
4268    #[tokio::test]
4269    async fn spawn_cmd_probe_stops_on_request() {
4270        let id = DaemonId::new("global", "probe-test");
4271        let probe = spawn_cmd_probe(&id, &"sleep 30".into(), &std::env::temp_dir(), 0, None, &[]);
4272        let CmdProbe {
4273            cancel_tx,
4274            result_rx,
4275        } = probe;
4276        let _ = cancel_tx.send(());
4277        let status = result_rx.await.unwrap().unwrap();
4278        assert!(!status.success());
4279    }
4280
4281    #[tokio::test]
4282    async fn spawn_cmd_probe_receives_daemon_and_resolved_port_environment() {
4283        let id = DaemonId::new("worktree", "api");
4284        let daemon_env = IndexMap::from([("CUSTOM_VALUE".to_string(), "yes".to_string())]);
4285        // Written for the platform's default shell: cmd.exe reads the probe
4286        // as written, so it needs cmd's own `%VAR%` syntax there.
4287        let check = if cfg!(windows) {
4288            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"#
4289        } else {
4290            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"#
4291        };
4292        let probe = spawn_cmd_probe(
4293            &id,
4294            &check.into(),
4295            &std::env::temp_dir(),
4296            2,
4297            Some(&daemon_env),
4298            &[4100, 5100],
4299        );
4300        let status = probe.result_rx.await.unwrap().unwrap();
4301        assert!(status.success());
4302    }
4303
4304    #[test]
4305    fn configured_ready_port_follows_expected_port_bump() {
4306        assert_eq!(resolve_configured_ready_port(3000, &[3000], &[3004]), 3004);
4307        assert_eq!(
4308            resolve_configured_ready_port(4000, &[3000, 4000], &[3003, 4003]),
4309            4003
4310        );
4311        assert_eq!(resolve_configured_ready_port(8080, &[3000], &[3004]), 8080);
4312    }
4313}
4314
4315#[cfg(test)]
4316mod launch_command_tests {
4317    use super::{invalid_argv_program, launch_command};
4318    use crate::daemon_id::DaemonId;
4319
4320    #[test]
4321    fn refuses_an_argv_whose_program_rendered_empty_or_to_exec() {
4322        let id = DaemonId::new("proj", "api");
4323        assert!(invalid_argv_program(&id, &[]).is_some());
4324        assert!(invalid_argv_program(&id, &words(&["", "server.js"])).is_some());
4325        assert!(invalid_argv_program(&id, &words(&["exec", "node"])).is_some());
4326        assert_eq!(invalid_argv_program(&id, &words(&["node", ""])), None);
4327    }
4328
4329    #[test]
4330    fn refuses_a_probe_array_whose_program_rendered_empty_or_to_exec() {
4331        use super::invalid_probe_program;
4332        use crate::pitchfork_toml::RunCommand;
4333        let id = DaemonId::new("proj", "api");
4334        for field in ["ready_cmd", "health_cmd"] {
4335            let check =
4336                |run: Vec<String>| invalid_probe_program(&id, field, Some(&RunCommand::Argv(run)));
4337            assert!(check(vec![]).unwrap().contains(field));
4338            assert!(check(words(&["", "-h"])).is_some());
4339            assert!(check(words(&["exec", "pg_isready"])).is_some());
4340            assert_eq!(check(words(&["pg_isready", ""])), None);
4341        }
4342        // The string form goes through the shell, and no command is fine.
4343        assert_eq!(
4344            invalid_probe_program(&id, "health_cmd", Some(&RunCommand::Shell(String::new()))),
4345            None
4346        );
4347        assert_eq!(invalid_probe_program(&id, "health_cmd", None), None);
4348    }
4349
4350    fn words(words: &[&str]) -> Vec<String> {
4351        words.iter().map(|w| w.to_string()).collect()
4352    }
4353
4354    #[test]
4355    fn starts_the_first_word_with_the_rest_as_arguments() {
4356        let argv = words(&["node", "my server.js", "--name=\"a b\"", "&", "%PATH%"]);
4357        assert_eq!(
4358            launch_command(argv, None),
4359            (
4360                "node".to_string(),
4361                words(&["my server.js", "--name=\"a b\"", "&", "%PATH%"])
4362            )
4363        );
4364    }
4365
4366    #[test]
4367    fn mise_receives_every_word_after_the_separator() {
4368        let argv = words(&["node", "my server.js", "'single'"]);
4369        let mise = std::path::Path::new("/opt/mise/bin/mise");
4370        let (program, args) = launch_command(argv, Some(mise));
4371        assert_eq!(program, mise.to_string_lossy());
4372        assert_eq!(
4373            args,
4374            words(&["x", "--", "node", "my server.js", "'single'"])
4375        );
4376    }
4377}
4378
4379#[cfg(test)]
4380mod output_reader_tests {
4381    use super::{forward_output_lines, output_line_text};
4382
4383    #[test]
4384    fn line_endings_are_removed() {
4385        assert_eq!(output_line_text(b"ready\n"), "ready");
4386        // A PTY's ONLCR, and a program that already wrote `\r\n` through it.
4387        assert_eq!(output_line_text(b"ready\r\n"), "ready");
4388        assert_eq!(output_line_text(b"ready\r\r\n"), "ready");
4389        // The last line of the output may have no newline at all.
4390        assert_eq!(output_line_text(b"ready"), "ready");
4391    }
4392
4393    #[tokio::test]
4394    async fn a_line_that_is_not_utf8_does_not_end_the_read() {
4395        // What cut the output off before: `next_line` fails on the second
4396        // line, and nothing after it was read.
4397        let output: &[u8] = b"before\nbad \xff\xfe bytes\nafter 1\nafter 2\nlast";
4398        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
4399        forward_output_lines(tokio::io::BufReader::new(output), tx).await;
4400
4401        let mut lines = Vec::new();
4402        while let Some(line) = rx.recv().await {
4403            lines.push(line.text);
4404        }
4405        assert_eq!(lines.len(), 5, "{lines:?}");
4406        assert_eq!(lines[0], "before");
4407        assert!(lines[1].starts_with("bad "), "{:?}", lines[1]);
4408        assert_eq!(&lines[2..], ["after 1", "after 2", "last"]);
4409    }
4410}
4411
4412#[cfg(test)]
4413mod output_reader_eio_tests {
4414    use super::forward_output_lines;
4415    use std::pin::Pin;
4416    use std::task::{Context, Poll};
4417
4418    /// Output that ends as a Linux PTY master does once the slave closes:
4419    /// the last bytes, with no newline, then `EIO` instead of end of file.
4420    struct EndsWithEio(Option<&'static [u8]>);
4421
4422    impl tokio::io::AsyncRead for EndsWithEio {
4423        fn poll_read(
4424            mut self: Pin<&mut Self>,
4425            _cx: &mut Context<'_>,
4426            buf: &mut tokio::io::ReadBuf<'_>,
4427        ) -> Poll<std::io::Result<()>> {
4428            match self.0.take() {
4429                Some(bytes) => {
4430                    buf.put_slice(bytes);
4431                    Poll::Ready(Ok(()))
4432                }
4433                None => Poll::Ready(Err(std::io::Error::from_raw_os_error(5))),
4434            }
4435        }
4436    }
4437
4438    #[tokio::test]
4439    async fn a_last_line_cut_off_by_eio_is_still_forwarded() {
4440        let reader = tokio::io::BufReader::new(EndsWithEio(Some(b"first\nlast without newline")));
4441        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
4442        forward_output_lines(reader, tx).await;
4443
4444        let mut lines = Vec::new();
4445        while let Some(line) = rx.recv().await {
4446            lines.push(line.text);
4447        }
4448        assert_eq!(lines, ["first", "last without newline"]);
4449    }
4450}