Skip to main content

hyperactor_mesh/
bootstrap.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! ## Bootstrap invariants (BS-*)
10//!
11//! - **BS-1 (locking):** Do not acquire other locks from inside
12//!   `transition(...)`. The state lock is held for the duration of
13//!   the transition; acquiring another lock risks deadlock.
14
15use std::collections::HashMap;
16use std::collections::HashSet;
17use std::collections::VecDeque;
18use std::env::VarError;
19use std::fmt;
20use std::fs::OpenOptions;
21use std::future;
22use std::io;
23use std::io::Write;
24use std::panic::AssertUnwindSafe;
25use std::path::Path;
26use std::path::PathBuf;
27use std::str::FromStr;
28use std::sync::Arc;
29use std::sync::Mutex;
30use std::sync::OnceLock;
31use std::sync::Weak;
32use std::time::Duration;
33use std::time::SystemTime;
34
35use anyhow::Context;
36use async_trait::async_trait;
37use base64::prelude::*;
38use futures::FutureExt;
39use futures::StreamExt;
40use futures::stream;
41use humantime::format_duration;
42use hyperactor::ActorAddr;
43use hyperactor::ActorHandle;
44use hyperactor::ActorRef;
45use hyperactor::Endpoint as _;
46use hyperactor::Gateway;
47use hyperactor::Label;
48use hyperactor::ProcAddr;
49use hyperactor::channel;
50use hyperactor::channel::ChannelAddr;
51use hyperactor::channel::ChannelError;
52use hyperactor::channel::ChannelTransport;
53use hyperactor::channel::Rx;
54use hyperactor::channel::Tx;
55use hyperactor::context;
56use hyperactor::id::Uid;
57use hyperactor::mailbox::IntoBoxedMailboxSender;
58use hyperactor::mailbox::MailboxClient;
59use hyperactor::proc::Proc;
60use hyperactor_cast::cast_actor::CAST_ACTOR_NAME;
61use hyperactor_config::CONFIG;
62use hyperactor_config::ConfigAttr;
63use hyperactor_config::attrs::Attrs;
64use hyperactor_config::attrs::declare_attrs;
65use hyperactor_config::global::override_or_global;
66use serde::Deserialize;
67use serde::Serialize;
68use tempfile::TempDir;
69use tokio::process::Command;
70use tokio::sync::watch;
71use tracing::Instrument;
72use tracing::Level;
73use typeuri::Named;
74
75use crate::config::MESH_PROC_LAUNCHER_KIND;
76use crate::host::BulkTerminate;
77use crate::host::Host;
78use crate::host::HostError;
79use crate::host::HostShutdownHandles;
80use crate::host::ProcHandle;
81use crate::host::ProcManager;
82use crate::host::ReadyError as HostReadyError;
83use crate::host::SingleTerminate;
84use crate::host::TerminateError;
85use crate::host::TerminateSummary;
86use crate::host::WaitError;
87use crate::host::legacy_service_proc_id;
88use crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME;
89use crate::host_mesh::host_agent::HostAgent;
90use crate::logging::OutputTarget;
91use crate::logging::StreamFwder;
92use crate::proc_agent::ProcAgent;
93use crate::proc_launcher::LaunchOptions;
94use crate::proc_launcher::NativeProcLauncher;
95use crate::proc_launcher::ProcExitKind;
96use crate::proc_launcher::ProcExitResult;
97use crate::proc_launcher::ProcLauncher;
98use crate::proc_launcher::ProcLauncherError;
99use crate::proc_launcher::StdioHandling;
100#[cfg(target_os = "linux")]
101use crate::proc_launcher::SystemdProcLauncher;
102use crate::proc_launcher::format_process_name;
103use crate::resource;
104
105mod mailbox;
106
107declare_attrs! {
108    /// Enable forwarding child stdout/stderr over the mesh log
109    /// channel.
110    ///
111    /// When `true` (default): child stdio is piped; [`StreamFwder`]
112    /// mirrors output to the parent console and forwards bytes to the
113    /// log channel so a `LogForwardActor` can receive them.
114    ///
115    /// When `false`: no channel forwarding occurs. Child stdio may
116    /// still be piped if [`MESH_ENABLE_FILE_CAPTURE`] is `true` or
117    /// [`MESH_TAIL_LOG_LINES`] > 0; otherwise the child inherits the
118    /// parent stdio (no interception).
119    ///
120    /// This flag does not affect console mirroring: child output
121    /// always reaches the parent console—either via inheritance (no
122    /// piping) or via [`StreamFwder`] when piping is active.
123    @meta(CONFIG = ConfigAttr::new(
124        Some("HYPERACTOR_MESH_ENABLE_LOG_FORWARDING".to_string()),
125        Some("enable_log_forwarding".to_string()),
126    ))
127    pub attr MESH_ENABLE_LOG_FORWARDING: bool = false;
128
129    /// When `true`: if stdio is piped, each child's `StreamFwder`
130    /// also forwards lines to a host-scoped `FileAppender` managed by
131    /// the `BootstrapProcManager`. That appender creates exactly two
132    /// files per manager instance—one for stdout and one for
133    /// stderr—and **all** child processes' lines are multiplexed into
134    /// those two files. This can be combined with
135    /// [`MESH_ENABLE_LOG_FORWARDING`] ("stream+local").
136    ///
137    /// Notes:
138    /// - The on-disk files are *aggregate*, not per-process.
139    ///   Disambiguation is via the optional rank prefix (see
140    ///   `PREFIX_WITH_RANK`), which `StreamFwder` prepends to lines
141    ///   before writing.
142    /// - On local runs, file capture is suppressed unless
143    ///   `FORCE_FILE_LOG=true`. In that case `StreamFwder` still
144    ///   runs, but the `FileAppender` may be `None` and no files are
145    ///   written.
146    /// - `MESH_TAIL_LOG_LINES` only controls the in-memory rotating
147    ///   buffer used for peeking—independent of file capture.
148    @meta(CONFIG = ConfigAttr::new(
149        Some("HYPERACTOR_MESH_ENABLE_FILE_CAPTURE".to_string()),
150        Some("enable_file_capture".to_string()),
151    ))
152    pub attr MESH_ENABLE_FILE_CAPTURE: bool = false;
153
154    /// Maximum number of log lines retained in a proc's stderr/stdout
155    /// tail buffer. Used by [`StreamFwder`] when wiring child
156    /// pipes. Default: 100
157    @meta(CONFIG = ConfigAttr::new(
158        Some("HYPERACTOR_MESH_TAIL_LOG_LINES".to_string()),
159        Some("tail_log_lines".to_string()),
160    ))
161    pub attr MESH_TAIL_LOG_LINES: usize = 0;
162
163    /// If enabled (default), bootstrap child processes install
164    /// `PR_SET_PDEATHSIG(SIGKILL)` so the kernel reaps them if the
165    /// parent dies unexpectedly. This is a **production safety net**
166    /// against leaked children; tests usually disable it via
167    /// `std::env::set_var("HYPERACTOR_MESH_BOOTSTRAP_ENABLE_PDEATHSIG",
168    /// "false")`.
169    @meta(CONFIG = ConfigAttr::new(
170        Some("HYPERACTOR_MESH_BOOTSTRAP_ENABLE_PDEATHSIG".to_string()),
171        Some("mesh_bootstrap_enable_pdeathsig".to_string()),
172    ))
173    pub attr MESH_BOOTSTRAP_ENABLE_PDEATHSIG: bool = true;
174
175    /// Maximum number of child terminations to run concurrently
176    /// during bulk shutdown. Prevents unbounded spawning of
177    /// termination tasks (which could otherwise spike CPU, I/O, or
178    /// file descriptor load).
179    @meta(CONFIG = ConfigAttr::new(
180        Some("HYPERACTOR_MESH_TERMINATE_CONCURRENCY".to_string()),
181        Some("mesh_terminate_concurrency".to_string()),
182    ))
183    pub attr MESH_TERMINATE_CONCURRENCY: usize = 16;
184
185    /// Per-child grace window for termination. When a shutdown is
186    /// requested, the manager sends SIGTERM and waits this long for
187    /// the child to exit before escalating to SIGKILL.
188    @meta(CONFIG = ConfigAttr::new(
189        Some("HYPERACTOR_MESH_TERMINATE_TIMEOUT".to_string()),
190        Some("mesh_terminate_timeout".to_string()),
191    ))
192    pub attr MESH_TERMINATE_TIMEOUT: Duration = Duration::from_secs(10);
193}
194
195pub const CLIENT_TRACE_ID_ENV: &str = "MONARCH_CLIENT_TRACE_ID";
196
197/// A channel used by each process to receive its own stdout and stderr
198/// Because stdout and stderr can only be obtained by the parent process,
199/// they need to be streamed back to the process.
200pub(crate) const BOOTSTRAP_LOG_CHANNEL: &str = "BOOTSTRAP_LOG_CHANNEL";
201
202pub(crate) const BOOTSTRAP_MODE_ENV: &str = "HYPERACTOR_MESH_BOOTSTRAP_MODE";
203pub(crate) const PROCESS_NAME_ENV: &str = "HYPERACTOR_PROCESS_NAME";
204
205#[macro_export]
206macro_rules! ok {
207    ($expr:expr $(,)?) => {
208        match $expr {
209            Ok(value) => value,
210            Err(e) => return ::anyhow::Error::from(e),
211        }
212    };
213}
214
215pub async fn halt<R>() -> R {
216    future::pending::<()>().await;
217    unreachable!()
218}
219
220/// A handle that waits for a host to finish shutting down.
221///
222/// Obtained from [`host`]. Awaiting [`HostShutdownHandle::stop_and_join`]
223/// blocks until the [`ShutdownHost`] handler sends back the client transport
224/// handles, stops and joins them, and (if `exit_on_shutdown`) calls
225/// `process::exit`.
226///
227/// Note: [`DrainHost`] does **not** trigger this handle — a drained host
228/// keeps its mailbox server (and Unix socket) alive so new clients can
229/// reconnect to the same address.
230pub struct HostShutdownHandle {
231    rx: tokio::sync::oneshot::Receiver<HostShutdownHandles>,
232    exit_on_shutdown: bool,
233}
234
235/// A host whose children have drained but whose client transports are still serving.
236///
237/// Call [`stop_and_join`](Self::stop_and_join) after any final client-side
238/// flushes to stop and join the transport servers.
239pub struct DrainedHostShutdown {
240    handles: Option<HostShutdownHandles>,
241    exit_on_shutdown: bool,
242}
243
244impl HostShutdownHandle {
245    /// Wait for the host to drain its children and release its client transports.
246    ///
247    /// The returned transports remain live until
248    /// [`DrainedHostShutdown::stop_and_join`], so callers can perform final
249    /// client-side flushes before teardown.
250    pub async fn wait_for_drain(self) -> DrainedHostShutdown {
251        DrainedHostShutdown {
252            handles: self.rx.await.ok(),
253            exit_on_shutdown: self.exit_on_shutdown,
254        }
255    }
256
257    /// Wait for the host to drain, run `before_join` while its transports are
258    /// still serving, and then stop and join the transport servers.
259    pub async fn stop_and_join_after_drain(self, before_join: impl future::Future<Output = ()>) {
260        let drained = self.wait_for_drain().await;
261        before_join.await;
262        drained.stop_and_join().await;
263    }
264
265    /// Wait for the host to finish shutting down, stop and join its transport
266    /// servers, and optionally exit the process.
267    pub async fn stop_and_join(self) {
268        self.stop_and_join_after_drain(future::ready(())).await;
269    }
270}
271
272impl DrainedHostShutdown {
273    /// Stop and join the transport servers, then optionally exit the process.
274    pub async fn stop_and_join(mut self) {
275        if let Some(handles) = self.handles.take() {
276            handles
277                .stop_and_join("host shutdown: draining transport servers")
278                .await;
279        }
280        if self.exit_on_shutdown {
281            std::process::exit(0);
282        }
283    }
284}
285
286/// Bootstrap a host in this process using a caller-provided gateway.
287///
288/// The caller passes the [`Gateway`] in — typically [`Gateway::new`],
289/// but it may have been pre-configured (e.g., via
290/// [`Gateway::serve_via`] or [`Gateway::attach`] to connect to
291/// another gateway) before this call. Host construction serves the
292/// gateway's backend and frontend endpoints, so the host's
293/// `system_proc`, `local_proc`, `HostAgent`, and handler ports
294/// snapshot the frontend location when minted. Any preconfigured
295/// `serve_via` session remains active as an outbound route and local
296/// delivery location.
297///
298/// Returns `(host_mesh_agent, shutdown_handle)`:
299///
300/// - `host_mesh_agent` is the [`HostAgent`] actor handle. To obtain the
301///   local proc, use `GetLocalProc` on this agent, then `GetProc` on the
302///   returned proc mesh agent.
303/// - `shutdown_handle` joins the host's accept loop and runs the
304///   drain protocol; see [`HostShutdownHandle`].
305///
306/// - `addr`: the service proc identity and direct host frontend listening
307///   address. The gateway remints the proc's final location after binding and
308///   optional `via` attachment; a source-routed input location is rejected.
309/// - `command`: optional bootstrap command to spawn procs, otherwise [`BootstrapProcManager::current`].
310/// - `config`: optional runtime config overlay.
311/// - `exit_on_shutdown`: if true, [`HostShutdownHandle::join`] will call `process::exit` after draining.
312/// - `listener`: when `Some`, it is used as the frontend listening socket
313///   instead of binding a new one.
314/// - `gateway`: the gateway this host will multiplex traffic through.
315/// - `via`: when `Some`, attach `gateway` to this remote duplex address
316///   with `serve_via` during bootstrap — after the local serves but
317///   before any ref is minted — so refs advertise the routable `Via`
318///   location (used by out-of-cluster clients).
319pub async fn host(
320    addr: ProcAddr,
321    command: Option<BootstrapCommand>,
322    config: Option<Attrs>,
323    exit_on_shutdown: bool,
324    listener: Option<std::net::TcpListener>,
325    gateway: Gateway,
326    via: Option<ChannelAddr>,
327) -> anyhow::Result<(ActorHandle<HostAgent>, HostShutdownHandle)> {
328    if let Some(attrs) = config {
329        hyperactor_config::global::set(hyperactor_config::global::Source::Runtime, attrs);
330        tracing::debug!("bootstrap: installed Runtime config snapshot (Host)");
331    } else {
332        tracing::debug!("bootstrap: no config snapshot provided (Host)");
333    }
334
335    let command = match command {
336        Some(command) => command,
337        None => BootstrapCommand::current()?,
338    };
339    let manager = BootstrapProcManager::new(command)?;
340
341    let host = Host::new_with_gateway(manager, addr, listener, gateway, via).await?;
342    let addr = host.addr().clone();
343
344    // The ShutdownHost handler sends the active client transports back here
345    // for draining. The frontend starts before HostAgent is spawned, and the
346    // host address is published only after HostAgent binds its handler.
347    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<HostShutdownHandles>();
348
349    // Spawned bound: the frontend already accepts connections, and a client
350    // that knows this address may have sent to these singletons already.
351    let system_proc = host.system_proc().clone();
352    let host_mesh_agent = system_proc.spawn_bound_with_uid(
353        Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
354        HostAgent::new_process(host, Some(shutdown_tx)),
355    )?;
356    HostAgent::wait_initialized(&host_mesh_agent).await?;
357
358    system_proc.spawn_bound_with_uid(
359        Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
360        hyperactor_cast::cast_actor::CastActor::default(),
361    )?;
362
363    tracing::info!(
364        "serving host at {}, agent: {}",
365        addr,
366        host_mesh_agent.bind::<HostAgent>()
367    );
368
369    Ok((
370        host_mesh_agent,
371        HostShutdownHandle {
372            rx: shutdown_rx,
373            exit_on_shutdown,
374        },
375    ))
376}
377
378/// Bootstrap configures how a mesh process starts up.
379///
380/// Both `Proc` and `Host` variants may include an optional
381/// configuration snapshot (`hyperactor_config::Attrs`). This
382/// snapshot is serialized into the bootstrap payload and made
383/// available to the child. Interpretation and application of that
384/// snapshot is up to the child process; if omitted, the child falls
385/// back to environment/default values.
386#[derive(Clone, Debug, Serialize, Deserialize)]
387pub enum Bootstrap {
388    /// Bootstrap as a "v1" proc
389    Proc {
390        /// The ProcAddr of the proc to be bootstrapped.
391        proc_id: ProcAddr,
392        /// The backend address to which messages are forwarded.
393        /// See [`crate::host`] for channel topology details.
394        backend_addr: ChannelAddr,
395        /// The callback address used to indicate successful spawning.
396        callback_addr: ChannelAddr,
397        /// Directory for storing proc socket files. Procs place their sockets
398        /// in this directory, so that they can be looked up by other procs
399        /// for direct transfer.
400        socket_dir_path: PathBuf,
401        /// Optional config snapshot (`hyperactor_config::Attrs`)
402        /// captured by the parent. If present, the child installs it
403        /// as the `ClientOverride` layer so the parent's effective config
404        /// takes precedence over Defaults.
405        config: Option<Attrs>,
406    },
407
408    /// Bootstrap as a "v1" host bootstrap. This sets up a new `Host`,
409    /// managed by a [`crate::host_mesh::host_agent::HostAgent`].
410    Host {
411        /// The address on which to serve the host.
412        addr: ChannelAddr,
413        /// Callback used to publish host readiness after the [`HostAgent`] and
414        /// cast actor are bound.
415        callback_addr: ChannelAddr,
416        /// If specified, use the provided command instead of
417        /// [`BootstrapCommand::current`].
418        command: Option<BootstrapCommand>,
419        /// Optional config snapshot (`hyperactor_config::Attrs`)
420        /// captured by the parent. If present, the child installs it
421        /// as the `ClientOverride` layer so the parent's effective config
422        /// takes precedence over Defaults.
423        config: Option<Attrs>,
424        /// If true, exit the process after handling a shutdown request.
425        exit_on_shutdown: bool,
426    },
427}
428
429/// Parent side of the host-bootstrap readiness callback.
430pub struct HostBootstrapReady {
431    host_addr: ChannelAddr,
432    callback_addr: ChannelAddr,
433    callback_rx: hyperactor::channel::ChannelRx<()>,
434}
435
436impl HostBootstrapReady {
437    pub fn new(host_addr: ChannelAddr) -> Result<Self, ChannelError> {
438        let (callback_addr, callback_rx) =
439            channel::serve(ChannelAddr::any(ChannelTransport::Unix))?;
440        Ok(Self {
441            host_addr,
442            callback_addr,
443            callback_rx,
444        })
445    }
446
447    pub fn callback_addr(&self) -> ChannelAddr {
448        self.callback_addr.clone()
449    }
450
451    pub async fn wait(mut self) -> anyhow::Result<()> {
452        let timeout = hyperactor_config::global::get(hyperactor::config::HOST_SPAWN_READY_TIMEOUT);
453        if timeout.is_zero() {
454            self.callback_rx.recv().await.with_context(|| {
455                format!("host {} closed its bootstrap callback", self.host_addr)
456            })?;
457        } else {
458            tokio::time::timeout(timeout, self.callback_rx.recv())
459                .await
460                .with_context(|| {
461                    format!(
462                        "host {} did not become ready within {:?}",
463                        self.host_addr, timeout
464                    )
465                })?
466                .with_context(|| {
467                    format!("host {} closed its bootstrap callback", self.host_addr)
468                })?;
469        }
470        Ok(())
471    }
472}
473
474impl Bootstrap {
475    /// Serialize the mode into a environment-variable-safe string by
476    /// base64-encoding its JSON representation.
477    #[allow(clippy::result_large_err)]
478    pub(crate) fn to_env_safe_string(&self) -> crate::Result<String> {
479        Ok(BASE64_STANDARD.encode(serde_json::to_string(&self)?))
480    }
481
482    /// Deserialize the mode from the representation returned by [`to_env_safe_string`].
483    #[allow(clippy::result_large_err)]
484    pub(crate) fn from_env_safe_string(str: &str) -> crate::Result<Self> {
485        let data = BASE64_STANDARD.decode(str)?;
486        let data = std::str::from_utf8(&data)?;
487        Ok(serde_json::from_str(data)?)
488    }
489
490    /// Get a bootstrap configuration from the environment; returns `None`
491    /// if the environment does not specify a boostrap config.
492    pub fn get_from_env() -> anyhow::Result<Option<Self>> {
493        match std::env::var("HYPERACTOR_MESH_BOOTSTRAP_MODE") {
494            Ok(mode) => match Bootstrap::from_env_safe_string(&mode) {
495                Ok(mode) => Ok(Some(mode)),
496                Err(e) => {
497                    Err(anyhow::Error::from(e).context("parsing HYPERACTOR_MESH_BOOTSTRAP_MODE"))
498                }
499            },
500            Err(VarError::NotPresent) => Ok(None),
501            Err(e) => Err(anyhow::Error::from(e).context("reading HYPERACTOR_MESH_BOOTSTRAP_MODE")),
502        }
503    }
504
505    /// Inject this bootstrap configuration into the environment of the provided command.
506    pub fn to_env(&self, cmd: &mut Command) {
507        cmd.env(
508            "HYPERACTOR_MESH_BOOTSTRAP_MODE",
509            self.to_env_safe_string().unwrap(),
510        );
511    }
512
513    /// Bootstrap this binary according to this configuration.
514    /// This runs until all processes are ready to exit, or returns an error.
515    /// The Ok value is the exit code that should be used.
516    pub async fn bootstrap(self) -> anyhow::Result<i32> {
517        tracing::info!(
518            "bootstrapping mesh process: {}",
519            serde_json::to_string(&self).unwrap()
520        );
521
522        if Debug::is_active() {
523            let mut buf = Vec::new();
524            writeln!(&mut buf, "bootstrapping {}:", std::process::id()).unwrap();
525            #[cfg(unix)]
526            writeln!(
527                &mut buf,
528                "\tparent pid: {}",
529                std::os::unix::process::parent_id()
530            )
531            .unwrap();
532            writeln!(
533                &mut buf,
534                "\tconfig: {}",
535                serde_json::to_string(&self).unwrap()
536            )
537            .unwrap();
538            match std::env::current_exe() {
539                Ok(path) => writeln!(&mut buf, "\tcurrent_exe: {}", path.display()).unwrap(),
540                Err(e) => writeln!(&mut buf, "\tcurrent_exe: error<{}>", e).unwrap(),
541            }
542            writeln!(&mut buf, "\targs:").unwrap();
543            for arg in std::env::args() {
544                writeln!(&mut buf, "\t\t{}", arg).unwrap();
545            }
546            writeln!(&mut buf, "\tenv:").unwrap();
547            for (key, val) in std::env::vars() {
548                writeln!(&mut buf, "\t\t{}={}", key, val).unwrap();
549            }
550            let _ = Debug.write(&buf);
551            if let Ok(s) = std::str::from_utf8(&buf) {
552                tracing::info!("{}", s);
553            } else {
554                tracing::info!("{:?}", buf);
555            }
556        }
557
558        match self {
559            Bootstrap::Proc {
560                proc_id,
561                backend_addr,
562                callback_addr,
563                socket_dir_path,
564                config,
565            } => {
566                let entered = tracing::span!(
567                    Level::INFO,
568                    "proc_bootstrap",
569                    %proc_id,
570                    %backend_addr,
571                    %callback_addr,
572                    socket_dir_path = %socket_dir_path.display(),
573                )
574                .entered();
575                if let Some(attrs) = config {
576                    hyperactor_config::global::set(
577                        hyperactor_config::global::Source::ClientOverride,
578                        attrs,
579                    );
580                    tracing::debug!("bootstrap: installed ClientOverride config snapshot (Proc)");
581                } else {
582                    tracing::debug!("bootstrap: no config snapshot provided (Proc)");
583                }
584
585                if hyperactor_config::global::get(MESH_BOOTSTRAP_ENABLE_PDEATHSIG) {
586                    // Safety net: normal shutdown is via
587                    // `host_mesh.shutdown(&instance)`; PR_SET_PDEATHSIG
588                    // is a last-resort guard against leaks if that
589                    // protocol is bypassed.
590                    let _ = install_pdeathsig_kill();
591                } else {
592                    eprintln!("(bootstrap) PDEATHSIG disabled via config");
593                }
594
595                let local_addr = proc_id.addr().clone();
596                let (serve_addr, _) = local_proc_addr(&socket_dir_path, proc_id.id())?;
597
598                // The following is a modified host::spawn_proc to support direct
599                // dialing between spawned sibling procs: 1) we bind each proc to a
600                // deterministic address in socket_dir_path; 2) we use LocalProcDialer
601                // to dial those addresses directly.
602                let proc_sender = mailbox::LocalProcDialer::new(
603                    local_addr.clone(),
604                    socket_dir_path,
605                    MailboxClient::dial(backend_addr)?,
606                );
607
608                let proc = Proc::configured(proc_id.clone(), proc_sender.into_boxed());
609
610                let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<i32>();
611                let agent_handle = ProcAgent::boot_v1(proc.clone(), Some(shutdown_tx))
612                    .map_err(|e| HostError::AgentSpawnFailure(proc_id, e))?;
613
614                let span = entered.exit();
615
616                // Finally serve the proc on the same transport as the backend address,
617                // and call back.
618                let (proc_addr, proc_rx) = channel::serve(serve_addr)?;
619                let mailbox_handle =
620                    hyperactor::mailbox::MailboxServer::serve(proc.clone(), proc_rx);
621                channel::dial(callback_addr)?
622                    .send((proc_addr, agent_handle.bind::<ProcAgent>()))
623                    .instrument(span)
624                    .await
625                    .map_err(ChannelError::from)?;
626
627                // Wait for the StopAll handler to signal the exit code, then
628                // gracefully stop the mailbox server before exiting.
629                let exit_code = shutdown_rx.await.unwrap_or(1);
630                mailbox_handle.stop("process shutting down");
631                let _ = mailbox_handle.await;
632                tracing::info!("bootstrap shutting down with exit code {}", exit_code);
633                // Don't exit the proc, return Ok so the parent function can decide
634                // how to stop.
635                Ok(exit_code)
636            }
637            Bootstrap::Host {
638                addr,
639                callback_addr,
640                command,
641                config,
642                exit_on_shutdown,
643            } => {
644                let (_agent_handle, shutdown) = host(
645                    ProcAddr::new(legacy_service_proc_id(), addr.into()),
646                    command,
647                    config,
648                    exit_on_shutdown,
649                    None,
650                    Gateway::global().clone(),
651                    None,
652                )
653                .await?;
654                channel::dial(callback_addr)?
655                    .send(())
656                    .await
657                    .map_err(ChannelError::from)?;
658                shutdown.stop_and_join().await;
659                halt().await
660            }
661        }
662    }
663
664    /// A variant of [`bootstrap`] that logs the error and exits the process
665    /// if bootstrapping fails.
666    pub async fn bootstrap_or_die(self) -> ! {
667        let exit_code = match self.bootstrap().await {
668            Ok(exit_code) => exit_code,
669            Err(err) => {
670                tracing::error!("failed to bootstrap mesh process: {}", err);
671                1
672            }
673        };
674        std::process::exit(exit_code);
675    }
676}
677
678/// Install "kill me if parent dies" and close the race window.
679pub fn install_pdeathsig_kill() -> io::Result<()> {
680    #[cfg(target_os = "linux")]
681    {
682        // SAFETY: `getppid()` is a simple libc syscall returning the
683        // parent PID; it has no side effects and does not touch memory.
684        let ppid_before = unsafe { libc::getppid() };
685
686        // SAFETY: Calling into libc; does not dereference memory, just
687        // asks the kernel to deliver SIGKILL on parent death.
688        let rc = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_int) };
689        if rc != 0 {
690            return Err(io::Error::last_os_error());
691        }
692
693        // Race-close: if the parent died between our exec and prctl(),
694        // we won't get a signal, so detect that and exit now.
695        //
696        // If the parent PID changed, the parent has died and we've been
697        // reparented. Note: We cannot assume ppid == 1 means the parent
698        // died, as in container environments (e.g., Kubernetes) the parent
699        // may legitimately run as PID 1.
700        // SAFETY: `getppid()` is a simple libc syscall returning the
701        // parent PID; it has no side effects and does not touch memory.
702        let ppid_after = unsafe { libc::getppid() };
703        if ppid_before != ppid_after {
704            std::process::exit(0);
705        }
706    }
707    Ok(())
708}
709
710/// Represents the lifecycle state of a **proc as hosted in an OS
711/// process** managed by `BootstrapProcManager`.
712///
713/// Note: This type is deliberately distinct from [`ProcState`] and
714/// [`ProcStopReason`] (see `alloc.rs`). Those types model allocator
715/// *events* - e.g. "a proc was Created/Running/Stopped" - and are
716/// consumed from an event stream during allocation. By contrast,
717/// [`ProcStatus`] is a **live, queryable view**: it reflects the
718/// current observed status of a running proc, as seen through the
719/// [`BootstrapProcHandle`] API (stop, kill, status).
720///
721/// In short:
722/// - `ProcState`/`ProcStopReason`: historical / event-driven model
723/// - `ProcStatus`: immediate status surface for lifecycle control
724#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
725pub enum ProcStatus {
726    /// The OS process has been spawned but is not yet fully running.
727    /// (Process-level: child handle exists, no confirmation yet.)
728    Starting,
729    /// The OS process is alive and considered running.
730    /// (Proc-level: bootstrap may still be running.)
731    Running { started_at: SystemTime },
732    /// Ready means bootstrap has completed and the proc is serving.
733    /// (Proc-level: bootstrap completed.)
734    Ready {
735        started_at: SystemTime,
736        addr: ChannelAddr,
737        agent: ActorRef<ProcAgent>,
738    },
739    /// A stop has been requested (SIGTERM, graceful shutdown, etc.),
740    /// but the OS process has not yet fully exited. (Proc-level:
741    /// shutdown in progress; Process-level: still running.)
742    Stopping { started_at: SystemTime },
743    /// The process exited with a normal exit code. (Process-level:
744    /// exit observed.)
745    Stopped {
746        exit_code: i32,
747        stderr_tail: Vec<String>,
748    },
749    /// The process was killed by a signal (e.g. SIGKILL).
750    /// (Process-level: abnormal termination.)
751    Killed { signal: i32, core_dumped: bool },
752    /// The proc or its process failed for some other reason
753    /// (bootstrap error, unexpected condition, etc.). (Both levels:
754    /// catch-all failure.)
755    Failed { reason: String },
756}
757
758impl ProcStatus {
759    /// Returns `true` if the proc is in a terminal (exited) state:
760    /// [`ProcStatus::Stopped`], [`ProcStatus::Killed`], or
761    /// [`ProcStatus::Failed`].
762    #[inline]
763    pub fn is_exit(&self) -> bool {
764        matches!(
765            self,
766            ProcStatus::Stopped { .. } | ProcStatus::Killed { .. } | ProcStatus::Failed { .. }
767        )
768    }
769}
770
771impl std::fmt::Display for ProcStatus {
772    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
773        match self {
774            ProcStatus::Starting => write!(f, "Starting"),
775            ProcStatus::Running { started_at } => {
776                let uptime = started_at
777                    .elapsed()
778                    .map(|d| format!(" up {}", format_duration(d)))
779                    .unwrap_or_default();
780                write!(f, "Running{uptime}")
781            }
782            ProcStatus::Ready {
783                started_at, addr, ..
784            } => {
785                let uptime = started_at
786                    .elapsed()
787                    .map(|d| format!(" up {}", format_duration(d)))
788                    .unwrap_or_default();
789                write!(f, "Ready at {addr}{uptime}")
790            }
791            ProcStatus::Stopping { started_at } => {
792                let uptime = started_at
793                    .elapsed()
794                    .map(|d| format!(" up {}", format_duration(d)))
795                    .unwrap_or_default();
796                write!(f, "Stopping{uptime}")
797            }
798            ProcStatus::Stopped { exit_code, .. } => write!(f, "Stopped(exit={exit_code})"),
799            ProcStatus::Killed {
800                signal,
801                core_dumped,
802            } => {
803                if *core_dumped {
804                    write!(f, "Killed(sig={signal}, core)")
805                } else {
806                    write!(f, "Killed(sig={signal})")
807                }
808            }
809            ProcStatus::Failed { reason } => write!(f, "Failed({reason})"),
810        }
811    }
812}
813
814/// Error returned by [`BootstrapProcHandle::ready`].
815#[derive(Debug, Clone)]
816pub enum ReadyError {
817    /// The proc reached a terminal state before `Ready`.
818    Terminal(ProcStatus),
819    /// The internal watch channel closed unexpectedly.
820    ChannelClosed,
821}
822
823impl std::fmt::Display for ReadyError {
824    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
825        match self {
826            ReadyError::Terminal(st) => write!(f, "proc terminated before running: {st:?}"),
827            ReadyError::ChannelClosed => write!(f, "status channel closed"),
828        }
829    }
830}
831impl std::error::Error for ReadyError {}
832
833/// A handle to a proc launched by [`BootstrapProcManager`].
834///
835/// `BootstrapProcHandle` is a lightweight supervisor for an external
836/// process: it tracks and broadcasts lifecycle state, and exposes a
837/// small control/observation surface. While it may temporarily hold a
838/// `tokio::process::Child` (shared behind a mutex) so the exit
839/// monitor can `wait()` it, it is **not** the unique owner of the OS
840/// process, and dropping a `BootstrapProcHandle` does not by itself
841/// terminate the process.
842///
843/// What it pairs together:
844/// - the **logical proc identity** (`ProcAddr`)
845/// - the **live status surface** ([`ProcStatus`]), available both as
846///   a synchronous snapshot (`status()`) and as an async stream via a
847///   `tokio::sync::watch` channel (`watch()` / `changed()`)
848///
849/// Responsibilities:
850/// - Retain the child handle only until the exit monitor claims it,
851///   so the OS process can be awaited and its terminal status
852///   recorded.
853/// - Hold stdout/stderr tailers until the exit monitor takes them,
854///   then join to recover buffered output for diagnostics.
855/// - Update status via the `mark_*` transitions and broadcast changes
856///   over the watch channel so tasks can `await` lifecycle
857///   transitions without polling.
858/// - Provide the foundation for higher-level APIs like `wait()`
859///   (await terminal) and, later, `terminate()` / `kill()`.
860///
861/// Notes:
862/// - Manager-level cleanup happens in [`BootstrapProcManager::drop`]:
863///   it SIGKILLs any still-recorded PIDs; we do not rely on
864///   `Child::kill_on_drop`.
865///
866/// Relationship to types:
867/// - [`ProcStatus`]: live status surface, updated by this handle.
868/// - [`ProcState`]/[`ProcStopReason`] (in `alloc.rs`):
869///   allocator-facing, historical event log; not directly updated by
870///   this type.
871#[derive(Clone)]
872pub struct BootstrapProcHandle {
873    /// Logical identity of the proc in the mesh.
874    proc_id: ProcAddr,
875
876    /// Live lifecycle snapshot (see [`ProcStatus`]). Kept in a mutex
877    /// so [`BootstrapProcHandle::status`] can return a synchronous
878    /// copy. All mutations now flow through
879    /// [`BootstrapProcHandle::transition`], which updates this field
880    /// under the lock and then broadcasts on the watch channel.
881    status: Arc<std::sync::Mutex<ProcStatus>>,
882
883    /// Launcher used to terminate/kill the proc. The launcher owns
884    /// the actual OS child handle and PID tracking.
885    ///
886    /// We hold a `Weak` reference so that when `BootstrapProcManager`
887    /// drops, the launcher's `Arc` refcount reaches zero and its `Drop`
888    /// runs, cleaning up any remaining child processes. If the manager
889    /// is gone when we try to terminate/kill, we treat it as a no-op
890    /// (the proc is being killed by the launcher's Drop anyway).
891    launcher: Weak<dyn ProcLauncher>,
892
893    /// Stdout monitor for this proc. Created with `StreamFwder::start`, it
894    /// forwards output to a log channel and keeps a bounded ring buffer.
895    /// Transferred to the exit monitor, which joins it after `wait()`
896    /// to recover buffered lines.
897    stdout_fwder: Arc<std::sync::Mutex<Option<StreamFwder>>>,
898
899    /// Stderr monitor for this proc. Same behavior as `stdout_fwder`
900    /// but for stderr (used for exit-reason enrichment).
901    stderr_fwder: Arc<std::sync::Mutex<Option<StreamFwder>>>,
902
903    /// Watch sender for status transitions. Every `mark_*` goes
904    /// through [`BootstrapProcHandle::transition`], which updates the
905    /// snapshot under the lock and then `send`s the new
906    /// [`ProcStatus`].
907    tx: tokio::sync::watch::Sender<ProcStatus>,
908
909    /// Watch receiver seed. `watch()` clones this so callers can
910    /// `borrow()` the current status and `changed().await` future
911    /// transitions independently.
912    rx: tokio::sync::watch::Receiver<ProcStatus>,
913}
914
915impl fmt::Debug for BootstrapProcHandle {
916    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
917        let status = self.status.lock().expect("status mutex poisoned").clone();
918        f.debug_struct("BootstrapProcHandle")
919            .field("proc_id", &self.proc_id)
920            .field("status", &status)
921            .field("launcher", &"<dyn ProcLauncher>")
922            .field("tx", &"<watch::Sender>")
923            .field("rx", &"<watch::Receiver>")
924            // Intentionally skip stdout_tailer / stderr_tailer (not
925            // Debug).
926            .finish()
927    }
928}
929
930// See BS-1 in module doc.
931impl BootstrapProcHandle {
932    /// Construct a new [`BootstrapProcHandle`] for a freshly spawned
933    /// OS process hosting a proc.
934    ///
935    /// - Initializes the status to [`ProcStatus::Starting`] since the
936    ///   child process has been created but not yet confirmed running.
937    /// - Stores the launcher reference for terminate/kill delegation.
938    ///
939    /// This is the canonical entry point used by
940    /// `BootstrapProcManager` when it launches a proc into a new
941    /// process.
942    pub(crate) fn new(proc_id: ProcAddr, launcher: Weak<dyn ProcLauncher>) -> Self {
943        let (tx, rx) = watch::channel(ProcStatus::Starting);
944        Self {
945            proc_id,
946            status: Arc::new(std::sync::Mutex::new(ProcStatus::Starting)),
947            launcher,
948            stdout_fwder: Arc::new(std::sync::Mutex::new(None)),
949            stderr_fwder: Arc::new(std::sync::Mutex::new(None)),
950            tx,
951            rx,
952        }
953    }
954
955    /// Return the logical proc address in the mesh.
956    #[inline]
957    pub fn proc_addr(&self) -> &ProcAddr {
958        &self.proc_id
959    }
960
961    /// Create a new subscription to this proc's status stream.
962    ///
963    /// Each call returns a fresh [`watch::Receiver`] tied to this
964    /// handle's internal [`ProcStatus`] channel. The receiver can be
965    /// awaited on (`rx.changed().await`) to observe lifecycle
966    /// transitions as they occur.
967    ///
968    /// Notes:
969    /// - Multiple subscribers can exist simultaneously; each sees
970    ///   every status update in order.
971    /// - Use [`BootstrapProcHandle::status`] for a one-off snapshot;
972    ///   use `watch()` when you need to await changes over time.
973    #[inline]
974    pub fn watch(&self) -> tokio::sync::watch::Receiver<ProcStatus> {
975        self.rx.clone()
976    }
977
978    /// Wait until this proc's status changes.
979    ///
980    /// This is a convenience wrapper around
981    /// [`watch::Receiver::changed`]: it subscribes internally via
982    /// [`BootstrapProcHandle::watch`] and awaits the next transition.
983    /// If no subscribers exist or the channel is closed, this returns
984    /// without error.
985    ///
986    /// Typical usage:
987    /// ```ignore
988    /// handle.changed().await;
989    /// match handle.status() {
990    ///     ProcStatus::Running { .. } => { /* now running */ }
991    ///     ProcStatus::Stopped { .. } => { /* exited */ }
992    ///     _ => {}
993    /// }
994    /// ```
995    #[inline]
996    pub async fn changed(&self) {
997        let _ = self.watch().changed().await;
998    }
999
1000    /// Return a snapshot of the current [`ProcStatus`] for this proc.
1001    ///
1002    /// This is a *live view* of the lifecycle state as tracked by
1003    /// [`BootstrapProcManager`]. It reflects what is currently known
1004    /// about the underlying OS process (e.g., `Starting`, `Running`,
1005    /// `Stopping`, etc.).
1006    ///
1007    /// Internally this reads the mutex-guarded status. Use this when
1008    /// you just need a synchronous snapshot; use
1009    /// [`BootstrapProcHandle::watch`] or
1010    /// [`BootstrapProcHandle::changed`] if you want to await
1011    /// transitions asynchronously.
1012    #[must_use]
1013    pub fn status(&self) -> ProcStatus {
1014        // Source of truth for now is the mutex. We broadcast via
1015        // `watch` in `transition`, but callers that want a
1016        // synchronous snapshot should read the guarded value.
1017        self.status.lock().expect("status mutex poisoned").clone()
1018    }
1019
1020    /// Atomically apply a state transition while holding the status
1021    /// lock, and send the updated value on the watch channel **while
1022    /// still holding the lock**. This guarantees the mutex state and
1023    /// the broadcast value stay in sync and avoids reordering between
1024    /// concurrent transitions.
1025    fn transition<F>(&self, f: F) -> bool
1026    where
1027        F: FnOnce(&mut ProcStatus) -> bool,
1028    {
1029        let mut guard = self.status.lock().expect("status mutex poisoned");
1030        let _before = guard.clone();
1031        let changed = f(&mut guard);
1032        if changed {
1033            // Publish while still holding the lock to preserve order.
1034            let _ = self.tx.send(guard.clone());
1035        }
1036        changed
1037    }
1038
1039    /// Transition this proc into the [`ProcStatus::Running`] state.
1040    ///
1041    /// Called internally once the child OS process has been spawned.
1042    /// Records the `started_at` timestamp so that callers can query it
1043    /// later via [`BootstrapProcHandle::status`].
1044    ///
1045    /// This is a best-effort marker: it reflects that the process
1046    /// exists at the OS level, but does not guarantee that the proc
1047    /// has completed bootstrap or is fully ready.
1048    pub(crate) fn mark_running(&self, started_at: SystemTime) -> bool {
1049        self.transition(|st| match *st {
1050            ProcStatus::Starting => {
1051                *st = ProcStatus::Running { started_at };
1052                true
1053            }
1054            _ => {
1055                tracing::warn!(
1056                    "illegal transition: {:?} -> Running; leaving status unchanged",
1057                    *st
1058                );
1059                false
1060            }
1061        })
1062    }
1063
1064    /// Attempt to transition this proc into the [`ProcStatus::Ready`]
1065    /// state.
1066    ///
1067    /// This records the listening address and agent once the proc has
1068    /// successfully started and is ready to serve. The `started_at`
1069    /// timestamp is derived from the current `Running` state.
1070    ///
1071    /// Returns `true` if the transition succeeded (from `Starting` or
1072    /// `Running`), or `false` if the current state did not allow
1073    /// moving to `Ready`. In the latter case the state is left
1074    /// unchanged and a warning is logged.
1075    pub(crate) fn mark_ready(&self, addr: ChannelAddr, agent: ActorRef<ProcAgent>) -> bool {
1076        tracing::info!(proc_id = %self.proc_id, %addr, "{} ready at {}", self.proc_id, addr);
1077        self.transition(|st| match st {
1078            ProcStatus::Starting => {
1079                // Unexpected: we should be Running before Ready, but
1080                // handle gracefully with current time.
1081                *st = ProcStatus::Ready {
1082                    started_at: std::time::SystemTime::now(),
1083                    addr,
1084                    agent,
1085                };
1086                true
1087            }
1088            ProcStatus::Running { started_at } => {
1089                let started_at = *started_at;
1090                *st = ProcStatus::Ready {
1091                    started_at,
1092                    addr,
1093                    agent,
1094                };
1095                true
1096            }
1097            _ => {
1098                tracing::warn!(
1099                    "illegal transition: {:?} -> Ready; leaving status unchanged",
1100                    st
1101                );
1102                false
1103            }
1104        })
1105    }
1106
1107    /// Record that a stop has been requested for the proc (e.g. a
1108    /// graceful shutdown via SIGTERM), but the underlying process has
1109    /// not yet fully exited.
1110    pub(crate) fn mark_stopping(&self) -> bool {
1111        let now = std::time::SystemTime::now();
1112
1113        self.transition(|st| match *st {
1114            ProcStatus::Running { started_at } => {
1115                *st = ProcStatus::Stopping { started_at };
1116                true
1117            }
1118            ProcStatus::Ready { started_at, .. } => {
1119                *st = ProcStatus::Stopping { started_at };
1120                true
1121            }
1122            ProcStatus::Starting => {
1123                *st = ProcStatus::Stopping { started_at: now };
1124                true
1125            }
1126            _ => false,
1127        })
1128    }
1129
1130    /// Record that the process has exited normally with the given
1131    /// exit code.
1132    pub(crate) fn mark_stopped(&self, exit_code: i32, stderr_tail: Vec<String>) -> bool {
1133        self.transition(|st| match *st {
1134            ProcStatus::Starting
1135            | ProcStatus::Running { .. }
1136            | ProcStatus::Ready { .. }
1137            | ProcStatus::Stopping { .. } => {
1138                *st = ProcStatus::Stopped {
1139                    exit_code,
1140                    stderr_tail,
1141                };
1142                true
1143            }
1144            _ => {
1145                tracing::warn!(
1146                    "illegal transition: {:?} -> Stopped; leaving status unchanged",
1147                    *st
1148                );
1149                false
1150            }
1151        })
1152    }
1153
1154    /// Record that the process was killed by the given signal (e.g.
1155    /// SIGKILL, SIGTERM).
1156    pub(crate) fn mark_killed(&self, signal: i32, core_dumped: bool) -> bool {
1157        self.transition(|st| match *st {
1158            ProcStatus::Starting
1159            | ProcStatus::Running { .. }
1160            | ProcStatus::Ready { .. }
1161            | ProcStatus::Stopping { .. } => {
1162                *st = ProcStatus::Killed {
1163                    signal,
1164                    core_dumped,
1165                };
1166                true
1167            }
1168            _ => {
1169                tracing::warn!(
1170                    "illegal transition: {:?} -> Killed; leaving status unchanged",
1171                    *st
1172                );
1173                false
1174            }
1175        })
1176    }
1177
1178    /// Record that the proc or its process failed for an unexpected
1179    /// reason (bootstrap error, spawn failure, etc.).
1180    pub(crate) fn mark_failed<S: Into<String>>(&self, reason: S) -> bool {
1181        self.transition(|st| match *st {
1182            ProcStatus::Starting
1183            | ProcStatus::Running { .. }
1184            | ProcStatus::Ready { .. }
1185            | ProcStatus::Stopping { .. } => {
1186                *st = ProcStatus::Failed {
1187                    reason: reason.into(),
1188                };
1189                true
1190            }
1191            _ => {
1192                tracing::warn!(
1193                    "illegal transition: {:?} -> Failed; leaving status unchanged",
1194                    *st
1195                );
1196                false
1197            }
1198        })
1199    }
1200
1201    /// Wait until the proc has reached a terminal state and return
1202    /// it.
1203    ///
1204    /// Terminal means [`ProcStatus::Stopped`],
1205    /// [`ProcStatus::Killed`], or [`ProcStatus::Failed`]. If the
1206    /// current status is already terminal, returns immediately.
1207    ///
1208    /// Non-consuming: `BootstrapProcHandle` is a supervisor, not the
1209    /// owner of the OS process, so you can call `wait()` from
1210    /// multiple tasks concurrently.
1211    ///
1212    /// Implementation detail: listens on this handle's `watch`
1213    /// channel. It snapshots the current status, and if not terminal
1214    /// awaits the next change. If the channel closes unexpectedly,
1215    /// returns the last observed status.
1216    ///
1217    /// Mirrors `tokio::process::Child::wait()`, but yields the
1218    /// higher-level [`ProcStatus`] instead of an `ExitStatus`.
1219    #[must_use]
1220    pub async fn wait_inner(&self) -> ProcStatus {
1221        let mut rx = self.watch();
1222        loop {
1223            let st = rx.borrow().clone();
1224            if st.is_exit() {
1225                return st;
1226            }
1227            // If the channel closes, return the last observed value.
1228            if rx.changed().await.is_err() {
1229                return st;
1230            }
1231        }
1232    }
1233
1234    /// Wait until the proc reaches the [`ProcStatus::Ready`] state.
1235    ///
1236    /// If the proc hits a terminal state ([`ProcStatus::Stopped`],
1237    /// [`ProcStatus::Killed`], or [`ProcStatus::Failed`]) before ever
1238    /// becoming `Ready`, this returns
1239    /// `Err(ReadyError::Terminal(status))`. If the internal watch
1240    /// channel closes unexpectedly, this returns
1241    /// `Err(ReadyError::ChannelClosed)`. Otherwise it returns
1242    /// `Ok(())` when `Ready` is first observed.
1243    ///
1244    /// Non-consuming: `BootstrapProcHandle` is a supervisor, not the
1245    /// owner; multiple tasks may await `ready()` concurrently.
1246    /// `Stopping` is not treated as terminal here; we continue
1247    /// waiting until `Ready` or a terminal state is seen.
1248    ///
1249    /// Companion to [`BootstrapProcHandle::wait_inner`]:
1250    /// `wait_inner()` resolves on exit; `ready_inner()` resolves on
1251    /// startup.
1252    pub async fn ready_inner(&self) -> Result<(), ReadyError> {
1253        let mut rx = self.watch();
1254        loop {
1255            let st = rx.borrow().clone();
1256            match &st {
1257                ProcStatus::Ready { .. } => return Ok(()),
1258                s if s.is_exit() => return Err(ReadyError::Terminal(st)),
1259                _non_terminal => {
1260                    if rx.changed().await.is_err() {
1261                        return Err(ReadyError::ChannelClosed);
1262                    }
1263                }
1264            }
1265        }
1266    }
1267
1268    pub fn set_stream_monitors(&self, out: Option<StreamFwder>, err: Option<StreamFwder>) {
1269        *self
1270            .stdout_fwder
1271            .lock()
1272            .expect("stdout_tailer mutex poisoned") = out;
1273        *self
1274            .stderr_fwder
1275            .lock()
1276            .expect("stderr_tailer mutex poisoned") = err;
1277    }
1278
1279    fn take_stream_monitors(&self) -> (Option<StreamFwder>, Option<StreamFwder>) {
1280        let out = self
1281            .stdout_fwder
1282            .lock()
1283            .expect("stdout_tailer mutex poisoned")
1284            .take();
1285        let err = self
1286            .stderr_fwder
1287            .lock()
1288            .expect("stderr_tailer mutex poisoned")
1289            .take();
1290        (out, err)
1291    }
1292
1293    /// Wait for the proc to exit, escalating to launcher terminate/kill
1294    /// if it doesn't exit within `timeout`.
1295    ///
1296    /// This is a fire-and-forget helper: it assumes a stop signal has
1297    /// already been sent. It waits for exit, then escalates through
1298    /// terminate and kill if needed.
1299    pub(crate) async fn wait_or_brutally_kill(&self, timeout: Duration) {
1300        match tokio::time::timeout(timeout, self.wait_inner()).await {
1301            Ok(st) if st.is_exit() => return,
1302            _ => {}
1303        }
1304
1305        let _ = self.mark_stopping();
1306
1307        if let Some(launcher) = self.launcher.upgrade() {
1308            let ref_proc_id: ProcAddr = self.proc_id.clone();
1309            if let Err(e) = launcher.terminate(&ref_proc_id, timeout).await {
1310                tracing::warn!(
1311                    proc_id = %self.proc_id,
1312                    error = %e,
1313                    "wait_or_brutally_kill: launcher terminate failed, trying kill"
1314                );
1315                let _ = launcher.kill(&ref_proc_id).await;
1316            }
1317        }
1318
1319        let _ = self.wait_inner().await;
1320    }
1321
1322    /// Sends a StopAll message to the ProcAgent, which should exit the process.
1323    /// Waits for the successful state change of the process. If the process
1324    /// doesn't reach a terminal state, returns Err.
1325    async fn send_stop_all(
1326        &self,
1327        cx: &impl context::Actor,
1328        agent: ActorRef<ProcAgent>,
1329        timeout: Duration,
1330        reason: &str,
1331    ) -> anyhow::Result<ProcStatus> {
1332        // For all of the messages and replies in this function:
1333        // if the proc is already dead, then the message will be undeliverable,
1334        // which should be ignored.
1335        // If this message isn't deliverable to the agent, the process may have
1336        // stopped already. No need to produce any errors, just continue with
1337        // killing the process.
1338        let mut agent_port = agent.port();
1339        agent_port.return_undeliverable(false);
1340        agent_port.post(
1341            cx,
1342            resource::StopAll {
1343                reason: reason.to_string(),
1344            },
1345        );
1346        // The agent handling Stop should exit the process, if it doesn't within
1347        // the time window, we escalate to SIGTERM.
1348        match tokio::time::timeout(timeout, self.wait()).await {
1349            Ok(Ok(st)) => Ok(st),
1350            Ok(Err(e)) => Err(anyhow::anyhow!("agent did not exit the process: {:?}", e)),
1351            Err(_) => Err(anyhow::anyhow!("agent did not exit the process in time")),
1352        }
1353    }
1354}
1355
1356#[async_trait]
1357impl ProcHandle for BootstrapProcHandle {
1358    type Agent = ProcAgent;
1359    type TerminalStatus = ProcStatus;
1360
1361    #[inline]
1362    fn proc_addr(&self) -> &ProcAddr {
1363        &self.proc_id
1364    }
1365
1366    #[inline]
1367    fn addr(&self) -> Option<ChannelAddr> {
1368        match &*self.status.lock().expect("status mutex poisoned") {
1369            ProcStatus::Ready { addr, .. } => Some(addr.clone()),
1370            _ => None,
1371        }
1372    }
1373
1374    #[inline]
1375    fn agent_ref(&self) -> Option<ActorRef<Self::Agent>> {
1376        match &*self.status.lock().expect("status mutex poisoned") {
1377            ProcStatus::Ready { agent, .. } => Some(agent.clone()),
1378            _ => None,
1379        }
1380    }
1381
1382    /// Wait until this proc first reaches the [`ProcStatus::Ready`]
1383    /// state.
1384    ///
1385    /// Returns `Ok(())` once `Ready` is observed.
1386    ///
1387    /// If the proc transitions directly to a terminal state before
1388    /// becoming `Ready`, returns `Err(ReadyError::Terminal(status))`.
1389    ///
1390    /// If the internal status watch closes unexpectedly before
1391    /// `Ready` is observed, returns `Err(ReadyError::ChannelClosed)`.
1392    async fn ready(&self) -> Result<(), HostReadyError<Self::TerminalStatus>> {
1393        match self.ready_inner().await {
1394            Ok(()) => Ok(()),
1395            Err(ReadyError::Terminal(status)) => Err(HostReadyError::Terminal(status)),
1396            Err(ReadyError::ChannelClosed) => Err(HostReadyError::ChannelClosed),
1397        }
1398    }
1399
1400    /// Wait until this proc reaches a terminal [`ProcStatus`].
1401    ///
1402    /// Returns `Ok(status)` when a terminal state is observed
1403    /// (`Stopped`, `Killed`, or `Failed`).
1404    ///
1405    /// If the internal status watch closes before any terminal state
1406    /// is seen, returns `Err(WaitError::ChannelClosed)`.
1407    async fn wait(&self) -> Result<Self::TerminalStatus, WaitError> {
1408        let status = self.wait_inner().await;
1409        if status.is_exit() {
1410            Ok(status)
1411        } else {
1412            Err(WaitError::ChannelClosed)
1413        }
1414    }
1415
1416    /// Attempt to terminate the underlying OS process.
1417    ///
1418    /// This drives **process-level** teardown only:
1419    /// - First attempts graceful shutdown via `ProcAgent` if available.
1420    /// - If that fails or times out, delegates to the launcher's
1421    ///   `terminate()` method, which handles SIGTERM/SIGKILL escalation.
1422    ///
1423    /// If the process was already in a terminal state when called,
1424    /// returns [`TerminateError::AlreadyTerminated`].
1425    ///
1426    /// # Parameters
1427    /// - `timeout`: Grace period to wait after graceful shutdown before
1428    ///   escalating.
1429    /// - `reason`: Human-readable reason for termination.
1430    ///
1431    /// # Returns
1432    /// - `Ok(ProcStatus)` if the process exited during the
1433    ///   termination sequence.
1434    /// - `Err(TerminateError)` if already exited, signaling failed,
1435    ///   or the channel was lost.
1436    async fn terminate(
1437        &self,
1438        cx: &impl context::Actor,
1439        timeout: Duration,
1440        reason: &str,
1441    ) -> Result<ProcStatus, TerminateError<Self::TerminalStatus>> {
1442        // If already terminal, return that.
1443        let st0 = self.status();
1444        if st0.is_exit() {
1445            tracing::debug!(?st0, "terminate(): already terminal");
1446            return Err(TerminateError::AlreadyTerminated(st0));
1447        }
1448
1449        // Before signaling, try to close actors normally. Only works if
1450        // they are in the Ready state and have an Agent we can message.
1451        let agent = self.agent_ref();
1452        if let Some(agent) = agent {
1453            match self.send_stop_all(cx, agent.clone(), timeout, reason).await {
1454                Ok(st) => return Ok(st),
1455                Err(e) => {
1456                    // Variety of possible errors, proceed with launcher termination.
1457                    tracing::warn!(
1458                        "ProcAgent {} could not successfully stop all actors: {}",
1459                        agent.actor_addr(),
1460                        e,
1461                    );
1462                }
1463            }
1464        }
1465
1466        // Mark "Stopping" (ok if state races).
1467        let _ = self.mark_stopping();
1468
1469        // Delegate to launcher for SIGTERM/SIGKILL escalation.
1470        tracing::info!(proc_id = %self.proc_id, ?timeout, "terminate(): delegating to launcher");
1471        let ref_proc_id: ProcAddr = self.proc_id.clone();
1472        if let Some(launcher) = self.launcher.upgrade() {
1473            if let Err(e) = launcher.terminate(&ref_proc_id, timeout).await {
1474                tracing::warn!(proc_id = %self.proc_id, error=%e, "terminate(): launcher termination failed");
1475                return Err(TerminateError::Io(anyhow::anyhow!(
1476                    "launcher termination failed: {}",
1477                    e
1478                )));
1479            }
1480        } else {
1481            // Launcher dropped - its Drop is killing all procs anyway.
1482            tracing::debug!(proc_id = %self.proc_id, "terminate(): launcher gone, proc cleanup in progress");
1483        }
1484
1485        // Wait for the exit monitor to observe terminal state.
1486        let st = self.wait_inner().await;
1487        if st.is_exit() {
1488            tracing::info!(proc_id = %self.proc_id, ?st, "terminate(): exited");
1489            Ok(st)
1490        } else {
1491            Err(TerminateError::ChannelClosed)
1492        }
1493    }
1494
1495    /// Forcibly kill the underlying OS process.
1496    ///
1497    /// This bypasses any graceful shutdown semantics and immediately
1498    /// delegates to the launcher's `kill()` method. It is intended as
1499    /// a last-resort termination mechanism when `terminate()` fails or
1500    /// when no grace period is desired.
1501    ///
1502    /// # Behavior
1503    /// - If the process was already in a terminal state, returns
1504    ///   [`TerminateError::AlreadyTerminated`].
1505    /// - Otherwise delegates to the launcher's `kill()` method.
1506    /// - Then waits for the exit monitor to observe a terminal state.
1507    ///
1508    /// # Returns
1509    /// - `Ok(ProcStatus)` if the process exited after kill.
1510    /// - `Err(TerminateError)` if already exited, signaling failed,
1511    ///   or the channel was lost.
1512    async fn kill(&self) -> Result<ProcStatus, TerminateError<Self::TerminalStatus>> {
1513        // If already terminal, return that.
1514        let st0 = self.status();
1515        if st0.is_exit() {
1516            return Err(TerminateError::AlreadyTerminated(st0));
1517        }
1518
1519        // Delegate to launcher for kill.
1520        tracing::info!(proc_id = %self.proc_id, "kill(): delegating to launcher");
1521        let ref_proc_id: ProcAddr = self.proc_id.clone();
1522        if let Some(launcher) = self.launcher.upgrade() {
1523            if let Err(e) = launcher.kill(&ref_proc_id).await {
1524                tracing::warn!(proc_id = %self.proc_id, error=%e, "kill(): launcher kill failed");
1525                return Err(TerminateError::Io(anyhow::anyhow!(
1526                    "launcher kill failed: {}",
1527                    e
1528                )));
1529            }
1530        } else {
1531            // Launcher dropped - its Drop is killing all procs anyway.
1532            tracing::debug!(proc_id = %self.proc_id, "kill(): launcher gone, proc cleanup in progress");
1533        }
1534
1535        // Wait for exit monitor to record terminal status.
1536        let st = self.wait_inner().await;
1537        if st.is_exit() {
1538            Ok(st)
1539        } else {
1540            Err(TerminateError::ChannelClosed)
1541        }
1542    }
1543}
1544
1545/// A specification of the command used to bootstrap procs.
1546#[derive(Debug, Named, Serialize, Deserialize, Clone)]
1547pub struct BootstrapCommand {
1548    pub program: PathBuf,
1549    pub arg0: Option<String>,
1550    pub args: Vec<String>,
1551    /// Environment variables for the child process. If `inherit_env`
1552    /// is set, these are applied on top of the spawning process's
1553    /// environment; otherwise, the child starts from an empty
1554    /// environment. These take precedence over `env_remove`.
1555    /// Launchers set their bootstrap variables after these.
1556    pub env: HashMap<String, String>,
1557    /// Environment variables to remove from the inherited
1558    /// environment.
1559    pub env_remove: HashSet<String>,
1560    /// Whether the child process inherits the environment of the
1561    /// process that spawns it (typically the host agent).
1562    pub inherit_env: bool,
1563}
1564wirevalue::register_type!(BootstrapCommand);
1565
1566impl Default for BootstrapCommand {
1567    fn default() -> Self {
1568        Self {
1569            program: PathBuf::new(),
1570            arg0: None,
1571            args: vec![],
1572            env: HashMap::new(),
1573            env_remove: HashSet::new(),
1574            inherit_env: true,
1575        }
1576    }
1577}
1578
1579impl std::hash::Hash for BootstrapCommand {
1580    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1581        self.program.hash(state);
1582        self.arg0.hash(state);
1583        self.args.hash(state);
1584        let mut pairs: Vec<_> = self.env.iter().collect();
1585        pairs.sort();
1586        pairs.hash(state);
1587        let mut removed: Vec<_> = self.env_remove.iter().collect();
1588        removed.sort();
1589        removed.hash(state);
1590        self.inherit_env.hash(state);
1591    }
1592}
1593
1594impl PartialEq for BootstrapCommand {
1595    fn eq(&self, other: &Self) -> bool {
1596        self.program == other.program
1597            && self.arg0 == other.arg0
1598            && self.args == other.args
1599            && self.env == other.env
1600            && self.env_remove == other.env_remove
1601            && self.inherit_env == other.inherit_env
1602    }
1603}
1604
1605impl Eq for BootstrapCommand {}
1606
1607impl BootstrapCommand {
1608    /// Creates a bootstrap command specification to replicate the
1609    /// invocation of the currently running process.
1610    pub fn current() -> io::Result<Self> {
1611        let mut args: VecDeque<String> = std::env::args().collect();
1612        let arg0 = args.pop_front();
1613
1614        Ok(Self {
1615            program: std::env::current_exe()?,
1616            arg0,
1617            args: args.into(),
1618            ..Default::default()
1619        })
1620    }
1621
1622    /// Create a new `Command` reflecting this bootstrap command
1623    /// configuration.
1624    pub fn new(&self) -> Command {
1625        let mut cmd = Command::new(&self.program);
1626        if let Some(arg0) = &self.arg0 {
1627            cmd.arg0(arg0);
1628        }
1629        for arg in &self.args {
1630            cmd.arg(arg);
1631        }
1632        if !self.inherit_env {
1633            cmd.env_clear();
1634        }
1635        for k in &self.env_remove {
1636            cmd.env_remove(k);
1637        }
1638        for (k, v) in &self.env {
1639            cmd.env(k, v);
1640        }
1641        cmd
1642    }
1643
1644    /// Bootstrap command used for testing, invoking the Buck-built
1645    /// `monarch/hyperactor_mesh/bootstrap` binary.
1646    ///
1647    /// Intended for integration tests where we need to spawn real
1648    /// bootstrap processes under proc manager control. Not available
1649    /// outside of test builds.
1650    #[cfg(test)]
1651    #[cfg(fbcode_build)]
1652    pub(crate) fn test() -> Self {
1653        Self {
1654            program: crate::testresource::get("monarch/hyperactor_mesh/bootstrap"),
1655            ..Default::default()
1656        }
1657    }
1658}
1659
1660impl<T: Into<PathBuf>> From<T> for BootstrapCommand {
1661    /// Creates a bootstrap command from the provided path.
1662    fn from(s: T) -> Self {
1663        Self {
1664            program: s.into(),
1665            ..Default::default()
1666        }
1667    }
1668}
1669
1670/// Selects which built-in process launcher backend to use for
1671/// spawning procs.
1672///
1673/// This is an internal "implementation choice" control for the
1674/// `ProcLauncher` abstraction: both variants are expected to satisfy
1675/// the same lifecycle contract (launch, observe exit,
1676/// terminate/kill), but they differ in *how* the OS process is
1677/// supervised.
1678///
1679/// Variants:
1680/// - [`LauncherKind::Native`]: spawns and supervises child processes
1681///   directly using `tokio::process` (traditional parent/child
1682///   model).
1683/// - [`LauncherKind::Systemd`]: delegates supervision to `systemd
1684///   --user` by creating transient `.service` units and observing
1685///   lifecycle via D-Bus.
1686///
1687/// Configuration/parsing:
1688/// - The empty string and `"native"` map to [`LauncherKind::Native`]
1689///   (default).
1690/// - `"systemd"` maps to [`LauncherKind::Systemd`].
1691/// - Any other value is rejected as [`io::ErrorKind::InvalidInput`].
1692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1693pub(crate) enum LauncherKind {
1694    /// Spawn and supervise OS children directly (tokio-based
1695    /// launcher).
1696    Native,
1697    /// Spawn via transient `systemd --user` units and observe via
1698    /// D-Bus.
1699    #[cfg(target_os = "linux")]
1700    Systemd,
1701}
1702
1703impl FromStr for LauncherKind {
1704    type Err = io::Error;
1705
1706    /// Parse a launcher kind from configuration text.
1707    ///
1708    /// Accepted values (case-insensitive, surrounding whitespace
1709    /// ignored):
1710    /// - `""` or `"native"` → [`LauncherKind::Native`]
1711    /// - `"systemd"` → [`LauncherKind::Systemd`] (Linux only)
1712    ///
1713    /// Returns [`io::ErrorKind::InvalidInput`] for any other string.
1714    fn from_str(s: &str) -> Result<Self, Self::Err> {
1715        match s.trim().to_ascii_lowercase().as_str() {
1716            "" | "native" => Ok(Self::Native),
1717            #[cfg(target_os = "linux")]
1718            "systemd" => Ok(Self::Systemd),
1719            other => Err(io::Error::new(
1720                io::ErrorKind::InvalidInput,
1721                format!(
1722                    "unknown proc launcher kind {other:?}; expected 'native'{}",
1723                    if cfg!(target_os = "linux") {
1724                        " or 'systemd'"
1725                    } else {
1726                        ""
1727                    }
1728                ),
1729            )),
1730        }
1731    }
1732}
1733
1734/// Host-side manager for launching and supervising **bootstrap
1735/// processes** (via the `bootstrap` entry point).
1736///
1737/// `BootstrapProcManager` is responsible for:
1738/// - choosing and constructing the configured [`ProcLauncher`]
1739///   backend,
1740/// - preparing the bootstrap command/environment for each proc,
1741/// - tracking proc lifecycle state via [`BootstrapProcHandle`] /
1742///   [`ProcStatus`],
1743/// - providing status/query APIs over the set of active procs.
1744///
1745/// It maintains an async registry mapping [`ProcAddr`] →
1746/// [`BootstrapProcHandle`] for lifecycle queries and exit
1747/// observation.
1748///
1749/// ## Stdio and cleanup
1750///
1751/// Stdio handling and shutdown/cleanup behavior are
1752/// **launcher-dependent**:
1753/// - The native launcher may capture/tail stdout/stderr and manages
1754///   OS child processes directly.
1755/// - The systemd launcher delegates supervision to systemd transient
1756///   units on the user manager and does not expose a PID; stdio is
1757///   managed by systemd.
1758///
1759/// On drop/shutdown, process cleanup is *best-effort* and performed
1760/// via the selected launcher (e.g. direct child termination for
1761/// native, `StopUnit` for systemd).
1762pub struct BootstrapProcManager {
1763    /// The process launcher backend. Initialized on first use via
1764    /// `launcher()`, or explicitly via `set_launcher()`.
1765    launcher: OnceLock<Arc<dyn ProcLauncher>>,
1766
1767    /// The command specification used to bootstrap new processes.
1768    command: BootstrapCommand,
1769
1770    /// Async registry of running children, keyed by [`ProcAddr`]. Holds
1771    /// [`BootstrapProcHandle`]s so callers can query or monitor
1772    /// status.
1773    children: Arc<tokio::sync::Mutex<HashMap<ProcAddr, BootstrapProcHandle>>>,
1774
1775    /// FileMonitor that aggregates logs from all children. None if
1776    /// file monitor creation failed.
1777    file_appender: Option<Arc<crate::logging::FileAppender>>,
1778
1779    /// Directory for storing proc socket files. Procs place their
1780    /// sockets in this directory, so that they can be looked up by
1781    /// other procs for direct transfer.
1782    socket_dir: TempDir,
1783}
1784
1785impl BootstrapProcManager {
1786    /// Construct a new [`BootstrapProcManager`] that will launch
1787    /// procs using the given bootstrap command specification.
1788    ///
1789    /// This is the general entry point when you want to manage procs
1790    /// backed by a specific binary path (e.g. a bootstrap
1791    /// trampoline).
1792    pub(crate) fn new(command: BootstrapCommand) -> Result<Self, io::Error> {
1793        let file_appender = if hyperactor_config::global::get(MESH_ENABLE_FILE_CAPTURE) {
1794            match crate::logging::FileAppender::new() {
1795                Some(fm) => {
1796                    tracing::info!("file appender created successfully");
1797                    Some(Arc::new(fm))
1798                }
1799                None => {
1800                    tracing::warn!("failed to create file appender");
1801                    None
1802                }
1803            }
1804        } else {
1805            None
1806        };
1807
1808        Ok(Self {
1809            launcher: OnceLock::new(),
1810            command,
1811            children: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
1812            file_appender,
1813            socket_dir: runtime_dir()?,
1814        })
1815    }
1816
1817    /// Install a custom launcher.
1818    ///
1819    /// Returns error if already initialized (by prior
1820    /// `set_launcher()` OR by a spawn that triggered default init via
1821    /// `launcher()`).
1822    ///
1823    /// Must be called before any spawn operation that would
1824    /// initialize the default launcher.
1825    pub fn set_launcher(&self, launcher: Arc<dyn ProcLauncher>) -> Result<(), ProcLauncherError> {
1826        self.launcher.set(launcher).map_err(|_| {
1827            ProcLauncherError::Other(
1828                "launcher already initialized; call set_proc_launcher before first spawn".into(),
1829            )
1830        })
1831    }
1832
1833    /// Get the launcher, initializing with the default if not already
1834    /// set.
1835    ///
1836    /// Once this is called, any subsequent calls to `set_launcher()`
1837    /// will fail.
1838    pub fn launcher(&self) -> &Arc<dyn ProcLauncher> {
1839        self.launcher.get_or_init(|| {
1840            let kind_str = hyperactor_config::global::get_cloned(MESH_PROC_LAUNCHER_KIND);
1841            let kind: LauncherKind = kind_str.parse().unwrap_or(LauncherKind::Native);
1842            tracing::info!(kind = ?kind, config_value = %kind_str, "using default proc launcher");
1843            match kind {
1844                LauncherKind::Native => Arc::new(NativeProcLauncher::new()),
1845                #[cfg(target_os = "linux")]
1846                LauncherKind::Systemd => Arc::new(SystemdProcLauncher::new()),
1847            }
1848        })
1849    }
1850
1851    /// The bootstrap command used to launch processes.
1852    pub fn command(&self) -> &BootstrapCommand {
1853        &self.command
1854    }
1855
1856    /// The socket directory, where per-proc Unix sockets are placed.
1857    pub fn socket_dir(&self) -> &Path {
1858        self.socket_dir.path()
1859    }
1860
1861    /// Return the current [`ProcStatus`] for the given [`ProcAddr`], if
1862    /// the proc is known to this manager.
1863    ///
1864    /// This queries the live [`BootstrapProcHandle`] stored in the
1865    /// manager's internal map. It provides an immediate snapshot of
1866    /// lifecycle state (`Starting`, `Running`, `Stopping`, `Stopped`,
1867    /// etc.).
1868    ///
1869    /// Returns `None` if the manager has no record of the proc (e.g.
1870    /// never spawned here, or entry already removed).
1871    pub async fn status(&self, proc_id: &ProcAddr) -> Option<ProcStatus> {
1872        self.children.lock().await.get(proc_id).map(|h| h.status())
1873    }
1874
1875    /// Return a watch receiver for the given proc's status stream,
1876    /// if the proc is known to this manager.
1877    pub async fn watch(
1878        &self,
1879        proc_id: &ProcAddr,
1880    ) -> Option<tokio::sync::watch::Receiver<ProcStatus>> {
1881        self.children.lock().await.get(proc_id).map(|h| h.watch())
1882    }
1883
1884    /// Non-blocking stop: send `StopAll`, then spawn a background task
1885    /// that waits for exit and escalates if needed.
1886    ///
1887    /// The handle stays in `children` so that [`status()`] continues
1888    /// to reflect the live lifecycle (Stopping -> Stopped/Killed/Failed).
1889    /// Idempotent: no-ops if the proc is already stopping or exited.
1890    pub(crate) async fn request_stop(
1891        &self,
1892        cx: &impl context::Actor,
1893        proc: &ProcAddr,
1894        timeout: Duration,
1895        reason: &str,
1896    ) {
1897        let handle = {
1898            let guard = self.children.lock().await;
1899            guard.get(proc).cloned()
1900        };
1901
1902        let Some(handle) = handle else { return };
1903
1904        let status = handle.status();
1905        if status.is_exit() || matches!(status, ProcStatus::Stopping { .. }) {
1906            return;
1907        }
1908
1909        if let Some(agent) = handle.agent_ref() {
1910            let mut agent_port = agent.port();
1911            agent_port.return_undeliverable(false);
1912            let _ = agent_port.post(
1913                cx,
1914                resource::StopAll {
1915                    reason: reason.to_string(),
1916                },
1917            );
1918        }
1919
1920        let _ = handle.mark_stopping();
1921        tokio::spawn(async move {
1922            handle.wait_or_brutally_kill(timeout).await;
1923        });
1924    }
1925
1926    fn spawn_exit_monitor(
1927        &self,
1928        proc_id: ProcAddr,
1929        handle: BootstrapProcHandle,
1930        exit_rx: tokio::sync::oneshot::Receiver<ProcExitResult>,
1931    ) {
1932        tokio::spawn(async move {
1933            // Wait for the launcher to report terminal status.
1934            let exit_result = match exit_rx.await {
1935                Ok(res) => res,
1936                Err(_) => {
1937                    // exit_rx sender was dropped without sending - launcher error.
1938                    let _ = handle.mark_failed("exit_rx sender dropped unexpectedly");
1939                    tracing::error!(
1940                        name = "ProcStatus",
1941                        status = "Exited::ChannelDropped",
1942                        %proc_id,
1943                        "exit channel closed without result"
1944                    );
1945                    return;
1946                }
1947            };
1948
1949            // Collect stderr tail from StreamFwder if we captured stdio.
1950            // The launcher may also provide stderr_tail in exit_result;
1951            // prefer StreamFwder's tail if available (more complete).
1952            let mut stderr_tail: Vec<String> = Vec::new();
1953            let (stdout_mon, stderr_mon) = handle.take_stream_monitors();
1954
1955            if let Some(t) = stderr_mon {
1956                let (lines, _bytes) = t.abort().await;
1957                stderr_tail = lines;
1958            }
1959            if let Some(t) = stdout_mon {
1960                let (_lines, _bytes) = t.abort().await;
1961            }
1962
1963            // Fall back to launcher-provided tail if we didn't capture.
1964            if stderr_tail.is_empty()
1965                && let Some(tail) = exit_result.stderr_tail
1966            {
1967                stderr_tail = tail;
1968            }
1969
1970            let tail_str = if stderr_tail.is_empty() {
1971                None
1972            } else {
1973                Some(stderr_tail.join("\n"))
1974            };
1975
1976            match exit_result.kind {
1977                ProcExitKind::Exited { code } => {
1978                    let _ = handle.mark_stopped(code, stderr_tail);
1979                    tracing::info!(
1980                        name = "ProcStatus",
1981                        status = "Exited::ExitWithCode",
1982                        %proc_id,
1983                        exit_code = code,
1984                        tail = tail_str,
1985                        "proc exited with code {code}"
1986                    );
1987                }
1988                ProcExitKind::Signaled {
1989                    signal,
1990                    core_dumped,
1991                } => {
1992                    let _ = handle.mark_killed(signal, core_dumped);
1993                    tracing::info!(
1994                        name = "ProcStatus",
1995                        status = "Exited::KilledBySignal",
1996                        %proc_id,
1997                        tail = tail_str,
1998                        "killed by signal {signal}"
1999                    );
2000                }
2001                ProcExitKind::Failed { reason } => {
2002                    let _ = handle.mark_failed(&reason);
2003                    tracing::info!(
2004                        name = "ProcStatus",
2005                        status = "Exited::Failed",
2006                        %proc_id,
2007                        tail = tail_str,
2008                        "proc failed: {reason}"
2009                    );
2010                }
2011            }
2012        });
2013    }
2014}
2015
2016pub use crate::proc_launcher::ProcBind;
2017
2018/// The configuration used for bootstrapped procs.
2019pub struct BootstrapProcConfig {
2020    /// The proc's create rank.
2021    pub create_rank: usize,
2022
2023    /// Config values to set on the spawned proc's global config,
2024    /// at the `ClientOverride` layer.
2025    pub client_config_override: Attrs,
2026
2027    /// Optional per-process CPU/NUMA binding configuration.
2028    /// When set, the bootstrap command is wrapped with `numactl`
2029    /// (on NUMA systems) or `taskset` (Linux fallback) before launch.
2030    pub proc_bind: Option<ProcBind>,
2031    /// Optional bootstrap command override. When set, this command is used
2032    /// to spawn the proc instead of the manager's default bootstrap command.
2033    pub bootstrap_command: Option<BootstrapCommand>,
2034}
2035
2036struct LaunchCleanup {
2037    launcher: Arc<dyn ProcLauncher>,
2038    proc_id: Option<ProcAddr>,
2039}
2040
2041impl LaunchCleanup {
2042    fn disarm(mut self) {
2043        self.proc_id = None;
2044    }
2045}
2046
2047impl Drop for LaunchCleanup {
2048    fn drop(&mut self) {
2049        let Some(proc_id) = self.proc_id.take() else {
2050            return;
2051        };
2052        let launcher = Arc::clone(&self.launcher);
2053
2054        tokio::spawn(async move {
2055            if let Err(error) = launcher.kill(&proc_id).await {
2056                tracing::warn!(%proc_id, %error, "failed to clean up interrupted proc launch");
2057            }
2058        });
2059    }
2060}
2061
2062#[async_trait]
2063impl ProcManager for BootstrapProcManager {
2064    type Handle = BootstrapProcHandle;
2065
2066    type Config = BootstrapProcConfig;
2067
2068    /// Return the [`ChannelTransport`] used by this proc manager.
2069    ///
2070    /// For `BootstrapProcManager` this is always
2071    /// [`ChannelTransport::Unix`], since all procs are spawned
2072    /// locally on the same host and communicate over Unix domain
2073    /// sockets.
2074    fn transport(&self) -> ChannelTransport {
2075        ChannelTransport::Unix
2076    }
2077
2078    /// Launch a new proc under this [`BootstrapProcManager`].
2079    ///
2080    /// Spawns the configured bootstrap binary (`self.program`) in a
2081    /// fresh child process. The environment is populated with
2082    /// variables that describe the bootstrap context — most
2083    /// importantly `HYPERACTOR_MESH_BOOTSTRAP_MODE`, which carries a
2084    /// base64-encoded JSON [`Bootstrap::Proc`] payload (proc id,
2085    /// backend addr, callback addr, optional config snapshot).
2086    /// Additional variables like `BOOTSTRAP_LOG_CHANNEL` are also set
2087    /// up for logging and control.
2088    ///
2089    /// Responsibilities performed here:
2090    /// - Create a one-shot callback channel so the child can confirm
2091    ///   successful bootstrap and return its mailbox address plus agent
2092    ///   reference.
2093    /// - Spawn the OS process with stdout/stderr piped.
2094    /// - Stamp the new [`BootstrapProcHandle`] as
2095    ///   [`ProcStatus::Running`] once a PID is observed.
2096    /// - Wire stdout/stderr pipes into local writers and forward them
2097    ///   over the logging channel (`BOOTSTRAP_LOG_CHANNEL`).
2098    /// - Insert the handle into the manager's children map and start
2099    ///   an exit monitor to track process termination.
2100    ///
2101    /// Returns a [`BootstrapProcHandle`] that exposes the child
2102    /// process's lifecycle (status, wait/ready, termination). Errors
2103    /// are surfaced as [`HostError`].
2104    #[hyperactor::instrument(fields(proc_id=proc_id.to_string(), addr=backend_addr.to_string()))]
2105    async fn spawn(
2106        &self,
2107        proc_id: ProcAddr,
2108        backend_addr: ChannelAddr,
2109        config: BootstrapProcConfig,
2110    ) -> Result<Self::Handle, HostError> {
2111        let (callback_addr, mut callback_rx) = channel::serve::<(ChannelAddr, ActorRef<ProcAgent>)>(
2112            ChannelAddr::any(ChannelTransport::Unix),
2113        )?;
2114
2115        // Decide whether we need to capture stdio.
2116        let overrides = &config.client_config_override;
2117        let enable_forwarding = override_or_global(overrides, MESH_ENABLE_LOG_FORWARDING);
2118        let enable_file_capture = override_or_global(overrides, MESH_ENABLE_FILE_CAPTURE);
2119        let tail_size = override_or_global(overrides, MESH_TAIL_LOG_LINES);
2120        let need_stdio = enable_forwarding || enable_file_capture || tail_size > 0;
2121
2122        let mode = Bootstrap::Proc {
2123            proc_id: proc_id.clone(),
2124            backend_addr,
2125            callback_addr,
2126            socket_dir_path: self.socket_dir.path().to_owned(),
2127            config: Some(config.client_config_override.clone()),
2128        };
2129
2130        // Build LaunchOptions for the launcher.
2131        let bootstrap_payload = mode
2132            .to_env_safe_string()
2133            .map_err(|e| HostError::ProcessConfigurationFailure(proc_id.clone(), e.into()))?;
2134
2135        let opts = LaunchOptions {
2136            bootstrap_payload,
2137            process_name: format_process_name(&proc_id.clone()),
2138            command: config
2139                .bootstrap_command
2140                .as_ref()
2141                .unwrap_or(&self.command)
2142                .clone(),
2143            want_stdio: need_stdio,
2144            tail_lines: tail_size,
2145            log_channel: if enable_forwarding {
2146                Some(ChannelAddr::any(ChannelTransport::Unix))
2147            } else {
2148                None
2149            },
2150            proc_bind: config.proc_bind.clone(),
2151        };
2152
2153        // Launch via the configured launcher backend.
2154        tracing::info!(proc_id = %proc_id, "launching proc with opts={opts:?}");
2155        let ref_proc_id: ProcAddr = proc_id.clone();
2156        let launcher = Arc::clone(self.launcher());
2157        let launch_result = match AssertUnwindSafe(launcher.launch(&ref_proc_id, opts.clone()))
2158            .catch_unwind()
2159            .await
2160        {
2161            Ok(Ok(launch_result)) => launch_result,
2162            Ok(Err(error)) => {
2163                let io_err = match error {
2164                    ProcLauncherError::Launch(io_err) => io_err,
2165                    other => std::io::Error::other(other.to_string()),
2166                };
2167                return Err(HostError::ProcessSpawnFailure(
2168                    proc_id.clone(),
2169                    format!("{:?}", opts.command),
2170                    io_err,
2171                ));
2172            }
2173            Err(panic) => {
2174                if let Err(error) = launcher.kill(&ref_proc_id).await {
2175                    tracing::warn!(proc_id = %ref_proc_id, %error, "failed to clean up panicked proc launch");
2176                }
2177                std::panic::resume_unwind(panic);
2178            }
2179        };
2180        let cleanup = LaunchCleanup {
2181            launcher,
2182            proc_id: Some(proc_id.clone()),
2183        };
2184
2185        // Wire up StreamFwders if stdio was captured.
2186        let (out_fwder, err_fwder) = match launch_result.stdio {
2187            StdioHandling::Captured { stdout, stderr } => {
2188                let (file_stdout, file_stderr) = if enable_file_capture {
2189                    match self.file_appender.as_deref() {
2190                        Some(fm) => (
2191                            Some(fm.addr_for(OutputTarget::Stdout)),
2192                            Some(fm.addr_for(OutputTarget::Stderr)),
2193                        ),
2194                        None => {
2195                            tracing::warn!("enable_file_capture=true but no FileAppender");
2196                            (None, None)
2197                        }
2198                    }
2199                } else {
2200                    (None, None)
2201                };
2202
2203                let out = StreamFwder::start(
2204                    stdout,
2205                    file_stdout,
2206                    OutputTarget::Stdout,
2207                    tail_size,
2208                    opts.log_channel.clone(),
2209                    &ref_proc_id,
2210                    config.create_rank,
2211                );
2212                let err = StreamFwder::start(
2213                    stderr,
2214                    file_stderr,
2215                    OutputTarget::Stderr,
2216                    tail_size,
2217                    opts.log_channel.clone(),
2218                    &ref_proc_id,
2219                    config.create_rank,
2220                );
2221                (Some(out), Some(err))
2222            }
2223            StdioHandling::Inherited | StdioHandling::ManagedByLauncher => {
2224                if !need_stdio {
2225                    tracing::info!(
2226                        %proc_id, enable_forwarding, enable_file_capture, tail_size,
2227                        "child stdio NOT captured (forwarding/file_capture/tail all disabled)"
2228                    );
2229                }
2230                (None, None)
2231            }
2232        };
2233
2234        // Create handle with launcher reference for terminate/kill delegation.
2235        let handle = BootstrapProcHandle::new(proc_id.clone(), Arc::downgrade(self.launcher()));
2236        handle.mark_running(launch_result.started_at);
2237        handle.set_stream_monitors(out_fwder, err_fwder);
2238
2239        // Retain handle for lifecycle management.
2240        {
2241            let mut children = self.children.lock().await;
2242            children.insert(proc_id.clone(), handle.clone());
2243        }
2244
2245        // Kick off an exit monitor that updates ProcStatus when the
2246        // launcher reports terminal status.
2247        self.spawn_exit_monitor(proc_id.clone(), handle.clone(), launch_result.exit_rx);
2248
2249        // Handle callback from child proc when it confirms bootstrap.
2250        let h = handle.clone();
2251        tokio::spawn(async move {
2252            match callback_rx.recv().await {
2253                Ok((addr, agent)) => {
2254                    let _ = h.mark_ready(addr, agent);
2255                }
2256                Err(e) => {
2257                    // Child never called back; record failure.
2258                    let _ = h.mark_failed(format!("bootstrap callback failed: {e}"));
2259                }
2260            }
2261        });
2262
2263        // Callers do `handle.read().await` for mesh readiness.
2264        cleanup.disarm();
2265        Ok(handle)
2266    }
2267}
2268
2269#[async_trait]
2270impl SingleTerminate for BootstrapProcManager {
2271    /// Attempt to gracefully terminate one child procs managed by
2272    /// this `BootstrapProcManager`.
2273    ///
2274    /// Each child handle is asked to `terminate(timeout)`, which
2275    /// sends SIGTERM, waits up to the deadline, and escalates to
2276    /// SIGKILL if necessary. Termination is attempted concurrently,
2277    /// with at most `max_in_flight` tasks running at once.
2278    ///
2279    /// Logs a warning for each failure.
2280    async fn terminate_proc(
2281        &self,
2282        cx: &impl context::Actor,
2283        proc: &ProcAddr,
2284        timeout: Duration,
2285        reason: &str,
2286    ) -> Result<(Vec<ActorAddr>, Vec<ActorAddr>), anyhow::Error> {
2287        // Snapshot to avoid holding the lock across awaits.
2288        let proc_handle: Option<BootstrapProcHandle> = {
2289            let mut guard = self.children.lock().await;
2290            guard.remove(proc)
2291        };
2292
2293        if let Some(h) = proc_handle {
2294            h.terminate(cx, timeout, reason)
2295                .await
2296                .map(|_| (Vec::new(), Vec::new()))
2297                .map_err(|e| e.into())
2298        } else {
2299            Err(anyhow::anyhow!("proc doesn't exist: {}", proc))
2300        }
2301    }
2302}
2303
2304#[async_trait]
2305impl BulkTerminate for BootstrapProcManager {
2306    /// Attempt to gracefully terminate all child procs managed by
2307    /// this `BootstrapProcManager`.
2308    ///
2309    /// Each child handle is asked to `terminate(timeout)`, which
2310    /// sends SIGTERM, waits up to the deadline, and escalates to
2311    /// SIGKILL if necessary. Termination is attempted concurrently,
2312    /// with at most `max_in_flight` tasks running at once.
2313    ///
2314    /// Returns a [`TerminateSummary`] with counts of how many procs
2315    /// were attempted, how many successfully terminated (including
2316    /// those that were already terminal), and how many failed.
2317    ///
2318    /// Logs a warning for each failure.
2319    async fn terminate_all(
2320        &self,
2321        cx: &impl context::Actor,
2322        timeout: Duration,
2323        max_in_flight: usize,
2324        reason: &str,
2325    ) -> TerminateSummary {
2326        // Drain the children list to avoid holding the lock across awaits and
2327        // avoid subsequent calls from trying to terminate again.
2328        let handles: Vec<BootstrapProcHandle> = {
2329            let mut guard = self.children.lock().await;
2330            guard.drain().map(|(_, v)| v).collect()
2331        };
2332
2333        let attempted = handles.len();
2334        let mut ok = 0usize;
2335
2336        let results = stream::iter(handles.into_iter().map(|h| async move {
2337            match h.terminate(cx, timeout, reason).await {
2338                Ok(_) | Err(TerminateError::AlreadyTerminated(_)) => {
2339                    // Treat "already terminal" as success.
2340                    true
2341                }
2342                Err(e) => {
2343                    tracing::warn!(error=%e, "terminate_all: failed to terminate child");
2344                    false
2345                }
2346            }
2347        }))
2348        .buffer_unordered(max_in_flight.max(1))
2349        .collect::<Vec<bool>>()
2350        .await;
2351
2352        for r in results {
2353            if r {
2354                ok += 1;
2355            }
2356        }
2357
2358        TerminateSummary {
2359            attempted,
2360            ok,
2361            failed: attempted.saturating_sub(ok),
2362        }
2363    }
2364}
2365
2366/// Entry point to processes managed by hyperactor_mesh. Any process that is part
2367/// of a hyperactor_mesh program should call [`bootstrap`], which then configures
2368/// the process according to how it is invoked.
2369///
2370/// If bootstrap returns any error, it is defunct from the point of view of hyperactor_mesh,
2371/// and the process should likely exit:
2372///
2373/// ```ignore
2374/// let err = hyperactor_mesh::bootstrap().await;
2375/// tracing::error("could not bootstrap mesh process: {}", err);
2376/// std::process::exit(1);
2377/// ```
2378///
2379/// Use [`bootstrap_or_die`] to implement this behavior directly.
2380/// Else if the bootstrap returns Ok, the process has cleaned up successfully and
2381/// should exit the "main" of the program.
2382pub async fn bootstrap() -> anyhow::Result<i32> {
2383    let Some(boot) = Bootstrap::get_from_env()? else {
2384        anyhow::bail!(
2385            "bootstrap: no bootstrap mode configured (HYPERACTOR_MESH_BOOTSTRAP_MODE unset)"
2386        );
2387    };
2388    boot.bootstrap().await
2389}
2390
2391/// A variant of [`bootstrap`] that logs the error and exits the process
2392/// if bootstrapping fails.
2393pub async fn bootstrap_or_die() -> ! {
2394    match bootstrap().await {
2395        Ok(exit_code) => std::process::exit(exit_code),
2396        Err(err) => {
2397            let _ = writeln!(Debug, "failed to bootstrap mesh process: {}", err);
2398            tracing::error!("failed to bootstrap mesh process: {}", err);
2399            std::process::exit(1);
2400        }
2401    }
2402}
2403
2404#[derive(enum_as_inner::EnumAsInner)]
2405enum DebugSink {
2406    File(std::fs::File),
2407    Sink,
2408}
2409
2410impl DebugSink {
2411    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
2412        match self {
2413            DebugSink::File(f) => f.write(buf),
2414            DebugSink::Sink => Ok(buf.len()),
2415        }
2416    }
2417    fn flush(&mut self) -> io::Result<()> {
2418        match self {
2419            DebugSink::File(f) => f.flush(),
2420            DebugSink::Sink => Ok(()),
2421        }
2422    }
2423}
2424
2425fn debug_sink() -> &'static Mutex<DebugSink> {
2426    static DEBUG_SINK: OnceLock<Mutex<DebugSink>> = OnceLock::new();
2427    DEBUG_SINK.get_or_init(|| {
2428        let debug_path = {
2429            let mut p = std::env::temp_dir();
2430            if let Ok(user) = std::env::var("USER") {
2431                p.push(user);
2432            }
2433            std::fs::create_dir_all(&p).ok();
2434            p.push("monarch-bootstrap-debug.log");
2435            p
2436        };
2437        let sink = if debug_path.exists() {
2438            match OpenOptions::new()
2439                .append(true)
2440                .create(true)
2441                .open(debug_path.clone())
2442            {
2443                Ok(f) => DebugSink::File(f),
2444                Err(_e) => {
2445                    eprintln!(
2446                        "failed to open {} for bootstrap debug logging",
2447                        debug_path.display()
2448                    );
2449                    DebugSink::Sink
2450                }
2451            }
2452        } else {
2453            DebugSink::Sink
2454        };
2455        Mutex::new(sink)
2456    })
2457}
2458
2459/// If true, send `Debug` messages to stderr.
2460const DEBUG_TO_STDERR: bool = false;
2461
2462/// A bootstrap specific debug writer. If the file /tmp/monarch-bootstrap-debug.log
2463/// exists, then the writer's destination is that file; otherwise it discards all writes.
2464struct Debug;
2465
2466impl Debug {
2467    fn is_active() -> bool {
2468        DEBUG_TO_STDERR || debug_sink().lock().unwrap().is_file()
2469    }
2470}
2471
2472impl Write for Debug {
2473    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
2474        let res = debug_sink().lock().unwrap().write(buf);
2475        if DEBUG_TO_STDERR {
2476            let n = match res {
2477                Ok(n) => n,
2478                Err(_) => buf.len(),
2479            };
2480            let _ = io::stderr().write_all(&buf[..n]);
2481        }
2482
2483        res
2484    }
2485    fn flush(&mut self) -> io::Result<()> {
2486        let res = debug_sink().lock().unwrap().flush();
2487        if DEBUG_TO_STDERR {
2488            let _ = io::stderr().flush();
2489        }
2490        res
2491    }
2492}
2493
2494/// Build the bind/dial [`ChannelAddr`] for a local proc within `socket_dir`.
2495pub(crate) fn local_proc_addr(
2496    socket_dir: &Path,
2497    proc_id: &hyperactor::id::ProcId,
2498) -> anyhow::Result<(ChannelAddr, PathBuf)> {
2499    let path = proc_id.to_path_elem(socket_dir);
2500    let addr = std::os::unix::net::SocketAddr::from_pathname(path.clone())
2501        .with_context(|| {
2502            format!(
2503                "constructing unix socket address for proc {proc_id} \
2504            at {} ({} bytes); path must fit within SUN_LEN \
2505             (108 on Linux, 104 on macOS)",
2506                path.display(),
2507                path.as_os_str().len()
2508            )
2509        })?
2510        .into();
2511    Ok((addr, path))
2512}
2513
2514/// Create a new runtime [`TempDir`]. The directory is created in
2515/// `$XDG_RUNTIME_DIR` if set and the directory exists, otherwise
2516/// falling back to the system tempdir.
2517fn runtime_dir() -> io::Result<TempDir> {
2518    if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") {
2519        let path = PathBuf::from(runtime_dir);
2520        if path.is_dir() {
2521            return tempfile::tempdir_in(path);
2522        }
2523    }
2524    tempfile::tempdir()
2525}
2526
2527#[cfg(test)]
2528mod tests {
2529    use std::path::PathBuf;
2530    use std::sync::Arc;
2531    use std::sync::atomic::AtomicBool;
2532    use std::sync::atomic::Ordering;
2533
2534    use async_trait::async_trait;
2535    use futures::FutureExt;
2536    use hyperactor::ActorEnvironment;
2537    use hyperactor::Location;
2538    use hyperactor::PortHandle;
2539    use hyperactor::ProcId;
2540    use hyperactor::RemoteSpawn;
2541    use hyperactor::channel::ChannelAddr;
2542    use hyperactor::channel::ChannelTransport;
2543    use hyperactor::channel::TcpMode;
2544    use hyperactor::mailbox::MailboxSender;
2545    use hyperactor::mailbox::MessageEnvelope;
2546    use hyperactor::mailbox::Undeliverable;
2547    use hyperactor::testing::ids::test_proc_id;
2548    use hyperactor::testing::ids::test_proc_id_with_addr;
2549    use timed_test::async_timed_test;
2550
2551    use super::*;
2552
2553    #[derive(Clone, Copy)]
2554    enum CleanupLaunchBehavior {
2555        Succeed,
2556        Panic,
2557    }
2558
2559    struct CleanupLauncher {
2560        behavior: CleanupLaunchBehavior,
2561        killed: tokio::sync::mpsc::UnboundedSender<ProcAddr>,
2562    }
2563
2564    #[async_trait]
2565    impl ProcLauncher for CleanupLauncher {
2566        async fn launch(
2567            &self,
2568            _proc_id: &ProcAddr,
2569            _opts: LaunchOptions,
2570        ) -> Result<crate::proc_launcher::LaunchResult, ProcLauncherError> {
2571            match self.behavior {
2572                CleanupLaunchBehavior::Succeed => {
2573                    let (_exit_tx, exit_rx) = tokio::sync::oneshot::channel();
2574                    Ok(LaunchResult {
2575                        pid: None,
2576                        started_at: std::time::SystemTime::now(),
2577                        stdio: StdioHandling::ManagedByLauncher,
2578                        exit_rx,
2579                    })
2580                }
2581                CleanupLaunchBehavior::Panic => panic!("test launcher panic"),
2582            }
2583        }
2584
2585        async fn terminate(
2586            &self,
2587            _proc_id: &ProcAddr,
2588            _timeout: Duration,
2589        ) -> Result<(), ProcLauncherError> {
2590            unreachable!("cleanup guard test does not terminate a proc")
2591        }
2592
2593        async fn kill(&self, proc_id: &ProcAddr) -> Result<(), ProcLauncherError> {
2594            let _ = self.killed.send(proc_id.clone());
2595            Ok(())
2596        }
2597    }
2598
2599    fn manager_with_cleanup_launcher(
2600        behavior: CleanupLaunchBehavior,
2601    ) -> (
2602        BootstrapProcManager,
2603        tokio::sync::mpsc::UnboundedReceiver<ProcAddr>,
2604    ) {
2605        let (killed, killed_rx) = tokio::sync::mpsc::unbounded_channel();
2606
2607        let manager = BootstrapProcManager::new(BootstrapCommand::default())
2608            .expect("bootstrap proc manager should be created");
2609
2610        manager
2611            .set_launcher(Arc::new(CleanupLauncher { behavior, killed }))
2612            .expect("test launcher should be installed");
2613
2614        (manager, killed_rx)
2615    }
2616
2617    fn cleanup_test_config() -> BootstrapProcConfig {
2618        BootstrapProcConfig {
2619            create_rank: 0,
2620            client_config_override: Attrs::new(),
2621            proc_bind: None,
2622            bootstrap_command: None,
2623        }
2624    }
2625
2626    #[async_timed_test(timeout_secs = 30)]
2627    async fn cancelled_bootstrap_spawn_kills_launched_proc_before_registration() {
2628        let (manager, mut killed_rx) =
2629            manager_with_cleanup_launcher(CleanupLaunchBehavior::Succeed);
2630
2631        let proc_id = test_proc_id("cancelled-bootstrap-spawn");
2632        // Hold the registry lock at the ownership handoff. The spawn can launch
2633        // the proc and arm LaunchCleanup, but it cannot register the handle.
2634        // Cancelling here must kill the proc and leave no registry entry.
2635        let _children_guard = manager.children.lock().await;
2636        let mut spawn = Box::pin(manager.spawn(
2637            proc_id.clone(),
2638            ChannelAddr::any(ChannelTransport::Unix),
2639            cleanup_test_config(),
2640        ));
2641
2642        assert!(
2643            futures::poll!(&mut spawn).is_pending(),
2644            "spawn should wait to register the launched proc",
2645        );
2646        drop(spawn);
2647
2648        assert_eq!(
2649            killed_rx
2650                .recv()
2651                .await
2652                .expect("cancelled spawn should kill the launched proc"),
2653            proc_id,
2654        );
2655    }
2656
2657    #[async_timed_test(timeout_secs = 30)]
2658    async fn panicked_bootstrap_spawn_kills_proc_and_resumes_unwind() {
2659        let (manager, mut killed_rx) = manager_with_cleanup_launcher(CleanupLaunchBehavior::Panic);
2660        let proc_id = test_proc_id("panicked-bootstrap-spawn");
2661
2662        let result = AssertUnwindSafe(manager.spawn(
2663            proc_id.clone(),
2664            ChannelAddr::any(ChannelTransport::Unix),
2665            cleanup_test_config(),
2666        ))
2667        .catch_unwind()
2668        .await;
2669
2670        assert!(result.is_err(), "launcher panic should resume unwinding");
2671        assert_eq!(
2672            killed_rx
2673                .recv()
2674                .await
2675                .expect("panicked spawn should kill the launched proc"),
2676            proc_id,
2677        );
2678    }
2679
2680    fn env_command() -> BootstrapCommand {
2681        BootstrapCommand {
2682            program: PathBuf::from("/usr/bin/env"),
2683            env: HashMap::from([("BOOTSTRAP_COMMAND_TEST".to_string(), "present".to_string())]),
2684            ..Default::default()
2685        }
2686    }
2687
2688    async fn env_output(command: BootstrapCommand) -> Vec<String> {
2689        let output = command
2690            .new()
2691            .output()
2692            .await
2693            .expect("run env with the configured environment");
2694        String::from_utf8(output.stdout)
2695            .expect("env output should be UTF-8")
2696            .lines()
2697            .map(String::from)
2698            .collect()
2699    }
2700
2701    #[tokio::test]
2702    async fn bootstrap_command_inherits_environment() {
2703        let path = std::env::var("PATH").expect("test runner should set PATH");
2704
2705        let lines = env_output(env_command()).await;
2706
2707        assert!(lines.contains(&"BOOTSTRAP_COMMAND_TEST=present".to_string()));
2708        assert!(
2709            lines.contains(&format!("PATH={path}")),
2710            "bootstrap command should inherit the spawning process's environment"
2711        );
2712    }
2713
2714    #[tokio::test]
2715    async fn bootstrap_command_removes_inherited_environment() {
2716        assert!(
2717            std::env::var_os("PATH").is_some(),
2718            "test runner should set PATH"
2719        );
2720
2721        let lines = env_output(BootstrapCommand {
2722            env_remove: HashSet::from(["PATH".to_string(), "BOOTSTRAP_COMMAND_TEST".to_string()]),
2723            ..env_command()
2724        })
2725        .await;
2726
2727        assert!(
2728            !lines.iter().any(|line| line.starts_with("PATH=")),
2729            "removed variables should not be inherited"
2730        );
2731        assert!(
2732            lines.contains(&"BOOTSTRAP_COMMAND_TEST=present".to_string()),
2733            "env should take precedence over env_remove"
2734        );
2735    }
2736
2737    #[tokio::test]
2738    async fn bootstrap_command_uses_exact_environment() {
2739        assert!(
2740            std::env::var_os("PATH").is_some(),
2741            "test runner should set PATH"
2742        );
2743
2744        assert_eq!(
2745            env_output(BootstrapCommand {
2746                inherit_env: false,
2747                ..env_command()
2748            })
2749            .await,
2750            ["BOOTSTRAP_COMMAND_TEST=present"],
2751            "bootstrap command should use only its configured environment"
2752        );
2753    }
2754
2755    struct ShutdownFlushProbe {
2756        gateway: Gateway,
2757        expected_location: Location,
2758        flushed: Arc<AtomicBool>,
2759    }
2760
2761    #[async_trait]
2762    impl MailboxSender for ShutdownFlushProbe {
2763        fn post_unchecked(
2764            &self,
2765            _envelope: MessageEnvelope,
2766            _return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
2767        ) {
2768        }
2769
2770        async fn flush(&self) -> Result<(), anyhow::Error> {
2771            assert_eq!(
2772                self.gateway.default_location(),
2773                self.expected_location,
2774                "shutdown transports must remain live while the root proc flushes"
2775            );
2776            self.flushed.store(true, Ordering::SeqCst);
2777            Ok(())
2778        }
2779    }
2780
2781    #[tokio::test]
2782    async fn test_host_shutdown_flushes_before_joining_frontend() {
2783        let relay_gateway = Gateway::new();
2784        let mut relay = relay_gateway
2785            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2786            .unwrap();
2787        let relay_addr = relay_gateway.default_location().addr().clone();
2788
2789        let gateway = Gateway::isolated();
2790        let mut first = gateway
2791            .serve(ChannelAddr::any(ChannelTransport::Local))
2792            .unwrap();
2793        let first_location = gateway.default_location();
2794        let frontend = gateway
2795            .serve(ChannelAddr::any(ChannelTransport::Local))
2796            .unwrap();
2797        let frontend_location = gateway.default_location();
2798        assert_ne!(first_location, frontend_location);
2799        let via = gateway.serve_via(relay_addr).await.unwrap();
2800        let via_location = gateway.default_location();
2801        assert!(via_location.as_via().is_some());
2802
2803        let proc = Proc::builder()
2804            .proc_id(ProcId::instance(Label::strip("shutdown_flush_probe")))
2805            .shared_gateway(gateway.clone())
2806            .build()
2807            .unwrap();
2808        let flushed = Arc::new(AtomicBool::new(false));
2809        let probe_id = proc
2810            .proc_addr()
2811            .actor_addr("shutdown_flush_probe")
2812            .id()
2813            .clone();
2814        assert!(proc.muxer().bind(
2815            probe_id,
2816            ShutdownFlushProbe {
2817                gateway: gateway.clone(),
2818                expected_location: via_location,
2819                flushed: flushed.clone(),
2820            }
2821        ));
2822
2823        let (tx, rx) = tokio::sync::oneshot::channel();
2824        tx.send(HostShutdownHandles::new(frontend, Some(via)))
2825            .unwrap();
2826        let shutdown = HostShutdownHandle {
2827            rx,
2828            exit_on_shutdown: false,
2829        };
2830
2831        shutdown
2832            .stop_and_join_after_drain(async {
2833                proc.flush().await.expect("flush probe should succeed");
2834            })
2835            .await;
2836
2837        assert!(flushed.load(Ordering::SeqCst));
2838        assert_eq!(gateway.default_location(), first_location);
2839
2840        first.stop("test complete");
2841        first.join().await.unwrap();
2842        relay.stop("test complete");
2843        relay.join().await.unwrap();
2844    }
2845
2846    #[tokio::test]
2847    async fn test_cancelled_pre_join_stops_frontend() {
2848        let gateway = Gateway::isolated();
2849        let mut first = gateway
2850            .serve(ChannelAddr::any(ChannelTransport::Local))
2851            .unwrap();
2852        let first_location = gateway.default_location();
2853        let frontend = gateway
2854            .serve(ChannelAddr::any(ChannelTransport::Local))
2855            .unwrap();
2856        assert_ne!(gateway.default_location(), first_location);
2857
2858        let (tx, rx) = tokio::sync::oneshot::channel();
2859        tx.send(HostShutdownHandles::new(frontend, None)).unwrap();
2860        let shutdown = HostShutdownHandle {
2861            rx,
2862            exit_on_shutdown: false,
2863        };
2864        let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
2865        let shutdown_task = tokio::spawn(shutdown.stop_and_join_after_drain(async move {
2866            entered_tx
2867                .send(())
2868                .expect("pre-join notification receiver must remain live");
2869            future::pending::<()>().await;
2870        }));
2871
2872        entered_rx
2873            .await
2874            .expect("pre-join future must signal before cancellation");
2875        shutdown_task.abort();
2876        assert!(
2877            shutdown_task
2878                .await
2879                .expect_err("aborted join task must report cancellation")
2880                .is_cancelled()
2881        );
2882        assert_eq!(gateway.default_location(), first_location);
2883
2884        first.stop("test complete");
2885        first.join().await.unwrap();
2886    }
2887
2888    #[tokio::test]
2889    async fn test_dropped_shutdown_receiver_stops_frontend() {
2890        let gateway = Gateway::isolated();
2891        let mut first = gateway
2892            .serve(ChannelAddr::any(ChannelTransport::Local))
2893            .unwrap();
2894        let first_location = gateway.default_location();
2895        let frontend = gateway
2896            .serve(ChannelAddr::any(ChannelTransport::Local))
2897            .unwrap();
2898        assert_ne!(gateway.default_location(), first_location);
2899
2900        let (tx, rx) = tokio::sync::oneshot::channel();
2901        tx.send(HostShutdownHandles::new(frontend, None)).unwrap();
2902        drop(rx);
2903        assert_eq!(gateway.default_location(), first_location);
2904
2905        first.stop("test complete");
2906        first.join().await.unwrap();
2907    }
2908
2909    #[tokio::test]
2910    async fn test_host_bootstrap_ready_callback() {
2911        let host_addr = ChannelAddr::any(ChannelTransport::Unix);
2912        let ready = HostBootstrapReady::new(host_addr).expect("open readiness callback");
2913        let callback_addr = ready.callback_addr();
2914        let sender = tokio::spawn(async move {
2915            channel::dial(callback_addr)
2916                .expect("dial readiness callback")
2917                .send(())
2918                .await
2919                .expect("send readiness callback");
2920        });
2921
2922        ready.wait().await.expect("receive readiness callback");
2923        sender.await.expect("readiness sender task");
2924    }
2925
2926    #[tokio::test]
2927    async fn test_host_bootstrap_ready_times_out_without_callback() {
2928        let config = hyperactor_config::global::lock();
2929        let _timeout = config.override_key(
2930            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2931            Duration::from_millis(20),
2932        );
2933        let host_addr = ChannelAddr::any(ChannelTransport::Unix);
2934        let ready = HostBootstrapReady::new(host_addr).expect("open readiness callback");
2935
2936        let error = ready
2937            .wait()
2938            .await
2939            .expect_err("missing callback must fail closed");
2940        assert!(
2941            error.to_string().contains("did not become ready"),
2942            "unexpected readiness error: {error:#}"
2943        );
2944    }
2945
2946    #[test]
2947    fn test_bootstrap_mode_env_string_none_config_proc() {
2948        let value = Bootstrap::Proc {
2949            proc_id: test_proc_id("foo_0"),
2950            backend_addr: ChannelAddr::any(ChannelTransport::Tcp(TcpMode::Hostname)),
2951            callback_addr: ChannelAddr::any(ChannelTransport::Unix),
2952            socket_dir_path: PathBuf::from("notexist"),
2953            config: None,
2954        };
2955
2956        let safe = value.to_env_safe_string().unwrap();
2957        let round = Bootstrap::from_env_safe_string(&safe).unwrap();
2958
2959        // Re-encode and compare: deterministic round-trip of the
2960        // wire format.
2961        let safe2 = round.to_env_safe_string().unwrap();
2962        assert_eq!(safe, safe2, "env-safe round-trip should be stable");
2963
2964        // Sanity: the decoded variant is what we expect.
2965        match round {
2966            Bootstrap::Proc { config: None, .. } => {}
2967            other => panic!("expected Proc with None config, got {:?}", other),
2968        }
2969    }
2970
2971    #[test]
2972    fn test_bootstrap_mode_env_string_none_config_host() {
2973        let value = Bootstrap::Host {
2974            addr: ChannelAddr::any(ChannelTransport::Unix),
2975            callback_addr: ChannelAddr::any(ChannelTransport::Unix),
2976            command: None,
2977            config: None,
2978            exit_on_shutdown: false,
2979        };
2980
2981        let safe = value.to_env_safe_string().unwrap();
2982        let round = Bootstrap::from_env_safe_string(&safe).unwrap();
2983
2984        // Wire-format round-trip should be identical.
2985        let safe2 = round.to_env_safe_string().unwrap();
2986        assert_eq!(safe, safe2);
2987
2988        // Sanity: decoded variant is Host with None config.
2989        match round {
2990            Bootstrap::Host { config: None, .. } => {}
2991            other => panic!("expected Host with None config, got {:?}", other),
2992        }
2993    }
2994
2995    #[test]
2996    fn test_bootstrap_mode_env_string_invalid() {
2997        // Not valid base64
2998        assert!(Bootstrap::from_env_safe_string("!!!").is_err());
2999    }
3000
3001    #[test]
3002    fn test_bootstrap_config_snapshot_roundtrip() {
3003        // Build a small, distinctive Attrs snapshot.
3004        let mut attrs = Attrs::new();
3005        attrs[MESH_TAIL_LOG_LINES] = 123;
3006        attrs[MESH_BOOTSTRAP_ENABLE_PDEATHSIG] = false;
3007
3008        let socket_dir = runtime_dir().unwrap();
3009
3010        // Proc case
3011        {
3012            let original = Bootstrap::Proc {
3013                proc_id: test_proc_id("foo_42"),
3014                backend_addr: ChannelAddr::any(ChannelTransport::Unix),
3015                callback_addr: ChannelAddr::any(ChannelTransport::Unix),
3016                config: Some(attrs.clone()),
3017                socket_dir_path: socket_dir.path().to_owned(),
3018            };
3019            let env_str = original.to_env_safe_string().expect("encode bootstrap");
3020            let decoded = Bootstrap::from_env_safe_string(&env_str).expect("decode bootstrap");
3021            match &decoded {
3022                Bootstrap::Proc { config, .. } => {
3023                    let cfg = config.as_ref().expect("expected Some(attrs)");
3024                    assert_eq!(cfg[MESH_TAIL_LOG_LINES], 123);
3025                    assert!(!cfg[MESH_BOOTSTRAP_ENABLE_PDEATHSIG]);
3026                }
3027                other => panic!("unexpected variant after roundtrip: {:?}", other),
3028            }
3029        }
3030
3031        // Host case
3032        {
3033            let original = Bootstrap::Host {
3034                addr: ChannelAddr::any(ChannelTransport::Unix),
3035                callback_addr: ChannelAddr::any(ChannelTransport::Unix),
3036                command: None,
3037                config: Some(attrs.clone()),
3038                exit_on_shutdown: false,
3039            };
3040            let env_str = original.to_env_safe_string().expect("encode bootstrap");
3041            let decoded = Bootstrap::from_env_safe_string(&env_str).expect("decode bootstrap");
3042            match &decoded {
3043                Bootstrap::Host { config, .. } => {
3044                    let cfg = config.as_ref().expect("expected Some(attrs)");
3045                    assert_eq!(cfg[MESH_TAIL_LOG_LINES], 123);
3046                    assert!(!cfg[MESH_BOOTSTRAP_ENABLE_PDEATHSIG]);
3047                }
3048                other => panic!("unexpected variant after roundtrip: {:?}", other),
3049            }
3050        }
3051    }
3052
3053    #[tokio::test]
3054    async fn test_v1_child_logging() {
3055        use hyperactor::channel;
3056        use hyperactor::mailbox::BoxedMailboxSender;
3057        use hyperactor::mailbox::DialMailboxRouter;
3058        use hyperactor::mailbox::MailboxServer;
3059        use hyperactor::proc::Proc;
3060
3061        use crate::bootstrap::BOOTSTRAP_LOG_CHANNEL;
3062        use crate::logging::LogClientActor;
3063        use crate::logging::LogClientMessageClient;
3064        use crate::logging::LogForwardActor;
3065        use crate::logging::LogMessage;
3066        use crate::logging::OutputTarget;
3067        use crate::logging::test_tap;
3068
3069        let router = DialMailboxRouter::new();
3070        let (proc_addr, proc_rx) =
3071            channel::serve(ChannelAddr::any(ChannelTransport::Unix)).unwrap();
3072        let proc = Proc::configured(
3073            test_proc_id("client_0"),
3074            BoxedMailboxSender::new(router.clone()),
3075        );
3076        proc.clone().serve(proc_rx);
3077        let proc_ref: ProcAddr = test_proc_id("client_0");
3078        router.bind(proc_ref, proc_addr.clone());
3079        let client = proc.client("client");
3080
3081        let (tap_tx, mut tap_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
3082        test_tap::install(tap_tx);
3083
3084        let log_channel = ChannelAddr::any(ChannelTransport::Unix);
3085        // SAFETY: unit-test scoped env var
3086        unsafe {
3087            std::env::set_var(BOOTSTRAP_LOG_CHANNEL, log_channel.to_string());
3088        }
3089
3090        // Spawn the log client and disable aggregation (immediate
3091        // print + tap push).
3092        let log_client_actor = LogClientActor::new((), &ActorEnvironment::default())
3093            .await
3094            .unwrap();
3095        let log_client: ActorRef<LogClientActor> = proc.spawn(log_client_actor).bind();
3096        log_client.set_aggregate(&client, None).await.unwrap();
3097
3098        // Spawn the forwarder in this proc (it will serve
3099        // BOOTSTRAP_LOG_CHANNEL).
3100        let log_forwarder_actor =
3101            LogForwardActor::new(log_client.clone(), &ActorEnvironment::default())
3102                .await
3103                .unwrap();
3104        let _log_forwarder: ActorRef<LogForwardActor> = proc.spawn(log_forwarder_actor).bind();
3105
3106        // Dial the channel but don't post until we know the forwarder
3107        // is receiving.
3108        let tx = channel::dial::<LogMessage>(log_channel.clone()).unwrap();
3109
3110        // Send a fake log message as if it came from the proc
3111        // manager's writer.
3112        tx.post(LogMessage::Log {
3113            hostname: "testhost".into(),
3114            proc_id: "testproc[0]".into(),
3115            output_target: OutputTarget::Stdout,
3116            payload: wirevalue::Any::serialize(&"hello from child".to_string()).unwrap(),
3117        });
3118
3119        // Assert we see it via the tap.
3120        let line = tokio::time::timeout(Duration::from_secs(2), tap_rx.recv())
3121            .await
3122            .expect("timed out waiting for log line")
3123            .expect("tap channel closed unexpectedly");
3124        assert!(
3125            line.contains("hello from child"),
3126            "log line did not appear via LogClientActor; got: {line}"
3127        );
3128    }
3129
3130    mod proc_handle {
3131
3132        use std::sync::Arc;
3133        use std::time::Duration;
3134
3135        use async_trait::async_trait;
3136        use hyperactor::ActorRef;
3137        use hyperactor::ProcAddr;
3138        use hyperactor::testing::ids::test_proc_id;
3139
3140        use super::super::*;
3141        use crate::host::ProcHandle;
3142        use crate::proc_launcher::LaunchOptions;
3143        use crate::proc_launcher::LaunchResult;
3144        use crate::proc_launcher::ProcLauncher;
3145        use crate::proc_launcher::ProcLauncherError;
3146
3147        /// A test launcher that panics on any method call.
3148        ///
3149        /// This is used by unit tests that only exercise status
3150        /// transitions on `BootstrapProcHandle` without actually
3151        /// launching or terminating processes. If any launcher method
3152        /// is called, the test will panic—indicating an unexpected
3153        /// code path.
3154        struct TestProcLauncher;
3155
3156        #[async_trait]
3157        impl ProcLauncher for TestProcLauncher {
3158            async fn launch(
3159                &self,
3160                _proc_id: &ProcAddr,
3161                _opts: LaunchOptions,
3162            ) -> Result<LaunchResult, ProcLauncherError> {
3163                panic!("TestProcLauncher::launch should not be called in unit tests");
3164            }
3165
3166            async fn terminate(
3167                &self,
3168                _proc_id: &ProcAddr,
3169                _timeout: Duration,
3170            ) -> Result<(), ProcLauncherError> {
3171                panic!("TestProcLauncher::terminate should not be called in unit tests");
3172            }
3173
3174            async fn kill(&self, _proc_id: &ProcAddr) -> Result<(), ProcLauncherError> {
3175                panic!("TestProcLauncher::kill should not be called in unit tests");
3176            }
3177        }
3178
3179        // Helper: build a ProcHandle for state-transition unit tests.
3180        //
3181        // This creates a handle with a no-op test launcher. The
3182        // launcher will panic if any of its methods are called,
3183        // ensuring tests only exercise status transitions and not
3184        // actual process lifecycle.
3185        fn handle_for_test() -> BootstrapProcHandle {
3186            let proc_id: ProcAddr = test_proc_id("0");
3187            let launcher: Arc<dyn ProcLauncher> = Arc::new(TestProcLauncher);
3188            BootstrapProcHandle::new(proc_id, Arc::downgrade(&launcher))
3189        }
3190
3191        #[tokio::test]
3192        async fn starting_to_running_ok() {
3193            let h = handle_for_test();
3194            assert!(matches!(h.status(), ProcStatus::Starting));
3195            let child_started_at = std::time::SystemTime::now();
3196            assert!(h.mark_running(child_started_at));
3197            match h.status() {
3198                ProcStatus::Running { started_at } => {
3199                    assert_eq!(started_at, child_started_at);
3200                }
3201                other => panic!("expected Running, got {other:?}"),
3202            }
3203        }
3204
3205        #[tokio::test]
3206        async fn running_to_stopping_to_stopped_ok() {
3207            let h = handle_for_test();
3208            let child_started_at = std::time::SystemTime::now();
3209            assert!(h.mark_running(child_started_at));
3210            assert!(h.mark_stopping());
3211            assert!(matches!(h.status(), ProcStatus::Stopping { .. }));
3212            assert!(h.mark_stopped(0, Vec::new()));
3213            assert!(matches!(
3214                h.status(),
3215                ProcStatus::Stopped { exit_code: 0, .. }
3216            ));
3217        }
3218
3219        #[tokio::test]
3220        async fn running_to_killed_ok() {
3221            let h = handle_for_test();
3222            let child_started_at = std::time::SystemTime::now();
3223            assert!(h.mark_running(child_started_at));
3224            assert!(h.mark_killed(9, true));
3225            assert!(matches!(
3226                h.status(),
3227                ProcStatus::Killed {
3228                    signal: 9,
3229                    core_dumped: true
3230                }
3231            ));
3232        }
3233
3234        #[tokio::test]
3235        async fn running_to_failed_ok() {
3236            let h = handle_for_test();
3237            let child_started_at = std::time::SystemTime::now();
3238            assert!(h.mark_running(child_started_at));
3239            assert!(h.mark_failed("bootstrap error"));
3240            match h.status() {
3241                ProcStatus::Failed { reason } => {
3242                    assert_eq!(reason, "bootstrap error");
3243                }
3244                other => panic!("expected Failed(\"bootstrap error\"), got {other:?}"),
3245            }
3246        }
3247
3248        #[tokio::test]
3249        async fn illegal_transitions_are_rejected() {
3250            let h = handle_for_test();
3251            let child_started_at = std::time::SystemTime::now();
3252            // Starting -> Running is fine; second Running should be rejected.
3253            assert!(h.mark_running(child_started_at));
3254            assert!(!h.mark_running(std::time::SystemTime::now()));
3255            assert!(matches!(h.status(), ProcStatus::Running { .. }));
3256            // Once Stopped, we can't go to Running/Killed/Failed/etc.
3257            assert!(h.mark_stopping());
3258            assert!(h.mark_stopped(0, Vec::new()));
3259            assert!(!h.mark_running(child_started_at));
3260            assert!(!h.mark_killed(9, false));
3261            assert!(!h.mark_failed("nope"));
3262
3263            assert!(matches!(
3264                h.status(),
3265                ProcStatus::Stopped { exit_code: 0, .. }
3266            ));
3267        }
3268
3269        #[tokio::test]
3270        async fn transitions_from_ready_are_legal() {
3271            let h = handle_for_test();
3272            let addr = ChannelAddr::any(ChannelTransport::Unix);
3273            // Mark Running.
3274            let t0 = std::time::SystemTime::now();
3275            assert!(h.mark_running(t0));
3276            // Build a consistent AgentRef for Ready using the
3277            // handle's ProcAddr.
3278            let proc_id = <BootstrapProcHandle as ProcHandle>::proc_addr(&h);
3279            let actor_id = proc_id.actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME);
3280            let agent_ref: ActorRef<ProcAgent> = ActorRef::attest(actor_id);
3281            // Ready -> Stopping -> Stopped should be legal.
3282            assert!(h.mark_ready(addr, agent_ref));
3283            assert!(h.mark_stopping());
3284            assert!(h.mark_stopped(0, Vec::new()));
3285        }
3286
3287        #[tokio::test]
3288        async fn ready_to_killed_is_legal() {
3289            let h = handle_for_test();
3290            let addr = ChannelAddr::any(ChannelTransport::Unix);
3291            // Starting -> Running
3292            let t0 = std::time::SystemTime::now();
3293            assert!(h.mark_running(t0));
3294            // Build a consistent AgentRef for Ready using the
3295            // handle's ProcAddr.
3296            let proc_id = <BootstrapProcHandle as ProcHandle>::proc_addr(&h);
3297            let actor_id = proc_id.actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME);
3298            let agent: ActorRef<ProcAgent> = ActorRef::attest(actor_id);
3299            // Running -> Ready
3300            assert!(h.mark_ready(addr, agent));
3301            // Ready -> Killed
3302            assert!(h.mark_killed(9, false));
3303        }
3304
3305        #[tokio::test]
3306        async fn mark_failed_from_stopping_is_allowed() {
3307            let h = handle_for_test();
3308
3309            // Drive Starting -> Stopping.
3310            assert!(h.mark_stopping(), "precondition: to Stopping");
3311
3312            // Now allow Stopping -> Failed.
3313            assert!(
3314                h.mark_failed("boom"),
3315                "mark_failed() should succeed from Stopping"
3316            );
3317            match h.status() {
3318                ProcStatus::Failed { reason } => assert_eq!(reason, "boom"),
3319                other => panic!("expected Failed(\"boom\"), got {other:?}"),
3320            }
3321        }
3322    }
3323
3324    /// A test launcher that panics on any method call.
3325    ///
3326    /// This is used by unit tests that only exercise status
3327    /// transitions on `BootstrapProcHandle` without actually
3328    /// launching or terminating processes.
3329    struct TestLauncher;
3330
3331    #[async_trait::async_trait]
3332    impl crate::proc_launcher::ProcLauncher for TestLauncher {
3333        async fn launch(
3334            &self,
3335            _proc_id: &ProcAddr,
3336            _opts: crate::proc_launcher::LaunchOptions,
3337        ) -> Result<crate::proc_launcher::LaunchResult, crate::proc_launcher::ProcLauncherError>
3338        {
3339            panic!("TestLauncher::launch should not be called in unit tests");
3340        }
3341
3342        async fn terminate(
3343            &self,
3344            _proc_id: &ProcAddr,
3345            _timeout: std::time::Duration,
3346        ) -> Result<(), crate::proc_launcher::ProcLauncherError> {
3347            panic!("TestLauncher::terminate should not be called in unit tests");
3348        }
3349
3350        async fn kill(
3351            &self,
3352            _proc_id: &ProcAddr,
3353        ) -> Result<(), crate::proc_launcher::ProcLauncherError> {
3354            panic!("TestLauncher::kill should not be called in unit tests");
3355        }
3356    }
3357
3358    fn test_handle(proc_id: ProcAddr) -> BootstrapProcHandle {
3359        let launcher: std::sync::Arc<dyn crate::proc_launcher::ProcLauncher> =
3360            std::sync::Arc::new(TestLauncher);
3361        BootstrapProcHandle::new(proc_id, std::sync::Arc::downgrade(&launcher))
3362    }
3363
3364    #[tokio::test]
3365    async fn watch_notifies_on_status_changes() {
3366        let proc_id = test_proc_id("1");
3367        let handle = test_handle(proc_id);
3368        let mut rx = handle.watch();
3369
3370        // Starting -> Running
3371        let now = std::time::SystemTime::now();
3372        assert!(handle.mark_running(now));
3373        rx.changed().await.ok(); // Observe the transition.
3374        match &*rx.borrow() {
3375            ProcStatus::Running { started_at } => {
3376                assert_eq!(*started_at, now);
3377            }
3378            s => panic!("expected Running, got {s:?}"),
3379        }
3380
3381        // Running -> Stopped
3382        assert!(handle.mark_stopped(0, Vec::new()));
3383        rx.changed().await.ok(); // Observe the transition.
3384        assert!(matches!(
3385            &*rx.borrow(),
3386            ProcStatus::Stopped { exit_code: 0, .. }
3387        ));
3388    }
3389
3390    #[tokio::test]
3391    async fn ready_errs_if_process_exits_before_running() {
3392        let proc_id =
3393            test_proc_id_with_addr(ChannelAddr::any(ChannelTransport::Unix), "early-exit");
3394        let handle = test_handle(proc_id);
3395
3396        // Simulate the exit monitor doing its job directly here.
3397        // (Equivalent outcome: terminal state before Running.)
3398        assert!(handle.mark_stopped(7, Vec::new()));
3399
3400        // `ready()` should return Err with the terminal status.
3401        match handle.ready_inner().await {
3402            Ok(()) => panic!("ready() unexpectedly succeeded"),
3403            Err(ReadyError::Terminal(ProcStatus::Stopped { exit_code, .. })) => {
3404                assert_eq!(exit_code, 7)
3405            }
3406            Err(other) => panic!("expected Stopped(7), got {other:?}"),
3407        }
3408    }
3409
3410    #[tokio::test]
3411    async fn status_unknown_proc_is_none() {
3412        let manager = BootstrapProcManager::new(BootstrapCommand {
3413            program: PathBuf::from("/bin/true"),
3414            ..Default::default()
3415        })
3416        .unwrap();
3417        let unknown = test_proc_id_with_addr(ChannelAddr::any(ChannelTransport::Unix), "nope");
3418        assert!(manager.status(&unknown).await.is_none());
3419    }
3420
3421    #[tokio::test]
3422    async fn handle_ready_allows_waiters() {
3423        let proc_id = test_proc_id("42");
3424        let handle = test_handle(proc_id.clone());
3425
3426        let started_at = std::time::SystemTime::now();
3427        assert!(handle.mark_running(started_at));
3428
3429        let actor_id = proc_id.actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME);
3430        let agent_ref: ActorRef<ProcAgent> = ActorRef::attest(actor_id);
3431
3432        // Pick any addr to carry in Ready (what the child would have
3433        // called back with).
3434        let ready_addr = ChannelAddr::any(ChannelTransport::Unix);
3435
3436        // Stamp Ready and assert ready().await unblocks.
3437        assert!(handle.mark_ready(ready_addr.clone(), agent_ref));
3438        handle
3439            .ready_inner()
3440            .await
3441            .expect("ready_inner() should complete after Ready");
3442
3443        // Sanity-check the Ready fields we control
3444        // (started_at/addr).
3445        match handle.status() {
3446            ProcStatus::Ready {
3447                started_at: t,
3448                addr: a,
3449                ..
3450            } => {
3451                assert_eq!(t, started_at);
3452                assert_eq!(a, ready_addr);
3453            }
3454            other => panic!("expected Ready, got {other:?}"),
3455        }
3456    }
3457
3458    #[test]
3459    fn display_running_includes_uptime() {
3460        let started_at = std::time::SystemTime::now() - Duration::from_secs(42);
3461        let st = ProcStatus::Running { started_at };
3462
3463        let s = format!("{}", st);
3464        assert!(s.contains("Running"));
3465        assert!(s.contains("42s"));
3466    }
3467
3468    #[test]
3469    fn display_ready_includes_addr() {
3470        let started_at = std::time::SystemTime::now() - Duration::from_secs(5);
3471        let addr = ChannelAddr::any(ChannelTransport::Unix);
3472        let agent = ActorRef::attest(
3473            test_proc_id_with_addr(addr.clone(), "proc")
3474                .actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME),
3475        );
3476
3477        let st = ProcStatus::Ready {
3478            started_at,
3479            addr: addr.clone(),
3480            agent,
3481        };
3482
3483        let s = format!("{}", st);
3484        assert!(s.contains(&addr.to_string())); // addr
3485        assert!(s.contains("Ready"));
3486    }
3487
3488    #[test]
3489    fn display_stopped_includes_exit_code() {
3490        let st = ProcStatus::Stopped {
3491            exit_code: 7,
3492            stderr_tail: Vec::new(),
3493        };
3494        let s = format!("{}", st);
3495        assert!(s.contains("Stopped"));
3496        assert!(s.contains("7"));
3497    }
3498
3499    #[test]
3500    fn display_other_variants_does_not_panic() {
3501        let samples = vec![
3502            ProcStatus::Starting,
3503            ProcStatus::Stopping {
3504                started_at: std::time::SystemTime::now(),
3505            },
3506            ProcStatus::Ready {
3507                started_at: std::time::SystemTime::now(),
3508                addr: ChannelAddr::any(ChannelTransport::Unix),
3509                agent: ActorRef::attest(
3510                    test_proc_id_with_addr(ChannelAddr::any(ChannelTransport::Unix), "x")
3511                        .actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME),
3512                ),
3513            },
3514            ProcStatus::Killed {
3515                signal: 9,
3516                core_dumped: false,
3517            },
3518            ProcStatus::Failed {
3519                reason: "boom".into(),
3520            },
3521        ];
3522
3523        for st in samples {
3524            let _ = format!("{}", st); // Just make sure it doesn't panic.
3525        }
3526    }
3527
3528    #[tokio::test]
3529    async fn proc_handle_ready_ok_through_trait() {
3530        let proc_id =
3531            test_proc_id_with_addr(ChannelAddr::any(ChannelTransport::Unix), "ph-ready-ok");
3532        let handle = test_handle(proc_id.clone());
3533
3534        // Starting -> Running
3535        let t0 = std::time::SystemTime::now();
3536        assert!(handle.mark_running(t0));
3537
3538        // Synthesize Ready data
3539        let addr = ChannelAddr::any(ChannelTransport::Unix);
3540        let agent: ActorRef<ProcAgent> =
3541            ActorRef::attest(proc_id.actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME));
3542        assert!(handle.mark_ready(addr, agent));
3543
3544        // Call the trait method (not ready_inner).
3545        let r = <BootstrapProcHandle as ProcHandle>::ready(&handle).await;
3546        assert!(r.is_ok(), "expected Ok(()), got {r:?}");
3547    }
3548
3549    #[tokio::test]
3550    async fn proc_handle_wait_returns_terminal_status() {
3551        let proc_id = test_proc_id_with_addr(ChannelAddr::any(ChannelTransport::Unix), "ph-wait");
3552        let handle = test_handle(proc_id);
3553
3554        // Drive directly to a terminal state before calling wait()
3555        assert!(handle.mark_stopped(0, Vec::new()));
3556
3557        // Call the trait method (not wait_inner)
3558        let st = <BootstrapProcHandle as ProcHandle>::wait(&handle)
3559            .await
3560            .expect("wait should return Ok(terminal)");
3561
3562        match st {
3563            ProcStatus::Stopped { exit_code, .. } => assert_eq!(exit_code, 0),
3564            other => panic!("expected Stopped(0), got {other:?}"),
3565        }
3566    }
3567
3568    #[tokio::test]
3569    async fn ready_wrapper_maps_terminal_to_trait_error() {
3570        let proc_id = test_proc_id_with_addr(ChannelAddr::any(ChannelTransport::Unix), "wrap");
3571        let handle = test_handle(proc_id);
3572
3573        assert!(handle.mark_stopped(7, Vec::new()));
3574
3575        match <BootstrapProcHandle as ProcHandle>::ready(&handle).await {
3576            Ok(()) => panic!("expected Err"),
3577            Err(HostReadyError::Terminal(ProcStatus::Stopped { exit_code, .. })) => {
3578                assert_eq!(exit_code, 7);
3579            }
3580            Err(e) => panic!("unexpected error: {e:?}"),
3581        }
3582    }
3583
3584    /// Create a ProcAddr and a host **backend_addr** channel that the
3585    /// bootstrap child proc will dial to attach its mailbox to the
3586    /// host.
3587    ///
3588    /// - `proc_id`: logical identity for the child proc (pure name;
3589    ///   not an OS pid).
3590    /// - `backend_addr`: a mailbox address served by the **parent
3591    ///   (host) proc** here; the spawned bootstrap process dials this
3592    ///   so its messages route via the host.
3593    #[cfg(fbcode_build)]
3594    async fn make_proc_id_and_backend_addr(
3595        instance: &hyperactor::Client,
3596        _tag: &str,
3597    ) -> (ProcAddr, ChannelAddr) {
3598        // Serve a Unix channel as the "backend_addr" and hook it into
3599        // this test proc.
3600        let (backend_addr, rx) = channel::serve(ChannelAddr::any(ChannelTransport::Unix)).unwrap();
3601
3602        // Route messages arriving on backend_addr into this test
3603        // proc's mailbox so the bootstrap child can reach the host
3604        // router.
3605        hyperactor::mailbox::MailboxServer::serve(instance.proc().clone(), rx);
3606
3607        // We return an arbitrary (but unbound!) unix direct proc id here;
3608        // it is okay, as we're not testing connectivity.
3609        let proc_id = test_proc_id_with_addr(ChannelTransport::Unix.any(), "proc");
3610        (proc_id, backend_addr)
3611    }
3612
3613    #[tokio::test]
3614    #[cfg(fbcode_build)]
3615    async fn bootstrap_handle_terminate_graceful() {
3616        // Create a root direct-addressed proc + client instance.
3617        let root =
3618            hyperactor::Proc::direct(ChannelTransport::Unix.any(), "root".to_string()).unwrap();
3619        let instance = root.client("client");
3620
3621        let mgr = BootstrapProcManager::new(BootstrapCommand::test()).unwrap();
3622        let (proc_id, backend_addr) = make_proc_id_and_backend_addr(&instance, "t_term").await;
3623        let handle = mgr
3624            .spawn(
3625                proc_id.clone(),
3626                backend_addr.clone(),
3627                BootstrapProcConfig {
3628                    create_rank: 0,
3629                    client_config_override: Attrs::new(),
3630                    proc_bind: None,
3631                    bootstrap_command: None,
3632                },
3633            )
3634            .await
3635            .expect("spawn bootstrap child");
3636
3637        handle.ready().await.expect("ready");
3638
3639        let deadline = Duration::from_secs(2);
3640        match tokio::time::timeout(
3641            deadline * 2,
3642            handle.terminate(&instance, deadline, "test terminate"),
3643        )
3644        .await
3645        {
3646            Err(_) => panic!("terminate() future hung"),
3647            Ok(Ok(st)) => {
3648                match st {
3649                    ProcStatus::Stopped { exit_code, .. } => {
3650                        // child called exit(0) on SIGTERM
3651                        assert_eq!(exit_code, 0, "expected clean exit; got {exit_code}");
3652                    }
3653                    ProcStatus::Killed { signal, .. } => {
3654                        // If the child didn't trap SIGTERM, we'd see
3655                        // SIGTERM (15) here and indeed, this is what
3656                        // we see. Since we call
3657                        // `hyperactor::initialize_with_current_runtime();`
3658                        // we seem unable to trap `SIGTERM` and
3659                        // instead folly intercepts:
3660                        // [0] *** Aborted at 1758850539 (Unix time, try 'date -d @1758850539') ***
3661                        // [0] *** Signal 15 (SIGTERM) (0x3951c00173692) received by PID 1527420 (pthread TID 0x7f803de66cc0) (linux TID 1527420) (maybe from PID 1521298, UID 234780) (code: 0), stack trace: ***
3662                        // [0]     @ 000000000000e713 folly::symbolizer::(anonymous namespace)::innerSignalHandler(int, siginfo_t*, void*)
3663                        // [0]                        ./fbcode/folly/debugging/symbolizer/SignalHandler.cpp:485
3664                        // It gets worse. When run with
3665                        // '@fbcode//mode/dev-nosan' it terminates
3666                        // with a SEGFAULT (metamate says this is a
3667                        // well known issue at Meta). So, TL;DR I
3668                        // restore default `SIGTERM` handling after
3669                        // the test exe has called
3670                        // `initialize_with_runtime`.
3671                        assert_eq!(signal, libc::SIGTERM, "expected SIGTERM; got {signal}");
3672                    }
3673                    other => panic!("expected Stopped or Killed(SIGTERM); got {other:?}"),
3674                }
3675            }
3676            Ok(Err(e)) => panic!("terminate() failed: {e:?}"),
3677        }
3678    }
3679
3680    #[tokio::test]
3681    #[cfg(fbcode_build)]
3682    async fn bootstrap_handle_kill_forced() {
3683        // Root proc + client instance (so the child can dial back).
3684        let root =
3685            hyperactor::Proc::direct(ChannelTransport::Unix.any(), "root".to_string()).unwrap();
3686        let instance = root.client("client");
3687
3688        let mgr = BootstrapProcManager::new(BootstrapCommand::test()).unwrap();
3689
3690        // Proc identity + host backend channel the child will dial.
3691        let (proc_id, backend_addr) = make_proc_id_and_backend_addr(&instance, "t_kill").await;
3692
3693        // Launch the child bootstrap process.
3694        let handle = mgr
3695            .spawn(
3696                proc_id.clone(),
3697                backend_addr.clone(),
3698                BootstrapProcConfig {
3699                    create_rank: 0,
3700                    client_config_override: Attrs::new(),
3701                    proc_bind: None,
3702                    bootstrap_command: None,
3703                },
3704            )
3705            .await
3706            .expect("spawn bootstrap child");
3707
3708        // Wait until the child reports Ready (addr+agent returned via
3709        // callback).
3710        handle.ready().await.expect("ready");
3711
3712        // Force-kill the child and assert we observe a Killed
3713        // terminal status.
3714        let deadline = Duration::from_secs(5);
3715        match tokio::time::timeout(deadline, handle.kill()).await {
3716            Err(_) => panic!("kill() future hung"),
3717            Ok(Ok(st)) => {
3718                // We expect a KILLED terminal state.
3719                match st {
3720                    ProcStatus::Killed { signal, .. } => {
3721                        // On Linux this should be SIGKILL (9).
3722                        assert_eq!(signal, libc::SIGKILL, "expected SIGKILL; got {}", signal);
3723                    }
3724                    other => panic!("expected Killed status after kill(); got: {other:?}"),
3725                }
3726            }
3727            Ok(Err(e)) => panic!("kill() failed: {e:?}"),
3728        }
3729    }
3730
3731    #[tokio::test]
3732    #[cfg(fbcode_build)]
3733    async fn test_host_bootstrap() {
3734        use crate::host_mesh::host_agent::GetLocalProcClient;
3735        use crate::proc_agent::NewClientInstanceClient;
3736
3737        // Create a local instance just to call the local bootstrap actor.
3738        // We should find a way to avoid this for local handles.
3739        let temp_proc = Proc::isolated();
3740        let temp_instance = temp_proc.client("temp");
3741
3742        let handle = host(
3743            ProcAddr::new(
3744                legacy_service_proc_id(),
3745                ChannelAddr::any(ChannelTransport::Unix).into(),
3746            ),
3747            Some(BootstrapCommand::test()),
3748            None,
3749            false,
3750            None,
3751            Gateway::global().clone(),
3752            None,
3753        )
3754        .await
3755        .unwrap();
3756
3757        let local_proc = handle.0.get_local_proc(&temp_instance).await.unwrap();
3758        let _local_instance = local_proc
3759            .new_client_instance(&temp_instance)
3760            .await
3761            .unwrap();
3762    }
3763
3764    // BootstrapProcManager OnceLock Semantics Tests
3765    //
3766    // These tests verify the "install exactly once / default locks
3767    // in" behavior of the proc launcher OnceLock.
3768
3769    use std::time::Duration;
3770
3771    use crate::proc_launcher::LaunchOptions;
3772    use crate::proc_launcher::LaunchResult;
3773    use crate::proc_launcher::ProcExitKind;
3774    use crate::proc_launcher::ProcExitResult;
3775    use crate::proc_launcher::ProcLauncher;
3776    use crate::proc_launcher::ProcLauncherError;
3777    use crate::proc_launcher::StdioHandling;
3778
3779    /// A dummy proc launcher for testing. Does not actually launch
3780    /// anything.
3781    #[allow(dead_code)]
3782    struct DummyLauncher {
3783        /// Marker value to identify this instance.
3784        marker: u64,
3785    }
3786
3787    impl DummyLauncher {
3788        #[allow(dead_code)]
3789        fn new(marker: u64) -> Self {
3790            Self { marker }
3791        }
3792
3793        #[allow(dead_code)]
3794        fn marker(&self) -> u64 {
3795            self.marker
3796        }
3797    }
3798
3799    #[async_trait::async_trait]
3800    impl ProcLauncher for DummyLauncher {
3801        async fn launch(
3802            &self,
3803            _proc_id: &ProcAddr,
3804            _opts: LaunchOptions,
3805        ) -> Result<LaunchResult, ProcLauncherError> {
3806            let (tx, rx) = tokio::sync::oneshot::channel();
3807            // Immediately send exit result
3808            let _ = tx.send(ProcExitResult {
3809                kind: ProcExitKind::Exited { code: 0 },
3810                stderr_tail: Some(vec![]),
3811            });
3812            Ok(LaunchResult {
3813                pid: None,
3814                started_at: std::time::SystemTime::now(),
3815                stdio: StdioHandling::ManagedByLauncher,
3816                exit_rx: rx,
3817            })
3818        }
3819
3820        async fn terminate(
3821            &self,
3822            _proc_id: &ProcAddr,
3823            _timeout: Duration,
3824        ) -> Result<(), ProcLauncherError> {
3825            Ok(())
3826        }
3827
3828        async fn kill(&self, _proc_id: &ProcAddr) -> Result<(), ProcLauncherError> {
3829            Ok(())
3830        }
3831    }
3832
3833    // set_launcher() then launcher() returns the same Arc.
3834    #[test]
3835    #[cfg(fbcode_build)]
3836    fn test_set_launcher_then_get() {
3837        let manager = BootstrapProcManager::new(BootstrapCommand::test()).unwrap();
3838
3839        let custom: Arc<dyn ProcLauncher> = Arc::new(DummyLauncher::new(42));
3840        let custom_ptr = Arc::as_ptr(&custom);
3841
3842        // Install the custom launcher
3843        manager.set_launcher(custom).unwrap();
3844
3845        // Get the launcher and verify it's the same Arc
3846        let got = manager.launcher();
3847        let got_ptr = Arc::as_ptr(got);
3848
3849        assert_eq!(
3850            custom_ptr, got_ptr,
3851            "launcher() should return the same Arc that was set"
3852        );
3853    }
3854
3855    // launcher() first (forces default init), then set_launcher()
3856    // fails.
3857    #[test]
3858    #[cfg(fbcode_build)]
3859    fn test_get_launcher_then_set_fails() {
3860        let manager = BootstrapProcManager::new(BootstrapCommand::test()).unwrap();
3861
3862        // Force default initialization by calling launcher()
3863        let _ = manager.launcher();
3864
3865        // Now try to set a custom launcher - should fail
3866        let custom: Arc<dyn ProcLauncher> = Arc::new(DummyLauncher::new(99));
3867        let result = manager.set_launcher(custom);
3868
3869        assert!(
3870            result.is_err(),
3871            "set_launcher should fail after launcher() was called"
3872        );
3873
3874        // Verify error message mentions the cause
3875        let err = result.unwrap_err();
3876        let err_msg = err.to_string();
3877        assert!(
3878            err_msg.contains("already initialized"),
3879            "error should mention 'already initialized', got: {}",
3880            err_msg
3881        );
3882    }
3883
3884    // set_launcher() twice fails.
3885    #[test]
3886    #[cfg(fbcode_build)]
3887    fn test_set_launcher_twice_fails() {
3888        let manager = BootstrapProcManager::new(BootstrapCommand::test()).unwrap();
3889
3890        let first: Arc<dyn ProcLauncher> = Arc::new(DummyLauncher::new(1));
3891        let second: Arc<dyn ProcLauncher> = Arc::new(DummyLauncher::new(2));
3892
3893        // First set should succeed
3894        manager.set_launcher(first).unwrap();
3895
3896        // Second set should fail
3897        let result = manager.set_launcher(second);
3898        assert!(result.is_err(), "second set_launcher should fail");
3899
3900        // Verify error message
3901        let err = result.unwrap_err();
3902        let err_msg = err.to_string();
3903        assert!(
3904            err_msg.contains("already initialized"),
3905            "error should mention 'already initialized', got: {}",
3906            err_msg
3907        );
3908    }
3909
3910    /// OnceLock is empty before any call.
3911    #[test]
3912    #[cfg(fbcode_build)]
3913    fn test_launcher_initially_empty() {
3914        let manager = BootstrapProcManager::new(BootstrapCommand::test()).unwrap();
3915
3916        // At this point, the OnceLock should be empty (not yet
3917        // initialized) We can verify this by successfully calling
3918        // set_launcher
3919        let custom: Arc<dyn ProcLauncher> = Arc::new(DummyLauncher::new(123));
3920        let result = manager.set_launcher(custom);
3921
3922        assert!(
3923            result.is_ok(),
3924            "set_launcher should succeed on fresh manager"
3925        );
3926    }
3927
3928    /// launcher() returns the same Arc on repeated calls.
3929    #[test]
3930    #[cfg(fbcode_build)]
3931    fn test_launcher_idempotent() {
3932        let manager = BootstrapProcManager::new(BootstrapCommand::test()).unwrap();
3933
3934        // Call launcher() twice
3935        let first = manager.launcher();
3936        let second = manager.launcher();
3937
3938        // Should return the same Arc (same pointer)
3939        assert!(
3940            Arc::ptr_eq(first, second),
3941            "launcher() should return the same Arc on repeated calls"
3942        );
3943    }
3944}