Skip to main content

hyperactor_cast/
cast_actor.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//! The CastActor: a system actor that bootstraps and manages casting domains.
10//!
11//! ## Cast actor invariants (CA-*)
12//!
13//! - **CA-1 (direct-delivery equivalence):** The cast overlay is a transport
14//!   optimization. It preserves the logical behavior of sending directly to
15//!   every destination. It does not provide atomic fanout; each destination
16//!   can fail independently.
17//! - **CA-2 (domain coverage):** A domain's subtrees are nonempty, disjoint,
18//!   and together cover the complete domain region. In the absence of delivery
19//!   failures, each destination receives the message exactly once.
20//! - **CA-3 (setup ordering):** Domain materialization sends setup before any
21//!   cast from the returned handle. Correctness relies on Hyperactor preserving
22//!   message order between each sender and subtree root.
23//! - **CA-4 (destination ordering):** The originating sender allocates one
24//!   sequence number for each destination before fanout. Routing partitions
25//!   these sequences but does not replace or reorder them.
26//! - **CA-5 (route independence):** Route topology, including direct terminal
27//!   delivery, must not change message headers, port behavior, or the logical
28//!   result of a valid reducer.
29//! - **CA-6 (domain isolation):** A `CastActor` can serve many domains, including
30//!   domains with overlapping destination actors. Creating or destroying one
31//!   domain must not change another domain.
32//! - **CA-7 (domain lifecycle):** `CastDomainRef::destroy` is idempotent,
33//!   best-effort routing-state cleanup, not destination revocation. A domain
34//!   outlives its destination actors, callers do not cast after destruction,
35//!   and CastActors retain no tombstones for destroyed domains.
36//! - **CA-8 (failure containment):** Cast-message processing and delivery
37//!   failures must not fail the shared `CastActor`. Failures are returned to
38//!   the originating sender when the origin can be recovered.
39
40use std::collections::HashMap;
41use std::sync::Arc;
42use std::sync::OnceLock;
43
44use anyhow::Result;
45use async_trait::async_trait;
46use hyperactor::Actor;
47use hyperactor::ActorAddr;
48use hyperactor::ActorRef;
49use hyperactor::Context;
50use hyperactor::Endpoint as _;
51use hyperactor::EndpointLocation;
52use hyperactor::Handler;
53use hyperactor::IdleFlushPortRefRepr;
54use hyperactor::Instance;
55use hyperactor::Label;
56use hyperactor::OncePortRefRepr;
57use hyperactor::PortRef;
58use hyperactor::PortRefRepr;
59use hyperactor::ProcAddr;
60use hyperactor::RemoteEndpoint as _;
61use hyperactor::Uid;
62use hyperactor::accum::ReducerMode;
63use hyperactor::context;
64use hyperactor::mailbox::DeliveryFailure;
65use hyperactor::mailbox::DeliveryFailureReport;
66use hyperactor::mailbox::MailboxSender;
67use hyperactor::mailbox::MessageEnvelope;
68use hyperactor::mailbox::TransportFailure;
69use hyperactor::mailbox::TransportFailureReason;
70use hyperactor::mailbox::Undeliverable;
71use hyperactor::mailbox::UndeliverableMailboxSender;
72use hyperactor::mailbox::UndeliverableReason;
73use hyperactor::mailbox::monitored_return_handle;
74use hyperactor::ordering::SEQ_INFO;
75use hyperactor::ordering::SeqInfo;
76use hyperactor::ordering::SeqKey;
77use hyperactor::port::Port;
78use hyperactor::value_mesh::ValueMesh;
79use hyperactor_config::Flattrs;
80use hyperactor_config::NonZeroUsize as ConfigNonZeroUsize;
81use ndslice::Point;
82use ndslice::Region;
83use ndslice::view::View;
84use ndslice::view::ViewExt;
85use serde::Deserialize;
86use serde::Serialize;
87use typeuri::Named;
88use uuid::Uuid;
89
90use crate::tile::MaterializedTile;
91use crate::tile::Tile;
92pub use crate::tile::TilingPolicy;
93
94hyperactor_config::declare_attrs! {
95    /// Point associated with a cast delivery or actor construction.
96    ///
97    /// Message headers carry the recipient's point in the current casting
98    /// domain. An `ActorEnvironment` may carry an inherited construction point;
99    /// it does not establish actor-mesh membership.
100    pub attr CAST_POINT: Point;
101
102    /// Header stamped on each locally delivered message with the
103    /// original sender that initiated the cast.
104    pub attr CAST_ORIGINATING_SENDER: ActorAddr;
105
106    /// The multicast phase that attached context to a delivery failure.
107    pub attr CAST_FAILURE_PHASE: String;
108
109    /// The cast actor that attached multicast context to a delivery failure.
110    pub attr CAST_FAILURE_CAST_ACTOR: ActorAddr;
111
112    /// The originating cast sender.
113    pub attr CAST_FAILURE_ORIGIN: ActorAddr;
114
115    /// The return port used to send the undeliverable message to the origin.
116    pub attr CAST_FAILURE_RETURN_PORT: String;
117}
118
119#[cfg(test)]
120hyperactor_config::declare_attrs! {
121    /// Header stamped in tests with the cast tree path used to reach this
122    /// recipient.
123    pub attr CAST_LINEAGE: Vec<ActorAddr>;
124}
125
126/// Wire-compatible mirror of `hyperactor_mesh::resource::RankRepr`.
127///
128/// `hyperactor_cast` cannot depend on `hyperactor_mesh` without creating a
129/// crate cycle, but cast delivery still needs to stamp the rank multipart part
130/// for messages whose handlers read rank from the payload. The part is tagged
131/// with `RankRepr`'s typename, so this mirror must match it exactly.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133struct ResourceRankRepr(Option<usize>);
134
135impl Named for ResourceRankRepr {
136    fn typename() -> &'static str {
137        "hyperactor_mesh::resource::RankRepr"
138    }
139}
140
141/// Pure, data-only identifier for a cast domain.
142///
143/// This type contains no runtime references (e.g. `ActorRef`) and can
144/// be freely serialized, cloned, and shared.
145#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub struct CastDomainId {
147    /// Unique identifier for this domain's installed communication plan.
148    domain_id: Uid,
149}
150
151impl CastDomainId {
152    /// Create a new domain id.
153    pub fn new() -> Self {
154        Self {
155            domain_id: Uid::anonymous(),
156        }
157    }
158
159    /// The domain's unique identifier.
160    pub fn domain_id(&self) -> &Uid {
161        &self.domain_id
162    }
163
164    /// Materialize this domain id over concrete routing nodes and return an
165    /// addressable domain handle.
166    ///
167    /// Each node identifies the `CastActor` that relays its subtree and the
168    /// destination delivered locally by that actor.
169    pub fn materialize(
170        self,
171        cx: &impl context::Actor,
172        nodes: Arc<ValueMesh<CastNode>>,
173        tiling_policy: TilingPolicy,
174        headers: Flattrs,
175    ) -> anyhow::Result<CastDomainRef> {
176        let region = nodes.region().clone();
177
178        let member_mesh = Arc::new(ValueMesh::new(
179            region.clone(),
180            nodes
181                .values()
182                .map(|node| node.destination.actor().clone())
183                .collect(),
184        )?);
185
186        self.materialize_members(cx, member_mesh, nodes, region, tiling_policy, headers)
187    }
188
189    fn materialize_members(
190        self,
191        cx: &impl context::Actor,
192        member_mesh: Arc<ValueMesh<ActorAddr>>,
193        node_mesh: Arc<ValueMesh<CastNode>>,
194        region: Region,
195        tiling_policy: TilingPolicy,
196        headers: Flattrs,
197    ) -> anyhow::Result<CastDomainRef> {
198        let root_tile =
199            MaterializedTile::from_value_mesh_with_tile(Tile::from_view(&region), node_mesh);
200        anyhow::ensure!(
201            !root_tile.tile().space().is_empty(),
202            "cannot construct root-heaved subtree tiles for an empty tile"
203        );
204
205        let root_destination = root_tile
206            .root_item()
207            .ok_or_else(|| anyhow::anyhow!("cast routing mesh must contain a root node"))?
208            .destination
209            .clone();
210
211        let subtrees = tiling_policy
212            .children(root_tile.tile())
213            .into_iter()
214            .map(|subtree_tile| {
215                let served_region =
216                    Region::new(region.labels().to_vec(), subtree_tile.space().clone());
217
218                Ok(CastSubtree {
219                    route: CastRoute::try_from_tile(
220                        tiling_policy,
221                        &root_tile.subtile(subtree_tile),
222                    )?,
223                    served_region,
224                })
225            })
226            .collect::<Result<Vec<_>>>()?;
227
228        for subtree in &subtrees {
229            if let CastRoute::ViaCastActor(root_actor) = &subtree.route {
230                root_actor.port().post_with_headers(
231                    cx,
232                    headers.clone(),
233                    CreateCastDomain {
234                        cast_domain_id: self.clone(),
235                        region: region.clone(),
236                        tiling_policy,
237                        tile: root_tile.subtile(Tile::from_view(&subtree.served_region)),
238                    },
239                );
240            }
241        }
242
243        Ok(CastDomainRef::from_subtrees(
244            self,
245            root_destination,
246            subtrees,
247            member_mesh,
248        ))
249    }
250}
251
252impl std::fmt::Display for CastDomainId {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        write!(f, "{}", self.domain_id)
255    }
256}
257
258/// Opaque local handle for initiating work against a materialized cast domain.
259///
260/// Unlike [`CastDomainId`], this includes the independently seeded subtrees
261/// used to cast into and otherwise address the domain. Callers obtain this only
262/// by materializing a [`CastDomainId`].
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct CastDomainRef {
265    id: CastDomainId,
266    /// The first hop, executed directly by the sender.
267    sender_hop: CastHop,
268    /// Destination actor addresses keyed by this domain's rank space.
269    members: Arc<ValueMesh<ActorAddr>>,
270    /// Per-rank handler sequence keys derived lazily from `members`.
271    #[serde(skip)]
272    seq_keys: Arc<OnceLock<Arc<Vec<SeqKey>>>>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276struct CastSubtree {
277    route: CastRoute,
278    served_region: Region,
279}
280
281impl CastDomainRef {
282    /// Rebuild a cast-domain handle from its sender delivery and subtrees.
283    fn from_subtrees(
284        id: CastDomainId,
285        root_destination: CastDestination,
286        subtrees: Vec<CastSubtree>,
287        members: Arc<ValueMesh<ActorAddr>>,
288    ) -> Self {
289        let num_destinations = members.region().num_ranks();
290
291        Self {
292            id,
293            sender_hop: CastHop {
294                local_destination: root_destination,
295                next_hops: subtrees,
296                num_destinations,
297            },
298            members,
299            seq_keys: Arc::new(OnceLock::new()),
300        }
301    }
302
303    /// The pure identifier for this domain.
304    pub fn id(&self) -> CastDomainId {
305        self.id.clone()
306    }
307
308    /// The domain's unique identifier.
309    pub fn domain_id(&self) -> &Uid {
310        self.id.domain_id()
311    }
312
313    /// Destination actor addresses keyed by this domain's rank space.
314    pub fn members(&self) -> &ValueMesh<ActorAddr> {
315        &self.members
316    }
317
318    /// Cast a message to all members of this domain with caller-supplied headers.
319    ///
320    /// `headers` are the destination envelope headers supplied by the caller.
321    /// The cast layer stamps cast-owned fields on top before delivering through
322    /// the sender hop.
323    pub fn cast<M: Serialize + Named>(
324        &self,
325        cx: &impl context::Actor,
326        headers: Flattrs,
327        message: M,
328    ) -> anyhow::Result<()> {
329        self.cast_with_return_undeliverable(cx, headers, message, true)
330    }
331
332    /// Cast a message with explicit undeliverable-return behavior.
333    pub fn cast_with_return_undeliverable<M: Serialize + Named>(
334        &self,
335        cx: &impl context::Actor,
336        headers: Flattrs,
337        message: M,
338        return_undeliverable: bool,
339    ) -> anyhow::Result<()> {
340        let data = wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message)?;
341        let (session_id, seqs) = self.seqs_for_cast(cx)?;
342        let cast_message = CastMessage {
343            cast_domain_id: self.id.clone(),
344            sender: cx.mailbox().actor_addr().clone(),
345            session_id,
346            seqs,
347            #[cfg(test)]
348            lineage: Vec::new(),
349            headers,
350            dest_port: M::port(),
351            data,
352            return_undeliverable,
353        };
354
355        self.sender_hop
356            .forward(cx, &cast_message, &ForwardLineage::default())
357    }
358
359    /// Release this cast domain after all destination actors have shut down.
360    ///
361    /// Before calling this method, the caller must ensure that every destination
362    /// actor has reached a terminal state and cannot process further messages.
363    /// Do not use `destroy` to prevent message delivery; shut down the destination
364    /// actors first. Later casts may be rejected by the cast routing layer or by
365    /// the stopped destination actors.
366    ///
367    /// This operation is best-effort and does not acknowledge completion.
368    pub fn destroy(&self, cx: &impl context::Actor) {
369        let origin = cx.mailbox().actor_addr().clone();
370        for subtree in &self.sender_hop.next_hops {
371            if let CastRoute::ViaCastActor(root_actor) = &subtree.route {
372                root_actor.post(
373                    cx,
374                    DestroyCastDomain {
375                        domain_id: self.id.clone(),
376                        origin: origin.clone(),
377                    },
378                );
379            }
380        }
381    }
382
383    /// Allocate one normal sender-side sequence number per destination rank.
384    ///
385    /// This is the same model used by v1 `CommActor`: a complete `rank -> seq`
386    /// snapshot is allocated once, then partitioned among region roots, so
387    /// forwarding hops do not need route-local metadata to derive receiver
388    /// ordering. `ValueMesh` preserves the domain rank space while allowing
389    /// compact representations when seqs happen to be compressible.
390    fn seqs_for_cast(&self, cx: &impl context::Actor) -> Result<(Uuid, ValueMesh<u64>)> {
391        let sequencer = cx.instance().sequencer();
392
393        Ok((
394            sequencer.session_id(),
395            ValueMesh::from_ranges_with_default(
396                self.members.region().clone(),
397                0,
398                sequencer.assign_seqs(self.seq_keys.get_or_init(|| {
399                    Arc::new(
400                        self.members
401                            .values()
402                            .map(|member| SeqKey::for_handler(&member))
403                            .collect(),
404                    )
405                })),
406            )?,
407        ))
408    }
409}
410
411/// Return the tiles directly reached from `tile` by the current communication
412/// algorithm.
413///
414/// This asks only for the current tile's outgoing edges without materializing
415/// the full domain tree. The returned [`MaterializedTile`]s are still tiles of
416/// the input value type.
417///
418/// ```text
419/// current MaterializedTile:
420/// T0 [ A0 A1 A2 A3
421///      A4 A5 A6 A7 ]
422///
423/// next_tiles(current), rendered by destination actor rank:
424/// A0
425/// |-- T1 [ A1 ]
426/// |-- T2 [ A2 ]
427/// |-- T3 [ A3 ]
428/// `-- T4 [ A4 A5 A6 A7 ]
429/// ```
430fn next_tiles<T: 'static>(
431    tiling_policy: TilingPolicy,
432    tile: &MaterializedTile<T>,
433) -> Vec<MaterializedTile<T>> {
434    tiling_policy
435        .children(tile.tile())
436        .into_iter()
437        .map(|child| tile.subtile(child))
438        .collect()
439}
440
441/// Well-known actor name for the [`CastActor`] system actor.
442///
443/// One `CastActor` is expected to run on every proc under this name. Internal
444/// setup uses this known address to route setup commands to child tile roots.
445pub const CAST_ACTOR_NAME: &str = "cast";
446
447/// System actor that establishes casting domains.
448///
449/// One CastActor lives on every proc (well-known name [`CAST_ACTOR_NAME`]).
450/// It installs and propagates [`CreateCastDomain`]. After a domain is set up,
451/// the CastActor stores tile-local execution state in [`CastHop`] for
452/// subsequent multicast routing.
453#[derive(Debug, Default)]
454#[hyperactor::export(
455    handlers = [
456        CreateCastDomain,
457        DestroyCastDomain,
458        CastMessage
459    ],
460)]
461#[hyperactor::spawnable]
462pub struct CastActor {
463    /// Per-hop routing state installed on this actor.
464    installed_hops: HashMap<CastDomainId, CastHop>,
465}
466
467impl CastActor {
468    /// Return the typed CastActor reference for a system proc.
469    ///
470    /// Input: host proc `host_0`. Output: `host_0::cast`.
471    pub fn ref_for_proc(proc: ProcAddr) -> ActorRef<Self> {
472        ActorRef::attest(ActorAddr::root(proc, Label::strip(CAST_ACTOR_NAME)))
473    }
474}
475
476/// One delivery and relay step in a cast tree.
477#[derive(Debug, Clone, Serialize, Deserialize)]
478struct CastHop {
479    /// Destination delivered directly by this hop.
480    local_destination: CastDestination,
481    /// Precomputed outgoing subtrees.
482    next_hops: Vec<CastSubtree>,
483    /// Number of logical destinations reached through this hop.
484    num_destinations: usize,
485}
486
487/// One destination actor and its position in the cast domain.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct CastDestination {
490    point_in_domain: Point,
491    base_rank_in_domain: usize,
492    actor: ActorAddr,
493}
494
495impl CastDestination {
496    /// Build one logical destination for each actor.
497    ///
498    /// Input:
499    ///
500    /// ```text
501    /// region ranks: [0 1]
502    /// actors: [actor_0, actor_1]
503    /// ```
504    ///
505    /// Output:
506    ///
507    /// ```text
508    /// [
509    ///   Destination(point=0, base_rank=0, actor=actor_0),
510    ///   Destination(point=1, base_rank=1, actor=actor_1),
511    /// ]
512    /// ```
513    pub fn mesh(region: Region, actors: Vec<ActorAddr>) -> anyhow::Result<ValueMesh<Self>> {
514        anyhow::ensure!(
515            actors.len() == region.num_ranks(),
516            "cast domain member count must match the logical region"
517        );
518
519        let extent = region.extent();
520        let destinations = region
521            .slice()
522            .iter()
523            .zip(actors)
524            .map(|(base_rank_in_domain, actor)| {
525                let coordinates = region.slice().coordinates(base_rank_in_domain)?;
526
527                Ok(Self {
528                    point_in_domain: extent.point(coordinates)?,
529                    base_rank_in_domain,
530                    actor,
531                })
532            })
533            .collect::<anyhow::Result<Vec<_>>>()?;
534
535        ValueMesh::new(region, destinations).map_err(Into::into)
536    }
537
538    /// Select destinations and rebuild their points for the sliced region.
539    ///
540    /// Input:
541    ///
542    /// ```text
543    /// destinations: [actor_0, actor_1, actor_2, actor_3]
544    /// region: select base ranks [1 3]
545    /// ```
546    ///
547    /// Output:
548    ///
549    /// ```text
550    /// [actor_1, actor_3]
551    /// ```
552    ///
553    /// Each output node keeps its base rank and gets a point in the selected
554    /// region.
555    pub fn subset(
556        destinations: &ValueMesh<Self>,
557        region: Region,
558    ) -> anyhow::Result<ValueMesh<Self>> {
559        anyhow::ensure!(
560            region.is_subset(&destinations.region()),
561            "cast domain slice must be a subset of the logical region"
562        );
563
564        let actors = region
565            .slice()
566            .iter()
567            .map(|base_rank| {
568                destinations
569                    .get_by_base_rank(base_rank)
570                    .map(|destination| destination.actor.clone())
571                    .ok_or_else(|| anyhow::anyhow!("missing cast destination for rank {base_rank}"))
572            })
573            .collect::<anyhow::Result<Vec<_>>>()?;
574
575        Self::mesh(region, actors)
576    }
577
578    /// Return this destination's actor address.
579    ///
580    /// Input: `Destination(actor=host_0_actor_0)`.
581    /// Output: `host_0_actor_0`.
582    pub fn actor(&self) -> &ActorAddr {
583        &self.actor
584    }
585
586    fn seq(&self, seqs: &ValueMesh<u64>) -> anyhow::Result<u64> {
587        seqs.get_by_base_rank(self.base_rank_in_domain)
588            .copied()
589            .ok_or_else(|| {
590                anyhow::anyhow!("missing seq for base rank {}", self.base_rank_in_domain)
591            })
592    }
593}
594
595/// One CastActor routing node and its local destination.
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct CastNode {
598    cast_actor: ActorRef<CastActor>,
599    destination: CastDestination,
600}
601
602impl CastNode {
603    /// Create one routing node for a relay and its local destination.
604    ///
605    /// Input: `proc_0::cast` and `actor_0`.
606    /// Output: `proc_0::cast {actor_0}`.
607    pub fn new(cast_actor: ActorRef<CastActor>, destination: CastDestination) -> Self {
608        Self {
609            cast_actor,
610            destination,
611        }
612    }
613}
614
615/// One route from a cast sender or an installed cast hop.
616#[derive(Debug, Clone, Serialize, Deserialize)]
617enum CastRoute {
618    /// Continue routing a nonterminal child tile through its representative CastActor.
619    ViaCastActor(ActorRef<CastActor>),
620    /// Deliver a terminal child tile directly to its destination actor.
621    Direct(CastDestination),
622}
623
624impl CastRoute {
625    /// Route a terminal tile directly to its root actor; otherwise, route it
626    /// through the root actor's CastActor for further fanout.
627    fn try_from_tile(
628        tiling_policy: TilingPolicy,
629        tile: &MaterializedTile<CastNode>,
630    ) -> anyhow::Result<Self> {
631        let root_node = tile
632            .root_item()
633            .ok_or_else(|| anyhow::anyhow!("tile must have at least one cast node"))?;
634        let destination = root_node.destination.clone();
635
636        Ok(if next_tiles(tiling_policy, tile).is_empty() {
637            Self::Direct(destination)
638        } else {
639            Self::ViaCastActor(root_node.cast_actor.clone())
640        })
641    }
642}
643
644#[cfg(test)]
645fn cast_actor_ref_for_member(member: &ActorAddr) -> ActorRef<CastActor> {
646    CastActor::ref_for_proc(member.proc_addr().clone())
647}
648
649fn annotate_cast_failure(
650    envelope: &mut MessageEnvelope,
651    cast_actor: &ActorAddr,
652    phase: &str,
653    origin: &ActorAddr,
654    return_port: &hyperactor::PortAddr,
655) {
656    if let Some(failure) = envelope.root_delivery_failure_mut() {
657        annotate_cast_delivery_failure(failure, cast_actor, phase, origin, return_port);
658    }
659}
660
661fn annotate_cast_delivery_failure(
662    failure: &mut DeliveryFailure,
663    cast_actor: &ActorAddr,
664    phase: &str,
665    origin: &ActorAddr,
666    return_port: &hyperactor::PortAddr,
667) {
668    failure.attrs.set(CAST_FAILURE_PHASE, phase.to_string());
669    failure
670        .attrs
671        .set(CAST_FAILURE_CAST_ACTOR, cast_actor.clone());
672    failure.attrs.set(CAST_FAILURE_ORIGIN, origin.clone());
673    failure
674        .attrs
675        .set(CAST_FAILURE_RETURN_PORT, return_port.to_string());
676}
677
678#[async_trait]
679impl Actor for CastActor {
680    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
681        this.set_system();
682        Ok(())
683    }
684
685    async fn handle_undeliverable_message(
686        &mut self,
687        cx: &Instance<Self>,
688        _reason: hyperactor::mailbox::UndeliverableReason,
689        undelivered: Undeliverable<MessageEnvelope>,
690    ) -> Result<(), anyhow::Error> {
691        self.return_delivery_failure_to_origin(cx, undelivered)
692            .await
693    }
694
695    async fn handle_invalid_reference(
696        &mut self,
697        cx: &Instance<Self>,
698        _invalid: hyperactor::mailbox::InvalidReference,
699        undelivered: Undeliverable<MessageEnvelope>,
700    ) -> Result<(), anyhow::Error> {
701        self.return_delivery_failure_to_origin(cx, undelivered)
702            .await
703    }
704}
705
706impl CastActor {
707    fn return_cast_error_to_origin(
708        cx: &Context<Self>,
709        message: &CastMessage,
710        phase: &str,
711        reason: TransportFailureReason,
712    ) {
713        let destination = cx.self_addr().port_addr(Port::handler::<CastMessage>());
714        let mut failure = DeliveryFailure::new(UndeliverableReason::Transport(
715            TransportFailure::new(destination.clone(), reason),
716        ));
717        let return_port: PortRef<Undeliverable<MessageEnvelope>> =
718            PortRef::attest_handler_port(&message.sender);
719
720        match wirevalue::Any::serialize(message) {
721            Ok(data) => {
722                let mut message_envelope = MessageEnvelope::new(
723                    message.sender.clone(),
724                    destination,
725                    data,
726                    cx.headers().clone(),
727                );
728                message_envelope.push_delivery_failure(failure);
729                Self::return_cast_failure_to_origin(cx, message_envelope, message, phase);
730            }
731            Err(error) => {
732                tracing::error!(%error, "failed to serialize returned cast message");
733                annotate_cast_delivery_failure(
734                    &mut failure,
735                    cx.self_addr(),
736                    phase,
737                    &message.sender,
738                    return_port.port_addr(),
739                );
740                return_port.post(
741                    cx,
742                    Undeliverable::Report(DeliveryFailureReport::new(
743                        cx.self_addr().clone(),
744                        EndpointLocation::Port(destination),
745                        Some(CastMessage::typename().to_string()),
746                        failure,
747                    )),
748                );
749            }
750        }
751    }
752
753    fn return_cast_failure_to_origin(
754        cx: &Instance<Self>,
755        mut message_envelope: MessageEnvelope,
756        message: &CastMessage,
757        phase: &str,
758    ) {
759        let return_port = PortRef::attest_handler_port(&message.sender);
760        annotate_cast_failure(
761            &mut message_envelope,
762            cx.self_addr(),
763            phase,
764            &message.sender,
765            return_port.port_addr(),
766        );
767        message_envelope.set_header(CAST_ORIGINATING_SENDER, message.sender.clone());
768        return_port.post(cx, Undeliverable::Returned(message_envelope));
769    }
770
771    async fn return_delivery_failure_to_origin(
772        &mut self,
773        cx: &Instance<Self>,
774        undelivered: Undeliverable<MessageEnvelope>,
775    ) -> Result<(), anyhow::Error> {
776        let mut message_envelope = match undelivered {
777            Undeliverable::Returned(message_envelope) => message_envelope,
778            Undeliverable::Report(report) => {
779                tracing::error!(?report, "cast delivery failed without a returned message");
780                return Ok(());
781            }
782        };
783
784        // 1. Case delivery failure at a "forwarding" step.
785        if let Ok(message) = message_envelope.deserialized::<CastMessage>() {
786            Self::return_cast_failure_to_origin(cx, message_envelope, &message, "forward");
787            return Ok(());
788        }
789
790        // 2. Failure while forwarding a destroy request.
791        if let Ok(message) = message_envelope.deserialized::<DestroyCastDomain>() {
792            let return_port = PortRef::attest_handler_port(&message.origin);
793            annotate_cast_failure(
794                &mut message_envelope,
795                cx.self_addr(),
796                "destroy",
797                &message.origin,
798                return_port.port_addr(),
799            );
800            message_envelope.set_header(CAST_ORIGINATING_SENDER, message.origin.clone());
801            return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
802            return Ok(());
803        }
804
805        // 3. Failure while delivering from this CastActor to the local
806        // destination actor.
807        if let Some(sender) = message_envelope.headers().get(CAST_ORIGINATING_SENDER) {
808            let return_port = PortRef::attest_handler_port(&sender);
809            annotate_cast_failure(
810                &mut message_envelope,
811                cx.self_addr(),
812                "deliver_here",
813                &sender,
814                return_port.port_addr(),
815            );
816            return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
817            return Ok(());
818        }
819
820        // 4. A return of an undeliverable message was itself returned.
821        UndeliverableMailboxSender
822            .post(message_envelope, /*unused */ monitored_return_handle());
823        Ok(())
824    }
825}
826
827/// Install one hop of a cast domain and propagate setup down the routing tree.
828///
829/// Materialization sends this to each nonterminal subtree root's [`CastActor`].
830/// Each receiving [`CastActor`] stores its [`CastHop`], computes outgoing next
831/// hops from its materialized tile, and forwards this same message with the
832/// corresponding communication-child tile.
833#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
834struct CreateCastDomain {
835    cast_domain_id: CastDomainId,
836    region: Region,
837    tiling_policy: TilingPolicy,
838    tile: MaterializedTile<CastNode>,
839}
840wirevalue::register_type!(CreateCastDomain);
841
842#[async_trait]
843impl Handler<CreateCastDomain> for CastActor {
844    #[tracing::instrument(
845        level = "debug",
846        skip_all,
847        fields(
848            domain_id = %message.cast_domain_id,
849            rank = message.tile.root_rank(),
850            num_members = message.tile.rank_count(),
851        )
852    )]
853    async fn handle(
854        &mut self,
855        cx: &Context<Self>,
856        message: CreateCastDomain,
857    ) -> Result<(), anyhow::Error> {
858        let CreateCastDomain {
859            cast_domain_id,
860            region,
861            tiling_policy,
862            tile,
863        } = message;
864        if self.installed_hops.contains_key(&cast_domain_id) {
865            return Ok(());
866        }
867
868        let mut next_hops = Vec::new();
869
870        for next_tile in next_tiles(tiling_policy, &tile) {
871            let next_hop_served_region =
872                Region::new(region.labels().to_vec(), next_tile.tile().space().clone());
873
874            let route = CastRoute::try_from_tile(tiling_policy, &next_tile)?;
875
876            if let CastRoute::ViaCastActor(next_hop_cast_actor) = &route {
877                next_hop_cast_actor.post(
878                    cx,
879                    CreateCastDomain {
880                        cast_domain_id: cast_domain_id.clone(),
881                        region: region.clone(),
882                        tiling_policy,
883                        tile: next_tile,
884                    },
885                );
886            }
887            next_hops.push(CastSubtree {
888                route,
889                served_region: next_hop_served_region,
890            });
891        }
892
893        let local_destination = tile
894            .root_item()
895            .ok_or_else(|| anyhow::anyhow!("cast tile must have a root node"))?
896            .destination
897            .clone();
898
899        let cast_hop = CastHop {
900            local_destination,
901            next_hops,
902            num_destinations: tile.rank_count(),
903        };
904
905        #[cfg(test)]
906        {
907            tests::capture_installed_domain(cx, cast_domain_id.domain_id(), &cast_hop);
908        }
909
910        self.installed_hops.insert(cast_domain_id, cast_hop);
911
912        Ok(())
913    }
914}
915
916/// Fanout counts used to configure reducers for one cast hop.
917#[derive(Debug, Clone, Copy)]
918struct SplitFanout {
919    /// Number of immediate reply sources: child hops plus local delivery.
920    peer_count: usize,
921    /// Total cast destinations covered by this subtree, including the local
922    /// destination.
923    num_destinations: usize,
924}
925
926/// Rewrite reply port parts in the serialized message so that downstream
927/// actors reply through local proxy ports on the current sender or CastActor
928/// instead of directly to the original sender. Each proxy port reduces replies
929/// from downstream next hops plus the optional local delivery, forming a
930/// reduction tree that mirrors the cast tree.
931fn split_ports(
932    cx: &impl context::Actor,
933    data: &mut wirevalue::Any<wirevalue::encoding::Multipart>,
934    fanout: SplitFanout,
935) -> Result<()> {
936    data.visit_multipart_parts_mut::<PortRefRepr, anyhow::Error>(|port| {
937        if port.unsplit() {
938            return Ok(());
939        }
940
941        let split = port.port_addr().split(
942            cx,
943            port.reducer_spec().clone(),
944            ReducerMode::Streaming(port.streaming_opts().clone()),
945            port.get_return_undeliverable(),
946        )?;
947
948        #[cfg(test)]
949        {
950            tests::collect_split_port(port.port_addr(), &split);
951        }
952
953        port.update_port_addr(split);
954        Ok(())
955    })?;
956
957    data.visit_multipart_parts_mut::<IdleFlushPortRefRepr, anyhow::Error>(|port| {
958        if port.unsplit() || port.reducer_spec().is_none() {
959            return Ok(());
960        }
961
962        let opts = port.reducer_opts();
963
964        let expected = fanout
965            .num_destinations
966            .checked_mul(opts.expected_updates_per_destination.get())
967            .and_then(ConfigNonZeroUsize::new)
968            .ok_or_else(|| {
969                anyhow::anyhow!("expected reducer update count must be nonzero and cannot overflow")
970            })?;
971
972        let split = port.port_addr().split(
973            cx,
974            port.reducer_spec().cloned(),
975            ReducerMode::IdleFlush {
976                expected,
977                idle_timeout: opts.idle_timeout,
978                abandon_timeout: opts.abandon_timeout,
979            },
980            port.get_return_undeliverable(),
981        )?;
982
983        #[cfg(test)]
984        {
985            tests::collect_split_port(port.port_addr(), &split);
986        }
987
988        port.update_port_addr(split);
989        Ok(())
990    })?;
991
992    data.visit_multipart_parts_mut::<OncePortRefRepr, anyhow::Error>(|port| {
993        if port.unsplit() || port.reducer_spec().is_none() {
994            // OncePorts without reducers cannot be split. Pass through as-is.
995            // Using the port more than once will cause a delivery error downstream.
996            return Ok(());
997        }
998
999        let split = port.port_addr().split(
1000            cx,
1001            port.reducer_spec().clone(),
1002            ReducerMode::Once(fanout.peer_count),
1003            true,
1004        )?;
1005
1006        #[cfg(test)]
1007        {
1008            tests::collect_split_port(port.port_addr(), &split);
1009        }
1010
1011        port.update_port_addr(split);
1012        Ok(())
1013    })?;
1014
1015    Ok(())
1016}
1017
1018/// Test-only forwarding path metadata.
1019///
1020/// In production this is zero-sized and optimized away. In tests, each
1021/// forwarded message carries the actor addresses already traversed, and local
1022/// delivery appends the destination actor.
1023#[derive(Debug, Clone, Default)]
1024struct ForwardLineage {
1025    #[cfg(test)]
1026    actors: Vec<ActorAddr>,
1027}
1028
1029impl ForwardLineage {
1030    #[cfg(test)]
1031    fn from_message(message: &CastMessage) -> Self {
1032        Self {
1033            actors: message.lineage.clone(),
1034        }
1035    }
1036
1037    #[cfg(not(test))]
1038    fn from_message(_message: &CastMessage) -> Self {
1039        Self {}
1040    }
1041
1042    fn through(&self, actor: &ActorAddr) -> Self {
1043        #[cfg(test)]
1044        {
1045            let mut actors = self.actors.clone();
1046            actors.push(actor.clone());
1047            Self { actors }
1048        }
1049        #[cfg(not(test))]
1050        {
1051            let _ = actor;
1052            Self {}
1053        }
1054    }
1055
1056    #[cfg(test)]
1057    fn actors(&self) -> Vec<ActorAddr> {
1058        self.actors.clone()
1059    }
1060}
1061
1062/// Multicast payload routed through a cast domain.
1063///
1064/// Senders use this to execute their first hop. CastActors forward the same
1065/// message type to child hops; internal forwards differ only in test-only
1066/// lineage. Direct routes deliver the underlying payload from this envelope.
1067#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1068struct CastMessage {
1069    /// The domain to cast into.
1070    cast_domain_id: CastDomainId,
1071    /// Actor that initiated the cast.
1072    sender: ActorAddr,
1073    /// Sender-side sequencer session for this cast.
1074    session_id: Uuid,
1075    /// Per-domain-rank sequence numbers allocated by the sender before routing.
1076    seqs: ValueMesh<u64>,
1077    /// Test-only path of actor addresses traversed so far.
1078    #[cfg(test)]
1079    lineage: Vec<ActorAddr>,
1080    /// Message headers.
1081    headers: Flattrs,
1082    /// The target port index on each destination actor.
1083    dest_port: u64,
1084    /// The serialized message data.
1085    data: wirevalue::Any<wirevalue::encoding::Multipart>,
1086    /// Whether failed delivery should be returned to the originating sender.
1087    return_undeliverable: bool,
1088}
1089
1090wirevalue::register_type!(CastMessage);
1091
1092#[async_trait]
1093impl Handler<CastMessage> for CastActor {
1094    // HOT PATH: Be mindful of performance when making changes here.
1095    // To test how performance is affected by a change, run the RPC benchmarks in
1096    // `monarch/python/benches/`.
1097    #[tracing::instrument(
1098        level = "debug",
1099        skip_all,
1100        fields(
1101            domain_id = %message.cast_domain_id.domain_id(),
1102        )
1103    )]
1104    async fn handle(
1105        &mut self,
1106        cx: &Context<Self>,
1107        message: CastMessage,
1108    ) -> Result<(), anyhow::Error> {
1109        let Some(domain) = self.installed_hops.get(&message.cast_domain_id) else {
1110            Self::return_cast_error_to_origin(
1111                cx,
1112                &message,
1113                "unknown_domain",
1114                TransportFailureReason::NoRoute,
1115            );
1116            return Ok(());
1117        };
1118
1119        let lineage = ForwardLineage::from_message(&message).through(cx.self_addr());
1120        if let Err(error) = domain.forward(cx, &message, &lineage) {
1121            tracing::error!(
1122                %error,
1123                domain_id = %message.cast_domain_id,
1124                "failed to route cast message",
1125            );
1126            Self::return_cast_error_to_origin(
1127                cx,
1128                &message,
1129                "route",
1130                TransportFailureReason::LinkUnavailable(error.to_string()),
1131            );
1132        }
1133
1134        Ok(())
1135    }
1136}
1137
1138struct CastDelivery<'a> {
1139    sender: &'a ActorAddr,
1140    session_id: Uuid,
1141    seq: u64,
1142    headers: &'a Flattrs,
1143    dest_port: u64,
1144    return_undeliverable: bool,
1145}
1146
1147impl<'a> CastDelivery<'a> {
1148    fn try_from_message(
1149        message: &'a CastMessage,
1150        destination: &CastDestination,
1151    ) -> anyhow::Result<Self> {
1152        Ok(Self {
1153            sender: &message.sender,
1154            session_id: message.session_id,
1155            seq: destination.seq(&message.seqs)?,
1156            headers: &message.headers,
1157            dest_port: message.dest_port,
1158            return_undeliverable: message.return_undeliverable,
1159        })
1160    }
1161}
1162
1163// HOT PATH: Be mindful of performance when making changes here.
1164// To test how performance is affected by a change, run the RPC benchmarks in
1165// `monarch/python/benches/`.
1166fn deliver_to_destination(
1167    cx: &impl context::Actor,
1168    delivery: &CastDelivery<'_>,
1169    destination: &CastDestination,
1170    mut data: wirevalue::Any<wirevalue::encoding::Multipart>,
1171    lineage: &ForwardLineage,
1172) -> Result<()> {
1173    let rank = destination.point_in_domain.rank();
1174
1175    data.visit_multipart_parts_mut::<ResourceRankRepr, anyhow::Error>(
1176        |ResourceRankRepr(resource_rank)| {
1177            *resource_rank = Some(rank);
1178            Ok(())
1179        },
1180    )?;
1181
1182    let mut headers = delivery.headers.clone();
1183    headers.set(CAST_POINT, destination.point_in_domain.clone());
1184    headers.set(CAST_ORIGINATING_SENDER, delivery.sender.clone());
1185    let seq_info = SeqInfo::Session {
1186        session_id: delivery.session_id,
1187        seq: delivery.seq,
1188    };
1189    headers.set(SEQ_INFO, seq_info.clone());
1190
1191    #[cfg(not(test))]
1192    let _ = lineage;
1193
1194    #[cfg(test)]
1195    headers.set(CAST_LINEAGE, lineage.actors());
1196
1197    let dest = destination
1198        .actor
1199        .port_addr(Port::handler_id(delivery.dest_port, None));
1200    hyperactor::mailbox::headers::stamp_sender_actor_id_hash(&mut headers, delivery.sender);
1201    hyperactor::mailbox::headers::stamp_sender_actor_id(
1202        &mut headers,
1203        &seq_info,
1204        &dest,
1205        delivery.sender,
1206    );
1207    cx.instance().post_with_external_seq_info_and_return(
1208        dest,
1209        headers,
1210        data.erase_encoding(),
1211        delivery.return_undeliverable,
1212    );
1213    Ok(())
1214}
1215
1216impl CastHop {
1217    /// Deliver and forward one cast message from either the sender or a CastActor.
1218    ///
1219    /// Input:
1220    ///
1221    /// ```text
1222    /// local_destination: actor_0
1223    /// next_hops:         [actor_1, cast_2]
1224    /// seqs:               [s0 s1 s2 s3]
1225    /// ```
1226    ///
1227    /// Output:
1228    ///
1229    /// ```text
1230    /// actor_0 <- s0
1231    /// actor_1 <- s1
1232    /// cast_2  <- [s2 s3]
1233    /// ```
1234    fn forward(
1235        &self,
1236        cx: &impl context::Actor,
1237        message: &CastMessage,
1238        lineage: &ForwardLineage,
1239    ) -> Result<(), anyhow::Error> {
1240        // Split reply ports so that downstream next hops reply through this
1241        // hop's local proxy ports instead of directly to the original sender.
1242        let mut data = message.data.clone();
1243        split_ports(
1244            cx,
1245            &mut data,
1246            SplitFanout {
1247                peer_count: 1 + self.next_hops.len(),
1248                num_destinations: self.num_destinations,
1249            },
1250        )?;
1251
1252        let destination = &self.local_destination;
1253        let local_lineage = lineage.through(&destination.actor);
1254
1255        deliver_to_destination(
1256            cx,
1257            &CastDelivery::try_from_message(message, destination)?,
1258            destination,
1259            data.clone(),
1260            &local_lineage,
1261        )?;
1262
1263        for next_hop in &self.next_hops {
1264            match &next_hop.route {
1265                CastRoute::ViaCastActor(next_hop_cast_actor) => {
1266                    let forward_headers = message.headers.clone();
1267                    let mut port = next_hop_cast_actor.port();
1268                    port.return_undeliverable(message.return_undeliverable);
1269                    port.post_with_headers(
1270                        cx,
1271                        forward_headers,
1272                        CastMessage {
1273                            cast_domain_id: message.cast_domain_id.clone(),
1274                            sender: message.sender.clone(),
1275                            session_id: message.session_id,
1276                            seqs: message
1277                                .seqs
1278                                .sliced_unchecked(next_hop.served_region.clone()),
1279                            #[cfg(test)]
1280                            lineage: lineage.actors(),
1281                            headers: message.headers.clone(),
1282                            dest_port: message.dest_port,
1283                            data: data.clone(),
1284                            return_undeliverable: message.return_undeliverable,
1285                        },
1286                    );
1287                }
1288                CastRoute::Direct(destination) => {
1289                    let direct_lineage = lineage.through(&destination.actor);
1290                    deliver_to_destination(
1291                        cx,
1292                        &CastDelivery::try_from_message(message, destination)?,
1293                        destination,
1294                        data.clone(),
1295                        &direct_lineage,
1296                    )?;
1297                }
1298            }
1299        }
1300
1301        Ok(())
1302    }
1303}
1304
1305/// Internal command to destroy a casting domain and release its local routing state.
1306///
1307/// Sent to the entry-point [`CastActor`] by [`CastDomainRef::destroy`]. The
1308/// teardown propagates through the tree: each node removes its [`CastHop`] and
1309/// forwards [`DestroyCastDomain`] to its next hops. This is intentionally
1310/// idempotent and best-effort; unknown domains are ignored, and mailbox-level
1311/// undeliverable failures are returned to [`DestroyCastDomain::origin`].
1312#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1313struct DestroyCastDomain {
1314    /// The domain to tear down.
1315    domain_id: CastDomainId,
1316    /// Actor that initiated teardown and should receive undeliverable returns.
1317    origin: ActorAddr,
1318}
1319wirevalue::register_type!(DestroyCastDomain);
1320
1321#[async_trait]
1322impl Handler<DestroyCastDomain> for CastActor {
1323    #[tracing::instrument(
1324        level = "debug",
1325        skip_all,
1326        fields(domain_id = %message.domain_id)
1327    )]
1328    async fn handle(
1329        &mut self,
1330        cx: &Context<Self>,
1331        message: DestroyCastDomain,
1332    ) -> Result<(), anyhow::Error> {
1333        let Some(cast_hop) = self.installed_hops.remove(&message.domain_id) else {
1334            return Ok(());
1335        };
1336
1337        #[cfg(test)]
1338        {
1339            tests::capture_destroyed_domain(cx, message.domain_id.domain_id());
1340        }
1341
1342        for next_hop in &cast_hop.next_hops {
1343            if let CastRoute::ViaCastActor(next_hop) = &next_hop.route {
1344                next_hop.post(
1345                    cx,
1346                    DestroyCastDomain {
1347                        domain_id: message.domain_id.clone(),
1348                        origin: message.origin.clone(),
1349                    },
1350                );
1351            }
1352        }
1353
1354        Ok(())
1355    }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360    // Cast actor invariant coverage:
1361    //
1362    // CA-1: Partial - exact delivery is covered; independent destination
1363    //       failure is not covered.
1364    // CA-2: Direct - materialization properties and delivery tests cover it.
1365    // CA-3: Indirect - delivery tests cast immediately after materialization.
1366    // CA-4: Direct - the projection ordering test covers it.
1367    // CA-5: Mostly direct - headers, reducers, ports, and routed delivery are
1368    //       covered; equivalent route topologies are not compared.
1369    // CA-6: Partial - overlapping casts are covered; isolated destruction of
1370    //       one live overlapping domain is not covered.
1371    // CA-7: Direct - stopped destinations, repeated teardown, and post-destroy
1372    //       rejection are covered.
1373    // CA-8: Direct - unknown domains, routing errors, and report-only failures
1374    //       are covered.
1375
1376    use std::collections::BTreeMap;
1377    use std::collections::BTreeSet;
1378    use std::collections::HashMap;
1379    use std::num::NonZeroUsize;
1380    use std::sync::Mutex;
1381    use std::sync::OnceLock;
1382    use std::time::Duration;
1383
1384    use hyperactor::Client;
1385    use hyperactor::IdleFlushPortRef;
1386    use hyperactor::PortAddr;
1387    use hyperactor::ProcAddr;
1388    use hyperactor::accum::IdleFlushReducerOpts;
1389    use hyperactor::channel::ChannelTransport;
1390    use hyperactor::proc::Proc;
1391    use ndslice::Shape;
1392    use ndslice::Slice;
1393    use ndslice::ViewExt;
1394    use ndslice::shape;
1395    use ndslice::strategy::gen_region_strided;
1396    use ndslice::view::BuildFromRegionIndexed;
1397    use ndslice::view::Ranked;
1398    use proptest::prelude::*;
1399    use timed_test::async_timed_test;
1400    use tokio::runtime::Runtime;
1401    use typeuri::Named;
1402
1403    use super::*;
1404
1405    fn small_shape_sizes() -> impl Strategy<Value = Vec<usize>> {
1406        prop::collection::vec(1usize..=4, 1..=4).prop_filter("shape must stay small", |sizes| {
1407            sizes.iter().product::<usize>() <= 64
1408        })
1409    }
1410
1411    fn shape_from_sizes(sizes: &[usize]) -> Shape {
1412        Shape::new(
1413            (0..sizes.len()).map(|dim| format!("d{dim}")).collect(),
1414            Slice::new_row_major(sizes.to_vec()),
1415        )
1416        .unwrap()
1417    }
1418
1419    fn member(rank: usize) -> ActorAddr {
1420        ActorAddr::root(
1421            format!("proc{rank}@inproc://{rank}")
1422                .parse::<ProcAddr>()
1423                .unwrap(),
1424            Label::strip("member"),
1425        )
1426    }
1427
1428    fn validate_domain_tree(
1429        members: &HashMap<usize, ActorAddr>,
1430        tile: &MaterializedTile<ActorAddr>,
1431        seen_roots: &mut BTreeSet<usize>,
1432    ) -> Result<(), TestCaseError> {
1433        let expected_members = tile
1434            .tile()
1435            .space()
1436            .iter()
1437            .map(|rank| members[&rank].clone())
1438            .collect::<Vec<_>>();
1439        prop_assert_eq!(tile.items().cloned().collect::<Vec<_>>(), expected_members);
1440        prop_assert!(seen_roots.insert(tile.root_rank()));
1441
1442        for child in next_tiles(TilingPolicy::BlockPartitioning, tile) {
1443            validate_domain_tree(members, &child, seen_roots)?;
1444        }
1445
1446        Ok(())
1447    }
1448
1449    #[derive(Clone, Debug, PartialEq, Eq)]
1450    pub(crate) struct CastHopSnapshot {
1451        point_in_domain: Point,
1452        base_rank_in_domain: usize,
1453        next_hop_procs: BTreeSet<String>,
1454        direct_hop_procs: BTreeSet<String>,
1455        local_actor_proc: String,
1456    }
1457
1458    static INSTALLED_DOMAINS: OnceLock<Mutex<HashMap<Uid, BTreeMap<String, CastHopSnapshot>>>> =
1459        OnceLock::new();
1460    static DESTROYED_DOMAINS: OnceLock<Mutex<HashMap<Uid, BTreeSet<String>>>> = OnceLock::new();
1461
1462    fn installed_domains() -> &'static Mutex<HashMap<Uid, BTreeMap<String, CastHopSnapshot>>> {
1463        INSTALLED_DOMAINS.get_or_init(|| Mutex::new(HashMap::new()))
1464    }
1465
1466    fn destroyed_domains() -> &'static Mutex<HashMap<Uid, BTreeSet<String>>> {
1467        DESTROYED_DOMAINS.get_or_init(|| Mutex::new(HashMap::new()))
1468    }
1469
1470    pub(crate) fn capture_installed_domain(
1471        cx: &Context<'_, CastActor>,
1472        domain_id: &Uid,
1473        cast_hop: &CastHop,
1474    ) {
1475        let proc_name = cx.self_addr().proc_addr().log_name().to_string();
1476        let local_destination = &cast_hop.local_destination;
1477        let snapshot = CastHopSnapshot {
1478            point_in_domain: local_destination.point_in_domain.clone(),
1479            base_rank_in_domain: local_destination.base_rank_in_domain,
1480            next_hop_procs: cast_hop
1481                .next_hops
1482                .iter()
1483                .map(|next_hop| match &next_hop.route {
1484                    CastRoute::ViaCastActor(next_hop) => {
1485                        next_hop.actor_addr().proc_addr().log_name().to_string()
1486                    }
1487                    CastRoute::Direct(destination) => {
1488                        destination.actor.proc_addr().log_name().to_string()
1489                    }
1490                })
1491                .collect(),
1492            direct_hop_procs: cast_hop
1493                .next_hops
1494                .iter()
1495                .filter_map(|next_hop| match &next_hop.route {
1496                    CastRoute::ViaCastActor(_) => None,
1497                    CastRoute::Direct(destination) => {
1498                        Some(destination.actor.proc_addr().log_name().to_string())
1499                    }
1500                })
1501                .collect(),
1502            local_actor_proc: local_destination.actor.proc_addr().log_name().to_string(),
1503        };
1504        installed_domains()
1505            .lock()
1506            .unwrap()
1507            .entry(domain_id.clone())
1508            .or_default()
1509            .insert(proc_name, snapshot);
1510    }
1511
1512    pub(crate) fn capture_destroyed_domain(cx: &Context<'_, CastActor>, domain_id: &Uid) {
1513        let proc_name = cx.self_addr().proc_addr().log_name().to_string();
1514        destroyed_domains()
1515            .lock()
1516            .unwrap()
1517            .entry(domain_id.clone())
1518            .or_default()
1519            .insert(proc_name);
1520    }
1521
1522    fn clear_captured_domains() {
1523        installed_domains().lock().unwrap().clear();
1524        destroyed_domains().lock().unwrap().clear();
1525    }
1526
1527    fn captured_domain_snapshots(domain_id: &Uid) -> BTreeMap<String, CastHopSnapshot> {
1528        installed_domains()
1529            .lock()
1530            .unwrap()
1531            .get(domain_id)
1532            .cloned()
1533            .unwrap_or_default()
1534    }
1535
1536    fn destroyed_domain_snapshots(domain_id: &Uid) -> BTreeSet<String> {
1537        destroyed_domains()
1538            .lock()
1539            .unwrap()
1540            .get(domain_id)
1541            .cloned()
1542            .unwrap_or_default()
1543    }
1544
1545    fn members(count: usize) -> Vec<ActorAddr> {
1546        (0..count)
1547            .map(|i| {
1548                ActorAddr::root(
1549                    format!("proc{i}@inproc://{i}").parse::<ProcAddr>().unwrap(),
1550                    Label::strip("member"),
1551                )
1552            })
1553            .collect()
1554    }
1555
1556    /// Build a proc-local routing node for each test destination.
1557    ///
1558    /// Input:
1559    ///
1560    /// ```text
1561    /// region ranks: [0 1]
1562    /// members: {0: proc0::member, 1: proc1::member}
1563    /// ```
1564    ///
1565    /// Output:
1566    ///
1567    /// ```text
1568    /// [proc0::cast {proc0::member}, proc1::cast {proc1::member}]
1569    /// ```
1570    fn proc_cast_node_mesh(
1571        region: Region,
1572        members: &HashMap<usize, ActorAddr>,
1573    ) -> anyhow::Result<Arc<ValueMesh<CastNode>>> {
1574        let actors = region
1575            .slice()
1576            .iter()
1577            .map(|rank| {
1578                members
1579                    .get(&rank)
1580                    .cloned()
1581                    .ok_or_else(|| anyhow::anyhow!("missing test member for rank {rank}"))
1582            })
1583            .collect::<anyhow::Result<Vec<_>>>()?;
1584        let destinations = CastDestination::mesh(region.clone(), actors)?;
1585        let nodes = destinations
1586            .values()
1587            .map(|destination| {
1588                CastNode::new(
1589                    CastActor::ref_for_proc(destination.actor().proc_addr()),
1590                    destination.clone(),
1591                )
1592            })
1593            .collect::<Vec<_>>();
1594
1595        Ok(Arc::new(ValueMesh::new(region, nodes)?))
1596    }
1597
1598    #[test]
1599    fn test_cast_destination_mesh_and_subset_preserve_strided_base_ranks() {
1600        // Full region base ranks:
1601        //
1602        // row=0:  0  1  2  3
1603        // row=1:  4  5  6  7
1604        // row=2:  8  9 10 11
1605        // row=3: 12 13 14 15
1606        let full_region = Region::from(shape!(row = 4, col = 4));
1607
1608        let mesh_region = full_region
1609            .range("row", ndslice::Range(1, Some(4), 2))
1610            .unwrap();
1611
1612        let all_members = members(16);
1613
1614        let mesh_members = vec![
1615            all_members[4].clone(),
1616            all_members[5].clone(),
1617            all_members[6].clone(),
1618            all_members[7].clone(),
1619            all_members[12].clone(),
1620            all_members[13].clone(),
1621            all_members[14].clone(),
1622            all_members[15].clone(),
1623        ];
1624
1625        let destinations = CastDestination::mesh(mesh_region.clone(), mesh_members).unwrap();
1626
1627        let mesh_values = destinations
1628            .values()
1629            .map(|destination| {
1630                (
1631                    destination.actor.clone(),
1632                    destination.base_rank_in_domain,
1633                    destination.point_in_domain.coords(),
1634                )
1635            })
1636            .collect::<Vec<_>>();
1637
1638        assert_eq!(
1639            mesh_values,
1640            vec![
1641                (all_members[4].clone(), 4, vec![0, 0]),
1642                (all_members[5].clone(), 5, vec![0, 1]),
1643                (all_members[6].clone(), 6, vec![0, 2]),
1644                (all_members[7].clone(), 7, vec![0, 3]),
1645                (all_members[12].clone(), 12, vec![1, 0]),
1646                (all_members[13].clone(), 13, vec![1, 1]),
1647                (all_members[14].clone(), 14, vec![1, 2]),
1648                (all_members[15].clone(), 15, vec![1, 3]),
1649            ]
1650        );
1651
1652        // Select columns 1 and 3 from the already strided mesh:
1653        //
1654        // selected base ranks:  5  7
1655        //                     13 15
1656        // selected points:    0,0 0,1
1657        //                     1,0 1,1
1658        let subset_region = mesh_region
1659            .range("col", ndslice::Range(1, Some(4), 2))
1660            .unwrap();
1661
1662        let subset = CastDestination::subset(&destinations, subset_region).unwrap();
1663
1664        let subset_values = subset
1665            .values()
1666            .map(|destination| {
1667                (
1668                    destination.actor.clone(),
1669                    destination.base_rank_in_domain,
1670                    destination.point_in_domain.coords(),
1671                )
1672            })
1673            .collect::<Vec<_>>();
1674
1675        assert_eq!(
1676            subset_values,
1677            vec![
1678                (all_members[5].clone(), 5, vec![0, 0]),
1679                (all_members[7].clone(), 7, vec![0, 1]),
1680                (all_members[13].clone(), 13, vec![1, 0]),
1681                (all_members[15].clone(), 15, vec![1, 1]),
1682            ]
1683        );
1684    }
1685
1686    #[test]
1687    fn test_subset_members_preserves_selected_member_order() {
1688        // parent local ranks / members:
1689        //
1690        // row=0: 0  1      members[0] members[1]
1691        // row=1: 2  3      members[2] members[3]
1692        // row=2: 4  5      members[4] members[5]
1693        // row=3: 6  7      members[6] members[7]
1694        let parent_view = Region::from(shape!(row = 4, col = 2));
1695
1696        // selected region = row 1..3
1697        let child_view = parent_view
1698            .range("row", ndslice::Range(1, Some(3), 1))
1699            .unwrap();
1700        let members = members(8);
1701        let parent_members = ValueMesh::new(parent_view.clone(), members.clone()).unwrap();
1702
1703        let child_members = parent_members.subset(child_view.clone()).unwrap();
1704        let child_members = (0..Ranked::region(&child_members).num_ranks())
1705            .map(|rank| Ranked::get(&child_members, rank).unwrap().clone())
1706            .collect::<Vec<_>>();
1707
1708        // child local ranks -> parent/base ranks:
1709        // row=0: 0->2  1->3
1710        // row=1: 2->4  3->5
1711        assert_eq!(child_members, &members[2..6]);
1712    }
1713
1714    // -- Integration test infrastructure --
1715
1716    /// A simple message type for testing cast delivery.
1717    #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
1718    struct TestDelivery {
1719        payload: String,
1720    }
1721    wirevalue::register_type!(TestDelivery);
1722
1723    #[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1724    enum CastStoppedDomain {
1725        Cast,
1726        CastWithoutReturn,
1727        DestroyAndCast,
1728    }
1729    wirevalue::register_type!(CastStoppedDomain);
1730
1731    #[hyperactor::export(handlers = [CastStoppedDomain])]
1732    struct CastFailureProbe {
1733        members: HashMap<usize, ActorAddr>,
1734        region: Region,
1735        failures: PortRef<Undeliverable<MessageEnvelope>>,
1736        domain: Option<CastDomainRef>,
1737    }
1738
1739    #[async_trait]
1740    impl Actor for CastFailureProbe {
1741        async fn handle_delivery_failure_event(
1742            &mut self,
1743            cx: &Instance<Self>,
1744            undeliverable: Undeliverable<MessageEnvelope>,
1745        ) -> Result<(), anyhow::Error> {
1746            self.failures.post(cx, undeliverable);
1747            Ok(())
1748        }
1749    }
1750
1751    #[async_trait]
1752    impl Handler<CastStoppedDomain> for CastFailureProbe {
1753        async fn handle(
1754            &mut self,
1755            cx: &Context<Self>,
1756            message: CastStoppedDomain,
1757        ) -> Result<(), anyhow::Error> {
1758            if self.domain.is_none() {
1759                self.domain = Some(CastDomainId::new().materialize(
1760                    cx,
1761                    proc_cast_node_mesh(self.region.clone(), &self.members)?,
1762                    TilingPolicy::BlockPartitioning,
1763                    Flattrs::new(),
1764                )?);
1765            }
1766            let domain = self
1767                .domain
1768                .as_ref()
1769                .expect("cast domain was initialized above");
1770            let (payload, return_undeliverable) = match message {
1771                CastStoppedDomain::Cast => ("before-destroy", true),
1772                CastStoppedDomain::CastWithoutReturn => ("without-return", false),
1773                CastStoppedDomain::DestroyAndCast => {
1774                    domain.destroy(cx);
1775                    domain.destroy(cx);
1776                    ("after-destroy", true)
1777                }
1778            };
1779            domain.cast_with_return_undeliverable(
1780                cx,
1781                Flattrs::new(),
1782                TestDelivery {
1783                    payload: payload.to_string(),
1784                },
1785                return_undeliverable,
1786            )
1787        }
1788    }
1789
1790    /// Delivery record kept by test receivers in handler execution order.
1791    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, typeuri::Named)]
1792    struct TestDeliveryRecord {
1793        payload: String,
1794        lineage: Vec<ActorAddr>,
1795        operation_endpoint: Option<String>,
1796    }
1797    wirevalue::register_type!(TestDeliveryRecord);
1798
1799    #[derive(
1800        Debug,
1801        Clone,
1802        Default,
1803        PartialEq,
1804        Eq,
1805        Serialize,
1806        Deserialize,
1807        typeuri::Named
1808    )]
1809    struct TestDeliveryHistories {
1810        by_proc: BTreeMap<String, Vec<TestDeliveryRecord>>,
1811    }
1812    wirevalue::register_type!(TestDeliveryHistories);
1813
1814    impl TestDeliveryHistories {
1815        fn single(proc_name: String, deliveries: Vec<TestDeliveryRecord>) -> Self {
1816            Self {
1817                by_proc: [(proc_name, deliveries)].into_iter().collect(),
1818            }
1819        }
1820
1821        fn merge(&mut self, other: Self) -> anyhow::Result<()> {
1822            for (proc_name, history) in other.by_proc {
1823                anyhow::ensure!(
1824                    self.by_proc.insert(proc_name.clone(), history).is_none(),
1825                    "duplicate history reply from {proc_name}",
1826                );
1827            }
1828            Ok(())
1829        }
1830    }
1831
1832    #[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1833    struct GetHistory {
1834        reply_to: hyperactor::OncePortRef<TestDeliveryHistories>,
1835    }
1836    wirevalue::register_type!(GetHistory);
1837
1838    /// An actor that records delivered messages in local handler order.
1839    #[derive(Debug, Default)]
1840    #[hyperactor::export(
1841        handlers = [
1842            TestDelivery,
1843            GetHistory,
1844        ],
1845    )]
1846    struct TestReceiver {
1847        deliveries: Vec<TestDeliveryRecord>,
1848    }
1849
1850    #[async_trait]
1851    impl Actor for TestReceiver {
1852        async fn init(&mut self, _this: &Instance<Self>) -> Result<(), anyhow::Error> {
1853            Ok(())
1854        }
1855    }
1856
1857    #[async_trait]
1858    impl Handler<TestDelivery> for TestReceiver {
1859        async fn handle(
1860            &mut self,
1861            cx: &Context<Self>,
1862            msg: TestDelivery,
1863        ) -> Result<(), anyhow::Error> {
1864            let _seq_info = cx
1865                .headers()
1866                .get(SEQ_INFO)
1867                .expect("cast delivery should stamp SEQ_INFO");
1868            let lineage = cx.headers().get(CAST_LINEAGE).unwrap_or_default();
1869            let operation_endpoint = cx
1870                .headers()
1871                .get(hyperactor::mailbox::headers::OPERATION_ENDPOINT);
1872            self.deliveries.push(TestDeliveryRecord {
1873                payload: msg.payload,
1874                lineage,
1875                operation_endpoint,
1876            });
1877            Ok(())
1878        }
1879    }
1880
1881    #[async_trait]
1882    impl Handler<GetHistory> for TestReceiver {
1883        async fn handle(
1884            &mut self,
1885            cx: &Context<Self>,
1886            msg: GetHistory,
1887        ) -> Result<(), anyhow::Error> {
1888            msg.reply_to.post(
1889                cx,
1890                TestDeliveryHistories::single(
1891                    cx.self_addr().proc_addr().log_name().to_string(),
1892                    self.deliveries.clone(),
1893                ),
1894            );
1895            Ok(())
1896        }
1897    }
1898
1899    #[derive(typeuri::Named)]
1900    struct TestDeliveryHistoriesReducer;
1901
1902    impl hyperactor::accum::CommReducer for TestDeliveryHistoriesReducer {
1903        type Update = TestDeliveryHistories;
1904
1905        fn reduce(
1906            &self,
1907            mut left: Self::Update,
1908            right: Self::Update,
1909        ) -> anyhow::Result<Self::Update> {
1910            left.merge(right)?;
1911            Ok(left)
1912        }
1913    }
1914
1915    inventory::submit! {
1916        hyperactor::accum::ReducerFactory {
1917            typehash_f: <TestDeliveryHistoriesReducer as Named>::typehash,
1918            builder_f: |_| Ok(Box::new(TestDeliveryHistoriesReducer)),
1919        }
1920    }
1921
1922    struct TestDeliveryHistoriesAccumulator;
1923
1924    impl hyperactor::accum::Accumulator for TestDeliveryHistoriesAccumulator {
1925        type State = TestDeliveryHistories;
1926        type Update = TestDeliveryHistories;
1927
1928        fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
1929            state.merge(update)
1930        }
1931
1932        fn reducer_spec(&self) -> Option<hyperactor::accum::ReducerSpec> {
1933            Some(hyperactor::accum::ReducerSpec {
1934                typehash: <TestDeliveryHistoriesReducer as Named>::typehash(),
1935                builder_params: None,
1936            })
1937        }
1938    }
1939
1940    struct CastTestMesh {
1941        _client_proc: Arc<Proc>,
1942        client: Client,
1943        _procs: Arc<Vec<Proc>>,
1944        member_ids: HashMap<usize, ActorAddr>,
1945        receiver_ids: Vec<ActorAddr>,
1946    }
1947
1948    impl CastTestMesh {
1949        fn new(n: usize) -> Self {
1950            let client_proc =
1951                Proc::direct(ChannelTransport::Unix.any(), "client_proc".into()).unwrap();
1952            let client = client_proc.client("client");
1953
1954            let procs: Vec<Proc> = (0..n)
1955                .map(|i| {
1956                    let proc =
1957                        Proc::direct(ChannelTransport::Unix.any(), format!("proc_{i}")).unwrap();
1958                    let cast_handle = proc
1959                        .spawn_with_uid(
1960                            Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
1961                            CastActor::default(),
1962                        )
1963                        .unwrap();
1964                    let _: ActorRef<CastActor> = cast_handle.bind::<CastActor>();
1965                    proc
1966                })
1967                .collect();
1968            let member_ids = procs
1969                .iter()
1970                .enumerate()
1971                .map(|(rank, proc)| {
1972                    (
1973                        rank,
1974                        ActorAddr::root(proc.proc_addr().clone(), Label::strip("member")),
1975                    )
1976                })
1977                .collect();
1978
1979            Self {
1980                _client_proc: Arc::new(client_proc),
1981                client,
1982                _procs: Arc::new(procs),
1983                member_ids,
1984                receiver_ids: Vec::new(),
1985            }
1986        }
1987
1988        fn spawn_delivery_receivers(&mut self) {
1989            self.receiver_ids = self
1990                ._procs
1991                .iter()
1992                .map(|proc| {
1993                    let recv_handle = proc
1994                        .spawn_with_uid(
1995                            Uid::singleton(Label::strip("receiver")),
1996                            TestReceiver::default(),
1997                        )
1998                        .unwrap();
1999                    let _: ActorRef<TestReceiver> = recv_handle.bind::<TestReceiver>();
2000                    ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"))
2001                })
2002                .collect();
2003        }
2004
2005        fn spawn_split_port_receivers(&mut self) {
2006            self.receiver_ids = self
2007                ._procs
2008                .iter()
2009                .map(|proc| {
2010                    let recv_handle = proc
2011                        .spawn_with_uid(Uid::singleton(Label::strip("receiver")), SplitPortReceiver)
2012                        .unwrap();
2013                    let _: ActorRef<SplitPortReceiver> = recv_handle.bind::<SplitPortReceiver>();
2014                    ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"))
2015                })
2016                .collect();
2017        }
2018
2019        fn domain_members(&self) -> HashMap<usize, ActorAddr> {
2020            if self.receiver_ids.is_empty() {
2021                self.member_ids.clone()
2022            } else {
2023                self.member_ids
2024                    .keys()
2025                    .map(|rank| (*rank, self.receiver_ids[*rank].clone()))
2026                    .collect::<HashMap<_, _>>()
2027            }
2028        }
2029
2030        /// Returns a test mesh containing only the members selected by `region`.
2031        ///
2032        /// For a mesh with ranks `0..8`, a region containing ranks `4..8`
2033        /// produces a mesh with member ranks `4`, `5`, `6`, and `7` that shares
2034        /// the original live procs.
2035        fn sliced(&self, region: Region) -> Self {
2036            let all_members = self.domain_members();
2037            let member_ids = region
2038                .slice()
2039                .iter()
2040                .map(|rank| {
2041                    (
2042                        rank,
2043                        all_members
2044                            .get(&rank)
2045                            .expect("test mesh must contain every selected rank")
2046                            .clone(),
2047                    )
2048                })
2049                .collect();
2050
2051            Self {
2052                _client_proc: Arc::clone(&self._client_proc),
2053                client: self.client.clone(),
2054                _procs: Arc::clone(&self._procs),
2055                member_ids,
2056                receiver_ids: self.receiver_ids.clone(),
2057            }
2058        }
2059
2060        fn root_domain(&self, region: Region) -> CastDomainRef {
2061            let members = self.domain_members();
2062
2063            CastDomainId::new()
2064                .materialize(
2065                    &self.client,
2066                    proc_cast_node_mesh(region, &members).unwrap(),
2067                    TilingPolicy::BlockPartitioning,
2068                    Flattrs::new(),
2069                )
2070                .unwrap()
2071        }
2072
2073        fn proc_names(&self) -> Vec<String> {
2074            let mut ranks = self.domain_members().into_keys().collect::<Vec<_>>();
2075            ranks.sort_unstable();
2076
2077            ranks
2078                .into_iter()
2079                .map(|rank| format!("proc_{rank}"))
2080                .collect()
2081        }
2082
2083        fn root_domain_with_policy(&self, region: Region, policy: TilingPolicy) -> CastDomainRef {
2084            CastDomainId::new()
2085                .materialize(
2086                    &self.client,
2087                    proc_cast_node_mesh(region, &self.member_ids).unwrap(),
2088                    policy,
2089                    Flattrs::new(),
2090                )
2091                .unwrap()
2092        }
2093
2094        async fn wait_for_domain_snapshots(
2095            &self,
2096            domain_id: &Uid,
2097            expected_count: usize,
2098        ) -> BTreeMap<String, CastHopSnapshot> {
2099            tokio::time::timeout(Duration::from_secs(5), async {
2100                loop {
2101                    let snapshots = captured_domain_snapshots(domain_id);
2102                    if snapshots.len() == expected_count {
2103                        return snapshots;
2104                    }
2105                    tokio::time::sleep(Duration::from_millis(10)).await;
2106                }
2107            })
2108            .await
2109            .unwrap_or_else(|_| {
2110                panic!(
2111                    "timed out waiting for {expected_count} installed domain snapshots; saw {:?}",
2112                    captured_domain_snapshots(domain_id).keys()
2113                )
2114            })
2115        }
2116
2117        async fn wait_for_any_domain_snapshot(&self, domain_id: &Uid) {
2118            tokio::time::timeout(Duration::from_secs(5), async {
2119                while captured_domain_snapshots(domain_id).is_empty() {
2120                    tokio::time::sleep(Duration::from_millis(10)).await;
2121                }
2122            })
2123            .await
2124            .unwrap_or_else(|_| {
2125                panic!("timed out waiting for an installed domain snapshot for {domain_id}")
2126            });
2127        }
2128
2129        async fn wait_for_destroyed_domain_snapshots(
2130            &self,
2131            domain_id: &Uid,
2132            expected_count: usize,
2133        ) -> BTreeSet<String> {
2134            tokio::time::timeout(Duration::from_secs(5), async {
2135                loop {
2136                    let snapshots = destroyed_domain_snapshots(domain_id);
2137                    if snapshots.len() == expected_count {
2138                        return snapshots;
2139                    }
2140                    tokio::time::sleep(Duration::from_millis(10)).await;
2141                }
2142            })
2143            .await
2144            .unwrap_or_else(|_| {
2145                panic!(
2146                    "timed out waiting for {expected_count} destroyed domain snapshots; saw {:?}",
2147                    destroyed_domain_snapshots(domain_id)
2148                )
2149            })
2150        }
2151    }
2152
2153    proptest! {
2154        #[test]
2155        fn prop_domain_tree_materialization_covers_each_rank_once(sizes in small_shape_sizes()) {
2156            let shape = shape_from_sizes(&sizes);
2157            let region = Region::from(shape);
2158            let members = region
2159                .slice()
2160                .iter()
2161                .map(|rank| (rank, member(rank)))
2162                .collect::<HashMap<_, _>>();
2163            let root_tile = MaterializedTile::from_value_mesh_with_tile(
2164                Tile::from_view(&region),
2165                Arc::new(ValueMesh::build_indexed(region.clone(), members.clone()).unwrap()),
2166            );
2167
2168            let mut seen_roots = BTreeSet::new();
2169            validate_domain_tree(&members, &root_tile, &mut seen_roots)?;
2170
2171            let expected_roots = region.slice().iter().collect::<BTreeSet<_>>();
2172            prop_assert_eq!(seen_roots, expected_roots);
2173        }
2174
2175        #[test]
2176        fn prop_domain_destinations_preserve_member_mapping(sizes in small_shape_sizes()) {
2177            let shape = shape_from_sizes(&sizes);
2178            let region = Region::from(shape);
2179            let members = region
2180                .slice()
2181                .iter()
2182                .map(|rank| (rank, member(rank)))
2183                .collect::<HashMap<_, _>>();
2184            let root = MaterializedTile::from_value_mesh_with_tile(
2185                Tile::from_view(&region),
2186                Arc::new(ValueMesh::build_indexed(region.clone(), members.clone()).unwrap()),
2187            );
2188            let mut seen_roots = BTreeSet::new();
2189
2190            validate_domain_tree(&members, &root, &mut seen_roots)?;
2191
2192            prop_assert_eq!(
2193                seen_roots.into_iter().collect::<Vec<_>>(),
2194                region.slice().iter().collect::<Vec<_>>(),
2195            );
2196        }
2197    }
2198
2199    fn materialization_regions() -> impl Strategy<Value = Region> {
2200        prop_oneof![
2201            small_shape_sizes().prop_map(|sizes| Region::from(shape_from_sizes(sizes.as_slice()))),
2202            gen_region_strided(1..=4, 4, 3, 0),
2203        ]
2204    }
2205
2206    fn tiling_policies() -> impl Strategy<Value = TilingPolicy> {
2207        prop_oneof![
2208            Just(TilingPolicy::BlockPartitioning),
2209            (1usize..=4).prop_map(|fanout| TilingPolicy::BoundedFanout {
2210                fanout: NonZeroUsize::new(fanout).expect("generated fanout is nonzero"),
2211            }),
2212            Just(TilingPolicy::Bisection),
2213        ]
2214    }
2215
2216    fn materialization_runtime() -> &'static Runtime {
2217        static RUNTIME: OnceLock<Runtime> = OnceLock::new();
2218
2219        RUNTIME.get_or_init(|| {
2220            Runtime::new().expect("materialization property test should create its runtime")
2221        })
2222    }
2223
2224    fn validate_materialized_subtrees(
2225        region: Region,
2226        policy: TilingPolicy,
2227    ) -> Result<(), TestCaseError> {
2228        let proc = Proc::isolated();
2229        let sender = proc.actor_instance::<()>("materialization_sender").unwrap();
2230        sender.instance.bind::<()>();
2231
2232        // GIVEN: member actors covering a generated dense or affine region.
2233        let members = region
2234            .slice()
2235            .iter()
2236            .map(|rank| (rank, member(rank)))
2237            .collect::<HashMap<_, _>>();
2238
2239        // WHEN: the region is materialized under the generated tiling policy.
2240        let domain = CastDomainId::new()
2241            .materialize(
2242                &sender.instance,
2243                proc_cast_node_mesh(region.clone(), &members)
2244                    .map_err(|error| TestCaseError::fail(error.to_string()))?,
2245                policy,
2246                Flattrs::new(),
2247            )
2248            .map_err(|error| TestCaseError::fail(error.to_string()))?;
2249
2250        // THEN: the sender destination and its subtrees are nonempty and
2251        // disjoint, exactly cover the domain, and use their natural roots.
2252        let expected_ranks = region.slice().iter().collect::<BTreeSet<_>>();
2253        let mut covered_ranks = BTreeSet::new();
2254        let root_destination = &domain.sender_hop.local_destination;
2255        prop_assert_eq!(
2256            &root_destination.actor,
2257            &members[&root_destination.base_rank_in_domain]
2258        );
2259        prop_assert!(covered_ranks.insert(root_destination.base_rank_in_domain));
2260
2261        for subtree in &domain.sender_hop.next_hops {
2262            let subtree_ranks = subtree
2263                .served_region
2264                .slice()
2265                .iter()
2266                .collect::<BTreeSet<_>>();
2267            prop_assert!(!subtree_ranks.is_empty());
2268
2269            for rank in subtree_ranks {
2270                prop_assert!(expected_ranks.contains(&rank));
2271                prop_assert!(covered_ranks.insert(rank));
2272            }
2273
2274            let root_rank = Tile::from_view(&subtree.served_region).root_rank();
2275            match &subtree.route {
2276                CastRoute::ViaCastActor(root_actor) => {
2277                    prop_assert_eq!(
2278                        root_actor.actor_addr().clone(),
2279                        cast_actor_ref_for_member(&members[&root_rank])
2280                            .actor_addr()
2281                            .clone(),
2282                    );
2283                }
2284                CastRoute::Direct(destination) => {
2285                    prop_assert_eq!(subtree.served_region.num_ranks(), 1);
2286                    prop_assert_eq!(destination.base_rank_in_domain, root_rank);
2287                    prop_assert_eq!(
2288                        destination.point_in_domain.clone(),
2289                        region.point_of_base_rank(root_rank).unwrap(),
2290                    );
2291                    prop_assert_eq!(destination.actor.clone(), members[&root_rank].clone());
2292                }
2293            }
2294        }
2295
2296        prop_assert_eq!(covered_ranks, expected_ranks);
2297        Ok(())
2298    }
2299
2300    proptest! {
2301        #![proptest_config(ProptestConfig {
2302            cases: 64,
2303            ..ProptestConfig::default()
2304        })]
2305
2306        // CA-2 (domain coverage).
2307        #[test]
2308        fn prop_materialized_subtrees_partition_the_domain(
2309            region in materialization_regions(),
2310            policy in tiling_policies(),
2311        ) {
2312            let _runtime = materialization_runtime().enter();
2313            validate_materialized_subtrees(region, policy)?;
2314        }
2315    }
2316
2317    async fn cast_and_collect_histories(
2318        test_mesh: &CastTestMesh,
2319        cast_domain: &CastDomainRef,
2320    ) -> BTreeMap<String, Vec<TestDeliveryRecord>> {
2321        let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
2322            .open_reduce_port(TestDeliveryHistoriesAccumulator);
2323        let reply_ref = reply_handle.bind();
2324
2325        cast_domain
2326            .cast(
2327                &test_mesh.client,
2328                Flattrs::new(),
2329                GetHistory {
2330                    reply_to: reply_ref,
2331                },
2332            )
2333            .unwrap();
2334
2335        match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await {
2336            Ok(Ok(histories)) => histories.by_proc,
2337            Ok(Err(e)) => panic!("history recv error: {e}"),
2338            Err(_) => panic!("timed out waiting for reduced histories"),
2339        }
2340    }
2341
2342    #[async_timed_test(timeout_secs = 30)]
2343    async fn test_create_cast_domain_installs_expected_hops() {
2344        clear_captured_domains();
2345
2346        let test_mesh = CastTestMesh::new(8);
2347        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
2348        let snapshots = test_mesh
2349            .wait_for_domain_snapshots(root_domain.domain_id(), 3)
2350            .await;
2351
2352        let region = Region::from(shape!(a = 2, b = 2, c = 2));
2353        let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
2354            ("proc_2", vec!["proc_3"]),
2355            ("proc_4", vec!["proc_5", "proc_6"]),
2356            ("proc_6", vec!["proc_7"]),
2357        ]
2358        .into_iter()
2359        .map(|(proc_name, next_hops)| {
2360            (
2361                proc_name.to_string(),
2362                next_hops
2363                    .into_iter()
2364                    .map(str::to_string)
2365                    .collect::<BTreeSet<_>>(),
2366            )
2367        })
2368        .collect();
2369
2370        assert_eq!(
2371            snapshots.keys().cloned().collect::<BTreeSet<_>>(),
2372            [2, 4, 6]
2373                .into_iter()
2374                .map(|rank| format!("proc_{rank}"))
2375                .collect()
2376        );
2377
2378        let expected_direct_hops: BTreeMap<String, BTreeSet<String>> = [
2379            ("proc_2", vec!["proc_3"]),
2380            ("proc_4", vec!["proc_5"]),
2381            ("proc_6", vec!["proc_7"]),
2382        ]
2383        .into_iter()
2384        .map(|(proc_name, next_hops)| {
2385            (
2386                proc_name.to_string(),
2387                next_hops.into_iter().map(str::to_string).collect(),
2388            )
2389        })
2390        .collect();
2391
2392        for rank in [2, 4, 6] {
2393            let proc_name = format!("proc_{rank}");
2394            let snapshot = snapshots
2395                .get(&proc_name)
2396                .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
2397
2398            assert_eq!(snapshot.base_rank_in_domain, rank);
2399            assert_eq!(
2400                snapshot.point_in_domain,
2401                region.point_of_base_rank(rank).unwrap()
2402            );
2403            assert_eq!(snapshot.local_actor_proc, proc_name);
2404            assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
2405            assert_eq!(snapshot.direct_hop_procs, expected_direct_hops[&proc_name]);
2406        }
2407    }
2408
2409    fn expected_reply_counts(proc_names: &[&str]) -> BTreeMap<String, u64> {
2410        proc_names
2411            .iter()
2412            .map(|proc_name| (proc_name.to_string(), 1))
2413            .collect()
2414    }
2415
2416    async fn cast_and_collect_reply_counts(
2417        test_mesh: &CastTestMesh,
2418        cast_domain: &CastDomainRef,
2419        payload: &str,
2420    ) -> BTreeMap<String, u64> {
2421        let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
2422            .open_reduce_port(TestReplyCountsAccumulator);
2423        let reply_ref = reply_handle.bind();
2424
2425        cast_domain
2426            .cast(
2427                &test_mesh.client,
2428                Flattrs::new(),
2429                TestRequestWithReply {
2430                    payload: payload.to_string(),
2431                    reply_to: reply_ref,
2432                },
2433            )
2434            .unwrap();
2435
2436        match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await {
2437            Ok(Ok(reply_counts)) => reply_counts.counts_by_proc,
2438            Ok(Err(e)) => panic!("reply recv error: {e}"),
2439            Err(_) => panic!("timed out waiting for reduced replies"),
2440        }
2441    }
2442
2443    // CA-1 (direct-delivery equivalence), CA-3 (setup ordering), and CA-5
2444    // (route independence).
2445    #[async_timed_test(timeout_secs = 30)]
2446    async fn test_cast_message_delivery_8_procs() {
2447        let config = hyperactor_config::global::lock();
2448        let _guard = config.override_key(
2449            hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
2450            true,
2451        );
2452
2453        let n = 8;
2454        let mut test_mesh = CastTestMesh::new(n);
2455        test_mesh.spawn_delivery_receivers();
2456        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
2457
2458        let expected_payloads = vec![
2459            "hello-0".to_string(),
2460            "hello-1".to_string(),
2461            "hello-2".to_string(),
2462        ];
2463        for payload in &expected_payloads {
2464            root_domain
2465                .cast(
2466                    &test_mesh.client,
2467                    Flattrs::new(),
2468                    TestDelivery {
2469                        payload: payload.clone(),
2470                    },
2471                )
2472                .unwrap();
2473        }
2474
2475        let expected_histories: BTreeMap<String, Vec<String>> = test_mesh
2476            .proc_names()
2477            .into_iter()
2478            .map(|proc_name| (proc_name, expected_payloads.clone()))
2479            .collect();
2480        let histories = cast_and_collect_histories(&test_mesh, &root_domain).await;
2481
2482        let observed_payloads: BTreeMap<String, Vec<String>> = histories
2483            .iter()
2484            .map(|(proc_name, history)| {
2485                (
2486                    proc_name.clone(),
2487                    history
2488                        .iter()
2489                        .map(|delivery| delivery.payload.clone())
2490                        .collect(),
2491                )
2492            })
2493            .collect();
2494        assert_eq!(observed_payloads, expected_histories);
2495
2496        let mut lineage_by_proc = BTreeMap::new();
2497        for (proc_name, history) in histories {
2498            assert_eq!(
2499                history.len(),
2500                expected_payloads.len(),
2501                "proc {proc_name} received the wrong number of deliveries"
2502            );
2503            let first_lineage = history[0].lineage.clone();
2504            for delivery in &history {
2505                assert_eq!(
2506                    delivery.lineage, first_lineage,
2507                    "proc {proc_name} changed lineage across root casts"
2508                );
2509            }
2510            lineage_by_proc.insert(proc_name, first_lineage);
2511        }
2512
2513        let expected_lineage: BTreeMap<String, Vec<ActorAddr>> = [
2514            (
2515                "proc_0".to_string(),
2516                vec![test_mesh.receiver_ids[0].clone()],
2517            ),
2518            (
2519                "proc_1".to_string(),
2520                vec![test_mesh.receiver_ids[1].clone()],
2521            ),
2522            (
2523                "proc_2".to_string(),
2524                vec![
2525                    cast_actor_ref_for_member(&test_mesh.receiver_ids[2])
2526                        .actor_addr()
2527                        .clone(),
2528                    test_mesh.receiver_ids[2].clone(),
2529                ],
2530            ),
2531            (
2532                "proc_3".to_string(),
2533                vec![
2534                    cast_actor_ref_for_member(&test_mesh.receiver_ids[2])
2535                        .actor_addr()
2536                        .clone(),
2537                    test_mesh.receiver_ids[3].clone(),
2538                ],
2539            ),
2540            (
2541                "proc_4".to_string(),
2542                vec![
2543                    cast_actor_ref_for_member(&test_mesh.receiver_ids[4])
2544                        .actor_addr()
2545                        .clone(),
2546                    test_mesh.receiver_ids[4].clone(),
2547                ],
2548            ),
2549            (
2550                "proc_5".to_string(),
2551                vec![
2552                    cast_actor_ref_for_member(&test_mesh.receiver_ids[4])
2553                        .actor_addr()
2554                        .clone(),
2555                    test_mesh.receiver_ids[5].clone(),
2556                ],
2557            ),
2558            (
2559                "proc_6".to_string(),
2560                vec![
2561                    cast_actor_ref_for_member(&test_mesh.receiver_ids[4])
2562                        .actor_addr()
2563                        .clone(),
2564                    cast_actor_ref_for_member(&test_mesh.receiver_ids[6])
2565                        .actor_addr()
2566                        .clone(),
2567                    test_mesh.receiver_ids[6].clone(),
2568                ],
2569            ),
2570            (
2571                "proc_7".to_string(),
2572                vec![
2573                    cast_actor_ref_for_member(&test_mesh.receiver_ids[4])
2574                        .actor_addr()
2575                        .clone(),
2576                    cast_actor_ref_for_member(&test_mesh.receiver_ids[6])
2577                        .actor_addr()
2578                        .clone(),
2579                    test_mesh.receiver_ids[7].clone(),
2580                ],
2581            ),
2582        ]
2583        .into_iter()
2584        .collect();
2585        assert_eq!(lineage_by_proc, expected_lineage);
2586    }
2587
2588    #[async_timed_test(timeout_secs = 30)]
2589    async fn test_root_absorption_block_partitioning_delivers_once() {
2590        // GIVEN: a 4-by-4 domain.
2591        let mut test_mesh = CastTestMesh::new(16);
2592        test_mesh.spawn_split_port_receivers();
2593
2594        // WHEN: block partitioning is applied to every dimension at the root.
2595        let root_domain = test_mesh.root_domain(shape!(hosts = 4, procs = 4).into());
2596
2597        // THEN: the sender owns rank 0 and the remaining coordinates select
2598        // subtree roots.
2599        assert_eq!(
2600            root_domain.sender_hop.local_destination.base_rank_in_domain,
2601            0
2602        );
2603        let mut subtree_root_ranks = root_domain
2604            .sender_hop
2605            .next_hops
2606            .iter()
2607            .map(|subtree| subtree.served_region.slice().offset())
2608            .collect::<Vec<_>>();
2609        subtree_root_ranks.sort_unstable();
2610        assert_eq!(subtree_root_ranks, vec![1, 2, 3, 4, 8, 12]);
2611
2612        // Every proc receives exactly one reply-producing delivery.
2613        let proc_names = test_mesh.proc_names();
2614        assert_eq!(
2615            cast_and_collect_reply_counts(&test_mesh, &root_domain, "root-heaved").await,
2616            expected_reply_counts(&proc_names.iter().map(String::as_str).collect::<Vec<_>>())
2617        );
2618    }
2619
2620    // CA-5 (route independence).
2621    #[async_timed_test(timeout_secs = 30)]
2622    async fn test_cast_preserves_supplied_operation_context_headers() {
2623        let n = 2;
2624        let mut test_mesh = CastTestMesh::new(n);
2625        test_mesh.spawn_delivery_receivers();
2626        let root_domain = test_mesh.root_domain(shape!(rank = 2).into());
2627
2628        let mut headers = Flattrs::new();
2629        headers.set(
2630            hyperactor::mailbox::headers::OPERATION_ENDPOINT,
2631            "endpoint.call()".to_string(),
2632        );
2633        root_domain
2634            .cast(
2635                &test_mesh.client,
2636                headers,
2637                TestDelivery {
2638                    payload: "with-operation-context".to_string(),
2639                },
2640            )
2641            .unwrap();
2642
2643        let histories = cast_and_collect_histories(&test_mesh, &root_domain).await;
2644        for history in histories.values() {
2645            assert_eq!(
2646                history[0].operation_endpoint.as_deref(),
2647                Some("endpoint.call()")
2648            );
2649        }
2650    }
2651
2652    // CA-6 (domain isolation), CA-7 (domain lifecycle), and CA-8 (failure
2653    // containment).
2654    #[async_timed_test(timeout_secs = 30)]
2655    async fn test_stopped_actors_return_casts_before_and_after_domain_destroy() {
2656        clear_captured_domains();
2657
2658        // GIVEN: all destination actors are stopped before one actor creates
2659        // and destroys their cast domain.
2660        let mut test_mesh = CastTestMesh::new(8);
2661        test_mesh.spawn_delivery_receivers();
2662        for (proc, receiver) in test_mesh._procs.iter().zip(&test_mesh.receiver_ids) {
2663            let mut status = proc
2664                .stop_actor(receiver.id(), "cast domain test shutdown".to_string())
2665                .expect("delivery receiver should be running");
2666            status
2667                .wait_for(|status| status.is_terminal())
2668                .await
2669                .expect("delivery receiver status should remain observable");
2670        }
2671        let region = Region::from(shape!(a = 2, b = 2, c = 2));
2672        let (failure_handle, mut failure_receiver) =
2673            context::Mailbox::mailbox(&test_mesh.client).open_port();
2674        let probe_handle = test_mesh
2675            ._client_proc
2676            .spawn_with_uid(
2677                Uid::singleton(Label::strip("cast_failure_probe")),
2678                CastFailureProbe {
2679                    members: test_mesh.domain_members(),
2680                    region: region.clone(),
2681                    failures: failure_handle.bind(),
2682                    domain: None,
2683                },
2684            )
2685            .unwrap();
2686        let probe_ref: ActorRef<CastFailureProbe> = probe_handle.bind::<CastFailureProbe>();
2687
2688        // WHEN: the probe casts through the live domain to the stopped actors.
2689        probe_ref
2690            .port::<CastStoppedDomain>()
2691            .post(&test_mesh.client, CastStoppedDomain::Cast);
2692
2693        // THEN: delivery to a stopped actor returns to the original sender.
2694        let returned = tokio::time::timeout(Duration::from_secs(5), failure_receiver.recv())
2695            .await
2696            .expect("stopped actor should return the cast to the sender")
2697            .expect("cast failure receiver should remain open");
2698        let Undeliverable::Returned(envelope) = returned else {
2699            panic!("stopped actor should return the original message envelope");
2700        };
2701        assert_eq!(
2702            envelope.headers().get(CAST_ORIGINATING_SENDER),
2703            Some(probe_ref.actor_addr().clone()),
2704        );
2705
2706        // WHEN: the probe destroys the same domain and casts again.
2707        probe_ref
2708            .port::<CastStoppedDomain>()
2709            .post(&test_mesh.client, CastStoppedDomain::DestroyAndCast);
2710
2711        // THEN: the unavailable route also returns the cast to the sender.
2712        // Direct destinations can also return failures, so wait for a return
2713        // from a destroyed CastActor route without assuming arrival order.
2714        let envelope = tokio::time::timeout(Duration::from_secs(5), async {
2715            loop {
2716                let returned = failure_receiver
2717                    .recv()
2718                    .await
2719                    .expect("cast failure receiver should remain open");
2720                if let Undeliverable::Returned(envelope) = returned
2721                    && envelope
2722                        .root_delivery_failure()
2723                        .and_then(|failure| failure.attrs.get(CAST_FAILURE_PHASE))
2724                        == Some("unknown_domain".to_string())
2725                {
2726                    break envelope;
2727                }
2728            }
2729        })
2730        .await
2731        .expect("destroyed CastActor route should return to the sender");
2732        assert_eq!(
2733            envelope
2734                .root_delivery_failure()
2735                .and_then(|failure| failure.attrs.get(CAST_FAILURE_PHASE)),
2736            Some("unknown_domain".to_string()),
2737        );
2738        assert_eq!(
2739            envelope.headers().get(CAST_ORIGINATING_SENDER),
2740            Some(probe_ref.actor_addr().clone()),
2741        );
2742
2743        // The failed domain must not prevent the shared CastActor from serving
2744        // another domain.
2745        let live_domain = test_mesh.root_domain(region);
2746        test_mesh
2747            .wait_for_any_domain_snapshot(live_domain.domain_id())
2748            .await;
2749    }
2750
2751    #[async_timed_test(timeout_secs = 30)]
2752    async fn test_stopped_actors_drop_casts_when_returns_are_disabled() {
2753        let mut test_mesh = CastTestMesh::new(8);
2754        test_mesh.spawn_delivery_receivers();
2755        for (proc, receiver) in test_mesh._procs.iter().zip(&test_mesh.receiver_ids) {
2756            let mut status = proc
2757                .stop_actor(receiver.id(), "cast domain test shutdown".to_string())
2758                .expect("delivery receiver should be running");
2759            status
2760                .wait_for(|status| status.is_terminal())
2761                .await
2762                .expect("delivery receiver status should remain observable");
2763        }
2764
2765        let region = Region::from(shape!(a = 2, b = 2, c = 2));
2766        let (failure_handle, mut failure_receiver) =
2767            context::Mailbox::mailbox(&test_mesh.client).open_port();
2768        let probe_handle = test_mesh
2769            ._client_proc
2770            .spawn_with_uid(
2771                Uid::singleton(Label::strip("cast_failure_probe")),
2772                CastFailureProbe {
2773                    members: test_mesh.domain_members(),
2774                    region,
2775                    failures: failure_handle.bind(),
2776                    domain: None,
2777                },
2778            )
2779            .unwrap();
2780        let probe_ref: ActorRef<CastFailureProbe> = probe_handle.bind::<CastFailureProbe>();
2781
2782        probe_ref
2783            .port::<CastStoppedDomain>()
2784            .post(&test_mesh.client, CastStoppedDomain::CastWithoutReturn);
2785
2786        assert!(
2787            tokio::time::timeout(Duration::from_millis(500), failure_receiver.recv())
2788                .await
2789                .is_err(),
2790            "stopped actors should not return casts when returns are disabled"
2791        );
2792    }
2793
2794    // CA-8 (failure containment).
2795    #[async_timed_test(timeout_secs = 30)]
2796    async fn test_cast_routing_error_returns_undeliverable() {
2797        // GIVEN: an installed hop receives a cast without its destination seq.
2798        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client_proc".into()).unwrap();
2799        let client = client_proc.client("client");
2800        let (failure_handle, mut failure_receiver) = context::Mailbox::mailbox(&client).open_port();
2801        let probe_handle = client_proc
2802            .spawn_with_uid(
2803                Uid::singleton(Label::strip("cast_failure_probe")),
2804                CastFailureProbe {
2805                    members: HashMap::new(),
2806                    region: Region::from(Shape::unity()),
2807                    failures: failure_handle.bind(),
2808                    domain: None,
2809                },
2810            )
2811            .unwrap();
2812        let probe_ref: ActorRef<CastFailureProbe> = probe_handle.bind::<CastFailureProbe>();
2813        let cast_proc = Proc::direct(ChannelTransport::Unix.any(), "cast_proc".into()).unwrap();
2814        let actor_instance = cast_proc.actor_instance::<CastActor>("cast").unwrap();
2815        let cx = Context::new(&actor_instance.instance, Flattrs::new());
2816        let mut cast_actor = CastActor::default();
2817        let domain_id = CastDomainId::new();
2818        let domain_region = Region::from(shape!(x = 2));
2819        cast_actor.installed_hops.insert(
2820            domain_id.clone(),
2821            CastHop {
2822                local_destination: CastDestination {
2823                    point_in_domain: domain_region.point_of_base_rank(1).unwrap(),
2824                    base_rank_in_domain: 1,
2825                    actor: ActorAddr::root(cast_proc.proc_addr().clone(), Label::strip("receiver")),
2826                },
2827                next_hops: Vec::new(),
2828                num_destinations: 1,
2829            },
2830        );
2831        let seq_region = domain_region
2832            .range("x", ndslice::Range(0, Some(1), 1))
2833            .unwrap();
2834
2835        // WHEN: the malformed cast is handled.
2836        Handler::<CastMessage>::handle(
2837            &mut cast_actor,
2838            &cx,
2839            CastMessage {
2840                cast_domain_id: domain_id,
2841                sender: probe_ref.actor_addr().clone(),
2842                session_id: client.sequencer().session_id(),
2843                seqs: ValueMesh::new(seq_region, vec![1]).unwrap(),
2844                lineage: Vec::new(),
2845                headers: Flattrs::new(),
2846                dest_port: TestDelivery::port(),
2847                data: wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&TestDelivery {
2848                    payload: "malformed".to_string(),
2849                })
2850                .unwrap(),
2851                return_undeliverable: true,
2852            },
2853        )
2854        .await
2855        .expect("routing errors must not escape the CastActor handler");
2856
2857        // THEN: the routing failure is returned to the original sender.
2858        let returned = tokio::time::timeout(Duration::from_secs(5), failure_receiver.recv())
2859            .await
2860            .expect("routing failure should return to the sender")
2861            .expect("routing failure receiver should remain open");
2862        let Undeliverable::Returned(envelope) = returned else {
2863            panic!("routing failure should return the original message envelope");
2864        };
2865        assert_eq!(
2866            envelope
2867                .root_delivery_failure()
2868                .and_then(|failure| failure.attrs.get(CAST_FAILURE_PHASE)),
2869            Some("route".to_string()),
2870        );
2871    }
2872
2873    // CA-8 (failure containment).
2874    #[async_timed_test(timeout_secs = 30)]
2875    async fn test_delivery_failure_report_does_not_fail_cast_actor() {
2876        // GIVEN: a delivery failure report without the original message.
2877        let proc = Proc::direct(ChannelTransport::Unix.any(), "cast_proc".into()).unwrap();
2878        let actor_instance = proc.actor_instance::<CastActor>("cast").unwrap();
2879        let mut cast_actor = CastActor::default();
2880        let destination = actor_instance
2881            .instance
2882            .self_addr()
2883            .port_addr(Port::handler::<CastMessage>());
2884        let report = DeliveryFailureReport::new(
2885            actor_instance.instance.self_addr().clone(),
2886            EndpointLocation::Port(destination.clone()),
2887            Some(CastMessage::typename().to_string()),
2888            DeliveryFailure::new(UndeliverableReason::Transport(TransportFailure::new(
2889                destination,
2890                TransportFailureReason::NoRoute,
2891            ))),
2892        );
2893
2894        // WHEN: the report is handled.
2895        let result = cast_actor
2896            .return_delivery_failure_to_origin(
2897                &actor_instance.instance,
2898                Undeliverable::Report(report),
2899            )
2900            .await;
2901
2902        // THEN: report-only failures do not fail the shared CastActor.
2903        result.expect("delivery failure reports must not fail the CastActor");
2904    }
2905
2906    // CA-7 (domain lifecycle).
2907    #[async_timed_test(timeout_secs = 30)]
2908    async fn test_destroy_domain_propagates_to_all_hops() {
2909        clear_captured_domains();
2910
2911        let test_mesh = CastTestMesh::new(8);
2912        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
2913        let domain_id = root_domain.domain_id().clone();
2914        test_mesh.wait_for_domain_snapshots(&domain_id, 3).await;
2915
2916        root_domain.destroy(&test_mesh.client);
2917
2918        let destroyed = test_mesh
2919            .wait_for_destroyed_domain_snapshots(&domain_id, 3)
2920            .await;
2921        assert_eq!(
2922            destroyed,
2923            [2, 4, 6]
2924                .into_iter()
2925                .map(|rank| format!("proc_{rank}"))
2926                .collect()
2927        );
2928    }
2929
2930    #[async_timed_test(timeout_secs = 30)]
2931    async fn test_cast_message_delivery_slice() {
2932        let config = hyperactor_config::global::lock();
2933        let _guard = config.override_key(
2934            hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
2935            true,
2936        );
2937
2938        let n = 8;
2939        let mut test_mesh = CastTestMesh::new(n);
2940        test_mesh.spawn_delivery_receivers();
2941
2942        let slice_region = Region::from(shape!(a = 2, b = 2, c = 2))
2943            .range("a", ndslice::Range(1, Some(2), 1))
2944            .unwrap();
2945        let slice_mesh = test_mesh.sliced(slice_region.clone());
2946        let slice_domain =
2947            slice_mesh.root_domain_with_policy(slice_region, TilingPolicy::BlockPartitioning);
2948
2949        let expected_payloads = vec![
2950            "hello-0".to_string(),
2951            "hello-1".to_string(),
2952            "hello-2".to_string(),
2953        ];
2954        for payload in &expected_payloads {
2955            slice_domain
2956                .cast(
2957                    &test_mesh.client,
2958                    Flattrs::new(),
2959                    TestDelivery {
2960                        payload: payload.clone(),
2961                    },
2962                )
2963                .unwrap();
2964        }
2965
2966        let expected_histories: BTreeMap<String, Vec<String>> = [
2967            "proc_4".to_string(),
2968            "proc_5".to_string(),
2969            "proc_6".to_string(),
2970            "proc_7".to_string(),
2971        ]
2972        .into_iter()
2973        .map(|proc_name| (proc_name, expected_payloads.clone()))
2974        .collect();
2975        let histories = cast_and_collect_histories(&slice_mesh, &slice_domain).await;
2976
2977        let observed_payloads: BTreeMap<String, Vec<String>> = histories
2978            .iter()
2979            .map(|(proc_name, history)| {
2980                (
2981                    proc_name.clone(),
2982                    history
2983                        .iter()
2984                        .map(|delivery| delivery.payload.clone())
2985                        .collect(),
2986                )
2987            })
2988            .collect();
2989        assert_eq!(observed_payloads, expected_histories);
2990
2991        let mut lineage_by_proc = BTreeMap::new();
2992        for (proc_name, history) in histories {
2993            assert_eq!(
2994                history.len(),
2995                expected_payloads.len(),
2996                "proc {proc_name} received the wrong number of deliveries"
2997            );
2998            let first_lineage = history[0].lineage.clone();
2999            for delivery in &history {
3000                assert_eq!(
3001                    delivery.lineage, first_lineage,
3002                    "proc {proc_name} changed lineage across slice casts"
3003                );
3004            }
3005            lineage_by_proc.insert(proc_name, first_lineage);
3006        }
3007
3008        let expected_lineage: BTreeMap<String, Vec<ActorAddr>> = [
3009            (
3010                "proc_4".to_string(),
3011                vec![test_mesh.receiver_ids[4].clone()],
3012            ),
3013            (
3014                "proc_5".to_string(),
3015                vec![test_mesh.receiver_ids[5].clone()],
3016            ),
3017            (
3018                "proc_6".to_string(),
3019                vec![
3020                    cast_actor_ref_for_member(&test_mesh.receiver_ids[6])
3021                        .actor_addr()
3022                        .clone(),
3023                    test_mesh.receiver_ids[6].clone(),
3024                ],
3025            ),
3026            (
3027                "proc_7".to_string(),
3028                vec![
3029                    cast_actor_ref_for_member(&test_mesh.receiver_ids[6])
3030                        .actor_addr()
3031                        .clone(),
3032                    test_mesh.receiver_ids[7].clone(),
3033                ],
3034            ),
3035        ]
3036        .into_iter()
3037        .collect();
3038        assert_eq!(lineage_by_proc, expected_lineage);
3039    }
3040
3041    // CA-4 (destination ordering) and CA-6 (domain isolation).
3042    #[async_timed_test(timeout_secs = 30)]
3043    async fn test_sender_sequenced_casts_are_observed_in_projection_order() {
3044        let config = hyperactor_config::global::lock();
3045        let _guard = config.override_key(
3046            hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
3047            true,
3048        );
3049
3050        let n = 4;
3051        let mut test_mesh = CastTestMesh::new(n);
3052        test_mesh.spawn_delivery_receivers();
3053        let root = test_mesh.root_domain(shape!(rank = 4).into());
3054
3055        let rank_0_to_2_region = Region::from(shape!(rank = 4))
3056            .range("rank", ndslice::Range(0, Some(2), 1))
3057            .unwrap();
3058        let rank_0_to_2 = test_mesh
3059            .sliced(rank_0_to_2_region.clone())
3060            .root_domain_with_policy(rank_0_to_2_region, TilingPolicy::BlockPartitioning);
3061
3062        let rank_1_to_3_region = Region::from(shape!(rank = 4))
3063            .range("rank", ndslice::Range(1, Some(3), 1))
3064            .unwrap();
3065        let rank_1_to_3 = test_mesh
3066            .sliced(rank_1_to_3_region.clone())
3067            .root_domain_with_policy(rank_1_to_3_region, TilingPolicy::BlockPartitioning);
3068
3069        let rank_2_to_4_region = Region::from(shape!(rank = 4))
3070            .range("rank", ndslice::Range(2, Some(4), 1))
3071            .unwrap();
3072        let rank_2_to_4 = test_mesh
3073            .sliced(rank_2_to_4_region.clone())
3074            .root_domain_with_policy(rank_2_to_4_region, TilingPolicy::BlockPartitioning);
3075
3076        let casts = [
3077            (
3078                &root,
3079                "root-0",
3080                vec!["proc_0", "proc_1", "proc_2", "proc_3"],
3081            ),
3082            (&rank_0_to_2, "rank_0_to_2-1", vec!["proc_0", "proc_1"]),
3083            (&rank_1_to_3, "rank_1_to_3-2", vec!["proc_1", "proc_2"]),
3084            (&rank_2_to_4, "rank_2_to_4-3", vec!["proc_2", "proc_3"]),
3085            (
3086                &root,
3087                "root-4",
3088                vec!["proc_0", "proc_1", "proc_2", "proc_3"],
3089            ),
3090        ];
3091
3092        let mut expected_histories: BTreeMap<String, Vec<String>> = test_mesh
3093            .proc_names()
3094            .into_iter()
3095            .map(|proc_name| (proc_name, Vec::new()))
3096            .collect();
3097
3098        for (domain, payload, expected_receivers) in &casts {
3099            domain
3100                .cast(
3101                    &test_mesh.client,
3102                    Flattrs::new(),
3103                    TestDelivery {
3104                        payload: payload.to_string(),
3105                    },
3106                )
3107                .unwrap();
3108
3109            for proc_name in expected_receivers {
3110                expected_histories
3111                    .get_mut(*proc_name)
3112                    .unwrap()
3113                    .push(payload.to_string());
3114            }
3115        }
3116
3117        let observed_histories: BTreeMap<String, Vec<String>> =
3118            cast_and_collect_histories(&test_mesh, &root)
3119                .await
3120                .into_iter()
3121                .map(|(proc_name, history)| {
3122                    (
3123                        proc_name,
3124                        history
3125                            .into_iter()
3126                            .map(|delivery| delivery.payload)
3127                            .collect(),
3128                    )
3129                })
3130                .collect();
3131
3132        assert_eq!(observed_histories, expected_histories);
3133    }
3134
3135    // -- Port splitting test infrastructure --
3136    //
3137    // The `split_ports` function records (original, split) edges into a
3138    // global vec under `#[cfg(test)]`.  After a cast we reconstruct
3139    // the split-port tree and verify it mirrors the cast tree.
3140
3141    #[derive(Debug, Clone)]
3142    struct SplitEdge {
3143        from: PortAddr,
3144        to: PortAddr,
3145        is_leaf: bool,
3146    }
3147
3148    struct SplitPortRecording {
3149        root: PortAddr,
3150        edges: Vec<SplitEdge>,
3151    }
3152
3153    struct SplitPortRecordingGuard;
3154
3155    static SPLIT_PORT_TREE: OnceLock<Mutex<Option<SplitPortRecording>>> = OnceLock::new();
3156
3157    fn split_port_tree() -> &'static Mutex<Option<SplitPortRecording>> {
3158        SPLIT_PORT_TREE.get_or_init(|| Mutex::new(None))
3159    }
3160
3161    pub(crate) fn collect_split_port(original: &PortAddr, split: &PortAddr) {
3162        let mut guard = split_port_tree().lock().unwrap();
3163        let Some(recording) = guard.as_mut() else {
3164            return;
3165        };
3166        if original != &recording.root && !recording.edges.iter().any(|edge| &edge.to == original) {
3167            return;
3168        }
3169        recording.edges.push(SplitEdge {
3170            from: original.clone(),
3171            to: split.clone(),
3172            is_leaf: original != &recording.root,
3173        });
3174    }
3175
3176    fn record_split_port_tree(root: PortAddr) -> SplitPortRecordingGuard {
3177        *split_port_tree().lock().unwrap() = Some(SplitPortRecording {
3178            root,
3179            edges: Vec::new(),
3180        });
3181        SplitPortRecordingGuard
3182    }
3183
3184    impl SplitPortRecordingGuard {
3185        fn edges(&self) -> Vec<SplitEdge> {
3186            split_port_tree()
3187                .lock()
3188                .unwrap()
3189                .as_ref()
3190                .map(|recording| recording.edges.clone())
3191                .unwrap_or_default()
3192        }
3193    }
3194
3195    impl Drop for SplitPortRecordingGuard {
3196        fn drop(&mut self) {
3197            *split_port_tree().lock().unwrap() = None;
3198        }
3199    }
3200
3201    /// Reconstruct split-port paths from leaf to root.
3202    /// Returns a map from leaf `PortAddr` to the root-first split path.
3203    fn build_split_paths(edges: &[SplitEdge]) -> BTreeMap<PortAddr, Vec<PortAddr>> {
3204        let mut child_to_parent: HashMap<PortAddr, PortAddr> = HashMap::new();
3205        let mut leaves = Vec::new();
3206
3207        for edge in edges {
3208            child_to_parent.insert(edge.to.clone(), edge.from.clone());
3209            if edge.is_leaf {
3210                leaves.push(edge.to.clone());
3211            }
3212        }
3213
3214        let mut result = BTreeMap::new();
3215        for leaf in leaves {
3216            let mut path = vec![leaf.clone()];
3217            let mut current = leaf.clone();
3218            while let Some(parent) = child_to_parent.get(&current) {
3219                path.push(parent.clone());
3220                current = parent.clone();
3221            }
3222            path.reverse();
3223            result.insert(leaf, path);
3224        }
3225        result
3226    }
3227
3228    /// Extract the proc name (rank) from each `PortAddr` in a split-port path,
3229    /// stripping leading sender entries before the path enters the domain.
3230    fn split_path_ranks(
3231        paths: &BTreeMap<PortAddr, Vec<PortAddr>>,
3232        rank_lookup: &HashMap<String, usize>,
3233    ) -> BTreeMap<usize, Vec<usize>> {
3234        paths
3235            .iter()
3236            .map(|(leaf, path)| {
3237                let ranks: Vec<usize> = path
3238                    .iter()
3239                    .map(|port| port.actor_addr().proc_addr().log_name().to_string())
3240                    .skip_while(|proc_name| !rank_lookup.contains_key(proc_name))
3241                    .map(|proc_name| {
3242                        *rank_lookup
3243                            .get(&proc_name)
3244                            .unwrap_or_else(|| panic!("unknown proc {proc_name} in split path"))
3245                    })
3246                    .collect();
3247                let leaf_proc = leaf.actor_addr().proc_addr().log_name().to_string();
3248                let leaf_rank = rank_lookup[&leaf_proc];
3249                (leaf_rank, ranks)
3250            })
3251            .collect()
3252    }
3253
3254    /// A reply type for the port splitting test.
3255    #[derive(
3256        Debug,
3257        Clone,
3258        Default,
3259        PartialEq,
3260        Eq,
3261        Serialize,
3262        Deserialize,
3263        typeuri::Named
3264    )]
3265    struct TestReplyCounts {
3266        counts_by_proc: BTreeMap<String, u64>,
3267    }
3268    wirevalue::register_type!(TestReplyCounts);
3269
3270    impl TestReplyCounts {
3271        fn single(proc_name: String) -> Self {
3272            Self {
3273                counts_by_proc: [(proc_name, 1)].into_iter().collect(),
3274            }
3275        }
3276
3277        fn merge(&mut self, other: Self) {
3278            for (proc_name, count) in other.counts_by_proc {
3279                *self.counts_by_proc.entry(proc_name).or_default() += count;
3280            }
3281        }
3282    }
3283
3284    #[derive(typeuri::Named)]
3285    struct TestReplyCountsReducer;
3286
3287    impl hyperactor::accum::CommReducer for TestReplyCountsReducer {
3288        type Update = TestReplyCounts;
3289
3290        fn reduce(
3291            &self,
3292            mut left: Self::Update,
3293            right: Self::Update,
3294        ) -> anyhow::Result<Self::Update> {
3295            left.merge(right);
3296            Ok(left)
3297        }
3298    }
3299
3300    inventory::submit! {
3301        hyperactor::accum::ReducerFactory {
3302            typehash_f: <TestReplyCountsReducer as Named>::typehash,
3303            builder_f: |_| Ok(Box::new(TestReplyCountsReducer)),
3304        }
3305    }
3306
3307    struct TestReplyCountsAccumulator;
3308
3309    impl hyperactor::accum::Accumulator for TestReplyCountsAccumulator {
3310        type State = TestReplyCounts;
3311        type Update = TestReplyCounts;
3312
3313        fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
3314            state.merge(update);
3315            Ok(())
3316        }
3317
3318        fn reducer_spec(&self) -> Option<hyperactor::accum::ReducerSpec> {
3319            Some(hyperactor::accum::ReducerSpec {
3320                typehash: <TestReplyCountsReducer as Named>::typehash(),
3321                builder_params: None,
3322            })
3323        }
3324    }
3325
3326    /// A message with a reply port for testing port splitting.
3327    #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
3328    struct TestRequestWithReply {
3329        payload: String,
3330        reply_to: hyperactor::OncePortRef<TestReplyCounts>,
3331    }
3332    wirevalue::register_type!(TestRequestWithReply);
3333
3334    #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
3335    struct TestIdleFlushRequestWithReply {
3336        delayed_proc: String,
3337        delay: Duration,
3338        replies_per_destination: usize,
3339        reply_to: IdleFlushPortRef<TestReplyCounts>,
3340    }
3341    wirevalue::register_type!(TestIdleFlushRequestWithReply);
3342
3343    /// An actor that receives a cast message and sends a reply back
3344    /// through the (potentially split) reply port.
3345    #[derive(Debug, Default)]
3346    #[hyperactor::export(
3347        handlers = [TestRequestWithReply, TestIdleFlushRequestWithReply],
3348    )]
3349    struct SplitPortReceiver;
3350
3351    #[async_trait]
3352    impl Actor for SplitPortReceiver {
3353        async fn init(&mut self, _this: &Instance<Self>) -> Result<(), anyhow::Error> {
3354            Ok(())
3355        }
3356    }
3357
3358    #[async_trait]
3359    impl Handler<TestRequestWithReply> for SplitPortReceiver {
3360        async fn handle(
3361            &mut self,
3362            cx: &Context<Self>,
3363            msg: TestRequestWithReply,
3364        ) -> Result<(), anyhow::Error> {
3365            msg.reply_to.post(
3366                cx,
3367                TestReplyCounts::single(cx.self_addr().proc_addr().log_name().to_string()),
3368            );
3369            Ok(())
3370        }
3371    }
3372
3373    #[async_trait]
3374    impl Handler<TestIdleFlushRequestWithReply> for SplitPortReceiver {
3375        async fn handle(
3376            &mut self,
3377            cx: &Context<Self>,
3378            msg: TestIdleFlushRequestWithReply,
3379        ) -> Result<(), anyhow::Error> {
3380            let proc_name = cx.self_addr().proc_addr().log_name().to_string();
3381
3382            if proc_name == msg.delayed_proc {
3383                tokio::time::sleep(msg.delay).await;
3384            }
3385
3386            for _ in 0..msg.replies_per_destination {
3387                msg.reply_to
3388                    .post(cx, TestReplyCounts::single(proc_name.clone()));
3389            }
3390
3391            Ok(())
3392        }
3393    }
3394
3395    /// CA-5 (route independence): verify that port splitting rewrites reply
3396    /// ports to mirror the cast tree and returns replies to the original sender.
3397    #[async_timed_test(timeout_secs = 30)]
3398    async fn test_port_splitting_replies_and_tree() {
3399        let n = 8;
3400        let mut test_mesh = CastTestMesh::new(n);
3401        test_mesh.spawn_split_port_receivers();
3402        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
3403
3404        let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
3405            .open_reduce_port(TestReplyCountsAccumulator);
3406        let reply_ref = reply_handle.bind();
3407        let split_port_recording = record_split_port_tree(reply_ref.port_addr().clone());
3408
3409        // Cast a message with the reply port.
3410        root_domain
3411            .cast(
3412                &test_mesh.client,
3413                Flattrs::new(),
3414                TestRequestWithReply {
3415                    payload: "split_test".to_string(),
3416                    reply_to: reply_ref,
3417                },
3418            )
3419            .unwrap();
3420
3421        let reply_counts = match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await
3422        {
3423            Ok(Ok(reply_counts)) => reply_counts.counts_by_proc,
3424            Ok(Err(e)) => panic!("reply recv error: {e}"),
3425            Err(_) => panic!("timed out waiting for reduced replies"),
3426        };
3427
3428        // Every proc should have sent exactly one reply. Since counts are
3429        // preserved, duplicate deliveries show up as counts greater than one.
3430        let expected_counts: BTreeMap<String, u64> =
3431            (0..n).map(|i| (format!("proc_{i}"), 1)).collect();
3432        assert_eq!(reply_counts, expected_counts);
3433
3434        // Verify the split-port tree mirrors the CastActor forwarding tree.
3435        let edges = split_port_recording.edges();
3436        let paths = build_split_paths(&edges);
3437
3438        let rank_lookup: HashMap<String, usize> =
3439            (0..n).map(|i| (format!("proc_{i}"), i)).collect();
3440        let rank_paths = split_path_ranks(&paths, &rank_lookup);
3441
3442        let expected: BTreeMap<usize, Vec<usize>> = [(2, vec![2]), (4, vec![4]), (6, vec![4, 6])]
3443            .into_iter()
3444            .collect();
3445
3446        assert_eq!(
3447            rank_paths, expected,
3448            "split-port tree doesn't mirror cast tree"
3449        );
3450    }
3451
3452    #[async_timed_test(timeout_secs = 30)]
3453    async fn test_idle_flush_split_reducer_preserves_late_reply() {
3454        let n = 8;
3455
3456        let mut test_mesh = CastTestMesh::new(n);
3457
3458        test_mesh.spawn_split_port_receivers();
3459        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
3460
3461        let (reply_handle, mut reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
3462            .open_accum_port(TestReplyCountsAccumulator);
3463
3464        let reply_to = reply_handle.bind().into_idle_flush(IdleFlushReducerOpts {
3465            idle_timeout: Duration::from_millis(50),
3466            abandon_timeout: Duration::from_secs(30),
3467            expected_updates_per_destination: NonZeroUsize::new(2).expect("2 is non-zero").into(),
3468        });
3469        let split_port_recording = record_split_port_tree(reply_to.port_addr().clone());
3470
3471        root_domain
3472            .cast(
3473                &test_mesh.client,
3474                Flattrs::new(),
3475                TestIdleFlushRequestWithReply {
3476                    delayed_proc: "proc_7".to_string(),
3477                    delay: Duration::from_millis(1500),
3478                    replies_per_destination: 2,
3479                    reply_to,
3480                },
3481            )
3482            .unwrap();
3483
3484        let partial = tokio::time::timeout(Duration::from_secs(1), async {
3485            loop {
3486                let reply = reply_rx.recv().await.unwrap();
3487                if reply.counts_by_proc.len() == n - 1 {
3488                    break reply;
3489                }
3490            }
3491        })
3492        .await
3493        .expect("responsive subtrees should flush before the delayed reply");
3494
3495        assert_eq!(
3496            partial.counts_by_proc,
3497            [
3498                "proc_0", "proc_1", "proc_2", "proc_3", "proc_4", "proc_5", "proc_6"
3499            ]
3500            .into_iter()
3501            .map(|proc_name| (proc_name.to_string(), 2))
3502            .collect()
3503        );
3504
3505        let complete = tokio::time::timeout(Duration::from_secs(2), async {
3506            loop {
3507                let reply = reply_rx.recv().await.unwrap();
3508                if reply.counts_by_proc.len() == n {
3509                    break reply;
3510                }
3511            }
3512        })
3513        .await
3514        .expect("the late reply should complete the idle-flush reduction");
3515
3516        assert_eq!(
3517            complete.counts_by_proc,
3518            (0..n).map(|rank| (format!("proc_{rank}"), 2)).collect()
3519        );
3520
3521        let edges = split_port_recording.edges();
3522        let paths = build_split_paths(&edges);
3523
3524        let rank_lookup: HashMap<String, usize> =
3525            (0..n).map(|i| (format!("proc_{i}"), i)).collect();
3526        let rank_paths = split_path_ranks(&paths, &rank_lookup);
3527
3528        let expected: BTreeMap<usize, Vec<usize>> = [(2, vec![2]), (4, vec![4]), (6, vec![4, 6])]
3529            .into_iter()
3530            .collect();
3531
3532        assert_eq!(
3533            rank_paths, expected,
3534            "idle-flush split-port tree doesn't mirror cast tree"
3535        );
3536    }
3537
3538    // Tests that a serialized BoundedFanout policy drives root-heaved
3539    // cast-domain setup end to end. Terminal hops are direct destinations
3540    // rather than installed CastActors.
3541    #[async_timed_test(timeout_secs = 30)]
3542    async fn test_root_heaved_bounded_fanout_installs_expected_hops() {
3543        clear_captured_domains();
3544
3545        // GIVEN: an 8-rank domain with fanout 2.
3546        let test_mesh = CastTestMesh::new(8);
3547
3548        // WHEN: the domain is materialized.
3549        let root_domain = test_mesh.root_domain_with_policy(
3550            shape!(a = 8).into(),
3551            TilingPolicy::BoundedFanout {
3552                fanout: NonZeroUsize::new(2).unwrap(),
3553            },
3554        );
3555        let snapshots = test_mesh
3556            .wait_for_domain_snapshots(root_domain.domain_id(), 3)
3557            .await;
3558
3559        // THEN: the sender owns the root destination and the two immediate
3560        // policy children are subtrees.
3561        assert_eq!(
3562            root_domain.sender_hop.local_destination.base_rank_in_domain,
3563            0
3564        );
3565        assert_eq!(
3566            root_domain
3567                .sender_hop
3568                .next_hops
3569                .iter()
3570                .map(|subtree| subtree.served_region.slice().offset())
3571                .collect::<BTreeSet<_>>(),
3572            [1, 5].into_iter().collect()
3573        );
3574
3575        let region = Region::from(shape!(a = 8));
3576        // Forwarded subtree roots own their recursive subtrees. The sender
3577        // delivers proc_0 directly.
3578        let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
3579            ("proc_1", vec!["proc_2", "proc_4"]),
3580            ("proc_2", vec!["proc_3"]),
3581            ("proc_5", vec!["proc_6", "proc_7"]),
3582        ]
3583        .into_iter()
3584        .map(|(proc_name, next_hops)| {
3585            (
3586                proc_name.to_string(),
3587                next_hops
3588                    .into_iter()
3589                    .map(str::to_string)
3590                    .collect::<BTreeSet<_>>(),
3591            )
3592        })
3593        .collect();
3594
3595        assert_eq!(
3596            snapshots.keys().cloned().collect::<BTreeSet<_>>(),
3597            [1, 2, 5]
3598                .into_iter()
3599                .map(|rank| format!("proc_{rank}"))
3600                .collect()
3601        );
3602
3603        let expected_direct_hops: BTreeMap<String, BTreeSet<String>> = [
3604            ("proc_1", vec!["proc_4"]),
3605            ("proc_2", vec!["proc_3"]),
3606            ("proc_5", vec!["proc_6", "proc_7"]),
3607        ]
3608        .into_iter()
3609        .map(|(proc_name, next_hops)| {
3610            (
3611                proc_name.to_string(),
3612                next_hops.into_iter().map(str::to_string).collect(),
3613            )
3614        })
3615        .collect();
3616
3617        for rank in [1, 2, 5] {
3618            let proc_name = format!("proc_{rank}");
3619            let snapshot = snapshots
3620                .get(&proc_name)
3621                .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
3622
3623            assert_eq!(snapshot.base_rank_in_domain, rank);
3624            assert_eq!(
3625                snapshot.point_in_domain,
3626                region.point_of_base_rank(rank).unwrap()
3627            );
3628            assert_eq!(snapshot.local_actor_proc, proc_name);
3629            assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
3630            assert_eq!(snapshot.direct_hop_procs, expected_direct_hops[&proc_name]);
3631        }
3632    }
3633
3634    // Tests that a serialized Bisection policy drives root-heaved cast-domain
3635    // setup end to end, with terminal hops delivered directly.
3636    #[async_timed_test(timeout_secs = 30)]
3637    async fn test_root_heaved_bisection_installs_expected_hops() {
3638        clear_captured_domains();
3639
3640        // GIVEN: an 8-rank domain with bisection tiling.
3641        let test_mesh = CastTestMesh::new(8);
3642
3643        // WHEN: the domain is materialized.
3644        let root_domain =
3645            test_mesh.root_domain_with_policy(shape!(a = 8).into(), TilingPolicy::Bisection);
3646        let snapshots = test_mesh
3647            .wait_for_domain_snapshots(root_domain.domain_id(), 3)
3648            .await;
3649
3650        // THEN: the sender owns the root destination and the three immediate
3651        // policy children are subtrees.
3652        assert_eq!(
3653            root_domain.sender_hop.local_destination.base_rank_in_domain,
3654            0
3655        );
3656        assert_eq!(
3657            root_domain
3658                .sender_hop
3659                .next_hops
3660                .iter()
3661                .map(|subtree| subtree.served_region.slice().offset())
3662                .collect::<BTreeSet<_>>(),
3663            [1, 2, 4].into_iter().collect()
3664        );
3665
3666        let region = Region::from(shape!(a = 8));
3667        // Forwarded subtree roots own their recursive subtrees. The sender
3668        // delivers proc_0 directly, and proc_1 remains a direct child.
3669        let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
3670            ("proc_2", vec!["proc_3"]),
3671            ("proc_4", vec!["proc_5", "proc_6"]),
3672            ("proc_6", vec!["proc_7"]),
3673        ]
3674        .into_iter()
3675        .map(|(proc_name, next_hops)| {
3676            (
3677                proc_name.to_string(),
3678                next_hops
3679                    .into_iter()
3680                    .map(str::to_string)
3681                    .collect::<BTreeSet<_>>(),
3682            )
3683        })
3684        .collect();
3685
3686        assert_eq!(
3687            snapshots.keys().cloned().collect::<BTreeSet<_>>(),
3688            [2, 4, 6]
3689                .into_iter()
3690                .map(|rank| format!("proc_{rank}"))
3691                .collect()
3692        );
3693
3694        let expected_direct_hops: BTreeMap<String, BTreeSet<String>> = [
3695            ("proc_2", vec!["proc_3"]),
3696            ("proc_4", vec!["proc_5"]),
3697            ("proc_6", vec!["proc_7"]),
3698        ]
3699        .into_iter()
3700        .map(|(proc_name, next_hops)| {
3701            (
3702                proc_name.to_string(),
3703                next_hops.into_iter().map(str::to_string).collect(),
3704            )
3705        })
3706        .collect();
3707
3708        for rank in [2, 4, 6] {
3709            let proc_name = format!("proc_{rank}");
3710            let snapshot = snapshots
3711                .get(&proc_name)
3712                .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
3713
3714            assert_eq!(snapshot.base_rank_in_domain, rank);
3715            assert_eq!(
3716                snapshot.point_in_domain,
3717                region.point_of_base_rank(rank).unwrap()
3718            );
3719            assert_eq!(snapshot.local_actor_proc, proc_name);
3720            assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
3721            assert_eq!(snapshot.direct_hop_procs, expected_direct_hops[&proc_name]);
3722        }
3723    }
3724}