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