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 its
18//! 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::collections::hash_map::Entry;
63use std::fmt;
64use std::marker::PhantomData;
65use std::panic::AssertUnwindSafe;
66use std::str::FromStr;
67use std::sync::Arc;
68use std::sync::Mutex as StdMutex;
69use std::time::Duration;
70
71use async_trait::async_trait;
72use futures::Future;
73use futures::FutureExt;
74use futures::StreamExt;
75use futures::stream;
76use hyperactor::Actor;
77use hyperactor::ActorAddr;
78use hyperactor::ActorHandle;
79use hyperactor::ActorRef;
80use hyperactor::Gateway;
81use hyperactor::Location;
82use hyperactor::Proc;
83use hyperactor::ProcAddr;
84use hyperactor::ProcId;
85use hyperactor::actor::Binds;
86use hyperactor::actor::Referable;
87use hyperactor::channel;
88use hyperactor::channel::ChannelAddr;
89use hyperactor::channel::ChannelError;
90use hyperactor::channel::ChannelTransport;
91use hyperactor::channel::Rx;
92use hyperactor::channel::ServerError;
93use hyperactor::channel::Tx;
94use hyperactor::context;
95use hyperactor::gateway::GatewayServeHandle;
96use hyperactor::gateway::PeerAttachError;
97use hyperactor::gateway::PeerAttachGuard;
98use hyperactor::id::Label;
99use hyperactor::mailbox::IntoBoxedMailboxSender as _;
100use hyperactor::mailbox::MailboxClient;
101use hyperactor::mailbox::MailboxServer;
102use tokio::process::Child;
103use tokio::process::Command;
104use tokio::sync::Mutex;
105
106use crate::mesh_id::ResourceId;
107
108/// Name of the system service proc on a host.
109///
110/// Hosts the admin actor layer: HostMeshAgent, MeshAdminAgent, and bridge.
111pub const SERVICE_PROC_NAME: &str = hyperactor::proc::LEGACY_SERVICE_PROC_NAME;
112
113pub(crate) fn legacy_service_proc_id() -> ProcId {
114    ProcId::singleton(Label::strip(SERVICE_PROC_NAME))
115}
116
117/// Name of the local client proc on a host.
118///
119/// See LP-1 (lazy activation) in module doc.
120///
121/// In pure-Rust programs (e.g. sieve, dining_philosophers)
122/// `GetLocalProc` is never sent, so the local proc remains empty
123/// throughout the program's lifetime. Code that inspects the local
124/// proc's actors must not assume they exist.
125pub const LOCAL_PROC_NAME: &str = "local";
126
127/// The type of error produced by host operations.
128#[derive(Debug, thiserror::Error)]
129pub enum HostError {
130    /// A channel error occurred during a host operation.
131    #[error(transparent)]
132    ChannelError(#[from] ChannelError),
133
134    /// A duplex server error occurred during a host operation.
135    #[error(transparent)]
136    ServerError(#[from] ServerError),
137
138    /// The named proc already exists and cannot be spawned.
139    #[error("proc '{0}' already exists")]
140    ProcExists(String),
141
142    /// The host's proc-name registry cannot be used safely.
143    #[error("host proc-name registry is poisoned")]
144    ProcRegistryPoisoned,
145
146    /// Attaching a spawned proc to the host gateway failed.
147    #[error("failed to attach proc '{0}' to the host gateway: {1}")]
148    PeerAttachFailure(ProcAddr, #[source] PeerAttachError),
149
150    /// The detached task spawning a proc panicked.
151    #[error("proc '{0}' spawn panicked: {1}")]
152    SpawnPanicked(String, String),
153
154    /// Failures occuring while spawning a subprocess.
155    #[error("proc '{0}' (command: {1}) failed to spawn process: {2}")]
156    ProcessSpawnFailure(ProcAddr, String, #[source] std::io::Error),
157
158    /// Failures occuring while configuring a subprocess.
159    #[error("proc '{0}' failed to configure process: {1}")]
160    ProcessConfigurationFailure(ProcAddr, #[source] anyhow::Error),
161
162    /// Failures occuring while spawning a management actor in a proc.
163    #[error("failed to spawn agent on proc '{0}': {1}")]
164    AgentSpawnFailure(ProcAddr, #[source] anyhow::Error),
165
166    /// An input parameter was missing.
167    #[error("parameter '{0}' missing: {1}")]
168    MissingParameter(String, std::env::VarError),
169
170    /// An input parameter was invalid.
171    #[error("parameter '{0}' invalid: {1}")]
172    InvalidParameter(String, anyhow::Error),
173
174    /// Attaching the gateway to a remote `serve_via` session failed.
175    #[error("failed to attach gateway via session: {0}")]
176    ViaAttachFailure(#[source] anyhow::Error),
177
178    /// Constructing a built-in proc failed.
179    #[error("failed to construct built-in proc: {0}")]
180    ProcConstructionFailure(#[source] anyhow::Error),
181
182    /// The service proc startup address contained a source route.
183    #[error("host service proc address must have a direct frontend location, got {0}")]
184    InvalidFrontendLocation(Location),
185}
186
187/// Client transport handles transferred to the bootstrap shutdown task.
188///
189/// Dropping this value stops any handles that have not already been joined,
190/// so cancellation cannot detach a live frontend or via transport.
191#[derive(Debug)]
192pub(crate) struct HostShutdownHandles {
193    frontend: Option<GatewayServeHandle>,
194    via: Option<GatewayServeHandle>,
195}
196
197impl HostShutdownHandles {
198    pub(crate) fn new(frontend: GatewayServeHandle, via: Option<GatewayServeHandle>) -> Self {
199        Self {
200            frontend: Some(frontend),
201            via,
202        }
203    }
204
205    pub(crate) async fn stop_and_join(mut self, reason: &str) {
206        if let Some(mut frontend) = self.frontend.take() {
207            frontend.stop(reason);
208            if let Err(error) = frontend.join().await {
209                tracing::warn!(%error, "failed to join host shutdown frontend");
210            }
211        }
212        if let Some(mut via) = self.via.take() {
213            via.stop(reason);
214            if let Err(error) = via.join().await {
215                tracing::warn!(%error, "failed to join host shutdown via transport");
216            }
217        }
218    }
219}
220
221impl Drop for HostShutdownHandles {
222    fn drop(&mut self) {
223        if let Some(frontend) = &mut self.frontend {
224            frontend.stop("host shutdown handles dropped");
225        }
226        if let Some(via) = &mut self.via {
227            via.stop("host shutdown handles dropped");
228        }
229    }
230}
231
232struct ProcHandleKillGuard<H: ProcHandle> {
233    handle: Option<H>,
234}
235
236impl<H: ProcHandle> ProcHandleKillGuard<H> {
237    fn handle(&self) -> &H {
238        self.handle
239            .as_ref()
240            .expect("active spawn cleanup owns the proc handle")
241    }
242
243    fn disarm(mut self) {
244        self.handle = None;
245    }
246
247    fn maybe_kill(&mut self) -> impl Future<Output = ()> + Send + 'static {
248        let handle = self.handle.take();
249
250        async move {
251            let Some(handle) = handle else {
252                return;
253            };
254            match handle.kill().await {
255                Ok(_) | Err(TerminateError::AlreadyTerminated(_)) => {}
256                Err(error) => {
257                    tracing::warn!(proc = %handle.proc_addr(), %error, "failed to clean up proc spawn");
258                }
259            }
260        }
261    }
262}
263
264impl<H: ProcHandle> Drop for ProcHandleKillGuard<H> {
265    fn drop(&mut self) {
266        if self.handle.is_some() {
267            tokio::spawn(self.maybe_kill());
268        }
269    }
270}
271
272/// Lifecycle manager for the procs on one machine.
273///
274/// The host delegates all connectivity to its [`Gateway`]. It creates
275/// built-in service/local procs, asks its [`ProcManager`] to spawn children,
276/// and keeps the gateway peer registrations for those children alive.
277pub struct Host<M> {
278    /// Peer guards for spawned child procs, keyed by name. The stored
279    /// [`PeerAttachGuard`] keeps the gateway peer route for the child
280    /// alive; dropping it removes the entry (used by
281    /// [`Host::terminate_children`] to free slots).
282    procs: Arc<StdMutex<HashMap<String, PeerAttachGuard>>>,
283    frontend_addr: ChannelAddr,
284    backend_addr: ChannelAddr,
285    /// Connectivity for every proc owned by this host.
286    ///
287    /// The built-in procs share the gateway in-process. Spawned children have
288    /// their own gateways and are registered here with
289    /// [`Gateway::attach_peer`].
290    gateway: Gateway,
291    frontend_handle: Option<GatewayServeHandle>,
292    backend_handle: Option<GatewayServeHandle>,
293    /// Duplex `serve_via` session to a remote gateway, present when this
294    /// host was bootstrapped out-of-cluster. Kept alive for the host's
295    /// lifetime so the cluster route and outbound forwarder persist.
296    via_handle: Option<GatewayServeHandle>,
297    manager: Arc<M>,
298    service_proc: Proc,
299    local_proc: Proc,
300}
301
302impl<M: ProcManager> Host<M> {
303    /// Construct a host and start its gateway frontend server on `addr`.
304    pub async fn new(manager: M, addr: ChannelAddr) -> Result<Self, HostError> {
305        Self::new_with_default(manager, addr, None).await
306    }
307
308    /// Like [`new`], but optionally uses an already-bound listener.
309    ///
310    /// When `listener` is `Some`, it is used as the frontend listening socket
311    /// instead of binding a new one.
312    pub async fn new_with_default(
313        manager: M,
314        addr: ChannelAddr,
315        listener: Option<std::net::TcpListener>,
316    ) -> Result<Self, HostError> {
317        // Default to the process-wide global gateway so procs on this
318        // host share one routing table with the rest of the process.
319        // Callers that need a different gateway (e.g. via attach) build
320        // it externally and pass it to [`new_with_gateway`].
321        Self::new_with_gateway(
322            manager,
323            ProcAddr::new(legacy_service_proc_id(), addr.into()),
324            listener,
325            Gateway::global().clone(),
326            None,
327        )
328        .await
329    }
330
331    /// Like [`new_with_default`], but uses a caller-provided [`Gateway`] and
332    /// service [`ProcAddr`].
333    ///
334    /// `addr` is a startup specification: its proc id becomes the service proc
335    /// identity, and its direct location is used to serve the frontend. The
336    /// final service proc location comes from the gateway after binding, so it
337    /// may differ because of an alias, an ephemeral address, or `via`. A
338    /// source-routed [`Location::Via`] is therefore rejected here.
339    ///
340    /// When `via` is `Some`, the gateway attaches to that remote duplex
341    /// address with [`Gateway::serve_via`] *after* the local
342    /// frontend/backend serves but *before* minting the built-in procs,
343    /// so the via session is the newest active serve. That ordering
344    /// makes every ref minted on this host advertise the routable
345    /// `Via` location rather than the bare local frontend, which is
346    /// what lets an out-of-cluster client receive return traffic over
347    /// the duplex.
348    #[hyperactor::instrument(fields(addr=addr.to_string()))]
349    pub async fn new_with_gateway(
350        manager: M,
351        addr: ProcAddr,
352        listener: Option<std::net::TcpListener>,
353        gateway: Gateway,
354        via: Option<ChannelAddr>,
355    ) -> Result<Self, HostError> {
356        let service_proc_id = addr.id().clone();
357        let addr = match addr.location() {
358            Location::Addr(addr) => addr.clone(),
359            Location::Via(..) => {
360                return Err(HostError::InvalidFrontendLocation(addr.location().clone()));
361            }
362        };
363        let mut backend_handle = Gateway::serve(&gateway, ChannelAddr::any(manager.transport()))?;
364        let backend_addr = gateway.default_location().addr().clone();
365
366        // Serve the frontend. Kernel-socket (net) transports use a muxed
367        // listener so simplex clients and duplex attach clients share one
368        // address; the gateway owns both the simplex and (`AttachWire`)
369        // duplex accept paths, so there is a single attach protocol whether
370        // a frontend is muxed or a plain duplex endpoint. `serve_mux`
371        // requires a net transport, so we branch on `is_net()` rather than
372        // `supports_duplex()` (the in-process `Local` transport supports
373        // duplex but is not a kernel socket). Both paths register the bound
374        // address as the gateway's active serve location.
375        let frontend_result: Result<GatewayServeHandle, ChannelError> = if addr.transport().is_net()
376        {
377            gateway.serve_mux_with_listener(addr, listener)
378        } else {
379            gateway.serve_with_listener(addr, listener)
380        };
381        let mut frontend_handle = match frontend_result {
382            Ok(handle) => handle,
383            Err(error) => {
384                backend_handle.stop("host setup failed");
385                if let Err(join_error) = backend_handle.join().await {
386                    tracing::warn!(
387                        error = %join_error,
388                        "failed to join backend server after host setup error"
389                    );
390                }
391                return Err(error.into());
392            }
393        };
394        let frontend_addr = gateway.default_location().addr().clone();
395        assert!(
396            !matches!(frontend_addr, ChannelAddr::Alias { .. }),
397            "gateway frontend serve must canonicalize aliases"
398        );
399
400        // Attach to the remote gateway after the local serves are live
401        // but before any proc or actor ref is minted below. `serve_via`
402        // must be the newest active serve so it supplies the gateway's
403        // `default_location`; otherwise the local frontend serve above
404        // would win, and refs would advertise a bare, cluster-
405        // unreachable address — the out-of-cluster return-path bug.
406        let via_handle = match via {
407            Some(via_addr) => match gateway.serve_via(via_addr).await {
408                Ok(handle) => Some(handle),
409                Err(error) => {
410                    frontend_handle.stop("host setup failed");
411                    if let Err(join_error) = frontend_handle.join().await {
412                        tracing::warn!(
413                            error = %join_error,
414                            "failed to join frontend server after via attach error"
415                        );
416                    }
417                    backend_handle.stop("host setup failed");
418                    if let Err(join_error) = backend_handle.join().await {
419                        tracing::warn!(
420                            error = %join_error,
421                            "failed to join backend server after via attach error"
422                        );
423                    }
424                    return Err(HostError::ViaAttachFailure(error));
425                }
426            },
427            None => None,
428        };
429
430        // Set up the system proc and the local client proc after the
431        // gateway servers are live. The HostAgent is published only
432        // after it binds its handler, so the brief unroutable window is
433        // before normal clients can discover this host.
434        let expected_service_proc_addr =
435            ProcAddr::new(service_proc_id.clone(), gateway.default_location());
436        let service_proc = Proc::builder()
437            .proc_id(service_proc_id)
438            .shared_gateway(gateway.clone())
439            .build()
440            .map_err(HostError::ProcConstructionFailure)?;
441        assert_eq!(
442            service_proc.proc_addr(),
443            expected_service_proc_addr,
444            "service proc must use the gateway's active advertised location"
445        );
446        let local_proc = Proc::builder()
447            .proc_id(ProcId::instance(Label::strip(LOCAL_PROC_NAME)))
448            .shared_gateway(gateway.clone())
449            .build()
450            .map_err(HostError::ProcConstructionFailure)?;
451        let service_proc_id = service_proc.proc_addr().clone();
452        let local_proc_id = local_proc.proc_addr().clone();
453
454        tracing::info!(
455            frontend_addr = frontend_addr.to_string(),
456            backend_addr = backend_addr.to_string(),
457            service_proc_id = service_proc_id.to_string(),
458            local_proc_id = local_proc_id.to_string(),
459            "serving host"
460        );
461
462        Ok(Host {
463            procs: Arc::new(StdMutex::new(HashMap::new())),
464            frontend_addr,
465            backend_addr,
466            gateway,
467            frontend_handle: Some(frontend_handle),
468            backend_handle: Some(backend_handle),
469            via_handle,
470            manager: Arc::new(manager),
471            service_proc,
472            local_proc,
473        })
474    }
475
476    /// The underlying proc manager.
477    pub fn manager(&self) -> &M {
478        self.manager.as_ref()
479    }
480
481    /// The address which accepts messages destined for this host.
482    pub fn addr(&self) -> &ChannelAddr {
483        &self.frontend_addr
484    }
485
486    /// The system proc associated with this host.
487    /// This is used to run host-level system services like host managers.
488    pub fn system_proc(&self) -> &Proc {
489        &self.service_proc
490    }
491
492    /// The local proc associated with this host (`LOCAL_PROC_NAME`).
493    ///
494    /// Starts with zero actors; see invariant LP-1 on
495    /// [`LOCAL_PROC_NAME`] for activation semantics.
496    pub fn local_proc(&self) -> &Proc {
497        &self.local_proc
498    }
499
500    /// Spawn a child proc with the given `name`.
501    ///
502    /// On success, the proc is ready and reachable through the returned
503    /// [`ProcAddr`]. The proc id is derived from `name`; its location is
504    /// advertised through this host's frontend gateway using a `Via(child_uid,
505    /// host_location)` source route.
506    /// The caller must ensure that no other spawn with the same `name` is in
507    /// flight. This method only rejects names that are already registered with the host.
508    pub(crate) fn spawn(
509        &self,
510        name: String,
511        config: M::Config,
512    ) -> impl Future<Output = Result<(ProcAddr, ActorRef<ManagerAgent<M>>), HostError>> + Send + 'static
513    where
514        M: Send + Sync + 'static,
515        M::Config: Send + 'static,
516    {
517        let procs = Arc::clone(&self.procs);
518        let backend_addr = self.backend_addr.clone();
519        let gateway = self.gateway.clone();
520        let manager = Arc::clone(&self.manager);
521
522        async move {
523            if procs
524                .lock()
525                .map_err(|_| HostError::ProcRegistryPoisoned)?
526                .contains_key(&name)
527            {
528                return Err(HostError::ProcExists(name));
529            }
530
531            // Preserve the route to this host, then append the child hop that
532            // is peeled by this host's gateway.
533            let resource_id = ResourceId::from_name(&name);
534            let proc_uid = resource_id.uid().clone();
535            let proc_id =
536                resource_id.proc_addr(gateway.default_location().append_via(proc_uid.clone()));
537            let handle = manager.spawn(proc_id.clone(), backend_addr, config).await?;
538            let mut proc_handle_guard = ProcHandleKillGuard {
539                handle: Some(handle),
540            };
541
542            let created = async {
543                let timeout: Duration =
544                    hyperactor_config::global::get(hyperactor::config::HOST_SPAWN_READY_TIMEOUT);
545
546                let ready = if timeout == Duration::from_secs(0) {
547                    ReadyProc::ensure(proc_handle_guard.handle()).await
548                } else {
549                    match tokio::time::timeout(
550                        timeout,
551                        ReadyProc::ensure(proc_handle_guard.handle()),
552                    )
553                    .await
554                    {
555                        Ok(result) => result,
556                        Err(_elapsed) => Err(ReadyProcError::Timeout),
557                    }
558                }
559                .map_err(|e| {
560                    HostError::ProcessConfigurationFailure(
561                        proc_id.clone(),
562                        anyhow::anyhow!("{e:?}"),
563                    )
564                })?;
565
566                let child_sender = MailboxClient::dial(ready.addr().clone()).map_err(|e| {
567                    HostError::ProcessConfigurationFailure(
568                        proc_id.clone(),
569                        anyhow::anyhow!("failed to dial spawned proc at {}: {}", ready.addr(), e),
570                    )
571                })?;
572                let agent_ref = ready.agent_ref().clone();
573                drop(ready);
574
575                let guard = gateway
576                    .attach_peer(proc_uid.clone(), child_sender.into_boxed())
577                    .map_err(|error| HostError::PeerAttachFailure(proc_id.clone(), error))?;
578                match procs
579                    .lock()
580                    .map_err(|_| HostError::ProcRegistryPoisoned)?
581                    .entry(name.clone())
582                {
583                    Entry::Vacant(entry) => {
584                        entry.insert(guard);
585                    }
586                    Entry::Occupied(_) => return Err(HostError::ProcExists(name.clone())),
587                }
588
589                Ok((proc_id.clone(), agent_ref))
590            }
591            .await;
592
593            match created {
594                Ok(created) => {
595                    proc_handle_guard.disarm();
596                    Ok(created)
597                }
598                Err(error) => {
599                    proc_handle_guard.maybe_kill().await;
600                    Err(error)
601                }
602            }
603        }
604    }
605
606    /// The host's [`Gateway`]. All incoming traffic addressed to this
607    /// host's procs is routed through the gateway: in-process procs
608    /// via the gateway's local delivery path, and spawned child
609    /// proc gateways through peer routes registered with
610    /// [`Gateway::attach_peer`].
611    pub fn gateway(&self) -> &Gateway {
612        &self.gateway
613    }
614
615    /// Remove a child proc's gateway registration and release its name.
616    pub(crate) fn remove_proc(&self, name: &str) {
617        let guard = self
618            .procs
619            .lock()
620            .expect("procs mutex poisoned")
621            .remove(name);
622        drop(guard);
623    }
624
625    /// Take ownership of the frontend server handle.
626    ///
627    /// This is only used by bootstrap shutdown: the host is dropped after
628    /// taking the handle, and the bootstrap join path stops and drains the
629    /// frontend server explicitly.
630    pub(crate) fn take_frontend_handle(&mut self) -> Option<GatewayServeHandle> {
631        self.frontend_handle.take()
632    }
633
634    /// Take the transport handles that must remain live through final client flushes.
635    pub(crate) fn take_shutdown_handles(&mut self) -> HostShutdownHandles {
636        let frontend = self
637            .take_frontend_handle()
638            .expect("host shutdown must own its frontend handle");
639        HostShutdownHandles::new(frontend, self.via_handle.take())
640    }
641
642    /// Stop and join every gateway server owned by this host.
643    pub(crate) async fn shutdown_servers(&mut self) {
644        if let Some(mut handle) = self.frontend_handle.take() {
645            handle.stop("host shutdown");
646            if let Err(error) = handle.join().await {
647                tracing::warn!(%error, "failed to join host frontend server");
648            }
649        }
650        if let Some(mut handle) = self.backend_handle.take() {
651            handle.stop("host shutdown");
652            if let Err(error) = handle.join().await {
653                tracing::warn!(%error, "failed to join host backend server");
654            }
655        }
656        if let Some(mut handle) = self.via_handle.take() {
657            handle.stop("host shutdown");
658            if let Err(error) = handle.join().await {
659                tracing::warn!(%error, "failed to join host via server");
660            }
661        }
662    }
663}
664
665impl<M> Drop for Host<M> {
666    fn drop(&mut self) {
667        if let Some(mut handle) = self.frontend_handle.take() {
668            handle.stop("host dropped");
669        }
670        if let Some(mut handle) = self.backend_handle.take() {
671            handle.stop("host dropped");
672        }
673        if let Some(mut handle) = self.via_handle.take() {
674            handle.stop("host dropped");
675        }
676    }
677}
678
679/// Error returned by [`ProcHandle::ready`].
680#[derive(Debug, Clone)]
681pub enum ReadyError<TerminalStatus> {
682    /// The proc reached a terminal state before becoming Ready.
683    Terminal(TerminalStatus),
684    /// Implementation lost its status channel / cannot observe state.
685    ChannelClosed,
686}
687
688/// Error returned by [`ready_proc`].
689#[derive(Debug, Clone)]
690pub enum ReadyProcError<TerminalStatus> {
691    /// Timed out waiting for ready.
692    Timeout,
693    /// The underlying `ready()` call failed.
694    Ready(ReadyError<TerminalStatus>),
695    /// The handle's `addr()` returned `None` after `ready()` succeeded.
696    MissingAddr,
697    /// The handle's `agent_ref()` returned `None` after `ready()`
698    /// succeeded.
699    MissingAgentRef,
700}
701
702impl<T> From<ReadyError<T>> for ReadyProcError<T> {
703    fn from(e: ReadyError<T>) -> Self {
704        ReadyProcError::Ready(e)
705    }
706}
707
708/// Error returned by [`ProcHandle::wait`].
709#[derive(Debug, Clone)]
710pub enum WaitError {
711    /// Implementation lost its status channel / cannot observe state.
712    ChannelClosed,
713}
714
715/// Error returned by [`ProcHandle::terminate`] and
716/// [`ProcHandle::kill`].
717///
718/// - `Unsupported`: the manager cannot perform the requested proc
719///   signaling (e.g., local/in-process manager that doesn't emulate
720///   kill).
721/// - `AlreadyTerminated(term)`: the proc was already terminal; `term`
722///   is the same value `wait()` would return.
723/// - `ChannelClosed`: the manager lost its lifecycle channel and
724///   cannot reliably observe state transitions.
725/// - `Io(err)`: manager-specific failure delivering the signal or
726///   performing shutdown (e.g., OS error on kill).
727#[derive(Debug)]
728pub enum TerminateError<TerminalStatus> {
729    /// Manager doesn't support signaling (e.g., Local manager).
730    Unsupported,
731    /// A terminal state was already reached while attempting
732    /// terminate/kill.
733    AlreadyTerminated(TerminalStatus),
734    /// Implementation lost its status channel / cannot observe state.
735    ChannelClosed,
736    /// Manager-specific failure to deliver signal or perform
737    /// shutdown.
738    Io(anyhow::Error),
739}
740
741impl<T: fmt::Debug> fmt::Display for TerminateError<T> {
742    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
743        match self {
744            TerminateError::Unsupported => write!(f, "terminate/kill unsupported by manager"),
745            TerminateError::AlreadyTerminated(st) => {
746                write!(f, "proc already terminated (status: {st:?})")
747            }
748            TerminateError::ChannelClosed => {
749                write!(f, "lifecycle channel closed; cannot observe state")
750            }
751            TerminateError::Io(err) => write!(f, "I/O error during terminate/kill: {err}"),
752        }
753    }
754}
755
756impl<T: fmt::Debug> std::error::Error for TerminateError<T> {
757    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
758        match self {
759            TerminateError::Io(err) => Some(err.root_cause()),
760            _ => None,
761        }
762    }
763}
764
765/// Summary of results from a bulk termination attempt.
766///
767/// - `attempted`: total number of child procs for which termination
768///   was attempted.
769/// - `ok`: number of procs successfully terminated (includes those
770///   that were already in a terminal state).
771/// - `failed`: number of procs that could not be terminated (e.g.
772///   signaling errors or lost lifecycle channel).
773#[derive(Debug)]
774pub struct TerminateSummary {
775    /// Total number of child procs for which termination was
776    /// attempted.
777    pub attempted: usize,
778    /// Number of procs that successfully reached a terminal state.
779    ///
780    /// This count includes both procs that exited cleanly after
781    /// `terminate(timeout)` and those that were already in a terminal
782    /// state before termination was attempted.
783    pub ok: usize,
784    /// Number of procs that failed to terminate.
785    ///
786    /// Failures typically arise from signaling errors (e.g., OS
787    /// failure to deliver SIGTERM/SIGKILL) or a lost lifecycle
788    /// channel, meaning the manager could no longer observe state
789    /// transitions.
790    pub failed: usize,
791}
792
793impl fmt::Display for TerminateSummary {
794    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
795        write!(
796            f,
797            "attempted={} ok={} failed={}",
798            self.attempted, self.ok, self.failed
799        )
800    }
801}
802
803#[async_trait::async_trait]
804/// Trait for terminating a single proc.
805pub trait SingleTerminate: Send + Sync {
806    /// Gracefully terminate the given proc.
807    ///
808    /// Initiates a polite shutdown for the child, waits up to `timeout` for
809    /// completion, then escalates to a forceful stop.
810    ///
811    /// Implementation notes:
812    /// - "Polite shutdown" and "forceful stop" are intentionally
813    ///   abstract. Implementors should map these to whatever
814    ///   semantics they control (e.g., proc-level drain/abort, RPCs,
815    ///   OS signals).
816    /// - The operation must be idempotent and tolerate races with
817    ///   concurrent termination or external exits.
818    ///
819    /// # Parameters
820    /// - `timeout`: Per-child grace period before escalation to a
821    ///   forceful stop.
822    /// - `reason`: Human-readable reason for termination.
823    /// Returns a tuple of (polite shutdown actors vec, forceful stop actors vec)
824    async fn terminate_proc(
825        &self,
826        cx: &impl context::Actor,
827        proc: &ProcAddr,
828        timeout: std::time::Duration,
829        reason: &str,
830    ) -> Result<(Vec<ActorAddr>, Vec<ActorAddr>), anyhow::Error>;
831}
832
833/// Trait for managers that can terminate many child **units** in
834/// bulk.
835///
836/// Implementors provide a concurrency-bounded, graceful shutdown over
837/// all currently tracked children (polite stop → wait → forceful
838/// stop), returning a summary of outcomes. The exact stop/kill
839/// semantics are manager-specific: for example, an OS-process manager
840/// might send signals, while an in-process manager might drain/abort
841/// tasks.
842#[async_trait::async_trait]
843pub trait BulkTerminate: Send + Sync {
844    /// Gracefully terminate all known children.
845    ///
846    /// Initiates a polite shutdown for each child, waits up to
847    /// `timeout` for completion, then escalates to a forceful stop
848    /// for any that remain. Work may be done in parallel, capped by
849    /// `max_in_flight`. The returned [`TerminateSummary`] reports how
850    /// many children were attempted, succeeded, and failed.
851    ///
852    /// Implementation notes:
853    /// - "Polite shutdown" and "forceful stop" are intentionally
854    ///   abstract. Implementors should map these to whatever
855    ///   semantics they control (e.g., proc-level drain/abort, RPCs,
856    ///   OS signals).
857    /// - The operation must be idempotent and tolerate races with
858    ///   concurrent termination or external exits.
859    ///
860    /// # Parameters
861    /// - `timeout`: Per-child grace period before escalation to a
862    ///   forceful stop.
863    /// - `max_in_flight`: Upper bound on concurrent terminations (≥
864    ///   1) to prevent resource spikes (I/O, CPU, file descriptors,
865    ///   etc.).
866    async fn terminate_all(
867        &self,
868        cx: &impl context::Actor,
869        timeout: std::time::Duration,
870        max_in_flight: usize,
871        reason: &str,
872    ) -> TerminateSummary;
873}
874
875// Host convenience that's available only when its manager supports
876// bulk termination.
877impl<M: ProcManager + BulkTerminate> Host<M> {
878    /// Gracefully terminate all procs spawned by this host.
879    ///
880    /// Delegates to the underlying manager’s
881    /// [`BulkTerminate::terminate_all`] implementation. Use this to
882    /// perform orderly teardown during scale-down or shutdown.
883    ///
884    /// # Parameters
885    /// - `timeout`: Per-child grace period before escalation.
886    /// - `max_in_flight`: Upper bound on concurrent terminations.
887    ///
888    /// # Returns
889    /// A [`TerminateSummary`] with counts of attempted/ok/failed
890    /// terminations.
891    pub async fn terminate_children(
892        &mut self,
893        cx: &impl context::Actor,
894        timeout: Duration,
895        max_in_flight: usize,
896        reason: &str,
897    ) -> TerminateSummary {
898        let summary = self
899            .manager
900            .terminate_all(cx, timeout, max_in_flight, reason)
901            .await;
902        {
903            let _guards = {
904                let mut entries = self.procs.lock().expect("procs mutex poisoned");
905                std::mem::take(&mut *entries)
906            };
907        }
908        summary
909    }
910}
911
912#[async_trait::async_trait]
913impl<M: ProcManager + SingleTerminate> SingleTerminate for Host<M> {
914    async fn terminate_proc(
915        &self,
916        cx: &impl context::Actor,
917        proc: &ProcAddr,
918        timeout: Duration,
919        reason: &str,
920    ) -> Result<(Vec<ActorAddr>, Vec<ActorAddr>), anyhow::Error> {
921        self.manager.terminate_proc(cx, proc, timeout, reason).await
922    }
923}
924
925/// Capability proving a proc is ready.
926///
927/// [`ReadyProc::ensure`] validates that `addr()` and `agent_ref()`
928/// are available; this type carries that proof, providing infallible
929/// accessors.
930///
931/// Obtain a `ReadyProc` by calling `ready_proc(&handle).await`.
932pub struct ReadyProc<'a, H: ProcHandle> {
933    handle: &'a H,
934    addr: ChannelAddr,
935    agent_ref: ActorRef<H::Agent>,
936}
937
938impl<'a, H: ProcHandle> ReadyProc<'a, H> {
939    /// Wait for a proc to become ready, then return a capability that
940    /// provides infallible access to `addr()` and `agent_ref()`.
941    ///
942    /// This is the type-safe way to obtain the proc's address and
943    /// agent reference. After this function returns `Ok(ready)`, both
944    /// `ready.addr()` and `ready.agent_ref()` are guaranteed to
945    /// succeed.
946    pub async fn ensure(
947        handle: &'a H,
948    ) -> Result<ReadyProc<'a, H>, ReadyProcError<H::TerminalStatus>> {
949        handle.ready().await?;
950        let addr = handle.addr().ok_or(ReadyProcError::MissingAddr)?;
951        let agent_ref = handle.agent_ref().ok_or(ReadyProcError::MissingAgentRef)?;
952        Ok(ReadyProc {
953            handle,
954            addr,
955            agent_ref,
956        })
957    }
958
959    /// The proc's logical address.
960    pub fn proc_addr(&self) -> &ProcAddr {
961        self.handle.proc_addr()
962    }
963
964    /// The proc's address (guaranteed available after ready).
965    pub fn addr(&self) -> &ChannelAddr {
966        &self.addr
967    }
968
969    /// The agent actor reference (guaranteed available after ready).
970    pub fn agent_ref(&self) -> &ActorRef<H::Agent> {
971        &self.agent_ref
972    }
973}
974
975/// Minimal uniform surface for a spawned-**proc** handle returned by
976/// a `ProcManager`. Each manager can return its own concrete handle,
977/// as long as it exposes these. A **proc** is the Hyperactor runtime
978/// + its actors (lifecycle controlled via `Proc` APIs such as
979/// `destroy_and_wait`). A proc **may** be hosted *inside* an OS
980/// **process**, but it is conceptually distinct:
981///
982/// - `LocalProcManager`: runs the proc **in this OS process**; there
983///   is no child process to signal. Lifecycle is entirely proc-level.
984/// - `ProcessProcManager` (test-only here): launches an **external OS
985///   process** which hosts the proc, but this toy manager does
986///   **not** wire a control plane for shutdown, nor an exit monitor.
987///
988/// This trait is therefore written in terms of the **proc**
989/// lifecycle:
990///
991/// - `ready()` resolves when the proc is Ready (mailbox bound; agent
992///   available).
993/// - `wait()` resolves with the proc's terminal status
994///   (Stopped/Killed/Failed).
995/// - `terminate()` requests a graceful shutdown of the *proc* and
996///   waits up to the deadline; managers that also own a child OS
997///   process may escalate to `SIGKILL` if the proc does not exit in
998///   time.
999/// - `kill()` requests an immediate, forced termination. For
1000///    in-process procs, this may be implemented as an immediate
1001///    drain/abort of actor tasks. For external procs, this is
1002///    typically a `SIGKILL`.
1003///
1004/// The shape of the terminal value is `Self::TerminalStatus`.
1005/// Managers that track rich info (exit code, signal, address, agent)
1006/// can expose it; trivial managers may use `()`.
1007///
1008/// Managers that do not support signaling must return `Unsupported`.
1009#[async_trait]
1010pub trait ProcHandle: Clone + Send + Sync + 'static {
1011    /// The agent actor type installed in the proc by the manager.
1012    /// Must implement both:
1013    /// - [`Actor`], because the agent actually runs inside the proc,
1014    ///   and
1015    /// - [`Referable`], so callers can hold `ActorRef<Self::Agent>`.
1016    type Agent: Actor + Referable;
1017
1018    /// The type of terminal status produced when the proc exits.
1019    ///
1020    /// For example, an external proc manager may use a rich status
1021    /// enum (e.g. `ProcStatus`), while an in-process manager may use
1022    /// a trivial unit type. This is the value returned by
1023    /// [`ProcHandle::wait`] and carried by [`ReadyError::Terminal`].
1024    type TerminalStatus: std::fmt::Debug + Clone + Send + Sync + 'static;
1025
1026    /// The proc's logical address on this host.
1027    fn proc_addr(&self) -> &ProcAddr;
1028
1029    /// The proc's address (the one callers bind into the host
1030    /// router). May return `None` before `ready()` completes.
1031    /// Guaranteed to return `Some` after `ready()` succeeds.
1032    ///
1033    /// **Prefer [`ready_proc()`]** for type-safe access that
1034    /// guarantees availability at compile time.
1035    fn addr(&self) -> Option<ChannelAddr>;
1036
1037    /// The agent actor reference hosted in the proc. May return
1038    /// `None` before `ready()` completes. Guaranteed to return `Some`
1039    /// after `ready()` succeeds.
1040    ///
1041    /// **Prefer [`ready_proc()`]** for type-safe access that
1042    /// guarantees availability at compile time.
1043    fn agent_ref(&self) -> Option<ActorRef<Self::Agent>>;
1044
1045    /// Resolves when the proc becomes Ready. Multi-waiter,
1046    /// non-consuming.
1047    async fn ready(&self) -> Result<(), ReadyError<Self::TerminalStatus>>;
1048
1049    /// Resolves with the terminal status (Stopped/Killed/Failed/etc).
1050    /// Multi-waiter, non-consuming.
1051    async fn wait(&self) -> Result<Self::TerminalStatus, WaitError>;
1052
1053    /// Politely stop the proc before the deadline; managers that own
1054    /// a child OS process may escalate to a forced kill at the
1055    /// deadline. Idempotent and race-safe: concurrent callers
1056    /// coalesce; the first terminal outcome wins and all callers
1057    /// observe it via `wait()`.
1058    ///
1059    /// Returns the single terminal status the proc reached (the same
1060    /// value `wait()` will return). Never fabricates terminal states:
1061    /// this is only returned after the exit monitor observes
1062    /// termination.
1063    ///
1064    /// # Parameters
1065    /// - `cx`: The actor context for sending messages.
1066    /// - `timeout`: Grace period before escalation.
1067    /// - `reason`: Human-readable reason for termination.
1068    async fn terminate(
1069        &self,
1070        cx: &impl context::Actor,
1071        timeout: Duration,
1072        reason: &str,
1073    ) -> Result<Self::TerminalStatus, TerminateError<Self::TerminalStatus>>;
1074
1075    /// Force the proc down immediately. For in-process managers this
1076    /// may abort actor tasks; for external managers this typically
1077    /// sends `SIGKILL`. Also idempotent/race-safe; the terminal
1078    /// outcome is the one observed by `wait()`.
1079    async fn kill(&self) -> Result<Self::TerminalStatus, TerminateError<Self::TerminalStatus>>;
1080}
1081
1082/// A trait describing a manager of procs, responsible for bootstrapping
1083/// procs on a host, and managing their lifetimes. The manager spawns an
1084/// `Agent`-typed actor on each proc, responsible for managing the proc.
1085#[async_trait]
1086pub trait ProcManager {
1087    /// Concrete handle type this manager returns.
1088    type Handle: ProcHandle;
1089
1090    /// Additional configuration for the proc, supported by this manager.
1091    type Config = ();
1092
1093    /// The preferred transport for this ProcManager.
1094    /// In practice this will be [`ChannelTransport::Local`]
1095    /// for testing, and [`ChannelTransport::Unix`] for external
1096    /// processes.
1097    fn transport(&self) -> ChannelTransport;
1098
1099    /// Spawn a new proc with the provided proc address. The proc should use
1100    /// `forwarder_addr` for messages destined outside of itself. The returned
1101    /// handle exposes the address that accepts messages for the proc.
1102    ///
1103    /// An agent actor is also spawned, and the corresponding actor
1104    /// ref is returned.
1105    async fn spawn(
1106        &self,
1107        proc_id: ProcAddr,
1108        forwarder_addr: ChannelAddr,
1109        config: Self::Config,
1110    ) -> Result<Self::Handle, HostError>;
1111}
1112
1113/// Type alias for the agent actor managed by a given [`ProcManager`].
1114///
1115/// This resolves to the `Agent` type exposed by the manager's
1116/// associated `Handle` (via [`ProcHandle::Agent`]). It provides a
1117/// convenient shorthand so call sites can refer to
1118/// `ActorRef<ManagerAgent<M>>` instead of the more verbose
1119/// `<M::Handle as ProcHandle>::Agent`.
1120///
1121/// # Example
1122/// ```ignore
1123/// fn takes_agent_ref<M: ProcManager>(r: ActorRef<ManagerAgent<M>>) { … }
1124/// ```
1125pub type ManagerAgent<M> = <<M as ProcManager>::Handle as ProcHandle>::Agent; // rust issue #112792
1126
1127/// Lifecycle status for procs managed by [`LocalProcManager`].
1128///
1129/// Used by [`LocalProcManager::request_stop`] to track background
1130/// teardown progress.
1131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132pub enum LocalProcStatus {
1133    /// A stop has been requested but teardown is still in progress.
1134    Stopping,
1135    /// Teardown completed.
1136    Stopped,
1137}
1138
1139/// A ProcManager that spawns **in-process** procs (test-only).
1140///
1141/// The proc runs inside this same OS process; there is **no** child
1142/// process to signal. Lifecycle is purely proc-level:
1143/// - `terminate(timeout)`: delegates to
1144///   `Proc::destroy_and_wait(timeout)`, which drains and, at the
1145///   deadline, aborts remaining actors.
1146/// - `kill()`: uses a zero deadline to emulate a forced stop via
1147///   `destroy_and_wait(Duration::ZERO)`.
1148/// - `wait()`: trivial (no external lifecycle to observe).
1149///
1150///   No OS signals are sent or required.
1151pub struct LocalProcManager<S> {
1152    procs: Arc<Mutex<HashMap<ProcAddr, Proc>>>,
1153    stopping: Arc<Mutex<HashMap<ProcAddr, tokio::sync::watch::Sender<LocalProcStatus>>>>,
1154    spawn: S,
1155}
1156
1157impl<S> LocalProcManager<S> {
1158    /// Create a new in-process proc manager with the given agent
1159    /// params.
1160    pub fn new(spawn: S) -> Self {
1161        Self {
1162            procs: Arc::new(Mutex::new(HashMap::new())),
1163            stopping: Arc::new(Mutex::new(HashMap::new())),
1164            spawn,
1165        }
1166    }
1167
1168    async fn discard_proc(&self, proc_id: &ProcAddr, reason: &str) {
1169        let proc = self.procs.lock().await.remove(proc_id);
1170        if let Some(mut proc) = proc
1171            && let Err(error) = proc.destroy_and_wait(Duration::ZERO, reason).await
1172        {
1173            tracing::warn!(%proc_id, %error, "failed to discard local proc");
1174        }
1175    }
1176
1177    /// Non-blocking stop: remove the proc and spawn a background task
1178    /// that tears it down.
1179    ///
1180    /// Status transitions through `Stopping` -> `Stopped` and is
1181    /// observable via [`local_proc_status`] and [`watch`]. Idempotent:
1182    /// no-ops if the proc is already stopping or stopped.
1183    pub async fn request_stop(&self, proc: &ProcAddr, timeout: Duration, reason: &str) {
1184        {
1185            let guard = self.stopping.lock().await;
1186            if guard.contains_key(proc) {
1187                return;
1188            }
1189        }
1190
1191        let mut proc_handle = {
1192            let mut guard = self.procs.lock().await;
1193            match guard.remove(proc) {
1194                Some(p) => p,
1195                None => return,
1196            }
1197        };
1198
1199        let proc_ref: ProcAddr = proc_handle.proc_addr().clone();
1200        let (tx, _) = tokio::sync::watch::channel(LocalProcStatus::Stopping);
1201        self.stopping.lock().await.insert(proc_ref.clone(), tx);
1202
1203        let stopping = Arc::clone(&self.stopping);
1204        let reason = reason.to_string();
1205        tokio::spawn(async move {
1206            if let Err(e) = proc_handle.destroy_and_wait(timeout, &reason).await {
1207                tracing::warn!(error = %e, "request_stop(local): destroy_and_wait failed");
1208            }
1209            if let Some(tx) = stopping.lock().await.get(&proc_ref) {
1210                let _ = tx.send(LocalProcStatus::Stopped);
1211            }
1212        });
1213    }
1214
1215    /// Query the lifecycle status of a proc that was stopped via
1216    /// [`request_stop`].
1217    ///
1218    /// Returns `None` if the proc was never stopped through this path.
1219    pub async fn local_proc_status(&self, proc: &ProcAddr) -> Option<LocalProcStatus> {
1220        self.stopping.lock().await.get(proc).map(|tx| *tx.borrow())
1221    }
1222
1223    /// Subscribe to lifecycle status changes for a proc that was
1224    /// stopped via [`request_stop`].
1225    ///
1226    /// Returns `None` if the proc was never stopped through this path.
1227    pub async fn watch(
1228        &self,
1229        proc: &ProcAddr,
1230    ) -> Option<tokio::sync::watch::Receiver<LocalProcStatus>> {
1231        self.stopping
1232            .lock()
1233            .await
1234            .get(proc)
1235            .map(|tx| tx.subscribe())
1236    }
1237}
1238
1239#[async_trait]
1240impl<S> BulkTerminate for LocalProcManager<S>
1241where
1242    S: Send + Sync,
1243{
1244    async fn terminate_all(
1245        &self,
1246        _cx: &impl context::Actor,
1247        timeout: std::time::Duration,
1248        max_in_flight: usize,
1249        reason: &str,
1250    ) -> TerminateSummary {
1251        // Drain procs so we don't hold the lock across awaits and subsequent
1252        // calls to terminate_all don't try to re-terminate.
1253        let procs: Vec<Proc> = {
1254            let mut guard = self.procs.lock().await;
1255            guard.drain().map(|(_, v)| v).collect()
1256        };
1257
1258        let attempted = procs.len();
1259
1260        let results = stream::iter(procs.into_iter().map(|mut p| async move {
1261            // For local manager, graceful proc-level stop.
1262            match p.destroy_and_wait(timeout, reason).await {
1263                Ok(_) => true,
1264                Err(e) => {
1265                    tracing::warn!(error=%e, "terminate_all(local): destroy_and_wait failed");
1266                    false
1267                }
1268            }
1269        }))
1270        .buffer_unordered(max_in_flight.max(1))
1271        .collect::<Vec<bool>>()
1272        .await;
1273
1274        let ok = results.into_iter().filter(|b| *b).count();
1275
1276        TerminateSummary {
1277            attempted,
1278            ok,
1279            failed: attempted.saturating_sub(ok),
1280        }
1281    }
1282}
1283
1284#[async_trait::async_trait]
1285impl<S> SingleTerminate for LocalProcManager<S>
1286where
1287    S: Send + Sync,
1288{
1289    async fn terminate_proc(
1290        &self,
1291        _cx: &impl context::Actor,
1292        proc: &ProcAddr,
1293        timeout: std::time::Duration,
1294        reason: &str,
1295    ) -> Result<(Vec<ActorAddr>, Vec<ActorAddr>), anyhow::Error> {
1296        // Snapshot procs so we don't hold the lock across awaits.
1297        let procs: Option<Proc> = {
1298            let mut guard = self.procs.lock().await;
1299            guard.remove(proc)
1300        };
1301        if let Some(mut p) = procs {
1302            p.destroy_and_wait(timeout, reason).await
1303        } else {
1304            Err(anyhow::anyhow!("proc {} doesn't exist", proc))
1305        }
1306    }
1307}
1308
1309/// A lightweight [`ProcHandle`] for procs managed **in-process** via
1310/// [`LocalProcManager`].
1311///
1312/// This handle wraps the minimal identifying state of a spawned proc:
1313/// - its [`ProcId`] (logical identity on the host),
1314/// - the proc's [`ChannelAddr`] (the address callers bind into the
1315///   host router), and
1316/// - the [`ActorAddr`] to the agent actor hosted in the proc.
1317///
1318/// Unlike external handles, `LocalHandle` does **not** manage an OS
1319/// child process. It provides a uniform surface (`proc_id()`,
1320/// `addr()`, `agent_ref()`) and implements `terminate()`/`kill()` by
1321/// calling into the underlying `Proc::destroy_and_wait`, i.e.,
1322/// **proc-level** shutdown.
1323///
1324/// **Type parameter:** `A` is constrained by the `ProcHandle::Agent`
1325/// bound (`Actor + Referable`).
1326pub struct LocalHandle<A: Actor + Referable> {
1327    proc_id: ProcAddr,
1328    addr: ChannelAddr,
1329    agent_ref: ActorRef<A>,
1330    procs: Arc<Mutex<HashMap<ProcAddr, Proc>>>,
1331}
1332
1333// Manual `Clone` to avoid requiring `A: Clone`.
1334impl<A: Actor + Referable> Clone for LocalHandle<A> {
1335    fn clone(&self) -> Self {
1336        Self {
1337            proc_id: self.proc_id.clone(),
1338            addr: self.addr.clone(),
1339            agent_ref: self.agent_ref.clone(),
1340            procs: Arc::clone(&self.procs),
1341        }
1342    }
1343}
1344
1345#[async_trait]
1346impl<A: Actor + Referable> ProcHandle for LocalHandle<A> {
1347    /// `Agent = A` (inherits `Actor + Referable` from the trait
1348    /// bound).
1349    type Agent = A;
1350    type TerminalStatus = ();
1351
1352    fn proc_addr(&self) -> &ProcAddr {
1353        &self.proc_id
1354    }
1355
1356    fn addr(&self) -> Option<ChannelAddr> {
1357        Some(self.addr.clone())
1358    }
1359
1360    fn agent_ref(&self) -> Option<ActorRef<Self::Agent>> {
1361        Some(self.agent_ref.clone())
1362    }
1363
1364    /// Always resolves immediately: a local proc is created
1365    /// in-process and is usable as soon as the handle exists.
1366    async fn ready(&self) -> Result<(), ReadyError<Self::TerminalStatus>> {
1367        Ok(())
1368    }
1369    /// Always resolves immediately with `()`: a local proc has no
1370    /// external lifecycle to await. There is no OS child process
1371    /// behind this handle.
1372    async fn wait(&self) -> Result<Self::TerminalStatus, WaitError> {
1373        Ok(())
1374    }
1375
1376    async fn terminate(
1377        &self,
1378        _cx: &impl context::Actor,
1379        timeout: Duration,
1380        reason: &str,
1381    ) -> Result<(), TerminateError<Self::TerminalStatus>> {
1382        let mut proc = {
1383            let guard = self.procs.lock().await;
1384            match guard.get(self.proc_addr()) {
1385                Some(p) => p.clone(),
1386                None => {
1387                    // The proc was already removed; treat as already
1388                    // terminal.
1389                    return Err(TerminateError::AlreadyTerminated(()));
1390                }
1391            }
1392        };
1393
1394        // Graceful stop of the *proc* (actors) with a deadline. This
1395        // will drain and then abort remaining actors at expiry.
1396        let _ = proc
1397            .destroy_and_wait(timeout, reason)
1398            .await
1399            .map_err(TerminateError::Io)?;
1400
1401        Ok(())
1402    }
1403
1404    async fn kill(&self) -> Result<(), TerminateError<Self::TerminalStatus>> {
1405        // Forced stop == zero deadline; `destroy_and_wait` will
1406        // immediately abort remaining actors and return.
1407        let mut proc = {
1408            let guard = self.procs.lock().await;
1409            match guard.get(self.proc_addr()) {
1410                Some(p) => p.clone(),
1411                None => return Err(TerminateError::AlreadyTerminated(())),
1412            }
1413        };
1414
1415        let _ = proc
1416            .destroy_and_wait(Duration::from_millis(0), "kill")
1417            .await
1418            .map_err(TerminateError::Io)?;
1419
1420        Ok(())
1421    }
1422}
1423
1424/// Local, in-process ProcManager.
1425///
1426/// **Type bounds:**
1427/// - `A: Actor + Referable + Binds<A>`
1428///   - `Actor`: the agent actually runs inside the proc.
1429///   - `Referable`: callers hold `ActorRef<A>` to the agent; this
1430///     bound is required for typed remote refs.
1431///   - `Binds<A>`: lets the runtime wire the agent's handler ports.
1432/// - `F: Future<Output = anyhow::Result<ActorHandle<A>>> + Send`:
1433///   the spawn closure returns a Send future (we `tokio::spawn` it).
1434/// - `S: Fn(Proc) -> F + Sync`: the factory can be called from
1435///   concurrent contexts.
1436///
1437/// Result handle is `LocalHandle<A>` (whose `Agent = A` via `ProcHandle`).
1438#[async_trait]
1439impl<A, S, F> ProcManager for LocalProcManager<S>
1440where
1441    A: Actor + Referable + Binds<A>,
1442    F: Future<Output = anyhow::Result<ActorHandle<A>>> + Send,
1443    S: Fn(Proc) -> F + Sync,
1444{
1445    type Handle = LocalHandle<A>;
1446
1447    fn transport(&self) -> ChannelTransport {
1448        ChannelTransport::Local
1449    }
1450
1451    #[hyperactor::instrument(fields(proc_id=proc_id.to_string(), addr=forwarder_addr.to_string()))]
1452    async fn spawn(
1453        &self,
1454        proc_id: ProcAddr,
1455        forwarder_addr: ChannelAddr,
1456        _config: (),
1457    ) -> Result<Self::Handle, HostError> {
1458        let transport = forwarder_addr.transport();
1459        let proc = Proc::configured(
1460            proc_id.clone(),
1461            MailboxClient::dial(forwarder_addr)?.into_boxed(),
1462        );
1463        let (proc_addr, rx) = channel::serve(ChannelAddr::any(transport))?;
1464        self.procs
1465            .lock()
1466            .await
1467            .insert(proc_id.clone(), proc.clone());
1468        let _handle = proc.clone().serve(rx);
1469        let agent_handle = match AssertUnwindSafe((self.spawn)(proc)).catch_unwind().await {
1470            Ok(Ok(agent_handle)) => agent_handle,
1471            Ok(Err(error)) => {
1472                self.discard_proc(&proc_id, "proc agent failed to spawn")
1473                    .await;
1474                return Err(HostError::AgentSpawnFailure(proc_id, error));
1475            }
1476            Err(panic) => {
1477                self.discard_proc(&proc_id, "proc agent spawn panicked")
1478                    .await;
1479                std::panic::resume_unwind(panic);
1480            }
1481        };
1482
1483        Ok(LocalHandle {
1484            proc_id,
1485            addr: proc_addr,
1486            agent_ref: agent_handle.bind(),
1487            procs: Arc::clone(&self.procs),
1488        })
1489    }
1490}
1491
1492/// A ProcManager that manages each proc as a **separate OS process**
1493/// (test-only toy).
1494///
1495/// This implementation launches a child via `Command` and relies on
1496/// `kill_on_drop(true)` so that children are SIGKILLed if the manager
1497/// (or host) drops. There is **no** graceful proc control plane or
1498/// exit monitor wired here. `terminate()` and `kill()` therefore
1499/// remove and force-kill the child directly, while `wait()` remains
1500/// trivial and does not report real lifecycle state.
1501///
1502/// It follows a simple protocol:
1503///
1504/// Each process is launched with the following environment variables:
1505/// - `HYPERACTOR_HOST_BACKEND_ADDR`: the backend address to which all messages are forwarded,
1506/// - `HYPERACTOR_HOST_PROC_ID`: the proc id to assign the launched proc, and
1507/// - `HYPERACTOR_HOST_CALLBACK_ADDR`: the channel address with which to return the proc's address
1508///
1509/// The launched proc should also spawn an actor to manage it - the details of this are
1510/// implementation dependent, and outside the scope of the process manager.
1511///
1512/// The function [`boot_proc`] provides a convenient implementation of the
1513/// protocol.
1514pub struct ProcessProcManager<A> {
1515    program: std::path::PathBuf,
1516    children: Arc<Mutex<HashMap<ProcAddr, Child>>>,
1517    _phantom: PhantomData<A>,
1518}
1519
1520struct ProcessChildCleanup {
1521    children: Arc<Mutex<HashMap<ProcAddr, Child>>>,
1522    proc_id: Option<ProcAddr>,
1523}
1524
1525impl ProcessChildCleanup {
1526    fn disarm(mut self) {
1527        self.proc_id = None;
1528    }
1529}
1530
1531impl Drop for ProcessChildCleanup {
1532    fn drop(&mut self) {
1533        let Some(proc_id) = self.proc_id.take() else {
1534            return;
1535        };
1536        let children = Arc::clone(&self.children);
1537
1538        tokio::spawn(async move {
1539            if let Some(mut child) = children.lock().await.remove(&proc_id) {
1540                if let Err(error) = child.kill().await {
1541                    tracing::warn!(%proc_id, %error, "failed to kill interrupted process spawn");
1542                }
1543
1544                if let Err(error) = child.wait().await {
1545                    tracing::warn!(%proc_id, %error, "failed to reap interrupted process spawn");
1546                }
1547            }
1548        });
1549    }
1550}
1551
1552impl<A> ProcessProcManager<A> {
1553    /// Create a new ProcessProcManager that runs the provided
1554    /// command.
1555    pub fn new(program: std::path::PathBuf) -> Self {
1556        Self {
1557            program,
1558            children: Arc::new(Mutex::new(HashMap::new())),
1559            _phantom: PhantomData,
1560        }
1561    }
1562}
1563
1564impl<A> Drop for ProcessProcManager<A> {
1565    fn drop(&mut self) {
1566        // When the manager is dropped, `children` is dropped, which
1567        // drops each `Child` handle. With `kill_on_drop(true)`, the OS
1568        // will SIGKILL the processes. Nothing else to do here.
1569    }
1570}
1571
1572/// A [`ProcHandle`] implementation for procs managed as separate
1573/// OS processes via [`ProcessProcManager`].
1574///
1575/// This handle records the logical identity and connectivity of an
1576/// external child process:
1577/// - its [`ProcId`] (unique identity on the host),
1578/// - the proc's [`ChannelAddr`] (address registered in the host
1579///   router),
1580/// - and the [`ActorRef`] of the agent actor spawned inside the proc.
1581///
1582/// Unlike [`LocalHandle`], this corresponds to a real OS process
1583/// launched by the manager. In this **toy** implementation the handle
1584/// has no graceful shutdown control plane or exit monitor. It retains
1585/// access to the manager's child registry so `terminate()` and `kill()`
1586/// can remove, kill, and reap the process when a host spawn fails.
1587///
1588/// The type bound `A: Actor + Referable` comes from the
1589/// [`ProcHandle::Agent`] requirement: `Actor` because the agent
1590/// actually runs inside the proc, and `Referable` because it must
1591/// be referenceable via [`ActorRef<A>`] (i.e., safe to carry as a
1592/// typed remote reference).
1593#[derive(Debug)]
1594pub struct ProcessHandle<A: Actor + Referable> {
1595    proc_id: ProcAddr,
1596    addr: ChannelAddr,
1597    agent_ref: ActorRef<A>,
1598    children: Arc<Mutex<HashMap<ProcAddr, Child>>>,
1599}
1600
1601// Manual `Clone` to avoid requiring `A: Clone`.
1602impl<A: Actor + Referable> Clone for ProcessHandle<A> {
1603    fn clone(&self) -> Self {
1604        Self {
1605            proc_id: self.proc_id.clone(),
1606            addr: self.addr.clone(),
1607            agent_ref: self.agent_ref.clone(),
1608            children: Arc::clone(&self.children),
1609        }
1610    }
1611}
1612
1613#[async_trait]
1614impl<A: Actor + Referable> ProcHandle for ProcessHandle<A> {
1615    /// Agent must be both an `Actor` (runs in the proc) and a
1616    /// `Referable` (so it can be referenced via `ActorRef<A>`).
1617    type Agent = A;
1618    type TerminalStatus = ();
1619
1620    fn proc_addr(&self) -> &ProcAddr {
1621        &self.proc_id
1622    }
1623
1624    fn addr(&self) -> Option<ChannelAddr> {
1625        Some(self.addr.clone())
1626    }
1627
1628    fn agent_ref(&self) -> Option<ActorRef<Self::Agent>> {
1629        Some(self.agent_ref.clone())
1630    }
1631
1632    /// Resolves immediately. `ProcessProcManager::spawn` returns this
1633    /// handle only after the child has called back with (addr,
1634    /// agent), i.e. after readiness.
1635    async fn ready(&self) -> Result<(), ReadyError<Self::TerminalStatus>> {
1636        Ok(())
1637    }
1638    /// Resolves immediately with `()`. This handle does not track
1639    /// child lifecycle; there is no watcher in this implementation.
1640    async fn wait(&self) -> Result<Self::TerminalStatus, WaitError> {
1641        Ok(())
1642    }
1643
1644    async fn terminate(
1645        &self,
1646        _cx: &impl context::Actor,
1647        _deadline: Duration,
1648        _reason: &str,
1649    ) -> Result<(), TerminateError<Self::TerminalStatus>> {
1650        self.kill().await
1651    }
1652
1653    async fn kill(&self) -> Result<(), TerminateError<Self::TerminalStatus>> {
1654        let Some(mut child) = self.children.lock().await.remove(&self.proc_id) else {
1655            return Err(TerminateError::AlreadyTerminated(()));
1656        };
1657        child
1658            .kill()
1659            .await
1660            .map_err(|error| TerminateError::Io(error.into()))?;
1661        child
1662            .wait()
1663            .await
1664            .map_err(|error| TerminateError::Io(error.into()))?;
1665        Ok(())
1666    }
1667}
1668
1669#[async_trait]
1670impl<A> ProcManager for ProcessProcManager<A>
1671where
1672    // Agent actor runs in the proc (`Actor`) and must be
1673    // referenceable (`Referable`).
1674    A: Actor + Referable + Sync,
1675{
1676    type Handle = ProcessHandle<A>;
1677
1678    fn transport(&self) -> ChannelTransport {
1679        ChannelTransport::Unix
1680    }
1681
1682    #[hyperactor::instrument(fields(proc_id=proc_id.to_string(), addr=forwarder_addr.to_string()))]
1683    async fn spawn(
1684        &self,
1685        proc_id: ProcAddr,
1686        forwarder_addr: ChannelAddr,
1687        _config: (),
1688    ) -> Result<Self::Handle, HostError> {
1689        let (callback_addr, mut callback_rx) =
1690            channel::serve(ChannelAddr::any(ChannelTransport::Unix))?;
1691
1692        let mut cmd = Command::new(&self.program);
1693        cmd.env("HYPERACTOR_HOST_PROC_ID", proc_id.to_string());
1694        cmd.env("HYPERACTOR_HOST_BACKEND_ADDR", forwarder_addr.to_string());
1695        cmd.env("HYPERACTOR_HOST_CALLBACK_ADDR", callback_addr.to_string());
1696
1697        // Lifetime strategy: mark the child with
1698        // `kill_on_drop(true)` so the OS will send SIGKILL if the
1699        // handle is dropped and retain the `Child` in
1700        // `self.children`, tying its lifetime to the manager/host.
1701        //
1702        // This is the simplest viable policy to avoid orphaned
1703        // subprocesses in CI; more sophisticated lifecycle control
1704        // (graceful shutdown, restart) will be layered on later.
1705
1706        // Kill the child when its handle is dropped.
1707        cmd.kill_on_drop(true);
1708
1709        let child = cmd.spawn().map_err(|e| {
1710            HostError::ProcessSpawnFailure(proc_id.clone(), self.program.display().to_string(), e)
1711        })?;
1712
1713        // Retain the handle so it lives for the life of the
1714        // manager/host.
1715        {
1716            let mut children = self.children.lock().await;
1717            children.insert(proc_id.clone(), child);
1718        }
1719        let cleanup = ProcessChildCleanup {
1720            children: Arc::clone(&self.children),
1721            proc_id: Some(proc_id.clone()),
1722        };
1723
1724        // Wait for the child's callback with (addr, agent_ref)
1725        let (proc_addr, agent_ref) = callback_rx.recv().await?;
1726
1727        // TODO(production): For a non-test implementation, plumb a
1728        // shutdown path:
1729        // - expose a proc-level graceful stop RPC on the agent and
1730        //   implement `terminate(timeout)` by invoking it and, on
1731        //   deadline, call `Child::kill()`; implement `kill()` as
1732        //   immediate `Child::kill()`.
1733        // - wire an exit monitor so `wait()` resolves with a real
1734        //   terminal status.
1735        let handle = ProcessHandle {
1736            proc_id,
1737            addr: proc_addr,
1738            agent_ref,
1739            children: Arc::clone(&self.children),
1740        };
1741        cleanup.disarm();
1742        Ok(handle)
1743    }
1744}
1745
1746impl<A> ProcessProcManager<A>
1747where
1748    // `Actor`: runs in the proc; `Referable`: referenceable via
1749    // ActorRef; `Binds<A>`: wires ports.
1750    A: Actor + Referable + Binds<A>,
1751{
1752    /// Boot a process in a ProcessProcManager<A>. Should be called from processes spawned
1753    /// by the process manager. `boot_proc` will spawn the provided actor type (with parameters)
1754    /// onto the newly created Proc, and bind its handler. This allows the user to install an agent to
1755    /// manage the proc itself.
1756    pub async fn boot_proc<S, F>(spawn: S) -> Result<Proc, HostError>
1757    where
1758        S: FnOnce(Proc) -> F,
1759        F: Future<Output = Result<ActorHandle<A>, anyhow::Error>>,
1760    {
1761        let proc_id: ProcAddr = Self::parse_env("HYPERACTOR_HOST_PROC_ID")?;
1762        let backend_addr: ChannelAddr = Self::parse_env("HYPERACTOR_HOST_BACKEND_ADDR")?;
1763        let callback_addr: ChannelAddr = Self::parse_env("HYPERACTOR_HOST_CALLBACK_ADDR")?;
1764        spawn_proc(proc_id, backend_addr, callback_addr, spawn).await
1765    }
1766
1767    fn parse_env<T, E>(key: &str) -> Result<T, HostError>
1768    where
1769        T: FromStr<Err = E>,
1770        E: Into<anyhow::Error>,
1771    {
1772        std::env::var(key)
1773            .map_err(|e| HostError::MissingParameter(key.to_string(), e))?
1774            .parse()
1775            .map_err(|e: E| HostError::InvalidParameter(key.to_string(), e.into()))
1776    }
1777}
1778
1779/// Spawn a proc at `proc_id` with an `A`-typed agent actor,
1780/// forwarding messages to the provided `backend_addr`,
1781/// and returning the proc's address and agent actor on
1782/// the provided `callback_addr`.
1783#[hyperactor::instrument(fields(proc_id=proc_id.to_string(), addr=backend_addr.to_string(), callback_addr=callback_addr.to_string()))]
1784pub async fn spawn_proc<A, S, F>(
1785    proc_id: ProcAddr,
1786    backend_addr: ChannelAddr,
1787    callback_addr: ChannelAddr,
1788    spawn: S,
1789) -> Result<Proc, HostError>
1790where
1791    // `Actor`: runs in the proc; `Referable`: allows ActorRef<A>;
1792    // `Binds<A>`: wires ports
1793    A: Actor + Referable + Binds<A>,
1794    S: FnOnce(Proc) -> F,
1795    F: Future<Output = Result<ActorHandle<A>, anyhow::Error>>,
1796{
1797    let backend_transport = backend_addr.transport();
1798    let proc = Proc::configured(
1799        proc_id.clone(),
1800        MailboxClient::dial(backend_addr)?.into_boxed(),
1801    );
1802
1803    let agent_handle = spawn(proc.clone())
1804        .await
1805        .map_err(|e| HostError::AgentSpawnFailure(proc_id.clone(), e))?;
1806
1807    // Finally serve the proc on the same transport as the backend address,
1808    // and call back.
1809    let (proc_addr, proc_rx) = channel::serve(ChannelAddr::any(backend_transport))?;
1810    proc.clone().serve(proc_rx);
1811    let agent_ref: ActorRef<A> = agent_handle.bind::<A>();
1812    channel::dial::<(ChannelAddr, ActorRef<A>)>(callback_addr)?
1813        .send((proc_addr, agent_ref))
1814        .await
1815        .map_err(ChannelError::from)?;
1816
1817    Ok(proc)
1818}
1819
1820/// Testing support for hosts. This is linked outside of cfg(test)
1821/// as it is needed by an external binary.
1822pub mod testing {
1823    use async_trait::async_trait;
1824    use hyperactor::Actor;
1825    use hyperactor::ActorAddr;
1826    use hyperactor::Context;
1827    use hyperactor::Endpoint as _;
1828    use hyperactor::Handler;
1829    use hyperactor::OncePortRef;
1830    /// Just a simple actor, available in both the bootstrap binary as well as
1831    /// hyperactor tests.
1832    #[derive(Debug, Default)]
1833    #[hyperactor::export(handlers = [OncePortRef<ActorAddr>])]
1834    pub struct EchoActor;
1835
1836    impl Actor for EchoActor {}
1837
1838    #[async_trait]
1839    impl Handler<OncePortRef<ActorAddr>> for EchoActor {
1840        async fn handle(
1841            &mut self,
1842            cx: &Context<Self>,
1843            reply: OncePortRef<ActorAddr>,
1844        ) -> Result<(), anyhow::Error> {
1845            reply.post(cx, cx.self_addr().clone());
1846            Ok(())
1847        }
1848    }
1849}
1850
1851#[cfg(test)]
1852mod tests {
1853    use std::os::unix::fs::PermissionsExt;
1854    use std::sync::Arc;
1855    use std::time::Duration;
1856
1857    use hyperactor::Addr;
1858    use hyperactor::Endpoint as _;
1859    use hyperactor::Label;
1860    use hyperactor::Location;
1861    use hyperactor::OncePortRef;
1862    use hyperactor::Uid;
1863    use hyperactor::channel::ChannelTransport;
1864    use hyperactor::channel::Tx;
1865    use hyperactor::channel::TxStatus;
1866    use hyperactor::context::Mailbox;
1867    use hyperactor::mailbox::DialMailboxRouter;
1868    use hyperactor::mailbox::MessageEnvelope;
1869    use hyperactor::port::Port;
1870    #[cfg(fbcode_build)]
1871    use hyperactor_config::attrs::Attrs;
1872    use timed_test::async_timed_test;
1873
1874    use super::testing::EchoActor;
1875    use super::*;
1876    #[cfg(fbcode_build)]
1877    use crate::bootstrap::BootstrapCommand;
1878    #[cfg(fbcode_build)]
1879    use crate::bootstrap::BootstrapProcConfig;
1880    #[cfg(fbcode_build)]
1881    use crate::bootstrap::BootstrapProcManager;
1882    #[cfg(fbcode_build)]
1883    use crate::config_dump::ConfigDump;
1884    #[cfg(fbcode_build)]
1885    use crate::config_dump::ConfigDumpResult;
1886
1887    #[tokio::test]
1888    async fn test_basic() {
1889        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
1890            Ok(proc.spawn_with_label::<()>("host_agent", ()))
1891        });
1892        let procs = Arc::clone(&proc_manager.procs);
1893        let host = Host::new(proc_manager, ChannelAddr::any(ChannelTransport::Unix))
1894            .await
1895            .unwrap();
1896
1897        let (proc_id1, _ref) = host.spawn("proc1".to_string(), ()).await.unwrap();
1898        // The spawned proc's identity matches the requested name, and
1899        // its location resolves to the host's frontend address with
1900        // a `Via(proc_uid, ...)` source-routing prefix.
1901        assert_eq!(proc_id1.id(), &ResourceId::from_name("proc1").proc_id());
1902        assert_eq!(proc_id1.location().addr(), host.addr());
1903        let (via_uid, _) = proc_id1
1904            .location()
1905            .as_via()
1906            .expect("spawned proc_addr must carry a via prefix");
1907        assert_eq!(via_uid, proc_id1.id().uid());
1908        assert!(procs.lock().await.contains_key(&proc_id1));
1909
1910        let (proc_id2, _ref) = host.spawn("proc2".to_string(), ()).await.unwrap();
1911        assert!(procs.lock().await.contains_key(&proc_id2));
1912
1913        let proc1 = procs.lock().await.get(&proc_id1).unwrap().clone();
1914        let proc2 = procs.lock().await.get(&proc_id2).unwrap().clone();
1915
1916        // Make sure they can talk to each other:
1917        let instance1 = proc1.client("client");
1918        let instance2 = proc2.client("client");
1919
1920        let (port, mut rx) = instance1.mailbox().open_port();
1921
1922        port.bind().post(&instance2, "hello".to_string());
1923        assert_eq!(rx.recv().await.unwrap(), "hello".to_string());
1924
1925        // Make sure that the system proc is also wired in correctly.
1926        let system_actor = host.system_proc().client("test");
1927
1928        // system->proc
1929        port.bind()
1930            .post(&system_actor, "hello from the system proc".to_string());
1931        assert_eq!(
1932            rx.recv().await.unwrap(),
1933            "hello from the system proc".to_string()
1934        );
1935
1936        // system->system
1937        let (port, mut rx) = system_actor.mailbox().open_port();
1938        port.bind()
1939            .post(&system_actor, "hello from the system".to_string());
1940        assert_eq!(
1941            rx.recv().await.unwrap(),
1942            "hello from the system".to_string()
1943        );
1944
1945        // proc->system
1946        port.bind()
1947            .post(&instance1, "hello from the instance1".to_string());
1948        assert_eq!(
1949            rx.recv().await.unwrap(),
1950            "hello from the instance1".to_string()
1951        );
1952    }
1953
1954    #[cfg(fbcode_build)]
1955    async fn assert_bootstrap_child_round_trip(service_proc_id: ProcId) {
1956        let manager = BootstrapProcManager::new(BootstrapCommand::test()).unwrap();
1957        let mut host = Host::new_with_gateway(
1958            manager,
1959            ProcAddr::new(
1960                service_proc_id,
1961                ChannelAddr::any(ChannelTransport::Unix).into(),
1962            ),
1963            None,
1964            Gateway::new(),
1965            None,
1966        )
1967        .await
1968        .unwrap();
1969
1970        let child_name = ResourceId::instance(Label::strip("route-child")).to_string();
1971        let (_child_proc, child_agent) = host
1972            .spawn(
1973                child_name,
1974                BootstrapProcConfig {
1975                    create_rank: 0,
1976                    client_config_override: Attrs::new(),
1977                    proc_bind: None,
1978                    bootstrap_command: None,
1979                },
1980            )
1981            .await
1982            .unwrap();
1983
1984        let service_client = host.system_proc().client("route-test");
1985        let (reply_handle, reply_rx) = service_client
1986            .mailbox()
1987            .open_once_port::<ConfigDumpResult>();
1988        child_agent.post(
1989            &service_client,
1990            ConfigDump {
1991                result: reply_handle.bind(),
1992            },
1993        );
1994        tokio::time::timeout(Duration::from_secs(5), reply_rx.recv())
1995            .await
1996            .expect("service-to-child request and child-to-service reply timed out")
1997            .expect("config dump reply channel closed");
1998
1999        let summary = host
2000            .terminate_children(
2001                &service_client,
2002                Duration::from_secs(2),
2003                1,
2004                "routing test complete",
2005            )
2006            .await;
2007        assert_eq!(summary.failed, 0);
2008        host.shutdown_servers().await;
2009    }
2010
2011    #[tokio::test]
2012    #[cfg(fbcode_build)]
2013    async fn test_bootstrap_child_round_trip_with_legacy_service_proc() {
2014        assert_bootstrap_child_round_trip(legacy_service_proc_id()).await;
2015    }
2016
2017    #[tokio::test]
2018    #[cfg(fbcode_build)]
2019    async fn test_bootstrap_child_round_trip_with_instance_service_proc() {
2020        let service_proc_id = ProcId::new(
2021            Uid::Instance(0x5e12_71ce, Some(Label::strip(SERVICE_PROC_NAME))),
2022            None,
2023        );
2024        assert_bootstrap_child_round_trip(service_proc_id).await;
2025    }
2026
2027    #[tokio::test]
2028    // 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" } }
2029    #[cfg_attr(not(fbcode_build), ignore)]
2030    async fn test_process_proc_manager() {
2031        hyperactor_telemetry::initialize_logging(hyperactor_telemetry::DefaultTelemetryClock {});
2032
2033        // EchoActor is "host_agent" used to test connectivity.
2034        let process_manager = ProcessProcManager::<EchoActor>::new(
2035            buck_resources::get("monarch/hyperactor_mesh/host_bootstrap").unwrap(),
2036        );
2037        let host = Host::new(process_manager, ChannelAddr::any(ChannelTransport::Unix))
2038            .await
2039            .unwrap();
2040
2041        // (1) Spawn and check invariants.
2042        assert!(matches!(host.addr().transport(), ChannelTransport::Unix));
2043        let (proc1, echo1) = host.spawn("proc1".to_string(), ()).await.unwrap();
2044        let (proc2, echo2) = host.spawn("proc2".to_string(), ()).await.unwrap();
2045        assert_eq!(echo1.actor_addr().proc_addr(), proc1);
2046        assert_eq!(echo2.actor_addr().proc_addr(), proc2);
2047
2048        // (2) Duplicate name rejection.
2049        let dup = host.spawn("proc1".to_string(), ()).await;
2050        assert!(matches!(dup, Err(HostError::ProcExists(_))));
2051
2052        // (3) Create a standalone client proc and verify echo1 agent responds.
2053        // Request: client proc -> host frontend/router -> echo1 (proc1).
2054        // Reply:   echo1 (proc1) -> host backend -> host router -> client port.
2055        // This confirms that an external proc (created via
2056        // `Proc::direct`) can address a child proc through the host,
2057        // and receive a correct reply.
2058        let client = Proc::direct(
2059            ChannelAddr::any(host.addr().transport()),
2060            "test".to_string(),
2061        )
2062        .unwrap();
2063        let client_inst = client.client("test");
2064        let (port, rx) = client_inst.mailbox().open_once_port();
2065        echo1.post(&client_inst, port.bind());
2066        let id = tokio::time::timeout(Duration::from_secs(5), rx.recv())
2067            .await
2068            .unwrap()
2069            .unwrap();
2070        assert_eq!(id, *echo1.actor_addr());
2071
2072        // (4) Child <-> external client request -> reply:
2073        // Request: client proc (standalone via `Proc::direct`) ->
2074        //          host frontend/router -> echo2 (proc2).
2075        // Reply:   echo2 (proc2) -> host backend -> host router ->
2076        //          client port (standalone proc).
2077        // This exercises cross-proc routing between a child and an
2078        // external client under the same host.
2079        let (port2, rx2) = client_inst.mailbox().open_once_port();
2080        echo2.post(&client_inst, port2.bind());
2081        let id2 = tokio::time::timeout(Duration::from_secs(5), rx2.recv())
2082            .await
2083            .unwrap()
2084            .unwrap();
2085        assert_eq!(id2, *echo2.actor_addr());
2086
2087        // (5) System -> child request -> cross-proc reply:
2088        // Request: system proc -> host router (frontend) -> echo1
2089        //          (proc1, child).
2090        // Reply: echo1 (proc1) -> proc1 forwarder -> host backend ->
2091        //        host router -> client proc direct addr (Proc::direct) ->
2092        //        client port.
2093        // Because `client_inst` runs in its own proc, the reply
2094        // traverses the host (not local delivery within proc1).
2095        let sys_inst = host.system_proc().client("sys-client");
2096        let (port3, rx3) = client_inst.mailbox().open_once_port();
2097        // Send from system -> child via a message that ultimately
2098        // replies to client's port
2099        echo1.post(&sys_inst, port3.bind());
2100        let id3 = tokio::time::timeout(Duration::from_secs(5), rx3.recv())
2101            .await
2102            .unwrap()
2103            .unwrap();
2104        assert_eq!(id3, *echo1.actor_addr());
2105    }
2106
2107    #[async_timed_test(timeout_secs = 30)]
2108    async fn cancelled_process_startup_kills_reaps_and_unregisters_child() {
2109        fn process_exists(pid: u32) -> bool {
2110            (
2111                // SAFETY: signal 0 only checks the PID
2112                unsafe { libc::kill(pid as libc::pid_t, 0) }
2113            ) == 0
2114        }
2115
2116        let program_dir = tempfile::tempdir().expect("test directory should be created");
2117        let program = {
2118            let program = program_dir.path().join("pending_process.sh");
2119
2120            tokio::fs::write(&program, "#!/bin/sh\nexec /bin/sleep 600\n")
2121                .await
2122                .expect("test program should be written");
2123
2124            let mut permissions = tokio::fs::metadata(&program)
2125                .await
2126                .expect("test program metadata should be available")
2127                .permissions();
2128
2129            permissions.set_mode(0o700);
2130
2131            tokio::fs::set_permissions(&program, permissions)
2132                .await
2133                .expect("test program should be executable");
2134
2135            program
2136        };
2137
2138        let manager = Arc::new(ProcessProcManager::<EchoActor>::new(program));
2139        let proc_id = ResourceId::proc_addr_from_name(
2140            ChannelAddr::any(ChannelTransport::Unix),
2141            "cancelled-process",
2142        );
2143        let startup = tokio::spawn({
2144            let manager = Arc::clone(&manager);
2145            let proc_id = proc_id.clone();
2146
2147            async move {
2148                manager
2149                    .spawn(proc_id, ChannelAddr::any(ChannelTransport::Unix), ())
2150                    .await
2151            }
2152        });
2153
2154        let pid = loop {
2155            {
2156                let children = manager.children.lock().await;
2157
2158                if let Some(child) = children.get(&proc_id) {
2159                    break child.id().expect("test child should have a process ID");
2160                }
2161            }
2162            tokio::task::yield_now().await;
2163        };
2164        assert!(process_exists(pid));
2165
2166        startup.abort();
2167
2168        assert!(
2169            startup
2170                .await
2171                .expect_err("startup task should be cancelled")
2172                .is_cancelled(),
2173        );
2174
2175        loop {
2176            if !process_exists(pid) {
2177                assert_eq!(
2178                    std::io::Error::last_os_error().raw_os_error(),
2179                    Some(libc::ESRCH),
2180                    "cancelled child should be killed and reaped",
2181                );
2182                break;
2183            }
2184            tokio::task::yield_now().await;
2185        }
2186        assert!(
2187            !manager.children.lock().await.contains_key(&proc_id),
2188            "cancelled child should be removed from the child registry",
2189        );
2190    }
2191
2192    #[tokio::test]
2193    async fn local_ready_and_wait_are_immediate() {
2194        // Build a LocalHandle directly.
2195        let addr = ChannelAddr::any(ChannelTransport::Local);
2196        let proc_ref = ResourceId::proc_addr_from_name(addr.clone(), "p");
2197        let actor_ref = proc_ref.actor_addr("host_agent");
2198        let agent_ref = ActorRef::<()>::attest(actor_ref);
2199        let h = LocalHandle::<()> {
2200            proc_id: proc_ref,
2201            addr,
2202            agent_ref,
2203            procs: Arc::new(Mutex::new(HashMap::new())),
2204        };
2205
2206        // ready() resolves immediately
2207        assert!(h.ready().await.is_ok());
2208
2209        // wait() resolves immediately with unit TerminalStatus
2210        assert!(h.wait().await.is_ok());
2211
2212        // Multiple concurrent waiters both succeed
2213        let (r1, r2) = tokio::join!(h.ready(), h.ready());
2214        assert!(r1.is_ok() && r2.is_ok());
2215    }
2216
2217    // --
2218    // Fixtures for `host::spawn` tests.
2219
2220    #[derive(Debug, Clone, Copy)]
2221    enum ReadyMode {
2222        OkAfter(Duration),
2223        Pending,
2224        ErrTerminal,
2225        ErrChannelClosed,
2226    }
2227
2228    #[derive(Debug, Clone)]
2229    struct TestHandle {
2230        id: ProcAddr,
2231        addr: ChannelAddr,
2232        agent: ActorRef<()>,
2233        mode: ReadyMode,
2234        omit_addr: bool,
2235        omit_agent: bool,
2236        kill_notify: Option<Arc<tokio::sync::Notify>>,
2237        kill_release: Option<Arc<tokio::sync::Notify>>,
2238    }
2239
2240    #[async_trait::async_trait]
2241    impl ProcHandle for TestHandle {
2242        type Agent = ();
2243        type TerminalStatus = ();
2244
2245        fn proc_addr(&self) -> &ProcAddr {
2246            &self.id
2247        }
2248
2249        fn addr(&self) -> Option<ChannelAddr> {
2250            if self.omit_addr {
2251                None
2252            } else {
2253                Some(self.addr.clone())
2254            }
2255        }
2256
2257        fn agent_ref(&self) -> Option<ActorRef<Self::Agent>> {
2258            if self.omit_agent {
2259                None
2260            } else {
2261                Some(self.agent.clone())
2262            }
2263        }
2264
2265        async fn ready(&self) -> Result<(), ReadyError<Self::TerminalStatus>> {
2266            match self.mode {
2267                ReadyMode::OkAfter(d) => {
2268                    if !d.is_zero() {
2269                        tokio::time::sleep(d).await;
2270                    }
2271                    Ok(())
2272                }
2273                ReadyMode::Pending => std::future::pending().await,
2274                ReadyMode::ErrTerminal => Err(ReadyError::Terminal(())),
2275                ReadyMode::ErrChannelClosed => Err(ReadyError::ChannelClosed),
2276            }
2277        }
2278        async fn wait(&self) -> Result<Self::TerminalStatus, WaitError> {
2279            Ok(())
2280        }
2281        async fn terminate(
2282            &self,
2283            _cx: &impl context::Actor,
2284            _timeout: Duration,
2285            _reason: &str,
2286        ) -> Result<Self::TerminalStatus, TerminateError<Self::TerminalStatus>> {
2287            Err(TerminateError::Unsupported)
2288        }
2289        async fn kill(&self) -> Result<Self::TerminalStatus, TerminateError<Self::TerminalStatus>> {
2290            if let Some(notify) = &self.kill_notify {
2291                notify.notify_one();
2292            }
2293            if let Some(release) = &self.kill_release {
2294                release.notified().await;
2295            }
2296            Ok(())
2297        }
2298    }
2299
2300    #[derive(Debug, Clone)]
2301    struct TestManager {
2302        mode: ReadyMode,
2303        omit_addr: bool,
2304        omit_agent: bool,
2305        transport: ChannelTransport,
2306        kill_notify: Option<Arc<tokio::sync::Notify>>,
2307        kill_release: Option<Arc<tokio::sync::Notify>>,
2308    }
2309
2310    impl TestManager {
2311        fn local(mode: ReadyMode) -> Self {
2312            Self {
2313                mode,
2314                omit_addr: false,
2315                omit_agent: false,
2316                transport: ChannelTransport::Local,
2317                kill_notify: None,
2318                kill_release: None,
2319            }
2320        }
2321        fn with_omissions(mut self, addr: bool, agent: bool) -> Self {
2322            self.omit_addr = addr;
2323            self.omit_agent = agent;
2324            self
2325        }
2326        fn with_kill_notify(mut self, notify: Arc<tokio::sync::Notify>) -> Self {
2327            self.kill_notify = Some(notify);
2328            self
2329        }
2330        fn with_kill_release(mut self, release: Arc<tokio::sync::Notify>) -> Self {
2331            self.kill_release = Some(release);
2332            self
2333        }
2334    }
2335
2336    #[async_trait::async_trait]
2337    impl ProcManager for TestManager {
2338        type Handle = TestHandle;
2339
2340        fn transport(&self) -> ChannelTransport {
2341            self.transport.clone()
2342        }
2343
2344        async fn spawn(
2345            &self,
2346            proc_id: ProcAddr,
2347            forwarder_addr: ChannelAddr,
2348            _config: (),
2349        ) -> Result<Self::Handle, HostError> {
2350            let agent = ActorRef::<()>::attest(proc_id.actor_addr("host_agent"));
2351            Ok(TestHandle {
2352                id: proc_id,
2353                addr: forwarder_addr,
2354                agent,
2355                mode: self.mode,
2356                omit_addr: self.omit_addr,
2357                omit_agent: self.omit_agent,
2358                kill_notify: self.kill_notify.clone(),
2359                kill_release: self.kill_release.clone(),
2360            })
2361        }
2362    }
2363
2364    #[tokio::test]
2365    async fn host_spawn_cleans_up_handle_after_readiness_failure() {
2366        let cleanup_started = Arc::new(tokio::sync::Notify::new());
2367        let cleanup_release = Arc::new(tokio::sync::Notify::new());
2368        let host = Host::new(
2369            TestManager::local(ReadyMode::ErrTerminal)
2370                .with_kill_notify(Arc::clone(&cleanup_started))
2371                .with_kill_release(Arc::clone(&cleanup_release)),
2372            ChannelAddr::any(ChannelTransport::Local),
2373        )
2374        .await
2375        .expect("host should start");
2376
2377        let spawn = tokio::spawn(host.spawn("failed-proc".to_string(), ()));
2378        cleanup_started.notified().await;
2379        assert!(
2380            !spawn.is_finished(),
2381            "spawn returned before failed-proc cleanup completed",
2382        );
2383        cleanup_release.notify_one();
2384
2385        let error = spawn
2386            .await
2387            .expect("spawn task should complete")
2388            .expect_err("readiness failure should fail the spawn");
2389        assert!(matches!(
2390            error,
2391            HostError::ProcessConfigurationFailure(_, _)
2392        ));
2393        assert!(
2394            !host
2395                .procs
2396                .lock()
2397                .expect("procs mutex poisoned")
2398                .contains_key("failed-proc"),
2399            "failed spawn should not leave a peer registration",
2400        );
2401    }
2402
2403    #[tokio::test]
2404    async fn local_manager_cleans_up_proc_when_agent_spawn_panics() {
2405        async fn panicking_spawn(_proc: Proc) -> anyhow::Result<ActorHandle<()>> {
2406            panic!("test agent spawn panic");
2407        }
2408
2409        let manager = LocalProcManager::new(panicking_spawn);
2410        let managed_procs = Arc::clone(&manager.procs);
2411        let host = Host::new(manager, ChannelAddr::any(ChannelTransport::Local))
2412            .await
2413            .expect("host should start");
2414
2415        let result = AssertUnwindSafe(host.spawn("panicking-proc".to_string(), ()))
2416            .catch_unwind()
2417            .await;
2418        assert!(result.is_err(), "agent spawn panic should be preserved");
2419        assert!(
2420            managed_procs.lock().await.is_empty(),
2421            "panicking agent spawn should not leave a managed proc",
2422        );
2423        assert!(
2424            !host
2425                .procs
2426                .lock()
2427                .expect("procs mutex poisoned")
2428                .contains_key("panicking-proc"),
2429            "panicking agent spawn should release its name reservation",
2430        );
2431    }
2432
2433    #[tokio::test]
2434    async fn host_spawn_times_out_when_configured() {
2435        let cfg = hyperactor_config::global::lock();
2436        let _g = cfg.override_key(
2437            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2438            Duration::from_millis(10),
2439        );
2440
2441        let host = Host::new(
2442            TestManager::local(ReadyMode::Pending),
2443            ChannelAddr::any(ChannelTransport::Local),
2444        )
2445        .await
2446        .unwrap();
2447
2448        let err = host.spawn("t".into(), ()).await.expect_err("must time out");
2449        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
2450    }
2451
2452    #[tokio::test]
2453    async fn host_spawn_timeout_zero_disables_and_succeeds() {
2454        let cfg = hyperactor_config::global::lock();
2455        let _g = cfg.override_key(
2456            hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2457            Duration::from_secs(0),
2458        );
2459
2460        let host = Host::new(
2461            TestManager::local(ReadyMode::OkAfter(Duration::from_millis(20))),
2462            ChannelAddr::any(ChannelTransport::Local),
2463        )
2464        .await
2465        .unwrap();
2466
2467        let (pid, agent) = host.spawn("ok".into(), ()).await.expect("must succeed");
2468        assert_eq!(agent.actor_addr().proc_addr(), pid);
2469        assert!(
2470            host.procs
2471                .lock()
2472                .expect("procs mutex poisoned")
2473                .contains_key("ok")
2474        );
2475    }
2476
2477    #[tokio::test]
2478    async fn host_spawn_maps_channel_closed_ready_error_to_config_failure() {
2479        let host = Host::new(
2480            TestManager::local(ReadyMode::ErrChannelClosed),
2481            ChannelAddr::any(ChannelTransport::Local),
2482        )
2483        .await
2484        .unwrap();
2485
2486        let err = host.spawn("p".into(), ()).await.expect_err("must fail");
2487        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
2488    }
2489
2490    #[tokio::test]
2491    async fn host_spawn_maps_terminal_ready_error_to_config_failure() {
2492        let host = Host::new(
2493            TestManager::local(ReadyMode::ErrTerminal),
2494            ChannelAddr::any(ChannelTransport::Local),
2495        )
2496        .await
2497        .unwrap();
2498
2499        let err = host.spawn("p".into(), ()).await.expect_err("must fail");
2500        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
2501    }
2502
2503    #[tokio::test]
2504    async fn host_spawn_fails_if_ready_but_missing_addr() {
2505        let host = Host::new(
2506            TestManager::local(ReadyMode::OkAfter(Duration::ZERO)).with_omissions(true, false),
2507            ChannelAddr::any(ChannelTransport::Local),
2508        )
2509        .await
2510        .unwrap();
2511
2512        let err = host
2513            .spawn("no-addr".into(), ())
2514            .await
2515            .expect_err("must fail");
2516        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
2517    }
2518
2519    #[tokio::test]
2520    async fn host_spawn_fails_if_ready_but_missing_agent() {
2521        let host = Host::new(
2522            TestManager::local(ReadyMode::OkAfter(Duration::ZERO)).with_omissions(false, true),
2523            ChannelAddr::any(ChannelTransport::Local),
2524        )
2525        .await
2526        .unwrap();
2527
2528        let err = host
2529            .spawn("no-agent".into(), ())
2530            .await
2531            .expect_err("must fail");
2532        assert!(matches!(err, HostError::ProcessConfigurationFailure(_, _)));
2533    }
2534
2535    // test_duplex_remote_proc, test_duplex_undeliverable_from_client,
2536    // test_duplex_undeliverable_from_host, and test_duplex_teardown
2537    // were removed: proc-level attach is gone. Gateway-attach is
2538    // exercised by the via tests in `hyperactor::gateway::tests`;
2539    // undeliverable bouncing is unchanged at the host level and is
2540    // exercised by the gateway-attach tests.
2541
2542    /// Repro for the OSS broken-link issue: when the host's duplex
2543    /// frontend shuts down with messages still on the wire, the
2544    /// simplex peer must see a clean close (and pending acks must
2545    /// flush) rather than retry-looping for `MESSAGE_DELIVERY_TIMEOUT`.
2546    ///
2547    /// Before the fix: the peer's `NetTx` got no acks for in-flight
2548    /// messages and no `Closed` response, so it spent the full 30 s
2549    /// `MESSAGE_DELIVERY_TIMEOUT` reconnecting against a dead host.
2550    ///
2551    /// This test posts a message, then stops the serve handle and
2552    /// asserts the simplex `NetTx` transitions to `Closed` quickly —
2553    /// well under `MESSAGE_DELIVERY_TIMEOUT`.
2554    #[tokio::test]
2555    async fn test_simplex_peer_sees_clean_close_on_host_shutdown() {
2556        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2557            Ok(proc.spawn_with_label::<EchoActor>("host_agent", EchoActor))
2558        });
2559        let mut host =
2560            Host::new_with_default(proc_manager, ChannelAddr::any(ChannelTransport::Unix), None)
2561                .await
2562                .unwrap();
2563        let mut serve_handle = host.take_frontend_handle().unwrap();
2564
2565        // Spawn an EchoActor and send a request from a simplex client.
2566        let echo_handle = host.system_proc().spawn(EchoActor);
2567        let echo_ref = echo_handle.bind::<EchoActor>();
2568
2569        let dial_router = DialMailboxRouter::new();
2570        dial_router.bind(
2571            Addr::from(host.system_proc().proc_addr().clone()),
2572            host.addr().clone(),
2573        );
2574        let client_addr = ChannelAddr::any(ChannelTransport::Unix);
2575        let (client_listen_addr, client_rx) = channel::serve(client_addr).unwrap();
2576        let client_proc_id = ResourceId::proc_addr_from_name(client_listen_addr, "client");
2577        let client_proc = Proc::configured(client_proc_id, dial_router.into_boxed());
2578        let _client_handle = client_proc.clone().serve(client_rx);
2579
2580        let client_inst = client_proc.client("requester");
2581        let (reply_port, reply_handle) = client_inst.mailbox().open_once_port::<ActorAddr>();
2582        let reply_port = reply_port.bind();
2583        echo_ref
2584            .port::<OncePortRef<ActorAddr>>()
2585            .post(&client_inst, reply_port);
2586        let _ = tokio::time::timeout(Duration::from_secs(5), reply_handle.recv())
2587            .await
2588            .expect("baseline round-trip timed out")
2589            .expect("baseline recv failed");
2590
2591        // Snapshot the client's outbound NetTx status before shutdown.
2592        let host_tx = channel::dial::<MessageEnvelope>(host.addr().clone()).unwrap();
2593        // Push one message so the lazy-connect kicks in.
2594        let dummy_dest = host
2595            .system_proc()
2596            .proc_addr()
2597            .actor_addr("noop")
2598            .port_addr(Port::from(0u64));
2599        let mut envelope = MessageEnvelope::serialize(
2600            client_inst.self_addr().clone(),
2601            dummy_dest,
2602            &"warmup".to_string(),
2603            Default::default(),
2604        )
2605        .unwrap();
2606        envelope.set_return_undeliverable(false);
2607        host_tx.post(envelope);
2608        // Wait briefly for connection to establish.
2609        tokio::time::sleep(Duration::from_millis(200)).await;
2610        assert!(matches!(*host_tx.status().borrow(), TxStatus::Active));
2611
2612        // Shut down the host's frontend. The fix ensures pending
2613        // recv-side acks are flushed AND a `Closed` response is sent,
2614        // so the simplex peer transitions to `Closed` promptly.
2615        serve_handle.stop("test shutdown");
2616        let _ = tokio::time::timeout(Duration::from_secs(5), serve_handle.join())
2617            .await
2618            .expect("serve handle did not resolve");
2619
2620        // The simplex peer should see Closed within a few seconds —
2621        // not the full MESSAGE_DELIVERY_TIMEOUT (30 s). Wait for the
2622        // status watch to flip.
2623        let mut status = host_tx.status().clone();
2624        tokio::time::timeout(Duration::from_secs(10), async {
2625            loop {
2626                if let TxStatus::Closed(_) = *status.borrow() {
2627                    return;
2628                }
2629                if status.changed().await.is_err() {
2630                    return;
2631                }
2632            }
2633        })
2634        .await
2635        .expect("simplex peer did not see Closed within 10s of host shutdown");
2636
2637        match &*host_tx.status().borrow() {
2638            TxStatus::Closed(_) => {}
2639            other => panic!("expected TxStatus::Closed, got {:?}", other),
2640        }
2641    }
2642
2643    /// Stress repro: many simplex clients send rapid request+reply
2644    /// traffic to the host's duplex frontend and the host shuts down
2645    /// while traffic is in flight. This mirrors the OSS test pattern
2646    /// where `HostMeshShutdownGuard::drop` sends `ShutdownHost`.
2647    #[tokio::test]
2648    async fn test_simplex_clients_during_host_shutdown() {
2649        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2650            Ok(proc.spawn_with_label::<EchoActor>("host_agent", EchoActor))
2651        });
2652        let mut host =
2653            Host::new_with_default(proc_manager, ChannelAddr::any(ChannelTransport::Unix), None)
2654                .await
2655                .unwrap();
2656        let mut serve_handle = host.take_frontend_handle().unwrap();
2657
2658        let echo_handle = host.system_proc().spawn(EchoActor);
2659        let echo_ref = echo_handle.bind::<EchoActor>();
2660        let host_addr = host.addr().clone();
2661        let echo_actor_id = echo_ref.actor_addr().clone();
2662        let system_proc_id = host.system_proc().proc_addr().clone();
2663
2664        // Spawn N clients, each sending M requests.
2665        const N_CLIENTS: usize = 4;
2666        const M_REQUESTS: usize = 5;
2667
2668        let mut client_tasks = Vec::new();
2669        for ci in 0..N_CLIENTS {
2670            let host_addr = host_addr.clone();
2671            let echo_actor_id = echo_actor_id.clone();
2672            let system_proc_id = system_proc_id.clone();
2673            client_tasks.push(tokio::spawn(async move {
2674                let dial_router = DialMailboxRouter::new();
2675                dial_router.bind(Addr::from(system_proc_id.clone()), host_addr);
2676                let client_addr = ChannelAddr::any(ChannelTransport::Unix);
2677                let (client_listen_addr, client_rx) = channel::serve(client_addr).unwrap();
2678                let client_proc_id =
2679                    ResourceId::proc_addr_from_name(client_listen_addr, format!("client-{}", ci));
2680                let client_proc = Proc::configured(client_proc_id, dial_router.into_boxed());
2681                let _client_handle = client_proc.clone().serve(client_rx);
2682
2683                let echo_ref = ActorRef::<EchoActor>::attest(echo_actor_id);
2684
2685                for ri in 0..M_REQUESTS {
2686                    let client_inst = client_proc.client(&format!("req-{}", ri));
2687                    let (reply_port, reply_handle) =
2688                        client_inst.mailbox().open_once_port::<ActorAddr>();
2689                    let reply_port = reply_port.bind();
2690                    echo_ref
2691                        .port::<OncePortRef<ActorAddr>>()
2692                        .post(&client_inst, reply_port);
2693                    let received =
2694                        tokio::time::timeout(Duration::from_secs(10), reply_handle.recv())
2695                            .await
2696                            .expect("timeout waiting for reply")
2697                            .expect("recv failed");
2698                    assert_eq!(received, *echo_ref.actor_addr());
2699                }
2700            }));
2701        }
2702
2703        for task in client_tasks {
2704            task.await.unwrap();
2705        }
2706
2707        // Shut down. The handle must resolve cleanly.
2708        serve_handle.stop("test cleanup");
2709        tokio::time::timeout(Duration::from_secs(10), serve_handle.join())
2710            .await
2711            .expect("serve handle did not resolve")
2712            .expect("serve task error");
2713    }
2714
2715    /// Repro for the broken-link errors seen in OSS Python tests:
2716    /// an external simplex `Proc::direct` dialing the host's duplex
2717    /// frontend should be able to round-trip a request + reply.
2718    #[tokio::test]
2719    async fn test_simplex_client_to_duplex_host() {
2720        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2721            Ok(proc.spawn_with_label::<EchoActor>("host_agent", EchoActor))
2722        });
2723        let host =
2724            Host::new_with_default(proc_manager, ChannelAddr::any(ChannelTransport::Unix), None)
2725                .await
2726                .unwrap();
2727
2728        // Spawn an EchoActor on the host's system_proc.
2729        let echo_handle = host.system_proc().spawn(EchoActor);
2730        let echo_ref = echo_handle.bind::<EchoActor>();
2731
2732        // Create an external simplex client proc with a dial router
2733        // bound to the host's frontend address. This mirrors what the
2734        // Python "root client" does: a `Proc::direct` whose forwarder
2735        // is a `DialMailboxRouter` with the host's frontend address as
2736        // a route to the host's procs.
2737        let client_addr = ChannelAddr::any(ChannelTransport::Unix);
2738        let dial_router = DialMailboxRouter::new();
2739        dial_router.bind(
2740            Addr::from(host.system_proc().proc_addr().clone()),
2741            host.addr().clone(),
2742        );
2743        let (client_listen_addr, client_rx) = channel::serve(client_addr).unwrap();
2744        let client_proc_id = ResourceId::proc_addr_from_name(client_listen_addr, "external-client");
2745        let client_proc = Proc::configured(client_proc_id, dial_router.into_boxed());
2746        let _client_handle = client_proc.clone().serve(client_rx);
2747
2748        let client_inst = client_proc.client("requester");
2749
2750        // Send a request to the echo actor on the host. The reply
2751        // travels back through the host's dial router → simplex dial
2752        // → client's frontend.
2753        let (reply_port, reply_handle) = client_inst.mailbox().open_once_port::<ActorAddr>();
2754        let reply_port = reply_port.bind();
2755        echo_ref
2756            .port::<OncePortRef<ActorAddr>>()
2757            .post(&client_inst, reply_port);
2758
2759        let received = tokio::time::timeout(Duration::from_secs(10), reply_handle.recv())
2760            .await
2761            .expect("timed out waiting for reply")
2762            .expect("recv failed");
2763        assert_eq!(received, *echo_ref.actor_addr());
2764    }
2765
2766    #[tokio::test]
2767    async fn test_explicit_service_and_local_proc_ids_are_instances() {
2768        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2769            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2770        });
2771        let service_proc_id = ProcId::instance(hyperactor::id::Label::strip("service"));
2772
2773        let host = Host::new_with_gateway(
2774            proc_manager,
2775            ProcAddr::new(
2776                service_proc_id.clone(),
2777                ChannelAddr::any(ChannelTransport::Unix).into(),
2778            ),
2779            None,
2780            Gateway::new(),
2781            None,
2782        )
2783        .await
2784        .unwrap();
2785
2786        assert_eq!(host.system_proc().proc_id(), &service_proc_id);
2787        assert!(matches!(
2788            host.local_proc().proc_id().uid(),
2789            hyperactor::id::Uid::Instance(..)
2790        ));
2791        assert_eq!(
2792            host.local_proc()
2793                .proc_id()
2794                .label()
2795                .map(|label| label.as_str()),
2796            Some(LOCAL_PROC_NAME)
2797        );
2798    }
2799
2800    #[tokio::test]
2801    async fn test_new_with_gateway_rejects_via_frontend_location() {
2802        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2803            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2804        });
2805        let location = Location::from(ChannelAddr::any(ChannelTransport::Unix))
2806            .with_via(Uid::instance(Label::strip("remote")));
2807        let service_proc_addr = ProcAddr::new(legacy_service_proc_id(), location.clone());
2808
2809        let result =
2810            Host::new_with_gateway(proc_manager, service_proc_addr, None, Gateway::new(), None)
2811                .await;
2812
2813        assert!(matches!(
2814            result,
2815            Err(HostError::InvalidFrontendLocation(error_location))
2816                if error_location == location
2817        ));
2818    }
2819
2820    #[tokio::test]
2821    async fn test_new_with_gateway_remints_service_proc_at_bound_address() {
2822        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2823            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2824        });
2825        let service_proc_id = ProcId::instance(Label::strip("service"));
2826        let startup_addr = ChannelAddr::from_zmq_url("tcp://127.0.0.1:0").unwrap();
2827        let mut host = Host::new_with_gateway(
2828            proc_manager,
2829            ProcAddr::new(service_proc_id.clone(), startup_addr.into()),
2830            None,
2831            Gateway::new(),
2832            None,
2833        )
2834        .await
2835        .unwrap();
2836
2837        let ChannelAddr::Tcp(bound_addr) = host.addr() else {
2838            panic!("expected TCP frontend address")
2839        };
2840        assert_ne!(bound_addr.port(), 0);
2841        assert_eq!(
2842            host.system_proc().proc_addr(),
2843            ProcAddr::new(service_proc_id, Location::from(host.addr().clone()))
2844        );
2845
2846        host.shutdown_servers().await;
2847    }
2848
2849    #[tokio::test]
2850    async fn test_spawn_uses_latest_serve_location_after_prior_default_override() {
2851        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2852            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2853        });
2854        let gateway = Gateway::new();
2855        let attached_host_addr: ChannelAddr = "tcp:10.0.159.108:26600".parse().unwrap();
2856        let attached_uid = Uid::instance(Label::strip("attached"));
2857        let attached_location = Location::from(attached_host_addr).with_via(attached_uid);
2858        gateway.set_default_location(attached_location.clone());
2859
2860        let host = Host::new_with_gateway(
2861            proc_manager,
2862            ProcAddr::new(
2863                legacy_service_proc_id(),
2864                ChannelAddr::any(ChannelTransport::Unix).into(),
2865            ),
2866            None,
2867            gateway,
2868            None,
2869        )
2870        .await
2871        .unwrap();
2872
2873        let (proc_id, _agent) = host.spawn("proc1".to_string(), ()).await.unwrap();
2874        let (proc_uid, host_location) = proc_id
2875            .location()
2876            .as_via()
2877            .expect("spawned proc must carry child via");
2878
2879        assert_eq!(proc_uid, proc_id.id().uid());
2880        assert_eq!(host_location.as_ref(), &Location::from(host.addr().clone()));
2881        assert_ne!(host_location.as_ref(), &attached_location);
2882    }
2883
2884    #[tokio::test]
2885    async fn test_spawn_uses_latest_direct_gateway_serve_location() {
2886        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2887            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2888        });
2889
2890        let host = Host::new_with_gateway(
2891            proc_manager,
2892            ProcAddr::new(
2893                legacy_service_proc_id(),
2894                ChannelAddr::any(ChannelTransport::Unix).into(),
2895            ),
2896            None,
2897            Gateway::new(),
2898            None,
2899        )
2900        .await
2901        .unwrap();
2902        let mut later_frontend = host
2903            .gateway()
2904            .serve_with_listener(ChannelAddr::any(ChannelTransport::Unix), None)
2905            .unwrap();
2906        let later_location = host.gateway().default_location();
2907        assert_ne!(later_location.addr(), host.addr());
2908
2909        let (proc_id, _agent) = host.spawn("proc1".to_string(), ()).await.unwrap();
2910        let (proc_uid, host_location) = proc_id
2911            .location()
2912            .as_via()
2913            .expect("spawned proc must carry child via");
2914
2915        assert_eq!(proc_uid, proc_id.id().uid());
2916        assert_eq!(host_location.as_ref(), &later_location);
2917
2918        later_frontend.stop("test cleanup");
2919        later_frontend.join().await.unwrap();
2920    }
2921
2922    // Regression: an out-of-cluster client (`via` set) must advertise
2923    // its refs at the `Via` location so in-cluster hosts route return
2924    // traffic back over the duplex. Before the fix, the host's own
2925    // frontend serve clobbered the via as `default_location`, so refs
2926    // carried a bare, cluster-unreachable address and the attach-time
2927    // config-push acks timed out (`MESH_ATTACH_CONFIG_TIMEOUT`).
2928    #[tokio::test]
2929    async fn test_new_with_gateway_via_advertises_via_location() {
2930        // A remote gateway accepting duplex attaches stands in for the
2931        // in-cluster host the client attaches to.
2932        let remote_gw = Gateway::new();
2933        let mut remote_accept = remote_gw
2934            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2935            .unwrap();
2936        let remote_addr = remote_gw.default_location().addr().clone();
2937
2938        let proc_manager = LocalProcManager::new(|proc: Proc| async move {
2939            Ok(proc.spawn_with_label::<()>("host_agent", ()))
2940        });
2941
2942        let gateway = Gateway::new();
2943        let fallback_location = gateway.default_location();
2944        let mut host = Host::new_with_gateway(
2945            proc_manager,
2946            ProcAddr::new(
2947                legacy_service_proc_id(),
2948                ChannelAddr::any(ChannelTransport::Unix).into(),
2949            ),
2950            None,
2951            gateway.clone(),
2952            Some(remote_addr.clone()),
2953        )
2954        .await
2955        .unwrap();
2956
2957        // The gateway must advertise the Via (not the bare local
2958        // frontend) as its default location for newly bound refs.
2959        let default_location = host.gateway().default_location();
2960        let (via_uid, inner) = default_location
2961            .as_via()
2962            .expect("default location must carry the via prefix");
2963        assert_eq!(via_uid, host.gateway().uid());
2964        assert_eq!(inner.addr(), &remote_addr);
2965
2966        // The built-in service proc, minted after `serve_via`, must
2967        // also carry the Via — it advertised a bare address before the
2968        // fix.
2969        let svc_proc_addr = host.system_proc().proc_addr();
2970        let (_, svc_inner) = svc_proc_addr
2971            .location()
2972            .as_via()
2973            .expect("service proc must carry the via prefix");
2974        assert_eq!(svc_inner.addr(), &remote_addr);
2975
2976        let shutdown_handles = host.take_shutdown_handles();
2977        drop(host);
2978        assert_eq!(
2979            gateway.default_location(),
2980            default_location,
2981            "shutdown handles must retain the via location after the host drops"
2982        );
2983        shutdown_handles.stop_and_join("test cleanup").await;
2984        assert_eq!(gateway.default_location(), fallback_location);
2985
2986        remote_accept.stop("test cleanup");
2987        remote_accept.join().await.unwrap();
2988    }
2989}