Skip to main content

pitchfork_cli/supervisor/
mod.rs

1//! Supervisor module - daemon process supervisor
2//!
3//! This module is split into focused submodules:
4//! - `state`: State access layer (get/set operations)
5//! - `lifecycle`: Daemon start/stop operations
6//! - `adopt`: Re-adoption of orphaned daemons after a supervisor crash
7//! - `log_sink`: Out-of-process capture of daemon output
8//! - `autostop`: Autostop logic and boot daemon startup
9//! - `retry`: Retry logic with backoff
10//! - `watchers`: Background tasks (interval, cron, file watching)
11//! - `ipc_handlers`: IPC request dispatch
12
13mod adopt;
14mod autostop;
15mod health;
16mod hooks;
17mod idle;
18mod ipc_handlers;
19mod lifecycle;
20mod log_sink;
21#[cfg(unix)]
22mod pty;
23mod retry;
24mod state;
25mod watchers;
26
27use crate::daemon_id::DaemonId;
28use crate::daemon_status::DaemonStatus;
29use crate::deps::reverse_stop_order;
30use crate::ipc::server::{IpcServer, IpcServerHandle, StartupLock};
31
32use crate::procs::PROCS;
33use crate::settings::settings;
34use crate::state_file::StateFile;
35use crate::{Result, env};
36#[cfg(unix)]
37use duct::cmd;
38#[cfg(unix)]
39use miette::IntoDiagnostic;
40use once_cell::sync::Lazy;
41use std::collections::HashMap;
42use std::collections::HashSet;
43use std::fs;
44#[cfg(unix)]
45use std::os::unix::fs::PermissionsExt;
46use std::path::PathBuf;
47use std::process::exit;
48use std::sync::atomic;
49use std::sync::atomic::{AtomicBool, AtomicU32};
50use std::time::Duration;
51#[cfg(unix)]
52use tokio::signal::unix::SignalKind;
53use tokio::sync::{Mutex, Notify};
54use tokio::task::JoinHandle;
55use tokio::{signal, time};
56
57/// Exit statuses reaped by the container-mode zombie reaper for managed daemon
58/// PIDs. On non-Linux Unix platforms where `waitid(WNOWAIT)` is unavailable,
59/// `waitpid(None, WNOHANG)` may race with Tokio's `child.wait()`. When the
60/// zombie reaper wins, the exit status is stashed here so the monitoring task
61/// in lifecycle.rs can recover it instead of treating the ECHILD as a failure.
62///
63/// On Linux this map is unused because the reaper uses `waitid` with `WNOWAIT`
64/// to peek before reaping, which avoids the race entirely.
65#[cfg(all(unix, not(target_os = "linux")))]
66pub(crate) static REAPED_STATUSES: Lazy<Mutex<HashMap<u32, i32>>> =
67    Lazy::new(|| Mutex::new(HashMap::new()));
68
69// Re-export types needed by other modules
70pub(crate) use state::UpsertDaemonOpts;
71
72pub struct Supervisor {
73    pub(crate) state_file: Mutex<StateFile>,
74    pub(crate) pending_notifications: Mutex<Vec<(log::LevelFilter, String)>>,
75    pub(crate) last_refreshed_at: Mutex<time::Instant>,
76    /// Daemons whose retry sequence a foreground `run` is already working
77    /// through, each with the flag that asks it to stop. The backoff between
78    /// its attempts leaves the record errored with no PID, which is exactly
79    /// what `check_retry` looks for, so without this the background checker
80    /// would start the next attempt itself and the foreground call would be
81    /// left reporting on a run it does not own. `stop` raises the flag, so the
82    /// sequence ends rather than starting another attempt behind the user's
83    /// back.
84    /// One flag per claim: two starts can be working through the same
85    /// daemon's retries at once, and a stop has to reach all of them.
86    pub(crate) retrying:
87        std::sync::Mutex<HashMap<DaemonId, Vec<std::sync::Arc<std::sync::atomic::AtomicBool>>>>,
88    /// How many times each daemon has been stopped. The retry checker reads
89    /// this when it decides to run an attempt and again when it is about to
90    /// start one, holding the daemon's lock: a stop in between means the
91    /// attempt it approved is one the user has since called off.
92    pub(crate) stop_epochs: std::sync::Mutex<HashMap<DaemonId, u64>>,
93    /// Map of daemon ID to scheduled autostop time
94    pub(crate) pending_autostops: Mutex<HashMap<DaemonId, time::Instant>>,
95    /// Autostop stops that have been spawned as detached tasks but have not
96    /// yet begun stopping. `cancel_pending_autostops_for_dir` flips the flag
97    /// to call off the stop when a shell re-enters the directory while the
98    /// stop task is still in flight.
99    pub(crate) in_flight_autostops:
100        Mutex<HashMap<DaemonId, std::sync::Arc<std::sync::atomic::AtomicBool>>>,
101    /// Handle for graceful IPC server shutdown
102    pub(crate) ipc_shutdown: Mutex<Option<IpcServerHandle>>,
103    /// Tracks in-flight hook tasks so shutdown can wait for them to complete
104    pub(crate) hook_tasks: Mutex<Vec<JoinHandle<()>>>,
105    /// Number of monitoring tasks that are still running (between process exit
106    /// and hook registration completion). Used by `close()` to know when it is
107    /// safe to drain `hook_tasks`.
108    pub(crate) active_monitors: AtomicU32,
109    /// Signalled by each monitoring task after it finishes registering hooks
110    /// (or decides it has nothing to register). `close()` waits on this.
111    pub(crate) monitor_done: Notify,
112    /// Cancellation token for the proxy server — cancelled on shutdown to
113    /// stop accepting new connections and drain in-flight ones.
114    pub(crate) proxy_cancel: Mutex<Option<tokio_util::sync::CancellationToken>>,
115    /// Join handle for the proxy task so shutdown can wait for cleanup.
116    pub(crate) proxy_task: Mutex<Option<JoinHandle<()>>>,
117    /// Join handle for the loopback DNS resolver, cancelled by `proxy_cancel`.
118    pub(crate) dns_task: Mutex<Option<JoinHandle<()>>>,
119    /// mDNS publisher for LAN mode (None if LAN mode is disabled).
120    /// Shared with the LAN IP monitor task so it can re-publish on IP change.
121    pub(crate) mdns_publisher:
122        Mutex<Option<std::sync::Arc<tokio::sync::Mutex<crate::proxy::mdns::MdnsPublisher>>>>,
123    /// Join handle for the LAN IP monitor task.
124    pub(crate) lan_monitor_task: Mutex<Option<JoinHandle<()>>>,
125    /// Cancellation token for the background state flush task.
126    pub(crate) flush_cancel: std::sync::Mutex<Option<tokio_util::sync::CancellationToken>>,
127    /// Daemons that currently have a live monitoring task (child `wait()`
128    /// monitor or adopted-orphan poll monitor), keyed to the PID being
129    /// monitored plus a unique registration token. Lets orphan
130    /// reconciliation tell a supervised daemon from one whose monitor died
131    /// with a previous supervisor process.
132    pub(crate) monitored: std::sync::Mutex<HashMap<DaemonId, adopt::MonitorEntry>>,
133    /// Where to deliver output a log sink reports over IPC.
134    ///
135    /// A daemon whose output is captured by a sink writes nothing this process
136    /// reads, so the sink evaluates the daemon's readiness pattern itself and
137    /// sends back the line that matched. The monitoring task registers here
138    /// before the sink starts and unregisters when it ends, so a line arriving
139    /// from a sink that outlived its daemon has nowhere to go and is dropped.
140    pub(crate) sink_output: std::sync::Mutex<HashMap<DaemonId, log_sink::Relay>>,
141    /// Per-daemon stop locks. A stop holds the daemon's lock for its whole
142    /// duration (which now includes waiting for the entire process group to
143    /// exit), and starts/orphan-cleanup acquire it first — serializing them
144    /// against in-flight stops instead of racing the Stopping window.
145    pub(crate) stop_locks: Mutex<HashMap<DaemonId, std::sync::Arc<tokio::sync::Mutex<()>>>>,
146    /// Set when `close()` begins. From then on this supervisor's own
147    /// state-file record is on its way out and must not be restored.
148    pub(crate) shutting_down: AtomicBool,
149    /// Set once the starts under way when shutdown began have finished (see
150    /// `freeze_starts`). Everyone who needs the final list of daemons waits
151    /// on the same freeze.
152    starts_frozen: tokio::sync::OnceCell<()>,
153}
154
155/// A line of daemon output on its way to the monitoring task.
156#[derive(Debug, Clone)]
157pub(crate) struct OutputLine {
158    pub(crate) text: String,
159    pub(crate) source: OutputSource,
160}
161
162/// Where a line of daemon output came from, which decides what is left to do
163/// with it.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub(crate) enum OutputSource {
166    /// Read by this process from the daemon's stdout, stderr or PTY master.
167    /// Nothing has been done with it yet.
168    Local,
169    /// Reported by the daemon's log sink, which has already written the line to
170    /// the store — storing it again here would duplicate it.
171    ///
172    /// `fires_hook` is the sink's answer to whether this line passed the
173    /// `on_output` hook's filter and debounce. It is carried rather than
174    /// re-derived because a line can be reported for readiness alone, and
175    /// firing a hook that filters for something else would be wrong.
176    Sink { fires_hook: bool },
177}
178
179pub(crate) fn interval_duration() -> Duration {
180    settings().general_interval()
181}
182
183pub static SUPERVISOR: Lazy<Supervisor> =
184    Lazy::new(|| Supervisor::new().expect("Error creating supervisor"));
185
186pub async fn start_if_not_running() -> Result<()> {
187    let sf = StateFile::get();
188    if let Some(d) = sf.daemons.get(&DaemonId::pitchfork())
189        && supervisor_record_is_live(d)
190    {
191        return Ok(());
192    }
193    // The record can be missing or stale while a supervisor keeps serving
194    // IPC, e.g. when state.toml was replaced or rewritten by another tool.
195    // Starting another one would take over the socket and leave the running
196    // supervisor, and every daemon it manages, unreachable.
197    if crate::ipc::supervisor_listening().await {
198        debug!("supervisor is listening on the IPC socket but not recorded in the state file");
199        return Ok(());
200    }
201    start_in_background()
202}
203
204/// How long `supervisor start/run --force` waits for the supervisor it
205/// replaces to stop serving IPC. A supervisor being stopped keeps its socket until its daemons
206/// have stopped, which can take a while.
207pub(crate) const IPC_SOCKET_RELEASE_TIMEOUT: Duration = Duration::from_secs(30);
208
209/// How long a daemon's monitor keeps reading output left in its pipe after
210/// the process exits, before it records the exit. A child that inherited the
211/// pipe can hold it open for all of that.
212pub(crate) const EXIT_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
213/// How often changes to the state are written to the state file.
214pub(crate) const STATE_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
215
216/// How long `close()` waits for the LAN IP monitor to stop on its own.
217const LAN_MONITOR_STOP_WAIT: Duration = Duration::from_secs(1);
218/// How long `close()` waits for the DNS resolver to stop on its own.
219const DNS_STOP_WAIT: Duration = Duration::from_secs(5);
220/// How long `close()` waits for the proxy: longer than its own drain budget,
221/// so it finishes on its own terms rather than being cut off mid-drain.
222const PROXY_STOP_WAIT: Duration =
223    Duration::from_secs(crate::proxy::server::SHUTDOWN_DRAIN_BUDGET.as_secs() + 2);
224/// The longest `close()` can spend before it starts stopping daemons.
225const SHUTDOWN_PRE_STOP_BUDGET: Duration = Duration::from_secs(
226    LAN_MONITOR_STOP_WAIT.as_secs() + DNS_STOP_WAIT.as_secs() + PROXY_STOP_WAIT.as_secs(),
227);
228/// How long `close()` waits, once the daemons are stopped, for their
229/// monitoring tasks to register the hooks they fire.
230const MONITOR_DRAIN_WAIT: Duration = Duration::from_secs(5);
231/// How long `close()` then waits for those hooks to finish, all together.
232const HOOK_DRAIN_WAIT: Duration = Duration::from_secs(30);
233/// The longest `close()` can spend after stopping daemons.
234const SHUTDOWN_POST_STOP_BUDGET: Duration =
235    Duration::from_secs(MONITOR_DRAIN_WAIT.as_secs() + HOOK_DRAIN_WAIT.as_secs());
236
237/// Wait until no supervisor is listening on the IPC socket, failing if one
238/// still is after [`IPC_SOCKET_RELEASE_TIMEOUT`].
239///
240/// Used when replacing a supervisor: the old one keeps serving IPC while it
241/// shuts down, and binding the socket before it lets go would leave clients
242/// talking to whichever supervisor they happen to reach.
243pub(crate) async fn wait_for_ipc_socket_release() -> Result<()> {
244    let deadline = time::Instant::now() + IPC_SOCKET_RELEASE_TIMEOUT;
245    let mut waiting = false;
246    while crate::ipc::supervisor_listening().await {
247        if time::Instant::now() >= deadline {
248            return Err(miette::miette!(
249                "another pitchfork supervisor is still listening on {} after {}s; \
250                 stop it with `pitchfork supervisor stop` before starting a new one",
251                crate::ipc::socket_display(),
252                IPC_SOCKET_RELEASE_TIMEOUT.as_secs()
253            ));
254        }
255        if !waiting {
256            info!(
257                "waiting for the supervisor listening on {} to shut down",
258                crate::ipc::socket_display()
259            );
260            waiting = true;
261        }
262        time::sleep(Duration::from_millis(100)).await;
263    }
264    Ok(())
265}
266
267/// Whether the supervisor's own state-file record still describes a live
268/// pitchfork supervisor, rather than a stale entry whose PID the OS has since
269/// handed to an unrelated process.
270///
271/// The record survives crashes and reboots, so a bare liveness probe on its
272/// PID is not enough: after a reboot low PIDs go to early system daemons, and
273/// a `kill(pid, 0)` on one of those says "alive". The check therefore also
274/// requires the identity the supervisor recorded about itself at startup to
275/// match the live process — see [`supervisor_identity_matches`]. When the
276/// PID is alive but the identity does not match, the record is stale; callers
277/// are free to overwrite it and must never signal that PID.
278pub(crate) fn supervisor_record_is_live(record: &crate::daemon::Daemon) -> bool {
279    let Some(pid) = record.pid else {
280        return false;
281    };
282    if !PROCS.is_running(pid) {
283        return false;
284    }
285    if record.start_time.is_none() && record.boot_time.is_none() {
286        // A record from a supervisor older than v2.18.0 carries no identity
287        // at all, so nothing can contradict it. Rather than trust any live
288        // PID, require the process to at least be a pitchfork binary: that
289        // rejects the reboot case (an unrelated system daemon on the PID)
290        // while a still-running old supervisor stays recognisable.
291        PROCS.refresh_pids(&[pid]);
292        let title = PROCS.title(pid);
293        if legacy_supervisor_title_matches(title.as_deref()) {
294            return true;
295        }
296        warn!(
297            "pid {pid} recorded for the supervisor by an older pitchfork is now {title:?}, not a pitchfork process; treating the record as stale"
298        );
299        return false;
300    }
301    if supervisor_identity_matches(
302        record.start_time,
303        PROCS.start_time(pid),
304        record.boot_time,
305        PROCS.boot_time(),
306    ) {
307        return true;
308    }
309    warn!(
310        "pid {pid} recorded for the supervisor belongs to another process now (recorded start_time {:?} boot_time {:?}, live start_time {:?} boot_time {}); treating the record as stale",
311        record.start_time,
312        record.boot_time,
313        PROCS.start_time(pid),
314        PROCS.boot_time()
315    );
316    false
317}
318
319/// Whether a live process name can be a pitchfork supervisor. Only used for
320/// legacy records that carry no start or boot time (see
321/// [`supervisor_record_is_live`]); an unreadable name is not accepted, since
322/// the record has nothing else vouching for it.
323pub(crate) fn legacy_supervisor_title_matches(title: Option<&str>) -> bool {
324    title.is_some_and(|t| t.to_ascii_lowercase().starts_with("pitchfork"))
325}
326
327/// Whether a live process can be the supervisor a state-file record describes.
328///
329/// The kernel start token is the identity, as in [`process_identity_matches`]:
330/// when both the recorded and the live token can be read, they alone decide.
331/// Equal tokens mean the same process generation; different tokens mean the
332/// PID was recycled, within this boot or across a reboot.
333///
334/// The recorded boot time is only consulted when a token is missing on either
335/// side. It must not veto matching tokens: on Linux and macOS the reported
336/// boot time is derived from the realtime clock, so an NTP step or a
337/// sleep/resume moves it while the supervisor keeps running, and treating that
338/// as a reboot would spawn a second supervisor and orphan the first. Without
339/// tokens, though, a boot time from a previous boot is the one thing that can
340/// still prove the record stale, and a record predating both fields is not
341/// contradicted by anything (callers apply a weaker check to those).
342pub(crate) fn supervisor_identity_matches(
343    recorded_start_time: Option<u64>,
344    current_start_time: Option<u64>,
345    recorded_boot_time: Option<u64>,
346    current_boot_time: u64,
347) -> bool {
348    match (recorded_start_time, current_start_time) {
349        (Some(recorded), Some(current)) => recorded == current,
350        _ => recorded_boot_time.is_none_or(|recorded| {
351            recorded.abs_diff(current_boot_time) <= BOOT_TIME_TOLERANCE_SECS
352        }),
353    }
354}
355
356pub fn start_in_background() -> Result<()> {
357    debug!("starting supervisor in background");
358    // Ensure the log directory exists so we can redirect stderr there.
359    // Panics and other fatal errors from the background supervisor process
360    // would otherwise be silently swallowed.
361    let log_file = &*env::PITCHFORK_LOG_FILE;
362    if let Some(parent) = log_file.parent() {
363        let _ = fs::create_dir_all(parent);
364    }
365    #[cfg(unix)]
366    fix_state_dir_permissions();
367
368    // On Unix, use duct with stderr redirected to the log file.
369    #[cfg(unix)]
370    {
371        let stderr_file = fs::OpenOptions::new()
372            .create(true)
373            .append(true)
374            .open(log_file)
375            .into_diagnostic()?;
376        // Computed here rather than in the child: sysconf is not on the
377        // async-signal-safe list.
378        let max_fd = max_inherited_fd();
379        cmd!(&*env::PITCHFORK_BIN, "supervisor", "run")
380            .env_remove("PITCHFORK_CONFIG")
381            .stdin_null()
382            .stdout_null()
383            .stderr_file(stderr_file)
384            .before_spawn(move |cmd| {
385                use std::os::unix::process::CommandExt;
386                // SAFETY: the hook only issues close_range/fcntl/setsid
387                // syscalls, which are async-signal-safe, and does not allocate.
388                unsafe {
389                    cmd.pre_exec(move || {
390                        // Leave the caller's session and process group. Left in
391                        // them, the supervisor would receive the SIGHUP the
392                        // kernel sends the foreground process group when the
393                        // controlling terminal hangs up (an ssh logout, a closed
394                        // terminal), and SIGHUP makes it stop every daemon.
395                        // The result is deliberately ignored. In a freshly forked
396                        // child setsid() fails only if a sandbox (seccomp, ...)
397                        // denies it, or the child is already a process group
398                        // leader, which duct never arranges. Failing the spawn
399                        // would stop pitchfork working at all there, which is
400                        // worse than a supervisor that is merely not detached:
401                        // that is how it behaved before. This hook cannot log
402                        // (async-signal-safe only), so it cannot warn either.
403                        let _ = libc::setsid();
404                        cloexec_inherited_fds(max_fd);
405                        Ok(())
406                    });
407                }
408                Ok(())
409            })
410            .start()
411            .into_diagnostic()?;
412    }
413
414    // On Windows, use CreateProcessW directly with bInheritHandles=FALSE.
415    // std::process::Command always sets bInheritHandles=TRUE when any stdio
416    // handle is configured (even Stdio::null()), which causes the background
417    // supervisor to inherit ALL inheritable handles from the parent —
418    // including bats' stdout capture pipe. The supervisor keeps the pipe
419    // open after the CLI exits, and bats hangs forever waiting for EOF.
420    //
421    // CreateProcessW with bInheritHandles=FALSE prevents any handle
422    // inheritance. We pass NUL device handles for stdin/stdout/stderr
423    // via STARTUPINFO without inheriting any parent handles.
424    #[cfg(windows)]
425    {
426        use windows_sys::Win32::Foundation::{CloseHandle, FALSE};
427        use windows_sys::Win32::System::Threading::{
428            CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DETACHED_PROCESS,
429            PROCESS_INFORMATION, STARTUPINFOW,
430        };
431
432        // With bInheritHandles=FALSE, the child inherits NO parent handles.
433        // The supervisor uses its own internal file-based logger
434        // (PITCHFORK_LOG_FILE), so it doesn't need stdio from the parent.
435        // We don't set STARTF_USESTDHANDLES because that flag requires
436        // bInheritHandles=TRUE to function correctly per Microsoft docs.
437        // Without stdio handles, the detached process gets null stdio by
438        // default, which is exactly what we want.
439        let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() };
440        si.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
441
442        let bin_path = &*env::PITCHFORK_BIN;
443        let mut cmd_line: Vec<u16> = format!("\"{}\" supervisor run\0", bin_path.to_string_lossy())
444            .encode_utf16()
445            .collect();
446
447        use std::os::windows::ffi::OsStrExt;
448        let mut vars: Vec<_> = std::env::vars_os()
449            .filter(|(k, _)| !k.to_string_lossy().eq_ignore_ascii_case("PITCHFORK_CONFIG"))
450            .collect();
451        vars.sort_by_key(|(k, _)| k.to_string_lossy().to_uppercase());
452        let mut environment = Vec::<u16>::new();
453        for (key, value) in vars {
454            environment.extend(key.encode_wide());
455            environment.push(b'=' as u16);
456            environment.extend(value.encode_wide());
457            environment.push(0);
458        }
459        environment.extend([0, 0]);
460        let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
461        let ok = unsafe {
462            CreateProcessW(
463                std::ptr::null(),
464                cmd_line.as_mut_ptr(),
465                std::ptr::null(),
466                std::ptr::null(),
467                FALSE, // bInheritHandles = FALSE — the whole point
468                DETACHED_PROCESS | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT,
469                environment.as_ptr().cast(),
470                std::ptr::null(),
471                &si,
472                &mut pi,
473            )
474        };
475
476        if ok == 0 {
477            return Err(miette::miette!(
478                "CreateProcessW failed for supervisor: {}",
479                std::io::Error::last_os_error()
480            ));
481        }
482
483        // Close process/thread handles — we don't need them (detached process).
484        unsafe {
485            CloseHandle(pi.hProcess);
486            CloseHandle(pi.hThread);
487        }
488    }
489
490    Ok(())
491}
492
493/// Upper bound (exclusive) for the fd scan in [`cloexec_fd_scan`], used when
494/// `close_range` is unavailable (macOS, Linux < 5.11).
495#[cfg(unix)]
496fn max_inherited_fd() -> libc::c_int {
497    // SAFETY: sysconf has no preconditions.
498    let open_max = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) };
499    // RLIMIT_NOFILE alone is not a safe bound: a caller can open a
500    // high-numbered descriptor and then lower the limit before running us.
501    // Also list the fds that are actually open (both macOS and Linux provide
502    // /dev/fd) so the scan reaches every one of them.
503    let open_fds = std::fs::read_dir("/dev/fd")
504        .into_iter()
505        .flatten()
506        .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok());
507    fd_scan_end(open_max, open_fds)
508}
509
510#[cfg(unix)]
511fn fd_scan_end(
512    open_max: libc::c_long,
513    open_fds: impl IntoIterator<Item = libc::c_int>,
514) -> libc::c_int {
515    // -1 means "no limit"; cap the scan so an unlimited or huge RLIMIT_NOFILE
516    // doesn't make spawning the supervisor noticeably slow.
517    let limit = if open_max <= 0 {
518        1 << 16
519    } else {
520        open_max.min(1 << 20) as libc::c_int
521    };
522    let past_highest_open = open_fds
523        .into_iter()
524        .max()
525        .map_or(0, |fd| fd.saturating_add(1));
526    limit.max(past_highest_open)
527}
528
529/// Keep descriptors the CLI inherited without O_CLOEXEC from leaking into the
530/// background supervisor (and from there into every daemon it spawns).
531///
532/// Callers routinely hand us such descriptors: bats' fd 3, or pipes from a
533/// wrapping script. The long-lived supervisor would otherwise hold them open
534/// forever, so whoever waits for EOF on the pipe (bats, `$(...)`, CI log
535/// capture) hangs even after the CLI has exited. This is the Unix counterpart
536/// of `bInheritHandles=FALSE` in the Windows branch of `start_in_background`.
537///
538/// Runs in the forked child after stdio has been set up on fds 0-2. It marks
539/// fds >= 3 close-on-exec instead of closing them outright so that std's
540/// internal exec-status pipe (already CLOEXEC) keeps working and exec failures
541/// are still reported to the parent. Must stay async-signal-safe: no
542/// allocation, no locks, syscalls only.
543#[cfg(unix)]
544fn cloexec_inherited_fds(max_fd: libc::c_int) {
545    #[cfg(any(target_os = "linux", target_os = "android"))]
546    {
547        // SAFETY: close_range takes plain integer arguments. CLOSE_RANGE_CLOEXEC
548        // needs Linux 5.11; older kernels return ENOSYS/EINVAL and we fall
549        // through to the scan below.
550        let ret = unsafe {
551            libc::syscall(
552                libc::SYS_close_range,
553                3 as libc::c_uint,
554                libc::c_uint::MAX,
555                libc::CLOSE_RANGE_CLOEXEC as libc::c_uint,
556            )
557        };
558        if ret == 0 {
559            return;
560        }
561    }
562    cloexec_fd_scan(max_fd);
563}
564
565/// Fallback for [`cloexec_inherited_fds`]: mark fds `3..max_fd` close-on-exec
566/// one at a time. Async-signal-safe.
567#[cfg(unix)]
568fn cloexec_fd_scan(max_fd: libc::c_int) {
569    for fd in 3..max_fd {
570        // SAFETY: fcntl on an arbitrary fd number is safe; unused numbers
571        // just fail with EBADF.
572        unsafe {
573            let flags = libc::fcntl(fd, libc::F_GETFD);
574            if flags >= 0 && flags & libc::FD_CLOEXEC == 0 {
575                libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
576            }
577        }
578    }
579}
580
581/// Decide whether a project session should be removed during refresh.
582///
583/// `recorded_title` is the title snapshot taken at the start of refresh.
584/// `session` is the current state entry re-read under the lock. If the state
585/// has been updated since the snapshot (e.g., re-entered with the same
586/// PID/dir but a new title), we must skip removal to avoid deleting the new
587/// session. The host PID lives in the session key now, so there is no
588/// `liveness_pid` field to compare against.
589#[cfg(any(unix, test))]
590fn should_remove_liveness_session(
591    session: &crate::state_file::ProjectSession,
592    recorded_title: &Option<String>,
593    current_title: Option<&str>,
594    is_running: bool,
595) -> bool {
596    // If the state was updated since the snapshot (e.g., re-entered with the
597    // same PID/dir but a new title), skip removal to avoid deleting the new
598    // session.
599    if session.liveness_title.as_ref() != recorded_title.as_ref() {
600        return false;
601    }
602    // Dead host process — evict.
603    if !is_running {
604        return true;
605    }
606    // Host is alive. Evict only on a real title mismatch (PID reuse). If no
607    // title was recorded, or the current title is unavailable, we cannot
608    // reliably detect PID reuse — keep the session rather than risk evicting
609    // a live process.
610    match (recorded_title.as_deref(), current_title) {
611        (Some(recorded), Some(current)) => recorded != current,
612        _ => false,
613    }
614}
615
616/// How long stopping `daemons` can take on shutdown, on top of `base`.
617///
618/// The supervisor stops its daemons dependents first, one level after
619/// another, and each can take its whole stop timeout plus the wait after
620/// SIGKILL. Killed before it is done, the supervisor leaves the daemons of the
621/// later levels running with no supervisor. So `base` (the
622/// `supervisor.stop_timeout` setting, also each daemon's default timeout) is
623/// extended by every running daemon's stop in turn: an upper bound, since the
624/// daemons of one level stop at the same time.
625pub(crate) fn daemons_stop_budget<'a>(
626    daemons: impl IntoIterator<Item = &'a crate::daemon::Daemon>,
627    base: std::time::Duration,
628) -> std::time::Duration {
629    let pitchfork_id = DaemonId::pitchfork();
630    daemons
631        .into_iter()
632        .filter(|d| d.pid.is_some() && d.id != pitchfork_id)
633        .map(|d| {
634            // A daemon without its own timeout is given the supervisor's.
635            let timeout = d.stop_signal.and_then(|s| s.timeout).unwrap_or(base);
636            timeout + crate::procs::PROCESS_GROUP_SIGKILL_WAIT
637        })
638        .fold(base, |total, stop| total.saturating_add(stop))
639}
640
641/// The longest the supervisor's whole shutdown can take with `daemons` to
642/// stop, so `supervisor stop` can allow for it before killing the supervisor:
643/// the daemons' stops (see [`daemons_stop_budget`]) and what `close()` does
644/// before and after them.
645pub(crate) fn shutdown_budget<'a>(
646    daemons: impl IntoIterator<Item = &'a crate::daemon::Daemon>,
647    base: std::time::Duration,
648) -> std::time::Duration {
649    daemons_stop_budget(daemons, base)
650        .saturating_add(SHUTDOWN_PRE_STOP_BUDGET)
651        .saturating_add(SHUTDOWN_POST_STOP_BUDGET)
652}
653
654/// Set when a graceful shutdown begins, by a signal or a `Shutdown` request.
655/// A signal received after that forces the exit (see `force_exit`).
656static SHUTDOWN_BEGUN: AtomicBool = AtomicBool::new(false);
657
658/// Taken by whichever exit ends the process: a graceful stop once `close()`
659/// has returned, or a forced exit as soon as it starts. The other then waits
660/// for the process to end instead of exiting with its own status, so a forced
661/// exit's status cannot be overtaken by a graceful exit with success.
662static EXIT_CLAIMED: AtomicBool = AtomicBool::new(false);
663
664/// Wait for an exit another path has claimed; it ends the process.
665async fn wait_for_claimed_exit() -> ! {
666    loop {
667        std::future::pending::<()>().await;
668    }
669}
670
671/// End a graceful stop with success, unless a forced exit claimed the process
672/// first.
673async fn exit_gracefully() -> ! {
674    if EXIT_CLAIMED.swap(true, atomic::Ordering::SeqCst) {
675        wait_for_claimed_exit().await
676    }
677    exit(0)
678}
679
680impl Supervisor {
681    pub fn new() -> Result<Self> {
682        Ok(Self {
683            state_file: Mutex::new(StateFile::read(&*env::PITCHFORK_STATE_FILE).unwrap_or_else(
684                |e| {
685                    warn!("failed to read state file, starting with empty state: {e}");
686                    StateFile::new(env::PITCHFORK_STATE_FILE.clone())
687                },
688            )),
689            last_refreshed_at: Mutex::new(time::Instant::now()),
690            pending_notifications: Mutex::new(vec![]),
691            retrying: std::sync::Mutex::new(HashMap::new()),
692            stop_epochs: std::sync::Mutex::new(HashMap::new()),
693            pending_autostops: Mutex::new(HashMap::new()),
694            in_flight_autostops: Mutex::new(HashMap::new()),
695            ipc_shutdown: Mutex::new(None),
696            hook_tasks: Mutex::new(Vec::new()),
697            active_monitors: AtomicU32::new(0),
698            monitor_done: Notify::new(),
699            proxy_cancel: Mutex::new(None),
700            proxy_task: Mutex::new(None),
701            dns_task: Mutex::new(None),
702            mdns_publisher: Mutex::new(None),
703            lan_monitor_task: Mutex::new(None),
704            flush_cancel: std::sync::Mutex::new(None),
705            monitored: std::sync::Mutex::new(HashMap::new()),
706            sink_output: std::sync::Mutex::new(HashMap::new()),
707            stop_locks: Mutex::new(HashMap::new()),
708            shutting_down: AtomicBool::new(false),
709            starts_frozen: tokio::sync::OnceCell::new(),
710        })
711    }
712
713    /// Get (or create) the per-daemon stop lock for `id`.
714    pub(crate) async fn stop_lock(&self, id: &DaemonId) -> std::sync::Arc<tokio::sync::Mutex<()>> {
715        self.stop_locks
716            .lock()
717            .await
718            .entry(id.clone())
719            .or_default()
720            .clone()
721    }
722
723    /// A restart in flight when the previous supervisor exited will never
724    /// finish: no one is left to start the new process. A record it left
725    /// without a process is recorded as stopped, and one whose old process
726    /// was still being stopped as stopping, which is what a plain stop
727    /// interrupted at the same point leaves for orphan cleanup to reconcile.
728    async fn settle_interrupted_restarts(&self) {
729        let mut state_file = self.state_file.lock().await;
730        let restarting: Vec<(DaemonId, bool)> = state_file
731            .daemons
732            .values()
733            .filter(|d| d.status.is_restarting())
734            .map(|d| (d.id.clone(), d.pid.is_some()))
735            .collect();
736        for (id, has_pid) in restarting {
737            let status = if has_pid {
738                DaemonStatus::Stopping
739            } else {
740                DaemonStatus::Stopped
741            };
742            debug!(
743                "daemon {id} was left restarting by a previous supervisor; recording it as {status}"
744            );
745            state_file.set_status(&id, status);
746        }
747    }
748
749    pub async fn start(
750        &self,
751        is_boot: bool,
752        container: bool,
753        web_port: Option<u16>,
754        web_path: Option<String>,
755    ) -> Result<()> {
756        // Ensure the state directory and its contents are accessible by non-root
757        // users. This is needed when the supervisor is started with `sudo` — all
758        // files it creates are owned by root, which prevents normal CLI clients
759        // from reading/writing state or connecting to the IPC socket.
760        #[cfg(unix)]
761        fix_state_dir_permissions();
762
763        // Ignoring Ctrl+C is inherited, so a supervisor started from a process
764        // that ignores it would neither see Ctrl+C itself nor let its daemons
765        // see it: a daemon with `stop_signal = "SIGINT"` would never get the
766        // Ctrl+C sent to stop it. Handle it again before any daemon starts.
767        #[cfg(windows)]
768        if unsafe { windows_sys::Win32::System::Console::SetConsoleCtrlHandler(None, 0) } == 0 {
769            warn!(
770                "failed to stop ignoring Ctrl+C: {}",
771                std::io::Error::last_os_error()
772            );
773        }
774
775        // Refuse to run beside a supervisor that is already listening, and
776        // do so before recording ourselves in the state file or starting any
777        // daemons: taking over its socket would leave it running but
778        // unreachable. (`--force` has already waited for the one it replaced
779        // to let go of the socket.) The lock is held until our own listener
780        // is bound, so a supervisor starting at the same time waits here and
781        // then finds this one listening.
782        let startup_lock = StartupLock::acquire().await?;
783        if crate::ipc::supervisor_listening().await {
784            return Err(miette::miette!(
785                "another pitchfork supervisor is already listening on {}",
786                crate::ipc::socket_display()
787            ));
788        }
789
790        let pid = std::process::id();
791        // Ensure PROCS has data for the supervisor PID before upsert_daemon reads title()
792        PROCS.refresh_pids(&[pid]);
793        // Determine container mode: CLI flag takes priority, then settings.
794        // Running as PID 1 always enables it: orphaned descendants of daemons
795        // re-parent to us, and without the zombie reaper they would accumulate
796        // as unreaped zombies — which also keep their process group alive,
797        // stalling whole-group stop waits indefinitely.
798        let container_mode =
799            container || settings().supervisor.container || std::process::id() == 1;
800        if container_mode {
801            info!("Starting supervisor in container/PID1 mode with pid {pid}");
802        } else {
803            info!("Starting supervisor with pid {pid}");
804        }
805
806        // Whether the previous supervisor exited uncleanly must be read before
807        // we record ourselves in the state file just below (see
808        // `supervisor_exited_uncleanly`); the background cleanup task runs
809        // after that record exists, so it receives the answer instead of
810        // reading it too late.
811        let unclean = supervisor_exited_uncleanly(self).await;
812
813        self.upsert_daemon(
814            UpsertDaemonOpts::builder(DaemonId::pitchfork())
815                .set(|o| {
816                    o.pid = Some(pid);
817                    o.status = DaemonStatus::Running;
818                })
819                .build(),
820        )
821        .await?;
822        self.settle_interrupted_restarts().await;
823        #[cfg(unix)]
824        fix_state_dir_permissions();
825
826        // Self-heal: if the boot registration points to a stale binary path
827        // (e.g. after a brew/mise upgrade), re-register with the current path.
828        // Runs in the background — must not block or fail supervisor startup.
829        tokio::task::spawn_blocking(|| {
830            if let Ok(boot_manager) = crate::boot_manager::BootManager::new() {
831                boot_manager.check_and_reregister_if_stale();
832            }
833        });
834
835        // If the previous supervisor died uncleanly, its daemon child processes
836        // may still be alive (orphaned, re-parented to init).  Terminate them
837        // before starting replacements so we don't end up with duplicate
838        // processes holding the same ports.
839        //
840        // This runs in the background: each orphan kill now waits for its
841        // whole process group to exit (seconds per orphan), and doing that
842        // inline would delay IPC socket creation past the CLI's short connect
843        // budget on autostart. Per-daemon stop locks serialize the cleanup
844        // against any Run/Stop requests that arrive for the same daemon in the
845        // meantime, and boot daemons start after cleanup completes so they
846        // cannot observe an orphan as "already running".
847        let boot_after_cleanup = is_boot;
848        tokio::spawn(async move {
849            cleanup_orphaned_daemons(&SUPERVISOR, unclean).await;
850            if boot_after_cleanup {
851                info!("Boot start mode enabled, starting boot_start daemons");
852                if let Err(e) = SUPERVISOR.start_boot_daemons().await {
853                    error!("failed to start boot daemons: {e}");
854                }
855            }
856        });
857
858        self.interval_watch()?;
859
860        // Run the first cron check synchronously before starting the cron
861        // watcher and IPC server. This registers config-only cron daemons and
862        // fires any `immediate=true` triggers in the foreground, so they cannot
863        // race with a concurrent `pitchfork start` IPC. By the time the cron
864        // watcher's first tick runs, `last_cron_triggered` is already anchored
865        // and the immediate daemons are already running.
866        if let Err(e) = self.check_cron_schedules().await {
867            error!("failed to check cron schedules on startup: {e}");
868        }
869
870        self.cron_watch()?;
871        self.signals()?;
872        self.daemon_file_watch().await?;
873
874        // In container mode, install SIGCHLD handler to reap orphaned/zombie processes
875        #[cfg(unix)]
876        if container_mode {
877            self.reap_zombies()?;
878        }
879
880        // Start web server: CLI --web-port takes priority, then settings.web.auto_start + bind_port
881        let s = settings();
882        let effective_port = web_port.or_else(|| {
883            if s.web.auto_start {
884                match u16::try_from(s.web.bind_port).ok().filter(|&p| p > 0) {
885                    Some(p) => Some(p),
886                    None => {
887                        error!(
888                            "web.bind_port {} is out of valid port range (1-65535), web UI disabled",
889                            s.web.bind_port
890                        );
891                        None
892                    }
893                }
894            } else {
895                None
896            }
897        });
898        // CLI --web-path takes priority, then settings.web.base_path
899        let effective_path = web_path.or_else(|| {
900            let bp = s.web.base_path.clone();
901            if bp.is_empty() { None } else { Some(bp) }
902        });
903        if let Some(port) = effective_port {
904            tokio::spawn(async move {
905                if let Err(e) = crate::web::serve(port, effective_path).await {
906                    error!("Web server error: {e}");
907                }
908            });
909        }
910
911        // Start standalone API server if configured
912        let api_port = if s.api.auto_start {
913            match u16::try_from(s.api.bind_port).ok().filter(|&p| p > 0) {
914                Some(p) => Some(p),
915                None => {
916                    error!(
917                        "api.bind_port {} is out of valid port range (1-65535), API server disabled",
918                        s.api.bind_port
919                    );
920                    None
921                }
922            }
923        } else {
924            None
925        };
926        if let Some(port) = api_port {
927            tokio::spawn(async move {
928                if let Err(e) = crate::web::serve_api(port, None).await {
929                    error!("API server error: {e}");
930                }
931            });
932        }
933
934        // Start reverse proxy server if enabled
935        if s.proxy.enable {
936            // Pre-generate the TLS certificate synchronously before spawning the proxy
937            // task. This ensures the cert exists immediately after `sup start` returns,
938            // so `proxy trust` can be run right away without waiting for the async task.
939            #[cfg(feature = "proxy-tls")]
940            if s.proxy.https {
941                let proxy_dir = crate::env::PITCHFORK_STATE_DIR.join("proxy");
942                let ca_cert_path = proxy_dir.join("ca.pem");
943                let ca_key_path = proxy_dir.join("ca-key.pem");
944                // Checked and written under the CA lock: `proxy setup` may be
945                // generating the same pair right now.
946                match crate::proxy::server::ensure_ca(&ca_cert_path, &ca_key_path, || {
947                    ca_cert_path.exists() && ca_key_path.exists()
948                }) {
949                    Ok(true) => {
950                        info!(
951                            "Generated local CA certificate at {}",
952                            ca_cert_path.display()
953                        );
954                    }
955                    Ok(false) => {}
956                    Err(e) => {
957                        error!("Failed to generate CA certificate: {e}");
958                    }
959                }
960
961                // Auto-trust: attempt to install the CA certificate into the
962                // system trust store. May fail silently due to permissions;
963                // user can run `pitchfork proxy trust` manually.
964                if s.proxy.auto_trust && ca_cert_path.exists() {
965                    use crate::proxy::trust::{AutoTrustResult, auto_trust};
966                    match auto_trust(&ca_cert_path) {
967                        AutoTrustResult::AlreadyTrusted => {}
968                        AutoTrustResult::Trusted => {
969                            info!("CA certificate auto-trusted in system store");
970                        }
971                        AutoTrustResult::NotTrusted { reason } => {
972                            warn!("Auto-trust skipped: {reason}");
973                            warn!("Run `pitchfork proxy trust` to install manually");
974                        }
975                    }
976                }
977            }
978            // Spawn the proxy server and wait for its bind result via a oneshot
979            // channel.  This avoids the TOCTOU race of a pre-flight bind check
980            // while still surfacing binding failures immediately.
981            let (bind_tx, bind_rx) = tokio::sync::oneshot::channel();
982            let proxy_cancel = tokio_util::sync::CancellationToken::new();
983            let proxy_cancel_clone = proxy_cancel.clone();
984            *self.proxy_cancel.lock().await = Some(proxy_cancel);
985            let proxy_task = tokio::spawn(async move {
986                if let Err(e) = crate::proxy::server::serve(bind_tx, proxy_cancel_clone).await {
987                    error!("Proxy server error: {e}");
988                }
989            });
990            *self.proxy_task.lock().await = Some(proxy_task);
991            match bind_rx.await {
992                Ok(Ok(())) => {
993                    info!("Proxy server bound successfully");
994                    // Resolved once and shared: mDNS and the DNS resolver
995                    // must advertise the same address.
996                    let lan_ip = self.resolve_lan_ip().await;
997                    self.start_mdns(lan_ip).await;
998                    self.start_dns_resolver(lan_ip).await;
999                }
1000                Ok(Err(msg)) => {
1001                    error!("{msg}");
1002                    self.add_notification(log::LevelFilter::Error, msg).await;
1003                }
1004                Err(_) => {
1005                    // Sender dropped without sending — serve() panicked or
1006                    // returned before signalling.  Already logged by the
1007                    // spawn error handler above.
1008                }
1009            }
1010        }
1011
1012        // Pre-warm slug cache so the first /api/proxies request is fast.
1013        // Spawned as a background task so it does not block startup.
1014        tokio::spawn(async {
1015            crate::proxy::server::get_cached_slugs().await;
1016        });
1017
1018        let (ipc, ipc_handle) = IpcServer::new(startup_lock).await?;
1019        *self.ipc_shutdown.lock().await = Some(ipc_handle);
1020        self.start_state_flush_task();
1021        self.conn_watch(ipc).await
1022    }
1023
1024    /// Start the loopback DNS resolver for the proxy TLD.
1025    ///
1026    /// The resolver shares the proxy's cancellation token, so it stops with the
1027    /// proxy. A bind failure is a notification rather than a fatal error: the
1028    /// proxy still works for anyone who reaches it some other way.
1029    async fn start_dns_resolver(&self, lan_ip: Option<std::net::Ipv4Addr>) {
1030        let s = crate::settings::settings();
1031        if !s.proxy.dns {
1032            return;
1033        }
1034        let cfg = crate::proxy::dns::config_from_settings(&s, lan_ip);
1035        let port = crate::proxy::dns::dns_port(&s);
1036        // Loopback only: the resolver is for this machine's stub resolver, and
1037        // LAN peers are served by mDNS instead.
1038        let addr = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port));
1039
1040        // The token's guard is held until the handle is stored. `close` takes
1041        // the token under this lock before it collects `dns_task`, so it either
1042        // runs first — and there is no token to spawn with — or waits until
1043        // the handle is in place to be drained. Releasing it earlier left a
1044        // window where `close` found no handle and the task outlived shutdown
1045        // holding the resolver's sockets.
1046        let cancel_guard = self.proxy_cancel.lock().await;
1047        let Some(cancel) = cancel_guard.clone() else {
1048            return;
1049        };
1050        let (bind_tx, bind_rx) = tokio::sync::oneshot::channel();
1051        let task = tokio::spawn(async move {
1052            if let Err(e) = crate::proxy::dns::serve(cfg, addr, bind_tx, cancel).await {
1053                error!("DNS resolver error: {e}");
1054            }
1055        });
1056        *self.dns_task.lock().await = Some(task);
1057        drop(cancel_guard);
1058        match bind_rx.await {
1059            Ok(Ok(())) => info!("DNS resolver bound successfully"),
1060            Ok(Err(msg)) => {
1061                let msg = format!(
1062                    "{msg}\nProxy host names will not resolve through pitchfork. \
1063                     Choose another port with proxy.dns_port, or set proxy.dns = false."
1064                );
1065                error!("{msg}");
1066                self.add_notification(log::LevelFilter::Error, msg).await;
1067            }
1068            Err(_) => {}
1069        }
1070    }
1071
1072    /// Watch the LAN address and keep the DNS responder, and mDNS when it is
1073    /// running, pointed at the current one.
1074    ///
1075    /// Started even when the mDNS publisher could not be created: the DNS
1076    /// responder serves this machine regardless, and an address it keeps
1077    /// answering with after the interface has moved is worse than useless.
1078    ///
1079    /// Does nothing when `proxy.lan_ip` pins an address. That is a choice to
1080    /// respect, not a starting point to drift from — the check lives here so
1081    /// neither caller can forget it.
1082    async fn start_lan_ip_monitor(
1083        &self,
1084        initial_ip: std::net::Ipv4Addr,
1085        port: u16,
1086        publisher: Option<std::sync::Arc<tokio::sync::Mutex<crate::proxy::mdns::MdnsPublisher>>>,
1087    ) {
1088        if !crate::settings::settings().proxy.lan_ip.is_empty() {
1089            return;
1090        }
1091        // No cancellation token means `close` has already taken it, so shutdown
1092        // is under way. Spawning here would leave a task polling with no way to
1093        // stop it, and past the point where `close` collects the handle.
1094        //
1095        // Held until the handle is stored, for the reason `start_dns_resolver`
1096        // gives: otherwise `close` can collect `lan_monitor_task` in between.
1097        let cancel_guard = self.proxy_cancel.lock().await;
1098        let Some(cancel) = cancel_guard.clone() else {
1099            debug!("Not starting the LAN IP monitor: the supervisor is shutting down");
1100            return;
1101        };
1102        let monitor_cancel = Some(cancel);
1103        let task = tokio::spawn(async move {
1104            let mut last_ip = initial_ip;
1105            let mut ticker = tokio::time::interval(std::time::Duration::from_secs(5));
1106            ticker.tick().await; // first tick is immediate
1107            loop {
1108                // Cancellation is raced against the tick, not checked after
1109                // it. Checking afterwards means the task only notices once the
1110                // full interval has elapsed, so a shutdown that waits a second
1111                // for it always gives up and aborts instead — the graceful
1112                // path would never once be taken.
1113                match monitor_cancel.as_ref() {
1114                    Some(cancel) => {
1115                        tokio::select! {
1116                            _ = ticker.tick() => {}
1117                            _ = cancel.cancelled() => break,
1118                        }
1119                    }
1120                    None => {
1121                        ticker.tick().await;
1122                    }
1123                }
1124                if let Some(new_ip) = crate::proxy::lan_ip::detect_lan_ip_if_changed(last_ip).await
1125                {
1126                    log::info!("LAN IP changed: {last_ip} → {new_ip}");
1127                    last_ip = new_ip;
1128                    crate::proxy::dns::update_lan_ip(new_ip);
1129                    if let Some(publisher) = publisher.as_ref() {
1130                        publisher.lock().await.republish_all(new_ip, port);
1131                    }
1132                }
1133            }
1134        });
1135        *self.lan_monitor_task.lock().await = Some(task);
1136        drop(cancel_guard);
1137    }
1138
1139    /// Start mDNS publishing for LAN mode (called after the proxy binds successfully).
1140    /// The LAN address mDNS publishes and the DNS resolver answers with.
1141    ///
1142    /// Resolved once and handed to both. Detecting separately in each let them
1143    /// disagree when the interface address changed in between, which would
1144    /// advertise one address over mDNS and serve another over DNS, and probed
1145    /// the network twice at startup for one answer.
1146    ///
1147    /// `None` means LAN mode is off, or is on and the address could not be
1148    /// determined; either way the caller has nothing to publish. The reason is
1149    /// reported here so it is said once rather than by each caller.
1150    async fn resolve_lan_ip(&self) -> Option<std::net::Ipv4Addr> {
1151        let s = crate::settings::settings();
1152        let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
1153        if !s.proxy.enable || !lan_enabled {
1154            return None;
1155        }
1156        if s.proxy.lan_ip.is_empty() {
1157            let detected = crate::proxy::lan_ip::detect_lan_ip().await;
1158            if detected.is_none() {
1159                error!(
1160                    "LAN mode is enabled but no LAN IP address could be detected. \
1161                     Set proxy.lan_ip to a specific address, or ensure you are connected to a network."
1162                );
1163            }
1164            return detected;
1165        }
1166        match s.proxy.lan_ip.parse::<std::net::Ipv4Addr>() {
1167            Ok(ip) => Some(ip),
1168            Err(e) => {
1169                let msg = format!(
1170                    concat!(
1171                        "proxy.lan_ip {:?} is not a valid IPv4 address: {}. ",
1172                        "LAN mode will not start; fix the setting or clear it ",
1173                        "to auto-detect."
1174                    ),
1175                    s.proxy.lan_ip, e
1176                );
1177                error!("{msg}");
1178                self.add_notification(log::LevelFilter::Error, msg).await;
1179                None
1180            }
1181        }
1182    }
1183
1184    async fn start_mdns(&self, lan_ip: Option<std::net::Ipv4Addr>) {
1185        let s = crate::settings::settings();
1186        let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
1187        if !s.proxy.enable || !lan_enabled {
1188            return;
1189        }
1190
1191        let Some(lan_ip) = lan_ip else { return };
1192        let port = u16::try_from(s.proxy.port).unwrap_or(443);
1193
1194        let Some(mut publisher) = crate::proxy::mdns::MdnsPublisher::new(lan_ip) else {
1195            error!("Failed to start mDNS publisher. Is Avahi (Linux) or Bonjour (macOS) running?");
1196            // The DNS responder hands out this address too, and it is useful on
1197            // this machine whether or not mDNS came up. Keep watching the
1198            // interface so its answers do not go stale.
1199            self.start_lan_ip_monitor(lan_ip, port, None).await;
1200            return;
1201        };
1202
1203        // Publish all registered slugs.
1204        let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
1205        for slug in slugs.keys() {
1206            let hostname = format!("{slug}.local");
1207            publisher.publish(&hostname, port);
1208        }
1209
1210        log::info!(
1211            "LAN mode: mDNS publishing on {lan_ip}, {} slug(s) registered",
1212            slugs.len()
1213        );
1214
1215        let publisher = std::sync::Arc::new(tokio::sync::Mutex::new(publisher));
1216
1217        // Start the IP monitor. It declines on its own when the address is
1218        // pinned rather than auto-detected.
1219        self.start_lan_ip_monitor(lan_ip, port, Some(publisher.clone()))
1220            .await;
1221
1222        *self.mdns_publisher.lock().await = Some(publisher);
1223    }
1224
1225    /// Re-read slugs from config and update mDNS records.
1226    ///
1227    /// Publishes new slugs and unpublishes removed ones. Called via IPC when
1228    /// `proxy add` or `proxy remove` modifies the slug registry.
1229    async fn sync_mdns(&self) {
1230        // Clone the Arc and release the outer lock immediately so we don't
1231        // block close() from taking the publisher during shutdown.
1232        let publisher = {
1233            let guard = self.mdns_publisher.lock().await;
1234            match guard.as_ref() {
1235                Some(p) => p.clone(),
1236                None => {
1237                    debug!("sync_mdns: mDNS publisher not active, skipping");
1238                    return;
1239                }
1240            }
1241        };
1242
1243        let s = crate::settings::settings();
1244        let port = u16::try_from(s.proxy.port).unwrap_or(443);
1245
1246        let slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
1247        let mut pub_guard = publisher.lock().await;
1248
1249        // Unpublish slugs that no longer exist in config.
1250        let current_keys: Vec<&String> = slugs.keys().collect();
1251        let registered: Vec<String> = pub_guard.registered_hostnames();
1252        for hostname in &registered {
1253            // hostname is "slug.local" — extract slug part.
1254            let slug = hostname.strip_suffix(".local").unwrap_or(hostname);
1255            if !current_keys.iter().any(|k| k.as_str() == slug) {
1256                log::info!("mDNS: unpublishing removed slug {slug}");
1257                pub_guard.unpublish(hostname);
1258            }
1259        }
1260
1261        // Publish new slugs that aren't yet registered.
1262        for slug in slugs.keys() {
1263            let hostname = format!("{slug}.local");
1264            if !pub_guard.is_published(&hostname) {
1265                log::info!("mDNS: publishing new slug {slug}");
1266                pub_guard.publish(&hostname, port);
1267            }
1268        }
1269    }
1270
1271    /// Spawn a background task that periodically flushes the state file to
1272    /// disk if it has been marked dirty.  Uses debouncing (1s interval) to
1273    /// batch rapid state changes.
1274    fn start_state_flush_task(&self) {
1275        let cancel = tokio_util::sync::CancellationToken::new();
1276        *self.flush_cancel.lock().unwrap() = Some(cancel.clone());
1277        tokio::spawn(async move {
1278            let mut interval = time::interval(STATE_FLUSH_INTERVAL);
1279            interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
1280            loop {
1281                tokio::select! {
1282                    _ = interval.tick() => {}
1283                    _ = cancel.cancelled() => {
1284                        debug!("state flush task received shutdown signal");
1285                        break;
1286                    }
1287                }
1288                let state = SUPERVISOR.state_file.lock().await;
1289                if state.is_dirty()
1290                    && let Err(e) = state.write()
1291                {
1292                    warn!("failed to flush state file: {e}");
1293                }
1294            }
1295            debug!("state flush task exiting");
1296        });
1297    }
1298
1299    pub(crate) async fn flush_state(&self) {
1300        let state = self.state_file.lock().await;
1301        if state.is_dirty()
1302            && let Err(e) = state.write()
1303        {
1304            warn!("failed to flush state file: {e}");
1305        }
1306    }
1307
1308    pub(crate) async fn refresh(&self) -> Result<()> {
1309        trace!("refreshing");
1310
1311        // Collect PIDs we need to check (shell PIDs and liveness PIDs)
1312        // This is more efficient than refreshing all processes on the system
1313        let dirs_with_pids = self.get_dirs_with_shell_pids().await;
1314        let liveness_sessions = self.get_liveness_sessions().await;
1315        let pids_to_check: Vec<u32> = dirs_with_pids
1316            .values()
1317            .flatten()
1318            .copied()
1319            .chain(liveness_sessions.iter().map(|(pid, _, _)| *pid))
1320            .collect::<std::collections::HashSet<_>>()
1321            .into_iter()
1322            .collect();
1323
1324        if pids_to_check.is_empty() {
1325            // No PIDs to check, skip the expensive refresh
1326            trace!("no tracked PIDs to check, skipping process refresh");
1327        } else {
1328            debug!("refreshing PIDs: {pids_to_check:?}");
1329            PROCS.refresh_pids(&pids_to_check);
1330        }
1331
1332        let mut last_refreshed_at = self.last_refreshed_at.lock().await;
1333        *last_refreshed_at = time::Instant::now();
1334
1335        self.restore_own_record().await;
1336
1337        #[cfg_attr(not(unix), allow(unused_mut))]
1338        let mut dirs_to_leave: Vec<PathBuf> = Vec::new();
1339
1340        // Prune shell PIDs that are no longer running. This is essential on
1341        // Unix so that exited shells don't keep daemons alive forever.
1342        //
1343        // On Windows, skip this check: Git Bash (MSYS2) PIDs from `$$` are
1344        // Cygwin-internal PIDs that are invisible to sysinfo (which sees
1345        // Windows PIDs). The is_running check would always return false,
1346        // immediately removing every registered shell and breaking autostop.
1347        // Shell registration/deregistration relies on UpdateShellDir IPC
1348        // messages instead.
1349        #[cfg(unix)]
1350        for (dir, pids) in dirs_with_pids {
1351            let to_remove = pids
1352                .iter()
1353                .filter(|pid| !PROCS.is_running(**pid))
1354                .collect::<Vec<_>>();
1355            for pid in &to_remove {
1356                self.remove_shell_pid(**pid).await?
1357            }
1358            if to_remove.len() == pids.len() {
1359                dirs_to_leave.push(dir);
1360            }
1361        }
1362
1363        // Atomically remove project sessions whose host PID has died or whose
1364        // recorded title no longer matches the current process title. Every
1365        // project session carries a host PID in its key, so we iterate all of
1366        // them. Re-reading the sessions under the lock prevents enter/leave
1367        // interleaving from deleting a session that was just replaced with a
1368        // new title snapshot.
1369        //
1370        // Gated to Unix to mirror the shell-PID pruning above: on Windows,
1371        // Git Bash (MSYS2) `$$` PIDs are Cygwin-internal and invisible to
1372        // sysinfo, so the liveness check would immediately revoke every
1373        // freshly-entered session. Windows relies on explicit `project leave`
1374        // (or shell UpdateShellDir) for deregistration instead.
1375        #[cfg(unix)]
1376        {
1377            let mut state = self.state_file.lock().await;
1378            for (pid, dir, recorded_title) in liveness_sessions {
1379                let Some(session) = state.get_project_session(pid, &dir) else {
1380                    continue;
1381                };
1382                let current_title = PROCS.title(pid);
1383                let is_running = PROCS.is_running(pid);
1384                debug!(
1385                    "refresh liveness session pid {pid} dir {} recorded_title={recorded_title:?} current_title={current_title:?} is_running={is_running}",
1386                    dir.display()
1387                );
1388                if should_remove_liveness_session(
1389                    session,
1390                    &recorded_title,
1391                    current_title.as_deref(),
1392                    is_running,
1393                ) {
1394                    warn!(
1395                        "removing project session pid {pid} dir {} (liveness pid title mismatch or dead)",
1396                        dir.display()
1397                    );
1398                    if state.remove_project_session(pid, &dir).is_some() {
1399                        dirs_to_leave.push(dir);
1400                    }
1401                }
1402            }
1403        }
1404
1405        for dir in dirs_to_leave {
1406            self.leave_dir(&dir).await?;
1407        }
1408
1409        // Catch state-`running` daemons that lost their monitor (e.g. the
1410        // monitor died with a previous supervisor): mark dead ones errored
1411        // and re-adopt live ones. Runs before check_retry so a daemon marked
1412        // errored here is retried on this same tick.
1413        self.reconcile_unmonitored_daemons().await;
1414
1415        self.check_retry().await?;
1416        self.process_pending_autostops().await?;
1417
1418        Ok(())
1419    }
1420
1421    /// Install a SIGCHLD handler that reaps orphaned zombie child processes.
1422    ///
1423    /// When running as PID 1 inside a container, orphaned processes are
1424    /// re-parented to PID 1. Without explicit reaping, they accumulate
1425    /// as zombies in the process table indefinitely.
1426    ///
1427    /// Only reaps processes that are NOT managed by the supervisor (i.e.
1428    /// not tracked in the state file). Managed daemon processes are reaped
1429    /// by their monitoring tasks via `child.wait()`.
1430    ///
1431    /// ## Strategy
1432    ///
1433    /// **Linux**: Uses `waitid(Id::All, WNOHANG | WNOWAIT | WEXITED)` to
1434    /// *peek* at the next zombie without consuming its status. If the PID
1435    /// belongs to a managed daemon, the reaper skips it so Tokio's
1436    /// `child.wait()` can collect the status normally. Only unmanaged
1437    /// orphans are actually reaped (via `waitpid(Pid, WNOHANG)`). This
1438    /// eliminates the race entirely.
1439    ///
1440    /// **Non-Linux Unix** (e.g. macOS — mainly for local development;
1441    /// container mode targets Linux): `waitid` is unavailable, so we fall
1442    /// back to `waitpid(None, WNOHANG)`. If the reaper accidentally
1443    /// consumes a managed PID's status, it stashes the exit code in
1444    /// [`REAPED_STATUSES`] for the monitoring task to recover.
1445    #[cfg(unix)]
1446    fn reap_zombies(&self) -> Result<()> {
1447        let mut stream = signal::unix::signal(SignalKind::child())
1448            .map_err(|e| miette::miette!("Failed to register SIGCHLD handler: {e}"))?;
1449        tokio::spawn(async move {
1450            loop {
1451                stream.recv().await;
1452                // Collect PIDs of managed daemons so we don't steal their exit status
1453                let managed_pids: HashSet<u32> = SUPERVISOR
1454                    .state_file
1455                    .lock()
1456                    .await
1457                    .daemons
1458                    .values()
1459                    .filter_map(|d| d.pid)
1460                    .collect();
1461                // Reap all available zombie children that are NOT managed
1462                Self::reap_unmanaged_zombies(&managed_pids).await;
1463            }
1464        });
1465        info!("container mode: SIGCHLD zombie reaper installed");
1466        Ok(())
1467    }
1468
1469    /// Linux implementation: peek with `waitid(WNOWAIT)` then selectively reap.
1470    ///
1471    /// `WNOWAIT` leaves the zombie in the table so we can inspect its PID
1472    /// without consuming the exit status. Only if the PID is *not* managed
1473    /// do we call `waitpid(Pid, WNOHANG)` to actually reap it.
1474    #[cfg(target_os = "linux")]
1475    async fn reap_unmanaged_zombies(managed_pids: &HashSet<u32>) {
1476        use nix::sys::wait::{Id, WaitPidFlag, WaitStatus, waitid, waitpid};
1477        use nix::unistd::Pid;
1478
1479        loop {
1480            // Peek at the next zombie without consuming it
1481            let peek_flags = WaitPidFlag::WNOHANG | WaitPidFlag::WNOWAIT | WaitPidFlag::WEXITED;
1482            match waitid(Id::All, peek_flags) {
1483                Ok(WaitStatus::StillAlive) => break,
1484                Ok(status) => {
1485                    let Some(pid_raw) = status.pid().map(|p| p.as_raw() as u32) else {
1486                        break;
1487                    };
1488                    if managed_pids.contains(&pid_raw) {
1489                        // This is a managed daemon — leave it for Tokio's child.wait().
1490                        // We must break out of the loop because waitid(Id::All) would
1491                        // keep returning the same zombie if we don't consume it.
1492                        trace!(
1493                            "zombie reaper: skipping managed daemon pid {pid_raw}, \
1494                             leaving for Tokio to reap"
1495                        );
1496                        break;
1497                    }
1498                    // Not managed — actually reap it
1499                    match waitpid(Pid::from_raw(pid_raw as i32), Some(WaitPidFlag::WNOHANG)) {
1500                        Ok(s) => trace!("reaped orphaned zombie child: {s:?}"),
1501                        Err(nix::errno::Errno::ECHILD) => break,
1502                        Err(e) => {
1503                            trace!("waitpid error reaping pid {pid_raw}: {e}");
1504                            break;
1505                        }
1506                    }
1507                }
1508                Err(nix::errno::Errno::ECHILD) => break, // no children at all
1509                Err(e) => {
1510                    trace!("waitid error in zombie reaper: {e}");
1511                    break;
1512                }
1513            }
1514        }
1515    }
1516
1517    /// Non-Linux fallback: blind `waitpid(None, WNOHANG)` with stash recovery.
1518    ///
1519    /// Since `waitid(WNOWAIT)` is not available, we cannot peek. If we
1520    /// accidentally reap a managed PID, we stash the exit code in
1521    /// [`REAPED_STATUSES`] so the monitoring task can recover it.
1522    #[cfg(all(unix, not(target_os = "linux")))]
1523    async fn reap_unmanaged_zombies(managed_pids: &HashSet<u32>) {
1524        use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
1525
1526        loop {
1527            match waitpid(None, Some(WaitPidFlag::WNOHANG)) {
1528                Ok(WaitStatus::StillAlive) => break,
1529                Ok(status) => {
1530                    let Some(pid) = status.pid().map(|p| p.as_raw() as u32) else {
1531                        continue;
1532                    };
1533                    if managed_pids.contains(&pid) {
1534                        // Race lost — stash the exit code for lifecycle recovery
1535                        let exit_code = match status {
1536                            WaitStatus::Exited(_, code) => code,
1537                            WaitStatus::Signaled(_, sig, _) => -(sig as i32),
1538                            _ => -1,
1539                        };
1540                        warn!(
1541                            "zombie reaper reaped managed daemon pid {pid} \
1542                             (exit_code={exit_code}); stashing status for recovery"
1543                        );
1544                        REAPED_STATUSES.lock().await.insert(pid, exit_code);
1545                    } else {
1546                        trace!("reaped orphaned zombie child: {status:?}");
1547                    }
1548                }
1549                Err(nix::errno::Errno::ECHILD) => break, // no more children
1550                Err(e) => {
1551                    trace!("waitpid error in zombie reaper: {e}");
1552                    break;
1553                }
1554            }
1555        }
1556    }
1557
1558    #[cfg(unix)]
1559    fn signals(&self) -> Result<()> {
1560        let signals = [
1561            SignalKind::terminate(),
1562            SignalKind::alarm(),
1563            SignalKind::interrupt(),
1564            SignalKind::quit(),
1565            SignalKind::hangup(),
1566            SignalKind::user_defined1(),
1567            SignalKind::user_defined2(),
1568        ];
1569        for signal in signals {
1570            let stream = match signal::unix::signal(signal) {
1571                Ok(s) => s,
1572                Err(e) => {
1573                    warn!("Failed to register signal handler for {signal:?}: {e}");
1574                    continue;
1575                }
1576            };
1577            tokio::spawn(async move {
1578                let mut stream = stream;
1579                loop {
1580                    stream.recv().await;
1581                    if SHUTDOWN_BEGUN.swap(true, atomic::Ordering::SeqCst) {
1582                        SUPERVISOR.force_exit().await;
1583                    } else {
1584                        // Spawned, so this loop keeps receiving: a second
1585                        // signal of the same kind must reach `force_exit`
1586                        // rather than wait for the graceful stop to finish.
1587                        tokio::spawn(SUPERVISOR.handle_signal());
1588                    }
1589                }
1590            });
1591        }
1592        Ok(())
1593    }
1594
1595    #[cfg(windows)]
1596    fn signals(&self) -> Result<()> {
1597        tokio::spawn(async move {
1598            loop {
1599                if let Err(e) = signal::ctrl_c().await {
1600                    error!("Failed to wait for ctrl-c: {}", e);
1601                    return;
1602                }
1603                if SHUTDOWN_BEGUN.swap(true, atomic::Ordering::SeqCst) {
1604                    SUPERVISOR.force_exit().await;
1605                } else {
1606                    tokio::spawn(SUPERVISOR.handle_signal());
1607                }
1608            }
1609        });
1610        Ok(())
1611    }
1612
1613    async fn handle_signal(&self) {
1614        info!("received signal, stopping");
1615        self.close().await;
1616        exit_gracefully().await
1617    }
1618
1619    /// A second signal while `close()` is still stopping daemons: exit now,
1620    /// but kill the daemons not stopped yet first. They run in their own
1621    /// sessions, so exiting without this would leave them running with no
1622    /// supervisor.
1623    async fn force_exit(&self) -> ! {
1624        if EXIT_CLAIMED.swap(true, atomic::Ordering::SeqCst) {
1625            // The graceful stop has finished and is exiting already.
1626            wait_for_claimed_exit().await
1627        }
1628        // Set by `close()` too, but this may run first: from now on a start
1629        // that takes its daemon's lock does not spawn (see `run_once`).
1630        self.shutting_down.store(true, atomic::Ordering::Release);
1631        warn!("received another signal while stopping; killing the remaining daemons and exiting");
1632        let mut killed = HashSet::new();
1633        self.kill_active_daemons(&mut killed).await;
1634        // A start that passed its check before the flag holds its daemon's
1635        // stop lock until the daemon's PID is recorded. Its process is killed
1636        // above if it has already spawned (it is monitored from the moment it
1637        // spawns); wait the locks out for one still on its way, then kill
1638        // again. The stops `close()` holds locks for end quickly now that
1639        // their daemons are killed; the wait is bounded all the same, by one
1640        // deadline for all the locks so a forced exit is not held up by each.
1641        let locks: Vec<_> = self.stop_locks.lock().await.values().cloned().collect();
1642        let deadline = time::Instant::now() + Duration::from_secs(5);
1643        for lock in locks {
1644            let _ = time::timeout_at(deadline, lock.lock()).await;
1645        }
1646        self.kill_active_daemons(&mut killed).await;
1647        exit(1)
1648    }
1649
1650    /// Kill the process group of every active daemon not in `killed` at once,
1651    /// with no grace period, adding each one dealt with to `killed`.
1652    ///
1653    /// Daemons being monitored count as well: a start registers its process
1654    /// for monitoring as soon as it spawns, before its PID is recorded in
1655    /// state, and that can take a while (a cron start writes the state file
1656    /// first).
1657    ///
1658    /// The group is killed even when its leader, the PID on record, has
1659    /// already exited: the graceful stop may have ended a shell whose children
1660    /// ignore the signal. While the group has members its ID cannot be reused,
1661    /// so it is still this daemon's; a live leader is checked against its
1662    /// recorded start time, as `stop` does. A kill that fails is not counted,
1663    /// so a later pass tries again.
1664    async fn kill_active_daemons(&self, killed: &mut HashSet<(DaemonId, u32)>) {
1665        let mut kills = tokio::task::JoinSet::new();
1666        let mut targets: Vec<((DaemonId, u32), Option<u64>)> = self
1667            .active_daemons()
1668            .await
1669            .into_iter()
1670            .filter_map(|d| Some(((d.id, d.pid?), d.start_time)))
1671            .collect();
1672        let monitored: Vec<_> = self
1673            .monitored
1674            .lock()
1675            .expect("monitored lock poisoned")
1676            .iter()
1677            .map(|(id, entry)| (id.clone(), entry.pid))
1678            .collect();
1679        for key in monitored {
1680            if !targets.iter().any(|(k, _)| *k == key) {
1681                // Not recorded yet, so there is no start time to check it
1682                // against; it is a child this supervisor has just spawned.
1683                targets.push((key, None));
1684            }
1685        }
1686        for (key, start_time) in targets {
1687            let pid = key.1;
1688            if killed.contains(&key) {
1689                continue;
1690            }
1691            if PROCS.is_running(pid) {
1692                if !signalling_pid_is_authorized(start_time, PROCS.start_time(pid)) {
1693                    // Recycled: not the daemon's process any more.
1694                    killed.insert(key);
1695                    continue;
1696                }
1697            } else if !PROCS.process_group_alive(pid) {
1698                // Leader and group both gone: nothing left to kill.
1699                killed.insert(key);
1700                continue;
1701            }
1702            kills.spawn(async move {
1703                // The stop signal is followed by SIGKILL at once.
1704                let result = PROCS
1705                    .kill_process_group_async(
1706                        pid,
1707                        crate::config_types::StopSignal::default().into(),
1708                        Some(Duration::from_millis(1)),
1709                    )
1710                    .await;
1711                if let Err(e) = &result {
1712                    error!("failed to kill daemon {}: {e}", key.0);
1713                }
1714                (key, result.is_ok())
1715            });
1716        }
1717        for (key, ok) in kills.join_all().await {
1718            if ok {
1719                killed.insert(key);
1720            }
1721        }
1722    }
1723
1724    /// Stop new daemons from starting, and wait for the starts already under
1725    /// way to finish, so the active daemons can be listed for good.
1726    ///
1727    /// A start that took its daemon's stop lock before `shutting_down` was set
1728    /// may still be spawning; its daemon must be in the list. A start that
1729    /// takes a lock from now on sees the flag and does not spawn (see
1730    /// `run_once`).
1731    ///
1732    /// Done once: a caller that comes while the freeze is under way waits for
1733    /// it to finish, and one that comes later returns at once. By then the
1734    /// locks may be held by `close()`'s stops, which waiting for would hold a
1735    /// `Shutdown` reply until those stops end.
1736    async fn freeze_starts(&self) {
1737        self.shutting_down.store(true, atomic::Ordering::Release);
1738        self.starts_frozen
1739            .get_or_init(|| async {
1740                let locks: Vec<_> = self.stop_locks.lock().await.values().cloned().collect();
1741                for lock in locks {
1742                    drop(lock.lock().await);
1743                }
1744            })
1745            .await;
1746    }
1747
1748    /// Begin a graceful shutdown for `supervisor stop` and return how long it
1749    /// may take: the client waits that long before killing the supervisor.
1750    ///
1751    /// The budget is computed here rather than by the client: only the
1752    /// supervisor knows its own settings, and only once new starts are frozen
1753    /// is the list of daemons to stop final.
1754    pub(crate) async fn begin_shutdown(&self) -> Duration {
1755        // Under way already, the freeze is waited for rather than skipped: a
1756        // daemon whose start it waits for is not listed until that start ends.
1757        // A shutdown that has frozen starts already answers at once, and the
1758        // daemons it has yet to stop are still active.
1759        self.freeze_starts().await;
1760        let base = settings().supervisor_stop_timeout();
1761        let budget = shutdown_budget(&self.active_daemons().await, base);
1762        if !SHUTDOWN_BEGUN.swap(true, atomic::Ordering::SeqCst) {
1763            tokio::spawn(async {
1764                info!("stop requested, stopping");
1765                SUPERVISOR.close().await;
1766                exit_gracefully().await
1767            });
1768        }
1769        budget
1770    }
1771
1772    pub(crate) async fn close(&self) {
1773        self.shutting_down.store(true, atomic::Ordering::Release);
1774        // Signal the proxy server to stop accepting new connections
1775        // and drain in-flight ones, *before* stopping daemons so the
1776        // proxy has time to finish forwarding active requests.
1777        if let Some(cancel) = self.proxy_cancel.lock().await.take() {
1778            cancel.cancel();
1779        }
1780
1781        // Stop the LAN IP monitor task. It watches the token that was just
1782        // cancelled, so give it a moment to come back on its own rather than
1783        // cutting it off mid-iteration the instant after asking it to stop.
1784        if let Some(mut monitor_task) = self.lan_monitor_task.lock().await.take()
1785            && tokio::time::timeout(LAN_MONITOR_STOP_WAIT, &mut monitor_task)
1786                .await
1787                .is_err()
1788        {
1789            monitor_task.abort();
1790        }
1791
1792        // Shutdown the mDNS publisher (sends goodbye packets).
1793        if let Some(publisher) = self.mdns_publisher.lock().await.take() {
1794            publisher.lock().await.shutdown();
1795        }
1796
1797        if let Some(mut dns_task) = self.dns_task.lock().await.take()
1798            && tokio::time::timeout(DNS_STOP_WAIT, &mut dns_task)
1799                .await
1800                .is_err()
1801        {
1802            // Cancelled but still running: aborted, as the LAN monitor is,
1803            // so its sockets are not left bound to the resolver port.
1804            dns_task.abort();
1805        }
1806
1807        if let Some(proxy_task) = self.proxy_task.lock().await.take() {
1808            let _ = tokio::time::timeout(PROXY_STOP_WAIT, proxy_task).await;
1809        }
1810
1811        // Clean up /etc/hosts entries managed by pitchfork
1812        let s = settings();
1813        if s.proxy.enable && s.proxy.sync_hosts {
1814            crate::proxy::hosts::clean_hosts_file();
1815        }
1816
1817        self.freeze_starts().await;
1818
1819        let pitchfork_id = DaemonId::pitchfork();
1820        let active = self.active_daemons().await;
1821        let active_ids: Vec<DaemonId> = active
1822            .iter()
1823            .filter(|d| d.id != pitchfork_id)
1824            .map(|d| d.id.clone())
1825            .collect();
1826
1827        // Stop daemons in reverse dependency order, from the dependencies
1828        // they were started with: the supervisor's own directory need not
1829        // see their projects' config.
1830        // Daemons within the same level are stopped concurrently.
1831        //
1832        // Each stop waits for the daemon's whole process group (bounded by its
1833        // stop budget) and levels are sequential, so total shutdown time is the
1834        // sum of the slowest stop per level. If an external manager (docker,
1835        // systemd) kills us before this completes, cleanup_orphaned_daemons()
1836        // recovers the leftover processes and stale state on the next start.
1837        let daemons = self.state_file.lock().await.daemons.clone();
1838        let ids = active_ids.clone();
1839        let stop_levels =
1840            match tokio::task::spawn_blocking(move || reverse_stop_order(&ids, &daemons)).await {
1841                Ok(levels) => levels,
1842                // Stopping everything at once still stops everything.
1843                Err(e) => {
1844                    error!("computing the shutdown order panicked: {e}");
1845                    vec![active_ids.clone()]
1846                }
1847            };
1848        for level in &stop_levels {
1849            let mut tasks = Vec::new();
1850            for id in level {
1851                let id = id.clone();
1852                tasks.push(tokio::spawn(async move {
1853                    if let Err(err) = SUPERVISOR.stop(&id).await {
1854                        error!("failed to stop daemon {id}: {err}");
1855                    }
1856                }));
1857            }
1858            for task in tasks {
1859                let _ = task.await;
1860            }
1861        }
1862        // A forced exit kills the daemons, so the stops above can finish
1863        // before it has. Shutting the IPC server down next would let the
1864        // supervisor return and exit with success, so leave the exit to it.
1865        // (A graceful stop claims the exit only after this returns.)
1866        if EXIT_CLAIMED.load(atomic::Ordering::SeqCst) {
1867            wait_for_claimed_exit().await
1868        }
1869        let _ = self.remove_daemon(&pitchfork_id).await;
1870
1871        // Signal the background state flush task to exit so it doesn't
1872        // keep waking up and acquiring the state mutex after shutdown.
1873        if let Some(cancel) = self.flush_cancel.lock().unwrap().take() {
1874            cancel.cancel();
1875        }
1876
1877        // Force-flush state to disk before shutting down IPC so no
1878        // in-memory-only changes are lost.
1879        {
1880            let state = self.state_file.lock().await;
1881            if state.is_dirty()
1882                && let Err(e) = state.write()
1883            {
1884                warn!("failed to flush state file during shutdown: {e}");
1885            }
1886        }
1887
1888        // Signal IPC server to shut down gracefully
1889        if let Some(mut handle) = self.ipc_shutdown.lock().await.take() {
1890            handle.shutdown().await;
1891        }
1892
1893        // Wait for all in-flight monitoring tasks to finish registering their
1894        // hook handles. Each monitoring task increments `active_monitors` when
1895        // its process exits, and decrements it (+ notifies `monitor_done`)
1896        // after all fire_hook() calls complete. This replaces the old
1897        // yield_now() approach which had a race window.
1898        let drain_timeout = time::sleep(MONITOR_DRAIN_WAIT);
1899        tokio::pin!(drain_timeout);
1900        loop {
1901            if self.active_monitors.load(atomic::Ordering::Acquire) == 0 {
1902                break;
1903            }
1904            tokio::select! {
1905                _ = self.monitor_done.notified() => {}
1906                _ = &mut drain_timeout => {
1907                    warn!("timed out waiting for monitoring tasks to register hooks, proceeding with shutdown");
1908                    break;
1909                }
1910            }
1911        }
1912        // The hooks run at the same time, so they are given one deadline
1913        // together: `supervisor stop` allows for this wait, and must not be
1914        // outlasted by it.
1915        let handles: Vec<JoinHandle<()>> = std::mem::take(&mut *self.hook_tasks.lock().await);
1916        let hook_deadline = time::Instant::now() + HOOK_DRAIN_WAIT;
1917        for handle in handles {
1918            match time::timeout_at(hook_deadline, handle).await {
1919                Ok(_) => {} // Hook completed (success or error, doesn't matter)
1920                Err(_) => {
1921                    warn!(
1922                        "hook task did not complete within {HOOK_DRAIN_WAIT:?} during shutdown, skipping"
1923                    );
1924                }
1925            }
1926        }
1927
1928        // Unix: remove the socket directory if it is empty. The IPC server
1929        // already removed our socket; anything left belongs to a supervisor
1930        // that replaced this one (e.g. `supervisor run --force`) while we
1931        // were stopping daemons, and must not be deleted.
1932        // Windows: named pipes have no filesystem component.
1933        #[cfg(unix)]
1934        let _ = fs::remove_dir(&*env::IPC_SOCK_DIR);
1935    }
1936
1937    pub(crate) async fn add_notification(&self, level: log::LevelFilter, message: String) {
1938        self.pending_notifications
1939            .lock()
1940            .await
1941            .push((level, message));
1942    }
1943}
1944
1945/// Fix ownership on the state directory so non-root users can access files
1946/// created by a `sudo`-started supervisor.
1947///
1948/// When `[settings.supervisor] user`, a recorded invoking user (see
1949/// [`env::InvokingUser`]), or `SUDO_UID`/`SUDO_GID` are set, we
1950/// `chown` the state directory and safe subdirectories back to that non-root
1951/// runtime user. This is strictly better than `chmod 0o666` because it does not
1952/// widen the permission bits — the files stay owner-only (0o600/0o700) but the
1953/// *owner* is the user that daemon processes and CLI clients need to share.
1954///
1955/// **Security**: The `proxy/` subtree is intentionally skipped. It contains
1956/// `ca-key.pem` which must remain `0o600` and owned by the process that
1957/// generated it. Changing its ownership or permissions would expose the CA
1958/// private key to other local users.
1959///
1960/// If none of these are available (e.g. a direct root login, or a boot service
1961/// installed from a root shell), we fall back to relaxing permissions on only
1962/// the `sock/` and `logs/` subdirectories (plus `state.toml`) so CLI clients
1963/// can still function.
1964#[cfg(unix)]
1965fn fix_state_dir_permissions() {
1966    let state_dir = &*env::PITCHFORK_STATE_DIR;
1967    if let Some((uid, gid)) = state_owner_ids() {
1968        if !state_dir.exists()
1969            && let Err(err) = fs::create_dir_all(state_dir)
1970        {
1971            warn!(
1972                "failed to create state directory for ownership fix at {}: {err}",
1973                state_dir.display()
1974            );
1975            return;
1976        }
1977
1978        // Best path: chown back to the runtime user. Permissions stay tight.
1979        chown_recursive(state_dir, uid, gid, true);
1980        debug!(
1981            "chowned state directory to uid={uid} gid={gid} at {}",
1982            state_dir.display()
1983        );
1984    } else {
1985        if !state_dir.exists() {
1986            return;
1987        }
1988
1989        // Fallback: relax permissions on safe subdirectories only.
1990        // proxy/ is never touched.
1991        chmod_safe_subtrees(state_dir);
1992        debug!(
1993            "relaxed permissions on safe subtrees at {}",
1994            state_dir.display()
1995        );
1996    }
1997}
1998
1999#[cfg(unix)]
2000pub(crate) fn state_owner_ids() -> Option<(u32, u32)> {
2001    if !nix::unistd::Uid::effective().is_root() {
2002        return None;
2003    }
2004
2005    let s = settings();
2006    let user = s.supervisor.user.trim();
2007    if !user.is_empty() {
2008        return resolve_supervisor_user_ids(user).or_else(|| {
2009            warn!(
2010                "failed to resolve supervisor.user '{user}' for state ownership; falling back to the invoking user"
2011            );
2012            env::invoking_user_ids()
2013        });
2014    }
2015
2016    env::invoking_user_ids()
2017}
2018
2019#[cfg(unix)]
2020fn resolve_supervisor_user_ids(user: &str) -> Option<(u32, u32)> {
2021    let user_record = if user.chars().all(|c| c.is_ascii_digit()) {
2022        let uid = user.parse::<u32>().ok()?;
2023        nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
2024            .ok()
2025            .flatten()
2026    } else {
2027        nix::unistd::User::from_name(user).ok().flatten()
2028    }?;
2029
2030    Some((user_record.uid.as_raw(), user_record.gid.as_raw()))
2031}
2032
2033/// Recursively `chown` a directory tree. If `skip_proxy` is true, the `proxy/`
2034/// subdirectory is skipped entirely to protect the CA private key.
2035#[cfg(unix)]
2036fn chown_recursive(dir: &std::path::Path, uid: u32, gid: u32, skip_proxy: bool) {
2037    // chown the directory itself
2038    let _ = chown_path(dir, uid, gid);
2039
2040    let entries = match std::fs::read_dir(dir) {
2041        Ok(e) => e,
2042        Err(_) => return,
2043    };
2044    for entry in entries.flatten() {
2045        let path = entry.path();
2046        if path.is_dir() {
2047            // Skip proxy/ at the top level of the state directory
2048            if skip_proxy
2049                && let Some(name) = path.file_name().and_then(|n| n.to_str())
2050                && name == "proxy"
2051            {
2052                continue;
2053            }
2054            chown_recursive(&path, uid, gid, false);
2055        } else {
2056            let _ = chown_path(&path, uid, gid);
2057        }
2058    }
2059}
2060
2061/// `chown` a single path using libc. Returns Ok(()) on success.
2062#[cfg(unix)]
2063fn chown_path(path: &std::path::Path, uid: u32, gid: u32) -> std::io::Result<()> {
2064    use std::ffi::CString;
2065    use std::os::unix::ffi::OsStrExt;
2066    let c_path = CString::new(path.as_os_str().as_bytes())
2067        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
2068    let ret = unsafe { libc::chown(c_path.as_ptr(), uid, gid) };
2069    if ret == 0 {
2070        Ok(())
2071    } else {
2072        Err(std::io::Error::last_os_error())
2073    }
2074}
2075
2076/// Fallback: relax permissions on safe subdirectories only (sock/, logs/, and
2077/// state.toml). The proxy/ subtree is never touched.
2078#[cfg(unix)]
2079fn chmod_safe_subtrees(state_dir: &std::path::Path) {
2080    // The state directory itself needs to be traversable
2081    let _ = fs::set_permissions(state_dir, fs::Permissions::from_mode(0o755));
2082
2083    // state.toml — needs to be readable by CLI clients
2084    let state_file = state_dir.join("state.toml");
2085    if state_file.exists() {
2086        let _ = fs::set_permissions(&state_file, fs::Permissions::from_mode(0o644));
2087    }
2088
2089    // Safe subdirectories: sock/ and logs/
2090    for subdir_name in &["sock", "logs"] {
2091        let subdir = state_dir.join(subdir_name);
2092        if subdir.is_dir() {
2093            chmod_recursive(&subdir);
2094        }
2095    }
2096}
2097
2098/// On startup, reconcile daemon processes left behind by a previous supervisor
2099/// that was terminated unexpectedly (e.g. `kill -9`).
2100///
2101/// This iterates the state file for daemon entries with a recorded PID. If the
2102/// PID is still alive and its current identity matches the recorded start time
2103/// (or the recorded title for older state files), it is assumed to be an orphan
2104/// from the previous supervisor session and `supervisor.orphan_policy` decides
2105/// its fate: `adopt` (default) resumes supervision via a poll monitor and keeps
2106/// the daemon's state intact; `kill` terminates it and resets its state to
2107/// `Stopped` with no PID. If a matching live process cannot be terminated
2108/// securely, its running state is retained to prevent a duplicate instance
2109/// from being started.
2110///
2111/// Missing or mismatched identity data fails closed so a PID recycled by an
2112/// unrelated process is never adopted or killed. On Unix platforms without
2113/// durable process handles, orphan termination also fails closed because the
2114/// PID/PGID cannot be pinned between identity validation and signaling.
2115///
2116/// This is gated by the `supervisor.cleanup_orphans` setting (default: true).
2117///
2118/// `unclean` is whether the previous supervisor exited uncleanly (see
2119/// [`supervisor_exited_uncleanly`]). It is read in `start()` before the
2120/// starting supervisor records itself in the state file — this function runs
2121/// in the background after that record exists, so reading it here would
2122/// always report unclean.
2123async fn cleanup_orphaned_daemons(supervisor: &Supervisor, unclean: bool) {
2124    if !settings().supervisor.cleanup_orphans {
2125        return;
2126    }
2127
2128    let candidates: Vec<_> = {
2129        let state = supervisor.state_file.lock().await;
2130        state
2131            .daemons
2132            .values()
2133            .filter(|d| d.id != DaemonId::pitchfork() && d.pid.is_some())
2134            .cloned()
2135            .collect()
2136    };
2137
2138    if candidates.is_empty() {
2139        return;
2140    }
2141
2142    info!(
2143        "checking {} daemon(s) for orphaned processes",
2144        candidates.len()
2145    );
2146
2147    let policy = orphan_policy();
2148    let boot_time = PROCS.boot_time();
2149
2150    // Reconcile orphans in parallel — a kill waits for the daemon's whole
2151    // process group to exit, bounded by that daemon's stop budget, so
2152    // sequential processing would make total cleanup time the sum of the
2153    // budgets.
2154    let tasks: Vec<_> = candidates
2155        .into_iter()
2156        .map(|daemon| {
2157            let policy = policy.clone();
2158            tokio::spawn(cleanup_orphaned_daemon(daemon, policy, boot_time, unclean))
2159        })
2160        .collect();
2161    for task in tasks {
2162        let _ = task.await;
2163    }
2164}
2165
2166/// Reconcile a single orphan candidate: adopt it, kill it, or reset its state,
2167/// per the policy and identity checks described on [`cleanup_orphaned_daemons`].
2168///
2169/// Holds the daemon's stop lock so a concurrent Run/Stop request for the same
2170/// daemon (cleanup runs in the background) serializes with the orphan kill,
2171/// and re-checks the recorded PID under the lock: if it changed, another path
2172/// already replaced or cleaned up this record and the snapshot is stale.
2173async fn cleanup_orphaned_daemon(
2174    daemon: crate::daemon::Daemon,
2175    policy: String,
2176    boot_time: u64,
2177    unclean: bool,
2178) {
2179    let supervisor: &Supervisor = &SUPERVISOR;
2180    let Some(pid) = daemon.pid else { return };
2181
2182    let lock = supervisor.stop_lock(&daemon.id).await;
2183    let _guard = lock.lock().await;
2184    let current_pid = {
2185        let state = supervisor.state_file.lock().await;
2186        state.daemons.get(&daemon.id).and_then(|d| d.pid)
2187    };
2188    if current_pid != Some(pid) {
2189        debug!(
2190            "orphan cleanup: daemon {} pid changed (recorded {pid}, now {current_pid:?}), skipping",
2191            daemon.id
2192        );
2193        return;
2194    }
2195
2196    // Refresh the candidate immediately before checking it: waiting for the
2197    // stop lock can await another path's stop timeout, during which this PID
2198    // may exit and be recycled.
2199    PROCS.refresh_pids(&[pid]);
2200
2201    if !PROCS.is_running(pid) {
2202        // PID already dead — the daemon exited while unsupervised, so
2203        // record a terminal status that reflects whether it died under a
2204        // crashed supervisor (retryable) or with the machine.
2205        let status = unobserved_exit_status(
2206            &daemon.status,
2207            daemon.boot_time,
2208            boot_time,
2209            unclean,
2210            daemon.oneshot,
2211        );
2212        reset_daemon_state(supervisor, &daemon.id, status, ExitObservation::Unobserved).await;
2213        return;
2214    }
2215
2216    // Safety check: verify the live process really is the daemon we
2217    // recorded, not an unrelated process that received a recycled PID.
2218    // The kernel start time is a stable identity for the lifetime of a
2219    // process, and is the only thing accepted as one.
2220    let current_start_time = PROCS.start_time(pid);
2221    let matches = process_identity_matches(daemon.start_time, current_start_time);
2222
2223    if !matches {
2224        // Either side missing means the identity cannot be checked at all,
2225        // which is different from checking it and finding a stranger: retain
2226        // the running state rather than resetting a record whose process may
2227        // well still be the daemon.
2228        if daemon.start_time.is_none() || current_start_time.is_none() {
2229            warn!(
2230                "could not verify the identity of live pid {pid} recorded for daemon {}; retaining running state",
2231                daemon.id,
2232            );
2233            return;
2234        }
2235        warn!(
2236            "pid {pid} recorded for daemon {} belongs to a different process now (PID recycled); resetting state without killing",
2237            daemon.id,
2238        );
2239        // The daemon died at some unknown point and the OS handed its PID
2240        // to something else — same unobserved exit as a dead PID.
2241        let status = unobserved_exit_status(
2242            &daemon.status,
2243            daemon.boot_time,
2244            boot_time,
2245            unclean,
2246            daemon.oneshot,
2247        );
2248        reset_daemon_state(supervisor, &daemon.id, status, ExitObservation::Unobserved).await;
2249        return;
2250    }
2251
2252    // Both policies need a verified start time: killing revalidates it
2253    // while pinned to the process, and adoption anchors its poll monitor
2254    // to it so a later PID recycle is never mistaken for the daemon.
2255    let Some(expected_start_time) = current_start_time else {
2256        warn!(
2257            "could not read start time for live pid {pid} recorded for daemon {}; retaining running state",
2258            daemon.id,
2259        );
2260        return;
2261    };
2262
2263    // Identity verified — the process really is our orphaned daemon.
2264    // The policy decides whether supervision resumes or the slate is
2265    // wiped clean.
2266    if policy == "adopt" {
2267        supervisor
2268            .adopt_daemon(&daemon, pid, expected_start_time)
2269            .await;
2270        return;
2271    }
2272
2273    info!("terminating orphaned daemon {} (pid {pid})", daemon.id);
2274
2275    let stop_cfg = daemon.stop_signal.unwrap_or_default();
2276    let termination_result = PROCS
2277        .kill_process_group_if_start_time_matches_async(
2278            pid,
2279            Some(expected_start_time),
2280            stop_cfg.signal.into(),
2281            stop_cfg.timeout,
2282        )
2283        .await;
2284
2285    match termination_result {
2286        Ok(true) => {}
2287        Ok(false) => {
2288            warn!(
2289                "could not securely terminate orphaned daemon {} (pid {pid}); retaining running state",
2290                daemon.id
2291            );
2292            return;
2293        }
2294        Err(err) => {
2295            warn!(
2296                "failed to terminate orphaned daemon {} (pid {pid}): {err}; retaining running state",
2297                daemon.id
2298            );
2299            return;
2300        }
2301    }
2302
2303    // We terminated the orphan ourselves, so this is an observed,
2304    // intentional stop rather than an unobserved exit.
2305    reset_daemon_state(
2306        supervisor,
2307        &daemon.id,
2308        DaemonStatus::Stopped,
2309        ExitObservation::Terminated,
2310    )
2311    .await;
2312}
2313
2314/// Effective `supervisor.orphan_policy`, warning on an unrecognized value
2315/// (which falls back to the default of adopting).
2316pub(crate) fn orphan_policy() -> String {
2317    let policy = settings().supervisor.orphan_policy.clone();
2318    match policy.as_str() {
2319        "adopt" | "kill" => policy,
2320        other => {
2321            warn!("unknown supervisor.orphan_policy '{other}', defaulting to 'adopt'");
2322            "adopt".to_string()
2323        }
2324    }
2325}
2326
2327/// Verify that live process identity matches the persisted daemon identity.
2328///
2329/// Both start times are required. A process name was once accepted in place of
2330/// a recorded start time, for state written before start times existed, but a
2331/// name is not an identity: a recycled PID belonging to another copy of the same
2332/// program matches it, and adopting or killing on that basis acts on the wrong
2333/// process. Missing identity, on either side, means unverifiable — and
2334/// unverifiable must never authorize acting on a process.
2335fn process_identity_matches(
2336    recorded_start_time: Option<u64>,
2337    current_start_time: Option<u64>,
2338) -> bool {
2339    match (recorded_start_time, current_start_time) {
2340        (Some(recorded), Some(current)) => recorded == current,
2341        _ => false,
2342    }
2343}
2344
2345/// Whether a PID read from persisted state may be signalled.
2346///
2347/// Stopping a daemon signals its whole process *group*, so acting on a PID that
2348/// has been recycled since it was recorded takes down an unrelated process tree.
2349/// Records are refused only when their identity is positively contradicted: if
2350/// either start time is unknown the PID stays as signallable as it was before
2351/// identities were recorded, so a daemon whose record predates the field can
2352/// still be stopped rather than becoming permanently unstoppable.
2353///
2354/// This is deliberately weaker than [`process_identity_matches`], which decides
2355/// whether to adopt or kill a process nobody asked about. Here the user has
2356/// named the daemon and asked for it to stop; the check exists to catch the
2357/// case where the answer is provably the wrong process.
2358pub(crate) fn signalling_pid_is_authorized(
2359    recorded_start_time: Option<u64>,
2360    current_start_time: Option<u64>,
2361) -> bool {
2362    !matches!(
2363        (recorded_start_time, current_start_time),
2364        (Some(recorded), Some(current)) if recorded != current
2365    )
2366}
2367
2368/// How a daemon's run ended, which decides what happens to the recorded
2369/// `last_exit_success` that cron `retrigger = "success" | "fail"` reads.
2370#[derive(Clone, Copy, PartialEq, Eq)]
2371pub(crate) enum ExitObservation {
2372    /// Nobody saw how the run ended, because the monitor that would have
2373    /// observed it died with a previous supervisor. The recorded outcome is
2374    /// cleared to `None`.
2375    ///
2376    /// Every option here is imperfect, so this picks the one that asserts
2377    /// nothing false. `Some(false)` would fabricate a failure, silently
2378    /// breaking a `retrigger = "success"` chain whose run may well have
2379    /// succeeded; `Some(true)` fabricates the opposite; keeping the previous
2380    /// value attributes an earlier run's outcome to this one. `None` says
2381    /// "unknown", reusing the reading the cron watcher already applies to a
2382    /// daemon that has never run.
2383    ///
2384    /// The tradeoff is that `None` satisfies both `retrigger = "success"`
2385    /// (`unwrap_or(true)`) and `retrigger = "fail"` (`!unwrap_or(false)`), so
2386    /// such a daemon fires once at its next scheduled time regardless of which
2387    /// it configured. That is schedule-gated rather than a loop, and it biases
2388    /// toward running the daemon over leaving it permanently untriggered.
2389    /// Distinguishing "unknown" from "never ran" would require a third cron
2390    /// state and is deliberately left out of scope here.
2391    Unobserved,
2392    /// We terminated the process ourselves, so the outcome is not a mystery:
2393    /// it stopped because we asked it to. Recorded as a success, matching the
2394    /// convention `Supervisor::stop` already uses for a deliberate stop.
2395    Terminated,
2396}
2397
2398impl ExitObservation {
2399    /// The `last_exit_success` value this observation implies.
2400    pub(crate) fn last_exit_success(self) -> Option<bool> {
2401        match self {
2402            ExitObservation::Unobserved => None,
2403            ExitObservation::Terminated => Some(true),
2404        }
2405    }
2406}
2407
2408/// Clear a daemon's runtime state (pid, process identity, active port) after
2409/// its process is gone or is no longer ours to manage.
2410///
2411/// Config fields are preserved by cloning the existing record, so a reset can
2412/// never drop a daemon's command, retry policy, or schedule.
2413async fn reset_daemon_state(
2414    supervisor: &Supervisor,
2415    id: &DaemonId,
2416    status: DaemonStatus,
2417    observation: ExitObservation,
2418) {
2419    let mut state_file = supervisor.state_file.lock().await;
2420    let Some(existing) = state_file.daemons.get(id) else {
2421        return;
2422    };
2423    let mut daemon = existing.clone();
2424    daemon.pid = None;
2425    daemon.title = None;
2426    daemon.start_time = None;
2427    daemon.boot_time = None;
2428    daemon.status = status;
2429    daemon.last_exit_success = observation.last_exit_success();
2430    daemon.active_port = None;
2431    state_file.clear_active_port(id);
2432    state_file.insert_daemon(id, daemon);
2433}
2434
2435/// Boot times this far apart are treated as different boots.
2436///
2437/// Sized to the only platform that reports a jittery value: Windows derives
2438/// boot time as `now - GetTickCount64()`, sampling two clocks independently,
2439/// so consecutive calls within one boot can differ by about a second. Linux
2440/// (`/proc/stat` btime) and macOS (`kern.boottime`) report stable values.
2441///
2442/// Deliberately kept this tight so a genuine reboot can never fall inside it:
2443/// a prior session would have to boot, start the supervisor, spawn a daemon,
2444/// have that daemon die, and complete a reboot inside two seconds, which no
2445/// real boot cycle reaches. A larger window would misread a short-lived
2446/// previous boot (e.g. a device in a reboot loop) as the current one and
2447/// resurrect daemons a reboot should have left stopped.
2448pub(crate) const BOOT_TIME_TOLERANCE_SECS: u64 = 2;
2449
2450/// Terminal status for a daemon whose process is gone and whose exit was
2451/// never observed, because the monitor that would have seen it died with a
2452/// previous supervisor.
2453///
2454/// A daemon recorded `Running` was expected to still be alive, so it died
2455/// under the crashed supervisor: `Errored(-1)` ("unknown exit code") makes it
2456/// eligible for its configured retries. Two cases stay `Stopped` instead:
2457///
2458/// - records from an earlier boot, whose processes died with the machine —
2459///   auto-restarting those is what `boot_start` is for, and reviving every
2460///   retry-configured daemon after a reboot would be a surprise
2461/// - any other status (in practice `Stopping`), i.e. an intentional stop that
2462///   completed while the supervisor was gone
2463pub(crate) fn unobserved_exit_status(
2464    status: &DaemonStatus,
2465    recorded_boot_time: Option<u64>,
2466    current_boot_time: u64,
2467    supervisor_exited_uncleanly: bool,
2468    oneshot: bool,
2469) -> DaemonStatus {
2470    let same_boot = recorded_boot_time
2471        .is_some_and(|recorded| recorded.abs_diff(current_boot_time) <= BOOT_TIME_TOLERANCE_SECS);
2472    // A task gets `stopped` rather than `errored` for the same reason the
2473    // adopted path does: `errored` is what `check_retry` looks for, and
2474    // nobody saw how this run ended, so retrying it would re-run a migration
2475    // or a seed that may well have succeeded. Leave re-running to an explicit
2476    // start.
2477    if status.is_running() && same_boot && supervisor_exited_uncleanly && !oneshot {
2478        DaemonStatus::Errored(-1)
2479    } else {
2480        DaemonStatus::Stopped
2481    }
2482}
2483
2484/// Whether the supervisor that owned this state file failed to shut down
2485/// cleanly, meaning any daemon it left behind stopped for reasons nobody
2486/// recorded.
2487///
2488/// A clean shutdown removes the supervisor's own entry: `close()` does it on
2489/// Unix, where the stop signal is delivered and handled, and the
2490/// `supervisor stop` command does it on Windows, which has no POSIX signals
2491/// and force-terminates the process instead. A crash, an external `kill -9`,
2492/// or a `--force` replacement all leave the entry behind.
2493///
2494/// This must be read before the starting supervisor records itself, which is
2495/// why `cleanup_orphaned_daemons` runs first in `start()`.
2496async fn supervisor_exited_uncleanly(supervisor: &Supervisor) -> bool {
2497    supervisor
2498        .state_file
2499        .lock()
2500        .await
2501        .daemons
2502        .contains_key(&DaemonId::pitchfork())
2503}
2504
2505/// Recursively chmod: directories → 0o755, files → 0o644.
2506#[cfg(unix)]
2507fn chmod_recursive(dir: &std::path::Path) {
2508    let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o755));
2509    let entries = match fs::read_dir(dir) {
2510        Ok(e) => e,
2511        Err(_) => return,
2512    };
2513    for entry in entries.flatten() {
2514        let path = entry.path();
2515        if path.is_dir() {
2516            chmod_recursive(&path);
2517        } else {
2518            let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o644));
2519        }
2520    }
2521}
2522
2523#[cfg(test)]
2524mod tests {
2525    use super::{
2526        BOOT_TIME_TOLERANCE_SECS, legacy_supervisor_title_matches, process_identity_matches,
2527        should_remove_liveness_session, signalling_pid_is_authorized, supervisor_identity_matches,
2528        unobserved_exit_status,
2529    };
2530    use crate::daemon_status::DaemonStatus;
2531    use crate::state_file::ProjectSession;
2532
2533    const BOOT: u64 = 1_700_000_000;
2534
2535    #[test]
2536    fn unobserved_running_death_in_current_boot_is_retryable() {
2537        // Died under a crashed supervisor during this boot: Errored(-1) makes
2538        // the daemon eligible for its configured retries.
2539        assert!(matches!(
2540            unobserved_exit_status(&DaemonStatus::Running, Some(BOOT), BOOT, true, false),
2541            DaemonStatus::Errored(-1)
2542        ));
2543    }
2544
2545    #[test]
2546    fn unobserved_task_death_is_stopped_not_retried() {
2547        // Nobody saw how the run ended, so `errored` would hand a migration or
2548        // a seed to check_retry on a guess. Matches what the adopted path
2549        // records, and what the guide promises.
2550        assert!(matches!(
2551            unobserved_exit_status(&DaemonStatus::Running, Some(BOOT), BOOT, true, true),
2552            DaemonStatus::Stopped
2553        ));
2554    }
2555
2556    #[test]
2557    fn unobserved_running_death_from_previous_boot_is_stopped() {
2558        // The process died with the machine; reviving every retry-configured
2559        // daemon after a reboot is what boot_start is for.
2560        assert!(matches!(
2561            unobserved_exit_status(
2562                &DaemonStatus::Running,
2563                Some(BOOT - 86_400),
2564                BOOT,
2565                true,
2566                false
2567            ),
2568            DaemonStatus::Stopped
2569        ));
2570    }
2571
2572    #[test]
2573    fn unobserved_exit_tolerates_boot_time_jitter() {
2574        // Windows recomputes boot time as now - GetTickCount64(), which can
2575        // drift about a second between samples within one boot.
2576        let within = BOOT + BOOT_TIME_TOLERANCE_SECS;
2577        assert!(matches!(
2578            unobserved_exit_status(&DaemonStatus::Running, Some(within), BOOT, true, false),
2579            DaemonStatus::Errored(-1)
2580        ));
2581        let beyond = BOOT + BOOT_TIME_TOLERANCE_SECS + 1;
2582        assert!(matches!(
2583            unobserved_exit_status(&DaemonStatus::Running, Some(beyond), BOOT, true, false),
2584            DaemonStatus::Stopped
2585        ));
2586    }
2587
2588    #[test]
2589    fn unobserved_exit_after_clean_shutdown_is_stopped() {
2590        // A deliberate `supervisor stop` can leave running records behind on
2591        // platforms where the supervisor cannot handle the stop signal. Those
2592        // daemons were stopped on purpose, so they must not be reported as
2593        // failures or resurrected by the retry checker.
2594        assert!(matches!(
2595            unobserved_exit_status(&DaemonStatus::Running, Some(BOOT), BOOT, false, false),
2596            DaemonStatus::Stopped
2597        ));
2598    }
2599
2600    #[test]
2601    fn unobserved_exit_treats_short_previous_boot_as_previous() {
2602        // A device in a reboot loop can produce consecutive boots seconds
2603        // apart. The jitter window must stay far below that so those records
2604        // are still recognised as belonging to an earlier boot.
2605        for gap in [5, 30, 59, 60] {
2606            assert!(
2607                matches!(
2608                    unobserved_exit_status(
2609                        &DaemonStatus::Running,
2610                        Some(BOOT - gap),
2611                        BOOT,
2612                        true,
2613                        false
2614                    ),
2615                    DaemonStatus::Stopped
2616                ),
2617                "boot {gap}s earlier should be treated as a previous boot"
2618            );
2619        }
2620    }
2621
2622    #[test]
2623    fn unobserved_exit_without_recorded_boot_time_is_stopped() {
2624        // Legacy state files predating the field fail closed to today's
2625        // behavior rather than triggering surprise retries.
2626        assert!(matches!(
2627            unobserved_exit_status(&DaemonStatus::Running, None, BOOT, true, false),
2628            DaemonStatus::Stopped
2629        ));
2630    }
2631
2632    #[test]
2633    fn supervisor_identity_matches_same_generation() {
2634        assert!(supervisor_identity_matches(
2635            Some(100),
2636            Some(100),
2637            Some(BOOT),
2638            BOOT
2639        ));
2640    }
2641
2642    #[test]
2643    fn supervisor_identity_survives_clock_steps_when_tokens_match() {
2644        // An NTP step or sleep/resume moves the realtime-derived boot time by
2645        // far more than the tolerance while the supervisor keeps running.
2646        // Matching start tokens prove it is the same process; declaring it
2647        // stale here would start a second supervisor.
2648        assert!(supervisor_identity_matches(
2649            Some(100),
2650            Some(100),
2651            Some(BOOT),
2652            BOOT + 3600
2653        ));
2654        assert!(supervisor_identity_matches(
2655            Some(100),
2656            Some(100),
2657            Some(BOOT + 3600),
2658            BOOT
2659        ));
2660    }
2661
2662    #[test]
2663    fn supervisor_identity_rejects_recycled_pid() {
2664        // The supervisor died and something else got its PID, within this
2665        // boot or across a reboot: the token differs either way.
2666        assert!(!supervisor_identity_matches(
2667            Some(100),
2668            Some(200),
2669            Some(BOOT),
2670            BOOT
2671        ));
2672        assert!(!supervisor_identity_matches(
2673            Some(100),
2674            Some(200),
2675            Some(BOOT),
2676            BOOT + 3600
2677        ));
2678    }
2679
2680    #[test]
2681    fn supervisor_identity_uses_boot_time_when_a_token_is_missing() {
2682        // Discussion #877: the record survived a reboot and an early system
2683        // daemon now owns the PID. With no live token to compare, the boot
2684        // time is what proves the record stale.
2685        assert!(!supervisor_identity_matches(
2686            Some(100),
2687            None,
2688            Some(BOOT),
2689            BOOT + 3600
2690        ));
2691        // Same boot (within Windows boot-time jitter) and no contradiction.
2692        assert!(supervisor_identity_matches(
2693            Some(100),
2694            None,
2695            Some(BOOT),
2696            BOOT + BOOT_TIME_TOLERANCE_SECS
2697        ));
2698    }
2699
2700    #[test]
2701    fn supervisor_identity_tolerates_legacy_records() {
2702        // A record written before either field existed is not contradicted by
2703        // anything here; `supervisor_record_is_live` applies the process-name
2704        // check to those instead.
2705        assert!(supervisor_identity_matches(None, Some(100), None, BOOT));
2706        // A record with only a boot time is still rejected across a reboot.
2707        assert!(!supervisor_identity_matches(
2708            None,
2709            Some(100),
2710            Some(BOOT),
2711            BOOT + 3600
2712        ));
2713        assert!(supervisor_identity_matches(
2714            None,
2715            Some(100),
2716            Some(BOOT),
2717            BOOT
2718        ));
2719    }
2720
2721    #[test]
2722    fn legacy_supervisor_title_requires_a_pitchfork_process() {
2723        assert!(legacy_supervisor_title_matches(Some("pitchfork")));
2724        assert!(legacy_supervisor_title_matches(Some("pitchfork.exe")));
2725        assert!(legacy_supervisor_title_matches(Some("Pitchfork")));
2726        // Discussion #877: an Apple LaunchAgent inherited the PID after a reboot.
2727        assert!(!legacy_supervisor_title_matches(Some(
2728            "AMPDeviceDiscoveryAgent"
2729        )));
2730        assert!(!legacy_supervisor_title_matches(Some("sleep")));
2731        assert!(!legacy_supervisor_title_matches(None));
2732    }
2733
2734    #[test]
2735    fn unobserved_exit_of_stopping_daemon_is_stopped() {
2736        // An intentional stop that completed while the supervisor was gone is
2737        // not a failure, even within the same boot.
2738        assert!(matches!(
2739            unobserved_exit_status(&DaemonStatus::Stopping, Some(BOOT), BOOT, true, false),
2740            DaemonStatus::Stopped
2741        ));
2742    }
2743
2744    #[test]
2745    fn orphan_identity_requires_both_start_times() {
2746        assert!(process_identity_matches(Some(123), Some(123)));
2747        assert!(!process_identity_matches(Some(123), Some(456)));
2748        // Unreadable current identity: unverifiable, so not a match.
2749        assert!(!process_identity_matches(Some(123), None));
2750    }
2751
2752    #[test]
2753    fn signalling_is_refused_only_for_a_contradicted_identity() {
2754        // Provably someone else's process group: refuse.
2755        assert!(!signalling_pid_is_authorized(Some(123), Some(456)));
2756        // Verified as the daemon's own.
2757        assert!(signalling_pid_is_authorized(Some(123), Some(123)));
2758        // Unknown on either side. Stopping stays possible, because the user has
2759        // named this daemon and a record that cannot be verified must not become
2760        // one that can never be stopped.
2761        assert!(signalling_pid_is_authorized(None, Some(123)));
2762        assert!(signalling_pid_is_authorized(Some(123), None));
2763        assert!(signalling_pid_is_authorized(None, None));
2764    }
2765
2766    #[test]
2767    fn orphan_identity_rejects_records_without_a_start_time() {
2768        // State written before start times were recorded. A process name used
2769        // to stand in here, but another copy of the same program on a recycled
2770        // PID matches a name, so such records are no longer verifiable and must
2771        // not authorize adopting or killing anything.
2772        assert!(!process_identity_matches(None, Some(123)));
2773        assert!(!process_identity_matches(None, None));
2774    }
2775
2776    #[test]
2777    fn should_not_remove_when_state_title_differs_from_snapshot() {
2778        // The session was re-entered after the snapshot was taken, producing a
2779        // new title in state. The snapshot title is stale; skip removal.
2780        let session = ProjectSession {
2781            liveness_title: Some("new_title".to_string()),
2782        };
2783        let recorded_title = Some("old_title".to_string());
2784
2785        assert!(!should_remove_liveness_session(
2786            &session,
2787            &recorded_title,
2788            Some("new_title"),
2789            true,
2790        ));
2791    }
2792
2793    #[test]
2794    fn should_remove_when_running_title_mismatches() {
2795        let session = ProjectSession {
2796            liveness_title: Some("recorded_title".to_string()),
2797        };
2798        let recorded_title = Some("recorded_title".to_string());
2799
2800        assert!(should_remove_liveness_session(
2801            &session,
2802            &recorded_title,
2803            Some("different_title"),
2804            true,
2805        ));
2806    }
2807
2808    #[test]
2809    fn should_remove_when_dead() {
2810        let session = ProjectSession {
2811            liveness_title: Some("recorded_title".to_string()),
2812        };
2813        let recorded_title = Some("recorded_title".to_string());
2814
2815        assert!(should_remove_liveness_session(
2816            &session,
2817            &recorded_title,
2818            Some("recorded_title"),
2819            false,
2820        ));
2821    }
2822
2823    #[test]
2824    fn should_not_remove_when_alive_and_title_matches() {
2825        let session = ProjectSession {
2826            liveness_title: Some("recorded_title".to_string()),
2827        };
2828        let recorded_title = Some("recorded_title".to_string());
2829
2830        assert!(!should_remove_liveness_session(
2831            &session,
2832            &recorded_title,
2833            Some("recorded_title"),
2834            true,
2835        ));
2836    }
2837
2838    #[cfg(unix)]
2839    #[test]
2840    fn fd_scan_end_reaches_open_fds_above_the_fd_limit() {
2841        // A caller opened fd 900, then lowered RLIMIT_NOFILE to 256.
2842        assert_eq!(super::fd_scan_end(256, [0, 1, 2, 900]), 901);
2843        assert_eq!(super::fd_scan_end(256, [0, 1, 2, 10]), 256);
2844        assert_eq!(super::fd_scan_end(-1, []), 1 << 16);
2845        assert_eq!(super::fd_scan_end(libc::c_long::MAX, []), 1 << 20);
2846    }
2847
2848    /// Exercises the fcntl fallback directly: on Linux >= 5.11 the spawn path
2849    /// never reaches it, but it is the only implementation on macOS.
2850    #[cfg(unix)]
2851    #[test]
2852    fn cloexec_fd_scan_hides_inherited_fds_from_children() {
2853        use std::os::unix::process::CommandExt;
2854        use std::process::{Command, Stdio};
2855
2856        // A pipe without O_CLOEXEC, like bats' fd 3 or a wrapper script's pipe.
2857        let mut fds = [0; 2];
2858        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
2859        let fd = fds[1];
2860        let child_sees_fd = |scan: bool| {
2861            let mut cmd = Command::new("sh");
2862            cmd.arg("-c")
2863                .arg(format!("[ -e /dev/fd/{fd} ]"))
2864                .stdin(Stdio::null())
2865                .stdout(Stdio::null())
2866                .stderr(Stdio::null());
2867            if scan {
2868                let max_fd = super::max_inherited_fd();
2869                unsafe {
2870                    cmd.pre_exec(move || {
2871                        super::cloexec_fd_scan(max_fd);
2872                        Ok(())
2873                    });
2874                }
2875            }
2876            cmd.status().unwrap().success()
2877        };
2878        let without_scan = child_sees_fd(false);
2879        let with_scan = child_sees_fd(true);
2880        unsafe {
2881            libc::close(fds[0]);
2882            libc::close(fds[1]);
2883        }
2884        assert!(without_scan, "control: child should inherit fd {fd}");
2885        assert!(!with_scan, "fd {fd} leaked past cloexec_fd_scan");
2886    }
2887
2888    fn budget_daemon(
2889        name: &str,
2890        pid: Option<u32>,
2891        timeout: Option<std::time::Duration>,
2892    ) -> crate::daemon::Daemon {
2893        crate::daemon::Daemon {
2894            id: crate::daemon_id::DaemonId::new("proj", name),
2895            pid,
2896            stop_signal: timeout.map(|timeout| crate::config_types::StopConfig {
2897                timeout: Some(timeout),
2898                ..crate::config_types::StopConfig::default()
2899            }),
2900            ..crate::daemon::Daemon::default()
2901        }
2902    }
2903
2904    #[test]
2905    fn stop_budget_covers_each_running_daemon_in_turn() {
2906        let base = std::time::Duration::from_secs(5);
2907        let sigkill = crate::procs::PROCESS_GROUP_SIGKILL_WAIT;
2908        let daemons = [
2909            budget_daemon("db", Some(1), Some(std::time::Duration::from_secs(4))),
2910            budget_daemon("app", Some(2), Some(std::time::Duration::from_secs(4))),
2911            // No timeout of its own: the supervisor's applies.
2912            budget_daemon("worker", Some(3), None),
2913            // Not running: nothing to stop.
2914            budget_daemon("idle", None, Some(std::time::Duration::from_secs(60))),
2915            crate::daemon::Daemon {
2916                id: crate::daemon_id::DaemonId::pitchfork(),
2917                pid: Some(4),
2918                ..crate::daemon::Daemon::default()
2919            },
2920        ];
2921        assert_eq!(
2922            super::daemons_stop_budget(&daemons, base),
2923            base + (std::time::Duration::from_secs(4) + sigkill) * 2 + (base + sigkill)
2924        );
2925    }
2926
2927    #[test]
2928    fn stop_budget_is_the_base_with_no_running_daemons() {
2929        let base = std::time::Duration::from_secs(5);
2930        assert_eq!(super::daemons_stop_budget(&[], base), base);
2931    }
2932}