Skip to main content

hyperactor_mesh/
host_mesh.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//! Host-mesh attach and lifecycle.
10//!
11//! ## Host-mesh invariants (HM-*)
12//!
13//! These are the load-bearing semantic contracts of `HostMesh::attach()`
14//! and `HostMeshRef::push_config()`. They describe what callers may
15//! rely on; they do not pin specific mechanisms (the current
16//! implementation chooses a particular send path, error taxonomy, and
17//! timeout shape — those are not invariants and may evolve).
18//!
19//! - **HM-1 (attach-config-complete).** If `HostMesh::attach()` returns
20//!   `Ok`, every attached host has installed the client's propagatable
21//!   config snapshot.
22//!
23//! - **HM-2 (attach-config-fails-closed).** If config push fails on
24//!   any attached host, `HostMesh::attach()` returns `Err`. It must
25//!   not return a partially-configured mesh as success.
26//!
27//! - **HM-3 (in-band-request-failure-surface).** Attach-time
28//!   config-push *request* failure must surface through `attach()` /
29//!   `push_config()` as a structured error. It must not bypass that
30//!   result path by returning the outbound request on the caller's
31//!   `Undeliverable<MessageEnvelope>` channel. The invariant names a
32//!   specific prohibited bypass; what a caller chooses to do with the
33//!   returned `Err` (escalate, retry, abort) is outside scope.
34//!   Cross-cutting: depends on hyperactor undeliverable semantics in
35//!   `hyperactor::reference` and `hyperactor::actor`; the invariant
36//!   still belongs here because the attach contract is owned here.
37//!
38//! - **HM-4 (host-scoped-error-reporting).** Config-push failure
39//!   reported from `push_config()` identifies the failing host(s)
40//!   individually, so callers can act per-host. The contract commits
41//!   to per-host *identity*; the failure-mode taxonomy carried
42//!   alongside it (the `ConfigPushFailure` variant set) is
43//!   implementation detail and may evolve without changing the
44//!   contract.
45
46#![allow(clippy::result_large_err)]
47
48use hyperactor::ActorRef;
49use hyperactor::Endpoint as _;
50use hyperactor::Gateway;
51use hyperactor::Handler;
52use hyperactor::accum::StreamingReducerOpts;
53use hyperactor::channel::ChannelTransport;
54use hyperactor::id::Label;
55use hyperactor::id::Uid;
56use hyperactor_cast::cast_actor::CastActor;
57use hyperactor_config::CONFIG;
58use hyperactor_config::ConfigAttr;
59use hyperactor_config::attrs::declare_attrs;
60use ndslice::view::CollectMeshExt;
61
62use crate::mesh_admin::MeshAdminAgent;
63use crate::supervision::MeshFailure;
64
65pub mod host_agent;
66
67use std::collections::HashSet;
68use std::ops::Deref;
69use std::ops::DerefMut;
70use std::str::FromStr;
71use std::sync::Arc;
72use std::time::Duration;
73
74use hyperactor::ActorAddr;
75use hyperactor::ProcAddr;
76use hyperactor::channel::ChannelAddr;
77use hyperactor::context;
78use hyperactor_cast::cast_actor::CAST_ACTOR_NAME;
79use ndslice::Extent;
80use ndslice::Region;
81use ndslice::ViewExt;
82use ndslice::extent;
83use ndslice::view;
84use ndslice::view::Ranked;
85use ndslice::view::RegionParseError;
86use serde::Deserialize;
87use serde::Serialize;
88use tracing::Instrument;
89use typeuri::Named;
90
91use crate::ActorMeshRef;
92use crate::Bootstrap;
93use crate::ProcMesh;
94use crate::ValueMesh;
95use crate::bootstrap::BootstrapCommand;
96use crate::bootstrap::BootstrapProcManager;
97use crate::bootstrap::ProcBind;
98use crate::host::Host;
99use crate::host::LocalProcManager;
100use crate::host::SERVICE_PROC_NAME;
101pub use crate::host_mesh::host_agent::HostAgent;
102use crate::host_mesh::host_agent::ProcManagerSpawnFn;
103use crate::host_mesh::host_agent::ProcState;
104use crate::mesh_controller::ProcMeshController;
105use crate::mesh_id::ActorMeshId;
106use crate::mesh_id::HostMeshId;
107use crate::mesh_id::ProcMeshId;
108use crate::mesh_id::ResourceId;
109use crate::proc_agent::ProcAgent;
110use crate::proc_mesh::ProcMeshRef;
111use crate::resource;
112use crate::resource::GetRankStatus;
113use crate::resource::RankedValues;
114use crate::resource::Status;
115use crate::resource::WaitRankStatusClient;
116use crate::transport::DEFAULT_TRANSPORT;
117
118/// Actor name for `ProcMeshController` when spawned as a named child.
119pub const PROC_MESH_CONTROLLER_NAME: &str = "proc_mesh_controller";
120
121declare_attrs! {
122    /// The maximum idle time between updates while spawning proc
123    /// meshes.
124    @meta(CONFIG = ConfigAttr::new(
125        Some("HYPERACTOR_MESH_PROC_SPAWN_MAX_IDLE".to_string()),
126        Some("mesh_proc_spawn_max_idle".to_string()),
127    ))
128    pub attr PROC_SPAWN_MAX_IDLE: Duration = Duration::from_secs(30);
129
130    /// The maximum idle time between updates while stopping proc
131    /// meshes.
132    @meta(CONFIG = ConfigAttr::new(
133        Some("HYPERACTOR_MESH_PROC_STOP_MAX_IDLE".to_string()),
134        Some("proc_stop_max_idle".to_string()),
135    ))
136    pub attr PROC_STOP_MAX_IDLE: Duration = Duration::from_secs(30);
137
138    /// The maximum idle time between updates while querying host meshes
139    /// for their proc states.
140    @meta(CONFIG = ConfigAttr::new(
141        Some("HYPERACTOR_MESH_GET_PROC_STATE_MAX_IDLE".to_string()),
142        Some("get_proc_state_max_idle".to_string()),
143    ))
144    pub attr GET_PROC_STATE_MAX_IDLE: Duration = Duration::from_mins(1);
145}
146
147pub(crate) fn host_agent_ref(host_addr: ChannelAddr) -> ActorRef<HostAgent> {
148    let host_addr = host_addr.into_dial_addr();
149    ActorRef::attest(
150        ResourceId::proc_addr_from_name(host_addr, SERVICE_PROC_NAME)
151            .actor_addr(host_agent::HOST_MESH_AGENT_ACTOR_NAME),
152    )
153}
154
155fn named_proc_on_host(agent: &ActorRef<HostAgent>, id: &ResourceId) -> ProcAddr {
156    let location =
157        hyperactor::Location::from(agent.actor_addr().addr().clone()).with_via(id.uid().clone());
158    id.proc_addr(location)
159}
160
161/// Per-host failure modes for the attach-time config push.
162///
163/// **Implementation detail of [`ConfigPushError`].** The variant
164/// taxonomy is *not* part of HM-4 or any other invariant — HM-4
165/// commits to per-host *identity* in the error, not to a specific
166/// menu of failure subtypes. The variant set may grow, shrink, or be
167/// reshaped without changing the contract; tests should not pin to a
168/// specific variant unless the fixture deterministically guarantees
169/// it.
170#[derive(Debug, thiserror::Error)]
171pub enum ConfigPushFailure {
172    /// Synchronous send failure (e.g. the request port refused the
173    /// post). The error is preserved for triage.
174    #[error("send failed: {0}")]
175    SendFailed(#[source] Box<hyperactor::mailbox::MailboxSenderError>),
176
177    /// The collective cast failed before the caller observed the
178    /// acknowledgement barrier.
179    #[error("cast failed: {0}")]
180    CastFailed(String),
181
182    /// The awaited reply did not arrive within
183    /// `MESH_ATTACH_CONFIG_TIMEOUT`. With request-bounce suppression
184    /// (HM-3 mechanism), this is the dominant failure mode for an
185    /// unreachable host: the channel-side `BrokenLink` is logged at
186    /// debug from `MailboxClient`'s buffer task; the contract surface
187    /// is just "did not reply in time".
188    #[error("reply timed out after MESH_ATTACH_CONFIG_TIMEOUT")]
189    ReplyTimedOut,
190
191    /// The reply receiver closed before any value arrived (sender
192    /// side dropped, or the local mailbox tore down).
193    #[error("reply channel closed before reply")]
194    ReplyChannelClosed,
195}
196
197/// Aggregated `attach()` config-push failure surface — one entry per
198/// host that didn't acknowledge installation.
199///
200/// HM-4: per-host identity is preserved so callers can act per-host.
201/// The host-identity key is the host channel address; the per-host
202/// cause is a `ConfigPushFailure`
203/// (taxonomy is implementation-detail; see the type's doc).
204#[derive(Debug)]
205pub struct ConfigPushError {
206    /// One entry per host whose config push didn't succeed.
207    pub failures: Vec<(ChannelAddr, ConfigPushFailure)>,
208}
209
210impl std::fmt::Display for ConfigPushError {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        write!(
213            f,
214            "config push failed during attach on {} host(s):",
215            self.failures.len()
216        )?;
217        for (host, failure) in &self.failures {
218            write!(f, "\n  - {}: {}", host, failure)?;
219        }
220        Ok(())
221    }
222}
223
224impl std::error::Error for ConfigPushError {
225    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
226        // Per-host causes are surfaced through `Display`. A single
227        // top-level `source()` would arbitrarily pick one host's
228        // cause and obscure the others.
229        None
230    }
231}
232
233/// An owned mesh of hosts.
234///
235/// # Lifecycle
236/// `HostMesh` owns host lifecycles. Callers **must** invoke
237/// [`HostMesh::shutdown`] for deterministic teardown.
238///
239/// In tests and production, prefer explicit shutdown to guarantee
240/// that host agents drop their `BootstrapProcManager`s and that all
241/// child procs are reaped. You can use `shutdown_guard` to get a wrapper
242/// which will try to do a best-effort shutdown on Drop.
243pub struct HostMesh {
244    id: HostMeshId,
245    extent: Extent,
246    current_ref: HostMeshRef,
247    /// Whether [`HostMeshShutdownGuard`] should attempt best-effort shutdown
248    /// on drop. `stop()` intentionally keeps hosts alive, so it disables this.
249    shutdown_on_drop: bool,
250}
251
252impl HostMesh {
253    /// Emit a telemetry event for this host mesh creation.
254    fn notify_created(&self) {
255        let name_str = self.id.to_string();
256        let mesh_id_hash = hyperactor_telemetry::hash_to_u64(&self.id);
257
258        hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
259            id: mesh_id_hash,
260            timestamp: std::time::SystemTime::now(),
261            class: "Host".to_string(),
262            given_name: self
263                .id
264                .display_label()
265                .map(|l| l.as_str())
266                .unwrap_or("unnamed")
267                .to_string(),
268            full_name: name_str,
269            shape_json: serde_json::to_string(&self.extent).unwrap_or_default(),
270            parent_mesh_id: None,
271            parent_view_json: None,
272        });
273
274        // Notify telemetry of each HostAgent actor in this mesh.
275        // These are skipped in Proc::spawn_inner. mesh_id directly points to host mesh.
276        let now = std::time::SystemTime::now();
277        for (rank, actor) in self.current_ref.host_agent_mesh.values().enumerate() {
278            hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
279                id: hyperactor_telemetry::hash_to_u64(actor.actor_addr().id()),
280                timestamp: now,
281                mesh_id: mesh_id_hash,
282                rank: rank as u64,
283                full_name: actor.actor_addr().to_string(),
284                display_name: None,
285            });
286        }
287    }
288
289    /// Bring up a local single-host mesh and, in the launcher
290    /// process, return a `HostMesh` handle for it.
291    ///
292    /// There are two execution modes:
293    ///
294    /// - bootstrap-child mode: if `Bootstrap::get_from_env()` says
295    ///   this process was launched as a bootstrap child, we call
296    ///   `boot.bootstrap().await`, which hands control to the
297    ///   bootstrap logic for this process (as defined by the
298    ///   `BootstrapCommand` the parent used to spawn it). if that
299    ///   call returns, we log the error and terminate. this branch
300    ///   does not produce a `HostMesh`.
301    ///
302    /// - launcher mode: otherwise, we are the process that is setting
303    ///   up the mesh. we create a `Host`, spawn a `HostAgent` in
304    ///   it, and build a single-host `HostMesh` around that. that
305    ///   `HostMesh` is returned to the caller.
306    ///
307    /// This API is intended for tests, examples, and local bring-up,
308    /// not production.
309    ///
310    /// TODO: fix up ownership
311    pub async fn local() -> crate::Result<HostMesh> {
312        Self::local_with_bootstrap(BootstrapCommand::current()?).await
313    }
314
315    /// Same as [`local`], but the caller supplies the
316    /// `BootstrapCommand` instead of deriving it from the current
317    /// process.
318    ///
319    /// The provided `bootstrap_cmd` is used when spawning bootstrap
320    /// children and determines the behavior of
321    /// `boot.bootstrap().await` in those children.
322    pub async fn local_with_bootstrap(bootstrap_cmd: BootstrapCommand) -> crate::Result<HostMesh> {
323        if let Ok(Some(boot)) = Bootstrap::get_from_env() {
324            let result = boot.bootstrap().await;
325            if let Err(err) = result {
326                tracing::error!("failed to bootstrap local host mesh process: {}", err);
327            }
328            std::process::exit(1);
329        }
330
331        let addr = hyperactor_config::global::get_cloned(DEFAULT_TRANSPORT).binding_addr();
332
333        let manager = BootstrapProcManager::new(bootstrap_cmd)?;
334        // Use a dedicated gateway, not the process-wide global one. This
335        // host coexists with the global-context singleton host (see
336        // `global_context`), which owns the global gateway; sharing it
337        // would collide on the legacy `service`/`local` pseudo-singleton
338        // proc ids.
339        let host = Host::new_with_gateway(manager, addr, None, Gateway::new(), None).await?;
340        let addr = host.addr().clone();
341        let system_proc = host.system_proc().clone();
342        let host_mesh_agent = system_proc
343            .spawn_with_uid(
344                Uid::singleton(Label::new(host_agent::HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
345                HostAgent::new_process(host, None),
346            )
347            .map_err(crate::Error::SingletonActorSpawnError)?;
348        HostAgent::wait_initialized(&host_mesh_agent).await?;
349        host_mesh_agent.bind::<HostAgent>();
350        let cast_handle = system_proc
351            .spawn_with_uid(
352                Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
353                CastActor::default(),
354            )
355            .map_err(crate::Error::SingletonActorSpawnError)?;
356        cast_handle.bind::<CastActor>();
357
358        let host_mesh_ref = HostMeshRef::new(
359            HostMeshId::instance(Label::new("local").unwrap()),
360            extent!(hosts = 1).into(),
361            vec![addr],
362        )?;
363        Ok(HostMesh::take(host_mesh_ref))
364    }
365
366    /// Create a local in-process host mesh where all procs run in the
367    /// current OS process.
368    ///
369    /// Unlike [`local`] which spawns child processes for each proc,
370    /// this method uses [`LocalProcManager`] to run everything
371    /// in-process. This makes all actors visible in the admin tree
372    /// (useful for debugging with the TUI).
373    ///
374    /// This API is intended for tests, examples, and debugging.
375    pub async fn local_in_process() -> crate::Result<HostMesh> {
376        let addr = hyperactor_config::global::get_cloned(DEFAULT_TRANSPORT).binding_addr();
377        Ok(HostMesh::take(Self::local_n_in_process(vec![addr]).await?))
378    }
379
380    /// Create a local in-process host mesh with multiple hosts, where
381    /// all procs run in the current OS process using [`LocalProcManager`].
382    ///
383    /// Each address in `host_addrs` becomes a separate host. The resulting
384    /// mesh has `extent!(hosts = host_addrs.len())`.
385    ///
386    /// This API is intended for unit tests that need a multi-host mesh
387    /// within a single process.
388    pub(crate) async fn local_n_in_process(
389        host_addrs: Vec<ChannelAddr>,
390    ) -> crate::Result<HostMeshRef> {
391        let n = host_addrs.len();
392        let mut in_process_host_addrs = Vec::with_capacity(n);
393        for host_addr in host_addrs {
394            in_process_host_addrs.push(Self::create_in_process_host(host_addr).await?);
395        }
396        HostMeshRef::new(
397            HostMeshId::instance(Label::new("local").unwrap()),
398            extent!(hosts = n).into(),
399            in_process_host_addrs,
400        )
401    }
402
403    /// Create a single in-process host at the given address, returning
404    /// its channel address.
405    async fn create_in_process_host(addr: ChannelAddr) -> crate::Result<ChannelAddr> {
406        let spawn: ProcManagerSpawnFn =
407            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
408        let manager = LocalProcManager::new(spawn);
409        // Each in-process host gets its own gateway, not the
410        // process-wide global one. Several hosts coexist in one process
411        // here, and the legacy `service`/`local` pseudo-singleton proc
412        // ids would collide if they all attached to the same gateway.
413        let host = Host::new_with_gateway(manager, addr, None, Gateway::new(), None).await?;
414        let addr = host.addr().clone();
415        let system_proc = host.system_proc().clone();
416        let host_mesh_agent = system_proc
417            .spawn_with_uid(
418                Uid::singleton(Label::new(host_agent::HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
419                HostAgent::new_local(host),
420            )
421            .map_err(crate::Error::SingletonActorSpawnError)?;
422        HostAgent::wait_initialized(&host_mesh_agent).await?;
423        host_mesh_agent.bind::<HostAgent>();
424
425        let cast_handle = system_proc
426            .spawn_with_uid(
427                Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
428                CastActor::default(),
429            )
430            .map_err(crate::Error::SingletonActorSpawnError)?;
431
432        cast_handle.bind::<CastActor>();
433
434        Ok(addr)
435    }
436
437    /// Create a new process-based host mesh. Each host is represented by a local process,
438    /// which manages its set of procs. This is not a true host mesh the sense that each host
439    /// is not independent. The intent of `process` is for testing, examples, and experimentation.
440    ///
441    /// The bootstrap command is used to bootstrap both hosts and processes, thus it should be
442    /// a command that reaches [`crate::bootstrap_or_die`]. `process` is itself a valid bootstrap
443    /// entry point; thus using `BootstrapCommand::current` works correctly as long as `process`
444    /// is called early in the lifecycle of the process and reached unconditionally.
445    ///
446    /// TODO: thread through ownership
447    pub async fn process(extent: Extent, command: BootstrapCommand) -> crate::Result<HostMesh> {
448        if let Ok(Some(boot)) = Bootstrap::get_from_env() {
449            let result = boot.bootstrap().await;
450            if let Err(err) = result {
451                tracing::error!("failed to bootstrap process host mesh process: {}", err);
452            }
453            std::process::exit(1);
454        }
455
456        let bind_spec = hyperactor_config::global::get_cloned(DEFAULT_TRANSPORT);
457        let mut host_addrs = Vec::with_capacity(extent.num_ranks());
458        for _ in 0..extent.num_ranks() {
459            // Note: this can be racy. Possibly we should have a callback channel.
460            let addr = bind_spec.binding_addr();
461            let bootstrap = Bootstrap::Host {
462                addr: addr.clone(),
463                command: Some(command.clone()),
464                config: Some(hyperactor_config::global::attrs()),
465                exit_on_shutdown: false,
466            };
467
468            let mut cmd = command.new();
469            bootstrap.to_env(&mut cmd);
470            cmd.spawn()?;
471            host_addrs.push(addr);
472        }
473
474        let host_mesh_ref = HostMeshRef::new(
475            HostMeshId::instance(Label::new("process").unwrap()),
476            extent.into(),
477            host_addrs,
478        )?;
479        Ok(HostMesh::take(host_mesh_ref))
480    }
481    /// Take ownership of an existing host mesh reference.
482    ///
483    /// Consumes the `HostMeshRef` and returns an owned `HostMesh` that assumes
484    /// lifecycle responsibility for those hosts.
485    pub fn take(mesh: HostMeshRef) -> Self {
486        let id = mesh.id.clone();
487        let extent = mesh.region().extent().clone();
488
489        let result = Self {
490            current_ref: mesh,
491            id,
492            extent,
493            shutdown_on_drop: true,
494        };
495        result.notify_created();
496        result
497    }
498
499    /// Attach to pre-existing workers and push client config.
500    ///
501    /// This is the "simple bootstrap" attach protocol:
502    /// 1. Wraps the provided addresses into a `HostMeshRef`.
503    /// 2. Snapshots `propagatable_attrs()` from the client's global config.
504    /// 3. Pushes the config to each host agent as `Source::ClientOverride`,
505    ///    awaiting per-host installation acknowledgement.
506    /// 4. Returns the owned `HostMesh`.
507    ///
508    /// HM-1 / HM-2 / HM-3 / HM-4 (see module docs): if config push
509    /// fails on any host, this returns `Err`. A successful return
510    /// means every attached host installed the propagatable config
511    /// snapshot.
512    pub async fn attach(
513        cx: &impl context::Actor,
514        id: HostMeshId,
515        addresses: Vec<ChannelAddr>,
516    ) -> crate::Result<Self> {
517        let mesh_ref = HostMeshRef::from_hosts(id, addresses);
518        let config = hyperactor_config::global::propagatable_attrs();
519        mesh_ref.push_config(cx, config).await?;
520        Ok(Self::take(mesh_ref))
521    }
522
523    /// Request a clean shutdown of all hosts owned by this
524    /// `HostMesh`.
525    ///
526    /// Uses a two-phase approach:
527    /// 1. Cast `DrainHost` and wait for every host to finish draining
528    ///    its user procs while networking stays alive.
529    /// 2. Cast `ShutdownHost` and wait for every host to acknowledge
530    ///    that its local shutdown handler has completed.
531    #[hyperactor::instrument(fields(host_mesh=self.id.to_string()))]
532    pub async fn shutdown(&mut self, cx: &impl hyperactor::context::Actor) -> anyhow::Result<()> {
533        let t0 = std::time::Instant::now();
534        tracing::info!(name = "HostMeshStatus", status = "Shutdown::Attempt");
535
536        // Phase 1: terminate all user procs while service infrastructure stays
537        // alive so forwarder flushes can complete across hosts.
538        if let Err(e) = self.current_ref.cast_drain(cx, None).await {
539            tracing::warn!(
540                name = "HostMeshStatus",
541                status = "Shutdown::Drain::Failed",
542                drain_ms = t0.elapsed().as_millis(),
543                error = %e,
544                "failed to cast DrainHost barrier"
545            );
546        }
547        let drain_ms = t0.elapsed().as_millis();
548
549        // Phase 2: request host shutdown once the drain barrier has cleared.
550        let t1 = std::time::Instant::now();
551        let shutdown_result = self.current_ref.cast_shutdown(cx).await;
552        let shutdown_ack_ms = t1.elapsed().as_millis();
553        let total_ms = t0.elapsed().as_millis();
554        if let Err(e) = shutdown_result {
555            tracing::warn!(
556                name = "HostMeshStatus",
557                status = "Shutdown::Ack::Failed",
558                drain_ms,
559                shutdown_ack_ms,
560                total_ms,
561                error = %e,
562                "failed waiting for ShutdownHost acknowledgment barrier"
563            );
564        } else {
565            tracing::info!(
566                name = "HostMeshStatus",
567                status = "Shutdown::Success",
568                drain_ms,
569                shutdown_ack_ms,
570                total_ms
571            );
572        }
573
574        self.shutdown_on_drop = false;
575        Ok(())
576    }
577
578    /// Consumes and wraps this HostMesh with a HostMeshShutdownGuard, which will
579    /// ensure shutdown is run on Drop.
580    pub fn shutdown_guard(self) -> HostMeshShutdownGuard {
581        HostMeshShutdownGuard(self)
582    }
583
584    /// Stop all hosts owned by this `HostMesh`, draining user procs
585    /// but keeping worker processes and their sockets alive for
586    /// reconnection.
587    ///
588    /// After `stop`, the same worker addresses can be passed to
589    /// [`HostMesh::attach`] to create a new mesh.
590    #[hyperactor::instrument(fields(host_mesh=self.id.to_string()))]
591    pub async fn stop(&mut self, cx: &impl hyperactor::context::Actor) -> anyhow::Result<()> {
592        let t0 = std::time::Instant::now();
593        tracing::info!(name = "HostMeshStatus", status = "Stop::Attempt");
594
595        let result = self.current_ref.cast_drain(cx, Some(self.id.clone())).await;
596        let total_ms = t0.elapsed().as_millis();
597        match result {
598            Ok(()) => {
599                tracing::info!(name = "HostMeshStatus", status = "Stop::Success", total_ms,);
600            }
601            Err(e) => tracing::warn!(
602                name = "HostMeshStatus",
603                status = "Stop::Drain::Failed",
604                total_ms,
605                error = %e,
606                "failed waiting for DrainHost acknowledgment barrier"
607            ),
608        }
609
610        // Defuse the Drop impl so it doesn't send ShutdownHost to hosts we
611        // intentionally kept alive.
612        self.shutdown_on_drop = false;
613
614        Ok(())
615    }
616}
617
618impl HostMesh {
619    /// Set the bootstrap command on the underlying `HostMeshRef`,
620    /// so that future `spawn` calls use it. Unlike
621    /// `HostMeshRef::with_bootstrap` this mutates in place,
622    /// preserving ownership.
623    pub fn set_bootstrap(&mut self, cmd: BootstrapCommand) {
624        self.current_ref = self.current_ref.clone().with_bootstrap(cmd);
625    }
626}
627
628impl Deref for HostMesh {
629    type Target = HostMeshRef;
630
631    fn deref(&self) -> &Self::Target {
632        &self.current_ref
633    }
634}
635
636impl AsRef<HostMeshRef> for HostMesh {
637    fn as_ref(&self) -> &HostMeshRef {
638        self
639    }
640}
641
642impl AsRef<HostMeshRef> for HostMeshRef {
643    fn as_ref(&self) -> &HostMeshRef {
644        self
645    }
646}
647
648/// Wrapper around HostMesh that runs shutdown on Drop.
649pub struct HostMeshShutdownGuard(pub HostMesh);
650
651impl Deref for HostMeshShutdownGuard {
652    type Target = HostMesh;
653
654    fn deref(&self) -> &HostMesh {
655        &self.0
656    }
657}
658
659impl DerefMut for HostMeshShutdownGuard {
660    fn deref_mut(&mut self) -> &mut HostMesh {
661        &mut self.0
662    }
663}
664
665impl Drop for HostMeshShutdownGuard {
666    /// Best-effort cleanup for owned host meshes on drop.
667    ///
668    /// When a `HostMesh` is dropped, it attempts to shut down all
669    /// hosts it owns:
670    /// - If a Tokio runtime is available, we spawn an ephemeral
671    ///   `Proc` + `Instance` and best-effort cast `ShutdownHost`
672    ///   through the owned hosts. This ensures that the embedded
673    ///   `BootstrapProcManager`s are dropped, and all child procs they
674    ///   spawned are killed when the cast succeeds.
675    /// - If no runtime is available, we cannot perform async cleanup
676    ///   here; in that case we log a warning and rely on kernel-level
677    ///   PDEATHSIG or the individual `BootstrapProcManager`'s `Drop`
678    ///   as the final safeguard.
679    ///
680    /// This path is **last resort**: callers should prefer explicit
681    /// [`HostMesh::shutdown`] to guarantee orderly teardown. Drop
682    /// only provides opportunistic cleanup to prevent process leaks
683    /// if shutdown is skipped.
684    fn drop(&mut self) {
685        if !self.0.shutdown_on_drop {
686            tracing::debug!(
687                name = "HostMeshStatus",
688                host_mesh = %self.0.id,
689                status = "DropCleanup::Skipped",
690                "hostmesh drop-cleanup skipped after explicit stop/shutdown"
691            );
692            return;
693        }
694
695        tracing::info!(
696            name = "HostMeshStatus",
697            host_mesh = %self.0.id,
698            status = "Dropping",
699        );
700        let current_ref = self.0.current_ref.clone();
701        let host_count = current_ref.region().num_ranks();
702
703        // Best-effort only when a Tokio runtime is available.
704        if host_count == 0 {
705            tracing::debug!(
706                host_mesh = %self.0.id,
707                "HostMesh drop cleanup skipped because no owned hosts remain"
708            );
709        } else if let Ok(handle) = tokio::runtime::Handle::try_current() {
710            let mesh_id = self.0.id.clone();
711            let span = tracing::info_span!(
712                "hostmesh_drop_cleanup",
713                host_mesh = %mesh_id,
714                hosts = host_count,
715            );
716
717            handle.spawn(
718                async move {
719                    // Spin up a tiny ephemeral proc+instance to get an
720                    // Actor context.
721                    match hyperactor::Proc::direct(
722                        ChannelTransport::Unix.any(),
723                        "hostmesh-drop".to_string(),
724                    ) {
725                        Err(e) => {
726                            tracing::warn!(
727                                error = %e,
728                                "failed to construct ephemeral Proc for drop-cleanup; \
729                                 relying on PDEATHSIG/manager Drop"
730                            );
731                        }
732                        Ok(proc) => {
733                            let client = proc.client("drop");
734                            if let Err(e) = current_ref.cast_shutdown(&client).await {
735                                tracing::warn!(
736                                    error = %e,
737                                    "drop-cleanup: failed to cast ShutdownHost"
738                                );
739                            } else {
740                                tracing::info!(
741                                    hosts = host_count,
742                                    "hostmesh drop-cleanup shutdown barrier complete"
743                                );
744                            }
745                        }
746                    }
747                }
748                .instrument(span),
749            );
750        } else {
751            // No runtime here; PDEATHSIG and manager Drop remain the
752            // last-resort safety net.
753            tracing::warn!(
754                host_mesh = %self.0.id,
755                hosts = host_count,
756                "HostMesh dropped without a Tokio runtime; skipping \
757                 best-effort shutdown. This indicates that .shutdown() \
758                 on this mesh has not been called before program exit \
759                 (perhaps due to a missing call to \
760                 'monarch.actor.shutdown_context()'?) This in turn can \
761                 lead to backtrace output due to folly SIGTERM \
762                 handlers."
763            );
764        }
765
766        tracing::info!(
767            name = "HostMeshStatus",
768            host_mesh = %self.0.id,
769            status = "Dropped",
770        );
771    }
772}
773
774/// Helper: legacy shim for error types that still require
775/// RankedValues<Status>. TODO(shayne-fletcher): Delete this
776/// shim once Error::ActorSpawnError carries a StatusMesh
777/// (ValueMesh<Status>) directly. At that point, use the mesh
778/// as-is and remove `mesh_to_rankedvalues_*` calls below.
779/// is_sentinel should return true if the value matches a previous filled in
780/// value. If the input value matches the sentinel, it gets replaced with the
781/// default.
782pub(crate) fn mesh_to_rankedvalues_with_default<T, F>(
783    mesh: &ValueMesh<T>,
784    default: T,
785    is_sentinel: F,
786    len: usize,
787) -> RankedValues<T>
788where
789    T: Eq + Clone + 'static,
790    F: Fn(&T) -> bool,
791{
792    let mut out = RankedValues::from((0..len, default));
793    for (i, s) in mesh.values().enumerate() {
794        if !is_sentinel(&s) {
795            out.merge_from(RankedValues::from((i..i + 1, s)));
796        }
797    }
798    out
799}
800
801/// A non-owning reference to a mesh of hosts.
802///
803/// Logically, this is a data structure that contains a set of ranked
804/// hosts organized into a [`Region`]. `HostMeshRef`s can be sliced to
805/// produce new references that contain a subset of the hosts in the
806/// original mesh.
807///
808/// `HostMeshRef`s have a concrete syntax, implemented by its
809/// `Display` and `FromStr` implementations.
810///
811/// This type does **not** control lifecycle. It only describes the
812/// topology of hosts. To take ownership and perform deterministic
813/// teardown, use [`HostMesh::take`], which returns an owned
814/// [`HostMesh`] that guarantees cleanup on `shutdown()` or `Drop`.
815///
816/// Cloning this type does not confer ownership. If a corresponding
817/// owned [`HostMesh`] shuts down the hosts, operations via a cloned
818/// `HostMeshRef` may fail because the hosts are no longer running.
819#[derive(Debug, Clone, Named, Serialize, Deserialize, PartialEq, Eq, Hash)]
820pub struct HostMeshRef {
821    id: HostMeshId,
822    host_agent_mesh: ActorMeshRef<HostAgent>,
823    /// Uniform bootstrap command to use when spawning procs on this
824    /// mesh. When `None`, each host agent uses its own default
825    /// command. Per-proc overrides are supplied at spawn time via the
826    /// `per_rank_bootstrap` parameter on [`HostMeshRef::spawn`].
827    #[serde(default)]
828    pub bootstrap_command: Option<BootstrapCommand>,
829}
830
831/// A function that produces a per-rank [`BootstrapCommand`], called
832/// once per proc during spawn with that proc's [`view::Point`] over
833/// the combined `host_extent ⊕ per_host` extent. Returning an error
834/// aborts the spawn with that error surfaced as a configuration
835/// failure.
836pub type PerRankBootstrapFn = dyn Fn(view::Point) -> anyhow::Result<BootstrapCommand> + Send + Sync;
837
838wirevalue::register_type!(HostMeshRef);
839
840impl HostMeshRef {
841    /// Create a new (raw) HostMeshRef from the provided region and associated
842    /// ranks, which must match in cardinality.
843    #[allow(clippy::result_large_err)]
844    fn new(id: HostMeshId, region: Region, host_addrs: Vec<ChannelAddr>) -> crate::Result<Self> {
845        if region.num_ranks() != host_addrs.len() {
846            return Err(crate::Error::InvalidRankCardinality {
847                expected: region.num_ranks(),
848                actual: host_addrs.len(),
849            });
850        }
851        let host_agent_mesh = Self::host_agent_mesh_ref_from_addrs(&region, host_addrs)?;
852        Ok(Self {
853            id,
854            host_agent_mesh,
855            bootstrap_command: None,
856        })
857    }
858
859    /// Create a new HostMeshRef from an arbitrary set of hosts. This is meant to
860    /// enable extrinsic bootstrapping.
861    pub fn from_hosts(id: HostMeshId, host_addrs: Vec<ChannelAddr>) -> Self {
862        let region = extent!(hosts = host_addrs.len()).into();
863        let host_agent_mesh = Self::host_agent_mesh_ref_from_addrs(&region, host_addrs)
864            .expect("host rank cardinality must match generated region");
865        Self {
866            id,
867            host_agent_mesh,
868            bootstrap_command: None,
869        }
870    }
871
872    /// Create a new HostMeshRef from an arbitrary set of host mesh agents.
873    pub fn from_host_agents(
874        id: HostMeshId,
875        agents: Vec<ActorRef<HostAgent>>,
876    ) -> crate::Result<Self> {
877        let region = extent!(hosts = agents.len()).into();
878        let host_agent_mesh = Self::host_agent_mesh_ref_from_agents(&region, agents)?;
879        Ok(Self {
880            id,
881            host_agent_mesh,
882            bootstrap_command: None,
883        })
884    }
885
886    /// Create a unit HostMeshRef from a host mesh agent.
887    pub fn from_host_agent(id: HostMeshId, agent: ActorRef<HostAgent>) -> crate::Result<Self> {
888        let region = Extent::unity().into();
889        // Canonicalize to the host's base dial address (as the deleted `HostRef`
890        // did via `into_dial_addr`). A client-attached `this_host` agent's
891        // location is `Via(client_gateway, addr)`; keeping that via hop makes it
892        // propagate into the locations of procs spawned on the host
893        // (`Via(child, Via(client_gateway, addr))`), which a *remote* host's
894        // gateway cannot peel — breaking reverse remote->local reachability. The
895        // bare dial address yields the single-hop `Via(child, addr)` the peer
896        // network routes both ways. Only the unit (this_host) path needs this;
897        // multi-host/allocated meshes carry their own dial addresses already.
898        let agent = host_agent_ref(agent.actor_addr().proc_addr().addr().clone());
899        let host_agent_mesh = Self::host_agent_mesh_ref_from_agents(&region, vec![agent])?;
900        Ok(Self {
901            id,
902            host_agent_mesh,
903            bootstrap_command: None,
904        })
905    }
906
907    /// Return a new `HostMeshRef` that will use `cmd` when spawning procs,
908    /// overriding the host agent's default bootstrap command.
909    pub fn with_bootstrap(self, cmd: BootstrapCommand) -> Self {
910        Self {
911            bootstrap_command: Some(cmd),
912            ..self
913        }
914    }
915
916    fn host_agent_mesh_ref_from_addrs(
917        region: &Region,
918        host_addrs: Vec<ChannelAddr>,
919    ) -> crate::Result<ActorMeshRef<HostAgent>> {
920        let agents = host_addrs.into_iter().map(host_agent_ref).collect();
921        Self::host_agent_mesh_ref_from_agents(region, agents)
922    }
923
924    fn host_agent_mesh_ref_from_agents(
925        region: &Region,
926        agents: Vec<ActorRef<HostAgent>>,
927    ) -> crate::Result<ActorMeshRef<HostAgent>> {
928        let members = Arc::new(
929            agents
930                .into_iter()
931                .map(|agent| agent.actor_addr().clone())
932                .collect_mesh::<ValueMesh<_>>(region.clone())
933                .map_err(|error| crate::Error::ConfigurationError(error.into()))?,
934        );
935
936        Ok(ActorMeshRef::new(
937            ActorMeshId::singleton(Label::strip(host_agent::HOST_MESH_AGENT_ACTOR_NAME)),
938            // The host-agent mesh is not backed by a user proc mesh.
939            None,
940            region.clone(),
941            None,
942            members,
943        ))
944    }
945
946    async fn cast_drain(
947        &self,
948        cx: &impl context::Actor,
949        host_mesh_id: Option<HostMeshId>,
950    ) -> anyhow::Result<()> {
951        let region = self.region().clone();
952        let num_hosts = region.num_ranks();
953        if num_hosts == 0 {
954            return Ok(());
955        }
956
957        // Each host reports a single-rank `Stopped` overlay once it has
958        // drained; reduce them into a full StatusMesh so we can tell which
959        // hosts (if any) never acknowledged.
960        let (reply, rx) = cx.mailbox().open_accum_port_opts(
961            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
962            StreamingReducerOpts {
963                max_update_interval: Some(std::time::Duration::from_millis(50)),
964                initial_update_interval: None,
965            },
966        );
967        let mut reply = reply.bind();
968        reply.return_undeliverable(false);
969
970        let terminate_timeout =
971            hyperactor_config::global::get(crate::bootstrap::MESH_TERMINATE_TIMEOUT);
972
973        self.host_agent_mesh.cast(
974            cx,
975            host_agent::DrainHost {
976                timeout: terminate_timeout,
977                max_in_flight: hyperactor_config::global::get(
978                    crate::bootstrap::MESH_TERMINATE_CONCURRENCY,
979                )
980                .clamp(1, 256),
981                host_mesh_id,
982                rank: Default::default(),
983                reply,
984            },
985        )?;
986
987        // Hosts only report after a (timeout-bounded) `terminate_children`, so
988        // the barrier's max-idle must exceed the per-host drain timeout.
989        let barrier_timeout = terminate_timeout.saturating_add(std::time::Duration::from_secs(30));
990
991        match GetRankStatus::wait(rx, num_hosts, barrier_timeout, region).await {
992            Ok(_) => Ok(()),
993            Err(partial) => {
994                let missing: Vec<usize> = partial
995                    .values()
996                    .enumerate()
997                    .filter(|(_, status)| status.is_not_exist())
998                    .map(|(rank, _)| rank)
999                    .collect();
1000                anyhow::bail!(
1001                    "DrainHost barrier timed out after {:?}; {} of {} hosts did not acknowledge (host ranks {:?})",
1002                    barrier_timeout,
1003                    missing.len(),
1004                    num_hosts,
1005                    missing,
1006                )
1007            }
1008        }
1009    }
1010
1011    async fn cast_shutdown(&self, cx: &impl context::Actor) -> anyhow::Result<()> {
1012        let num_hosts = self.region().num_ranks();
1013        if num_hosts == 0 {
1014            return Ok(());
1015        }
1016
1017        // Each host replies its own rank directly once shutdown work is done.
1018        // `ShutdownHost` acks cannot be tree-reduced (hosts exit right after
1019        // acking), so we collect one direct reply per host on a plain
1020        // multi-receive port rather than a reduced barrier, and track which
1021        // ranks acknowledged so we can report the hosts that didn't.
1022        let (ack, mut rx) = cx.mailbox().open_port::<usize>();
1023        // Bind `.unsplit()`: every `PortRef` becomes a multipart part the cast
1024        // split loop would otherwise tree-reduce. `ShutdownHost` acks must reach
1025        // the caller directly (hosts exit right after acking), so mark this port
1026        // unsplit to keep it out of the reduction tree.
1027        let mut ack = ack.bind().unsplit();
1028        ack.return_undeliverable(false);
1029
1030        let terminate_timeout =
1031            hyperactor_config::global::get(crate::bootstrap::MESH_TERMINATE_TIMEOUT);
1032
1033        self.host_agent_mesh.cast(
1034            cx,
1035            host_agent::ShutdownHost {
1036                timeout: terminate_timeout,
1037                max_in_flight: hyperactor_config::global::get(
1038                    crate::bootstrap::MESH_TERMINATE_CONCURRENCY,
1039                )
1040                .clamp(1, 256),
1041                rank: Default::default(),
1042                ack,
1043            },
1044        )?;
1045
1046        // Hosts only reply after a (timeout-bounded) termination pass, so the
1047        // per-reply wait must exceed the per-host terminate timeout.
1048        let barrier_timeout = terminate_timeout.saturating_add(std::time::Duration::from_secs(30));
1049
1050        let mut acked = std::collections::HashSet::new();
1051
1052        while acked.len() < num_hosts {
1053            match tokio::time::timeout(barrier_timeout, rx.recv()).await {
1054                Ok(Ok(rank)) => {
1055                    acked.insert(rank);
1056                }
1057                Ok(Err(err)) => return Err(anyhow::Error::from(err)),
1058                Err(_) => {
1059                    let missing: Vec<usize> =
1060                        (0..num_hosts).filter(|r| !acked.contains(r)).collect();
1061
1062                    anyhow::bail!(
1063                        "ShutdownHost barrier timed out after {:?}; {} of {} hosts did not acknowledge shutdown (host ranks {:?})",
1064                        barrier_timeout,
1065                        missing.len(),
1066                        num_hosts,
1067                        missing,
1068                    );
1069                }
1070            }
1071        }
1072        Ok(())
1073    }
1074
1075    /// Cast `StreamState<ProcState>` to every host agent so each host streams
1076    /// its procs' state back through the cast tree (fanning in at cast actor 0)
1077    /// instead of every host dialing the subscriber directly.
1078    pub(crate) fn cast_stream_state(
1079        &self,
1080        cx: &impl context::Actor,
1081        id: ResourceId,
1082        subscriber: hyperactor::PortRef<resource::State<ProcState>>,
1083    ) -> anyhow::Result<()> {
1084        Ok(self
1085            .host_agent_mesh
1086            .cast(cx, resource::StreamState::<ProcState> { id, subscriber })?)
1087    }
1088
1089    /// Returns the host entries as `(addr_string, ActorRef<HostAgent>)` pairs.
1090    /// Used by `MeshAdminAgent::effective_hosts()` to merge C into the
1091    /// admin's host list (see CH-1 in mesh_admin module doc).
1092    pub(crate) fn host_entries(&self) -> Vec<(String, ActorRef<HostAgent>)> {
1093        self.host_agent_mesh
1094            .values()
1095            .map(|agent| (agent.actor_addr().addr().to_string(), agent.clone()))
1096            .collect()
1097    }
1098
1099    /// Push client config to all host agents in this mesh via the HostAgent
1100    /// actor mesh.
1101    ///
1102    /// Each host installs the attrs as `Source::ClientOverride`.
1103    /// Idempotent: sending the same attrs twice replaces the layer.
1104    ///
1105    /// Implements HM-1, HM-2, HM-3, and HM-4 (see module docs): returns
1106    /// `Err(ConfigPushError)` if the acknowledgement barrier does not complete.
1107    /// Each host acks its own ordinal via a reduced status barrier, so a
1108    /// timeout names exactly the hosts that did not install. Only a synchronous
1109    /// failure to initiate the cast at all is reported mesh-wide.
1110    pub(crate) async fn push_config(
1111        &self,
1112        cx: &impl context::Actor,
1113        attrs: hyperactor_config::attrs::Attrs,
1114    ) -> Result<(), ConfigPushError> {
1115        let timeout = hyperactor_config::global::get(crate::config::MESH_ATTACH_CONFIG_TIMEOUT);
1116        let host_addrs = self.host_addrs();
1117        let num_hosts = host_addrs.len();
1118
1119        if num_hosts == 0 {
1120            tracing::info!(success = 0, "push_config complete");
1121            return Ok(());
1122        }
1123
1124        fn failures_for_host_addrs(
1125            host_addrs: &[ChannelAddr],
1126            mut make_failure: impl FnMut() -> ConfigPushFailure,
1127        ) -> ConfigPushError {
1128            ConfigPushError {
1129                failures: host_addrs
1130                    .iter()
1131                    .cloned()
1132                    .map(|host_addr| (host_addr, make_failure()))
1133                    .collect(),
1134            }
1135        }
1136
1137        let region = self.region().clone();
1138
1139        // Each host posts a single-rank `Running` overlay at its ordinal once
1140        // it has installed the config; reduce them into a StatusMesh barrier so
1141        // a timeout names exactly which hosts (if any) never acknowledged.
1142        let (reply, rx) = cx.mailbox().open_accum_port_opts(
1143            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1144            StreamingReducerOpts {
1145                max_update_interval: Some(std::time::Duration::from_millis(50)),
1146                initial_update_interval: None,
1147            },
1148        );
1149        let mut reply = reply.bind();
1150        reply.return_undeliverable(false);
1151
1152        // HM-3: an unreachable host does not bounce the outbound request into
1153        // the caller's `Undeliverable<MessageEnvelope>` handler — it surfaces as
1154        // a channel-level `BrokenLink` (logged at debug), and the missing ack is
1155        // detected by the barrier timeout below. `return_undeliverable(false)`
1156        // above covers the ack direction. Covered by
1157        // `test_attach_fails_closed_on_unreachable_host`.
1158        if let Err(err) = self.host_agent_mesh.cast(
1159            cx,
1160            host_agent::SetClientConfig {
1161                attrs,
1162                rank: Default::default(),
1163                reply,
1164            },
1165        ) {
1166            let error = err.to_string();
1167
1168            tracing::warn!(error = %error, "config push cast failed");
1169
1170            // The collective cast could not be initiated at all (a synchronous
1171            // send failure, before any host was contacted) — genuinely
1172            // mesh-wide, so report every host.
1173            return Err(failures_for_host_addrs(&host_addrs, || {
1174                ConfigPushFailure::CastFailed(error.clone())
1175            }));
1176        }
1177
1178        match GetRankStatus::wait(rx, num_hosts, timeout, region).await {
1179            Ok(_) => {
1180                tracing::info!(success = num_hosts, "push_config complete");
1181                Ok(())
1182            }
1183            Err(partial) => {
1184                // Ranks still at `NotExist` never acknowledged within the
1185                // timeout (or the reply channel closed). Report exactly those
1186                // hosts, preserving per-host identity (HM-4).
1187                let failures: Vec<(ChannelAddr, ConfigPushFailure)> = partial
1188                    .values()
1189                    .enumerate()
1190                    .filter(|(_, status)| status.is_not_exist())
1191                    .map(|(rank, _)| (host_addrs[rank].clone(), ConfigPushFailure::ReplyTimedOut))
1192                    .collect();
1193
1194                tracing::info!(
1195                    success = num_hosts - failures.len(),
1196                    failed = failures.len(),
1197                    "push_config complete with failures"
1198                );
1199
1200                Err(ConfigPushError { failures })
1201            }
1202        }
1203    }
1204
1205    /// Spawn a ProcMesh onto this host mesh. The per_host extent specifies the shape
1206    /// of the procs to spawn on each host.
1207    ///
1208    /// `proc_bind`, when provided, is a per-process CPU/NUMA binding
1209    /// configuration. Its length must equal the number of ranks in
1210    /// `per_host`. Each entry maps binding keys (`cpunodebind`,
1211    /// `membind`, `physcpubind`, `cpus`) to their values.
1212    /// Only takes effect when running on Linux.
1213    ///
1214    /// `per_rank_bootstrap`, when provided, is a function called once
1215    /// per proc to produce that proc's [`BootstrapCommand`]. The
1216    /// function receives a [`view::Point`] over the combined
1217    /// `host_extent ⊕ per_host` extent. Its return value takes
1218    /// precedence over `self.bootstrap_command` for that proc only.
1219    ///
1220    /// Spawn is issued as a single `SpawnProcs` cast — each HostAgent spawns all
1221    /// of its per-host proc slots — then fenced with an accumulated status
1222    /// barrier so returned procs are addressable and ready.
1223    #[allow(clippy::result_large_err)]
1224    pub async fn spawn<C: context::Actor>(
1225        &self,
1226        cx: &C,
1227        name: &str,
1228        per_host: Extent,
1229        proc_bind: Option<Vec<ProcBind>>,
1230        per_rank_bootstrap: Option<Box<PerRankBootstrapFn>>,
1231    ) -> crate::Result<ProcMesh>
1232    where
1233        C::A: Handler<MeshFailure>,
1234    {
1235        self.spawn_inner(
1236            cx,
1237            ProcMeshId::instance(Label::strip(name)),
1238            per_host,
1239            proc_bind,
1240            per_rank_bootstrap,
1241        )
1242        .await
1243    }
1244
1245    #[hyperactor::instrument(fields(host_mesh=self.id.to_string(), proc_mesh=proc_mesh_id.to_string()))]
1246    async fn spawn_inner<C: context::Actor>(
1247        &self,
1248        cx: &C,
1249        proc_mesh_id: ProcMeshId,
1250        per_host: Extent,
1251        proc_bind: Option<Vec<ProcBind>>,
1252        per_rank_bootstrap: Option<Box<PerRankBootstrapFn>>,
1253    ) -> crate::Result<ProcMesh>
1254    where
1255        C::A: Handler<MeshFailure>,
1256    {
1257        tracing::info!(name = "HostMeshStatus", status = "ProcMesh::Spawn::Attempt");
1258        tracing::info!(name = "ProcMeshStatus", status = "Spawn::Attempt",);
1259        let result = self
1260            .spawn_inner_inner(cx, proc_mesh_id, per_host, proc_bind, per_rank_bootstrap)
1261            .await;
1262        match &result {
1263            Ok(_) => {
1264                tracing::info!(name = "HostMeshStatus", status = "ProcMesh::Spawn::Success");
1265                tracing::info!(name = "ProcMeshStatus", status = "Spawn::Success");
1266            }
1267            Err(error) => {
1268                tracing::error!(name = "HostMeshStatus", status = "ProcMesh::Spawn::Failed", %error);
1269                tracing::error!(name = "ProcMeshStatus", status = "Spawn::Failed", %error);
1270            }
1271        }
1272        result
1273    }
1274
1275    async fn spawn_inner_inner<C: context::Actor>(
1276        &self,
1277        cx: &C,
1278        proc_mesh_id: ProcMeshId,
1279        per_host: Extent,
1280        proc_bind: Option<Vec<ProcBind>>,
1281        per_rank_bootstrap: Option<Box<PerRankBootstrapFn>>,
1282    ) -> crate::Result<ProcMesh>
1283    where
1284        C::A: Handler<MeshFailure>,
1285    {
1286        let per_host_labels = per_host.labels().iter().collect::<HashSet<_>>();
1287        let host_labels = self.region().labels().iter().collect::<HashSet<_>>();
1288        if !per_host_labels
1289            .intersection(&host_labels)
1290            .collect::<Vec<_>>()
1291            .is_empty()
1292        {
1293            return Err(crate::Error::ConfigurationError(anyhow::anyhow!(
1294                "per_host dims overlap with existing dims when spawning proc mesh"
1295            )));
1296        }
1297        if let Some(proc_bind) = proc_bind.as_ref()
1298            && proc_bind.len() != per_host.num_ranks()
1299        {
1300            return Err(crate::Error::ConfigurationError(anyhow::anyhow!(
1301                "proc_bind length does not match per_host extent"
1302            )));
1303        }
1304
1305        let extent = self
1306            .region()
1307            .extent()
1308            .concat(&per_host)
1309            .map_err(|err| crate::Error::ConfigurationError(err.into()))?;
1310
1311        let region: Region = extent.clone().into();
1312
1313        tracing::info!(
1314            name = "ProcMeshStatus",
1315            status = "Spawn::Attempt",
1316            %region,
1317            "spawning proc mesh"
1318        );
1319
1320        let mut procs = Vec::new();
1321        let num_ranks = region.num_ranks();
1322        // Accumulator outputs full StatusMesh snapshots; seed with
1323        // NotExist.
1324        let (port, rx) = cx.mailbox().open_accum_port_opts(
1325            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1326            StreamingReducerOpts {
1327                max_update_interval: Some(Duration::from_millis(50)),
1328                initial_update_interval: None,
1329            },
1330        );
1331
1332        // Build each proc's `ProcRef` up front: the caller derives the same
1333        // id/rank (`host_agent::proc_name(&proc_mesh_id, create_rank)`) that
1334        // each HostAgent will, so the refs are ready before the cast lands. A single
1335        // `SpawnProcs` cast (below) then has each HostAgent spawn all of its
1336        // per-host slots, replying with status overlays to drive the readiness
1337        // barrier.
1338        let mut proc_names = Vec::new();
1339        let client_config_override = hyperactor_config::global::propagatable_attrs();
1340        for (host_rank, agent) in self.host_agent_mesh.values().enumerate() {
1341            for per_host_rank in 0..per_host.num_ranks() {
1342                let create_rank = per_host.num_ranks() * host_rank + per_host_rank;
1343                let proc_name = host_agent::proc_name(&proc_mesh_id, create_rank);
1344                proc_names.push(proc_name.clone());
1345                let proc_id = named_proc_on_host(&agent, &proc_name);
1346                let proc_agent =
1347                    ActorRef::attest(proc_id.actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME));
1348                tracing::info!(
1349                    name = "ProcMeshStatus",
1350                    status = "Spawn::CreatingProc",
1351                    %proc_id,
1352                    rank = create_rank,
1353                );
1354                procs.push(crate::proc_mesh::ProcRef::new(
1355                    proc_id,
1356                    create_rank,
1357                    // TODO: specify or retrieve from state instead, to avoid attestation.
1358                    proc_agent,
1359                ));
1360            }
1361        }
1362
1363        let mut reply_port = port.bind();
1364
1365        reply_port.return_undeliverable(false);
1366
1367        let total_procs = self.region().num_ranks() * per_host.num_ranks();
1368
1369        let bootstrap_commands = match per_rank_bootstrap.as_ref() {
1370            Some(per_rank_bootstrap) => Some(
1371                (0..total_procs)
1372                    .map(|create_rank| {
1373                        per_rank_bootstrap(
1374                            extent
1375                                .point_of_rank(create_rank)
1376                                .expect("rank in combined extent"),
1377                        )
1378                        .map(Some)
1379                        .map_err(crate::Error::ConfigurationError)
1380                    })
1381                    .collect::<crate::Result<Vec<_>>>()?,
1382            ),
1383            None => None,
1384        };
1385
1386        // One cast: each HostAgent spawns all `num_per_host` of its proc slots,
1387        // deriving each proc's id/rank from its stamped host rank.
1388        self.host_agent_mesh.cast(
1389            cx,
1390            host_agent::SpawnProcs {
1391                rank: resource::Rank::default(),
1392                proc_mesh_id: proc_mesh_id.clone(),
1393                num_per_host: per_host.num_ranks(),
1394                client_config_override,
1395                host_mesh_id: Some(self.id.clone()),
1396                default_bootstrap_command: self.bootstrap_command.clone(),
1397                proc_bind,
1398                bootstrap_commands,
1399                status_reply: Some(reply_port),
1400            },
1401        )?;
1402
1403        let start_time = tokio::time::Instant::now();
1404
1405        // Wait on accumulated StatusMesh snapshots until complete or
1406        // timeout.
1407        match GetRankStatus::wait(
1408            rx,
1409            num_ranks,
1410            hyperactor_config::global::get(PROC_SPAWN_MAX_IDLE),
1411            region.clone(), // fallback mesh if nothing arrives
1412        )
1413        .await
1414        {
1415            Ok(statuses) => {
1416                // If any rank is terminating, surface a
1417                // ProcCreationError pointing at that rank.
1418                if let Some((rank, status)) = statuses
1419                    .values()
1420                    .enumerate()
1421                    .find(|(_, s)| s.is_terminating())
1422                {
1423                    let proc_name = &proc_names[rank];
1424                    let host_rank = rank / per_host.num_ranks();
1425                    let mesh_agent = self
1426                        .host_agent_mesh
1427                        .get(host_rank)
1428                        .expect("host rank must be in host agent mesh")
1429                        .clone();
1430                    let (reply_tx, mut reply_rx) = cx.mailbox().open_port();
1431                    let mut reply_tx = reply_tx.bind();
1432                    // If this proc dies or some other issue renders the reply undeliverable,
1433                    // the reply does not need to be returned to the sender.
1434                    reply_tx.return_undeliverable(false);
1435                    mesh_agent.post(
1436                        cx,
1437                        resource::GetState {
1438                            id: proc_name.clone(),
1439                            reply: reply_tx,
1440                        },
1441                    );
1442                    let state = match tokio::time::timeout(
1443                        hyperactor_config::global::get(PROC_SPAWN_MAX_IDLE),
1444                        reply_rx.recv(),
1445                    )
1446                    .await
1447                    {
1448                        Ok(Ok(state)) => state,
1449                        _ => resource::State {
1450                            id: proc_name.clone(),
1451                            status,
1452                            state: None,
1453                            generation: 0,
1454                            timestamp: std::time::SystemTime::now(),
1455                        },
1456                    };
1457
1458                    tracing::error!(
1459                        name = "ProcMeshStatus",
1460                        status = "Spawn::GetRankStatus",
1461                        rank = host_rank,
1462                        "rank {} is terminating with state: {}",
1463                        host_rank,
1464                        state
1465                    );
1466
1467                    return Err(crate::Error::ProcCreationError {
1468                        state: Box::new(state),
1469                        host_rank,
1470                        mesh_agent,
1471                    });
1472                }
1473            }
1474            Err(complete) => {
1475                tracing::error!(
1476                    name = "ProcMeshStatus",
1477                    status = "Spawn::GetRankStatus",
1478                    "timeout after {:?} when waiting for procs being created",
1479                    hyperactor_config::global::get(PROC_SPAWN_MAX_IDLE),
1480                );
1481                // Fill remaining ranks with a timeout status via the
1482                // legacy shim.
1483                let legacy = mesh_to_rankedvalues_with_default(
1484                    &complete,
1485                    Status::Timeout(start_time.elapsed()),
1486                    Status::is_not_exist,
1487                    num_ranks,
1488                );
1489                return Err(crate::Error::ProcSpawnError { statuses: legacy });
1490            }
1491        }
1492
1493        let mut mesh = ProcMesh::create(proc_mesh_id, extent, self.clone(), procs);
1494        if let Ok(ref mut mesh) = mesh {
1495            // Spawn a unique mesh controller for each proc mesh, so the type of the
1496            // mesh can be preserved. Procs reached a non-terminating state above,
1497            // so seed the controller's per-rank statuses as Running.
1498            let mesh_ref: ProcMeshRef = (**mesh).clone();
1499            let region = ndslice::view::Ranked::region(&mesh_ref).clone();
1500            let initial_statuses: crate::ValueMesh<resource::Status> =
1501                std::iter::repeat_n(resource::Status::Running, region.num_ranks())
1502                    .collect_mesh::<crate::ValueMesh<_>>(region)?;
1503            let controller = ProcMeshController::new(mesh_ref, None, None, initial_statuses);
1504            // hyperactor::proc AI-3: controller name must include mesh
1505            // identity for proc-wide ActorAddr uniqueness.
1506            let controller_name = format!("{}_{}", PROC_MESH_CONTROLLER_NAME, mesh.id());
1507            let controller_handle = cx.spawn_with_label(&controller_name, controller);
1508            // Bind the actor's well-known ports (Signal, IntrospectMessage,
1509            // Undeliverable). Without this, the controller's mailbox has no
1510            // port entries and messages (including introspection queries)
1511            // are returned as undeliverable.
1512            let controller_ref: ActorRef<ProcMeshController> = controller_handle.bind();
1513            mesh.set_controller(Some(controller_ref));
1514        }
1515        mesh
1516    }
1517
1518    /// The identity of the referenced host mesh.
1519    pub fn id(&self) -> &HostMeshId {
1520        &self.id
1521    }
1522
1523    /// The `ActorMesh<HostAgent>` backing this host mesh. Casting to it routes
1524    /// through the host mesh's cast tree (root = its cast actor 0), so replies
1525    /// to a bound port reduce up the tree instead of every host dialing the
1526    /// caller directly.
1527    pub(crate) fn agent_mesh(&self) -> &ActorMeshRef<HostAgent> {
1528        &self.host_agent_mesh
1529    }
1530
1531    /// The host channel addresses in rank order.
1532    pub fn host_addrs(&self) -> Vec<ChannelAddr> {
1533        self.host_agent_mesh
1534            .values()
1535            .map(|agent| agent.actor_addr().addr().clone())
1536            .collect()
1537    }
1538
1539    /// Stop every proc in this proc mesh.
1540    ///
1541    /// On success returns the final per-rank `StatusMesh`, in which every
1542    /// rank is guaranteed to be `is_terminated()` (`Stopped`, `Failed`, or
1543    /// `Timeout`). Callers can apply these statuses to controller health
1544    /// state so that subsequent `GetState` queries reflect reality.
1545    ///
1546    /// Returns `crate::Error::ProcMeshStopError` if any rank did not reach
1547    /// a terminal state within `PROC_STOP_MAX_IDLE`; the error carries the
1548    /// best-known per-rank statuses for the same purpose.
1549    #[hyperactor::instrument(fields(host_mesh=self.id.to_string(), proc_mesh=proc_mesh_id.to_string()))]
1550    pub(crate) async fn stop_proc_mesh(
1551        &self,
1552        cx: &impl hyperactor::context::Actor,
1553        proc_mesh_id: &ProcMeshId,
1554        procs: impl IntoIterator<Item = ProcAddr>,
1555        region: Region,
1556        reason: String,
1557    ) -> crate::Result<crate::StatusMesh> {
1558        // Accumulator outputs full StatusMesh snapshots; seed with
1559        // NotExist.
1560        let mut proc_names = Vec::new();
1561        let num_ranks = region.num_ranks();
1562        // Accumulator outputs full StatusMesh snapshots; seed with
1563        // NotExist.
1564        let (port, rx) = cx.mailbox().open_accum_port_opts(
1565            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1566            StreamingReducerOpts {
1567                max_update_interval: Some(Duration::from_millis(50)),
1568                initial_update_interval: None,
1569            },
1570        );
1571        for proc_id in procs.into_iter() {
1572            let addr = proc_id.addr().clone();
1573            // The name stored in HostAgent is not the same as the
1574            // one stored in the ProcMesh. We instead take each proc id
1575            // and map it to that particular agent.
1576            let proc_resource_id = ResourceId::new(proc_id.uid().clone(), proc_id.label().cloned());
1577            proc_names.push(proc_resource_id.clone());
1578
1579            // Note that we don't send 1 message per host agent, we send 1 message
1580            // per proc.
1581            let host_agent = host_agent_ref(addr);
1582            host_agent.post(
1583                cx,
1584                resource::Stop {
1585                    id: proc_resource_id.clone(),
1586                    reason: reason.clone(),
1587                },
1588            );
1589            host_agent
1590                .wait_rank_status(cx, proc_resource_id, Status::Stopped, port.bind())
1591                .await
1592                .map_err(|e| crate::Error::CallError(host_agent.actor_addr().clone(), e))?;
1593
1594            tracing::info!(
1595                name = "ProcMeshStatus",
1596                %proc_id,
1597                status = "Stop::Sent",
1598            );
1599        }
1600        tracing::info!(
1601            name = "HostMeshStatus",
1602            status = "ProcMesh::Stop::Sent",
1603            "sending Stop to proc mesh for {} procs: {}",
1604            proc_names.len(),
1605            proc_names
1606                .iter()
1607                .map(|n| n.to_string())
1608                .collect::<Vec<_>>()
1609                .join(", ")
1610        );
1611
1612        let start_time = tokio::time::Instant::now();
1613
1614        match GetRankStatus::wait(
1615            rx,
1616            num_ranks,
1617            hyperactor_config::global::get(PROC_STOP_MAX_IDLE),
1618            region.clone(), // fallback mesh if nothing arrives
1619        )
1620        .await
1621        {
1622            Ok(statuses) => {
1623                let all_stopped = statuses.values().all(|s| s.is_terminated());
1624                if !all_stopped {
1625                    let legacy = mesh_to_rankedvalues_with_default(
1626                        &statuses,
1627                        Status::NotExist,
1628                        Status::is_not_exist,
1629                        num_ranks,
1630                    );
1631                    tracing::error!(
1632                        name = "ProcMeshStatus",
1633                        status = "FailedToStop",
1634                        "failed to terminate proc mesh: {:?}",
1635                        statuses,
1636                    );
1637                    return Err(crate::Error::ProcMeshStopError { statuses: legacy });
1638                }
1639                tracing::info!(name = "ProcMeshStatus", status = "Stopped");
1640                Ok(statuses)
1641            }
1642            Err(complete) => {
1643                // Fill remaining ranks with a timeout status via the
1644                // legacy shim.
1645                let legacy = mesh_to_rankedvalues_with_default(
1646                    &complete,
1647                    Status::Timeout(start_time.elapsed()),
1648                    Status::is_not_exist,
1649                    num_ranks,
1650                );
1651                tracing::error!(
1652                    name = "ProcMeshStatus",
1653                    status = "StoppingTimeout",
1654                    "failed to terminate proc mesh {} before timeout: {:?}",
1655                    proc_mesh_id,
1656                    legacy,
1657                );
1658                Err(crate::Error::ProcMeshStopError { statuses: legacy })
1659            }
1660        }
1661    }
1662}
1663
1664/// An ordered set of host entries, deduplicated by `HostAgent` `ActorAddr`
1665/// in first-seen order.
1666///
1667/// Insertion is idempotent by construction — SA-3 (dedup by ActorAddr)
1668/// is a property of this type, not a comment on careful control flow.
1669/// First-seen order is preserved: the first occurrence of a given
1670/// ActorAddr wins; subsequent duplicates are silently dropped.
1671struct HostSet {
1672    seen: HashSet<ActorAddr>,
1673    entries: Vec<(String, ActorRef<HostAgent>)>,
1674}
1675
1676impl HostSet {
1677    fn new() -> Self {
1678        Self {
1679            seen: HashSet::new(),
1680            entries: Vec::new(),
1681        }
1682    }
1683
1684    /// Insert a host entry. No-op if `ActorAddr` already present (SA-3).
1685    /// First-seen order is preserved.
1686    fn insert(&mut self, addr: String, agent_ref: ActorRef<HostAgent>) {
1687        if self.seen.insert(agent_ref.actor_addr().clone()) {
1688            self.entries.push((addr, agent_ref));
1689        }
1690    }
1691
1692    /// Extend from a `HostMeshRef`. SA-3 applies per entry.
1693    fn extend_from_mesh(&mut self, mesh: &HostMeshRef) {
1694        for (addr, agent) in mesh.host_entries() {
1695            self.insert(addr, agent);
1696        }
1697    }
1698
1699    fn into_vec(self) -> Vec<(String, ActorRef<HostAgent>)> {
1700        self.entries
1701    }
1702}
1703
1704/// Ordered union of hosts from meshes and optional client host
1705/// entries, deduplicated by `HostAgent` `ActorAddr` in first-seen
1706/// order.
1707///
1708/// SA-3 dedup and SA-6 client-host merge are structural properties
1709/// of [`HostSet`], not invariants on this function's control flow.
1710fn aggregate_hosts(
1711    meshes: &[impl AsRef<HostMeshRef>],
1712    client_host_entries: Option<Vec<(String, ActorRef<HostAgent>)>>,
1713) -> Vec<(String, ActorRef<HostAgent>)> {
1714    let mut set = HostSet::new();
1715
1716    // SA-3: dedup across all mesh hosts in first-seen order.
1717    for mesh in meshes {
1718        set.extend_from_mesh(mesh.as_ref());
1719    }
1720
1721    // CH-1 / SA-6: client host entries merged after mesh aggregation.
1722    if let Some(entries) = client_host_entries {
1723        for (addr, agent_ref) in entries {
1724            set.insert(addr, agent_ref);
1725        }
1726    }
1727
1728    set.into_vec()
1729}
1730
1731/// Spawn a [`MeshAdminAgent`] that aggregates hosts from multiple
1732/// meshes.
1733///
1734/// The admin agent runs on the caller's local proc — the `Proc` of
1735/// the actor context `cx`. Hosts are deduplicated by actor ID across
1736/// all meshes.
1737///
1738/// Spawn a `MeshAdminAgent` aggregating topology across one or more
1739/// meshes. Returns a typed `ActorRef<MeshAdminAgent>`. Callers that
1740/// need the admin URL query it via `get_admin_addr`.
1741///
1742/// See the `mesh_admin` module doc for the SA-* (spawn/aggregation),
1743/// CH-* (client host), and AI-* (admin identity) invariants.
1744pub async fn spawn_admin(
1745    meshes: impl IntoIterator<Item = impl AsRef<HostMeshRef>>,
1746    cx: &impl hyperactor::context::Actor,
1747    admin_addr: Option<std::net::SocketAddr>,
1748    telemetry_url: Option<String>,
1749) -> anyhow::Result<ActorRef<MeshAdminAgent>> {
1750    let meshes: Vec<_> = meshes.into_iter().collect();
1751    anyhow::ensure!(!meshes.is_empty(), "at least one mesh is required (SA-1)");
1752    for (i, mesh) in meshes.iter().enumerate() {
1753        anyhow::ensure!(
1754            mesh.as_ref().region().num_ranks() != 0,
1755            "mesh at index {} has no hosts (SA-2)",
1756            i,
1757        );
1758    }
1759
1760    let client_entries =
1761        crate::global_context::try_this_host().map(|client_host| client_host.host_entries());
1762    let hosts = aggregate_hosts(&meshes, client_entries);
1763
1764    let root_client_id = cx.mailbox().actor_addr().clone();
1765
1766    // Spawn the admin on the caller's local proc. Placement now
1767    // follows the caller context rather than mesh topology.
1768    let local_proc = cx.instance().proc();
1769    let agent_handle = local_proc.spawn_with_uid(
1770        Uid::singleton(Label::new(crate::mesh_admin::MESH_ADMIN_ACTOR_NAME).unwrap()),
1771        crate::mesh_admin::MeshAdminAgent::new(
1772            hosts,
1773            Some(root_client_id),
1774            admin_addr,
1775            telemetry_url,
1776        ),
1777    )?;
1778    let admin_ref = agent_handle.bind();
1779    Ok(admin_ref)
1780}
1781
1782impl view::Ranked for HostMeshRef {
1783    type Item = ActorRef<HostAgent>;
1784
1785    fn region(&self) -> &Region {
1786        self.host_agent_mesh.region()
1787    }
1788
1789    fn get(&self, rank: usize) -> Option<&Self::Item> {
1790        self.host_agent_mesh.get(rank)
1791    }
1792}
1793
1794impl view::RankedSliceable for HostMeshRef {
1795    fn sliced(&self, region: Region) -> Self {
1796        Self {
1797            id: self.id.clone(),
1798            host_agent_mesh: self.host_agent_mesh.sliced(region),
1799            bootstrap_command: self.bootstrap_command.clone(),
1800        }
1801    }
1802}
1803
1804impl std::fmt::Display for HostMeshRef {
1805    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1806        write!(f, "{}:", self.id)?;
1807        for (rank, agent) in self.host_agent_mesh.values().enumerate() {
1808            if rank > 0 {
1809                write!(f, ",")?;
1810            }
1811            write!(f, "{}", agent.actor_addr().addr())?;
1812        }
1813        write!(f, "@{}", self.region())
1814    }
1815}
1816
1817/// The type of error occuring during `HostMeshRef` parsing.
1818#[derive(thiserror::Error, Debug)]
1819pub enum HostMeshRefParseError {
1820    #[error(transparent)]
1821    RegionParseError(#[from] RegionParseError),
1822
1823    #[error("invalid host mesh ref: missing region")]
1824    MissingRegion,
1825
1826    #[error("invalid host mesh ref: missing id")]
1827    MissingId,
1828
1829    #[error(transparent)]
1830    InvalidId(#[from] crate::mesh_id::ResourceIdParseError),
1831
1832    #[error(transparent)]
1833    InvalidHostMeshRef(#[from] Box<crate::Error>),
1834
1835    #[error(transparent)]
1836    Other(#[from] anyhow::Error),
1837}
1838
1839impl From<crate::Error> for HostMeshRefParseError {
1840    fn from(err: crate::Error) -> Self {
1841        Self::InvalidHostMeshRef(Box::new(err))
1842    }
1843}
1844
1845impl FromStr for HostMeshRef {
1846    type Err = HostMeshRefParseError;
1847
1848    fn from_str(s: &str) -> Result<Self, Self::Err> {
1849        let (id_str, rest) = s.split_once(':').ok_or(HostMeshRefParseError::MissingId)?;
1850
1851        let id = HostMeshId::from_str(id_str)?;
1852
1853        let (host_addrs, region) = rest
1854            .split_once('@')
1855            .ok_or(HostMeshRefParseError::MissingRegion)?;
1856        let host_addrs = if host_addrs.trim().is_empty() {
1857            Vec::new()
1858        } else {
1859            host_addrs
1860                .split(',')
1861                .map(|host_addr| host_addr.trim())
1862                .map(ChannelAddr::from_str)
1863                .collect::<Result<Vec<_>, _>>()?
1864        };
1865        let region = region.parse()?;
1866        Ok(HostMeshRef::new(id, region, host_addrs)?)
1867    }
1868}
1869
1870#[cfg(test)]
1871mod tests {
1872    #[cfg(fbcode_build)]
1873    use std::assert_matches;
1874
1875    #[cfg(fbcode_build)]
1876    use hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER;
1877    #[cfg(fbcode_build)]
1878    use hyperactor_config::attrs::Attrs;
1879    use ndslice::ViewExt;
1880    use ndslice::extent;
1881    #[cfg(fbcode_build)]
1882    use timed_test::assert_no_process_leak;
1883    #[cfg(fbcode_build)]
1884    use tokio::process::Command;
1885    #[cfg(fbcode_build)]
1886    use tracing_test::traced_test;
1887
1888    use super::*;
1889    #[cfg(fbcode_build)]
1890    use crate::ActorMesh;
1891    #[cfg(fbcode_build)]
1892    use crate::Bootstrap;
1893    #[cfg(fbcode_build)]
1894    use crate::bootstrap::MESH_TAIL_LOG_LINES;
1895    #[cfg(fbcode_build)]
1896    use crate::comm::ENABLE_NATIVE_V1_CASTING;
1897    #[cfg(fbcode_build)]
1898    use crate::resource::Status;
1899    #[cfg(fbcode_build)]
1900    use crate::testactor;
1901    #[cfg(fbcode_build)]
1902    use crate::testactor::GetConfigAttrs;
1903    #[cfg(fbcode_build)]
1904    use crate::testactor::SetConfigAttrs;
1905    use crate::testing;
1906
1907    #[test]
1908    fn test_host_mesh_subset() {
1909        let hosts: HostMeshRef = "test:local:1,local:2,local:3,local:4@replica=2/2,host=2/1"
1910            .parse()
1911            .unwrap();
1912        assert_eq!(
1913            hosts.range("replica", 1).unwrap().to_string(),
1914            "test:local:3,local:4@2+replica=1/2,host=2/1"
1915        );
1916    }
1917
1918    #[test]
1919    fn test_host_mesh_ref_parse_roundtrip() {
1920        let host_mesh_ref = HostMeshRef::new(
1921            HostMeshId::singleton(Label::new("test").unwrap()),
1922            extent!(replica = 2, host = 2).into(),
1923            vec![
1924                "tcp:127.0.0.1:123".parse().unwrap(),
1925                "tcp:127.0.0.1:123".parse().unwrap(),
1926                "tcp:127.0.0.1:123".parse().unwrap(),
1927                "tcp:127.0.0.1:123".parse().unwrap(),
1928            ],
1929        )
1930        .unwrap();
1931
1932        let parsed: HostMeshRef = host_mesh_ref.to_string().parse().unwrap();
1933        assert_eq!(parsed.id().to_string(), host_mesh_ref.id().to_string());
1934        assert_eq!(parsed.region(), host_mesh_ref.region());
1935        assert_eq!(parsed.host_addrs(), host_mesh_ref.host_addrs());
1936        assert_eq!(parsed.bootstrap_command, host_mesh_ref.bootstrap_command);
1937    }
1938
1939    /// Allocate a new port on localhost. This drops the listener, releasing the socket,
1940    /// before returning. Hyperactor's channel::net applies SO_REUSEADDR, so we do not hav
1941    /// to wait out the socket's TIMED_WAIT state.
1942    ///
1943    /// Even so, this is racy.
1944    fn free_localhost_addr() -> ChannelAddr {
1945        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1946        ChannelAddr::Tcp(listener.local_addr().unwrap())
1947    }
1948
1949    #[cfg(fbcode_build)]
1950    async fn execute_extrinsic_allocation(config: &hyperactor_config::global::ConfigLock) {
1951        let _guard = config.override_key(crate::bootstrap::MESH_BOOTSTRAP_ENABLE_PDEATHSIG, false);
1952
1953        let program = crate::testresource::get("monarch/hyperactor_mesh/bootstrap");
1954
1955        let hosts = vec![free_localhost_addr(), free_localhost_addr()];
1956
1957        let mut children = Vec::new();
1958        for host in hosts.iter() {
1959            let mut cmd = Command::new(program.clone());
1960            let boot = Bootstrap::Host {
1961                addr: host.clone(),
1962                command: None, // use current binary
1963                config: None,
1964                exit_on_shutdown: false,
1965            };
1966            boot.to_env(&mut cmd);
1967            cmd.kill_on_drop(true);
1968            children.push(cmd.spawn().unwrap());
1969        }
1970
1971        let instance = testing::instance();
1972        let host_mesh =
1973            HostMeshRef::from_hosts(HostMeshId::singleton(Label::new("test").unwrap()), hosts);
1974
1975        let proc_mesh = host_mesh
1976            .spawn(&testing::instance(), "test", extent!(gpus = 4), None, None)
1977            .await
1978            .unwrap();
1979
1980        let actor_mesh: ActorMesh<testactor::TestActor> = proc_mesh
1981            .spawn(&testing::instance(), "test", &())
1982            .await
1983            .unwrap();
1984
1985        testactor::assert_mesh_shape(actor_mesh).await;
1986
1987        HostMesh::take(host_mesh)
1988            .shutdown(&instance)
1989            .await
1990            .expect("hosts shutdown");
1991    }
1992
1993    #[tokio::test]
1994    #[cfg(fbcode_build)]
1995    async fn test_extrinsic_allocation_v0() {
1996        let config = hyperactor_config::global::lock();
1997        let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, false);
1998        execute_extrinsic_allocation(&config).await;
1999    }
2000
2001    #[tokio::test]
2002    #[cfg(fbcode_build)]
2003    async fn test_extrinsic_allocation_v1() {
2004        let config = hyperactor_config::global::lock();
2005        let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, true);
2006        let _guard1 = config.override_key(ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
2007        execute_extrinsic_allocation(&config).await;
2008    }
2009
2010    /// `HostMesh::shutdown` emits a `Shutdown::Success` status log once every
2011    /// host tears down cleanly with no failed hosts (see the `tracing::info!`
2012    /// in `HostMesh::shutdown`). Drive the full allocate-then-shutdown path and
2013    /// assert that the success log fired.
2014    #[expect(
2015        clippy::await_holding_invalid_type,
2016        reason = "tracing-test's #[traced_test] enters a span whose Entered guard is held across awaits; this is inherent to the macro and harmless in a test"
2017    )]
2018    #[traced_test]
2019    #[tokio::test]
2020    #[cfg(fbcode_build)]
2021    async fn test_shutdown_succeeds() {
2022        let config = hyperactor_config::global::lock();
2023        execute_extrinsic_allocation(&config).await;
2024
2025        assert!(
2026            logs_contain("Shutdown::Success"),
2027            "Shutdown::Success status log not found after shutting down host mesh"
2028        );
2029    }
2030
2031    #[tokio::test]
2032    #[cfg(fbcode_build)]
2033    async fn test_failing_proc_allocation() {
2034        let lock = hyperactor_config::global::lock();
2035        let _guard = lock.override_key(MESH_TAIL_LOG_LINES, 100);
2036
2037        let program = crate::testresource::get("monarch/hyperactor_mesh/bootstrap");
2038
2039        let hosts = vec![free_localhost_addr(), free_localhost_addr()];
2040
2041        let mut children = Vec::new();
2042        for host in hosts.iter() {
2043            let mut cmd = Command::new(program.clone());
2044            let boot = Bootstrap::Host {
2045                addr: host.clone(),
2046                config: None,
2047                // The entire purpose of this is to fail:
2048                command: Some(BootstrapCommand::from("false")),
2049                exit_on_shutdown: false,
2050            };
2051            boot.to_env(&mut cmd);
2052            cmd.kill_on_drop(true);
2053            children.push(cmd.spawn().unwrap());
2054        }
2055        let host_mesh =
2056            HostMeshRef::from_hosts(HostMeshId::singleton(Label::new("test").unwrap()), hosts);
2057
2058        let instance = testing::instance();
2059
2060        let err = host_mesh
2061            .spawn(&instance, "test", Extent::unity(), None, None)
2062            .await
2063            .unwrap_err();
2064        assert_matches!(
2065            err,
2066            crate::Error::ProcCreationError { state, .. }
2067            if matches!(state.status, resource::Status::Failed(ref msg) if msg.contains("failed to configure process: Ready(Terminal(Stopped { exit_code: 1"))
2068        );
2069    }
2070
2071    #[cfg(fbcode_build)]
2072    #[assert_no_process_leak]
2073    #[tokio::test]
2074    async fn test_halting_proc_allocation() {
2075        let config = hyperactor_config::global::lock();
2076        let _guard1 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(20));
2077
2078        let program = crate::testresource::get("monarch/hyperactor_mesh/bootstrap");
2079
2080        let hosts = vec![free_localhost_addr(), free_localhost_addr()];
2081
2082        let mut children = Vec::new();
2083
2084        for (index, host) in hosts.iter().enumerate() {
2085            let mut cmd = Command::new(program.clone());
2086            let command = if index == 0 {
2087                let mut command = BootstrapCommand::from("sleep");
2088                command.args.push("60".to_string());
2089                Some(command)
2090            } else {
2091                None
2092            };
2093            let boot = Bootstrap::Host {
2094                addr: host.clone(),
2095                config: None,
2096                command,
2097                exit_on_shutdown: false,
2098            };
2099            boot.to_env(&mut cmd);
2100            cmd.kill_on_drop(true);
2101            children.push(cmd.spawn().unwrap());
2102        }
2103        let host_mesh =
2104            HostMeshRef::from_hosts(HostMeshId::singleton(Label::new("test").unwrap()), hosts);
2105
2106        let instance = testing::instance();
2107
2108        let err = host_mesh
2109            .spawn(&instance, "test", Extent::unity(), None, None)
2110            .await
2111            .unwrap_err();
2112        let statuses = err.into_proc_spawn_error().unwrap();
2113        assert_matches!(
2114            &statuses.materialized_iter(2).cloned().collect::<Vec<_>>()[..],
2115            &[Status::Timeout(_), Status::Running]
2116        );
2117    }
2118
2119    #[tokio::test]
2120    #[cfg(fbcode_build)]
2121    async fn test_client_config_override() {
2122        let config = hyperactor_config::global::lock();
2123        let _guard1 = config.override_key(crate::bootstrap::MESH_BOOTSTRAP_ENABLE_PDEATHSIG, false);
2124        let _guard2 = config.override_key(
2125            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2126            Duration::from_mins(2),
2127        );
2128        let _guard3 = config.override_key(
2129            hyperactor::config::MESSAGE_DELIVERY_TIMEOUT,
2130            Duration::from_mins(1),
2131        );
2132        let _guard4 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_mins(2));
2133
2134        // Unset env vars that were mirrored by TestOverride, so child
2135        // processes don't inherit them. This allows Runtime layer to
2136        // override ClientOverride. SAFETY: Single-threaded test under
2137        // global config lock.
2138        unsafe {
2139            std::env::remove_var("HYPERACTOR_HOST_SPAWN_READY_TIMEOUT");
2140            std::env::remove_var("HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT");
2141        }
2142
2143        let instance = testing::instance();
2144
2145        let mut hm = testing::host_mesh(2).await;
2146        let proc_mesh = hm
2147            .spawn(instance, "test", Extent::unity(), None, None)
2148            .await
2149            .unwrap();
2150        let proc_ids = proc_mesh
2151            .proc_ids()
2152            .map(|proc_addr| proc_addr.id().clone())
2153            .collect::<Vec<_>>();
2154        let unique_proc_ids = proc_ids.iter().collect::<std::collections::HashSet<_>>();
2155
2156        assert_eq!(proc_ids.len(), 2);
2157        assert_eq!(unique_proc_ids.len(), proc_ids.len());
2158
2159        let actor_mesh: ActorMesh<testactor::TestActor> =
2160            proc_mesh.spawn(instance, "test", &()).await.unwrap();
2161
2162        let mut attrs_override = Attrs::new();
2163        attrs_override.set(
2164            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2165            Duration::from_mins(3),
2166        );
2167        actor_mesh
2168            .cast(
2169                instance,
2170                SetConfigAttrs(
2171                    bincode::serde::encode_to_vec(&attrs_override, bincode::config::legacy())
2172                        .unwrap(),
2173                ),
2174            )
2175            .unwrap();
2176
2177        let (tx, mut rx) = instance.open_port();
2178        actor_mesh
2179            .cast(instance, GetConfigAttrs(tx.bind()))
2180            .unwrap();
2181        let actual_attrs = rx.recv().await.unwrap();
2182        let actual_attrs =
2183            bincode::serde::decode_from_slice::<Attrs, _>(&actual_attrs, bincode::config::legacy())
2184                .map(|(v, _)| v)
2185                .unwrap();
2186
2187        assert_eq!(
2188            *actual_attrs
2189                .get(hyperactor::config::HOST_SPAWN_READY_TIMEOUT)
2190                .unwrap(),
2191            Duration::from_mins(3)
2192        );
2193        assert_eq!(
2194            *actual_attrs
2195                .get(hyperactor::config::MESSAGE_DELIVERY_TIMEOUT)
2196                .unwrap(),
2197            Duration::from_mins(1)
2198        );
2199
2200        let _ = hm.shutdown(instance).await;
2201    }
2202
2203    // ---- HM-* invariant tests ----
2204    //
2205    // HM-1 (attach-config-complete) is covered by
2206    // `test_client_config_override` above: a successful end-to-end
2207    // attach + per-host config-override observation.
2208    //
2209    // The tests below cover HM-2, HM-3, and HM-4 in a single fixture
2210    // — `attach()` against a host address with no listener. Because
2211    // `testing::TestRootClient::handle::<MeshFailure>` panics on any
2212    // supervision event, a passing test is itself the HM-3
2213    // observation: no `Undeliverable<MessageEnvelope>` reached the
2214    // root client (had the bounce escaped, the test would panic).
2215
2216    /// HM-2 / HM-3 / HM-4: `attach()` against an unreachable host
2217    /// returns a structured `Err` that names the failing host, and
2218    /// the calling actor stays alive (no supervision crash from a
2219    /// bounce on the request path).
2220    #[tokio::test]
2221    async fn test_attach_fails_closed_on_unreachable_host() {
2222        let config = hyperactor_config::global::lock();
2223        // Tighten the per-host timeout so the test doesn't sit on
2224        // the 10 s default.
2225        let _guard = config.override_key(
2226            crate::config::MESH_ATTACH_CONFIG_TIMEOUT,
2227            Duration::from_millis(500),
2228        );
2229
2230        let instance = testing::instance();
2231
2232        // `free_localhost_addr` binds a TCP port and immediately
2233        // drops the listener. SO_REUSEADDR + no further bind means
2234        // sends to this address never connect — exactly the
2235        // production-shape failure mode.
2236        let unreachable = free_localhost_addr();
2237
2238        let id = HostMeshId::instance(Label::new("hm_test").unwrap());
2239        let result = HostMesh::attach(instance, id, vec![unreachable.clone()]).await;
2240
2241        // HM-2: attach returns Err on any failed config push.
2242        let err = match result {
2243            Ok(_) => panic!("HM-2: attach must fail when a host is unreachable"),
2244            Err(e) => e,
2245        };
2246
2247        // HM-4: the structured error names the failing host.
2248        let push_err = match err {
2249            crate::Error::ConfigPushFailed(e) => e,
2250            other => panic!("expected ConfigPushFailed, got: {other:?}"),
2251        };
2252        assert_eq!(push_err.failures.len(), 1);
2253        let (failed_host, _failure) = &push_err.failures[0];
2254        assert_eq!(
2255            failed_host, &unreachable,
2256            "HM-4: failure entry must identify the unreachable host"
2257        );
2258        // Intentionally do NOT pin the `_failure` variant — the
2259        // contract commits to per-host identity, not to a specific
2260        // failure-mode subtype (see ConfigPushFailure's doc).
2261
2262        // HM-3: the test process getting here without panicking is
2263        // itself the assertion. `TestRootClient::handle::<MeshFailure>`
2264        // panics on supervision events; if the request bounce had
2265        // escaped through `Undeliverable<MessageEnvelope>`, the
2266        // root-client's default delivery-failure handling would
2267        // surface an `UndeliverableMessageError::DeliveryFailure`,
2268        // supervision would fire, and we wouldn't be here.
2269    }
2270
2271    #[test]
2272    fn test_host_mesh_ref_canonicalizes_alias_to_dial_addr() {
2273        let dial_to = ChannelAddr::from_zmq_url("tcp://127.0.0.1:26600").unwrap();
2274        let alias = ChannelAddr::from_zmq_url("tcp://127.0.0.1:26600@tcp://0.0.0.0:26600").unwrap();
2275
2276        let mesh = HostMeshRef::from_hosts(
2277            HostMeshId::singleton(Label::new("alias").unwrap()),
2278            vec![alias],
2279        );
2280
2281        assert_eq!(mesh.host_addrs(), vec![dial_to.clone()]);
2282        assert_eq!(
2283            ndslice::view::Ranked::get(&mesh, 0)
2284                .expect("host rank should exist")
2285                .actor_addr()
2286                .proc_addr()
2287                .addr(),
2288            &dial_to
2289        );
2290    }
2291
2292    #[tokio::test]
2293    async fn test_sa1_empty_mesh_set_rejected() {
2294        let instance = testing::instance();
2295        let result = spawn_admin(std::iter::empty::<&HostMeshRef>(), instance, None, None).await;
2296        let err = result.unwrap_err().to_string();
2297        assert!(err.contains("SA-1"), "expected SA-1 error, got: {err}");
2298    }
2299
2300    #[tokio::test]
2301    async fn test_sa2_empty_hosts_rejected() {
2302        let instance = testing::instance();
2303        let mesh =
2304            HostMeshRef::from_hosts(HostMeshId::singleton(Label::new("empty").unwrap()), vec![]);
2305        let result = spawn_admin([&mesh], instance, None, None).await;
2306        let err = result.unwrap_err().to_string();
2307        assert!(err.contains("SA-2"), "expected SA-2 error, got: {err}");
2308    }
2309
2310    /// SA-3: `HostSet::insert` is idempotent — inserting the same
2311    /// `ActorAddr` twice does not add a duplicate entry, and first-seen
2312    /// order is preserved. This is a structural property of `HostSet`,
2313    /// not an invariant on `aggregate_hosts` control flow.
2314    #[test]
2315    fn test_sa3_host_set_insert_idempotent() {
2316        let addr_a: ChannelAddr = "tcp:127.0.0.1:2001".parse().unwrap();
2317        let addr_b: ChannelAddr = "tcp:127.0.0.1:2002".parse().unwrap();
2318
2319        let ref_a = host_agent_ref(addr_a.clone());
2320        let ref_b = host_agent_ref(addr_b.clone());
2321
2322        let mut set = HostSet::new();
2323        set.insert(addr_a.to_string(), ref_a.clone());
2324        set.insert(addr_b.to_string(), ref_b.clone());
2325        // Insert ref_a again — should be a no-op (SA-3).
2326        set.insert("duplicate_addr".to_string(), ref_a.clone());
2327
2328        let result = set.into_vec();
2329        assert_eq!(
2330            result.len(),
2331            2,
2332            "SA-3: duplicate ActorAddr must not add entry"
2333        );
2334        assert_eq!(
2335            result[0].0,
2336            addr_a.to_string(),
2337            "SA-3: first-seen order preserved"
2338        );
2339        assert_eq!(
2340            result[1].0,
2341            addr_b.to_string(),
2342            "SA-3: first-seen order preserved"
2343        );
2344    }
2345
2346    #[test]
2347    fn test_sa3_aggregate_hosts_dedup() {
2348        let addr_a: ChannelAddr = "tcp:127.0.0.1:1001".parse().unwrap();
2349        let addr_b: ChannelAddr = "tcp:127.0.0.1:1002".parse().unwrap();
2350        let addr_c: ChannelAddr = "tcp:127.0.0.1:1003".parse().unwrap();
2351
2352        // mesh_a: hosts a, b
2353        let mesh_a = HostMeshRef::from_hosts(
2354            HostMeshId::singleton(Label::new("mesh-a").unwrap()),
2355            vec![addr_a.clone(), addr_b.clone()],
2356        );
2357        // mesh_b: hosts b, c  (b overlaps with mesh_a)
2358        let mesh_b = HostMeshRef::from_hosts(
2359            HostMeshId::singleton(Label::new("mesh-b").unwrap()),
2360            vec![addr_b.clone(), addr_c.clone()],
2361        );
2362
2363        let result = aggregate_hosts(&[&mesh_a, &mesh_b], None);
2364
2365        // 3 unique hosts: a, b, c — b is deduplicated.
2366        assert_eq!(result.len(), 3, "expected 3 hosts, got {:?}", result);
2367
2368        // First-seen order: a (mesh_a[0]), b (mesh_a[1]), c (mesh_b[1]).
2369        let addrs: Vec<String> = result.iter().map(|(a, _)| a.clone()).collect();
2370        assert_eq!(addrs[0], addr_a.to_string());
2371        assert_eq!(addrs[1], addr_b.to_string());
2372        assert_eq!(addrs[2], addr_c.to_string());
2373    }
2374
2375    /// SA-6 / CH-1: client host entries are deduplicated against the
2376    /// already-aggregated mesh host set.
2377    #[test]
2378    fn test_sa6_ch1_client_host_dedup() {
2379        let addr_a: ChannelAddr = "tcp:127.0.0.1:1001".parse().unwrap();
2380        let addr_b: ChannelAddr = "tcp:127.0.0.1:1002".parse().unwrap();
2381
2382        let mesh = HostMeshRef::from_hosts(
2383            HostMeshId::singleton(Label::new("mesh").unwrap()),
2384            vec![addr_a.clone(), addr_b.clone()],
2385        );
2386
2387        // Client host entry overlaps with addr_a.
2388        let client_ref = host_agent_ref(addr_a.clone());
2389        let client_entries = vec![("client_addr".to_string(), client_ref)];
2390
2391        let result = aggregate_hosts(&[&mesh], Some(client_entries));
2392
2393        // addr_a already in mesh — client entry is deduplicated.
2394        assert_eq!(result.len(), 2, "expected 2 hosts, got {:?}", result);
2395        let addrs: Vec<String> = result.iter().map(|(a, _)| a.clone()).collect();
2396        assert_eq!(addrs[0], addr_a.to_string());
2397        assert_eq!(addrs[1], addr_b.to_string());
2398    }
2399}