Skip to main content

hyperactor_mesh/
proc_agent.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! The mesh agent actor manages procs in ProcMeshes.
10
11// EnumAsInner generates code that triggers a false positive
12// unused_assignments lint on struct variant fields. #[allow] on the
13// enum itself doesn't propagate into derive-macro-generated code, so
14// the suppression must be at module scope.
15#![allow(unused_assignments)]
16
17use std::collections::HashMap;
18use std::time::Duration;
19
20use async_trait::async_trait;
21use hyperactor::Actor;
22use hyperactor::ActorAddr;
23use hyperactor::ActorEnvironment;
24use hyperactor::ActorHandle;
25use hyperactor::Addr;
26use hyperactor::Client;
27use hyperactor::Context;
28use hyperactor::Data;
29use hyperactor::Endpoint as _;
30use hyperactor::Handler;
31use hyperactor::IdleFlushPortRef;
32use hyperactor::Instance;
33use hyperactor::PortAddr;
34use hyperactor::PortHandle;
35use hyperactor::PortRef;
36use hyperactor::RemoteEndpoint as _;
37use hyperactor::actor::handle_undeliverable_message;
38use hyperactor::actor::remote::Remote;
39use hyperactor::id::Label;
40use hyperactor::id::Uid;
41use hyperactor::mailbox::MessageEnvelope;
42use hyperactor::mailbox::Undeliverable;
43use hyperactor::mailbox::UndeliverableReason;
44use hyperactor::proc::Proc;
45use hyperactor::supervision::ActorSupervisionEvent;
46use hyperactor_cast::cast_actor::CAST_ACTOR_NAME;
47use hyperactor_cast::cast_actor::CAST_POINT;
48use hyperactor_config::CONFIG;
49use hyperactor_config::ConfigAttr;
50use hyperactor_config::Flattrs;
51use hyperactor_config::attrs::declare_attrs;
52use hyperactor_remote::ActorSpawner;
53use hyperactor_remote::proc_spawner::actor_spawner_uid;
54use serde::Deserialize;
55use serde::Serialize;
56use typeuri::Named;
57
58use crate::client_root::CLIENT_ROOT;
59use crate::client_root::ClientRootApi;
60use crate::client_root::ClientRootError;
61use crate::client_root::ClientRootRef;
62use crate::client_root::EnsureClientRootService;
63use crate::client_root::EnsureClientRootServiceReply;
64use crate::config_dump::ConfigDump;
65use crate::config_dump::ConfigDumpResult;
66use crate::introspect::ProcessMemoryStats;
67use crate::mesh_id::ResourceId;
68use crate::pyspy::PySpyDump;
69use crate::pyspy::PySpyProfile;
70use crate::pyspy::PySpyProfileWorker;
71use crate::pyspy::PySpyWorker;
72use crate::resource;
73
74/// Actor name used when spawning the proc agent on user procs.
75pub const PROC_AGENT_ACTOR_NAME: &str = "proc_agent";
76
77declare_attrs! {
78    /// Whether to self kill actors, procs, and hosts whose owner is not
79    /// reachable. `None` disables orphan cleanup entirely; `Some(d)` sets the
80    /// keepalive expiry to `d`.
81    @meta(CONFIG = ConfigAttr::new(
82        Some("HYPERACTOR_MESH_ORPHAN_TIMEOUT".to_string()),
83        Some("mesh_orphan_timeout".to_string()),
84    ))
85    pub attr MESH_ORPHAN_TIMEOUT: Option<Duration> = Some(Duration::from_secs(60));
86
87    /// Interval at which each ProcAgent republishes introspection
88    /// on a periodic timer and emits the
89    /// `process.memory.rss_bytes` / `process.memory.vm_bytes`
90    /// Scuba/OTLP gauges. Linux only — has no effect on other
91    /// platforms. `Duration::ZERO` disables the periodic timer
92    /// (gauges then never fire); non-periodic republishes (boot,
93    /// post-spawn, supervision-event coalesce) still publish
94    /// introspect attrs but do not emit gauges.
95    @meta(CONFIG = ConfigAttr::new(
96        Some("HYPERACTOR_PROCESS_MEMORY_METRIC_INTERVAL".to_string()),
97        Some("process_memory_metric_interval".to_string()),
98    ))
99    pub attr PROCESS_MEMORY_METRIC_INTERVAL: Duration = Duration::from_secs(300);
100
101    /// Header tag for StreamState subscriber messages. When present on an
102    /// undeliverable envelope, ProcAgent removes the dead subscriber instead
103    /// of treating it as an error.
104    pub(crate) attr STREAM_STATE_SUBSCRIBER: bool;
105}
106
107/// Deferred republish of introspect properties.
108///
109/// Carries an `emit_memory_metrics` flag distinguishing two senders:
110///
111/// - `emit_memory_metrics: false` — sent from the supervision event
112///   handler with a delay so the supervision handler returns
113///   immediately without blocking the ProcAgent message loop.
114///   Multiple rapid supervision events (e.g., 4 actors failing
115///   simultaneously via broadcast) coalesce into a single republish
116///   via the `introspect_dirty` flag. Without this, calling
117///   `publish_introspect_properties` inline in the supervision
118///   handler starves `GetRankStatus` polls from the
119///   `ActorMeshController`, preventing `__supervise__` from firing
120///   within the test timeout. See D94960791 for the root cause
121///   analysis.
122///
123/// - `emit_memory_metrics: true` — sent from `Actor::init` and
124///   re-armed by the handler at the `PROCESS_MEMORY_METRIC_INTERVAL`
125///   cadence.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
127struct RepublishIntrospect {
128    emit_memory_metrics: bool,
129}
130wirevalue::register_type!(RepublishIntrospect);
131
132/// Collect live actor children and system actor children from the
133/// proc's instance DashMap using `all_instance_keys()` with point
134/// lookups. This avoids the convoy starvation from `all_actor_ids()`
135/// which holds shard read locks while doing heavy per-entry work.
136/// See S12 in `introspect` module doc.
137fn collect_live_children(
138    proc: &hyperactor::Proc,
139) -> (
140    Vec<hyperactor::introspect::IntrospectRef>,
141    Vec<crate::introspect::NodeRef>,
142) {
143    let all_keys = proc.all_instance_keys();
144    let mut children = Vec::with_capacity(all_keys.len());
145    let mut system_children = Vec::new();
146    for id in all_keys {
147        if let Some(cell) = proc.get_instance_by_id(&id) {
148            let actor_addr = cell.actor_addr().clone();
149            if cell.is_system() {
150                system_children.push(crate::introspect::NodeRef::Actor(actor_addr.clone()));
151            }
152            children.push(hyperactor::introspect::IntrospectRef::Actor(actor_addr));
153        }
154    }
155    (children, system_children)
156}
157
158/// Actor state used for v1 API.
159#[derive(Debug)]
160struct ActorInstanceState {
161    create_rank: usize,
162    spawn: Result<ActorAddr, anyhow::Error>,
163    /// True once a stop signal has been sent. This does *not* mean the actor
164    /// has reached a terminal state — that is determined by observing
165    /// supervision events.
166    stop_initiated: bool,
167    /// The supervision event observed for this actor, if it has reached
168    /// terminal state.
169    supervision_event: Option<ActorSupervisionEvent>,
170    /// Streaming subscribers that receive `RankedState<ActorState>` on every
171    /// state change, paired with the actor's rank in that subscriber's view.
172    /// Dead subscribers are removed via undeliverable handling.
173    subscribers: Vec<(usize, PortRef<resource::RankedState<ActorState>>)>,
174    /// The time at which the actor should be considered expired if no further
175    /// keepalive is received. `None` meaning it will never expire.
176    expiry_time: Option<std::time::SystemTime>,
177    /// Monotonic generation counter, incremented on every state-mutating
178    /// operation (spawn, stop, supervision event). Used for last-writer-wins
179    /// ordering in the mesh controller.
180    generation: u64,
181    /// Pending `WaitRankStatus` callers: each entry is the minimum status
182    /// threshold, the delivered view rank at which to position the reply
183    /// overlay, and the reply port to send once the threshold is met.
184    pending_wait_status: Vec<(
185        resource::Status,
186        usize,
187        IdleFlushPortRef<crate::StatusOverlay>,
188    )>,
189}
190
191/// Identity of a root-owned client-root service. `actor_states` (keyed by the
192/// service's instance `ResourceId`) remains the sole lifecycle registry; this
193/// records only what a repeat ensure must match exactly (CROOT-3, CROOT-9).
194#[derive(Debug)]
195struct ServiceEntry {
196    /// The registered remote actor type name.
197    actor_type: String,
198    /// The exact serialized `RemoteSpawn` parameter bytes.
199    params: Data,
200    /// The service actor's fresh instance id, whose `actor_states` entry stores
201    /// the spawn result.
202    id: ResourceId,
203}
204
205impl ActorInstanceState {
206    /// Derive the resource status from spawn result, stop initiation,
207    /// and the observed supervision event.
208    fn status(&self) -> resource::Status {
209        match &self.spawn {
210            Err(e) => resource::Status::Failed(e.to_string()),
211            Ok(_) => match &self.supervision_event {
212                Some(event) if event.is_error() => resource::Status::Failed(format!("{}", event)),
213                Some(_) => resource::Status::Stopped,
214                None if self.stop_initiated => resource::Status::Stopping,
215                None => resource::Status::Running,
216            },
217        }
218    }
219
220    /// True if the actor has reached a terminal state (stopped or failed),
221    /// or if it never successfully spawned.
222    fn is_terminal(&self) -> bool {
223        match &self.spawn {
224            Err(_) => true,
225            Ok(_) => self.supervision_event.is_some(),
226        }
227    }
228
229    /// True if the supervision event is an error.
230    fn has_errors(&self) -> bool {
231        self.supervision_event
232            .as_ref()
233            .is_some_and(|e| e.is_error())
234    }
235
236    /// Build the `State<ActorState>` for this instance, suitable for
237    /// replies and subscriber notifications.
238    fn to_state(&self, id: &ResourceId) -> resource::State<ActorState> {
239        let status = self.status();
240        let actor_state = self.spawn.as_ref().ok().map(|actor_id| ActorState {
241            actor_id: actor_id.clone(),
242            create_rank: self.create_rank,
243            supervision_events: self.supervision_event.clone().into_iter().collect(),
244        });
245        resource::State {
246            id: id.clone(),
247            status,
248            state: actor_state,
249            generation: self.generation,
250            timestamp: std::time::SystemTime::now(),
251        }
252    }
253
254    /// Notify all observers that this actor's status has changed:
255    /// streaming subscribers get the full state, and one-shot
256    /// `WaitRankStatus` waiters whose threshold is now met get replied
257    /// to and removed.
258    fn notify_status_changed(&mut self, cx: &impl hyperactor::context::Actor, id: &ResourceId) {
259        // Streaming subscribers (persistent).
260        let state = self.to_state(id);
261        for (view_rank, subscriber) in &self.subscribers {
262            let mut headers = Flattrs::new();
263            headers.set(STREAM_STATE_SUBSCRIBER, true);
264            subscriber.post_with_headers(
265                cx,
266                headers,
267                resource::RankedState {
268                    rank: resource::Rank::new(*view_rank),
269                    state: state.clone(),
270                },
271            );
272        }
273
274        // One-shot waiters (predicated). Each retains the reply rank it was
275        // stashed with (RSP-3), positioned in the caller's view (RSP-1).
276        let status = self.status();
277        self.pending_wait_status
278            .retain(|(min_status, rank, reply)| {
279                if status >= *min_status {
280                    let overlay = crate::StatusOverlay::try_from_runs(vec![(
281                        *rank..(*rank + 1),
282                        status.clone(),
283                    )])
284                    .expect("valid single-run overlay");
285                    let _ = reply.post(cx, overlay);
286                    false
287                } else {
288                    true
289                }
290            });
291    }
292}
293
294#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Named)]
295pub(crate) struct SelfCheck {}
296
297/// A mesh agent is responsible for managing procs in a [`ProcMesh`].
298///
299/// ## Supervision event ingestion (remote)
300///
301/// `ProcAgent` is the *process/rank-local* sink for
302/// `ActorSupervisionEvent`s produced by the runtime (actor failures,
303/// routing failures, undeliverables, etc.).
304///
305/// We **export** `ActorSupervisionEvent` as a handler so that other
306/// procs—most importantly the process-global root client created by
307/// `context()`—can forward undeliverables as supervision
308/// events to the *currently active* mesh.
309///
310/// Without exporting this handler, `ActorSupervisionEvent` cannot be
311/// addressed via `ActorAddr`/`PortAddr` across processes, and the
312/// global-root-client undeliverable → supervision pipeline would
313/// degrade to log-only behavior (events become undeliverable again or
314/// are dropped).
315///
316/// See GC-1 in `global_context` module doc.
317#[hyperactor::export(
318    handlers=[
319        ActorSupervisionEvent,
320        resource::CreateOrUpdate<ActorSpec>,
321        resource::Stop,
322        resource::StopAll,
323        resource::GetState<ActorState>,
324        resource::StreamState<ActorState>,
325        resource::KeepaliveGetState<ActorState>,
326        resource::GetRankStatus,
327        resource::WaitRankStatus,
328        RepublishIntrospect,
329        PySpyDump,
330        PySpyProfile,
331        ConfigDump,
332    ]
333)]
334pub struct ProcAgent {
335    proc: Proc,
336    remote: Remote,
337    /// Actors created and tracked through the resource behavior.
338    actor_states: HashMap<ResourceId, ActorInstanceState>,
339    /// If true, and supervisor is None, record supervision events to be reported
340    /// to owning actors later.
341    record_supervision_events: bool,
342    /// True when supervision events have arrived but introspect
343    /// properties haven't been republished yet.
344    introspect_dirty: bool,
345    /// If set, the shutdown handler will send the exit code through this
346    /// channel instead of calling process::exit directly, allowing the
347    /// caller to perform graceful shutdown (e.g. draining the mailbox server).
348    shutdown_tx: Option<tokio::sync::oneshot::Sender<i32>>,
349    /// True once a StopAll message has been received. When set, the
350    /// supervision event handler checks whether all actors have reached
351    /// terminal state and, if so, triggers process shutdown.
352    stopping_all: bool,
353    /// If set, check for expired actors whose keepalive has lapsed.
354    mesh_orphan_timeout: Option<Duration>,
355    /// Root-owned client-root services keyed by validated static service name.
356    /// Populated only on the one root ProcAgent that binds `ClientRootApi`;
357    /// empty (and unreachable) on worker ProcAgents (CROOT-1, CROOT-4).
358    client_root_services: HashMap<Label, ServiceEntry>,
359}
360
361impl ProcAgent {
362    pub(crate) fn boot_v1(
363        proc: Proc,
364        shutdown_tx: Option<tokio::sync::oneshot::Sender<i32>>,
365    ) -> Result<ActorHandle<Self>, anyhow::Error> {
366        proc.spawn_bound_with_uid(
367            Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
368            hyperactor_cast::cast_actor::CastActor::default(),
369        )?;
370
371        // `spawn_data` discovers the per-proc ActorSpawner at its well-known UID,
372        // so legacy ProcAgent bootstrapping must install it. This compatibility
373        // step goes away when proc bootstrapping moves off ProcAgent entirely.
374        proc.spawn_bound_with_uid(actor_spawner_uid(), ActorSpawner)?;
375
376        let orphan_timeout = hyperactor_config::global::get(MESH_ORPHAN_TIMEOUT);
377        let agent = ProcAgent {
378            proc: proc.clone(),
379            remote: Remote::collect(),
380            actor_states: HashMap::new(),
381            record_supervision_events: true,
382            introspect_dirty: false,
383            shutdown_tx,
384            stopping_all: false,
385            mesh_orphan_timeout: orphan_timeout,
386            client_root_services: HashMap::new(),
387        };
388        proc.spawn_bound_with_uid::<Self>(
389            Uid::singleton(Label::new(PROC_AGENT_ACTOR_NAME).unwrap()),
390            agent,
391        )
392    }
393
394    /// Returns true when every tracked actor has a terminal supervision event
395    /// (or failed to spawn). Used to determine when shutdown can proceed
396    /// after a StopAll.
397    fn all_actors_terminal(&self) -> bool {
398        self.actor_states.values().all(|state| state.is_terminal())
399    }
400
401    /// Trigger process shutdown. Flushes the forwarder first so that
402    /// supervision events reach their destinations, then sends through
403    /// `shutdown_tx` if available, otherwise calls `process::exit`.
404    async fn shutdown(&mut self) {
405        let has_errors = self.actor_states.values().any(|state| state.has_errors());
406        let exit_code = if has_errors { 1 } else { 0 };
407
408        let flush_timeout =
409            hyperactor_config::global::get(hyperactor::config::FORWARDER_FLUSH_TIMEOUT);
410        match tokio::time::timeout(flush_timeout, self.proc.flush()).await {
411            Ok(Err(err)) => {
412                tracing::warn!("forwarder flush failed during shutdown: {}", err);
413            }
414            Err(_elapsed) => {
415                tracing::warn!("forwarder flush timed out during shutdown");
416            }
417            Ok(Ok(())) => {}
418        }
419
420        // Stop and join the mailbox server (no-op if this proc was
421        // not created with one). Pending receive-side acks are
422        // flushed before the underlying channel server is torn down.
423        self.proc.join_mailbox_server().await;
424
425        tracing::info!(
426            "shutting down process after all actors reached terminal state (exit_code={})",
427            exit_code,
428        );
429
430        if let Some(tx) = self.shutdown_tx.take() {
431            let _ = tx.send(exit_code);
432            return;
433        }
434        std::process::exit(exit_code);
435    }
436
437    /// Send a stop signal to an actor on this proc. This is fire-and-forget;
438    /// it does not wait for the actor to reach terminal status.
439    fn stop_actor_by_id(&self, actor_id: &ActorAddr, reason: &str) {
440        tracing::info!(
441            name = "StopActor",
442            %actor_id,
443            actor_name = actor_id.log_name(),
444            %reason,
445        );
446        self.proc.stop_actor(actor_id.id(), reason.to_string());
447    }
448
449    /// Publish the current proc properties and children list for
450    /// introspection. See S12 in `introspect` module doc.
451    fn publish_introspect_properties(
452        &self,
453        cx: &impl hyperactor::context::Actor,
454    ) -> ProcessMemoryStats {
455        let (mut children, mut system_children) = collect_live_children(&self.proc);
456
457        // Terminated actors appear as children but don't inflate
458        // the actor count. Track them in stopped_children so the
459        // TUI can filter/gray without per-child fetches.
460        let mut stopped_children: Vec<crate::introspect::NodeRef> = Vec::new();
461        for id in self.proc.all_terminated_actor_ids() {
462            let child_ref = hyperactor::introspect::IntrospectRef::Actor(id.clone());
463            let node_ref = crate::introspect::NodeRef::Actor(id.clone());
464            stopped_children.push(node_ref.clone());
465            if let Some(snapshot) = self.proc.terminated_snapshot(&id) {
466                let snapshot_attrs: hyperactor_config::Attrs =
467                    serde_json::from_str(&snapshot.attrs).unwrap_or_default();
468                if snapshot_attrs
469                    .get(hyperactor::introspect::IS_SYSTEM)
470                    .copied()
471                    .unwrap_or(false)
472                {
473                    system_children.push(node_ref);
474                }
475            }
476            if !children.contains(&child_ref) {
477                children.push(child_ref);
478            }
479        }
480
481        let stopped_retention_cap =
482            hyperactor_config::global::get(hyperactor::config::TERMINATED_SNAPSHOT_RETENTION);
483
484        // FI-5: is_poisoned iff failed_actor_count > 0.
485        let failed_actor_count = self
486            .actor_states
487            .values()
488            .filter(|s| s.has_errors())
489            .count();
490
491        // Attrs-based introspection.
492        let num_live = children.len();
493        let mut attrs = hyperactor_config::Attrs::new();
494        attrs.set(crate::introspect::NODE_TYPE, "proc".to_string());
495        attrs.set(
496            crate::introspect::PROC_NAME,
497            self.proc
498                .proc_addr()
499                .label()
500                .map(|l| l.as_str().to_string())
501                .unwrap_or_else(|| self.proc.proc_addr().id().to_string()),
502        );
503        attrs.set(crate::introspect::NUM_ACTORS, num_live);
504        attrs.set(hyperactor::introspect::CHILDREN, children);
505        attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children);
506        attrs.set(crate::introspect::STOPPED_CHILDREN, stopped_children);
507        attrs.set(
508            crate::introspect::STOPPED_RETENTION_CAP,
509            stopped_retention_cap,
510        );
511        attrs.set(crate::introspect::IS_POISONED, failed_actor_count > 0);
512        attrs.set(crate::introspect::FAILED_ACTOR_COUNT, failed_actor_count);
513
514        // PD-* proc debug stats intentionally join two signal classes:
515        // hosting-process memory for the OS process that owns this
516        // proc, and proc-local queue pressure aggregated over live
517        // actors only.
518        let memory = crate::introspect::ProcessMemoryStats::read_from_procfs();
519        memory.to_attrs(&mut attrs);
520
521        // Proc-wide total from runtime accounting path (O(1)).
522        let queue_total = self.proc.queue_depth_total();
523        attrs.set(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_TOTAL, queue_total);
524
525        // Per-actor max still needs the per-actor scan (PD-4: live actors only).
526        let mut queue_max: u64 = 0;
527        for actor_id in self.proc.all_instance_keys() {
528            if let Some(cell) = self.proc.get_instance_by_id(&actor_id) {
529                queue_max = queue_max.max(cell.queue_depth());
530            }
531        }
532        attrs.set(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_MAX, queue_max);
533
534        // Retained queue-pressure evidence (PD-6, PD-7).
535        attrs.set(
536            crate::introspect::ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK,
537            self.proc.queue_depth_high_water_mark(),
538        );
539        attrs.set(
540            crate::introspect::LAST_NONZERO_QUEUE_DEPTH_AGE_MS,
541            self.proc.last_nonzero_queue_depth_age_ms(),
542        );
543
544        cx.instance().publish_attrs(attrs);
545
546        memory
547    }
548}
549
550#[async_trait]
551impl Actor for ProcAgent {
552    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
553        this.set_system();
554        self.proc.set_supervision_coordinator(this.port().bind())?;
555        let _ = self.publish_introspect_properties(this);
556
557        // Resolve terminated actor snapshots via QueryChild so that
558        // dead actors remain directly queryable by reference.
559        let proc = self.proc.clone();
560        let self_id = this.self_addr().clone();
561        this.set_query_child_handler(move |child_ref| {
562            use hyperactor::introspect::IntrospectResult;
563
564            if let Addr::Actor(actor_ref) = child_ref
565                && let Some(snapshot) = proc.terminated_snapshot(actor_ref)
566            {
567                return snapshot;
568            }
569
570            // PA-1 (ProcAgent path): proc-node children used by
571            // admin/TUI must be computed from live proc state at query
572            // time, not solely from cached published_properties.
573            // Therefore a direct proc.spawn_with_label() actor must appear on the
574            // next QueryChild(Addr::Proc) response without an
575            // extra publish event. See
576            // test_query_child_proc_returns_live_children.
577            if let Addr::Proc(proc_ref) = child_ref
578                && *proc_ref == proc.proc_addr()
579            {
580                let (mut children, mut system_children) = collect_live_children(&proc);
581
582                let mut stopped_children: Vec<crate::introspect::NodeRef> = Vec::new();
583                for id in proc.all_terminated_actor_ids() {
584                    let child_ref = hyperactor::introspect::IntrospectRef::Actor(id.clone());
585                    let node_ref = crate::introspect::NodeRef::Actor(id.clone());
586                    stopped_children.push(node_ref.clone());
587                    if let Some(snapshot) = proc.terminated_snapshot(&id) {
588                        let snapshot_attrs: hyperactor_config::Attrs =
589                            serde_json::from_str(&snapshot.attrs).unwrap_or_default();
590                        if snapshot_attrs
591                            .get(hyperactor::introspect::IS_SYSTEM)
592                            .copied()
593                            .unwrap_or(false)
594                        {
595                            system_children.push(node_ref);
596                        }
597                    }
598                    if !children.contains(&child_ref) {
599                        children.push(child_ref);
600                    }
601                }
602
603                let stopped_retention_cap = hyperactor_config::global::get(
604                    hyperactor::config::TERMINATED_SNAPSHOT_RETENTION,
605                );
606
607                let (is_poisoned, failed_actor_count) = proc
608                    .get_instance(&self_id)
609                    .and_then(|cell| cell.published_attrs())
610                    .map(|attrs| {
611                        let is_poisoned = attrs
612                            .get(crate::introspect::IS_POISONED)
613                            .copied()
614                            .unwrap_or(false);
615                        let failed_actor_count = attrs
616                            .get(crate::introspect::FAILED_ACTOR_COUNT)
617                            .copied()
618                            .unwrap_or(0);
619                        (is_poisoned, failed_actor_count)
620                    })
621                    .unwrap_or((false, 0));
622
623                // Build attrs for this proc node.
624                let num_live = children.len();
625                let mut attrs = hyperactor_config::Attrs::new();
626                attrs.set(crate::introspect::NODE_TYPE, "proc".to_string());
627                attrs.set(
628                    crate::introspect::PROC_NAME,
629                    proc_ref
630                        .label()
631                        .map(|l| l.as_str().to_string())
632                        .unwrap_or_else(|| proc_ref.id().to_string()),
633                );
634                attrs.set(crate::introspect::NUM_ACTORS, num_live);
635                attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children);
636                attrs.set(crate::introspect::STOPPED_CHILDREN, stopped_children);
637                attrs.set(
638                    crate::introspect::STOPPED_RETENTION_CAP,
639                    stopped_retention_cap,
640                );
641                attrs.set(crate::introspect::IS_POISONED, is_poisoned);
642                attrs.set(crate::introspect::FAILED_ACTOR_COUNT, failed_actor_count);
643
644                // PD-*: include proc debug stats in QueryChild
645                // to prevent resolution drift from the publish path.
646                let memory = crate::introspect::ProcessMemoryStats::read_from_procfs();
647                memory.to_attrs(&mut attrs);
648                attrs.set(
649                    crate::introspect::ACTOR_WORK_QUEUE_DEPTH_TOTAL,
650                    proc.queue_depth_total(),
651                );
652                let mut queue_max: u64 = 0;
653                for aid in proc.all_instance_keys() {
654                    if let Some(cell) = proc.get_instance_by_id(&aid) {
655                        queue_max = queue_max.max(cell.queue_depth());
656                    }
657                }
658                attrs.set(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_MAX, queue_max);
659                attrs.set(
660                    crate::introspect::ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK,
661                    proc.queue_depth_high_water_mark(),
662                );
663                attrs.set(
664                    crate::introspect::LAST_NONZERO_QUEUE_DEPTH_AGE_MS,
665                    proc.last_nonzero_queue_depth_age_ms(),
666                );
667
668                let attrs_json = serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
669
670                return IntrospectResult {
671                    identity: hyperactor::introspect::IntrospectRef::Proc(proc_ref.clone()),
672                    attrs: attrs_json,
673                    children,
674                    parent: None,
675                    as_of: std::time::SystemTime::now(),
676                };
677            }
678
679            {
680                let mut error_attrs = hyperactor_config::Attrs::new();
681                error_attrs.set(hyperactor::introspect::ERROR_CODE, "not_found".to_string());
682                error_attrs.set(
683                    hyperactor::introspect::ERROR_MESSAGE,
684                    format!("child {} not found", child_ref),
685                );
686                let identity = match child_ref {
687                    Addr::Proc(p) => hyperactor::introspect::IntrospectRef::Proc(p.clone()),
688                    Addr::Actor(a) => hyperactor::introspect::IntrospectRef::Actor(a.clone()),
689                    Addr::Port(p) => hyperactor::introspect::IntrospectRef::Actor(p.actor_addr()),
690                };
691                IntrospectResult {
692                    identity,
693                    attrs: serde_json::to_string(&error_attrs).unwrap_or_else(|_| "{}".to_string()),
694                    children: Vec::new(),
695                    parent: None,
696                    as_of: std::time::SystemTime::now(),
697                }
698            }
699        });
700
701        if let Some(delay) = &self.mesh_orphan_timeout {
702            this.post_after(this, SelfCheck::default(), *delay);
703        }
704        if cfg!(target_os = "linux") {
705            let interval = hyperactor_config::global::get(PROCESS_MEMORY_METRIC_INTERVAL);
706            if !interval.is_zero() {
707                this.post_after(
708                    this,
709                    RepublishIntrospect {
710                        emit_memory_metrics: true,
711                    },
712                    interval,
713                );
714            }
715        }
716        Ok(())
717    }
718
719    async fn handle_undeliverable_message(
720        &mut self,
721        cx: &Instance<Self>,
722        reason: UndeliverableReason,
723        envelope: Undeliverable<MessageEnvelope>,
724    ) -> Result<(), anyhow::Error> {
725        let Some(returned) = envelope.as_message() else {
726            return handle_undeliverable_message(cx, reason, envelope);
727        };
728        if let Some(true) = returned.headers().get(STREAM_STATE_SUBSCRIBER) {
729            let dest_port_id: PortAddr = returned.dest().clone();
730            let port = PortRef::<resource::RankedState<ActorState>>::attest(dest_port_id);
731            // Remove this subscriber from whichever actor instance holds it.
732            for instance in self.actor_states.values_mut() {
733                instance.subscribers.retain(|(_, s)| s != &port);
734            }
735            Ok(())
736        } else {
737            handle_undeliverable_message(cx, reason, envelope)
738        }
739    }
740
741    async fn handle_invalid_reference(
742        &mut self,
743        cx: &Instance<Self>,
744        invalid: hyperactor::mailbox::InvalidReference,
745        envelope: Undeliverable<MessageEnvelope>,
746    ) -> Result<(), anyhow::Error> {
747        let Some(returned) = envelope.as_message() else {
748            return hyperactor::actor::handle_invalid_reference(cx, invalid, envelope);
749        };
750        if let Some(true) = returned.headers().get(STREAM_STATE_SUBSCRIBER) {
751            let dest_port_id: PortAddr = returned.dest().clone();
752            let port = PortRef::<resource::RankedState<ActorState>>::attest(dest_port_id);
753            for instance in self.actor_states.values_mut() {
754                instance.subscribers.retain(|(_, s)| s != &port);
755            }
756            Ok(())
757        } else {
758            hyperactor::actor::handle_invalid_reference(cx, invalid, envelope)
759        }
760    }
761}
762
763#[async_trait]
764impl Handler<ActorSupervisionEvent> for ProcAgent {
765    async fn handle(
766        &mut self,
767        cx: &Context<Self>,
768        event: ActorSupervisionEvent,
769    ) -> anyhow::Result<()> {
770        if self.record_supervision_events {
771            if event.is_error() {
772                tracing::warn!(
773                    name = "SupervisionEvent",
774                    proc_id = %self.proc.proc_addr(),
775                    %event,
776                    "recording supervision error",
777                );
778            } else {
779                tracing::debug!(
780                    name = "SupervisionEvent",
781                    proc_id = %self.proc.proc_addr(),
782                    %event,
783                    "recording non-error supervision event",
784                );
785            }
786            // Record the event in the actor's instance state and notify subscribers.
787            if let Some((id, instance)) = self.actor_states.iter_mut().find(|(_, s)| {
788                s.spawn
789                    .as_ref()
790                    .ok()
791                    .is_some_and(|actor_id| actor_id.id() == event.actor_id.id())
792            }) {
793                instance.supervision_event = Some(event.clone());
794                instance.generation += 1;
795                let id = id.clone();
796                instance.notify_status_changed(cx, &id);
797            }
798            // Defer republish so introspection picks up is_poisoned /
799            // failed_actor_count without blocking the message loop.
800            // Multiple rapid events coalesce into one republish.
801            if !self.introspect_dirty {
802                self.introspect_dirty = true;
803                cx.post_after(
804                    cx,
805                    RepublishIntrospect {
806                        emit_memory_metrics: false,
807                    },
808                    std::time::Duration::from_millis(100),
809                );
810            }
811
812            // If StopAll was requested, check whether all actors have now
813            // reached terminal state. If so, shut down the process.
814            if self.stopping_all && self.all_actors_terminal() {
815                self.shutdown().await;
816            }
817        }
818        if !self.record_supervision_events && event.is_error() {
819            // If there is no supervisor, and nothing is recording these, crash
820            // the whole process on error events.
821            tracing::error!(
822                name = "supervision_event_transmit_failed",
823                proc_id = %cx.self_addr().proc_addr(),
824                %event,
825                "could not propagate supervision event, crashing",
826            );
827
828            // We should have a custom "crash" function here, so that this works
829            // in testing of the LocalAllocator, etc.
830            std::process::exit(1);
831        }
832        Ok(())
833    }
834}
835
836#[async_trait]
837impl Handler<RepublishIntrospect> for ProcAgent {
838    async fn handle(&mut self, cx: &Context<Self>, msg: RepublishIntrospect) -> anyhow::Result<()> {
839        self.introspect_dirty = false;
840        let memory = self.publish_introspect_properties(cx);
841        if msg.emit_memory_metrics {
842            let proc_id = self.proc.proc_addr().to_string();
843            let pid = std::process::id() as i64;
844            if let Some(rss) = memory.process_rss_bytes {
845                crate::metrics::PROCESS_RSS_BYTES.record(
846                    rss as f64,
847                    hyperactor_telemetry::kv_pairs!(
848                        "proc_id" => proc_id.clone(),
849                        "pid" => pid,
850                    ),
851                );
852            }
853            if let Some(vm) = memory.process_vm_size_bytes {
854                crate::metrics::PROCESS_VM_SIZE_BYTES.record(
855                    vm as f64,
856                    hyperactor_telemetry::kv_pairs!(
857                        "proc_id" => proc_id,
858                        "pid" => pid,
859                    ),
860                );
861            }
862            let interval = hyperactor_config::global::get(PROCESS_MEMORY_METRIC_INTERVAL);
863            if !interval.is_zero() {
864                cx.post_after(
865                    cx,
866                    RepublishIntrospect {
867                        emit_memory_metrics: true,
868                    },
869                    interval,
870                );
871            }
872        }
873        Ok(())
874    }
875}
876
877#[async_trait]
878impl Handler<PySpyDump> for ProcAgent {
879    async fn handle(
880        &mut self,
881        cx: &Context<Self>,
882        message: PySpyDump,
883    ) -> Result<(), anyhow::Error> {
884        PySpyWorker::spawn_and_forward(cx, message.opts, message.result)
885    }
886}
887
888#[async_trait]
889impl Handler<PySpyProfile> for ProcAgent {
890    async fn handle(
891        &mut self,
892        cx: &Context<Self>,
893        message: PySpyProfile,
894    ) -> Result<(), anyhow::Error> {
895        PySpyProfileWorker::spawn_and_forward(cx, message.request, message.result)
896    }
897}
898
899#[async_trait]
900impl Handler<ConfigDump> for ProcAgent {
901    async fn handle(
902        &mut self,
903        cx: &Context<Self>,
904        message: ConfigDump,
905    ) -> Result<(), anyhow::Error> {
906        let entries = hyperactor_config::global::config_entries();
907        // Reply is best-effort: the caller may have timed out and dropped
908        // the once-port.  That must not crash this actor.
909        let _ = message.result.post(cx, ConfigDumpResult { entries });
910        Ok(())
911    }
912}
913
914// Implement the resource behavior for managing actors:
915
916/// Actor spec.
917#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
918pub struct ActorSpec {
919    /// registered actor type
920    pub actor_type: String,
921    /// serialized parameters
922    pub params_data: Data,
923    /// The caller's environment for the spawned actor. Proc-mesh construction
924    /// replaces its `CAST_POINT` with the point assigned to the new actor.
925    pub actor_environment: ActorEnvironment,
926}
927wirevalue::register_type!(ActorSpec);
928
929/// Actor state.
930#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
931pub struct ActorState {
932    /// The actor's ID.
933    pub actor_id: ActorAddr,
934    /// The actor's dense rank in the view it was first created over. Stable for
935    /// the actor's lifetime and independent of any later view that reuses the
936    /// actor, so it is not the actor's rank in an overlapping or sliced view.
937    pub create_rank: usize,
938    // TODO status: ActorStatus,
939    pub supervision_events: Vec<ActorSupervisionEvent>,
940}
941wirevalue::register_type!(ActorState);
942
943impl ProcAgent {
944    /// Create a root-tracked actor and record its state under `id`, returning
945    /// whether the new state should be published.
946    ///
947    /// On a poisoned proc (an actor with an error supervision event) it records a
948    /// failed state without spawning and returns `false`. Otherwise it shapes
949    /// the caller's environment with the optional construction point and returns
950    /// `true` whether or not shaping or `gspawn` succeeds; failures are cached in
951    /// `actor_states`, which remains the sole lifecycle authority. Shared by the
952    /// mesh `CreateOrUpdate` path and the client-root ensure path.
953    async fn create_actor(
954        &mut self,
955        id: ResourceId,
956        create_rank: usize,
957        spec: ActorSpec,
958        construction_point: Option<ndslice::Point>,
959    ) -> bool {
960        let poisoned = self.actor_states.values().any(|s| s.has_errors());
961        let spawn = if poisoned {
962            Err(anyhow::anyhow!(
963                "Cannot spawn new actors on mesh with supervision events"
964            ))
965        } else {
966            let ActorSpec {
967                actor_type,
968                params_data,
969                mut actor_environment,
970            } = spec;
971            let shape_result = match construction_point {
972                Some(point) => actor_environment
973                    .set(CAST_POINT, point)
974                    .map_err(anyhow::Error::from),
975                None => Ok(()),
976            };
977            match shape_result {
978                Ok(()) => {
979                    self.remote
980                        .gspawn(
981                            &self.proc,
982                            &actor_type,
983                            id.uid().clone(),
984                            params_data,
985                            actor_environment,
986                        )
987                        .await
988                }
989                Err(error) => Err(error),
990            }
991        };
992        self.actor_states.insert(
993            id,
994            ActorInstanceState {
995                create_rank,
996                spawn,
997                stop_initiated: false,
998                supervision_event: None,
999                subscribers: Vec::new(),
1000                expiry_time: None,
1001                generation: 1,
1002                pending_wait_status: Vec::new(),
1003            },
1004        );
1005        !poisoned
1006    }
1007
1008    /// Create or reuse the statically named, root-owned service and return its
1009    /// address. Runs entirely inside the serial ProcAgent handler, so concurrent
1010    /// identical ensures collapse: the first records the entry and the rest
1011    /// observe it (CROOT-3, CROOT-5).
1012    async fn ensure_client_root_service(
1013        &mut self,
1014        cx: &Context<'_, Self>,
1015        service_name: Label,
1016        actor_type: String,
1017        params: Data,
1018    ) -> Result<ActorAddr, ClientRootError> {
1019        let conflict = || ClientRootError::ConflictingSpec {
1020            name: service_name.as_str().to_string(),
1021        };
1022
1023        // Reuse an existing service only on an exact (type, params) match; a
1024        // service that has since gone terminal fails closed in `service_result`.
1025        if let Some(entry) = self.client_root_services.get(&service_name) {
1026            if entry.actor_type != actor_type || entry.params != params {
1027                return Err(conflict());
1028            }
1029            return Self::service_result(&self.actor_states, &entry.id, &service_name);
1030        }
1031
1032        // Refuse to create a new service once shutdown has begun: a StopAll has
1033        // already snapshotted and stopped the live actors, so a service created
1034        // afterwards would never be stopped and would strand shutdown.
1035        if self.stopping_all {
1036            return Err(ClientRootError::Unavailable {
1037                name: service_name.as_str().to_string(),
1038                reason: "the client root is shutting down".to_string(),
1039            });
1040        }
1041
1042        // A fresh random instance id (CROOT-3): this avoids the predictable
1043        // collision a name-derived id would create with a mesh actor or a
1044        // preoccupying actor. The loop guards the negligible random-uid collision
1045        // against the ids already present in `actor_states`.
1046        let id = loop {
1047            let candidate = ResourceId::instance(service_name.clone());
1048            if !self.actor_states.contains_key(&candidate) {
1049                break candidate;
1050            }
1051        };
1052
1053        // The created service inherits the same client-root capability so its own
1054        // descendants stay in this root (CROOT-7).
1055        let self_root = ClientRootRef::from_ref(
1056            hyperactor::context::Actor::instance(cx).bind::<ClientRootApi>(),
1057        );
1058        let mut service_env = hyperactor::ActorEnvironment::default();
1059        service_env
1060            .set(CLIENT_ROOT, self_root)
1061            .map_err(|e| ClientRootError::Spawn {
1062                name: service_name.as_str().to_string(),
1063                message: e.to_string(),
1064            })?;
1065        let spec = ActorSpec {
1066            actor_type: actor_type.clone(),
1067            params_data: params.clone(),
1068            actor_environment: service_env,
1069        };
1070        // Root-owned construction uses its fresh environment unchanged; the
1071        // requester's message headers must not leak into the long-lived service.
1072        let publish_state = self.create_actor(id.clone(), 0, spec, None).await;
1073
1074        // Record the identity so later ensures reuse it; the spawn result
1075        // (including a cached failure) lives in `actor_states`.
1076        self.client_root_services.insert(
1077            service_name.clone(),
1078            ServiceEntry {
1079                actor_type,
1080                params,
1081                id: id.clone(),
1082            },
1083        );
1084        // Reflect the new service in the root's introspect view, matching the
1085        // mesh create path whenever creation was processed.
1086        if publish_state {
1087            let _ = self.publish_introspect_properties(cx);
1088        }
1089        Self::service_result(&self.actor_states, &id, &service_name)
1090    }
1091
1092    /// Read the recorded spawn result for a service's reserved id.
1093    fn service_result(
1094        actor_states: &HashMap<ResourceId, ActorInstanceState>,
1095        id: &ResourceId,
1096        service_name: &Label,
1097    ) -> Result<ActorAddr, ClientRootError> {
1098        let Some(state) = actor_states.get(id) else {
1099            return Err(ClientRootError::Spawn {
1100                name: service_name.as_str().to_string(),
1101                message: "service actor state is missing".to_string(),
1102            });
1103        };
1104        // A cached spawn failure (including a poisoned-proc failure) is returned
1105        // as-is; proc poisoning is latched, so it is never retried.
1106        let addr = state.spawn.as_ref().map_err(|e| ClientRootError::Spawn {
1107            name: service_name.as_str().to_string(),
1108            message: e.to_string(),
1109        })?;
1110        // Fail closed if the service spawned but has since gone terminal
1111        // (stopped, failed, or stopping): reuse must never hand back a dead ref.
1112        match state.status() {
1113            resource::Status::Running => Ok(addr.clone()),
1114            other => Err(ClientRootError::Unavailable {
1115                name: service_name.as_str().to_string(),
1116                reason: format!("service is {}", other),
1117            }),
1118        }
1119    }
1120}
1121
1122#[async_trait]
1123impl Handler<resource::CreateOrUpdate<ActorSpec>> for ProcAgent {
1124    async fn handle(
1125        &mut self,
1126        cx: &Context<Self>,
1127        create_or_update: resource::CreateOrUpdate<ActorSpec>,
1128    ) -> anyhow::Result<()> {
1129        if self.actor_states.contains_key(&create_or_update.id) {
1130            // There is no update.
1131            return Ok(());
1132        }
1133        // Once shutdown has begun, a new actor would escape the StopAll snapshot
1134        // and strand shutdown; do not create it.
1135        if self.stopping_all {
1136            return Ok(());
1137        }
1138        let create_rank = create_or_update.rank.unwrap();
1139        // Only the cast point is construction context. Replace the inherited
1140        // value with this actor's assigned point; other request headers remain
1141        // scoped to this delivery and are not stored on the actor (AENV-4).
1142        let construction_point = cx.headers().get(CAST_POINT);
1143        // Publish introspect whenever creation was processed on a non-poisoned
1144        // proc, including when environment shaping or spawning failed.
1145        let publish_state = self
1146            .create_actor(
1147                create_or_update.id.clone(),
1148                create_rank,
1149                create_or_update.spec,
1150                construction_point,
1151            )
1152            .await;
1153        if publish_state {
1154            let _ = self.publish_introspect_properties(cx);
1155        }
1156        Ok(())
1157    }
1158}
1159
1160#[async_trait]
1161impl Handler<EnsureClientRootService> for ProcAgent {
1162    async fn handle(
1163        &mut self,
1164        cx: &Context<Self>,
1165        message: EnsureClientRootService,
1166    ) -> anyhow::Result<()> {
1167        let EnsureClientRootService {
1168            service_name,
1169            actor_type,
1170            params,
1171            mut reply,
1172        } = message;
1173        // A departed requester must not fault the root: an undeliverable reply is
1174        // dropped rather than returned to this ProcAgent (CROOT-11). Expected
1175        // failures travel in the typed reply, and the handler always returns Ok.
1176        reply.return_undeliverable(false);
1177        let result = self
1178            .ensure_client_root_service(cx, service_name, actor_type, params)
1179            .await;
1180        reply.post(cx, EnsureClientRootServiceReply(result));
1181        Ok(())
1182    }
1183}
1184
1185#[async_trait]
1186impl Handler<resource::Stop> for ProcAgent {
1187    async fn handle(&mut self, cx: &Context<Self>, message: resource::Stop) -> anyhow::Result<()> {
1188        let actor_id = match self.actor_states.get_mut(&message.id) {
1189            Some(actor_state) => {
1190                let id = actor_state.spawn.as_ref().ok().cloned();
1191                if id.is_some() && !actor_state.stop_initiated {
1192                    actor_state.stop_initiated = true;
1193                    actor_state.generation += 1;
1194                    actor_state.notify_status_changed(cx, &message.id);
1195                    id
1196                } else {
1197                    None
1198                }
1199            }
1200            None => None,
1201        };
1202        if let Some(actor_id) = actor_id {
1203            self.stop_actor_by_id(&actor_id, &message.reason);
1204        }
1205
1206        Ok(())
1207    }
1208}
1209
1210/// Handles `StopAll` by sending stop signals to all child actors.
1211/// Process shutdown is deferred until all actors have reached terminal
1212/// state, as observed through supervision events.
1213#[async_trait]
1214impl Handler<resource::StopAll> for ProcAgent {
1215    async fn handle(
1216        &mut self,
1217        _cx: &Context<Self>,
1218        message: resource::StopAll,
1219    ) -> anyhow::Result<()> {
1220        self.stopping_all = true;
1221
1222        // Send stop signals to all actors that haven't been stopped yet.
1223        let to_stop: Vec<ActorAddr> = self
1224            .actor_states
1225            .values_mut()
1226            .filter_map(|state| {
1227                if state.stop_initiated {
1228                    return None;
1229                }
1230                state.stop_initiated = true;
1231                state.spawn.as_ref().ok().cloned()
1232            })
1233            .collect();
1234
1235        for actor_id in &to_stop {
1236            self.stop_actor_by_id(actor_id, &message.reason);
1237        }
1238
1239        // If there are no actors to stop, shut down immediately.
1240        if self.all_actors_terminal() {
1241            self.shutdown().await;
1242        }
1243
1244        Ok(())
1245    }
1246}
1247
1248#[async_trait]
1249impl Handler<resource::GetRankStatus> for ProcAgent {
1250    async fn handle(
1251        &mut self,
1252        cx: &Context<Self>,
1253        get_rank_status: resource::GetRankStatus,
1254    ) -> anyhow::Result<()> {
1255        use crate::StatusOverlay;
1256
1257        // Position the overlay at the rank the request carries (the recipient's
1258        // rank in the caller's view), not the actor's first-creation rank (RSP-1);
1259        // an absent actor yields an empty overlay (RSP-4).
1260        let overlay = match self.actor_states.get(&get_rank_status.id) {
1261            Some(state) => {
1262                let rank = get_rank_status.rank.unwrap();
1263                StatusOverlay::try_from_runs(vec![(rank..(rank + 1), state.status())])
1264                    .expect("valid single-run overlay")
1265            }
1266            None => StatusOverlay::new(),
1267        };
1268        get_rank_status.reply.post(cx, overlay);
1269        Ok(())
1270    }
1271}
1272
1273#[async_trait]
1274impl Handler<resource::WaitRankStatus> for ProcAgent {
1275    async fn handle(
1276        &mut self,
1277        cx: &Context<Self>,
1278        msg: resource::WaitRankStatus,
1279    ) -> anyhow::Result<()> {
1280        use crate::StatusOverlay;
1281
1282        // The request carries the reply rank (the recipient's rank in the
1283        // caller's view) (RSP-1); a deferred waiter retains it, since the cast
1284        // context is gone by flush time (RSP-3). An absent actor replies with an
1285        // empty overlay (RSP-4).
1286        let Some(status) = self.actor_states.get(&msg.id).map(|state| state.status()) else {
1287            let _ = msg.reply.post(cx, StatusOverlay::new());
1288            return Ok(());
1289        };
1290        let rank = msg.rank.unwrap();
1291
1292        // If already at or past the requested threshold, reply immediately.
1293        if status >= msg.min_status {
1294            let overlay = StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status)])
1295                .expect("valid single-run overlay");
1296            let _ = msg.reply.post(cx, overlay);
1297            return Ok(());
1298        }
1299
1300        // Otherwise, stash the waiter with its reply rank. It will be flushed
1301        // when the status changes (supervision event or stop).
1302        if let Some(state) = self.actor_states.get_mut(&msg.id) {
1303            state
1304                .pending_wait_status
1305                .push((msg.min_status, rank, msg.reply));
1306        }
1307        Ok(())
1308    }
1309}
1310
1311#[async_trait]
1312impl Handler<resource::GetState<ActorState>> for ProcAgent {
1313    async fn handle(
1314        &mut self,
1315        cx: &Context<Self>,
1316        get_state: resource::GetState<ActorState>,
1317    ) -> anyhow::Result<()> {
1318        let state = match self.actor_states.get(&get_state.id) {
1319            Some(instance) => instance.to_state(&get_state.id),
1320            None => resource::State {
1321                id: get_state.id.clone(),
1322                status: resource::Status::NotExist,
1323                state: None,
1324                generation: 0,
1325                timestamp: std::time::SystemTime::now(),
1326            },
1327        };
1328
1329        get_state.reply.post(cx, state);
1330        Ok(())
1331    }
1332}
1333
1334#[async_trait]
1335impl Handler<resource::StreamState<ActorState>> for ProcAgent {
1336    async fn handle(
1337        &mut self,
1338        cx: &Context<Self>,
1339        stream_state: resource::StreamState<ActorState>,
1340    ) -> anyhow::Result<()> {
1341        // The cast layer fills each recipient's rank in the subscriber's view.
1342        // Retain it because later notifications are direct posts, outside the
1343        // subscription cast context.
1344        let view_rank = stream_state.subscriber_rank.unwrap();
1345
1346        let state = match self.actor_states.get_mut(&stream_state.id) {
1347            Some(instance) => {
1348                let state = instance.to_state(&stream_state.id);
1349                instance
1350                    .subscribers
1351                    .push((view_rank, stream_state.subscriber.clone()));
1352                state
1353            }
1354            None => resource::State {
1355                id: stream_state.id.clone(),
1356                status: resource::Status::NotExist,
1357                state: None,
1358                generation: 0,
1359                timestamp: std::time::SystemTime::now(),
1360            },
1361        };
1362
1363        // Send the current state immediately at the subscriber's view rank.
1364        let mut headers = Flattrs::new();
1365        headers.set(STREAM_STATE_SUBSCRIBER, true);
1366        stream_state.subscriber.post_with_headers(
1367            cx,
1368            headers,
1369            resource::RankedState {
1370                rank: resource::Rank::new(view_rank),
1371                state,
1372            },
1373        );
1374        Ok(())
1375    }
1376}
1377
1378#[async_trait]
1379impl Handler<resource::KeepaliveGetState<ActorState>> for ProcAgent {
1380    async fn handle(
1381        &mut self,
1382        cx: &Context<Self>,
1383        message: resource::KeepaliveGetState<ActorState>,
1384    ) -> anyhow::Result<()> {
1385        // Same impl as GetState, but additionally update the expiry time on the actor.
1386        if let Ok(instance_state) =
1387            self.actor_states
1388                .get_mut(&message.get_state.id)
1389                .ok_or_else(|| {
1390                    anyhow::anyhow!(
1391                        "attempting to register a keepalive for an actor that doesn't exist: {}",
1392                        message.get_state.id
1393                    )
1394                })
1395        {
1396            instance_state.expiry_time = Some(message.expires_after);
1397        }
1398
1399        // Forward the rest of the impl to GetState.
1400        <Self as Handler<resource::GetState<ActorState>>>::handle(self, cx, message.get_state).await
1401    }
1402}
1403
1404/// A local handler to get a new client instance on the proc.
1405/// This is used to create root client instances.
1406#[derive(Debug, hyperactor::Handler, hyperactor::HandleClient)]
1407pub struct NewClientInstance {
1408    #[reply]
1409    pub client_instance: PortHandle<Client>,
1410}
1411
1412#[async_trait]
1413impl Handler<NewClientInstance> for ProcAgent {
1414    async fn handle(
1415        &mut self,
1416        cx: &Context<Self>,
1417        NewClientInstance { client_instance }: NewClientInstance,
1418    ) -> anyhow::Result<()> {
1419        let client = self.proc.client("client");
1420        client_instance.post(cx, client);
1421        Ok(())
1422    }
1423}
1424
1425/// A handler to get a clone of the proc managed by this agent.
1426/// This is used to obtain the local proc from a host mesh.
1427#[derive(Debug, hyperactor::Handler, hyperactor::HandleClient)]
1428pub struct GetProc {
1429    #[reply]
1430    pub proc: PortHandle<Proc>,
1431}
1432
1433#[async_trait]
1434impl Handler<GetProc> for ProcAgent {
1435    async fn handle(
1436        &mut self,
1437        cx: &Context<Self>,
1438        GetProc { proc }: GetProc,
1439    ) -> anyhow::Result<()> {
1440        proc.post(cx, self.proc.clone());
1441        Ok(())
1442    }
1443}
1444
1445#[async_trait]
1446impl Handler<SelfCheck> for ProcAgent {
1447    async fn handle(&mut self, cx: &Context<Self>, _: SelfCheck) -> anyhow::Result<()> {
1448        // Check each actor's expiry time. If the current time is past the expiry,
1449        // stop the actor. This allows automatic cleanup when a controller disappears
1450        // but owned resources remain. It is important that this check runs on the
1451        // same proc as the child actor itself, since the controller could be dead or
1452        // disconnected.
1453        let Some(duration) = &self.mesh_orphan_timeout else {
1454            return Ok(());
1455        };
1456        let duration = *duration;
1457        let now = std::time::SystemTime::now();
1458
1459        // Collect expired actors before mutating, since stop_actor borrows &mut self.
1460        let expired: Vec<(ResourceId, ActorAddr)> = self
1461            .actor_states
1462            .iter()
1463            .filter_map(|(id, state)| {
1464                let expiry = state.expiry_time?;
1465                // If a stop was already initiated we don't need to do it again.
1466                if now > expiry
1467                    && !state.stop_initiated
1468                    && let Ok(actor_id) = &state.spawn
1469                {
1470                    return Some((id.clone(), actor_id.clone()));
1471                }
1472                None
1473            })
1474            .collect();
1475
1476        if !expired.is_empty() {
1477            tracing::info!(
1478                "stopping {} orphaned actors past their keepalive expiry",
1479                expired.len(),
1480            );
1481        }
1482
1483        for (id, actor_id) in expired {
1484            if let Some(state) = self.actor_states.get_mut(&id) {
1485                state.stop_initiated = true;
1486            }
1487            self.stop_actor_by_id(&actor_id, "orphaned");
1488        }
1489
1490        // Reschedule.
1491        cx.post_after(cx, SelfCheck::default(), duration);
1492        Ok(())
1493    }
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498    use std::sync::Arc;
1499
1500    use hyperactor::ActorRef;
1501
1502    use super::*;
1503
1504    // A no-op actor used to test direct proc-level spawning.
1505    #[derive(Debug, Default, Serialize, Deserialize)]
1506    #[hyperactor::export(handlers = [])]
1507    struct ExtraActor;
1508    impl hyperactor::Actor for ExtraActor {}
1509    hyperactor::register_spawnable!(ExtraActor);
1510
1511    // The `ProcAgent` address is known before `boot_v1` runs. The proc buffers
1512    // a message sent there; because `boot_v1` spawns the agent with its
1513    // handlers bound, the message is handled rather than returned.
1514    #[tokio::test]
1515    async fn boot_v1_handles_messages_sent_before_boot() {
1516        use hyperactor::Proc;
1517        use hyperactor::channel::ChannelTransport;
1518
1519        let config = hyperactor_config::global::lock();
1520        let _timeout = config.override_key(
1521            hyperactor::config::PENDING_ACTOR_DELIVERY_TIMEOUT,
1522            Duration::from_secs(30),
1523        );
1524
1525        let proc = Proc::direct(ChannelTransport::Unix.any(), "early".to_string()).unwrap();
1526        let client = proc.client("client");
1527        let agent: ActorRef<ProcAgent> =
1528            ActorRef::attest(proc.proc_addr().actor_addr(PROC_AGENT_ACTOR_NAME));
1529        let id = ResourceId::instance(Label::new("absent").unwrap());
1530        let (reply, mut reply_rx) = client.open_port::<resource::State<ActorState>>();
1531        agent.post(
1532            &client,
1533            resource::GetState {
1534                id: id.clone(),
1535                reply: reply.bind(),
1536            },
1537        );
1538
1539        let _agent = ProcAgent::boot_v1(proc.clone(), None).unwrap();
1540
1541        let state = tokio::time::timeout(Duration::from_secs(10), reply_rx.recv())
1542            .await
1543            .expect("the agent should answer the message sent before boot")
1544            .unwrap();
1545        assert_eq!(state.id, id);
1546        assert_eq!(state.status, resource::Status::NotExist);
1547    }
1548    // Verifies that QueryChild(Addr::Proc) on a ProcAgent returns
1549    // a live IntrospectResult whose children reflect actors spawned
1550    // directly on the proc — i.e. via proc.spawn_with_label(), which bypasses the
1551    // gspawn message handler and therefore never triggers
1552    // publish_introspect_properties.
1553    //
1554    // Exercises PA-1 (see mesh_admin module doc).
1555    //
1556    // Regression guard for the bug introduced in 9a08d559: removing
1557    // handle_introspect left publish_introspect_properties as the only
1558    // update path, which missed supervision-spawned actors (e.g. every
1559    // sieve actor after sieve[0]). See also
1560    // mesh_admin::tests::test_proc_children_reflect_directly_spawned_actors.
1561    #[tokio::test]
1562    async fn test_query_child_proc_returns_live_children() {
1563        use hyperactor::Proc;
1564        use hyperactor::actor::ActorStatus;
1565        use hyperactor::channel::ChannelTransport;
1566        use hyperactor::introspect::IntrospectMessage;
1567        use hyperactor::introspect::IntrospectResult;
1568
1569        let proc = Proc::direct(ChannelTransport::Unix.any(), "test_proc".to_string()).unwrap();
1570        let agent_handle = ProcAgent::boot_v1(proc.clone(), None).unwrap();
1571
1572        // Wait for ProcAgent to finish init.
1573        agent_handle
1574            .status()
1575            .wait_for(|s| matches!(s, ActorStatus::Idle))
1576            .await
1577            .unwrap();
1578
1579        // Client instance for opening reply ports.
1580        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
1581        let client = client_proc.client("client");
1582
1583        let agent_id: ActorAddr = proc.proc_addr().actor_addr(PROC_AGENT_ACTOR_NAME);
1584        let port = agent_id.introspect_port();
1585
1586        // Helper: send QueryChild(Proc) and return the payload with a
1587        // timeout so a misrouted reply fails fast rather than hanging.
1588        let query = |client: &hyperactor::Client| {
1589            let (reply_port, reply_rx) = client.open_once_port::<IntrospectResult>();
1590            port.post(
1591                client,
1592                IntrospectMessage::QueryChild {
1593                    child_ref: Addr::Proc(proc.proc_addr().clone()),
1594                    reply: reply_port.bind(),
1595                },
1596            );
1597            reply_rx
1598        };
1599        let recv = |rx: hyperactor::mailbox::OncePortReceiver<IntrospectResult>| async move {
1600            tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1601                .await
1602                .expect("QueryChild(Proc) timed out — reply never delivered")
1603                .expect("reply channel closed")
1604        };
1605
1606        // Initial query: ProcAgent itself should appear in children.
1607        let payload = recv(query(&client)).await;
1608        // Verify this is a proc node by checking attrs contain node_type=proc.
1609        let attrs: hyperactor_config::Attrs =
1610            serde_json::from_str(&payload.attrs).expect("valid attrs JSON");
1611        assert_eq!(
1612            attrs.get(crate::introspect::NODE_TYPE).map(String::as_str),
1613            Some("proc"),
1614            "expected node_type=proc in attrs, got {:?}",
1615            payload.attrs
1616        );
1617        assert!(
1618            payload
1619                .children
1620                .iter()
1621                .any(|c| c.to_string().contains(PROC_AGENT_ACTOR_NAME)),
1622            "initial children {:?} should contain proc_agent",
1623            payload.children
1624        );
1625        let initial_count = payload.children.len();
1626
1627        // Spawn an actor directly on the proc, bypassing ProcAgent's
1628        // gspawn message handler. This is how supervision-spawned
1629        // actors (e.g. sieve children) are created.
1630        proc.spawn_with_label("extra_actor", ExtraActor);
1631
1632        // Second query: extra_actor must appear without any republish.
1633        let payload2 = recv(query(&client)).await;
1634        let attrs2: hyperactor_config::Attrs =
1635            serde_json::from_str(&payload2.attrs).expect("valid attrs JSON");
1636        assert_eq!(
1637            attrs2.get(crate::introspect::NODE_TYPE).map(String::as_str),
1638            Some("proc"),
1639            "expected node_type=proc in attrs, got {:?}",
1640            payload2.attrs
1641        );
1642        assert!(
1643            payload2
1644                .children
1645                .iter()
1646                .any(|c| c.to_string().contains("extra_actor")),
1647            "after direct spawn, children {:?} should contain extra_actor",
1648            payload2.children
1649        );
1650        assert!(
1651            payload2.children.len() > initial_count,
1652            "expected at least {} children after direct spawn, got {:?}",
1653            initial_count + 1,
1654            payload2.children
1655        );
1656    }
1657
1658    // Exercises S12 (see introspect module doc): introspection must
1659    // not impair actor liveness. Rapidly spawns and stops
1660    // actors while concurrently querying QueryChild(Addr::Proc).
1661    // The spawn/stop loop must complete within the timeout and the
1662    // iteration count must match -- if DashMap convoy starvation
1663    // blocks the proc, the timeout fires and the test fails.
1664    #[tokio::test]
1665    async fn test_rapid_spawn_stop_does_not_stall_proc_agent() {
1666        use std::sync::Arc;
1667        use std::sync::atomic::AtomicUsize;
1668        use std::sync::atomic::Ordering;
1669
1670        use hyperactor::Proc;
1671        use hyperactor::actor::ActorStatus;
1672        use hyperactor::channel::ChannelTransport;
1673        use hyperactor::introspect::IntrospectMessage;
1674        use hyperactor::introspect::IntrospectResult;
1675
1676        let proc = Proc::direct(ChannelTransport::Unix.any(), "test_proc".to_string()).unwrap();
1677        let agent_handle = ProcAgent::boot_v1(proc.clone(), None).unwrap();
1678
1679        agent_handle
1680            .status()
1681            .wait_for(|s| matches!(s, ActorStatus::Idle))
1682            .await
1683            .unwrap();
1684
1685        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
1686        let client = client_proc.client("client");
1687
1688        let agent_id: ActorAddr = proc.proc_addr().actor_addr(PROC_AGENT_ACTOR_NAME);
1689        let port = agent_id.introspect_port();
1690
1691        // Concurrent query task: send QueryChild(Proc) every 10ms.
1692        let query_client_proc =
1693            Proc::direct(ChannelTransport::Unix.any(), "query_client".to_string()).unwrap();
1694        let query_client = query_client_proc.client("qc");
1695        let query_port = port.clone();
1696        let query_proc_id = proc.proc_addr().clone();
1697        let query_count = Arc::new(AtomicUsize::new(0));
1698        let query_count_clone = query_count.clone();
1699        let query_task = tokio::spawn(async move {
1700            loop {
1701                let (reply_port, reply_rx) = query_client.open_once_port::<IntrospectResult>();
1702                let mut reply = reply_port.bind();
1703                reply.return_undeliverable(false);
1704                query_port.post(
1705                    &query_client,
1706                    IntrospectMessage::QueryChild {
1707                        child_ref: Addr::Proc(query_proc_id.clone()),
1708                        reply,
1709                    },
1710                );
1711                match tokio::time::timeout(std::time::Duration::from_secs(2), reply_rx.recv()).await
1712                {
1713                    Ok(Ok(_)) => {
1714                        query_count_clone.fetch_add(1, Ordering::Relaxed);
1715                    }
1716                    _ => {} // Transient failures expected during churn
1717                }
1718                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1719            }
1720        });
1721
1722        // Rapid spawn/stop loop with liveness timeout.
1723        const ITERATIONS: usize = 200;
1724        let mut completed = 0usize;
1725        let result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
1726            for i in 0..ITERATIONS {
1727                let name = format!("churn_{}", i);
1728                let handle = proc.spawn_with_label(&name, ExtraActor);
1729                let actor_id = handle.actor_addr().clone();
1730                if let Some(mut status) = proc.stop_actor(actor_id.id(), "churn".to_string()) {
1731                    let _ = tokio::time::timeout(
1732                        std::time::Duration::from_secs(5),
1733                        status.wait_for(ActorStatus::is_terminal),
1734                    )
1735                    .await;
1736                }
1737                completed += 1;
1738            }
1739        })
1740        .await;
1741
1742        query_task.abort();
1743        let _ = query_task.await; // Join to suppress noisy panic on drop.
1744
1745        assert!(
1746            result.is_ok(),
1747            "spawn/stop loop stalled after {completed}/{ITERATIONS} iterations — \
1748             DashMap convoy starvation likely"
1749        );
1750        assert_eq!(
1751            completed, ITERATIONS,
1752            "expected {ITERATIONS} completed iterations, got {completed}"
1753        );
1754        assert!(
1755            query_count.load(Ordering::Relaxed) > 0,
1756            "concurrent QueryChild queries never succeeded — query task may not have run"
1757        );
1758
1759        // Final consistency check: QueryChild should still work.
1760        let (reply_port, reply_rx) = client.open_once_port::<IntrospectResult>();
1761        port.post(
1762            &client,
1763            IntrospectMessage::QueryChild {
1764                child_ref: Addr::Proc(proc.proc_addr().clone()),
1765                reply: reply_port.bind(),
1766            },
1767        );
1768        let final_payload =
1769            tokio::time::timeout(std::time::Duration::from_secs(5), reply_rx.recv())
1770                .await
1771                .expect("final QueryChild timed out")
1772                .expect("final QueryChild channel closed");
1773        let attrs: hyperactor_config::Attrs =
1774            serde_json::from_str(&final_payload.attrs).expect("valid attrs JSON");
1775        assert_eq!(
1776            attrs.get(crate::introspect::NODE_TYPE).map(String::as_str),
1777            Some("proc"),
1778        );
1779    }
1780
1781    #[tokio::test]
1782    async fn test_stream_state_and_unsubscribe() {
1783        use hyperactor::Proc;
1784        use hyperactor::actor::ActorStatus;
1785        use hyperactor::channel::ChannelTransport;
1786
1787        use crate::resource::CreateOrUpdateClient;
1788        use crate::resource::GetStateClient;
1789        use crate::resource::StopClient;
1790        use crate::resource::StreamStateClient;
1791
1792        let proc = Proc::direct(ChannelTransport::Unix.any(), "test_proc".to_string()).unwrap();
1793        let agent_handle = ProcAgent::boot_v1(proc.clone(), None).unwrap();
1794        agent_handle
1795            .status()
1796            .wait_for(|s| matches!(s, ActorStatus::Idle))
1797            .await
1798            .unwrap();
1799
1800        let client = proc.client("client");
1801        let agent_ref: ActorRef<ProcAgent> = agent_handle.bind();
1802
1803        // A missing actor has no `ActorState` payload, so the streamed message
1804        // itself must retain the subscription rank.
1805        let missing_name =
1806            ResourceId::singleton(hyperactor::id::Label::new("missing-actor").unwrap());
1807        let (missing_port, mut missing_rx) =
1808            client.open_port::<resource::RankedState<ActorState>>();
1809        agent_ref
1810            .stream_state(
1811                &client,
1812                missing_name,
1813                resource::Rank::new(6),
1814                missing_port.bind(),
1815            )
1816            .await
1817            .unwrap();
1818        let missing = missing_rx.recv().await.expect("missing state reply");
1819        assert_eq!(missing.rank.unwrap(), 6);
1820        assert_eq!(missing.state.status, resource::Status::NotExist);
1821        assert_eq!(missing.state.state, None);
1822
1823        let actor_type = hyperactor::actor::remote::Remote::collect()
1824            .name_of::<ExtraActor>()
1825            .unwrap()
1826            .to_string();
1827        let actor_params =
1828            bincode::serde::encode_to_vec(&ExtraActor, bincode::config::legacy()).unwrap();
1829        let actor_name = ResourceId::singleton(hyperactor::id::Label::new("test-actor").unwrap());
1830
1831        // 1. Spawn an actor via CreateOrUpdate.
1832        agent_ref
1833            .create_or_update(
1834                &client,
1835                actor_name.clone(),
1836                resource::Rank::new(0),
1837                ActorSpec {
1838                    actor_type: actor_type.clone(),
1839                    params_data: actor_params.clone(),
1840                    actor_environment: ActorEnvironment::default(),
1841                },
1842            )
1843            .await
1844            .unwrap();
1845
1846        // 2. Subscribe to state updates.
1847        let subscriber_rank = 7;
1848        let (sub_port, mut sub_rx) = client.open_port::<resource::RankedState<ActorState>>();
1849        agent_ref
1850            .stream_state(
1851                &client,
1852                actor_name.clone(),
1853                resource::Rank::new(subscriber_rank),
1854                sub_port.bind(),
1855            )
1856            .await
1857            .unwrap();
1858
1859        // 3. Should receive the initial state (Running).
1860        let initial = sub_rx.recv().await.expect("subscriber channel error");
1861        assert_eq!(initial.rank.unwrap(), subscriber_rank);
1862        assert_eq!(initial.state.status, resource::Status::Running);
1863        assert!(initial.state.state.is_some());
1864
1865        // 4. Send Stop — should receive Stopping.
1866        agent_ref
1867            .stop(&client, actor_name.clone(), "test".to_string())
1868            .await
1869            .unwrap();
1870
1871        let stopping = sub_rx.recv().await.expect("subscriber channel error");
1872        assert_eq!(stopping.rank.unwrap(), subscriber_rank);
1873        assert_eq!(stopping.state.status, resource::Status::Stopping);
1874
1875        // 5. Wait for the Stopped supervision event update.
1876        let stopped = sub_rx.recv().await.expect("subscriber channel error");
1877        assert_eq!(stopped.rank.unwrap(), subscriber_rank);
1878        assert_eq!(stopped.state.status, resource::Status::Stopped);
1879
1880        // 6. Test implicit unsubscription via undeliverable.
1881        let actor_name_2 =
1882            ResourceId::singleton(hyperactor::id::Label::new("test-actor-2").unwrap());
1883        agent_ref
1884            .create_or_update(
1885                &client,
1886                actor_name_2.clone(),
1887                resource::Rank::new(1),
1888                ActorSpec {
1889                    actor_type: actor_type.clone(),
1890                    params_data: actor_params.clone(),
1891                    actor_environment: ActorEnvironment::default(),
1892                },
1893            )
1894            .await
1895            .unwrap();
1896
1897        let (sub_port_2, mut sub_rx_2) = client.open_port::<resource::RankedState<ActorState>>();
1898        agent_ref
1899            .stream_state(
1900                &client,
1901                actor_name_2.clone(),
1902                resource::Rank::new(8),
1903                sub_port_2.bind(),
1904            )
1905            .await
1906            .unwrap();
1907
1908        let initial_2 = sub_rx_2.recv().await.expect("subscriber 2 channel error");
1909        assert_eq!(initial_2.rank.unwrap(), 8);
1910        assert_eq!(initial_2.state.status, resource::Status::Running);
1911
1912        // Drop the receiver so the next send bounces as undeliverable.
1913        drop(sub_rx_2);
1914
1915        // Stop the second actor — triggers notify_status_changed to the
1916        // dead subscriber. ProcAgent should handle the undeliverable
1917        // gracefully.
1918        agent_ref
1919            .stop(
1920                &client,
1921                actor_name_2.clone(),
1922                "test unsubscribe".to_string(),
1923            )
1924            .await
1925            .unwrap();
1926
1927        // Wait for actor_2 to reach terminal state via a new stream subscription.
1928        let (sub_port_3, mut sub_rx_3) = client.open_port::<resource::RankedState<ActorState>>();
1929        agent_ref
1930            .stream_state(
1931                &client,
1932                actor_name_2.clone(),
1933                resource::Rank::new(9),
1934                sub_port_3.bind(),
1935            )
1936            .await
1937            .unwrap();
1938        loop {
1939            let state = sub_rx_3.recv().await.expect("subscriber 3 channel error");
1940            assert_eq!(state.rank.unwrap(), 9);
1941            if state.state.status.is_terminating() {
1942                break;
1943            }
1944        }
1945
1946        // Verify ProcAgent is still alive after the undeliverable was handled.
1947        let state = agent_ref
1948            .get_state(&client, actor_name_2.clone())
1949            .await
1950            .unwrap();
1951        assert!(
1952            state.status.is_terminating(),
1953            "expected terminating status, got {:?}",
1954            state.status,
1955        );
1956    }
1957
1958    // ── PD-4/PD-5: live proc-agent queue pressure test ────────
1959
1960    // A blocking actor for inducing queue pressure. Uses a shared
1961    // Notify for the block/unblock protocol since actor messages
1962    // must be Serialize + Clone.
1963    #[derive(Debug, Default, Serialize, Deserialize)]
1964    #[hyperactor::export(handlers = [BlockMsg])]
1965    struct BlockActor {
1966        #[serde(skip)]
1967        gate: Option<Arc<tokio::sync::Notify>>,
1968    }
1969    impl hyperactor::Actor for BlockActor {}
1970
1971    #[derive(
1972        Debug,
1973        Clone,
1974        Serialize,
1975        Deserialize,
1976        Named,
1977        hyperactor::Handler,
1978        hyperactor::HandleClient
1979    )]
1980    enum BlockMsg {
1981        /// Block until the shared Notify fires.
1982        Block(),
1983        /// No-op message to queue behind a blocked Block.
1984        Noop(),
1985    }
1986    wirevalue::register_type!(BlockMsg);
1987
1988    #[async_trait::async_trait]
1989    #[hyperactor::handle(BlockMsg)]
1990    impl BlockMsgHandler for BlockActor {
1991        async fn block(&mut self, _cx: &hyperactor::Context<Self>) -> Result<(), anyhow::Error> {
1992            if let Some(gate) = &self.gate {
1993                gate.notified().await;
1994            }
1995            Ok(())
1996        }
1997        async fn noop(&mut self, _cx: &hyperactor::Context<Self>) -> Result<(), anyhow::Error> {
1998            Ok(())
1999        }
2000    }
2001
2002    // PD-4/PD-5: QueryChild(Proc) returns non-zero queue stats
2003    // while actors are under induced pressure. This proves the
2004    // live proc-agent introspection path carries the queue depth
2005    // signal that the TUI depends on.
2006    //
2007    // Queue depth is an instantaneous snapshot at query time,
2008    // not backlog history.
2009    #[tokio::test]
2010    async fn test_query_child_proc_queue_depth_under_pressure() {
2011        use hyperactor::Proc;
2012        use hyperactor::actor::ActorStatus;
2013        use hyperactor::channel::ChannelTransport;
2014        use hyperactor::introspect::IntrospectMessage;
2015        use hyperactor::introspect::IntrospectResult;
2016
2017        let proc = Proc::direct(ChannelTransport::Unix.any(), "qd_proc".to_string()).unwrap();
2018        let agent_handle = ProcAgent::boot_v1(proc.clone(), None).unwrap();
2019
2020        agent_handle
2021            .status()
2022            .wait_for(|s| matches!(s, ActorStatus::Idle))
2023            .await
2024            .unwrap();
2025
2026        let client_proc =
2027            Proc::direct(ChannelTransport::Unix.any(), "qd_client".to_string()).unwrap();
2028        let client = client_proc.client("client");
2029
2030        // Spawn a blocking actor with a shared gate.
2031        let gate = Arc::new(tokio::sync::Notify::new());
2032        let blocker = proc.spawn(BlockActor {
2033            gate: Some(Arc::clone(&gate)),
2034        });
2035
2036        // Block the actor and queue additional work behind it.
2037        blocker.block(&client).await.unwrap();
2038        // Give the actor time to enter the handler.
2039        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2040        blocker.noop(&client).await.unwrap();
2041        blocker.noop(&client).await.unwrap();
2042
2043        // QueryChild(Proc) — same aggregation logic as mesh-admin
2044        // resolution.
2045        let agent_id: ActorAddr = proc.proc_addr().actor_addr(PROC_AGENT_ACTOR_NAME);
2046        let port = agent_id.introspect_port();
2047
2048        // Poll until queue stats are non-zero.
2049        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
2050        loop {
2051            let (reply_port, reply_rx) = client.open_once_port::<IntrospectResult>();
2052            port.post(
2053                &client,
2054                IntrospectMessage::QueryChild {
2055                    child_ref: Addr::Proc(proc.proc_addr().clone()),
2056                    reply: reply_port.bind(),
2057                },
2058            );
2059            let payload = tokio::time::timeout(std::time::Duration::from_secs(3), reply_rx.recv())
2060                .await
2061                .expect("QueryChild timed out")
2062                .expect("reply channel closed");
2063
2064            let attrs: hyperactor_config::Attrs =
2065                serde_json::from_str(&payload.attrs).expect("valid attrs JSON");
2066
2067            let total = attrs
2068                .get(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_TOTAL)
2069                .copied()
2070                .unwrap_or(0);
2071            let max = attrs
2072                .get(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_MAX)
2073                .copied()
2074                .unwrap_or(0);
2075
2076            if total > 0 {
2077                assert!(max > 0, "max should be > 0 when total is {total}");
2078                assert!(max <= total, "PD-1: max ({max}) <= total ({total})");
2079                break;
2080            }
2081
2082            assert!(
2083                tokio::time::Instant::now() < deadline,
2084                "timed out waiting for non-zero queue depth in QueryChild(Proc)",
2085            );
2086            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2087        }
2088
2089        // Unblock the actor.
2090        gate.notify_one();
2091    }
2092
2093    hyperactor_config::attrs::declare_attrs! {
2094        attr AENV_SPEC_TAG: u64;
2095        attr AENV_SPEC_LABEL: String;
2096    }
2097
2098    // AENV-3: the actor environment travels with `ActorSpec` through the same
2099    // positional codec used for every remote spawn, and `ActorSpec` equality
2100    // composes the environment's semantic (order-independent) equality rather
2101    // than a raw-buffer comparison.
2102    #[test]
2103    fn actor_environment_serializes_in_actor_spec() {
2104        let mut env = ActorEnvironment::default();
2105        env.set(AENV_SPEC_TAG, 7u64).expect("insert tag");
2106        env.set(AENV_SPEC_LABEL, "root".to_string())
2107            .expect("insert label");
2108
2109        let spec = ActorSpec {
2110            actor_type: "probe".to_string(),
2111            params_data: vec![1, 2, 3],
2112            actor_environment: env.clone(),
2113        };
2114
2115        let bytes = bincode::serde::encode_to_vec(&spec, bincode::config::legacy()).unwrap();
2116        let restored: ActorSpec =
2117            bincode::serde::decode_from_slice(&bytes, bincode::config::legacy())
2118                .map(|(v, _)| v)
2119                .unwrap();
2120
2121        assert_eq!(restored.actor_environment.get(AENV_SPEC_TAG), Some(7u64));
2122        assert_eq!(
2123            restored.actor_environment.get(AENV_SPEC_LABEL),
2124            Some("root".to_string())
2125        );
2126        assert_eq!(restored.actor_environment, env);
2127        // Derived `ActorSpec` equality composes the semantic environment
2128        // equality.
2129        assert_eq!(restored, spec);
2130    }
2131
2132    // ----- client-root capability proofs -----
2133
2134    // Boot a root ProcAgent and bind the client-root API on it.
2135    async fn boot_client_root() -> (
2136        hyperactor::Proc,
2137        ActorHandle<ProcAgent>,
2138        crate::client_root::ClientRootRef,
2139    ) {
2140        use hyperactor::Proc;
2141        use hyperactor::actor::ActorStatus;
2142        use hyperactor::channel::ChannelTransport;
2143
2144        let proc = Proc::direct(ChannelTransport::Unix.any(), "root".to_string()).unwrap();
2145        let agent = ProcAgent::boot_v1(proc.clone(), None).unwrap();
2146        agent
2147            .status()
2148            .wait_for(|s| matches!(s, ActorStatus::Idle))
2149            .await
2150            .unwrap();
2151        let client_root = crate::client_root::ClientRootRef::bind(&agent);
2152        (proc, agent, client_root)
2153    }
2154
2155    // Proof 1: the capability serde-round-trips its bound reference, while its
2156    // display/Debug are redacted and textual parse is rejected (CROOT-2).
2157    #[tokio::test]
2158    async fn client_root_ref_redacts_and_round_trips() {
2159        use hyperactor_config::AttrValue;
2160
2161        use crate::client_root::ClientRootRef;
2162
2163        let (_proc, _agent, client_root) = boot_client_root().await;
2164
2165        let bytes = bincode::serde::encode_to_vec(&client_root, bincode::config::legacy()).unwrap();
2166        let restored: ClientRootRef =
2167            bincode::serde::decode_from_slice(&bytes, bincode::config::legacy())
2168                .map(|(v, _)| v)
2169                .unwrap();
2170        assert_eq!(
2171            restored, client_root,
2172            "capability must round-trip via serde"
2173        );
2174
2175        assert_eq!(client_root.display(), "<redacted>");
2176        assert_eq!(format!("{:?}", client_root), "ClientRootRef(<redacted>)");
2177        assert!(
2178            ClientRootRef::parse("anything").is_err(),
2179            "a bearer capability must not be reconstructible from text"
2180        );
2181    }
2182
2183    // Proof 10: binding the API twice on the same root returns the same
2184    // reference and adds no registration/activation state (CROOT-1).
2185    #[tokio::test]
2186    async fn client_root_bind_is_idempotent() {
2187        use crate::client_root::ClientRootRef;
2188
2189        let (_proc, agent, first) = boot_client_root().await;
2190        let second = ClientRootRef::bind(&agent);
2191        assert_eq!(first, second);
2192    }
2193
2194    // Proofs 3/5: ensuring the same service returns one root-owned actor, and a
2195    // requester exiting does not stop it — a later requester observes the same
2196    // address.
2197    #[tokio::test]
2198    async fn client_root_ensure_is_root_owned_and_single_identity() {
2199        use crate::client_root::ClientRootService;
2200
2201        let (proc, _agent, client_root) = boot_client_root().await;
2202        let service = ClientRootService::<ExtraActor>::declare("probe-service");
2203
2204        let caller_a = proc.client("caller-a");
2205        let first = service
2206            .ensure(&caller_a, &client_root, ())
2207            .await
2208            .expect("first ensure creates the service");
2209        // The first requester context is gone; a later ensure still reuses the
2210        // one service (CROOT-3). Departed-requester survival and the in-flight
2211        // drop are covered by `client_root_departed_requester_does_not_fault_root`.
2212        drop(caller_a);
2213
2214        let caller_b = proc.client("caller-b");
2215        let second = service
2216            .ensure(&caller_b, &client_root, ())
2217            .await
2218            .expect("second ensure reuses the service after requester exit");
2219        assert_eq!(
2220            first.actor_addr(),
2221            second.actor_addr(),
2222            "identical ensures must resolve to the one root-owned service (CROOT-3)"
2223        );
2224    }
2225
2226    // A second registered actor type, distinct from `ExtraActor`, so a repeat
2227    // ensure under the same name with a different type conflicts.
2228    #[derive(Debug, Default, Serialize, Deserialize)]
2229    #[hyperactor::export(handlers = [])]
2230    struct OtherActor;
2231    impl hyperactor::Actor for OtherActor {}
2232    hyperactor::register_spawnable!(OtherActor);
2233
2234    // CROOT-3: the same service name with a different actor type conflicts and
2235    // surfaces as a transparent `Error::ClientRootError(ConflictingSpec)`.
2236    #[tokio::test]
2237    async fn client_root_conflicting_type_is_rejected() {
2238        use crate::client_root::ClientRootError;
2239        use crate::client_root::ClientRootService;
2240
2241        let (proc, _agent, client_root) = boot_client_root().await;
2242        let caller = proc.client("caller");
2243
2244        ClientRootService::<ExtraActor>::declare("shared")
2245            .ensure(&caller, &client_root, ())
2246            .await
2247            .expect("first ensure creates the service");
2248
2249        let err = ClientRootService::<OtherActor>::declare("shared")
2250            .ensure(&caller, &client_root, ())
2251            .await
2252            .expect_err("a different actor type for the same name must conflict");
2253        assert!(
2254            matches!(
2255                err,
2256                crate::Error::ClientRootError(ClientRootError::ConflictingSpec { .. })
2257            ),
2258            "expected ClientRootError::ConflictingSpec, got {err:?}"
2259        );
2260    }
2261
2262    // H5 / CROOT-3: the service actor uses a fresh instance id, not a
2263    // deterministic `client-root-<name>` singleton, avoiding the predictable
2264    // collision a name-derived id would create with a mesh actor.
2265    #[tokio::test]
2266    async fn client_root_service_uses_instance_id() {
2267        use crate::client_root::ClientRootService;
2268
2269        let (proc, _agent, client_root) = boot_client_root().await;
2270        let caller = proc.client("caller");
2271        let svc = ClientRootService::<ExtraActor>::declare("probe")
2272            .ensure(&caller, &client_root, ())
2273            .await
2274            .expect("ensure creates the service");
2275        assert!(
2276            !svc.actor_addr().is_root(),
2277            "a client-root service must use a fresh instance uid, not a singleton id"
2278        );
2279    }
2280
2281    // A spawnable actor whose remote constructor increments a global counter, so
2282    // a test can prove single-flight *construction*, not just a single identity.
2283    static COUNTING_ACTOR_BUILDS: std::sync::atomic::AtomicUsize =
2284        std::sync::atomic::AtomicUsize::new(0);
2285
2286    #[derive(Debug)]
2287    #[hyperactor::export(handlers = [])]
2288    #[hyperactor::spawnable]
2289    struct CountingActor;
2290    impl hyperactor::Actor for CountingActor {}
2291
2292    #[async_trait::async_trait]
2293    impl hyperactor::RemoteSpawn for CountingActor {
2294        type Params = ();
2295        async fn new(_params: (), _environment: &ActorEnvironment) -> anyhow::Result<Self> {
2296            COUNTING_ACTOR_BUILDS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2297            Ok(Self)
2298        }
2299    }
2300
2301    // Proof 2: concurrent identical ensures collapse through the serial root
2302    // ProcAgent to a single service identity AND a single construction.
2303    #[tokio::test]
2304    async fn client_root_concurrent_ensures_collapse() {
2305        use std::sync::atomic::Ordering;
2306
2307        use crate::client_root::ClientRootService;
2308
2309        COUNTING_ACTOR_BUILDS.store(0, Ordering::SeqCst);
2310        let (proc, _agent, client_root) = boot_client_root().await;
2311        let ensure = |name: &'static str| {
2312            let caller = proc.client(name);
2313            let client_root = &client_root;
2314            async move {
2315                ClientRootService::<CountingActor>::declare("concurrent")
2316                    .ensure(&caller, client_root, ())
2317                    .await
2318                    .expect("ensure succeeds")
2319                    .actor_addr()
2320                    .clone()
2321            }
2322        };
2323
2324        let (a, b, c, d) = tokio::join!(
2325            ensure("caller-0"),
2326            ensure("caller-1"),
2327            ensure("caller-2"),
2328            ensure("caller-3"),
2329        );
2330        assert_eq!(a, b, "concurrent ensures must resolve to one service");
2331        assert_eq!(a, c, "concurrent ensures must resolve to one service");
2332        assert_eq!(a, d, "concurrent ensures must resolve to one service");
2333        assert_eq!(
2334            COUNTING_ACTOR_BUILDS.load(Ordering::SeqCst),
2335            1,
2336            "single-flight: identical concurrent ensures must construct once"
2337        );
2338    }
2339
2340    // H3: `service_result` fails closed for a service that has gone terminal or
2341    // never spawned; only a running service returns its address.
2342    #[tokio::test]
2343    async fn client_root_service_result_fails_closed_on_terminal() {
2344        use hyperactor::actor::ActorStatus;
2345
2346        use crate::client_root::ClientRootError;
2347
2348        let (_proc, agent, _client_root) = boot_client_root().await;
2349        let addr = agent.bind::<ProcAgent>().actor_addr().clone();
2350        let name = Label::new("svc").unwrap();
2351        let id = ResourceId::instance(name.clone());
2352
2353        let mk = |spawn: Result<ActorAddr, anyhow::Error>,
2354                  stop_initiated: bool,
2355                  supervision_event: Option<ActorSupervisionEvent>| {
2356            ActorInstanceState {
2357                create_rank: 0,
2358                spawn,
2359                stop_initiated,
2360                supervision_event,
2361                subscribers: Vec::new(),
2362                expiry_time: None,
2363                generation: 1,
2364                pending_wait_status: Vec::new(),
2365            }
2366        };
2367
2368        // Running -> Ok(addr).
2369        let mut states = HashMap::new();
2370        states.insert(id.clone(), mk(Ok(addr.clone()), false, None));
2371        assert_eq!(
2372            ProcAgent::service_result(&states, &id, &name).expect("running service resolves"),
2373            addr
2374        );
2375
2376        // Stopping (stop initiated, no event yet) -> Unavailable.
2377        let mut states = HashMap::new();
2378        states.insert(id.clone(), mk(Ok(addr.clone()), true, None));
2379        assert!(matches!(
2380            ProcAgent::service_result(&states, &id, &name),
2381            Err(ClientRootError::Unavailable { .. })
2382        ));
2383
2384        // Terminal via a supervision event -> Unavailable.
2385        let mut states = HashMap::new();
2386        let event = ActorSupervisionEvent::new(
2387            addr.clone(),
2388            None,
2389            ActorStatus::Stopped("done".into()),
2390            None,
2391        );
2392        states.insert(id.clone(), mk(Ok(addr.clone()), false, Some(event)));
2393        assert!(matches!(
2394            ProcAgent::service_result(&states, &id, &name),
2395            Err(ClientRootError::Unavailable { .. })
2396        ));
2397
2398        // Cached spawn failure -> Spawn.
2399        let mut states = HashMap::new();
2400        states.insert(id.clone(), mk(Err(anyhow::anyhow!("boom")), false, None));
2401        assert!(matches!(
2402            ProcAgent::service_result(&states, &id, &name),
2403            Err(ClientRootError::Spawn { .. })
2404        ));
2405
2406        // Missing state -> Spawn.
2407        let states: HashMap<ResourceId, ActorInstanceState> = HashMap::new();
2408        assert!(matches!(
2409            ProcAgent::service_result(&states, &id, &name),
2410            Err(ClientRootError::Spawn { .. })
2411        ));
2412    }
2413
2414    // CROOT-6: reading the capability from an environment that lacks it fails
2415    // closed with the typed `MissingClientRoot`, not a silent absence.
2416    #[tokio::test]
2417    async fn client_root_from_env_absent_fails_closed() {
2418        use crate::client_root::ClientRootRef;
2419
2420        let (proc, _agent, _client_root) = boot_client_root().await;
2421        let caller = proc.client("no-capability");
2422        let environment = hyperactor::context::Actor::instance(&caller).actor_environment();
2423        assert!(
2424            matches!(
2425                ClientRootRef::from_env(environment),
2426                Err(crate::Error::MissingClientRoot)
2427            ),
2428            "a caller without the capability must fail closed with MissingClientRoot"
2429        );
2430    }
2431
2432    // Boot a root ProcAgent with a shutdown channel so a StopAll signals the
2433    // exit code instead of calling `process::exit`.
2434    async fn boot_client_root_with_shutdown() -> (
2435        hyperactor::Proc,
2436        ActorHandle<ProcAgent>,
2437        crate::client_root::ClientRootRef,
2438        tokio::sync::oneshot::Receiver<i32>,
2439    ) {
2440        use hyperactor::Proc;
2441        use hyperactor::actor::ActorStatus;
2442        use hyperactor::channel::ChannelTransport;
2443
2444        let proc = Proc::direct(ChannelTransport::Unix.any(), "root".to_string()).unwrap();
2445        let (tx, rx) = tokio::sync::oneshot::channel();
2446        let agent = ProcAgent::boot_v1(proc.clone(), Some(tx)).unwrap();
2447        agent
2448            .status()
2449            .wait_for(|s| matches!(s, ActorStatus::Idle))
2450            .await
2451            .unwrap();
2452        let client_root = crate::client_root::ClientRootRef::bind(&agent);
2453        (proc, agent, client_root, rx)
2454    }
2455
2456    // A root-owned service that reports the client-root capability it reads back
2457    // from its own environment, so a test can compare its exact identity.
2458    #[derive(Debug, Serialize, Deserialize, Named)]
2459    struct ClientRootObservation(Option<crate::client_root::ClientRootRef>);
2460    wirevalue::register_type!(ClientRootObservation);
2461
2462    #[derive(Debug, Serialize, Deserialize, Named)]
2463    struct ObserveClientRoot {
2464        reply: hyperactor::OncePortRef<ClientRootObservation>,
2465    }
2466    wirevalue::register_type!(ObserveClientRoot);
2467
2468    #[derive(Debug, Default, Serialize, Deserialize)]
2469    #[hyperactor::export(handlers = [ObserveClientRoot])]
2470    struct EnvProbeActor;
2471    impl hyperactor::Actor for EnvProbeActor {}
2472    hyperactor::register_spawnable!(EnvProbeActor);
2473
2474    #[async_trait::async_trait]
2475    impl Handler<ObserveClientRoot> for EnvProbeActor {
2476        async fn handle(
2477            &mut self,
2478            cx: &Context<Self>,
2479            msg: ObserveClientRoot,
2480        ) -> anyhow::Result<()> {
2481            let environment = hyperactor::context::Actor::instance(cx).actor_environment();
2482            let observed = crate::client_root::ClientRootRef::from_env(environment).ok();
2483            msg.reply.post(cx, ClientRootObservation(observed));
2484            Ok(())
2485        }
2486    }
2487
2488    // H1 / CROOT-11: a requester that departs before the reply lands must not
2489    // fault the root. The reply is posted to a dropped once-port, which without
2490    // `return_undeliverable(false)` would return to the root's fatal
2491    // `handle_invalid_reference`. The root must still serve a later request.
2492    #[tokio::test]
2493    async fn client_root_departed_requester_does_not_fault_root() {
2494        use std::time::Duration;
2495
2496        use hyperactor::ActorRef;
2497        use hyperactor::Endpoint;
2498
2499        use crate::client_root::ClientRootApi;
2500        use crate::client_root::ClientRootService;
2501
2502        let (proc, _agent, client_root) = boot_client_root().await;
2503
2504        {
2505            let caller = proc.client("ephemeral");
2506            let (reply_handle, reply_receiver) =
2507                caller.open_once_port::<EnsureClientRootServiceReply>();
2508            let reply = reply_handle.bind();
2509            // Drop the receiver BEFORE posting so the reply is guaranteed
2510            // undeliverable; otherwise the root could answer before the port is
2511            // gone and the fatal-return path would never be exercised.
2512            drop(reply_receiver);
2513            let actor_type = Remote::collect()
2514                .name_of::<ExtraActor>()
2515                .unwrap()
2516                .to_string();
2517            let params = bincode::serde::encode_to_vec((), bincode::config::legacy()).unwrap();
2518            let api = ActorRef::<ClientRootApi>::attest(client_root.addr().clone());
2519            api.post(
2520                &caller,
2521                EnsureClientRootService {
2522                    service_name: Label::new("ephemeral-svc").unwrap(),
2523                    actor_type,
2524                    params,
2525                    reply,
2526                },
2527            );
2528            drop(caller);
2529        }
2530
2531        // Two sequential survivor ensures. The undeliverable reply-return is
2532        // enqueued to the root while it handles the request above, so by the time
2533        // the second ensure completes the root has processed it; a fatal return
2534        // would fail (or hang) one of these rather than slip past a single fast
2535        // reply.
2536        let survivor = proc.client("survivor");
2537        for name in ["after-departure-1", "after-departure-2"] {
2538            tokio::time::timeout(
2539                Duration::from_secs(15),
2540                ClientRootService::<ExtraActor>::declare(name).ensure(&survivor, &client_root, ()),
2541            )
2542            .await
2543            .expect("root must stay responsive after a departed requester")
2544            .expect("root must serve a new ensure after a departed requester");
2545        }
2546    }
2547
2548    // A stop-gated service: its handler blocks until released, so a test can hold
2549    // a proc open across a StopAll and then observe shutdown run to completion.
2550    // The gate is process-global (statics) because a remote-spawned actor cannot
2551    // carry a non-serializable field.
2552    static GATE_ENTERED: tokio::sync::Notify = tokio::sync::Notify::const_new();
2553    static GATE_RELEASE: tokio::sync::Notify = tokio::sync::Notify::const_new();
2554
2555    #[derive(
2556        Debug,
2557        Clone,
2558        Serialize,
2559        Deserialize,
2560        Named,
2561        hyperactor::Handler,
2562        hyperactor::HandleClient
2563    )]
2564    enum GateMsg {
2565        Block(),
2566    }
2567    wirevalue::register_type!(GateMsg);
2568
2569    #[derive(Debug, Default, Serialize, Deserialize)]
2570    #[hyperactor::export(handlers = [GateMsg])]
2571    struct GateActor;
2572    impl hyperactor::Actor for GateActor {}
2573    hyperactor::register_spawnable!(GateActor);
2574
2575    #[async_trait::async_trait]
2576    #[hyperactor::handle(GateMsg)]
2577    impl GateMsgHandler for GateActor {
2578        async fn block(&mut self, _cx: &Context<Self>) -> anyhow::Result<()> {
2579            GATE_ENTERED.notify_one();
2580            GATE_RELEASE.notified().await;
2581            Ok(())
2582        }
2583    }
2584
2585    // H2: once shutdown has begun a NEW ensure is refused, so a service cannot
2586    // escape the StopAll snapshot and strand shutdown. A gated keeper holds the
2587    // proc open across StopAll; releasing it lets shutdown run to completion.
2588    #[tokio::test]
2589    async fn client_root_ensure_rejected_during_shutdown() {
2590        use std::time::Duration;
2591
2592        use hyperactor::Endpoint;
2593
2594        use crate::client_root::ClientRootError;
2595        use crate::client_root::ClientRootService;
2596
2597        let (proc, agent, client_root, shutdown_rx) = boot_client_root_with_shutdown().await;
2598
2599        // A gated keeper never reaches terminal state while blocked, so StopAll
2600        // defers shutdown until we release it.
2601        let keeper = ClientRootService::<GateActor>::declare("keeper")
2602            .ensure(&proc.client("keeper-caller"), &client_root, ())
2603            .await
2604            .expect("keeper service is created before shutdown");
2605        keeper.post(&proc.client("gate-driver"), GateMsg::Block());
2606        GATE_ENTERED.notified().await;
2607
2608        // Begin shutdown; the blocked keeper defers it. A new ensure is refused.
2609        let control = proc.client("control");
2610        agent.bind::<ProcAgent>().post(
2611            &control,
2612            resource::StopAll {
2613                reason: "test shutdown".to_string(),
2614            },
2615        );
2616        let err = tokio::time::timeout(
2617            Duration::from_secs(15),
2618            ClientRootService::<ExtraActor>::declare("too-late").ensure(&control, &client_root, ()),
2619        )
2620        .await
2621        .expect("agent must answer while the keeper defers shutdown")
2622        .expect_err("a new ensure during shutdown must be rejected");
2623        assert!(
2624            matches!(
2625                err,
2626                crate::Error::ClientRootError(ClientRootError::Unavailable { .. })
2627            ),
2628            "ensure during shutdown must be Unavailable, got {err:?}"
2629        );
2630
2631        // Release the keeper; it terminates and shutdown completes, delivering a
2632        // clean exit code through `shutdown_rx`.
2633        GATE_RELEASE.notify_one();
2634        let code = tokio::time::timeout(Duration::from_secs(30), shutdown_rx)
2635            .await
2636            .expect("shutdown must complete once the last actor terminates")
2637            .expect("shutdown channel delivered an exit code");
2638        assert_eq!(code, 0, "clean shutdown exit code");
2639    }
2640
2641    // CROOT-7: a service spawned through ensure inherits the client-root
2642    // capability in its environment and reads it back with `from_env`.
2643    #[tokio::test]
2644    async fn client_root_service_observes_inherited_capability() {
2645        use std::time::Duration;
2646
2647        use hyperactor::Endpoint;
2648
2649        use crate::client_root::ClientRootService;
2650
2651        let (proc, _agent, client_root) = boot_client_root().await;
2652        let caller = proc.client("caller");
2653        let probe = ClientRootService::<EnvProbeActor>::declare("env-probe")
2654            .ensure(&caller, &client_root, ())
2655            .await
2656            .expect("ensure creates the probe service");
2657
2658        let (reply_handle, reply_receiver) = caller.open_once_port::<ClientRootObservation>();
2659        probe.post(
2660            &caller,
2661            ObserveClientRoot {
2662                reply: reply_handle.bind(),
2663            },
2664        );
2665        let ClientRootObservation(observed) =
2666            tokio::time::timeout(Duration::from_secs(15), reply_receiver.recv())
2667                .await
2668                .expect("probe must reply")
2669                .expect("reply channel is open");
2670        assert_eq!(
2671            observed,
2672            Some(client_root.clone()),
2673            "a service must inherit the exact client-root capability, not merely some capability"
2674        );
2675    }
2676}