Skip to main content

hyperactor_mesh/
host.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//! Host lifecycle management for procs running on one machine.
10//!
11//! A [`Host`] owns one [`Gateway`] and one [`ProcManager`]. The manager makes
12//! procs real, either by spawning them in-process for tests or by launching
13//! separate OS processes. The gateway owns connectivity: it serves the backend
14//! and frontend endpoints, multiplexes inbound traffic to in-process procs,
15//! and routes spawned child proc gateways through peer routes.
16//!
17//! Use [`Host::new`] or [`Host::new_with_gateway`] to construct a host, start
18//! its backend and frontend accept loops, then [`Host::spawn`] to create child procs.
19//! Spawned children are returned as [`ProcAddr`] values whose location is
20//! advertised through this host's gateway.
21//!
22//! ## Gateway topology
23//!
24//! A host gateway exposes a frontend endpoint `*` and serves a backend
25//! endpoint `#` for child proc gateways. Each spawned child has its own
26//! gateway endpoint (`#1`, `#2`, ...), uses `#` as its forwarder, and is
27//! advertised as `Via(child_uid, host_location)`. The `host_location` is the
28//! host gateway's advertised location: the newest active frontend serve or
29//! `serve_via` session.
30//!
31//! The host gateway keeps one peer route per child uid. When it receives a
32//! message for `Via(child_uid, host_location)`, it peels the child hop and
33//! forwards the envelope to the child's gateway. In-process service and local
34//! procs are delivered through the gateway's local proc table.
35//!
36//! ```text
37//! inbound to host_location
38//! (*, or Via(...) when attached)
39//!          |
40//!          v
41//!   +---------------+     peer child_uid_1 -> #1     +----------------------+
42//!   | Host Gateway  |------------------------------->| child gateway/proc 1 |
43//!   | local procs:  |                                | addr #1              |
44//!   | service/local |<-------------------------------| forwarder -> #       |
45//!   +---------------+          host backend #         +----------------------+
46//!          |
47//!          | peer child_uid_2 -> #2
48//!          v
49//!   +----------------------+
50//!   | child gateway/proc 2 |
51//!   | addr #2              |
52//!   | forwarder -> #       |
53//!   +----------------------+
54//! ```
55//!
56//! The built-in service and local procs are created during construction and
57//! run in-process on the host gateway. The local proc starts with no actors; a
58//! `ProcAgent` and root client actor are added lazily when
59//! `HostMeshAgent::handle(GetLocalProc)` first asks for it.
60
61use std::collections::HashMap;
62use std::fmt;
63use std::marker::PhantomData;
64use std::str::FromStr;
65use std::sync::Arc;
66use std::time::Duration;
67
68use async_trait::async_trait;
69use futures::Future;
70use futures::StreamExt;
71use futures::stream;
72use hyperactor::Actor;
73use hyperactor::ActorAddr;
74use hyperactor::ActorHandle;
75use hyperactor::ActorRef;
76use hyperactor::Gateway;
77use hyperactor::Proc;
78use hyperactor::ProcAddr;
79use hyperactor::actor::Binds;
80use hyperactor::actor::Referable;
81use hyperactor::channel;
82use hyperactor::channel::ChannelAddr;
83use hyperactor::channel::ChannelError;
84use hyperactor::channel::ChannelTransport;
85use hyperactor::channel::Rx;
86use hyperactor::channel::ServerError;
87use hyperactor::channel::Tx;
88use hyperactor::context;
89use hyperactor::gateway::GatewayServeHandle;
90use hyperactor::gateway::PeerAttachGuard;
91use hyperactor::mailbox::IntoBoxedMailboxSender as _;
92use hyperactor::mailbox::MailboxClient;
93use hyperactor::mailbox::MailboxServer;
94/// Name of the local client proc on a host.
95///
96/// See LP-1 (lazy activation) in module doc.
97///
98/// In pure-Rust programs (e.g. sieve, dining_philosophers)
99/// `GetLocalProc` is never sent, so the local proc remains empty
100/// throughout the program's lifetime. Code that inspects the local
101/// proc's actors must not assume they exist.
102pub use hyperactor::proc::LEGACY_LOCAL_PROC_NAME as LOCAL_PROC_NAME;
103/// Name of the system service proc on a host.
104///
105/// Hosts the admin actor layer: HostMeshAgent, MeshAdminAgent, and bridge.
106pub use hyperactor::proc::LEGACY_SERVICE_PROC_NAME as SERVICE_PROC_NAME;
107use tokio::process::Child;
108use tokio::process::Command;
109use tokio::sync::Mutex;
110
111use crate::mesh_id::ResourceId;
112
113/// The type of error produced by host operations.
114#[derive(Debug, thiserror::Error)]
115pub enum HostError {
116    /// A channel error occurred during a host operation.
117    #[error(transparent)]
118    ChannelError(#[from] ChannelError),
119
120    /// A duplex server error occurred during a host operation.
121    #[error(transparent)]
122    ServerError(#[from] ServerError),
123
124    /// The named proc already exists and cannot be spawned.
125    #[error("proc '{0}' already exists")]
126    ProcExists(String),
127
128    /// Failures occuring while spawning a subprocess.
129    #[error("proc '{0}' (command: {1}) failed to spawn process: {2}")]
130    ProcessSpawnFailure(ProcAddr, String, #[source] std::io::Error),
131
132    /// Failures occuring while configuring a subprocess.
133    #[error("proc '{0}' failed to configure process: {1}")]
134    ProcessConfigurationFailure(ProcAddr, #[source] anyhow::Error),
135
136    /// Failures occuring while spawning a management actor in a proc.
137    #[error("failed to spawn agent on proc '{0}': {1}")]
138    AgentSpawnFailure(ProcAddr, #[source] anyhow::Error),
139
140    /// An input parameter was missing.
141    #[error("parameter '{0}' missing: {1}")]
142    MissingParameter(String, std::env::VarError),
143
144    /// An input parameter was invalid.
145    #[error("parameter '{0}' invalid: {1}")]
146    InvalidParameter(String, anyhow::Error),
147
148    /// Attaching the gateway to a remote `serve_via` session failed.
149    #[error("failed to attach gateway via session: {0}")]
150    ViaAttachFailure(#[source] anyhow::Error),
151}
152
153/// Lifecycle manager for the procs on one machine.
154///
155/// The host delegates all connectivity to its [`Gateway`]. It creates
156/// built-in service/local procs, asks its [`ProcManager`] to spawn children,
157/// and keeps the gateway peer registrations for those children alive.
158pub struct Host<M> {
159    /// Peer guards for spawned child procs, keyed by name. The stored
160    /// [`PeerAttachGuard`] keeps the gateway peer route for the child
161    /// alive; dropping it removes the entry (used by
162    /// [`Host::terminate_children`] to free slots).
163    procs: HashMap<String, PeerAttachGuard>,
164    frontend_addr: ChannelAddr,
165    backend_addr: ChannelAddr,
166    /// Connectivity for every proc owned by this host.
167    ///
168    /// The built-in procs share the gateway in-process. Spawned children have
169    /// their own gateways and are registered here with
170    /// [`Gateway::attach_peer`].
171    gateway: Gateway,
172    frontend_handle: Option<GatewayServeHandle>,
173    backend_handle: Option<GatewayServeHandle>,
174    /// Duplex `serve_via` session to a remote gateway, present when this
175    /// host was bootstrapped out-of-cluster. Kept alive for the host's
176    /// lifetime so the cluster route and outbound forwarder persist.
177    via_handle: Option<GatewayServeHandle>,
178    manager: M,
179    service_proc: Proc,
180    local_proc: Proc,
181}
182
183impl<M: ProcManager> Host<M> {
184    /// Construct a host and start its gateway frontend server on `addr`.
185    pub async fn new(manager: M, addr: ChannelAddr) -> Result<Self, HostError> {
186        Self::new_with_default(manager, addr, None).await
187    }
188
189    /// Like [`new`], but optionally uses an already-bound listener.
190    ///
191    /// When `listener` is `Some`, it is used as the frontend listening socket
192    /// instead of binding a new one.
193    pub async fn new_with_default(
194        manager: M,
195        addr: ChannelAddr,
196        listener: Option<std::net::TcpListener>,
197    ) -> Result<Self, HostError> {
198        // Default to the process-wide global gateway so procs on this
199        // host share one routing table with the rest of the process.
200        // Callers that need a different gateway (e.g. via attach) build
201        // it externally and pass it to [`new_with_gateway`].
202        Self::new_with_gateway(manager, addr, listener, Gateway::global().clone(), None).await
203    }
204
205    /// Like [`new_with_default`], but uses a caller-provided
206    /// [`Gateway`] instead of creating one internally.
207    ///
208    /// Serving the backend and frontend endpoints, choosing the frontend
209    /// transport, and adopting the frontend address as the gateway's
210    /// advertised location are all owned by the gateway. The host operates on
211    /// a vanilla gateway: it never inspects the transport nor rewrites the
212    /// gateway's location. Adopting the bound frontend address makes the
213    /// legacy pseudo-singleton proc ids (system, local) carry it so remote
214    /// hosts can reach them by name.
215    ///
216    /// When `via` is `Some`, the gateway attaches to that remote duplex
217    /// address with [`Gateway::serve_via`] *after* the local
218    /// frontend/backend serves but *before* minting the built-in procs,
219    /// so the via session is the newest active serve. That ordering
220    /// makes every ref minted on this host advertise the routable
221    /// `Via` location rather than the bare local frontend, which is
222    /// what lets an out-of-cluster client receive return traffic over
223    /// the duplex.
224    #[hyperactor::instrument(fields(addr=addr.to_string()))]
225    pub async fn new_with_gateway(
226        manager: M,
227        addr: ChannelAddr,
228        listener: Option<std::net::TcpListener>,
229        gateway: Gateway,
230        via: Option<ChannelAddr>,
231    ) -> Result<Self, HostError> {
232        let mut backend_handle = Gateway::serve(&gateway, ChannelAddr::any(manager.transport()))?;
233        let backend_addr = gateway.default_location().addr().clone();
234
235        // Serve the frontend. Kernel-socket (net) transports use a muxed
236        // listener so simplex clients and duplex attach clients share one
237        // address; the gateway owns both the simplex and (`AttachWire`)
238        // duplex accept paths, so there is a single attach protocol whether
239        // a frontend is muxed or a plain duplex endpoint. `serve_mux`
240        // requires a net transport, so we branch on `is_net()` rather than
241        // `supports_duplex()` (the in-process `Local` transport supports
242        // duplex but is not a kernel socket). Both paths register the bound
243        // address as the gateway's active serve location.
244        let frontend_result: Result<GatewayServeHandle, ChannelError> = if addr.transport().is_net()
245        {
246            gateway.serve_mux_with_listener(addr, listener)
247        } else {
248            gateway.serve_with_listener(addr, listener)
249        };
250        let mut frontend_handle = match frontend_result {
251            Ok(handle) => handle,
252            Err(error) => {
253                backend_handle.stop("host setup failed");
254                if let Err(join_error) = backend_handle.join().await {
255                    tracing::warn!(
256                        error = %join_error,
257                        "failed to join backend server after host setup error"
258                    );
259                }
260                return Err(error.into());
261            }
262        };
263        let frontend_addr = gateway.default_location().addr().clone();
264
265        // Attach to the remote gateway after the local serves are live
266        // but before any proc or actor ref is minted below. `serve_via`
267        // must be the newest active serve so it supplies the gateway's
268        // `default_location`; otherwise the local frontend serve above
269        // would win, and refs would advertise a bare, cluster-
270        // unreachable address — the out-of-cluster return-path bug.
271        let via_handle = match via {
272            Some(via_addr) => match gateway.serve_via(via_addr).await {
273                Ok(handle) => Some(handle),
274                Err(error) => {
275                    frontend_handle.stop("host setup failed");
276                    if let Err(join_error) = frontend_handle.join().await {
277                        tracing::warn!(
278                            error = %join_error,
279                            "failed to join frontend server after via attach error"
280                        );
281                    }
282                    backend_handle.stop("host setup failed");
283                    if let Err(join_error) = backend_handle.join().await {
284                        tracing::warn!(
285                            error = %join_error,
286                            "failed to join backend server after via attach error"
287                        );
288                    }
289                    return Err(HostError::ViaAttachFailure(error));
290                }
291            },
292            None => None,
293        };
294
295        // Set up the system proc and the local client proc after the
296        // gateway servers are live. The HostAgent is published only
297        // after it binds its handler, so the brief unroutable window is
298        // before normal clients can discover this host.
299        let service_proc = Proc::legacy_service_pseudo_singleton_on_gateway(gateway.clone());
300        let local_proc = Proc::legacy_local_pseudo_singleton_on_gateway(gateway.clone());
301        let service_proc_id = service_proc.proc_addr().clone();
302        let local_proc_id = local_proc.proc_addr().clone();
303
304        tracing::info!(
305            frontend_addr = frontend_addr.to_string(),
306            backend_addr = backend_addr.to_string(),
307            service_proc_id = service_proc_id.to_string(),
308            local_proc_id = local_proc_id.to_string(),
309            "serving host"
310        );
311
312        Ok(Host {
313            procs: HashMap::new(),
314            frontend_addr,
315            backend_addr,
316            gateway,
317            frontend_handle: Some(frontend_handle),
318            backend_handle: Some(backend_handle),
319            via_handle,
320            manager,
321            service_proc,
322            local_proc,
323        })
324    }
325
326    /// The underlying proc manager.
327    pub fn manager(&self) -> &M {
328        &self.manager
329    }
330
331    /// The address which accepts messages destined for this host.
332    pub fn addr(&self) -> &ChannelAddr {
333        &self.frontend_addr
334    }
335
336    /// The system proc associated with this host.
337    /// This is used to run host-level system services like host managers.
338    pub fn system_proc(&self) -> &Proc {
339        &self.service_proc
340    }
341
342    /// The local proc associated with this host (`LOCAL_PROC_NAME`).
343    ///
344    /// Starts with zero actors; see invariant LP-1 on
345    /// [`LOCAL_PROC_NAME`] for activation semantics.
346    pub fn local_proc(&self) -> &Proc {
347        &self.local_proc
348    }
349
350    /// Spawn a child proc with the given `name`.
351    ///
352    /// On success, the proc is ready and reachable through the returned
353    /// [`ProcAddr`]. The proc id is derived from `name`; its location is
354    /// advertised through this host's frontend gateway using a `Via(child_uid,
355    /// host_location)` source route.
356    pub async fn spawn(
357        &mut self,
358        name: String,
359        config: M::Config,
360    ) -> Result<(ProcAddr, ActorRef<ManagerAgent<M>>), HostError> {
361        if self.procs.contains_key(&name) {
362            return Err(HostError::ProcExists(name));
363        }
364
365        // Advertise the child with a `Via(child_uid, host_location)`
366        // location so peers source-route through this host: the outer
367        // hop matches the peer entry installed below, and gets peeled
368        // to deliver to the child's gateway. The host location comes
369        // from the gateway's active routing state, so a later
370        // `serve`/`serve_via` controls newly spawned child refs.
371        let resource_id = ResourceId::from_name(&name);
372        let proc_uid = resource_id.uid().clone();
373        let host_location = self.gateway.default_location();
374        let location = host_location.with_via(proc_uid.clone());
375        let proc_id = resource_id.proc_addr(location);
376        let handle = self
377            .manager
378            .spawn(proc_id.clone(), self.backend_addr.clone(), config)
379            .await?;
380
381        // Await readiness (config-driven; 0s disables timeout).
382        let to: Duration =
383            hyperactor_config::global::get(hyperactor::config::HOST_SPAWN_READY_TIMEOUT);
384        let ready = if to == Duration::from_secs(0) {
385            ReadyProc::ensure(&handle).await
386        } else {
387            match tokio::time::timeout(to, ReadyProc::ensure(&handle)).await {
388                Ok(result) => result,
389                Err(_elapsed) => Err(ReadyProcError::Timeout),
390            }
391        }
392        .map_err(|e| {
393            HostError::ProcessConfigurationFailure(proc_id.clone(), anyhow::anyhow!("{e:?}"))
394        })?;
395
396        let child_sender = MailboxClient::dial(ready.addr().clone()).map_err(|e| {
397            HostError::ProcessConfigurationFailure(
398                proc_id.clone(),
399                anyhow::anyhow!("failed to dial spawned proc at {}: {}", ready.addr(), e),
400            )
401        })?;
402        // The proc id derives from `name`, and we rejected a duplicate
403        // `name` above, so this peer uid is unique.
404        let guard = self
405            .gateway
406            .attach_peer(proc_uid, child_sender.into_boxed())
407            .expect("spawned proc uid is unique: duplicate name rejected above");
408        self.procs.insert(name.clone(), guard);
409
410        Ok((proc_id, ready.agent_ref().clone()))
411    }
412
413    /// The host's [`Gateway`]. All incoming traffic addressed to this
414    /// host's procs is routed through the gateway: in-process procs
415    /// via the gateway's local delivery path, and spawned child
416    /// proc gateways through peer routes registered with
417    /// [`Gateway::attach_peer`].
418    pub fn gateway(&self) -> &Gateway {
419        &self.gateway
420    }
421
422    /// Take ownership of the frontend server handle.
423    ///
424    /// This is only used by bootstrap shutdown: the host is dropped after
425    /// taking the handle, and the bootstrap join path stops and drains the
426    /// frontend server explicitly.
427    pub(crate) fn take_frontend_handle(&mut self) -> Option<GatewayServeHandle> {
428        self.frontend_handle.take()
429    }
430}
431
432impl<M> Drop for Host<M> {
433    fn drop(&mut self) {
434        if let Some(mut handle) = self.frontend_handle.take() {
435            handle.stop("host dropped");
436        }
437        if let Some(mut handle) = self.backend_handle.take() {
438            handle.stop("host dropped");
439        }
440        if let Some(mut handle) = self.via_handle.take() {
441            handle.stop("host dropped");
442        }
443    }
444}
445
446/// Error returned by [`ProcHandle::ready`].
447#[derive(Debug, Clone)]
448pub enum ReadyError<TerminalStatus> {
449    /// The proc reached a terminal state before becoming Ready.
450    Terminal(TerminalStatus),
451    /// Implementation lost its status channel / cannot observe state.
452    ChannelClosed,
453}
454
455/// Error returned by [`ready_proc`].
456#[derive(Debug, Clone)]
457pub enum ReadyProcError<TerminalStatus> {
458    /// Timed out waiting for ready.
459    Timeout,
460    /// The underlying `ready()` call failed.
461    Ready(ReadyError<TerminalStatus>),
462    /// The handle's `addr()` returned `None` after `ready()` succeeded.
463    MissingAddr,
464    /// The handle's `agent_ref()` returned `None` after `ready()`
465    /// succeeded.
466    MissingAgentRef,
467}
468
469impl<T> From<ReadyError<T>> for ReadyProcError<T> {
470    fn from(e: ReadyError<T>) -> Self {
471        ReadyProcError::Ready(e)
472    }
473}
474
475/// Error returned by [`ProcHandle::wait`].
476#[derive(Debug, Clone)]
477pub enum WaitError {
478    /// Implementation lost its status channel / cannot observe state.
479    ChannelClosed,
480}
481
482/// Error returned by [`ProcHandle::terminate`] and
483/// [`ProcHandle::kill`].
484///
485/// - `Unsupported`: the manager cannot perform the requested proc
486///   signaling (e.g., local/in-process manager that doesn't emulate
487///   kill).
488/// - `AlreadyTerminated(term)`: the proc was already terminal; `term`
489///   is the same value `wait()` would return.
490/// - `ChannelClosed`: the manager lost its lifecycle channel and
491///   cannot reliably observe state transitions.
492/// - `Io(err)`: manager-specific failure delivering the signal or
493///   performing shutdown (e.g., OS error on kill).
494#[derive(Debug)]
495pub enum TerminateError<TerminalStatus> {
496    /// Manager doesn't support signaling (e.g., Local manager).
497    Unsupported,
498    /// A terminal state was already reached while attempting
499    /// terminate/kill.
500    AlreadyTerminated(TerminalStatus),
501    /// Implementation lost its status channel / cannot observe state.
502    ChannelClosed,
503    /// Manager-specific failure to deliver signal or perform
504    /// shutdown.
505    Io(anyhow::Error),
506}
507
508impl<T: fmt::Debug> fmt::Display for TerminateError<T> {
509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510        match self {
511            TerminateError::Unsupported => write!(f, "terminate/kill unsupported by manager"),
512            TerminateError::AlreadyTerminated(st) => {
513                write!(f, "proc already terminated (status: {st:?})")
514            }
515            TerminateError::ChannelClosed => {
516                write!(f, "lifecycle channel closed; cannot observe state")
517            }
518            TerminateError::Io(err) => write!(f, "I/O error during terminate/kill: {err}"),
519        }
520    }
521}
522
523impl<T: fmt::Debug> std::error::Error for TerminateError<T> {
524    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
525        match self {
526            TerminateError::Io(err) => Some(err.root_cause()),
527            _ => None,
528        }
529    }
530}
531
532/// Summary of results from a bulk termination attempt.
533///
534/// - `attempted`: total number of child procs for which termination
535///   was attempted.
536/// - `ok`: number of procs successfully terminated (includes those
537///   that were already in a terminal state).
538/// - `failed`: number of procs that could not be terminated (e.g.
539///   signaling errors or lost lifecycle channel).
540#[derive(Debug)]
541pub struct TerminateSummary {
542    /// Total number of child procs for which termination was
543    /// attempted.
544    pub attempted: usize,
545    /// Number of procs that successfully reached a terminal state.
546    ///
547    /// This count includes both procs that exited cleanly after
548    /// `terminate(timeout)` and those that were already in a terminal
549    /// state before termination was attempted.
550    pub ok: usize,
551    /// Number of procs that failed to terminate.
552    ///
553    /// Failures typically arise from signaling errors (e.g., OS
554    /// failure to deliver SIGTERM/SIGKILL) or a lost lifecycle
555    /// channel, meaning the manager could no longer observe state
556    /// transitions.
557    pub failed: usize,
558}
559
560impl fmt::Display for TerminateSummary {
561    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562        write!(
563            f,
564            "attempted={} ok={} failed={}",
565            self.attempted, self.ok, self.failed
566        )
567    }
568}
569
570#[async_trait::async_trait]
571/// Trait for terminating a single proc.
572pub trait SingleTerminate: Send + Sync {
573    /// Gracefully terminate the given proc.
574    ///
575    /// Initiates a polite shutdown for the child, waits up to `timeout` for
576    /// completion, then escalates to a forceful stop.
577    ///
578    /// Implementation notes:
579    /// - "Polite shutdown" and "forceful stop" are intentionally
580    ///   abstract. Implementors should map these to whatever
581    ///   semantics they control (e.g., proc-level drain/abort, RPCs,
582    ///   OS signals).
583    /// - The operation must be idempotent and tolerate races with
584    ///   concurrent termination or external exits.
585    ///
586    /// # Parameters
587    /// - `timeout`: Per-child grace period before escalation to a
588    ///   forceful stop.
589    /// - `reason`: Human-readable reason for termination.
590    /// Returns a tuple of (polite shutdown actors vec, forceful stop actors vec)
591    async fn terminate_proc(
592        &self,
593        cx: &impl context::Actor,
594        proc: &ProcAddr,
595        timeout: std::time::Duration,
596        reason: &str,
597    ) -> Result<(Vec<ActorAddr>, Vec<ActorAddr>), anyhow::Error>;
598}
599
600/// Trait for managers that can terminate many child **units** in
601/// bulk.
602///
603/// Implementors provide a concurrency-bounded, graceful shutdown over
604/// all currently tracked children (polite stop → wait → forceful
605/// stop), returning a summary of outcomes. The exact stop/kill
606/// semantics are manager-specific: for example, an OS-process manager
607/// might send signals, while an in-process manager might drain/abort
608/// tasks.
609#[async_trait::async_trait]
610pub trait BulkTerminate: Send + Sync {
611    /// Gracefully terminate all known children.
612    ///
613    /// Initiates a polite shutdown for each child, waits up to
614    /// `timeout` for completion, then escalates to a forceful stop
615    /// for any that remain. Work may be done in parallel, capped by
616    /// `max_in_flight`. The returned [`TerminateSummary`] reports how
617    /// many children were attempted, succeeded, and failed.
618    ///
619    /// Implementation notes:
620    /// - "Polite shutdown" and "forceful stop" are intentionally
621    ///   abstract. Implementors should map these to whatever
622    ///   semantics they control (e.g., proc-level drain/abort, RPCs,
623    ///   OS signals).
624    /// - The operation must be idempotent and tolerate races with
625    ///   concurrent termination or external exits.
626    ///
627    /// # Parameters
628    /// - `timeout`: Per-child grace period before escalation to a
629    ///   forceful stop.
630    /// - `max_in_flight`: Upper bound on concurrent terminations (≥
631    ///   1) to prevent resource spikes (I/O, CPU, file descriptors,
632    ///   etc.).
633    async fn terminate_all(
634        &self,
635        cx: &impl context::Actor,
636        timeout: std::time::Duration,
637        max_in_flight: usize,
638        reason: &str,
639    ) -> TerminateSummary;
640}
641
642// Host convenience that's available only when its manager supports
643// bulk termination.
644impl<M: ProcManager + BulkTerminate> Host<M> {
645    /// Gracefully terminate all procs spawned by this host.
646    ///
647    /// Delegates to the underlying manager’s
648    /// [`BulkTerminate::terminate_all`] implementation. Use this to
649    /// perform orderly teardown during scale-down or shutdown.
650    ///
651    /// # Parameters
652    /// - `timeout`: Per-child grace period before escalation.
653    /// - `max_in_flight`: Upper bound on concurrent terminations.
654    ///
655    /// # Returns
656    /// A [`TerminateSummary`] with counts of attempted/ok/failed
657    /// terminations.
658    pub async fn terminate_children(
659        &mut self,
660        cx: &impl context::Actor,
661        timeout: Duration,
662        max_in_flight: usize,
663        reason: &str,
664    ) -> TerminateSummary {
665        let summary = self
666            .manager
667            .terminate_all(cx, timeout, max_in_flight, reason)
668            .await;
669        // Detach procs from the gateway by dropping their attach
670        // guards, freeing the name slots for any future respawns.
671        self.procs.clear();
672        summary
673    }
674}
675
676#[async_trait::async_trait]
677impl<M: ProcManager + SingleTerminate> SingleTerminate for Host<M> {
678    async fn terminate_proc(
679        &self,
680        cx: &impl context::Actor,
681        proc: &ProcAddr,
682        timeout: Duration,
683        reason: &str,
684    ) -> Result<(Vec<ActorAddr>, Vec<ActorAddr>), anyhow::Error> {
685        self.manager.terminate_proc(cx, proc, timeout, reason).await
686    }
687}
688
689/// Capability proving a proc is ready.
690///
691/// [`ReadyProc::ensure`] validates that `addr()` and `agent_ref()`
692/// are available; this type carries that proof, providing infallible
693/// accessors.
694///
695/// Obtain a `ReadyProc` by calling `ready_proc(&handle).await`.
696pub struct ReadyProc<'a, H: ProcHandle> {
697    handle: &'a H,
698    addr: ChannelAddr,
699    agent_ref: ActorRef<H::Agent>,
700}
701
702impl<'a, H: ProcHandle> ReadyProc<'a, H> {
703    /// Wait for a proc to become ready, then return a capability that
704    /// provides infallible access to `addr()` and `agent_ref()`.
705    ///
706    /// This is the type-safe way to obtain the proc's address and
707    /// agent reference. After this function returns `Ok(ready)`, both
708    /// `ready.addr()` and `ready.agent_ref()` are guaranteed to
709    /// succeed.
710    pub async fn ensure(
711        handle: &'a H,
712    ) -> Result<ReadyProc<'a, H>, ReadyProcError<H::TerminalStatus>> {
713        handle.ready().await?;
714        let addr = handle.addr().ok_or(ReadyProcError::MissingAddr)?;
715        let agent_ref = handle.agent_ref().ok_or(ReadyProcError::MissingAgentRef)?;
716        Ok(ReadyProc {
717            handle,
718            addr,
719            agent_ref,
720        })
721    }
722
723    /// The proc's logical address.
724    pub fn proc_addr(&self) -> &ProcAddr {
725        self.handle.proc_addr()
726    }
727
728    /// The proc's address (guaranteed available after ready).
729    pub fn addr(&self) -> &ChannelAddr {
730        &self.addr
731    }
732
733    /// The agent actor reference (guaranteed available after ready).
734    pub fn agent_ref(&self) -> &ActorRef<H::Agent> {
735        &self.agent_ref
736    }
737}
738
739/// Minimal uniform surface for a spawned-**proc** handle returned by
740/// a `ProcManager`. Each manager can return its own concrete handle,
741/// as long as it exposes these. A **proc** is the Hyperactor runtime
742/// + its actors (lifecycle controlled via `Proc` APIs such as
743/// `destroy_and_wait`). A proc **may** be hosted *inside* an OS
744/// **process**, but it is conceptually distinct:
745///
746/// - `LocalProcManager`: runs the proc **in this OS process**; there
747///   is no child process to signal. Lifecycle is entirely proc-level.
748/// - `ProcessProcManager` (test-only here): launches an **external OS
749///   process** which hosts the proc, but this toy manager does
750///   **not** wire a control plane for shutdown, nor an exit monitor.
751///
752/// This trait is therefore written in terms of the **proc**
753/// lifecycle:
754///
755/// - `ready()` resolves when the proc is Ready (mailbox bound; agent
756///   available).
757/// - `wait()` resolves with the proc's terminal status
758///   (Stopped/Killed/Failed).
759/// - `terminate()` requests a graceful shutdown of the *proc* and
760///   waits up to the deadline; managers that also own a child OS
761///   process may escalate to `SIGKILL` if the proc does not exit in
762///   time.
763/// - `kill()` requests an immediate, forced termination. For
764///    in-process procs, this may be implemented as an immediate
765///    drain/abort of actor tasks. For external procs, this is
766///    typically a `SIGKILL`.
767///
768/// The shape of the terminal value is `Self::TerminalStatus`.
769/// Managers that track rich info (exit code, signal, address, agent)
770/// can expose it; trivial managers may use `()`.
771///
772/// Managers that do not support signaling must return `Unsupported`.
773#[async_trait]
774pub trait ProcHandle: Clone + Send + Sync + 'static {
775    /// The agent actor type installed in the proc by the manager.
776    /// Must implement both:
777    /// - [`Actor`], because the agent actually runs inside the proc,
778    ///   and
779    /// - [`Referable`], so callers can hold `ActorRef<Self::Agent>`.
780    type Agent: Actor + Referable;
781
782    /// The type of terminal status produced when the proc exits.
783    ///
784    /// For example, an external proc manager may use a rich status
785    /// enum (e.g. `ProcStatus`), while an in-process manager may use
786    /// a trivial unit type. This is the value returned by
787    /// [`ProcHandle::wait`] and carried by [`ReadyError::Terminal`].
788    type TerminalStatus: std::fmt::Debug + Clone + Send + Sync + 'static;
789
790    /// The proc's logical address on this host.
791    fn proc_addr(&self) -> &ProcAddr;
792
793    /// The proc's address (the one callers bind into the host
794    /// router). May return `None` before `ready()` completes.
795    /// Guaranteed to return `Some` after `ready()` succeeds.
796    ///
797    /// **Prefer [`ready_proc()`]** for type-safe access that
798    /// guarantees availability at compile time.
799    fn addr(&self) -> Option<ChannelAddr>;
800
801    /// The agent actor reference hosted in the proc. May return
802    /// `None` before `ready()` completes. Guaranteed to return `Some`
803    /// after `ready()` succeeds.
804    ///
805    /// **Prefer [`ready_proc()`]** for type-safe access that
806    /// guarantees availability at compile time.
807    fn agent_ref(&self) -> Option<ActorRef<Self::Agent>>;
808
809    /// Resolves when the proc becomes Ready. Multi-waiter,
810    /// non-consuming.
811    async fn ready(&self) -> Result<(), ReadyError<Self::TerminalStatus>>;
812
813    /// Resolves with the terminal status (Stopped/Killed/Failed/etc).
814    /// Multi-waiter, non-consuming.
815    async fn wait(&self) -> Result<Self::TerminalStatus, WaitError>;
816
817    /// Politely stop the proc before the deadline; managers that own
818    /// a child OS process may escalate to a forced kill at the
819    /// deadline. Idempotent and race-safe: concurrent callers
820    /// coalesce; the first terminal outcome wins and all callers
821    /// observe it via `wait()`.
822    ///
823    /// Returns the single terminal status the proc reached (the same
824    /// value `wait()` will return). Never fabricates terminal states:
825    /// this is only returned after the exit monitor observes
826    /// termination.
827    ///
828    /// # Parameters
829    /// - `cx`: The actor context for sending messages.
830    /// - `timeout`: Grace period before escalation.
831    /// - `reason`: Human-readable reason for termination.
832    async fn terminate(
833        &self,
834        cx: &impl context::Actor,
835        timeout: Duration,
836        reason: &str,
837    ) -> Result<Self::TerminalStatus, TerminateError<Self::TerminalStatus>>;
838
839    /// Force the proc down immediately. For in-process managers this
840    /// may abort actor tasks; for external managers this typically
841    /// sends `SIGKILL`. Also idempotent/race-safe; the terminal
842    /// outcome is the one observed by `wait()`.
843    async fn kill(&self) -> Result<Self::TerminalStatus, TerminateError<Self::TerminalStatus>>;
844}
845
846/// A trait describing a manager of procs, responsible for bootstrapping
847/// procs on a host, and managing their lifetimes. The manager spawns an
848/// `Agent`-typed actor on each proc, responsible for managing the proc.
849#[async_trait]
850pub trait ProcManager {
851    /// Concrete handle type this manager returns.
852    type Handle: ProcHandle;
853
854    /// Additional configuration for the proc, supported by this manager.
855    type Config = ();
856
857    /// The preferred transport for this ProcManager.
858    /// In practice this will be [`ChannelTransport::Local`]
859    /// for testing, and [`ChannelTransport::Unix`] for external
860    /// processes.
861    fn transport(&self) -> ChannelTransport;
862
863    /// Spawn a new proc with the provided proc address. The proc should use
864    /// `forwarder_addr` for messages destined outside of itself. The returned
865    /// handle exposes the address that accepts messages for the proc.
866    ///
867    /// An agent actor is also spawned, and the corresponding actor
868    /// ref is returned.
869    async fn spawn(
870        &self,
871        proc_id: ProcAddr,
872        forwarder_addr: ChannelAddr,
873        config: Self::Config,
874    ) -> Result<Self::Handle, HostError>;
875}
876
877/// Type alias for the agent actor managed by a given [`ProcManager`].
878///
879/// This resolves to the `Agent` type exposed by the manager's
880/// associated `Handle` (via [`ProcHandle::Agent`]). It provides a
881/// convenient shorthand so call sites can refer to
882/// `ActorRef<ManagerAgent<M>>` instead of the more verbose
883/// `<M::Handle as ProcHandle>::Agent`.
884///
885/// # Example
886/// ```ignore
887/// fn takes_agent_ref<M: ProcManager>(r: ActorRef<ManagerAgent<M>>) { … }
888/// ```
889pub type ManagerAgent<M> = <<M as ProcManager>::Handle as ProcHandle>::Agent; // rust issue #112792
890
891/// Lifecycle status for procs managed by [`LocalProcManager`].
892///
893/// Used by [`LocalProcManager::request_stop`] to track background
894/// teardown progress.
895#[derive(Debug, Clone, Copy, PartialEq, Eq)]
896pub enum LocalProcStatus {
897    /// A stop has been requested but teardown is still in progress.
898    Stopping,
899    /// Teardown completed.
900    Stopped,
901}
902
903/// A ProcManager that spawns **in-process** procs (test-only).
904///
905/// The proc runs inside this same OS process; there is **no** child
906/// process to signal. Lifecycle is purely proc-level:
907/// - `terminate(timeout)`: delegates to
908///   `Proc::destroy_and_wait(timeout)`, which drains and, at the
909///   deadline, aborts remaining actors.
910/// - `kill()`: uses a zero deadline to emulate a forced stop via
911///   `destroy_and_wait(Duration::ZERO)`.
912/// - `wait()`: trivial (no external lifecycle to observe).
913///
914///   No OS signals are sent or required.
915pub struct LocalProcManager<S> {
916    procs: Arc<Mutex<HashMap<ProcAddr, Proc>>>,
917    stopping: Arc<Mutex<HashMap<ProcAddr, tokio::sync::watch::Sender<LocalProcStatus>>>>,
918    spawn: S,
919}
920
921impl<S> LocalProcManager<S> {
922    /// Create a new in-process proc manager with the given agent
923    /// params.
924    pub fn new(spawn: S) -> Self {
925        Self {
926            procs: Arc::new(Mutex::new(HashMap::new())),
927            stopping: Arc::new(Mutex::new(HashMap::new())),
928            spawn,
929        }
930    }
931
932    /// Non-blocking stop: remove the proc and spawn a background task
933    /// that tears it down.
934    ///
935    /// Status transitions through `Stopping` -> `Stopped` and is
936    /// observable via [`local_proc_status`] and [`watch`]. Idempotent:
937    /// no-ops if the proc is already stopping or stopped.
938    pub async fn request_stop(&self, proc: &ProcAddr, timeout: Duration, reason: &str) {
939        {
940            let guard = self.stopping.lock().await;
941            if guard.contains_key(proc) {
942                return;
943            }
944        }
945
946        let mut proc_handle = {
947            let mut guard = self.procs.lock().await;
948            match guard.remove(proc) {
949                Some(p) => p,
950                None => return,
951            }
952        };
953
954        let proc_ref: ProcAddr = proc_handle.proc_addr().clone();
955        let (tx, _) = tokio::sync::watch::channel(LocalProcStatus::Stopping);
956        self.stopping.lock().await.insert(proc_ref.clone(), tx);
957
958        let stopping = Arc::clone(&self.stopping);
959        let reason = reason.to_string();
960        tokio::spawn(async move {
961            if let Err(e) = proc_handle.destroy_and_wait(timeout, &reason).await {
962                tracing::warn!(error = %e, "request_stop(local): destroy_and_wait failed");
963            }
964            if let Some(tx) = stopping.lock().await.get(&proc_ref) {
965                let _ = tx.send(LocalProcStatus::Stopped);
966            }
967        });
968    }
969
970    /// Query the lifecycle status of a proc that was stopped via
971    /// [`request_stop`].
972    ///
973    /// Returns `None` if the proc was never stopped through this path.
974    pub async fn local_proc_status(&self, proc: &ProcAddr) -> Option<LocalProcStatus> {
975        self.stopping.lock().await.get(proc).map(|tx| *tx.borrow())
976    }
977
978    /// Subscribe to lifecycle status changes for a proc that was
979    /// stopped via [`request_stop`].
980    ///
981    /// Returns `None` if the proc was never stopped through this path.
982    pub async fn watch(
983        &self,
984        proc: &ProcAddr,
985    ) -> Option<tokio::sync::watch::Receiver<LocalProcStatus>> {
986        self.stopping
987            .lock()
988            .await
989            .get(proc)
990            .map(|tx| tx.subscribe())
991    }
992}
993
994#[async_trait]
995impl<S> BulkTerminate for LocalProcManager<S>
996where
997    S: Send + Sync,
998{
999    async fn terminate_all(
1000        &self,
1001        _cx: &impl context::Actor,
1002        timeout: std::time::Duration,
1003        max_in_flight: usize,
1004        reason: &str,
1005    ) -> TerminateSummary {
1006        // Drain procs so we don't hold the lock across awaits and subsequent
1007        // calls to terminate_all don't try to re-terminate.
1008        let procs: Vec<Proc> = {
1009            let mut guard = self.procs.lock().await;
1010            guard.drain().map(|(_, v)| v).collect()
1011        };
1012
1013        let attempted = procs.len();
1014
1015        let results = stream::iter(procs.into_iter().map(|mut p| async move {
1016            // For local manager, graceful proc-level stop.
1017            match p.destroy_and_wait(timeout, reason).await {
1018                Ok(_) => true,
1019                Err(e) => {
1020                    tracing::warn!(error=%e, "terminate_all(local): destroy_and_wait failed");
1021                    false
1022                }
1023            }
1024        }))
1025        .buffer_unordered(max_in_flight.max(1))
1026        .collect::<Vec<bool>>()
1027        .await;
1028
1029        let ok = results.into_iter().filter(|b| *b).count();
1030
1031        TerminateSummary {
1032            attempted,
1033            ok,
1034            failed: attempted.saturating_sub(ok),
1035        }
1036    }
1037}
1038
1039#[async_trait::async_trait]
1040impl<S> SingleTerminate for LocalProcManager<S>
1041where
1042    S: Send + Sync,
1043{
1044    async fn terminate_proc(
1045        &self,
1046        _cx: &impl context::Actor,
1047        proc: &ProcAddr,
1048        timeout: std::time::Duration,
1049        reason: &str,
1050    ) -> Result<(Vec<ActorAddr>, Vec<ActorAddr>), anyhow::Error> {
1051        // Snapshot procs so we don't hold the lock across awaits.
1052        let procs: Option<Proc> = {
1053            let mut guard = self.procs.lock().await;
1054            guard.remove(proc)
1055        };
1056        if let Some(mut p) = procs {
1057            p.destroy_and_wait(timeout, reason).await
1058        } else {
1059            Err(anyhow::anyhow!("proc {} doesn't exist", proc))
1060        }
1061    }
1062}
1063
1064/// A lightweight [`ProcHandle`] for procs managed **in-process** via
1065/// [`LocalProcManager`].
1066///
1067/// This handle wraps the minimal identifying state of a spawned proc:
1068/// - its [`ProcId`] (logical identity on the host),
1069/// - the proc's [`ChannelAddr`] (the address callers bind into the
1070///   host router), and
1071/// - the [`ActorAddr`] to the agent actor hosted in the proc.
1072///
1073/// Unlike external handles, `LocalHandle` does **not** manage an OS
1074/// child process. It provides a uniform surface (`proc_id()`,
1075/// `addr()`, `agent_ref()`) and implements `terminate()`/`kill()` by
1076/// calling into the underlying `Proc::destroy_and_wait`, i.e.,
1077/// **proc-level** shutdown.
1078///
1079/// **Type parameter:** `A` is constrained by the `ProcHandle::Agent`
1080/// bound (`Actor + Referable`).
1081pub struct LocalHandle<A: Actor + Referable> {
1082    proc_id: ProcAddr,
1083    addr: ChannelAddr,
1084    agent_ref: ActorRef<A>,
1085    procs: Arc<Mutex<HashMap<ProcAddr, Proc>>>,
1086}
1087
1088// Manual `Clone` to avoid requiring `A: Clone`.
1089impl<A: Actor + Referable> Clone for LocalHandle<A> {
1090    fn clone(&self) -> Self {
1091        Self {
1092            proc_id: self.proc_id.clone(),
1093            addr: self.addr.clone(),
1094            agent_ref: self.agent_ref.clone(),
1095            procs: Arc::clone(&self.procs),
1096        }
1097    }
1098}
1099
1100#[async_trait]
1101impl<A: Actor + Referable> ProcHandle for LocalHandle<A> {
1102    /// `Agent = A` (inherits `Actor + Referable` from the trait
1103    /// bound).
1104    type Agent = A;
1105    type TerminalStatus = ();
1106
1107    fn proc_addr(&self) -> &ProcAddr {
1108        &self.proc_id
1109    }
1110
1111    fn addr(&self) -> Option<ChannelAddr> {
1112        Some(self.addr.clone())
1113    }
1114
1115    fn agent_ref(&self) -> Option<ActorRef<Self::Agent>> {
1116        Some(self.agent_ref.clone())
1117    }
1118
1119    /// Always resolves immediately: a local proc is created
1120    /// in-process and is usable as soon as the handle exists.
1121    async fn ready(&self) -> Result<(), ReadyError<Self::TerminalStatus>> {
1122        Ok(())
1123    }
1124    /// Always resolves immediately with `()`: a local proc has no
1125    /// external lifecycle to await. There is no OS child process
1126    /// behind this handle.
1127    async fn wait(&self) -> Result<Self::TerminalStatus, WaitError> {
1128        Ok(())
1129    }
1130
1131    async fn terminate(
1132        &self,
1133        _cx: &impl context::Actor,
1134        timeout: Duration,
1135        reason: &str,
1136    ) -> Result<(), TerminateError<Self::TerminalStatus>> {
1137        let mut proc = {
1138            let guard = self.procs.lock().await;
1139            match guard.get(self.proc_addr()) {
1140                Some(p) => p.clone(),
1141                None => {
1142                    // The proc was already removed; treat as already
1143                    // terminal.
1144                    return Err(TerminateError::AlreadyTerminated(()));
1145                }
1146            }
1147        };
1148
1149        // Graceful stop of the *proc* (actors) with a deadline. This
1150        // will drain and then abort remaining actors at expiry.
1151        let _ = proc
1152            .destroy_and_wait(timeout, reason)
1153            .await
1154            .map_err(TerminateError::Io)?;
1155
1156        Ok(())
1157    }
1158
1159    async fn kill(&self) -> Result<(), TerminateError<Self::TerminalStatus>> {
1160        // Forced stop == zero deadline; `destroy_and_wait` will
1161        // immediately abort remaining actors and return.
1162        let mut proc = {
1163            let guard = self.procs.lock().await;
1164            match guard.get(self.proc_addr()) {
1165                Some(p) => p.clone(),
1166                None => return Err(TerminateError::AlreadyTerminated(())),
1167            }
1168        };
1169
1170        let _ = proc
1171            .destroy_and_wait(Duration::from_millis(0), "kill")
1172            .await
1173            .map_err(TerminateError::Io)?;
1174
1175        Ok(())
1176    }
1177}
1178
1179/// Local, in-process ProcManager.
1180///
1181/// **Type bounds:**
1182/// - `A: Actor + Referable + Binds<A>`
1183///   - `Actor`: the agent actually runs inside the proc.
1184///   - `Referable`: callers hold `ActorRef<A>` to the agent; this
1185///     bound is required for typed remote refs.
1186///   - `Binds<A>`: lets the runtime wire the agent's handler ports.
1187/// - `F: Future<Output = anyhow::Result<ActorHandle<A>>> + Send`:
1188///   the spawn closure returns a Send future (we `tokio::spawn` it).
1189/// - `S: Fn(Proc) -> F + Sync`: the factory can be called from
1190///   concurrent contexts.
1191///
1192/// Result handle is `LocalHandle<A>` (whose `Agent = A` via `ProcHandle`).
1193#[async_trait]
1194impl<A, S, F> ProcManager for LocalProcManager<S>
1195where
1196    A: Actor + Referable + Binds<A>,
1197    F: Future<Output = anyhow::Result<ActorHandle<A>>> + Send,
1198    S: Fn(Proc) -> F + Sync,
1199{
1200    type Handle = LocalHandle<A>;
1201
1202    fn transport(&self) -> ChannelTransport {
1203        ChannelTransport::Local
1204    }
1205
1206    #[hyperactor::instrument(fields(proc_id=proc_id.to_string(), addr=forwarder_addr.to_string()))]
1207    async fn spawn(
1208        &self,
1209        proc_id: ProcAddr,
1210        forwarder_addr: ChannelAddr,
1211        _config: (),
1212    ) -> Result<Self::Handle, HostError> {
1213        let transport = forwarder_addr.transport();
1214        let proc = Proc::configured(
1215            proc_id.clone(),
1216            MailboxClient::dial(forwarder_addr)?.into_boxed(),
1217        );
1218        let (proc_addr, rx) = channel::serve(ChannelAddr::any(transport))?;
1219        self.procs
1220            .lock()
1221            .await
1222            .insert(proc_id.clone(), proc.clone());
1223        let _handle = proc.clone().serve(rx);
1224        let agent_handle = (self.spawn)(proc)
1225            .await
1226            .map_err(|e| HostError::AgentSpawnFailure(proc_id.clone(), e))?;
1227
1228        Ok(LocalHandle {
1229            proc_id,
1230            addr: proc_addr,
1231            agent_ref: agent_handle.bind(),
1232            procs: Arc::clone(&self.procs),
1233        })
1234    }
1235}
1236
1237/// A ProcManager that manages each proc as a **separate OS process**
1238/// (test-only toy).
1239///
1240/// This implementation launches a child via `Command` and relies on
1241/// `kill_on_drop(true)` so that children are SIGKILLed if the manager
1242/// (or host) drops. There is **no** proc control plane (no RPC to a
1243/// proc agent for shutdown) and **no** exit monitor wired here.
1244/// Consequently:
1245/// - `terminate()` and `kill()` return `Unsupported`.
1246/// - `wait()` is trivial (no lifecycle observation).
1247///
1248/// It follows a simple protocol:
1249///
1250/// Each process is launched with the following environment variables:
1251/// - `HYPERACTOR_HOST_BACKEND_ADDR`: the backend address to which all messages are forwarded,
1252/// - `HYPERACTOR_HOST_PROC_ID`: the proc id to assign the launched proc, and
1253/// - `HYPERACTOR_HOST_CALLBACK_ADDR`: the channel address with which to return the proc's address
1254///
1255/// The launched proc should also spawn an actor to manage it - the details of this are
1256/// implementation dependent, and outside the scope of the process manager.
1257///
1258/// The function [`boot_proc`] provides a convenient implementation of the
1259/// protocol.
1260pub struct ProcessProcManager<A> {
1261    program: std::path::PathBuf,
1262    children: Arc<Mutex<HashMap<ProcAddr, Child>>>,
1263    _phantom: PhantomData<A>,
1264}
1265
1266impl<A> ProcessProcManager<A> {
1267    /// Create a new ProcessProcManager that runs the provided
1268    /// command.
1269    pub fn new(program: std::path::PathBuf) -> Self {
1270        Self {
1271            program,
1272            children: Arc::new(Mutex::new(HashMap::new())),
1273            _phantom: PhantomData,
1274        }
1275    }
1276}
1277
1278impl<A> Drop for ProcessProcManager<A> {
1279    fn drop(&mut self) {
1280        // When the manager is dropped, `children` is dropped, which
1281        // drops each `Child` handle. With `kill_on_drop(true)`, the OS
1282        // will SIGKILL the processes. Nothing else to do here.
1283    }
1284}
1285
1286/// A [`ProcHandle`] implementation for procs managed as separate
1287/// OS processes via [`ProcessProcManager`].
1288///
1289/// This handle records the logical identity and connectivity of an
1290/// external child process:
1291/// - its [`ProcId`] (unique identity on the host),
1292/// - the proc's [`ChannelAddr`] (address registered in the host
1293///   router),
1294/// - and the [`ActorRef`] of the agent actor spawned inside the proc.
1295///
1296/// Unlike [`LocalHandle`], this corresponds to a real OS process
1297/// launched by the manager. In this **toy** implementation the handle
1298/// does not own/monitor the `Child` and there is no shutdown control
1299/// plane. It is a stable, clonable surface exposing the proc's
1300/// identity, address, and agent reference so host code can interact
1301/// uniformly with local/external procs. `terminate()`/`kill()` are
1302/// intentionally `Unsupported` here; process cleanup relies on
1303/// `cmd.kill_on_drop(true)` when launching the child (the OS will
1304/// SIGKILL it if the handle is dropped).
1305///
1306/// The type bound `A: Actor + Referable` comes from the
1307/// [`ProcHandle::Agent`] requirement: `Actor` because the agent
1308/// actually runs inside the proc, and `Referable` because it must
1309/// be referenceable via [`ActorRef<A>`] (i.e., safe to carry as a
1310/// typed remote reference).
1311#[derive(Debug)]
1312pub struct ProcessHandle<A: Actor + Referable> {
1313    proc_id: ProcAddr,
1314    addr: ChannelAddr,
1315    agent_ref: ActorRef<A>,
1316}
1317
1318// Manual `Clone` to avoid requiring `A: Clone`.
1319impl<A: Actor + Referable> Clone for ProcessHandle<A> {
1320    fn clone(&self) -> Self {
1321        Self {
1322            proc_id: self.proc_id.clone(),
1323            addr: self.addr.clone(),
1324            agent_ref: self.agent_ref.clone(),
1325        }
1326    }
1327}
1328
1329#[async_trait]
1330impl<A: Actor + Referable> ProcHandle for ProcessHandle<A> {
1331    /// Agent must be both an `Actor` (runs in the proc) and a
1332    /// `Referable` (so it can be referenced via `ActorRef<A>`).
1333    type Agent = A;
1334    type TerminalStatus = ();
1335
1336    fn proc_addr(&self) -> &ProcAddr {
1337        &self.proc_id
1338    }
1339
1340    fn addr(&self) -> Option<ChannelAddr> {
1341        Some(self.addr.clone())
1342    }
1343
1344    fn agent_ref(&self) -> Option<ActorRef<Self::Agent>> {
1345        Some(self.agent_ref.clone())
1346    }
1347
1348    /// Resolves immediately. `ProcessProcManager::spawn` returns this
1349    /// handle only after the child has called back with (addr,
1350    /// agent), i.e. after readiness.
1351    async fn ready(&self) -> Result<(), ReadyError<Self::TerminalStatus>> {
1352        Ok(())
1353    }
1354    /// Resolves immediately with `()`. This handle does not track
1355    /// child lifecycle; there is no watcher in this implementation.
1356    async fn wait(&self) -> Result<Self::TerminalStatus, WaitError> {
1357        Ok(())
1358    }
1359
1360    async fn terminate(
1361        &self,
1362        _cx: &impl context::Actor,
1363        _deadline: Duration,
1364        _reason: &str,
1365    ) -> Result<(), TerminateError<Self::TerminalStatus>> {
1366        Err(TerminateError::Unsupported)
1367    }
1368
1369    async fn kill(&self) -> Result<(), TerminateError<Self::TerminalStatus>> {
1370        Err(TerminateError::Unsupported)
1371    }
1372}
1373
1374#[async_trait]
1375impl<A> ProcManager for ProcessProcManager<A>
1376where
1377    // Agent actor runs in the proc (`Actor`) and must be
1378    // referenceable (`Referable`).
1379    A: Actor + Referable + Sync,
1380{
1381    type Handle = ProcessHandle<A>;
1382
1383    fn transport(&self) -> ChannelTransport {
1384        ChannelTransport::Unix
1385    }
1386
1387    #[hyperactor::instrument(fields(proc_id=proc_id.to_string(), addr=forwarder_addr.to_string()))]
1388    async fn spawn(
1389        &self,
1390        proc_id: ProcAddr,
1391        forwarder_addr: ChannelAddr,
1392        _config: (),
1393    ) -> Result<Self::Handle, HostError> {
1394        let (callback_addr, mut callback_rx) =
1395            channel::serve(ChannelAddr::any(ChannelTransport::Unix))?;
1396
1397        let mut cmd = Command::new(&self.program);
1398        cmd.env("HYPERACTOR_HOST_PROC_ID", proc_id.to_string());
1399        cmd.env("HYPERACTOR_HOST_BACKEND_ADDR", forwarder_addr.to_string());
1400        cmd.env("HYPERACTOR_HOST_CALLBACK_ADDR", callback_addr.to_string());
1401
1402        // Lifetime strategy: mark the child with
1403        // `kill_on_drop(true)` so the OS will send SIGKILL if the
1404        // handle is dropped and retain the `Child` in
1405        // `self.children`, tying its lifetime to the manager/host.
1406        //
1407        // This is the simplest viable policy to avoid orphaned
1408        // subprocesses in CI; more sophisticated lifecycle control
1409        // (graceful shutdown, restart) will be layered on later.
1410
1411        // Kill the child when its handle is dropped.
1412        cmd.kill_on_drop(true);
1413
1414        let child = cmd.spawn().map_err(|e| {
1415            HostError::ProcessSpawnFailure(proc_id.clone(), self.program.display().to_string(), e)
1416        })?;
1417
1418        // Retain the handle so it lives for the life of the
1419        // manager/host.
1420        {
1421            let mut children = self.children.lock().await;
1422            children.insert(proc_id.clone(), child);
1423        }
1424
1425        // Wait for the child's callback with (addr, agent_ref)
1426        let (proc_addr, agent_ref) = callback_rx.recv().await?;
1427
1428        // TODO(production): For a non-test implementation, plumb a
1429        // shutdown path:
1430        // - expose a proc-level graceful stop RPC on the agent and
1431        //   implement `terminate(timeout)` by invoking it and, on
1432        //   deadline, call `Child::kill()`; implement `kill()` as
1433        //   immediate `Child::kill()`.
1434        // - wire an exit monitor so `wait()` resolves with a real
1435        //   terminal status.
1436        Ok(ProcessHandle {
1437            proc_id,
1438            addr: proc_addr,
1439            agent_ref,
1440        })
1441    }
1442}
1443
1444impl<A> ProcessProcManager<A>
1445where
1446    // `Actor`: runs in the proc; `Referable`: referenceable via
1447    // ActorRef; `Binds<A>`: wires ports.
1448    A: Actor + Referable + Binds<A>,
1449{
1450    /// Boot a process in a ProcessProcManager<A>. Should be called from processes spawned
1451    /// by the process manager. `boot_proc` will spawn the provided actor type (with parameters)
1452    /// onto the newly created Proc, and bind its handler. This allows the user to install an agent to
1453    /// manage the proc itself.
1454    pub async fn boot_proc<S, F>(spawn: S) -> Result<Proc, HostError>
1455    where
1456        S: FnOnce(Proc) -> F,
1457        F: Future<Output = Result<ActorHandle<A>, anyhow::Error>>,
1458    {
1459        let proc_id: ProcAddr = Self::parse_env("HYPERACTOR_HOST_PROC_ID")?;
1460        let backend_addr: ChannelAddr = Self::parse_env("HYPERACTOR_HOST_BACKEND_ADDR")?;
1461        let callback_addr: ChannelAddr = Self::parse_env("HYPERACTOR_HOST_CALLBACK_ADDR")?;
1462        spawn_proc(proc_id, backend_addr, callback_addr, spawn).await
1463    }
1464
1465    fn parse_env<T, E>(key: &str) -> Result<T, HostError>
1466    where
1467        T: FromStr<Err = E>,
1468        E: Into<anyhow::Error>,
1469    {
1470        std::env::var(key)
1471            .map_err(|e| HostError::MissingParameter(key.to_string(), e))?
1472            .parse()
1473            .map_err(|e: E| HostError::InvalidParameter(key.to_string(), e.into()))
1474    }
1475}
1476
1477/// Spawn a proc at `proc_id` with an `A`-typed agent actor,
1478/// forwarding messages to the provided `backend_addr`,
1479/// and returning the proc's address and agent actor on
1480/// the provided `callback_addr`.
1481#[hyperactor::instrument(fields(proc_id=proc_id.to_string(), addr=backend_addr.to_string(), callback_addr=callback_addr.to_string()))]
1482pub async fn spawn_proc<A, S, F>(
1483    proc_id: ProcAddr,
1484    backend_addr: ChannelAddr,
1485    callback_addr: ChannelAddr,
1486    spawn: S,
1487) -> Result<Proc, HostError>
1488where
1489    // `Actor`: runs in the proc; `Referable`: allows ActorRef<A>;
1490    // `Binds<A>`: wires ports
1491    A: Actor + Referable + Binds<A>,
1492    S: FnOnce(Proc) -> F,
1493    F: Future<Output = Result<ActorHandle<A>, anyhow::Error>>,
1494{
1495    let backend_transport = backend_addr.transport();
1496    let proc = Proc::configured(
1497        proc_id.clone(),
1498        MailboxClient::dial(backend_addr)?.into_boxed(),
1499    );
1500
1501    let agent_handle = spawn(proc.clone())
1502        .await
1503        .map_err(|e| HostError::AgentSpawnFailure(proc_id.clone(), e))?;
1504
1505    // Finally serve the proc on the same transport as the backend address,
1506    // and call back.
1507    let (proc_addr, proc_rx) = channel::serve(ChannelAddr::any(backend_transport))?;
1508    proc.clone().serve(proc_rx);
1509    let agent_ref: ActorRef<A> = agent_handle.bind::<A>();
1510    channel::dial::<(ChannelAddr, ActorRef<A>)>(callback_addr)?
1511        .send((proc_addr, agent_ref))
1512        .await
1513        .map_err(ChannelError::from)?;
1514
1515    Ok(proc)
1516}
1517
1518/// Testing support for hosts. This is linked outside of cfg(test)
1519/// as it is needed by an external binary.
1520pub mod testing {
1521    use async_trait::async_trait;
1522    use hyperactor::Actor;
1523    use hyperactor::ActorAddr;
1524    use hyperactor::Context;
1525    use hyperactor::Endpoint as _;
1526    use hyperactor::Handler;
1527    use hyperactor::OncePortRef;
1528    /// Just a simple actor, available in both the bootstrap binary as well as
1529    /// hyperactor tests.
1530    #[derive(Debug, Default)]
1531    #[hyperactor::export(handlers = [OncePortRef<ActorAddr>])]
1532    pub struct EchoActor;
1533
1534    impl Actor for EchoActor {}
1535
1536    #[async_trait]
1537    impl Handler<OncePortRef<ActorAddr>> for EchoActor {
1538        async fn handle(
1539            &mut self,
1540            cx: &Context<Self>,
1541            reply: OncePortRef<ActorAddr>,
1542        ) -> Result<(), anyhow::Error> {
1543            reply.post(cx, cx.self_addr().clone());
1544            Ok(())
1545        }
1546    }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551    use std::sync::Arc;
1552    use std::time::Duration;
1553
1554    use hyperactor::Addr;
1555    use hyperactor::Endpoint as _;
1556    use hyperactor::Label;
1557    use hyperactor::Location;
1558    use hyperactor::OncePortRef;
1559    use hyperactor::Uid;
1560    use hyperactor::channel::ChannelTransport;
1561    use hyperactor::channel::Tx;
1562    use hyperactor::channel::TxStatus;
1563    use hyperactor::context::Mailbox;
1564    use hyperactor::mailbox::DialMailboxRouter;
1565    use hyperactor::mailbox::MessageEnvelope;
1566    use hyperactor::port::Port;
1567
1568    use super::testing::EchoActor;
1569    use super::*;
1570
1571    #[tokio::test]
1572    async fn test_basic() {
1573        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
1574            Ok(proc.spawn_with_label::<()>("host_agent", ()))
1575        });
1576        let procs = Arc::clone(&proc_manager.procs);
1577        let mut host = Host::new(proc_manager, ChannelAddr::any(ChannelTransport::Unix))
1578            .await
1579            .unwrap();
1580
1581        let (proc_id1, _ref) = host.spawn("proc1".to_string(), ()).await.unwrap();
1582        // The spawned proc's identity matches the requested name, and
1583        // its location resolves to the host's frontend address with
1584        // a `Via(proc_uid, ...)` source-routing prefix.
1585        assert_eq!(proc_id1.id(), &ResourceId::from_name("proc1").proc_id());
1586        assert_eq!(proc_id1.location().addr(), host.addr());
1587        let (via_uid, _) = proc_id1
1588            .location()
1589            .as_via()
1590            .expect("spawned proc_addr must carry a via prefix");
1591        assert_eq!(via_uid, proc_id1.id().uid());
1592        assert!(procs.lock().await.contains_key(&proc_id1));
1593
1594        let (proc_id2, _ref) = host.spawn("proc2".to_string(), ()).await.unwrap();
1595        assert!(procs.lock().await.contains_key(&proc_id2));
1596
1597        let proc1 = procs.lock().await.get(&proc_id1).unwrap().clone();
1598        let proc2 = procs.lock().await.get(&proc_id2).unwrap().clone();
1599
1600        // Make sure they can talk to each other:
1601        let instance1 = proc1.client("client");
1602        let instance2 = proc2.client("client");
1603
1604        let (port, mut rx) = instance1.mailbox().open_port();
1605
1606        port.bind().post(&instance2, "hello".to_string());
1607        assert_eq!(rx.recv().await.unwrap(), "hello".to_string());
1608
1609        // Make sure that the system proc is also wired in correctly.
1610        let system_actor = host.system_proc().client("test");
1611
1612        // system->proc
1613        port.bind()
1614            .post(&system_actor, "hello from the system proc".to_string());
1615        assert_eq!(
1616            rx.recv().await.unwrap(),
1617            "hello from the system proc".to_string()
1618        );
1619
1620        // system->system
1621        let (port, mut rx) = system_actor.mailbox().open_port();
1622        port.bind()
1623            .post(&system_actor, "hello from the system".to_string());
1624        assert_eq!(
1625            rx.recv().await.unwrap(),
1626            "hello from the system".to_string()
1627        );
1628
1629        // proc->system
1630        port.bind()
1631            .post(&instance1, "hello from the instance1".to_string());
1632        assert_eq!(
1633            rx.recv().await.unwrap(),
1634            "hello from the instance1".to_string()
1635        );
1636    }
1637
1638    #[tokio::test]
1639    // TODO: OSS: called `Result::unwrap()` on an `Err` value: ReadFailed { manifest_path: "/meta-pytorch/monarch/target/debug/deps/hyperactor-0e1fe83af739d976.resources.json", source: Os { code: 2, kind: NotFound, message: "No such file or directory" } }
1640    #[cfg_attr(not(fbcode_build), ignore)]
1641    async fn test_process_proc_manager() {
1642        hyperactor_telemetry::initialize_logging(hyperactor_telemetry::DefaultTelemetryClock {});
1643
1644        // EchoActor is "host_agent" used to test connectivity.
1645        let process_manager = ProcessProcManager::<EchoActor>::new(
1646            buck_resources::get("monarch/hyperactor_mesh/host_bootstrap").unwrap(),
1647        );
1648        let mut host = Host::new(process_manager, ChannelAddr::any(ChannelTransport::Unix))
1649            .await
1650            .unwrap();
1651
1652        // (1) Spawn and check invariants.
1653        assert!(matches!(host.addr().transport(), ChannelTransport::Unix));
1654        let (proc1, echo1) = host.spawn("proc1".to_string(), ()).await.unwrap();
1655        let (proc2, echo2) = host.spawn("proc2".to_string(), ()).await.unwrap();
1656        assert_eq!(echo1.actor_addr().proc_addr(), proc1);
1657        assert_eq!(echo2.actor_addr().proc_addr(), proc2);
1658
1659        // (2) Duplicate name rejection.
1660        let dup = host.spawn("proc1".to_string(), ()).await;
1661        assert!(matches!(dup, Err(HostError::ProcExists(_))));
1662
1663        // (3) Create a standalone client proc and verify echo1 agent responds.
1664        // Request: client proc -> host frontend/router -> echo1 (proc1).
1665        // Reply:   echo1 (proc1) -> host backend -> host router -> client port.
1666        // This confirms that an external proc (created via
1667        // `Proc::direct`) can address a child proc through the host,
1668        // and receive a correct reply.
1669        let client = Proc::direct(
1670            ChannelAddr::any(host.addr().transport()),
1671            "test".to_string(),
1672        )
1673        .unwrap();
1674        let client_inst = client.client("test");
1675        let (port, rx) = client_inst.mailbox().open_once_port();
1676        echo1.post(&client_inst, port.bind());
1677        let id = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1678            .await
1679            .unwrap()
1680            .unwrap();
1681        assert_eq!(id, *echo1.actor_addr());
1682
1683        // (4) Child <-> external client request -> reply:
1684        // Request: client proc (standalone via `Proc::direct`) ->
1685        //          host frontend/router -> echo2 (proc2).
1686        // Reply:   echo2 (proc2) -> host backend -> host router ->
1687        //          client port (standalone proc).
1688        // This exercises cross-proc routing between a child and an
1689        // external client under the same host.
1690        let (port2, rx2) = client_inst.mailbox().open_once_port();
1691        echo2.post(&client_inst, port2.bind());
1692        let id2 = tokio::time::timeout(Duration::from_secs(5), rx2.recv())
1693            .await
1694            .unwrap()
1695            .unwrap();
1696        assert_eq!(id2, *echo2.actor_addr());
1697
1698        // (5) System -> child request -> cross-proc reply:
1699        // Request: system proc -> host router (frontend) -> echo1
1700        //          (proc1, child).
1701        // Reply: echo1 (proc1) -> proc1 forwarder -> host backend ->
1702        //        host router -> client proc direct addr (Proc::direct) ->
1703        //        client port.
1704        // Because `client_inst` runs in its own proc, the reply
1705        // traverses the host (not local delivery within proc1).
1706        let sys_inst = host.system_proc().client("sys-client");
1707        let (port3, rx3) = client_inst.mailbox().open_once_port();
1708        // Send from system -> child via a message that ultimately
1709        // replies to client's port
1710        echo1.post(&sys_inst, port3.bind());
1711        let id3 = tokio::time::timeout(Duration::from_secs(5), rx3.recv())
1712            .await
1713            .unwrap()
1714            .unwrap();
1715        assert_eq!(id3, *echo1.actor_addr());
1716    }
1717
1718    #[tokio::test]
1719    async fn local_ready_and_wait_are_immediate() {
1720        // Build a LocalHandle directly.
1721        let addr = ChannelAddr::any(ChannelTransport::Local);
1722        let proc_ref = ResourceId::proc_addr_from_name(addr.clone(), "p");
1723        let actor_ref = proc_ref.actor_addr("host_agent");
1724        let agent_ref = ActorRef::<()>::attest(actor_ref);
1725        let h = LocalHandle::<()> {
1726            proc_id: proc_ref,
1727            addr,
1728            agent_ref,
1729            procs: Arc::new(Mutex::new(HashMap::new())),
1730        };
1731
1732        // ready() resolves immediately
1733        assert!(h.ready().await.is_ok());
1734
1735        // wait() resolves immediately with unit TerminalStatus
1736        assert!(h.wait().await.is_ok());
1737
1738        // Multiple concurrent waiters both succeed
1739        let (r1, r2) = tokio::join!(h.ready(), h.ready());
1740        assert!(r1.is_ok() && r2.is_ok());
1741    }
1742
1743    // --
1744    // Fixtures for `host::spawn` tests.
1745
1746    #[derive(Debug, Clone, Copy)]
1747    enum ReadyMode {
1748        OkAfter(Duration),
1749        Pending,
1750        ErrTerminal,
1751        ErrChannelClosed,
1752    }
1753
1754    #[derive(Debug, Clone)]
1755    struct TestHandle {
1756        id: ProcAddr,
1757        addr: ChannelAddr,
1758        agent: ActorRef<()>,
1759        mode: ReadyMode,
1760        omit_addr: bool,
1761        omit_agent: bool,
1762    }
1763
1764    #[async_trait::async_trait]
1765    impl ProcHandle for TestHandle {
1766        type Agent = ();
1767        type TerminalStatus = ();
1768
1769        fn proc_addr(&self) -> &ProcAddr {
1770            &self.id
1771        }
1772
1773        fn addr(&self) -> Option<ChannelAddr> {
1774            if self.omit_addr {
1775                None
1776            } else {
1777                Some(self.addr.clone())
1778            }
1779        }
1780
1781        fn agent_ref(&self) -> Option<ActorRef<Self::Agent>> {
1782            if self.omit_agent {
1783                None
1784            } else {
1785                Some(self.agent.clone())
1786            }
1787        }
1788
1789        async fn ready(&self) -> Result<(), ReadyError<Self::TerminalStatus>> {
1790            match self.mode {
1791                ReadyMode::OkAfter(d) => {
1792                    if !d.is_zero() {
1793                        tokio::time::sleep(d).await;
1794                    }
1795                    Ok(())
1796                }
1797                ReadyMode::Pending => std::future::pending().await,
1798                ReadyMode::ErrTerminal => Err(ReadyError::Terminal(())),
1799                ReadyMode::ErrChannelClosed => Err(ReadyError::ChannelClosed),
1800            }
1801        }
1802        async fn wait(&self) -> Result<Self::TerminalStatus, WaitError> {
1803            Ok(())
1804        }
1805        async fn terminate(
1806            &self,
1807            _cx: &impl context::Actor,
1808            _timeout: Duration,
1809            _reason: &str,
1810        ) -> Result<Self::TerminalStatus, TerminateError<Self::TerminalStatus>> {
1811            Err(TerminateError::Unsupported)
1812        }
1813        async fn kill(&self) -> Result<Self::TerminalStatus, TerminateError<Self::TerminalStatus>> {
1814            Err(TerminateError::Unsupported)
1815        }
1816    }
1817
1818    #[derive(Debug, Clone)]
1819    struct TestManager {
1820        mode: ReadyMode,
1821        omit_addr: bool,
1822        omit_agent: bool,
1823        transport: ChannelTransport,
1824    }
1825
1826    impl TestManager {
1827        fn local(mode: ReadyMode) -> Self {
1828            Self {
1829                mode,
1830                omit_addr: false,
1831                omit_agent: false,
1832                transport: ChannelTransport::Local,
1833            }
1834        }
1835        fn with_omissions(mut self, addr: bool, agent: bool) -> Self {
1836            self.omit_addr = addr;
1837            self.omit_agent = agent;
1838            self
1839        }
1840    }
1841
1842    #[async_trait::async_trait]
1843    impl ProcManager for TestManager {
1844        type Handle = TestHandle;
1845
1846        fn transport(&self) -> ChannelTransport {
1847            self.transport.clone()
1848        }
1849
1850        async fn spawn(
1851            &self,
1852            proc_id: ProcAddr,
1853            forwarder_addr: ChannelAddr,
1854            _config: (),
1855        ) -> Result<Self::Handle, HostError> {
1856            let agent = ActorRef::<()>::attest(proc_id.actor_addr("host_agent"));
1857            Ok(TestHandle {
1858                id: proc_id,
1859                addr: forwarder_addr,
1860                agent,
1861                mode: self.mode,
1862                omit_addr: self.omit_addr,
1863                omit_agent: self.omit_agent,
1864            })
1865        }
1866    }
1867
1868    #[tokio::test]
1869    async fn host_spawn_times_out_when_configured() {
1870        let cfg = hyperactor_config::global::lock();
1871        let _g = cfg.override_key(
1872            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1873            Duration::from_millis(10),
1874        );
1875
1876        let mut host = Host::new(
1877            TestManager::local(ReadyMode::Pending),
1878            ChannelAddr::any(ChannelTransport::Local),
1879        )
1880        .await
1881        .unwrap();
1882
1883        let err = host.spawn("t".into(), ()).await.expect_err("must time out");
1884        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
1885    }
1886
1887    #[tokio::test]
1888    async fn host_spawn_timeout_zero_disables_and_succeeds() {
1889        let cfg = hyperactor_config::global::lock();
1890        let _g = cfg.override_key(
1891            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
1892            Duration::from_secs(0),
1893        );
1894
1895        let mut host = Host::new(
1896            TestManager::local(ReadyMode::OkAfter(Duration::from_millis(20))),
1897            ChannelAddr::any(ChannelTransport::Local),
1898        )
1899        .await
1900        .unwrap();
1901
1902        let (pid, agent) = host.spawn("ok".into(), ()).await.expect("must succeed");
1903        assert_eq!(agent.actor_addr().proc_addr(), pid);
1904        assert!(host.procs.contains_key("ok"));
1905    }
1906
1907    #[tokio::test]
1908    async fn host_spawn_maps_channel_closed_ready_error_to_config_failure() {
1909        let mut host = Host::new(
1910            TestManager::local(ReadyMode::ErrChannelClosed),
1911            ChannelAddr::any(ChannelTransport::Local),
1912        )
1913        .await
1914        .unwrap();
1915
1916        let err = host.spawn("p".into(), ()).await.expect_err("must fail");
1917        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
1918    }
1919
1920    #[tokio::test]
1921    async fn host_spawn_maps_terminal_ready_error_to_config_failure() {
1922        let mut host = Host::new(
1923            TestManager::local(ReadyMode::ErrTerminal),
1924            ChannelAddr::any(ChannelTransport::Local),
1925        )
1926        .await
1927        .unwrap();
1928
1929        let err = host.spawn("p".into(), ()).await.expect_err("must fail");
1930        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
1931    }
1932
1933    #[tokio::test]
1934    async fn host_spawn_fails_if_ready_but_missing_addr() {
1935        let mut host = Host::new(
1936            TestManager::local(ReadyMode::OkAfter(Duration::ZERO)).with_omissions(true, false),
1937            ChannelAddr::any(ChannelTransport::Local),
1938        )
1939        .await
1940        .unwrap();
1941
1942        let err = host
1943            .spawn("no-addr".into(), ())
1944            .await
1945            .expect_err("must fail");
1946        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
1947    }
1948
1949    #[tokio::test]
1950    async fn host_spawn_fails_if_ready_but_missing_agent() {
1951        let mut host = Host::new(
1952            TestManager::local(ReadyMode::OkAfter(Duration::ZERO)).with_omissions(false, true),
1953            ChannelAddr::any(ChannelTransport::Local),
1954        )
1955        .await
1956        .unwrap();
1957
1958        let err = host
1959            .spawn("no-agent".into(), ())
1960            .await
1961            .expect_err("must fail");
1962        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
1963    }
1964
1965    // test_duplex_remote_proc, test_duplex_undeliverable_from_client,
1966    // test_duplex_undeliverable_from_host, and test_duplex_teardown
1967    // were removed: proc-level attach is gone. Gateway-attach is
1968    // exercised by the via tests in `hyperactor::gateway::tests`;
1969    // undeliverable bouncing is unchanged at the host level and is
1970    // exercised by the gateway-attach tests.
1971
1972    /// Repro for the OSS broken-link issue: when the host's duplex
1973    /// frontend shuts down with messages still on the wire, the
1974    /// simplex peer must see a clean close (and pending acks must
1975    /// flush) rather than retry-looping for `MESSAGE_DELIVERY_TIMEOUT`.
1976    ///
1977    /// Before the fix: the peer's `NetTx` got no acks for in-flight
1978    /// messages and no `Closed` response, so it spent the full 30 s
1979    /// `MESSAGE_DELIVERY_TIMEOUT` reconnecting against a dead host.
1980    ///
1981    /// This test posts a message, then stops the serve handle and
1982    /// asserts the simplex `NetTx` transitions to `Closed` quickly —
1983    /// well under `MESSAGE_DELIVERY_TIMEOUT`.
1984    #[tokio::test]
1985    async fn test_simplex_peer_sees_clean_close_on_host_shutdown() {
1986        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
1987            Ok(proc.spawn_with_label::<EchoActor>("host_agent", EchoActor))
1988        });
1989        let mut host =
1990            Host::new_with_default(proc_manager, ChannelAddr::any(ChannelTransport::Unix), None)
1991                .await
1992                .unwrap();
1993        let mut serve_handle = host.take_frontend_handle().unwrap();
1994
1995        // Spawn an EchoActor and send a request from a simplex client.
1996        let echo_handle = host.system_proc().spawn(EchoActor);
1997        let echo_ref = echo_handle.bind::<EchoActor>();
1998
1999        let dial_router = DialMailboxRouter::new();
2000        dial_router.bind(
2001            Addr::from(host.system_proc().proc_addr().clone()),
2002            host.addr().clone(),
2003        );
2004        let client_addr = ChannelAddr::any(ChannelTransport::Unix);
2005        let (client_listen_addr, client_rx) = channel::serve(client_addr).unwrap();
2006        let client_proc_id = ResourceId::proc_addr_from_name(client_listen_addr, "client");
2007        let client_proc = Proc::configured(client_proc_id, dial_router.into_boxed());
2008        let _client_handle = client_proc.clone().serve(client_rx);
2009
2010        let client_inst = client_proc.client("requester");
2011        let (reply_port, reply_handle) = client_inst.mailbox().open_once_port::<ActorAddr>();
2012        let reply_port = reply_port.bind();
2013        echo_ref
2014            .port::<OncePortRef<ActorAddr>>()
2015            .post(&client_inst, reply_port);
2016        let _ = tokio::time::timeout(Duration::from_secs(5), reply_handle.recv())
2017            .await
2018            .expect("baseline round-trip timed out")
2019            .expect("baseline recv failed");
2020
2021        // Snapshot the client's outbound NetTx status before shutdown.
2022        let host_tx = channel::dial::<MessageEnvelope>(host.addr().clone()).unwrap();
2023        // Push one message so the lazy-connect kicks in.
2024        let dummy_dest = host
2025            .system_proc()
2026            .proc_addr()
2027            .actor_addr("noop")
2028            .port_addr(Port::from(0u64));
2029        let envelope = MessageEnvelope::serialize(
2030            client_inst.self_addr().clone(),
2031            dummy_dest,
2032            &"warmup".to_string(),
2033            Default::default(),
2034        )
2035        .unwrap();
2036        host_tx.post(envelope);
2037        // Wait briefly for connection to establish.
2038        tokio::time::sleep(Duration::from_millis(200)).await;
2039        assert!(matches!(*host_tx.status().borrow(), TxStatus::Active));
2040
2041        // Shut down the host's frontend. The fix ensures pending
2042        // recv-side acks are flushed AND a `Closed` response is sent,
2043        // so the simplex peer transitions to `Closed` promptly.
2044        serve_handle.stop("test shutdown");
2045        let _ = tokio::time::timeout(Duration::from_secs(5), serve_handle.join())
2046            .await
2047            .expect("serve handle did not resolve");
2048
2049        // The simplex peer should see Closed within a few seconds —
2050        // not the full MESSAGE_DELIVERY_TIMEOUT (30 s). Wait for the
2051        // status watch to flip.
2052        let mut status = host_tx.status().clone();
2053        tokio::time::timeout(Duration::from_secs(10), async {
2054            loop {
2055                if let TxStatus::Closed(_) = *status.borrow() {
2056                    return;
2057                }
2058                if status.changed().await.is_err() {
2059                    return;
2060                }
2061            }
2062        })
2063        .await
2064        .expect("simplex peer did not see Closed within 10s of host shutdown");
2065
2066        match &*host_tx.status().borrow() {
2067            TxStatus::Closed(_) => {}
2068            other => panic!("expected TxStatus::Closed, got {:?}", other),
2069        }
2070    }
2071
2072    /// Stress repro: many simplex clients send rapid request+reply
2073    /// traffic to the host's duplex frontend and the host shuts down
2074    /// while traffic is in flight. This mirrors the OSS test pattern
2075    /// where `HostMeshShutdownGuard::drop` sends `ShutdownHost`.
2076    #[tokio::test]
2077    async fn test_simplex_clients_during_host_shutdown() {
2078        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2079            Ok(proc.spawn_with_label::<EchoActor>("host_agent", EchoActor))
2080        });
2081        let mut host =
2082            Host::new_with_default(proc_manager, ChannelAddr::any(ChannelTransport::Unix), None)
2083                .await
2084                .unwrap();
2085        let mut serve_handle = host.take_frontend_handle().unwrap();
2086
2087        let echo_handle = host.system_proc().spawn(EchoActor);
2088        let echo_ref = echo_handle.bind::<EchoActor>();
2089        let host_addr = host.addr().clone();
2090        let echo_actor_id = echo_ref.actor_addr().clone();
2091        let system_proc_id = host.system_proc().proc_addr().clone();
2092
2093        // Spawn N clients, each sending M requests.
2094        const N_CLIENTS: usize = 4;
2095        const M_REQUESTS: usize = 5;
2096
2097        let mut client_tasks = Vec::new();
2098        for ci in 0..N_CLIENTS {
2099            let host_addr = host_addr.clone();
2100            let echo_actor_id = echo_actor_id.clone();
2101            let system_proc_id = system_proc_id.clone();
2102            client_tasks.push(tokio::spawn(async move {
2103                let dial_router = DialMailboxRouter::new();
2104                dial_router.bind(Addr::from(system_proc_id.clone()), host_addr);
2105                let client_addr = ChannelAddr::any(ChannelTransport::Unix);
2106                let (client_listen_addr, client_rx) = channel::serve(client_addr).unwrap();
2107                let client_proc_id =
2108                    ResourceId::proc_addr_from_name(client_listen_addr, format!("client-{}", ci));
2109                let client_proc = Proc::configured(client_proc_id, dial_router.into_boxed());
2110                let _client_handle = client_proc.clone().serve(client_rx);
2111
2112                let echo_ref = ActorRef::<EchoActor>::attest(echo_actor_id);
2113
2114                for ri in 0..M_REQUESTS {
2115                    let client_inst = client_proc.client(&format!("req-{}", ri));
2116                    let (reply_port, reply_handle) =
2117                        client_inst.mailbox().open_once_port::<ActorAddr>();
2118                    let reply_port = reply_port.bind();
2119                    echo_ref
2120                        .port::<OncePortRef<ActorAddr>>()
2121                        .post(&client_inst, reply_port);
2122                    let received =
2123                        tokio::time::timeout(Duration::from_secs(10), reply_handle.recv())
2124                            .await
2125                            .expect("timeout waiting for reply")
2126                            .expect("recv failed");
2127                    assert_eq!(received, *echo_ref.actor_addr());
2128                }
2129            }));
2130        }
2131
2132        for task in client_tasks {
2133            task.await.unwrap();
2134        }
2135
2136        // Shut down. The handle must resolve cleanly.
2137        serve_handle.stop("test cleanup");
2138        tokio::time::timeout(Duration::from_secs(10), serve_handle.join())
2139            .await
2140            .expect("serve handle did not resolve")
2141            .expect("serve task error");
2142    }
2143
2144    /// Repro for the broken-link errors seen in OSS Python tests:
2145    /// an external simplex `Proc::direct` dialing the host's duplex
2146    /// frontend should be able to round-trip a request + reply.
2147    #[tokio::test]
2148    async fn test_simplex_client_to_duplex_host() {
2149        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2150            Ok(proc.spawn_with_label::<EchoActor>("host_agent", EchoActor))
2151        });
2152        let host =
2153            Host::new_with_default(proc_manager, ChannelAddr::any(ChannelTransport::Unix), None)
2154                .await
2155                .unwrap();
2156
2157        // Spawn an EchoActor on the host's system_proc.
2158        let echo_handle = host.system_proc().spawn(EchoActor);
2159        let echo_ref = echo_handle.bind::<EchoActor>();
2160
2161        // Create an external simplex client proc with a dial router
2162        // bound to the host's frontend address. This mirrors what the
2163        // Python "root client" does: a `Proc::direct` whose forwarder
2164        // is a `DialMailboxRouter` with the host's frontend address as
2165        // a route to the host's procs.
2166        let client_addr = ChannelAddr::any(ChannelTransport::Unix);
2167        let dial_router = DialMailboxRouter::new();
2168        dial_router.bind(
2169            Addr::from(host.system_proc().proc_addr().clone()),
2170            host.addr().clone(),
2171        );
2172        let (client_listen_addr, client_rx) = channel::serve(client_addr).unwrap();
2173        let client_proc_id = ResourceId::proc_addr_from_name(client_listen_addr, "external-client");
2174        let client_proc = Proc::configured(client_proc_id, dial_router.into_boxed());
2175        let _client_handle = client_proc.clone().serve(client_rx);
2176
2177        let client_inst = client_proc.client("requester");
2178
2179        // Send a request to the echo actor on the host. The reply
2180        // travels back through the host's dial router → simplex dial
2181        // → client's frontend.
2182        let (reply_port, reply_handle) = client_inst.mailbox().open_once_port::<ActorAddr>();
2183        let reply_port = reply_port.bind();
2184        echo_ref
2185            .port::<OncePortRef<ActorAddr>>()
2186            .post(&client_inst, reply_port);
2187
2188        let received = tokio::time::timeout(Duration::from_secs(10), reply_handle.recv())
2189            .await
2190            .expect("timed out waiting for reply")
2191            .expect("recv failed");
2192        assert_eq!(received, *echo_ref.actor_addr());
2193    }
2194
2195    #[tokio::test]
2196    async fn test_spawn_uses_latest_serve_location_after_prior_default_override() {
2197        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2198            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2199        });
2200        let gateway = Gateway::new();
2201        let attached_host_addr: ChannelAddr = "tcp:10.0.159.108:26600".parse().unwrap();
2202        let attached_uid = Uid::instance(Label::strip("attached"));
2203        let attached_location = Location::from(attached_host_addr).with_via(attached_uid);
2204        gateway.set_default_location(attached_location.clone());
2205
2206        let mut host = Host::new_with_gateway(
2207            proc_manager,
2208            ChannelAddr::any(ChannelTransport::Unix),
2209            None,
2210            gateway,
2211            None,
2212        )
2213        .await
2214        .unwrap();
2215
2216        let (proc_id, _agent) = host.spawn("proc1".to_string(), ()).await.unwrap();
2217        let (proc_uid, host_location) = proc_id
2218            .location()
2219            .as_via()
2220            .expect("spawned proc must carry child via");
2221
2222        assert_eq!(proc_uid, proc_id.id().uid());
2223        assert_eq!(host_location.as_ref(), &Location::from(host.addr().clone()));
2224        assert_ne!(host_location.as_ref(), &attached_location);
2225    }
2226
2227    #[tokio::test]
2228    async fn test_spawn_uses_latest_direct_gateway_serve_location() {
2229        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2230            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2231        });
2232
2233        let mut host = Host::new_with_gateway(
2234            proc_manager,
2235            ChannelAddr::any(ChannelTransport::Unix),
2236            None,
2237            Gateway::new(),
2238            None,
2239        )
2240        .await
2241        .unwrap();
2242        let mut later_frontend = host
2243            .gateway()
2244            .serve_with_listener(ChannelAddr::any(ChannelTransport::Unix), None)
2245            .unwrap();
2246        let later_location = host.gateway().default_location();
2247        assert_ne!(later_location.addr(), host.addr());
2248
2249        let (proc_id, _agent) = host.spawn("proc1".to_string(), ()).await.unwrap();
2250        let (proc_uid, host_location) = proc_id
2251            .location()
2252            .as_via()
2253            .expect("spawned proc must carry child via");
2254
2255        assert_eq!(proc_uid, proc_id.id().uid());
2256        assert_eq!(host_location.as_ref(), &later_location);
2257
2258        later_frontend.stop("test cleanup");
2259        later_frontend.join().await.unwrap();
2260    }
2261
2262    // Regression: an out-of-cluster client (`via` set) must advertise
2263    // its refs at the `Via` location so in-cluster hosts route return
2264    // traffic back over the duplex. Before the fix, the host's own
2265    // frontend serve clobbered the via as `default_location`, so refs
2266    // carried a bare, cluster-unreachable address and the attach-time
2267    // config-push acks timed out (`MESH_ATTACH_CONFIG_TIMEOUT`).
2268    #[tokio::test]
2269    async fn test_new_with_gateway_via_advertises_via_location() {
2270        // A remote gateway accepting duplex attaches stands in for the
2271        // in-cluster host the client attaches to.
2272        let remote_gw = Gateway::new();
2273        let mut remote_accept = remote_gw
2274            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2275            .unwrap();
2276        let remote_addr = remote_gw.default_location().addr().clone();
2277
2278        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2279            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2280        });
2281
2282        let host = Host::new_with_gateway(
2283            proc_manager,
2284            ChannelAddr::any(ChannelTransport::Unix),
2285            None,
2286            Gateway::new(),
2287            Some(remote_addr.clone()),
2288        )
2289        .await
2290        .unwrap();
2291
2292        // The gateway must advertise the Via (not the bare local
2293        // frontend) as its default location for newly bound refs.
2294        let default_location = host.gateway().default_location();
2295        let (via_uid, inner) = default_location
2296            .as_via()
2297            .expect("default location must carry the via prefix");
2298        assert_eq!(via_uid, host.gateway().uid());
2299        assert_eq!(inner.addr(), &remote_addr);
2300
2301        // The built-in service proc, minted after `serve_via`, must
2302        // also carry the Via — it advertised a bare address before the
2303        // fix.
2304        let svc_proc_addr = host.system_proc().proc_addr();
2305        let (_, svc_inner) = svc_proc_addr
2306            .location()
2307            .as_via()
2308            .expect("service proc must carry the via prefix");
2309        assert_eq!(svc_inner.addr(), &remote_addr);
2310
2311        remote_accept.stop("test cleanup");
2312    }
2313}