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