Skip to main content

hyperactor_mesh/
actor_mesh.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! ## Actor mesh invariants (AM-*)
10//!
11//! - **AM-1 (dense refs):** Every `ActorMeshRef` has exactly one actor ref per
12//!   rank in its `Region`. Managed refs materialize those refs from their cast
13//!   domain, while data refs store them in a `ValueMesh`.
14//! - **AM-2 (slice materialization):** `RankedSliceable::sliced` has no
15//!   caller context, so it carries only a raw cast-domain descriptor. The first
16//!   cast through that ref materializes the descriptor with the caller context
17//!   before sending the cast message, sequencing setup and delivery on the same
18//!   sender stream.
19//! - **AM-3 (explicit direct monitoring):** Each call to
20//!   `ActorMeshRef::monitor` creates an independent direct actor monitor. The
21//!   managed variant separately retains its legacy controller supervision.
22
23use std::collections::HashMap;
24use std::fmt;
25use std::hash::Hash;
26use std::hash::Hasher;
27use std::ops::Deref;
28use std::sync::Arc;
29use std::sync::OnceLock as OnceCell;
30use std::time::Duration;
31
32use hyperactor::ActorAddr;
33use hyperactor::ActorLocal;
34use hyperactor::ActorRef;
35use hyperactor::AnyActorHandle;
36use hyperactor::Endpoint as _;
37use hyperactor::PortRef;
38use hyperactor::RemoteEndpoint as _;
39use hyperactor::RemoteHandles;
40use hyperactor::RemoteMessage;
41use hyperactor::actor::ActorStatus;
42use hyperactor::actor::Referable;
43use hyperactor::context;
44use hyperactor::mailbox::PortReceiver;
45use hyperactor::supervision::ActorSupervisionEvent;
46use hyperactor_cast::TilingPolicy;
47use hyperactor_cast::cast_actor::CastActor;
48use hyperactor_cast::cast_actor::CastDestination;
49use hyperactor_cast::cast_actor::CastDomainId;
50use hyperactor_cast::cast_actor::CastDomainRef;
51use hyperactor_cast::cast_actor::CastNode;
52use hyperactor_config::CONFIG;
53use hyperactor_config::ConfigAttr;
54use hyperactor_config::Flattrs;
55use hyperactor_config::attrs::declare_attrs;
56use ndslice::ViewExt as _;
57use ndslice::view;
58use ndslice::view::Ranked;
59use ndslice::view::Region;
60use serde::Deserialize;
61use serde::Deserializer;
62use serde::Serialize;
63use serde::Serializer;
64use tokio::sync::watch;
65
66use crate::Error;
67use crate::ProcMeshRef;
68use crate::ValueMesh;
69use crate::casting;
70use crate::config::MAX_CAST_FANOUT;
71use crate::host_mesh::GET_PROC_STATE_MAX_IDLE;
72use crate::host_mesh::mesh_to_rankedvalues_with_default;
73use crate::mesh_controller::ActorMeshController;
74use crate::mesh_controller::SUPERVISION_POLL_FREQUENCY;
75use crate::mesh_controller::Subscribe;
76use crate::mesh_controller::Unsubscribe;
77use crate::mesh_id::ActorMeshId;
78use crate::mesh_id::ProcMeshId;
79use crate::monitor::MeshMonitor;
80use crate::proc_mesh::GET_ACTOR_STATE_MAX_IDLE;
81use crate::proc_mesh::telemetry_actor_mesh_id;
82use crate::resource;
83use crate::supervision::MeshFailure;
84use crate::supervision::Unhealthy;
85
86declare_attrs! {
87    /// Liveness watchdog for the supervision stream. If no
88    /// supervision message (healthy or unhealthy) is observed within
89    /// this duration, the controller is assumed to be unreachable and
90    /// the mesh is treated as unhealthy. This timeout is about
91    /// detecting silence, not slow messages.
92    /// This value must be > poll frequency + get actor state timeout + get proc state timeout
93    /// or else it is possible to declare the controller dead before it could
94    /// feasibly have received a healthy reply.
95    @meta(CONFIG = ConfigAttr::new(
96        Some("HYPERACTOR_MESH_SUPERVISION_WATCHDOG_TIMEOUT".to_string()),
97        Some("supervision_watchdog_timeout".to_string()),
98    ))
99    pub attr SUPERVISION_WATCHDOG_TIMEOUT: Duration = Duration::from_mins(2);
100}
101
102/// An ActorMesh is a collection of ranked A-typed actors: the lifecycle owner
103/// over an [`ActorMeshRef`], which is the cast/address surface.
104///
105/// The mesh may be *managed* — created via `ProcMesh` and backed by a controller
106/// — or *data-only* — spawned through an `ActorSpawner` and carrying each rank's
107/// local lifecycle handle. Both variants own their actors and can stop them.
108/// Monitoring works through the mesh's members in either case.
109///
110/// Bound note: `A: Referable` because the mesh stores/returns `ActorRef<A>`,
111/// which is only defined for `A: Referable`.
112#[derive(Debug)]
113pub struct ActorMesh<A: Referable> {
114    /// The cast/address surface, either controller-backed (`Managed`) or
115    /// detached (`Data`).
116    current_ref: ActorMeshRef<A>,
117    lifecycle: ActorMeshLifecycle,
118}
119
120/// The local lifecycle handle for one spawner-backed rank.
121pub(crate) type ActorMeshStopHandle = AnyActorHandle;
122
123#[derive(Debug, Clone)]
124enum ActorMeshLifecycle {
125    Managed,
126    Data {
127        stop_handles: Vec<ActorMeshStopHandle>,
128        stop_requested: Arc<OnceCell<()>>,
129    },
130}
131
132/// The data-only variant of [`ActorMeshRef`]: a cheap, detached cast/address
133/// surface built directly from actor refs.
134///
135/// Membership is a [`Region`] paired with exactly one actor ref per rank. The
136/// mesh is identified by the [`ActorMeshId`] it was constructed with, which
137/// slices and serialized copies keep. It has no remote supervision stream; a
138/// caller monitors it on demand via [`ActorMeshRef::monitor`]. Multi-member
139/// casting uses a bounded-fanout cast domain.
140pub struct DataActorMesh<A: Referable> {
141    id: ActorMeshId,
142    members: ValueMesh<ActorRef<A>>,
143    cast_domain: Arc<ActorMeshCastDomain>,
144    monitor: Arc<ActorLocal<Arc<MeshMonitor>>>,
145}
146
147impl<A: Referable> fmt::Debug for DataActorMesh<A> {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.debug_struct("DataActorMesh")
150            .field("id", &self.id)
151            .field("members", &self.members)
152            .field("cast_domain", &self.cast_domain)
153            .finish_non_exhaustive()
154    }
155}
156
157// `A: Referable` for the same reason as the struct: the mesh holds `ActorRef<A>`.
158impl<A: Referable> ActorMesh<A> {
159    pub(crate) fn new(
160        proc_mesh: ProcMeshRef,
161        id: ActorMeshId,
162        controller: Option<ActorRef<ActorMeshController<A>>>,
163        members: Arc<ValueMesh<ActorAddr>>,
164    ) -> Self {
165        let region = proc_mesh.region().clone();
166        let current_ref = ActorMeshRef::new_managed(
167            id.clone(),
168            Some(proc_mesh.id().clone()),
169            region,
170            controller.clone(),
171            members,
172        );
173
174        Self {
175            current_ref,
176            lifecycle: ActorMeshLifecycle::Managed,
177        }
178    }
179
180    /// Create a data actor mesh from actor refs and their lifecycle handles.
181    pub(crate) fn try_new_data(
182        id: ActorMeshId,
183        region: Region,
184        members: Vec<ActorRef<A>>,
185        stop_handles: Vec<ActorMeshStopHandle>,
186    ) -> crate::Result<Self> {
187        assert!(!stop_handles.is_empty(), "data actor mesh must own actors");
188        assert_eq!(
189            stop_handles.len(),
190            members.len(),
191            "data actor mesh must own one lifecycle handle per member"
192        );
193        let current_ref = ActorMeshRef::try_new_data(id, region, members).inspect_err(|_| {
194            stop_handles.iter().for_each(|handle| {
195                let _ = handle.stop("data mesh construction failed");
196            });
197        })?;
198        Ok(Self {
199            current_ref,
200            lifecycle: ActorMeshLifecycle::Data {
201                stop_handles,
202                stop_requested: Arc::new(OnceCell::new()),
203            },
204        })
205    }
206
207    pub(crate) fn set_controller(&mut self, controller: Option<ActorRef<ActorMeshController<A>>>) {
208        self.current_ref
209            .as_managed_mut()
210            .expect("data actor meshes do not use a controller")
211            .set_controller(controller);
212    }
213
214    /// Stop actors on this mesh across all procs.
215    ///
216    /// A data mesh stops each rank's local supervisor proxy. The shared stop
217    /// fence makes this operation one-shot across owner clones.
218    pub async fn stop(&mut self, cx: &impl context::Actor, reason: String) -> crate::Result<()> {
219        if let ActorMeshLifecycle::Data {
220            stop_handles,
221            stop_requested,
222        } = &mut self.lifecycle
223        {
224            if stop_requested.set(()).is_err() {
225                return Ok(());
226            }
227            let stop_handles = std::mem::take(stop_handles);
228            assert!(
229                !stop_handles.is_empty(),
230                "live data actor mesh must own actors"
231            );
232            let mut stop_error = None;
233            for handle in &stop_handles {
234                if let Err(error) = handle.stop(&reason)
235                    && stop_error.is_none()
236                {
237                    stop_error = Some(error);
238                }
239            }
240            return match stop_error {
241                Some(error) => Err(Error::Other(anyhow::Error::new(error))),
242                None => Ok(()),
243            };
244        }
245        // Remove the controller so all future meshes
246        // created from this one (such as slices) know they are already stopped.
247        // Refs and slices on other machines will still be able to query the
248        // controller and will be sent a notification about this stop by the controller
249        // itself.
250        let (controller, id, num_ranks) = {
251            let managed = self
252                .current_ref
253                .as_managed_mut()
254                .expect("managed actor mesh has a managed ref");
255            (
256                managed.take_controller(),
257                managed.id().resource_id().clone(),
258                managed.region().num_ranks(),
259            )
260        };
261        if let Some(controller) = controller {
262            // Run the Stop/GetState exchange. We wrap it so that, no matter
263            // how it ends, we can record a single unhealthy event
264            // afterwards. Taking the controller is one-way: once it is gone,
265            // no future call through this handle can retry the stop, so a
266            // silently-still-healthy mesh with a vanished controller would
267            // hide the fact that the stop never reached (or never confirmed)
268            // the actors.
269            let result: crate::Result<()> = async {
270                controller.post(
271                    cx,
272                    resource::Stop {
273                        id: id.clone(),
274                        reason,
275                    },
276                );
277                // The controller processes messages serially, and its `Stop`
278                // handler already awaits the underlying ProcAgent wait, which
279                // sends its own `WaitRankStatus` to the ProcAgents and
280                // blocks up to `ACTOR_SPAWN_MAX_IDLE` for the actors to
281                // reach `Stopped`. By the time the controller gets to this
282                // `GetState`, its `health_state.statuses` already reflects
283                // the outcome (Stopping, Stopped, Failed, or Timeout on
284                // abort-budget exhaustion). We just need to serialize
285                // behind the Stop handler and read the result.
286                let (port, mut rx) = cx.mailbox().open_port();
287                controller.post(
288                    cx,
289                    resource::GetState::<resource::mesh::State<()>> {
290                        id: id.clone(),
291                        reply: port.bind(),
292                    },
293                );
294                let statuses = rx.recv().await?;
295                let Some(state) = &statuses.state else {
296                    return Err(Error::Other(anyhow::anyhow!(
297                        "non-existent state in GetState reply from controller: {}",
298                        controller.actor_addr()
299                    )));
300                };
301                // `is_terminating` accepts Stopping, Stopped, Failed, and
302                // Timeout. The controller's Stop handler has already
303                // awaited (or timed out) the underlying ProcAgent wait, so
304                // any rank still in Running here means the controller
305                // never processed the stop for that rank — a genuine
306                // error.
307                let all_terminating = state.statuses.values().all(|s| s.is_terminating());
308                if !all_terminating {
309                    let legacy = mesh_to_rankedvalues_with_default(
310                        &state.statuses,
311                        resource::Status::NotExist,
312                        resource::Status::is_not_exist,
313                        num_ranks,
314                    );
315                    return Err(Error::ActorStopError { statuses: legacy });
316                }
317                Ok(())
318            }
319            .await;
320
321            // Record the unhealthy event regardless of outcome. On success
322            // the mesh is stopped; on failure the controller is gone and
323            // the actors may still be running, but callers need to see the
324            // mesh as unhealthy either way so they stop treating it as
325            // live.
326            let status = match &result {
327                Ok(()) => ActorStatus::Stopped("mesh stopped".to_string()),
328                Err(e) => ActorStatus::Stopped(format!("mesh stop failed: {e}")),
329            };
330            self.current_ref.record_stopped(cx, status);
331
332            result?;
333        }
334        Ok(())
335    }
336}
337
338declare_attrs! {
339    /// The id of the actor mesh an actor was spawned into. Data meshes label
340    /// their members' supervision events with it, so the owner can name the
341    /// failed mesh.
342    pub attr ACTOR_MESH_ID: ActorMeshId;
343}
344
345impl<A: Referable> fmt::Display for ActorMesh<A> {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        write!(f, "{}", self.current_ref)
348    }
349}
350
351impl<A: Referable> Deref for ActorMesh<A> {
352    type Target = ActorMeshRef<A>;
353
354    fn deref(&self) -> &Self::Target {
355        &self.current_ref
356    }
357}
358
359/// Manual implementation of Clone because `A` doesn't need to implement Clone
360/// but we still want to be able to clone the ActorMesh.
361impl<A: Referable> Clone for ActorMesh<A> {
362    fn clone(&self) -> Self {
363        Self {
364            current_ref: self.current_ref.clone(),
365            lifecycle: self.lifecycle.clone(),
366        }
367    }
368}
369
370impl<A: Referable> Drop for ActorMesh<A> {
371    fn drop(&mut self) {
372        tracing::info!(
373            name = "ActorMeshStatus",
374            actor_name = %self.id(),
375            status = "Dropped",
376        );
377    }
378}
379
380impl<A: Referable> DataActorMesh<A> {
381    fn new_unchecked(id: ActorMeshId, members: ValueMesh<ActorRef<A>>) -> Self {
382        Self::with_cast_domain_config(
383            id,
384            members,
385            CastDomainId::new(),
386            default_cast_tiling_policy(),
387        )
388    }
389
390    fn with_cast_domain_config(
391        id: ActorMeshId,
392        members: ValueMesh<ActorRef<A>>,
393        cast_domain_id: CastDomainId,
394        tiling_policy: TilingPolicy,
395    ) -> Self {
396        let region = members.region().clone();
397        let member_addrs = members
398            .values()
399            .map(|member| member.actor_addr().clone())
400            .collect::<Vec<_>>();
401
402        Self {
403            id,
404            members,
405            cast_domain: Arc::new(ActorMeshCastDomain::with_config(
406                cast_domain_id,
407                Arc::new(
408                    CastDestination::mesh(region, member_addrs)
409                        .expect("actor refs from a dense mesh preserve cardinality"),
410                ),
411                tiling_policy,
412            )),
413            monitor: Arc::new(ActorLocal::new()),
414        }
415    }
416
417    /// Create a data-only actor mesh named `id` from a region and its actor
418    /// refs.
419    ///
420    /// `members` must hold one ref per rank, in region order. The mesh does not
421    /// own, stop, or supervise the actors; it is a plain view over the refs.
422    pub fn try_new(
423        id: ActorMeshId,
424        region: Region,
425        members: Vec<ActorRef<A>>,
426    ) -> crate::Result<Self> {
427        Ok(Self::new_unchecked(id, ValueMesh::new(region, members)?))
428    }
429
430    /// Return the mesh id.
431    pub fn id(&self) -> &ActorMeshId {
432        &self.id
433    }
434
435    /// Return the dense actor-ref mesh.
436    pub fn members(&self) -> &ValueMesh<ActorRef<A>> {
437        &self.members
438    }
439
440    /// Cast `message` to every member through a bounded-fanout cast domain.
441    pub fn cast<M>(&self, cx: &impl context::Actor, message: M) -> crate::Result<()>
442    where
443        A: RemoteHandles<M>,
444        M: RemoteMessage + Clone,
445    {
446        self.cast_with_headers(cx, &Flattrs::new(), message)
447    }
448
449    /// Cast `message` to every member, merging `caller_headers` into each
450    /// envelope.
451    pub fn cast_with_headers<M>(
452        &self,
453        cx: &impl context::Actor,
454        caller_headers: &Flattrs,
455        message: M,
456    ) -> crate::Result<()>
457    where
458        A: RemoteHandles<M>,
459        M: RemoteMessage + Clone,
460    {
461        self.cast_with_return_undeliverable(cx, caller_headers, message, true)
462    }
463
464    /// Cast `message` with explicit undeliverable-return behavior.
465    pub fn cast_with_return_undeliverable<M>(
466        &self,
467        cx: &impl context::Actor,
468        caller_headers: &Flattrs,
469        message: M,
470        return_undeliverable: bool,
471    ) -> crate::Result<()>
472    where
473        A: RemoteHandles<M>,
474        M: RemoteMessage + Clone,
475    {
476        if self.region().num_ranks() == 0 {
477            return Ok(());
478        }
479
480        let mut headers = caller_headers.clone();
481        headers.set(
482            casting::CAST_ORIGINATING_SENDER,
483            cx.instance().self_addr().clone(),
484        );
485        // TODO: Fix cast-domain routing for multiple nonterminal hops on one
486        // proc before supporting co-located DataActorMesh members (D119701912).
487        self.cast_domain
488            .ensure_materialized(cx, &headers)
489            .and_then(|domain| {
490                domain.cast_with_return_undeliverable(cx, headers, message, return_undeliverable)
491            })
492            .map_err(Error::Other)
493    }
494}
495
496impl<A: Referable> Clone for DataActorMesh<A> {
497    fn clone(&self) -> Self {
498        Self {
499            id: self.id.clone(),
500            members: self.members.clone(),
501            cast_domain: self.cast_domain.clone(),
502            // Exact clones share their actor-local monitor slot, even before
503            // its key is initialized. Slices use `with_cast_domain_config`
504            // instead, so each sliced region gets its own monitor.
505            monitor: Arc::clone(&self.monitor),
506        }
507    }
508}
509
510impl<A: Referable> Serialize for DataActorMesh<A> {
511    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
512    where
513        S: Serializer,
514    {
515        // Actor-local monitors are process state, so serialization omits them.
516        // Deserialization creates a fresh slot even when the new ref is used by
517        // the same actor as the original ref.
518        (
519            &self.id,
520            &self.members,
521            &self.cast_domain.id,
522            self.cast_domain.tiling_policy,
523        )
524            .serialize(serializer)
525    }
526}
527
528impl<'de, A: Referable> Deserialize<'de> for DataActorMesh<A> {
529    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
530    where
531        D: Deserializer<'de>,
532    {
533        let (id, members, cast_domain_id, tiling_policy) = Deserialize::deserialize(deserializer)?;
534        Ok(Self::with_cast_domain_config(
535            id,
536            members,
537            cast_domain_id,
538            tiling_policy,
539        ))
540    }
541}
542
543impl<A: Referable> view::Ranked for DataActorMesh<A> {
544    type Item = ActorRef<A>;
545
546    fn region(&self) -> &Region {
547        self.members.region()
548    }
549
550    fn get(&self, rank: usize) -> Option<&Self::Item> {
551        self.members.get(rank)
552    }
553}
554
555impl<A: Referable> view::RankedSliceable for DataActorMesh<A> {
556    fn sliced(&self, region: Region) -> Self {
557        Self::with_cast_domain_config(
558            self.id.clone(),
559            self.members.sliced(region),
560            CastDomainId::new(),
561            self.cast_domain.tiling_policy,
562        )
563    }
564}
565
566/// A reference to a stable snapshot of an [`ActorMesh`]: the cast and address
567/// surface, cheap to clone and to serialize.
568///
569/// `Managed` is the controller-backed ref, and `Data` is the detached ref. Both
570/// variants use cast-tree delivery and expose the same dense [`view::Ranked`]
571/// and [`view::RankedSliceable`] surface.
572#[derive(typeuri::Named)]
573pub enum ActorMeshRef<A: Referable> {
574    // Boxed to keep the enum size bounded by the `Data` representation
575    // (`clippy::large_enum_variant`).
576    Managed(Box<ManagedActorMeshRef<A>>),
577    Data(DataActorMesh<A>),
578}
579
580// Serde representation for `ActorMeshRef`, tagged by variant. Manual (via this
581// derived repr) so we do not require `A: Serialize`/`Deserialize`; the field
582// types are unconditionally serde. The `Managed` payload is boxed (both to match
583// the enum and to avoid `clippy::large_enum_variant`); `Box<T>` preserves the
584// managed payload's shape inside the new tagged representation.
585#[derive(Serialize, Deserialize)]
586#[serde(bound(serialize = "", deserialize = ""))]
587enum ActorMeshRefRepr<A: Referable> {
588    Managed(
589        Box<(
590            ActorMeshId,
591            Option<ProcMeshId>,
592            Option<ActorRef<ActorMeshController<A>>>,
593            ActorMeshCastDomain,
594        )>,
595    ),
596    Data(DataActorMesh<A>),
597}
598
599impl<A: Referable> ActorMeshRef<A> {
600    pub(crate) fn new_managed(
601        id: ActorMeshId,
602        proc_mesh_id: Option<ProcMeshId>,
603        region: Region,
604        controller: Option<ActorRef<ActorMeshController<A>>>,
605        members: Arc<ValueMesh<ActorAddr>>,
606    ) -> Self {
607        Self::Managed(Box::new(ManagedActorMeshRef::new(
608            id,
609            proc_mesh_id,
610            controller,
611            Arc::new(
612                CastDestination::mesh(region, members.values().collect())
613                    .expect("actor addresses from a value mesh preserve cardinality"),
614            ),
615        )))
616    }
617
618    /// Create a data-only ref named `id` from a region and its actor refs.
619    pub fn try_new_data(
620        id: ActorMeshId,
621        region: Region,
622        members: Vec<ActorRef<A>>,
623    ) -> crate::Result<Self> {
624        Ok(Self::Data(DataActorMesh::try_new(id, region, members)?))
625    }
626
627    /// Return the legacy dense ref when this is the managed variant.
628    pub fn as_managed(&self) -> Option<&ManagedActorMeshRef<A>> {
629        match self {
630            Self::Managed(managed) => Some(managed),
631            Self::Data(_) => None,
632        }
633    }
634
635    /// Return the legacy dense ref mutably when this is the managed variant.
636    pub fn as_managed_mut(&mut self) -> Option<&mut ManagedActorMeshRef<A>> {
637        match self {
638            Self::Managed(managed) => Some(managed),
639            Self::Data(_) => None,
640        }
641    }
642
643    /// Return the mesh id.
644    pub fn id(&self) -> &ActorMeshId {
645        match self {
646            Self::Managed(managed) => managed.id(),
647            Self::Data(data) => data.id(),
648        }
649    }
650
651    /// Cast a message to all the actors in this mesh.
652    ///
653    /// This initiates delivery without waiting for it; use [`Self::monitor`] to
654    /// observe actors that stop or fail.
655    pub fn cast<M>(&self, cx: &impl context::Actor, message: M) -> crate::Result<()>
656    where
657        A: RemoteHandles<M>,
658        M: RemoteMessage + Clone,
659    {
660        match self {
661            Self::Managed(m) => m.cast(cx, message),
662            Self::Data(d) => d.cast(cx, message),
663        }
664    }
665
666    pub fn cast_with_headers<M>(
667        &self,
668        cx: &impl context::Actor,
669        caller_headers: &Flattrs,
670        message: M,
671    ) -> crate::Result<()>
672    where
673        A: RemoteHandles<M>,
674        M: RemoteMessage + Clone,
675    {
676        self.cast_with_return_undeliverable(cx, caller_headers, message, true)
677    }
678
679    /// Cast `message` with explicit undeliverable-return behavior.
680    pub fn cast_with_return_undeliverable<M>(
681        &self,
682        cx: &impl context::Actor,
683        caller_headers: &Flattrs,
684        message: M,
685        return_undeliverable: bool,
686    ) -> crate::Result<()>
687    where
688        A: RemoteHandles<M>,
689        M: RemoteMessage + Clone,
690    {
691        match self {
692            Self::Managed(m) => {
693                m.cast_with_return_undeliverable(cx, caller_headers, message, return_undeliverable)
694            }
695            Self::Data(d) => {
696                d.cast_with_return_undeliverable(cx, caller_headers, message, return_undeliverable)
697            }
698        }
699    }
700
701    /// Return the next supervision event observed on this mesh.
702    pub async fn next_supervision_event(
703        &self,
704        cx: &impl context::Actor,
705    ) -> Result<MeshFailure, anyhow::Error> {
706        match self {
707            Self::Managed(managed) => managed.next_supervision_event(cx).await,
708            Self::Data(data) => {
709                let monitor = data
710                    .monitor
711                    .entry(cx)
712                    .or_insert_with(|| {
713                        Arc::new(MeshMonitor::spawn_dormant(cx, self.monitored_actors()))
714                    })
715                    .get()
716                    .clone();
717                let _activation = monitor.activate(cx);
718                Ok((&*monitor).await)
719            }
720        }
721    }
722
723    /// See [`ManagedActorMeshRef::clone_with_supervision_receiver`].
724    pub fn clone_with_supervision_receiver(&self) -> Self {
725        match self {
726            Self::Managed(m) => Self::Managed(Box::new(m.clone_with_supervision_receiver())),
727            Self::Data(d) => Self::Data(d.clone()),
728        }
729    }
730
731    pub(crate) fn record_stopped(&self, cx: &impl context::Actor, status: ActorStatus) {
732        self.as_managed()
733            .expect("only managed actor meshes record local stopped state")
734            .record_stopped(cx, status);
735    }
736
737    /// Create a new, independent monitor over every rank.
738    pub fn monitor(&self, cx: &impl context::Actor) -> MeshMonitor {
739        MeshMonitor::spawn_persistent(cx, self.monitored_actors())
740    }
741
742    fn monitored_actors(&self) -> ValueMesh<ActorAddr> {
743        let region = self.region().clone();
744        let actors = self
745            .values()
746            .map(|actor| actor.actor_addr().clone())
747            .collect();
748        ValueMesh::new(region, actors)
749            .expect("actor addresses collected from a dense mesh preserve cardinality")
750    }
751}
752
753impl<A: Referable> Clone for ActorMeshRef<A> {
754    fn clone(&self) -> Self {
755        match self {
756            Self::Managed(m) => Self::Managed(m.clone()),
757            Self::Data(d) => Self::Data(d.clone()),
758        }
759    }
760}
761
762impl<A: Referable> fmt::Display for ActorMeshRef<A> {
763    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
764        match self {
765            Self::Managed(m) => write!(f, "{}", m),
766            Self::Data(d) => write!(f, "{:?}", d),
767        }
768    }
769}
770
771impl<A: Referable> fmt::Debug for ActorMeshRef<A> {
772    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
773        match self {
774            Self::Managed(m) => write!(f, "{:?}", m),
775            Self::Data(d) => write!(f, "{:?}", d),
776        }
777    }
778}
779
780impl<A: Referable> PartialEq for ActorMeshRef<A> {
781    fn eq(&self, other: &Self) -> bool {
782        match (self, other) {
783            (Self::Managed(a), Self::Managed(b)) => a == b,
784            (Self::Data(a), Self::Data(b)) => a == b,
785            _ => false,
786        }
787    }
788}
789impl<A: Referable> Eq for ActorMeshRef<A> {}
790
791impl<A: Referable> Hash for ActorMeshRef<A> {
792    fn hash<H: Hasher>(&self, state: &mut H) {
793        match self {
794            Self::Managed(m) => {
795                0u8.hash(state);
796                m.hash(state);
797            }
798            Self::Data(d) => {
799                1u8.hash(state);
800                d.hash(state);
801            }
802        }
803    }
804}
805
806impl<A: Referable> Serialize for ActorMeshRef<A> {
807    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
808    where
809        S: Serializer,
810    {
811        let repr = match self {
812            Self::Managed(m) => ActorMeshRefRepr::<A>::Managed(Box::new((
813                m.id.clone(),
814                m.proc_mesh_id.clone(),
815                m.controller.clone(),
816                m.cast_domain.clone(),
817            ))),
818            Self::Data(d) => ActorMeshRefRepr::<A>::Data(d.clone()),
819        };
820        repr.serialize(serializer)
821    }
822}
823
824impl<'de, A: Referable> Deserialize<'de> for ActorMeshRef<A> {
825    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
826    where
827        D: Deserializer<'de>,
828    {
829        Ok(match ActorMeshRefRepr::<A>::deserialize(deserializer)? {
830            ActorMeshRefRepr::Managed(boxed) => {
831                let (id, proc_mesh_id, controller, cast_domain) = *boxed;
832                Self::Managed(Box::new(ManagedActorMeshRef::with_cast_domain(
833                    id,
834                    proc_mesh_id,
835                    controller,
836                    cast_domain,
837                    DEFAULT_PAGE,
838                )))
839            }
840            ActorMeshRefRepr::Data(data) => Self::Data(data),
841        })
842    }
843}
844
845impl<A: Referable> view::Ranked for ActorMeshRef<A> {
846    type Item = ActorRef<A>;
847
848    fn region(&self) -> &Region {
849        match self {
850            Self::Managed(managed) => managed.as_ref().region(),
851            Self::Data(data) => data.region(),
852        }
853    }
854
855    fn get(&self, rank: usize) -> Option<&Self::Item> {
856        match self {
857            Self::Managed(managed) => managed.as_ref().get(rank),
858            Self::Data(data) => data.get(rank),
859        }
860    }
861}
862
863impl<A: Referable> view::RankedSliceable for ActorMeshRef<A> {
864    fn sliced(&self, region: Region) -> Self {
865        match self {
866            Self::Managed(managed) => Self::Managed(Box::new(managed.as_ref().sliced(region))),
867            Self::Data(data) => Self::Data(data.sliced(region)),
868        }
869    }
870}
871
872impl<A: Referable> PartialEq for DataActorMesh<A> {
873    fn eq(&self, other: &Self) -> bool {
874        self.id == other.id && self.members == other.members
875    }
876}
877impl<A: Referable> Eq for DataActorMesh<A> {}
878
879impl<A: Referable> Hash for DataActorMesh<A> {
880    fn hash<H: Hasher>(&self, state: &mut H) {
881        self.id.hash(state);
882        self.members.hash(state);
883    }
884}
885
886/// Influences paging behavior for the lazy cache. Smaller pages
887/// reduce over-allocation for sparse access; larger pages reduce the
888/// number of heap allocations for contiguous scans.
889const DEFAULT_PAGE: usize = 1024;
890
891/// A lazily materialized page of ActorRefs.
892struct Page<A: Referable> {
893    slots: Box<[OnceCell<ActorRef<A>>]>,
894}
895
896impl<A: Referable> Page<A> {
897    fn new(len: usize) -> Self {
898        let mut v = Vec::with_capacity(len);
899        for _ in 0..len {
900            v.push(OnceCell::new());
901        }
902        Self {
903            slots: v.into_boxed_slice(),
904        }
905    }
906}
907
908#[derive(Default)]
909struct HealthState {
910    unhealthy_event: Option<Unhealthy>,
911    crashed_ranks: HashMap<usize, ActorSupervisionEvent>,
912}
913
914impl HealthState {
915    fn failure_for_region(&self, region: &Region) -> Option<MeshFailure> {
916        let unhealthy = self.unhealthy_event.as_ref()?;
917        let mut failure = match unhealthy {
918            Unhealthy::StreamClosed(failure) | Unhealthy::Crashed(failure) => failure.clone(),
919        };
920        if failure.crashed_ranks.is_empty() {
921            return Some(failure);
922        }
923        let mut crashed_ranks = self
924            .crashed_ranks
925            .keys()
926            .copied()
927            .filter(|rank| region.slice().contains(*rank))
928            .collect::<Vec<_>>();
929        crashed_ranks.sort_unstable();
930        if crashed_ranks.is_empty() {
931            return None;
932        }
933        failure.crashed_ranks = crashed_ranks;
934        Some(failure)
935    }
936}
937
938impl std::fmt::Debug for HealthState {
939    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
940        f.debug_struct("HealthState")
941            .field("unhealthy_event", &self.unhealthy_event)
942            .field("crashed_ranks", &self.crashed_ranks)
943            .finish()
944    }
945}
946
947#[derive(Clone)]
948enum MessageOrFailure<M: Send + Sync + Clone + Default + 'static> {
949    Message(M),
950    // anyhow::Error and MailboxError are not clone-able, which we need to move
951    // out of a tokio watch Ref.
952    Failure(String),
953    Timeout,
954}
955
956impl<M: Send + Sync + Clone + Default + 'static> Default for MessageOrFailure<M> {
957    fn default() -> Self {
958        Self::Message(M::default())
959    }
960}
961
962fn default_cast_tiling_policy() -> TilingPolicy {
963    TilingPolicy::BoundedFanout {
964        fanout: hyperactor_config::global::get(MAX_CAST_FANOUT).into(),
965    }
966}
967
968/// Build one routing node for each destination without changing the mesh shape.
969///
970/// Input:
971///
972/// ```text
973/// destinations: [proc_0::actor_0, proc_1::actor_1]
974/// policy: BlockPartitioning
975/// ```
976///
977/// Output:
978///
979/// ```text
980/// [
981///   proc_0::cast {actor_0},
982///   proc_1::cast {actor_1},
983/// ]
984/// ```
985fn cast_node_mesh(
986    destinations: &ValueMesh<CastDestination>,
987) -> anyhow::Result<ValueMesh<CastNode>> {
988    let nodes = destinations
989        .values()
990        .map(|destination| {
991            CastNode::new(
992                CastActor::ref_for_proc(destination.actor().proc_addr()),
993                destination,
994            )
995        })
996        .collect::<Vec<_>>();
997
998    ValueMesh::new(destinations.region().clone(), nodes).map_err(Into::into)
999}
1000
1001#[derive(Clone)]
1002struct ActorMeshCastDomain {
1003    id: CastDomainId,
1004    destinations: Arc<ValueMesh<CastDestination>>,
1005    tiling_policy: TilingPolicy,
1006    cast_domain: ActorLocal<CastDomainRef>,
1007}
1008
1009impl std::fmt::Debug for ActorMeshCastDomain {
1010    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1011        f.debug_struct("ActorMeshCastDomain")
1012            .field("id", &self.id)
1013            .field("destinations", &self.destinations)
1014            .field("tiling_policy", &self.tiling_policy)
1015            .finish_non_exhaustive()
1016    }
1017}
1018
1019impl ActorMeshCastDomain {
1020    fn new(destinations: Arc<ValueMesh<CastDestination>>) -> Self {
1021        Self::with_config(
1022            CastDomainId::new(),
1023            destinations,
1024            default_cast_tiling_policy(),
1025        )
1026    }
1027
1028    fn with_config(
1029        id: CastDomainId,
1030        destinations: Arc<ValueMesh<CastDestination>>,
1031        tiling_policy: TilingPolicy,
1032    ) -> Self {
1033        Self {
1034            id,
1035            destinations,
1036            tiling_policy,
1037            cast_domain: ActorLocal::new(),
1038        }
1039    }
1040
1041    fn ensure_materialized(
1042        &self,
1043        cx: &impl context::Actor,
1044        headers: &Flattrs,
1045    ) -> anyhow::Result<CastDomainRef> {
1046        if let hyperactor::actor_local::Entry::Occupied(cast_domain) = self.cast_domain.entry(cx) {
1047            return Ok(cast_domain.get().clone());
1048        }
1049
1050        let nodes = Arc::new(cast_node_mesh(&self.destinations)?);
1051        let cast_domain =
1052            self.id
1053                .clone()
1054                .materialize(cx, nodes, self.tiling_policy, headers.clone())?;
1055
1056        self.cast_domain.entry(cx).or_insert(cast_domain.clone());
1057
1058        Ok(cast_domain)
1059    }
1060
1061    fn region(&self) -> &Region {
1062        self.destinations.region()
1063    }
1064}
1065
1066impl Serialize for ActorMeshCastDomain {
1067    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1068    where
1069        S: Serializer,
1070    {
1071        (&self.id, &self.destinations, self.tiling_policy).serialize(serializer)
1072    }
1073}
1074
1075impl<'de> Deserialize<'de> for ActorMeshCastDomain {
1076    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1077    where
1078        D: Deserializer<'de>,
1079    {
1080        let (id, destinations, tiling_policy) =
1081            <(CastDomainId, Arc<ValueMesh<CastDestination>>, TilingPolicy)>::deserialize(
1082                deserializer,
1083            )?;
1084
1085        Ok(Self {
1086            id,
1087            destinations,
1088            tiling_policy,
1089            cast_domain: ActorLocal::new(),
1090        })
1091    }
1092}
1093
1094/// Turn the single-owner PortReceiver into a watch receiver, which can be
1095/// cloned and subscribed to. Requires a default message to pre-populate with.
1096/// Option can be used as M to provide a default of None.
1097fn into_watch<M: Send + Sync + Clone + Default + 'static>(
1098    mut rx: PortReceiver<M>,
1099) -> watch::Receiver<MessageOrFailure<M>> {
1100    let (sender, receiver) = watch::channel(MessageOrFailure::<M>::default());
1101    // Apply a watchdog timeout to the supervision stream. If no
1102    // supervision message (healthy or unhealthy) is observed within
1103    // this window, we assume the controller is unreachable and
1104    // surface a terminal failure on the watch channel. This is a
1105    // watchdog against indefinite silence, not a message-delivery
1106    // guarantee, and may conservatively treat a quiet but healthy
1107    // controller as failed.
1108    let timeout = hyperactor_config::global::get(SUPERVISION_WATCHDOG_TIMEOUT);
1109    let poll_frequency = hyperactor_config::global::get(SUPERVISION_POLL_FREQUENCY);
1110    let get_actor_state_max_idle = hyperactor_config::global::get(GET_ACTOR_STATE_MAX_IDLE);
1111    let get_proc_state_max_idle = hyperactor_config::global::get(GET_PROC_STATE_MAX_IDLE);
1112    let total_time = poll_frequency + get_actor_state_max_idle + get_proc_state_max_idle;
1113    if timeout < total_time {
1114        tracing::warn!(
1115            "HYPERACTOR_MESH_SUPERVISION_WATCHDOG_TIMEOUT={} is too short. It should be >= {} (SUPERVISION_POLL_FREQUENCY={} + GET_ACTOR_STATE_MAX_IDLE={} + GET_PROC_STATE_MAX_IDLE={})",
1116            humantime::format_duration(timeout),
1117            humantime::format_duration(total_time),
1118            humantime::format_duration(poll_frequency),
1119            humantime::format_duration(get_actor_state_max_idle),
1120            humantime::format_duration(get_proc_state_max_idle),
1121        );
1122    }
1123    tokio::spawn(async move {
1124        loop {
1125            let message = match tokio::time::timeout(timeout, rx.recv()).await {
1126                Ok(Ok(msg)) => MessageOrFailure::Message(msg),
1127                Ok(Err(e)) => MessageOrFailure::Failure(e.to_string()),
1128                Err(_) => MessageOrFailure::Timeout,
1129            };
1130            let is_failure = matches!(
1131                message,
1132                MessageOrFailure::Failure(_) | MessageOrFailure::Timeout
1133            );
1134            if sender.send(message).is_err() {
1135                // After a sending error, exit the task.
1136                break;
1137            }
1138            if is_failure {
1139                // No need to keep polling if we've received an error or timeout.
1140                break;
1141            }
1142        }
1143    });
1144    receiver
1145}
1146
1147/// The managed variant of [`ActorMeshRef`]: a controller-backed reference to a
1148/// stable snapshot of a legacy [`ActorMesh`], over a dense `ndslice` region.
1149pub struct ManagedActorMeshRef<A: Referable> {
1150    id: ActorMeshId,
1151    /// Id of the proc mesh backing this actor mesh, if any. Retained as a
1152    /// lightweight id (not the `ProcMeshRef`) so telemetry can derive the same
1153    /// mesh id as creation time without holding proc-mesh state. `None` for
1154    /// meshes not backed by a user proc mesh (e.g. the host-agent mesh).
1155    proc_mesh_id: Option<ProcMeshId>,
1156    /// Reference to a remote controller actor living on the proc that spawned
1157    /// the actors in this ref. If None, the actor mesh was already stopped, or
1158    /// this is a mesh ref to a "system actor" which has no controller and should
1159    /// not be stopped. If Some, the actor mesh may still be stopped, and the
1160    /// next_supervision_event function can be used to alert that the mesh has
1161    /// stopped.
1162    controller: Option<ActorRef<ActorMeshController<A>>>,
1163
1164    /// Cast-domain handle for this mesh view.
1165    ///
1166    /// A cast domain is the `hyperactor_cast` routing state used to deliver a
1167    /// mesh-wide cast without iterating over every destination actor. The
1168    /// descriptor can be carried by pure slices; routing setup is fenced by the
1169    /// first cast from the caller that uses this ref.
1170    cast_domain: ActorMeshCastDomain,
1171    /// Recorded health issues with the mesh, to quickly consult before sending
1172    /// out any casted messages. This is a locally updated copy of the authoritative
1173    /// state stored on the ActorMeshController.
1174    health_state: ActorLocal<HealthState>,
1175    /// Shared cloneable receiver for supervision events, used by next_supervision_event.
1176    /// Needs tokio mutex because it is held across an await point.
1177    /// Should not be shared across actors because each actor context needs its
1178    /// own subscriber.
1179    receiver: ActorLocal<
1180        Arc<
1181            tokio::sync::Mutex<(
1182                PortRef<Option<MeshFailure>>,
1183                watch::Receiver<MessageOrFailure<Option<MeshFailure>>>,
1184            )>,
1185        >,
1186    >,
1187    /// Lazily allocated collection of pages:
1188    /// - The outer `OnceCell` defers creating the vector until first
1189    ///   use.
1190    /// - The `Vec` holds slots for multiple pages.
1191    /// - Each slot is itself a `OnceCell<Box<Page<A>>>`, so that each
1192    ///   page can be initialized on demand.
1193    /// - A `Page<A>` is a boxed slice of `OnceCell<ActorRef<A>>`,
1194    ///   i.e. the actual storage for actor references within that
1195    ///   page.
1196    pages: OnceCell<Vec<OnceCell<Box<Page<A>>>>>,
1197    // Page size knob (not serialize; defaults after deserialize).
1198    page_size: usize,
1199}
1200
1201impl<A: Referable> ManagedActorMeshRef<A> {
1202    fn cached_failure(&self, cx: &impl context::Actor) -> Option<MeshFailure> {
1203        let health_state = self.health_state.entry(cx).or_default();
1204        health_state.get().failure_for_region(self.region())
1205    }
1206
1207    /// Cast a message to all the actors in this mesh
1208    #[allow(clippy::result_large_err)]
1209    pub fn cast<M>(&self, cx: &impl context::Actor, message: M) -> crate::Result<()>
1210    where
1211        A: RemoteHandles<M>,
1212        M: RemoteMessage + Clone,
1213    {
1214        self.cast_with_headers(cx, &Flattrs::new(), message)
1215    }
1216
1217    /// Cast a message to all the actors in this mesh, merging
1218    /// caller-supplied `caller_headers` into the per-rank envelope
1219    /// headers before send. Used to propagate caller-known context
1220    /// (e.g. operation-context keys marked with `OPERATION_CONTEXT_HEADER`)
1221    /// onto the outgoing request so receivers can project it back
1222    /// onto replies.
1223    #[allow(clippy::result_large_err)]
1224    pub fn cast_with_headers<M>(
1225        &self,
1226        cx: &impl context::Actor,
1227        caller_headers: &Flattrs,
1228        message: M,
1229    ) -> crate::Result<()>
1230    where
1231        A: RemoteHandles<M>,
1232        M: RemoteMessage + Clone,
1233    {
1234        self.cast_with_return_undeliverable(cx, caller_headers, message, true)
1235    }
1236
1237    /// Cast `message` with explicit undeliverable-return behavior.
1238    #[expect(
1239        clippy::result_large_err,
1240        reason = "actor mesh errors preserve casting and mesh failure context"
1241    )]
1242    pub fn cast_with_return_undeliverable<M>(
1243        &self,
1244        cx: &impl context::Actor,
1245        caller_headers: &Flattrs,
1246        message: M,
1247        return_undeliverable: bool,
1248    ) -> crate::Result<()>
1249    where
1250        A: RemoteHandles<M>,
1251        M: RemoteMessage + Clone,
1252    {
1253        self.emit_sent_message_telemetry(cx, self.region());
1254
1255        let mut headers = caller_headers.clone();
1256        headers.set(
1257            casting::CAST_ORIGINATING_SENDER,
1258            cx.instance().self_addr().clone(),
1259        );
1260        headers.set(crate::casting::CAST_ACTOR_MESH_ID, self.id.clone());
1261
1262        if self.len() == 0 {
1263            Ok(())
1264        } else {
1265            self.cast_domain
1266                .ensure_materialized(cx, &headers)
1267                .map_err(|e| Error::CastingError(self.id.clone(), e))?
1268                .cast_with_return_undeliverable(cx, headers, message, return_undeliverable)
1269                .map_err(|e| Error::CastingError(self.id.clone(), e))
1270        }
1271    }
1272
1273    /// Cast a message to one randomly chosen actor in this mesh, merging
1274    /// caller-supplied `caller_headers` into the outgoing envelope.
1275    #[allow(clippy::result_large_err)]
1276    pub fn cast_choose_with_headers<M>(
1277        &self,
1278        cx: &impl context::Actor,
1279        caller_headers: &Flattrs,
1280        message: M,
1281    ) -> crate::Result<()>
1282    where
1283        A: RemoteHandles<M>,
1284        M: RemoteMessage + Clone,
1285    {
1286        self.emit_sent_message_telemetry(
1287            cx,
1288            &Region::new(
1289                Vec::new(),
1290                ndslice::Slice::new(0, Vec::new(), Vec::new())
1291                    .expect("zero-dimensional slice is valid"),
1292            ),
1293        );
1294
1295        let num_ranks = self.cast_domain.region().num_ranks();
1296
1297        if num_ranks == 0 {
1298            return Ok(());
1299        }
1300
1301        let rank_index = rand::random::<u64>() as usize % num_ranks;
1302
1303        let point = self
1304            .cast_domain
1305            .region()
1306            .extent()
1307            .point_of_rank(rank_index)
1308            .map_err(|err| Error::CastingError(self.id.clone(), err.into()))?;
1309
1310        let actor = self.materialize(rank_index).ok_or_else(|| {
1311            Error::CastingError(
1312                self.id.clone(),
1313                anyhow::anyhow!("missing actor for chosen rank {rank_index}"),
1314            )
1315        })?;
1316
1317        self.post_cast_direct(cx, point, actor, message, caller_headers)
1318    }
1319
1320    fn emit_sent_message_telemetry(&self, cx: &impl context::Actor, region: &Region) {
1321        hyperactor_telemetry::notify_sent_message(hyperactor_telemetry::SentMessageEvent {
1322            timestamp: std::time::SystemTime::now(),
1323            sender_actor_id: hyperactor_telemetry::hash_to_u64(cx.mailbox().actor_addr().id()),
1324            actor_mesh_id: match &self.proc_mesh_id {
1325                Some(proc_mesh_id) => telemetry_actor_mesh_id(proc_mesh_id, &self.id),
1326                // No backing proc mesh (e.g. the host-agent mesh): key telemetry
1327                // on the actor mesh id alone.
1328                None => hyperactor_telemetry::hash_to_u64(&self.id),
1329            },
1330            view_json: serde_json::to_string(region).unwrap_or_default(),
1331            shape_json: {
1332                let shape: ndslice::Shape = region.into();
1333                serde_json::to_string(&shape).unwrap_or_default()
1334            },
1335        });
1336    }
1337
1338    #[allow(clippy::result_large_err)]
1339    fn post_cast_direct<M>(
1340        &self,
1341        cx: &impl context::Actor,
1342        point: ndslice::Point,
1343        actor: &ActorRef<A>,
1344        message: M,
1345        caller_headers: &Flattrs,
1346    ) -> crate::Result<()>
1347    where
1348        A: RemoteHandles<M>,
1349        M: RemoteMessage,
1350    {
1351        let create_rank = point.rank();
1352        let mut headers = caller_headers.clone();
1353        casting::set_cast_info_on_headers(&mut headers, point, cx.instance().self_addr().clone());
1354
1355        // Make sure that we rewrite ranks, as these may be used for
1356        // bootstrapping actors.
1357        let mut data = wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message)
1358            .map_err(|e| Error::CastingError(self.id.clone(), e.into()))?;
1359        data.visit_multipart_parts_mut::<resource::RankRepr, anyhow::Error>(
1360            |resource::RankRepr(rank)| {
1361                *rank = Some(create_rank);
1362                Ok(())
1363            },
1364        )
1365        .map_err(|e| Error::CastingError(self.id.clone(), e))?;
1366        let rebound_message = data
1367            .deserialized_unchecked()
1368            .map_err(|e| Error::CastingError(self.id.clone(), e.into()))?;
1369        actor.post_with_headers(cx, headers, rebound_message);
1370        Ok(())
1371    }
1372
1373    pub(crate) fn new(
1374        id: ActorMeshId,
1375        proc_mesh_id: Option<ProcMeshId>,
1376        controller: Option<ActorRef<ActorMeshController<A>>>,
1377        destinations: Arc<ValueMesh<CastDestination>>,
1378    ) -> Self {
1379        Self::with_page_size(id, proc_mesh_id, DEFAULT_PAGE, controller, destinations)
1380    }
1381
1382    pub fn id(&self) -> &ActorMeshId {
1383        &self.id
1384    }
1385
1386    pub(crate) fn with_page_size(
1387        id: ActorMeshId,
1388        proc_mesh_id: Option<ProcMeshId>,
1389        page_size: usize,
1390        controller: Option<ActorRef<ActorMeshController<A>>>,
1391        destinations: Arc<ValueMesh<CastDestination>>,
1392    ) -> Self {
1393        Self::with_cast_domain(
1394            id,
1395            proc_mesh_id,
1396            controller,
1397            ActorMeshCastDomain::new(destinations),
1398            page_size,
1399        )
1400    }
1401
1402    fn with_cast_domain(
1403        id: ActorMeshId,
1404        proc_mesh_id: Option<ProcMeshId>,
1405        controller: Option<ActorRef<ActorMeshController<A>>>,
1406        cast_domain: ActorMeshCastDomain,
1407        page_size: usize,
1408    ) -> Self {
1409        Self {
1410            id,
1411            proc_mesh_id,
1412            controller,
1413            cast_domain,
1414            health_state: ActorLocal::new(),
1415            receiver: ActorLocal::new(),
1416            pages: OnceCell::new(),
1417            page_size: page_size.max(1),
1418        }
1419    }
1420
1421    #[inline]
1422    fn len(&self) -> usize {
1423        self.cast_domain.region().num_ranks()
1424    }
1425
1426    pub fn controller(&self) -> &Option<ActorRef<ActorMeshController<A>>> {
1427        &self.controller
1428    }
1429
1430    fn take_controller(&mut self) -> Option<ActorRef<ActorMeshController<A>>> {
1431        self.controller.take()
1432    }
1433
1434    fn set_controller(&mut self, controller: Option<ActorRef<ActorMeshController<A>>>) {
1435        self.controller = controller;
1436    }
1437
1438    fn ensure_pages(&self) -> &Vec<OnceCell<Box<Page<A>>>> {
1439        let n = self.len().div_ceil(self.page_size); // ⌈len / page_size⌉
1440        self.pages
1441            .get_or_init(|| (0..n).map(|_| OnceCell::new()).collect())
1442    }
1443
1444    fn materialize(&self, rank: usize) -> Option<&ActorRef<A>> {
1445        let len = self.len();
1446        if rank >= len {
1447            return None;
1448        }
1449        let cast_domain = &self.cast_domain;
1450        let p = self.page_size;
1451        let page_ix = rank / p;
1452        let local_ix = rank % p;
1453
1454        let pages = self.ensure_pages();
1455        let page = pages[page_ix].get_or_init(|| {
1456            // Last page may be partial.
1457            let base = page_ix * p;
1458            let remaining = len - base;
1459            let page_len = remaining.min(p);
1460            Box::new(Page::<A>::new(page_len))
1461        });
1462
1463        Some(page.slots[local_ix].get_or_init(|| {
1464            // AM-1: see module doc. Cast destinations have the same dense
1465            // local-rank order as this mesh ref's region.
1466            debug_assert!(rank < self.len(), "rank must be within [0, len)");
1467            ActorRef::attest(
1468                Ranked::get(cast_domain.destinations.as_ref(), rank)
1469                    .map(CastDestination::actor)
1470                    .expect("rank must be present in cast-domain member map")
1471                    .clone(),
1472            )
1473        }))
1474    }
1475
1476    fn init_supervision_receiver(
1477        controller: &ActorRef<ActorMeshController<A>>,
1478        cx: &impl context::Actor,
1479    ) -> (
1480        PortRef<Option<MeshFailure>>,
1481        watch::Receiver<MessageOrFailure<Option<MeshFailure>>>,
1482    ) {
1483        let (tx, rx) = cx.mailbox().open_port();
1484        let tx = tx.bind();
1485        controller.post(cx, Subscribe(tx.clone()));
1486        (tx, into_watch(rx))
1487    }
1488
1489    /// Returns the next supervision event occurring on this mesh. Await this
1490    /// simultaneously with the return result of a message (such as awaiting a reply after a cast)
1491    /// to get back a message that indicates the actor that failed, instead of
1492    /// waiting forever for a reply.
1493    /// If there are multiple simultaneous awaits of next_supervision_event,
1494    /// all of them will receive the same event.
1495    pub async fn next_supervision_event(
1496        &self,
1497        cx: &impl context::Actor,
1498    ) -> Result<MeshFailure, anyhow::Error> {
1499        if let Some(failure) = self.cached_failure(cx) {
1500            tracing::debug!(
1501                actor_mesh = %self.id,
1502                crashed_ranks = ?failure.crashed_ranks,
1503                "returning cached supervision failure"
1504            );
1505            return Ok(failure);
1506        }
1507        let controller = if let Some(c) = self.controller() {
1508            c
1509        } else {
1510            return Err(anyhow::anyhow!(
1511                "unexpected healthy state while controller is gone"
1512            ));
1513        };
1514        let rx = {
1515            // Make sure to create only one PortReceiver per context.
1516            let entry = self.receiver.entry(cx).or_insert_with(|| {
1517                Arc::new(tokio::sync::Mutex::new(Self::init_supervision_receiver(
1518                    controller, cx,
1519                )))
1520            });
1521            // Need to clone so the lifetime is disconnected from entry, which
1522            // isn't Send so can't be held across an await point.
1523            Arc::clone(entry.get())
1524        };
1525        let message = {
1526            let mut rx = rx.lock().await;
1527            let subscriber_port = rx.0.clone();
1528            let message =
1529                rx.1.wait_for(|message| {
1530                    // Filter out messages that do not apply to these ranks. This
1531                    // is relevant for slices since we get messages back for the
1532                    // whole mesh.
1533                    if let MessageOrFailure::Message(message) = message {
1534                        if let Some(message) = &message {
1535                            let region = self.region().slice();
1536                            if message.crashed_ranks.is_empty() {
1537                                // Whole-mesh event (e.g. mesh stop).
1538                                true
1539                            } else {
1540                                // Accept if any crashed rank overlaps with
1541                                // this slice's region.
1542                                message.crashed_ranks.iter().any(|r| region.contains(*r))
1543                            }
1544                        } else {
1545                            // Filter out messages that are not failures. These are used
1546                            // to ensure the controller is still reachable, but are not
1547                            // otherwise interesting.
1548                            false
1549                        }
1550                    } else {
1551                        // either failure case is interesting
1552                        true
1553                    }
1554                })
1555                .await?;
1556            let message = message.clone();
1557            let is_failure = matches!(
1558                message,
1559                MessageOrFailure::Failure(_) | MessageOrFailure::Timeout
1560            );
1561            if is_failure {
1562                // In failure cases, the receiver is dropped, so we can unsubscribe
1563                // from the controller. The controller can detect this
1564                // on its own, but an explicit unsubscribe prevents error logs
1565                // about this receiver being unreachable.
1566                let mut port = controller.port();
1567                // We don't care if the controller is unreachable for an unsubscribe.
1568                port.return_undeliverable(false);
1569                let _ = port.post(cx, Unsubscribe(subscriber_port));
1570            }
1571            // If we successfully got a message back, we can't unsubscribe because
1572            // the receiver might be shared with other calls to next_supervision_event,
1573            // or with clones of this ActorMeshRef.
1574            match message {
1575                MessageOrFailure::Message(message) => Ok::<MeshFailure, anyhow::Error>(
1576                    message.expect("filter excludes any None messages"),
1577                ),
1578                MessageOrFailure::Failure(failure) => Err(anyhow::anyhow!("{}", failure)),
1579                MessageOrFailure::Timeout => {
1580                    // Treat timeout from controller as a supervision failure,
1581                    // the controller is unreachable.
1582                    Ok(MeshFailure {
1583                        actor_mesh_name: Some(self.id().to_string()),
1584                        event: ActorSupervisionEvent::new(
1585                            controller.actor_addr().clone(),
1586                            None,
1587                            ActorStatus::generic_failure(format!(
1588                                "timed out reaching controller {} for mesh {}. Assuming controller's proc is dead",
1589                                controller.actor_addr(),
1590                                self.id()
1591                            )),
1592                            None,
1593                        ),
1594                        crashed_ranks: vec![],
1595                        // MFCA-4: synthesized locally when the controller is
1596                        // unreachable, not a controller report.
1597                        reporting_controller: None,
1598                    })
1599                }
1600            }?
1601        };
1602        // Update the health state now that we have received a message.
1603        let event = &message.event;
1604        // Make sure not to hold this lock across an await point.
1605        let mut entry = self.health_state.entry(cx).or_default();
1606        let health_state = entry.get_mut();
1607        if let ActorStatus::Failed(_) = event.actor_status {
1608            for &rank in &message.crashed_ranks {
1609                health_state.crashed_ranks.insert(rank, event.clone());
1610            }
1611        }
1612        health_state.unhealthy_event = match &event.actor_status {
1613            ActorStatus::Failed(_) => Some(Unhealthy::Crashed(message.clone())),
1614            ActorStatus::Stopped(_) => Some(Unhealthy::StreamClosed(message.clone())),
1615            _ => None,
1616        };
1617        Ok(message)
1618    }
1619
1620    /// Record a synthetic "stopped" unhealthy event so future casts through this
1621    /// ref (and its slices) observe the mesh as no longer live.
1622    pub(crate) fn record_stopped(&self, cx: &impl context::Actor, status: ActorStatus) {
1623        let mut entry = self.health_state.entry(cx).or_default();
1624        let health_state = entry.get_mut();
1625        health_state.unhealthy_event = Some(Unhealthy::StreamClosed(MeshFailure {
1626            actor_mesh_name: Some(self.id().to_string()),
1627            event: ActorSupervisionEvent::new(
1628                // Use an actor id from the mesh.
1629                self.get(0)
1630                    .expect("mesh must have at least one rank")
1631                    .actor_addr()
1632                    .clone(),
1633                None,
1634                status,
1635                None,
1636            ),
1637            crashed_ranks: vec![],
1638            // MFCA-4: synthesized locally by the mesh handle, not a controller
1639            // report.
1640            reporting_controller: None,
1641        }));
1642    }
1643
1644    /// Same as Clone, but includes a shared supervision receiver. This copy will
1645    /// share the same health state and get the same supervision events.
1646    /// Will have a separate cache.
1647    pub fn clone_with_supervision_receiver(&self) -> Self {
1648        Self {
1649            id: self.id.clone(),
1650            proc_mesh_id: self.proc_mesh_id.clone(),
1651            controller: self.controller.clone(),
1652            cast_domain: self.cast_domain.clone(),
1653            health_state: self.health_state.clone(),
1654            receiver: self.receiver.clone(),
1655            // Cache does not support Clone at this time.
1656            pages: OnceCell::new(),
1657            page_size: self.page_size,
1658        }
1659    }
1660}
1661
1662impl<A: Referable> Clone for ManagedActorMeshRef<A> {
1663    fn clone(&self) -> Self {
1664        Self {
1665            id: self.id.clone(),
1666            proc_mesh_id: self.proc_mesh_id.clone(),
1667            controller: self.controller.clone(),
1668            cast_domain: self.cast_domain.clone(),
1669            // Cloning should not use the same health state or receiver, because
1670            // it should make a new subscriber.
1671            health_state: ActorLocal::new(),
1672            receiver: ActorLocal::new(),
1673            pages: OnceCell::new(), // No clone cache.
1674            page_size: self.page_size,
1675        }
1676    }
1677}
1678
1679impl<A: Referable> fmt::Display for ManagedActorMeshRef<A> {
1680    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1681        write!(
1682            f,
1683            "{}:{}@{}",
1684            self.id,
1685            A::typename(),
1686            self.cast_domain.region()
1687        )
1688    }
1689}
1690
1691impl<A: Referable> PartialEq for ManagedActorMeshRef<A> {
1692    fn eq(&self, other: &Self) -> bool {
1693        // Value identity: the same mesh (`id`) over the same `region`. `id` is
1694        // cloned through `sliced()` and preserved across serialization, and the
1695        // members are a function of `(id, region)`, so this captures "same
1696        // actors." The cast domain's `domain_id` is a freshly-minted per-slice
1697        // routing token (`Uid::anonymous()`), so it is deliberately excluded —
1698        // two independent slices to the same region denote the same actors and
1699        // must compare equal.
1700        self.id == other.id && self.cast_domain.region() == other.cast_domain.region()
1701    }
1702}
1703impl<A: Referable> Eq for ManagedActorMeshRef<A> {}
1704
1705impl<A: Referable> Hash for ManagedActorMeshRef<A> {
1706    fn hash<H: Hasher>(&self, state: &mut H) {
1707        self.id.hash(state);
1708        self.cast_domain.region().hash(state);
1709    }
1710}
1711
1712impl<A: Referable> fmt::Debug for ManagedActorMeshRef<A> {
1713    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1714        f.debug_struct("ActorMeshRef")
1715            .field("region", self.cast_domain.region())
1716            .field("id", &self.id)
1717            .field("page_size", &self.page_size)
1718            .finish_non_exhaustive() // No print cache.
1719    }
1720}
1721
1722// Implement Serialize manually, without requiring A: Serialize
1723impl<A: Referable> Serialize for ManagedActorMeshRef<A> {
1724    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1725    where
1726        S: Serializer,
1727    {
1728        // Serialize only the fields that don't depend on A.
1729        (
1730            &self.id,
1731            &self.proc_mesh_id,
1732            &self.controller,
1733            &self.cast_domain,
1734        )
1735            .serialize(serializer)
1736    }
1737}
1738
1739// Implement Deserialize manually, without requiring A: Deserialize
1740impl<'de, A: Referable> Deserialize<'de> for ManagedActorMeshRef<A> {
1741    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1742    where
1743        D: Deserializer<'de>,
1744    {
1745        let (id, proc_mesh_id, controller, cast_domain) = <(
1746            ActorMeshId,
1747            Option<ProcMeshId>,
1748            Option<ActorRef<ActorMeshController<A>>>,
1749            ActorMeshCastDomain,
1750        )>::deserialize(deserializer)?;
1751        Ok(Self::with_cast_domain(
1752            id,
1753            proc_mesh_id,
1754            controller,
1755            cast_domain,
1756            DEFAULT_PAGE,
1757        ))
1758    }
1759}
1760
1761impl<A: Referable> view::Ranked for ManagedActorMeshRef<A> {
1762    type Item = ActorRef<A>;
1763
1764    #[inline]
1765    fn region(&self) -> &Region {
1766        self.cast_domain.region()
1767    }
1768
1769    #[inline]
1770    fn get(&self, rank: usize) -> Option<&Self::Item> {
1771        self.materialize(rank)
1772    }
1773}
1774
1775impl<A: Referable> view::RankedSliceable for ManagedActorMeshRef<A> {
1776    /// Return a pure slice of this actor mesh.
1777    ///
1778    /// This method cannot install routing state because the trait has no caller
1779    /// context. Instead it carries a lazy cast-domain descriptor. The first cast
1780    /// through the returned ref posts setup from that caller before sending the
1781    /// cast message, preserving normal sender-side stream ordering.
1782    fn sliced(&self, region: Region) -> Self {
1783        // Slices inherit cached failures that were already observed on the parent
1784        // mesh ref so new sub-slices do not race the controller replay path.
1785        // The supervision receiver stays independent because each slice applies
1786        // its own region filter to future updates.
1787        debug_assert!(region.is_subset(self.region()));
1788        Self {
1789            id: self.id.clone(),
1790            proc_mesh_id: self.proc_mesh_id.clone(),
1791            controller: self.controller.clone(),
1792            cast_domain: ActorMeshCastDomain::new(Arc::new(
1793                CastDestination::subset(self.cast_domain.destinations.as_ref(), region.clone())
1794                    .expect("actor mesh slice must preserve its destination nodes"),
1795            )),
1796            health_state: self.health_state.clone(),
1797            receiver: ActorLocal::new(),
1798            pages: OnceCell::new(),
1799            page_size: self.page_size,
1800        }
1801    }
1802}
1803
1804#[cfg(all(test, fbcode_build))]
1805mod tests {
1806
1807    use std::collections::HashMap;
1808    use std::collections::HashSet;
1809    use std::ops::Deref;
1810    use std::sync::Arc;
1811
1812    use hyperactor::ActorRef;
1813    use hyperactor::Endpoint as _;
1814    use hyperactor::Proc;
1815    use hyperactor::ProcAddr;
1816    use hyperactor::actor::ActorErrorKind;
1817    use hyperactor::actor::ActorStatus;
1818    use hyperactor::channel::ChannelAddr;
1819    use hyperactor::context::Mailbox as _;
1820    use hyperactor::id::Label;
1821    use hyperactor::mailbox;
1822    use hyperactor::supervision::ActorSupervisionEvent;
1823    use hyperactor_cast::TilingPolicy;
1824    use hyperactor_cast::cast_actor::CastDestination;
1825    use hyperactor_cast::cast_actor::CastDomainId;
1826    use ndslice::Extent;
1827    use ndslice::Region;
1828    use ndslice::Slice;
1829    use ndslice::ViewExt;
1830    use ndslice::extent;
1831    use ndslice::view::Ranked;
1832    use ndslice::view::RankedSliceable;
1833    use timed_test::assert_no_process_leak;
1834    use timed_test::async_timed_test;
1835    use tokio::time::Duration;
1836
1837    use super::ActorMesh;
1838    use super::DataActorMesh;
1839    use crate::ActorMeshRef;
1840    use crate::ProcMesh;
1841    use crate::ValueMesh;
1842    use crate::host_mesh::GET_PROC_STATE_MAX_IDLE;
1843    use crate::host_mesh::PROC_SPAWN_MAX_IDLE;
1844    use crate::mesh_controller::ActorMeshControlPlane;
1845    use crate::mesh_controller::ActorMeshController;
1846    use crate::mesh_controller::SUPERVISION_POLL_FREQUENCY;
1847    use crate::mesh_id::ActorMeshId;
1848    use crate::proc_agent::ActorState;
1849    use crate::proc_mesh::ACTOR_SPAWN_MAX_IDLE;
1850    use crate::proc_mesh::GET_ACTOR_STATE_MAX_IDLE;
1851    use crate::proc_mesh::USE_DIRECT_SPAWN;
1852    use crate::resource;
1853    use crate::supervision::MeshFailure;
1854    use crate::test_utils::local_host_mesh;
1855    use crate::testactor;
1856    use crate::testing;
1857
1858    #[test]
1859    fn test_actor_mesh_ref_is_send_and_sync() {
1860        fn assert_send_sync<T: Send + Sync>() {}
1861        assert_send_sync::<ActorMeshRef<()>>();
1862    }
1863
1864    #[async_timed_test(timeout_secs = 30)]
1865    async fn test_data_actor_mesh_ref_observes_terminal_status() {
1866        let proc = Proc::isolated();
1867        let client = proc.client("client");
1868        let target = client.spawn_with_label("rank0", testactor::TestActor);
1869        let actor_ref = target.bind::<testactor::TestActor>();
1870        let actor_addr = target.actor_addr().clone();
1871        let mesh = ActorMeshRef::try_new_data(
1872            ActorMeshId::instance(Label::new("actors").unwrap()),
1873            extent!(actors = 1).into(),
1874            vec![actor_ref],
1875        )
1876        .expect("data actor mesh ref should be valid");
1877
1878        let mut wait = Box::pin(mesh.next_supervision_event(&client));
1879        tokio::select! {
1880            failure = &mut wait => panic!("unexpected failure before stop: {failure:?}"),
1881            _ = tokio::task::yield_now() => {}
1882        }
1883        target
1884            .drain_and_stop("rank complete")
1885            .expect("target should accept stop");
1886
1887        let failure = tokio::time::timeout(Duration::from_secs(1), wait)
1888            .await
1889            .expect("timed out waiting for data actor supervision")
1890            .expect("data actor supervision should succeed");
1891        assert_eq!(failure.crashed_ranks, vec![0]);
1892        assert_eq!(failure.event.actor_id, actor_addr);
1893        assert!(matches!(
1894            failure.event.actor_status,
1895            ActorStatus::Stopped(ref reason) if reason == "rank complete"
1896        ));
1897    }
1898
1899    #[test]
1900    fn test_actor_mesh_ref_data_variant_is_detached_data_structure() {
1901        let region: Region = extent!(replicas = 2).into();
1902        let members = vec![
1903            ActorRef::<testactor::TestActor>::attest(
1904                ProcAddr::instance(ChannelAddr::Local(9000), "data").actor_addr("rank0"),
1905            ),
1906            ActorRef::<testactor::TestActor>::attest(
1907                ProcAddr::instance(ChannelAddr::Local(9000), "data").actor_addr("rank1"),
1908            ),
1909        ];
1910
1911        let id = ActorMeshId::instance(Label::new("data").unwrap());
1912        let mesh: ActorMeshRef<testactor::TestActor> =
1913            ActorMeshRef::try_new_data(id.clone(), region.clone(), members.clone()).unwrap();
1914
1915        assert_eq!(mesh.id(), &id);
1916        assert_eq!(mesh.region(), &region);
1917        assert_eq!(mesh.get(1).unwrap().actor_addr(), members[1].actor_addr());
1918        assert_eq!(
1919            mesh.values()
1920                .map(|actor_ref| actor_ref.actor_addr().clone())
1921                .collect::<Vec<_>>(),
1922            members
1923                .iter()
1924                .map(|actor_ref| actor_ref.actor_addr().clone())
1925                .collect::<Vec<_>>()
1926        );
1927        let ActorMeshRef::Data(data) = &mesh else {
1928            panic!("data constructor returned a managed ref");
1929        };
1930        assert_eq!(data.members().values().count(), 2);
1931        let cast_domain_id = data.cast_domain.id.clone();
1932        assert!(matches!(
1933            data.cast_domain.tiling_policy,
1934            TilingPolicy::BoundedFanout { .. }
1935        ));
1936
1937        let cloned = mesh.clone();
1938        let ActorMeshRef::Data(cloned_data) = &cloned else {
1939            panic!("cloned data ref changed variant");
1940        };
1941        assert_eq!(cloned_data.cast_domain.id, cast_domain_id);
1942        assert!(Arc::ptr_eq(&data.monitor, &cloned_data.monitor));
1943
1944        let slice_region = region
1945            .range("replicas", 1..2)
1946            .expect("rank 1 slice should exist");
1947        let slice = mesh.sliced(slice_region.clone());
1948        let ActorMeshRef::Data(slice_data) = &slice else {
1949            panic!("data slice changed variant");
1950        };
1951        assert_ne!(slice_data.cast_domain.id, cast_domain_id);
1952        assert!(!Arc::ptr_eq(&data.monitor, &slice_data.monitor));
1953        assert_eq!(slice.id(), &id);
1954        assert_eq!(slice.region(), &slice_region);
1955        assert_eq!(slice.values().count(), 1);
1956        assert_eq!(slice.get(0).unwrap().actor_addr(), members[1].actor_addr());
1957
1958        let encoded = serde_json::to_vec(&mesh).expect("data ref should serialize");
1959        let decoded: ActorMeshRef<testactor::TestActor> =
1960            serde_json::from_slice(&encoded).expect("data ref should deserialize");
1961        assert_eq!(decoded, mesh);
1962        assert_eq!(decoded.id(), &id);
1963        assert_eq!(decoded.region(), &region);
1964        assert_eq!(decoded.values().count(), 2);
1965        let ActorMeshRef::Data(decoded_data) = &decoded else {
1966            panic!("decoded data ref changed variant");
1967        };
1968        assert_eq!(decoded_data.cast_domain.id, cast_domain_id);
1969        assert!(!Arc::ptr_eq(&data.monitor, &decoded_data.monitor));
1970
1971        assert!(matches!(
1972            ActorMeshRef::try_new_data(id.clone(), region.clone(), vec![members[0].clone()]),
1973            Err(crate::Error::InvalidRankCardinality {
1974                expected: 2,
1975                actual: 1,
1976            })
1977        ));
1978    }
1979
1980    #[test]
1981    fn test_data_actor_mesh_slice_preserves_tiling_policy() {
1982        let region: Region = extent!(replicas = 2).into();
1983        let members = ValueMesh::new(
1984            region.clone(),
1985            vec![
1986                ActorRef::<testactor::TestActor>::attest(
1987                    ProcAddr::instance(ChannelAddr::Local(9000), "data").actor_addr("rank0"),
1988                ),
1989                ActorRef::<testactor::TestActor>::attest(
1990                    ProcAddr::instance(ChannelAddr::Local(9000), "data").actor_addr("rank1"),
1991                ),
1992            ],
1993        )
1994        .expect("member mesh should be valid");
1995        let data = DataActorMesh::with_cast_domain_config(
1996            ActorMeshId::instance(Label::new("data").unwrap()),
1997            members,
1998            CastDomainId::new(),
1999            TilingPolicy::BlockPartitioning,
2000        );
2001        let slice_region = region
2002            .range("replicas", 1..2)
2003            .expect("rank 1 slice should exist");
2004
2005        let slice = data.sliced(slice_region);
2006
2007        assert!(matches!(
2008            slice.cast_domain.tiling_policy,
2009            TilingPolicy::BlockPartitioning
2010        ));
2011        assert_ne!(slice.cast_domain.id, data.cast_domain.id);
2012    }
2013
2014    #[tokio::test]
2015    async fn test_actor_mesh_ref_monitor_owns_status_and_guards_operations() {
2016        let proc = Proc::isolated();
2017        let sender = proc.actor_instance::<()>("sender").unwrap();
2018        sender.instance.bind::<()>();
2019        let target = sender
2020            .instance
2021            .spawn_with_label("rank0", testactor::TestActor);
2022        let region: Region = extent!(replicas = 1).into();
2023        let actor_ref: ActorRef<testactor::TestActor> = target.bind();
2024        let mesh = ActorMeshRef::try_new_data(
2025            ActorMeshId::instance(Label::new("data").unwrap()),
2026            region,
2027            vec![actor_ref.clone()],
2028        )
2029        .expect("data ref should be valid");
2030        let monitor = mesh.monitor(&sender.instance);
2031        let independent_monitor = mesh.monitor(&sender.instance);
2032
2033        assert_eq!(monitor.status(0), Some(ActorStatus::Unknown));
2034        assert_eq!(independent_monitor.status(0), Some(ActorStatus::Unknown));
2035        assert_eq!(mesh.get(0).expect("rank 0 should exist"), &actor_ref);
2036
2037        let observed = {
2038            let mut wait = std::pin::pin!(monitor.guard(std::future::pending::<()>()));
2039            tokio::select! {
2040                biased;
2041                failure = &mut wait => panic!("unexpected failure before stop: {failure:?}"),
2042                _ = tokio::task::yield_now() => {}
2043            }
2044
2045            target
2046                .drain_and_stop("rank complete")
2047                .expect("target should accept stop");
2048
2049            tokio::time::timeout(Duration::from_secs(10), wait)
2050                .await
2051                .expect("timed out waiting for monitored guard failure")
2052                .expect_err("pending operation should lose to rank failure")
2053        };
2054        let independently_observed = tokio::time::timeout(
2055            Duration::from_secs(10),
2056            independent_monitor.guard(std::future::pending::<()>()),
2057        )
2058        .await
2059        .expect("timed out waiting for independent monitor failure")
2060        .expect_err("pending operation should lose to rank failure");
2061        assert_eq!(observed.crashed_ranks, vec![0]);
2062        assert_eq!(independently_observed.crashed_ranks, vec![0]);
2063        assert!(matches!(
2064            observed.event.actor_status,
2065            ActorStatus::Stopped(ref reason) if reason == "rank complete"
2066        ));
2067        assert!(matches!(
2068            monitor.status(0).expect("rank 0 should remain monitored"),
2069            ActorStatus::Stopped(ref reason) if reason == "rank complete"
2070        ));
2071
2072        let (port, _rx) = mailbox::open_port(&sender.instance);
2073        mesh.cast(&sender.instance, testactor::GetActorId(port.bind()))
2074            .expect("casting does not implicitly check monitor status");
2075
2076        tokio::time::timeout(Duration::from_secs(5), target)
2077            .await
2078            .expect("timed out waiting for target to stop");
2079
2080        mesh.cast(&sender.instance, ())
2081            .expect("casting to a stopped actor should still initiate delivery");
2082    }
2083
2084    #[tokio::test]
2085    async fn test_actor_mesh_ref_lazy_materialization() {
2086        let config = hyperactor_config::global::lock();
2087        let _mode = config.override_key(USE_DIRECT_SPAWN, false);
2088        // 1) Bring up procs and spawn actors.
2089        let instance = testing::instance();
2090        // Small mesh so the test runs fast, but > page_size so we
2091        // cross a boundary
2092        let mut hm = testing::host_mesh(2).await;
2093        let pm: ProcMesh = hm
2094            .spawn(instance, "test", extent!(gpus = 2), None, None)
2095            .await
2096            .unwrap();
2097        let am: ActorMesh<testactor::TestActor> = pm.spawn(instance, "test", &()).await.unwrap();
2098
2099        // 2) Build our ActorMeshRef with a tiny page size (2) to
2100        // force multiple pages:
2101        // page 0: ranks [0,1], page 1: [2,3], page 2: [4,5]
2102        let page_size = 2;
2103        let ActorMeshRef::Managed(managed) = am.deref() else {
2104            panic!("spawned actor mesh should be managed");
2105        };
2106        let amr: ActorMeshRef<testactor::TestActor> =
2107            ActorMeshRef::Managed(Box::new(super::ManagedActorMeshRef::with_page_size(
2108                am.id().clone(),
2109                managed.proc_mesh_id.clone(),
2110                page_size,
2111                None,
2112                Arc::clone(&managed.cast_domain.destinations),
2113            )));
2114        assert_eq!(amr.extent(), extent!(hosts = 2, gpus = 2));
2115        assert_eq!(amr.region().num_ranks(), 4);
2116        assert_eq!(amr.values().count(), 4);
2117
2118        let encoded = serde_json::to_vec(&amr).expect("managed ref should serialize");
2119        let decoded: ActorMeshRef<testactor::TestActor> =
2120            serde_json::from_slice(&encoded).expect("managed ref should deserialize");
2121        assert!(matches!(&decoded, ActorMeshRef::Managed(_)));
2122        assert_eq!(decoded, amr);
2123        assert_eq!(decoded.region(), amr.region());
2124        assert_eq!(decoded.get(3), amr.get(3));
2125
2126        // 3) Within-rank pointer stability (OnceLock caches &ActorRef)
2127        let p0_a = amr.get(0).expect("rank 0 exists") as *const _;
2128        let p0_b = amr.get(0).expect("rank 0 exists") as *const _;
2129        assert_eq!(p0_a, p0_b, "same rank should return same cached pointer");
2130
2131        // 4) Same page, different rank (both materialize fine)
2132        let p1_a = amr.get(1).expect("rank 1 exists") as *const _;
2133        let p1_b = amr.get(1).expect("rank 1 exists") as *const _;
2134        assert_eq!(p1_a, p1_b, "same rank should return same cached pointer");
2135        // They're different ranks, so the pointers are different
2136        // (distinct OnceLocks in the page)
2137        assert_ne!(p0_a, p1_a, "different ranks have different cache slots");
2138
2139        // 5) Cross a page boundary (rank 2 is in a different page than rank 0/1)
2140        let p2_a = amr.get(2).expect("rank 2 exists") as *const _;
2141        let p2_b = amr.get(2).expect("rank 2 exists") as *const _;
2142        assert_eq!(p2_a, p2_b, "same rank should return same cached pointer");
2143        assert_ne!(p0_a, p2_a, "different pages have different cache slots");
2144
2145        // 6) Clone should drop the cache but keep identity (actor_id)
2146        let amr_clone = amr.clone();
2147        let orig_id_0 = amr.get(0).unwrap().actor_addr().clone();
2148        let clone_id_0 = amr_clone.get(0).unwrap().actor_addr().clone();
2149        assert_eq!(orig_id_0, clone_id_0, "clone preserves identity");
2150        let p0_clone = amr_clone.get(0).unwrap() as *const _;
2151        assert_ne!(
2152            p0_a, p0_clone,
2153            "cloned ActorMeshRef has a fresh cache (different pointer)"
2154        );
2155
2156        // 7) Slicing preserves page_size and clears cache
2157        // (RankedSliceable::sliced)
2158        let sliced = amr.range("hosts", 0..2).expect("slice should be valid"); // leaves 4 ranks
2159        assert_eq!(sliced.region().num_ranks(), 4);
2160        assert!(
2161            sliced.get(0).is_some(),
2162            "RankedSliceable::sliced preserves a lazy cast-domain descriptor"
2163        );
2164        // First access materializes a new cache for the sliced view.
2165        let sp0_a = sliced.get(0).unwrap() as *const _;
2166        let sp0_b = sliced.get(0).unwrap() as *const _;
2167        assert_eq!(sp0_a, sp0_b, "sliced view has its own cache slot per rank");
2168        // Cross-page inside the slice too (page_size = 2 => pages are
2169        // [0..2), [2..4)).
2170        let sp2 = sliced.get(2).unwrap() as *const _;
2171        assert_ne!(sp0_a, sp2, "sliced view crosses its own page boundary");
2172
2173        // 8) Hash/Eq ignore cache state; identical identity collapses
2174        // to one set entry.
2175        let mut set = HashSet::new();
2176        set.insert(amr.clone());
2177        set.insert(amr.clone());
2178        assert_eq!(set.len(), 1, "cache state must not affect Hash/Eq");
2179
2180        // 9) As a sanity check, cast to ensure the refs are indeed
2181        // usable/live.
2182        let (port, mut rx) = mailbox::open_port(instance);
2183        // Send to rank 0 and rank 3 (extent 3x2 => at least 4 ranks
2184        // exist).
2185        amr.get(0)
2186            .expect("rank 0 exists")
2187            .post(instance, testactor::GetActorId(port.bind()));
2188        amr.get(3)
2189            .expect("rank 3 exists")
2190            .post(instance, testactor::GetActorId(port.bind()));
2191        let id_a = tokio::time::timeout(Duration::from_secs(3), rx.recv())
2192            .await
2193            .expect("timed out waiting for first reply")
2194            .expect("channel closed before first reply");
2195        let id_b = tokio::time::timeout(Duration::from_secs(3), rx.recv())
2196            .await
2197            .expect("timed out waiting for second reply")
2198            .expect("channel closed before second reply");
2199        assert_ne!(id_a, id_b, "two different ranks responded");
2200
2201        let _ = hm.shutdown(instance).await;
2202    }
2203
2204    #[async_timed_test(timeout_secs = 300)]
2205    #[cfg(fbcode_build)]
2206    async fn test_actor_mesh_slice_casts_only_to_slice_members() {
2207        let instance = testing::instance();
2208        let mut hm = testing::host_mesh(2).await;
2209        let pm: ProcMesh = hm
2210            .spawn(instance, "test", extent!(gpus = 2), None, None)
2211            .await
2212            .unwrap();
2213        let actor_mesh: ActorMesh<testactor::TestActor> =
2214            pm.spawn(instance, "test", &()).await.unwrap();
2215
2216        {
2217            let host1_region = actor_mesh
2218                .region()
2219                .range("hosts", 1..2)
2220                .expect("host slice should exist");
2221            let host1 = actor_mesh.sliced(host1_region);
2222            // This waits for one reply from every actor in the sliced mesh and
2223            // then asserts that no extra actors replied. Passing `None` does not
2224            // pin exact sequence numbers, but the destination handler still unwraps
2225            // `SEQ_INFO`, so the delivered cast must carry ordering metadata.
2226            testactor::assert_casting_correctness(&host1, instance, None).await;
2227
2228            {
2229                let host1_gpu1_region = host1
2230                    .region()
2231                    .range("gpus", 1..2)
2232                    .expect("nested slice should exist");
2233                let host1_gpu1 = host1.sliced(host1_gpu1_region);
2234                testactor::assert_casting_correctness(&host1_gpu1, instance, None).await;
2235            }
2236        }
2237
2238        let _ = hm.shutdown(instance).await;
2239    }
2240
2241    async fn assert_slice_cast_points(
2242        actor_mesh: &ActorMeshRef<testactor::TestActor>,
2243        instance: &impl hyperactor::context::Actor,
2244    ) {
2245        let (port, mut rx) = mailbox::open_port(instance);
2246        actor_mesh
2247            .cast(
2248                instance,
2249                testactor::GetCastInfo {
2250                    cast_info: port.bind(),
2251                },
2252            )
2253            .unwrap();
2254
2255        let mut expected: HashMap<_, _> = actor_mesh
2256            .values()
2257            .enumerate()
2258            .map(|(rank, actor_ref)| {
2259                (
2260                    actor_ref.actor_addr().clone(),
2261                    actor_mesh
2262                        .extent()
2263                        .point_of_rank(rank)
2264                        .expect("rank must be in-bounds for slice extent"),
2265                )
2266            })
2267            .collect();
2268
2269        while !expected.is_empty() {
2270            let (point, actor_ref, _sender) =
2271                tokio::time::timeout(Duration::from_secs(3), rx.recv())
2272                    .await
2273                    .expect("timed out waiting for cast info")
2274                    .expect("channel closed before receiving cast info");
2275            let expected_point = expected
2276                .remove(actor_ref.actor_addr())
2277                .expect("received cast info from unexpected actor");
2278            assert_eq!(
2279                point, expected_point,
2280                "cast point should be computed from slice-local rank and shape"
2281            );
2282        }
2283
2284        tokio::time::sleep(Duration::from_secs(1)).await;
2285        let result = rx.try_recv();
2286        assert!(result.as_ref().unwrap().is_none(), "got {result:?}");
2287    }
2288
2289    #[async_timed_test(timeout_secs = 60)]
2290    #[cfg(fbcode_build)]
2291    async fn test_actor_mesh_slice_cast_uses_slice_local_points() {
2292        let instance = testing::instance();
2293        let mut hm = testing::host_mesh(2).await;
2294        let pm: ProcMesh = hm
2295            .spawn(instance, "test", extent!(gpus = 2), None, None)
2296            .await
2297            .unwrap();
2298        let actor_mesh: ActorMesh<testactor::TestActor> =
2299            pm.spawn(instance, "test", &()).await.unwrap();
2300
2301        let host1_region = actor_mesh
2302            .region()
2303            .range("hosts", 1..2)
2304            .expect("host slice should exist");
2305        let host1 = actor_mesh.sliced(host1_region);
2306        assert_slice_cast_points(&host1, instance).await;
2307
2308        let host1_gpu1_region = host1
2309            .region()
2310            .range("gpus", 1..2)
2311            .expect("nested slice should exist");
2312        let host1_gpu1 = host1.sliced(host1_gpu1_region);
2313        assert_slice_cast_points(&host1_gpu1, instance).await;
2314
2315        let _ = hm.shutdown(instance).await;
2316    }
2317
2318    #[async_timed_test(timeout_secs = 300)]
2319    async fn test_actor_states_with_panic() {
2320        hyperactor_telemetry::initialize_logging_for_test();
2321
2322        let instance = testing::instance();
2323        let config = hyperactor_config::global::lock();
2324        let _mode = config.override_key(USE_DIRECT_SPAWN, false);
2325        let _proc_spawn = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(120));
2326        let _actor_spawn = config.override_key(ACTOR_SPAWN_MAX_IDLE, Duration::from_secs(120));
2327        let _host_spawn = config.override_key(
2328            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2329            Duration::from_secs(120),
2330        );
2331
2332        // Listen for supervision events sent to the parent instance.
2333        let (supervision_port, mut supervision_receiver) = instance.open_port::<MeshFailure>();
2334        let supervisor = supervision_port.bind();
2335        let num_replicas = 1;
2336        let mut hm = testing::host_mesh(num_replicas).await;
2337        let proc_mesh = hm
2338            .spawn(instance, "test", Extent::unity(), None, None)
2339            .await
2340            .unwrap();
2341        let child_name = ActorMeshId::instance(Label::new("child").unwrap());
2342
2343        // Need to use a wrapper as there's no way to customize the handler for MeshFailure
2344        // on the client instance. The client would just panic with the message.
2345        let actor_mesh: ActorMesh<testactor::WrapperActor> = proc_mesh
2346            .spawn(
2347                instance,
2348                "wrapper",
2349                &(proc_mesh.deref().clone(), supervisor, child_name.clone()),
2350            )
2351            .await
2352            .unwrap();
2353
2354        // Trigger the supervision error.
2355        actor_mesh
2356            .cast(
2357                instance,
2358                testactor::CauseSupervisionEvent {
2359                    kind: testactor::SupervisionEventType::Panic,
2360                    send_to_children: true,
2361                },
2362            )
2363            .unwrap();
2364
2365        // The error will come back on two different pathways:
2366        // * on the ActorMeshRef stored in WrapperActor
2367        //   as an observable supervision event as a subscriber.
2368        // * on the owning actor (WrapperActor here) to be handled.
2369        // We test to ensure both have occurred.
2370
2371        // First test the ActorMeshRef got the event.
2372        // Use a NextSupervisionFailure message to get the event from the wrapper
2373        // actor.
2374        let (failure_port, mut failure_receiver) = instance.open_port::<Option<MeshFailure>>();
2375        actor_mesh
2376            .cast(
2377                instance,
2378                testactor::NextSupervisionFailure(failure_port.bind()),
2379            )
2380            .unwrap();
2381        let failure = failure_receiver
2382            .recv()
2383            .await
2384            .unwrap()
2385            .expect("no supervision event found on ref from wrapper actor");
2386        let check_failure = move |failure: MeshFailure| {
2387            assert_eq!(failure.actor_mesh_name, Some(child_name.to_string()));
2388            assert!(
2389                failure
2390                    .event
2391                    .actor_id
2392                    .label()
2393                    .unwrap()
2394                    .as_str()
2395                    .starts_with(child_name.label().unwrap().as_str())
2396            );
2397            if let ActorStatus::Failed(ActorErrorKind::Generic(msg)) = &failure.event.actor_status {
2398                assert!(msg.contains("panic"), "{}", msg);
2399                assert!(msg.contains("for testing"), "{}", msg);
2400            } else {
2401                panic!("actor status is not failed: {}", failure.event.actor_status);
2402            }
2403        };
2404        check_failure(failure);
2405
2406        // The wrapper actor should *not* have an event.
2407
2408        // Wait for a supervision event to reach the wrapper actor.
2409        for _ in 0..num_replicas {
2410            let failure =
2411                tokio::time::timeout(Duration::from_secs(20), supervision_receiver.recv())
2412                    .await
2413                    .expect("timeout")
2414                    .unwrap();
2415            check_failure(failure);
2416        }
2417
2418        let _ = hm.shutdown(instance).await;
2419    }
2420
2421    #[assert_no_process_leak]
2422    #[async_timed_test(timeout_secs = 300)]
2423    async fn test_actor_states_with_process_exit() {
2424        hyperactor_telemetry::initialize_logging_for_test();
2425
2426        let config = hyperactor_config::global::lock();
2427        let _mode = config.override_key(USE_DIRECT_SPAWN, false);
2428        let _poll = config.override_key(SUPERVISION_POLL_FREQUENCY, Duration::from_secs(1));
2429        let _guard = config.override_key(GET_ACTOR_STATE_MAX_IDLE, Duration::from_secs(1));
2430        let _proc_guard = config.override_key(GET_PROC_STATE_MAX_IDLE, Duration::from_secs(1));
2431        let _proc_spawn = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(120));
2432        let _host_spawn = config.override_key(
2433            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2434            Duration::from_secs(120),
2435        );
2436
2437        let instance = testing::instance();
2438        // Listen for supervision events sent to the parent instance.
2439        let (supervision_port, mut supervision_receiver) = instance.open_port::<MeshFailure>();
2440        let supervisor = supervision_port.bind();
2441        let num_replicas = 1;
2442        let mut hm = testing::host_mesh(num_replicas).await;
2443        let proc_mesh = hm
2444            .spawn(instance, "test", Extent::unity(), None, None)
2445            .await
2446            .unwrap();
2447        let mut second_hm = testing::host_mesh(num_replicas).await;
2448        let second_proc_mesh = second_hm
2449            .spawn(instance, "test2", Extent::unity(), None, None)
2450            .await
2451            .unwrap();
2452        let child_name = ActorMeshId::instance(Label::new("child").unwrap());
2453
2454        // Need to use a wrapper as there's no way to customize the handler for MeshFailure
2455        // on the client instance. The client would just panic with the message.
2456        let actor_mesh: ActorMesh<testactor::WrapperActor> = proc_mesh
2457            .spawn(
2458                instance,
2459                "wrapper",
2460                &(
2461                    // Need a second set of proc meshes for the inner test actor, so the
2462                    // WrapperActor is still alive and gets the message.
2463                    second_proc_mesh.deref().clone(),
2464                    supervisor,
2465                    child_name.clone(),
2466                ),
2467            )
2468            .await
2469            .unwrap();
2470
2471        actor_mesh
2472            .cast(
2473                instance,
2474                testactor::CauseSupervisionEvent {
2475                    kind: testactor::SupervisionEventType::ProcessExit(1),
2476                    send_to_children: true,
2477                },
2478            )
2479            .unwrap();
2480
2481        // Same drill as for panic, except this one is for process exit.
2482        let (failure_port, mut failure_receiver) = instance.open_port::<Option<MeshFailure>>();
2483        actor_mesh
2484            .cast(
2485                instance,
2486                testactor::NextSupervisionFailure(failure_port.bind()),
2487            )
2488            .unwrap();
2489        let failure = failure_receiver
2490            .recv()
2491            .await
2492            .unwrap()
2493            .expect("no supervision event found on ref from wrapper actor");
2494
2495        let check_failure = move |failure: MeshFailure| {
2496            assert_eq!(failure.actor_mesh_name, Some(child_name.to_string()));
2497            assert!(
2498                failure
2499                    .event
2500                    .actor_id
2501                    .label()
2502                    .unwrap()
2503                    .as_str()
2504                    .starts_with(child_name.label().unwrap().as_str())
2505            );
2506            if let ActorStatus::Failed(ActorErrorKind::Generic(msg)) = &failure.event.actor_status {
2507                assert!(msg.contains("exited with non-zero code 1"), "{}", msg);
2508            } else {
2509                panic!("actor status is not failed: {}", failure.event.actor_status);
2510            }
2511        };
2512        check_failure(failure);
2513
2514        // Wait for a supervision event to occur on these actors.
2515        for _ in 0..num_replicas {
2516            let failure =
2517                tokio::time::timeout(Duration::from_secs(20), supervision_receiver.recv())
2518                    .await
2519                    .expect("timeout")
2520                    .unwrap();
2521            check_failure(failure);
2522        }
2523
2524        let _ = second_hm.shutdown(instance).await;
2525        let _ = hm.shutdown(instance).await;
2526    }
2527
2528    #[async_timed_test(timeout_secs = 300)]
2529    async fn test_actor_states_on_sliced_mesh() {
2530        hyperactor_telemetry::initialize_logging_for_test();
2531
2532        let instance = testing::instance();
2533        // Listen for supervision events sent to the parent instance.
2534        let (supervision_port, mut supervision_receiver) = instance.open_port::<MeshFailure>();
2535        let supervisor = supervision_port.bind();
2536        let (mut hm, _actor_mesh, sliced, sliced_replicas, child_name) = {
2537            let config = hyperactor_config::global::lock();
2538            let _mode = config.override_key(USE_DIRECT_SPAWN, false);
2539            let _proc_spawn = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(120));
2540            let _actor_spawn = config.override_key(ACTOR_SPAWN_MAX_IDLE, Duration::from_secs(120));
2541            let _host_spawn = config.override_key(
2542                hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2543                Duration::from_secs(120),
2544            );
2545            let num_replicas = 2;
2546            let hm = testing::host_mesh(num_replicas).await;
2547            let proc_mesh = hm
2548                .spawn(instance, "test", Extent::unity(), None, None)
2549                .await
2550                .unwrap();
2551            let child_name = ActorMeshId::instance(Label::new("child").unwrap());
2552
2553            // Need to use a wrapper as there's no way to customize the handler for MeshFailure
2554            // on the client instance. The client would just panic with the message.
2555            let actor_mesh: ActorMesh<testactor::WrapperActor> = proc_mesh
2556                .spawn(
2557                    instance,
2558                    "wrapper",
2559                    &(proc_mesh.deref().clone(), supervisor, child_name.clone()),
2560                )
2561                .await
2562                .unwrap();
2563            let sliced = actor_mesh
2564                .range("hosts", 1..2)
2565                .expect("slice should be valid");
2566            let sliced_replicas = sliced.region().num_ranks();
2567            (hm, actor_mesh, sliced, sliced_replicas, child_name)
2568        };
2569
2570        // TODO: check that independent slice refs don't get the supervision event.
2571        sliced
2572            .cast(
2573                instance,
2574                testactor::CauseSupervisionEvent {
2575                    kind: testactor::SupervisionEventType::Panic,
2576                    send_to_children: true,
2577                },
2578            )
2579            .unwrap();
2580
2581        for _ in 0..sliced_replicas {
2582            let supervision_message =
2583                tokio::time::timeout(Duration::from_secs(20), supervision_receiver.recv())
2584                    .await
2585                    .expect("timeout")
2586                    .unwrap();
2587            let event = supervision_message.event;
2588            assert!(
2589                event
2590                    .actor_id
2591                    .label()
2592                    .unwrap()
2593                    .as_str()
2594                    .starts_with(child_name.label().unwrap().as_str())
2595            );
2596            if let ActorStatus::Failed(ActorErrorKind::Generic(msg)) = &event.actor_status {
2597                assert!(msg.contains("panic"));
2598                assert!(msg.contains("for testing"));
2599            } else {
2600                panic!("actor status is not failed: {}", event.actor_status);
2601            }
2602        }
2603
2604        let _ = hm.shutdown(instance).await;
2605    }
2606
2607    /// Streamed supervision over a renumbered slice must attribute a failure to
2608    /// the actor's rank in *that* view, not its first-creation rank. The child
2609    /// singleton is first created over the whole (so the shared proc, gpu 1, has
2610    /// `create_rank` 1), then observed by a controller over the slice (gpu 1 ->
2611    /// rank 0). A streamed failure at slice rank 0 must reach the owner attributed
2612    /// to rank 0 even though its payload retains `create_rank` 1.
2613    ///
2614    /// The whole-view child is spawned controllerless and kept alive through the
2615    /// assertion. The test posts the streamed update directly to the slice
2616    /// controller so polling cannot mask an incorrect streamed attribution.
2617    #[async_timed_test(timeout_secs = 30)]
2618    #[cfg(fbcode_build)]
2619    async fn test_streamed_supervision_uses_current_view_rank() {
2620        let instance = testing::instance();
2621        let (supervision_port, mut supervision_receiver) = instance.open_port::<MeshFailure>();
2622        let supervisor = supervision_port.bind();
2623
2624        let mut hm = local_host_mesh(1).await;
2625        let proc_mesh = hm
2626            .spawn(instance, "test", extent!(gpus = 2), None, None)
2627            .await
2628            .unwrap();
2629        let whole = proc_mesh.deref().clone();
2630        // gpu 1 is rank 1 in the whole and rank 0 in this slice.
2631        let slice = proc_mesh.range("gpus", 1..2).unwrap();
2632
2633        // One singleton id shared by the whole-view actor and slice controller.
2634        let child_id = ActorMeshId::singleton(Label::strip("svc_sup"));
2635
2636        // Pre-create the child over the whole, controllerless so no whole-view
2637        // controller also reports the panic; gpu 1's child gets create_rank 1.
2638        // Keep it alive through the assertion so the shared actor persists.
2639        let _whole_child: ActorMesh<testactor::TestActor> = whole
2640            .spawn_with_name(instance, child_id.clone(), &(), None, true)
2641            .await
2642            .unwrap();
2643
2644        let child_slice_region = _whole_child
2645            .region()
2646            .range("gpus", 1..2)
2647            .expect("child slice should exist");
2648        let child_slice = _whole_child.sliced(child_slice_region);
2649
2650        let (stream_port, mut stream_rx) =
2651            instance.open_port::<resource::RankedState<ActorState>>();
2652        slice
2653            .agent_mesh()
2654            .cast(
2655                instance,
2656                resource::StreamState::<ActorState> {
2657                    id: child_id.resource_id().clone(),
2658                    subscriber_rank: resource::Rank::default(),
2659                    subscriber: stream_port.bind(),
2660                },
2661            )
2662            .unwrap();
2663        let streamed = tokio::time::timeout(Duration::from_secs(10), stream_rx.recv())
2664            .await
2665            .expect("no initial streamed state before timeout")
2666            .unwrap();
2667        assert_eq!(streamed.rank.unwrap(), 0);
2668        assert_eq!(
2669            streamed
2670                .state
2671                .state
2672                .as_ref()
2673                .expect("running actor should carry ActorState")
2674                .create_rank,
2675            1,
2676        );
2677
2678        let controller: ActorMeshController<testactor::TestActor> = ActorMeshController::new(
2679            ActorMeshControlPlane::new(child_slice.clone(), slice),
2680            Some("svc_sup".to_string()),
2681            Some(supervisor),
2682            crate::StatusMesh::from_single(child_slice.region().clone(), resource::Status::Running),
2683        );
2684        let controller_handle = instance.spawn_with_label("stream_rank_controller", controller);
2685        controller_handle
2686            .status()
2687            .wait_for(|status| matches!(status, ActorStatus::Idle))
2688            .await
2689            .unwrap();
2690        let controller: ActorRef<ActorMeshController<testactor::TestActor>> =
2691            controller_handle.bind();
2692
2693        let actor_id = child_slice
2694            .get(0)
2695            .expect("slice actor should exist")
2696            .actor_addr()
2697            .clone();
2698        let event = ActorSupervisionEvent::new(
2699            actor_id.clone(),
2700            None,
2701            ActorStatus::generic_failure("streamed test failure"),
2702            None,
2703        );
2704        controller.post(
2705            instance,
2706            resource::RankedState {
2707                rank: resource::Rank::new(0),
2708                state: resource::State {
2709                    id: child_id.resource_id().clone(),
2710                    status: resource::Status::Failed("streamed test failure".to_string()),
2711                    state: Some(ActorState {
2712                        actor_id,
2713                        create_rank: 1,
2714                        supervision_events: vec![event],
2715                    }),
2716                    generation: u64::MAX,
2717                    timestamp: std::time::SystemTime::now(),
2718                },
2719            },
2720        );
2721
2722        let failure = tokio::time::timeout(Duration::from_secs(10), supervision_receiver.recv())
2723            .await
2724            .expect("no supervision failure delivered before timeout")
2725            .unwrap();
2726        assert_eq!(
2727            failure.crashed_ranks,
2728            vec![0],
2729            "failure attributed to slice-local rank 0: {failure:?}",
2730        );
2731        assert!(
2732            failure
2733                .actor_mesh_name
2734                .as_deref()
2735                .is_some_and(|name| name.contains("svc_sup")),
2736            "failure names the child mesh: {:?}",
2737            failure.actor_mesh_name,
2738        );
2739
2740        controller_handle.stop("test complete").unwrap();
2741        controller_handle
2742            .status()
2743            .wait_for(ActorStatus::is_terminal)
2744            .await
2745            .unwrap();
2746
2747        let _ = hm.shutdown(instance).await;
2748    }
2749
2750    async fn execute_cast(config: &hyperactor_config::global::ConfigLock) {
2751        let _guard = config.override_key(crate::bootstrap::MESH_BOOTSTRAP_ENABLE_PDEATHSIG, false);
2752        let _proc_spawn = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
2753        let _host_spawn = config.override_key(
2754            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2755            Duration::from_secs(60),
2756        );
2757
2758        let instance = testing::instance();
2759        let mut host_mesh = testing::host_mesh(2).await;
2760        let proc_mesh = host_mesh
2761            .spawn(instance, "test", Extent::unity(), None, None)
2762            .await
2763            .unwrap();
2764        let actor_mesh: ActorMesh<testactor::TestActor> =
2765            proc_mesh.spawn(instance, "test", &()).await.unwrap();
2766
2767        let (cast_info, mut cast_info_rx) = instance.mailbox().open_port();
2768        actor_mesh
2769            .cast(
2770                instance,
2771                testactor::GetCastInfo {
2772                    cast_info: cast_info.bind(),
2773                },
2774            )
2775            .unwrap();
2776
2777        let mut point_to_actor: HashSet<_> = ndslice::ViewExt::iter(actor_mesh.deref()).collect();
2778        while !point_to_actor.is_empty() {
2779            let (point, origin_actor_ref, sender_actor_id) = cast_info_rx.recv().await.unwrap();
2780            let key = (point, origin_actor_ref);
2781            assert!(
2782                point_to_actor.remove(&key),
2783                "key {:?} not present or removed twice",
2784                key
2785            );
2786            assert_eq!(&sender_actor_id, instance.self_addr());
2787        }
2788
2789        let _ = host_mesh.shutdown(instance).await;
2790    }
2791
2792    #[async_timed_test(timeout_secs = 60)]
2793    async fn test_sliced_actor_mesh_cast_v1_reaches_slice_members() {
2794        let config = hyperactor_config::global::lock();
2795        let _guard = config.override_key(crate::bootstrap::MESH_BOOTSTRAP_ENABLE_PDEATHSIG, false);
2796        let _proc_spawn = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
2797        let _host_spawn = config.override_key(
2798            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2799            Duration::from_secs(60),
2800        );
2801
2802        let instance = testing::instance();
2803        let mut host_mesh = testing::host_mesh(2).await;
2804        let proc_mesh = host_mesh
2805            .spawn(instance, "test", Extent::unity(), None, None)
2806            .await
2807            .unwrap();
2808        let root_actor_mesh: ActorMesh<testactor::TestActor> =
2809            proc_mesh.spawn(instance, "test", &()).await.unwrap();
2810
2811        // Cast through a sliced mesh — `cast` still means all, but all is
2812        // scoped to the immutable sliced region.
2813        let actor_mesh = root_actor_mesh.sliced(Region::new(
2814            vec!["rank".to_string()],
2815            Slice::new(0, vec![1], vec![1]).unwrap(),
2816        ));
2817        let (cast_info, mut cast_info_rx) = instance.mailbox().open_port();
2818        actor_mesh
2819            .cast(
2820                instance,
2821                testactor::GetCastInfo {
2822                    cast_info: cast_info.bind(),
2823                },
2824            )
2825            .unwrap();
2826
2827        let (point, _actor_ref, _sender) = cast_info_rx.recv().await.unwrap();
2828        let received_ranks = HashSet::from([point.rank()]);
2829        assert_eq!(received_ranks, HashSet::from([0]));
2830
2831        // Also cast the root mesh — all ranks should be reached via V1.
2832        let (cast_info2, mut cast_info_rx2) = instance.mailbox().open_port();
2833        root_actor_mesh
2834            .cast(
2835                instance,
2836                testactor::GetCastInfo {
2837                    cast_info: cast_info2.bind(),
2838                },
2839            )
2840            .unwrap();
2841
2842        let mut all_ranks: HashSet<usize> = HashSet::new();
2843        for _ in 0..2 {
2844            let (point, _actor_ref, _sender) = cast_info_rx2.recv().await.unwrap();
2845            all_ranks.insert(point.rank());
2846        }
2847        assert_eq!(all_ranks, HashSet::from([0, 1]));
2848
2849        let _ = host_mesh.shutdown(instance).await;
2850    }
2851
2852    #[async_timed_test(timeout_secs = 60)]
2853    async fn test_cast_domain_stamps_resource_rank_binding() {
2854        let client_proc = hyperactor::proc::Proc::direct(
2855            hyperactor::channel::ChannelTransport::Unix.any(),
2856            "client_proc".into(),
2857        )
2858        .unwrap();
2859
2860        let client = client_proc.client("client");
2861
2862        let mut procs = Vec::new();
2863
2864        let members = (0..2)
2865            .map(|rank| {
2866                let proc = hyperactor::proc::Proc::direct(
2867                    hyperactor::channel::ChannelTransport::Unix.any(),
2868                    format!("proc_{rank}"),
2869                )
2870                .unwrap();
2871
2872                let cast_handle = proc
2873                    .spawn_with_uid(
2874                        hyperactor::Uid::singleton(Label::strip("cast")),
2875                        hyperactor_cast::cast_actor::CastActor::default(),
2876                    )
2877                    .unwrap();
2878
2879                let _: hyperactor::ActorRef<hyperactor_cast::cast_actor::CastActor> =
2880                    cast_handle.bind();
2881
2882                let receiver_handle = proc
2883                    .spawn_with_uid(
2884                        hyperactor::Uid::singleton(Label::strip("receiver")),
2885                        testactor::TestActor,
2886                    )
2887                    .unwrap();
2888
2889                let _: hyperactor::ActorRef<testactor::TestActor> = receiver_handle.bind();
2890
2891                let actor_addr =
2892                    hyperactor::ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"));
2893
2894                procs.push(proc);
2895
2896                (rank, actor_addr)
2897            })
2898            .collect::<HashMap<_, _>>();
2899
2900        let region = Region::from(ndslice::shape!(rank = 2));
2901        let destinations = CastDestination::mesh(
2902            region.clone(),
2903            (0..2).map(|rank| members[&rank].clone()).collect(),
2904        )
2905        .unwrap();
2906        let nodes = super::cast_node_mesh(&destinations).unwrap();
2907        let cast_domain = hyperactor_cast::cast_actor::CastDomainId::new()
2908            .materialize(
2909                &client,
2910                Arc::new(nodes),
2911                hyperactor_cast::cast_actor::TilingPolicy::BlockPartitioning,
2912                hyperactor_config::Flattrs::new(),
2913            )
2914            .unwrap();
2915
2916        let (rank_port, mut rank_rx) = client.mailbox().open_port();
2917
2918        cast_domain
2919            .cast(
2920                &client,
2921                hyperactor_config::Flattrs::new(),
2922                testactor::GetResourceRank {
2923                    rank: crate::resource::Rank::default(),
2924                    reply: rank_port.bind(),
2925                },
2926            )
2927            .unwrap();
2928
2929        let mut received_ranks = HashSet::new();
2930
2931        for _ in 0..2 {
2932            let (_point, rank) = rank_rx.recv().await.unwrap();
2933
2934            received_ranks.insert(rank);
2935        }
2936
2937        assert_eq!(received_ranks, HashSet::from([Some(0), Some(1)]));
2938    }
2939
2940    #[async_timed_test(timeout_secs = 30)]
2941    async fn test_cast() {
2942        let config = hyperactor_config::global::lock();
2943        execute_cast(&config).await;
2944    }
2945
2946    /// Test that undeliverable messages are properly returned to the
2947    /// sender when communication to a proc is broken.
2948    ///
2949    /// This is the V1 version of the test from
2950    /// hyperactor_multiprocess/src/proc_actor.rs::test_undeliverable_message_return.
2951    #[assert_no_process_leak]
2952    #[async_timed_test(timeout_secs = 60)]
2953    async fn test_undeliverable_message_return() {
2954        use hyperactor::mailbox::MessageEnvelope;
2955        use hyperactor::mailbox::Undeliverable;
2956        use hyperactor::testing::pingpong::PingPongActor;
2957        use hyperactor::testing::pingpong::PingPongMessage;
2958
2959        hyperactor_telemetry::initialize_logging_for_test();
2960
2961        let instance = testing::instance();
2962
2963        // Create a proc mesh with 2 hosts.
2964        let (mut hm, proc_mesh) = {
2965            let config = hyperactor_config::global::lock();
2966            let _proc_spawn_guard =
2967                config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
2968            let _host_spawn_guard = config.override_key(
2969                hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2970                Duration::from_secs(60),
2971            );
2972            let hm = testing::host_mesh(2).await;
2973            let proc_mesh = hm
2974                .spawn(instance, "test", Extent::unity(), None, None)
2975                .await
2976                .unwrap();
2977            (hm, proc_mesh)
2978        };
2979
2980        // Set up undeliverable message port for collecting undeliverables
2981        let (undeliverable_port, mut undeliverable_rx) =
2982            instance.open_port::<Undeliverable<MessageEnvelope>>();
2983
2984        // Spawn actors individually on each host by spawning separate actor meshes
2985        // with specific proc selections.
2986        let ping_proc_mesh = proc_mesh.range("hosts", 0..1).unwrap();
2987        let pong_proc_mesh = proc_mesh.range("hosts", 1..2).unwrap();
2988
2989        let ping_mesh: ActorMesh<PingPongActor> = ping_proc_mesh
2990            .spawn(
2991                instance,
2992                "ping",
2993                &(Some(undeliverable_port.bind()), None, None),
2994            )
2995            .await
2996            .unwrap();
2997
2998        let mut pong_mesh: ActorMesh<PingPongActor> = pong_proc_mesh
2999            .spawn(instance, "pong", &(None, None, None))
3000            .await
3001            .unwrap();
3002
3003        // Get individual actor refs
3004        let ping_handle = ping_mesh.values().next().unwrap();
3005        let pong_handle = pong_mesh.values().next().unwrap();
3006
3007        // Verify ping-pong works initially
3008        let (done_tx, done_rx) = instance.open_once_port();
3009        ping_handle.post(
3010            instance,
3011            PingPongMessage(2, pong_handle.clone(), done_tx.bind()),
3012        );
3013        assert!(
3014            done_rx.recv().await.unwrap(),
3015            "Initial ping-pong should work"
3016        );
3017
3018        // Now stop the pong actor mesh to break communication
3019        pong_mesh
3020            .stop(instance, "test stop".to_string())
3021            .await
3022            .unwrap();
3023
3024        // Give it a moment to fully stop
3025        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3026
3027        // Set message delivery timeout for faster test
3028        let config = hyperactor_config::global::lock();
3029        let _guard = config.override_key(
3030            hyperactor::config::MESSAGE_DELIVERY_TIMEOUT,
3031            std::time::Duration::from_secs(5),
3032        );
3033
3034        // Send multiple messages that will all fail to be delivered
3035        let n = 100usize;
3036        for i in 1..=n {
3037            let ttl = 66 + i as u64; // Avoid ttl = 66 (which would cause other test behavior)
3038            let (once_tx, _once_rx) = instance.open_once_port();
3039            ping_handle.post(
3040                instance,
3041                PingPongMessage(ttl, pong_handle.clone(), once_tx.bind()),
3042            );
3043        }
3044
3045        // Collect all undeliverable messages.
3046        // The fact that we successfully collect them proves the ping actor
3047        // is still running and handling undeliverables correctly (not crashing).
3048        let mut count = 0;
3049        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
3050        while count < n && tokio::time::Instant::now() < deadline {
3051            match tokio::time::timeout(std::time::Duration::from_secs(1), undeliverable_rx.recv())
3052                .await
3053            {
3054                Ok(Ok(Undeliverable::Returned(envelope))) => {
3055                    let _: PingPongMessage = envelope.deserialized().unwrap();
3056                    count += 1;
3057                }
3058                Ok(Ok(Undeliverable::Report(_))) => break,
3059                Ok(Err(_)) => break, // Channel closed
3060                Err(_) => break,     // Timeout
3061            }
3062        }
3063
3064        assert_eq!(
3065            count, n,
3066            "Expected {} undeliverable messages, got {}",
3067            n, count
3068        );
3069
3070        let _ = hm.shutdown(instance).await;
3071    }
3072
3073    /// Test that `stop()` returns bounded by `ACTOR_SPAWN_MAX_IDLE` even
3074    /// when actors are stuck inside a handler and never observe the
3075    /// `DrainAndStop` signal. The controller's `Stop` handler awaits
3076    /// the underlying ProcAgent wait, which waits up to `ACTOR_SPAWN_MAX_IDLE`
3077    /// for ProcAgents to report `Stopped`; when that idle window elapses it
3078    /// stamps `Status::Timeout` into the controller's health state, and the
3079    /// subsequent `GetState` reads that back. The actors' tokio tasks
3080    /// continue running in the background: no code path in the mesh layer
3081    /// forcibly aborts them via `JoinHandle::abort()`.
3082    #[async_timed_test(timeout_secs = 30)]
3083    async fn test_actor_mesh_stop_timeout() {
3084        hyperactor_telemetry::initialize_logging_for_test();
3085
3086        // `ACTOR_SPAWN_MAX_IDLE` bounds how long the controller's Stop
3087        // handler waits for ProcAgents to report `Stopped`. Shorten it
3088        // from 30s to 1s so the test finishes quickly.
3089        let config = hyperactor_config::global::lock();
3090        let _mode = config.override_key(USE_DIRECT_SPAWN, false);
3091        let _proc_spawn = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
3092        let _host_spawn = config.override_key(
3093            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
3094            Duration::from_secs(60),
3095        );
3096
3097        let instance = testing::instance();
3098
3099        // Create proc mesh with 2 procs
3100        let mut hm = testing::host_mesh(2).await;
3101        let proc_mesh = hm
3102            .spawn(instance, "test", Extent::unity(), None, None)
3103            .await
3104            .unwrap();
3105
3106        // Spawn SleepActors across the mesh that will block longer
3107        // than timeout
3108        let mut sleep_mesh: ActorMesh<testactor::SleepActor> =
3109            proc_mesh.spawn(instance, "sleepers", &()).await.unwrap();
3110        let _guard = config.override_key(ACTOR_SPAWN_MAX_IDLE, std::time::Duration::from_secs(1));
3111
3112        // Send each actor a message to sleep for 5 seconds. `Instance::run`
3113        // only polls the signal receiver at message boundaries, so
3114        // `DrainAndStop` will sit queued in the signal mailbox until this
3115        // handler completes. Nothing forcibly aborts it.
3116        for actor_ref in sleep_mesh.values() {
3117            actor_ref.post(instance, std::time::Duration::from_secs(5));
3118        }
3119
3120        // Give actors time to start sleeping
3121        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3122
3123        // Count how many actors we spawned (for verification later)
3124        let expected_actors = sleep_mesh.values().count();
3125
3126        // Now stop the mesh. The controller's Stop handler will give up on
3127        // waiting for `Stopped` after ACTOR_SPAWN_MAX_IDLE and mark the
3128        // ranks as `Status::Timeout`. Time this operation to confirm we
3129        // return on that budget rather than waiting the full 5s sleep.
3130        let stop_start = tokio::time::Instant::now();
3131        let result = sleep_mesh.stop(instance, "test stop".to_string()).await;
3132        let stop_duration = tokio::time::Instant::now().duration_since(stop_start);
3133
3134        // `stop()` returns `Ok(())` because `is_terminating()` accepts
3135        // `Status::Timeout`. We still check the duration below to confirm
3136        // the timeout path (not a natural graceful stop) produced this.
3137        match result {
3138            Ok(_) => {
3139                tracing::info!(
3140                    "stop returned Ok for {} actors; their tokio tasks \
3141                     may still be running until their handler yields",
3142                    expected_actors
3143                );
3144            }
3145            Err(ref e) => {
3146                let err_str = format!("{:?}", e);
3147                assert!(
3148                    err_str.contains("Timeout"),
3149                    "Expected Timeout error, got: {:?}",
3150                    e
3151                );
3152            }
3153        }
3154
3155        // Verify that stop returned on the ACTOR_SPAWN_MAX_IDLE budget
3156        // (~1s) rather than the full 5s sleep. This confirms we hit the
3157        // controller's idle timeout while querying for `Stopped` — not
3158        // that the actors were actually aborted; they weren't.
3159        assert!(
3160            stop_duration < std::time::Duration::from_millis(4500),
3161            "Stop took {:?}, expected < 4.5s (controller should have given up waiting for Stopped)",
3162            stop_duration
3163        );
3164        assert!(
3165            stop_duration >= std::time::Duration::from_millis(900),
3166            "Stop took {:?}, expected >= 900ms (should have waited for the 1s idle timeout)",
3167            stop_duration
3168        );
3169
3170        let _ = hm.shutdown(instance).await;
3171    }
3172
3173    /// Test that actors stop gracefully when they respond to stop
3174    /// signals within the timeout. Complementary to
3175    /// test_actor_mesh_stop_timeout which tests abort behavior. V1
3176    /// equivalent of
3177    /// hyperactor_multiprocess/src/proc_actor.rs::test_stop
3178    #[async_timed_test(timeout_secs = 60)]
3179    async fn test_actor_mesh_stop_graceful() {
3180        hyperactor_telemetry::initialize_logging_for_test();
3181
3182        let config = hyperactor_config::global::lock();
3183        let _mode = config.override_key(USE_DIRECT_SPAWN, false);
3184        let _proc_spawn = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(60));
3185        let _host_spawn = config.override_key(
3186            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
3187            Duration::from_secs(60),
3188        );
3189
3190        let instance = testing::instance();
3191
3192        // Create proc mesh with 2 procs
3193        let mut hm = testing::host_mesh(2).await;
3194        let proc_mesh = hm
3195            .spawn(instance, "test", Extent::unity(), None, None)
3196            .await
3197            .unwrap();
3198
3199        // Spawn TestActors - these stop cleanly (no blocking
3200        // operations)
3201        let mut actor_mesh: ActorMesh<testactor::TestActor> =
3202            proc_mesh.spawn(instance, "test_actors", &()).await.unwrap();
3203
3204        // Cloned mesh will still have its controller, even if the owned mesh
3205        // causes a stop.
3206        let mesh_ref = actor_mesh.deref().clone();
3207
3208        let expected_actors = actor_mesh.values().count();
3209        assert!(expected_actors > 0, "Should have spawned some actors");
3210
3211        // Time the stop operation
3212        let stop_start = tokio::time::Instant::now();
3213        let result = actor_mesh.stop(instance, "test stop".to_string()).await;
3214        let stop_duration = tokio::time::Instant::now().duration_since(stop_start);
3215
3216        // Graceful stop should succeed (return Ok)
3217        assert!(
3218            result.is_ok(),
3219            "Stop should succeed for responsive actors, got: {:?}",
3220            result.err()
3221        );
3222
3223        // Verify stop completed quickly (< 2 seconds). Responsive
3224        // actors should stop almost immediately, not wait for
3225        // timeout.
3226        assert!(
3227            stop_duration < std::time::Duration::from_secs(5),
3228            "Graceful stop took {:?}, expected < 5s (actors should stop quickly)",
3229            stop_duration
3230        );
3231
3232        tracing::info!(
3233            "Successfully stopped {} actors in {:?}",
3234            expected_actors,
3235            stop_duration
3236        );
3237
3238        // Check that the next returned supervision event is a Stopped event.
3239        // Note that Ref meshes get Stopped events, and Owned meshes do not,
3240        // because only the owner can stop them anyway.
3241        // Each owned mesh has an implicit ref mesh though, so that is what we
3242        // test here.
3243        let next_event = actor_mesh.next_supervision_event(instance).await.unwrap();
3244        let mesh_id = mesh_ref
3245            .as_managed()
3246            .expect("owned actor mesh ref should be managed")
3247            .id();
3248        assert_eq!(next_event.actor_mesh_name, Some(mesh_id.to_string()));
3249        assert!(matches!(
3250            next_event.event.actor_status,
3251            ActorStatus::Stopped(_)
3252        ));
3253        // Check that a cloned Ref from earlier gets the same event. Every clone
3254        // should get the same event, even if it's not a subscriber.
3255        let next_event = mesh_ref.next_supervision_event(instance).await.unwrap();
3256        assert_eq!(next_event.actor_mesh_name, Some(mesh_id.to_string()));
3257        assert!(matches!(
3258            next_event.event.actor_status,
3259            ActorStatus::Stopped(_)
3260        ));
3261
3262        let _ = hm.shutdown(instance).await;
3263    }
3264}