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