Skip to main content

pitchfork_cli/supervisor/
lifecycle.rs

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