Skip to main content

hyperactor_remote/
supervision.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//! Remote supervision actors.
10//!
11//! [`Supervisor`] is the parent-side proxy. It supervises a [`Worker`], starts
12//! the configured liveness actor, forwards parent stop requests to the worker, and
13//! re-raises worker-reported supervision events through Hyperactor's ordinary
14//! `UnhandledSupervisionEvent` path.
15//!
16//! [`Worker`] is the worker-side container. It starts empty, accepts one child
17//! actor, accepts one active remote supervision session, starts the worker-side
18//! liveness actor, and reports child or liveness lifecycle events to the supervisor
19//! session.
20
21use async_trait::async_trait;
22use hyperactor::Actor;
23use hyperactor::ActorAddr;
24use hyperactor::ActorHandle;
25use hyperactor::ActorRef;
26use hyperactor::AnyActorHandle;
27use hyperactor::Context;
28use hyperactor::Endpoint;
29use hyperactor::Handler;
30use hyperactor::Instance;
31use hyperactor::OncePortHandle;
32use hyperactor::PortRef;
33use hyperactor::Uid;
34use hyperactor::actor::ActorErrorKind;
35use hyperactor::actor::ActorStatus;
36use hyperactor::actor::StopMode;
37use hyperactor::context;
38use hyperactor::context::Actor as _;
39use hyperactor::mailbox::MessageEnvelope;
40use hyperactor::mailbox::Undeliverable;
41use hyperactor::mailbox::UndeliverableReason;
42use hyperactor::supervision::ActorSupervisionEvent;
43use hyperactor_config::Flattrs;
44
45use crate::Gspawn;
46use crate::KeepaliveLink;
47use crate::KeepaliveSupervisor;
48use crate::OrphanPolicy;
49use crate::RemoteActorDisposition;
50use crate::Supervise;
51use crate::SupervisionOptions;
52use crate::SupervisorEvent;
53use crate::WorkerCommand;
54use crate::WorkerLike;
55
56#[derive(Debug)]
57struct PendingStop {
58    mode: StopMode,
59    reason: String,
60}
61
62#[derive(Debug)]
63struct SupervisorSession {
64    worker: PortRef<WorkerCommand>,
65    child: ActorAddr,
66    display_name: Option<String>,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
70struct SessionId(hyperactor::Uid);
71
72impl SessionId {
73    fn into_uid(self) -> hyperactor::Uid {
74        self.0
75    }
76}
77
78type BootstrapCallback =
79    Box<dyn FnOnce(&Instance<Supervisor>, Supervise) -> anyhow::Result<()> + Send>;
80
81enum SupervisorBootstrap {
82    ExistingWorker {
83        worker: ActorRef<WorkerLike>,
84    },
85    Callback {
86        fallback_actor: ActorAddr,
87        callback: Option<BootstrapCallback>,
88    },
89}
90
91impl std::fmt::Debug for SupervisorBootstrap {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::ExistingWorker { worker } => f
95                .debug_struct("ExistingWorker")
96                .field("worker", worker)
97                .finish(),
98            Self::Callback { fallback_actor, .. } => f
99                .debug_struct("Callback")
100                .field("fallback_actor", fallback_actor)
101                .finish_non_exhaustive(),
102        }
103    }
104}
105
106impl SupervisorBootstrap {
107    fn fallback_actor(&self) -> ActorAddr {
108        match self {
109            Self::ExistingWorker { worker } => worker.actor_addr().clone(),
110            Self::Callback { fallback_actor, .. } => fallback_actor.clone(),
111        }
112    }
113
114    fn worker_endpoint(&self) -> Option<&ActorRef<WorkerLike>> {
115        match self {
116            Self::ExistingWorker { worker } => Some(worker),
117            Self::Callback { .. } => None,
118        }
119    }
120
121    fn start(&mut self, cx: &Instance<Supervisor>, supervise: Supervise) -> anyhow::Result<()> {
122        match self {
123            Self::ExistingWorker { worker } => {
124                (&*worker).post(cx, supervise);
125            }
126            Self::Callback { callback, .. } => {
127                callback
128                    .take()
129                    .expect("supervisor bootstrap callback started more than once")(
130                    cx, supervise
131                )?;
132            }
133        }
134        Ok(())
135    }
136}
137
138/// Parent-side proxy for one remote supervision relationship.
139#[derive(Debug)]
140pub struct Supervisor {
141    bootstrap: SupervisorBootstrap,
142    liveness: Option<KeepaliveLink>,
143    options: SupervisionOptions,
144    session_id: Uid,
145    liveness_handle: Option<ActorHandle<KeepaliveSupervisor>>,
146    session: Option<SupervisorSession>,
147    pending_stop: Option<PendingStop>,
148    /// Optional one-shot notification posted after the worker links the child.
149    ready: Option<OncePortHandle<()>>,
150    labels: Flattrs,
151}
152
153impl Supervisor {
154    /// Create a supervisor proxy for `worker`.
155    pub fn new(
156        worker: ActorRef<WorkerLike>,
157        liveness: KeepaliveLink,
158        options: SupervisionOptions,
159    ) -> Self {
160        Self::new_uid(worker, liveness, options, hyperactor::Uid::anonymous())
161    }
162
163    /// Create a supervisor proxy with an explicit session id.
164    pub fn new_uid(
165        worker: ActorRef<WorkerLike>,
166        liveness: KeepaliveLink,
167        options: SupervisionOptions,
168        session_id: hyperactor::Uid,
169    ) -> Self {
170        Self::new_with_bootstrap(
171            SupervisorBootstrap::ExistingWorker { worker },
172            liveness,
173            options,
174            session_id,
175            None,
176        )
177    }
178
179    /// Create a supervisor proxy whose supervise request is delivered by `bootstrap`.
180    ///
181    /// During supervisor initialization, this starts the supervisor-side
182    /// liveness actor, stores its handle, builds the worker-side [`Supervise`]
183    /// request, and calls `bootstrap` exactly once. The callback is responsible for
184    /// delivering that `Supervise` to the worker that should be supervised, usually
185    /// by embedding it in another spawn or routing message. If the callback
186    /// returns an error, supervisor initialization fails.
187    ///
188    /// `bootstrap` must not retain the `Supervise` for later use. Until the worker
189    /// accepts the supervise request and reports [`SupervisorEvent::Linked`], this
190    /// supervisor has no worker control endpoint; stop requests are therefore
191    /// held pending. `fallback_actor` is used only to synthesize a supervision
192    /// event if liveness fails before the worker reports the supervised child.
193    /// When `ready` is set, it is posted once the worker reports `Linked` — the
194    /// earliest point at which messages to the child are guaranteed to route.
195    pub(crate) fn bootstrap_uid<F>(
196        liveness: KeepaliveLink,
197        options: SupervisionOptions,
198        session_id: Uid,
199        fallback_actor: ActorAddr,
200        ready: Option<OncePortHandle<()>>,
201        bootstrap: F,
202    ) -> Self
203    where
204        F: FnOnce(&Instance<Supervisor>, Supervise) -> anyhow::Result<()> + Send + 'static,
205    {
206        Self::new_with_bootstrap(
207            SupervisorBootstrap::Callback {
208                fallback_actor,
209                callback: Some(Box::new(bootstrap)),
210            },
211            liveness,
212            options,
213            session_id,
214            ready,
215        )
216    }
217
218    fn new_with_bootstrap(
219        bootstrap: SupervisorBootstrap,
220        liveness: KeepaliveLink,
221        options: SupervisionOptions,
222        session_id: Uid,
223        ready: Option<OncePortHandle<()>>,
224    ) -> Self {
225        Self {
226            bootstrap,
227            liveness: Some(liveness),
228            options,
229            session_id,
230            liveness_handle: None,
231            session: None,
232            pending_stop: None,
233            ready,
234            labels: Flattrs::new(),
235        }
236    }
237
238    /// Set the labels on every supervision event raised for the supervised
239    /// actor, replacing any labels the event already carries.
240    pub fn with_labels(mut self, labels: Flattrs) -> Self {
241        self.labels = labels;
242        self
243    }
244}
245
246#[async_trait]
247impl Actor for Supervisor {
248    async fn init(&mut self, this: &Instance<Self>) -> anyhow::Result<()> {
249        let (liveness_handle, liveness) = self
250            .liveness
251            .take()
252            .expect("supervisor initialized more than once")
253            .spawn_supervisor(this)?;
254        self.liveness_handle = Some(liveness_handle);
255        self.bootstrap.start(
256            this,
257            Supervise {
258                session_id: self.session_id.clone(),
259                supervisor: this.port::<SupervisorEvent>().bind(),
260                parent: this.self_addr().clone(),
261                liveness,
262                options: self.options.clone(),
263                labels: self.labels.clone(),
264            },
265        )?;
266        Ok(())
267    }
268
269    async fn handle_stop(
270        &mut self,
271        this: &Instance<Self>,
272        mode: StopMode,
273        reason: &str,
274    ) -> anyhow::Result<()> {
275        self.pending_stop = Some(PendingStop {
276            mode,
277            reason: reason.to_string(),
278        });
279        self.send_pending_worker_stop(this)
280    }
281
282    async fn handle_supervision_event(
283        &mut self,
284        _this: &Instance<Self>,
285        event: &ActorSupervisionEvent,
286    ) -> anyhow::Result<bool> {
287        if self
288            .liveness_handle
289            .as_ref()
290            .is_some_and(|handle| handle.actor_addr() == &event.actor_id)
291        {
292            let event = self.synthesize_unreachable_event("supervision liveness failed");
293            return self.propagate_event(event);
294        }
295        Ok(!event.is_error())
296    }
297}
298
299#[async_trait]
300impl Handler<SupervisorEvent> for Supervisor {
301    async fn handle(&mut self, cx: &Context<Self>, message: SupervisorEvent) -> anyhow::Result<()> {
302        match message {
303            SupervisorEvent::Linked {
304                session_id,
305                worker,
306                child,
307                display_name,
308            } => {
309                self.ensure_session(&session_id)?;
310                if let Some(ready) = self.ready.take() {
311                    ready.post(cx, ());
312                }
313                self.session = Some(SupervisorSession {
314                    worker,
315                    child,
316                    display_name,
317                });
318                self.send_pending_worker_stop(cx)?;
319                Ok(())
320            }
321            SupervisorEvent::SuperviseRejected { session_id, reason } => {
322                self.ensure_session(&session_id)?;
323                anyhow::bail!("remote supervision request rejected: {}", reason)
324            }
325            SupervisorEvent::SupervisionEvent {
326                session_id,
327                event,
328                disposition: _,
329            } => {
330                self.ensure_session(&session_id)?;
331                self.propagate_event(event)
332            }
333            SupervisorEvent::Unlinked { session_id, reason } => {
334                self.ensure_session(&session_id)?;
335                cx.exit(&reason)?;
336                Ok(())
337            }
338        }
339    }
340}
341
342impl Supervisor {
343    fn ensure_session(&self, session_id: &hyperactor::Uid) -> anyhow::Result<()> {
344        if session_id != &self.session_id {
345            anyhow::bail!("remote supervision session id mismatch");
346        }
347        Ok(())
348    }
349
350    fn send_pending_worker_stop(&mut self, cx: &Instance<Self>) -> anyhow::Result<()> {
351        let Some(stop) = self.pending_stop.take() else {
352            return Ok(());
353        };
354        let Some(session) = &self.session else {
355            let Some(worker) = self.bootstrap.worker_endpoint() else {
356                self.pending_stop = Some(stop);
357                return Ok(());
358            };
359            worker.post(
360                cx,
361                WorkerCommand::Stop {
362                    session_id: self.session_id.clone(),
363                    mode: stop.mode,
364                    reason: stop.reason,
365                },
366            );
367            return Ok(());
368        };
369        (&session.worker).post(
370            cx,
371            WorkerCommand::Stop {
372                session_id: self.session_id.clone(),
373                mode: stop.mode,
374                reason: stop.reason,
375            },
376        );
377        Ok(())
378    }
379
380    fn synthesize_unreachable_event(&self, reason: &str) -> ActorSupervisionEvent {
381        if let Some(session) = &self.session {
382            ActorSupervisionEvent::new(
383                session.child.clone(),
384                session.display_name.clone(),
385                ActorStatus::generic_failure(reason),
386                None,
387            )
388        } else {
389            ActorSupervisionEvent::new(
390                self.bootstrap.fallback_actor(),
391                None,
392                ActorStatus::generic_failure(reason),
393                None,
394            )
395        }
396    }
397
398    fn propagate_event<T>(&self, event: ActorSupervisionEvent) -> anyhow::Result<T> {
399        Err(anyhow::Error::new(
400            ActorErrorKind::UnhandledSupervisionEvent(Box::new(
401                event.with_labels(self.labels.clone()),
402            )),
403        ))
404    }
405}
406
407#[derive(Debug)]
408struct WorkerSession {
409    session_id: SessionId,
410    supervisor: PortRef<SupervisorEvent>,
411    options: SupervisionOptions,
412    liveness_handle: AnyActorHandle,
413    labels: Flattrs,
414}
415
416/// Local-only request to spawn a child actor under a [`Worker`].
417#[derive(Debug)]
418pub struct Spawn<C: Actor> {
419    child: C,
420    uid: Option<Uid>,
421}
422
423impl<C: Actor> Spawn<C> {
424    /// Create a local spawn request with a fresh child uid.
425    pub fn new(child: C) -> Self {
426        Self { child, uid: None }
427    }
428
429    /// Create a local spawn request with an explicit child uid.
430    pub fn with_uid(uid: Uid, child: C) -> Self {
431        Self {
432            child,
433            uid: Some(uid),
434        }
435    }
436}
437
438/// Local request to spawn a registered actor and supervise it in one step.
439#[derive(Debug)]
440pub(crate) struct GspawnAndSupervise {
441    gspawn: Gspawn,
442    supervise: Supervise,
443}
444
445impl GspawnAndSupervise {
446    pub(crate) fn new(gspawn: Gspawn, supervise: Supervise) -> Self {
447        Self { gspawn, supervise }
448    }
449}
450
451/// Worker-side container for one remotely supervised child actor.
452#[derive(Debug, Default)]
453#[hyperactor::export(Supervise, WorkerCommand)]
454pub struct Worker {
455    state: SupervisedChild,
456}
457
458impl Worker {
459    /// Create an empty worker shell.
460    pub fn new() -> Self {
461        Self::default()
462    }
463}
464
465#[async_trait]
466impl Actor for Worker {
467    async fn init(&mut self, _this: &Instance<Self>) -> anyhow::Result<()> {
468        Ok(())
469    }
470
471    async fn handle_supervision_event(
472        &mut self,
473        this: &Instance<Self>,
474        event: &ActorSupervisionEvent,
475    ) -> anyhow::Result<bool> {
476        self.state.handle_supervision_event(this, event)
477    }
478
479    async fn handle_undeliverable_message(
480        &mut self,
481        _this: &Instance<Self>,
482        _reason: UndeliverableReason,
483        _envelope: Undeliverable<MessageEnvelope>,
484    ) -> anyhow::Result<()> {
485        self.state
486            .handle_orphaned_supervisor("supervisor session undeliverable")
487    }
488
489    async fn handle_delivery_failure_event(
490        &mut self,
491        _this: &Instance<Self>,
492        _envelope: Undeliverable<MessageEnvelope>,
493    ) -> anyhow::Result<()> {
494        self.state
495            .handle_orphaned_supervisor("supervisor session undeliverable")
496    }
497}
498
499#[async_trait]
500impl Handler<Supervise> for Worker {
501    async fn handle(&mut self, cx: &Context<Self>, message: Supervise) -> anyhow::Result<()> {
502        self.state.supervise(cx, message).await
503    }
504}
505
506#[async_trait]
507impl Handler<WorkerCommand> for Worker {
508    async fn handle(&mut self, cx: &Context<Self>, message: WorkerCommand) -> anyhow::Result<()> {
509        self.state.handle_supervised_worker(cx, message)
510    }
511}
512
513#[async_trait]
514impl<C> Handler<Spawn<C>> for Worker
515where
516    C: Actor + Send + Sync + 'static,
517{
518    async fn handle(&mut self, cx: &Context<Self>, message: Spawn<C>) -> anyhow::Result<()> {
519        let display_name = message.child.display_name();
520        let child = match message.uid {
521            Some(uid) => cx.instance().spawn_with_uid(uid, message.child)?.into_any(),
522            None => cx.instance().spawn(message.child).into_any(),
523        };
524        self.state.set_child(child, display_name)?;
525        Ok(())
526    }
527}
528
529#[async_trait]
530impl Handler<GspawnAndSupervise> for Worker {
531    async fn handle(
532        &mut self,
533        cx: &Context<Self>,
534        message: GspawnAndSupervise,
535    ) -> anyhow::Result<()> {
536        let GspawnAndSupervise { gspawn, supervise } = message;
537        let session_id = supervise.session_id.clone();
538        let supervisor = supervise.supervisor.clone();
539        match gspawn.spawn_child(cx).await {
540            Ok(child) => {
541                self.state.set_child(child, None)?;
542                self.state.supervise(cx, supervise).await?;
543            }
544            Err(err) => {
545                let _ = supervisor.post(
546                    cx,
547                    SupervisorEvent::SuperviseRejected {
548                        session_id,
549                        reason: format!("actor spawn failed: {}", err),
550                    },
551                );
552                cx.exit("actor spawn failed")?;
553            }
554        }
555        Ok(())
556    }
557}
558
559#[derive(Debug, Default)]
560pub(crate) struct SupervisedChild {
561    child_handle: Option<AnyActorHandle>,
562    child_display_name: Option<String>,
563    session: Option<WorkerSession>,
564}
565
566impl SupervisedChild {
567    pub(crate) fn set_child(
568        &mut self,
569        child_handle: AnyActorHandle,
570        child_display_name: Option<String>,
571    ) -> anyhow::Result<()> {
572        if self.child_handle.is_some() {
573            anyhow::bail!("worker already has a supervised child");
574        }
575        self.child_handle = Some(child_handle);
576        self.child_display_name = child_display_name;
577        Ok(())
578    }
579
580    pub(crate) async fn supervise<C>(&mut self, cx: &C, message: Supervise) -> anyhow::Result<()>
581    where
582        C: context::Actor,
583        C::A: Handler<WorkerCommand>,
584    {
585        if self.session.is_some() {
586            let _ = message.supervisor.post(
587                cx,
588                SupervisorEvent::SuperviseRejected {
589                    session_id: message.session_id,
590                    reason: "worker already supervised".to_string(),
591                },
592            );
593            return Ok(());
594        }
595
596        if self.child_handle.is_none() {
597            let _ = message.supervisor.post(
598                cx,
599                SupervisorEvent::SuperviseRejected {
600                    session_id: message.session_id,
601                    reason: "worker has no supervised child".to_string(),
602                },
603            );
604            return Ok(());
605        }
606
607        let liveness_handle = message.liveness.spawn_worker(cx).await?;
608        let child_addr = self
609            .child_handle
610            .as_ref()
611            .expect("worker child must be spawned before linking")
612            .actor_id()
613            .clone();
614        let supervisor = message.supervisor.clone();
615        let session_id = SessionId(message.session_id.clone());
616        self.session = Some(WorkerSession {
617            session_id,
618            supervisor: message.supervisor,
619            options: message.options,
620            liveness_handle,
621            labels: message.labels,
622        });
623        supervisor.post(
624            cx,
625            SupervisorEvent::Linked {
626                session_id: message.session_id.clone(),
627                worker: cx.instance().port::<WorkerCommand>().bind(),
628                child: child_addr,
629                display_name: self.child_display_name.clone(),
630            },
631        );
632        Ok(())
633    }
634
635    pub(crate) fn handle_supervision_event<A: Actor>(
636        &mut self,
637        this: &Instance<A>,
638        event: &ActorSupervisionEvent,
639    ) -> anyhow::Result<bool> {
640        if self.is_liveness_event(event) {
641            self.handle_liveness_event(this, event)?;
642            return Ok(true);
643        }
644        if self.is_child_event(event) {
645            if let Some(session) = &self.session {
646                let supervisor = session.supervisor.clone();
647                let session_id = session.session_id.clone();
648                supervisor.post(
649                    this,
650                    SupervisorEvent::SupervisionEvent {
651                        session_id: session_id.into_uid(),
652                        event: event.clone().with_labels(session.labels.clone()),
653                        disposition: RemoteActorDisposition::Terminal,
654                    },
655                );
656            }
657            self.child_handle = None;
658            this.exit("supervised child stopped")?;
659            return Ok(true);
660        }
661
662        debug_assert!(
663            self.session.is_none(),
664            "worker with active session received supervision event for non-liveness non-child actor: {:?}",
665            event.actor_id
666        );
667        if self.session.is_none() {
668            Ok(true)
669        } else {
670            Ok(!event.is_error())
671        }
672    }
673
674    pub(crate) fn handle_supervised_worker<A: Actor>(
675        &mut self,
676        cx: &Context<A>,
677        message: WorkerCommand,
678    ) -> anyhow::Result<()> {
679        match message {
680            WorkerCommand::Stop {
681                session_id,
682                mode,
683                reason,
684            } => {
685                self.accept_session_id(session_id)?;
686                self.stop_child(mode, &reason)?;
687            }
688            WorkerCommand::Unlink { session_id, reason } => {
689                let session_id = self.accept_session_id(session_id)?;
690                if let Some(session) = self.session.take() {
691                    let _ = session.liveness_handle.stop(&reason);
692                    match session.options.orphan_policy {
693                        OrphanPolicy::Stop => self.stop_child(StopMode::Stop, &reason)?,
694                        OrphanPolicy::Detach => (),
695                    }
696
697                    let _ = session.supervisor.post(
698                        cx,
699                        SupervisorEvent::Unlinked {
700                            session_id: session_id.into_uid(),
701                            reason,
702                        },
703                    );
704                }
705            }
706        }
707        Ok(())
708    }
709
710    fn accept_session_id(&self, raw: hyperactor::Uid) -> anyhow::Result<SessionId> {
711        let Some(session) = &self.session else {
712            anyhow::bail!("worker is not linked");
713        };
714        let session_id = SessionId(raw);
715        if session_id != session.session_id {
716            anyhow::bail!("remote supervision session id mismatch");
717        }
718        Ok(session_id)
719    }
720
721    fn stop_child(&self, mode: StopMode, reason: &str) -> anyhow::Result<()> {
722        let Some(child) = &self.child_handle else {
723            return Ok(());
724        };
725        match mode {
726            StopMode::Stop => child.stop(reason)?,
727            StopMode::DrainAndStop => child.drain_and_stop(reason)?,
728        }
729        Ok(())
730    }
731
732    fn is_child_event(&self, event: &ActorSupervisionEvent) -> bool {
733        self.child_handle
734            .as_ref()
735            .is_some_and(|child| child.actor_id() == &event.actor_id)
736    }
737
738    fn is_liveness_event(&self, event: &ActorSupervisionEvent) -> bool {
739        self.session
740            .as_ref()
741            .is_some_and(|session| session.liveness_handle.actor_id() == &event.actor_id)
742    }
743
744    pub(crate) fn handle_orphaned_supervisor(&mut self, reason: &str) -> anyhow::Result<()> {
745        match self.unlink_orphaned_supervisor(reason) {
746            Some(OrphanPolicy::Stop) => self.stop_child(StopMode::Stop, reason),
747            Some(OrphanPolicy::Detach) | None => Ok(()),
748        }
749    }
750
751    fn unlink_orphaned_supervisor(&mut self, reason: &str) -> Option<OrphanPolicy> {
752        let session = self.session.take()?;
753        let _ = session.liveness_handle.stop(reason);
754        Some(session.options.orphan_policy)
755    }
756
757    fn handle_liveness_event<A: Actor>(
758        &mut self,
759        cx: &Instance<A>,
760        event: &ActorSupervisionEvent,
761    ) -> anyhow::Result<()> {
762        let Some(session) = &self.session else {
763            return Ok(());
764        };
765        let supervisor = session.supervisor.clone();
766        let session_id = session.session_id.clone();
767        let orphan_policy = session.options.orphan_policy;
768        if let Some(child) = &self.child_handle {
769            supervisor.post(
770                cx,
771                SupervisorEvent::SupervisionEvent {
772                    session_id: session_id.into_uid(),
773                    event: ActorSupervisionEvent::new(
774                        child.actor_id().clone(),
775                        self.child_display_name.clone(),
776                        ActorStatus::generic_failure(format!(
777                            "supervision liveness failed: {}",
778                            event
779                        )),
780                        None,
781                    )
782                    .with_labels(session.labels.clone()),
783                    disposition: RemoteActorDisposition::Unreachable,
784                },
785            );
786        }
787        let session = self
788            .session
789            .take()
790            .expect("liveness event must have an active session");
791        let _ = session.liveness_handle.stop("supervision liveness failed");
792        if orphan_policy == OrphanPolicy::Stop {
793            self.stop_child(StopMode::Stop, "supervision liveness failed")?;
794        }
795        Ok(())
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use std::sync::Arc;
802    use std::time::Duration;
803
804    use hyperactor::PortHandle;
805    use hyperactor::Proc;
806    use hyperactor::Uid;
807    use hyperactor::mailbox::PortReceiver;
808    use hyperactor_config::attrs::declare_attrs;
809    use serde::Deserialize;
810    use serde::Serialize;
811    use tokio::sync::Notify;
812    use typeuri::Named;
813
814    use super::*;
815
816    declare_attrs! {
817        attr TEST_LABEL: u64;
818    }
819
820    fn test_labels(value: u64) -> Flattrs {
821        let mut labels = Flattrs::new();
822        labels.set(TEST_LABEL, value);
823        labels
824    }
825
826    #[derive(Clone, Debug, Serialize, Deserialize, Named)]
827    enum TestChildCommand {
828        Fail,
829        Drain(String),
830    }
831    wirevalue::register_type!(TestChildCommand);
832
833    #[derive(Clone, Debug, Serialize, Deserialize, Named)]
834    struct KillParent;
835    wirevalue::register_type!(KillParent);
836
837    #[derive(Debug)]
838    enum TestChildAction {
839        FailAfter(Duration),
840        DrainAfter(Duration, String),
841    }
842
843    #[derive(Debug)]
844    #[hyperactor::export(TestChildCommand)]
845    struct TestChild {
846        ready: PortHandle<ActorAddr>,
847        stopped: PortHandle<String>,
848        action: Option<TestChildAction>,
849        drain_observer: Option<PortHandle<String>>,
850    }
851
852    impl TestChild {
853        fn with_drain_observer(mut self, port: PortHandle<String>) -> Self {
854            self.drain_observer = Some(port);
855            self
856        }
857    }
858
859    #[async_trait]
860    impl Actor for TestChild {
861        async fn init(&mut self, this: &Instance<Self>) -> anyhow::Result<()> {
862            self.ready.post(this, this.self_addr().clone());
863            match self.action.take() {
864                Some(TestChildAction::FailAfter(delay)) => {
865                    this.post_after(this, TestChildCommand::Fail, delay);
866                }
867                Some(TestChildAction::DrainAfter(delay, tag)) => {
868                    this.post_after(this, TestChildCommand::Drain(tag), delay);
869                }
870                None => {}
871            }
872            Ok(())
873        }
874
875        async fn handle_stop(
876            &mut self,
877            this: &Instance<Self>,
878            mode: StopMode,
879            reason: &str,
880        ) -> anyhow::Result<()> {
881            self.stopped.post(this, reason.to_string());
882            this.close();
883            match mode {
884                StopMode::Stop => this.exit(reason)?,
885                StopMode::DrainAndStop => this.exit_after_drain(reason)?,
886            }
887            Ok(())
888        }
889    }
890
891    #[async_trait]
892    impl Handler<TestChildCommand> for TestChild {
893        async fn handle(
894            &mut self,
895            cx: &Context<Self>,
896            message: TestChildCommand,
897        ) -> anyhow::Result<()> {
898            match message {
899                TestChildCommand::Fail => cx.kill("test child failed")?,
900                TestChildCommand::Drain(tag) => {
901                    if let Some(ref port) = self.drain_observer {
902                        port.post(cx, tag);
903                    }
904                }
905            }
906            Ok(())
907        }
908    }
909
910    #[derive(Debug)]
911    struct Parent {
912        supervisor: Option<Supervisor>,
913        events: PortHandle<ActorSupervisionEvent>,
914    }
915
916    #[async_trait]
917    impl Actor for Parent {
918        async fn init(&mut self, this: &Instance<Self>) -> anyhow::Result<()> {
919            this.spawn(
920                self.supervisor
921                    .take()
922                    .expect("parent initialized more than once"),
923            );
924            Ok(())
925        }
926
927        async fn handle_supervision_event(
928            &mut self,
929            this: &Instance<Self>,
930            event: &ActorSupervisionEvent,
931        ) -> anyhow::Result<bool> {
932            self.events.post(this, event.clone());
933            Ok(true)
934        }
935    }
936
937    #[derive(Debug)]
938    #[hyperactor::export(KillParent)]
939    struct Grandparent {
940        parent: Option<Parent>,
941        parent_addr: PortHandle<ActorAddr>,
942        parent_handle: Option<ActorHandle<Parent>>,
943        events: PortHandle<ActorSupervisionEvent>,
944    }
945
946    #[async_trait]
947    impl Actor for Grandparent {
948        async fn init(&mut self, this: &Instance<Self>) -> anyhow::Result<()> {
949            let parent = this.spawn(
950                self.parent
951                    .take()
952                    .expect("grandparent initialized more than once"),
953            );
954            self.parent_addr.post(this, parent.actor_addr().clone());
955            self.parent_handle = Some(parent);
956            Ok(())
957        }
958
959        async fn handle_supervision_event(
960            &mut self,
961            this: &Instance<Self>,
962            event: &ActorSupervisionEvent,
963        ) -> anyhow::Result<bool> {
964            self.events.post(this, event.clone());
965            Ok(true)
966        }
967    }
968
969    #[async_trait]
970    impl Handler<KillParent> for Grandparent {
971        async fn handle(
972            &mut self,
973            _cx: &Context<Self>,
974            _message: KillParent,
975        ) -> anyhow::Result<()> {
976            self.parent_handle
977                .as_ref()
978                .expect("parent must be spawned before kill")
979                .kill("test parent killed")?;
980            Ok(())
981        }
982    }
983
984    fn test_child(
985        ready: PortHandle<ActorAddr>,
986        stopped: PortHandle<String>,
987        action: Option<TestChildAction>,
988    ) -> TestChild {
989        TestChild {
990            ready,
991            stopped,
992            action,
993            drain_observer: None,
994        }
995    }
996
997    // Sets up the `Parent → Supervisor → Worker` harness
998    // (not Grandparent or synthetic-liveness). Spawns the worker and its
999    // child, awaits the child's ready signal, then spawns the parent with
1000    // `Supervisor::new_uid` using the supplied session id and
1001    // options. Any post-spawn `Supervise`/`Linked` sleep is intentionally
1002    // omitted - tests add it themselves next to the message send that
1003    // depends on it.
1004    async fn spawn_supervised_pair(
1005        proc: &Proc,
1006        inst: &hyperactor::Client,
1007        session_id: hyperactor::Uid,
1008        options: SupervisionOptions,
1009        child_action: Option<TestChildAction>,
1010    ) -> (
1011        ActorAddr,                           // child address
1012        ActorHandle<Worker>,                 // worker handle
1013        ActorHandle<Parent>,                 // parent handle
1014        PortReceiver<String>,                // child stop notification
1015        PortReceiver<ActorSupervisionEvent>, // parent's supervision events
1016    ) {
1017        // The ready receiver is consumed since no test needs more
1018        // than one child address.
1019        let (ready, mut ready_rx) = inst.open_port::<ActorAddr>();
1020        let (stopped, stopped_rx) = inst.open_port::<String>();
1021        let (events, events_rx) = inst.open_port::<ActorSupervisionEvent>();
1022        let worker = proc.spawn(Worker::new());
1023        worker.post(inst, Spawn::new(test_child(ready, stopped, child_action)));
1024        let child_addr = ready_rx.recv().await.unwrap();
1025        // Spawning Parent starts the supervision handshake: Parent::init
1026        // spawns the Supervisor whose own init sends `Supervise` to the
1027        // worker.
1028        let parent = proc.spawn(Parent {
1029            supervisor: Some(Supervisor::new_uid(
1030                worker.bind::<WorkerLike>(),
1031                KeepaliveLink::new(Duration::from_millis(5), Duration::from_secs(60)),
1032                options,
1033                session_id,
1034            )),
1035            events,
1036        });
1037        (child_addr, worker, parent, stopped_rx, events_rx)
1038    }
1039
1040    #[derive(Debug)]
1041    #[hyperactor::export(Supervise, WorkerCommand)]
1042    struct TestSlowWorker {
1043        link_started: PortHandle<()>,
1044        release_link: Arc<Notify>,
1045        received_stop: PortHandle<String>,
1046        session: Option<(hyperactor::Uid, PortRef<SupervisorEvent>)>,
1047    }
1048
1049    #[async_trait]
1050    impl Actor for TestSlowWorker {
1051        async fn init(&mut self, _this: &Instance<Self>) -> anyhow::Result<()> {
1052            Ok(())
1053        }
1054    }
1055
1056    #[async_trait]
1057    impl Handler<Supervise> for TestSlowWorker {
1058        async fn handle(&mut self, cx: &Context<Self>, message: Supervise) -> anyhow::Result<()> {
1059            self.session = Some((message.session_id, message.supervisor));
1060            self.link_started.post(cx, ());
1061            self.release_link.notified().await;
1062            Ok(())
1063        }
1064    }
1065
1066    #[async_trait]
1067    impl Handler<WorkerCommand> for TestSlowWorker {
1068        async fn handle(
1069            &mut self,
1070            cx: &Context<Self>,
1071            message: WorkerCommand,
1072        ) -> anyhow::Result<()> {
1073            if let WorkerCommand::Stop {
1074                session_id, reason, ..
1075            } = message
1076            {
1077                let Some((expected_session_id, supervisor)) = self.session.take() else {
1078                    anyhow::bail!("stop received before supervise");
1079                };
1080                anyhow::ensure!(session_id == expected_session_id, "unexpected session id");
1081                self.received_stop.post(cx, reason.clone());
1082                let _ = supervisor.post(cx, SupervisorEvent::Unlinked { session_id, reason });
1083            }
1084            Ok(())
1085        }
1086    }
1087
1088    // proc
1089    // ├── client instance
1090    // │   ├── ready port: receives the child address
1091    // │   ├── stopped port: unused in this test
1092    // │   └── events port: receives parent-observed supervision events
1093    // ├── worker: Worker
1094    // │   ├── child: TestChild
1095    // │   │   └── self-schedules TestChildCommand::Fail
1096    // │   └── worker-side liveness actor: KeepaliveWorker
1097    // └── parent: Parent
1098    //     └── supervisor: Supervisor
1099    //         └── supervisor-side liveness actor: KeepaliveSupervisor
1100    //
1101    // TestChild fails, Worker reports the child event to Supervisor's private
1102    // SupervisorEvent port, Supervisor re-raises it through
1103    // UnhandledSupervisionEvent, and Parent forwards the observed event to the
1104    // client events port.
1105    #[tokio::test]
1106    async fn test_child_failure_propagates_to_parent() {
1107        let proc = Proc::isolated();
1108        let client = proc.client("client");
1109        let session_id = Uid::anonymous();
1110
1111        let (child_addr, worker, parent, _stopped_rx, mut event_rx) = spawn_supervised_pair(
1112            &proc,
1113            &client,
1114            session_id.clone(),
1115            SupervisionOptions::default(),
1116            Some(TestChildAction::FailAfter(Duration::from_millis(200))),
1117        )
1118        .await;
1119
1120        let event = tokio::time::timeout(Duration::from_secs(5), event_rx.recv())
1121            .await
1122            .unwrap()
1123            .unwrap();
1124
1125        assert_eq!(event.actor_id, child_addr);
1126        assert!(matches!(event.actor_status, ActorStatus::Failed(_)));
1127
1128        parent.stop("test").unwrap();
1129        tokio::time::timeout(Duration::from_secs(5), parent)
1130            .await
1131            .unwrap();
1132        tokio::time::timeout(Duration::from_secs(5), worker)
1133            .await
1134            .unwrap();
1135    }
1136
1137    // Same harness as `test_child_failure_propagates_to_parent`, except the
1138    // Supervisor carries labels. The Worker stamps the labels it received in
1139    // `Supervise`, and the Supervisor replaces them with its own before
1140    // re-raising, so Parent observes the Supervisor's labels.
1141    #[tokio::test]
1142    async fn test_supervisor_labels_replace_child_event_labels() {
1143        let proc = Proc::isolated();
1144        let client = proc.client("client");
1145        let (ready, mut ready_rx) = client.open_port::<ActorAddr>();
1146        let (stopped, _stopped_rx) = client.open_port::<String>();
1147        let (events, mut event_rx) = client.open_port::<ActorSupervisionEvent>();
1148        let worker = proc.spawn(Worker::new());
1149        worker.post(
1150            &client,
1151            Spawn::new(test_child(
1152                ready,
1153                stopped,
1154                Some(TestChildAction::FailAfter(Duration::from_millis(200))),
1155            )),
1156        );
1157        let child_addr = ready_rx.recv().await.unwrap();
1158        let parent = proc.spawn(Parent {
1159            supervisor: Some(
1160                Supervisor::new_uid(
1161                    worker.bind::<WorkerLike>(),
1162                    KeepaliveLink::new(Duration::from_millis(5), Duration::from_secs(60)),
1163                    SupervisionOptions::default(),
1164                    Uid::anonymous(),
1165                )
1166                .with_labels(test_labels(7)),
1167            ),
1168            events,
1169        });
1170
1171        let event = tokio::time::timeout(Duration::from_secs(5), event_rx.recv())
1172            .await
1173            .unwrap()
1174            .unwrap();
1175        assert_eq!(event.actor_id, child_addr);
1176        assert_eq!(
1177            event.labels.get(TEST_LABEL),
1178            Some(7),
1179            "the parent should observe the supervisor's labels"
1180        );
1181
1182        parent.stop("test").unwrap();
1183        tokio::time::timeout(Duration::from_secs(5), parent)
1184            .await
1185            .unwrap();
1186        tokio::time::timeout(Duration::from_secs(5), worker)
1187            .await
1188            .unwrap();
1189    }
1190
1191    // The client stands in for the Supervisor and sends `Supervise` with
1192    // labels directly. When TestChild fails, the event the Worker reports on
1193    // the session port carries those labels, not the child's own (empty) ones.
1194    #[tokio::test]
1195    async fn test_worker_labels_child_event() {
1196        let proc = Proc::isolated();
1197        let client = proc.client("client");
1198        let (ready, mut ready_rx) = client.open_port::<ActorAddr>();
1199        let (stopped, _stopped_rx) = client.open_port::<String>();
1200        let (supervisor, mut supervisor_rx) = client.open_port::<SupervisorEvent>();
1201        let worker = proc.spawn(Worker::new());
1202        worker.post(
1203            &client,
1204            Spawn::new(test_child(
1205                ready,
1206                stopped,
1207                Some(TestChildAction::FailAfter(Duration::from_millis(200))),
1208            )),
1209        );
1210        let (liveness_handle, liveness) =
1211            KeepaliveLink::new(Duration::from_secs(60), Duration::from_secs(60))
1212                .spawn_supervisor(&client)
1213                .unwrap();
1214        let child_addr = ready_rx.recv().await.unwrap();
1215
1216        worker.post(
1217            &client,
1218            Supervise {
1219                session_id: Uid::anonymous(),
1220                supervisor: supervisor.bind(),
1221                parent: client.self_addr().clone(),
1222                liveness,
1223                options: SupervisionOptions::default(),
1224                labels: test_labels(11),
1225            },
1226        );
1227        let linked = tokio::time::timeout(Duration::from_secs(5), supervisor_rx.recv())
1228            .await
1229            .unwrap()
1230            .unwrap();
1231        assert!(matches!(linked, SupervisorEvent::Linked { .. }));
1232
1233        let reported = tokio::time::timeout(Duration::from_secs(5), supervisor_rx.recv())
1234            .await
1235            .unwrap()
1236            .unwrap();
1237        let SupervisorEvent::SupervisionEvent { event, .. } = reported else {
1238            panic!("expected a supervision event, got {reported:?}");
1239        };
1240        assert_eq!(event.actor_id, child_addr);
1241        assert!(matches!(event.actor_status, ActorStatus::Failed(_)));
1242        assert_eq!(
1243            event.labels.get(TEST_LABEL),
1244            Some(11),
1245            "the worker should stamp the session's labels"
1246        );
1247
1248        tokio::time::timeout(Duration::from_secs(5), worker)
1249            .await
1250            .unwrap();
1251        liveness_handle.stop("test").unwrap();
1252        tokio::time::timeout(Duration::from_secs(5), liveness_handle)
1253            .await
1254            .unwrap();
1255    }
1256
1257    // proc
1258    // ├── client instance
1259    // │   ├── ready port: receives the child address
1260    // │   ├── stopped port: receives the child stop reason
1261    // │   └── events port: present but not expected to receive an event
1262    // ├── worker: Worker
1263    // │   ├── child: TestChild
1264    // │   └── worker-side liveness actor: KeepaliveWorker
1265    // └── parent: Parent
1266    //     └── supervisor: Supervisor
1267    //         └── supervisor-side liveness actor: KeepaliveSupervisor
1268    //
1269    // Stopping Parent stops Supervisor, Supervisor forwards the same stop mode
1270    // and reason to Worker, Worker stops TestChild, and TestChild reports the
1271    // reason through the client stopped port before the handles complete.
1272    #[tokio::test]
1273    async fn test_parent_stop_stops_remote_child_before_parent_finishes() {
1274        let proc = Proc::isolated();
1275        let client = proc.client("client");
1276        let session_id = Uid::anonymous();
1277
1278        let (_child_addr, worker, parent, mut stopped_rx, _event_rx) = spawn_supervised_pair(
1279            &proc,
1280            &client,
1281            session_id.clone(),
1282            SupervisionOptions::default(),
1283            None,
1284        )
1285        .await;
1286
1287        tokio::time::sleep(Duration::from_millis(100)).await;
1288        parent.stop("test parent stopping").unwrap();
1289
1290        let reason = tokio::time::timeout(Duration::from_secs(5), stopped_rx.recv())
1291            .await
1292            .unwrap()
1293            .unwrap();
1294        assert_eq!(reason, "parent stopping");
1295
1296        tokio::time::timeout(Duration::from_secs(5), parent)
1297            .await
1298            .unwrap();
1299        tokio::time::timeout(Duration::from_secs(5), worker)
1300            .await
1301            .unwrap();
1302    }
1303
1304    // proc
1305    // ├── client instance
1306    // │   ├── ready port: receives the child address
1307    // │   ├── stopped port: receives the child stop reason
1308    // │   ├── supervisor port: receives the initial Linked message
1309    // │   └── supervisor-side liveness actor: KeepaliveSupervisor
1310    // └── worker: Worker
1311    //     ├── child: TestChild
1312    //     └── worker-side liveness actor: KeepaliveWorker
1313    //
1314    // Worker accepts Supervise and records an active supervisor session. The test
1315    // then injects an Undeliverable for a worker-sent message. Worker treats
1316    // the supervisor session as orphaned, applies OrphanPolicy::Stop, and
1317    // exits after the child stops.
1318    #[tokio::test]
1319    async fn test_worker_undeliverable_supervisor_session_stops_child() {
1320        let proc = Proc::isolated();
1321        let client = proc.client("client");
1322        let (ready, mut ready_rx) = client.open_port::<ActorAddr>();
1323        let (stopped, mut stopped_rx) = client.open_port::<String>();
1324        let (supervisor, mut supervisor_rx) = client.open_port::<SupervisorEvent>();
1325        let supervisor_ref = supervisor.bind();
1326        let worker = proc.spawn(Worker::new());
1327        worker.post(&client, Spawn::new(test_child(ready, stopped, None)));
1328        let (liveness_handle, liveness) =
1329            KeepaliveLink::new(Duration::from_secs(60), Duration::from_secs(60))
1330                .spawn_supervisor(&client)
1331                .unwrap();
1332        let _child_addr = ready_rx.recv().await.unwrap();
1333        let session_id = hyperactor::Uid::anonymous();
1334
1335        worker.post(
1336            &client,
1337            Supervise {
1338                session_id: session_id.clone(),
1339                supervisor: supervisor_ref.clone(),
1340                parent: client.self_addr().clone(),
1341                liveness,
1342                options: SupervisionOptions::default(),
1343                labels: Flattrs::new(),
1344            },
1345        );
1346        let linked = tokio::time::timeout(Duration::from_secs(5), supervisor_rx.recv())
1347            .await
1348            .unwrap()
1349            .unwrap();
1350        assert!(matches!(linked, SupervisorEvent::Linked { .. }));
1351
1352        let envelope = MessageEnvelope::serialize(
1353            worker.actor_addr().clone(),
1354            supervisor_ref.port_addr().clone(),
1355            &SupervisorEvent::Unlinked {
1356                session_id,
1357                reason: "test".to_string(),
1358            },
1359            hyperactor_config::Flattrs::new(),
1360        )
1361        .unwrap();
1362        worker
1363            .port::<Undeliverable<MessageEnvelope>>()
1364            .post(&client, Undeliverable::Returned(envelope));
1365
1366        let reason = tokio::time::timeout(Duration::from_secs(5), stopped_rx.recv())
1367            .await
1368            .unwrap()
1369            .unwrap();
1370        assert_eq!(reason, "supervisor session undeliverable");
1371
1372        let status = tokio::time::timeout(Duration::from_secs(5), worker)
1373            .await
1374            .unwrap();
1375        assert!(matches!(status, ActorStatus::Stopped(_)));
1376
1377        liveness_handle.stop("test").unwrap();
1378        tokio::time::timeout(Duration::from_secs(5), liveness_handle)
1379            .await
1380            .unwrap();
1381    }
1382
1383    // proc
1384    // ├── client instance
1385    // │   ├── ready port: receives the child address
1386    // │   ├── stopped port: receives the child stop reason
1387    // │   └── events port: present but not expected to receive an event
1388    // ├── worker: Worker
1389    // │   ├── child: TestChild
1390    // │   └── worker-side liveness actor: KeepaliveWorker
1391    // └── grandparent: Grandparent
1392    //     └── parent: Parent
1393    //         └── supervisor: Supervisor
1394    //             └── supervisor-side liveness actor: KeepaliveSupervisor
1395    //
1396    // Killing Parent still tears down its local supervision subtree. Grandparent
1397    // handles the parent failure so the test process does not treat it as an
1398    // unhandled root failure. Parent stops Supervisor, Supervisor forwards the
1399    // stop to Worker, and Worker stops TestChild.
1400    #[tokio::test]
1401    async fn test_parent_kill_stops_remote_child() {
1402        let proc = Proc::isolated();
1403        let client = proc.client("client");
1404        let (ready, mut ready_rx) = client.open_port::<ActorAddr>();
1405        let (stopped, mut stopped_rx) = client.open_port::<String>();
1406        let (events, mut event_rx) = client.open_port::<ActorSupervisionEvent>();
1407        let (parent_addr, mut parent_addr_rx) = client.open_port::<ActorAddr>();
1408        let worker = proc.spawn(Worker::new());
1409        worker.post(&client, Spawn::new(test_child(ready, stopped, None)));
1410        let grandparent = proc.spawn(Grandparent {
1411            parent: Some(Parent {
1412                supervisor: Some(Supervisor::new(
1413                    worker.bind::<WorkerLike>(),
1414                    KeepaliveLink::new(Duration::from_millis(100), Duration::from_millis(300)),
1415                    SupervisionOptions::default(),
1416                )),
1417                events: events.clone(),
1418            }),
1419            parent_addr,
1420            parent_handle: None,
1421            events,
1422        });
1423        let _child_addr = ready_rx.recv().await.unwrap();
1424        let parent_addr = parent_addr_rx.recv().await.unwrap();
1425
1426        tokio::time::sleep(Duration::from_millis(100)).await;
1427        grandparent.post(&client, KillParent);
1428
1429        let reason = tokio::time::timeout(Duration::from_secs(5), stopped_rx.recv())
1430            .await
1431            .unwrap()
1432            .unwrap();
1433        assert_eq!(reason, "parent stopping");
1434
1435        let event = tokio::time::timeout(Duration::from_secs(5), event_rx.recv())
1436            .await
1437            .unwrap()
1438            .unwrap();
1439        assert_eq!(event.actor_id, parent_addr);
1440        assert!(matches!(event.actor_status, ActorStatus::Failed(_)));
1441
1442        grandparent.stop("test").unwrap();
1443        tokio::time::timeout(Duration::from_secs(5), grandparent)
1444            .await
1445            .unwrap();
1446        tokio::time::timeout(Duration::from_secs(5), worker)
1447            .await
1448            .unwrap();
1449    }
1450
1451    // proc
1452    // ├── client instance
1453    // │   ├── ready port: receives the child address
1454    // │   ├── stopped port: not expected to fire (Detach leaves child running)
1455    // │   ├── supervisor port: receives the initial Linked message
1456    // │   └── supervisor-side liveness actor: KeepaliveSupervisor
1457    // └── worker: Worker
1458    //     ├── child: TestChild
1459    //     └── worker-side liveness actor: KeepaliveWorker
1460    //
1461    // Worker accepts Supervise with OrphanPolicy::Detach and records an
1462    // active supervisor session. The test then injects an
1463    // Undeliverable for a worker-sent message. Worker treats the
1464    // supervisor session as orphaned, stops the liveness actor and clears
1465    // the session, but does NOT stop the child. Worker remains alive
1466    // with no active session.
1467    #[tokio::test]
1468    async fn test_detach_orphan_policy_leaves_child_running_on_supervisor_loss() {
1469        let proc = Proc::isolated();
1470        let inst = proc.client("inst");
1471        let (ready, mut ready_rx) = inst.open_port::<ActorAddr>();
1472        let (stopped, mut stopped_rx) = inst.open_port::<String>();
1473        let (supervisor, mut supervisor_rx) = inst.open_port::<SupervisorEvent>();
1474        let supervisor_ref = supervisor.bind();
1475        let worker = proc.spawn(Worker::new());
1476        worker.post(&inst, Spawn::new(test_child(ready, stopped, None)));
1477        let (keep_alive, link_spec) =
1478            KeepaliveLink::new(Duration::from_secs(60), Duration::from_secs(60))
1479                .spawn_supervisor(&inst)
1480                .unwrap();
1481        let _child_addr = ready_rx.recv().await.unwrap();
1482        let session_id = hyperactor::Uid::anonymous();
1483
1484        worker.post(
1485            &inst,
1486            Supervise {
1487                session_id: session_id.clone(),
1488                supervisor: supervisor_ref.clone(),
1489                parent: inst.self_addr().clone(),
1490                liveness: link_spec,
1491                options: SupervisionOptions {
1492                    orphan_policy: OrphanPolicy::Detach,
1493                    ..Default::default()
1494                },
1495                labels: Flattrs::new(),
1496            },
1497        );
1498        let linked = tokio::time::timeout(Duration::from_secs(5), supervisor_rx.recv())
1499            .await
1500            .unwrap()
1501            .unwrap();
1502        assert!(matches!(linked, SupervisorEvent::Linked { .. }));
1503
1504        // Forge an undeliverable bounce for a worker-to-supervisor
1505        // message - a production trigger for
1506        // `handle_orphaned_supervisor` where `OrphanPolicy` is
1507        // applied.
1508        let envelope = MessageEnvelope::serialize(
1509            worker.actor_addr().clone(),
1510            supervisor_ref.port_addr().clone(),
1511            &SupervisorEvent::Unlinked {
1512                session_id,
1513                reason: "test".to_string(),
1514            },
1515            hyperactor_config::Flattrs::new(),
1516        )
1517        .unwrap();
1518        worker
1519            .port::<Undeliverable<MessageEnvelope>>()
1520            .post(&inst, Undeliverable::Returned(envelope));
1521
1522        // Under `Detach`, the worker clears its session and stops the
1523        // liveness, but does NOT stop the child. No message shhould arrive on
1524        // the stopped port within a generous timeout.
1525        let timed_out = tokio::time::timeout(Duration::from_millis(500), stopped_rx.recv()).await;
1526        assert!(
1527            timed_out.is_err(),
1528            "child should not be stopped under OrphanPolicy::Detach"
1529        );
1530
1531        // The worker itself is still alive. Cleanly stopping it should
1532        // succeed and yield a Stopped status.
1533        worker.stop("test").unwrap();
1534        let status = tokio::time::timeout(Duration::from_secs(5), worker)
1535            .await
1536            .unwrap();
1537        assert!(
1538            matches!(status, ActorStatus::Stopped(_)),
1539            "worker should accept clean stop after Detach orphan, got {:?}",
1540            status
1541        );
1542
1543        keep_alive.stop("test").unwrap();
1544        tokio::time::timeout(Duration::from_secs(5), keep_alive)
1545            .await
1546            .unwrap();
1547    }
1548
1549    // proc
1550    // ├── client instance
1551    // │   ├── ready port: receives the child address
1552    // │   ├── stopped port: receives the child stop reason
1553    // │   └── events port: receives parent-observed supervision events
1554    // ├── worker: Worker
1555    // │   ├── child: TestChild
1556    // │   └── worker-side liveness actor: KeepaliveWorker
1557    // └── parent: Parent
1558    //     └── supervisor: Supervisor (constructed with a known session id)
1559    //         └── supervisor-side liveness actor: KeepaliveSupervisor
1560    //
1561    // After the link is established the test sends
1562    // `WorkerCommand::Unlink` to the worker with the matching
1563    // session id. Under `OrphanPolicy::Stop` the worker stops the link
1564    // actor, stops the child with the unlink reason, and sends
1565    // `Unlinked` back. The supervisor receives `Unlinked` and exits
1566    // cleanly via `cx.exit(reason)`; the parent observes that exit as a
1567    // `Stopped(reason)` supervision event. The worker then observes the
1568    // child's terminal event and exits as well.
1569    #[tokio::test]
1570    async fn test_supervised_worker_unlink_stops_child_under_stop_policy() {
1571        let proc = Proc::isolated();
1572        let inst = proc.client("inst");
1573        let session_id = Uid::anonymous();
1574
1575        let (_child_addr, worker, parent, mut stopped_rx, mut events_rx) = spawn_supervised_pair(
1576            &proc,
1577            &inst,
1578            session_id.clone(),
1579            SupervisionOptions::default(),
1580            None,
1581        )
1582        .await;
1583
1584        // Give the Supervise/Linked handshake time to complete before
1585        // issuing Unlink.
1586        tokio::time::sleep(Duration::from_millis(100)).await;
1587
1588        worker.post(
1589            &inst,
1590            WorkerCommand::Unlink {
1591                session_id: session_id.clone(),
1592                reason: "test unlink".to_string(),
1593            },
1594        );
1595
1596        // Under OrphanPolicy::Stop the worker stops the child with
1597        // the unlink reason.
1598        let reason = tokio::time::timeout(Duration::from_secs(5), stopped_rx.recv())
1599            .await
1600            .unwrap()
1601            .unwrap();
1602        assert_eq!(reason, "test unlink");
1603
1604        // Supervisor receives `Unlinked` and exits cleanly via
1605        // cx.exit; Parent observes that supervisor exit as a
1606        // `Stopped(reason)` event.
1607        let event = tokio::time::timeout(Duration::from_secs(5), events_rx.recv())
1608            .await
1609            .unwrap()
1610            .unwrap();
1611        match event.actor_status {
1612            ActorStatus::Stopped(reason) => assert_eq!(reason, "test unlink"),
1613            status => panic!("expected supervisor Stopped event, got {:?}", status),
1614        }
1615
1616        // Worker observed the child's terminal event and exited as
1617        // well. No `SupervisionEvent { Terminal }` is forwarded
1618        // because the Unlink handler already cleared the session -
1619        // `Unlinked` is the sole protocol message in this path.
1620        let status = tokio::time::timeout(Duration::from_secs(5), worker)
1621            .await
1622            .unwrap();
1623        assert!(matches!(status, ActorStatus::Stopped(_)));
1624
1625        parent.stop("test").unwrap();
1626        tokio::time::timeout(Duration::from_secs(5), parent)
1627            .await
1628            .unwrap();
1629    }
1630
1631    // proc
1632    // ├── client instance
1633    // │   ├── ready port: receives the child address
1634    // │   ├── stopped port: not expected to fire (Detach leaves child running)
1635    // │   └── events port: receives parent-observed supervision events
1636    // ├── worker: Worker
1637    // │   ├── child: TestChild
1638    // │   └── worker-side liveness actor: KeepaliveWorker
1639    // └── parent: Parent
1640    //     └── supervisor: Supervisor (constructed with a known session id)
1641    //         └── supervisor-side liveness actor: KeepaliveSupervisor
1642    //
1643    // After the link is established the test sends
1644    // `WorkerCommand::Unlink` to the worker with the matching
1645    // session id. Under `OrphanPolicy::Detach` the worker stops the
1646    // liveness actor and clears the session but does NOT stop the child; it
1647    // then sends `Unlinked` back. The supervisor receives `Unlinked`
1648    // and exits cleanly via `cx.exit(reason)`; the parent observes that
1649    // exit as a `Stopped(reason)` supervision event. The child and the
1650    // worker both remain alive; the test cleanly stops the worker for
1651    // teardown.
1652    #[tokio::test]
1653    async fn test_supervised_worker_unlink_leaves_child_running_under_detach_policy() {
1654        let proc = Proc::isolated();
1655        let inst = proc.client("inst");
1656        let session_id = Uid::anonymous();
1657
1658        let (_child_addr, worker, parent, mut stopped_rx, mut events_rx) = spawn_supervised_pair(
1659            &proc,
1660            &inst,
1661            session_id.clone(),
1662            SupervisionOptions {
1663                orphan_policy: OrphanPolicy::Detach,
1664                ..Default::default()
1665            },
1666            None,
1667        )
1668        .await;
1669
1670        // Give the Supervise/Linked handshake time to complete before
1671        // issuing Unlink.
1672        tokio::time::sleep(Duration::from_millis(100)).await;
1673
1674        worker.post(
1675            &inst,
1676            WorkerCommand::Unlink {
1677                session_id: session_id.clone(),
1678                reason: "test unlink".to_string(),
1679            },
1680        );
1681
1682        // Supervisor receives `Unlinked` and exits cleanly via cx.exit;
1683        // Parent observes that supervisor exit as a `Stopped(reason)`
1684        // event. This is the positive signal that the Unlink round-trip
1685        // has completed; only after this can we meaningfully assert the
1686        // negative (no child stop).
1687        let event = tokio::time::timeout(Duration::from_secs(5), events_rx.recv())
1688            .await
1689            .unwrap()
1690            .unwrap();
1691        match event.actor_status {
1692            ActorStatus::Stopped(reason) => assert_eq!(reason, "test unlink"),
1693            status => panic!("expected supervisor Stopped event, got {:?}", status),
1694        }
1695
1696        // Under OrphanPolicy::Detach the worker does NOT stop the
1697        // child. The stopped port should receive no message within a
1698        // generous timeout window.
1699        let timed_out = tokio::time::timeout(Duration::from_millis(500), stopped_rx.recv()).await;
1700        assert!(
1701            timed_out.is_err(),
1702            "child should not be stopped under OrphanPolicy::Detach"
1703        );
1704
1705        // The worker is still alive: Unlink left the child running, so
1706        // no child-terminal event cascaded the worker into
1707        // `this.exit("supervised child stopped")`. A clean stop
1708        // succeeds and yields Stopped(_).
1709        worker.stop("test").unwrap();
1710        let status = tokio::time::timeout(Duration::from_secs(5), worker)
1711            .await
1712            .unwrap();
1713        assert!(
1714            matches!(status, ActorStatus::Stopped(_)),
1715            "worker should accept clean stop after Detach unlink, got {:?}",
1716            status
1717        );
1718
1719        parent.stop("test").unwrap();
1720        tokio::time::timeout(Duration::from_secs(5), parent)
1721            .await
1722            .unwrap();
1723    }
1724
1725    // proc
1726    // ├── client instance
1727    // │   ├── ready port: receives the child address (consumed by helper)
1728    // │   ├── stopped port: not expected to fire
1729    // │   ├── events1 port: parent1's supervision events (silent — first link succeeds)
1730    // │   └── events2 port: parent2's supervision events (Failed with bail reason)
1731    // ├── worker: Worker
1732    // │   ├── child: TestChild
1733    // │   └── worker-side liveness actor: KeepaliveWorker (under session_id1)
1734    // ├── parent1: Parent
1735    // │   └── supervisor1: Supervisor (session_id1) — wins the race, receives Linked
1736    // └── parent2: Parent
1737    //     └── supervisor2: Supervisor (session_id2) — rejected, bails
1738    //
1739    // Spawn worker + parent1 via the helper, sleep 100ms so the
1740    // worker has `self.session = Some(...)` before parent2's Supervise
1741    // arrives, then spawn a second parent whose Supervisor targets
1742    // the same worker with a different session id. supervisor2 sends
1743    // `Supervise`, the worker sees `self.session.is_some()` and replies
1744    // with `SupervisorEvent::SuperviseRejected { reason: "worker already
1745    // linked" }` (`supervision.rs:181`). supervisor2's
1746    // `Handler<SupervisorEvent>` arm bails with "remote supervision
1747    // link rejected: worker already linked", supervisor2 fails,
1748    // parent2 observes that failure on events_rx2.
1749    #[tokio::test]
1750    async fn test_concurrent_link_rejects_second_supervisor() {
1751        let proc = Proc::isolated();
1752        let inst = proc.client("inst");
1753        let session_id1 = Uid::anonymous();
1754        let (_child_addr, worker, parent1, _stopped_rx, mut events_rx1) = spawn_supervised_pair(
1755            &proc,
1756            &inst,
1757            session_id1.clone(),
1758            SupervisionOptions::default(),
1759            None,
1760        )
1761        .await;
1762
1763        // Let the first Supervise/Linked handshake complete so the worker
1764        // has self.session.is_some() = true before parent2's Supervise
1765        // arrives.
1766        tokio::time::sleep(Duration::from_millis(100)).await;
1767
1768        // Spawn a second parent whose Supervisor targets the same
1769        // worker with a different session id. Its Supervise will be
1770        // rejected.
1771        let session_id2 = Uid::anonymous();
1772        let (events2, mut events_rx2) = inst.open_port::<ActorSupervisionEvent>();
1773        let parent2 = proc.spawn_with_label(
1774            "parent2",
1775            Parent {
1776                supervisor: Some(Supervisor::new_uid(
1777                    worker.bind::<WorkerLike>(),
1778                    KeepaliveLink::new(Duration::from_millis(5), Duration::from_secs(60)),
1779                    SupervisionOptions::default(),
1780                    session_id2.clone(),
1781                )),
1782                events: events2,
1783            },
1784        );
1785
1786        // supervisor2 receives SupervisorEvent::SuperviseRejected and
1787        // bails with "remote supervision request rejected: worker
1788        // already supervised". parent2 observes that
1789        // failure.
1790        let event = tokio::time::timeout(Duration::from_secs(5), events_rx2.recv())
1791            .await
1792            .unwrap()
1793            .unwrap();
1794        assert!(
1795            matches!(event.actor_status, ActorStatus::Failed(_)),
1796            "expected supervisor2 Failed event, got {:?}",
1797            event.actor_status
1798        );
1799        assert!(
1800            event
1801                .to_string()
1802                .contains("remote supervision request rejected: worker already supervised"),
1803            "expected SuperviseRejected bail string in failure message, got: {}",
1804            event
1805        );
1806
1807        // parent1's supervisor stays linked and healthy: its events
1808        // port should not receive any failure within a short window.
1809        let timed_out = tokio::time::timeout(Duration::from_millis(200), events_rx1.recv()).await;
1810        assert!(
1811            timed_out.is_err(),
1812            "first supervisor should remain linked, no failure expected"
1813        );
1814
1815        parent1.stop("test").unwrap();
1816        tokio::time::timeout(Duration::from_secs(5), parent1)
1817            .await
1818            .unwrap();
1819        // Worker exits as a downstream effect of parent1's stop
1820        // (cascading WorkerCommand::Stop -> child stop -> Worker
1821        // exit).
1822        tokio::time::timeout(Duration::from_secs(5), worker)
1823            .await
1824            .unwrap();
1825
1826        parent2.stop("test").unwrap();
1827        tokio::time::timeout(Duration::from_secs(5), parent2)
1828            .await
1829            .unwrap();
1830    }
1831
1832    // proc
1833    // ├── client instance
1834    // │   ├── link_started port: signals when TestSlowWorker enters Handler<Supervise>
1835    // │   ├── received_stop port: signals when Stop arrives at the worker side
1836    // │   └── events port: parent's supervision events (Parent absorbs)
1837    // ├── worker: TestSlowWorker (fake WorkerLike, never sends Linked)
1838    // └── parent: Parent
1839    //     └── supervisor: Supervisor (constructed with a known session id)
1840    //         └── supervisor-side liveness actor: KeepaliveSupervisor
1841    //
1842    // Protocol-surface test for `Supervisor::pending_stop`. The
1843    // fake worker records the session, signals link_started, and
1844    // blocks on a Notify the test holds. The test calls
1845    // `parent.stop` while the worker is still in Handler<Supervise>,
1846    // then releases the worker. The pending_stop machinery must
1847    // have already queued `WorkerCommand::Stop` carrying the
1848    // parent's reason and the matching session_id. The worker
1849    // validates the session_id, sends the reason via
1850    // `received_stop`, then emits a synthetic `Unlinked` so the
1851    // supervisor exits cleanly via cx.exit (the Unlinked arm only
1852    // checks session_id; it does not require a prior Linked).
1853    #[tokio::test]
1854    async fn test_stop_before_linked_propagates_via_pending_stop() {
1855        let proc = Proc::isolated();
1856        let inst = proc.client("inst");
1857        let (link_started, mut link_started_rx) = inst.open_port::<()>();
1858        let (received_stop, mut received_stop_rx) = inst.open_port::<String>();
1859        let (events, _events_rx) = inst.open_port::<ActorSupervisionEvent>();
1860        let release_link = Arc::new(Notify::new());
1861        let worker = proc.spawn(TestSlowWorker {
1862            link_started,
1863            release_link: Arc::clone(&release_link),
1864            received_stop,
1865            session: None,
1866        });
1867        let session_id = Uid::anonymous();
1868        let parent = proc.spawn(Parent {
1869            supervisor: Some(Supervisor::new_uid(
1870                worker.bind::<WorkerLike>(),
1871                KeepaliveLink::new(Duration::from_millis(5), Duration::from_secs(60)),
1872                SupervisionOptions::default(),
1873                session_id.clone(),
1874            )),
1875            events,
1876        });
1877
1878        // Observe Supervise receipt - worker is now blocked in Handler<Supervise>.
1879        tokio::time::timeout(Duration::from_secs(5), link_started_rx.recv())
1880            .await
1881            .unwrap()
1882            .unwrap();
1883
1884        // `Supervisor::handle_stop` does not call `cx.exit` — the
1885        // only path that exits the supervisor is
1886        // `SupervisorEvent::Unlinked`. That's why `TestSlowWorker`
1887        // emits `Unlinked` in its `Stop` handler.
1888        parent.stop("test stop").unwrap();
1889        release_link.notify_one();
1890
1891        let stop_reason = tokio::time::timeout(Duration::from_secs(5), received_stop_rx.recv())
1892            .await
1893            .unwrap()
1894            .unwrap();
1895        assert_eq!(stop_reason, "parent stopping");
1896
1897        tokio::time::timeout(Duration::from_secs(5), parent)
1898            .await
1899            .unwrap();
1900        worker.stop("test").unwrap();
1901        tokio::time::timeout(Duration::from_secs(5), worker)
1902            .await
1903            .unwrap();
1904    }
1905
1906    // proc
1907    // ├── client instance
1908    // │   ├── ready port: receives the child address
1909    // │   ├── stopped port: receives the child stop reason
1910    // │   ├── drained port: receives the child's work tag
1911    // │   └── events port: parent's supervision events (silent — Parent absorbs)
1912    // ├── worker: Worker (with DrainAfter(10ms, "child work"))
1913    // │   ├── child: TestChild
1914    // │   └── worker-side liveness actor: KeepaliveWorker
1915    // └── parent: Parent
1916    //     └── supervisor: Supervisor (constructed with a known session id)
1917    //         └── supervisor-side liveness actor: KeepaliveSupervisor
1918    //
1919    // End-to-end proxy for the worker->child DrainAndStop path:
1920    // verifies that `Worker::stop_child` takes the `DrainAndStop`
1921    // branch, `TestChild::handle_stop` takes `exit_after_drain`, and
1922    // the child can process ordinary work before terminating. It does
1923    // not force work to remain queued at the instant stop arrives;
1924    // that stricter property would require a blocked-handler style
1925    // test.
1926    #[tokio::test]
1927    async fn test_drain_and_stop_propagates_through_worker_after_child_work() {
1928        let proc = Proc::isolated();
1929        let inst = proc.client("inst");
1930        let (ready, mut ready_rx) = inst.open_port::<ActorAddr>();
1931        let (stopped, mut stopped_rx) = inst.open_port::<String>();
1932        let (drained, mut drained_rx) = inst.open_port::<String>();
1933        let (events, _events_rx) = inst.open_port::<ActorSupervisionEvent>();
1934        let worker = proc.spawn(Worker::new());
1935        worker.post(
1936            &inst,
1937            Spawn::new(
1938                test_child(
1939                    ready,
1940                    stopped,
1941                    Some(TestChildAction::DrainAfter(
1942                        Duration::from_millis(10),
1943                        "child work".to_string(),
1944                    )),
1945                )
1946                .with_drain_observer(drained),
1947            ),
1948        );
1949        let _child_addr = ready_rx.recv().await.unwrap();
1950        let session_id = Uid::anonymous();
1951        let parent = proc.spawn(Parent {
1952            supervisor: Some(Supervisor::new_uid(
1953                worker.bind::<WorkerLike>(),
1954                KeepaliveLink::new(Duration::from_millis(5), Duration::from_secs(60)),
1955                SupervisionOptions::default(),
1956                session_id.clone(),
1957            )),
1958            events,
1959        });
1960
1961        // Wait for the self-scheduled child work.
1962        let drained_tag = tokio::time::timeout(Duration::from_secs(5), drained_rx.recv())
1963            .await
1964            .unwrap()
1965            .unwrap();
1966        assert_eq!(drained_tag, "child work");
1967
1968        // Now send Stop with DrainAndStop.
1969        worker.post(
1970            &inst,
1971            WorkerCommand::Stop {
1972                session_id: session_id.clone(),
1973                mode: StopMode::DrainAndStop,
1974                reason: "drain test".to_string(),
1975            },
1976        );
1977
1978        // Child reports the drain-stop reason via handle_stop.
1979        let reason = tokio::time::timeout(Duration::from_secs(5), stopped_rx.recv())
1980            .await
1981            .unwrap()
1982            .unwrap();
1983        assert_eq!(reason, "drain test");
1984
1985        // Worker exits as a downstream effect of the child stop.
1986        let status = tokio::time::timeout(Duration::from_secs(5), worker)
1987            .await
1988            .unwrap();
1989        assert!(matches!(status, ActorStatus::Stopped(_)));
1990
1991        parent.stop("test").unwrap();
1992        tokio::time::timeout(Duration::from_secs(5), parent)
1993            .await
1994            .unwrap();
1995    }
1996
1997    // proc
1998    // ├── client instance
1999    // │   ├── ready port: child address
2000    // │   ├── stopped port: child stop reasons (silent across both unlinks under Detach)
2001    // │   ├── events1 port: parent1's supervision events
2002    // │   └── events2 port: parent2's supervision events
2003    // ├── worker: Worker
2004    // │   └── child: TestChild (survives both unlinks under Detach)
2005    // ├── parent1: Parent
2006    // │   └── supervisor1 (session_id1, OrphanPolicy::Detach)
2007    // └── parent2: Parent
2008    //     └── supervisor2 (session_id2, OrphanPolicy::Detach)
2009    //
2010    // Verifies the worker accepts a fresh Supervise after Unlink clears
2011    // self.session. Under OrphanPolicy::Detach the child and worker
2012    // survive the first unlink, allowing a second supervisor with
2013    // session_id2 to link. A second Unlink with session_id2 confirms
2014    // accept_session_id installed the new session.
2015    #[tokio::test]
2016    async fn test_worker_accepts_relink_after_unlink_under_detach_policy() {
2017        let proc = Proc::isolated();
2018        let inst = proc.client("inst");
2019        let session_id1 = Uid::anonymous();
2020        let (_child_addr, worker, parent1, mut stopped_rx, mut events_rx1) = spawn_supervised_pair(
2021            &proc,
2022            &inst,
2023            session_id1.clone(),
2024            SupervisionOptions {
2025                orphan_policy: OrphanPolicy::Detach,
2026                ..Default::default()
2027            },
2028            None,
2029        )
2030        .await;
2031
2032        // Give the first Supervise/Linked handshake time to complete before
2033        // issuing Unlink.
2034        tokio::time::sleep(Duration::from_millis(100)).await;
2035
2036        worker.post(
2037            &inst,
2038            WorkerCommand::Unlink {
2039                session_id: session_id1.clone(),
2040                reason: "first unlink".to_string(),
2041            },
2042        );
2043
2044        let event1 = tokio::time::timeout(Duration::from_secs(5), events_rx1.recv())
2045            .await
2046            .unwrap()
2047            .unwrap();
2048        match event1.actor_status {
2049            ActorStatus::Stopped(reason) => assert_eq!(reason, "first unlink"),
2050            status => panic!("expected supervisor1 Stopped event, got {:?}", status),
2051        }
2052
2053        let timed_out = tokio::time::timeout(Duration::from_millis(500), stopped_rx.recv()).await;
2054        assert!(
2055            timed_out.is_err(),
2056            "child should survive unlink under OrphanPolicy::Detach"
2057        );
2058
2059        let session_id2 = Uid::anonymous();
2060        let (events2, mut events_rx2) = inst.open_port::<ActorSupervisionEvent>();
2061        let parent2 = proc.spawn_with_label(
2062            "parent2",
2063            Parent {
2064                supervisor: Some(Supervisor::new_uid(
2065                    worker.bind::<WorkerLike>(),
2066                    KeepaliveLink::new(Duration::from_millis(5), Duration::from_secs(60)),
2067                    SupervisionOptions {
2068                        orphan_policy: OrphanPolicy::Detach,
2069                        ..Default::default()
2070                    },
2071                    session_id2.clone(),
2072                )),
2073                events: events2,
2074            },
2075        );
2076
2077        // Give the second Supervise/Linked handshake time to complete.
2078        tokio::time::sleep(Duration::from_millis(100)).await;
2079
2080        worker.post(
2081            &inst,
2082            WorkerCommand::Unlink {
2083                session_id: session_id2.clone(),
2084                reason: "second unlink".to_string(),
2085            },
2086        );
2087
2088        let event2 = tokio::time::timeout(Duration::from_secs(5), events_rx2.recv())
2089            .await
2090            .unwrap()
2091            .unwrap();
2092        match event2.actor_status {
2093            ActorStatus::Stopped(reason) => assert_eq!(reason, "second unlink"),
2094            status => panic!("expected supervisor2 Stopped event, got {:?}", status),
2095        }
2096
2097        parent1.stop("test").unwrap();
2098        tokio::time::timeout(Duration::from_secs(5), parent1)
2099            .await
2100            .unwrap();
2101        parent2.stop("test").unwrap();
2102        tokio::time::timeout(Duration::from_secs(5), parent2)
2103            .await
2104            .unwrap();
2105        worker.stop("test").unwrap();
2106        tokio::time::timeout(Duration::from_secs(5), worker)
2107            .await
2108            .unwrap();
2109    }
2110}