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