Skip to main content

hyperactor_mesh/
proc_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
9use std::any::type_name;
10use std::collections::HashMap;
11use std::collections::HashSet;
12use std::fmt;
13use std::hash::Hash;
14use std::ops::Deref;
15use std::sync::Arc;
16use std::time::Duration;
17
18use hyperactor::ActorAddr;
19use hyperactor::ActorId;
20use hyperactor::ActorRef;
21use hyperactor::Endpoint as _;
22use hyperactor::Handler;
23use hyperactor::ProcAddr;
24use hyperactor::RemoteMessage;
25use hyperactor::RemoteSpawn;
26use hyperactor::accum::IdleFlushReducerOpts;
27use hyperactor::actor::ActorStatus;
28use hyperactor::actor::remote::Remote;
29use hyperactor::context;
30use hyperactor::id::Label;
31use hyperactor::supervision::ActorSupervisionEvent;
32use hyperactor_config::CONFIG;
33use hyperactor_config::ConfigAttr;
34use hyperactor_config::Flattrs;
35use hyperactor_config::NonZeroUsize;
36use hyperactor_config::attrs::declare_attrs;
37use hyperactor_remote::ActorSpawner;
38use hyperactor_remote::ActorSpawnerEndpoint;
39use hyperactor_remote::KeepaliveLink;
40use hyperactor_remote::proc_spawner::actor_spawner_uid;
41use hyperactor_telemetry::hash_to_u64;
42use ndslice::Extent;
43use ndslice::ViewExt as _;
44use ndslice::view;
45use ndslice::view::CollectMeshExt;
46use ndslice::view::Ranked;
47use ndslice::view::Region;
48use serde::Deserialize;
49use serde::Serialize;
50use typeuri::Named;
51
52use crate::ActorMesh;
53use crate::ActorMeshRef;
54use crate::Error;
55use crate::HostMeshRef;
56use crate::ValueMesh;
57use crate::actor_mesh::ACTOR_MESH_ID;
58use crate::actor_mesh::ActorMeshStopHandle;
59use crate::casting::CAST_POINT;
60use crate::host_mesh::GET_PROC_STATE_MAX_IDLE;
61use crate::host_mesh::host_agent::GetHostProcStates;
62use crate::host_mesh::host_agent::ProcState;
63use crate::host_mesh::mesh_to_rankedvalues_with_default;
64use crate::mesh_controller::ActorMeshControlPlane;
65use crate::mesh_controller::ActorMeshController;
66use crate::mesh_id::ActorMeshId;
67use crate::mesh_id::ProcMeshId;
68use crate::mesh_id::ResourceId;
69use crate::proc_agent;
70use crate::proc_agent::ActorState;
71use crate::proc_agent::ProcAgent;
72use crate::resource;
73use crate::resource::GetRankStatus;
74use crate::resource::Status;
75use crate::supervision::MeshFailure;
76
77declare_attrs! {
78    /// Whether ordinary actor mesh spawns use the direct spawn path.
79    @meta(CONFIG = ConfigAttr::new(
80        Some("HYPERACTOR_MESH_USE_DIRECT_SPAWN".to_string()),
81        Some("use_direct_spawn".to_string()),
82    ))
83    pub attr USE_DIRECT_SPAWN: bool = false;
84
85    /// The maximum idle time between updates while spawning actor
86    /// meshes.
87    @meta(CONFIG = ConfigAttr::new(
88        Some("HYPERACTOR_MESH_ACTOR_SPAWN_MAX_IDLE".to_string()),
89        Some("actor_spawn_max_idle".to_string()),
90    ))
91    pub attr ACTOR_SPAWN_MAX_IDLE: Duration = Duration::from_secs(30);
92
93    /// The maximum idle time between updates while waiting for a response to GetState
94    /// from ProcAgent.
95    @meta(CONFIG = ConfigAttr::new(
96        Some("HYPERACTOR_MESH_GET_ACTOR_STATE_MAX_IDLE".to_string()),
97        Some("get_actor_state_max_idle".to_string()),
98    ))
99    pub attr GET_ACTOR_STATE_MAX_IDLE: Duration = Duration::from_secs(30);
100}
101
102/// Returns the telemetry `meshes.id` value for an actor mesh.
103pub fn telemetry_actor_mesh_id(proc_mesh_id: &ProcMeshId, actor_mesh_id: &ActorMeshId) -> u64 {
104    hash_to_u64(&(proc_mesh_id, actor_mesh_id))
105}
106
107/// A reference to a single [`hyperactor::Proc`].
108#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
109pub struct ProcRef {
110    proc_id: ProcAddr,
111    /// The rank of this proc at creation.
112    create_rank: usize,
113    /// The agent managing this proc.
114    agent: ActorRef<ProcAgent>,
115}
116
117impl ProcRef {
118    /// Create a new proc ref from the provided id, create rank and agent.
119    pub fn new(proc_id: ProcAddr, create_rank: usize, agent: ActorRef<ProcAgent>) -> Self {
120        Self {
121            proc_id,
122            create_rank,
123            agent,
124        }
125    }
126
127    pub fn proc_addr(&self) -> &ProcAddr {
128        &self.proc_id
129    }
130
131    pub(crate) fn actor_addr(&self, id: &ActorMeshId) -> ActorAddr {
132        self.proc_id.actor_addr_uid(id.uid().clone())
133    }
134
135    /// Return the actor spawner hosted by this proc.
136    pub(crate) fn spawner(&self) -> ActorRef<ActorSpawner> {
137        ActorRef::attest(self.proc_id.actor_addr_uid(actor_spawner_uid()))
138    }
139}
140
141/// A mesh of processes.
142#[derive(Debug)]
143pub struct ProcMesh {
144    #[allow(dead_code)]
145    id: ProcMeshId,
146    current_ref: ProcMeshRef,
147    controller: Option<ActorRef<crate::mesh_controller::ProcMeshController>>,
148}
149
150enum SpawnedActorMesh<A: RemoteSpawn> {
151    Managed {
152        mesh: ActorMesh<A>,
153        statuses: crate::StatusMesh,
154    },
155    Data(ActorMesh<A>),
156}
157
158impl ProcMesh {
159    pub(crate) fn create(
160        id: ProcMeshId,
161        extent: Extent,
162        hosts: HostMeshRef,
163        ranks: Vec<ProcRef>,
164    ) -> crate::Result<Self> {
165        let region = extent.into();
166        let ranks = Arc::new(ranks);
167
168        // Set the global supervision sink to the first ProcAgent's
169        // supervision event handler. Last-mesh-wins semantics: if a
170        // previous mesh installed a sink, it is replaced.
171        if let Some(first) = ranks.first() {
172            crate::global_context::set_global_supervision_sink(
173                first.agent.port::<ActorSupervisionEvent>(),
174            );
175        }
176
177        let current_ref = ProcMeshRef::new(id.clone(), region, ranks, Some(hosts)).unwrap();
178
179        // Notify telemetry that the ProcAgent mesh was created.
180        {
181            let name_str = id.to_string();
182            let mesh_id_hash = hash_to_u64(&id);
183
184            let hm = current_ref
185                .host_mesh
186                .as_ref()
187                .expect("ProcMesh always has a host mesh");
188            let parent_mesh_id = hash_to_u64(hm.id());
189            let parent_view_json = serde_json::to_string(hm.region())
190                .unwrap_or_else(|e| format!("encountered error when serializing region: {}", e));
191
192            hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
193                id: mesh_id_hash,
194                timestamp: std::time::SystemTime::now(),
195                class: "Proc".to_string(),
196                given_name: id
197                    .display_label()
198                    .map(|l| l.as_str())
199                    .unwrap_or("unnamed")
200                    .to_string(),
201                full_name: name_str,
202                shape_json: serde_json::to_string(&current_ref.region.extent()).unwrap_or_default(),
203                parent_mesh_id: Some(parent_mesh_id),
204                parent_view_json: Some(parent_view_json),
205            });
206
207            // Notify telemetry of each ProcAgent actor in this mesh.
208            // These are skipped in Proc::spawn_inner. mesh_id directly points to proc mesh.
209            let now = std::time::SystemTime::now();
210            for rank in current_ref.ranks.iter() {
211                let actor_addr = rank.agent.actor_addr();
212
213                hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
214                    id: hyperactor_telemetry::hash_to_u64(actor_addr.id()),
215                    timestamp: now,
216                    mesh_id: mesh_id_hash,
217                    rank: rank.create_rank as u64,
218                    full_name: actor_addr.to_string(),
219                    display_name: None,
220                });
221            }
222        }
223
224        Ok(Self {
225            id,
226            current_ref,
227            controller: None,
228        })
229    }
230
231    /// Set or clear the controller actor managing this mesh.
232    pub(crate) fn set_controller(
233        &mut self,
234        controller: Option<ActorRef<crate::mesh_controller::ProcMeshController>>,
235    ) {
236        self.controller = controller;
237    }
238
239    /// Stop this mesh gracefully.
240    ///
241    /// If a `ProcMeshController` is present (owned meshes spawned from a host
242    /// mesh), the stop is delegated to the controller via `resource::Stop`;
243    /// the controller's handler awaits `HostMeshRef::stop_proc_mesh`, which
244    /// casts `Stop` + `WaitRankStatus{min_status: Stopped}` to the
245    /// HostAgents and waits up to `PROC_STOP_MAX_IDLE` for every proc to
246    /// reach `Stopped`. We then serialize behind that handler with a
247    /// `GetState` to read the final statuses out of the controller's
248    /// `health_state`.
249    pub async fn stop(&mut self, cx: &impl context::Actor, reason: String) -> anyhow::Result<()> {
250        if let Some(controller) = self.controller.take() {
251            let id = self.id.resource_id().clone();
252            controller.post(
253                cx,
254                resource::Stop {
255                    id: id.clone(),
256                    reason,
257                },
258            );
259
260            // The controller processes messages serially, so by the time it
261            // gets to this `GetState`, its `health_state.statuses` already
262            // reflects the outcome of `stop_proc_mesh` (Stopping, Stopped,
263            // Failed, or Timeout on `PROC_STOP_MAX_IDLE` exhaustion).
264            let (port, mut rx) = cx.mailbox().open_port();
265            controller.post(
266                cx,
267                resource::GetState::<resource::mesh::State<()>> {
268                    id: id.clone(),
269                    reply: port.bind(),
270                },
271            );
272
273            let statuses = rx.recv().await?;
274            let Some(state) = &statuses.state else {
275                anyhow::bail!(
276                    "non-existent state in GetState reply from controller: {}",
277                    controller.actor_addr()
278                );
279            };
280            // `is_terminating` accepts Stopping, Stopped, Failed, and
281            // Timeout. The controller's Stop handler has already awaited
282            // (or timed out) the underlying HostAgent wait, so any rank
283            // still in Running here means the controller never processed
284            // the stop for that rank.
285            let all_stopped = state.statuses.values().all(|s| s.is_terminating());
286            if !all_stopped {
287                anyhow::bail!(
288                    "proc mesh {} not all procs reached terminating state after stop: {:?}",
289                    id,
290                    state.statuses,
291                );
292            }
293            return Ok(());
294        }
295
296        let region = self.region.clone();
297        let procs = self.current_ref.proc_ids().collect::<Vec<ProcAddr>>();
298        // We use the proc mesh region rather than the host mesh region
299        // because the host agent stores one entry per proc, not per host.
300        self.current_ref
301            .host_mesh
302            .as_ref()
303            .expect("ProcMesh always has a host mesh")
304            .stop_proc_mesh(cx, &self.id, procs, region, reason)
305            .await
306            .map(|_| ())
307            .map_err(anyhow::Error::from)
308    }
309}
310
311impl fmt::Display for ProcMesh {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        write!(f, "{}", self.current_ref)
314    }
315}
316
317impl Deref for ProcMesh {
318    type Target = ProcMeshRef;
319
320    fn deref(&self) -> &Self::Target {
321        &self.current_ref
322    }
323}
324
325impl Drop for ProcMesh {
326    fn drop(&mut self) {
327        tracing::info!(
328            name = "ProcMeshStatus",
329            proc_mesh = %self.id,
330            status = "Dropped",
331        );
332    }
333}
334
335/// A reference to a ProcMesh, consisting of a set of ranked [`ProcRef`]s,
336/// arranged into a region. ProcMeshes are named, uniquely identifying the
337/// ProcMesh from which the reference was derived.
338///
339/// ProcMeshes can be sliced to create new ProcMeshes with a subset of the
340/// original ranks.
341///
342/// `ProcMeshRef::sliced` is intentionally pure. A sliced proc mesh can still
343/// expose dense `ProcRef`s, while the backing `ProcAgent` `ActorMeshRef`
344/// carries a lazy cast-domain descriptor that installs itself on first cast.
345#[derive(Debug, Clone, Named, Serialize, Deserialize)]
346pub struct ProcMeshRef {
347    id: ProcMeshId,
348    region: Region,
349    ranks: Arc<Vec<ProcRef>>,
350    /// Actor mesh for the `ProcAgent`s backing this proc mesh view.
351    ///
352    /// `ProcMeshRef::sliced` derives a sliced agent mesh with a lazy cast
353    /// descriptor. The first cast through that view installs the descriptor on
354    /// the caller's sender stream.
355    ///
356    /// The `ProcMeshRef` itself keeps dense `ProcRef`s so it can later
357    /// materialize this field from the current view without consulting the
358    /// parent mesh or using a temporary proc.
359    proc_agent_mesh: ActorMeshRef<ProcAgent>,
360    // Some if this was spawned from a host mesh, else none.
361    host_mesh: Option<HostMeshRef>,
362}
363wirevalue::register_type!(ProcMeshRef);
364
365// The proc-agent actor mesh is derived from `ranks`, so it is not part of
366// `ProcMeshRef` identity.
367impl PartialEq for ProcMeshRef {
368    fn eq(&self, other: &Self) -> bool {
369        self.id == other.id
370            && self.region == other.region
371            && self.ranks == other.ranks
372            && self.host_mesh == other.host_mesh
373    }
374}
375
376impl Eq for ProcMeshRef {}
377
378impl std::hash::Hash for ProcMeshRef {
379    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
380        self.id.hash(state);
381        self.region.hash(state);
382        self.ranks.hash(state);
383        self.host_mesh.hash(state);
384    }
385}
386
387impl ProcMeshRef {
388    /// Create a new ProcMeshRef from the given id, region, ranks, and so on.
389    #[allow(clippy::result_large_err)]
390    fn new(
391        id: ProcMeshId,
392        region: Region,
393        ranks: Arc<Vec<ProcRef>>,
394        host_mesh: Option<HostMeshRef>,
395    ) -> crate::Result<Self> {
396        if ranks.is_empty() {
397            return Err(crate::Error::ConfigurationError(anyhow::anyhow!(
398                "empty proc meshes are not supported"
399            )));
400        }
401        if region.num_ranks() != ranks.len() {
402            return Err(crate::Error::InvalidRankCardinality {
403                expected: region.num_ranks(),
404                actual: ranks.len(),
405            });
406        }
407        let proc_agent_mesh = Self::proc_agent_mesh_ref(&id, &region, &ranks)?;
408        Ok(Self {
409            id,
410            region,
411            ranks,
412            proc_agent_mesh,
413            host_mesh,
414        })
415    }
416
417    /// Create a singleton ProcMeshRef, given the provided ProcRef and id.
418    /// This is used to support creating local singleton proc meshes to support `this_proc()`
419    /// in python client actors.
420    pub fn new_singleton(id: ProcMeshId, proc_ref: ProcRef) -> crate::Result<Self> {
421        let region: Region = Extent::unity().into();
422        let ranks = Arc::new(vec![proc_ref]);
423        let proc_agent_mesh = Self::proc_agent_mesh_ref(&id, &region, &ranks)?;
424        Ok(Self {
425            id,
426            region,
427            ranks,
428            proc_agent_mesh,
429            host_mesh: None,
430        })
431    }
432
433    pub fn id(&self) -> &ProcMeshId {
434        &self.id
435    }
436
437    pub fn host_mesh_id(&self) -> Option<&crate::mesh_id::HostMeshId> {
438        self.host_mesh.as_ref().map(|h| h.id())
439    }
440
441    /// Returns the HostMeshRef that owns this ProcMeshRef, if any.
442    pub fn hosts(&self) -> Option<&HostMeshRef> {
443        self.host_mesh.as_ref()
444    }
445
446    pub(crate) fn agent_mesh(&self) -> &ActorMeshRef<ProcAgent> {
447        &self.proc_agent_mesh
448    }
449
450    fn proc_agent_mesh_ref(
451        proc_mesh_id: &ProcMeshId,
452        region: &Region,
453        ranks: &[ProcRef],
454    ) -> crate::Result<ActorMeshRef<ProcAgent>> {
455        let agent_label = ranks
456            .first()
457            .unwrap()
458            .agent
459            .actor_addr()
460            .label()
461            .cloned()
462            .unwrap_or_else(|| Label::new(proc_agent::PROC_AGENT_ACTOR_NAME).unwrap());
463        let id = ActorMeshId::singleton(agent_label);
464
465        let members = Arc::new(
466            ranks
467                .iter()
468                .map(|rank| rank.agent.actor_addr().clone())
469                .collect_mesh::<ValueMesh<_>>(region.clone())
470                .map_err(|error| crate::Error::ConfigurationError(error.into()))?,
471        );
472
473        Ok(ActorMeshRef::new_managed(
474            id,
475            Some(proc_mesh_id.clone()),
476            region.clone(),
477            None,
478            members,
479        ))
480    }
481
482    /// Query the state of all actors in this mesh matching the given id.
483    pub async fn actor_states(
484        &self,
485        cx: &impl context::Actor,
486        id: ActorMeshId,
487    ) -> crate::Result<ValueMesh<resource::State<ActorState>>> {
488        self.actor_states_with_keepalive(cx, id, None).await
489    }
490
491    /// Query the state of all actors in this mesh matching the given id.
492    /// If keepalive is Some, use a message that indicates to the recipient
493    /// that the owner of the mesh is still alive, along with the expiry time
494    /// after which the actor should be considered orphaned. Else, use a normal
495    /// state query.
496    pub(crate) async fn actor_states_with_keepalive(
497        &self,
498        cx: &impl context::Actor,
499        id: ActorMeshId,
500        keepalive: Option<std::time::SystemTime>,
501    ) -> crate::Result<ValueMesh<resource::State<ActorState>>> {
502        let (port, mut rx) = cx.mailbox().open_port::<resource::State<ActorState>>();
503        let mut port = port.bind();
504        // If this proc dies or some other issue renders the reply undeliverable,
505        // the reply does not need to be returned to the sender.
506        port.return_undeliverable(false);
507        // TODO: Use accumulation to get back a single value (representing whether
508        // *any* of the actors failed) instead of a mesh.
509        let get_state = resource::GetState::<ActorState> {
510            id: id.resource_id().clone(),
511            reply: port,
512        };
513        if let Some(expires_after) = keepalive {
514            self.proc_agent_mesh.cast(
515                cx,
516                resource::KeepaliveGetState {
517                    expires_after,
518                    get_state,
519                },
520            )?;
521        } else {
522            self.proc_agent_mesh.cast(cx, get_state)?;
523        }
524        let expected = self.ranks.len();
525        // Map each managed actor's identity to its rank in *this* (current)
526        // view. Locations may differ between the controller and worker, while
527        // `create_rank` is relative to the view where the actor was first spawned.
528        let rank_of_actor: HashMap<ActorId, usize> = self
529            .ranks
530            .iter()
531            .enumerate()
532            .map(|(rank, proc_ref)| (proc_ref.actor_addr(&id).id().clone(), rank))
533            .collect();
534        let mut states = Vec::with_capacity(expected);
535        let timeout = hyperactor_config::global::get(GET_ACTOR_STATE_MAX_IDLE);
536        for _ in 0..expected {
537            // The agent runs on the same process as the running actor, so if some
538            // fatal event caused the process to crash (e.g. OOM, signal, process exit),
539            // the agent will be unresponsive.
540            // We handle this by setting a timeout on the recv, and if we don't get a
541            // message we assume the agent is dead and return a failed state.
542            let state = tokio::time::timeout(timeout, rx.recv()).await;
543            if let Ok(state) = state {
544                // Handle non-timeout receiver error.
545                let state = state?;
546                match state.state {
547                    Some(ref inner) => {
548                        // Position by this actor's rank in the current view, not
549                        // its historical `create_rank`.
550                        let rank = *rank_of_actor.get(inner.actor_id.id()).ok_or_else(|| {
551                            anyhow::anyhow!(
552                                "state reply from {} is not a member of the queried view",
553                                inner.actor_id,
554                            )
555                        })?;
556                        states.push((rank, state));
557                    }
558                    None => {
559                        // A received `None` is a definitive NotExist answer;
560                        // return immediately rather than waiting for the timeout
561                        // padding (which is only for ranks that never reply).
562                        return Err(Error::NotExist(state.id));
563                    }
564                }
565            } else {
566                tracing::error!(
567                    "timeout waiting for a message after {:?} from proc mesh agent in mesh {}",
568                    timeout,
569                    self.proc_agent_mesh
570                );
571                // Timeout error, stop reading from the receiver and send back what we have so far,
572                // padding with failed states.
573                let all_ranks = (0..self.ranks.len()).collect::<HashSet<_>>();
574                let completed_ranks = states.iter().map(|(rank, _)| *rank).collect::<HashSet<_>>();
575                let mut leftover_ranks = all_ranks.difference(&completed_ranks).collect::<Vec<_>>();
576                assert_eq!(leftover_ranks.len(), expected - states.len());
577                while states.len() < expected {
578                    let rank = *leftover_ranks
579                        .pop()
580                        .expect("leftover ranks should not be empty");
581                    let agent = self.proc_agent_mesh.get(rank).expect("agent should exist");
582                    let agent_id = agent.actor_addr().clone();
583                    states.push((
584                        // We populate with any ranks leftover at the time of the timeout.
585                        rank,
586                        resource::State {
587                            id: id.resource_id().clone(),
588                            status: resource::Status::Timeout(timeout),
589                            // We don't know the ActorAddr that used to live on this rank.
590                            // But we do know the mesh agent id, so we'll use that.
591                            // Use u64::MAX so this synthetic state always wins
592                            // last-writer-wins ordering against real streamed updates.
593                            generation: u64::MAX,
594                            timestamp: std::time::SystemTime::now(),
595                            state: Some(ActorState {
596                                actor_id: agent_id.clone(),
597                                create_rank: rank,
598                                supervision_events: vec![ActorSupervisionEvent::new(
599                                    agent_id,
600                                    None,
601                                    ActorStatus::generic_failure(format!(
602                                        "timeout waiting for message from proc mesh agent while querying for \"{}\". The process likely crashed",
603                                        id,
604                                    )),
605                                    None,
606                                )],
607                            }),
608                        },
609                    ));
610                }
611                break;
612            }
613        }
614        // Ensure that all ranks have replied. Note that if the mesh is sliced,
615        // not all create_ranks may be in the mesh.
616        // Sort by rank, so that the resulting mesh is ordered.
617        states.sort_by_key(|(rank, _)| *rank);
618        let vm = states
619            .into_iter()
620            .map(|(_, state)| state)
621            .collect_mesh::<ValueMesh<_>>(self.region.clone())?;
622        Ok(vm)
623    }
624
625    /// Get the state of every proc in this proc mesh.
626    ///
627    /// Casts a single `GetHostProcStates` to the routing host-agent mesh
628    /// carrying this mesh's selected global ranks (the mesh may be sliced, so
629    /// they need not be a dense `0..n`). The cast may reach hosts that own no
630    /// selected procs, but each HostAgent filters locally and only hosts with
631    /// matching ranks reply. Replies reduce up the cast tree (fanning in at cast
632    /// actor 0) instead of every host dialing this caller. When `keepalive` is
633    /// `Some`, each proc's expiry is extended (orphan protection, as with
634    /// `KeepaliveGetState`). Returns `None` when this proc mesh is not backed by
635    /// a host mesh (local/in-process meshes). On timeout, ranks whose host did
636    /// not reply are padded with a `Timeout` state.
637    #[allow(clippy::result_large_err)]
638    pub async fn states(
639        &self,
640        cx: &impl context::Actor,
641        keepalive: Option<std::time::SystemTime>,
642    ) -> crate::Result<Option<ValueMesh<resource::State<ProcState>>>> {
643        // Only meaningful when this proc mesh is backed by a host mesh.
644        let Some(host_mesh) = self.host_mesh.as_ref() else {
645            return Ok(None);
646        };
647        let region = self.region.clone();
648        let timeout = hyperactor_config::global::get(GET_PROC_STATE_MAX_IDLE);
649
650        // Per-rank template seeded with `Timeout` placeholders. Hosts overlay
651        // the ranks they own, so any rank never reported keeps its placeholder
652        // and the result is a complete mesh with no post-hoc padding.
653        let template: ValueMesh<resource::State<ProcState>> = self
654            .ranks
655            .iter()
656            .map(|proc_ref| resource::State {
657                id: ResourceId::new(
658                    proc_ref.proc_id.uid().clone(),
659                    proc_ref.proc_id.label().cloned(),
660                ),
661                status: resource::Status::Timeout(timeout),
662                state: None,
663                generation: 0,
664                timestamp: std::time::SystemTime::now(),
665            })
666            .collect_mesh::<ValueMesh<_>>(region.clone())?;
667        // Snapshot returned if no host replies before the idle timeout.
668        let fallback = template.clone();
669
670        // Accumulator port: receives sparse per-host overlays and emits the
671        // merged full mesh (right-wins). The host mesh is a routing
672        // over-approximation for sliced proc meshes; HostAgents that own
673        // selected ranks post an overlay, others stay silent.
674        let (port, rx) = cx.mailbox().open_idle_flush_accum_port(
675            template,
676            IdleFlushReducerOpts {
677                idle_timeout: Duration::from_millis(50),
678                abandon_timeout: Duration::from_secs(30),
679                expected_updates_per_destination: NonZeroUsize::MIN,
680            },
681        );
682
683        let reply = port.bind();
684
685        host_mesh.agent_mesh().cast(
686            cx,
687            GetHostProcStates {
688                proc_mesh_id: self.id.clone(),
689                region: region.clone(),
690                keepalive,
691                reply,
692            },
693        )?;
694
695        // Wait until every rank has reported (moved off its `Timeout`
696        // placeholder) or we idle out. Either way the mesh is complete: ranks
697        // whose host never replied stay `Timeout`, and a failed proc surfaces as
698        // its `Failed` state rather than an error.
699        let mesh =
700            match resource::wait_mesh(rx, timeout, fallback, |s: &resource::State<ProcState>| {
701                !matches!(s.status, resource::Status::Timeout(_))
702            })
703            .await
704            {
705                Ok(mesh) | Err(mesh) => mesh,
706            };
707
708        Ok(Some(mesh))
709    }
710
711    /// Returns an iterator over the proc ids in this mesh.
712    pub(crate) fn proc_ids(&self) -> impl Iterator<Item = ProcAddr> {
713        self.ranks.iter().map(|proc_ref| proc_ref.proc_id.clone())
714    }
715
716    /// Spawn an actor on all of the procs in this mesh, returning a
717    /// new ActorMesh.
718    ///
719    /// Bounds:
720    /// - `A: Actor` - the actor actually runs inside each proc.
721    /// - `A: Referable` - so we can return typed `ActorRef<A>`s
722    ///   inside the `ActorMesh`.
723    /// - `A::Params: RemoteMessage` - spawn parameters must be
724    ///   serializable and routable.
725    pub async fn spawn<A: RemoteSpawn, C: context::Actor>(
726        &self,
727        cx: &C,
728        name: &str,
729        params: &A::Params,
730    ) -> crate::Result<ActorMesh<A>>
731    where
732        A::Params: RemoteMessage,
733        C::A: Handler<MeshFailure>,
734    {
735        // Spawning from a string is never a system actor.
736        let id = ActorMeshId::instance(Label::strip(name));
737        self.spawn_with_name(cx, id, params, None, false).await
738    }
739
740    /// Spawn a 'service' actor. Service actors are *singletons*, using
741    /// reserved names. The provided name is used verbatim as the actor's
742    /// name, and thus it may be persistently looked up by constructing
743    /// the appropriate name.
744    ///
745    /// Note: avoid using service actors if possible; the mechanism will
746    /// be replaced by an actor registry.
747    pub async fn spawn_service<A: RemoteSpawn, C: context::Actor>(
748        &self,
749        cx: &C,
750        name: &str,
751        params: &A::Params,
752    ) -> crate::Result<ActorMesh<A>>
753    where
754        A::Params: RemoteMessage,
755        C::A: Handler<MeshFailure>,
756    {
757        let id = ActorMeshId::singleton(Label::strip(name));
758        self.spawn_with_name(cx, id, params, None, false).await
759    }
760
761    /// Spawn a controllerless 'service' actor.
762    ///
763    /// Like [`spawn_service`](Self::spawn_service), this spawns a singleton
764    /// service actor (a reserved name, deduped to one). Unlike `spawn_service`,
765    /// the resulting mesh has **no** `ActorMeshController`: no per-caller
766    /// controller actor is spawned, and the caller `C` is therefore **not**
767    /// required to implement `Handler<MeshFailure>`.
768    ///
769    /// This is for coordinators that own their own supervision — e.g. a
770    /// controllerless singleton that itself spawns controller-ful children —
771    /// where a per-caller controller would be redundant.
772    #[hyperactor::instrument(fields(
773        host_mesh=self.host_mesh_id().map(|id| id.to_string()),
774        proc_mesh=self.id.to_string(),
775        actor_name=name.to_string(),
776    ))]
777    pub async fn spawn_controllerless_service<A: RemoteSpawn, C: context::Actor>(
778        &self,
779        cx: &C,
780        name: &str,
781        params: &A::Params,
782    ) -> crate::Result<ActorMesh<A>>
783    where
784        A::Params: RemoteMessage,
785    {
786        tracing::info!(
787            name = "ProcMeshStatus",
788            status = "ActorMesh::Spawn::Attempt",
789        );
790        tracing::info!(name = "ActorMeshStatus", status = "Spawn::Attempt");
791        let id = ActorMeshId::singleton(Label::strip(name));
792        let result = self
793            .spawn_managed_mesh_inner(cx, id, params, None)
794            .await
795            .map(|(mesh, _statuses)| mesh);
796        match &result {
797            Ok(_) => {
798                tracing::info!(
799                    name = "ProcMeshStatus",
800                    status = "ActorMesh::Spawn::Success",
801                );
802                tracing::info!(name = "ActorMeshStatus", status = "Spawn::Success");
803            }
804            Err(error) => {
805                tracing::error!(name = "ProcMeshStatus", status = "ActorMesh::Spawn::Failed", %error);
806                tracing::error!(name = "ActorMeshStatus", status = "Spawn::Failed", %error);
807            }
808        }
809        result
810    }
811
812    /// Spawn an actor on all procs in this mesh using the new data-only mesh API.
813    /// `actor_mesh_id` identifies the mesh and its members, and must be an
814    /// instance id.
815    ///
816    /// This initiates an actor spawn on each proc and constructs a stoppable
817    /// data mesh from the actor refs and local supervisor handles without
818    /// waiting for the remote supervision sessions to link. The mesh is usable
819    /// immediately: each proc queues messages for its actor until the actor
820    /// starts. If a request cannot be initiated, all previously created local
821    /// supervisors receive a best-effort stop request.
822    ///
823    /// Bounds:
824    /// - `A: Actor` - the actor actually runs inside each proc.
825    /// - `A: Referable` - so we can return typed `ActorRef<A>`s inside the `ActorMesh`.
826    /// - `A::Params: RemoteMessage` - spawn parameters must be serializable and routable.
827    pub async fn spawn_data<A: RemoteSpawn, C: context::Actor>(
828        &self,
829        cx: &C,
830        actor_mesh_id: ActorMeshId,
831        params: &A::Params,
832    ) -> crate::Result<ActorMesh<A>>
833    where
834        A::Params: RemoteMessage,
835    {
836        if !actor_mesh_id.uid().is_instance() {
837            return Err(Error::ConfigurationError(anyhow::anyhow!(
838                "data-backed actor meshes require instance ids"
839            )));
840        }
841        self.spawn_data_mesh_inner(cx, params, actor_mesh_id)
842    }
843
844    fn spawn_data_mesh_inner<A: RemoteSpawn, C: context::Actor>(
845        &self,
846        cx: &C,
847        params: &A::Params,
848        actor_mesh_id: ActorMeshId,
849    ) -> crate::Result<ActorMesh<A>>
850    where
851        A::Params: RemoteMessage,
852    {
853        let region = self.region().clone();
854        let mut members = Vec::with_capacity(self.ranks.len());
855        let mut stop_handles = Vec::with_capacity(self.ranks.len());
856        let serialized_params = bincode::serde::encode_to_vec(params, bincode::config::legacy())?;
857
858        let stop_spawned = |handles: &[ActorMeshStopHandle], reason: &str| {
859            for handle in handles {
860                let _ = handle.stop(reason);
861            }
862        };
863
864        // Each call only schedules a local supervisor that sends a one-way spawn
865        // request. No rank waits for another rank's remote actor initialization.
866        for (rank, proc_ref) in self.ranks.iter().enumerate() {
867            let params = match bincode::serde::decode_from_slice(
868                &serialized_params,
869                bincode::config::legacy(),
870            ) {
871                Ok((params, _)) => params,
872                Err(error) => {
873                    stop_spawned(&stop_handles, "data mesh spawn failed");
874                    return Err(error.into());
875                }
876            };
877            let point = region
878                .extent()
879                .point_of_rank(rank)
880                .expect("proc mesh rank must have a point");
881            let mut environment = cx.instance().actor_environment().clone();
882            if let Err(error) = environment.set(CAST_POINT, point.clone()) {
883                stop_spawned(&stop_handles, "data mesh spawn failed");
884                return Err(Error::Other(error.into()));
885            }
886            // The actor's own environment carries its point, but supervision
887            // events are raised on the spawner's side, so they need a copy.
888            // TODO: unify the actor environment and supervision labels.
889            let mut labels = Flattrs::new();
890            labels.set(CAST_POINT, point);
891            labels.set(ACTOR_MESH_ID, actor_mesh_id.clone());
892            let actor_spawner = proc_ref.spawner();
893            let (actor_ref, stop_handle) = match actor_spawner.spawn_uid_with_link_and_ready(
894                cx,
895                actor_mesh_id.uid().clone(),
896                params,
897                KeepaliveLink::default(),
898                None,
899                environment,
900                labels,
901            ) {
902                Ok(spawned) => spawned,
903                Err(error) => {
904                    stop_spawned(&stop_handles, "data mesh spawn failed");
905                    return Err(Error::Other(error));
906                }
907            };
908            members.push(actor_ref);
909            stop_handles.push(stop_handle.into_any());
910        }
911
912        ActorMesh::try_new_data(actor_mesh_id, region, members, stop_handles)
913    }
914
915    /// Spawn an actor on all procs in this mesh under the given
916    /// [`ActorMeshId`](crate::mesh_id::ActorMeshId), returning a new `ActorMesh`.
917    ///
918    /// This is the underlying implementation used by [`spawn`]; it
919    /// differs only in that the actor mesh id is passed explicitly
920    /// rather than as a `&str`.
921    ///
922    /// Bounds:
923    /// - `A: Actor` - the actor actually runs inside each proc.
924    /// - `A: Referable` - so we can return typed `ActorRef<A>`s
925    ///   inside the `ActorMesh`.
926    /// - `A::Params: RemoteMessage` - spawn parameters must be
927    ///   serializable and routable.
928    /// - `C::A: Handler<MeshFailure>` - in order to spawn actors,
929    ///   the actor must accept messages of type `MeshFailure`. This
930    ///   is delivered when the actors spawned in the mesh have a failure that
931    ///   isn't handled.
932    #[hyperactor::instrument(fields(
933        host_mesh=self.host_mesh_id().map(|id| id.to_string()),
934        proc_mesh=self.id.to_string(),
935        actor_name=name.to_string(),
936    ))]
937    pub async fn spawn_with_name<A: RemoteSpawn, C: context::Actor>(
938        &self,
939        cx: &C,
940        name: ActorMeshId,
941        params: &A::Params,
942        supervision_display_name: Option<String>,
943        is_system_actor: bool,
944    ) -> crate::Result<ActorMesh<A>>
945    where
946        A::Params: RemoteMessage,
947        C::A: Handler<MeshFailure>,
948    {
949        tracing::info!(
950            name = "ProcMeshStatus",
951            status = "ActorMesh::Spawn::Attempt",
952        );
953        tracing::info!(name = "ActorMeshStatus", status = "Spawn::Attempt");
954        let result = self
955            .spawn_with_name_inner(cx, name, params, supervision_display_name, is_system_actor)
956            .await;
957        match &result {
958            Ok(_) => {
959                tracing::info!(
960                    name = "ProcMeshStatus",
961                    status = "ActorMesh::Spawn::Success",
962                );
963                tracing::info!(name = "ActorMeshStatus", status = "Spawn::Success");
964            }
965            Err(error) => {
966                tracing::error!(name = "ProcMeshStatus", status = "ActorMesh::Spawn::Failed", %error);
967                tracing::error!(name = "ActorMeshStatus", status = "Spawn::Failed", %error);
968            }
969        }
970        result
971    }
972
973    async fn spawn_with_name_inner<A: RemoteSpawn, C: context::Actor>(
974        &self,
975        cx: &C,
976        actor_mesh_id: ActorMeshId,
977        params: &A::Params,
978        supervision_display_name: Option<String>,
979        is_system_actor: bool,
980    ) -> crate::Result<ActorMesh<A>>
981    where
982        C::A: Handler<MeshFailure>,
983    {
984        let spawned = self
985            .spawn_mesh_inner(
986                cx,
987                actor_mesh_id,
988                params,
989                supervision_display_name.clone(),
990                is_system_actor,
991            )
992            .await?;
993        match spawned {
994            SpawnedActorMesh::Data(mesh) => Ok(mesh),
995            SpawnedActorMesh::Managed { mut mesh, statuses } => {
996                // System actors are managed by their owning runtime, not an
997                // ActorMeshController.
998                if !is_system_actor {
999                    // Spawn a unique mesh manager for each actor mesh, so the type of the
1000                    // mesh can be preserved.
1001                    let controller: ActorMeshController<A> = ActorMeshController::new(
1002                        ActorMeshControlPlane::new(mesh.deref().clone(), self.clone()),
1003                        supervision_display_name,
1004                        Some(cx.instance().port().bind()),
1005                        statuses,
1006                    );
1007                    // hyperactor::proc AI-3: controller name must include mesh
1008                    // identity for proc-wide ActorAddr uniqueness. A fixed base name alone
1009                    // collides across parents because pid allocation is
1010                    // parent-scoped.
1011                    let controller_name = format!(
1012                        "{}_{}",
1013                        crate::mesh_controller::ACTOR_MESH_CONTROLLER_NAME,
1014                        mesh.id()
1015                    );
1016                    let controller = cx.spawn_with_label(&controller_name, controller);
1017                    // Controller and ActorMesh both depend on references from each other, break
1018                    // the cycle by setting the controller after the fact.
1019                    mesh.set_controller(Some(controller.bind()));
1020                }
1021                Ok(mesh)
1022            }
1023        }
1024    }
1025
1026    async fn spawn_mesh_inner<A: RemoteSpawn, C: context::Actor>(
1027        &self,
1028        cx: &C,
1029        actor_mesh_id: ActorMeshId,
1030        params: &A::Params,
1031        supervision_display_name: Option<String>,
1032        is_system_actor: bool,
1033    ) -> crate::Result<SpawnedActorMesh<A>> {
1034        if hyperactor_config::global::get(USE_DIRECT_SPAWN)
1035            && !is_system_actor
1036            && actor_mesh_id.uid().is_instance()
1037        {
1038            return self
1039                .spawn_data_mesh_inner(cx, params, actor_mesh_id)
1040                .map(SpawnedActorMesh::Data);
1041        }
1042
1043        let (mesh, statuses) = self
1044            .spawn_managed_mesh_inner(cx, actor_mesh_id, params, supervision_display_name)
1045            .await?;
1046        Ok(SpawnedActorMesh::Managed { mesh, statuses })
1047    }
1048
1049    /// The managed spawn path used by singleton services and by ordinary
1050    /// spawns when [`USE_DIRECT_SPAWN`] is disabled: the create/update cast,
1051    /// the accumulator port, the bounded
1052    /// `GetRankStatus::wait` with terminal / timeout mapping, mesh
1053    /// materialization, and telemetry. It carries **no**
1054    /// `C::A: Handler<MeshFailure>` bound — that is required only by the
1055    /// controller construction (`cx.instance().port().bind()`), which the
1056    /// bounded caller layers on afterward. Returns the mesh together with the
1057    /// final `StatusMesh` (moved into the controller for non-system actors;
1058    /// discarded otherwise — system actors and the controllerless path).
1059    async fn spawn_managed_mesh_inner<A: RemoteSpawn, C: context::Actor>(
1060        &self,
1061        cx: &C,
1062        actor_mesh_id: ActorMeshId,
1063        params: &A::Params,
1064        supervision_display_name: Option<String>,
1065    ) -> crate::Result<(ActorMesh<A>, crate::StatusMesh)> {
1066        let remote = Remote::collect();
1067        // `RemoteSpawn` + `register_spawnable!(A)` ensure that `A` has a
1068        // `SpawnableActor` entry in this registry, so
1069        // `name_of::<A>()` can resolve its global type name.
1070        let actor_type = remote
1071            .name_of::<A>()
1072            .ok_or(Error::ActorTypeNotRegistered(type_name::<A>().to_string()))?
1073            .to_string();
1074
1075        let serialized_params = bincode::serde::encode_to_vec(params, bincode::config::legacy())?;
1076        self.proc_agent_mesh.cast(
1077            cx,
1078            resource::CreateOrUpdate::<proc_agent::ActorSpec> {
1079                id: actor_mesh_id.resource_id().clone(),
1080                rank: Default::default(),
1081                spec: proc_agent::ActorSpec {
1082                    actor_type: actor_type.clone(),
1083                    params_data: serialized_params.clone(),
1084                    actor_environment: cx.instance().actor_environment().clone(),
1085                },
1086            },
1087        )?;
1088
1089        let region = self.region().clone();
1090        // Open an accum port that *receives overlays* and *emits full
1091        // meshes*.
1092        //
1093        // NOTE: Mailbox initializes the accumulator state via
1094        // `Default`, which is an *empty* ValueMesh (0 ranks). Our
1095        // Accumulator<ValueMesh<T>> implementation detects this on
1096        // the first update and replaces it with the caller-supplied
1097        // template (the `self` passed into open_idle_flush_accum_port), which we
1098        // seed here as "full NotExist over the target region".
1099        let (port, rx) = cx.mailbox().open_idle_flush_accum_port(
1100            // Initial state for the accumulator: full mesh seeded to
1101            // NotExist.
1102            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1103            IdleFlushReducerOpts {
1104                idle_timeout: Duration::from_millis(50),
1105                abandon_timeout: Duration::from_secs(30),
1106                expected_updates_per_destination: NonZeroUsize::MIN,
1107            },
1108        );
1109
1110        let mut reply = port.bind();
1111        // If this proc dies or some other issue renders the reply undeliverable,
1112        // the reply does not need to be returned to the sender.
1113        reply.return_undeliverable(false);
1114        // Send a message to all ranks. They reply with overlays to
1115        // `port`.
1116        self.proc_agent_mesh.cast(
1117            cx,
1118            resource::GetRankStatus {
1119                id: actor_mesh_id.resource_id().clone(),
1120                // Filled by the cast layer with this recipient's dense view rank.
1121                rank: Default::default(),
1122                reply,
1123            },
1124        )?;
1125
1126        let start_time = tokio::time::Instant::now();
1127
1128        // Wait for all ranks to report a terminal or running status.
1129        // If any proc reports a failure (via supervision) or the mesh
1130        // times out, `wait()` returns Err with the final snapshot.
1131        //
1132        // `rx` is the accumulator output stream: each time reduced
1133        // overlays are applied, it emits a new StatusMesh snapshot.
1134        // `wait()` loops on it, deciding when the stream is
1135        // "complete" (no more NotExist) or times out.
1136        let statuses = match GetRankStatus::wait(
1137            rx,
1138            self.ranks.len(),
1139            hyperactor_config::global::get(ACTOR_SPAWN_MAX_IDLE),
1140            region.clone(), // fallback
1141        )
1142        .await
1143        {
1144            Ok(statuses) => {
1145                // Spawn succeeds only if no rank has reported a
1146                // supervision/terminal state. This preserves the old
1147                // `first_terminating().is_none()` semantics.
1148                let has_terminating = statuses.values().any(|s| s.is_terminating());
1149                if !has_terminating {
1150                    Ok(statuses)
1151                } else {
1152                    let legacy = mesh_to_rankedvalues_with_default(
1153                        &statuses,
1154                        Status::NotExist,
1155                        Status::is_not_exist,
1156                        self.ranks.len(),
1157                    );
1158                    Err(Error::ActorSpawnError { statuses: legacy })
1159                }
1160            }
1161            Err(complete) => {
1162                // Fill remaining ranks with a timeout status, now
1163                // handled via the legacy shim.
1164                let elapsed = start_time.elapsed();
1165                let legacy = mesh_to_rankedvalues_with_default(
1166                    &complete,
1167                    Status::Timeout(elapsed),
1168                    Status::is_not_exist,
1169                    self.ranks.len(),
1170                );
1171                Err(Error::ActorSpawnError { statuses: legacy })
1172            }
1173        }?;
1174
1175        let actor_mesh_members = Arc::new(
1176            self.ranks
1177                .iter()
1178                .map(|rank| rank.actor_addr(&actor_mesh_id))
1179                .collect_mesh::<ValueMesh<_>>(self.region().clone())
1180                .map_err(|error| crate::Error::ConfigurationError(error.into()))?,
1181        );
1182
1183        let mesh = ActorMesh::new(
1184            self.clone(),
1185            actor_mesh_id.clone(),
1186            None,
1187            actor_mesh_members,
1188        );
1189        // Notify telemetry that an actor mesh was created.
1190        {
1191            let id_str = actor_mesh_id.to_string();
1192
1193            // Hash the proc mesh id for parent_mesh_id.
1194            let parent_mesh_id_hash = hash_to_u64(self.id());
1195            let mesh_id_hash = telemetry_actor_mesh_id(self.id(), &actor_mesh_id);
1196
1197            hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
1198                id: mesh_id_hash,
1199                timestamp: std::time::SystemTime::now(),
1200                class: supervision_display_name
1201                    .as_deref()
1202                    .and_then(python_class_from_supervision_name)
1203                    .unwrap_or(actor_type),
1204                given_name: actor_mesh_id
1205                    .display_label()
1206                    .map(|l| l.as_str())
1207                    .unwrap_or("unnamed")
1208                    .to_string(),
1209                full_name: id_str,
1210                shape_json: serde_json::to_string(&self.region().extent()).unwrap_or_default(),
1211                parent_mesh_id: Some(parent_mesh_id_hash),
1212                parent_view_json: serde_json::to_string(self.region()).ok(),
1213            });
1214
1215            // Notify telemetry of each actor in this mesh. The rank is
1216            // the actor's position within the actor mesh (not the proc's
1217            // create_rank, which reflects the original unsliced mesh).
1218            let now = std::time::SystemTime::now();
1219            for (rank, proc_ref) in self.ranks.iter().enumerate() {
1220                let display_name = supervision_display_name.as_ref().map(|sdn| {
1221                    let point = self.region().extent().point_of_rank(rank).unwrap();
1222                    crate::actor_display_name(sdn, &point)
1223                });
1224                let actor_addr = proc_ref.actor_addr(&actor_mesh_id);
1225                hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
1226                    id: hyperactor_telemetry::hash_to_u64(actor_addr.id()),
1227                    timestamp: now,
1228                    mesh_id: mesh_id_hash,
1229                    rank: rank as u64,
1230                    full_name: actor_addr.to_string(),
1231                    display_name,
1232                });
1233            }
1234        }
1235
1236        Ok((mesh, statuses))
1237    }
1238
1239    /// Send stop actors message to all mesh agents for a specific actor mesh id.
1240    #[hyperactor::instrument(fields(
1241        host_mesh = self.host_mesh_id().map(|id| id.to_string()),
1242        proc_mesh = self.id.to_string(),
1243        actor_mesh = actor_mesh_id.to_string(),
1244    ))]
1245    pub(crate) async fn stop_actor_by_id(
1246        &self,
1247        cx: &impl context::Actor,
1248        actor_mesh_id: ActorMeshId,
1249        reason: String,
1250    ) -> crate::Result<ValueMesh<Status>> {
1251        tracing::info!(name = "ProcMeshStatus", status = "ActorMesh::Stop::Attempt");
1252        tracing::info!(name = "ActorMeshStatus", status = "Stop::Attempt");
1253        let result = self.stop_actor_by_id_inner(cx, actor_mesh_id, reason).await;
1254        match &result {
1255            Ok(_) => {
1256                tracing::info!(name = "ProcMeshStatus", status = "ActorMesh::Stop::Success");
1257                tracing::info!(name = "ActorMeshStatus", status = "Stop::Success");
1258            }
1259            Err(error) => {
1260                tracing::error!(name = "ProcMeshStatus", status = "ActorMesh::Stop::Failed", %error);
1261                tracing::error!(name = "ActorMeshStatus", status = "Stop::Failed", %error);
1262            }
1263        }
1264        result
1265    }
1266
1267    async fn stop_actor_by_id_inner(
1268        &self,
1269        cx: &impl context::Actor,
1270        actor_mesh_id: ActorMeshId,
1271        reason: String,
1272    ) -> crate::Result<ValueMesh<Status>> {
1273        let region = self.region().clone();
1274        self.proc_agent_mesh.cast(
1275            cx,
1276            resource::Stop {
1277                id: actor_mesh_id.resource_id().clone(),
1278                reason,
1279            },
1280        )?;
1281
1282        // Open an accum port that *receives overlays* and *emits full
1283        // meshes*.
1284        //
1285        // NOTE: Mailbox initializes the accumulator state via
1286        // `Default`, which is an *empty* ValueMesh (0 ranks). Our
1287        // Accumulator<ValueMesh<T>> implementation detects this on
1288        // the first update and replaces it with the caller-supplied
1289        // template (the `self` passed into open_idle_flush_accum_port), which we
1290        // seed here as "full NotExist over the target region".
1291        let (port, rx) = cx.mailbox().open_idle_flush_accum_port(
1292            // Initial state for the accumulator: full mesh seeded to
1293            // NotExist.
1294            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1295            IdleFlushReducerOpts {
1296                idle_timeout: Duration::from_millis(50),
1297                abandon_timeout: Duration::from_secs(30),
1298                expected_updates_per_destination: NonZeroUsize::MIN,
1299            },
1300        );
1301        // Use WaitRankStatus instead of GetRankStatus so agents defer
1302        // their reply until the actor reaches terminal state, rather
1303        // than replying immediately with Stopping.
1304        self.proc_agent_mesh.cast(
1305            cx,
1306            resource::WaitRankStatus {
1307                id: actor_mesh_id.resource_id().clone(),
1308                // Filled by the cast layer with this recipient's dense view rank.
1309                rank: Default::default(),
1310                min_status: Status::Stopped,
1311                reply: port.bind(),
1312            },
1313        )?;
1314        let start_time = tokio::time::Instant::now();
1315
1316        // Reuse actor spawn idle time.
1317        let max_idle_time = hyperactor_config::global::get(ACTOR_SPAWN_MAX_IDLE);
1318        match GetRankStatus::wait(
1319            rx,
1320            self.ranks.len(),
1321            max_idle_time,
1322            region.clone(), // fallback mesh if nothing arrives
1323        )
1324        .await
1325        {
1326            Ok(statuses) => {
1327                // Check that all actors are in a terminating state (Stopping
1328                // or beyond). Failed is ok, because one of these actors may
1329                // have failed earlier and we're trying to stop the others.
1330                let all_stopped = statuses.values().all(|s| s.is_terminating());
1331                if all_stopped {
1332                    Ok(statuses)
1333                } else {
1334                    let legacy = mesh_to_rankedvalues_with_default(
1335                        &statuses,
1336                        Status::NotExist,
1337                        Status::is_not_exist,
1338                        self.ranks.len(),
1339                    );
1340                    Err(Error::ActorStopError { statuses: legacy })
1341                }
1342            }
1343            Err(complete) => {
1344                // Fill remaining ranks with a timeout status via the
1345                // legacy shim.
1346                let legacy = mesh_to_rankedvalues_with_default(
1347                    &complete,
1348                    Status::Timeout(start_time.elapsed()),
1349                    Status::is_not_exist,
1350                    self.ranks.len(),
1351                );
1352                Err(Error::ActorStopError { statuses: legacy })
1353            }
1354        }
1355    }
1356}
1357
1358impl fmt::Display for ProcMeshRef {
1359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1360        write!(f, "{}{{{}}}", self.id, self.region)
1361    }
1362}
1363
1364impl view::Ranked for ProcMeshRef {
1365    type Item = ProcRef;
1366
1367    fn region(&self) -> &Region {
1368        &self.region
1369    }
1370
1371    fn get(&self, rank: usize) -> Option<&Self::Item> {
1372        self.ranks.get(rank)
1373    }
1374}
1375
1376impl view::RankedSliceable for ProcMeshRef {
1377    /// Return a pure slice of this proc mesh.
1378    ///
1379    /// The returned `ProcMeshRef` contains the selected dense `ProcRef`s, and
1380    /// its `proc_agent_mesh` carries a lazy actor-mesh slice descriptor.
1381    fn sliced(&self, region: Region) -> Self {
1382        debug_assert!(region.is_subset(view::Ranked::region(self)));
1383        let ranks = self
1384            .region()
1385            .remap(&region)
1386            .unwrap()
1387            .map(|index| self.get(index).unwrap().clone())
1388            .collect::<Vec<_>>();
1389
1390        Self {
1391            id: self.id.clone(),
1392            proc_agent_mesh: self.proc_agent_mesh.sliced(region.clone()),
1393            region,
1394            ranks: Arc::new(ranks),
1395            host_mesh: self.host_mesh.clone(),
1396        }
1397    }
1398}
1399
1400/// Extract a Python class display name from a supervision display name.
1401///
1402/// The supervision display name format is `{instance}.<{module}.{ClassName} {mesh_name}>`.
1403/// Returns `"Python<ClassName>"` if the format matches, `None` otherwise.
1404///
1405/// Scope note: this function is used only by telemetry
1406/// (`MeshEvent.class`), which needs the Python class as a
1407/// structured string and has no structured carrier today. It is
1408/// not on the supervision rendering path.
1409///
1410/// TODO: retained only because the telemetry path needs a
1411/// structured Python-class string and this is the only available
1412/// source. A follow-up should add a structured carrier (e.g. an
1413/// `actor_class` field on `ActorSupervisionEvent`, or a dedicated
1414/// telemetry-side field) and delete this function.
1415fn python_class_from_supervision_name(sdn: &str) -> Option<String> {
1416    let inner = sdn.rsplit_once('<')?.1.strip_suffix('>')?;
1417    let qualified = inner.split_whitespace().next()?;
1418    let class_name = qualified.rsplit_once('.')?.1;
1419    Some(format!("Python<{class_name}>"))
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    #[cfg(fbcode_build)]
1425    use std::collections::HashMap;
1426    #[cfg(fbcode_build)]
1427    use std::collections::HashSet;
1428    #[cfg(fbcode_build)]
1429    use std::net::IpAddr;
1430    #[cfg(fbcode_build)]
1431    use std::net::Ipv4Addr;
1432    #[cfg(fbcode_build)]
1433    use std::net::SocketAddr;
1434    #[cfg(fbcode_build)]
1435    use std::ops::Deref;
1436    #[cfg(fbcode_build)]
1437    use std::time::Duration;
1438
1439    #[cfg(fbcode_build)]
1440    use hyperactor::ActorEnvironment;
1441    #[cfg(fbcode_build)]
1442    use hyperactor::ActorRef;
1443    #[cfg(fbcode_build)]
1444    use hyperactor::Endpoint as _;
1445    #[cfg(fbcode_build)]
1446    use hyperactor::Instance;
1447    #[cfg(fbcode_build)]
1448    use hyperactor::ProcAddr;
1449    #[cfg(fbcode_build)]
1450    use hyperactor::accum::IdleFlushReducerOpts;
1451    #[cfg(fbcode_build)]
1452    use hyperactor::channel::BindSpec;
1453    #[cfg(fbcode_build)]
1454    use hyperactor::channel::ChannelAddr;
1455    #[cfg(fbcode_build)]
1456    use hyperactor::context::Mailbox;
1457    #[cfg(fbcode_build)]
1458    use hyperactor::id::Label;
1459    #[cfg(fbcode_build)]
1460    use hyperactor_config::NonZeroUsize;
1461    #[cfg(fbcode_build)]
1462    use ndslice::ViewExt as _;
1463    #[cfg(fbcode_build)]
1464    use ndslice::extent;
1465    #[cfg(fbcode_build)]
1466    use ndslice::view::Ranked as _;
1467    #[cfg(fbcode_build)]
1468    use timed_test::assert_no_process_leak;
1469    #[cfg(fbcode_build)]
1470    use timed_test::async_timed_test;
1471    #[cfg(fbcode_build)]
1472    use uuid::Uuid;
1473
1474    #[cfg(fbcode_build)]
1475    use super::ACTOR_SPAWN_MAX_IDLE;
1476    #[cfg(fbcode_build)]
1477    use super::ProcMeshRef;
1478    #[cfg(fbcode_build)]
1479    use super::ProcRef;
1480    #[cfg(fbcode_build)]
1481    use super::USE_DIRECT_SPAWN;
1482    #[cfg(fbcode_build)]
1483    use crate::ActorMesh;
1484    #[cfg(fbcode_build)]
1485    use crate::casting::CAST_POINT;
1486    #[cfg(fbcode_build)]
1487    use crate::host_mesh::HostMesh;
1488    #[cfg(fbcode_build)]
1489    use crate::host_mesh::PROC_SPAWN_MAX_IDLE;
1490    #[cfg(fbcode_build)]
1491    use crate::mesh_id::ActorMeshId;
1492    #[cfg(fbcode_build)]
1493    use crate::mesh_id::ProcMeshId;
1494    #[cfg(fbcode_build)]
1495    use crate::proc_agent::PROC_AGENT_ACTOR_NAME;
1496    #[cfg(fbcode_build)]
1497    use crate::proc_agent::ProcAgent;
1498    #[cfg(fbcode_build)]
1499    use crate::resource::RankedValues;
1500    #[cfg(fbcode_build)]
1501    use crate::resource::Status;
1502    #[cfg(fbcode_build)]
1503    use crate::testactor;
1504    #[cfg(fbcode_build)]
1505    use crate::testing;
1506    #[cfg(fbcode_build)]
1507    use crate::transport::DEFAULT_TRANSPORT;
1508
1509    #[cfg(fbcode_build)]
1510    async fn execute_spawn_actor() {
1511        hyperactor_telemetry::initialize_logging(hyperactor_telemetry::DefaultTelemetryClock {});
1512
1513        let instance = testing::instance();
1514
1515        let mut hm = testing::host_mesh(2).await;
1516        let proc_mesh = hm
1517            .spawn(&instance, "test", extent!(gpus = 1), None, None)
1518            .await
1519            .unwrap();
1520        let actor_mesh = proc_mesh.spawn(instance, "test", &()).await.unwrap();
1521        testactor::assert_mesh_shape(actor_mesh).await;
1522
1523        let _ = hm.shutdown(instance).await;
1524    }
1525
1526    #[async_timed_test(timeout_secs = 300)]
1527    #[cfg(fbcode_build)]
1528    async fn spawn_data_uses_default_actor_spawner() {
1529        let instance = testing::instance();
1530        let mut hm = testing::host_mesh(1).await;
1531        let proc_mesh = hm
1532            .spawn(instance, "spawn_data", extent!(gpus = 2), None, None)
1533            .await
1534            .expect("spawn proc mesh");
1535
1536        let mut actor_mesh: ActorMesh<testactor::TestActor> = proc_mesh
1537            .spawn_data(
1538                instance,
1539                ActorMeshId::instance(Label::strip("default_spawner")),
1540                &(),
1541            )
1542            .await
1543            .expect("spawn data actor mesh through default spawner");
1544        assert_eq!(actor_mesh.region().num_ranks(), 2);
1545        let actor_uids: HashSet<_> = actor_mesh
1546            .values()
1547            .map(|actor| actor.actor_addr().id().uid().clone())
1548            .collect();
1549        assert_eq!(
1550            actor_uids.len(),
1551            1,
1552            "all data mesh actors should share one uid"
1553        );
1554
1555        // Message every rank before any of them can have started.
1556        let (port, mut rx) = instance.mailbox().open_port();
1557        for actor in actor_mesh.values() {
1558            actor.post(instance, testactor::GetActorId(port.bind()));
1559        }
1560        let mut replied = HashSet::new();
1561        for _ in 0..actor_mesh.region().num_ranks() {
1562            let (actor_addr, _seq) = tokio::time::timeout(Duration::from_secs(60), rx.recv())
1563                .await
1564                .expect("timed out waiting for a rank to reply")
1565                .expect("reply port closed");
1566            replied.insert(actor_addr);
1567        }
1568        assert_eq!(
1569            replied,
1570            actor_mesh
1571                .values()
1572                .map(|actor| actor.actor_addr().clone())
1573                .collect::<HashSet<_>>(),
1574            "every rank should receive the message sent before it started"
1575        );
1576
1577        let mut actor_mesh_clone = actor_mesh.clone();
1578        actor_mesh
1579            .stop(instance, "test complete".to_string())
1580            .await
1581            .expect("stop data actor mesh");
1582        actor_mesh_clone
1583            .stop(instance, "test complete".to_string())
1584            .await
1585            .expect("stopping a data actor mesh clone should be idempotent");
1586
1587        hm.shutdown(instance)
1588            .await
1589            .expect("host mesh shutdown should complete");
1590    }
1591
1592    #[async_timed_test(timeout_secs = 300)]
1593    #[cfg(fbcode_build)]
1594    async fn spawn_uses_direct_path_when_configured_but_services_remain_managed() {
1595        let config = hyperactor_config::global::lock();
1596        let _mode = config.override_key(USE_DIRECT_SPAWN, true);
1597        let instance = testing::instance();
1598        let mut hm = testing::host_mesh(1).await;
1599        let proc_mesh = hm
1600            .spawn(
1601                instance,
1602                "configured_data_spawn",
1603                extent!(gpus = 2),
1604                None,
1605                None,
1606            )
1607            .await
1608            .expect("spawn proc mesh");
1609
1610        let mut actor_mesh: ActorMesh<testactor::TestActor> = proc_mesh
1611            .spawn(instance, "configured_data_actor", &())
1612            .await
1613            .expect("initiate configured data actor mesh spawn");
1614        assert!(
1615            actor_mesh.as_managed().is_none(),
1616            "configured instance spawn should return a data mesh"
1617        );
1618        assert_eq!(
1619            actor_mesh.region().num_ranks(),
1620            2,
1621            "direct spawn should cover all proc mesh ranks"
1622        );
1623        assert!(
1624            actor_mesh.values().all(|actor| {
1625                actor.actor_addr().label().map(|label| label.as_str())
1626                    == Some("configured_data_actor")
1627            }),
1628            "direct spawn should preserve the requested actor label"
1629        );
1630
1631        let service: ActorMesh<testactor::TestActor> = proc_mesh
1632            .spawn_service(instance, "configured_managed_service", &())
1633            .await
1634            .expect("spawn managed service");
1635        assert!(
1636            service.as_managed().is_some(),
1637            "service spawn should ignore the data mesh flag"
1638        );
1639
1640        let system_actor: ActorMesh<testactor::TestActor> = proc_mesh
1641            .spawn_with_name(
1642                instance,
1643                ActorMeshId::instance(Label::strip("configured_managed_system_actor")),
1644                &(),
1645                None,
1646                true,
1647            )
1648            .await
1649            .expect("spawn managed system actor");
1650        assert!(
1651            system_actor.as_managed().is_some(),
1652            "system actor spawn should ignore the direct spawn flag"
1653        );
1654
1655        actor_mesh
1656            .stop(instance, "test complete".to_string())
1657            .await
1658            .expect("stop configured data actor mesh");
1659        hm.shutdown(instance)
1660            .await
1661            .expect("host mesh shutdown should complete");
1662    }
1663
1664    #[async_timed_test(timeout_secs = 300)]
1665    #[cfg(fbcode_build)]
1666    async fn actor_environment_survives_two_remote_hops_and_shapes_cast_point() {
1667        const SENTINEL: u64 = 0xA0FE;
1668        let operation_timeout = Duration::from_secs(120);
1669
1670        let config = hyperactor_config::global::lock();
1671        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, operation_timeout);
1672        let _guard2 = config.override_key(
1673            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1674            operation_timeout,
1675        );
1676
1677        let base = testing::instance();
1678        let persistent_point = extent!(persistent = 3).point_of_rank(2).unwrap();
1679        let mut environment = ActorEnvironment::default();
1680        environment
1681            .set(testactor::ACTOR_ENVIRONMENT_TEST_TAG, SENTINEL)
1682            .expect("seed persistent tag");
1683        environment
1684            .set(CAST_POINT, persistent_point.clone())
1685            .expect("seed persistent cast-point collision");
1686        let root = base
1687            .proc()
1688            .actor_instance_in_environment::<testing::TestRootClient>(
1689                &format!("actor_environment_root_{}", Uuid::now_v7()),
1690                environment,
1691            )
1692            .expect("create seeded root instance");
1693
1694        let mut hm = testing::host_mesh(1).await;
1695        let proc_mesh = hm
1696            .spawn(
1697                &root.instance,
1698                "actor_environment",
1699                extent!(gpus = 2),
1700                None,
1701                None,
1702            )
1703            .await
1704            .expect("spawn proc mesh");
1705        let first_hop = proc_mesh
1706            .range("gpus", 0..1)
1707            .expect("first proc-mesh slice");
1708        let second_hop = proc_mesh
1709            .range("gpus", 1..2)
1710            .expect("second proc-mesh slice");
1711        let first_point = first_hop
1712            .region()
1713            .extent()
1714            .point_of_rank(0)
1715            .expect("first slice has rank zero");
1716        let second_point = second_hop
1717            .region()
1718            .extent()
1719            .point_of_rank(0)
1720            .expect("second slice has rank zero");
1721        let first_proc_addr = first_hop
1722            .get(0)
1723            .expect("first proc")
1724            .proc_addr()
1725            .to_string();
1726        let second_proc_addr = second_hop
1727            .get(0)
1728            .expect("second proc")
1729            .proc_addr()
1730            .to_string();
1731        assert_ne!(
1732            first_proc_addr, second_proc_addr,
1733            "the two hops must target distinct proc addresses",
1734        );
1735        assert!(
1736            persistent_point != first_point && persistent_point != second_point,
1737            "seeded and assigned cast points must be distinct",
1738        );
1739
1740        let (reply, mut replies) = root
1741            .instance
1742            .open_port::<testactor::ActorEnvironmentObservation>();
1743        first_hop
1744            .spawn_controllerless_service::<testactor::ActorEnvironmentProbe, _>(
1745                &root.instance,
1746                "actor_environment_hop_1",
1747                &testactor::ActorEnvironmentProbeParams {
1748                    label: "hop_1".to_string(),
1749                    reply: reply.bind(),
1750                    nested: Some((
1751                        second_hop,
1752                        "actor_environment_hop_2".to_string(),
1753                        "hop_2".to_string(),
1754                    )),
1755                },
1756            )
1757            .await
1758            .expect("spawn first environment probe");
1759
1760        let expected = HashMap::from([
1761            ("hop_1".to_string(), (first_point, first_proc_addr)),
1762            ("hop_2".to_string(), (second_point, second_proc_addr)),
1763        ]);
1764        let mut seen = HashSet::new();
1765        let reply_deadline = tokio::time::Instant::now() + operation_timeout;
1766        for _ in 0..2 {
1767            let observed = tokio::time::timeout_at(reply_deadline, replies.recv())
1768                .await
1769                .expect("environment probe reply timed out")
1770                .expect("environment probe reply port closed");
1771            let (expected_point, expected_proc_addr) = expected
1772                .get(&observed.label)
1773                .unwrap_or_else(|| panic!("unexpected probe label {}", observed.label));
1774            assert!(
1775                seen.insert(observed.label.clone()),
1776                "received duplicate observation from {}",
1777                observed.label,
1778            );
1779            assert_eq!(
1780                observed.persistent_tag,
1781                Some(SENTINEL),
1782                "{} must inherit the persistent tag",
1783                observed.label,
1784            );
1785            assert_eq!(
1786                observed.constructor_tag,
1787                Some(SENTINEL),
1788                "{} constructor must see the persistent tag",
1789                observed.label,
1790            );
1791            assert_eq!(
1792                observed.stored_point,
1793                Some(expected_point.clone()),
1794                "{} must store its caller-shaped construction point",
1795                observed.label,
1796            );
1797            assert_eq!(
1798                observed.constructor_point,
1799                Some(expected_point.clone()),
1800                "{} constructor must see its real rank-local cast point",
1801                observed.label,
1802            );
1803            assert_eq!(
1804                &observed.proc_addr, expected_proc_addr,
1805                "{} must run on its requested proc",
1806                observed.label,
1807            );
1808        }
1809        assert_eq!(seen.len(), expected.len(), "both hops must report");
1810
1811        hm.shutdown(&root.instance)
1812            .await
1813            .expect("host mesh shutdown should complete");
1814    }
1815
1816    #[async_timed_test(timeout_secs = 300)]
1817    #[cfg(fbcode_build)]
1818    async fn direct_spawn_shapes_cast_point() {
1819        const SENTINEL: u64 = 0xD1EC;
1820        let config = hyperactor_config::global::lock();
1821        let _mode = config.override_key(USE_DIRECT_SPAWN, true);
1822
1823        let base = testing::instance();
1824        let persistent_point = extent!(persistent = 3).point_of_rank(2).unwrap();
1825        let mut environment = ActorEnvironment::default();
1826        environment
1827            .set(testactor::ACTOR_ENVIRONMENT_TEST_TAG, SENTINEL)
1828            .expect("seed persistent tag");
1829        environment
1830            .set(CAST_POINT, persistent_point.clone())
1831            .expect("seed persistent cast-point collision");
1832        let root = base
1833            .proc()
1834            .actor_instance_in_environment::<testing::TestRootClient>(
1835                &format!("direct_spawn_root_{}", Uuid::now_v7()),
1836                environment,
1837            )
1838            .expect("create seeded root instance");
1839
1840        let mut hm = testing::host_mesh(1).await;
1841        let proc_mesh = hm
1842            .spawn(
1843                &root.instance,
1844                "direct_spawn_env",
1845                extent!(gpus = 2),
1846                None,
1847                None,
1848            )
1849            .await
1850            .expect("spawn proc mesh");
1851        let expected: HashMap<_, _> = (0..proc_mesh.region().num_ranks())
1852            .map(|rank| {
1853                (
1854                    proc_mesh.get(rank).expect("proc").proc_addr().to_string(),
1855                    proc_mesh.region().extent().point_of_rank(rank).unwrap(),
1856                )
1857            })
1858            .collect();
1859        assert!(
1860            !expected.values().any(|point| *point == persistent_point),
1861            "seeded and assigned cast points must be distinct",
1862        );
1863
1864        let (reply, mut replies) = root
1865            .instance
1866            .open_port::<testactor::ActorEnvironmentObservation>();
1867        let mut actor_mesh: ActorMesh<testactor::ActorEnvironmentProbe> = proc_mesh
1868            .spawn_with_name(
1869                &root.instance,
1870                ActorMeshId::instance(Label::strip("direct_spawn_probe")),
1871                &testactor::ActorEnvironmentProbeParams {
1872                    label: "direct".to_string(),
1873                    reply: reply.bind(),
1874                    nested: None,
1875                },
1876                None,
1877                false,
1878            )
1879            .await
1880            .expect("spawn direct environment probes");
1881        assert!(
1882            actor_mesh.as_managed().is_none(),
1883            "configured instance spawn should return a data mesh"
1884        );
1885
1886        let mut seen = HashSet::new();
1887        for _ in 0..expected.len() {
1888            let observed = tokio::time::timeout(Duration::from_secs(120), replies.recv())
1889                .await
1890                .expect("environment probe reply timed out")
1891                .expect("environment probe reply port closed");
1892            let expected_point = expected
1893                .get(&observed.proc_addr)
1894                .unwrap_or_else(|| panic!("unexpected probe proc {}", observed.proc_addr));
1895            assert!(
1896                seen.insert(observed.proc_addr.clone()),
1897                "received duplicate observation from {}",
1898                observed.proc_addr,
1899            );
1900            assert_eq!(
1901                observed.constructor_tag,
1902                Some(SENTINEL),
1903                "constructor must see the persistent tag"
1904            );
1905            assert_eq!(
1906                observed.constructor_point.as_ref(),
1907                Some(expected_point),
1908                "constructor must see its rank-local point, not the caller's"
1909            );
1910            assert_eq!(
1911                observed.stored_point.as_ref(),
1912                Some(expected_point),
1913                "instance must store its rank-local point"
1914            );
1915        }
1916
1917        actor_mesh
1918            .stop(&root.instance, "test complete".to_string())
1919            .await
1920            .expect("stop direct environment probes");
1921        hm.shutdown(&root.instance)
1922            .await
1923            .expect("host mesh shutdown should complete");
1924    }
1925
1926    /// Reuse a per-proc singleton service across two overlapping proc-mesh views
1927    /// whose shared proc is renumbered — a whole view and a `gpus` sub-slice, so
1928    /// the shared proc (gpu 1) is rank 1 in the whole and rank 0 in the slice.
1929    /// `spawn_service` creates one deduped actor per proc, so the slice must
1930    /// keep the whole's actor on the shared proc.
1931    ///
1932    /// One `host_mesh(1)` fixture drives three uniquely-named scenarios to keep
1933    /// the harness footprint minimal (one Tokio runtime, one host process, no
1934    /// process-global config override): reuse whole-then-slice, reuse
1935    /// slice-then-whole, and a deferred `WaitRankStatus` over the slice. Each
1936    /// spawn must resolve (a reused spawn positions its readiness overlay at the
1937    /// consuming-view rank, so the accumulator completes), the slice must
1938    /// reference the whole's shared actor with no new actors, and a Stopped-waiter
1939    /// registered while the service runs must flush at slice-local ranks.
1940    /// The assertions exercise the ProcAgent readiness path; the per-view
1941    /// controllers remain idle because this test does not trigger supervision.
1942    /// Snapshot assertions also verify that both reuse orders return actor state
1943    /// in the queried view's rank order.
1944    #[async_timed_test(timeout_secs = 120)]
1945    #[cfg(fbcode_build)]
1946    async fn test_singleton_service_reuse_over_overlapping_views() {
1947        let instance = testing::instance();
1948        let mut hm = testing::host_mesh(1).await;
1949        let proc_mesh = hm
1950            .spawn(&instance, "test", extent!(gpus = 2), None, None)
1951            .await
1952            .unwrap();
1953        let whole = proc_mesh.deref().clone();
1954        let slice = proc_mesh.range("gpus", 1..2).unwrap();
1955
1956        // The slice reuses the whole's actor on the shared proc; no new actors.
1957        fn assert_shared(
1958            whole_mesh: &ActorMesh<testactor::TestActor>,
1959            slice_mesh: &ActorMesh<testactor::TestActor>,
1960        ) {
1961            let whole_ids: HashSet<_> = whole_mesh
1962                .values()
1963                .map(|actor_ref| actor_ref.actor_addr().id().clone())
1964                .collect();
1965            let slice_ids: HashSet<_> = slice_mesh
1966                .values()
1967                .map(|actor_ref| actor_ref.actor_addr().id().clone())
1968                .collect();
1969            assert_eq!(whole_ids.len(), 2, "whole view spans two procs");
1970            assert_eq!(slice_ids.len(), 1, "slice spans one proc");
1971            assert!(
1972                slice_ids.is_subset(&whole_ids),
1973                "slice reuses the shared proc's actor: {slice_ids:?} vs {whole_ids:?}",
1974            );
1975            assert_eq!(
1976                whole_ids.union(&slice_ids).count(),
1977                2,
1978                "no new actors created for the overlapping slice",
1979            );
1980        }
1981
1982        // Scenario 1: spawn over the whole, then reuse over the renumbered slice.
1983        let w1 = whole
1984            .spawn_service::<testactor::TestActor, _>(instance, "svc_whole_first", &())
1985            .await
1986            .unwrap();
1987        let s1 = slice
1988            .spawn_service::<testactor::TestActor, _>(instance, "svc_whole_first", &())
1989            .await
1990            .unwrap();
1991        assert_shared(&w1, &s1);
1992        let s1_states = slice.actor_states(instance, s1.id().clone()).await.unwrap();
1993        for rank in 0..slice.region().num_ranks() {
1994            assert_eq!(
1995                s1_states
1996                    .get(rank)
1997                    .and_then(|state| state.state.as_ref())
1998                    .map(|state| &state.actor_id),
1999                s1.get(rank).map(|actor| actor.actor_addr()),
2000                "whole-first state is positioned at slice rank {rank}",
2001            );
2002        }
2003
2004        // Scenario 2: spawn over the slice first, then reuse over the whole.
2005        let s2 = slice
2006            .spawn_service::<testactor::TestActor, _>(instance, "svc_slice_first", &())
2007            .await
2008            .unwrap();
2009        let w2 = whole
2010            .spawn_service::<testactor::TestActor, _>(instance, "svc_slice_first", &())
2011            .await
2012            .unwrap();
2013        assert_shared(&w2, &s2);
2014        let w2_states = whole.actor_states(instance, w2.id().clone()).await.unwrap();
2015        for rank in 0..whole.region().num_ranks() {
2016            assert_eq!(
2017                w2_states
2018                    .get(rank)
2019                    .and_then(|state| state.state.as_ref())
2020                    .map(|state| &state.actor_id),
2021                w2.get(rank).map(|actor| actor.actor_addr()),
2022                "slice-first state is positioned at whole-view rank {rank}",
2023            );
2024        }
2025
2026        // Scenario 3: a `WaitRankStatus{Stopped}` registered over the renumbered
2027        // slice while the service runs is deferred and flushed on stop. The
2028        // flushed overlays land at slice-local ranks, within the slice region the
2029        // accumulator was opened over, so the wait completes. Registering before
2030        // the stop makes the deferred branch deterministic.
2031        whole
2032            .spawn_service::<testactor::TestActor, _>(instance, "svc_wait", &())
2033            .await
2034            .unwrap();
2035        let sw = slice
2036            .spawn_service::<testactor::TestActor, _>(instance, "svc_wait", &())
2037            .await
2038            .unwrap();
2039        let id = sw.id().resource_id().clone();
2040        let region = slice.region().clone();
2041        let num_ranks = region.num_ranks();
2042        let (port, rx) = instance.mailbox().open_idle_flush_accum_port(
2043            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
2044            IdleFlushReducerOpts {
2045                idle_timeout: Duration::from_millis(50),
2046                abandon_timeout: Duration::from_secs(30),
2047                expected_updates_per_destination: NonZeroUsize::MIN,
2048            },
2049        );
2050        slice
2051            .agent_mesh()
2052            .cast(
2053                instance,
2054                crate::resource::WaitRankStatus {
2055                    id: id.clone(),
2056                    // Filled by the cast layer with this recipient's dense view rank.
2057                    rank: Default::default(),
2058                    min_status: Status::Stopped,
2059                    reply: port.bind(),
2060                },
2061            )
2062            .unwrap();
2063        slice
2064            .agent_mesh()
2065            .cast(
2066                instance,
2067                crate::resource::Stop {
2068                    id: id.clone(),
2069                    reason: "test".to_string(),
2070                },
2071            )
2072            .unwrap();
2073        let statuses = crate::resource::GetRankStatus::wait(
2074            rx,
2075            num_ranks,
2076            hyperactor_config::global::get(ACTOR_SPAWN_MAX_IDLE),
2077            region,
2078        )
2079        .await
2080        .unwrap();
2081        assert!(
2082            statuses.values().all(|s| s.is_terminating()),
2083            "deferred waiters flushed at slice-local ranks: {statuses:?}",
2084        );
2085
2086        hm.shutdown(instance)
2087            .await
2088            .expect("host mesh shutdown should complete");
2089    }
2090
2091    /// Actor-state replies match the queried view when the controller carries
2092    /// an IPv4 location and the worker reports IPv6 for the same actor identity.
2093    #[async_timed_test(timeout_secs = 120)]
2094    #[cfg(fbcode_build)]
2095    async fn test_actor_states_matches_actor_identity_across_ipv4_and_ipv6() {
2096        let instance = testing::instance();
2097        let config = hyperactor_config::global::lock();
2098        let _guard = config.override_key(
2099            DEFAULT_TRANSPORT,
2100            BindSpec::Addr(ChannelAddr::Tcp("[::1]:0".parse().unwrap())),
2101        );
2102        let mut host_mesh = HostMesh::local().await.unwrap();
2103        let mut worker_mesh = host_mesh
2104            .spawn(
2105                instance,
2106                "location_mismatch_worker",
2107                extent!(gpus = 1),
2108                None,
2109                None,
2110            )
2111            .await
2112            .unwrap();
2113
2114        let worker_proc_addr = worker_mesh.get(0).unwrap().proc_addr().clone();
2115        let ChannelAddr::Tcp(worker_socket_addr) = worker_proc_addr.addr() else {
2116            panic!("worker proc must use TCP")
2117        };
2118        assert!(worker_socket_addr.is_ipv6(), "worker must use IPv6");
2119        let proc_agent: ActorRef<ProcAgent> =
2120            ActorRef::attest(worker_proc_addr.actor_addr(PROC_AGENT_ACTOR_NAME).clone());
2121        let controller_proc_addr = ProcAddr::new(
2122            worker_proc_addr.id().clone(),
2123            ChannelAddr::Tcp(SocketAddr::new(
2124                IpAddr::V4(Ipv4Addr::LOCALHOST),
2125                worker_socket_addr.port(),
2126            ))
2127            .into(),
2128        );
2129        let controller_view = ProcMeshRef::new_singleton(
2130            ProcMeshId::singleton(Label::strip("location_mismatch_view")),
2131            ProcRef::new(controller_proc_addr, 0, proc_agent),
2132        )
2133        .unwrap();
2134
2135        let actor_mesh = controller_view
2136            .spawn_controllerless_service::<testactor::TestActor, _>(
2137                instance,
2138                "location_mismatch_actor",
2139                &(),
2140            )
2141            .await
2142            .unwrap();
2143        let expected_actor = actor_mesh.get(0).unwrap().actor_addr();
2144        let states = controller_view
2145            .actor_states(instance, actor_mesh.id().clone())
2146            .await
2147            .unwrap();
2148        let reported_actor = &states
2149            .get(0)
2150            .and_then(|state| state.state.as_ref())
2151            .unwrap()
2152            .actor_id;
2153
2154        assert_ne!(
2155            reported_actor, expected_actor,
2156            "IPv6 worker and IPv4 controller addresses must differ",
2157        );
2158        assert_eq!(
2159            reported_actor.id(),
2160            expected_actor.id(),
2161            "worker and controller addresses must identify the same actor",
2162        );
2163        assert!(
2164            matches!(reported_actor.addr(), ChannelAddr::Tcp(addr) if addr.is_ipv6()),
2165            "worker must report an IPv6 location",
2166        );
2167        assert!(
2168            matches!(expected_actor.addr(), ChannelAddr::Tcp(addr) if addr.is_ipv4()),
2169            "controller must retain an IPv4 location",
2170        );
2171
2172        worker_mesh
2173            .stop(instance, "test complete".to_string())
2174            .await
2175            .unwrap();
2176        host_mesh.shutdown(instance).await.unwrap();
2177    }
2178
2179    #[async_timed_test(timeout_secs = 120)]
2180    #[cfg(fbcode_build)]
2181    async fn test_spawn_actor_casting() {
2182        let config = hyperactor_config::global::lock();
2183        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(120));
2184        let _guard2 = config.override_key(
2185            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2186            Duration::from_secs(120),
2187        );
2188        execute_spawn_actor().await;
2189    }
2190
2191    /// Spawn an actor mesh, then do a random number of casts to bump the seq
2192    /// numbers for all actors participating in the cast. This avoids the test
2193    /// mistakenly passing.
2194    #[cfg(fbcode_build)]
2195    async fn spawn_for_seq_test(
2196        cx: &Instance<testing::TestRootClient>,
2197        proc_mesh: &super::ProcMeshRef,
2198    ) -> ActorMesh<testactor::TestActor> {
2199        let actor_mesh: ActorMesh<testactor::TestActor> =
2200            proc_mesh.spawn(cx, "test", &()).await.unwrap();
2201
2202        let instance = cx
2203            .proc()
2204            .client(&format!("random_casts_{}", Uuid::now_v7()));
2205        let n = 1;
2206        for _ in 0..n {
2207            actor_mesh.cast(&instance, ()).unwrap();
2208        }
2209        println!(
2210            "did {} casts with sequencer session id {}",
2211            n,
2212            instance.sequencer().session_id()
2213        );
2214        actor_mesh
2215    }
2216
2217    #[async_timed_test(timeout_secs = 60)]
2218    #[cfg(fbcode_build)]
2219    async fn test_seq_from_same_sender_to_different_meshes() {
2220        let config = hyperactor_config::global::lock();
2221        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
2222        let _guard2 = config.override_key(
2223            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2224            Duration::from_secs(60),
2225        );
2226
2227        hyperactor_telemetry::initialize_logging_for_test();
2228        let instance = testing::instance();
2229        let session_id = instance.sequencer().session_id();
2230
2231        let mut hm = testing::host_mesh(2).await;
2232        let proc_mesh = hm
2233            .spawn(&instance, "test", extent!(gpus = 1), None, None)
2234            .await
2235            .unwrap();
2236        let proc_mesh_ref = proc_mesh.deref();
2237
2238        // Sequence numbers are scoped based on the (client, dest) pair.
2239        // So casts to different meshes from the same client instance would
2240        // result in seq 1 for all casts.
2241        let handles = (0..3)
2242            .map(|_| {
2243                let proc_mesh_ref_clone = proc_mesh_ref.clone();
2244                tokio::spawn(async move {
2245                    let actor_mesh = spawn_for_seq_test(instance, &proc_mesh_ref_clone).await;
2246                    let expected_seqs = vec![1; 2];
2247                    testactor::assert_casting_correctness(
2248                        &actor_mesh,
2249                        instance,
2250                        Some((session_id, expected_seqs)),
2251                    )
2252                    .await;
2253                })
2254            })
2255            .collect::<Vec<_>>();
2256        futures::future::join_all(handles).await;
2257
2258        let _ = hm.shutdown(instance).await;
2259    }
2260
2261    /// Verify that the seq numbers are assigned correctly when we cast to
2262    /// different views of the same root mesh.
2263    #[async_timed_test(timeout_secs = 60)]
2264    #[cfg(fbcode_build)]
2265    async fn test_seq_from_same_sender_to_different_views() {
2266        let config = hyperactor_config::global::lock();
2267        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
2268        let _guard2 = config.override_key(
2269            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2270            Duration::from_secs(60),
2271        );
2272
2273        hyperactor_telemetry::initialize_logging_for_test();
2274
2275        let instance = testing::instance();
2276        let session_id = instance.sequencer().session_id();
2277
2278        let mut hm = testing::host_mesh(3).await;
2279        let proc_mesh = hm
2280            .spawn(&instance, "test", extent!(gpus = 1), None, None)
2281            .await
2282            .unwrap();
2283
2284        let actor_mesh = spawn_for_seq_test(instance, &proc_mesh).await;
2285
2286        // First cast. The seq should be 1 for all actors.
2287        let expected_seqs = vec![1; 3];
2288        testactor::assert_casting_correctness(
2289            &actor_mesh,
2290            instance,
2291            Some((session_id, expected_seqs)),
2292        )
2293        .await;
2294
2295        // Verify casting to the sliced actor mesh
2296        let sliced_actor_mesh = actor_mesh.range("hosts", 1..3).unwrap();
2297        // Second cast. The seq should be 2 for actors in the sliced mesh.
2298        let expected_seqs = vec![2; 2];
2299        testactor::assert_casting_correctness(
2300            &sliced_actor_mesh,
2301            instance,
2302            Some((session_id, expected_seqs)),
2303        )
2304        .await;
2305
2306        // Verify casting to a different sliced actor mesh
2307        let sliced_actor_mesh = actor_mesh.range("hosts", 0..2).unwrap();
2308        // For actors in the previous sliced mesh, the seq should be 3 since
2309        // this is the third cast for them. For other actors, the seq should
2310        // be 2.
2311        let expected_seqs = vec![2, 3];
2312        testactor::assert_casting_correctness(
2313            &sliced_actor_mesh,
2314            instance,
2315            Some((session_id, expected_seqs)),
2316        )
2317        .await;
2318
2319        let _ = hm.shutdown(instance).await;
2320    }
2321
2322    #[async_timed_test(timeout_secs = 60)]
2323    #[cfg(fbcode_build)]
2324    async fn test_seq_from_different_senders() {
2325        let config = hyperactor_config::global::lock();
2326        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
2327        let _guard2 = config.override_key(
2328            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2329            Duration::from_secs(60),
2330        );
2331
2332        hyperactor_telemetry::initialize_logging_for_test();
2333
2334        use hyperactor::Proc;
2335        use hyperactor::channel::ChannelTransport;
2336
2337        let proc = Proc::direct(ChannelTransport::Unix.any(), "test_0".to_string()).unwrap();
2338        let instance = proc
2339            .actor_instance::<testing::TestRootClient>("test_client")
2340            .unwrap()
2341            .instance;
2342        let first_instance = proc
2343            .actor_instance::<testing::TestRootClient>("first_client")
2344            .unwrap()
2345            .instance;
2346        let second_instance = proc
2347            .actor_instance::<testing::TestRootClient>("second_client")
2348            .unwrap()
2349            .instance;
2350        let third_instance = proc
2351            .actor_instance::<testing::TestRootClient>("third_client")
2352            .unwrap()
2353            .instance;
2354
2355        let mut hm = testing::host_mesh(2).await;
2356        let proc_mesh = hm
2357            .spawn(&instance, "test", extent!(gpus = 1), None, None)
2358            .await
2359            .unwrap();
2360
2361        let actor_mesh = spawn_for_seq_test(&instance, &proc_mesh).await;
2362
2363        // Sequence numbers are calculated based on the sequencer, i.e. the
2364        // client name. So three casts would result in seq 1 for all actors.
2365        for inst in [&first_instance, &second_instance, &third_instance] {
2366            let expected_seqs = vec![1; 2];
2367            let session_id = inst.sequencer().session_id();
2368            testactor::assert_casting_correctness(
2369                &actor_mesh,
2370                inst,
2371                Some((session_id, expected_seqs)),
2372            )
2373            .await;
2374        }
2375
2376        let _ = hm.shutdown(&instance).await;
2377    }
2378
2379    #[cfg(fbcode_build)]
2380    #[assert_no_process_leak]
2381    #[tokio::test]
2382    async fn test_failing_spawn_actor() {
2383        hyperactor_telemetry::initialize_logging(hyperactor_telemetry::DefaultTelemetryClock {});
2384
2385        let config = hyperactor_config::global::lock();
2386        let _mode = config.override_key(USE_DIRECT_SPAWN, false);
2387        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
2388        let _guard2 = config.override_key(
2389            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2390            Duration::from_secs(60),
2391        );
2392
2393        let instance = testing::instance();
2394
2395        let mut hm = testing::host_mesh(1).await;
2396        let proc_mesh = hm
2397            .spawn(&instance, "test", extent!(gpus = 1), None, None)
2398            .await
2399            .unwrap();
2400        let err = proc_mesh
2401            .spawn::<testactor::FailingCreateTestActor, Instance<testing::TestRootClient>>(
2402                instance,
2403                "testfail",
2404                &(),
2405            )
2406            .await
2407            .unwrap_err();
2408        let statuses = err.into_actor_spawn_error().unwrap();
2409        assert_eq!(
2410            statuses,
2411            RankedValues::from((0..1, Status::Failed("test failure".to_string()))),
2412        );
2413
2414        let _ = hm.shutdown(instance).await;
2415    }
2416
2417    #[cfg(fbcode_build)]
2418    #[assert_no_process_leak]
2419    #[tokio::test]
2420    async fn test_failing_spawn_controllerless() {
2421        hyperactor_telemetry::initialize_logging(hyperactor_telemetry::DefaultTelemetryClock {});
2422
2423        let config = hyperactor_config::global::lock();
2424        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
2425        let _guard2 = config.override_key(
2426            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2427            Duration::from_secs(60),
2428        );
2429
2430        let instance = testing::instance();
2431
2432        let mut hm = testing::host_mesh(1).await;
2433        let proc_mesh = hm
2434            .spawn(&instance, "test", extent!(gpus = 1), None, None)
2435            .await
2436            .unwrap();
2437        // The controllerless path shares spawn_mesh_inner, so it must still run
2438        // the bounded spawn wait and surface the same ActorSpawnError shape on a
2439        // terminal init failure — identical to spawn/spawn_service.
2440        let err = proc_mesh
2441            .spawn_controllerless_service::<testactor::FailingCreateTestActor, _>(
2442                instance,
2443                "testfail_controllerless",
2444                &(),
2445            )
2446            .await
2447            .unwrap_err();
2448        let statuses = err.into_actor_spawn_error().unwrap();
2449        assert_eq!(
2450            statuses,
2451            RankedValues::from((0..1, Status::Failed("test failure".to_string()))),
2452        );
2453
2454        let _ = hm.shutdown(instance).await;
2455    }
2456
2457    #[async_timed_test(timeout_secs = 60)]
2458    #[cfg(fbcode_build)]
2459    async fn test_spawn_actor_on_proc_mesh_slice_only_spawns_slice_members() {
2460        let instance = testing::instance();
2461
2462        let mut hm = testing::host_mesh(2).await;
2463        let proc_mesh = hm
2464            .spawn(&instance, "test", extent!(gpus = 2), None, None)
2465            .await
2466            .unwrap();
2467        let host1 = proc_mesh.range("hosts", 1..2).unwrap();
2468        let actor_name = crate::mesh_id::ActorMeshId::instance(
2469            hyperactor::id::Label::new("slice_only").unwrap(),
2470        );
2471
2472        let actor_mesh = host1
2473            .spawn_with_name::<testactor::TestActor, _>(
2474                instance,
2475                actor_name.clone(),
2476                &(),
2477                None,
2478                false,
2479            )
2480            .await
2481            .unwrap();
2482        testactor::assert_casting_correctness(&actor_mesh, instance, None).await;
2483
2484        let slice_states = host1
2485            .actor_states(instance, actor_name.clone())
2486            .await
2487            .unwrap();
2488        assert_eq!(slice_states.extent(), host1.extent());
2489
2490        let err = proc_mesh
2491            .actor_states(instance, actor_name.clone())
2492            .await
2493            .unwrap_err();
2494        let expected_name = actor_name.into();
2495        match err {
2496            crate::Error::NotExist(name) if name == expected_name => {}
2497            other => panic!("expected NotExist for {expected_name}, got {other:?}"),
2498        }
2499
2500        let _ = hm.shutdown(instance).await;
2501    }
2502
2503    /// `proc_states` must resolve exactly the procs of the (possibly sliced)
2504    /// mesh it is called on — gpu-dim slices, host-dim slices, and
2505    /// slice-of-slice — rather than re-deriving a dense `0..n` from per-host
2506    /// counts or from the recipient's stamped (sliced-ordinal) rank.
2507    #[cfg(fbcode_build)]
2508    async fn assert_proc_states_match_slice<C: hyperactor::context::Actor>(
2509        mesh: &super::ProcMeshRef,
2510        cx: &C,
2511    ) {
2512        // The slice's own procs, by their original global rank.
2513        let mut expected: Vec<usize> = mesh.ranks.iter().map(|r| r.create_rank).collect();
2514        expected.sort();
2515
2516        let states = mesh
2517            .states(cx, None)
2518            .await
2519            .unwrap()
2520            .expect("host-backed mesh yields Some");
2521
2522        assert_eq!(states.extent(), mesh.extent());
2523
2524        let mut got: Vec<usize> = states
2525            .values()
2526            .map(|s| {
2527                assert!(
2528                    !matches!(
2529                        s.status,
2530                        Status::NotExist | Status::Failed(_) | Status::Timeout(_)
2531                    ),
2532                    "rank should resolve to a live proc, got status {:?}",
2533                    s.status
2534                );
2535                s.state.expect("live proc carries ProcState").create_rank
2536            })
2537            .collect();
2538
2539        got.sort();
2540
2541        assert_eq!(
2542            got, expected,
2543            "proc_states must return exactly this (sub)mesh's procs"
2544        );
2545    }
2546
2547    #[async_timed_test(timeout_secs = 60)]
2548    #[cfg(fbcode_build)]
2549    async fn test_proc_states_on_sliced_mesh() {
2550        let instance = testing::instance();
2551        let mut hm = testing::host_mesh(2).await;
2552        // 2 hosts x 4 gpus, host-major: global rank = host * 4 + gpu.
2553        let proc_mesh = hm
2554            .spawn(&instance, "test", extent!(gpus = 4), None, None)
2555            .await
2556            .unwrap();
2557
2558        // Full mesh: ranks 0..8.
2559        assert_proc_states_match_slice(&proc_mesh, instance).await;
2560        // gpu-dim slice (gpus 2..4): ranks {2,3,6,7} — the original mis-derivation.
2561        assert_proc_states_match_slice(&proc_mesh.range("gpus", 2..4).unwrap(), instance).await;
2562        // host-dim slice (host 1): ranks {4,5,6,7} — the stamped-ordinal trap.
2563        assert_proc_states_match_slice(&proc_mesh.range("hosts", 1..2).unwrap(), instance).await;
2564        // slice-of-slice (host 1, gpus 2..4): ranks {6,7} — pins base-rank composition.
2565        assert_proc_states_match_slice(
2566            &proc_mesh
2567                .range("gpus", 2..4)
2568                .unwrap()
2569                .range("hosts", 1..2)
2570                .unwrap(),
2571            instance,
2572        )
2573        .await;
2574
2575        let _ = hm.shutdown(instance).await;
2576    }
2577
2578    #[test]
2579    fn test_python_class_from_supervision_name() {
2580        use super::python_class_from_supervision_name;
2581
2582        assert_eq!(
2583            python_class_from_supervision_name("instance0.<my_module.MyWorker test_mesh>"),
2584            Some("Python<MyWorker>".to_string()),
2585        );
2586        assert_eq!(
2587            python_class_from_supervision_name(
2588                "instance0.<package.submodule.TrainingActor mesh_0>"
2589            ),
2590            Some("Python<TrainingActor>".to_string()),
2591        );
2592        // No angle brackets — not a Python supervision name.
2593        assert_eq!(python_class_from_supervision_name("plain_name"), None,);
2594        // Malformed: missing dot-qualified class name.
2595        assert_eq!(
2596            python_class_from_supervision_name("instance0.<NoModule mesh>"),
2597            None,
2598        );
2599    }
2600
2601    // Compile-only proof that `spawn_controllerless_service` does NOT require
2602    // `C::A: Handler<MeshFailure>` — the whole point of the controllerless
2603    // path. This generic wrapper omits that bound and still type-checks; the
2604    // identical wrapper over `spawn_service` would fail to compile. (Generic
2605    // fn bodies are type-checked at definition, so this holds without ever
2606    // being called.)
2607    #[allow(dead_code)]
2608    async fn _spawn_controllerless_needs_no_mesh_failure_handler<A, C>(
2609        pm: &super::ProcMeshRef,
2610        cx: &C,
2611        name: &str,
2612        params: &A::Params,
2613    ) -> crate::Result<crate::ActorMesh<A>>
2614    where
2615        A: hyperactor::RemoteSpawn,
2616        A::Params: hyperactor::RemoteMessage,
2617        C: hyperactor::context::Actor,
2618    {
2619        pm.spawn_controllerless_service::<A, C>(cx, name, params)
2620            .await
2621    }
2622
2623    #[cfg(fbcode_build)]
2624    async fn execute_spawn_controllerless() {
2625        hyperactor_telemetry::initialize_logging(hyperactor_telemetry::DefaultTelemetryClock {});
2626
2627        let instance = testing::instance();
2628
2629        let mut hm = testing::host_mesh(2).await;
2630        let proc_mesh = hm
2631            .spawn(&instance, "test", extent!(gpus = 1), None, None)
2632            .await
2633            .unwrap();
2634
2635        // A controllerless service mesh has no ActorMeshController...
2636        let controllerless = proc_mesh
2637            .spawn_controllerless_service::<testactor::TestActor, _>(
2638                instance,
2639                "svc_controllerless",
2640                &(),
2641            )
2642            .await
2643            .unwrap();
2644        assert!(
2645            controllerless
2646                .as_managed()
2647                .expect("controllerless service mesh should be managed")
2648                .controller()
2649                .is_none(),
2650            "spawn_controllerless_service must not create a controller",
2651        );
2652
2653        // ...whereas an ordinary service mesh does (contrast).
2654        let with_controller = proc_mesh
2655            .spawn_service::<testactor::TestActor, _>(instance, "svc_with_controller", &())
2656            .await
2657            .unwrap();
2658        assert!(
2659            with_controller
2660                .as_managed()
2661                .expect("service mesh should be managed")
2662                .controller()
2663                .is_some(),
2664            "spawn_service must create a controller",
2665        );
2666
2667        let _ = hm.shutdown(instance).await;
2668    }
2669
2670    #[async_timed_test(timeout_secs = 120)]
2671    #[cfg(fbcode_build)]
2672    async fn test_spawn_controllerless_service() {
2673        let config = hyperactor_config::global::lock();
2674        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(120));
2675        let _guard2 = config.override_key(
2676            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2677            Duration::from_secs(120),
2678        );
2679        execute_spawn_controllerless().await;
2680    }
2681}