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