Skip to main content

kunobi_daemon/
launch.rs

1//! Client-side start of a local daemon from a stdio shim.
2//!
3//! Enable the `launch` feature. A process that only binds and serves keeps
4//! `local` and does not compile this module.
5//!
6//! These are primitives, not a full replacement coordinator. A cache daemon
7//! keeps [`crate::replacement`] and an application health probe; a byte-pump
8//! shim can use [`spawn_and_wait`] with a connect probe.
9//!
10//! # Roles
11//!
12//! The kernel bind is the election: `local::unix_socket::acquire` on Unix or
13//! `local::windows_socket::acquire` on Windows returns `Won` or
14//! `AlreadyRunning`. Each module exists only on its own platform, so these are
15//! not links. Clients never unlink the endpoint.
16//!
17//! An advisory [`crate::ProcessLock`] on a sibling path is layer two. It
18//! reduces a thundering herd of client forks. If the lock and the kernel
19//! disagree, the kernel is right. Continue to bind or connect.
20//!
21//! Liveness is the caller's probe (connect or a protocol handshake), never the
22//! existence of a discovery file. [`wait_until_live`] is a bool wrapper over
23//! [`crate::readiness::wait_until`].
24//!
25//! [`spawn`] does not change stdio: the caller sets it. [`spawn_detached`]
26//! nulls stdin, stdout and stderr for shims whose protocol owns those streams.
27//! Both leave the child in the caller's session and, on Windows, let it
28//! inherit every inheritable handle other than the caller's standard ones.
29//!
30//! # Starting a daemon
31//!
32//! A process meant to outlive its caller needs more: Ctrl-C in the caller's
33//! terminal must not stop it, and it must not hold the caller's pipes open.
34//! [`DaemonCommand`] starts it in a new session on Unix, and on Windows with a
35//! hidden console of its own and an explicit list of the only handles it may
36//! inherit. Use it for a long-lived peer; keep [`spawn`] for short-lived
37//! children whose lifetime the caller controls.
38//!
39//! [`DaemonChild::wait_until_live`] probes it until it serves, and stops early
40//! when it exits first: the OS reports the exit as an event, so a daemon that
41//! fails at startup does not cost the caller its whole budget.
42//!
43//! # Knowing when it is ready
44//!
45//! [`DaemonCommand::readiness_channel`] gives the daemon a channel to say when
46//! it is ready and to report progress on the way (see
47//! [`crate::readiness::channel`]). A daemon that dies during startup is then
48//! noticed at once, and the launcher's deadline becomes the longest silence it
49//! accepts instead of a total. The daemon's word is not a proof: probe it
50//! before using it.
51
52use std::convert::Infallible;
53use std::ffi::OsString;
54use std::fs::File;
55use std::io;
56use std::path::{Path, PathBuf};
57use std::process::{Child, Command, ExitStatus, Stdio};
58use std::time::{Duration, Instant};
59
60use crate::local::ProcessHandle;
61use crate::readiness;
62use crate::readiness::channel::{Channel, ENV};
63
64/// Gap between unsuccessful liveness probes. Same cadence as [`readiness`].
65pub const POLL: Duration = readiness::POLL_INTERVAL;
66
67/// Outcome of [`spawn_and_wait`].
68///
69/// `live` is independent of `spawn_error`: another client may have won the
70/// bind after this spawn failed.
71#[derive(Debug)]
72#[non_exhaustive]
73pub struct SpawnWait {
74    /// The caller's probe succeeded before the budget elapsed.
75    pub live: bool,
76    /// Why this process could not exec the peer. `None` if exec started.
77    pub spawn_error: Option<io::Error>,
78}
79
80/// Spawn `command` with OS spawn hygiene. Stdio, argv and env stay as set.
81///
82/// On Unix this restores SIGCHLD around `Command::spawn` so a failed exec
83/// still returns an error instead of panicking, then reap-ignores again.
84/// On Windows this suppresses inherit on the caller's standard handles for
85/// the spawn, then restores them.
86pub fn spawn(command: &mut Command) -> io::Result<Child> {
87    #[cfg(unix)]
88    {
89        crate::local::unix::spawn_with_child_waiting(command)
90    }
91    #[cfg(windows)]
92    {
93        let _spawn = crate::spawn_lock::spawning();
94        let _guard = crate::local::windows::StdioInheritGuard::suppress()?;
95        command.spawn()
96    }
97}
98
99/// Spawn a peer with stdin, stdout and stderr detached.
100///
101/// Use this when the caller's stdio is a client protocol. Callers that need a
102/// daemon log on stderr set that stream themselves and call [`spawn`].
103pub fn spawn_detached(command: &mut Command) -> io::Result<Child> {
104    command
105        .stdin(Stdio::null())
106        .stdout(Stdio::null())
107        .stderr(Stdio::null());
108    spawn(command)
109}
110
111/// Where a daemon's stdout or stderr goes. Its stdin is always the null device.
112#[derive(Debug, Default)]
113pub enum DaemonOutput {
114    /// Discard the stream.
115    #[default]
116    Null,
117    /// Write the stream to this file, typically a daemon log opened for append.
118    File(File),
119}
120
121#[derive(Clone, Debug, PartialEq)]
122enum EnvChange {
123    Set(OsString, OsString),
124    Remove(OsString),
125}
126
127/// A daemon to start detached from the calling process.
128///
129/// The child inherits the caller's environment with the changes made here, in
130/// order. Pass an absolute program path: on Windows the search for a bare name
131/// differs from `Command`'s, and batch files are refused.
132///
133/// On Unix the child is a new session leader (`setsid`): Ctrl-C and the hangup
134/// of the caller's terminal do not reach it. Every descriptor above stderr is
135/// closed across the exec, so it starts with only its standard streams.
136///
137/// On Windows it gets its own hidden console and a new process group, and
138/// inherits only its three standard handles, so no pipe the caller holds stays
139/// open because of it. It is also taken out of the caller's job object when
140/// that job allows it.
141///
142/// A [readiness channel](DaemonCommand::readiness_channel) is the one
143/// exception on Unix: its descriptor also survives the exec. On Windows the
144/// daemon opens that channel by name and inherits nothing for it.
145///
146/// # Job objects on Windows
147///
148/// A job can forbid its processes' children from leaving it, and cargo's does:
149/// cargo puts itself and every child in a job that kills them all when cargo
150/// is interrupted, without allowing breakaway. A daemon started under such a
151/// job stays in it and dies with the caller on Ctrl-C, whatever this builder
152/// does. [`DaemonChild::in_callers_job`] reports that case so the caller can
153/// say so. The only way out is to start the daemon from outside the job, for
154/// example through a scheduled task or a service.
155#[derive(Debug)]
156pub struct DaemonCommand {
157    program: PathBuf,
158    args: Vec<OsString>,
159    env: Vec<EnvChange>,
160    current_dir: Option<PathBuf>,
161    stdout: DaemonOutput,
162    stderr: DaemonOutput,
163    readiness: bool,
164}
165
166impl DaemonCommand {
167    /// A daemon running `program`, with no arguments and both outputs discarded.
168    pub fn new(program: impl AsRef<Path>) -> Self {
169        Self {
170            program: program.as_ref().to_path_buf(),
171            args: Vec::new(),
172            env: Vec::new(),
173            current_dir: None,
174            stdout: DaemonOutput::Null,
175            stderr: DaemonOutput::Null,
176            readiness: false,
177        }
178    }
179
180    /// Append one argument.
181    pub fn arg(&mut self, arg: impl Into<OsString>) -> &mut Self {
182        self.args.push(arg.into());
183        self
184    }
185
186    /// Append several arguments.
187    pub fn args<I, S>(&mut self, args: I) -> &mut Self
188    where
189        I: IntoIterator<Item = S>,
190        S: Into<OsString>,
191    {
192        self.args.extend(args.into_iter().map(Into::into));
193        self
194    }
195
196    /// Set a variable in the child's environment.
197    ///
198    /// On Windows, where names are case-insensitive, a later change to `path`
199    /// replaces `Path`. That match folds ASCII letters only.
200    pub fn env(&mut self, name: impl Into<OsString>, value: impl Into<OsString>) -> &mut Self {
201        self.env.push(EnvChange::Set(name.into(), value.into()));
202        self
203    }
204
205    /// Remove a variable the child would otherwise inherit.
206    pub fn env_remove(&mut self, name: impl Into<OsString>) -> &mut Self {
207        self.env.push(EnvChange::Remove(name.into()));
208        self
209    }
210
211    /// Run the child in `dir` instead of the caller's working directory.
212    pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
213        self.current_dir = Some(dir.as_ref().to_path_buf());
214        self
215    }
216
217    /// Where stdout goes. Discarded by default.
218    pub fn stdout(&mut self, target: DaemonOutput) -> &mut Self {
219        self.stdout = target;
220        self
221    }
222
223    /// Where stderr goes. Discarded by default.
224    pub fn stderr(&mut self, target: DaemonOutput) -> &mut Self {
225        self.stderr = target;
226        self
227    }
228
229    /// Give the daemon a readiness channel; [`DaemonChild::take_readiness`]
230    /// returns the launcher's end.
231    ///
232    /// The daemon takes its end with
233    /// [`Notifier::from_env`](crate::readiness::channel::Notifier::from_env),
234    /// which finds it through the [`ENV`] variable. Without this call the
235    /// daemon gets no channel, and [`ENV`] is removed from its environment in
236    /// case the caller's own has it.
237    ///
238    /// On Unix the channel is a pipe. On macOS the standard library creates a
239    /// pipe and then marks it close-on-exec, so a child that another thread
240    /// spawns in between, other than through this crate, can inherit it and
241    /// hide the daemon's exit. The crate's own spawns wait while the pipe is
242    /// created, as they do for a listener; see `local::unix_socket::acquire`
243    /// for what other spawns need. On Windows it is a named pipe the
244    /// daemon opens by name, and the launcher also watches the daemon's
245    /// process, so a daemon that exits before it connects is reported at once.
246    ///
247    /// ```no_run
248    /// use kunobi_daemon::launch::DaemonCommand;
249    /// use std::time::Duration;
250    ///
251    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
252    /// let mut daemon = DaemonCommand::new("/usr/local/bin/exampled")
253    ///     .readiness_channel()
254    ///     .spawn()?;
255    /// let mut channel = daemon.take_readiness().expect("asked for above");
256    /// // Up to ten seconds between messages, however long the start takes.
257    /// if let Err(reason) = channel.wait(Duration::from_secs(10)) {
258    ///     let doing = channel.last_progress().unwrap_or_default();
259    ///     return Err(format!("{reason} (last progress: {doing:?})").into());
260    /// }
261    /// // Ready is the daemon's word: probe its endpoint before relying on it.
262    /// # Ok(())
263    /// # }
264    /// ```
265    pub fn readiness_channel(&mut self) -> &mut Self {
266        self.readiness = true;
267        self
268    }
269
270    /// Start the daemon.
271    ///
272    /// Returns once the OS has created the process. Whether it goes on to
273    /// serve is the caller's liveness probe, as with [`spawn_and_wait`].
274    pub fn spawn(&self) -> io::Result<DaemonChild> {
275        #[cfg(unix)]
276        {
277            use std::os::fd::AsRawFd;
278            let mut command = Command::new(&self.program);
279            command.args(&self.args).stdin(Stdio::null());
280            for change in &self.env {
281                match change {
282                    EnvChange::Set(name, value) => command.env(name, value),
283                    EnvChange::Remove(name) => command.env_remove(name),
284                };
285            }
286            if let Some(dir) = &self.current_dir {
287                command.current_dir(dir);
288            }
289            command.stdout(unix_stdio(&self.stdout)?);
290            command.stderr(unix_stdio(&self.stderr)?);
291            let readiness = if self.readiness {
292                let (reader, child_end) = crate::local::unix::ready_pipe()?;
293                command.env(ENV, child_end.as_raw_fd().to_string());
294                Some((Channel::start(reader)?, child_end))
295            } else {
296                command.env_remove(ENV);
297                None
298            };
299            let keep = readiness
300                .as_ref()
301                .map(|(_, child_end)| child_end.as_raw_fd());
302            let (inner, exit) =
303                crate::local::unix::spawn_in_new_session_watched(&mut command, keep)?;
304            // Dropping this copy of the daemon's end leaves the daemon the
305            // only writer, so its exit ends the channel.
306            Ok(DaemonChild {
307                inner: crate::candidate::Reaped::new(inner),
308                exit,
309                readiness: readiness.map(|(channel, _child_end)| channel),
310            })
311        }
312        #[cfg(windows)]
313        {
314            use crate::local::command_line::EnvChange as WideChange;
315            use crate::local::windows::ready;
316            use crate::local::windows_spawn::{Spawn, Target, spawn};
317            use std::os::windows::ffi::OsStrExt;
318            let wide = |text: &OsString| text.encode_wide().collect::<Vec<u16>>();
319            let mut env: Vec<WideChange> = self
320                .env
321                .iter()
322                .map(|change| match change {
323                    EnvChange::Set(name, value) => WideChange::Set(wide(name), wide(value)),
324                    EnvChange::Remove(name) => WideChange::Remove(wide(name)),
325                })
326                .collect();
327            let name: Vec<u16> = ENV.encode_utf16().collect();
328            // The daemon opens the channel by name, so it inherits no handle
329            // for it and neither can any other child of this process.
330            let readiness = if self.readiness {
331                let (pipe_name, pipe) = ready::create()?;
332                env.push(WideChange::Set(name, pipe_name.encode_utf16().collect()));
333                let (started, process) = std::sync::mpsc::channel();
334                let channel = Channel::start(ready::Reader::new(pipe, process))?;
335                Some((channel, started))
336            } else {
337                env.push(WideChange::Remove(name));
338                None
339            };
340            fn target(output: &DaemonOutput) -> Target<'_> {
341                match output {
342                    DaemonOutput::Null => Target::Null,
343                    DaemonOutput::File(file) => Target::File(file),
344                }
345            }
346            let mut inner = spawn(&Spawn {
347                program: &self.program,
348                args: &self.args,
349                env: &env,
350                current_dir: self.current_dir.as_deref(),
351                stdout: target(&self.stdout),
352                stderr: target(&self.stderr),
353            })?;
354            let handles = (|| {
355                let exit = ProcessHandle::open(
356                    crate::ProcessId::new(inner.id()).expect("spawned daemon has a nonzero PID"),
357                )?;
358                // Before the daemon connects, its exit is the only event
359                // that can end the channel. Clone its owned process handle.
360                let readiness = match readiness {
361                    Some((channel, started)) => {
362                        let _ = started.send(inner.watch()?);
363                        Some(channel)
364                    }
365                    None => None,
366                };
367                Ok((exit, readiness))
368            })();
369            match handles {
370                Ok((exit, readiness)) => Ok(DaemonChild {
371                    inner,
372                    exit,
373                    readiness,
374                }),
375                Err(error) => {
376                    let _ = inner.kill();
377                    Err(error)
378                }
379            }
380        }
381    }
382}
383
384/// The program, arguments, environment changes and working directory of
385/// `command`, as a daemon to start detached.
386///
387/// Nothing else carries over: not its stdio, which [`DaemonOutput`] sets,
388/// nor `env_clear`, which `Command` does not report, nor platform settings
389/// such as `arg0`, `pre_exec` or creation flags.
390impl From<&Command> for DaemonCommand {
391    fn from(command: &Command) -> Self {
392        let mut daemon = Self::new(command.get_program());
393        daemon.args(command.get_args());
394        for (name, value) in command.get_envs() {
395            match value {
396                Some(value) => daemon.env(name, value),
397                None => daemon.env_remove(name),
398            };
399        }
400        if let Some(dir) = command.get_current_dir() {
401            daemon.current_dir(dir);
402        }
403        daemon
404    }
405}
406
407#[cfg(unix)]
408fn unix_stdio(target: &DaemonOutput) -> io::Result<Stdio> {
409    Ok(match target {
410        DaemonOutput::Null => Stdio::null(),
411        DaemonOutput::File(file) => Stdio::from(file.try_clone()?),
412    })
413}
414
415/// A daemon started by [`DaemonCommand::spawn`].
416///
417/// Dropping it leaves the daemon running, as dropping a `std::process::Child`
418/// does. On Unix the daemon is also reaped once it exits, so a launcher that
419/// gives up on it leaves no zombie: at once if it has exited, otherwise by a
420/// thread that waits for it while it runs. That thread is best effort: if it
421/// cannot be created, a running daemon becomes a zombie when it exits, as
422/// before. [`Candidate::from`](crate::Candidate)
423/// takes it as a candidate that is stopped unless selected.
424///
425/// Its [`ProcessHandle`] is opened at spawn, before anything can reap the
426/// daemon, so it follows the daemon even in a process that ignores SIGCHLD.
427#[derive(Debug)]
428pub struct DaemonChild {
429    #[cfg(unix)]
430    inner: crate::candidate::Reaped,
431    #[cfg(windows)]
432    inner: crate::local::windows_spawn::WindowsChild,
433    exit: ProcessHandle,
434    readiness: Option<Channel>,
435}
436
437/// How [`DaemonChild::wait_until_live`] ended.
438#[derive(Clone, Copy, Debug, PartialEq, Eq)]
439#[non_exhaustive]
440pub enum Startup {
441    /// A probe succeeded.
442    Live,
443    /// The daemon exited, and a probe made after its exit failed too. A
444    /// daemon that lost the bind election to another one exits as well, and
445    /// the probe after its exit then finds the winner, so this means no
446    /// daemon answered.
447    Exited,
448    /// The deadline passed with the daemon still running and no probe
449    /// successful.
450    TimedOut,
451}
452
453impl DaemonChild {
454    /// The daemon's process ID.
455    pub fn id(&self) -> crate::ProcessId {
456        crate::ProcessId::new(self.inner.id()).expect("spawned daemon has a nonzero PID")
457    }
458
459    /// The launcher's end of the readiness channel, the first time it is
460    /// asked for. `None` without [`DaemonCommand::readiness_channel`].
461    pub fn take_readiness(&mut self) -> Option<Channel> {
462        self.readiness.take()
463    }
464
465    /// True when the daemon was left in the caller's job object because that
466    /// job forbids breakaway, so it will die with the caller's job (see the
467    /// job objects section of [`DaemonCommand`]). Always false on Unix, which
468    /// has no job objects.
469    ///
470    /// False does not prove the daemon is in no job. With nested jobs, a job
471    /// that allows breakaway lets the daemon leave it even when an enclosing
472    /// job does not; the daemon then stays in that outer job without the
473    /// breakaway being refused.
474    pub fn in_callers_job(&self) -> bool {
475        #[cfg(unix)]
476        {
477            false
478        }
479        #[cfg(windows)]
480        {
481            self.inner.in_callers_job()
482        }
483    }
484
485    /// The exit status if the daemon has exited, without blocking.
486    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
487        self.inner.try_wait()
488    }
489
490    /// Block until the daemon exits.
491    pub fn wait(&mut self) -> io::Result<ExitStatus> {
492        self.inner.wait()
493    }
494
495    /// Block until the daemon exits or `deadline` passes: its exit status,
496    /// or `None` while it still runs.
497    ///
498    /// The status comes from waiting for the child, so in a process that
499    /// ignores SIGCHLD, where the kernel discards it, this fails after the
500    /// exit. [`Self::exit_handle`] still reports the exit there.
501    pub fn wait_until(&mut self, deadline: Instant) -> io::Result<Option<ExitStatus>> {
502        if self.exits_by(deadline)? {
503            self.inner.wait().map(Some)
504        } else {
505            Ok(None)
506        }
507    }
508
509    /// Whether the daemon exits by `deadline`. Where the handle follows only
510    /// the PID, this asks the child instead: see
511    /// [`crate::local::ProcessHandle`]'s fallback.
512    fn exits_by(&mut self, deadline: Instant) -> io::Result<bool> {
513        crate::local::wait_child(|| self.inner.try_wait(), Some(&mut self.exit), deadline)
514    }
515
516    /// The daemon's exit as an event, for a caller that waits on it together
517    /// with other work, for example with `exited` under the `async` feature.
518    pub fn exit_handle(&mut self) -> &mut ProcessHandle {
519        &mut self.exit
520    }
521
522    /// Probe with `is_live` until it succeeds, the daemon exits, or
523    /// `deadline` passes.
524    ///
525    /// Between probes this waits on the daemon's exit rather than sleeping,
526    /// for [`POLL`] at most, so a daemon that dies during startup ends the
527    /// wait at once. `is_live` must attempt a connect or a protocol probe and
528    /// bound its own blocking. An error means the exit could not be observed;
529    /// the daemon may still be running.
530    pub fn wait_until_live(
531        &mut self,
532        deadline: Instant,
533        mut is_live: impl FnMut() -> bool,
534    ) -> io::Result<Startup> {
535        loop {
536            if is_live() {
537                return Ok(Startup::Live);
538            }
539            let now = Instant::now();
540            if now >= deadline {
541                return Ok(Startup::TimedOut);
542            }
543            if self.exits_by(deadline.min(now + POLL))? {
544                return Ok(if is_live() {
545                    Startup::Live
546                } else {
547                    Startup::Exited
548                });
549            }
550        }
551    }
552
553    /// Stop the daemon forcibly: SIGKILL on Unix, `TerminateProcess` on
554    /// Windows. A daemon that already exited is not an error.
555    pub fn kill(&mut self) -> io::Result<()> {
556        self.inner.kill()
557    }
558}
559
560/// Poll `is_live` until it succeeds or `deadline` is reached.
561///
562/// `is_live` must attempt a connect or a protocol probe. Do not use
563/// `path.exists()`. Application proofs with errors use
564/// [`readiness::wait_until`] directly.
565pub fn wait_until_live(deadline: Instant, mut is_live: impl FnMut() -> bool) -> bool {
566    readiness::wait_until(deadline, |_| Ok::<_, Infallible>(is_live().then_some(())))
567        .ok()
568        .flatten()
569        .is_some()
570}
571
572/// Detach-spawn `command`, then wait on `is_live` for `budget`.
573///
574/// Exec failure is not fatal: another shim may already have started the peer.
575/// The caller logs `spawn_error` if it wants a diagnostic. For the same
576/// reason the wait does not end when the spawned child exits: the peer
577/// another shim started may still be coming up. A caller that owns the only
578/// start uses [`DaemonCommand`] and [`DaemonChild::wait_until_live`].
579pub fn spawn_and_wait(
580    command: &mut Command,
581    budget: Duration,
582    is_live: impl FnMut() -> bool,
583) -> SpawnWait {
584    let spawn_error = spawn_detached(command).err();
585    let live = wait_until_live(Instant::now() + budget, is_live);
586    SpawnWait { live, spawn_error }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn wait_until_live_fails_when_the_probe_never_succeeds() {
595        let started = Instant::now();
596        assert!(!wait_until_live(
597            Instant::now() + Duration::from_millis(300),
598            || false,
599        ));
600        assert!(started.elapsed() >= Duration::from_millis(250));
601        assert!(started.elapsed() < Duration::from_secs(3));
602    }
603
604    #[test]
605    fn wait_until_live_returns_as_soon_as_the_probe_succeeds() {
606        let mut n = 0;
607        assert!(wait_until_live(
608            Instant::now() + Duration::from_secs(2),
609            || {
610                n += 1;
611                n >= 2
612            }
613        ));
614        assert!(n >= 2);
615    }
616
617    #[test]
618    fn a_missing_binary_is_reported_on_spawn_error() {
619        let mut command = Command::new("/this/does/not/exist-kunobi-daemon-launch");
620        let outcome = spawn_and_wait(&mut command, Duration::from_millis(80), || false);
621        assert!(!outcome.live);
622        assert!(outcome.spawn_error.is_some());
623    }
624
625    #[cfg(unix)]
626    #[test]
627    fn a_stale_socket_file_is_not_live() {
628        let dir = tempfile::tempdir().unwrap();
629        let sock = dir.path().join("b.sock");
630        {
631            let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
632            drop(listener);
633        }
634        assert!(sock.exists());
635        let stale_deadline = Instant::now() + Duration::from_secs(1);
636        while std::os::unix::net::UnixStream::connect(&sock).is_ok() {
637            assert!(
638                Instant::now() < stale_deadline,
639                "closed listener stayed live"
640            );
641            #[expect(
642                clippy::disallowed_methods,
643                reason = "Bounded test probe waits for the closed Unix listener to stop accepting."
644            )]
645            std::thread::sleep(Duration::from_millis(10));
646        }
647        assert!(!wait_until_live(
648            Instant::now() + Duration::from_millis(300),
649            || std::os::unix::net::UnixStream::connect(&sock).is_ok(),
650        ));
651    }
652
653    #[cfg(unix)]
654    #[test]
655    fn spawn_and_wait_still_sees_a_peer_another_client_started() {
656        let dir = tempfile::tempdir().unwrap();
657        let sock = dir.path().join("b.sock");
658        let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
659        let mut command = Command::new("/this/does/not/exist-kunobi-daemon-launch");
660        let outcome = spawn_and_wait(&mut command, Duration::from_secs(2), || {
661            std::os::unix::net::UnixStream::connect(&sock).is_ok()
662        });
663        assert!(outcome.live);
664        assert!(outcome.spawn_error.is_some());
665        drop(listener);
666    }
667
668    #[cfg(unix)]
669    #[test]
670    fn spawn_and_wait_waits_for_a_peer_that_binds_later() {
671        let dir = tempfile::tempdir().unwrap();
672        let sock = dir.path().join("b.sock");
673        let delayed_socket = sock.clone();
674        let (stop_tx, stop_rx) = std::sync::mpsc::channel();
675        let delayed = std::thread::spawn(move || {
676            #[expect(
677                clippy::disallowed_methods,
678                reason = "The fixture binds after startup to exercise retrying a failed liveness probe."
679            )]
680            std::thread::sleep(Duration::from_millis(250));
681            let listener = std::os::unix::net::UnixListener::bind(delayed_socket).unwrap();
682            let _ = stop_rx.recv_timeout(Duration::from_secs(2));
683            listener
684        });
685        let mut command = Command::new("/this/does/not/exist-kunobi-daemon-launch");
686        let outcome = spawn_and_wait(&mut command, Duration::from_secs(2), || {
687            std::os::unix::net::UnixStream::connect(&sock).is_ok()
688        });
689        stop_tx.send(()).unwrap();
690        drop(delayed.join().unwrap());
691        assert!(outcome.live);
692    }
693
694    #[cfg(unix)]
695    fn sleeper() -> DaemonChild {
696        let mut command = DaemonCommand::new("/bin/sleep");
697        command.arg("30");
698        command.spawn().unwrap()
699    }
700
701    /// Run by [`a_daemon_survives_sigint_to_its_callers_process_group`] as the
702    /// caller whose group receives SIGINT. Does nothing in a normal test run.
703    #[cfg(unix)]
704    #[test]
705    #[ignore = "helper process for the SIGINT test"]
706    fn detach_sigint_helper() {
707        let Some(out) = std::env::var_os("KDAEMON_DETACH_HELPER_OUT") else {
708            return;
709        };
710        let child = sleeper();
711        std::fs::write(&out, child.id().to_string()).unwrap();
712        // Stay alive until the signal ends this process.
713        #[expect(
714            clippy::disallowed_methods,
715            reason = "The signal-test helper must stay alive until the parent sends SIGINT."
716        )]
717        std::thread::sleep(Duration::from_secs(30));
718    }
719
720    #[cfg(unix)]
721    #[test]
722    fn a_daemon_survives_sigint_to_its_callers_process_group() {
723        use std::os::unix::process::CommandExt;
724        let dir = tempfile::tempdir().unwrap();
725        let out = dir.path().join("pid");
726        // The helper leads a process group of its own, standing in for the
727        // terminal's foreground group that Ctrl-C signals.
728        let mut helper = Command::new(std::env::current_exe().unwrap())
729            .args([
730                "--exact",
731                "launch::tests::detach_sigint_helper",
732                "--ignored",
733                "--nocapture",
734                "--test-threads=1",
735            ])
736            .env("KDAEMON_DETACH_HELPER_OUT", &out)
737            .stdout(Stdio::null())
738            .process_group(0)
739            .spawn()
740            .unwrap();
741        let deadline = Instant::now() + Duration::from_secs(20);
742        let daemon: u32 = loop {
743            if let Ok(pid) = std::fs::read_to_string(&out)
744                && let Ok(pid) = pid.trim().parse()
745            {
746                break pid;
747            }
748            assert!(Instant::now() < deadline, "helper never started the daemon");
749            #[expect(
750                clippy::disallowed_methods,
751                reason = "The cross-process fixture reports its daemon PID through a file, with a deadline."
752            )]
753            std::thread::sleep(Duration::from_millis(20));
754        };
755        // Opened while the daemon certainly runs, so it follows that process.
756        let mut daemon_exit = ProcessHandle::open(crate::ProcessId::new(daemon).unwrap()).unwrap();
757        crate::local::unix::interrupt_process_group(helper.id()).unwrap();
758        let status = helper.wait().unwrap();
759        // A daemon that shared the group dies of the same SIGINT; give it
760        // time to.
761        let died = daemon_exit
762            .wait_until(Instant::now() + Duration::from_secs(2))
763            .unwrap();
764        let _ = Command::new("kill")
765            .args(["-KILL", &daemon.to_string()])
766            .status();
767        assert!(!status.success(), "SIGINT did not reach the helper's group");
768        assert!(!died, "the daemon died with its caller's process group");
769    }
770
771    /// True if every write end is gone within `timeout`: a read then ends.
772    #[cfg(unix)]
773    fn reaches_eof_within(read: File, timeout: Duration) -> bool {
774        use std::io::Read;
775        let (done, finished) = std::sync::mpsc::channel();
776        std::thread::spawn(move || {
777            let mut read = read;
778            let mut buffer = [0u8; 64];
779            while matches!(read.read(&mut buffer), Ok(n) if n > 0) {}
780            let _ = done.send(());
781        });
782        finished.recv_timeout(timeout).is_ok()
783    }
784
785    #[cfg(unix)]
786    #[test]
787    fn a_daemon_does_not_keep_the_callers_pipe_open() {
788        let (read, write) = crate::local::unix::inheritable_pipe();
789        let mut child = sleeper();
790        drop(write);
791        let eof = reaches_eof_within(read, Duration::from_secs(5));
792        child.kill().unwrap();
793        child.wait().unwrap();
794        assert!(eof, "the daemon kept the caller's pipe open");
795    }
796
797    /// The control for the test above: a plain spawn does keep the pipe open,
798    /// so a pass there is the descriptor sweep working.
799    #[cfg(unix)]
800    #[test]
801    fn a_plain_spawn_would_keep_the_callers_pipe_open() {
802        let (read, write) = crate::local::unix::inheritable_pipe();
803        let mut child = Command::new("/bin/sleep")
804            .arg("30")
805            .stdin(Stdio::null())
806            .stdout(Stdio::null())
807            .stderr(Stdio::null())
808            .spawn()
809            .unwrap();
810        drop(write);
811        let eof = reaches_eof_within(read, Duration::from_secs(1));
812        child.kill().unwrap();
813        child.wait().unwrap();
814        assert!(!eof, "a plain spawn is expected to inherit the write end");
815    }
816
817    #[cfg(unix)]
818    #[test]
819    fn a_daemon_gets_its_output_file_env_and_directory() {
820        let dir = tempfile::tempdir().unwrap();
821        let log = dir.path().join("daemon.log");
822        let file = File::create(&log).unwrap();
823        // Cargo sets CARGO_MANIFEST_DIR for the test process and no shell
824        // invents it, so its absence in the child proves the removal.
825        assert!(std::env::var_os("CARGO_MANIFEST_DIR").is_some());
826        let mut command = DaemonCommand::new("/bin/sh");
827        command
828            .args([
829                "-c",
830                "echo \"$KDAEMON_SET|${CARGO_MANIFEST_DIR:-unset}|$(pwd)\" >&2",
831            ])
832            .env("KDAEMON_SET", "set-by-builder")
833            .env_remove("CARGO_MANIFEST_DIR")
834            .current_dir(dir.path())
835            .stderr(DaemonOutput::File(file));
836        let mut child = command.spawn().unwrap();
837        assert!(child.wait().unwrap().success());
838        let written = std::fs::read_to_string(&log).unwrap();
839        let expected_dir = dir.path().canonicalize().unwrap();
840        assert_eq!(
841            written.trim(),
842            format!("set-by-builder|unset|{}", expected_dir.display())
843        );
844    }
845
846    #[test]
847    fn a_daemon_command_takes_a_command_s_program_arguments_environment_and_directory() {
848        let mut command = Command::new("exampled");
849        command
850            .args(["serve", "--quiet"])
851            .env("KDAEMON_SET", "set-by-command")
852            .env_remove("KDAEMON_REMOVED")
853            .current_dir("daemon-dir");
854        let daemon = DaemonCommand::from(&command);
855        assert_eq!(daemon.program, Path::new("exampled"));
856        assert_eq!(daemon.args, ["serve", "--quiet"]);
857        // `Command` reports its changes by name, not in the order they were made.
858        assert_eq!(daemon.env.len(), 2);
859        assert!(daemon.env.contains(&EnvChange::Set(
860            "KDAEMON_SET".into(),
861            "set-by-command".into()
862        )));
863        assert!(
864            daemon
865                .env
866                .contains(&EnvChange::Remove("KDAEMON_REMOVED".into()))
867        );
868        assert_eq!(daemon.current_dir.as_deref(), Some(Path::new("daemon-dir")));
869    }
870
871    #[test]
872    fn a_missing_daemon_binary_is_a_spawn_error() {
873        let error = DaemonCommand::new("/this/does/not/exist-kunobi-daemon")
874            .spawn()
875            .unwrap_err();
876        assert_eq!(error.kind(), io::ErrorKind::NotFound);
877    }
878
879    /// A FIFO that holds a daemon from [`held_daemon`] until released.
880    #[cfg(unix)]
881    struct Held {
882        fifo: PathBuf,
883        _dir: tempfile::TempDir,
884    }
885
886    #[cfg(unix)]
887    impl Held {
888        /// Let the daemon exit. Blocks until it has opened the FIFO.
889        fn release(&self) {
890            std::fs::write(&self.fifo, b"go\n").unwrap();
891        }
892    }
893
894    #[cfg(unix)]
895    impl Drop for Held {
896        /// Release a daemon a failed test left waiting, without blocking.
897        fn drop(&mut self) {
898            use std::io::Write;
899            use std::os::unix::fs::OpenOptionsExt;
900            if let Ok(mut fifo) = std::fs::OpenOptions::new()
901                .write(true)
902                .custom_flags(libc::O_NONBLOCK)
903                .open(&self.fifo)
904            {
905                let _ = fifo.write_all(b"go\n");
906            }
907        }
908    }
909
910    /// A daemon that runs until [`Held::release`], then exits with code 3.
911    /// Opening a FIFO to read blocks until a writer opens it, so nothing but
912    /// the release lets it finish.
913    #[cfg(unix)]
914    fn held_daemon() -> (DaemonChild, Held) {
915        let dir = tempfile::tempdir().unwrap();
916        let fifo = dir.path().join("release");
917        assert!(
918            Command::new("mkfifo")
919                .arg(&fifo)
920                .status()
921                .unwrap()
922                .success()
923        );
924        let mut command = DaemonCommand::new("/bin/sh");
925        command
926            .args(["-c", "read line < \"$1\"; exit 3", "sh"])
927            .arg(&fifo);
928        (command.spawn().unwrap(), Held { fifo, _dir: dir })
929    }
930
931    #[cfg(unix)]
932    #[test]
933    fn a_daemon_that_exits_before_it_is_live_ends_the_wait() {
934        let (mut child, held) = held_daemon();
935        held.release();
936        let started = Instant::now();
937        let outcome = child
938            .wait_until_live(started + Duration::from_secs(60), || false)
939            .unwrap();
940        assert_eq!(outcome, Startup::Exited);
941        assert!(started.elapsed() < Duration::from_secs(30));
942        assert_eq!(child.wait().unwrap().code(), Some(3));
943    }
944
945    #[cfg(unix)]
946    #[test]
947    fn a_live_daemon_is_reported_as_soon_as_a_probe_succeeds() {
948        let (mut child, held) = held_daemon();
949        let mut probes = 0;
950        let started = Instant::now();
951        let outcome = child
952            .wait_until_live(started + Duration::from_secs(60), || {
953                probes += 1;
954                probes == 3
955            })
956            .unwrap();
957        assert_eq!(outcome, Startup::Live);
958        assert_eq!(probes, 3);
959        assert!(started.elapsed() < Duration::from_secs(30));
960
961        assert!(child.wait_until(Instant::now()).unwrap().is_none());
962        held.release();
963        let status = child
964            .wait_until(Instant::now() + Duration::from_secs(30))
965            .unwrap()
966            .expect("the daemon exited once released");
967        assert_eq!(status.code(), Some(3));
968    }
969
970    #[cfg(unix)]
971    #[test]
972    fn a_daemon_that_never_becomes_live_times_out_without_busy_probing() {
973        let (mut child, held) = held_daemon();
974        let mut probes = 0;
975        let deadline = Instant::now() + Duration::from_millis(200);
976        let outcome = child
977            .wait_until_live(deadline, || {
978                probes += 1;
979                false
980            })
981            .unwrap();
982        assert_eq!(outcome, Startup::TimedOut);
983        assert!(Instant::now() >= deadline);
984        // The exit wait spaces the probes by up to POLL.
985        assert!(probes <= 20, "{probes} probes in 200 ms");
986        held.release();
987        child.wait().unwrap();
988    }
989
990    #[cfg(unix)]
991    #[test]
992    fn a_daemon_that_exits_while_another_serves_is_live() {
993        // A daemon that lost the bind election exits, and the probe made
994        // after its exit reaches the one that won.
995        let (mut child, held) = held_daemon();
996        held.release();
997        child.exit_handle().wait().unwrap();
998        assert_eq!(child.exit_handle().pid(), child.id());
999        let mut probes = 0;
1000        let outcome = child
1001            .wait_until_live(Instant::now() + Duration::from_secs(60), || {
1002                probes += 1;
1003                probes == 2
1004            })
1005            .unwrap();
1006        assert_eq!(outcome, Startup::Live);
1007        assert_eq!(child.wait().unwrap().code(), Some(3));
1008    }
1009
1010    /// True once no process, not even a zombie, has `pid`.
1011    #[cfg(unix)]
1012    #[allow(unsafe_code)]
1013    fn reaped(pid: crate::ProcessId) -> bool {
1014        let pid = libc::pid_t::try_from(pid.get()).unwrap();
1015        // SAFETY: signal 0 delivers nothing; kill only checks that a process
1016        // with this PID exists and may be signalled.
1017        let result = unsafe { libc::kill(pid, 0) };
1018        result == -1 && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
1019    }
1020
1021    /// Whether `pid` is reaped within ten seconds.
1022    #[cfg(unix)]
1023    fn reaped_soon(pid: crate::ProcessId) -> bool {
1024        let deadline = Instant::now() + Duration::from_secs(10);
1025        crate::backoff::poll(|| Ok(reaped(pid)), Some(deadline)).unwrap()
1026    }
1027
1028    #[cfg(unix)]
1029    #[test]
1030    fn a_dropped_daemon_keeps_running_and_is_reaped_once_it_exits() {
1031        let (child, held) = held_daemon();
1032        let pid = child.id();
1033        let mut exit = ProcessHandle::open(pid).unwrap();
1034        drop(child);
1035        assert!(
1036            !exit
1037                .wait_until(Instant::now() + Duration::from_millis(200))
1038                .unwrap(),
1039            "dropping the daemon's handle stopped it"
1040        );
1041        held.release();
1042        assert!(exit.wait_until(Instant::now() + SILENCE).unwrap());
1043        assert!(reaped_soon(pid), "the exited daemon was left a zombie");
1044    }
1045
1046    #[test]
1047    fn a_daemon_taken_as_a_candidate_is_stopped_when_dropped_unselected() {
1048        let child = silent_daemon().spawn().unwrap();
1049        let pid = child.id();
1050        let mut exit = ProcessHandle::open(pid).unwrap();
1051        let mut candidate = crate::Candidate::from(child);
1052        assert_eq!(candidate.id(), pid);
1053        assert_eq!(candidate.exit_handle().unwrap().pid(), pid);
1054        assert!(candidate.try_wait().unwrap().is_none());
1055        assert!(candidate.wait_until(Instant::now()).unwrap().is_none());
1056        drop(candidate);
1057        assert!(
1058            exit.has_exited().unwrap(),
1059            "the unselected daemon was not stopped"
1060        );
1061    }
1062
1063    #[cfg(unix)]
1064    #[test]
1065    fn a_selected_daemon_candidate_keeps_running_and_is_reaped_once_it_exits() {
1066        let (child, held) = held_daemon();
1067        let pid = child.id();
1068        let mut exit = ProcessHandle::open(pid).unwrap();
1069        let mut candidate = crate::Candidate::from(child);
1070        candidate.selected();
1071        drop(candidate);
1072        assert!(
1073            !exit
1074                .wait_until(Instant::now() + Duration::from_millis(200))
1075                .unwrap(),
1076            "dropping a selected candidate stopped it"
1077        );
1078        held.release();
1079        assert!(exit.wait_until(Instant::now() + SILENCE).unwrap());
1080        assert!(reaped_soon(pid), "the exited daemon was left a zombie");
1081    }
1082
1083    #[cfg(unix)]
1084    #[test]
1085    fn spawn_detached_execs() {
1086        let bin = ["/usr/bin/true", "/bin/true"]
1087            .into_iter()
1088            .find(|path| std::path::Path::new(path).exists());
1089        let Some(bin) = bin else {
1090            return;
1091        };
1092        let mut command = Command::new(bin);
1093        let mut child = spawn_detached(&mut command).unwrap();
1094        assert!(child.wait().unwrap().success());
1095    }
1096
1097    // ── Readiness channel ───────────────────────────────────────────
1098
1099    use crate::readiness::channel::{NotReady, Notifier};
1100
1101    /// Long enough never to pass in a test that expects an answer first.
1102    const SILENCE: Duration = Duration::from_secs(60);
1103
1104    fn progress_then_ready(mut notifier: Notifier) -> io::Result<()> {
1105        notifier.progress("")?;
1106        notifier.progress("loading")?;
1107        notifier.ready()
1108    }
1109
1110    /// Run by the readiness tests as the daemon, which reports what it found
1111    /// in the file the test names. Does nothing in a normal test run.
1112    #[test]
1113    #[ignore = "helper process for the readiness tests"]
1114    #[allow(unsafe_code)]
1115    fn readiness_helper() {
1116        let (Some(mode), Some(out)) = (
1117            std::env::var_os("KDAEMON_READY_HELPER"),
1118            std::env::var_os("KDAEMON_READY_HELPER_OUT"),
1119        ) else {
1120            return;
1121        };
1122        // SAFETY: this process runs this one test; libtest's main thread only
1123        // waits for it and does not touch the environment. The launcher that
1124        // set the variable is the test that started this process.
1125        let taken = unsafe { Notifier::from_env() };
1126        // Taking the channel removes the variable, so nothing else sees it.
1127        let left = std::env::var_os(ENV).is_some();
1128        // SAFETY: as above.
1129        let again = unsafe { Notifier::from_env() };
1130        let report = match (mode.to_str(), taken) {
1131            _ if left => "the variable was left in the environment".to_owned(),
1132            _ if !matches!(again, Ok(None)) => format!("taken twice: {again:?}"),
1133            (Some("progress-then-ready"), Ok(Some(notifier))) => {
1134                match progress_then_ready(notifier) {
1135                    Ok(()) => "sent".to_owned(),
1136                    Err(error) => error.to_string(),
1137                }
1138            }
1139            (Some("exit-before-ready"), Ok(Some(_notifier))) => "exiting".to_owned(),
1140            (Some("no-channel"), Ok(None)) => "no channel".to_owned(),
1141            (mode, taken) => format!("unexpected: {mode:?} {taken:?}"),
1142        };
1143        std::fs::write(out, report).unwrap();
1144    }
1145
1146    /// This test binary as a daemon running [`readiness_helper`] in `mode`.
1147    fn readiness_helper_daemon(mode: &str, out: &Path) -> DaemonCommand {
1148        let mut command = DaemonCommand::new(std::env::current_exe().unwrap());
1149        command
1150            .args([
1151                "--exact",
1152                "launch::tests::readiness_helper",
1153                "--ignored",
1154                "--nocapture",
1155                "--test-threads=1",
1156            ])
1157            .env("KDAEMON_READY_HELPER", mode)
1158            .env("KDAEMON_READY_HELPER_OUT", out.as_os_str());
1159        command
1160    }
1161
1162    /// A daemon that runs for 30 seconds unless it is killed, and says nothing.
1163    fn silent_daemon() -> DaemonCommand {
1164        #[cfg(unix)]
1165        {
1166            let mut command = DaemonCommand::new("/bin/sleep");
1167            command.arg("30");
1168            command
1169        }
1170        #[cfg(windows)]
1171        {
1172            let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
1173            let mut command =
1174                DaemonCommand::new(PathBuf::from(root).join("System32").join("PING.EXE"));
1175            command.args(["-n", "30", "127.0.0.1"]);
1176            command
1177        }
1178    }
1179
1180    #[test]
1181    fn a_daemon_reports_progress_then_ready() {
1182        let dir = tempfile::tempdir().unwrap();
1183        let out = dir.path().join("report");
1184        let mut child = readiness_helper_daemon("progress-then-ready", &out)
1185            .readiness_channel()
1186            .spawn()
1187            .unwrap();
1188        let mut channel = child.take_readiness().expect("the channel was asked for");
1189        assert!(
1190            child.take_readiness().is_none(),
1191            "the channel is taken once"
1192        );
1193        assert_eq!(channel.wait(SILENCE), Ok(()));
1194        assert_eq!(channel.progress_reports(), 2);
1195        assert_eq!(channel.last_progress().as_deref(), Some("loading"));
1196        assert!(child.wait().unwrap().success());
1197        assert_eq!(std::fs::read_to_string(&out).unwrap(), "sent");
1198    }
1199
1200    #[test]
1201    fn a_daemon_that_exits_before_ready_is_noticed_at_once() {
1202        let dir = tempfile::tempdir().unwrap();
1203        let out = dir.path().join("report");
1204        let mut child = readiness_helper_daemon("exit-before-ready", &out)
1205            .readiness_channel()
1206            .spawn()
1207            .unwrap();
1208        let mut channel = child.take_readiness().unwrap();
1209        // Both observers follow the same daemon: opening its exit handle
1210        // must neither lose the readiness descriptor nor keep it alive.
1211        assert!(
1212            child
1213                .exit_handle()
1214                .wait_until(Instant::now() + SILENCE)
1215                .unwrap()
1216        );
1217        assert_eq!(channel.wait(SILENCE), Err(NotReady::Died));
1218        assert_eq!(
1219            child
1220                .wait_until_live(Instant::now() + SILENCE, || false)
1221                .unwrap(),
1222            Startup::Exited,
1223        );
1224        child.wait().unwrap();
1225        assert_eq!(std::fs::read_to_string(&out).unwrap(), "exiting");
1226    }
1227
1228    #[test]
1229    fn a_killed_daemon_ends_its_channel() {
1230        let mut child = silent_daemon().readiness_channel().spawn().unwrap();
1231        let mut channel = child.take_readiness().unwrap();
1232        // The daemon holds its end while it runs.
1233        assert_eq!(channel.wait(Duration::ZERO), Err(NotReady::Silent));
1234        child.kill().unwrap();
1235        child.wait().unwrap();
1236        // The launcher kept no copy of the daemon's end, so the kill ends it.
1237        assert_eq!(channel.wait(SILENCE), Err(NotReady::Died));
1238    }
1239
1240    #[test]
1241    fn without_opting_in_the_daemon_gets_no_channel() {
1242        let dir = tempfile::tempdir().unwrap();
1243        let out = dir.path().join("report");
1244        let mut command = readiness_helper_daemon("no-channel", &out);
1245        // A value in the caller's own environment never reaches the daemon.
1246        command.env(ENV, "5");
1247        let mut child = command.spawn().unwrap();
1248        assert!(child.take_readiness().is_none());
1249        assert!(child.wait().unwrap().success());
1250        assert_eq!(std::fs::read_to_string(&out).unwrap(), "no channel");
1251    }
1252
1253    #[cfg(unix)]
1254    #[test]
1255    fn a_daemon_with_a_readiness_channel_still_does_not_keep_the_callers_pipe_open() {
1256        let (read, write) = crate::local::unix::inheritable_pipe();
1257        let mut command = DaemonCommand::new("/bin/sleep");
1258        command.arg("30").readiness_channel();
1259        let mut child = command.spawn().unwrap();
1260        drop(write);
1261        let eof = reaches_eof_within(read, Duration::from_secs(5));
1262        child.kill().unwrap();
1263        child.wait().unwrap();
1264        assert!(eof, "the channel's exception let the caller's pipe through");
1265    }
1266
1267    #[cfg(feature = "async")]
1268    #[tokio::test]
1269    async fn a_daemon_s_readiness_can_be_awaited() {
1270        let dir = tempfile::tempdir().unwrap();
1271        let out = dir.path().join("report");
1272        let mut child = readiness_helper_daemon("progress-then-ready", &out)
1273            .readiness_channel()
1274            .spawn()
1275            .unwrap();
1276        let mut channel = child.take_readiness().unwrap();
1277        assert_eq!(channel.wait_async(SILENCE).await, Ok(()));
1278        assert_eq!(channel.progress_reports(), 2);
1279        assert!(child.wait().unwrap().success());
1280    }
1281}