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::HashSet;
11use std::fmt;
12use std::hash::Hash;
13use std::ops::Deref;
14use std::sync::Arc;
15use std::time::Duration;
16
17use hyperactor::ActorAddr;
18use hyperactor::ActorRef;
19use hyperactor::Endpoint as _;
20use hyperactor::Handler;
21use hyperactor::ProcAddr;
22use hyperactor::RemoteMessage;
23use hyperactor::RemoteSpawn;
24use hyperactor::accum::StreamingReducerOpts;
25use hyperactor::actor::ActorStatus;
26use hyperactor::actor::remote::Remote;
27use hyperactor::context;
28use hyperactor::id::Label;
29use hyperactor::supervision::ActorSupervisionEvent;
30use hyperactor_config::CONFIG;
31use hyperactor_config::ConfigAttr;
32use hyperactor_config::attrs::declare_attrs;
33use hyperactor_telemetry::hash_to_u64;
34use ndslice::Extent;
35use ndslice::ViewExt as _;
36use ndslice::view;
37use ndslice::view::CollectMeshExt;
38use ndslice::view::Ranked;
39use ndslice::view::Region;
40use serde::Deserialize;
41use serde::Serialize;
42use typeuri::Named;
43
44use crate::ActorMesh;
45use crate::ActorMeshRef;
46use crate::Error;
47use crate::HostMeshRef;
48use crate::ValueMesh;
49use crate::host_mesh::GET_PROC_STATE_MAX_IDLE;
50use crate::host_mesh::host_agent::GetHostProcStates;
51use crate::host_mesh::host_agent::ProcState;
52use crate::host_mesh::mesh_to_rankedvalues_with_default;
53use crate::mesh_controller::ActorMeshControlPlane;
54use crate::mesh_controller::ActorMeshController;
55use crate::mesh_id::ActorMeshId;
56use crate::mesh_id::ProcMeshId;
57use crate::mesh_id::ResourceId;
58use crate::proc_agent;
59use crate::proc_agent::ActorState;
60use crate::proc_agent::ProcAgent;
61use crate::resource;
62use crate::resource::GetRankStatus;
63use crate::resource::Status;
64use crate::supervision::MeshFailure;
65
66declare_attrs! {
67    /// The maximum idle time between updates while spawning actor
68    /// meshes.
69    @meta(CONFIG = ConfigAttr::new(
70        Some("HYPERACTOR_MESH_ACTOR_SPAWN_MAX_IDLE".to_string()),
71        Some("actor_spawn_max_idle".to_string()),
72    ))
73    pub attr ACTOR_SPAWN_MAX_IDLE: Duration = Duration::from_secs(30);
74
75    /// The maximum idle time between updates while waiting for a response to GetState
76    /// from ProcAgent.
77    @meta(CONFIG = ConfigAttr::new(
78        Some("HYPERACTOR_MESH_GET_ACTOR_STATE_MAX_IDLE".to_string()),
79        Some("get_actor_state_max_idle".to_string()),
80    ))
81    pub attr GET_ACTOR_STATE_MAX_IDLE: Duration = Duration::from_secs(30);
82}
83
84/// Returns the telemetry `meshes.id` value for an actor mesh.
85pub fn telemetry_actor_mesh_id(proc_mesh_id: &ProcMeshId, actor_mesh_id: &ActorMeshId) -> u64 {
86    hash_to_u64(&(proc_mesh_id, actor_mesh_id))
87}
88
89/// A reference to a single [`hyperactor::Proc`].
90#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
91pub struct ProcRef {
92    proc_id: ProcAddr,
93    /// The rank of this proc at creation.
94    create_rank: usize,
95    /// The agent managing this proc.
96    agent: ActorRef<ProcAgent>,
97}
98
99impl ProcRef {
100    /// Create a new proc ref from the provided id, create rank and agent.
101    pub fn new(proc_id: ProcAddr, create_rank: usize, agent: ActorRef<ProcAgent>) -> Self {
102        Self {
103            proc_id,
104            create_rank,
105            agent,
106        }
107    }
108
109    pub fn proc_addr(&self) -> &ProcAddr {
110        &self.proc_id
111    }
112
113    pub(crate) fn actor_addr(&self, id: &ActorMeshId) -> ActorAddr {
114        self.proc_id.actor_addr_uid(id.uid().clone())
115    }
116}
117
118/// A mesh of processes.
119#[derive(Debug)]
120pub struct ProcMesh {
121    #[allow(dead_code)]
122    id: ProcMeshId,
123    current_ref: ProcMeshRef,
124    controller: Option<ActorRef<crate::mesh_controller::ProcMeshController>>,
125}
126
127impl ProcMesh {
128    pub(crate) fn create(
129        id: ProcMeshId,
130        extent: Extent,
131        hosts: HostMeshRef,
132        ranks: Vec<ProcRef>,
133    ) -> crate::Result<Self> {
134        let region = extent.into();
135        let ranks = Arc::new(ranks);
136
137        // Set the global supervision sink to the first ProcAgent's
138        // supervision event handler. Last-mesh-wins semantics: if a
139        // previous mesh installed a sink, it is replaced.
140        if let Some(first) = ranks.first() {
141            crate::global_context::set_global_supervision_sink(
142                first.agent.port::<ActorSupervisionEvent>(),
143            );
144        }
145
146        let current_ref = ProcMeshRef::new(id.clone(), region, ranks, Some(hosts)).unwrap();
147
148        // Notify telemetry that the ProcAgent mesh was created.
149        {
150            let name_str = id.to_string();
151            let mesh_id_hash = hash_to_u64(&id);
152
153            let hm = current_ref
154                .host_mesh
155                .as_ref()
156                .expect("ProcMesh always has a host mesh");
157            let parent_mesh_id = hash_to_u64(hm.id());
158            let parent_view_json = serde_json::to_string(hm.region())
159                .unwrap_or_else(|e| format!("encountered error when serializing region: {}", e));
160
161            hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
162                id: mesh_id_hash,
163                timestamp: std::time::SystemTime::now(),
164                class: "Proc".to_string(),
165                given_name: id
166                    .display_label()
167                    .map(|l| l.as_str())
168                    .unwrap_or("unnamed")
169                    .to_string(),
170                full_name: name_str,
171                shape_json: serde_json::to_string(&current_ref.region.extent()).unwrap_or_default(),
172                parent_mesh_id: Some(parent_mesh_id),
173                parent_view_json: Some(parent_view_json),
174            });
175
176            // Notify telemetry of each ProcAgent actor in this mesh.
177            // These are skipped in Proc::spawn_inner. mesh_id directly points to proc mesh.
178            let now = std::time::SystemTime::now();
179            for rank in current_ref.ranks.iter() {
180                let actor_addr = rank.agent.actor_addr();
181
182                hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
183                    id: hyperactor_telemetry::hash_to_u64(actor_addr.id()),
184                    timestamp: now,
185                    mesh_id: mesh_id_hash,
186                    rank: rank.create_rank as u64,
187                    full_name: actor_addr.to_string(),
188                    display_name: None,
189                });
190            }
191        }
192
193        Ok(Self {
194            id,
195            current_ref,
196            controller: None,
197        })
198    }
199
200    /// Set or clear the controller actor managing this mesh.
201    pub(crate) fn set_controller(
202        &mut self,
203        controller: Option<ActorRef<crate::mesh_controller::ProcMeshController>>,
204    ) {
205        self.controller = controller;
206    }
207
208    /// Stop this mesh gracefully.
209    ///
210    /// If a `ProcMeshController` is present (owned meshes spawned from a host
211    /// mesh), the stop is delegated to the controller via `resource::Stop`;
212    /// the controller's handler awaits `HostMeshRef::stop_proc_mesh`, which
213    /// casts `Stop` + `WaitRankStatus{min_status: Stopped}` to the
214    /// HostAgents and waits up to `PROC_STOP_MAX_IDLE` for every proc to
215    /// reach `Stopped`. We then serialize behind that handler with a
216    /// `GetState` to read the final statuses out of the controller's
217    /// `health_state`.
218    pub async fn stop(&mut self, cx: &impl context::Actor, reason: String) -> anyhow::Result<()> {
219        if let Some(controller) = self.controller.take() {
220            let id = self.id.resource_id().clone();
221            controller.post(
222                cx,
223                resource::Stop {
224                    id: id.clone(),
225                    reason,
226                },
227            );
228
229            // The controller processes messages serially, so by the time it
230            // gets to this `GetState`, its `health_state.statuses` already
231            // reflects the outcome of `stop_proc_mesh` (Stopping, Stopped,
232            // Failed, or Timeout on `PROC_STOP_MAX_IDLE` exhaustion).
233            let (port, mut rx) = cx.mailbox().open_port();
234            controller.post(
235                cx,
236                resource::GetState::<resource::mesh::State<()>> {
237                    id: id.clone(),
238                    reply: port.bind(),
239                },
240            );
241
242            let statuses = rx.recv().await?;
243            let Some(state) = &statuses.state else {
244                anyhow::bail!(
245                    "non-existent state in GetState reply from controller: {}",
246                    controller.actor_addr()
247                );
248            };
249            // `is_terminating` accepts Stopping, Stopped, Failed, and
250            // Timeout. The controller's Stop handler has already awaited
251            // (or timed out) the underlying HostAgent wait, so any rank
252            // still in Running here means the controller never processed
253            // the stop for that rank.
254            let all_stopped = state.statuses.values().all(|s| s.is_terminating());
255            if !all_stopped {
256                anyhow::bail!(
257                    "proc mesh {} not all procs reached terminating state after stop: {:?}",
258                    id,
259                    state.statuses,
260                );
261            }
262            return Ok(());
263        }
264
265        let region = self.region.clone();
266        let procs = self.current_ref.proc_ids().collect::<Vec<ProcAddr>>();
267        // We use the proc mesh region rather than the host mesh region
268        // because the host agent stores one entry per proc, not per host.
269        self.current_ref
270            .host_mesh
271            .as_ref()
272            .expect("ProcMesh always has a host mesh")
273            .stop_proc_mesh(cx, &self.id, procs, region, reason)
274            .await
275            .map(|_| ())
276            .map_err(anyhow::Error::from)
277    }
278}
279
280impl fmt::Display for ProcMesh {
281    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282        write!(f, "{}", self.current_ref)
283    }
284}
285
286impl Deref for ProcMesh {
287    type Target = ProcMeshRef;
288
289    fn deref(&self) -> &Self::Target {
290        &self.current_ref
291    }
292}
293
294impl Drop for ProcMesh {
295    fn drop(&mut self) {
296        tracing::info!(
297            name = "ProcMeshStatus",
298            proc_mesh = %self.id,
299            status = "Dropped",
300        );
301    }
302}
303
304/// A reference to a ProcMesh, consisting of a set of ranked [`ProcRef`]s,
305/// arranged into a region. ProcMeshes are named, uniquely identifying the
306/// ProcMesh from which the reference was derived.
307///
308/// ProcMeshes can be sliced to create new ProcMeshes with a subset of the
309/// original ranks.
310///
311/// `ProcMeshRef::sliced` is intentionally pure. A sliced proc mesh can still
312/// expose dense `ProcRef`s, while the backing `ProcAgent` `ActorMeshRef`
313/// carries a lazy cast-domain descriptor that installs itself on first cast.
314#[derive(Debug, Clone, Named, Serialize, Deserialize)]
315pub struct ProcMeshRef {
316    id: ProcMeshId,
317    region: Region,
318    ranks: Arc<Vec<ProcRef>>,
319    /// Actor mesh for the `ProcAgent`s backing this proc mesh view.
320    ///
321    /// `ProcMeshRef::sliced` derives a sliced agent mesh with a lazy cast
322    /// descriptor. The first cast through that view installs the descriptor on
323    /// the caller's sender stream.
324    ///
325    /// The `ProcMeshRef` itself keeps dense `ProcRef`s so it can later
326    /// materialize this field from the current view without consulting the
327    /// parent mesh or using a temporary proc.
328    proc_agent_mesh: ActorMeshRef<ProcAgent>,
329    // Some if this was spawned from a host mesh, else none.
330    host_mesh: Option<HostMeshRef>,
331}
332wirevalue::register_type!(ProcMeshRef);
333
334// The proc-agent actor mesh is derived from `ranks`, so it is not part of
335// `ProcMeshRef` identity.
336impl PartialEq for ProcMeshRef {
337    fn eq(&self, other: &Self) -> bool {
338        self.id == other.id
339            && self.region == other.region
340            && self.ranks == other.ranks
341            && self.host_mesh == other.host_mesh
342    }
343}
344
345impl Eq for ProcMeshRef {}
346
347impl std::hash::Hash for ProcMeshRef {
348    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
349        self.id.hash(state);
350        self.region.hash(state);
351        self.ranks.hash(state);
352        self.host_mesh.hash(state);
353    }
354}
355
356impl ProcMeshRef {
357    /// Create a new ProcMeshRef from the given id, region, ranks, and so on.
358    #[allow(clippy::result_large_err)]
359    fn new(
360        id: ProcMeshId,
361        region: Region,
362        ranks: Arc<Vec<ProcRef>>,
363        host_mesh: Option<HostMeshRef>,
364    ) -> crate::Result<Self> {
365        if ranks.is_empty() {
366            return Err(crate::Error::ConfigurationError(anyhow::anyhow!(
367                "empty proc meshes are not supported"
368            )));
369        }
370        if region.num_ranks() != ranks.len() {
371            return Err(crate::Error::InvalidRankCardinality {
372                expected: region.num_ranks(),
373                actual: ranks.len(),
374            });
375        }
376        let proc_agent_mesh = Self::proc_agent_mesh_ref(&id, &region, &ranks)?;
377        Ok(Self {
378            id,
379            region,
380            ranks,
381            proc_agent_mesh,
382            host_mesh,
383        })
384    }
385
386    /// Create a singleton ProcMeshRef, given the provided ProcRef and id.
387    /// This is used to support creating local singleton proc meshes to support `this_proc()`
388    /// in python client actors.
389    pub fn new_singleton(id: ProcMeshId, proc_ref: ProcRef) -> crate::Result<Self> {
390        let region: Region = Extent::unity().into();
391        let ranks = Arc::new(vec![proc_ref]);
392        let proc_agent_mesh = Self::proc_agent_mesh_ref(&id, &region, &ranks)?;
393        Ok(Self {
394            id,
395            region,
396            ranks,
397            proc_agent_mesh,
398            host_mesh: None,
399        })
400    }
401
402    pub fn id(&self) -> &ProcMeshId {
403        &self.id
404    }
405
406    pub fn host_mesh_id(&self) -> Option<&crate::mesh_id::HostMeshId> {
407        self.host_mesh.as_ref().map(|h| h.id())
408    }
409
410    /// Returns the HostMeshRef that owns this ProcMeshRef, if any.
411    pub fn hosts(&self) -> Option<&HostMeshRef> {
412        self.host_mesh.as_ref()
413    }
414
415    pub(crate) fn agent_mesh(&self) -> &ActorMeshRef<ProcAgent> {
416        &self.proc_agent_mesh
417    }
418
419    fn proc_agent_mesh_ref(
420        proc_mesh_id: &ProcMeshId,
421        region: &Region,
422        ranks: &[ProcRef],
423    ) -> crate::Result<ActorMeshRef<ProcAgent>> {
424        let agent_label = ranks
425            .first()
426            .unwrap()
427            .agent
428            .actor_addr()
429            .label()
430            .cloned()
431            .unwrap_or_else(|| Label::new(proc_agent::PROC_AGENT_ACTOR_NAME).unwrap());
432        let id = ActorMeshId::singleton(agent_label);
433
434        let members = Arc::new(
435            ranks
436                .iter()
437                .map(|rank| rank.agent.actor_addr().clone())
438                .collect_mesh::<ValueMesh<_>>(region.clone())
439                .map_err(|error| crate::Error::ConfigurationError(error.into()))?,
440        );
441
442        Ok(ActorMeshRef::new(
443            id,
444            Some(proc_mesh_id.clone()),
445            region.clone(),
446            None,
447            members,
448        ))
449    }
450
451    /// Query the state of all actors in this mesh matching the given id.
452    pub async fn actor_states(
453        &self,
454        cx: &impl context::Actor,
455        id: ActorMeshId,
456    ) -> crate::Result<ValueMesh<resource::State<ActorState>>> {
457        self.actor_states_with_keepalive(cx, id, None).await
458    }
459
460    /// Query the state of all actors in this mesh matching the given id.
461    /// If keepalive is Some, use a message that indicates to the recipient
462    /// that the owner of the mesh is still alive, along with the expiry time
463    /// after which the actor should be considered orphaned. Else, use a normal
464    /// state query.
465    pub(crate) async fn actor_states_with_keepalive(
466        &self,
467        cx: &impl context::Actor,
468        id: ActorMeshId,
469        keepalive: Option<std::time::SystemTime>,
470    ) -> crate::Result<ValueMesh<resource::State<ActorState>>> {
471        let (port, mut rx) = cx.mailbox().open_port::<resource::State<ActorState>>();
472        let mut port = port.bind();
473        // If this proc dies or some other issue renders the reply undeliverable,
474        // the reply does not need to be returned to the sender.
475        port.return_undeliverable(false);
476        // TODO: Use accumulation to get back a single value (representing whether
477        // *any* of the actors failed) instead of a mesh.
478        let get_state = resource::GetState::<ActorState> {
479            id: id.resource_id().clone(),
480            reply: port,
481        };
482        if let Some(expires_after) = keepalive {
483            self.proc_agent_mesh.cast(
484                cx,
485                resource::KeepaliveGetState {
486                    expires_after,
487                    get_state,
488                },
489            )?;
490        } else {
491            self.proc_agent_mesh.cast(cx, get_state)?;
492        }
493        let expected = self.ranks.len();
494        let mut states = Vec::with_capacity(expected);
495        let timeout = hyperactor_config::global::get(GET_ACTOR_STATE_MAX_IDLE);
496        for _ in 0..expected {
497            // The agent runs on the same process as the running actor, so if some
498            // fatal event caused the process to crash (e.g. OOM, signal, process exit),
499            // the agent will be unresponsive.
500            // We handle this by setting a timeout on the recv, and if we don't get a
501            // message we assume the agent is dead and return a failed state.
502            let state = tokio::time::timeout(timeout, rx.recv()).await;
503            if let Ok(state) = state {
504                // Handle non-timeout receiver error.
505                let state = state?;
506                match state.state {
507                    Some(ref inner) => {
508                        states.push((inner.create_rank, state));
509                    }
510                    None => {
511                        return Err(Error::NotExist(state.id));
512                    }
513                }
514            } else {
515                tracing::error!(
516                    "timeout waiting for a message after {:?} from proc mesh agent in mesh {}",
517                    timeout,
518                    self.proc_agent_mesh
519                );
520                // Timeout error, stop reading from the receiver and send back what we have so far,
521                // padding with failed states.
522                let all_ranks = (0..self.ranks.len()).collect::<HashSet<_>>();
523                let completed_ranks = states.iter().map(|(rank, _)| *rank).collect::<HashSet<_>>();
524                let mut leftover_ranks = all_ranks.difference(&completed_ranks).collect::<Vec<_>>();
525                assert_eq!(leftover_ranks.len(), expected - states.len());
526                while states.len() < expected {
527                    let rank = *leftover_ranks
528                        .pop()
529                        .expect("leftover ranks should not be empty");
530                    let agent = self.proc_agent_mesh.get(rank).expect("agent should exist");
531                    let agent_id = agent.actor_addr().clone();
532                    states.push((
533                        // We populate with any ranks leftover at the time of the timeout.
534                        rank,
535                        resource::State {
536                            id: id.resource_id().clone(),
537                            status: resource::Status::Timeout(timeout),
538                            // We don't know the ActorAddr that used to live on this rank.
539                            // But we do know the mesh agent id, so we'll use that.
540                            // Use u64::MAX so this synthetic state always wins
541                            // last-writer-wins ordering against real streamed updates.
542                            generation: u64::MAX,
543                            timestamp: std::time::SystemTime::now(),
544                            state: Some(ActorState {
545                                actor_id: agent_id.clone(),
546                                create_rank: rank,
547                                supervision_events: vec![ActorSupervisionEvent::new(
548                                    agent_id,
549                                    None,
550                                    ActorStatus::generic_failure(format!(
551                                        "timeout waiting for message from proc mesh agent while querying for \"{}\". The process likely crashed",
552                                        id,
553                                    )),
554                                    None,
555                                )],
556                            }),
557                        },
558                    ));
559                }
560                break;
561            }
562        }
563        // Ensure that all ranks have replied. Note that if the mesh is sliced,
564        // not all create_ranks may be in the mesh.
565        // Sort by rank, so that the resulting mesh is ordered.
566        states.sort_by_key(|(rank, _)| *rank);
567        let vm = states
568            .into_iter()
569            .map(|(_, state)| state)
570            .collect_mesh::<ValueMesh<_>>(self.region.clone())?;
571        Ok(vm)
572    }
573
574    /// Get the state of every proc in this proc mesh.
575    ///
576    /// Casts a single `GetHostProcStates` to the routing host-agent mesh
577    /// carrying this mesh's selected global ranks (the mesh may be sliced, so
578    /// they need not be a dense `0..n`). The cast may reach hosts that own no
579    /// selected procs, but each HostAgent filters locally and only hosts with
580    /// matching ranks reply. Replies reduce up the cast tree (fanning in at cast
581    /// actor 0) instead of every host dialing this caller. When `keepalive` is
582    /// `Some`, each proc's expiry is extended (orphan protection, as with
583    /// `KeepaliveGetState`). Returns `None` when this proc mesh is not backed by
584    /// a host mesh (local/in-process meshes). On timeout, ranks whose host did
585    /// not reply are padded with a `Timeout` state.
586    #[allow(clippy::result_large_err)]
587    pub async fn states(
588        &self,
589        cx: &impl context::Actor,
590        keepalive: Option<std::time::SystemTime>,
591    ) -> crate::Result<Option<ValueMesh<resource::State<ProcState>>>> {
592        // Only meaningful when this proc mesh is backed by a host mesh.
593        let Some(host_mesh) = self.host_mesh.as_ref() else {
594            return Ok(None);
595        };
596        let region = self.region.clone();
597        let timeout = hyperactor_config::global::get(GET_PROC_STATE_MAX_IDLE);
598
599        // Per-rank template seeded with `Timeout` placeholders. Hosts overlay
600        // the ranks they own, so any rank never reported keeps its placeholder
601        // and the result is a complete mesh with no post-hoc padding.
602        let template: ValueMesh<resource::State<ProcState>> = self
603            .ranks
604            .iter()
605            .map(|proc_ref| resource::State {
606                id: ResourceId::new(
607                    proc_ref.proc_id.uid().clone(),
608                    proc_ref.proc_id.label().cloned(),
609                ),
610                status: resource::Status::Timeout(timeout),
611                state: None,
612                generation: 0,
613                timestamp: std::time::SystemTime::now(),
614            })
615            .collect_mesh::<ValueMesh<_>>(region.clone())?;
616        // Snapshot returned if no host replies before the idle timeout.
617        let fallback = template.clone();
618
619        // Accumulator port: receives sparse per-host overlays and emits the
620        // merged full mesh (right-wins). The host mesh is a routing
621        // over-approximation for sliced proc meshes; HostAgents that own
622        // selected ranks post an overlay, others stay silent.
623        let (port, rx) = cx.mailbox().open_accum_port_opts(
624            template,
625            StreamingReducerOpts {
626                max_update_interval: Some(Duration::from_millis(50)),
627                initial_update_interval: None,
628            },
629        );
630
631        host_mesh.agent_mesh().cast(
632            cx,
633            GetHostProcStates {
634                proc_mesh_id: self.id.clone(),
635                region: region.clone(),
636                keepalive,
637                reply: port.bind(),
638            },
639        )?;
640
641        // Wait until every rank has reported (moved off its `Timeout`
642        // placeholder) or we idle out. Either way the mesh is complete: ranks
643        // whose host never replied stay `Timeout`, and a failed proc surfaces as
644        // its `Failed` state rather than an error.
645        let mesh =
646            match resource::wait_mesh(rx, timeout, fallback, |s: &resource::State<ProcState>| {
647                !matches!(s.status, resource::Status::Timeout(_))
648            })
649            .await
650            {
651                Ok(mesh) | Err(mesh) => mesh,
652            };
653
654        Ok(Some(mesh))
655    }
656
657    /// Returns an iterator over the proc ids in this mesh.
658    pub(crate) fn proc_ids(&self) -> impl Iterator<Item = ProcAddr> {
659        self.ranks.iter().map(|proc_ref| proc_ref.proc_id.clone())
660    }
661
662    /// Spawn an actor on all of the procs in this mesh, returning a
663    /// new ActorMesh.
664    ///
665    /// Bounds:
666    /// - `A: Actor` - the actor actually runs inside each proc.
667    /// - `A: Referable` - so we can return typed `ActorRef<A>`s
668    ///   inside the `ActorMesh`.
669    /// - `A::Params: RemoteMessage` - spawn parameters must be
670    ///   serializable and routable.
671    pub async fn spawn<A: RemoteSpawn, C: context::Actor>(
672        &self,
673        cx: &C,
674        name: &str,
675        params: &A::Params,
676    ) -> crate::Result<ActorMesh<A>>
677    where
678        A::Params: RemoteMessage,
679        C::A: Handler<MeshFailure>,
680    {
681        // Spawning from a string is never a system actor.
682        let id = ActorMeshId::instance(Label::strip(name));
683        self.spawn_with_name(cx, id, params, None, false).await
684    }
685
686    /// Spawn a 'service' actor. Service actors are *singletons*, using
687    /// reserved names. The provided name is used verbatim as the actor's
688    /// name, and thus it may be persistently looked up by constructing
689    /// the appropriate name.
690    ///
691    /// Note: avoid using service actors if possible; the mechanism will
692    /// be replaced by an actor registry.
693    pub async fn spawn_service<A: RemoteSpawn, C: context::Actor>(
694        &self,
695        cx: &C,
696        name: &str,
697        params: &A::Params,
698    ) -> crate::Result<ActorMesh<A>>
699    where
700        A::Params: RemoteMessage,
701        C::A: Handler<MeshFailure>,
702    {
703        let id = ActorMeshId::singleton(Label::strip(name));
704        self.spawn_with_name(cx, id, params, None, false).await
705    }
706
707    /// Spawn an actor on all procs in this mesh under the given
708    /// [`ActorMeshId`](crate::mesh_id::ActorMeshId), returning a new `ActorMesh`.
709    ///
710    /// This is the underlying implementation used by [`spawn`]; it
711    /// differs only in that the actor mesh id is passed explicitly
712    /// rather than as a `&str`.
713    ///
714    /// Bounds:
715    /// - `A: Actor` - the actor actually runs inside each proc.
716    /// - `A: Referable` - so we can return typed `ActorRef<A>`s
717    ///   inside the `ActorMesh`.
718    /// - `A::Params: RemoteMessage` - spawn parameters must be
719    ///   serializable and routable.
720    /// - `C::A: Handler<MeshFailure>` - in order to spawn actors,
721    ///   the actor must accept messages of type `MeshFailure`. This
722    ///   is delivered when the actors spawned in the mesh have a failure that
723    ///   isn't handled.
724    #[hyperactor::instrument(fields(
725        host_mesh=self.host_mesh_id().map(|id| id.to_string()),
726        proc_mesh=self.id.to_string(),
727        actor_name=name.to_string(),
728    ))]
729    pub async fn spawn_with_name<A: RemoteSpawn, C: context::Actor>(
730        &self,
731        cx: &C,
732        name: ActorMeshId,
733        params: &A::Params,
734        supervision_display_name: Option<String>,
735        is_system_actor: bool,
736    ) -> crate::Result<ActorMesh<A>>
737    where
738        A::Params: RemoteMessage,
739        C::A: Handler<MeshFailure>,
740    {
741        tracing::info!(
742            name = "ProcMeshStatus",
743            status = "ActorMesh::Spawn::Attempt",
744        );
745        tracing::info!(name = "ActorMeshStatus", status = "Spawn::Attempt");
746        let result = self
747            .spawn_with_name_inner(cx, name, params, supervision_display_name, is_system_actor)
748            .await;
749        match &result {
750            Ok(_) => {
751                tracing::info!(
752                    name = "ProcMeshStatus",
753                    status = "ActorMesh::Spawn::Success",
754                );
755                tracing::info!(name = "ActorMeshStatus", status = "Spawn::Success");
756            }
757            Err(error) => {
758                tracing::error!(name = "ProcMeshStatus", status = "ActorMesh::Spawn::Failed", %error);
759                tracing::error!(name = "ActorMeshStatus", status = "Spawn::Failed", %error);
760            }
761        }
762        result
763    }
764
765    async fn spawn_with_name_inner<A: RemoteSpawn, C: context::Actor>(
766        &self,
767        cx: &C,
768        actor_mesh_id: ActorMeshId,
769        params: &A::Params,
770        supervision_display_name: Option<String>,
771        is_system_actor: bool,
772    ) -> crate::Result<ActorMesh<A>>
773    where
774        C::A: Handler<MeshFailure>,
775    {
776        let remote = Remote::collect();
777        // `RemoteSpawn` + `register_spawnable!(A)` ensure that `A` has a
778        // `SpawnableActor` entry in this registry, so
779        // `name_of::<A>()` can resolve its global type name.
780        let actor_type = remote
781            .name_of::<A>()
782            .ok_or(Error::ActorTypeNotRegistered(type_name::<A>().to_string()))?
783            .to_string();
784
785        let serialized_params = bincode::serde::encode_to_vec(params, bincode::config::legacy())?;
786        self.proc_agent_mesh.cast(
787            cx,
788            resource::CreateOrUpdate::<proc_agent::ActorSpec> {
789                id: actor_mesh_id.resource_id().clone(),
790                rank: Default::default(),
791                spec: proc_agent::ActorSpec {
792                    actor_type: actor_type.clone(),
793                    params_data: serialized_params.clone(),
794                },
795            },
796        )?;
797
798        let region = self.region().clone();
799        // Open an accum port that *receives overlays* and *emits full
800        // meshes*.
801        //
802        // NOTE: Mailbox initializes the accumulator state via
803        // `Default`, which is an *empty* ValueMesh (0 ranks). Our
804        // Accumulator<ValueMesh<T>> implementation detects this on
805        // the first update and replaces it with the caller-supplied
806        // template (the `self` passed into open_accum_port), which we
807        // seed here as "full NotExist over the target region".
808        let (port, rx) = cx.mailbox().open_accum_port_opts(
809            // Initial state for the accumulator: full mesh seeded to
810            // NotExist.
811            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
812            StreamingReducerOpts {
813                max_update_interval: Some(Duration::from_millis(50)),
814                initial_update_interval: None,
815            },
816        );
817
818        let mut reply = port.bind();
819        // If this proc dies or some other issue renders the reply undeliverable,
820        // the reply does not need to be returned to the sender.
821        reply.return_undeliverable(false);
822        // Send a message to all ranks. They reply with overlays to
823        // `port`.
824        self.proc_agent_mesh.cast(
825            cx,
826            resource::GetRankStatus {
827                id: actor_mesh_id.resource_id().clone(),
828                reply,
829            },
830        )?;
831
832        let start_time = tokio::time::Instant::now();
833
834        // Wait for all ranks to report a terminal or running status.
835        // If any proc reports a failure (via supervision) or the mesh
836        // times out, `wait()` returns Err with the final snapshot.
837        //
838        // `rx` is the accumulator output stream: each time reduced
839        // overlays are applied, it emits a new StatusMesh snapshot.
840        // `wait()` loops on it, deciding when the stream is
841        // "complete" (no more NotExist) or times out.
842        let statuses = match GetRankStatus::wait(
843            rx,
844            self.ranks.len(),
845            hyperactor_config::global::get(ACTOR_SPAWN_MAX_IDLE),
846            region.clone(), // fallback
847        )
848        .await
849        {
850            Ok(statuses) => {
851                // Spawn succeeds only if no rank has reported a
852                // supervision/terminal state. This preserves the old
853                // `first_terminating().is_none()` semantics.
854                let has_terminating = statuses.values().any(|s| s.is_terminating());
855                if !has_terminating {
856                    Ok(statuses)
857                } else {
858                    let legacy = mesh_to_rankedvalues_with_default(
859                        &statuses,
860                        Status::NotExist,
861                        Status::is_not_exist,
862                        self.ranks.len(),
863                    );
864                    Err(Error::ActorSpawnError { statuses: legacy })
865                }
866            }
867            Err(complete) => {
868                // Fill remaining ranks with a timeout status, now
869                // handled via the legacy shim.
870                let elapsed = start_time.elapsed();
871                let legacy = mesh_to_rankedvalues_with_default(
872                    &complete,
873                    Status::Timeout(elapsed),
874                    Status::is_not_exist,
875                    self.ranks.len(),
876                );
877                Err(Error::ActorSpawnError { statuses: legacy })
878            }
879        }?;
880
881        let actor_mesh_members = Arc::new(
882            self.ranks
883                .iter()
884                .map(|rank| rank.actor_addr(&actor_mesh_id))
885                .collect_mesh::<ValueMesh<_>>(self.region().clone())
886                .map_err(|error| crate::Error::ConfigurationError(error.into()))?,
887        );
888
889        let mut mesh = ActorMesh::new(
890            self.clone(),
891            actor_mesh_id.clone(),
892            None,
893            actor_mesh_members,
894        );
895        // System actors are managed by their owning runtime, not an
896        // ActorMeshController.
897        if !is_system_actor {
898            // Spawn a unique mesh manager for each actor mesh, so the type of the
899            // mesh can be preserved.
900            let controller: ActorMeshController<A> = ActorMeshController::new(
901                ActorMeshControlPlane::new(mesh.deref().clone(), self.clone()),
902                supervision_display_name.clone(),
903                Some(cx.instance().port().bind()),
904                statuses,
905            );
906            // hyperactor::proc AI-3: controller name must include mesh
907            // identity for proc-wide ActorAddr uniqueness. A fixed base name alone
908            // collides across parents because pid allocation is
909            // parent-scoped.
910            let controller_name = format!(
911                "{}_{}",
912                crate::mesh_controller::ACTOR_MESH_CONTROLLER_NAME,
913                mesh.id()
914            );
915            let controller = cx.spawn_with_label(&controller_name, controller);
916            // Controller and ActorMesh both depend on references from each other, break
917            // the cycle by setting the controller after the fact.
918            mesh.set_controller(Some(controller.bind()));
919        }
920        // Notify telemetry that an actor mesh was created.
921        {
922            let id_str = mesh.id().to_string();
923
924            // Hash the proc mesh id for parent_mesh_id.
925            let parent_mesh_id_hash = hash_to_u64(self.id());
926            let mesh_id_hash = telemetry_actor_mesh_id(self.id(), mesh.id());
927
928            hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
929                id: mesh_id_hash,
930                timestamp: std::time::SystemTime::now(),
931                class: supervision_display_name
932                    .as_deref()
933                    .and_then(python_class_from_supervision_name)
934                    .unwrap_or(actor_type),
935                given_name: mesh
936                    .id()
937                    .display_label()
938                    .map(|l| l.as_str())
939                    .unwrap_or("unnamed")
940                    .to_string(),
941                full_name: id_str,
942                shape_json: serde_json::to_string(&self.region().extent()).unwrap_or_default(),
943                parent_mesh_id: Some(parent_mesh_id_hash),
944                parent_view_json: serde_json::to_string(self.region()).ok(),
945            });
946
947            // Notify telemetry of each actor in this mesh. The rank is
948            // the actor's position within the actor mesh (not the proc's
949            // create_rank, which reflects the original unsliced mesh).
950            let now = std::time::SystemTime::now();
951            for (rank, proc_ref) in self.ranks.iter().enumerate() {
952                let display_name = supervision_display_name.as_ref().map(|sdn| {
953                    let point = self.region().extent().point_of_rank(rank).unwrap();
954                    crate::actor_display_name(sdn, &point)
955                });
956                let actor_addr = proc_ref.actor_addr(&actor_mesh_id);
957                hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
958                    id: hyperactor_telemetry::hash_to_u64(actor_addr.id()),
959                    timestamp: now,
960                    mesh_id: mesh_id_hash,
961                    rank: rank as u64,
962                    full_name: actor_addr.to_string(),
963                    display_name,
964                });
965            }
966        }
967
968        Ok(mesh)
969    }
970
971    /// Send stop actors message to all mesh agents for a specific actor mesh id.
972    #[hyperactor::instrument(fields(
973        host_mesh = self.host_mesh_id().map(|id| id.to_string()),
974        proc_mesh = self.id.to_string(),
975        actor_mesh = actor_mesh_id.to_string(),
976    ))]
977    pub(crate) async fn stop_actor_by_id(
978        &self,
979        cx: &impl context::Actor,
980        actor_mesh_id: ActorMeshId,
981        reason: String,
982    ) -> crate::Result<ValueMesh<Status>> {
983        tracing::info!(name = "ProcMeshStatus", status = "ActorMesh::Stop::Attempt");
984        tracing::info!(name = "ActorMeshStatus", status = "Stop::Attempt");
985        let result = self.stop_actor_by_id_inner(cx, actor_mesh_id, reason).await;
986        match &result {
987            Ok(_) => {
988                tracing::info!(name = "ProcMeshStatus", status = "ActorMesh::Stop::Success");
989                tracing::info!(name = "ActorMeshStatus", status = "Stop::Success");
990            }
991            Err(error) => {
992                tracing::error!(name = "ProcMeshStatus", status = "ActorMesh::Stop::Failed", %error);
993                tracing::error!(name = "ActorMeshStatus", status = "Stop::Failed", %error);
994            }
995        }
996        result
997    }
998
999    async fn stop_actor_by_id_inner(
1000        &self,
1001        cx: &impl context::Actor,
1002        actor_mesh_id: ActorMeshId,
1003        reason: String,
1004    ) -> crate::Result<ValueMesh<Status>> {
1005        let region = self.region().clone();
1006        self.proc_agent_mesh.cast(
1007            cx,
1008            resource::Stop {
1009                id: actor_mesh_id.resource_id().clone(),
1010                reason,
1011            },
1012        )?;
1013
1014        // Open an accum port that *receives overlays* and *emits full
1015        // meshes*.
1016        //
1017        // NOTE: Mailbox initializes the accumulator state via
1018        // `Default`, which is an *empty* ValueMesh (0 ranks). Our
1019        // Accumulator<ValueMesh<T>> implementation detects this on
1020        // the first update and replaces it with the caller-supplied
1021        // template (the `self` passed into open_accum_port), which we
1022        // seed here as "full NotExist over the target region".
1023        let (port, rx) = cx.mailbox().open_accum_port_opts(
1024            // Initial state for the accumulator: full mesh seeded to
1025            // NotExist.
1026            crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1027            StreamingReducerOpts {
1028                max_update_interval: Some(Duration::from_millis(50)),
1029                initial_update_interval: None,
1030            },
1031        );
1032        // Use WaitRankStatus instead of GetRankStatus so agents defer
1033        // their reply until the actor reaches terminal state, rather
1034        // than replying immediately with Stopping.
1035        self.proc_agent_mesh.cast(
1036            cx,
1037            resource::WaitRankStatus {
1038                id: actor_mesh_id.resource_id().clone(),
1039                min_status: Status::Stopped,
1040                reply: port.bind(),
1041            },
1042        )?;
1043        let start_time = tokio::time::Instant::now();
1044
1045        // Reuse actor spawn idle time.
1046        let max_idle_time = hyperactor_config::global::get(ACTOR_SPAWN_MAX_IDLE);
1047        match GetRankStatus::wait(
1048            rx,
1049            self.ranks.len(),
1050            max_idle_time,
1051            region.clone(), // fallback mesh if nothing arrives
1052        )
1053        .await
1054        {
1055            Ok(statuses) => {
1056                // Check that all actors are in a terminating state (Stopping
1057                // or beyond). Failed is ok, because one of these actors may
1058                // have failed earlier and we're trying to stop the others.
1059                let all_stopped = statuses.values().all(|s| s.is_terminating());
1060                if all_stopped {
1061                    Ok(statuses)
1062                } else {
1063                    let legacy = mesh_to_rankedvalues_with_default(
1064                        &statuses,
1065                        Status::NotExist,
1066                        Status::is_not_exist,
1067                        self.ranks.len(),
1068                    );
1069                    Err(Error::ActorStopError { statuses: legacy })
1070                }
1071            }
1072            Err(complete) => {
1073                // Fill remaining ranks with a timeout status via the
1074                // legacy shim.
1075                let legacy = mesh_to_rankedvalues_with_default(
1076                    &complete,
1077                    Status::Timeout(start_time.elapsed()),
1078                    Status::is_not_exist,
1079                    self.ranks.len(),
1080                );
1081                Err(Error::ActorStopError { statuses: legacy })
1082            }
1083        }
1084    }
1085}
1086
1087impl fmt::Display for ProcMeshRef {
1088    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1089        write!(f, "{}{{{}}}", self.id, self.region)
1090    }
1091}
1092
1093impl view::Ranked for ProcMeshRef {
1094    type Item = ProcRef;
1095
1096    fn region(&self) -> &Region {
1097        &self.region
1098    }
1099
1100    fn get(&self, rank: usize) -> Option<&Self::Item> {
1101        self.ranks.get(rank)
1102    }
1103}
1104
1105impl view::RankedSliceable for ProcMeshRef {
1106    /// Return a pure slice of this proc mesh.
1107    ///
1108    /// The returned `ProcMeshRef` contains the selected dense `ProcRef`s, and
1109    /// its `proc_agent_mesh` carries a lazy actor-mesh slice descriptor.
1110    fn sliced(&self, region: Region) -> Self {
1111        debug_assert!(region.is_subset(view::Ranked::region(self)));
1112        let ranks = self
1113            .region()
1114            .remap(&region)
1115            .unwrap()
1116            .map(|index| self.get(index).unwrap().clone())
1117            .collect::<Vec<_>>();
1118
1119        Self {
1120            id: self.id.clone(),
1121            proc_agent_mesh: self.proc_agent_mesh.sliced(region.clone()),
1122            region,
1123            ranks: Arc::new(ranks),
1124            host_mesh: self.host_mesh.clone(),
1125        }
1126    }
1127}
1128
1129/// Extract a Python class display name from a supervision display name.
1130///
1131/// The supervision display name format is `{instance}.<{module}.{ClassName} {mesh_name}>`.
1132/// Returns `"Python<ClassName>"` if the format matches, `None` otherwise.
1133///
1134/// Scope note: this function is used only by telemetry
1135/// (`MeshEvent.class`), which needs the Python class as a
1136/// structured string and has no structured carrier today. It is
1137/// not on the supervision rendering path.
1138///
1139/// TODO: retained only because the telemetry path needs a
1140/// structured Python-class string and this is the only available
1141/// source. A follow-up should add a structured carrier (e.g. an
1142/// `actor_class` field on `ActorSupervisionEvent`, or a dedicated
1143/// telemetry-side field) and delete this function.
1144fn python_class_from_supervision_name(sdn: &str) -> Option<String> {
1145    let inner = sdn.rsplit_once('<')?.1.strip_suffix('>')?;
1146    let qualified = inner.split_whitespace().next()?;
1147    let class_name = qualified.rsplit_once('.')?.1;
1148    Some(format!("Python<{class_name}>"))
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    #[cfg(fbcode_build)]
1154    use std::ops::Deref;
1155    #[cfg(fbcode_build)]
1156    use std::time::Duration;
1157
1158    #[cfg(fbcode_build)]
1159    use hyperactor::Instance;
1160    #[cfg(fbcode_build)]
1161    use hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER;
1162    #[cfg(fbcode_build)]
1163    use ndslice::ViewExt as _;
1164    #[cfg(fbcode_build)]
1165    use ndslice::extent;
1166    #[cfg(fbcode_build)]
1167    use timed_test::assert_no_process_leak;
1168    #[cfg(fbcode_build)]
1169    use timed_test::async_timed_test;
1170    #[cfg(fbcode_build)]
1171    use uuid::Uuid;
1172
1173    #[cfg(fbcode_build)]
1174    use crate::ActorMesh;
1175    #[cfg(fbcode_build)]
1176    use crate::comm::ENABLE_NATIVE_V1_CASTING;
1177    #[cfg(fbcode_build)]
1178    use crate::host_mesh::PROC_SPAWN_MAX_IDLE;
1179    #[cfg(fbcode_build)]
1180    use crate::resource::RankedValues;
1181    #[cfg(fbcode_build)]
1182    use crate::resource::Status;
1183    #[cfg(fbcode_build)]
1184    use crate::testactor;
1185    #[cfg(fbcode_build)]
1186    use crate::testing;
1187
1188    #[cfg(fbcode_build)]
1189    async fn execute_spawn_actor() {
1190        hyperactor_telemetry::initialize_logging(hyperactor_telemetry::DefaultTelemetryClock {});
1191
1192        let instance = testing::instance();
1193
1194        let mut hm = testing::host_mesh(2).await;
1195        let proc_mesh = hm
1196            .spawn(&instance, "test", extent!(gpus = 1), None, None)
1197            .await
1198            .unwrap();
1199        let actor_mesh = proc_mesh.spawn(instance, "test", &()).await.unwrap();
1200        testactor::assert_mesh_shape(actor_mesh).await;
1201
1202        let _ = hm.shutdown(instance).await;
1203    }
1204
1205    #[async_timed_test(timeout_secs = 120)]
1206    #[cfg(fbcode_build)]
1207    async fn test_spawn_actor_v1_casting() {
1208        let config = hyperactor_config::global::lock();
1209        let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, true);
1210        let _guard2 = config.override_key(ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
1211        let _guard3 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(120));
1212        let _guard4 = config.override_key(
1213            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1214            Duration::from_secs(120),
1215        );
1216        execute_spawn_actor().await;
1217    }
1218
1219    #[async_timed_test(timeout_secs = 120)]
1220    #[cfg(fbcode_build)]
1221    async fn test_spawn_actor_v1_casting_p2p() {
1222        let config = hyperactor_config::global::lock();
1223        let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, true);
1224        let _guard2 = config.override_key(ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
1225        let _guard3 = config.override_key(crate::config::V1_CAST_POINT_TO_POINT_THRESHOLD, 1024);
1226        let _guard4 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(120));
1227        let _guard5 = config.override_key(
1228            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1229            Duration::from_secs(120),
1230        );
1231        execute_spawn_actor().await;
1232    }
1233
1234    #[async_timed_test(timeout_secs = 120)]
1235    #[cfg(fbcode_build)]
1236    async fn test_spawn_actor_v0_casting() {
1237        let config = hyperactor_config::global::lock();
1238        let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, false);
1239        let _guard2 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(120));
1240        let _guard3 = config.override_key(
1241            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1242            Duration::from_secs(120),
1243        );
1244        execute_spawn_actor().await;
1245    }
1246
1247    /// Spawn an actor mesh, then do a random number of casts to bump the seq
1248    /// numbers for all actors participating in the cast. This avoids the test
1249    /// mistakenly passing.
1250    #[cfg(fbcode_build)]
1251    async fn spawn_for_seq_test(
1252        cx: &Instance<testing::TestRootClient>,
1253        proc_mesh: &super::ProcMeshRef,
1254    ) -> ActorMesh<testactor::TestActor> {
1255        let actor_mesh: ActorMesh<testactor::TestActor> =
1256            proc_mesh.spawn(cx, "test", &()).await.unwrap();
1257
1258        let instance = cx
1259            .proc()
1260            .client(&format!("random_casts_{}", Uuid::now_v7()));
1261        let n = 1;
1262        for _ in 0..n {
1263            actor_mesh.cast(&instance, ()).unwrap();
1264        }
1265        println!(
1266            "did {} casts with sequencer session id {}",
1267            n,
1268            instance.sequencer().session_id()
1269        );
1270        actor_mesh
1271    }
1272
1273    #[async_timed_test(timeout_secs = 60)]
1274    #[cfg(fbcode_build)]
1275    async fn test_seq_from_same_sender_to_different_meshes() {
1276        let config = hyperactor_config::global::lock();
1277        let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, true);
1278        let _guard2 = config.override_key(ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
1279        let _guard3 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
1280        let _guard4 = config.override_key(
1281            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1282            Duration::from_secs(60),
1283        );
1284
1285        hyperactor_telemetry::initialize_logging_for_test();
1286        let instance = testing::instance();
1287        let session_id = instance.sequencer().session_id();
1288
1289        let mut hm = testing::host_mesh(2).await;
1290        let proc_mesh = hm
1291            .spawn(&instance, "test", extent!(gpus = 1), None, None)
1292            .await
1293            .unwrap();
1294        let proc_mesh_ref = proc_mesh.deref();
1295
1296        // Sequence numbers are scoped based on the (client, dest) pair.
1297        // So casts to different meshes from the same client instance would
1298        // result in seq 1 for all casts.
1299        let handles = (0..3)
1300            .map(|_| {
1301                let proc_mesh_ref_clone = proc_mesh_ref.clone();
1302                tokio::spawn(async move {
1303                    let actor_mesh = spawn_for_seq_test(instance, &proc_mesh_ref_clone).await;
1304                    let expected_seqs = vec![1; 2];
1305                    testactor::assert_casting_correctness(
1306                        &actor_mesh,
1307                        instance,
1308                        Some((session_id, expected_seqs)),
1309                    )
1310                    .await;
1311                })
1312            })
1313            .collect::<Vec<_>>();
1314        futures::future::join_all(handles).await;
1315
1316        let _ = hm.shutdown(instance).await;
1317    }
1318
1319    /// Verify that the seq numbers are assigned correctly when we cast to
1320    /// different views of the same root mesh.
1321    #[async_timed_test(timeout_secs = 60)]
1322    #[cfg(fbcode_build)]
1323    async fn test_seq_from_same_sender_to_different_views() {
1324        let config = hyperactor_config::global::lock();
1325        let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, true);
1326        let _guard2 = config.override_key(ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
1327        let _guard3 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
1328        let _guard4 = config.override_key(
1329            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1330            Duration::from_secs(60),
1331        );
1332
1333        hyperactor_telemetry::initialize_logging_for_test();
1334
1335        let instance = testing::instance();
1336        let session_id = instance.sequencer().session_id();
1337
1338        let mut hm = testing::host_mesh(3).await;
1339        let proc_mesh = hm
1340            .spawn(&instance, "test", extent!(gpus = 1), None, None)
1341            .await
1342            .unwrap();
1343
1344        let actor_mesh = spawn_for_seq_test(instance, &proc_mesh).await;
1345
1346        // First cast. The seq should be 1 for all actors.
1347        let expected_seqs = vec![1; 3];
1348        testactor::assert_casting_correctness(
1349            &actor_mesh,
1350            instance,
1351            Some((session_id, expected_seqs)),
1352        )
1353        .await;
1354
1355        // Verify casting to the sliced actor mesh
1356        let sliced_actor_mesh = actor_mesh.range("hosts", 1..3).unwrap();
1357        // Second cast. The seq should be 2 for actors in the sliced mesh.
1358        let expected_seqs = vec![2; 2];
1359        testactor::assert_casting_correctness(
1360            &sliced_actor_mesh,
1361            instance,
1362            Some((session_id, expected_seqs)),
1363        )
1364        .await;
1365
1366        // Verify casting to a different sliced actor mesh
1367        let sliced_actor_mesh = actor_mesh.range("hosts", 0..2).unwrap();
1368        // For actors in the previous sliced mesh, the seq should be 3 since
1369        // this is the third cast for them. For other actors, the seq should
1370        // be 2.
1371        let expected_seqs = vec![2, 3];
1372        testactor::assert_casting_correctness(
1373            &sliced_actor_mesh,
1374            instance,
1375            Some((session_id, expected_seqs)),
1376        )
1377        .await;
1378
1379        let _ = hm.shutdown(instance).await;
1380    }
1381
1382    #[async_timed_test(timeout_secs = 60)]
1383    #[cfg(fbcode_build)]
1384    async fn test_seq_from_different_senders() {
1385        let config = hyperactor_config::global::lock();
1386        let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, true);
1387        let _guard2 = config.override_key(ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
1388        let _guard3 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
1389        let _guard4 = config.override_key(
1390            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1391            Duration::from_secs(60),
1392        );
1393
1394        hyperactor_telemetry::initialize_logging_for_test();
1395
1396        use hyperactor::Proc;
1397        use hyperactor::channel::ChannelTransport;
1398
1399        let proc = Proc::direct(ChannelTransport::Unix.any(), "test_0".to_string()).unwrap();
1400        let instance = proc
1401            .actor_instance::<testing::TestRootClient>("test_client")
1402            .unwrap()
1403            .instance;
1404        let first_instance = proc
1405            .actor_instance::<testing::TestRootClient>("first_client")
1406            .unwrap()
1407            .instance;
1408        let second_instance = proc
1409            .actor_instance::<testing::TestRootClient>("second_client")
1410            .unwrap()
1411            .instance;
1412        let third_instance = proc
1413            .actor_instance::<testing::TestRootClient>("third_client")
1414            .unwrap()
1415            .instance;
1416
1417        let mut hm = testing::host_mesh(2).await;
1418        let proc_mesh = hm
1419            .spawn(&instance, "test", extent!(gpus = 1), None, None)
1420            .await
1421            .unwrap();
1422
1423        let actor_mesh = spawn_for_seq_test(&instance, &proc_mesh).await;
1424
1425        // Sequence numbers are calculated based on the sequencer, i.e. the
1426        // client name. So three casts would result in seq 1 for all actors.
1427        for inst in [&first_instance, &second_instance, &third_instance] {
1428            let expected_seqs = vec![1; 2];
1429            let session_id = inst.sequencer().session_id();
1430            testactor::assert_casting_correctness(
1431                &actor_mesh,
1432                inst,
1433                Some((session_id, expected_seqs)),
1434            )
1435            .await;
1436        }
1437
1438        let _ = hm.shutdown(&instance).await;
1439    }
1440
1441    #[cfg(fbcode_build)]
1442    #[assert_no_process_leak]
1443    #[tokio::test]
1444    async fn test_failing_spawn_actor() {
1445        hyperactor_telemetry::initialize_logging(hyperactor_telemetry::DefaultTelemetryClock {});
1446
1447        let config = hyperactor_config::global::lock();
1448        let _guard = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
1449        let _guard2 = config.override_key(
1450            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1451            Duration::from_secs(60),
1452        );
1453
1454        let instance = testing::instance();
1455
1456        let mut hm = testing::host_mesh(1).await;
1457        let proc_mesh = hm
1458            .spawn(&instance, "test", extent!(gpus = 1), None, None)
1459            .await
1460            .unwrap();
1461        let err = proc_mesh
1462            .spawn::<testactor::FailingCreateTestActor, Instance<testing::TestRootClient>>(
1463                instance,
1464                "testfail",
1465                &(),
1466            )
1467            .await
1468            .unwrap_err();
1469        let statuses = err.into_actor_spawn_error().unwrap();
1470        assert_eq!(
1471            statuses,
1472            RankedValues::from((0..1, Status::Failed("test failure".to_string()))),
1473        );
1474
1475        let _ = hm.shutdown(instance).await;
1476    }
1477
1478    #[async_timed_test(timeout_secs = 60)]
1479    #[cfg(fbcode_build)]
1480    async fn test_spawn_actor_on_proc_mesh_slice_only_spawns_slice_members() {
1481        let instance = testing::instance();
1482
1483        let mut hm = testing::host_mesh(2).await;
1484        let proc_mesh = hm
1485            .spawn(&instance, "test", extent!(gpus = 2), None, None)
1486            .await
1487            .unwrap();
1488        let host1 = proc_mesh.range("hosts", 1..2).unwrap();
1489        let actor_name = crate::mesh_id::ActorMeshId::instance(
1490            hyperactor::id::Label::new("slice_only").unwrap(),
1491        );
1492
1493        let actor_mesh = host1
1494            .spawn_with_name::<testactor::TestActor, _>(
1495                instance,
1496                actor_name.clone(),
1497                &(),
1498                None,
1499                false,
1500            )
1501            .await
1502            .unwrap();
1503        testactor::assert_casting_correctness(&actor_mesh, instance, None).await;
1504
1505        let slice_states = host1
1506            .actor_states(instance, actor_name.clone())
1507            .await
1508            .unwrap();
1509        assert_eq!(slice_states.extent(), host1.extent());
1510
1511        let err = proc_mesh
1512            .actor_states(instance, actor_name.clone())
1513            .await
1514            .unwrap_err();
1515        let expected_name = actor_name.into();
1516        match err {
1517            crate::Error::NotExist(name) if name == expected_name => {}
1518            other => panic!("expected NotExist for {expected_name}, got {other:?}"),
1519        }
1520
1521        let _ = hm.shutdown(instance).await;
1522    }
1523
1524    /// `proc_states` must resolve exactly the procs of the (possibly sliced)
1525    /// mesh it is called on — gpu-dim slices, host-dim slices, and
1526    /// slice-of-slice — rather than re-deriving a dense `0..n` from per-host
1527    /// counts or from the recipient's stamped (sliced-ordinal) rank.
1528    #[cfg(fbcode_build)]
1529    async fn assert_proc_states_match_slice<C: hyperactor::context::Actor>(
1530        mesh: &super::ProcMeshRef,
1531        cx: &C,
1532    ) {
1533        // The slice's own procs, by their original global rank.
1534        let mut expected: Vec<usize> = mesh.ranks.iter().map(|r| r.create_rank).collect();
1535        expected.sort();
1536
1537        let states = mesh
1538            .states(cx, None)
1539            .await
1540            .unwrap()
1541            .expect("host-backed mesh yields Some");
1542
1543        assert_eq!(states.extent(), mesh.extent());
1544
1545        let mut got: Vec<usize> = states
1546            .values()
1547            .map(|s| {
1548                assert!(
1549                    !matches!(
1550                        s.status,
1551                        Status::NotExist | Status::Failed(_) | Status::Timeout(_)
1552                    ),
1553                    "rank should resolve to a live proc, got status {:?}",
1554                    s.status
1555                );
1556                s.state.expect("live proc carries ProcState").create_rank
1557            })
1558            .collect();
1559
1560        got.sort();
1561
1562        assert_eq!(
1563            got, expected,
1564            "proc_states must return exactly this (sub)mesh's procs"
1565        );
1566    }
1567
1568    #[async_timed_test(timeout_secs = 60)]
1569    #[cfg(fbcode_build)]
1570    async fn test_proc_states_on_sliced_mesh() {
1571        let instance = testing::instance();
1572        let mut hm = testing::host_mesh(2).await;
1573        // 2 hosts x 4 gpus, host-major: global rank = host * 4 + gpu.
1574        let proc_mesh = hm
1575            .spawn(&instance, "test", extent!(gpus = 4), None, None)
1576            .await
1577            .unwrap();
1578
1579        // Full mesh: ranks 0..8.
1580        assert_proc_states_match_slice(&proc_mesh, instance).await;
1581        // gpu-dim slice (gpus 2..4): ranks {2,3,6,7} — the original mis-derivation.
1582        assert_proc_states_match_slice(&proc_mesh.range("gpus", 2..4).unwrap(), instance).await;
1583        // host-dim slice (host 1): ranks {4,5,6,7} — the stamped-ordinal trap.
1584        assert_proc_states_match_slice(&proc_mesh.range("hosts", 1..2).unwrap(), instance).await;
1585        // slice-of-slice (host 1, gpus 2..4): ranks {6,7} — pins base-rank composition.
1586        assert_proc_states_match_slice(
1587            &proc_mesh
1588                .range("gpus", 2..4)
1589                .unwrap()
1590                .range("hosts", 1..2)
1591                .unwrap(),
1592            instance,
1593        )
1594        .await;
1595
1596        let _ = hm.shutdown(instance).await;
1597    }
1598
1599    #[test]
1600    fn test_python_class_from_supervision_name() {
1601        use super::python_class_from_supervision_name;
1602
1603        assert_eq!(
1604            python_class_from_supervision_name("instance0.<my_module.MyWorker test_mesh>"),
1605            Some("Python<MyWorker>".to_string()),
1606        );
1607        assert_eq!(
1608            python_class_from_supervision_name(
1609                "instance0.<package.submodule.TrainingActor mesh_0>"
1610            ),
1611            Some("Python<TrainingActor>".to_string()),
1612        );
1613        // No angle brackets — not a Python supervision name.
1614        assert_eq!(python_class_from_supervision_name("plain_name"), None,);
1615        // Malformed: missing dot-qualified class name.
1616        assert_eq!(
1617            python_class_from_supervision_name("instance0.<NoModule mesh>"),
1618            None,
1619        );
1620    }
1621}