1#![allow(clippy::result_large_err)]
47
48use hyperactor::ActorRef;
49use hyperactor::Endpoint as _;
50use hyperactor::Gateway;
51use hyperactor::Handler;
52use hyperactor::accum::StreamingReducerOpts;
53use hyperactor::channel::ChannelTransport;
54use hyperactor::id::Label;
55use hyperactor::id::Uid;
56use hyperactor_cast::cast_actor::CastActor;
57use hyperactor_config::CONFIG;
58use hyperactor_config::ConfigAttr;
59use hyperactor_config::attrs::declare_attrs;
60use ndslice::view::CollectMeshExt;
61
62use crate::mesh_admin::MeshAdminAgent;
63use crate::supervision::MeshFailure;
64
65pub mod host_agent;
66
67use std::collections::HashSet;
68use std::ops::Deref;
69use std::ops::DerefMut;
70use std::str::FromStr;
71use std::sync::Arc;
72use std::time::Duration;
73
74use hyperactor::ActorAddr;
75use hyperactor::ProcAddr;
76use hyperactor::channel::ChannelAddr;
77use hyperactor::context;
78use hyperactor_cast::cast_actor::CAST_ACTOR_NAME;
79use ndslice::Extent;
80use ndslice::Region;
81use ndslice::ViewExt;
82use ndslice::extent;
83use ndslice::view;
84use ndslice::view::Ranked;
85use ndslice::view::RegionParseError;
86use serde::Deserialize;
87use serde::Serialize;
88use tracing::Instrument;
89use typeuri::Named;
90
91use crate::ActorMeshRef;
92use crate::Bootstrap;
93use crate::ProcMesh;
94use crate::ValueMesh;
95use crate::bootstrap::BootstrapCommand;
96use crate::bootstrap::BootstrapProcManager;
97use crate::bootstrap::ProcBind;
98use crate::host::Host;
99use crate::host::LocalProcManager;
100use crate::host::SERVICE_PROC_NAME;
101pub use crate::host_mesh::host_agent::HostAgent;
102use crate::host_mesh::host_agent::ProcManagerSpawnFn;
103use crate::host_mesh::host_agent::ProcState;
104use crate::mesh_controller::ProcMeshController;
105use crate::mesh_id::ActorMeshId;
106use crate::mesh_id::HostMeshId;
107use crate::mesh_id::ProcMeshId;
108use crate::mesh_id::ResourceId;
109use crate::proc_agent::ProcAgent;
110use crate::proc_mesh::ProcMeshRef;
111use crate::resource;
112use crate::resource::GetRankStatus;
113use crate::resource::RankedValues;
114use crate::resource::Status;
115use crate::resource::WaitRankStatusClient;
116use crate::transport::DEFAULT_TRANSPORT;
117
118pub const PROC_MESH_CONTROLLER_NAME: &str = "proc_mesh_controller";
120
121declare_attrs! {
122 @meta(CONFIG = ConfigAttr::new(
125 Some("HYPERACTOR_MESH_PROC_SPAWN_MAX_IDLE".to_string()),
126 Some("mesh_proc_spawn_max_idle".to_string()),
127 ))
128 pub attr PROC_SPAWN_MAX_IDLE: Duration = Duration::from_secs(30);
129
130 @meta(CONFIG = ConfigAttr::new(
133 Some("HYPERACTOR_MESH_PROC_STOP_MAX_IDLE".to_string()),
134 Some("proc_stop_max_idle".to_string()),
135 ))
136 pub attr PROC_STOP_MAX_IDLE: Duration = Duration::from_secs(30);
137
138 @meta(CONFIG = ConfigAttr::new(
141 Some("HYPERACTOR_MESH_GET_PROC_STATE_MAX_IDLE".to_string()),
142 Some("get_proc_state_max_idle".to_string()),
143 ))
144 pub attr GET_PROC_STATE_MAX_IDLE: Duration = Duration::from_mins(1);
145}
146
147pub(crate) fn host_agent_ref(host_addr: ChannelAddr) -> ActorRef<HostAgent> {
148 let host_addr = host_addr.into_dial_addr();
149 ActorRef::attest(
150 ResourceId::proc_addr_from_name(host_addr, SERVICE_PROC_NAME)
151 .actor_addr(host_agent::HOST_MESH_AGENT_ACTOR_NAME),
152 )
153}
154
155fn named_proc_on_host(agent: &ActorRef<HostAgent>, id: &ResourceId) -> ProcAddr {
156 let location =
157 hyperactor::Location::from(agent.actor_addr().addr().clone()).with_via(id.uid().clone());
158 id.proc_addr(location)
159}
160
161#[derive(Debug, thiserror::Error)]
171pub enum ConfigPushFailure {
172 #[error("send failed: {0}")]
175 SendFailed(#[source] Box<hyperactor::mailbox::MailboxSenderError>),
176
177 #[error("cast failed: {0}")]
180 CastFailed(String),
181
182 #[error("reply timed out after MESH_ATTACH_CONFIG_TIMEOUT")]
189 ReplyTimedOut,
190
191 #[error("reply channel closed before reply")]
194 ReplyChannelClosed,
195}
196
197#[derive(Debug)]
205pub struct ConfigPushError {
206 pub failures: Vec<(ChannelAddr, ConfigPushFailure)>,
208}
209
210impl std::fmt::Display for ConfigPushError {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 write!(
213 f,
214 "config push failed during attach on {} host(s):",
215 self.failures.len()
216 )?;
217 for (host, failure) in &self.failures {
218 write!(f, "\n - {}: {}", host, failure)?;
219 }
220 Ok(())
221 }
222}
223
224impl std::error::Error for ConfigPushError {
225 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
226 None
230 }
231}
232
233pub struct HostMesh {
244 id: HostMeshId,
245 extent: Extent,
246 current_ref: HostMeshRef,
247 shutdown_on_drop: bool,
250}
251
252impl HostMesh {
253 fn notify_created(&self) {
255 let name_str = self.id.to_string();
256 let mesh_id_hash = hyperactor_telemetry::hash_to_u64(&self.id);
257
258 hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
259 id: mesh_id_hash,
260 timestamp: std::time::SystemTime::now(),
261 class: "Host".to_string(),
262 given_name: self
263 .id
264 .display_label()
265 .map(|l| l.as_str())
266 .unwrap_or("unnamed")
267 .to_string(),
268 full_name: name_str,
269 shape_json: serde_json::to_string(&self.extent).unwrap_or_default(),
270 parent_mesh_id: None,
271 parent_view_json: None,
272 });
273
274 let now = std::time::SystemTime::now();
277 for (rank, actor) in self.current_ref.host_agent_mesh.values().enumerate() {
278 hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
279 id: hyperactor_telemetry::hash_to_u64(actor.actor_addr().id()),
280 timestamp: now,
281 mesh_id: mesh_id_hash,
282 rank: rank as u64,
283 full_name: actor.actor_addr().to_string(),
284 display_name: None,
285 });
286 }
287 }
288
289 pub async fn local() -> crate::Result<HostMesh> {
312 Self::local_with_bootstrap(BootstrapCommand::current()?).await
313 }
314
315 pub async fn local_with_bootstrap(bootstrap_cmd: BootstrapCommand) -> crate::Result<HostMesh> {
323 if let Ok(Some(boot)) = Bootstrap::get_from_env() {
324 let result = boot.bootstrap().await;
325 if let Err(err) = result {
326 tracing::error!("failed to bootstrap local host mesh process: {}", err);
327 }
328 std::process::exit(1);
329 }
330
331 let addr = hyperactor_config::global::get_cloned(DEFAULT_TRANSPORT).binding_addr();
332
333 let manager = BootstrapProcManager::new(bootstrap_cmd)?;
334 let host = Host::new_with_gateway(manager, addr, None, Gateway::new(), None).await?;
340 let addr = host.addr().clone();
341 let system_proc = host.system_proc().clone();
342 let host_mesh_agent = system_proc
343 .spawn_with_uid(
344 Uid::singleton(Label::new(host_agent::HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
345 HostAgent::new_process(host, None),
346 )
347 .map_err(crate::Error::SingletonActorSpawnError)?;
348 HostAgent::wait_initialized(&host_mesh_agent).await?;
349 host_mesh_agent.bind::<HostAgent>();
350 let cast_handle = system_proc
351 .spawn_with_uid(
352 Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
353 CastActor::default(),
354 )
355 .map_err(crate::Error::SingletonActorSpawnError)?;
356 cast_handle.bind::<CastActor>();
357
358 let host_mesh_ref = HostMeshRef::new(
359 HostMeshId::instance(Label::new("local").unwrap()),
360 extent!(hosts = 1).into(),
361 vec![addr],
362 )?;
363 Ok(HostMesh::take(host_mesh_ref))
364 }
365
366 pub async fn local_in_process() -> crate::Result<HostMesh> {
376 let addr = hyperactor_config::global::get_cloned(DEFAULT_TRANSPORT).binding_addr();
377 Ok(HostMesh::take(Self::local_n_in_process(vec![addr]).await?))
378 }
379
380 pub(crate) async fn local_n_in_process(
389 host_addrs: Vec<ChannelAddr>,
390 ) -> crate::Result<HostMeshRef> {
391 let n = host_addrs.len();
392 let mut in_process_host_addrs = Vec::with_capacity(n);
393 for host_addr in host_addrs {
394 in_process_host_addrs.push(Self::create_in_process_host(host_addr).await?);
395 }
396 HostMeshRef::new(
397 HostMeshId::instance(Label::new("local").unwrap()),
398 extent!(hosts = n).into(),
399 in_process_host_addrs,
400 )
401 }
402
403 async fn create_in_process_host(addr: ChannelAddr) -> crate::Result<ChannelAddr> {
406 let spawn: ProcManagerSpawnFn =
407 Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
408 let manager = LocalProcManager::new(spawn);
409 let host = Host::new_with_gateway(manager, addr, None, Gateway::new(), None).await?;
414 let addr = host.addr().clone();
415 let system_proc = host.system_proc().clone();
416 let host_mesh_agent = system_proc
417 .spawn_with_uid(
418 Uid::singleton(Label::new(host_agent::HOST_MESH_AGENT_ACTOR_NAME).unwrap()),
419 HostAgent::new_local(host),
420 )
421 .map_err(crate::Error::SingletonActorSpawnError)?;
422 HostAgent::wait_initialized(&host_mesh_agent).await?;
423 host_mesh_agent.bind::<HostAgent>();
424
425 let cast_handle = system_proc
426 .spawn_with_uid(
427 Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
428 CastActor::default(),
429 )
430 .map_err(crate::Error::SingletonActorSpawnError)?;
431
432 cast_handle.bind::<CastActor>();
433
434 Ok(addr)
435 }
436
437 pub async fn process(extent: Extent, command: BootstrapCommand) -> crate::Result<HostMesh> {
448 if let Ok(Some(boot)) = Bootstrap::get_from_env() {
449 let result = boot.bootstrap().await;
450 if let Err(err) = result {
451 tracing::error!("failed to bootstrap process host mesh process: {}", err);
452 }
453 std::process::exit(1);
454 }
455
456 let bind_spec = hyperactor_config::global::get_cloned(DEFAULT_TRANSPORT);
457 let mut host_addrs = Vec::with_capacity(extent.num_ranks());
458 for _ in 0..extent.num_ranks() {
459 let addr = bind_spec.binding_addr();
461 let bootstrap = Bootstrap::Host {
462 addr: addr.clone(),
463 command: Some(command.clone()),
464 config: Some(hyperactor_config::global::attrs()),
465 exit_on_shutdown: false,
466 };
467
468 let mut cmd = command.new();
469 bootstrap.to_env(&mut cmd);
470 cmd.spawn()?;
471 host_addrs.push(addr);
472 }
473
474 let host_mesh_ref = HostMeshRef::new(
475 HostMeshId::instance(Label::new("process").unwrap()),
476 extent.into(),
477 host_addrs,
478 )?;
479 Ok(HostMesh::take(host_mesh_ref))
480 }
481 pub fn take(mesh: HostMeshRef) -> Self {
486 let id = mesh.id.clone();
487 let extent = mesh.region().extent().clone();
488
489 let result = Self {
490 current_ref: mesh,
491 id,
492 extent,
493 shutdown_on_drop: true,
494 };
495 result.notify_created();
496 result
497 }
498
499 pub async fn attach(
513 cx: &impl context::Actor,
514 id: HostMeshId,
515 addresses: Vec<ChannelAddr>,
516 ) -> crate::Result<Self> {
517 let mesh_ref = HostMeshRef::from_hosts(id, addresses);
518 let config = hyperactor_config::global::propagatable_attrs();
519 mesh_ref.push_config(cx, config).await?;
520 Ok(Self::take(mesh_ref))
521 }
522
523 #[hyperactor::instrument(fields(host_mesh=self.id.to_string()))]
532 pub async fn shutdown(&mut self, cx: &impl hyperactor::context::Actor) -> anyhow::Result<()> {
533 let t0 = std::time::Instant::now();
534 tracing::info!(name = "HostMeshStatus", status = "Shutdown::Attempt");
535
536 if let Err(e) = self.current_ref.cast_drain(cx, None).await {
539 tracing::warn!(
540 name = "HostMeshStatus",
541 status = "Shutdown::Drain::Failed",
542 drain_ms = t0.elapsed().as_millis(),
543 error = %e,
544 "failed to cast DrainHost barrier"
545 );
546 }
547 let drain_ms = t0.elapsed().as_millis();
548
549 let t1 = std::time::Instant::now();
551 let shutdown_result = self.current_ref.cast_shutdown(cx).await;
552 let shutdown_ack_ms = t1.elapsed().as_millis();
553 let total_ms = t0.elapsed().as_millis();
554 if let Err(e) = shutdown_result {
555 tracing::warn!(
556 name = "HostMeshStatus",
557 status = "Shutdown::Ack::Failed",
558 drain_ms,
559 shutdown_ack_ms,
560 total_ms,
561 error = %e,
562 "failed waiting for ShutdownHost acknowledgment barrier"
563 );
564 } else {
565 tracing::info!(
566 name = "HostMeshStatus",
567 status = "Shutdown::Success",
568 drain_ms,
569 shutdown_ack_ms,
570 total_ms
571 );
572 }
573
574 self.shutdown_on_drop = false;
575 Ok(())
576 }
577
578 pub fn shutdown_guard(self) -> HostMeshShutdownGuard {
581 HostMeshShutdownGuard(self)
582 }
583
584 #[hyperactor::instrument(fields(host_mesh=self.id.to_string()))]
591 pub async fn stop(&mut self, cx: &impl hyperactor::context::Actor) -> anyhow::Result<()> {
592 let t0 = std::time::Instant::now();
593 tracing::info!(name = "HostMeshStatus", status = "Stop::Attempt");
594
595 let result = self.current_ref.cast_drain(cx, Some(self.id.clone())).await;
596 let total_ms = t0.elapsed().as_millis();
597 match result {
598 Ok(()) => {
599 tracing::info!(name = "HostMeshStatus", status = "Stop::Success", total_ms,);
600 }
601 Err(e) => tracing::warn!(
602 name = "HostMeshStatus",
603 status = "Stop::Drain::Failed",
604 total_ms,
605 error = %e,
606 "failed waiting for DrainHost acknowledgment barrier"
607 ),
608 }
609
610 self.shutdown_on_drop = false;
613
614 Ok(())
615 }
616}
617
618impl HostMesh {
619 pub fn set_bootstrap(&mut self, cmd: BootstrapCommand) {
624 self.current_ref = self.current_ref.clone().with_bootstrap(cmd);
625 }
626}
627
628impl Deref for HostMesh {
629 type Target = HostMeshRef;
630
631 fn deref(&self) -> &Self::Target {
632 &self.current_ref
633 }
634}
635
636impl AsRef<HostMeshRef> for HostMesh {
637 fn as_ref(&self) -> &HostMeshRef {
638 self
639 }
640}
641
642impl AsRef<HostMeshRef> for HostMeshRef {
643 fn as_ref(&self) -> &HostMeshRef {
644 self
645 }
646}
647
648pub struct HostMeshShutdownGuard(pub HostMesh);
650
651impl Deref for HostMeshShutdownGuard {
652 type Target = HostMesh;
653
654 fn deref(&self) -> &HostMesh {
655 &self.0
656 }
657}
658
659impl DerefMut for HostMeshShutdownGuard {
660 fn deref_mut(&mut self) -> &mut HostMesh {
661 &mut self.0
662 }
663}
664
665impl Drop for HostMeshShutdownGuard {
666 fn drop(&mut self) {
685 if !self.0.shutdown_on_drop {
686 tracing::debug!(
687 name = "HostMeshStatus",
688 host_mesh = %self.0.id,
689 status = "DropCleanup::Skipped",
690 "hostmesh drop-cleanup skipped after explicit stop/shutdown"
691 );
692 return;
693 }
694
695 tracing::info!(
696 name = "HostMeshStatus",
697 host_mesh = %self.0.id,
698 status = "Dropping",
699 );
700 let current_ref = self.0.current_ref.clone();
701 let host_count = current_ref.region().num_ranks();
702
703 if host_count == 0 {
705 tracing::debug!(
706 host_mesh = %self.0.id,
707 "HostMesh drop cleanup skipped because no owned hosts remain"
708 );
709 } else if let Ok(handle) = tokio::runtime::Handle::try_current() {
710 let mesh_id = self.0.id.clone();
711 let span = tracing::info_span!(
712 "hostmesh_drop_cleanup",
713 host_mesh = %mesh_id,
714 hosts = host_count,
715 );
716
717 handle.spawn(
718 async move {
719 match hyperactor::Proc::direct(
722 ChannelTransport::Unix.any(),
723 "hostmesh-drop".to_string(),
724 ) {
725 Err(e) => {
726 tracing::warn!(
727 error = %e,
728 "failed to construct ephemeral Proc for drop-cleanup; \
729 relying on PDEATHSIG/manager Drop"
730 );
731 }
732 Ok(proc) => {
733 let client = proc.client("drop");
734 if let Err(e) = current_ref.cast_shutdown(&client).await {
735 tracing::warn!(
736 error = %e,
737 "drop-cleanup: failed to cast ShutdownHost"
738 );
739 } else {
740 tracing::info!(
741 hosts = host_count,
742 "hostmesh drop-cleanup shutdown barrier complete"
743 );
744 }
745 }
746 }
747 }
748 .instrument(span),
749 );
750 } else {
751 tracing::warn!(
754 host_mesh = %self.0.id,
755 hosts = host_count,
756 "HostMesh dropped without a Tokio runtime; skipping \
757 best-effort shutdown. This indicates that .shutdown() \
758 on this mesh has not been called before program exit \
759 (perhaps due to a missing call to \
760 'monarch.actor.shutdown_context()'?) This in turn can \
761 lead to backtrace output due to folly SIGTERM \
762 handlers."
763 );
764 }
765
766 tracing::info!(
767 name = "HostMeshStatus",
768 host_mesh = %self.0.id,
769 status = "Dropped",
770 );
771 }
772}
773
774pub(crate) fn mesh_to_rankedvalues_with_default<T, F>(
783 mesh: &ValueMesh<T>,
784 default: T,
785 is_sentinel: F,
786 len: usize,
787) -> RankedValues<T>
788where
789 T: Eq + Clone + 'static,
790 F: Fn(&T) -> bool,
791{
792 let mut out = RankedValues::from((0..len, default));
793 for (i, s) in mesh.values().enumerate() {
794 if !is_sentinel(&s) {
795 out.merge_from(RankedValues::from((i..i + 1, s)));
796 }
797 }
798 out
799}
800
801#[derive(Debug, Clone, Named, Serialize, Deserialize, PartialEq, Eq, Hash)]
820pub struct HostMeshRef {
821 id: HostMeshId,
822 host_agent_mesh: ActorMeshRef<HostAgent>,
823 #[serde(default)]
828 pub bootstrap_command: Option<BootstrapCommand>,
829}
830
831pub type PerRankBootstrapFn = dyn Fn(view::Point) -> anyhow::Result<BootstrapCommand> + Send + Sync;
837
838wirevalue::register_type!(HostMeshRef);
839
840impl HostMeshRef {
841 #[allow(clippy::result_large_err)]
844 fn new(id: HostMeshId, region: Region, host_addrs: Vec<ChannelAddr>) -> crate::Result<Self> {
845 if region.num_ranks() != host_addrs.len() {
846 return Err(crate::Error::InvalidRankCardinality {
847 expected: region.num_ranks(),
848 actual: host_addrs.len(),
849 });
850 }
851 let host_agent_mesh = Self::host_agent_mesh_ref_from_addrs(®ion, host_addrs)?;
852 Ok(Self {
853 id,
854 host_agent_mesh,
855 bootstrap_command: None,
856 })
857 }
858
859 pub fn from_hosts(id: HostMeshId, host_addrs: Vec<ChannelAddr>) -> Self {
862 let region = extent!(hosts = host_addrs.len()).into();
863 let host_agent_mesh = Self::host_agent_mesh_ref_from_addrs(®ion, host_addrs)
864 .expect("host rank cardinality must match generated region");
865 Self {
866 id,
867 host_agent_mesh,
868 bootstrap_command: None,
869 }
870 }
871
872 pub fn from_host_agents(
874 id: HostMeshId,
875 agents: Vec<ActorRef<HostAgent>>,
876 ) -> crate::Result<Self> {
877 let region = extent!(hosts = agents.len()).into();
878 let host_agent_mesh = Self::host_agent_mesh_ref_from_agents(®ion, agents)?;
879 Ok(Self {
880 id,
881 host_agent_mesh,
882 bootstrap_command: None,
883 })
884 }
885
886 pub fn from_host_agent(id: HostMeshId, agent: ActorRef<HostAgent>) -> crate::Result<Self> {
888 let region = Extent::unity().into();
889 let agent = host_agent_ref(agent.actor_addr().proc_addr().addr().clone());
899 let host_agent_mesh = Self::host_agent_mesh_ref_from_agents(®ion, vec![agent])?;
900 Ok(Self {
901 id,
902 host_agent_mesh,
903 bootstrap_command: None,
904 })
905 }
906
907 pub fn with_bootstrap(self, cmd: BootstrapCommand) -> Self {
910 Self {
911 bootstrap_command: Some(cmd),
912 ..self
913 }
914 }
915
916 fn host_agent_mesh_ref_from_addrs(
917 region: &Region,
918 host_addrs: Vec<ChannelAddr>,
919 ) -> crate::Result<ActorMeshRef<HostAgent>> {
920 let agents = host_addrs.into_iter().map(host_agent_ref).collect();
921 Self::host_agent_mesh_ref_from_agents(region, agents)
922 }
923
924 fn host_agent_mesh_ref_from_agents(
925 region: &Region,
926 agents: Vec<ActorRef<HostAgent>>,
927 ) -> crate::Result<ActorMeshRef<HostAgent>> {
928 let members = Arc::new(
929 agents
930 .into_iter()
931 .map(|agent| agent.actor_addr().clone())
932 .collect_mesh::<ValueMesh<_>>(region.clone())
933 .map_err(|error| crate::Error::ConfigurationError(error.into()))?,
934 );
935
936 Ok(ActorMeshRef::new(
937 ActorMeshId::singleton(Label::strip(host_agent::HOST_MESH_AGENT_ACTOR_NAME)),
938 None,
940 region.clone(),
941 None,
942 members,
943 ))
944 }
945
946 async fn cast_drain(
947 &self,
948 cx: &impl context::Actor,
949 host_mesh_id: Option<HostMeshId>,
950 ) -> anyhow::Result<()> {
951 let region = self.region().clone();
952 let num_hosts = region.num_ranks();
953 if num_hosts == 0 {
954 return Ok(());
955 }
956
957 let (reply, rx) = cx.mailbox().open_accum_port_opts(
961 crate::StatusMesh::from_single(region.clone(), Status::NotExist),
962 StreamingReducerOpts {
963 max_update_interval: Some(std::time::Duration::from_millis(50)),
964 initial_update_interval: None,
965 },
966 );
967 let mut reply = reply.bind();
968 reply.return_undeliverable(false);
969
970 let terminate_timeout =
971 hyperactor_config::global::get(crate::bootstrap::MESH_TERMINATE_TIMEOUT);
972
973 self.host_agent_mesh.cast(
974 cx,
975 host_agent::DrainHost {
976 timeout: terminate_timeout,
977 max_in_flight: hyperactor_config::global::get(
978 crate::bootstrap::MESH_TERMINATE_CONCURRENCY,
979 )
980 .clamp(1, 256),
981 host_mesh_id,
982 rank: Default::default(),
983 reply,
984 },
985 )?;
986
987 let barrier_timeout = terminate_timeout.saturating_add(std::time::Duration::from_secs(30));
990
991 match GetRankStatus::wait(rx, num_hosts, barrier_timeout, region).await {
992 Ok(_) => Ok(()),
993 Err(partial) => {
994 let missing: Vec<usize> = partial
995 .values()
996 .enumerate()
997 .filter(|(_, status)| status.is_not_exist())
998 .map(|(rank, _)| rank)
999 .collect();
1000 anyhow::bail!(
1001 "DrainHost barrier timed out after {:?}; {} of {} hosts did not acknowledge (host ranks {:?})",
1002 barrier_timeout,
1003 missing.len(),
1004 num_hosts,
1005 missing,
1006 )
1007 }
1008 }
1009 }
1010
1011 async fn cast_shutdown(&self, cx: &impl context::Actor) -> anyhow::Result<()> {
1012 let num_hosts = self.region().num_ranks();
1013 if num_hosts == 0 {
1014 return Ok(());
1015 }
1016
1017 let (ack, mut rx) = cx.mailbox().open_port::<usize>();
1023 let mut ack = ack.bind().unsplit();
1028 ack.return_undeliverable(false);
1029
1030 let terminate_timeout =
1031 hyperactor_config::global::get(crate::bootstrap::MESH_TERMINATE_TIMEOUT);
1032
1033 self.host_agent_mesh.cast(
1034 cx,
1035 host_agent::ShutdownHost {
1036 timeout: terminate_timeout,
1037 max_in_flight: hyperactor_config::global::get(
1038 crate::bootstrap::MESH_TERMINATE_CONCURRENCY,
1039 )
1040 .clamp(1, 256),
1041 rank: Default::default(),
1042 ack,
1043 },
1044 )?;
1045
1046 let barrier_timeout = terminate_timeout.saturating_add(std::time::Duration::from_secs(30));
1049
1050 let mut acked = std::collections::HashSet::new();
1051
1052 while acked.len() < num_hosts {
1053 match tokio::time::timeout(barrier_timeout, rx.recv()).await {
1054 Ok(Ok(rank)) => {
1055 acked.insert(rank);
1056 }
1057 Ok(Err(err)) => return Err(anyhow::Error::from(err)),
1058 Err(_) => {
1059 let missing: Vec<usize> =
1060 (0..num_hosts).filter(|r| !acked.contains(r)).collect();
1061
1062 anyhow::bail!(
1063 "ShutdownHost barrier timed out after {:?}; {} of {} hosts did not acknowledge shutdown (host ranks {:?})",
1064 barrier_timeout,
1065 missing.len(),
1066 num_hosts,
1067 missing,
1068 );
1069 }
1070 }
1071 }
1072 Ok(())
1073 }
1074
1075 pub(crate) fn cast_stream_state(
1079 &self,
1080 cx: &impl context::Actor,
1081 id: ResourceId,
1082 subscriber: hyperactor::PortRef<resource::State<ProcState>>,
1083 ) -> anyhow::Result<()> {
1084 Ok(self
1085 .host_agent_mesh
1086 .cast(cx, resource::StreamState::<ProcState> { id, subscriber })?)
1087 }
1088
1089 pub(crate) fn host_entries(&self) -> Vec<(String, ActorRef<HostAgent>)> {
1093 self.host_agent_mesh
1094 .values()
1095 .map(|agent| (agent.actor_addr().addr().to_string(), agent.clone()))
1096 .collect()
1097 }
1098
1099 pub(crate) async fn push_config(
1111 &self,
1112 cx: &impl context::Actor,
1113 attrs: hyperactor_config::attrs::Attrs,
1114 ) -> Result<(), ConfigPushError> {
1115 let timeout = hyperactor_config::global::get(crate::config::MESH_ATTACH_CONFIG_TIMEOUT);
1116 let host_addrs = self.host_addrs();
1117 let num_hosts = host_addrs.len();
1118
1119 if num_hosts == 0 {
1120 tracing::info!(success = 0, "push_config complete");
1121 return Ok(());
1122 }
1123
1124 fn failures_for_host_addrs(
1125 host_addrs: &[ChannelAddr],
1126 mut make_failure: impl FnMut() -> ConfigPushFailure,
1127 ) -> ConfigPushError {
1128 ConfigPushError {
1129 failures: host_addrs
1130 .iter()
1131 .cloned()
1132 .map(|host_addr| (host_addr, make_failure()))
1133 .collect(),
1134 }
1135 }
1136
1137 let region = self.region().clone();
1138
1139 let (reply, rx) = cx.mailbox().open_accum_port_opts(
1143 crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1144 StreamingReducerOpts {
1145 max_update_interval: Some(std::time::Duration::from_millis(50)),
1146 initial_update_interval: None,
1147 },
1148 );
1149 let mut reply = reply.bind();
1150 reply.return_undeliverable(false);
1151
1152 if let Err(err) = self.host_agent_mesh.cast(
1159 cx,
1160 host_agent::SetClientConfig {
1161 attrs,
1162 rank: Default::default(),
1163 reply,
1164 },
1165 ) {
1166 let error = err.to_string();
1167
1168 tracing::warn!(error = %error, "config push cast failed");
1169
1170 return Err(failures_for_host_addrs(&host_addrs, || {
1174 ConfigPushFailure::CastFailed(error.clone())
1175 }));
1176 }
1177
1178 match GetRankStatus::wait(rx, num_hosts, timeout, region).await {
1179 Ok(_) => {
1180 tracing::info!(success = num_hosts, "push_config complete");
1181 Ok(())
1182 }
1183 Err(partial) => {
1184 let failures: Vec<(ChannelAddr, ConfigPushFailure)> = partial
1188 .values()
1189 .enumerate()
1190 .filter(|(_, status)| status.is_not_exist())
1191 .map(|(rank, _)| (host_addrs[rank].clone(), ConfigPushFailure::ReplyTimedOut))
1192 .collect();
1193
1194 tracing::info!(
1195 success = num_hosts - failures.len(),
1196 failed = failures.len(),
1197 "push_config complete with failures"
1198 );
1199
1200 Err(ConfigPushError { failures })
1201 }
1202 }
1203 }
1204
1205 #[allow(clippy::result_large_err)]
1224 pub async fn spawn<C: context::Actor>(
1225 &self,
1226 cx: &C,
1227 name: &str,
1228 per_host: Extent,
1229 proc_bind: Option<Vec<ProcBind>>,
1230 per_rank_bootstrap: Option<Box<PerRankBootstrapFn>>,
1231 ) -> crate::Result<ProcMesh>
1232 where
1233 C::A: Handler<MeshFailure>,
1234 {
1235 self.spawn_inner(
1236 cx,
1237 ProcMeshId::instance(Label::strip(name)),
1238 per_host,
1239 proc_bind,
1240 per_rank_bootstrap,
1241 )
1242 .await
1243 }
1244
1245 #[hyperactor::instrument(fields(host_mesh=self.id.to_string(), proc_mesh=proc_mesh_id.to_string()))]
1246 async fn spawn_inner<C: context::Actor>(
1247 &self,
1248 cx: &C,
1249 proc_mesh_id: ProcMeshId,
1250 per_host: Extent,
1251 proc_bind: Option<Vec<ProcBind>>,
1252 per_rank_bootstrap: Option<Box<PerRankBootstrapFn>>,
1253 ) -> crate::Result<ProcMesh>
1254 where
1255 C::A: Handler<MeshFailure>,
1256 {
1257 tracing::info!(name = "HostMeshStatus", status = "ProcMesh::Spawn::Attempt");
1258 tracing::info!(name = "ProcMeshStatus", status = "Spawn::Attempt",);
1259 let result = self
1260 .spawn_inner_inner(cx, proc_mesh_id, per_host, proc_bind, per_rank_bootstrap)
1261 .await;
1262 match &result {
1263 Ok(_) => {
1264 tracing::info!(name = "HostMeshStatus", status = "ProcMesh::Spawn::Success");
1265 tracing::info!(name = "ProcMeshStatus", status = "Spawn::Success");
1266 }
1267 Err(error) => {
1268 tracing::error!(name = "HostMeshStatus", status = "ProcMesh::Spawn::Failed", %error);
1269 tracing::error!(name = "ProcMeshStatus", status = "Spawn::Failed", %error);
1270 }
1271 }
1272 result
1273 }
1274
1275 async fn spawn_inner_inner<C: context::Actor>(
1276 &self,
1277 cx: &C,
1278 proc_mesh_id: ProcMeshId,
1279 per_host: Extent,
1280 proc_bind: Option<Vec<ProcBind>>,
1281 per_rank_bootstrap: Option<Box<PerRankBootstrapFn>>,
1282 ) -> crate::Result<ProcMesh>
1283 where
1284 C::A: Handler<MeshFailure>,
1285 {
1286 let per_host_labels = per_host.labels().iter().collect::<HashSet<_>>();
1287 let host_labels = self.region().labels().iter().collect::<HashSet<_>>();
1288 if !per_host_labels
1289 .intersection(&host_labels)
1290 .collect::<Vec<_>>()
1291 .is_empty()
1292 {
1293 return Err(crate::Error::ConfigurationError(anyhow::anyhow!(
1294 "per_host dims overlap with existing dims when spawning proc mesh"
1295 )));
1296 }
1297 if let Some(proc_bind) = proc_bind.as_ref()
1298 && proc_bind.len() != per_host.num_ranks()
1299 {
1300 return Err(crate::Error::ConfigurationError(anyhow::anyhow!(
1301 "proc_bind length does not match per_host extent"
1302 )));
1303 }
1304
1305 let extent = self
1306 .region()
1307 .extent()
1308 .concat(&per_host)
1309 .map_err(|err| crate::Error::ConfigurationError(err.into()))?;
1310
1311 let region: Region = extent.clone().into();
1312
1313 tracing::info!(
1314 name = "ProcMeshStatus",
1315 status = "Spawn::Attempt",
1316 %region,
1317 "spawning proc mesh"
1318 );
1319
1320 let mut procs = Vec::new();
1321 let num_ranks = region.num_ranks();
1322 let (port, rx) = cx.mailbox().open_accum_port_opts(
1325 crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1326 StreamingReducerOpts {
1327 max_update_interval: Some(Duration::from_millis(50)),
1328 initial_update_interval: None,
1329 },
1330 );
1331
1332 let mut proc_names = Vec::new();
1339 let client_config_override = hyperactor_config::global::propagatable_attrs();
1340 for (host_rank, agent) in self.host_agent_mesh.values().enumerate() {
1341 for per_host_rank in 0..per_host.num_ranks() {
1342 let create_rank = per_host.num_ranks() * host_rank + per_host_rank;
1343 let proc_name = host_agent::proc_name(&proc_mesh_id, create_rank);
1344 proc_names.push(proc_name.clone());
1345 let proc_id = named_proc_on_host(&agent, &proc_name);
1346 let proc_agent =
1347 ActorRef::attest(proc_id.actor_addr(crate::proc_agent::PROC_AGENT_ACTOR_NAME));
1348 tracing::info!(
1349 name = "ProcMeshStatus",
1350 status = "Spawn::CreatingProc",
1351 %proc_id,
1352 rank = create_rank,
1353 );
1354 procs.push(crate::proc_mesh::ProcRef::new(
1355 proc_id,
1356 create_rank,
1357 proc_agent,
1359 ));
1360 }
1361 }
1362
1363 let mut reply_port = port.bind();
1364
1365 reply_port.return_undeliverable(false);
1366
1367 let total_procs = self.region().num_ranks() * per_host.num_ranks();
1368
1369 let bootstrap_commands = match per_rank_bootstrap.as_ref() {
1370 Some(per_rank_bootstrap) => Some(
1371 (0..total_procs)
1372 .map(|create_rank| {
1373 per_rank_bootstrap(
1374 extent
1375 .point_of_rank(create_rank)
1376 .expect("rank in combined extent"),
1377 )
1378 .map(Some)
1379 .map_err(crate::Error::ConfigurationError)
1380 })
1381 .collect::<crate::Result<Vec<_>>>()?,
1382 ),
1383 None => None,
1384 };
1385
1386 self.host_agent_mesh.cast(
1389 cx,
1390 host_agent::SpawnProcs {
1391 rank: resource::Rank::default(),
1392 proc_mesh_id: proc_mesh_id.clone(),
1393 num_per_host: per_host.num_ranks(),
1394 client_config_override,
1395 host_mesh_id: Some(self.id.clone()),
1396 default_bootstrap_command: self.bootstrap_command.clone(),
1397 proc_bind,
1398 bootstrap_commands,
1399 status_reply: Some(reply_port),
1400 },
1401 )?;
1402
1403 let start_time = tokio::time::Instant::now();
1404
1405 match GetRankStatus::wait(
1408 rx,
1409 num_ranks,
1410 hyperactor_config::global::get(PROC_SPAWN_MAX_IDLE),
1411 region.clone(), )
1413 .await
1414 {
1415 Ok(statuses) => {
1416 if let Some((rank, status)) = statuses
1419 .values()
1420 .enumerate()
1421 .find(|(_, s)| s.is_terminating())
1422 {
1423 let proc_name = &proc_names[rank];
1424 let host_rank = rank / per_host.num_ranks();
1425 let mesh_agent = self
1426 .host_agent_mesh
1427 .get(host_rank)
1428 .expect("host rank must be in host agent mesh")
1429 .clone();
1430 let (reply_tx, mut reply_rx) = cx.mailbox().open_port();
1431 let mut reply_tx = reply_tx.bind();
1432 reply_tx.return_undeliverable(false);
1435 mesh_agent.post(
1436 cx,
1437 resource::GetState {
1438 id: proc_name.clone(),
1439 reply: reply_tx,
1440 },
1441 );
1442 let state = match tokio::time::timeout(
1443 hyperactor_config::global::get(PROC_SPAWN_MAX_IDLE),
1444 reply_rx.recv(),
1445 )
1446 .await
1447 {
1448 Ok(Ok(state)) => state,
1449 _ => resource::State {
1450 id: proc_name.clone(),
1451 status,
1452 state: None,
1453 generation: 0,
1454 timestamp: std::time::SystemTime::now(),
1455 },
1456 };
1457
1458 tracing::error!(
1459 name = "ProcMeshStatus",
1460 status = "Spawn::GetRankStatus",
1461 rank = host_rank,
1462 "rank {} is terminating with state: {}",
1463 host_rank,
1464 state
1465 );
1466
1467 return Err(crate::Error::ProcCreationError {
1468 state: Box::new(state),
1469 host_rank,
1470 mesh_agent,
1471 });
1472 }
1473 }
1474 Err(complete) => {
1475 tracing::error!(
1476 name = "ProcMeshStatus",
1477 status = "Spawn::GetRankStatus",
1478 "timeout after {:?} when waiting for procs being created",
1479 hyperactor_config::global::get(PROC_SPAWN_MAX_IDLE),
1480 );
1481 let legacy = mesh_to_rankedvalues_with_default(
1484 &complete,
1485 Status::Timeout(start_time.elapsed()),
1486 Status::is_not_exist,
1487 num_ranks,
1488 );
1489 return Err(crate::Error::ProcSpawnError { statuses: legacy });
1490 }
1491 }
1492
1493 let mut mesh = ProcMesh::create(proc_mesh_id, extent, self.clone(), procs);
1494 if let Ok(ref mut mesh) = mesh {
1495 let mesh_ref: ProcMeshRef = (**mesh).clone();
1499 let region = ndslice::view::Ranked::region(&mesh_ref).clone();
1500 let initial_statuses: crate::ValueMesh<resource::Status> =
1501 std::iter::repeat_n(resource::Status::Running, region.num_ranks())
1502 .collect_mesh::<crate::ValueMesh<_>>(region)?;
1503 let controller = ProcMeshController::new(mesh_ref, None, None, initial_statuses);
1504 let controller_name = format!("{}_{}", PROC_MESH_CONTROLLER_NAME, mesh.id());
1507 let controller_handle = cx.spawn_with_label(&controller_name, controller);
1508 let controller_ref: ActorRef<ProcMeshController> = controller_handle.bind();
1513 mesh.set_controller(Some(controller_ref));
1514 }
1515 mesh
1516 }
1517
1518 pub fn id(&self) -> &HostMeshId {
1520 &self.id
1521 }
1522
1523 pub(crate) fn agent_mesh(&self) -> &ActorMeshRef<HostAgent> {
1528 &self.host_agent_mesh
1529 }
1530
1531 pub fn host_addrs(&self) -> Vec<ChannelAddr> {
1533 self.host_agent_mesh
1534 .values()
1535 .map(|agent| agent.actor_addr().addr().clone())
1536 .collect()
1537 }
1538
1539 #[hyperactor::instrument(fields(host_mesh=self.id.to_string(), proc_mesh=proc_mesh_id.to_string()))]
1550 pub(crate) async fn stop_proc_mesh(
1551 &self,
1552 cx: &impl hyperactor::context::Actor,
1553 proc_mesh_id: &ProcMeshId,
1554 procs: impl IntoIterator<Item = ProcAddr>,
1555 region: Region,
1556 reason: String,
1557 ) -> crate::Result<crate::StatusMesh> {
1558 let mut proc_names = Vec::new();
1561 let num_ranks = region.num_ranks();
1562 let (port, rx) = cx.mailbox().open_accum_port_opts(
1565 crate::StatusMesh::from_single(region.clone(), Status::NotExist),
1566 StreamingReducerOpts {
1567 max_update_interval: Some(Duration::from_millis(50)),
1568 initial_update_interval: None,
1569 },
1570 );
1571 for proc_id in procs.into_iter() {
1572 let addr = proc_id.addr().clone();
1573 let proc_resource_id = ResourceId::new(proc_id.uid().clone(), proc_id.label().cloned());
1577 proc_names.push(proc_resource_id.clone());
1578
1579 let host_agent = host_agent_ref(addr);
1582 host_agent.post(
1583 cx,
1584 resource::Stop {
1585 id: proc_resource_id.clone(),
1586 reason: reason.clone(),
1587 },
1588 );
1589 host_agent
1590 .wait_rank_status(cx, proc_resource_id, Status::Stopped, port.bind())
1591 .await
1592 .map_err(|e| crate::Error::CallError(host_agent.actor_addr().clone(), e))?;
1593
1594 tracing::info!(
1595 name = "ProcMeshStatus",
1596 %proc_id,
1597 status = "Stop::Sent",
1598 );
1599 }
1600 tracing::info!(
1601 name = "HostMeshStatus",
1602 status = "ProcMesh::Stop::Sent",
1603 "sending Stop to proc mesh for {} procs: {}",
1604 proc_names.len(),
1605 proc_names
1606 .iter()
1607 .map(|n| n.to_string())
1608 .collect::<Vec<_>>()
1609 .join(", ")
1610 );
1611
1612 let start_time = tokio::time::Instant::now();
1613
1614 match GetRankStatus::wait(
1615 rx,
1616 num_ranks,
1617 hyperactor_config::global::get(PROC_STOP_MAX_IDLE),
1618 region.clone(), )
1620 .await
1621 {
1622 Ok(statuses) => {
1623 let all_stopped = statuses.values().all(|s| s.is_terminated());
1624 if !all_stopped {
1625 let legacy = mesh_to_rankedvalues_with_default(
1626 &statuses,
1627 Status::NotExist,
1628 Status::is_not_exist,
1629 num_ranks,
1630 );
1631 tracing::error!(
1632 name = "ProcMeshStatus",
1633 status = "FailedToStop",
1634 "failed to terminate proc mesh: {:?}",
1635 statuses,
1636 );
1637 return Err(crate::Error::ProcMeshStopError { statuses: legacy });
1638 }
1639 tracing::info!(name = "ProcMeshStatus", status = "Stopped");
1640 Ok(statuses)
1641 }
1642 Err(complete) => {
1643 let legacy = mesh_to_rankedvalues_with_default(
1646 &complete,
1647 Status::Timeout(start_time.elapsed()),
1648 Status::is_not_exist,
1649 num_ranks,
1650 );
1651 tracing::error!(
1652 name = "ProcMeshStatus",
1653 status = "StoppingTimeout",
1654 "failed to terminate proc mesh {} before timeout: {:?}",
1655 proc_mesh_id,
1656 legacy,
1657 );
1658 Err(crate::Error::ProcMeshStopError { statuses: legacy })
1659 }
1660 }
1661 }
1662}
1663
1664struct HostSet {
1672 seen: HashSet<ActorAddr>,
1673 entries: Vec<(String, ActorRef<HostAgent>)>,
1674}
1675
1676impl HostSet {
1677 fn new() -> Self {
1678 Self {
1679 seen: HashSet::new(),
1680 entries: Vec::new(),
1681 }
1682 }
1683
1684 fn insert(&mut self, addr: String, agent_ref: ActorRef<HostAgent>) {
1687 if self.seen.insert(agent_ref.actor_addr().clone()) {
1688 self.entries.push((addr, agent_ref));
1689 }
1690 }
1691
1692 fn extend_from_mesh(&mut self, mesh: &HostMeshRef) {
1694 for (addr, agent) in mesh.host_entries() {
1695 self.insert(addr, agent);
1696 }
1697 }
1698
1699 fn into_vec(self) -> Vec<(String, ActorRef<HostAgent>)> {
1700 self.entries
1701 }
1702}
1703
1704fn aggregate_hosts(
1711 meshes: &[impl AsRef<HostMeshRef>],
1712 client_host_entries: Option<Vec<(String, ActorRef<HostAgent>)>>,
1713) -> Vec<(String, ActorRef<HostAgent>)> {
1714 let mut set = HostSet::new();
1715
1716 for mesh in meshes {
1718 set.extend_from_mesh(mesh.as_ref());
1719 }
1720
1721 if let Some(entries) = client_host_entries {
1723 for (addr, agent_ref) in entries {
1724 set.insert(addr, agent_ref);
1725 }
1726 }
1727
1728 set.into_vec()
1729}
1730
1731pub async fn spawn_admin(
1745 meshes: impl IntoIterator<Item = impl AsRef<HostMeshRef>>,
1746 cx: &impl hyperactor::context::Actor,
1747 admin_addr: Option<std::net::SocketAddr>,
1748 telemetry_url: Option<String>,
1749) -> anyhow::Result<ActorRef<MeshAdminAgent>> {
1750 let meshes: Vec<_> = meshes.into_iter().collect();
1751 anyhow::ensure!(!meshes.is_empty(), "at least one mesh is required (SA-1)");
1752 for (i, mesh) in meshes.iter().enumerate() {
1753 anyhow::ensure!(
1754 mesh.as_ref().region().num_ranks() != 0,
1755 "mesh at index {} has no hosts (SA-2)",
1756 i,
1757 );
1758 }
1759
1760 let client_entries =
1761 crate::global_context::try_this_host().map(|client_host| client_host.host_entries());
1762 let hosts = aggregate_hosts(&meshes, client_entries);
1763
1764 let root_client_id = cx.mailbox().actor_addr().clone();
1765
1766 let local_proc = cx.instance().proc();
1769 let agent_handle = local_proc.spawn_with_uid(
1770 Uid::singleton(Label::new(crate::mesh_admin::MESH_ADMIN_ACTOR_NAME).unwrap()),
1771 crate::mesh_admin::MeshAdminAgent::new(
1772 hosts,
1773 Some(root_client_id),
1774 admin_addr,
1775 telemetry_url,
1776 ),
1777 )?;
1778 let admin_ref = agent_handle.bind();
1779 Ok(admin_ref)
1780}
1781
1782impl view::Ranked for HostMeshRef {
1783 type Item = ActorRef<HostAgent>;
1784
1785 fn region(&self) -> &Region {
1786 self.host_agent_mesh.region()
1787 }
1788
1789 fn get(&self, rank: usize) -> Option<&Self::Item> {
1790 self.host_agent_mesh.get(rank)
1791 }
1792}
1793
1794impl view::RankedSliceable for HostMeshRef {
1795 fn sliced(&self, region: Region) -> Self {
1796 Self {
1797 id: self.id.clone(),
1798 host_agent_mesh: self.host_agent_mesh.sliced(region),
1799 bootstrap_command: self.bootstrap_command.clone(),
1800 }
1801 }
1802}
1803
1804impl std::fmt::Display for HostMeshRef {
1805 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1806 write!(f, "{}:", self.id)?;
1807 for (rank, agent) in self.host_agent_mesh.values().enumerate() {
1808 if rank > 0 {
1809 write!(f, ",")?;
1810 }
1811 write!(f, "{}", agent.actor_addr().addr())?;
1812 }
1813 write!(f, "@{}", self.region())
1814 }
1815}
1816
1817#[derive(thiserror::Error, Debug)]
1819pub enum HostMeshRefParseError {
1820 #[error(transparent)]
1821 RegionParseError(#[from] RegionParseError),
1822
1823 #[error("invalid host mesh ref: missing region")]
1824 MissingRegion,
1825
1826 #[error("invalid host mesh ref: missing id")]
1827 MissingId,
1828
1829 #[error(transparent)]
1830 InvalidId(#[from] crate::mesh_id::ResourceIdParseError),
1831
1832 #[error(transparent)]
1833 InvalidHostMeshRef(#[from] Box<crate::Error>),
1834
1835 #[error(transparent)]
1836 Other(#[from] anyhow::Error),
1837}
1838
1839impl From<crate::Error> for HostMeshRefParseError {
1840 fn from(err: crate::Error) -> Self {
1841 Self::InvalidHostMeshRef(Box::new(err))
1842 }
1843}
1844
1845impl FromStr for HostMeshRef {
1846 type Err = HostMeshRefParseError;
1847
1848 fn from_str(s: &str) -> Result<Self, Self::Err> {
1849 let (id_str, rest) = s.split_once(':').ok_or(HostMeshRefParseError::MissingId)?;
1850
1851 let id = HostMeshId::from_str(id_str)?;
1852
1853 let (host_addrs, region) = rest
1854 .split_once('@')
1855 .ok_or(HostMeshRefParseError::MissingRegion)?;
1856 let host_addrs = if host_addrs.trim().is_empty() {
1857 Vec::new()
1858 } else {
1859 host_addrs
1860 .split(',')
1861 .map(|host_addr| host_addr.trim())
1862 .map(ChannelAddr::from_str)
1863 .collect::<Result<Vec<_>, _>>()?
1864 };
1865 let region = region.parse()?;
1866 Ok(HostMeshRef::new(id, region, host_addrs)?)
1867 }
1868}
1869
1870#[cfg(test)]
1871mod tests {
1872 #[cfg(fbcode_build)]
1873 use std::assert_matches;
1874
1875 #[cfg(fbcode_build)]
1876 use hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER;
1877 #[cfg(fbcode_build)]
1878 use hyperactor_config::attrs::Attrs;
1879 use ndslice::ViewExt;
1880 use ndslice::extent;
1881 #[cfg(fbcode_build)]
1882 use timed_test::assert_no_process_leak;
1883 #[cfg(fbcode_build)]
1884 use tokio::process::Command;
1885 #[cfg(fbcode_build)]
1886 use tracing_test::traced_test;
1887
1888 use super::*;
1889 #[cfg(fbcode_build)]
1890 use crate::ActorMesh;
1891 #[cfg(fbcode_build)]
1892 use crate::Bootstrap;
1893 #[cfg(fbcode_build)]
1894 use crate::bootstrap::MESH_TAIL_LOG_LINES;
1895 #[cfg(fbcode_build)]
1896 use crate::comm::ENABLE_NATIVE_V1_CASTING;
1897 #[cfg(fbcode_build)]
1898 use crate::resource::Status;
1899 #[cfg(fbcode_build)]
1900 use crate::testactor;
1901 #[cfg(fbcode_build)]
1902 use crate::testactor::GetConfigAttrs;
1903 #[cfg(fbcode_build)]
1904 use crate::testactor::SetConfigAttrs;
1905 use crate::testing;
1906
1907 #[test]
1908 fn test_host_mesh_subset() {
1909 let hosts: HostMeshRef = "test:local:1,local:2,local:3,local:4@replica=2/2,host=2/1"
1910 .parse()
1911 .unwrap();
1912 assert_eq!(
1913 hosts.range("replica", 1).unwrap().to_string(),
1914 "test:local:3,local:4@2+replica=1/2,host=2/1"
1915 );
1916 }
1917
1918 #[test]
1919 fn test_host_mesh_ref_parse_roundtrip() {
1920 let host_mesh_ref = HostMeshRef::new(
1921 HostMeshId::singleton(Label::new("test").unwrap()),
1922 extent!(replica = 2, host = 2).into(),
1923 vec![
1924 "tcp:127.0.0.1:123".parse().unwrap(),
1925 "tcp:127.0.0.1:123".parse().unwrap(),
1926 "tcp:127.0.0.1:123".parse().unwrap(),
1927 "tcp:127.0.0.1:123".parse().unwrap(),
1928 ],
1929 )
1930 .unwrap();
1931
1932 let parsed: HostMeshRef = host_mesh_ref.to_string().parse().unwrap();
1933 assert_eq!(parsed.id().to_string(), host_mesh_ref.id().to_string());
1934 assert_eq!(parsed.region(), host_mesh_ref.region());
1935 assert_eq!(parsed.host_addrs(), host_mesh_ref.host_addrs());
1936 assert_eq!(parsed.bootstrap_command, host_mesh_ref.bootstrap_command);
1937 }
1938
1939 fn free_localhost_addr() -> ChannelAddr {
1945 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1946 ChannelAddr::Tcp(listener.local_addr().unwrap())
1947 }
1948
1949 #[cfg(fbcode_build)]
1950 async fn execute_extrinsic_allocation(config: &hyperactor_config::global::ConfigLock) {
1951 let _guard = config.override_key(crate::bootstrap::MESH_BOOTSTRAP_ENABLE_PDEATHSIG, false);
1952
1953 let program = crate::testresource::get("monarch/hyperactor_mesh/bootstrap");
1954
1955 let hosts = vec![free_localhost_addr(), free_localhost_addr()];
1956
1957 let mut children = Vec::new();
1958 for host in hosts.iter() {
1959 let mut cmd = Command::new(program.clone());
1960 let boot = Bootstrap::Host {
1961 addr: host.clone(),
1962 command: None, config: None,
1964 exit_on_shutdown: false,
1965 };
1966 boot.to_env(&mut cmd);
1967 cmd.kill_on_drop(true);
1968 children.push(cmd.spawn().unwrap());
1969 }
1970
1971 let instance = testing::instance();
1972 let host_mesh =
1973 HostMeshRef::from_hosts(HostMeshId::singleton(Label::new("test").unwrap()), hosts);
1974
1975 let proc_mesh = host_mesh
1976 .spawn(&testing::instance(), "test", extent!(gpus = 4), None, None)
1977 .await
1978 .unwrap();
1979
1980 let actor_mesh: ActorMesh<testactor::TestActor> = proc_mesh
1981 .spawn(&testing::instance(), "test", &())
1982 .await
1983 .unwrap();
1984
1985 testactor::assert_mesh_shape(actor_mesh).await;
1986
1987 HostMesh::take(host_mesh)
1988 .shutdown(&instance)
1989 .await
1990 .expect("hosts shutdown");
1991 }
1992
1993 #[tokio::test]
1994 #[cfg(fbcode_build)]
1995 async fn test_extrinsic_allocation_v0() {
1996 let config = hyperactor_config::global::lock();
1997 let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, false);
1998 execute_extrinsic_allocation(&config).await;
1999 }
2000
2001 #[tokio::test]
2002 #[cfg(fbcode_build)]
2003 async fn test_extrinsic_allocation_v1() {
2004 let config = hyperactor_config::global::lock();
2005 let _guard = config.override_key(ENABLE_NATIVE_V1_CASTING, true);
2006 let _guard1 = config.override_key(ENABLE_DEST_ACTOR_REORDERING_BUFFER, true);
2007 execute_extrinsic_allocation(&config).await;
2008 }
2009
2010 #[expect(
2015 clippy::await_holding_invalid_type,
2016 reason = "tracing-test's #[traced_test] enters a span whose Entered guard is held across awaits; this is inherent to the macro and harmless in a test"
2017 )]
2018 #[traced_test]
2019 #[tokio::test]
2020 #[cfg(fbcode_build)]
2021 async fn test_shutdown_succeeds() {
2022 let config = hyperactor_config::global::lock();
2023 execute_extrinsic_allocation(&config).await;
2024
2025 assert!(
2026 logs_contain("Shutdown::Success"),
2027 "Shutdown::Success status log not found after shutting down host mesh"
2028 );
2029 }
2030
2031 #[tokio::test]
2032 #[cfg(fbcode_build)]
2033 async fn test_failing_proc_allocation() {
2034 let lock = hyperactor_config::global::lock();
2035 let _guard = lock.override_key(MESH_TAIL_LOG_LINES, 100);
2036
2037 let program = crate::testresource::get("monarch/hyperactor_mesh/bootstrap");
2038
2039 let hosts = vec![free_localhost_addr(), free_localhost_addr()];
2040
2041 let mut children = Vec::new();
2042 for host in hosts.iter() {
2043 let mut cmd = Command::new(program.clone());
2044 let boot = Bootstrap::Host {
2045 addr: host.clone(),
2046 config: None,
2047 command: Some(BootstrapCommand::from("false")),
2049 exit_on_shutdown: false,
2050 };
2051 boot.to_env(&mut cmd);
2052 cmd.kill_on_drop(true);
2053 children.push(cmd.spawn().unwrap());
2054 }
2055 let host_mesh =
2056 HostMeshRef::from_hosts(HostMeshId::singleton(Label::new("test").unwrap()), hosts);
2057
2058 let instance = testing::instance();
2059
2060 let err = host_mesh
2061 .spawn(&instance, "test", Extent::unity(), None, None)
2062 .await
2063 .unwrap_err();
2064 assert_matches!(
2065 err,
2066 crate::Error::ProcCreationError { state, .. }
2067 if matches!(state.status, resource::Status::Failed(ref msg) if msg.contains("failed to configure process: Ready(Terminal(Stopped { exit_code: 1"))
2068 );
2069 }
2070
2071 #[cfg(fbcode_build)]
2072 #[assert_no_process_leak]
2073 #[tokio::test]
2074 async fn test_halting_proc_allocation() {
2075 let config = hyperactor_config::global::lock();
2076 let _guard1 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_secs(20));
2077
2078 let program = crate::testresource::get("monarch/hyperactor_mesh/bootstrap");
2079
2080 let hosts = vec![free_localhost_addr(), free_localhost_addr()];
2081
2082 let mut children = Vec::new();
2083
2084 for (index, host) in hosts.iter().enumerate() {
2085 let mut cmd = Command::new(program.clone());
2086 let command = if index == 0 {
2087 let mut command = BootstrapCommand::from("sleep");
2088 command.args.push("60".to_string());
2089 Some(command)
2090 } else {
2091 None
2092 };
2093 let boot = Bootstrap::Host {
2094 addr: host.clone(),
2095 config: None,
2096 command,
2097 exit_on_shutdown: false,
2098 };
2099 boot.to_env(&mut cmd);
2100 cmd.kill_on_drop(true);
2101 children.push(cmd.spawn().unwrap());
2102 }
2103 let host_mesh =
2104 HostMeshRef::from_hosts(HostMeshId::singleton(Label::new("test").unwrap()), hosts);
2105
2106 let instance = testing::instance();
2107
2108 let err = host_mesh
2109 .spawn(&instance, "test", Extent::unity(), None, None)
2110 .await
2111 .unwrap_err();
2112 let statuses = err.into_proc_spawn_error().unwrap();
2113 assert_matches!(
2114 &statuses.materialized_iter(2).cloned().collect::<Vec<_>>()[..],
2115 &[Status::Timeout(_), Status::Running]
2116 );
2117 }
2118
2119 #[tokio::test]
2120 #[cfg(fbcode_build)]
2121 async fn test_client_config_override() {
2122 let config = hyperactor_config::global::lock();
2123 let _guard1 = config.override_key(crate::bootstrap::MESH_BOOTSTRAP_ENABLE_PDEATHSIG, false);
2124 let _guard2 = config.override_key(
2125 hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2126 Duration::from_mins(2),
2127 );
2128 let _guard3 = config.override_key(
2129 hyperactor::config::MESSAGE_DELIVERY_TIMEOUT,
2130 Duration::from_mins(1),
2131 );
2132 let _guard4 = config.override_key(PROC_SPAWN_MAX_IDLE, Duration::from_mins(2));
2133
2134 unsafe {
2139 std::env::remove_var("HYPERACTOR_HOST_SPAWN_READY_TIMEOUT");
2140 std::env::remove_var("HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT");
2141 }
2142
2143 let instance = testing::instance();
2144
2145 let mut hm = testing::host_mesh(2).await;
2146 let proc_mesh = hm
2147 .spawn(instance, "test", Extent::unity(), None, None)
2148 .await
2149 .unwrap();
2150 let proc_ids = proc_mesh
2151 .proc_ids()
2152 .map(|proc_addr| proc_addr.id().clone())
2153 .collect::<Vec<_>>();
2154 let unique_proc_ids = proc_ids.iter().collect::<std::collections::HashSet<_>>();
2155
2156 assert_eq!(proc_ids.len(), 2);
2157 assert_eq!(unique_proc_ids.len(), proc_ids.len());
2158
2159 let actor_mesh: ActorMesh<testactor::TestActor> =
2160 proc_mesh.spawn(instance, "test", &()).await.unwrap();
2161
2162 let mut attrs_override = Attrs::new();
2163 attrs_override.set(
2164 hyperactor::config::HOST_SPAWN_READY_TIMEOUT,
2165 Duration::from_mins(3),
2166 );
2167 actor_mesh
2168 .cast(
2169 instance,
2170 SetConfigAttrs(
2171 bincode::serde::encode_to_vec(&attrs_override, bincode::config::legacy())
2172 .unwrap(),
2173 ),
2174 )
2175 .unwrap();
2176
2177 let (tx, mut rx) = instance.open_port();
2178 actor_mesh
2179 .cast(instance, GetConfigAttrs(tx.bind()))
2180 .unwrap();
2181 let actual_attrs = rx.recv().await.unwrap();
2182 let actual_attrs =
2183 bincode::serde::decode_from_slice::<Attrs, _>(&actual_attrs, bincode::config::legacy())
2184 .map(|(v, _)| v)
2185 .unwrap();
2186
2187 assert_eq!(
2188 *actual_attrs
2189 .get(hyperactor::config::HOST_SPAWN_READY_TIMEOUT)
2190 .unwrap(),
2191 Duration::from_mins(3)
2192 );
2193 assert_eq!(
2194 *actual_attrs
2195 .get(hyperactor::config::MESSAGE_DELIVERY_TIMEOUT)
2196 .unwrap(),
2197 Duration::from_mins(1)
2198 );
2199
2200 let _ = hm.shutdown(instance).await;
2201 }
2202
2203 #[tokio::test]
2221 async fn test_attach_fails_closed_on_unreachable_host() {
2222 let config = hyperactor_config::global::lock();
2223 let _guard = config.override_key(
2226 crate::config::MESH_ATTACH_CONFIG_TIMEOUT,
2227 Duration::from_millis(500),
2228 );
2229
2230 let instance = testing::instance();
2231
2232 let unreachable = free_localhost_addr();
2237
2238 let id = HostMeshId::instance(Label::new("hm_test").unwrap());
2239 let result = HostMesh::attach(instance, id, vec![unreachable.clone()]).await;
2240
2241 let err = match result {
2243 Ok(_) => panic!("HM-2: attach must fail when a host is unreachable"),
2244 Err(e) => e,
2245 };
2246
2247 let push_err = match err {
2249 crate::Error::ConfigPushFailed(e) => e,
2250 other => panic!("expected ConfigPushFailed, got: {other:?}"),
2251 };
2252 assert_eq!(push_err.failures.len(), 1);
2253 let (failed_host, _failure) = &push_err.failures[0];
2254 assert_eq!(
2255 failed_host, &unreachable,
2256 "HM-4: failure entry must identify the unreachable host"
2257 );
2258 }
2270
2271 #[test]
2272 fn test_host_mesh_ref_canonicalizes_alias_to_dial_addr() {
2273 let dial_to = ChannelAddr::from_zmq_url("tcp://127.0.0.1:26600").unwrap();
2274 let alias = ChannelAddr::from_zmq_url("tcp://127.0.0.1:26600@tcp://0.0.0.0:26600").unwrap();
2275
2276 let mesh = HostMeshRef::from_hosts(
2277 HostMeshId::singleton(Label::new("alias").unwrap()),
2278 vec![alias],
2279 );
2280
2281 assert_eq!(mesh.host_addrs(), vec![dial_to.clone()]);
2282 assert_eq!(
2283 ndslice::view::Ranked::get(&mesh, 0)
2284 .expect("host rank should exist")
2285 .actor_addr()
2286 .proc_addr()
2287 .addr(),
2288 &dial_to
2289 );
2290 }
2291
2292 #[tokio::test]
2293 async fn test_sa1_empty_mesh_set_rejected() {
2294 let instance = testing::instance();
2295 let result = spawn_admin(std::iter::empty::<&HostMeshRef>(), instance, None, None).await;
2296 let err = result.unwrap_err().to_string();
2297 assert!(err.contains("SA-1"), "expected SA-1 error, got: {err}");
2298 }
2299
2300 #[tokio::test]
2301 async fn test_sa2_empty_hosts_rejected() {
2302 let instance = testing::instance();
2303 let mesh =
2304 HostMeshRef::from_hosts(HostMeshId::singleton(Label::new("empty").unwrap()), vec![]);
2305 let result = spawn_admin([&mesh], instance, None, None).await;
2306 let err = result.unwrap_err().to_string();
2307 assert!(err.contains("SA-2"), "expected SA-2 error, got: {err}");
2308 }
2309
2310 #[test]
2315 fn test_sa3_host_set_insert_idempotent() {
2316 let addr_a: ChannelAddr = "tcp:127.0.0.1:2001".parse().unwrap();
2317 let addr_b: ChannelAddr = "tcp:127.0.0.1:2002".parse().unwrap();
2318
2319 let ref_a = host_agent_ref(addr_a.clone());
2320 let ref_b = host_agent_ref(addr_b.clone());
2321
2322 let mut set = HostSet::new();
2323 set.insert(addr_a.to_string(), ref_a.clone());
2324 set.insert(addr_b.to_string(), ref_b.clone());
2325 set.insert("duplicate_addr".to_string(), ref_a.clone());
2327
2328 let result = set.into_vec();
2329 assert_eq!(
2330 result.len(),
2331 2,
2332 "SA-3: duplicate ActorAddr must not add entry"
2333 );
2334 assert_eq!(
2335 result[0].0,
2336 addr_a.to_string(),
2337 "SA-3: first-seen order preserved"
2338 );
2339 assert_eq!(
2340 result[1].0,
2341 addr_b.to_string(),
2342 "SA-3: first-seen order preserved"
2343 );
2344 }
2345
2346 #[test]
2347 fn test_sa3_aggregate_hosts_dedup() {
2348 let addr_a: ChannelAddr = "tcp:127.0.0.1:1001".parse().unwrap();
2349 let addr_b: ChannelAddr = "tcp:127.0.0.1:1002".parse().unwrap();
2350 let addr_c: ChannelAddr = "tcp:127.0.0.1:1003".parse().unwrap();
2351
2352 let mesh_a = HostMeshRef::from_hosts(
2354 HostMeshId::singleton(Label::new("mesh-a").unwrap()),
2355 vec![addr_a.clone(), addr_b.clone()],
2356 );
2357 let mesh_b = HostMeshRef::from_hosts(
2359 HostMeshId::singleton(Label::new("mesh-b").unwrap()),
2360 vec![addr_b.clone(), addr_c.clone()],
2361 );
2362
2363 let result = aggregate_hosts(&[&mesh_a, &mesh_b], None);
2364
2365 assert_eq!(result.len(), 3, "expected 3 hosts, got {:?}", result);
2367
2368 let addrs: Vec<String> = result.iter().map(|(a, _)| a.clone()).collect();
2370 assert_eq!(addrs[0], addr_a.to_string());
2371 assert_eq!(addrs[1], addr_b.to_string());
2372 assert_eq!(addrs[2], addr_c.to_string());
2373 }
2374
2375 #[test]
2378 fn test_sa6_ch1_client_host_dedup() {
2379 let addr_a: ChannelAddr = "tcp:127.0.0.1:1001".parse().unwrap();
2380 let addr_b: ChannelAddr = "tcp:127.0.0.1:1002".parse().unwrap();
2381
2382 let mesh = HostMeshRef::from_hosts(
2383 HostMeshId::singleton(Label::new("mesh").unwrap()),
2384 vec![addr_a.clone(), addr_b.clone()],
2385 );
2386
2387 let client_ref = host_agent_ref(addr_a.clone());
2389 let client_entries = vec![("client_addr".to_string(), client_ref)];
2390
2391 let result = aggregate_hosts(&[&mesh], Some(client_entries));
2392
2393 assert_eq!(result.len(), 2, "expected 2 hosts, got {:?}", result);
2395 let addrs: Vec<String> = result.iter().map(|(a, _)| a.clone()).collect();
2396 assert_eq!(addrs[0], addr_a.to_string());
2397 assert_eq!(addrs[1], addr_b.to_string());
2398 }
2399}