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