hyperactor_mesh/global_context.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//! Process-global context, root client actor, and supervision bridge.
10//!
11//! This module provides the Rust equivalent of Python's `context()`,
12//! `this_host()`, and `this_proc()`. A singleton [`Host`] is lazily
13//! created with the [`GlobalClientActor`] on its `local_proc`:
14//!
15//! ```rust,ignore
16//! let cx = context().await;
17//! cx.actor_instance // c.f. Python: context().actor_instance
18//! this_host().await // c.f. Python: this_host()
19//! this_proc().await // c.f Python: this_proc()
20//! ```
21//!
22//! ## Undeliverables → supervision
23//!
24//! When the runtime detects that a message cannot be delivered, it
25//! produces an [`Undeliverable<MessageEnvelope>`]. The global root
26//! client observes these failures, converts them into
27//! [`ActorSupervisionEvent`]s, and forwards them to the currently
28//! active mesh supervision sink.
29//!
30//! **GC-1 (undeliverable routing):** Any
31//! `Undeliverable<MessageEnvelope>` observed by the global root
32//! client must be reported as an [`ActorSupervisionEvent`] to the
33//! active `ProcMesh`, and handling that failure must never crash the
34//! global client. The root client acts as a monitor, not a
35//! participant: routing failures are treated as signals to be
36//! reported, not fatal errors.
37//!
38//! ## Multiple ProcMeshes
39//!
40//! A process may allocate more than one `ProcMesh` (e.g.
41//! internal/controller meshes plus an application mesh). The root
42//! client is a process-wide singleton, so its supervision sink is
43//! also process-global.
44//!
45//! The active mesh is defined using **last-sink-wins** semantics:
46//! each newly allocated `ProcMesh` installs its sink, overriding the
47//! previous one.
48//!
49//! If no sink has been installed yet (early/late binding),
50//! undeliverables are logged and dropped, preserving forward progress
51//! until a mesh becomes available.
52
53use std::sync::OnceLock;
54use std::sync::RwLock;
55
56use async_trait::async_trait;
57use hyperactor::Actor;
58use hyperactor::ActorHandle;
59use hyperactor::Context;
60use hyperactor::Endpoint as _;
61use hyperactor::Handler;
62use hyperactor::Instance;
63use hyperactor::PortRef;
64use hyperactor::actor::ActorError;
65use hyperactor::actor::ActorErrorKind;
66use hyperactor::actor::ActorStatus;
67use hyperactor::actor::Signal;
68use hyperactor::id::Label;
69use hyperactor::id::Uid;
70use hyperactor::mailbox::DeliveryFailure;
71use hyperactor::mailbox::MessageEnvelope;
72use hyperactor::mailbox::TransportFailure;
73use hyperactor::mailbox::TransportFailureReason;
74use hyperactor::mailbox::Undeliverable;
75use hyperactor::mailbox::UndeliverableReason;
76use hyperactor::proc::ActorWorkReceiver;
77use hyperactor::proc::Proc;
78use hyperactor::supervision::ActorSupervisionEvent;
79use hyperactor_cast::cast_actor::CAST_ACTOR_NAME;
80use tokio::sync::mpsc;
81use tokio::task::JoinHandle;
82
83use crate::HostMeshRef;
84use crate::host::Host;
85use crate::host::LocalProcManager;
86use crate::host_mesh::host_agent::GetLocalProcClient;
87use crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME;
88use crate::host_mesh::host_agent::HostAgent;
89use crate::host_mesh::host_agent::ProcManagerSpawnFn;
90use crate::mesh_id::HostMeshId;
91use crate::mesh_id::ProcMeshId;
92use crate::proc_agent::GetProcClient;
93use crate::proc_agent::ProcAgent;
94use crate::proc_mesh::ProcMeshRef;
95use crate::proc_mesh::ProcRef;
96use crate::supervision::MeshFailure;
97use crate::transport::default_bind_spec;
98
99/// Single, process-wide supervision sink storage.
100///
101/// Routes undeliverables observed by the process-global root client
102/// (c.f. [`context()`]) to the *currently active* `ProcMesh`'s
103/// agent. Newer meshes override older ones ("last sink wins").
104///
105/// Uses `PortRef` (not `PortHandle`) because the sink target
106/// (`ProcAgent`) runs in a remote worker process.
107static GLOBAL_SUPERVISION_SINK: OnceLock<RwLock<Option<PortRef<ActorSupervisionEvent>>>> =
108 OnceLock::new();
109
110/// Returns the lazily-initialized container that holds the current
111/// process-global supervision sink.
112fn sink_cell() -> &'static RwLock<Option<PortRef<ActorSupervisionEvent>>> {
113 GLOBAL_SUPERVISION_SINK.get_or_init(|| RwLock::new(None))
114}
115
116/// Install (or replace) the process-global supervision sink used by
117/// the [`context()`] undeliverable → supervision bridge.
118///
119/// This uses **last-sink-wins** semantics: if multiple `ProcMesh`
120/// instances are created in the same process (e.g. controller meshes
121/// plus an application mesh), the most recently installed sink
122/// becomes the active destination for forwarded
123/// [`ActorSupervisionEvent`]s.
124///
125/// Returns the previously installed sink, if any, to allow callers to
126/// log/inspect overrides.
127///
128/// Note: the sink is a [`PortRef`] (not a `PortHandle`) because the
129/// destination [`ProcAgent`] may live in a different
130/// process/rank.
131pub(crate) fn set_global_supervision_sink(
132 sink: PortRef<ActorSupervisionEvent>,
133) -> Option<PortRef<ActorSupervisionEvent>> {
134 let cell = sink_cell();
135 let mut guard = cell.write().unwrap();
136 let prev = guard.take();
137 *guard = Some(sink);
138 prev
139}
140
141/// Get the current process-global supervision sink used by the
142/// [`context()`] undeliverable → supervision bridge.
143///
144/// Returns `None` until some mesh creation path installs a sink
145/// (early/late binding). Callers should treat this as "no active mesh
146/// yet": log and drop undeliverables rather than crashing the global
147/// root client.
148///
149/// Cloning a [`PortRef`] is cheap.
150///
151/// Used only by the process-global root client.
152fn get_global_supervision_sink() -> Option<PortRef<ActorSupervisionEvent>> {
153 sink_cell().read().unwrap().clone()
154}
155
156/// Process-global "root client" actor.
157///
158/// This actor lives on the `local_proc` of the singleton [`Host`]
159/// created by [`context()`], symmetric with Python's
160/// `RootClientActor` on `bootstrap_host()`'s local proc.
161///
162/// It acts as a *monitor* for routing failures observed at the
163/// process boundary: undeliverable messages are treated as signals to
164/// be reported via mesh supervision (when a sink is installed), not
165/// as fatal errors.
166///
167/// The actor is driven by `run()`, which `select!`s over:
168/// - `work_rx`: the primary dispatch queue for bound handler work
169/// items (including `Undeliverable<MessageEnvelope>` and
170/// `MeshFailure>`),
171/// - `supervision_rx`: supervision events delivered to this actor,
172/// and
173/// - `signal_rx`: control signals (currently minimal handling).
174#[derive(Debug)]
175#[hyperactor::export(handlers = [MeshFailure])]
176pub struct GlobalClientActor {
177 /// Control signals for the actor's proc (shutdown, etc.).
178 signal_rx: mpsc::UnboundedReceiver<Signal>,
179 /// Supervision events delivered to this actor instance.
180 ///
181 /// The root client is a monitor, so it should process these
182 /// events without crashing on routine routing/delivery failures
183 /// it observes.
184 supervision_rx: mpsc::UnboundedReceiver<ActorSupervisionEvent>,
185 /// Primary work queue for handler dispatch.
186 ///
187 /// Any bound handler message (e.g. `MeshFailure`,
188 /// `Undeliverable<MessageEnvelope>`, introspection, etc.) is
189 /// received here and executed via `WorkCell::handle`.
190 work_rx: ActorWorkReceiver<Self>,
191}
192
193impl GlobalClientActor {
194 fn run(mut self, instance: &'static Instance<Self>) -> JoinHandle<()> {
195 tokio::spawn(async move {
196 #[allow(unused_labels)]
197 let err = 'messages: loop {
198 tokio::select! {
199 work = self.work_rx.recv() => {
200 let work = work.expect("inconsistent work queue state");
201 if let Err(err) = work.handle(&mut self, instance).await {
202 while let Ok(supervision_event) = self.supervision_rx.try_recv() {
203 instance.handle_supervision_event(&mut self, supervision_event).await
204 .expect("GlobalClientActor::handle_supervision_event is infallible");
205 }
206 let kind = ActorErrorKind::processing(err);
207 break ActorError {
208 actor_id: Box::new(instance.self_addr().clone()),
209 kind: Box::new(kind),
210 };
211 }
212 }
213 Some(_) = self.signal_rx.recv() => {
214 // TODO: do we need any signal handling for the root client?
215 }
216 Some(supervision_event) = self.supervision_rx.recv() => {
217 instance.handle_supervision_event(&mut self, supervision_event).await
218 .expect("GlobalClientActor::handle_supervision_event is infallible");
219 }
220 };
221 };
222 let event = match *err.kind {
223 ActorErrorKind::UnhandledSupervisionEvent(event) => *event,
224 _ => {
225 let status = ActorStatus::generic_failure(err.kind.to_string());
226 ActorSupervisionEvent::new(
227 instance.self_addr().clone(),
228 Some("testclient".into()),
229 status,
230 None,
231 )
232 }
233 };
234 instance
235 .proc()
236 .handle_unhandled_supervision_event(instance, event);
237 })
238 }
239
240 async fn report_delivery_failure(
241 &mut self,
242 cx: &Instance<Self>,
243 undeliverable: Undeliverable<MessageEnvelope>,
244 ) -> Result<(), anyhow::Error> {
245 let mut env = match undeliverable {
246 Undeliverable::Returned(env) => env,
247 Undeliverable::Report(report) => {
248 let actor_ref = report.dest.actor_addr();
249 let error = report.error_msg().unwrap_or_default();
250 let event = ActorSupervisionEvent::new(
251 actor_ref.clone(),
252 None,
253 ActorStatus::generic_failure(format!(
254 "message not delivered to {}: {}",
255 report.dest, error
256 )),
257 None,
258 );
259 match get_global_supervision_sink() {
260 Some(sink) => {
261 sink.post(cx, event);
262 }
263 None => {
264 tracing::warn!(
265 actor=%actor_ref,
266 error=%error,
267 "no supervision sink; delivery failure report logged but not forwarded"
268 );
269 }
270 }
271 return Ok(());
272 }
273 };
274 env.push_delivery_failure(DeliveryFailure::new(UndeliverableReason::Transport(
275 TransportFailure::new(
276 env.dest().clone(),
277 TransportFailureReason::LinkUnavailable(
278 "message returned to global root client".to_string(),
279 ),
280 ),
281 )));
282 let actor_ref = env.dest().actor_addr();
283 let headers = env.headers().clone();
284 let event = ActorSupervisionEvent::new(
285 actor_ref.clone(),
286 None,
287 ActorStatus::generic_failure(format!("message not delivered: {}", env)),
288 Some(headers),
289 );
290
291 match get_global_supervision_sink() {
292 Some(sink) => {
293 sink.post(cx, event);
294 }
295 None => {
296 tracing::warn!(
297 actor=%actor_ref,
298 error=%env.error_msg().unwrap_or_default(),
299 "no supervision sink; undeliverable message logged but not forwarded"
300 );
301 }
302 }
303 Ok(())
304 }
305}
306
307/// Handle a returned (undeliverable) message observed by the
308/// process-global root client.
309///
310/// The global root client is a **monitor**, not a participant: it
311/// must not crash or propagate failures just because a routed message
312/// could not be delivered.
313///
314/// Instead, we translate the undeliverable into an
315/// `ActorSupervisionEvent` and forward it to the **active**
316/// `ProcMesh` via the process-global supervision sink ("last sink
317/// wins"). If no sink has been installed yet (e.g., before the first
318/// `ProcMesh` allocation completes), we log and drop the event.
319#[async_trait]
320impl Actor for GlobalClientActor {
321 /// The global root client is the root of the supervision tree:
322 /// there is no parent to escalate to. Child-actor failures (e.g.
323 /// ActorMeshControllers detecting dead procs after mesh teardown)
324 /// are expected and must not crash the process.
325 async fn handle_supervision_event(
326 &mut self,
327 _this: &Instance<Self>,
328 event: &ActorSupervisionEvent,
329 ) -> Result<bool, anyhow::Error> {
330 tracing::warn!(
331 %event,
332 "global root client absorbed child supervision event",
333 );
334 Ok(true)
335 }
336
337 async fn handle_delivery_failure_event(
338 &mut self,
339 cx: &Instance<Self>,
340 undeliverable: Undeliverable<MessageEnvelope>,
341 ) -> Result<(), anyhow::Error> {
342 self.report_delivery_failure(cx, undeliverable).await
343 }
344
345 async fn handle_undeliverable_message(
346 &mut self,
347 cx: &Instance<Self>,
348 _reason: UndeliverableReason,
349 undeliverable: Undeliverable<MessageEnvelope>,
350 ) -> Result<(), anyhow::Error> {
351 self.report_delivery_failure(cx, undeliverable).await
352 }
353
354 async fn handle_invalid_reference(
355 &mut self,
356 cx: &Instance<Self>,
357 _invalid: hyperactor::mailbox::InvalidReference,
358 undeliverable: Undeliverable<MessageEnvelope>,
359 ) -> Result<(), anyhow::Error> {
360 self.report_delivery_failure(cx, undeliverable).await
361 }
362}
363
364/// `MeshFailure` is a terminal supervision signal for an `ActorMesh`.
365///
366/// The process-global root client should never be a consumer of
367/// mesh-level supervision failures during normal operation: those
368/// events are expected to be observed and handled by the owning
369/// mesh/controller, not by the global client.
370///
371/// In processes that create and destroy multiple meshes (e.g.,
372/// benchmarks), `MeshFailure` events may arrive here during or after
373/// mesh teardown. Log loudly but do not crash — the global client is
374/// a monitor and must preserve forward progress.
375#[async_trait]
376impl Handler<MeshFailure> for GlobalClientActor {
377 async fn handle(&mut self, _cx: &Context<Self>, message: MeshFailure) -> anyhow::Result<()> {
378 tracing::error!("supervision failure reached global client: {}", message);
379 Ok(())
380 }
381}
382
383struct GlobalState {
384 actor_instance: &'static Instance<GlobalClientActor>,
385 host_mesh: HostMeshRef,
386 proc_mesh: ProcMeshRef,
387}
388
389/// Process-global, lazily-initialized Monarch context.
390///
391/// Backed by a `tokio::sync::OnceCell` so initialization is async and
392/// runs at most once per process. The first caller bootstraps the
393/// singleton host and root client actor (mirroring Python's
394/// `bootstrap_host()` / `context()`), and subsequent callers reuse
395/// the same `GlobalState`.
396///
397/// This provides a stable root `actor_instance` plus `this_host()` /
398/// `this_proc()` accessors.
399static GLOBAL_CONTEXT: tokio::sync::OnceCell<GlobalState> = tokio::sync::OnceCell::const_new();
400
401/// Bootstrap the singleton Host and GlobalClientActor. Mirrors
402/// Python's `bootstrap_host()` (monarch_hyperactor/src/host_mesh.rs).
403async fn bootstrap_host() -> GlobalState {
404 // 1. Create Host with LocalProcManager. The spawn closure is the
405 // ProcAgent boot function, called by HostAgent on GetLocalProc.
406 let spawn: ProcManagerSpawnFn =
407 Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
408 let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
409 let host = Host::new(manager, default_bind_spec().binding_addr())
410 .await
411 .expect("failed to create global host");
412
413 // 2. Extract system_proc before moving Host into HostAgent.
414 let system_proc = host.system_proc().clone();
415
416 // 3. Spawn HostAgent on system_proc (takes ownership of Host).
417 let host_agent = system_proc
418 .spawn_with_uid(
419 Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
420 HostAgent::new_local(host),
421 )
422 .expect("failed to spawn host agent");
423 HostAgent::wait_initialized(&host_agent)
424 .await
425 .expect("failed to initialize host agent");
426
427 let cast_handle = system_proc
428 .spawn_with_uid(
429 Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
430 hyperactor_cast::cast_actor::CastActor::default(),
431 )
432 .expect("failed to spawn cast actor");
433
434 cast_handle.bind::<hyperactor_cast::cast_actor::CastActor>();
435
436 // 4. Build HostMeshRef.
437 let host_mesh = HostMeshRef::from_host_agent(
438 HostMeshId::singleton(Label::new("local").unwrap()),
439 host_agent.bind(),
440 )
441 .expect("failed to create host mesh ref");
442
443 // 5. Get local_proc via HostAgent (lazily boots ProcAgent).
444 //
445 // We use a throwaway Proc::isolated() for the bootstrap request-reply
446 // calls, matching Python's bootstrap_host() (host_mesh.rs:330-333).
447 // This creates a temporary in-process-only proc context during init
448 // — intentionally acceptable for cross-language symmetry and easier
449 // reasoning about the bootstrap sequence.
450 let temp_proc = Proc::isolated();
451 let bootstrap_cx = temp_proc.client("bootstrap");
452 let local_proc_agent: ActorHandle<ProcAgent> = host_agent
453 .get_local_proc(&bootstrap_cx)
454 .await
455 .expect("failed to get local proc agent");
456
457 // 6. Get the actual Proc object.
458 let local_proc = local_proc_agent
459 .get_proc(&bootstrap_cx)
460 .await
461 .expect("failed to get local proc");
462
463 // 7. Build ProcMeshRef.
464 let proc_mesh = ProcMeshRef::new_singleton(
465 ProcMeshId::singleton(Label::new("local").unwrap()),
466 ProcRef::new(
467 local_proc_agent.actor_addr().proc_addr(),
468 0,
469 local_proc_agent.bind(),
470 ),
471 )
472 .expect("failed to create proc mesh ref");
473 let actor_instance = local_proc
474 .actor_instance::<GlobalClientActor>("client")
475 .expect("failed to create root client instance");
476
477 let hyperactor::proc::ActorInstance {
478 instance: client_instance,
479 handle,
480 supervision,
481 signal,
482 work,
483 } = actor_instance;
484
485 // GlobalClientActor uses a custom run loop that bypasses
486 // Actor::init, so set_system() must be called explicitly.
487 client_instance.set_system();
488 handle.bind::<GlobalClientActor>();
489
490 // Use a static OnceLock to get 'static lifetime for the instance.
491 static INSTANCE: OnceLock<(Instance<GlobalClientActor>, ActorHandle<GlobalClientActor>)> =
492 OnceLock::new();
493 INSTANCE
494 .set((client_instance, handle))
495 .map_err(|_| "already initialized root client instance")
496 .unwrap();
497 let (instance, _handle) = INSTANCE.get().unwrap();
498
499 let client = GlobalClientActor {
500 signal_rx: signal,
501 supervision_rx: supervision,
502 work_rx: work,
503 };
504 client.run(instance);
505
506 GlobalState {
507 actor_instance: instance,
508 host_mesh,
509 proc_mesh,
510 }
511}
512
513/// Process-global Monarch context for Rust programs. Symmetric with
514/// Python's `context()`.
515pub struct GlobalContext {
516 /// Consistent with Python's `context().actor_instance`
517 pub actor_instance: &'static Instance<GlobalClientActor>,
518 /// The singleton HostMesh. See also [`this_host()`].
519 pub host_mesh: &'static HostMeshRef,
520 /// The local ProcMesh. See also [`this_proc()`].
521 pub proc_mesh: &'static ProcMeshRef,
522}
523
524/// Returns the process-global Monarch context, lazily initialized.
525///
526/// On first call, creates a singleton [`Host`] and bootstraps
527/// [`GlobalClientActor`] on its `local_proc` — symmetric with
528/// Python's `bootstrap_host()`. Subsequent calls return immediately.
529///
530/// ```rust,ignore
531/// let cx = context().await;
532/// cx.actor_instance // c.f. Python: context().actor_instance
533/// ```
534///
535/// **Python programs do not use this.** Python has its own root
536/// client actor bootstrapped separately.
537pub async fn context() -> GlobalContext {
538 let state = GLOBAL_CONTEXT.get_or_init(bootstrap_host).await;
539 GlobalContext {
540 actor_instance: state.actor_instance,
541 host_mesh: &state.host_mesh,
542 proc_mesh: &state.proc_mesh,
543 }
544}
545
546/// Returns the singleton HostMesh c.f. Python's `this_host()`.
547pub async fn this_host() -> &'static HostMeshRef {
548 &GLOBAL_CONTEXT.get_or_init(bootstrap_host).await.host_mesh
549}
550
551/// Returns the local ProcMesh c.f Python's `this_proc()`.
552pub async fn this_proc() -> &'static ProcMeshRef {
553 &GLOBAL_CONTEXT.get_or_init(bootstrap_host).await.proc_mesh
554}
555
556/// Separate storage for client host registered by non-Rust runtimes
557/// (e.g. Python's `bootstrap_host()`). Checked by `try_this_host()`
558/// alongside `GLOBAL_CONTEXT`.
559static REGISTERED_CLIENT_HOST: std::sync::OnceLock<HostMeshRef> = std::sync::OnceLock::new();
560
561/// Register the client host mesh from an external runtime (Python).
562/// Called by Python's `bootstrap_host()` so that `try_this_host()`
563/// can discover C for the A/C invariant.
564pub fn register_client_host(host_mesh: HostMeshRef) {
565 let _ = REGISTERED_CLIENT_HOST.set(host_mesh);
566}
567
568/// Returns the client host mesh if available, without triggering
569/// lazy bootstrap. Checks both the Rust global context and the
570/// external registration (Python). Used by `MeshAdminAgent` to
571/// discover C at query time (A/C invariant).
572pub fn try_this_host() -> Option<&'static HostMeshRef> {
573 GLOBAL_CONTEXT
574 .get()
575 .map(|state| &state.host_mesh)
576 .or_else(|| REGISTERED_CLIENT_HOST.get())
577}
578
579#[cfg(test)]
580mod tests {
581 use std::time::Duration;
582
583 use hyperactor::testing::ids::test_actor_id;
584 use hyperactor_config::Flattrs;
585 #[cfg(fbcode_build)]
586 use ndslice::view::Extent;
587 #[cfg(fbcode_build)]
588 use timed_test::async_timed_test;
589
590 use super::*;
591 #[cfg(fbcode_build)]
592 use crate::testing;
593
594 /// Helper: send an `Undeliverable<MessageEnvelope>` to the global
595 /// root client's well-known undeliverable port via the runtime's
596 /// routing/dispatch path.
597 ///
598 /// This exercises the full integration boundary: serialisation →
599 /// routing → work_rx dispatch → `handle_undeliverable_message`.
600 ///
601 /// Uses the provided `dest_actor` so callers can distinguish
602 /// events from different injections (important because the global
603 /// sink is shared across tests running in the same process).
604 fn inject_undeliverable(
605 client: &'static Instance<GlobalClientActor>,
606 dest_actor: hyperactor::ActorAddr,
607 ) {
608 let env = MessageEnvelope::new(
609 client.self_addr().clone(),
610 dest_actor.port_addr(0.into()),
611 wirevalue::Any::serialize(&0u64).unwrap(),
612 Flattrs::new(),
613 );
614 // Target the global root client's well-known Undeliverable port.
615 let client_actor_id: hyperactor::ActorAddr = client.self_addr().clone();
616 let undeliverable_port =
617 PortRef::<Undeliverable<MessageEnvelope>>::attest_handler_port(&client_actor_id);
618 undeliverable_port.post(client, Undeliverable::Returned(env));
619 }
620
621 /// Verifies that creating a `ProcMesh` installs the
622 /// process-global supervision sink used by the global root
623 /// client.
624 #[async_timed_test(timeout_secs = 30)]
625 #[cfg(fbcode_build)]
626 async fn test_sink_installed_after_mesh_creation() {
627 let instance = testing::instance();
628 let mut hm = testing::host_mesh(2).await;
629 let _mesh = hm
630 .spawn(instance, "test", Extent::unity(), None, None)
631 .await
632 .unwrap();
633 assert!(
634 get_global_supervision_sink().is_some(),
635 "supervision sink must be set after ProcMesh creation"
636 );
637 let _ = hm.shutdown(instance).await;
638 }
639
640 /// Proves the full forwarding pipeline:
641 ///
642 /// Undeliverable<MessageEnvelope>
643 /// → GlobalClientActor::handle_undeliverable_message
644 /// → GLOBAL_SUPERVISION_SINK (PortRef)
645 /// → ActorSupervisionEvent delivered
646 ///
647 /// Installs a local port as the sink and verifies that the
648 /// `ActorSupervisionEvent` arrives there.
649 #[tokio::test]
650 async fn test_undeliverable_forwarded_to_sink() {
651 let cx = context().await;
652 let client = cx.actor_instance;
653
654 // Install a test sink we control.
655 let (sink_handle, mut sink_rx) = client.open_port::<ActorSupervisionEvent>();
656 set_global_supervision_sink(sink_handle.bind());
657
658 let marker = test_actor_id("fwd_test", "marker_actor");
659 inject_undeliverable(client, marker.clone());
660
661 // The handler runs asynchronously via work_rx; wait for the
662 // forwarded event with our marker.
663 let event = tokio::time::timeout(Duration::from_secs(5), async {
664 loop {
665 let ev = sink_rx.recv().await.expect("sink channel closed");
666 if ev.actor_id == marker {
667 return ev;
668 }
669 // Discard stale events from other tests sharing the
670 // global sink.
671 }
672 })
673 .await
674 .expect("timed out waiting for supervision event");
675
676 assert_eq!(
677 event.actor_id, marker,
678 "forwarded event must reference the undeliverable's destination actor"
679 );
680 }
681
682 /// Proves last-sink-wins: when two sinks are installed in
683 /// sequence, only the second receives the forwarded event.
684 #[tokio::test]
685 async fn test_last_sink_wins() {
686 let cx = context().await;
687 let client = cx.actor_instance;
688
689 // Install sink A.
690 let (sink_a_handle, _sink_a_rx) = client.open_port::<ActorSupervisionEvent>();
691 set_global_supervision_sink(sink_a_handle.bind());
692
693 // Install sink B (overrides A).
694 let (sink_b_handle, mut sink_b_rx) = client.open_port::<ActorSupervisionEvent>();
695 set_global_supervision_sink(sink_b_handle.bind());
696
697 let marker = test_actor_id("last_wins", "marker_actor");
698 inject_undeliverable(client, marker.clone());
699
700 // B should receive our marked event.
701 let event = tokio::time::timeout(Duration::from_secs(5), async {
702 loop {
703 let ev = sink_b_rx.recv().await.expect("sink B channel closed");
704 if ev.actor_id == marker {
705 return ev;
706 }
707 }
708 })
709 .await
710 .expect("timed out waiting for supervision event on sink B");
711 assert_eq!(event.actor_id, marker);
712 }
713
714 /// Proves the global client does not crash when no sink is
715 /// installed (early/late binding). The handler must log and
716 /// drop gracefully, and the client must remain usable
717 /// afterward.
718 #[tokio::test]
719 async fn test_no_crash_without_sink() {
720 let cx = context().await;
721 let client = cx.actor_instance;
722
723 // Clear any previously installed sink.
724 *sink_cell().write().unwrap() = None;
725
726 // Inject an undeliverable — should not panic.
727 inject_undeliverable(client, test_actor_id("no_sink", "marker_actor"));
728
729 // Give the async handler time to run.
730 tokio::time::sleep(Duration::from_millis(100)).await;
731
732 // The global client must still be alive and usable.
733 // Verify by installing a new sink and sending another
734 // undeliverable that arrives correctly.
735 let (sink_handle, mut sink_rx) = client.open_port::<ActorSupervisionEvent>();
736 set_global_supervision_sink(sink_handle.bind());
737
738 let marker = test_actor_id("no_sink_recovery", "marker_actor");
739 inject_undeliverable(client, marker.clone());
740
741 let event = tokio::time::timeout(Duration::from_secs(5), async {
742 loop {
743 let ev = sink_rx.recv().await.expect("sink channel closed");
744 if ev.actor_id == marker {
745 return ev;
746 }
747 }
748 })
749 .await
750 .expect("timed out: global client crashed or stopped processing");
751 assert_eq!(event.actor_id, marker);
752 }
753}