Skip to main content

hyperactor_mesh/host_mesh/
host_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 that manages a host.
10
11// TODO: Preserve termination failures across a full drain. If `terminate_proc`
12// fails after the proc manager removes the proc from its registry, the bulk
13// drain cannot retry it; report the failure instead of `Stopped`.
14// TODO: Decide whether `DrainHost` is a one-shot barrier or whether `stop()`
15// must retire the `HostMeshId`. Currently, stale references can create procs
16// with the same ID after the drain finishes.
17// TODO: Before drain teardown clears `HostAgent` state, reply to every pending
18// proc-status waiter with that proc's terminal status.
19
20// EnumAsInner generates code that triggers a false positive
21// unused_assignments lint on struct variant fields. #[allow] on the
22// enum itself doesn't propagate into derive-macro-generated code, so
23// the suppression must be at module scope.
24#![allow(unused_assignments)]
25
26use std::collections::HashMap;
27use std::collections::HashSet;
28use std::collections::hash_map::DefaultHasher;
29use std::fmt;
30use std::hash::Hash;
31use std::hash::Hasher;
32use std::panic::AssertUnwindSafe;
33use std::pin::Pin;
34use std::sync::OnceLock;
35
36use async_trait::async_trait;
37use enum_as_inner::EnumAsInner;
38use futures::FutureExt;
39use futures::future::Either;
40use hyperactor::Actor;
41use hyperactor::ActorHandle;
42use hyperactor::ActorRef;
43use hyperactor::Addr;
44use hyperactor::Context;
45use hyperactor::Endpoint as _;
46use hyperactor::HandleClient;
47use hyperactor::Handler;
48use hyperactor::IdleFlushPortRef;
49use hyperactor::Instance;
50use hyperactor::PortHandle;
51use hyperactor::PortRef;
52use hyperactor::Proc;
53use hyperactor::ProcAddr;
54use hyperactor::RefClient;
55use hyperactor::RemoteEndpoint as _;
56use hyperactor::Uid;
57use hyperactor::actor::ActorStatus;
58use hyperactor::actor::ActorStoppingReason;
59use hyperactor::context;
60use hyperactor::id::Label;
61use hyperactor::mailbox::MailboxSender as _;
62use hyperactor::value_mesh::ValueOverlay;
63use hyperactor_config::Flattrs;
64use hyperactor_config::attrs::Attrs;
65use ndslice::view::Region;
66use serde::Deserialize;
67use serde::Serialize;
68use tokio::time::Duration;
69use typeuri::Named;
70
71use crate::StatusOverlay;
72use crate::bootstrap;
73use crate::bootstrap::BootstrapCommand;
74use crate::bootstrap::BootstrapProcConfig;
75use crate::bootstrap::BootstrapProcManager;
76use crate::bootstrap::ProcBind;
77use crate::config_dump::ConfigDump;
78use crate::config_dump::ConfigDumpResult;
79use crate::host::Host;
80use crate::host::HostError;
81use crate::host::HostShutdownHandles;
82use crate::host::LOCAL_PROC_NAME;
83use crate::host::LocalProcManager;
84use crate::host::SERVICE_PROC_NAME;
85use crate::host::SingleTerminate;
86use crate::mesh_id::HostMeshId;
87use crate::mesh_id::ProcMeshId;
88use crate::mesh_id::ResourceId;
89use crate::proc_agent::ProcAgent;
90use crate::pyspy::PySpyDump;
91use crate::pyspy::PySpyProfile;
92use crate::pyspy::PySpyProfileWorker;
93use crate::pyspy::PySpyWorker;
94use crate::resource;
95use crate::resource::ProcSpec;
96use crate::resource::Status;
97
98pub(crate) type ProcManagerSpawnFuture =
99    Pin<Box<dyn Future<Output = anyhow::Result<ActorHandle<ProcAgent>>> + Send>>;
100pub(crate) type ProcManagerSpawnFn = Box<dyn Fn(Proc) -> ProcManagerSpawnFuture + Send + Sync>;
101
102/// Represents the different ways a [`Host`] can be managed by an agent.
103///
104/// A host can either:
105/// - [`Process`] — a host running as an external OS process, managed by
106///   [`BootstrapProcManager`].
107/// - [`Local`] — a host running in-process, managed by
108///   [`LocalProcManager`] with a custom spawn function.
109///
110/// This abstraction lets the same `HostAgent` work across both
111/// out-of-process and in-process execution modes.
112#[derive(EnumAsInner)]
113pub(crate) enum HostAgentMode {
114    Process {
115        host: Host<BootstrapProcManager>,
116        /// If set, the ShutdownHost handler sends the active transport handles
117        /// back to the bootstrap loop once shutdown is complete, so the caller
118        /// can drain them and exit.
119        shutdown_tx: Option<tokio::sync::oneshot::Sender<HostShutdownHandles>>,
120    },
121    Local(Host<LocalProcManager<ProcManagerSpawnFn>>),
122}
123
124impl HostAgentMode {
125    pub(crate) fn addr(&self) -> &hyperactor::channel::ChannelAddr {
126        #[allow(clippy::match_same_arms)]
127        match self {
128            HostAgentMode::Process { host, .. } => host.addr(),
129            HostAgentMode::Local(host) => host.addr(),
130        }
131    }
132
133    pub(crate) fn system_proc(&self) -> &Proc {
134        #[allow(clippy::match_same_arms)]
135        match self {
136            HostAgentMode::Process { host, .. } => host.system_proc(),
137            HostAgentMode::Local(host) => host.system_proc(),
138        }
139    }
140
141    pub(crate) fn local_proc(&self) -> &Proc {
142        #[allow(clippy::match_same_arms)]
143        match self {
144            HostAgentMode::Process { host, .. } => host.local_proc(),
145            HostAgentMode::Local(host) => host.local_proc(),
146        }
147    }
148
149    /// Non-blocking stop: send the stop signal and spawn a background
150    /// task for cleanup. Returns immediately without blocking the
151    /// actor.
152    async fn request_stop(
153        &self,
154        cx: &impl context::Actor,
155        proc: &ProcAddr,
156        timeout: Duration,
157        reason: &str,
158    ) {
159        match self {
160            HostAgentMode::Process { host, .. } => {
161                host.manager().request_stop(cx, proc, timeout, reason).await;
162            }
163            HostAgentMode::Local(host) => {
164                host.manager().request_stop(proc, timeout, reason).await;
165            }
166        }
167    }
168
169    /// Query a proc's lifecycle state, returning both the coarse
170    /// `resource::Status` used by the resource protocol and the
171    /// detailed `bootstrap::ProcStatus` (when available) for callers
172    /// that need process-level detail such as PIDs or exit codes.
173    async fn proc_status(
174        &self,
175        proc_id: &ProcAddr,
176    ) -> (resource::Status, Option<bootstrap::ProcStatus>) {
177        match self {
178            HostAgentMode::Process { host, .. } => match host.manager().status(proc_id).await {
179                Some(proc_status) => (proc_status.clone().into(), Some(proc_status)),
180                None => (resource::Status::Unknown, None),
181            },
182            HostAgentMode::Local(host) => {
183                let status = match host.manager().local_proc_status(proc_id).await {
184                    Some(crate::host::LocalProcStatus::Stopping) => resource::Status::Stopping,
185                    Some(crate::host::LocalProcStatus::Stopped) => resource::Status::Stopped,
186                    None => resource::Status::Running,
187                };
188                (status, None)
189            }
190        }
191    }
192
193    /// The bootstrap command used by the process manager, if any.
194    fn bootstrap_command(&self) -> Option<BootstrapCommand> {
195        match self {
196            HostAgentMode::Process { host, .. } => Some(host.manager().command().clone()),
197            HostAgentMode::Local(_) => None,
198        }
199    }
200}
201
202/// Derive the proc resource name for the proc at the given mesh `rank`.
203///
204/// `rank` is the proc's absolute rank in the proc mesh — its position in the
205/// `host_extent ⊕ per_host` region, i.e. [`crate::proc_mesh::ProcRef::create_rank`].
206/// This is the proc's first-class, mesh-level identity; the `(host, per-host
207/// slot)` layout is an implementation detail of how procs are placed and is
208/// deliberately NOT part of the name. Both the caller and the receiving
209/// [`HostAgent`] derive the same name from `rank`, so the caller can construct
210/// [`crate::proc_mesh::ProcRef`]s before the casted `CreateOrUpdate<ProcSpec>`
211/// messages arrive. The id is a stable function of the proc mesh id and rank,
212/// so every proc in the mesh has a distinct id.
213pub(crate) fn proc_name(proc_mesh_id: &ProcMeshId, rank: usize) -> ResourceId {
214    let label = Label::strip(&format!(
215        "{}-{}",
216        proc_mesh_id
217            .display_label()
218            .map(|label| label.as_str())
219            .unwrap_or("unnamed"),
220        rank
221    ));
222
223    match proc_mesh_id.uid() {
224        Uid::Singleton(_) => ResourceId::singleton(label),
225        Uid::Instance(_, _) => {
226            let mut hasher = DefaultHasher::new();
227            proc_mesh_id.hash(&mut hasher);
228            rank.hash(&mut hasher);
229            ResourceId::new(
230                Uid::Instance(hasher.finish(), Some(label.clone())),
231                Some(label),
232            )
233        }
234    }
235}
236
237pub(crate) fn proc_slots(
238    proc_mesh_id: &ProcMeshId,
239    host_rank: usize,
240    num_per_host: usize,
241) -> impl Iterator<Item = (usize, usize, ResourceId)> + '_ {
242    (0..num_per_host).map(move |per_host_rank| {
243        let rank = num_per_host * host_rank + per_host_rank;
244        (per_host_rank, rank, proc_name(proc_mesh_id, rank))
245    })
246}
247
248#[derive(Debug)]
249pub(crate) struct ProcMetadata {
250    rank: usize,
251    host_mesh_id: Option<HostMeshId>,
252    /// The proc mesh this proc belongs to. Used to scope per-mesh queries like
253    /// `StreamState`, since a host agent can hold procs from multiple meshes.
254    /// Always set for procs spawned through a proc mesh (the cast `SpawnProcs`
255    /// path populates it via `ProcSpec`). `None` only for procs created off that
256    /// path (e.g. the point-to-point `CreateOrUpdate` used by tests/admin),
257    /// which belong to no queryable mesh and are intentionally excluded from
258    /// per-mesh queries.
259    proc_mesh_id: Option<ProcMeshId>,
260}
261
262mod completion_action {
263    use super::Duration;
264
265    /// Deferred action for a proc that is still being created.
266    ///
267    /// Actions only strengthen from keep to stop, drain, or shutdown. Each
268    /// pending proc counted by a drain or shutdown must complete its accounting
269    /// exactly once, and the lifecycle reply must wait for that accounting.
270    #[derive(Debug)]
271    pub(crate) struct CompletionAction(State);
272
273    #[derive(Debug)]
274    pub(super) enum State {
275        Keep,
276        Stop { timeout: Duration, reason: String },
277        Drain,
278        Shutdown,
279    }
280
281    impl CompletionAction {
282        pub(super) fn is_keep(&self) -> bool {
283            matches!(&self.0, State::Keep)
284        }
285
286        pub(super) fn is_drain(&self) -> bool {
287            matches!(&self.0, State::Drain)
288        }
289
290        pub(super) fn request_stop(&mut self, timeout: Duration, reason: String) {
291            if self.is_keep() {
292                self.0 = State::Stop { timeout, reason };
293            }
294        }
295
296        /// Returns whether the requesting drain must account for this proc.
297        /// An existing drain action can be shared by another drain, but a
298        /// shutdown already owns the proc's completion.
299        pub(super) fn request_drain(&mut self) -> bool {
300            if matches!(&self.0, State::Shutdown) {
301                return false;
302            }
303
304            self.0 = State::Drain;
305            true
306        }
307
308        pub(super) fn request_shutdown(&mut self) {
309            self.0 = State::Shutdown;
310        }
311
312        pub(super) fn into_state(self) -> State {
313            self.0
314        }
315    }
316
317    impl super::ProcCreationState {
318        pub(super) fn pending(
319            metadata: super::ProcMetadata,
320            spawn_task: super::ProcSpawnTask,
321            expiry_time: Option<std::time::SystemTime>,
322        ) -> Self {
323            Self::Pending {
324                metadata,
325                on_completion: CompletionAction(State::Keep),
326                spawn_task,
327                expiry_time,
328            }
329        }
330    }
331}
332
333pub(crate) use completion_action::CompletionAction;
334
335#[derive(Debug)]
336pub(crate) struct ProcSpawnTask(Option<tokio::task::AbortHandle>);
337
338impl ProcSpawnTask {
339    fn disarm(mut self) {
340        self.0 = None;
341    }
342}
343
344impl Drop for ProcSpawnTask {
345    fn drop(&mut self) {
346        if let Some(task) = self.0.take() {
347            task.abort();
348        }
349    }
350}
351
352#[derive(Debug)]
353pub(crate) enum ProcCreationState {
354    Pending {
355        metadata: ProcMetadata,
356        on_completion: CompletionAction,
357        /// Cancels the detached spawn if this pending state is discarded.
358        spawn_task: ProcSpawnTask,
359        /// Latest owner keepalive received before proc creation completed.
360        expiry_time: Option<std::time::SystemTime>,
361    },
362    Created {
363        metadata: ProcMetadata,
364        proc_id: ProcAddr,
365        mesh_agent: ActorRef<ProcAgent>,
366        /// A graceful stop has been requested. The state remains available to
367        /// resource-status callers, but the proc is no longer a live
368        /// introspection child.
369        stop_requested: bool,
370        /// "Owner is alive" deadline communicated by the controller via
371        /// `KeepaliveGetState`. The host's `SelfCheck` reaper compares against this
372        /// and tears down procs whose owner has stopped extending the keepalive.
373        expiry_time: Option<std::time::SystemTime>,
374    },
375    Failed {
376        metadata: ProcMetadata,
377        error: HostError,
378    },
379}
380
381impl ProcCreationState {
382    fn metadata(&self) -> &ProcMetadata {
383        match self {
384            Self::Pending { metadata, .. }
385            | Self::Created { metadata, .. }
386            | Self::Failed { metadata, .. } => metadata,
387        }
388    }
389
390    fn from_spawn_result(
391        metadata: ProcMetadata,
392        expiry_time: Option<std::time::SystemTime>,
393        result: Result<(ProcAddr, ActorRef<ProcAgent>), HostError>,
394    ) -> Self {
395        match result {
396            Ok((proc_id, mesh_agent)) => Self::Created {
397                metadata,
398                proc_id,
399                mesh_agent,
400                stop_requested: false,
401                expiry_time,
402            },
403            Err(error) => Self::Failed { metadata, error },
404        }
405    }
406
407    fn set_expiry_time(&mut self, expires_after: std::time::SystemTime) {
408        match self {
409            Self::Pending { expiry_time, .. } | Self::Created { expiry_time, .. } => {
410                *expiry_time = Some(expires_after);
411            }
412            Self::Failed { .. } => {}
413        }
414    }
415
416    fn rank(&self) -> usize {
417        self.metadata().rank
418    }
419
420    fn host_mesh_id(&self) -> Option<&HostMeshId> {
421        self.metadata().host_mesh_id.as_ref()
422    }
423
424    fn proc_mesh_id(&self) -> Option<&ProcMeshId> {
425        self.metadata().proc_mesh_id.as_ref()
426    }
427}
428
429struct PendingDrain {
430    /// Number of matching proc spawns that were pending when the drain began.
431    /// Each counted spawn decrements this exactly once. Reaching zero completes
432    /// the drain and posts every reply exactly once.
433    remaining: usize,
434    failures: Vec<String>,
435    timeout: Duration,
436    max_in_flight: usize,
437    replies: Vec<PendingDrainReply>,
438}
439
440struct PendingDrainReply {
441    rank: usize,
442    reply: IdleFlushPortRef<crate::StatusOverlay>,
443}
444
445#[derive(Default)]
446struct PendingDrains {
447    /// A full drain subsumes the selective drains, which remain in the map so
448    /// that every selective request still receives its reply.
449    full: Option<PendingDrain>,
450    selective: HashMap<HostMeshId, PendingDrain>,
451}
452
453impl PendingDrains {
454    fn blocks_create(&self, host_mesh_id: Option<&HostMeshId>) -> bool {
455        self.full.is_some()
456            || host_mesh_id.is_some_and(|host_mesh_id| self.selective.contains_key(host_mesh_id))
457    }
458
459    fn full_is_ready(&self) -> bool {
460        self.full.as_ref().is_some_and(|drain| drain.remaining == 0)
461            && self.selective.values().all(|drain| drain.remaining == 0)
462    }
463    fn take_all(&mut self) -> impl Iterator<Item = PendingDrain> {
464        let full = self.full.take();
465        let selective = std::mem::take(&mut self.selective);
466        full.into_iter().chain(selective.into_values())
467    }
468}
469
470impl PendingDrain {
471    fn post_replies(self, cx: &Context<'_, HostAgent>) {
472        let status = if self.failures.is_empty() {
473            resource::Status::Stopped
474        } else {
475            resource::Status::Failed(self.failures.join("; "))
476        };
477
478        self.post_replies_with_status(cx, status);
479    }
480
481    fn post_replies_with_status(self, cx: &Context<'_, HostAgent>, status: resource::Status) {
482        for PendingDrainReply { rank, reply } in self.replies {
483            let overlay =
484                crate::StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status.clone())])
485                    .expect("valid single-run overlay");
486            reply.post(cx, overlay);
487        }
488    }
489}
490
491struct PendingShutdown {
492    remaining: usize,
493    timeout: Duration,
494    max_in_flight: usize,
495    acknowledgements: Vec<(usize, PortRef<usize>)>,
496    drain_replies: Vec<PendingDrainReply>,
497}
498
499/// Actor name used when spawning the host mesh agent on the system proc.
500pub const HOST_MESH_AGENT_ACTOR_NAME: &str = "host_agent";
501
502/// Lifecycle state of the host managed by [`HostAgent`].
503enum HostAgentState {
504    /// Waiting for a client to attach. The host is idle and ready
505    /// to accept new proc spawn requests.
506    Detached(HostAgentMode),
507    /// Actively running procs for an attached client.
508    Attached(HostAgentMode),
509    /// Procs are being drained by a DrainWorker. The host has been
510    /// temporarily moved to the worker. The host agent remains
511    /// responsive; min_proc_status() returns Stopping.
512    Draining,
513    /// Host fully shut down.
514    Shutdown,
515}
516
517/// A mesh agent is responsible for managing a host in a [`HostMesh`],
518/// through the resource behaviors defined in [`crate::resource`].
519/// Self-notification sent by bridge tasks when a proc's status changes.
520/// Not exported or registered — only used internally via `PortHandle`.
521#[derive(Debug, Serialize, Deserialize, Named)]
522struct ProcStatusChanged {
523    id: ResourceId,
524}
525
526/// Sent by DrainWorker back to HostAgent when draining completes.
527/// Not exported — delivered locally via PortHandle (no serialization).
528struct DrainComplete(HostAgentMode);
529
530/// Child actor whose only job is to run `host.terminate_children()` in
531/// its `init()`, return the host and ack to the parent via DrainComplete,
532/// and exit. Runs on the same proc as the host agent so it gets its
533/// own `Instance` (required by `terminate_children`).
534#[hyperactor::export(handlers = [])]
535struct DrainWorker {
536    host: Option<HostAgentMode>,
537    timeout: Duration,
538    max_in_flight: usize,
539    done_notify: PortHandle<DrainComplete>,
540}
541
542#[async_trait]
543impl Actor for DrainWorker {
544    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
545        if let Some(host) = self.host.as_mut() {
546            match host {
547                HostAgentMode::Process { host, .. } => {
548                    host.terminate_children(
549                        this,
550                        self.timeout,
551                        self.max_in_flight.clamp(1, 256),
552                        "drain host",
553                    )
554                    .await;
555                }
556                HostAgentMode::Local(host) => {
557                    host.terminate_children(this, self.timeout, self.max_in_flight, "drain host")
558                        .await;
559                }
560            }
561        }
562
563        // Restore the host before the parent reports drain completion.
564        if let Some(host) = self.host.take() {
565            let _ = self.done_notify.post(this, DrainComplete(host));
566        }
567
568        Ok(())
569    }
570}
571
572impl fmt::Debug for DrainWorker {
573    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
574        f.debug_struct("DrainWorker")
575            .field("timeout", &self.timeout)
576            .field("max_in_flight", &self.max_in_flight)
577            .finish()
578    }
579}
580
581#[hyperactor::export(
582    handlers=[
583        resource::CreateOrUpdate<ProcSpec>,
584        SpawnProcs,
585        WaitProcs,
586        resource::Stop,
587        resource::GetState<ProcState>,
588        resource::KeepaliveGetState<ProcState>,
589        GetHostProcStates,
590        resource::StreamState<ProcState>,
591        resource::GetRankStatus,
592        resource::WaitRankStatus,
593        resource::List,
594        ShutdownHost,
595        DrainHost,
596        SetClientConfig,
597        ProcStatusChanged,
598        PySpyDump,
599        PySpyProfile,
600        ConfigDump,
601        crate::proc_agent::SelfCheck,
602    ]
603)]
604pub struct HostAgent {
605    state: HostAgentState,
606    pub(crate) created: HashMap<ResourceId, ProcCreationState>,
607    pending_drains: PendingDrains,
608    pending_shutdown: Option<PendingShutdown>,
609    /// Pending `WaitRankStatus` waiters, keyed by resource name.
610    /// Each entry is `(min_status, rank, reply_port)`. Only touched
611    /// from `&mut self` handlers.
612    pending_proc_waiters: HashMap<
613        ResourceId,
614        Vec<(
615            resource::Status,
616            usize,
617            IdleFlushPortRef<crate::StatusOverlay>,
618        )>,
619    >,
620    /// Procs that already have an active bridge task watching their status.
621    watching: HashSet<ResourceId>,
622    /// Port handle for sending `ProcStatusChanged` to self. Set in `init()`.
623    proc_status_port: Option<PortHandle<ProcStatusChanged>>,
624    /// Lazily initialized ProcAgent on the host's local proc.
625    /// Boots on first [`GetLocalProc`] (LP-1 — see
626    /// `crate::host::LOCAL_PROC_NAME`).
627    local_mesh_agent: OnceLock<anyhow::Result<ActorHandle<ProcAgent>>>,
628}
629
630impl HostAgent {
631    /// Create a host mesh agent for a process-backed host.
632    pub(crate) fn new_process(
633        host: Host<BootstrapProcManager>,
634        shutdown_tx: Option<tokio::sync::oneshot::Sender<HostShutdownHandles>>,
635    ) -> Self {
636        Self::new(HostAgentMode::Process { host, shutdown_tx })
637    }
638
639    /// Create a host mesh agent for an in-process host.
640    pub fn new_local(host: Host<LocalProcManager<ProcManagerSpawnFn>>) -> Self {
641        Self::new(HostAgentMode::Local(host))
642    }
643
644    fn new(host: HostAgentMode) -> Self {
645        Self {
646            state: HostAgentState::Detached(host),
647            created: HashMap::new(),
648            pending_drains: PendingDrains::default(),
649            pending_shutdown: None,
650            pending_proc_waiters: HashMap::new(),
651            watching: HashSet::new(),
652            proc_status_port: None,
653            local_mesh_agent: OnceLock::new(),
654        }
655    }
656
657    /// Wait until the agent has completed `init` and can receive external
658    /// messages through the host gateway.
659    pub async fn wait_initialized(handle: &ActorHandle<Self>) -> anyhow::Result<()> {
660        let mut status = handle.status();
661        loop {
662            let current = status.borrow_and_update().clone();
663            match current {
664                ActorStatus::Idle | ActorStatus::Processing(_, _) => return Ok(()),
665                ActorStatus::Failed(err) => anyhow::bail!("host agent init failed: {err}"),
666                ActorStatus::Stopped(reason) => anyhow::bail!("host agent stopped: {reason}"),
667                ActorStatus::Stopping(ActorStoppingReason::Zombie(reason)) => {
668                    anyhow::bail!("host agent zombie: {reason}")
669                }
670                ActorStatus::Unknown
671                | ActorStatus::Created
672                | ActorStatus::Initializing
673                | ActorStatus::Client
674                | ActorStatus::Stopping(_) => {}
675            }
676            if status.changed().await.is_err() {
677                anyhow::bail!("host agent status channel closed before init completed");
678            }
679        }
680    }
681
682    /// Minimum status floor derived from the host agent's lifecycle.
683    /// Procs on this host cannot be healthier than this.
684    fn min_proc_status(&self) -> resource::Status {
685        if self.pending_shutdown.is_some() {
686            return resource::Status::Stopping;
687        }
688        match &self.state {
689            HostAgentState::Detached(_) | HostAgentState::Attached(_) => resource::Status::Running,
690            HostAgentState::Draining => resource::Status::Stopping,
691            HostAgentState::Shutdown => resource::Status::Stopped,
692        }
693    }
694
695    fn host(&self) -> Option<&HostAgentMode> {
696        match &self.state {
697            HostAgentState::Detached(h) | HostAgentState::Attached(h) => Some(h),
698            _ => None,
699        }
700    }
701
702    fn host_mut(&mut self) -> Option<&mut HostAgentMode> {
703        match &mut self.state {
704            HostAgentState::Detached(h) | HostAgentState::Attached(h) => Some(h),
705            _ => None,
706        }
707    }
708
709    /// Terminate all tracked children on the host and clear proc state.
710    ///
711    /// The host, system proc, mailbox server, and HostAgent all stay
712    /// alive — only user procs are killed. After this returns the host
713    /// is ready to accept new spawn requests with the same proc names.
714    async fn drain(
715        &mut self,
716        cx: &Context<'_, Self>,
717        timeout: std::time::Duration,
718        max_in_flight: usize,
719    ) {
720        if let Some(host_mode) = self.host_mut() {
721            match host_mode {
722                HostAgentMode::Process { host, .. } => {
723                    let summary = host
724                        .terminate_children(cx, timeout, max_in_flight.clamp(1, 256), "stop host")
725                        .await;
726                    tracing::info!(?summary, "terminated children on host");
727                }
728                HostAgentMode::Local(host) => {
729                    let summary = host
730                        .terminate_children(cx, timeout, max_in_flight, "stop host")
731                        .await;
732                    tracing::info!(?summary, "terminated children on local host");
733                }
734            }
735        }
736        self.created.clear();
737    }
738
739    /// Selectively stop procs belonging to a specific host mesh.
740    /// Only procs whose `host_mesh_id` matches `filter` are stopped;
741    /// all other procs are left running.
742    async fn drain_by_mesh_name(
743        &mut self,
744        cx: &Context<'_, Self>,
745        timeout: std::time::Duration,
746        filter: Option<&HostMeshId>,
747    ) {
748        let matching_ids: Vec<ResourceId> = self
749            .created
750            .iter()
751            .filter(|(_, state)| {
752                state.host_mesh_id() == filter
753                    && matches!(
754                        state,
755                        ProcCreationState::Created { .. } | ProcCreationState::Failed { .. }
756                    )
757            })
758            .map(|(id, _)| id.clone())
759            .collect();
760
761        if let Some(host_mode) = self.host() {
762            for id in &matching_ids {
763                if let Some(ProcCreationState::Created { proc_id, .. }) = self.created.get(id) {
764                    match host_mode {
765                        HostAgentMode::Process { host, .. } => {
766                            let _ = host
767                                .terminate_proc(cx, proc_id, timeout, "selective drain")
768                                .await;
769                        }
770                        HostAgentMode::Local(host) => {
771                            let _ = host
772                                .terminate_proc(cx, proc_id, timeout, "selective drain")
773                                .await;
774                        }
775                    }
776                }
777            }
778        }
779
780        // Remove drained entries and associated state so that
781        // future spawns with the same proc names get fresh watch bridges.
782        for id in &matching_ids {
783            self.created.remove(id);
784            self.watching.remove(id);
785            self.pending_proc_waiters.remove(id);
786        }
787
788        tracing::info!(
789            count = matching_ids.len(),
790            filter = ?filter,
791            "selectively drained procs",
792        );
793    }
794
795    /// Publish the current host properties and child list for
796    /// introspection. Called from init and after each state change
797    /// (proc created/stopped).
798    fn publish_introspect_properties(&self, cx: &Instance<Self>) {
799        let host = match self.host() {
800            Some(h) => h,
801            None => return, // host shut down or stopping
802        };
803
804        let addr = host.addr().to_string();
805        let mut children: Vec<hyperactor::introspect::IntrospectRef> = Vec::new();
806        let system_children: Vec<crate::introspect::NodeRef> = Vec::new(); // LC-2
807
808        // Procs are not system — only actors are. Both service and
809        // local appear as regular children; 's' in the TUI toggles
810        // actor visibility, not proc visibility.
811        children.push(hyperactor::introspect::IntrospectRef::Proc(
812            host.system_proc().proc_addr().clone(),
813        ));
814        children.push(hyperactor::introspect::IntrospectRef::Proc(
815            host.local_proc().proc_addr().clone(),
816        ));
817
818        // User procs.
819        for state in self.created.values() {
820            if let ProcCreationState::Created {
821                proc_id,
822                stop_requested: false,
823                ..
824            } = state
825            {
826                children.push(hyperactor::introspect::IntrospectRef::Proc(proc_id.clone()));
827            }
828        }
829
830        let num_procs = children.len();
831
832        let mut attrs = hyperactor_config::Attrs::new();
833        attrs.set(crate::introspect::NODE_TYPE, "host".to_string());
834        attrs.set(crate::introspect::ADDR, addr);
835        attrs.set(crate::introspect::NUM_PROCS, num_procs);
836        attrs.set(hyperactor::introspect::CHILDREN, children);
837        attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children);
838        // PD-*: hosting-process memory stats. This is the same
839        // hosting OS process signal surfaced on the proc path, but
840        // the host path does not attempt to publish proc-local queue
841        // pressure.
842        let memory = crate::introspect::ProcessMemoryStats::read_from_procfs();
843        memory.to_attrs(&mut attrs);
844        cx.publish_attrs(attrs);
845    }
846}
847
848#[async_trait]
849impl Actor for HostAgent {
850    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
851        this.bind::<Self>();
852        if matches!(self.host().unwrap(), HostAgentMode::Process { .. }) {
853            let (directory, file) = hyperactor_telemetry::log_file_path(
854                hyperactor_telemetry::env::Env::current(),
855                None,
856            )
857            .unwrap();
858            eprintln!(
859                "Monarch internal logs are being written to {}/{}.log; execution id {}",
860                directory,
861                file,
862                hyperactor_telemetry::env::execution_id(),
863            );
864        }
865        this.set_system();
866        self.publish_introspect_properties(this);
867
868        // Register callback for QueryChild — resolves system procs
869        // that are not independently addressable actors.
870        let host = self.host().expect("host present");
871        let system_proc = host.system_proc().clone();
872        let local_proc = host.local_proc().clone();
873        let self_id = this.self_addr().clone();
874        this.set_query_child_handler(move |child_ref| {
875            use hyperactor::introspect::IntrospectResult;
876
877            let proc = match child_ref {
878                Addr::Proc(proc_ref) => {
879                    if *proc_ref == system_proc.proc_addr() {
880                        Some((&system_proc, SERVICE_PROC_NAME))
881                    } else if *proc_ref == local_proc.proc_addr() {
882                        Some((&local_proc, LOCAL_PROC_NAME))
883                    } else {
884                        None
885                    }
886                }
887                _ => None,
888            };
889
890            match proc {
891                Some((proc, label)) => {
892                    // Use all_instance_keys() instead of
893                    // all_actor_ids() to avoid holding DashMap shard
894                    // read locks while doing Weak::upgrade() +
895                    // watch::borrow() + is_terminal() per entry.
896                    // Under rapid actor churn the per-entry work in
897                    // all_actor_ids() causes convoy starvation with
898                    // concurrent insert/remove operations, stalling
899                    // the spawn/exit path. all_instance_keys() just
900                    // clones keys — microseconds per shard. Actor
901                    // addresses and the is_system check use individual
902                    // point lookups outside the iteration. Stale keys
903                    // are harmless: if the point lookup fails, the actor
904                    // has already gone away.
905                    let all_keys = proc.all_instance_keys();
906                    let mut actors: Vec<hyperactor::introspect::IntrospectRef> =
907                        Vec::with_capacity(all_keys.len());
908                    let mut system_actors: Vec<crate::introspect::NodeRef> = Vec::new();
909                    for id in all_keys {
910                        if let Some(cell) = proc.get_instance_by_id(&id) {
911                            let actor_addr = cell.actor_addr().clone();
912                            if cell.is_system() {
913                                system_actors
914                                    .push(crate::introspect::NodeRef::Actor(actor_addr.clone()));
915                            }
916                            actors.push(hyperactor::introspect::IntrospectRef::Actor(actor_addr));
917                        }
918                    }
919                    let mut attrs = hyperactor_config::Attrs::new();
920                    attrs.set(crate::introspect::NODE_TYPE, "proc".to_string());
921                    attrs.set(crate::introspect::PROC_NAME, label.to_string());
922                    attrs.set(crate::introspect::NUM_ACTORS, actors.len());
923                    attrs.set(crate::introspect::SYSTEM_CHILDREN, system_actors.clone());
924                    // PD-*: include proc debug stats so QueryChild
925                    // results carry real signal. Memory from procfs,
926                    // queue stats from the Proc's runtime accounting.
927                    let memory = crate::introspect::ProcessMemoryStats::read_from_procfs();
928                    memory.to_attrs(&mut attrs);
929                    attrs.set(
930                        crate::introspect::ACTOR_WORK_QUEUE_DEPTH_TOTAL,
931                        proc.queue_depth_total(),
932                    );
933                    // Per-actor max from the live actor scan.
934                    let mut queue_max: u64 = 0;
935                    for aid in proc.all_instance_keys() {
936                        if let Some(cell) = proc.get_instance_by_id(&aid) {
937                            queue_max = queue_max.max(cell.queue_depth());
938                        }
939                    }
940                    attrs.set(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_MAX, queue_max);
941                    attrs.set(
942                        crate::introspect::ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK,
943                        proc.queue_depth_high_water_mark(),
944                    );
945                    attrs.set(
946                        crate::introspect::LAST_NONZERO_QUEUE_DEPTH_AGE_MS,
947                        proc.last_nonzero_queue_depth_age_ms(),
948                    );
949                    let attrs_json =
950                        serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
951
952                    IntrospectResult {
953                        identity: hyperactor::introspect::IntrospectRef::Proc(
954                            proc.proc_addr().clone(),
955                        ),
956                        attrs: attrs_json,
957                        children: actors,
958                        parent: Some(hyperactor::introspect::IntrospectRef::Actor(
959                            self_id.clone(),
960                        )),
961                        as_of: std::time::SystemTime::now(),
962                    }
963                }
964                None => {
965                    let mut error_attrs = hyperactor_config::Attrs::new();
966                    error_attrs.set(hyperactor::introspect::ERROR_CODE, "not_found".to_string());
967                    error_attrs.set(
968                        hyperactor::introspect::ERROR_MESSAGE,
969                        format!("child {} not found", child_ref),
970                    );
971                    let identity = match child_ref {
972                        Addr::Proc(p) => hyperactor::introspect::IntrospectRef::Proc(p.clone()),
973                        Addr::Actor(a) => hyperactor::introspect::IntrospectRef::Actor(a.clone()),
974                        Addr::Port(p) => {
975                            hyperactor::introspect::IntrospectRef::Actor(p.actor_addr())
976                        }
977                    };
978                    IntrospectResult {
979                        identity,
980                        attrs: serde_json::to_string(&error_attrs)
981                            .unwrap_or_else(|_| "{}".to_string()),
982                        children: Vec::new(),
983                        parent: None,
984                        as_of: std::time::SystemTime::now(),
985                    }
986                }
987            }
988        });
989
990        self.proc_status_port = Some(this.port::<ProcStatusChanged>());
991
992        // Kick off the SelfCheck reaper if the orphan timeout is configured.
993        // The reaper walks `created` looking for procs whose owner stopped
994        // extending the keepalive and tears them down.
995        if let Some(delay) = hyperactor_config::global::get(crate::proc_agent::MESH_ORPHAN_TIMEOUT)
996        {
997            this.post_after(this, crate::proc_agent::SelfCheck::default(), delay);
998        }
999
1000        Ok(())
1001    }
1002}
1003
1004impl fmt::Debug for HostAgent {
1005    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1006        f.debug_struct("HostAgent")
1007            .field("host", &"..")
1008            .field("created", &self.created)
1009            .finish()
1010    }
1011}
1012
1013/// Cast to every HostAgent to spawn a mesh's per-host proc slots in one message.
1014/// Identity is positional: each recipient derives its procs' ids/ranks from its
1015/// stamped `rank` plus `proc_mesh_id`/`num_per_host`, since a shared cast payload
1016/// can't carry a per-host `id` (unlike `CreateOrUpdate`).
1017#[derive(
1018    Serialize,
1019    Deserialize,
1020    Clone,
1021    Debug,
1022    Named,
1023    Handler,
1024    RefClient,
1025    HandleClient
1026)]
1027pub struct SpawnProcs {
1028    /// This host's ordinal within the cast region, stamped by the cast layer.
1029    pub rank: resource::Rank,
1030    /// Proc mesh id used to deterministically derive per-host proc ids.
1031    pub proc_mesh_id: ProcMeshId,
1032    /// Number of procs to spawn on this host.
1033    pub num_per_host: usize,
1034    /// Config values to set on each spawned proc's global config, at the
1035    /// `ClientOverride` layer.
1036    pub client_config_override: Attrs,
1037    /// The HostMesh that owns these procs (used by `DrainHost` for selective
1038    /// drain).
1039    pub host_mesh_id: Option<HostMeshId>,
1040    /// Bootstrap command to use when no per-rank override applies.
1041    pub default_bootstrap_command: Option<BootstrapCommand>,
1042    /// Optional per-proc CPU/NUMA binding, indexed by per-host slot. `HostMesh::spawn`
1043    /// already rejects a length that doesn't match the per-host extent, so the
1044    /// handler is deliberately tolerant of a missing slot (including an index
1045    /// past the vec's length): it spawns with no binding rather than panicking.
1046    pub proc_bind: Option<Vec<ProcBind>>,
1047    /// Optional per-rank bootstrap overrides, indexed by absolute proc rank
1048    /// (`num_per_host * host_rank + per_host_rank`).
1049    pub bootstrap_commands: Option<Vec<Option<BootstrapCommand>>>,
1050}
1051wirevalue::register_type!(SpawnProcs);
1052
1053#[async_trait]
1054impl Handler<SpawnProcs> for HostAgent {
1055    #[tracing::instrument("HostAgent::SpawnProcs", level = "info", skip_all, fields(host_rank, num = spawn.num_per_host))]
1056    async fn handle(&mut self, cx: &Context<Self>, spawn: SpawnProcs) -> anyhow::Result<()> {
1057        let host_rank = spawn
1058            .rank
1059            .0
1060            .expect("cast layer stamps the rank before delivery");
1061
1062        tracing::Span::current().record("host_rank", host_rank);
1063
1064        for (per_host_rank, rank, id) in
1065            proc_slots(&spawn.proc_mesh_id, host_rank, spawn.num_per_host)
1066        {
1067            let bootstrap_command = spawn
1068                .bootstrap_commands
1069                .as_ref()
1070                .and_then(|commands| commands.get(rank).cloned().flatten())
1071                .or_else(|| spawn.default_bootstrap_command.clone());
1072
1073            let proc_bind = spawn
1074                .proc_bind
1075                .as_ref()
1076                .and_then(|binds| binds.get(per_host_rank).cloned());
1077
1078            if let Err(error) = <Self as Handler<resource::CreateOrUpdate<ProcSpec>>>::handle(
1079                self,
1080                cx,
1081                resource::CreateOrUpdate {
1082                    id,
1083                    rank: resource::Rank::new(rank),
1084                    spec: ProcSpec {
1085                        client_config_override: spawn.client_config_override.clone(),
1086                        proc_bind,
1087                        bootstrap_command,
1088                        host_mesh_id: spawn.host_mesh_id.clone(),
1089                        proc_mesh_id: Some(spawn.proc_mesh_id.clone()),
1090                    },
1091                },
1092            )
1093            .await
1094            {
1095                tracing::error!(%error, "failed to request proc creation");
1096                break;
1097            }
1098        }
1099
1100        Ok(())
1101    }
1102}
1103
1104/// Cast after [`SpawnProcs`] to report when each derived proc reaches
1105/// [`Status::Running`]. Hyperactor delivers the two casts in order for each
1106/// sender-destination stream, so an absent proc means its create was rejected.
1107#[derive(
1108    Serialize,
1109    Deserialize,
1110    Clone,
1111    Debug,
1112    Named,
1113    Handler,
1114    RefClient,
1115    HandleClient
1116)]
1117pub struct WaitProcs {
1118    /// This host's ordinal within the cast region, stamped by the cast layer.
1119    pub rank: resource::Rank,
1120    /// Proc mesh id used to derive the same proc ids as [`SpawnProcs`].
1121    pub proc_mesh_id: ProcMeshId,
1122    /// Number of procs spawned on this host.
1123    pub num_per_host: usize,
1124    /// Sparse readiness updates for the caller's status barrier.
1125    pub status_reply: IdleFlushPortRef<crate::StatusOverlay>,
1126}
1127wirevalue::register_type!(WaitProcs);
1128
1129#[async_trait]
1130impl Handler<WaitProcs> for HostAgent {
1131    async fn handle(&mut self, cx: &Context<Self>, wait: WaitProcs) -> anyhow::Result<()> {
1132        let host_rank = wait
1133            .rank
1134            .0
1135            .expect("cast layer stamps the rank before delivery");
1136        let mut failed = Vec::new();
1137
1138        for (_, rank, id) in proc_slots(&wait.proc_mesh_id, host_rank, wait.num_per_host) {
1139            if self.created.contains_key(&id) {
1140                if let Err(error) = <Self as Handler<resource::WaitRankStatus>>::handle(
1141                    self,
1142                    cx,
1143                    resource::WaitRankStatus {
1144                        id,
1145                        rank: resource::Rank::new(rank),
1146                        min_status: Status::Running,
1147                        reply: wait.status_reply.clone(),
1148                    },
1149                )
1150                .await
1151                {
1152                    failed.push((
1153                        rank..(rank + 1),
1154                        Status::Failed(format!("failed to wait for proc status: {error}")),
1155                    ));
1156                }
1157            } else {
1158                failed.push((
1159                    rank..(rank + 1),
1160                    Status::Failed("proc creation was not accepted".to_string()),
1161                ));
1162            }
1163        }
1164
1165        if !failed.is_empty() {
1166            let overlay = crate::StatusOverlay::try_from_runs(failed)
1167                .expect("proc slots produce ordered non-overlapping ranks");
1168            wait.status_reply.post(cx, overlay);
1169        }
1170
1171        Ok(())
1172    }
1173}
1174
1175// Point-to-point proc spawn by carried `id`. The shared per-proc creation path:
1176// invoked directly (e.g. unit/admin tests) and once per slot by the `SpawnProcs`
1177// cast handler, which derives each proc's id/rank from the host's stamped rank.
1178#[async_trait]
1179impl Handler<resource::CreateOrUpdate<ProcSpec>> for HostAgent {
1180    #[tracing::instrument("HostAgent::CreateOrUpdate", level = "info", skip_all, fields(id=%create_or_update.id))]
1181    async fn handle(
1182        &mut self,
1183        cx: &Context<Self>,
1184        create_or_update: resource::CreateOrUpdate<ProcSpec>,
1185    ) -> anyhow::Result<()> {
1186        if self.created.contains_key(&create_or_update.id) {
1187            // Already requested or completed: there is no update.
1188            return Ok(());
1189        }
1190        if self.pending_shutdown.is_some() {
1191            tracing::warn!(
1192                id = %create_or_update.id,
1193                "ignoring CreateOrUpdate: HostAgent is shutting down"
1194            );
1195            return Ok(());
1196        }
1197        if self
1198            .pending_drains
1199            .blocks_create(create_or_update.spec.host_mesh_id.as_ref())
1200        {
1201            tracing::warn!(
1202                id = %create_or_update.id,
1203                "ignoring CreateOrUpdate: HostAgent is draining"
1204            );
1205            return Ok(());
1206        }
1207
1208        let host = match self.host() {
1209            Some(h) => h,
1210            None => {
1211                tracing::warn!(
1212                    id = %create_or_update.id,
1213                    "ignoring CreateOrUpdate: HostAgent has already shut down"
1214                );
1215                return Ok(());
1216            }
1217        };
1218        let rank = create_or_update.rank.unwrap();
1219        let id = create_or_update.id.clone();
1220        let spawn = match host {
1221            HostAgentMode::Process { host, .. } => Either::Left(host.spawn(
1222                id.to_string(),
1223                BootstrapProcConfig {
1224                    create_rank: rank,
1225                    client_config_override: create_or_update.spec.client_config_override.clone(),
1226                    proc_bind: create_or_update.spec.proc_bind.clone(),
1227                    bootstrap_command: create_or_update.spec.bootstrap_command.clone(),
1228                },
1229            )),
1230            HostAgentMode::Local(host) => Either::Right(host.spawn(id.to_string(), ())),
1231        };
1232
1233        let panic_id = id.to_string();
1234        let completion_id = id.clone();
1235        let client = Instance::<()>::self_client();
1236        let completion = cx.port::<ProcSpawned>();
1237
1238        let task = tokio::spawn(async move {
1239            let created = AssertUnwindSafe(spawn)
1240                .catch_unwind()
1241                .await
1242                .unwrap_or_else(|payload| {
1243                    Err(HostError::SpawnPanicked(
1244                        panic_id,
1245                        payload
1246                            .downcast_ref::<&str>()
1247                            .map(|message| (*message).to_string())
1248                            .or_else(|| payload.downcast_ref::<String>().cloned())
1249                            .unwrap_or_else(|| "non-string panic payload".to_string()),
1250                    ))
1251                });
1252            if let Err(error) = completion.try_post(
1253                &client,
1254                ProcSpawned {
1255                    id: completion_id.clone(),
1256                    created,
1257                },
1258            ) {
1259                tracing::warn!(id = %completion_id, %error, "failed to report proc spawn completion");
1260            }
1261        });
1262
1263        self.created.insert(
1264            id,
1265            ProcCreationState::pending(
1266                ProcMetadata {
1267                    rank,
1268                    host_mesh_id: create_or_update.spec.host_mesh_id.clone(),
1269                    proc_mesh_id: create_or_update.spec.proc_mesh_id.clone(),
1270                },
1271                ProcSpawnTask(Some(task.abort_handle())),
1272                None,
1273            ),
1274        );
1275
1276        Ok(())
1277    }
1278}
1279
1280struct ProcSpawned {
1281    id: ResourceId,
1282    created: Result<(ProcAddr, ActorRef<ProcAgent>), HostError>,
1283}
1284
1285#[async_trait]
1286impl Handler<ProcSpawned> for HostAgent {
1287    async fn handle(&mut self, cx: &Context<Self>, spawned: ProcSpawned) -> anyhow::Result<()> {
1288        let ProcSpawned { id, created } = spawned;
1289
1290        if let Err(e) = &created {
1291            tracing::error!("failed to spawn proc {}: {}", id, e);
1292        }
1293
1294        // A WaitRankStatus stashed before this proc existed carries its own reply
1295        // rank (RSP-3); starting a status watch bridge for it needs the proc_id.
1296        let proc_id = created.as_ref().ok().map(|(pid, _)| pid.clone());
1297        let drain_status = match &created {
1298            Ok(_) => Status::Stopped,
1299            Err(error) => Status::Failed(error.to_string()),
1300        };
1301        let shutdown_status = match &created {
1302            Ok(_) => Status::Stopping,
1303            Err(error) => Status::Failed(error.to_string()),
1304        };
1305
1306        let (metadata, on_completion, expiry_time) = match self.created.remove(&id) {
1307            Some(ProcCreationState::Pending {
1308                metadata,
1309                on_completion,
1310                spawn_task,
1311                expiry_time,
1312            }) => {
1313                spawn_task.disarm();
1314                (metadata, on_completion, expiry_time)
1315            }
1316            Some(ProcCreationState::Created { .. } | ProcCreationState::Failed { .. }) => {
1317                unreachable!("ProcSpawned requires a pending creation state for {id}")
1318            }
1319            None => unreachable!("ProcSpawned requires tracked creation state for {id}"),
1320        };
1321        let host_mesh_id = metadata.host_mesh_id.clone();
1322
1323        if !on_completion.is_drain() {
1324            self.created.insert(
1325                id.clone(),
1326                ProcCreationState::from_spawn_result(metadata, expiry_time, created),
1327            );
1328        }
1329
1330        match on_completion.into_state() {
1331            completion_action::State::Keep => {}
1332            completion_action::State::Stop { timeout, reason } => {
1333                if let Some(ProcCreationState::Created { stop_requested, .. }) =
1334                    self.created.get_mut(&id)
1335                {
1336                    *stop_requested = true;
1337                }
1338                if let (Some(proc_id), Some(host)) = (proc_id.as_ref(), self.host()) {
1339                    host.request_stop(cx, proc_id, timeout, &reason).await;
1340                }
1341            }
1342            completion_action::State::Drain => {
1343                let (timeout, reason) = if let Some(drain) = self.pending_drains.full.as_ref() {
1344                    (drain.timeout, "full drain")
1345                } else if let Some(drain) = host_mesh_id
1346                    .as_ref()
1347                    .and_then(|host_mesh_id| self.pending_drains.selective.get(host_mesh_id))
1348                {
1349                    (drain.timeout, "selective drain")
1350                } else {
1351                    unreachable!("pending drain owns deferred proc completion")
1352                };
1353
1354                if let (Some(proc_id), Some(host)) = (proc_id.as_ref(), self.host()) {
1355                    match host {
1356                        HostAgentMode::Process { host, .. } => {
1357                            let _ = host.terminate_proc(cx, proc_id, timeout, reason).await;
1358                        }
1359                        HostAgentMode::Local(host) => {
1360                            let _ = host.terminate_proc(cx, proc_id, timeout, reason).await;
1361                        }
1362                    }
1363                }
1364                self.watching.remove(&id);
1365
1366                self.flush_proc_waiters_with_status(cx, &id, drain_status);
1367
1368                if let Some(drain) = self.pending_drains.full.as_mut() {
1369                    drain.remaining = drain
1370                        .remaining
1371                        .checked_sub(1)
1372                        .expect("full drain count includes completed proc");
1373                }
1374
1375                let finished_selective_drain = match host_mesh_id
1376                    .as_ref()
1377                    .and_then(|host_mesh_id| self.pending_drains.selective.get_mut(host_mesh_id))
1378                {
1379                    Some(drain) => {
1380                        drain.remaining = drain
1381                            .remaining
1382                            .checked_sub(1)
1383                            .expect("selective drain count includes completed proc");
1384
1385                        drain.remaining == 0
1386                    }
1387                    None => false,
1388                };
1389
1390                if finished_selective_drain && self.pending_drains.full.is_none() {
1391                    let drain = self
1392                        .pending_drains
1393                        .selective
1394                        .remove(
1395                            host_mesh_id
1396                                .as_ref()
1397                                .expect("selective drain has a mesh id"),
1398                        )
1399                        .expect("completed selective drain exists");
1400                    drain.post_replies(cx);
1401                }
1402
1403                if self.pending_drains.full_is_ready() {
1404                    let drain = self
1405                        .pending_drains
1406                        .full
1407                        .take()
1408                        .expect("completed full drain exists");
1409
1410                    self.start_full_drain(cx, drain);
1411                }
1412
1413                self.publish_introspect_properties(cx);
1414                return Ok(());
1415            }
1416            completion_action::State::Shutdown => {
1417                self.flush_proc_waiters_with_status(cx, &id, shutdown_status);
1418
1419                let shutdown_finished = {
1420                    let shutdown = self
1421                        .pending_shutdown
1422                        .as_mut()
1423                        .expect("pending shutdown owns deferred proc completion");
1424                    shutdown.remaining = shutdown
1425                        .remaining
1426                        .checked_sub(1)
1427                        .expect("pending shutdown count includes completed proc");
1428                    shutdown.remaining == 0
1429                };
1430
1431                self.publish_introspect_properties(cx);
1432                if shutdown_finished {
1433                    let shutdown = self
1434                        .pending_shutdown
1435                        .take()
1436                        .expect("pending shutdown exists");
1437                    self.finish_shutdown(cx, shutdown).await;
1438                }
1439                return Ok(());
1440            }
1441        }
1442
1443        // Transition Detached → Attached on first proc creation.
1444        if let HostAgentState::Detached(_) = &self.state {
1445            let host = match std::mem::replace(&mut self.state, HostAgentState::Shutdown) {
1446                HostAgentState::Detached(h) => h,
1447                _ => unreachable!(),
1448            };
1449            self.state = HostAgentState::Attached(host);
1450        }
1451
1452        // Bridge status changes to any pending waiters, then flush once now.
1453        if self.pending_proc_waiters.contains_key(&id) {
1454            if let Some(proc_id) = &proc_id {
1455                self.start_watch_bridge(&id, proc_id).await;
1456            }
1457            self.flush_proc_waiters(cx, &id).await;
1458        }
1459
1460        self.publish_introspect_properties(cx);
1461        Ok(())
1462    }
1463}
1464
1465#[async_trait]
1466impl Handler<resource::Stop> for HostAgent {
1467    async fn handle(&mut self, cx: &Context<Self>, message: resource::Stop) -> anyhow::Result<()> {
1468        tracing::info!(
1469            name = "HostMeshAgentStatus",
1470            proc_id = %message.id,
1471            reason = %message.reason,
1472            "stopping proc"
1473        );
1474        let timeout = hyperactor_config::global::get(hyperactor::config::PROCESS_EXIT_TIMEOUT);
1475
1476        if let Some(ProcCreationState::Pending { on_completion, .. }) =
1477            self.created.get_mut(&message.id)
1478        {
1479            on_completion.request_stop(timeout, message.reason.clone());
1480            self.flush_proc_waiters(cx, &message.id).await;
1481            self.publish_introspect_properties(cx);
1482            return Ok(());
1483        }
1484
1485        let proc_id = match self.created.get_mut(&message.id) {
1486            Some(ProcCreationState::Created {
1487                proc_id,
1488                stop_requested,
1489                ..
1490            }) => {
1491                *stop_requested = true;
1492                Some(proc_id.clone())
1493            }
1494            _ => None,
1495        };
1496
1497        if let Some(proc_id) = proc_id {
1498            let host = match self.host() {
1499                Some(h) => h,
1500                None => {
1501                    // Host already shut down; all procs are terminated.
1502                    tracing::debug!(
1503                        proc_id = %message.id,
1504                        "ignoring Stop: HostAgent has already shut down"
1505                    );
1506                    return Ok(());
1507                }
1508            };
1509            host.request_stop(cx, &proc_id, timeout, &message.reason)
1510                .await;
1511        }
1512
1513        // Status may have changed to Stopping; notify pending waiters.
1514        self.flush_proc_waiters(cx, &message.id).await;
1515
1516        self.publish_introspect_properties(cx);
1517        Ok(())
1518    }
1519}
1520
1521impl HostAgent {
1522    /// The `(rank, status)` for a created proc, clamped to the host's minimum
1523    /// status. `rank == usize::MAX` means the proc is unknown to this host.
1524    async fn proc_rank_status(&self, id: &ResourceId) -> (usize, Status) {
1525        match self.created.get(id) {
1526            Some(ProcCreationState::Pending {
1527                metadata,
1528                on_completion,
1529                ..
1530            }) => (
1531                metadata.rank,
1532                if on_completion.is_keep() {
1533                    Status::Initializing
1534                } else {
1535                    Status::Stopping
1536                },
1537            ),
1538            Some(ProcCreationState::Created {
1539                metadata, proc_id, ..
1540            }) => {
1541                let raw_status = match self.host() {
1542                    Some(host) => host.proc_status(proc_id).await.0,
1543                    None => resource::Status::Unknown,
1544                };
1545                (metadata.rank, raw_status.clamp_min(self.min_proc_status()))
1546            }
1547            Some(ProcCreationState::Failed { metadata, error }) => {
1548                (metadata.rank, Status::Failed(error.to_string()))
1549            }
1550            None => (usize::MAX, Status::NotExist),
1551        }
1552    }
1553}
1554
1555#[async_trait]
1556impl Handler<resource::GetRankStatus> for HostAgent {
1557    async fn handle(
1558        &mut self,
1559        cx: &Context<Self>,
1560        get_rank_status: resource::GetRankStatus,
1561    ) -> anyhow::Result<()> {
1562        // `proc_rank_status` returns `usize::MAX` for a proc unknown to this
1563        // host, which still yields an empty overlay (RSP-4). A *known* proc is
1564        // positioned at the rank the request carries (the caller's view), not
1565        // the stored creation rank (RSP-1).
1566        let (found_rank, status) = self.proc_rank_status(&get_rank_status.id).await;
1567        let overlay = if found_rank == usize::MAX {
1568            StatusOverlay::new()
1569        } else {
1570            let rank = get_rank_status.rank.unwrap();
1571            StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status)])
1572                .expect("valid single-run overlay")
1573        };
1574        get_rank_status.reply.post(cx, overlay);
1575        Ok(())
1576    }
1577}
1578
1579#[async_trait]
1580impl Handler<resource::WaitRankStatus> for HostAgent {
1581    async fn handle(
1582        &mut self,
1583        cx: &Context<Self>,
1584        msg: resource::WaitRankStatus,
1585    ) -> anyhow::Result<()> {
1586        use crate::StatusOverlay;
1587        use crate::resource::Status;
1588
1589        // Position from the rank the request carries (the caller's view), not the
1590        // stored creation rank (RSP-1). A deferred / pre-create waiter retains it
1591        // (RSP-3).
1592        let rank = msg.rank.unwrap();
1593        match self.created.get(&msg.id) {
1594            Some(ProcCreationState::Created { proc_id, .. }) => {
1595                let status = match self.host() {
1596                    Some(host) => host.proc_status(proc_id).await.0,
1597                    None => Status::Stopped,
1598                };
1599
1600                // If already at or past the requested threshold, reply immediately.
1601                if status >= msg.min_status {
1602                    let overlay = StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status)])
1603                        .expect("valid single-run overlay");
1604                    let _ = msg.reply.post(cx, overlay);
1605                    return Ok(());
1606                }
1607
1608                // Stash the waiter and start a bridge if we don't have one yet.
1609                self.pending_proc_waiters
1610                    .entry(msg.id.clone())
1611                    .or_default()
1612                    .push((msg.min_status, rank, msg.reply));
1613
1614                let proc_id = proc_id.clone();
1615                self.start_watch_bridge(&msg.id, &proc_id).await;
1616            }
1617            Some(ProcCreationState::Failed { error, .. }) => {
1618                // Creation failed — reply immediately with Failed status.
1619                let overlay = StatusOverlay::try_from_runs(vec![(
1620                    rank..(rank + 1),
1621                    Status::Failed(error.to_string()),
1622                )])
1623                .expect("valid single-run overlay");
1624                let _ = msg.reply.post(cx, overlay);
1625            }
1626            Some(ProcCreationState::Pending { .. }) | None => {
1627                // Proc is not complete yet. Stash the waiter with the rank the
1628                // request carries (RSP-3); `ProcSpawned` starts the watch bridge.
1629                self.pending_proc_waiters
1630                    .entry(msg.id.clone())
1631                    .or_default()
1632                    .push((msg.min_status, rank, msg.reply));
1633            }
1634        }
1635
1636        Ok(())
1637    }
1638}
1639
1640#[async_trait]
1641impl Handler<ProcStatusChanged> for HostAgent {
1642    async fn handle(&mut self, cx: &Context<Self>, msg: ProcStatusChanged) -> anyhow::Result<()> {
1643        self.flush_proc_waiters(cx, &msg.id).await;
1644        Ok(())
1645    }
1646}
1647
1648impl HostAgent {
1649    /// Flush pending `WaitRankStatus` waiters whose threshold is now satisfied.
1650    async fn flush_proc_waiters(&mut self, cx: &Context<'_, Self>, id: &ResourceId) {
1651        let status = match self.created.get(id) {
1652            Some(ProcCreationState::Pending { on_completion, .. }) => {
1653                if on_completion.is_keep() {
1654                    Status::Initializing
1655                } else {
1656                    Status::Stopping
1657                }
1658            }
1659            Some(ProcCreationState::Created { proc_id, .. }) => match self.host() {
1660                Some(host) => host.proc_status(proc_id).await.0,
1661                None => Status::Stopped,
1662            },
1663            Some(ProcCreationState::Failed { error, .. }) => Status::Failed(error.to_string()),
1664            None => {
1665                // Proc not created yet, nothing to flush.
1666                return;
1667            }
1668        };
1669
1670        self.flush_proc_waiters_with_status(cx, id, status);
1671    }
1672
1673    fn flush_proc_waiters_with_status(
1674        &mut self,
1675        cx: &Context<'_, Self>,
1676        id: &ResourceId,
1677        status: Status,
1678    ) {
1679        let Some(waiters) = self.pending_proc_waiters.get_mut(id) else {
1680            return;
1681        };
1682
1683        let remaining = std::mem::take(waiters);
1684        for (min_status, rank, reply) in remaining {
1685            if status >= min_status {
1686                // Each waiter keeps the reply rank it registered with (RSP-3),
1687                // positioned in the caller's view (RSP-1).
1688                let overlay =
1689                    StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status.clone())])
1690                        .expect("valid single-run overlay");
1691                let _ = reply.post(cx, overlay);
1692            } else {
1693                waiters.push((min_status, rank, reply));
1694            }
1695        }
1696
1697        if waiters.is_empty() {
1698            self.pending_proc_waiters.remove(id);
1699        }
1700    }
1701
1702    /// Start a bridge task that watches a proc's status channel and sends
1703    /// `ProcStatusChanged` to self on each change. At most one bridge per proc.
1704    async fn start_watch_bridge(&mut self, id: &ResourceId, proc_id: &ProcAddr) {
1705        if self.watching.contains(id) {
1706            return;
1707        }
1708        self.watching.insert(id.clone());
1709
1710        let port = match &self.proc_status_port {
1711            Some(p) => p.clone(),
1712            None => return,
1713        };
1714
1715        match self.host() {
1716            Some(HostAgentMode::Process { host, .. }) => {
1717                if let Some(rx) = host.manager().watch(proc_id).await {
1718                    start_proc_watch(port, rx, id.clone(), |s| s.clone().into());
1719                }
1720            }
1721            Some(HostAgentMode::Local(host)) => {
1722                if let Some(rx) = host.manager().watch(proc_id).await {
1723                    start_proc_watch(port, rx, id.clone(), |s| (*s).into());
1724                }
1725            }
1726            None => {}
1727        }
1728    }
1729}
1730
1731/// Spawn a bridge task that watches a proc's status channel and sends
1732/// `ProcStatusChanged` to the actor via the given `PortHandle`.
1733fn start_proc_watch<S>(
1734    port: PortHandle<ProcStatusChanged>,
1735    mut rx: tokio::sync::watch::Receiver<S>,
1736    id: ResourceId,
1737    to_status: impl Fn(&S) -> resource::Status + Send + 'static,
1738) where
1739    S: Send + Sync + 'static,
1740{
1741    // TODO: replace Instance::self_client() with a proper mechanism
1742    // for sending to port handles without an actor context.
1743    let client = Instance::<()>::self_client();
1744    tokio::spawn(async move {
1745        loop {
1746            match rx.changed().await {
1747                Ok(()) => {
1748                    let status = to_status(&*rx.borrow());
1749                    let terminated = status.is_terminated();
1750                    let _ = port.post(client, ProcStatusChanged { id: id.clone() });
1751                    if terminated {
1752                        return;
1753                    }
1754                }
1755                Err(_) => {
1756                    let _ = port.post(client, ProcStatusChanged { id: id.clone() });
1757                    return;
1758                }
1759            }
1760        }
1761    });
1762}
1763
1764#[derive(
1765    Serialize,
1766    Deserialize,
1767    Clone,
1768    Debug,
1769    Named,
1770    Handler,
1771    RefClient,
1772    HandleClient
1773)]
1774pub struct ShutdownHost {
1775    /// Grace window: send SIGTERM and wait this long before
1776    /// escalating.
1777    pub timeout: std::time::Duration,
1778    /// Max number of children to terminate concurrently on this host.
1779    pub max_in_flight: usize,
1780    /// This host's ordinal within the shutdown cast region, stamped by the
1781    /// cast layer. The host echoes it back via `ack` so the caller can tell
1782    /// exactly which hosts acknowledged shutdown.
1783    pub rank: resource::Rank,
1784    /// Direct reply carrying this host's rank once shutdown work is done.
1785    ///
1786    /// Intentionally must NOT be split/tree-reduced: `ShutdownHost` makes each
1787    /// host exit right after acking, so a tree-reduced ack would stall — the
1788    /// node that fans in peers' acks tears down before they arrive. Replying
1789    /// directly to the caller survives the responders exiting. The caller binds
1790    /// this port `.unsplit()` so the cast layer leaves it alone, and collects
1791    /// one direct reply per host (see `HostMeshRef::cast_shutdown`).
1792    pub ack: PortRef<usize>,
1793}
1794wirevalue::register_type!(ShutdownHost);
1795
1796/// Drain user procs on this host but keep the host, service proc,
1797/// and networking alive. Used during mesh stop/shutdown so that
1798/// forwarder flushes can still reach remote hosts.
1799///
1800/// If `host_mesh_id` is `Some`, only procs belonging to that mesh
1801/// are stopped (selective drain). If `None`, all procs are
1802/// terminated (full drain).
1803#[derive(
1804    Serialize,
1805    Deserialize,
1806    Clone,
1807    Debug,
1808    Named,
1809    Handler,
1810    RefClient,
1811    HandleClient
1812)]
1813pub struct DrainHost {
1814    pub timeout: std::time::Duration,
1815    pub max_in_flight: usize,
1816    pub host_mesh_id: Option<HostMeshId>,
1817    /// The recipient's ordinal within the drain cast region, stamped by
1818    /// the cast layer. Used to position this host's status overlay.
1819    pub rank: resource::Rank,
1820    /// Idle-flush status reply. Each host reports a single-rank `Stopped`
1821    /// overlay once it has drained; the caller reduces these into a
1822    /// `StatusMesh` barrier and can detect hosts that never reported.
1823    pub reply: IdleFlushPortRef<crate::StatusOverlay>,
1824}
1825wirevalue::register_type!(DrainHost);
1826
1827#[async_trait]
1828impl Handler<DrainHost> for HostAgent {
1829    async fn handle(&mut self, cx: &Context<Self>, msg: DrainHost) -> anyhow::Result<()> {
1830        let rank = msg.rank.unwrap();
1831        if let Some(shutdown) = self.pending_shutdown.as_mut() {
1832            shutdown.drain_replies.push(PendingDrainReply {
1833                rank,
1834                reply: msg.reply,
1835            });
1836            return Ok(());
1837        }
1838
1839        let active_drain = match msg.host_mesh_id.as_ref() {
1840            Some(host_mesh_id) => self.pending_drains.selective.get_mut(host_mesh_id),
1841            None => self.pending_drains.full.as_mut(),
1842        };
1843        if let Some(drain) = active_drain {
1844            drain.replies.push(PendingDrainReply {
1845                rank,
1846                reply: msg.reply,
1847            });
1848            return Ok(());
1849        }
1850
1851        let remaining = self.mark_requests_for_drain(msg.host_mesh_id.as_ref());
1852        let drain = PendingDrain {
1853            remaining,
1854            failures: Vec::new(),
1855            timeout: msg.timeout,
1856            max_in_flight: msg.max_in_flight,
1857            replies: vec![PendingDrainReply {
1858                rank,
1859                reply: msg.reply,
1860            }],
1861        };
1862
1863        match msg.host_mesh_id {
1864            Some(host_mesh_id) => {
1865                if self.pending_drains.full.is_some() {
1866                    let replaced = self.pending_drains.selective.insert(host_mesh_id, drain);
1867                    debug_assert!(replaced.is_none(), "duplicate selective drain was joined");
1868                    if self.pending_drains.full_is_ready()
1869                        && !matches!(&self.state, HostAgentState::Draining)
1870                    {
1871                        let drain = self
1872                            .pending_drains
1873                            .full
1874                            .take()
1875                            .expect("ready full drain exists");
1876                        self.start_full_drain(cx, drain);
1877                    }
1878                    return Ok(());
1879                }
1880
1881                self.drain_by_mesh_name(cx, msg.timeout, Some(&host_mesh_id))
1882                    .await;
1883
1884                if remaining == 0 {
1885                    drain.post_replies(cx);
1886                } else {
1887                    let replaced = self.pending_drains.selective.insert(host_mesh_id, drain);
1888                    debug_assert!(replaced.is_none(), "duplicate selective drain was joined");
1889                }
1890            }
1891            None => {
1892                if remaining == 0
1893                    && self
1894                        .pending_drains
1895                        .selective
1896                        .values()
1897                        .all(|drain| drain.remaining == 0)
1898                {
1899                    self.start_full_drain(cx, drain);
1900                } else {
1901                    debug_assert!(
1902                        self.pending_drains.full.is_none(),
1903                        "duplicate full drain joined"
1904                    );
1905                    self.pending_drains.full = Some(drain);
1906                }
1907            }
1908        }
1909
1910        Ok(())
1911    }
1912}
1913
1914impl HostAgent {
1915    fn start_full_drain(&mut self, cx: &Context<'_, Self>, drain: PendingDrain) {
1916        if drain.remaining != 0 {
1917            unreachable!("full drain requires no remaining proc creations");
1918        }
1919        let timeout = drain.timeout;
1920        let max_in_flight = drain.max_in_flight;
1921
1922        let host = match std::mem::replace(&mut self.state, HostAgentState::Draining) {
1923            HostAgentState::Attached(h) => h,
1924            other @ (HostAgentState::Detached(_) | HostAgentState::Draining) => {
1925                // Nothing to drain — report immediately.
1926                self.state = other;
1927                drain.post_replies(cx);
1928                return;
1929            }
1930            HostAgentState::Shutdown => {
1931                self.state = HostAgentState::Shutdown;
1932                drain.post_replies(cx);
1933                return;
1934            }
1935        };
1936
1937        debug_assert!(
1938            self.pending_drains.full.is_none(),
1939            "duplicate full drain was joined"
1940        );
1941        self.pending_drains.full = Some(drain);
1942
1943        // Do not clear `self.created` here. The DrainWorker terminates procs
1944        // asynchronously, and status queries must still find their entries.
1945        let done_port = cx.port::<DrainComplete>();
1946
1947        cx.spawn_with_label(
1948            "drain_worker",
1949            DrainWorker {
1950                host: Some(host),
1951                timeout,
1952                max_in_flight,
1953                done_notify: done_port,
1954            },
1955        );
1956    }
1957    fn mark_requests_for_drain(&mut self, filter: Option<&HostMeshId>) -> usize {
1958        let mut remaining = 0;
1959        for state in self.created.values_mut() {
1960            if filter.is_some_and(|filter| state.host_mesh_id() != Some(filter)) {
1961                continue;
1962            }
1963
1964            if let ProcCreationState::Pending { on_completion, .. } = state
1965                && on_completion.request_drain()
1966            {
1967                remaining += 1;
1968            }
1969        }
1970        remaining
1971    }
1972}
1973
1974#[async_trait]
1975impl Handler<DrainComplete> for HostAgent {
1976    async fn handle(&mut self, cx: &Context<Self>, msg: DrainComplete) -> anyhow::Result<()> {
1977        self.state = HostAgentState::Detached(msg.0);
1978        self.created.clear();
1979        for drain in self.pending_drains.take_all() {
1980            drain.post_replies(cx);
1981        }
1982
1983        if self
1984            .pending_shutdown
1985            .as_ref()
1986            .is_some_and(|shutdown| shutdown.remaining == 0)
1987        {
1988            let shutdown = self
1989                .pending_shutdown
1990                .take()
1991                .expect("pending shutdown exists");
1992            self.finish_shutdown(cx, shutdown).await;
1993        }
1994        Ok(())
1995    }
1996}
1997
1998#[async_trait]
1999impl Handler<ShutdownHost> for HostAgent {
2000    async fn handle(&mut self, cx: &Context<Self>, msg: ShutdownHost) -> anyhow::Result<()> {
2001        let rank = msg.rank.unwrap();
2002        if matches!(&self.state, HostAgentState::Shutdown) {
2003            msg.ack.post(cx, rank);
2004            return Ok(());
2005        }
2006
2007        if let Some(shutdown) = self.pending_shutdown.as_mut() {
2008            shutdown.acknowledgements.push((rank, msg.ack));
2009            return Ok(());
2010        }
2011
2012        for drain in self.pending_drains.take_all() {
2013            drain.post_replies_with_status(
2014                cx,
2015                resource::Status::Failed("host shutdown superseded drain".to_string()),
2016            );
2017        }
2018
2019        let remaining = self.mark_requests_for_shutdown();
2020        self.pending_shutdown = Some(PendingShutdown {
2021            remaining,
2022            timeout: msg.timeout,
2023            max_in_flight: msg.max_in_flight,
2024            acknowledgements: vec![(rank, msg.ack)],
2025            drain_replies: Vec::new(),
2026        });
2027
2028        if remaining == 0 {
2029            let shutdown = self
2030                .pending_shutdown
2031                .take()
2032                .expect("pending shutdown exists");
2033            self.finish_shutdown(cx, shutdown).await;
2034        }
2035
2036        Ok(())
2037    }
2038}
2039
2040impl HostAgent {
2041    async fn finish_shutdown(&mut self, cx: &Context<'_, Self>, shutdown: PendingShutdown) {
2042        if matches!(&self.state, HostAgentState::Draining) {
2043            self.pending_shutdown = Some(shutdown);
2044            return;
2045        }
2046
2047        let PendingShutdown {
2048            remaining: 0,
2049            timeout,
2050            max_in_flight,
2051            acknowledgements,
2052            drain_replies,
2053        } = shutdown
2054        else {
2055            unreachable!("shutdown cannot finish while proc creation is pending");
2056        };
2057
2058        // Terminate children BEFORE acking, so the caller's networking
2059        // stays alive while children flush their forwarders during
2060        // teardown. If we ack first, the caller proceeds to tear down
2061        // the host proc's networking while children are still running,
2062        // causing their forwarder flushes to hang until
2063        // MESSAGE_DELIVERY_TIMEOUT expires.
2064        let created_ids = self
2065            .created
2066            .iter()
2067            .filter(|(_, state)| matches!(state, ProcCreationState::Created { .. }))
2068            .map(|(id, _)| id.clone())
2069            .collect::<Vec<_>>();
2070        if !self.created.is_empty() {
2071            self.drain(cx, timeout, max_in_flight).await;
2072        }
2073        for id in created_ids {
2074            self.flush_proc_waiters_with_status(cx, &id, Status::Stopped);
2075        }
2076
2077        for PendingDrainReply { rank, reply } in drain_replies {
2078            let overlay = crate::StatusOverlay::try_from_runs(vec![(
2079                rank..(rank + 1),
2080                resource::Status::Stopped,
2081            )])
2082            .expect("valid single-run overlay");
2083            reply.post(cx, overlay);
2084        }
2085
2086        // Drop the host and signal the bootstrap loop to drain the
2087        // mailbox and exit.
2088        match std::mem::replace(&mut self.state, HostAgentState::Shutdown) {
2089            HostAgentState::Detached(HostAgentMode::Process {
2090                mut host,
2091                shutdown_tx: Some(tx),
2092            })
2093            | HostAgentState::Attached(HostAgentMode::Process {
2094                mut host,
2095                shutdown_tx: Some(tx),
2096            }) => {
2097                // Keep the host's outbound gateway alive until the direct
2098                // shutdown acknowledgments have been flushed. The bootstrap
2099                // task may stop the frontend as soon as it receives the
2100                // handle below.
2101                for (rank, ack) in acknowledgements {
2102                    ack.post(cx, rank);
2103                }
2104                // Bounded by the caller's shutdown timeout: a flush toward a
2105                // peer that is already gone never completes.
2106                let flush_timeout =
2107                    hyperactor_config::global::get(hyperactor::config::FORWARDER_FLUSH_TIMEOUT)
2108                        .min(timeout);
2109                match tokio::time::timeout(flush_timeout, host.gateway().flush()).await {
2110                    Ok(Err(error)) => {
2111                        tracing::warn!(%error, "gateway flush failed during host shutdown");
2112                    }
2113                    Err(_elapsed) => {
2114                        tracing::warn!("gateway flush timed out during host shutdown");
2115                    }
2116                    Ok(Ok(())) => {}
2117                }
2118                tracing::info!(
2119                    proc_id = %cx.self_addr().proc_addr(),
2120                    actor_id = %cx.self_addr(),
2121                    "host is shut down, sending transport handles to bootstrap for draining"
2122                );
2123                let handles = host.take_shutdown_handles();
2124                if let Err(handles) = tx.send(handles) {
2125                    drop(handles);
2126                }
2127            }
2128            HostAgentState::Detached(HostAgentMode::Local(mut host))
2129            | HostAgentState::Attached(HostAgentMode::Local(mut host)) => {
2130                let procs = [host.local_proc().clone(), host.system_proc().clone()];
2131                let shutdown_client = Proc::global().client("local_host_shutdown");
2132                // Continue outside the system proc so the final ack can be sent
2133                // after that proc has stopped.
2134                tokio::spawn(async move {
2135                    for mut proc in procs {
2136                        let _ = proc.destroy_and_wait(timeout, "local host shutdown").await;
2137                    }
2138                    host.shutdown_servers().await;
2139                    for (rank, ack) in acknowledgements {
2140                        ack.post(&shutdown_client, rank);
2141                    }
2142                });
2143            }
2144            _ => {
2145                for (rank, ack) in acknowledgements {
2146                    ack.post(cx, rank);
2147                }
2148            }
2149        }
2150    }
2151
2152    fn mark_requests_for_shutdown(&mut self) -> usize {
2153        let mut remaining = 0;
2154        for state in self.created.values_mut() {
2155            if let ProcCreationState::Pending { on_completion, .. } = state {
2156                on_completion.request_shutdown();
2157                remaining += 1;
2158            }
2159        }
2160        remaining
2161    }
2162}
2163
2164#[derive(Debug, Clone, PartialEq, Eq, Named, Serialize, Deserialize)]
2165pub struct ProcState {
2166    pub proc_id: ProcAddr,
2167    pub create_rank: usize,
2168    pub mesh_agent: ActorRef<ProcAgent>,
2169    pub bootstrap_command: Option<BootstrapCommand>,
2170    pub proc_status: Option<bootstrap::ProcStatus>,
2171}
2172wirevalue::register_type!(ProcState);
2173
2174impl HostAgent {
2175    /// Build the `State<ProcState>` for a single proc `id` from `created`.
2176    /// Shared by the `GetState` and `GetHostProcStates` handlers.
2177    async fn proc_state(&self, id: &ResourceId) -> resource::State<ProcState> {
2178        match self.created.get(id) {
2179            Some(state) => self.proc_state_from(id, state).await,
2180            None => resource::State {
2181                id: id.clone(),
2182                status: resource::Status::NotExist,
2183                state: None,
2184                generation: 0,
2185                timestamp: std::time::SystemTime::now(),
2186            },
2187        }
2188    }
2189
2190    /// Like [`Self::proc_state`], but for an already-borrowed `ProcCreationState`
2191    /// so callers iterating `self.created` avoid a second lookup for the same
2192    /// entry.
2193    async fn proc_state_from(
2194        &self,
2195        id: &ResourceId,
2196        state: &ProcCreationState,
2197    ) -> resource::State<ProcState> {
2198        match state {
2199            ProcCreationState::Pending { on_completion, .. } => resource::State {
2200                id: id.clone(),
2201                status: if on_completion.is_keep() {
2202                    resource::Status::Initializing
2203                } else {
2204                    resource::Status::Stopping
2205                },
2206                state: None,
2207                generation: 0,
2208                timestamp: std::time::SystemTime::now(),
2209            },
2210            ProcCreationState::Created {
2211                metadata,
2212                proc_id,
2213                mesh_agent,
2214                ..
2215            } => {
2216                let (raw_status, proc_status, bootstrap_command) = match self.host() {
2217                    Some(host) => {
2218                        let (status, proc_status) = host.proc_status(proc_id).await;
2219                        (status, proc_status, host.bootstrap_command())
2220                    }
2221                    None => (resource::Status::Unknown, None, None),
2222                };
2223                let status = raw_status.clamp_min(self.min_proc_status());
2224                resource::State {
2225                    id: id.clone(),
2226                    status,
2227                    state: Some(ProcState {
2228                        proc_id: proc_id.clone(),
2229                        create_rank: metadata.rank,
2230                        mesh_agent: mesh_agent.clone(),
2231                        bootstrap_command,
2232                        proc_status,
2233                    }),
2234                    generation: 0,
2235                    timestamp: std::time::SystemTime::now(),
2236                }
2237            }
2238            ProcCreationState::Failed { error, .. } => resource::State {
2239                id: id.clone(),
2240                status: resource::Status::Failed(error.to_string()),
2241                state: None,
2242                generation: 0,
2243                timestamp: std::time::SystemTime::now(),
2244            },
2245        }
2246    }
2247}
2248
2249#[async_trait]
2250impl Handler<resource::GetState<ProcState>> for HostAgent {
2251    async fn handle(
2252        &mut self,
2253        cx: &Context<Self>,
2254        get_state: resource::GetState<ProcState>,
2255    ) -> anyhow::Result<()> {
2256        let state = self.proc_state(&get_state.id).await;
2257        get_state.reply.post(cx, state);
2258        Ok(())
2259    }
2260}
2261
2262/// Query the state of a proc mesh's procs, cast to the host agents backing that
2263/// mesh. The caller casts ONE of these carrying the queried `region` (the mesh
2264/// may be sliced, so the region need not be a dense `0..n`). The cast reaches
2265/// every routing host, but each rank's proc lives on exactly one host, so each
2266/// `HostAgent` that owns at least one selected proc reports those procs in a
2267/// single batch. Hosts that own no selected procs do not reply.
2268///
2269/// The bound `reply` port is split by the cast tree, so replies reduce up the
2270/// tree (to cast actor 0) instead of every host dialing the caller directly.
2271/// One batched reply per owning host means the caller sees `O(hosts)` reply
2272/// messages instead of `O(procs)`.
2273///
2274/// `GetState<ProcState>` cannot be cast this way because it carries a
2275/// fully-resolved id; this message resolves ids host-side instead.
2276///
2277/// If `keepalive` is `Some`, each proc's expiry is extended (same
2278/// orphan-protection semantics as `KeepaliveGetState`).
2279#[derive(Debug, Clone, Serialize, Deserialize, Named)]
2280pub struct GetHostProcStates {
2281    pub proc_mesh_id: ProcMeshId,
2282    /// The (possibly sliced) region being queried. Each host keeps the procs it
2283    /// owns for this mesh whose global rank lies in the region, tested with
2284    /// `Slice::contains` (offset/stride-aware) — so a sliced or host-offset
2285    /// region resolves correctly without relying on the recipient's stamped rank.
2286    pub region: Region,
2287    pub keepalive: Option<std::time::SystemTime>,
2288    /// Sparse overlay of the ranks this host owns for the mesh. The caller opens
2289    /// an accumulator port seeded with a full-region template, so per-host
2290    /// overlays reduce up the cast tree into the complete proc-state mesh (see
2291    /// `ProcMeshRef::states`).
2292    pub reply: IdleFlushPortRef<ValueOverlay<resource::State<ProcState>>>,
2293}
2294wirevalue::register_type!(GetHostProcStates);
2295
2296#[async_trait]
2297impl Handler<GetHostProcStates> for HostAgent {
2298    async fn handle(
2299        &mut self,
2300        cx: &Context<Self>,
2301        message: GetHostProcStates,
2302    ) -> anyhow::Result<()> {
2303        let selects = |state: &ProcCreationState| {
2304            state.proc_mesh_id() == Some(&message.proc_mesh_id)
2305                && message.region.slice().contains(state.rank())
2306        };
2307
2308        // Bump keepalive (if requested) in a separate mutable pass, so the read
2309        // loop can borrow `&self` via `proc_state` (mirrors `KeepaliveGetState`).
2310        if let Some(expires_after) = message.keepalive {
2311            for state in self.created.values_mut() {
2312                if selects(state) {
2313                    state.set_expiry_time(expires_after);
2314                }
2315            }
2316        }
2317
2318        // Build a sparse overlay of just the ranks this host owns for the mesh,
2319        // keyed by each proc's *base index within the queried region* — not its
2320        // absolute rank. The caller's `ValueMesh` addresses cells by base rank,
2321        // so on a sliced region (e.g. gpus 2..4 → ranks {2,3,6,7}) the absolute
2322        // rank would land in the wrong cell or out of bounds. The caller's
2323        // accumulator merges these per-host overlays into the full proc-state
2324        // mesh; a host owning no selected procs simply posts nothing.
2325        let mut runs = Vec::new();
2326        for (id, state) in self.created.iter() {
2327            if selects(state) {
2328                let base = message.region.slice().index(state.rank())?;
2329                runs.push((base..(base + 1), self.proc_state_from(id, state).await));
2330            }
2331        }
2332
2333        if !runs.is_empty() {
2334            // Runs are single-rank at distinct ranks; sort so the overlay's
2335            // sorted/non-overlapping normalization invariant holds.
2336            runs.sort_by_key(|(range, _)| range.start);
2337            message.reply.post(cx, ValueOverlay::try_from_runs(runs)?);
2338        }
2339
2340        Ok(())
2341    }
2342}
2343
2344#[async_trait]
2345impl Handler<crate::proc_agent::SelfCheck> for HostAgent {
2346    async fn handle(
2347        &mut self,
2348        cx: &Context<Self>,
2349        _: crate::proc_agent::SelfCheck,
2350    ) -> anyhow::Result<()> {
2351        // Walk procs and tear down any whose owner-supplied keepalive has
2352        // lapsed. Mirrors the proc-agent reaper but at host scope: we
2353        // address the same problem (a controller/client died abruptly)
2354        // for proc-level cleanup so the host doesn't leak children.
2355        let Some(duration) = hyperactor_config::global::get(crate::proc_agent::MESH_ORPHAN_TIMEOUT)
2356        else {
2357            return Ok(());
2358        };
2359        let now = std::time::SystemTime::now();
2360        let timeout = hyperactor_config::global::get(hyperactor::config::PROCESS_EXIT_TIMEOUT);
2361
2362        let expired: Vec<ResourceId> = self
2363            .created
2364            .iter()
2365            .filter_map(|(id, state)| {
2366                let ProcCreationState::Created { expiry_time, .. } = state else {
2367                    return None;
2368                };
2369                let expiry = *expiry_time.as_ref()?;
2370                if now > expiry { Some(id.clone()) } else { None }
2371            })
2372            .collect();
2373
2374        if !expired.is_empty() {
2375            tracing::info!(
2376                "stopping {} orphaned procs past their keepalive expiry",
2377                expired.len(),
2378            );
2379        }
2380        let reaped_any = !expired.is_empty();
2381
2382        for id in expired {
2383            let proc_id = match self.created.get_mut(&id) {
2384                Some(ProcCreationState::Created {
2385                    proc_id,
2386                    stop_requested,
2387                    expiry_time,
2388                    ..
2389                }) => {
2390                    *stop_requested = true;
2391                    *expiry_time = None;
2392                    proc_id.clone()
2393                }
2394                _ => continue,
2395            };
2396            if let Some(host) = self.host() {
2397                host.request_stop(cx, &proc_id, timeout, "orphaned").await;
2398            }
2399        }
2400        if reaped_any {
2401            self.publish_introspect_properties(cx);
2402        }
2403
2404        cx.post_after(cx, crate::proc_agent::SelfCheck::default(), duration);
2405        Ok(())
2406    }
2407}
2408
2409#[async_trait]
2410impl Handler<resource::List> for HostAgent {
2411    async fn handle(&mut self, cx: &Context<Self>, list: resource::List) -> anyhow::Result<()> {
2412        list.reply.post(cx, self.created.keys().cloned().collect());
2413        Ok(())
2414    }
2415}
2416
2417#[async_trait]
2418impl Handler<resource::KeepaliveGetState<ProcState>> for HostAgent {
2419    async fn handle(
2420        &mut self,
2421        cx: &Context<Self>,
2422        message: resource::KeepaliveGetState<ProcState>,
2423    ) -> anyhow::Result<()> {
2424        // Record the new expiry so the periodic SelfCheck reaper knows the
2425        // owner is still alive. If the owner stops extending the keepalive
2426        // (e.g. its process dies abruptly), the proc will be reaped past
2427        // `expires_after`.
2428        if let Some(state) = self.created.get_mut(&message.get_state.id) {
2429            state.set_expiry_time(message.expires_after);
2430        }
2431        <Self as Handler<resource::GetState<ProcState>>>::handle(self, cx, message.get_state).await
2432    }
2433}
2434
2435#[async_trait]
2436impl Handler<resource::StreamState<ProcState>> for HostAgent {
2437    async fn handle(
2438        &mut self,
2439        cx: &Context<Self>,
2440        stream_state: resource::StreamState<ProcState>,
2441    ) -> anyhow::Result<()> {
2442        // One cast delivers a single StreamState per host agent. Stream a state
2443        // for each proc this host owns that belongs to the subscribing mesh.
2444        // TODO: register `subscriber` for ongoing updates.
2445        let mut headers = Flattrs::new();
2446        headers.set(crate::proc_agent::STREAM_STATE_SUBSCRIBER, true);
2447
2448        for (id, proc) in self.created.iter() {
2449            // Skip procs that don't belong to the subscribing proc mesh.
2450            if proc
2451                .proc_mesh_id()
2452                .is_none_or(|mesh| mesh.resource_id() != &stream_state.id)
2453            {
2454                continue;
2455            }
2456
2457            let state = self.proc_state_from(id, proc).await;
2458
2459            stream_state.subscriber.post_with_headers(
2460                cx,
2461                headers.clone(),
2462                resource::RankedState {
2463                    rank: resource::Rank::new(proc.rank()),
2464                    state,
2465                },
2466            );
2467        }
2468        Ok(())
2469    }
2470}
2471
2472/// Push client configuration overrides to this host agent's process.
2473///
2474/// The attrs are installed as `Source::ClientOverride` (lowest explicit
2475/// priority), so the host's own env vars and file config take precedence.
2476/// This message is idempotent — sending the same attrs twice replaces
2477/// the layer wholesale.
2478///
2479/// Request-reply: the reply acts as a barrier confirming the config
2480/// is installed. The fatal-on-failure / best-effort policy is the
2481/// caller's contract, not this message's; for the canonical
2482/// attach-time contract see the HM-* invariants in `host_mesh.rs`.
2483#[derive(
2484    Debug,
2485    Clone,
2486    Named,
2487    Handler,
2488    RefClient,
2489    HandleClient,
2490    Serialize,
2491    Deserialize
2492)]
2493pub struct SetClientConfig {
2494    pub attrs: Attrs,
2495    /// This host's ordinal within the config-push cast region, stamped by the
2496    /// cast layer. Used to position this host's install ack overlay.
2497    pub rank: resource::Rank,
2498    /// Idle-flush install ack. Each host posts a single-rank overlay at its
2499    /// ordinal once it has installed the config; the caller reduces these into
2500    /// a `StatusMesh` barrier and can name exactly which hosts (if any) never
2501    /// acknowledged (HM-4). `StatusMesh` is used here only as a per-rank
2502    /// presence/ack barrier — the status value itself is not meaningful (see
2503    /// the handler).
2504    pub reply: IdleFlushPortRef<crate::StatusOverlay>,
2505}
2506wirevalue::register_type!(SetClientConfig);
2507
2508#[async_trait]
2509impl Handler<SetClientConfig> for HostAgent {
2510    async fn handle(&mut self, cx: &Context<Self>, msg: SetClientConfig) -> anyhow::Result<()> {
2511        let rank = msg.rank.0.expect("rank should be stamped before delivery");
2512        // Use `set` (not `create_or_merge`) because `push_config` always
2513        // sends a complete `propagatable_attrs()` snapshot. Replacing the
2514        // layer wholesale is intentional and idempotent.
2515        hyperactor_config::global::set(
2516            hyperactor_config::global::Source::ClientOverride,
2517            msg.attrs,
2518        );
2519        tracing::debug!("installed client config override on host agent");
2520        // Ack as a single-rank overlay at this host's ordinal. `StatusMesh` is
2521        // reused here purely as a per-rank presence/ack barrier, not as a
2522        // lifecycle signal: there is no "config installed" status, so we pick
2523        // `Running` only because the barrier just needs any value distinct from
2524        // the `NotExist` seed to mark this host as having acknowledged. A
2525        // purpose-built `ValueMesh<2-state>` would model this more honestly;
2526        // this reuses the already-registered `StatusMesh` reducer instead.
2527        let installed_overlay = crate::StatusOverlay::try_from_runs(vec![(
2528            rank..(rank + 1),
2529            resource::Status::Running,
2530        )])
2531        .expect("valid single-run overlay");
2532
2533        msg.reply.post(cx, installed_overlay);
2534
2535        Ok(())
2536    }
2537}
2538
2539/// Boot the ProcAgent on the host's local proc (LP-1).
2540///
2541/// The local proc starts empty; this message activates it by spawning
2542/// a `ProcAgent` (once, via `OnceLock`). Called by
2543/// `monarch_hyperactor::bootstrap_host` when setting up the Python
2544/// `this_proc()` singleton.
2545///
2546/// See also: `crate::host::LOCAL_PROC_NAME`.
2547#[derive(Debug, hyperactor::Handler, hyperactor::HandleClient)]
2548pub struct GetLocalProc {
2549    #[reply]
2550    pub proc_mesh_agent: PortHandle<ActorHandle<ProcAgent>>,
2551}
2552
2553#[async_trait]
2554impl Handler<GetLocalProc> for HostAgent {
2555    async fn handle(
2556        &mut self,
2557        cx: &Context<Self>,
2558        GetLocalProc { proc_mesh_agent }: GetLocalProc,
2559    ) -> anyhow::Result<()> {
2560        let host = self
2561            .host()
2562            .ok_or_else(|| anyhow::anyhow!("HostAgent has already shut down"))?;
2563        let agent = self
2564            .local_mesh_agent
2565            .get_or_init(|| ProcAgent::boot_v1(host.local_proc().clone(), None));
2566
2567        match agent {
2568            Err(e) => anyhow::bail!("error booting local proc: {}", e),
2569            Ok(agent) => proc_mesh_agent.post(cx, agent.clone()),
2570        };
2571
2572        Ok(())
2573    }
2574}
2575
2576#[async_trait]
2577impl Handler<PySpyDump> for HostAgent {
2578    async fn handle(
2579        &mut self,
2580        cx: &Context<Self>,
2581        message: PySpyDump,
2582    ) -> Result<(), anyhow::Error> {
2583        PySpyWorker::spawn_and_forward(cx, message.opts, message.result)
2584    }
2585}
2586
2587#[async_trait]
2588impl Handler<PySpyProfile> for HostAgent {
2589    async fn handle(
2590        &mut self,
2591        cx: &Context<Self>,
2592        message: PySpyProfile,
2593    ) -> Result<(), anyhow::Error> {
2594        PySpyProfileWorker::spawn_and_forward(cx, message.request, message.result)
2595    }
2596}
2597
2598#[async_trait]
2599impl Handler<ConfigDump> for HostAgent {
2600    async fn handle(
2601        &mut self,
2602        cx: &Context<Self>,
2603        message: ConfigDump,
2604    ) -> Result<(), anyhow::Error> {
2605        let entries = hyperactor_config::global::config_entries();
2606        message.result.post(cx, ConfigDumpResult { entries });
2607        Ok(())
2608    }
2609}
2610
2611#[cfg(all(test, fbcode_build))]
2612mod tests {
2613    use std::assert_matches;
2614    use std::sync::Arc;
2615    use std::sync::atomic::AtomicUsize;
2616    use std::sync::atomic::Ordering;
2617
2618    use hyperactor::ActorAddr;
2619    use hyperactor::Client;
2620    use hyperactor::PortReceiver;
2621    use hyperactor::Proc;
2622    use hyperactor::accum::IdleFlushReducerOpts;
2623    use hyperactor::channel::ChannelTransport;
2624    use hyperactor::id::Label;
2625    use hyperactor::id::Uid;
2626    use hyperactor_config::NonZeroUsize;
2627    use timed_test::async_timed_test;
2628
2629    use super::*;
2630    use crate::bootstrap::ProcStatus;
2631    use crate::host::LocalProcManager;
2632    use crate::mesh_id::ResourceId;
2633    use crate::resource::CreateOrUpdateClient;
2634    use crate::resource::GetRankStatusClient;
2635    use crate::resource::GetStateClient;
2636    use crate::resource::Rank;
2637    use crate::resource::WaitRankStatusClient;
2638
2639    /// Wrap a direct test reply so it has the same type as a cast reply.
2640    ///
2641    /// For example, a bound status port becomes an idle-flush status port with
2642    /// one expected update per destination. Direct sends do not split it, so
2643    /// the idle-flush settings do not change delivery in these tests.
2644    fn idle_flush_status_reply(
2645        reply: PortRef<crate::StatusOverlay>,
2646    ) -> IdleFlushPortRef<crate::StatusOverlay> {
2647        reply.into_idle_flush(IdleFlushReducerOpts {
2648            idle_timeout: Duration::from_millis(50),
2649            abandon_timeout: Duration::from_secs(30),
2650            expected_updates_per_destination: NonZeroUsize::MIN,
2651        })
2652    }
2653
2654    /// Assert `overlay` holds exactly one run covering `rank..rank+1`. The
2655    /// reply must land at the message rank the caller requested, not at any
2656    /// rank the proc was created under.
2657    fn assert_overlay_at_rank(overlay: &crate::StatusOverlay, rank: usize) {
2658        assert_eq!(overlay.len(), 1, "expected exactly one run: {overlay:?}");
2659        let (range, _) = overlay.runs().next().unwrap();
2660        assert_eq!(*range, rank..rank + 1, "overlay positioned at wrong rank");
2661    }
2662
2663    /// Like [`assert_overlay_at_rank`], but also require the single run to carry
2664    /// a `Failed` status.
2665    fn assert_overlay_failed_at_rank(overlay: &crate::StatusOverlay, rank: usize) {
2666        assert_eq!(overlay.len(), 1, "expected exactly one run: {overlay:?}");
2667        let (range, status) = overlay.runs().next().unwrap();
2668        assert_eq!(*range, rank..rank + 1, "overlay positioned at wrong rank");
2669        assert_matches!(status, resource::Status::Failed(_));
2670    }
2671
2672    async fn spawn_local_host_agent(spawn: ProcManagerSpawnFn) -> ActorHandle<HostAgent> {
2673        let host = Host::new(LocalProcManager::new(spawn), ChannelTransport::Unix.any())
2674            .await
2675            .expect("local host should start");
2676
2677        let host_agent = host
2678            .system_proc()
2679            .clone()
2680            .spawn_with_uid(
2681                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
2682                HostAgent::new_local(host),
2683            )
2684            .expect("host agent should start");
2685
2686        HostAgent::wait_initialized(&host_agent)
2687            .await
2688            .expect("host agent should initialize");
2689
2690        host_agent
2691    }
2692
2693    #[derive(Debug, hyperactor::Handler)]
2694    struct IsProcExpiryCleared {
2695        id: ResourceId,
2696        #[reply]
2697        reply: PortHandle<bool>,
2698    }
2699
2700    #[async_trait]
2701    impl Handler<IsProcExpiryCleared> for HostAgent {
2702        async fn handle(
2703            &mut self,
2704            cx: &Context<Self>,
2705            message: IsProcExpiryCleared,
2706        ) -> anyhow::Result<()> {
2707            let cleared = matches!(
2708                self.created.get(&message.id),
2709                Some(ProcCreationState::Created {
2710                    expiry_time: None,
2711                    ..
2712                })
2713            );
2714            message.reply.post(cx, cleared);
2715            Ok(())
2716        }
2717    }
2718
2719    async fn introspection_children(
2720        host_agent: &ActorHandle<HostAgent>,
2721        client: &Client,
2722    ) -> Vec<hyperactor::introspect::IntrospectRef> {
2723        let host_agent_ref: ActorRef<HostAgent> = host_agent.bind();
2724        let port = host_agent_ref.actor_addr().introspect_port();
2725        let (reply, rx) = client.open_once_port::<hyperactor::introspect::IntrospectResult>();
2726        port.post(
2727            client,
2728            hyperactor::introspect::IntrospectMessage::Query {
2729                view: hyperactor::introspect::IntrospectView::Entity,
2730                reply: reply.bind(),
2731            },
2732        );
2733        tokio::time::timeout(Duration::from_secs(5), rx.recv())
2734            .await
2735            .expect("host introspection query timed out")
2736            .expect("host introspection reply channel closed")
2737            .children
2738    }
2739
2740    #[derive(Debug)]
2741    struct DrainBlockingActor {
2742        stop_started: Arc<tokio::sync::Notify>,
2743        release_stop: Arc<tokio::sync::Notify>,
2744    }
2745
2746    #[async_trait]
2747    impl Actor for DrainBlockingActor {
2748        async fn handle_stop(
2749            &mut self,
2750            this: &Instance<Self>,
2751            mode: hyperactor::actor::StopMode,
2752            reason: &str,
2753        ) -> anyhow::Result<()> {
2754            let this = this.clone_for_py();
2755            let release_stop = Arc::clone(&self.release_stop);
2756            let reason = reason.to_string();
2757            this.close();
2758            self.stop_started.notify_one();
2759            tokio::spawn(async move {
2760                release_stop.notified().await;
2761                match mode {
2762                    hyperactor::actor::StopMode::Stop => this.exit(&reason).unwrap(),
2763                    hyperactor::actor::StopMode::DrainAndStop => {
2764                        this.exit_after_drain(&reason).unwrap()
2765                    }
2766                }
2767            });
2768            Ok(())
2769        }
2770    }
2771
2772    /// Spawn a Proc, wait for it to be running, request a drain
2773    async fn start_blocked_full_drain(
2774        client_name: &str,
2775    ) -> (
2776        ActorHandle<HostAgent>,
2777        Client,
2778        Arc<tokio::sync::Notify>,
2779        PortReceiver<crate::StatusOverlay>,
2780    ) {
2781        let stop_started = Arc::new(tokio::sync::Notify::new());
2782        let release_stop = Arc::new(tokio::sync::Notify::new());
2783
2784        let spawn: ProcManagerSpawnFn = {
2785            let stop_started_for_spawn = Arc::clone(&stop_started);
2786            let release_stop_for_spawn = Arc::clone(&release_stop);
2787
2788            Box::new(move |proc: Proc| {
2789                proc.spawn(DrainBlockingActor {
2790                    stop_started: Arc::clone(&stop_started_for_spawn),
2791                    release_stop: Arc::clone(&release_stop_for_spawn),
2792                });
2793                Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None)))
2794            })
2795        };
2796        let host_agent = spawn_local_host_agent(spawn).await;
2797
2798        let client = Proc::direct(ChannelTransport::Unix.any(), client_name.to_string())
2799            .unwrap()
2800            .client("client");
2801        let proc_id = ResourceId::instance(Label::new("proc").unwrap());
2802        let (running_reply, mut running_rx) = client.open_port::<crate::StatusOverlay>();
2803
2804        host_agent
2805            .wait_rank_status(
2806                &client,
2807                proc_id.clone(),
2808                resource::Rank::new(0),
2809                resource::Status::Running,
2810                idle_flush_status_reply(running_reply.bind()),
2811            )
2812            .await
2813            .expect("running waiter should be accepted");
2814
2815        host_agent
2816            .create_or_update(
2817                &client,
2818                proc_id,
2819                resource::Rank::new(0),
2820                ProcSpec::default(),
2821            )
2822            .await
2823            .expect("proc should start");
2824
2825        let running_status = running_rx
2826            .recv()
2827            .await
2828            .expect("proc should report running before drain");
2829
2830        assert_overlay_at_rank(&running_status, 0);
2831        assert_eq!(
2832            running_status.runs().next().unwrap().1,
2833            resource::Status::Running,
2834        );
2835
2836        let (drain_reply, drain_rx) = client.open_port::<crate::StatusOverlay>();
2837        host_agent
2838            .try_post(
2839                &client,
2840                DrainHost {
2841                    timeout: Duration::from_secs(20),
2842                    max_in_flight: 1,
2843                    host_mesh_id: None,
2844                    rank: resource::Rank::new(0),
2845                    reply: idle_flush_status_reply(drain_reply.bind()),
2846                },
2847            )
2848            .expect("drain request should be delivered");
2849        stop_started.notified().await;
2850
2851        (host_agent, client, release_stop, drain_rx)
2852    }
2853
2854    #[async_timed_test(timeout_secs = 30)]
2855    async fn create_or_update_reserves_proc_before_spawn_completes() {
2856        let spawn_calls = Arc::new(AtomicUsize::new(0));
2857        let spawn_started = Arc::new(tokio::sync::Notify::new());
2858        let (release_spawn, release_rx) = tokio::sync::watch::channel(false);
2859        let spawn_calls_for_manager = Arc::clone(&spawn_calls);
2860        let spawn_started_for_manager = Arc::clone(&spawn_started);
2861        let spawn: ProcManagerSpawnFn = Box::new(move |proc| {
2862            let mut release_rx = release_rx.clone();
2863            spawn_calls_for_manager.fetch_add(1, Ordering::SeqCst);
2864            spawn_started_for_manager.notify_one();
2865
2866            Box::pin(async move {
2867                release_rx
2868                    .wait_for(|released| *released)
2869                    .await
2870                    .map_err(|_| anyhow::anyhow!("spawn release channel closed"))?;
2871
2872                ProcAgent::boot_v1(proc, None)
2873            })
2874        });
2875        let host_agent = spawn_local_host_agent(spawn).await;
2876
2877        let client = Proc::direct(ChannelTransport::Unix.any(), "dedup_client".to_string())
2878            .unwrap()
2879            .client("client");
2880
2881        let id = ResourceId::instance(Label::new("deduplicated-proc").unwrap());
2882
2883        let (status_reply, mut status_rx) = client.open_port::<crate::StatusOverlay>();
2884
2885        host_agent
2886            .wait_rank_status(
2887                &client,
2888                id.clone(),
2889                resource::Rank::new(0),
2890                resource::Status::Running,
2891                idle_flush_status_reply(status_reply.bind()),
2892            )
2893            .await
2894            .expect("status waiter should be accepted");
2895
2896        host_agent
2897            .create_or_update(
2898                &client,
2899                id.clone(),
2900                resource::Rank::new(0),
2901                ProcSpec::default(),
2902            )
2903            .await
2904            .expect("first create request should be accepted");
2905
2906        spawn_started.notified().await;
2907        assert_eq!(spawn_calls.load(Ordering::SeqCst), 1);
2908
2909        host_agent
2910            .create_or_update(
2911                &client,
2912                id.clone(),
2913                resource::Rank::new(0),
2914                ProcSpec::default(),
2915            )
2916            .await
2917            .expect("duplicate create request should be accepted as a no-op");
2918
2919        assert_matches!(
2920            host_agent
2921                .get_state(&client, id)
2922                .await
2923                .expect("pending state should be available"),
2924            resource::State {
2925                status: resource::Status::Initializing,
2926                ..
2927            }
2928        );
2929
2930        release_spawn
2931            .send(true)
2932            .expect("spawn should still be waiting for release");
2933
2934        let overlay = status_rx
2935            .recv()
2936            .await
2937            .expect("spawn completion should resolve the status waiter");
2938
2939        assert_overlay_at_rank(&overlay, 0);
2940        assert_eq!(spawn_calls.load(Ordering::SeqCst), 1);
2941    }
2942
2943    #[async_timed_test(timeout_secs = 30)]
2944    async fn create_or_update_starts_distinct_procs_concurrently() {
2945        let (spawn_started_tx, mut spawn_started_rx) = tokio::sync::mpsc::unbounded_channel();
2946        let (release_spawn, release_rx) = tokio::sync::watch::channel(false);
2947
2948        let spawn: ProcManagerSpawnFn = Box::new(move |proc| {
2949            let mut release_rx = release_rx.clone();
2950
2951            spawn_started_tx
2952                .send(proc.proc_id().clone())
2953                .expect("spawn-start receiver should remain open");
2954
2955            Box::pin(async move {
2956                release_rx
2957                    .wait_for(|released| *released)
2958                    .await
2959                    .map_err(|_| anyhow::anyhow!("spawn release channel closed"))?;
2960
2961                ProcAgent::boot_v1(proc, None)
2962            })
2963        });
2964
2965        let host_agent = spawn_local_host_agent(spawn).await;
2966
2967        let client = Proc::direct(
2968            ChannelTransport::Unix.any(),
2969            "concurrent_client".to_string(),
2970        )
2971        .expect("client proc should start")
2972        .client("client");
2973
2974        let ids = [
2975            ResourceId::instance(Label::new("concurrent-proc-a").unwrap()),
2976            ResourceId::instance(Label::new("concurrent-proc-b").unwrap()),
2977        ];
2978        let expected_proc_ids = ids.iter().map(ResourceId::proc_id).collect::<Vec<_>>();
2979        let (status_reply, mut status_rx) = client.open_port::<crate::StatusOverlay>();
2980
2981        for (rank, id) in ids.iter().enumerate() {
2982            host_agent
2983                .wait_rank_status(
2984                    &client,
2985                    id.clone(),
2986                    resource::Rank::new(rank),
2987                    resource::Status::Running,
2988                    idle_flush_status_reply(status_reply.bind()),
2989                )
2990                .await
2991                .expect("status waiter should be accepted");
2992
2993            host_agent
2994                .create_or_update(
2995                    &client,
2996                    id.clone(),
2997                    resource::Rank::new(rank),
2998                    ProcSpec::default(),
2999                )
3000                .await
3001                .expect("create request should be accepted");
3002        }
3003
3004        let started = [
3005            spawn_started_rx
3006                .recv()
3007                .await
3008                .expect("first proc should start"),
3009            spawn_started_rx
3010                .recv()
3011                .await
3012                .expect("second proc should start"),
3013        ];
3014
3015        assert_ne!(started[0], started[1], "distinct proc spawns should start");
3016        assert!(
3017            expected_proc_ids
3018                .iter()
3019                .all(|proc_id| started.contains(proc_id)),
3020            "both requested proc spawns should start before either is released",
3021        );
3022        assert_eq!(
3023            status_rx
3024                .try_recv()
3025                .expect("status reply port should remain open"),
3026            None,
3027            "neither proc should be ready before spawn is released",
3028        );
3029
3030        release_spawn
3031            .send(true)
3032            .expect("spawn-release receivers should remain open");
3033
3034        let mut reported = [false; 2];
3035
3036        for _ in 0..ids.len() {
3037            let overlay = status_rx
3038                .recv()
3039                .await
3040                .expect("spawn completion should resolve its status waiter");
3041
3042            let (range, status) = overlay.runs().next().expect("status overlay has a run");
3043
3044            assert_eq!(status, &resource::Status::Running);
3045
3046            reported[range.start] = true;
3047        }
3048
3049        assert_eq!(reported, [true, true]);
3050    }
3051
3052    #[async_timed_test(timeout_secs = 30)]
3053    async fn stop_during_proc_creation_is_applied_after_spawn() {
3054        let spawn_started = Arc::new(tokio::sync::Notify::new());
3055        let (release_spawn, release_rx) = tokio::sync::watch::channel(false);
3056        let spawn_started_for_manager = Arc::clone(&spawn_started);
3057
3058        let spawn: ProcManagerSpawnFn = Box::new(move |proc| {
3059            let mut release_rx = release_rx.clone();
3060
3061            spawn_started_for_manager.notify_one();
3062
3063            Box::pin(async move {
3064                release_rx
3065                    .wait_for(|released| *released)
3066                    .await
3067                    .map_err(|_| anyhow::anyhow!("spawn release channel closed"))?;
3068
3069                ProcAgent::boot_v1(proc, None)
3070            })
3071        });
3072        let host_agent = spawn_local_host_agent(spawn).await;
3073
3074        let client = Proc::direct(
3075            ChannelTransport::Unix.any(),
3076            "stop_pending_client".to_string(),
3077        )
3078        .expect("client proc should start")
3079        .client("client");
3080
3081        let id = ResourceId::instance(Label::new("stop-pending-proc").unwrap());
3082        let (status_reply, mut status_rx) = client.open_port::<crate::StatusOverlay>();
3083
3084        host_agent
3085            .wait_rank_status(
3086                &client,
3087                id.clone(),
3088                resource::Rank::new(0),
3089                resource::Status::Stopped,
3090                idle_flush_status_reply(status_reply.bind()),
3091            )
3092            .await
3093            .expect("status waiter should be accepted");
3094
3095        host_agent
3096            .create_or_update(
3097                &client,
3098                id.clone(),
3099                resource::Rank::new(0),
3100                ProcSpec::default(),
3101            )
3102            .await
3103            .expect("create request should be accepted");
3104
3105        spawn_started.notified().await;
3106
3107        crate::resource::StopClient::stop(
3108            &host_agent,
3109            &client,
3110            id.clone(),
3111            "stop during creation".to_string(),
3112        )
3113        .await
3114        .expect("stop request should be accepted");
3115
3116        assert_matches!(
3117            host_agent
3118                .get_state(&client, id.clone())
3119                .await
3120                .expect("pending state should be available"),
3121            resource::State {
3122                status: resource::Status::Stopping,
3123                ..
3124            }
3125        );
3126        assert_eq!(
3127            status_rx
3128                .try_recv()
3129                .expect("status reply port should remain open"),
3130            None,
3131            "stop waiter should remain pending until spawn completes",
3132        );
3133
3134        release_spawn
3135            .send(true)
3136            .expect("spawn-release receiver should remain open");
3137
3138        let overlay = status_rx
3139            .recv()
3140            .await
3141            .expect("stopped proc should resolve its status waiter");
3142
3143        let (_, status) = overlay.runs().next().expect("status overlay has a run");
3144        assert_eq!(status, &resource::Status::Stopped);
3145        let proc_id = match host_agent
3146            .get_state(&client, id)
3147            .await
3148            .expect("stopped state should be available")
3149        {
3150            resource::State {
3151                state: Some(ProcState { proc_id, .. }),
3152                ..
3153            } => proc_id,
3154            state => panic!("expected stopped proc state, got {state:?}"),
3155        };
3156        let child_ref = hyperactor::introspect::IntrospectRef::Proc(proc_id);
3157        assert!(
3158            !introspection_children(&host_agent, &client)
3159                .await
3160                .contains(&child_ref),
3161            "a proc stopped during creation should not become an introspection child",
3162        );
3163    }
3164
3165    #[async_timed_test(timeout_secs = 30)]
3166    async fn selective_drain_waits_for_matching_creation_only() {
3167        let matching_id = ResourceId::instance(Label::new("matching-pending-proc").unwrap());
3168        let other_id = ResourceId::instance(Label::new("other-pending-proc").unwrap());
3169
3170        let (spawn_started_tx, mut spawn_started_rx) = tokio::sync::mpsc::unbounded_channel();
3171        let (release_matching, release_matching_rx) = tokio::sync::watch::channel(false);
3172        let (release_other, release_other_rx) = tokio::sync::watch::channel(false);
3173
3174        let spawn: ProcManagerSpawnFn = {
3175            let matching_proc_id_for_spawn = matching_id.proc_id();
3176
3177            Box::new(move |proc| {
3178                let is_matching = proc.proc_id() == &matching_proc_id_for_spawn;
3179
3180                let mut release_rx = if is_matching {
3181                    release_matching_rx.clone()
3182                } else {
3183                    release_other_rx.clone()
3184                };
3185
3186                spawn_started_tx
3187                    .send(proc.proc_id().clone())
3188                    .expect("spawn-start receiver should remain open");
3189
3190                Box::pin(async move {
3191                    release_rx
3192                        .wait_for(|released| *released)
3193                        .await
3194                        .map_err(|_| anyhow::anyhow!("spawn release channel closed"))?;
3195
3196                    ProcAgent::boot_v1(proc, None)
3197                })
3198            })
3199        };
3200        let host_agent = spawn_local_host_agent(spawn).await;
3201        let client = Proc::direct(
3202            ChannelTransport::Unix.any(),
3203            "selective_pending_client".to_string(),
3204        )
3205        .expect("client proc should start")
3206        .client("client");
3207
3208        let matching_mesh = HostMeshId::instance(Label::new("matching-mesh").unwrap());
3209        let other_mesh = HostMeshId::instance(Label::new("other-mesh").unwrap());
3210
3211        let (matching_status_reply, mut matching_status_rx) =
3212            client.open_port::<crate::StatusOverlay>();
3213
3214        let (other_status_reply, mut other_status_rx) = client.open_port::<crate::StatusOverlay>();
3215
3216        // Send WaitRankStatus for both
3217        {
3218            host_agent
3219                .wait_rank_status(
3220                    &client,
3221                    matching_id.clone(),
3222                    resource::Rank::new(0),
3223                    resource::Status::Stopped,
3224                    idle_flush_status_reply(matching_status_reply.bind()),
3225                )
3226                .await
3227                .expect("matching status waiter should be accepted");
3228
3229            host_agent
3230                .wait_rank_status(
3231                    &client,
3232                    other_id.clone(),
3233                    resource::Rank::new(1),
3234                    resource::Status::Running,
3235                    idle_flush_status_reply(other_status_reply.bind()),
3236                )
3237                .await
3238                .expect("other status waiter should be accepted");
3239        }
3240
3241        // Send CreateOrUpdate for both
3242        {
3243            host_agent
3244                .create_or_update(
3245                    &client,
3246                    matching_id.clone(),
3247                    resource::Rank::new(0),
3248                    ProcSpec {
3249                        host_mesh_id: Some(matching_mesh.clone()),
3250                        ..Default::default()
3251                    },
3252                )
3253                .await
3254                .expect("matching create request should be accepted");
3255
3256            host_agent
3257                .create_or_update(
3258                    &client,
3259                    other_id.clone(),
3260                    resource::Rank::new(1),
3261                    ProcSpec {
3262                        host_mesh_id: Some(other_mesh),
3263                        ..Default::default()
3264                    },
3265                )
3266                .await
3267                .expect("other create request should be accepted");
3268        }
3269
3270        // Assert both started spawn
3271        {
3272            let started = [
3273                spawn_started_rx
3274                    .recv()
3275                    .await
3276                    .expect("one proc should start"),
3277                spawn_started_rx
3278                    .recv()
3279                    .await
3280                    .expect("both procs should start"),
3281            ];
3282            assert!(
3283                started.contains(&matching_id.proc_id()),
3284                "matching proc should start",
3285            );
3286            assert!(
3287                started.contains(&other_id.proc_id()),
3288                "non-matching proc should start",
3289            );
3290        }
3291
3292        let (drain_reply, mut drain_rx) = client.open_port::<crate::StatusOverlay>();
3293
3294        // Drain matching
3295        {
3296            host_agent
3297                .drain_host(
3298                    &client,
3299                    Duration::from_secs(5),
3300                    16,
3301                    Some(matching_mesh),
3302                    resource::Rank::new(0),
3303                    idle_flush_status_reply(drain_reply.bind()),
3304                )
3305                .await
3306                .expect("selective drain should be accepted");
3307
3308            assert_matches!(
3309                host_agent
3310                    .get_state(&client, matching_id.clone())
3311                    .await
3312                    .expect("matching state should be available"),
3313                resource::State {
3314                    status: resource::Status::Stopping,
3315                    ..
3316                }
3317            );
3318            assert_matches!(
3319                host_agent
3320                    .get_state(&client, other_id.clone())
3321                    .await
3322                    .expect("other state should be available"),
3323                resource::State {
3324                    status: resource::Status::Initializing,
3325                    ..
3326                }
3327            );
3328            assert_eq!(
3329                drain_rx
3330                    .try_recv()
3331                    .expect("drain reply port should remain open"),
3332                None,
3333                "drain should wait for the matching proc creation",
3334            );
3335        }
3336
3337        // Allow matching spawn to finish and assert it has stopped
3338        {
3339            release_matching
3340                .send(true)
3341                .expect("matching spawn-release receiver should remain open");
3342
3343            let matching_status = matching_status_rx
3344                .recv()
3345                .await
3346                .expect("matching proc should report stopped");
3347
3348            assert_eq!(
3349                matching_status
3350                    .runs()
3351                    .next()
3352                    .expect("status overlay has a run")
3353                    .1,
3354                resource::Status::Stopped,
3355            );
3356        }
3357
3358        let drain_status = drain_rx
3359            .recv()
3360            .await
3361            .expect("selective drain should complete");
3362
3363        assert_eq!(
3364            drain_status
3365                .runs()
3366                .next()
3367                .expect("drain overlay has a run")
3368                .1,
3369            resource::Status::Stopped,
3370        );
3371        assert_matches!(
3372            host_agent
3373                .get_state(&client, matching_id)
3374                .await
3375                .expect("matching drained state should be available"),
3376            resource::State {
3377                status: resource::Status::NotExist,
3378                ..
3379            }
3380        );
3381        assert_matches!(
3382            host_agent
3383                .get_state(&client, other_id)
3384                .await
3385                .expect("other pending state should be available"),
3386            resource::State {
3387                status: resource::Status::Initializing,
3388                ..
3389            }
3390        );
3391
3392        // Assert other is still running
3393        {
3394            release_other
3395                .send(true)
3396                .expect("other spawn-release receiver should remain open");
3397
3398            let other_status = other_status_rx
3399                .recv()
3400                .await
3401                .expect("non-matching proc should report running");
3402
3403            assert_eq!(
3404                other_status
3405                    .runs()
3406                    .next()
3407                    .expect("status overlay has a run")
3408                    .1,
3409                resource::Status::Running,
3410            );
3411        }
3412    }
3413
3414    #[async_timed_test(timeout_secs = 30)]
3415    async fn drain_waits_for_create_requested_proc() {
3416        let (release_spawn, release_rx) = tokio::sync::watch::channel(false);
3417        let spawn: ProcManagerSpawnFn = Box::new(move |proc| {
3418            let mut release_rx = release_rx.clone();
3419
3420            Box::pin(async move {
3421                release_rx
3422                    .wait_for(|released| *released)
3423                    .await
3424                    .map_err(|_| anyhow::anyhow!("spawn release channel closed"))?;
3425
3426                ProcAgent::boot_v1(proc, None)
3427            })
3428        });
3429        let host_agent = spawn_local_host_agent(spawn).await;
3430
3431        let client = Proc::direct(ChannelTransport::Unix.any(), "drain_client".to_string())
3432            .unwrap()
3433            .client("client");
3434        let id = ResourceId::instance(Label::new("pending-proc").unwrap());
3435
3436        host_agent
3437            .create_or_update(
3438                &client,
3439                id.clone(),
3440                resource::Rank::new(0),
3441                ProcSpec::default(),
3442            )
3443            .await
3444            .expect("create request should be accepted");
3445
3446        assert_matches!(
3447            host_agent
3448                .get_state(&client, id.clone())
3449                .await
3450                .expect("create state should be available"),
3451            resource::State {
3452                status: resource::Status::Initializing,
3453                ..
3454            }
3455        );
3456
3457        let (status_reply, mut status_rx) = client.open_port::<crate::StatusOverlay>();
3458
3459        host_agent
3460            .wait_rank_status(
3461                &client,
3462                id.clone(),
3463                resource::Rank::new(0),
3464                resource::Status::Stopped,
3465                idle_flush_status_reply(status_reply.bind()),
3466            )
3467            .await
3468            .expect("status waiter should be accepted");
3469
3470        let (drain_reply, mut drain_rx) = client.open_port::<crate::StatusOverlay>();
3471
3472        host_agent
3473            .drain_host(
3474                &client,
3475                Duration::from_secs(5),
3476                16,
3477                None,
3478                resource::Rank::new(0),
3479                idle_flush_status_reply(drain_reply.bind()),
3480            )
3481            .await
3482            .expect("drain request should be accepted");
3483
3484        assert_matches!(
3485            host_agent
3486                .get_state(&client, id.clone())
3487                .await
3488                .expect("drain state should be available"),
3489            resource::State {
3490                status: resource::Status::Stopping,
3491                ..
3492            }
3493        );
3494        assert_eq!(
3495            drain_rx
3496                .try_recv()
3497                .expect("drain reply port should remain open"),
3498            None,
3499            "drain replied before the requested proc finished creating",
3500        );
3501        assert_eq!(
3502            status_rx
3503                .try_recv()
3504                .expect("status reply port should remain open"),
3505            None,
3506            "status waiter replied before the requested proc was drained",
3507        );
3508
3509        release_spawn
3510            .send(true)
3511            .expect("spawn should still be waiting for release");
3512
3513        let status_overlay = status_rx
3514            .recv()
3515            .await
3516            .expect("drain should resolve the pending status waiter");
3517
3518        let (_, status) = status_overlay
3519            .runs()
3520            .next()
3521            .expect("status overlay should have a run");
3522
3523        assert_eq!(status, &resource::Status::Stopped);
3524
3525        let overlay = drain_rx
3526            .recv()
3527            .await
3528            .expect("drain should reply after creation is terminated");
3529
3530        assert_overlay_at_rank(&overlay, 0);
3531
3532        let (_, status) = overlay
3533            .runs()
3534            .next()
3535            .expect("drain overlay should have a run");
3536
3537        assert_eq!(status, &resource::Status::Stopped);
3538
3539        assert_matches!(
3540            host_agent
3541                .get_state(&client, id)
3542                .await
3543                .expect("drained state should be available"),
3544            resource::State {
3545                status: resource::Status::NotExist,
3546                ..
3547            }
3548        );
3549    }
3550
3551    #[async_timed_test(timeout_secs = 30)]
3552    async fn shutdown_waits_for_create_requested_proc_and_rejects_new_creates() {
3553        let spawn_calls = Arc::new(AtomicUsize::new(0));
3554        let spawn_started = Arc::new(tokio::sync::Notify::new());
3555        let (release_spawn, release_rx) = tokio::sync::watch::channel(false);
3556        let spawn_calls_for_manager = Arc::clone(&spawn_calls);
3557        let spawn_started_for_manager = Arc::clone(&spawn_started);
3558        let spawn: ProcManagerSpawnFn = Box::new(move |proc| {
3559            let mut release_rx = release_rx.clone();
3560            spawn_calls_for_manager.fetch_add(1, Ordering::SeqCst);
3561            spawn_started_for_manager.notify_one();
3562            Box::pin(async move {
3563                release_rx
3564                    .wait_for(|released| *released)
3565                    .await
3566                    .map_err(|_| anyhow::anyhow!("spawn release channel closed"))?;
3567                ProcAgent::boot_v1(proc, None)
3568            })
3569        });
3570        let host_agent = spawn_local_host_agent(spawn).await;
3571
3572        let client = Proc::direct(
3573            ChannelTransport::Unix.any(),
3574            "shutdown_pending_create_client".to_string(),
3575        )
3576        .unwrap()
3577        .client("client");
3578
3579        let accepted_id = ResourceId::instance(Label::new("accepted-proc").unwrap());
3580        host_agent
3581            .create_or_update(
3582                &client,
3583                accepted_id.clone(),
3584                resource::Rank::new(0),
3585                ProcSpec::default(),
3586            )
3587            .await
3588            .expect("create request should be accepted");
3589        spawn_started.notified().await;
3590
3591        let (status_reply, mut status_rx) = client.open_port::<crate::StatusOverlay>();
3592        host_agent
3593            .wait_rank_status(
3594                &client,
3595                accepted_id.clone(),
3596                resource::Rank::new(0),
3597                resource::Status::Running,
3598                idle_flush_status_reply(status_reply.bind()),
3599            )
3600            .await
3601            .expect("status waiter should be accepted");
3602        let (stopped_reply, mut stopped_rx) = client.open_port::<crate::StatusOverlay>();
3603        host_agent
3604            .wait_rank_status(
3605                &client,
3606                accepted_id.clone(),
3607                resource::Rank::new(0),
3608                resource::Status::Stopped,
3609                idle_flush_status_reply(stopped_reply.bind()),
3610            )
3611            .await
3612            .expect("stopped waiter should be accepted");
3613
3614        let (ack, mut ack_rx) = client.open_port::<usize>();
3615        host_agent
3616            .try_post(
3617                &client,
3618                ShutdownHost {
3619                    timeout: Duration::from_secs(5),
3620                    max_in_flight: 1,
3621                    rank: Rank::new(7),
3622                    ack: ack.bind().unsplit(),
3623                },
3624            )
3625            .expect("shutdown request should be accepted");
3626
3627        let rejected_id = ResourceId::instance(Label::new("rejected-proc").unwrap());
3628        host_agent
3629            .create_or_update(
3630                &client,
3631                rejected_id.clone(),
3632                resource::Rank::new(1),
3633                ProcSpec::default(),
3634            )
3635            .await
3636            .expect("create during shutdown should be accepted as a no-op");
3637        assert_matches!(
3638            host_agent
3639                .get_state(&client, rejected_id)
3640                .await
3641                .expect("rejected create state should be available"),
3642            resource::State {
3643                status: resource::Status::NotExist,
3644                ..
3645            }
3646        );
3647
3648        let rejected_proc_mesh_id =
3649            ProcMeshId::singleton(Label::new("rejected-proc-mesh").unwrap());
3650        let (rejected_status_reply, mut rejected_status_rx) =
3651            client.open_port::<crate::StatusOverlay>();
3652
3653        let agent_ref: ActorRef<HostAgent> = host_agent.bind();
3654
3655        agent_ref.post(
3656            &client,
3657            SpawnProcs {
3658                rank: resource::Rank::new(0),
3659                proc_mesh_id: rejected_proc_mesh_id.clone(),
3660                num_per_host: 1,
3661                client_config_override: Attrs::new(),
3662                host_mesh_id: None,
3663                default_bootstrap_command: None,
3664                proc_bind: None,
3665                bootstrap_commands: None,
3666            },
3667        );
3668        agent_ref.post(
3669            &client,
3670            WaitProcs {
3671                rank: resource::Rank::new(0),
3672                proc_mesh_id: rejected_proc_mesh_id,
3673                num_per_host: 1,
3674                status_reply: idle_flush_status_reply(rejected_status_reply.bind()),
3675            },
3676        );
3677        assert_overlay_failed_at_rank(
3678            &rejected_status_rx
3679                .recv()
3680                .await
3681                .expect("rejected SpawnProcs should report failure"),
3682            0,
3683        );
3684        assert_matches!(
3685            host_agent
3686                .get_state(&client, accepted_id)
3687                .await
3688                .expect("accepted create state should be available"),
3689            resource::State {
3690                status: resource::Status::Stopping,
3691                ..
3692            }
3693        );
3694
3695        assert_eq!(spawn_calls.load(Ordering::SeqCst), 1);
3696        assert_eq!(
3697            ack_rx
3698                .try_recv()
3699                .expect("shutdown ack port should remain open"),
3700            None,
3701            "shutdown acknowledged before the accepted spawn completed",
3702        );
3703        assert_eq!(
3704            status_rx
3705                .try_recv()
3706                .expect("status reply port should remain open"),
3707            None,
3708            "status waiter replied before the accepted spawn completed",
3709        );
3710        assert_eq!(
3711            stopped_rx
3712                .try_recv()
3713                .expect("stopped reply port should remain open"),
3714            None,
3715            "stopped waiter replied before the accepted spawn completed",
3716        );
3717
3718        release_spawn
3719            .send(true)
3720            .expect("spawn should still be waiting for release");
3721        let status_overlay = status_rx
3722            .recv()
3723            .await
3724            .expect("shutdown should resolve the pending status waiter");
3725        let (_, status) = status_overlay
3726            .runs()
3727            .next()
3728            .expect("status overlay should have a run");
3729        assert_eq!(status, &resource::Status::Stopping);
3730        let stopped_overlay = stopped_rx
3731            .recv()
3732            .await
3733            .expect("shutdown should resolve the stopped waiter");
3734        let (_, status) = stopped_overlay
3735            .runs()
3736            .next()
3737            .expect("stopped overlay should have a run");
3738        assert_eq!(status, &resource::Status::Stopped);
3739        assert_eq!(ack_rx.recv().await.expect("shutdown should acknowledge"), 7);
3740        assert_eq!(spawn_calls.load(Ordering::SeqCst), 1);
3741    }
3742
3743    #[async_timed_test(timeout_secs = 30)]
3744    async fn create_records_spawn_task_panic_as_failure() {
3745        let spawn: ProcManagerSpawnFn = Box::new(|_proc| -> ProcManagerSpawnFuture {
3746            Box::pin(async move { panic!("test proc spawn panic") })
3747        });
3748        let host_agent = spawn_local_host_agent(spawn).await;
3749
3750        let client = Proc::direct(ChannelTransport::Unix.any(), "panic_client".to_string())
3751            .unwrap()
3752            .client("client");
3753        let id = ResourceId::instance(Label::new("panicking-proc").unwrap());
3754
3755        let (status_reply, mut status_rx) = client.open_port::<crate::StatusOverlay>();
3756        host_agent
3757            .wait_rank_status(
3758                &client,
3759                id.clone(),
3760                resource::Rank::new(0),
3761                resource::Status::Running,
3762                idle_flush_status_reply(status_reply.bind()),
3763            )
3764            .await
3765            .expect("status waiter should be accepted");
3766
3767        host_agent
3768            .create_or_update(
3769                &client,
3770                id.clone(),
3771                resource::Rank::new(0),
3772                ProcSpec::default(),
3773            )
3774            .await
3775            .expect("create request should be accepted");
3776
3777        let overlay = status_rx
3778            .recv()
3779            .await
3780            .expect("spawn panic should resolve the status waiter");
3781        assert_overlay_failed_at_rank(&overlay, 0);
3782        assert_matches!(
3783            host_agent
3784                .get_state(&client, id)
3785                .await
3786                .expect("failed state should be available"),
3787            resource::State {
3788                status: resource::Status::Failed(_),
3789                ..
3790            }
3791        );
3792    }
3793
3794    // RSP-* coverage map for the HostAgent rank-status tests. Each deliberately
3795    // uses `message_rank != creation_rank`, so a handler that read the creation
3796    // rank would fail:
3797    // - RSP-1 (position at the message rank, not creation rank):
3798    //   `test_wait_rank_status_already_running` (immediate WaitRankStatus and
3799    //   GetRankStatus on a known proc), `test_wait_rank_status_stop`,
3800    //   `test_wait_rank_status_before_proc_exists`, `test_spawn_procs_many_per_host`;
3801    //   `test_rank_status_created_error` covers the created-error branch (Failed
3802    //   overlay at the message rank).
3803    // - RSP-2 (direct delivery supplies the rank explicitly): every test above
3804    //   calls the generated client with `Rank::new(...)`; the cast half
3805    //   (`Rank::default()` late-bound in transit) is covered by the library
3806    //   regression in `proc_mesh.rs`.
3807    // - RSP-3 (deferred / pre-create waiter retains its registration rank):
3808    //   `test_wait_rank_status_stop` (deferred until stop),
3809    //   `test_wait_rank_status_before_proc_exists` (registered before the proc
3810    //   exists, then the proc is created at a different rank; the waiter keeps its
3811    //   own rank rather than adopting the creation rank), and
3812    //   `test_rank_status_created_error` (registered before a failed creation).
3813    // - RSP-4 (absence yields an empty overlay): the unknown-proc GetRankStatus
3814    //   assertion in `test_wait_rank_status_already_running`.
3815
3816    #[async_timed_test(timeout_secs = 30)]
3817    async fn test_basic() {
3818        let bootstrap_command = BootstrapCommand::test();
3819        let expected_bootstrap_command = bootstrap_command.clone();
3820        let host = Host::new(
3821            BootstrapProcManager::new(bootstrap_command).unwrap(),
3822            ChannelTransport::Unix.any(),
3823        )
3824        .await
3825        .unwrap();
3826
3827        let host_addr = host.addr().clone();
3828        let system_proc = host.system_proc().clone();
3829        let host_agent = system_proc
3830            .spawn_with_uid(
3831                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
3832                HostAgent::new_process(host, None),
3833            )
3834            .unwrap();
3835        HostAgent::wait_initialized(&host_agent).await.unwrap();
3836
3837        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
3838        let client = client_proc.client("client");
3839
3840        let id = ResourceId::instance(Label::new("proc1").unwrap());
3841
3842        // First, create the proc, then query its state:
3843
3844        host_agent
3845            .create_or_update(
3846                &client,
3847                id.clone(),
3848                resource::Rank::new(0),
3849                ProcSpec::default(),
3850            )
3851            .await
3852            .unwrap();
3853        let (running_reply, mut running_rx) = client.open_port::<crate::StatusOverlay>();
3854
3855        host_agent
3856            .wait_rank_status(
3857                &client,
3858                id.clone(),
3859                resource::Rank::new(0),
3860                resource::Status::Running,
3861                idle_flush_status_reply(running_reply.bind()),
3862            )
3863            .await
3864            .unwrap();
3865        running_rx.recv().await.unwrap();
3866
3867        // The host advertises spawned procs with a
3868        // `Via(proc_uid, Addr(host_addr))` location so its gateway can
3869        // peel and forward to the child's serving address. Construct
3870        // the expected proc_addr the same way.
3871        let expected_location =
3872            hyperactor::Location::from(host_addr.clone()).with_via(id.uid().clone());
3873        let expected_proc_addr = ProcAddr::new(id.proc_id(), expected_location);
3874        assert_matches!(
3875            host_agent.get_state(&client, id.clone()).await.unwrap(),
3876            resource::State {
3877                id: resource_id,
3878                status: resource::Status::Running,
3879                state: Some(ProcState {
3880                    // The proc itself should be direct addressed, with its name directly.
3881                    proc_id,
3882                    // The mesh agent should run in the same proc, under the name
3883                    // "proc_agent".
3884                    mesh_agent,
3885                    bootstrap_command: actual_bootstrap_command,
3886                    proc_status: Some(ProcStatus::Ready { started_at: _, addr: _, agent: proc_status_mesh_agent}),
3887                    ..
3888                }),
3889                ..
3890            } if id == resource_id
3891              && proc_id == expected_proc_addr
3892              && mesh_agent == ActorRef::attest(expected_proc_addr.actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME))
3893              && actual_bootstrap_command == Some(expected_bootstrap_command)
3894              && mesh_agent == proc_status_mesh_agent
3895        );
3896    }
3897
3898    #[tokio::test]
3899    async fn shutdown_local_host_stops_system_actors_before_ack() {
3900        let spawn: ProcManagerSpawnFn =
3901            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
3902        let host = Host::new(LocalProcManager::new(spawn), ChannelTransport::Unix.any())
3903            .await
3904            .unwrap();
3905        let system_proc = host.system_proc().clone();
3906        let host_agent = system_proc
3907            .spawn_with_uid(
3908                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
3909                HostAgent::new_local(host),
3910            )
3911            .unwrap();
3912        HostAgent::wait_initialized(&host_agent).await.unwrap();
3913        let cast_actor = system_proc
3914            .spawn_with_uid(
3915                Uid::singleton(Label::strip(hyperactor_cast::cast_actor::CAST_ACTOR_NAME)),
3916                hyperactor_cast::cast_actor::CastActor::default(),
3917            )
3918            .unwrap();
3919
3920        let client_proc = Proc::direct(
3921            ChannelTransport::Unix.any(),
3922            "shutdown_test_client".to_string(),
3923        )
3924        .unwrap();
3925        let client = client_proc.client("shutdown_test");
3926        let (ack, mut ack_rx) = client.open_port::<usize>();
3927        host_agent
3928            .try_post(
3929                &client,
3930                ShutdownHost {
3931                    timeout: Duration::from_secs(5),
3932                    max_in_flight: 1,
3933                    rank: Rank::new(0),
3934                    ack: ack.bind().unsplit(),
3935                },
3936            )
3937            .unwrap();
3938
3939        assert_eq!(ack_rx.recv().await.unwrap(), 0);
3940        assert!(
3941            host_agent.status().borrow().is_terminal(),
3942            "local HostAgent must stop before acknowledging shutdown"
3943        );
3944        assert!(
3945            cast_actor.status().borrow().is_terminal(),
3946            "local CastActor must stop before acknowledging shutdown"
3947        );
3948    }
3949
3950    #[async_timed_test(timeout_secs = 30)]
3951    async fn shutdown_waits_for_active_drain_worker() {
3952        let (host_agent, client, release_stop, mut drain_rx) =
3953            start_blocked_full_drain("shutdown_during_drain_client").await;
3954
3955        let (ack, mut ack_rx) = client.open_port::<usize>();
3956        host_agent
3957            .try_post(
3958                &client,
3959                ShutdownHost {
3960                    timeout: Duration::from_secs(5),
3961                    max_in_flight: 1,
3962                    rank: resource::Rank::new(0),
3963                    ack: ack.bind().unsplit(),
3964                },
3965            )
3966            .expect("shutdown request should be delivered");
3967
3968        let drain_status = drain_rx
3969            .recv()
3970            .await
3971            .expect("shutdown should supersede the active drain");
3972        assert_overlay_failed_at_rank(&drain_status, 0);
3973        assert_eq!(
3974            ack_rx
3975                .try_recv()
3976                .expect("shutdown ack port should remain open"),
3977            None,
3978            "shutdown was acknowledged before DrainComplete",
3979        );
3980
3981        release_stop.notify_one();
3982        assert_eq!(
3983            ack_rx
3984                .recv()
3985                .await
3986                .expect("shutdown should be acknowledged"),
3987            0,
3988        );
3989    }
3990
3991    #[async_timed_test(timeout_secs = 30)]
3992    async fn drain_during_pending_shutdown_waits_for_completion() {
3993        let (host_agent, client, release_stop, mut active_drain_rx) =
3994            start_blocked_full_drain("drain_during_shutdown_client").await;
3995
3996        let (ack, mut ack_rx) = client.open_port::<usize>();
3997        host_agent
3998            .try_post(
3999                &client,
4000                ShutdownHost {
4001                    timeout: Duration::from_secs(5),
4002                    max_in_flight: 1,
4003                    rank: resource::Rank::new(0),
4004                    ack: ack.bind().unsplit(),
4005                },
4006            )
4007            .expect("shutdown request should be delivered");
4008
4009        let active_drain_status = active_drain_rx
4010            .recv()
4011            .await
4012            .expect("shutdown should supersede the active drain");
4013        assert_overlay_failed_at_rank(&active_drain_status, 0);
4014
4015        let (drain_reply, mut drain_rx) = client.open_port::<crate::StatusOverlay>();
4016        host_agent
4017            .try_post(
4018                &client,
4019                DrainHost {
4020                    timeout: Duration::from_secs(20),
4021                    max_in_flight: 1,
4022                    host_mesh_id: None,
4023                    rank: resource::Rank::new(0),
4024                    reply: idle_flush_status_reply(drain_reply.bind()),
4025                },
4026            )
4027            .expect("drain request should be delivered during shutdown");
4028        assert_eq!(
4029            drain_rx
4030                .try_recv()
4031                .expect("drain reply port should remain open"),
4032            None,
4033            "drain completed before shutdown finished terminating procs",
4034        );
4035
4036        release_stop.notify_one();
4037        let drain_status = drain_rx
4038            .recv()
4039            .await
4040            .expect("drain during shutdown should complete");
4041        assert_overlay_at_rank(&drain_status, 0);
4042        assert_eq!(
4043            drain_status.runs().next().unwrap().1,
4044            resource::Status::Stopped,
4045        );
4046        assert_eq!(
4047            ack_rx
4048                .recv()
4049                .await
4050                .expect("shutdown should be acknowledged"),
4051            0,
4052        );
4053    }
4054
4055    /// Immediate status queries on one host fixture: WaitRankStatus and
4056    /// GetRankStatus on a running proc reply immediately, each positioning the
4057    /// overlay at the message rank (not the proc's creation rank); GetRankStatus
4058    /// for an unknown proc replies with an empty overlay. These share a single
4059    /// created proc to avoid standing up extra host/proc fixtures under parallel
4060    /// test execution.
4061    #[tokio::test]
4062    async fn test_wait_rank_status_already_running() {
4063        let host = Host::new(
4064            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
4065            ChannelTransport::Unix.any(),
4066        )
4067        .await
4068        .unwrap();
4069
4070        let system_proc = host.system_proc().clone();
4071        let host_agent = system_proc
4072            .spawn_with_uid(
4073                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
4074                HostAgent::new_process(host, None),
4075            )
4076            .unwrap();
4077        HostAgent::wait_initialized(&host_agent).await.unwrap();
4078
4079        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4080        let client = client_proc.client("client");
4081
4082        let id = ResourceId::instance(Label::new("proc1").unwrap());
4083        // Create at rank 0, but query at different message ranks so a handler that
4084        // read the creation rank instead of the message rank would fail.
4085        host_agent
4086            .create_or_update(
4087                &client,
4088                id.clone(),
4089                resource::Rank::new(0),
4090                ProcSpec::default(),
4091            )
4092            .await
4093            .unwrap();
4094
4095        // Proc is Running; WaitRankStatus for Running replies immediately, at its
4096        // own message rank.
4097        let wait_rank = 7;
4098        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
4099        host_agent
4100            .wait_rank_status(
4101                &client,
4102                id.clone(),
4103                resource::Rank::new(wait_rank),
4104                resource::Status::Running,
4105                idle_flush_status_reply(port.bind()),
4106            )
4107            .await
4108            .unwrap();
4109        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
4110            .await
4111            .expect("reply timed out")
4112            .expect("reply channel closed");
4113        assert_overlay_at_rank(&overlay, wait_rank);
4114
4115        // GetRankStatus for the same known proc positions at its own message rank.
4116        let get_rank = 4;
4117        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
4118        host_agent
4119            .get_rank_status(
4120                &client,
4121                id,
4122                resource::Rank::new(get_rank),
4123                idle_flush_status_reply(port.bind()),
4124            )
4125            .await
4126            .unwrap();
4127        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
4128            .await
4129            .expect("reply timed out")
4130            .expect("reply channel closed");
4131        assert_overlay_at_rank(&overlay, get_rank);
4132
4133        // GetRankStatus for an unknown proc replies with an empty overlay,
4134        // regardless of the message rank.
4135        let unknown = ResourceId::instance(Label::new("never-created").unwrap());
4136        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
4137        host_agent
4138            .get_rank_status(
4139                &client,
4140                unknown,
4141                resource::Rank::new(4),
4142                idle_flush_status_reply(port.bind()),
4143            )
4144            .await
4145            .unwrap();
4146        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
4147            .await
4148            .expect("reply timed out")
4149            .expect("reply channel closed");
4150        assert!(
4151            overlay.is_empty(),
4152            "expected empty overlay for unknown proc: {overlay:?}",
4153        );
4154    }
4155
4156    /// WaitRankStatus for Stopped, then stop the proc — reply should
4157    /// arrive only after the proc actually stops.
4158    #[tokio::test]
4159    async fn test_wait_rank_status_stop() {
4160        let host = Host::new(
4161            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
4162            ChannelTransport::Unix.any(),
4163        )
4164        .await
4165        .unwrap();
4166
4167        let system_proc = host.system_proc().clone();
4168        let host_agent = system_proc
4169            .spawn_with_uid(
4170                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
4171                HostAgent::new_process(host, None),
4172            )
4173            .unwrap();
4174        HostAgent::wait_initialized(&host_agent).await.unwrap();
4175
4176        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4177        let client = client_proc.client("client");
4178
4179        let id = ResourceId::instance(Label::new("proc1").unwrap());
4180        // Create at rank 0, defer at a different message rank; the deferred
4181        // waiter must retain the message rank through to its flush on stop.
4182        let message_rank = 5;
4183        host_agent
4184            .create_or_update(
4185                &client,
4186                id.clone(),
4187                resource::Rank::new(0),
4188                ProcSpec::default(),
4189            )
4190            .await
4191            .unwrap();
4192
4193        // Wait for Stopped — should not reply yet.
4194        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
4195        host_agent
4196            .wait_rank_status(
4197                &client,
4198                id.clone(),
4199                resource::Rank::new(message_rank),
4200                resource::Status::Stopped,
4201                idle_flush_status_reply(port.bind()),
4202            )
4203            .await
4204            .unwrap();
4205
4206        // Stop the proc.
4207        crate::resource::StopClient::stop(&host_agent, &client, id, "test".to_string())
4208            .await
4209            .unwrap();
4210
4211        // Now the reply should arrive.
4212        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
4213            .await
4214            .expect("reply timed out — proc did not reach Stopped")
4215            .expect("reply channel closed");
4216        assert_overlay_at_rank(&overlay, message_rank);
4217    }
4218
4219    #[tokio::test]
4220    async fn test_stopping_proc_is_not_an_introspection_child() {
4221        let spawn: ProcManagerSpawnFn =
4222            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4223        let host_agent = spawn_local_host_agent(spawn).await;
4224        let client_proc = Proc::direct(
4225            ChannelTransport::Unix.any(),
4226            "introspection_stop_client".to_string(),
4227        )
4228        .unwrap();
4229        let client = client_proc.client("client");
4230        let id = ResourceId::instance(Label::new("ephemeral-proc").unwrap());
4231
4232        host_agent
4233            .create_or_update(
4234                &client,
4235                id.clone(),
4236                resource::Rank::new(0),
4237                ProcSpec::default(),
4238            )
4239            .await
4240            .unwrap();
4241
4242        let (running_reply, mut running_rx) = client.open_port::<crate::StatusOverlay>();
4243        host_agent
4244            .wait_rank_status(
4245                &client,
4246                id.clone(),
4247                resource::Rank::new(0),
4248                resource::Status::Running,
4249                idle_flush_status_reply(running_reply.bind()),
4250            )
4251            .await
4252            .unwrap();
4253        running_rx.recv().await.expect("proc should start");
4254
4255        let proc_id = match host_agent.get_state(&client, id.clone()).await.unwrap() {
4256            resource::State {
4257                state: Some(ProcState { proc_id, .. }),
4258                ..
4259            } => proc_id,
4260            state => panic!("expected created proc state, got {state:?}"),
4261        };
4262        let child_ref = hyperactor::introspect::IntrospectRef::Proc(proc_id);
4263        assert!(
4264            introspection_children(&host_agent, &client)
4265                .await
4266                .contains(&child_ref),
4267            "running proc should be a host introspection child",
4268        );
4269
4270        crate::resource::StopClient::stop(
4271            &host_agent,
4272            &client,
4273            id.clone(),
4274            "command completed".to_string(),
4275        )
4276        .await
4277        .unwrap();
4278
4279        assert!(
4280            !introspection_children(&host_agent, &client)
4281                .await
4282                .contains(&child_ref),
4283            "stopping proc should not remain a live introspection child",
4284        );
4285        assert_matches!(
4286            host_agent.get_state(&client, id).await.unwrap(),
4287            resource::State {
4288                status: resource::Status::Stopping | resource::Status::Stopped,
4289                ..
4290            },
4291            "resource state should remain available while the proc stops",
4292        );
4293    }
4294
4295    #[tokio::test]
4296    async fn test_orphaned_proc_is_not_an_introspection_child() {
4297        let config = hyperactor_config::global::lock();
4298        let _orphan = config.override_key(
4299            crate::proc_agent::MESH_ORPHAN_TIMEOUT,
4300            Some(Duration::from_secs(3600)),
4301        );
4302        let spawn: ProcManagerSpawnFn =
4303            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4304        let host_agent = spawn_local_host_agent(spawn).await;
4305        let client_proc = Proc::direct(
4306            ChannelTransport::Unix.any(),
4307            "introspection_orphan_client".to_string(),
4308        )
4309        .unwrap();
4310        let client = client_proc.client("client");
4311        let id = ResourceId::instance(Label::new("orphaned-proc").unwrap());
4312
4313        host_agent
4314            .create_or_update(
4315                &client,
4316                id.clone(),
4317                resource::Rank::new(0),
4318                ProcSpec::default(),
4319            )
4320            .await
4321            .unwrap();
4322
4323        let (running_reply, mut running_rx) = client.open_port::<crate::StatusOverlay>();
4324        host_agent
4325            .wait_rank_status(
4326                &client,
4327                id.clone(),
4328                resource::Rank::new(0),
4329                resource::Status::Running,
4330                idle_flush_status_reply(running_reply.bind()),
4331            )
4332            .await
4333            .unwrap();
4334        running_rx.recv().await.expect("proc should start");
4335
4336        let (keepalive_reply, mut keepalive_rx) = client.open_port::<resource::State<ProcState>>();
4337        host_agent
4338            .try_post(
4339                &client,
4340                resource::KeepaliveGetState {
4341                    expires_after: std::time::SystemTime::UNIX_EPOCH,
4342                    get_state: resource::GetState {
4343                        id: id.clone(),
4344                        reply: keepalive_reply.bind(),
4345                    },
4346                },
4347            )
4348            .unwrap();
4349        let proc_id = match keepalive_rx.recv().await.expect("state should be returned") {
4350            resource::State {
4351                state: Some(ProcState { proc_id, .. }),
4352                ..
4353            } => proc_id,
4354            state => panic!("expected running proc state, got {state:?}"),
4355        };
4356        let child_ref = hyperactor::introspect::IntrospectRef::Proc(proc_id);
4357        assert!(
4358            introspection_children(&host_agent, &client)
4359                .await
4360                .contains(&child_ref),
4361            "proc should be visible before its keepalive is reaped",
4362        );
4363
4364        host_agent
4365            .try_post(&client, crate::proc_agent::SelfCheck::default())
4366            .unwrap();
4367
4368        tokio::time::timeout(Duration::from_secs(5), async {
4369            loop {
4370                if !introspection_children(&host_agent, &client)
4371                    .await
4372                    .contains(&child_ref)
4373                {
4374                    break;
4375                }
4376                tokio::task::yield_now().await;
4377            }
4378        })
4379        .await
4380        .expect("orphaned proc should be removed from introspection");
4381        assert_matches!(
4382            host_agent.get_state(&client, id.clone()).await.unwrap(),
4383            resource::State {
4384                status: resource::Status::Stopping | resource::Status::Stopped,
4385                ..
4386            },
4387            "orphaned proc resource state should remain available while it stops",
4388        );
4389
4390        let (expiry_reply, mut expiry_rx) = client.open_port::<bool>();
4391        host_agent
4392            .try_post(
4393                &client,
4394                IsProcExpiryCleared {
4395                    id,
4396                    reply: expiry_reply,
4397                },
4398            )
4399            .unwrap();
4400        assert!(
4401            expiry_rx
4402                .recv()
4403                .await
4404                .expect("expiry state should be returned"),
4405            "orphan reaping should clear expiry_time to avoid repeated stop requests",
4406        );
4407    }
4408
4409    /// WaitRankStatus sent before the proc is created — the waiter is stashed
4410    /// and replied to once CreateOrUpdate runs. The waiter's message rank is
4411    /// chosen before creation and survives unchanged even though the proc is
4412    /// later created at a different rank: the reply is positioned at the waiter's
4413    /// own rank, never the creation rank.
4414    #[tokio::test]
4415    async fn test_wait_rank_status_before_proc_exists() {
4416        let host = Host::new(
4417            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
4418            ChannelTransport::Unix.any(),
4419        )
4420        .await
4421        .unwrap();
4422
4423        let system_proc = host.system_proc().clone();
4424        let host_agent = system_proc
4425            .spawn_with_uid(
4426                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
4427                HostAgent::new_process(host, None),
4428            )
4429            .unwrap();
4430        HostAgent::wait_initialized(&host_agent).await.unwrap();
4431
4432        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4433        let client = client_proc.client("client");
4434
4435        let id = ResourceId::instance(Label::new("proc1").unwrap());
4436        // Waiter rank is chosen now, before the proc exists.
4437        let message_rank = 9;
4438
4439        // Wait for Running on a proc that doesn't exist yet.
4440        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
4441        host_agent
4442            .wait_rank_status(
4443                &client,
4444                id.clone(),
4445                resource::Rank::new(message_rank),
4446                resource::Status::Running,
4447                idle_flush_status_reply(port.bind()),
4448            )
4449            .await
4450            .unwrap();
4451
4452        // Now create the proc at a different rank — the stashed waiter is
4453        // flushed once the proc is Running, keeping its own message rank.
4454        host_agent
4455            .create_or_update(&client, id, resource::Rank::new(0), ProcSpec::default())
4456            .await
4457            .unwrap();
4458
4459        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
4460            .await
4461            .expect("reply timed out — waiter was not flushed after CreateOrUpdate")
4462            .expect("reply channel closed");
4463        assert_overlay_at_rank(&overlay, message_rank);
4464    }
4465
4466    /// A failed creation flushes a waiter registered before the creation attempt,
4467    /// and subsequent status queries report the cached failure immediately. Each
4468    /// reply is positioned at the rank carried by its request.
4469    #[tokio::test]
4470    async fn test_rank_status_created_error() {
4471        // A local, in-process host whose proc spawn deterministically fails, so
4472        // CreateOrUpdate caches `created: Err(..)` for the requested proc.
4473        let spawn: ProcManagerSpawnFn = Box::new(|_proc| {
4474            Box::pin(std::future::ready(Err::<ActorHandle<ProcAgent>, _>(
4475                anyhow::anyhow!("test failure"),
4476            )))
4477        });
4478        let host_agent = spawn_local_host_agent(spawn).await;
4479
4480        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4481        let client = client_proc.client("client");
4482
4483        let id = ResourceId::instance(Label::new("proc1").unwrap());
4484        // Register a waiter before the proc exists. It must be retained through
4485        // the failed creation attempt and resolved with the cached failure.
4486        let deferred_rank = 8;
4487        let (port, mut deferred_rx) = client.open_port::<crate::StatusOverlay>();
4488        host_agent
4489            .wait_rank_status(
4490                &client,
4491                id.clone(),
4492                resource::Rank::new(deferred_rank),
4493                resource::Status::Running,
4494                idle_flush_status_reply(port.bind()),
4495            )
4496            .await
4497            .unwrap();
4498
4499        // Creation is attempted at rank 0; the spawn fails and is cached. The
4500        // client call still returns Ok because status queries carry the failure.
4501        host_agent
4502            .create_or_update(
4503                &client,
4504                id.clone(),
4505                resource::Rank::new(0),
4506                ProcSpec::default(),
4507            )
4508            .await
4509            .unwrap();
4510
4511        let overlay = tokio::time::timeout(Duration::from_secs(30), deferred_rx.recv())
4512            .await
4513            .expect("pre-create waiter was not resolved after creation failed")
4514            .expect("reply channel closed");
4515        assert_overlay_failed_at_rank(&overlay, deferred_rank);
4516
4517        // WaitRankStatus for a known-but-failed proc replies immediately (no
4518        // deferral) with a Failed overlay at the message rank.
4519        let wait_rank = 6;
4520        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
4521        host_agent
4522            .wait_rank_status(
4523                &client,
4524                id.clone(),
4525                resource::Rank::new(wait_rank),
4526                resource::Status::Running,
4527                idle_flush_status_reply(port.bind()),
4528            )
4529            .await
4530            .unwrap();
4531        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
4532            .await
4533            .expect("reply timed out")
4534            .expect("reply channel closed");
4535        assert_overlay_failed_at_rank(&overlay, wait_rank);
4536
4537        // GetRankStatus for the same failed proc reports Failed at its own
4538        // message rank.
4539        let get_rank = 3;
4540        let (port, mut rx) = client.open_port::<crate::StatusOverlay>();
4541        host_agent
4542            .get_rank_status(
4543                &client,
4544                id,
4545                resource::Rank::new(get_rank),
4546                idle_flush_status_reply(port.bind()),
4547            )
4548            .await
4549            .unwrap();
4550        let overlay = tokio::time::timeout(Duration::from_secs(30), rx.recv())
4551            .await
4552            .expect("reply timed out")
4553            .expect("reply channel closed");
4554        assert_overlay_failed_at_rank(&overlay, get_rank);
4555    }
4556
4557    #[async_timed_test(timeout_secs = 30)]
4558    async fn duplicate_selective_drains_each_receive_completion() {
4559        let (spawn_started_tx, mut spawn_started_rx) = tokio::sync::watch::channel(false);
4560        let (release_spawn_tx, release_spawn_rx) = tokio::sync::watch::channel(false);
4561        let spawn: ProcManagerSpawnFn = Box::new(move |proc| {
4562            let spawn_started_tx = spawn_started_tx.clone();
4563            let mut release_spawn_rx = release_spawn_rx.clone();
4564            Box::pin(async move {
4565                spawn_started_tx
4566                    .send(true)
4567                    .expect("spawn-start receiver remains open");
4568                release_spawn_rx
4569                    .wait_for(|release| *release)
4570                    .await
4571                    .expect("spawn-release sender remains open");
4572                ProcAgent::boot_v1(proc, None)
4573            })
4574        });
4575        let host_agent = spawn_local_host_agent(spawn).await;
4576
4577        let client_proc =
4578            Proc::direct(ChannelTransport::Unix.any(), "drain_client".to_string()).unwrap();
4579        let client = client_proc.client("drain_client");
4580        let host_mesh_id = HostMeshId::instance(Label::new("mesh-a").unwrap());
4581        let proc_id = ResourceId::instance(Label::new("proc-a").unwrap());
4582        let agent_ref: ActorRef<HostAgent> = host_agent.bind();
4583
4584        agent_ref.post(
4585            &client,
4586            resource::CreateOrUpdate {
4587                id: proc_id,
4588                rank: resource::Rank::new(0),
4589                spec: ProcSpec {
4590                    host_mesh_id: Some(host_mesh_id.clone()),
4591                    ..Default::default()
4592                },
4593            },
4594        );
4595        spawn_started_rx
4596            .wait_for(|started| *started)
4597            .await
4598            .expect("spawn-start sender remains open");
4599
4600        let (first_reply, mut first_reply_rx) = client.open_port::<crate::StatusOverlay>();
4601        let (second_reply, mut second_reply_rx) = client.open_port::<crate::StatusOverlay>();
4602        agent_ref.post(
4603            &client,
4604            DrainHost {
4605                timeout: Duration::from_secs(5),
4606                max_in_flight: 16,
4607                host_mesh_id: Some(host_mesh_id.clone()),
4608                rank: resource::Rank::new(3),
4609                reply: idle_flush_status_reply(first_reply.bind()),
4610            },
4611        );
4612        agent_ref.post(
4613            &client,
4614            DrainHost {
4615                timeout: Duration::from_secs(5),
4616                max_in_flight: 16,
4617                host_mesh_id: Some(host_mesh_id),
4618                rank: resource::Rank::new(7),
4619                reply: idle_flush_status_reply(second_reply.bind()),
4620            },
4621        );
4622
4623        release_spawn_tx
4624            .send(true)
4625            .expect("spawn-release receivers remain open");
4626
4627        let first = first_reply_rx.recv().await.expect("first drain reply");
4628        assert_overlay_at_rank(&first, 3);
4629        assert_eq!(
4630            first.runs().next().unwrap().1,
4631            resource::Status::Stopped,
4632            "first drain should complete successfully",
4633        );
4634        let second = second_reply_rx.recv().await.expect("second drain reply");
4635        assert_overlay_at_rank(&second, 7);
4636        assert_eq!(
4637            second.runs().next().unwrap().1,
4638            resource::Status::Stopped,
4639            "second drain should complete successfully",
4640        );
4641    }
4642
4643    /// DrainHost with a host_mesh_id filter only stops procs
4644    /// belonging to that mesh; procs from other meshes are unaffected.
4645    #[async_timed_test(timeout_secs = 30)]
4646    async fn test_drain_scoped_to_host_mesh_id() {
4647        let host = Host::new(
4648            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
4649            ChannelTransport::Unix.any(),
4650        )
4651        .await
4652        .unwrap();
4653
4654        let system_proc = host.system_proc().clone();
4655        let host_agent = system_proc
4656            .spawn_with_uid(
4657                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
4658                HostAgent::new_process(host, None),
4659            )
4660            .unwrap();
4661        HostAgent::wait_initialized(&host_agent).await.unwrap();
4662
4663        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4664        let client = client_proc.client("client");
4665
4666        let mesh_a = HostMeshId::instance(Label::new("mesh-a").unwrap());
4667        let mesh_b = HostMeshId::instance(Label::new("mesh-b").unwrap());
4668        let proc_a_id = ResourceId::instance(Label::new("proc-a").unwrap());
4669        let proc_b_id = ResourceId::instance(Label::new("proc-b").unwrap());
4670
4671        // Create proc_a belonging to mesh_a.
4672        let spec_a = ProcSpec {
4673            host_mesh_id: Some(mesh_a.clone()),
4674            ..Default::default()
4675        };
4676        host_agent
4677            .create_or_update(&client, proc_a_id.clone(), resource::Rank::new(0), spec_a)
4678            .await
4679            .unwrap();
4680
4681        // Create proc_b belonging to mesh_b.
4682        let spec_b = ProcSpec {
4683            host_mesh_id: Some(mesh_b.clone()),
4684            ..Default::default()
4685        };
4686        host_agent
4687            .create_or_update(&client, proc_b_id.clone(), resource::Rank::new(1), spec_b)
4688            .await
4689            .unwrap();
4690
4691        let (running_reply, mut running_rx) = client.open_port::<crate::StatusOverlay>();
4692        for (id, rank) in [(&proc_a_id, 0), (&proc_b_id, 1)] {
4693            host_agent
4694                .wait_rank_status(
4695                    &client,
4696                    id.clone(),
4697                    resource::Rank::new(rank),
4698                    resource::Status::Running,
4699                    idle_flush_status_reply(running_reply.bind()),
4700                )
4701                .await
4702                .unwrap();
4703        }
4704        running_rx.recv().await.unwrap();
4705        running_rx.recv().await.unwrap();
4706
4707        // Both should be Running.
4708        assert_matches!(
4709            host_agent
4710                .get_state(&client, proc_a_id.clone())
4711                .await
4712                .unwrap(),
4713            resource::State {
4714                status: resource::Status::Running,
4715                ..
4716            }
4717        );
4718        assert_matches!(
4719            host_agent
4720                .get_state(&client, proc_b_id.clone())
4721                .await
4722                .unwrap(),
4723            resource::State {
4724                status: resource::Status::Running,
4725                ..
4726            }
4727        );
4728
4729        // Drain only mesh_a.
4730        let (drain_reply, mut drain_rx) = client.open_port::<crate::StatusOverlay>();
4731        host_agent
4732            .drain_host(
4733                &client,
4734                Duration::from_secs(5),
4735                16,
4736                Some(mesh_a.clone()),
4737                resource::Rank::new(0),
4738                idle_flush_status_reply(drain_reply.bind()),
4739            )
4740            .await
4741            .unwrap();
4742        // Wait for the host to report drained before asserting.
4743        drain_rx.recv().await.unwrap();
4744
4745        // proc_a should be gone (removed from created).
4746        assert_matches!(
4747            host_agent
4748                .get_state(&client, proc_a_id.clone())
4749                .await
4750                .unwrap(),
4751            resource::State {
4752                status: resource::Status::NotExist,
4753                ..
4754            }
4755        );
4756
4757        // proc_b should still be Running.
4758        assert_matches!(
4759            host_agent
4760                .get_state(&client, proc_b_id.clone())
4761                .await
4762                .unwrap(),
4763            resource::State {
4764                status: resource::Status::Running,
4765                ..
4766            }
4767        );
4768    }
4769
4770    /// DrainHost with host_mesh_id=None drains all procs regardless
4771    /// of their mesh affiliation (backwards compatibility).
4772    #[tokio::test]
4773    async fn test_drain_none_drains_all() {
4774        let host = Host::new(
4775            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
4776            ChannelTransport::Unix.any(),
4777        )
4778        .await
4779        .unwrap();
4780
4781        let system_proc = host.system_proc().clone();
4782        let host_agent = system_proc
4783            .spawn_with_uid(
4784                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
4785                HostAgent::new_process(host, None),
4786            )
4787            .unwrap();
4788        HostAgent::wait_initialized(&host_agent).await.unwrap();
4789
4790        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4791        let client = client_proc.client("client");
4792
4793        let mesh_a = HostMeshId::instance(Label::new("mesh-a").unwrap());
4794        let mesh_b = HostMeshId::instance(Label::new("mesh-b").unwrap());
4795        let proc_a_id = ResourceId::instance(Label::new("proc-a").unwrap());
4796        let proc_b_id = ResourceId::instance(Label::new("proc-b").unwrap());
4797
4798        let spec_a = ProcSpec {
4799            host_mesh_id: Some(mesh_a),
4800            ..Default::default()
4801        };
4802        host_agent
4803            .create_or_update(&client, proc_a_id.clone(), resource::Rank::new(0), spec_a)
4804            .await
4805            .unwrap();
4806
4807        let spec_b = ProcSpec {
4808            host_mesh_id: Some(mesh_b),
4809            ..Default::default()
4810        };
4811        host_agent
4812            .create_or_update(&client, proc_b_id.clone(), resource::Rank::new(1), spec_b)
4813            .await
4814            .unwrap();
4815
4816        // Drain all (no filter).
4817        let (drain_reply, mut drain_rx) = client.open_port::<crate::StatusOverlay>();
4818        host_agent
4819            .drain_host(
4820                &client,
4821                Duration::from_secs(5),
4822                16,
4823                None,
4824                resource::Rank::new(0),
4825                idle_flush_status_reply(drain_reply.bind()),
4826            )
4827            .await
4828            .unwrap();
4829        // Wait for the host to report drained before asserting.
4830        drain_rx.recv().await.unwrap();
4831
4832        // Both should be gone.
4833        assert_matches!(
4834            host_agent.get_state(&client, proc_a_id).await.unwrap(),
4835            resource::State {
4836                status: resource::Status::NotExist,
4837                ..
4838            }
4839        );
4840        assert_matches!(
4841            host_agent.get_state(&client, proc_b_id).await.unwrap(),
4842            resource::State {
4843                status: resource::Status::NotExist,
4844                ..
4845            }
4846        );
4847    }
4848
4849    // PD-6/PD-8 regression: QueryChild(Proc) on the service proc
4850    // returns non-zero queue stats after the host_agent has handled
4851    // messages. Guards against the bug where the HostAgent closure
4852    // defaulted queue stats to zero because it predated Proc-level
4853    // queue accessors.
4854    #[tokio::test]
4855    async fn test_service_proc_query_child_has_queue_stats() {
4856        use hyperactor::introspect::IntrospectMessage;
4857        use hyperactor::introspect::IntrospectResult;
4858
4859        let host = Host::new(
4860            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
4861            ChannelTransport::Unix.any(),
4862        )
4863        .await
4864        .unwrap();
4865
4866        let system_proc = host.system_proc().clone();
4867        let host_agent = system_proc
4868            .spawn_with_uid(
4869                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
4870                HostAgent::new_process(host, None),
4871            )
4872            .unwrap();
4873        HostAgent::wait_initialized(&host_agent).await.unwrap();
4874
4875        let client_proc =
4876            Proc::direct(ChannelTransport::Unix.any(), "qd_client".to_string()).unwrap();
4877        let client = client_proc.client("client");
4878
4879        // Spawn a proc so the host_agent processes at least one
4880        // CreateOrUpdate message, which goes through the work queue.
4881        let name = ResourceId::instance(Label::new("qd_test_proc").unwrap());
4882        host_agent
4883            .create_or_update(
4884                &client,
4885                name.clone(),
4886                resource::Rank::new(0),
4887                ProcSpec::default(),
4888            )
4889            .await
4890            .unwrap();
4891
4892        // The host_agent has now processed messages on the service
4893        // proc. Query the service proc's introspection.
4894        let agent_ref = system_proc
4895            .proc_addr()
4896            .actor_addr(HOST_MESH_AGENT_ACTOR_NAME);
4897        let agent_id: ActorAddr = agent_ref;
4898        let port = agent_id.introspect_port();
4899
4900        // Poll until we see non-zero watermark (evidence of queue
4901        // traffic since startup).
4902        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
4903        loop {
4904            let (reply_port, reply_rx) = client.open_once_port::<IntrospectResult>();
4905            port.post(
4906                &client,
4907                IntrospectMessage::QueryChild {
4908                    child_ref: Addr::Proc(system_proc.proc_addr().clone()),
4909                    reply: reply_port.bind(),
4910                },
4911            );
4912            let payload = tokio::time::timeout(std::time::Duration::from_secs(5), reply_rx.recv())
4913                .await
4914                .expect("QueryChild timed out")
4915                .expect("reply channel closed");
4916
4917            let attrs: hyperactor_config::Attrs =
4918                serde_json::from_str(&payload.attrs).expect("valid attrs JSON");
4919
4920            let hwm = attrs
4921                .get(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK)
4922                .copied()
4923                .unwrap_or(0);
4924            let last_nonzero: Option<u64> = attrs
4925                .get(crate::introspect::LAST_NONZERO_QUEUE_DEPTH_AGE_MS)
4926                .copied()
4927                .flatten();
4928
4929            if hwm > 0 {
4930                // The service proc's watermark should reflect
4931                // the messages the host_agent processed.
4932                assert!(
4933                    last_nonzero.is_some(),
4934                    "last-nonzero should be Some when watermark is {hwm}",
4935                );
4936                break;
4937            }
4938
4939            assert!(
4940                tokio::time::Instant::now() < deadline,
4941                "timed out waiting for service proc watermark > 0",
4942            );
4943            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4944        }
4945    }
4946
4947    /// A single `SpawnProcs` message at host rank 0 fans out into
4948    /// `num_per_host` procs, each created under the id derived from
4949    /// `proc_name(&proc_mesh_id, rank)`. All of them should come up Running.
4950    #[async_timed_test(timeout_secs = 30)]
4951    async fn test_spawn_procs_many_per_host() {
4952        let host = Host::new(
4953            BootstrapProcManager::new(BootstrapCommand::test()).unwrap(),
4954            ChannelTransport::Unix.any(),
4955        )
4956        .await
4957        .unwrap();
4958
4959        let system_proc = host.system_proc().clone();
4960        let host_agent = system_proc
4961            .spawn_with_uid(
4962                Uid::singleton(Label::new(HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
4963                HostAgent::new(HostAgentMode::Process {
4964                    host,
4965                    shutdown_tx: None,
4966                }),
4967            )
4968            .unwrap();
4969
4970        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4971        let client = client_proc.client("client");
4972
4973        let proc_mesh_id = ProcMeshId::singleton(Label::new("spawn-many").unwrap());
4974        let num_per_host = 4;
4975        let (spawn_status_reply, mut spawn_status_rx) = client.open_port::<crate::StatusOverlay>();
4976
4977        // Send the command and wait messages in the same order used by
4978        // HostMesh::spawn.
4979        let agent_ref: ActorRef<HostAgent> = host_agent.bind();
4980        agent_ref.post(
4981            &client,
4982            SpawnProcs {
4983                rank: resource::Rank::new(0),
4984                proc_mesh_id: proc_mesh_id.clone(),
4985                num_per_host,
4986                client_config_override: Attrs::new(),
4987                host_mesh_id: None,
4988                default_bootstrap_command: None,
4989                proc_bind: None,
4990                bootstrap_commands: None,
4991            },
4992        );
4993        agent_ref.post(
4994            &client,
4995            WaitProcs {
4996                rank: resource::Rank::new(0),
4997                proc_mesh_id: proc_mesh_id.clone(),
4998                num_per_host,
4999                status_reply: idle_flush_status_reply(spawn_status_reply.bind()),
5000            },
5001        );
5002
5003        let mut reported = vec![false; num_per_host];
5004        for _ in 0..num_per_host {
5005            let overlay = spawn_status_rx
5006                .recv()
5007                .await
5008                .expect("WaitProcs status reply channel closed");
5009            assert_eq!(overlay.len(), 1, "expected one proc status per reply");
5010            let (range, status) = overlay.runs().next().expect("status overlay has a run");
5011            assert_eq!(range.end, range.start + 1);
5012            assert_eq!(status, &resource::Status::Running);
5013            reported[range.start] = true;
5014        }
5015        assert!(reported.into_iter().all(|reported| reported));
5016    }
5017}