1#![allow(unused_assignments)]
16
17use std::collections::HashMap;
18use std::time::Duration;
19
20use async_trait::async_trait;
21use hyperactor::Actor;
22use hyperactor::ActorAddr;
23use hyperactor::ActorEnvironment;
24use hyperactor::ActorHandle;
25use hyperactor::Addr;
26use hyperactor::Client;
27use hyperactor::Context;
28use hyperactor::Data;
29use hyperactor::Endpoint as _;
30use hyperactor::Handler;
31use hyperactor::IdleFlushPortRef;
32use hyperactor::Instance;
33use hyperactor::PortAddr;
34use hyperactor::PortHandle;
35use hyperactor::PortRef;
36use hyperactor::RemoteEndpoint as _;
37use hyperactor::actor::handle_undeliverable_message;
38use hyperactor::actor::remote::Remote;
39use hyperactor::id::Label;
40use hyperactor::id::Uid;
41use hyperactor::mailbox::MessageEnvelope;
42use hyperactor::mailbox::Undeliverable;
43use hyperactor::mailbox::UndeliverableReason;
44use hyperactor::proc::Proc;
45use hyperactor::supervision::ActorSupervisionEvent;
46use hyperactor_cast::cast_actor::CAST_ACTOR_NAME;
47use hyperactor_cast::cast_actor::CAST_POINT;
48use hyperactor_config::CONFIG;
49use hyperactor_config::ConfigAttr;
50use hyperactor_config::Flattrs;
51use hyperactor_config::attrs::declare_attrs;
52use hyperactor_remote::ActorSpawner;
53use hyperactor_remote::proc_spawner::actor_spawner_uid;
54use serde::Deserialize;
55use serde::Serialize;
56use typeuri::Named;
57
58use crate::client_root::CLIENT_ROOT;
59use crate::client_root::ClientRootApi;
60use crate::client_root::ClientRootError;
61use crate::client_root::ClientRootRef;
62use crate::client_root::EnsureClientRootService;
63use crate::client_root::EnsureClientRootServiceReply;
64use crate::config_dump::ConfigDump;
65use crate::config_dump::ConfigDumpResult;
66use crate::introspect::ProcessMemoryStats;
67use crate::mesh_id::ResourceId;
68use crate::pyspy::PySpyDump;
69use crate::pyspy::PySpyProfile;
70use crate::pyspy::PySpyProfileWorker;
71use crate::pyspy::PySpyWorker;
72use crate::resource;
73
74pub const PROC_AGENT_ACTOR_NAME: &str = "proc_agent";
76
77declare_attrs! {
78 @meta(CONFIG = ConfigAttr::new(
82 Some("HYPERACTOR_MESH_ORPHAN_TIMEOUT".to_string()),
83 Some("mesh_orphan_timeout".to_string()),
84 ))
85 pub attr MESH_ORPHAN_TIMEOUT: Option<Duration> = Some(Duration::from_secs(60));
86
87 @meta(CONFIG = ConfigAttr::new(
96 Some("HYPERACTOR_PROCESS_MEMORY_METRIC_INTERVAL".to_string()),
97 Some("process_memory_metric_interval".to_string()),
98 ))
99 pub attr PROCESS_MEMORY_METRIC_INTERVAL: Duration = Duration::from_secs(300);
100
101 pub(crate) attr STREAM_STATE_SUBSCRIBER: bool;
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
127struct RepublishIntrospect {
128 emit_memory_metrics: bool,
129}
130wirevalue::register_type!(RepublishIntrospect);
131
132fn collect_live_children(
138 proc: &hyperactor::Proc,
139) -> (
140 Vec<hyperactor::introspect::IntrospectRef>,
141 Vec<crate::introspect::NodeRef>,
142) {
143 let all_keys = proc.all_instance_keys();
144 let mut children = Vec::with_capacity(all_keys.len());
145 let mut system_children = Vec::new();
146 for id in all_keys {
147 if let Some(cell) = proc.get_instance_by_id(&id) {
148 let actor_addr = cell.actor_addr().clone();
149 if cell.is_system() {
150 system_children.push(crate::introspect::NodeRef::Actor(actor_addr.clone()));
151 }
152 children.push(hyperactor::introspect::IntrospectRef::Actor(actor_addr));
153 }
154 }
155 (children, system_children)
156}
157
158#[derive(Debug)]
160struct ActorInstanceState {
161 create_rank: usize,
162 spawn: Result<ActorAddr, anyhow::Error>,
163 stop_initiated: bool,
167 supervision_event: Option<ActorSupervisionEvent>,
170 subscribers: Vec<(usize, PortRef<resource::RankedState<ActorState>>)>,
174 expiry_time: Option<std::time::SystemTime>,
177 generation: u64,
181 pending_wait_status: Vec<(
185 resource::Status,
186 usize,
187 IdleFlushPortRef<crate::StatusOverlay>,
188 )>,
189}
190
191#[derive(Debug)]
195struct ServiceEntry {
196 actor_type: String,
198 params: Data,
200 id: ResourceId,
203}
204
205impl ActorInstanceState {
206 fn status(&self) -> resource::Status {
209 match &self.spawn {
210 Err(e) => resource::Status::Failed(e.to_string()),
211 Ok(_) => match &self.supervision_event {
212 Some(event) if event.is_error() => resource::Status::Failed(format!("{}", event)),
213 Some(_) => resource::Status::Stopped,
214 None if self.stop_initiated => resource::Status::Stopping,
215 None => resource::Status::Running,
216 },
217 }
218 }
219
220 fn is_terminal(&self) -> bool {
223 match &self.spawn {
224 Err(_) => true,
225 Ok(_) => self.supervision_event.is_some(),
226 }
227 }
228
229 fn has_errors(&self) -> bool {
231 self.supervision_event
232 .as_ref()
233 .is_some_and(|e| e.is_error())
234 }
235
236 fn to_state(&self, id: &ResourceId) -> resource::State<ActorState> {
239 let status = self.status();
240 let actor_state = self.spawn.as_ref().ok().map(|actor_id| ActorState {
241 actor_id: actor_id.clone(),
242 create_rank: self.create_rank,
243 supervision_events: self.supervision_event.clone().into_iter().collect(),
244 });
245 resource::State {
246 id: id.clone(),
247 status,
248 state: actor_state,
249 generation: self.generation,
250 timestamp: std::time::SystemTime::now(),
251 }
252 }
253
254 fn notify_status_changed(&mut self, cx: &impl hyperactor::context::Actor, id: &ResourceId) {
259 let state = self.to_state(id);
261 for (view_rank, subscriber) in &self.subscribers {
262 let mut headers = Flattrs::new();
263 headers.set(STREAM_STATE_SUBSCRIBER, true);
264 subscriber.post_with_headers(
265 cx,
266 headers,
267 resource::RankedState {
268 rank: resource::Rank::new(*view_rank),
269 state: state.clone(),
270 },
271 );
272 }
273
274 let status = self.status();
277 self.pending_wait_status
278 .retain(|(min_status, rank, reply)| {
279 if status >= *min_status {
280 let overlay = crate::StatusOverlay::try_from_runs(vec![(
281 *rank..(*rank + 1),
282 status.clone(),
283 )])
284 .expect("valid single-run overlay");
285 let _ = reply.post(cx, overlay);
286 false
287 } else {
288 true
289 }
290 });
291 }
292}
293
294#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Named)]
295pub(crate) struct SelfCheck {}
296
297#[hyperactor::export(
318 handlers=[
319 ActorSupervisionEvent,
320 resource::CreateOrUpdate<ActorSpec>,
321 resource::Stop,
322 resource::StopAll,
323 resource::GetState<ActorState>,
324 resource::StreamState<ActorState>,
325 resource::KeepaliveGetState<ActorState>,
326 resource::GetRankStatus,
327 resource::WaitRankStatus,
328 RepublishIntrospect,
329 PySpyDump,
330 PySpyProfile,
331 ConfigDump,
332 ]
333)]
334pub struct ProcAgent {
335 proc: Proc,
336 remote: Remote,
337 actor_states: HashMap<ResourceId, ActorInstanceState>,
339 record_supervision_events: bool,
342 introspect_dirty: bool,
345 shutdown_tx: Option<tokio::sync::oneshot::Sender<i32>>,
349 stopping_all: bool,
353 mesh_orphan_timeout: Option<Duration>,
355 client_root_services: HashMap<Label, ServiceEntry>,
359}
360
361impl ProcAgent {
362 pub(crate) fn boot_v1(
363 proc: Proc,
364 shutdown_tx: Option<tokio::sync::oneshot::Sender<i32>>,
365 ) -> Result<ActorHandle<Self>, anyhow::Error> {
366 proc.spawn_bound_with_uid(
367 Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
368 hyperactor_cast::cast_actor::CastActor::default(),
369 )?;
370
371 proc.spawn_bound_with_uid(actor_spawner_uid(), ActorSpawner)?;
375
376 let orphan_timeout = hyperactor_config::global::get(MESH_ORPHAN_TIMEOUT);
377 let agent = ProcAgent {
378 proc: proc.clone(),
379 remote: Remote::collect(),
380 actor_states: HashMap::new(),
381 record_supervision_events: true,
382 introspect_dirty: false,
383 shutdown_tx,
384 stopping_all: false,
385 mesh_orphan_timeout: orphan_timeout,
386 client_root_services: HashMap::new(),
387 };
388 proc.spawn_bound_with_uid::<Self>(
389 Uid::singleton(Label::new(PROC_AGENT_ACTOR_NAME).unwrap()),
390 agent,
391 )
392 }
393
394 fn all_actors_terminal(&self) -> bool {
398 self.actor_states.values().all(|state| state.is_terminal())
399 }
400
401 async fn shutdown(&mut self) {
405 let has_errors = self.actor_states.values().any(|state| state.has_errors());
406 let exit_code = if has_errors { 1 } else { 0 };
407
408 let flush_timeout =
409 hyperactor_config::global::get(hyperactor::config::FORWARDER_FLUSH_TIMEOUT);
410 match tokio::time::timeout(flush_timeout, self.proc.flush()).await {
411 Ok(Err(err)) => {
412 tracing::warn!("forwarder flush failed during shutdown: {}", err);
413 }
414 Err(_elapsed) => {
415 tracing::warn!("forwarder flush timed out during shutdown");
416 }
417 Ok(Ok(())) => {}
418 }
419
420 self.proc.join_mailbox_server().await;
424
425 tracing::info!(
426 "shutting down process after all actors reached terminal state (exit_code={})",
427 exit_code,
428 );
429
430 if let Some(tx) = self.shutdown_tx.take() {
431 let _ = tx.send(exit_code);
432 return;
433 }
434 std::process::exit(exit_code);
435 }
436
437 fn stop_actor_by_id(&self, actor_id: &ActorAddr, reason: &str) {
440 tracing::info!(
441 name = "StopActor",
442 %actor_id,
443 actor_name = actor_id.log_name(),
444 %reason,
445 );
446 self.proc.stop_actor(actor_id.id(), reason.to_string());
447 }
448
449 fn publish_introspect_properties(
452 &self,
453 cx: &impl hyperactor::context::Actor,
454 ) -> ProcessMemoryStats {
455 let (mut children, mut system_children) = collect_live_children(&self.proc);
456
457 let mut stopped_children: Vec<crate::introspect::NodeRef> = Vec::new();
461 for id in self.proc.all_terminated_actor_ids() {
462 let child_ref = hyperactor::introspect::IntrospectRef::Actor(id.clone());
463 let node_ref = crate::introspect::NodeRef::Actor(id.clone());
464 stopped_children.push(node_ref.clone());
465 if let Some(snapshot) = self.proc.terminated_snapshot(&id) {
466 let snapshot_attrs: hyperactor_config::Attrs =
467 serde_json::from_str(&snapshot.attrs).unwrap_or_default();
468 if snapshot_attrs
469 .get(hyperactor::introspect::IS_SYSTEM)
470 .copied()
471 .unwrap_or(false)
472 {
473 system_children.push(node_ref);
474 }
475 }
476 if !children.contains(&child_ref) {
477 children.push(child_ref);
478 }
479 }
480
481 let stopped_retention_cap =
482 hyperactor_config::global::get(hyperactor::config::TERMINATED_SNAPSHOT_RETENTION);
483
484 let failed_actor_count = self
486 .actor_states
487 .values()
488 .filter(|s| s.has_errors())
489 .count();
490
491 let num_live = children.len();
493 let mut attrs = hyperactor_config::Attrs::new();
494 attrs.set(crate::introspect::NODE_TYPE, "proc".to_string());
495 attrs.set(
496 crate::introspect::PROC_NAME,
497 self.proc
498 .proc_addr()
499 .label()
500 .map(|l| l.as_str().to_string())
501 .unwrap_or_else(|| self.proc.proc_addr().id().to_string()),
502 );
503 attrs.set(crate::introspect::NUM_ACTORS, num_live);
504 attrs.set(hyperactor::introspect::CHILDREN, children);
505 attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children);
506 attrs.set(crate::introspect::STOPPED_CHILDREN, stopped_children);
507 attrs.set(
508 crate::introspect::STOPPED_RETENTION_CAP,
509 stopped_retention_cap,
510 );
511 attrs.set(crate::introspect::IS_POISONED, failed_actor_count > 0);
512 attrs.set(crate::introspect::FAILED_ACTOR_COUNT, failed_actor_count);
513
514 let memory = crate::introspect::ProcessMemoryStats::read_from_procfs();
519 memory.to_attrs(&mut attrs);
520
521 let queue_total = self.proc.queue_depth_total();
523 attrs.set(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_TOTAL, queue_total);
524
525 let mut queue_max: u64 = 0;
527 for actor_id in self.proc.all_instance_keys() {
528 if let Some(cell) = self.proc.get_instance_by_id(&actor_id) {
529 queue_max = queue_max.max(cell.queue_depth());
530 }
531 }
532 attrs.set(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_MAX, queue_max);
533
534 attrs.set(
536 crate::introspect::ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK,
537 self.proc.queue_depth_high_water_mark(),
538 );
539 attrs.set(
540 crate::introspect::LAST_NONZERO_QUEUE_DEPTH_AGE_MS,
541 self.proc.last_nonzero_queue_depth_age_ms(),
542 );
543
544 cx.instance().publish_attrs(attrs);
545
546 memory
547 }
548}
549
550#[async_trait]
551impl Actor for ProcAgent {
552 async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
553 this.set_system();
554 self.proc.set_supervision_coordinator(this.port().bind())?;
555 let _ = self.publish_introspect_properties(this);
556
557 let proc = self.proc.clone();
560 let self_id = this.self_addr().clone();
561 this.set_query_child_handler(move |child_ref| {
562 use hyperactor::introspect::IntrospectResult;
563
564 if let Addr::Actor(actor_ref) = child_ref
565 && let Some(snapshot) = proc.terminated_snapshot(actor_ref)
566 {
567 return snapshot;
568 }
569
570 if let Addr::Proc(proc_ref) = child_ref
578 && *proc_ref == proc.proc_addr()
579 {
580 let (mut children, mut system_children) = collect_live_children(&proc);
581
582 let mut stopped_children: Vec<crate::introspect::NodeRef> = Vec::new();
583 for id in proc.all_terminated_actor_ids() {
584 let child_ref = hyperactor::introspect::IntrospectRef::Actor(id.clone());
585 let node_ref = crate::introspect::NodeRef::Actor(id.clone());
586 stopped_children.push(node_ref.clone());
587 if let Some(snapshot) = proc.terminated_snapshot(&id) {
588 let snapshot_attrs: hyperactor_config::Attrs =
589 serde_json::from_str(&snapshot.attrs).unwrap_or_default();
590 if snapshot_attrs
591 .get(hyperactor::introspect::IS_SYSTEM)
592 .copied()
593 .unwrap_or(false)
594 {
595 system_children.push(node_ref);
596 }
597 }
598 if !children.contains(&child_ref) {
599 children.push(child_ref);
600 }
601 }
602
603 let stopped_retention_cap = hyperactor_config::global::get(
604 hyperactor::config::TERMINATED_SNAPSHOT_RETENTION,
605 );
606
607 let (is_poisoned, failed_actor_count) = proc
608 .get_instance(&self_id)
609 .and_then(|cell| cell.published_attrs())
610 .map(|attrs| {
611 let is_poisoned = attrs
612 .get(crate::introspect::IS_POISONED)
613 .copied()
614 .unwrap_or(false);
615 let failed_actor_count = attrs
616 .get(crate::introspect::FAILED_ACTOR_COUNT)
617 .copied()
618 .unwrap_or(0);
619 (is_poisoned, failed_actor_count)
620 })
621 .unwrap_or((false, 0));
622
623 let num_live = children.len();
625 let mut attrs = hyperactor_config::Attrs::new();
626 attrs.set(crate::introspect::NODE_TYPE, "proc".to_string());
627 attrs.set(
628 crate::introspect::PROC_NAME,
629 proc_ref
630 .label()
631 .map(|l| l.as_str().to_string())
632 .unwrap_or_else(|| proc_ref.id().to_string()),
633 );
634 attrs.set(crate::introspect::NUM_ACTORS, num_live);
635 attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children);
636 attrs.set(crate::introspect::STOPPED_CHILDREN, stopped_children);
637 attrs.set(
638 crate::introspect::STOPPED_RETENTION_CAP,
639 stopped_retention_cap,
640 );
641 attrs.set(crate::introspect::IS_POISONED, is_poisoned);
642 attrs.set(crate::introspect::FAILED_ACTOR_COUNT, failed_actor_count);
643
644 let memory = crate::introspect::ProcessMemoryStats::read_from_procfs();
647 memory.to_attrs(&mut attrs);
648 attrs.set(
649 crate::introspect::ACTOR_WORK_QUEUE_DEPTH_TOTAL,
650 proc.queue_depth_total(),
651 );
652 let mut queue_max: u64 = 0;
653 for aid in proc.all_instance_keys() {
654 if let Some(cell) = proc.get_instance_by_id(&aid) {
655 queue_max = queue_max.max(cell.queue_depth());
656 }
657 }
658 attrs.set(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_MAX, queue_max);
659 attrs.set(
660 crate::introspect::ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK,
661 proc.queue_depth_high_water_mark(),
662 );
663 attrs.set(
664 crate::introspect::LAST_NONZERO_QUEUE_DEPTH_AGE_MS,
665 proc.last_nonzero_queue_depth_age_ms(),
666 );
667
668 let attrs_json = serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
669
670 return IntrospectResult {
671 identity: hyperactor::introspect::IntrospectRef::Proc(proc_ref.clone()),
672 attrs: attrs_json,
673 children,
674 parent: None,
675 as_of: std::time::SystemTime::now(),
676 };
677 }
678
679 {
680 let mut error_attrs = hyperactor_config::Attrs::new();
681 error_attrs.set(hyperactor::introspect::ERROR_CODE, "not_found".to_string());
682 error_attrs.set(
683 hyperactor::introspect::ERROR_MESSAGE,
684 format!("child {} not found", child_ref),
685 );
686 let identity = match child_ref {
687 Addr::Proc(p) => hyperactor::introspect::IntrospectRef::Proc(p.clone()),
688 Addr::Actor(a) => hyperactor::introspect::IntrospectRef::Actor(a.clone()),
689 Addr::Port(p) => hyperactor::introspect::IntrospectRef::Actor(p.actor_addr()),
690 };
691 IntrospectResult {
692 identity,
693 attrs: serde_json::to_string(&error_attrs).unwrap_or_else(|_| "{}".to_string()),
694 children: Vec::new(),
695 parent: None,
696 as_of: std::time::SystemTime::now(),
697 }
698 }
699 });
700
701 if let Some(delay) = &self.mesh_orphan_timeout {
702 this.post_after(this, SelfCheck::default(), *delay);
703 }
704 if cfg!(target_os = "linux") {
705 let interval = hyperactor_config::global::get(PROCESS_MEMORY_METRIC_INTERVAL);
706 if !interval.is_zero() {
707 this.post_after(
708 this,
709 RepublishIntrospect {
710 emit_memory_metrics: true,
711 },
712 interval,
713 );
714 }
715 }
716 Ok(())
717 }
718
719 async fn handle_undeliverable_message(
720 &mut self,
721 cx: &Instance<Self>,
722 reason: UndeliverableReason,
723 envelope: Undeliverable<MessageEnvelope>,
724 ) -> Result<(), anyhow::Error> {
725 let Some(returned) = envelope.as_message() else {
726 return handle_undeliverable_message(cx, reason, envelope);
727 };
728 if let Some(true) = returned.headers().get(STREAM_STATE_SUBSCRIBER) {
729 let dest_port_id: PortAddr = returned.dest().clone();
730 let port = PortRef::<resource::RankedState<ActorState>>::attest(dest_port_id);
731 for instance in self.actor_states.values_mut() {
733 instance.subscribers.retain(|(_, s)| s != &port);
734 }
735 Ok(())
736 } else {
737 handle_undeliverable_message(cx, reason, envelope)
738 }
739 }
740
741 async fn handle_invalid_reference(
742 &mut self,
743 cx: &Instance<Self>,
744 invalid: hyperactor::mailbox::InvalidReference,
745 envelope: Undeliverable<MessageEnvelope>,
746 ) -> Result<(), anyhow::Error> {
747 let Some(returned) = envelope.as_message() else {
748 return hyperactor::actor::handle_invalid_reference(cx, invalid, envelope);
749 };
750 if let Some(true) = returned.headers().get(STREAM_STATE_SUBSCRIBER) {
751 let dest_port_id: PortAddr = returned.dest().clone();
752 let port = PortRef::<resource::RankedState<ActorState>>::attest(dest_port_id);
753 for instance in self.actor_states.values_mut() {
754 instance.subscribers.retain(|(_, s)| s != &port);
755 }
756 Ok(())
757 } else {
758 hyperactor::actor::handle_invalid_reference(cx, invalid, envelope)
759 }
760 }
761}
762
763#[async_trait]
764impl Handler<ActorSupervisionEvent> for ProcAgent {
765 async fn handle(
766 &mut self,
767 cx: &Context<Self>,
768 event: ActorSupervisionEvent,
769 ) -> anyhow::Result<()> {
770 if self.record_supervision_events {
771 if event.is_error() {
772 tracing::warn!(
773 name = "SupervisionEvent",
774 proc_id = %self.proc.proc_addr(),
775 %event,
776 "recording supervision error",
777 );
778 } else {
779 tracing::debug!(
780 name = "SupervisionEvent",
781 proc_id = %self.proc.proc_addr(),
782 %event,
783 "recording non-error supervision event",
784 );
785 }
786 if let Some((id, instance)) = self.actor_states.iter_mut().find(|(_, s)| {
788 s.spawn
789 .as_ref()
790 .ok()
791 .is_some_and(|actor_id| actor_id.id() == event.actor_id.id())
792 }) {
793 instance.supervision_event = Some(event.clone());
794 instance.generation += 1;
795 let id = id.clone();
796 instance.notify_status_changed(cx, &id);
797 }
798 if !self.introspect_dirty {
802 self.introspect_dirty = true;
803 cx.post_after(
804 cx,
805 RepublishIntrospect {
806 emit_memory_metrics: false,
807 },
808 std::time::Duration::from_millis(100),
809 );
810 }
811
812 if self.stopping_all && self.all_actors_terminal() {
815 self.shutdown().await;
816 }
817 }
818 if !self.record_supervision_events && event.is_error() {
819 tracing::error!(
822 name = "supervision_event_transmit_failed",
823 proc_id = %cx.self_addr().proc_addr(),
824 %event,
825 "could not propagate supervision event, crashing",
826 );
827
828 std::process::exit(1);
831 }
832 Ok(())
833 }
834}
835
836#[async_trait]
837impl Handler<RepublishIntrospect> for ProcAgent {
838 async fn handle(&mut self, cx: &Context<Self>, msg: RepublishIntrospect) -> anyhow::Result<()> {
839 self.introspect_dirty = false;
840 let memory = self.publish_introspect_properties(cx);
841 if msg.emit_memory_metrics {
842 let proc_id = self.proc.proc_addr().to_string();
843 let pid = std::process::id() as i64;
844 if let Some(rss) = memory.process_rss_bytes {
845 crate::metrics::PROCESS_RSS_BYTES.record(
846 rss as f64,
847 hyperactor_telemetry::kv_pairs!(
848 "proc_id" => proc_id.clone(),
849 "pid" => pid,
850 ),
851 );
852 }
853 if let Some(vm) = memory.process_vm_size_bytes {
854 crate::metrics::PROCESS_VM_SIZE_BYTES.record(
855 vm as f64,
856 hyperactor_telemetry::kv_pairs!(
857 "proc_id" => proc_id,
858 "pid" => pid,
859 ),
860 );
861 }
862 let interval = hyperactor_config::global::get(PROCESS_MEMORY_METRIC_INTERVAL);
863 if !interval.is_zero() {
864 cx.post_after(
865 cx,
866 RepublishIntrospect {
867 emit_memory_metrics: true,
868 },
869 interval,
870 );
871 }
872 }
873 Ok(())
874 }
875}
876
877#[async_trait]
878impl Handler<PySpyDump> for ProcAgent {
879 async fn handle(
880 &mut self,
881 cx: &Context<Self>,
882 message: PySpyDump,
883 ) -> Result<(), anyhow::Error> {
884 PySpyWorker::spawn_and_forward(cx, message.opts, message.result)
885 }
886}
887
888#[async_trait]
889impl Handler<PySpyProfile> for ProcAgent {
890 async fn handle(
891 &mut self,
892 cx: &Context<Self>,
893 message: PySpyProfile,
894 ) -> Result<(), anyhow::Error> {
895 PySpyProfileWorker::spawn_and_forward(cx, message.request, message.result)
896 }
897}
898
899#[async_trait]
900impl Handler<ConfigDump> for ProcAgent {
901 async fn handle(
902 &mut self,
903 cx: &Context<Self>,
904 message: ConfigDump,
905 ) -> Result<(), anyhow::Error> {
906 let entries = hyperactor_config::global::config_entries();
907 let _ = message.result.post(cx, ConfigDumpResult { entries });
910 Ok(())
911 }
912}
913
914#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
918pub struct ActorSpec {
919 pub actor_type: String,
921 pub params_data: Data,
923 pub actor_environment: ActorEnvironment,
926}
927wirevalue::register_type!(ActorSpec);
928
929#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
931pub struct ActorState {
932 pub actor_id: ActorAddr,
934 pub create_rank: usize,
938 pub supervision_events: Vec<ActorSupervisionEvent>,
940}
941wirevalue::register_type!(ActorState);
942
943impl ProcAgent {
944 async fn create_actor(
954 &mut self,
955 id: ResourceId,
956 create_rank: usize,
957 spec: ActorSpec,
958 construction_point: Option<ndslice::Point>,
959 ) -> bool {
960 let poisoned = self.actor_states.values().any(|s| s.has_errors());
961 let spawn = if poisoned {
962 Err(anyhow::anyhow!(
963 "Cannot spawn new actors on mesh with supervision events"
964 ))
965 } else {
966 let ActorSpec {
967 actor_type,
968 params_data,
969 mut actor_environment,
970 } = spec;
971 let shape_result = match construction_point {
972 Some(point) => actor_environment
973 .set(CAST_POINT, point)
974 .map_err(anyhow::Error::from),
975 None => Ok(()),
976 };
977 match shape_result {
978 Ok(()) => {
979 self.remote
980 .gspawn(
981 &self.proc,
982 &actor_type,
983 id.uid().clone(),
984 params_data,
985 actor_environment,
986 )
987 .await
988 }
989 Err(error) => Err(error),
990 }
991 };
992 self.actor_states.insert(
993 id,
994 ActorInstanceState {
995 create_rank,
996 spawn,
997 stop_initiated: false,
998 supervision_event: None,
999 subscribers: Vec::new(),
1000 expiry_time: None,
1001 generation: 1,
1002 pending_wait_status: Vec::new(),
1003 },
1004 );
1005 !poisoned
1006 }
1007
1008 async fn ensure_client_root_service(
1013 &mut self,
1014 cx: &Context<'_, Self>,
1015 service_name: Label,
1016 actor_type: String,
1017 params: Data,
1018 ) -> Result<ActorAddr, ClientRootError> {
1019 let conflict = || ClientRootError::ConflictingSpec {
1020 name: service_name.as_str().to_string(),
1021 };
1022
1023 if let Some(entry) = self.client_root_services.get(&service_name) {
1026 if entry.actor_type != actor_type || entry.params != params {
1027 return Err(conflict());
1028 }
1029 return Self::service_result(&self.actor_states, &entry.id, &service_name);
1030 }
1031
1032 if self.stopping_all {
1036 return Err(ClientRootError::Unavailable {
1037 name: service_name.as_str().to_string(),
1038 reason: "the client root is shutting down".to_string(),
1039 });
1040 }
1041
1042 let id = loop {
1047 let candidate = ResourceId::instance(service_name.clone());
1048 if !self.actor_states.contains_key(&candidate) {
1049 break candidate;
1050 }
1051 };
1052
1053 let self_root = ClientRootRef::from_ref(
1056 hyperactor::context::Actor::instance(cx).bind::<ClientRootApi>(),
1057 );
1058 let mut service_env = hyperactor::ActorEnvironment::default();
1059 service_env
1060 .set(CLIENT_ROOT, self_root)
1061 .map_err(|e| ClientRootError::Spawn {
1062 name: service_name.as_str().to_string(),
1063 message: e.to_string(),
1064 })?;
1065 let spec = ActorSpec {
1066 actor_type: actor_type.clone(),
1067 params_data: params.clone(),
1068 actor_environment: service_env,
1069 };
1070 let publish_state = self.create_actor(id.clone(), 0, spec, None).await;
1073
1074 self.client_root_services.insert(
1077 service_name.clone(),
1078 ServiceEntry {
1079 actor_type,
1080 params,
1081 id: id.clone(),
1082 },
1083 );
1084 if publish_state {
1087 let _ = self.publish_introspect_properties(cx);
1088 }
1089 Self::service_result(&self.actor_states, &id, &service_name)
1090 }
1091
1092 fn service_result(
1094 actor_states: &HashMap<ResourceId, ActorInstanceState>,
1095 id: &ResourceId,
1096 service_name: &Label,
1097 ) -> Result<ActorAddr, ClientRootError> {
1098 let Some(state) = actor_states.get(id) else {
1099 return Err(ClientRootError::Spawn {
1100 name: service_name.as_str().to_string(),
1101 message: "service actor state is missing".to_string(),
1102 });
1103 };
1104 let addr = state.spawn.as_ref().map_err(|e| ClientRootError::Spawn {
1107 name: service_name.as_str().to_string(),
1108 message: e.to_string(),
1109 })?;
1110 match state.status() {
1113 resource::Status::Running => Ok(addr.clone()),
1114 other => Err(ClientRootError::Unavailable {
1115 name: service_name.as_str().to_string(),
1116 reason: format!("service is {}", other),
1117 }),
1118 }
1119 }
1120}
1121
1122#[async_trait]
1123impl Handler<resource::CreateOrUpdate<ActorSpec>> for ProcAgent {
1124 async fn handle(
1125 &mut self,
1126 cx: &Context<Self>,
1127 create_or_update: resource::CreateOrUpdate<ActorSpec>,
1128 ) -> anyhow::Result<()> {
1129 if self.actor_states.contains_key(&create_or_update.id) {
1130 return Ok(());
1132 }
1133 if self.stopping_all {
1136 return Ok(());
1137 }
1138 let create_rank = create_or_update.rank.unwrap();
1139 let construction_point = cx.headers().get(CAST_POINT);
1143 let publish_state = self
1146 .create_actor(
1147 create_or_update.id.clone(),
1148 create_rank,
1149 create_or_update.spec,
1150 construction_point,
1151 )
1152 .await;
1153 if publish_state {
1154 let _ = self.publish_introspect_properties(cx);
1155 }
1156 Ok(())
1157 }
1158}
1159
1160#[async_trait]
1161impl Handler<EnsureClientRootService> for ProcAgent {
1162 async fn handle(
1163 &mut self,
1164 cx: &Context<Self>,
1165 message: EnsureClientRootService,
1166 ) -> anyhow::Result<()> {
1167 let EnsureClientRootService {
1168 service_name,
1169 actor_type,
1170 params,
1171 mut reply,
1172 } = message;
1173 reply.return_undeliverable(false);
1177 let result = self
1178 .ensure_client_root_service(cx, service_name, actor_type, params)
1179 .await;
1180 reply.post(cx, EnsureClientRootServiceReply(result));
1181 Ok(())
1182 }
1183}
1184
1185#[async_trait]
1186impl Handler<resource::Stop> for ProcAgent {
1187 async fn handle(&mut self, cx: &Context<Self>, message: resource::Stop) -> anyhow::Result<()> {
1188 let actor_id = match self.actor_states.get_mut(&message.id) {
1189 Some(actor_state) => {
1190 let id = actor_state.spawn.as_ref().ok().cloned();
1191 if id.is_some() && !actor_state.stop_initiated {
1192 actor_state.stop_initiated = true;
1193 actor_state.generation += 1;
1194 actor_state.notify_status_changed(cx, &message.id);
1195 id
1196 } else {
1197 None
1198 }
1199 }
1200 None => None,
1201 };
1202 if let Some(actor_id) = actor_id {
1203 self.stop_actor_by_id(&actor_id, &message.reason);
1204 }
1205
1206 Ok(())
1207 }
1208}
1209
1210#[async_trait]
1214impl Handler<resource::StopAll> for ProcAgent {
1215 async fn handle(
1216 &mut self,
1217 _cx: &Context<Self>,
1218 message: resource::StopAll,
1219 ) -> anyhow::Result<()> {
1220 self.stopping_all = true;
1221
1222 let to_stop: Vec<ActorAddr> = self
1224 .actor_states
1225 .values_mut()
1226 .filter_map(|state| {
1227 if state.stop_initiated {
1228 return None;
1229 }
1230 state.stop_initiated = true;
1231 state.spawn.as_ref().ok().cloned()
1232 })
1233 .collect();
1234
1235 for actor_id in &to_stop {
1236 self.stop_actor_by_id(actor_id, &message.reason);
1237 }
1238
1239 if self.all_actors_terminal() {
1241 self.shutdown().await;
1242 }
1243
1244 Ok(())
1245 }
1246}
1247
1248#[async_trait]
1249impl Handler<resource::GetRankStatus> for ProcAgent {
1250 async fn handle(
1251 &mut self,
1252 cx: &Context<Self>,
1253 get_rank_status: resource::GetRankStatus,
1254 ) -> anyhow::Result<()> {
1255 use crate::StatusOverlay;
1256
1257 let overlay = match self.actor_states.get(&get_rank_status.id) {
1261 Some(state) => {
1262 let rank = get_rank_status.rank.unwrap();
1263 StatusOverlay::try_from_runs(vec![(rank..(rank + 1), state.status())])
1264 .expect("valid single-run overlay")
1265 }
1266 None => StatusOverlay::new(),
1267 };
1268 get_rank_status.reply.post(cx, overlay);
1269 Ok(())
1270 }
1271}
1272
1273#[async_trait]
1274impl Handler<resource::WaitRankStatus> for ProcAgent {
1275 async fn handle(
1276 &mut self,
1277 cx: &Context<Self>,
1278 msg: resource::WaitRankStatus,
1279 ) -> anyhow::Result<()> {
1280 use crate::StatusOverlay;
1281
1282 let Some(status) = self.actor_states.get(&msg.id).map(|state| state.status()) else {
1287 let _ = msg.reply.post(cx, StatusOverlay::new());
1288 return Ok(());
1289 };
1290 let rank = msg.rank.unwrap();
1291
1292 if status >= msg.min_status {
1294 let overlay = StatusOverlay::try_from_runs(vec![(rank..(rank + 1), status)])
1295 .expect("valid single-run overlay");
1296 let _ = msg.reply.post(cx, overlay);
1297 return Ok(());
1298 }
1299
1300 if let Some(state) = self.actor_states.get_mut(&msg.id) {
1303 state
1304 .pending_wait_status
1305 .push((msg.min_status, rank, msg.reply));
1306 }
1307 Ok(())
1308 }
1309}
1310
1311#[async_trait]
1312impl Handler<resource::GetState<ActorState>> for ProcAgent {
1313 async fn handle(
1314 &mut self,
1315 cx: &Context<Self>,
1316 get_state: resource::GetState<ActorState>,
1317 ) -> anyhow::Result<()> {
1318 let state = match self.actor_states.get(&get_state.id) {
1319 Some(instance) => instance.to_state(&get_state.id),
1320 None => resource::State {
1321 id: get_state.id.clone(),
1322 status: resource::Status::NotExist,
1323 state: None,
1324 generation: 0,
1325 timestamp: std::time::SystemTime::now(),
1326 },
1327 };
1328
1329 get_state.reply.post(cx, state);
1330 Ok(())
1331 }
1332}
1333
1334#[async_trait]
1335impl Handler<resource::StreamState<ActorState>> for ProcAgent {
1336 async fn handle(
1337 &mut self,
1338 cx: &Context<Self>,
1339 stream_state: resource::StreamState<ActorState>,
1340 ) -> anyhow::Result<()> {
1341 let view_rank = stream_state.subscriber_rank.unwrap();
1345
1346 let state = match self.actor_states.get_mut(&stream_state.id) {
1347 Some(instance) => {
1348 let state = instance.to_state(&stream_state.id);
1349 instance
1350 .subscribers
1351 .push((view_rank, stream_state.subscriber.clone()));
1352 state
1353 }
1354 None => resource::State {
1355 id: stream_state.id.clone(),
1356 status: resource::Status::NotExist,
1357 state: None,
1358 generation: 0,
1359 timestamp: std::time::SystemTime::now(),
1360 },
1361 };
1362
1363 let mut headers = Flattrs::new();
1365 headers.set(STREAM_STATE_SUBSCRIBER, true);
1366 stream_state.subscriber.post_with_headers(
1367 cx,
1368 headers,
1369 resource::RankedState {
1370 rank: resource::Rank::new(view_rank),
1371 state,
1372 },
1373 );
1374 Ok(())
1375 }
1376}
1377
1378#[async_trait]
1379impl Handler<resource::KeepaliveGetState<ActorState>> for ProcAgent {
1380 async fn handle(
1381 &mut self,
1382 cx: &Context<Self>,
1383 message: resource::KeepaliveGetState<ActorState>,
1384 ) -> anyhow::Result<()> {
1385 if let Ok(instance_state) =
1387 self.actor_states
1388 .get_mut(&message.get_state.id)
1389 .ok_or_else(|| {
1390 anyhow::anyhow!(
1391 "attempting to register a keepalive for an actor that doesn't exist: {}",
1392 message.get_state.id
1393 )
1394 })
1395 {
1396 instance_state.expiry_time = Some(message.expires_after);
1397 }
1398
1399 <Self as Handler<resource::GetState<ActorState>>>::handle(self, cx, message.get_state).await
1401 }
1402}
1403
1404#[derive(Debug, hyperactor::Handler, hyperactor::HandleClient)]
1407pub struct NewClientInstance {
1408 #[reply]
1409 pub client_instance: PortHandle<Client>,
1410}
1411
1412#[async_trait]
1413impl Handler<NewClientInstance> for ProcAgent {
1414 async fn handle(
1415 &mut self,
1416 cx: &Context<Self>,
1417 NewClientInstance { client_instance }: NewClientInstance,
1418 ) -> anyhow::Result<()> {
1419 let client = self.proc.client("client");
1420 client_instance.post(cx, client);
1421 Ok(())
1422 }
1423}
1424
1425#[derive(Debug, hyperactor::Handler, hyperactor::HandleClient)]
1428pub struct GetProc {
1429 #[reply]
1430 pub proc: PortHandle<Proc>,
1431}
1432
1433#[async_trait]
1434impl Handler<GetProc> for ProcAgent {
1435 async fn handle(
1436 &mut self,
1437 cx: &Context<Self>,
1438 GetProc { proc }: GetProc,
1439 ) -> anyhow::Result<()> {
1440 proc.post(cx, self.proc.clone());
1441 Ok(())
1442 }
1443}
1444
1445#[async_trait]
1446impl Handler<SelfCheck> for ProcAgent {
1447 async fn handle(&mut self, cx: &Context<Self>, _: SelfCheck) -> anyhow::Result<()> {
1448 let Some(duration) = &self.mesh_orphan_timeout else {
1454 return Ok(());
1455 };
1456 let duration = *duration;
1457 let now = std::time::SystemTime::now();
1458
1459 let expired: Vec<(ResourceId, ActorAddr)> = self
1461 .actor_states
1462 .iter()
1463 .filter_map(|(id, state)| {
1464 let expiry = state.expiry_time?;
1465 if now > expiry
1467 && !state.stop_initiated
1468 && let Ok(actor_id) = &state.spawn
1469 {
1470 return Some((id.clone(), actor_id.clone()));
1471 }
1472 None
1473 })
1474 .collect();
1475
1476 if !expired.is_empty() {
1477 tracing::info!(
1478 "stopping {} orphaned actors past their keepalive expiry",
1479 expired.len(),
1480 );
1481 }
1482
1483 for (id, actor_id) in expired {
1484 if let Some(state) = self.actor_states.get_mut(&id) {
1485 state.stop_initiated = true;
1486 }
1487 self.stop_actor_by_id(&actor_id, "orphaned");
1488 }
1489
1490 cx.post_after(cx, SelfCheck::default(), duration);
1492 Ok(())
1493 }
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498 use std::sync::Arc;
1499
1500 use hyperactor::ActorRef;
1501
1502 use super::*;
1503
1504 #[derive(Debug, Default, Serialize, Deserialize)]
1506 #[hyperactor::export(handlers = [])]
1507 struct ExtraActor;
1508 impl hyperactor::Actor for ExtraActor {}
1509 hyperactor::register_spawnable!(ExtraActor);
1510
1511 #[tokio::test]
1515 async fn boot_v1_handles_messages_sent_before_boot() {
1516 use hyperactor::Proc;
1517 use hyperactor::channel::ChannelTransport;
1518
1519 let config = hyperactor_config::global::lock();
1520 let _timeout = config.override_key(
1521 hyperactor::config::PENDING_ACTOR_DELIVERY_TIMEOUT,
1522 Duration::from_secs(30),
1523 );
1524
1525 let proc = Proc::direct(ChannelTransport::Unix.any(), "early".to_string()).unwrap();
1526 let client = proc.client("client");
1527 let agent: ActorRef<ProcAgent> =
1528 ActorRef::attest(proc.proc_addr().actor_addr(PROC_AGENT_ACTOR_NAME));
1529 let id = ResourceId::instance(Label::new("absent").unwrap());
1530 let (reply, mut reply_rx) = client.open_port::<resource::State<ActorState>>();
1531 agent.post(
1532 &client,
1533 resource::GetState {
1534 id: id.clone(),
1535 reply: reply.bind(),
1536 },
1537 );
1538
1539 let _agent = ProcAgent::boot_v1(proc.clone(), None).unwrap();
1540
1541 let state = tokio::time::timeout(Duration::from_secs(10), reply_rx.recv())
1542 .await
1543 .expect("the agent should answer the message sent before boot")
1544 .unwrap();
1545 assert_eq!(state.id, id);
1546 assert_eq!(state.status, resource::Status::NotExist);
1547 }
1548 #[tokio::test]
1562 async fn test_query_child_proc_returns_live_children() {
1563 use hyperactor::Proc;
1564 use hyperactor::actor::ActorStatus;
1565 use hyperactor::channel::ChannelTransport;
1566 use hyperactor::introspect::IntrospectMessage;
1567 use hyperactor::introspect::IntrospectResult;
1568
1569 let proc = Proc::direct(ChannelTransport::Unix.any(), "test_proc".to_string()).unwrap();
1570 let agent_handle = ProcAgent::boot_v1(proc.clone(), None).unwrap();
1571
1572 agent_handle
1574 .status()
1575 .wait_for(|s| matches!(s, ActorStatus::Idle))
1576 .await
1577 .unwrap();
1578
1579 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
1581 let client = client_proc.client("client");
1582
1583 let agent_id: ActorAddr = proc.proc_addr().actor_addr(PROC_AGENT_ACTOR_NAME);
1584 let port = agent_id.introspect_port();
1585
1586 let query = |client: &hyperactor::Client| {
1589 let (reply_port, reply_rx) = client.open_once_port::<IntrospectResult>();
1590 port.post(
1591 client,
1592 IntrospectMessage::QueryChild {
1593 child_ref: Addr::Proc(proc.proc_addr().clone()),
1594 reply: reply_port.bind(),
1595 },
1596 );
1597 reply_rx
1598 };
1599 let recv = |rx: hyperactor::mailbox::OncePortReceiver<IntrospectResult>| async move {
1600 tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1601 .await
1602 .expect("QueryChild(Proc) timed out — reply never delivered")
1603 .expect("reply channel closed")
1604 };
1605
1606 let payload = recv(query(&client)).await;
1608 let attrs: hyperactor_config::Attrs =
1610 serde_json::from_str(&payload.attrs).expect("valid attrs JSON");
1611 assert_eq!(
1612 attrs.get(crate::introspect::NODE_TYPE).map(String::as_str),
1613 Some("proc"),
1614 "expected node_type=proc in attrs, got {:?}",
1615 payload.attrs
1616 );
1617 assert!(
1618 payload
1619 .children
1620 .iter()
1621 .any(|c| c.to_string().contains(PROC_AGENT_ACTOR_NAME)),
1622 "initial children {:?} should contain proc_agent",
1623 payload.children
1624 );
1625 let initial_count = payload.children.len();
1626
1627 proc.spawn_with_label("extra_actor", ExtraActor);
1631
1632 let payload2 = recv(query(&client)).await;
1634 let attrs2: hyperactor_config::Attrs =
1635 serde_json::from_str(&payload2.attrs).expect("valid attrs JSON");
1636 assert_eq!(
1637 attrs2.get(crate::introspect::NODE_TYPE).map(String::as_str),
1638 Some("proc"),
1639 "expected node_type=proc in attrs, got {:?}",
1640 payload2.attrs
1641 );
1642 assert!(
1643 payload2
1644 .children
1645 .iter()
1646 .any(|c| c.to_string().contains("extra_actor")),
1647 "after direct spawn, children {:?} should contain extra_actor",
1648 payload2.children
1649 );
1650 assert!(
1651 payload2.children.len() > initial_count,
1652 "expected at least {} children after direct spawn, got {:?}",
1653 initial_count + 1,
1654 payload2.children
1655 );
1656 }
1657
1658 #[tokio::test]
1665 async fn test_rapid_spawn_stop_does_not_stall_proc_agent() {
1666 use std::sync::Arc;
1667 use std::sync::atomic::AtomicUsize;
1668 use std::sync::atomic::Ordering;
1669
1670 use hyperactor::Proc;
1671 use hyperactor::actor::ActorStatus;
1672 use hyperactor::channel::ChannelTransport;
1673 use hyperactor::introspect::IntrospectMessage;
1674 use hyperactor::introspect::IntrospectResult;
1675
1676 let proc = Proc::direct(ChannelTransport::Unix.any(), "test_proc".to_string()).unwrap();
1677 let agent_handle = ProcAgent::boot_v1(proc.clone(), None).unwrap();
1678
1679 agent_handle
1680 .status()
1681 .wait_for(|s| matches!(s, ActorStatus::Idle))
1682 .await
1683 .unwrap();
1684
1685 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
1686 let client = client_proc.client("client");
1687
1688 let agent_id: ActorAddr = proc.proc_addr().actor_addr(PROC_AGENT_ACTOR_NAME);
1689 let port = agent_id.introspect_port();
1690
1691 let query_client_proc =
1693 Proc::direct(ChannelTransport::Unix.any(), "query_client".to_string()).unwrap();
1694 let query_client = query_client_proc.client("qc");
1695 let query_port = port.clone();
1696 let query_proc_id = proc.proc_addr().clone();
1697 let query_count = Arc::new(AtomicUsize::new(0));
1698 let query_count_clone = query_count.clone();
1699 let query_task = tokio::spawn(async move {
1700 loop {
1701 let (reply_port, reply_rx) = query_client.open_once_port::<IntrospectResult>();
1702 let mut reply = reply_port.bind();
1703 reply.return_undeliverable(false);
1704 query_port.post(
1705 &query_client,
1706 IntrospectMessage::QueryChild {
1707 child_ref: Addr::Proc(query_proc_id.clone()),
1708 reply,
1709 },
1710 );
1711 match tokio::time::timeout(std::time::Duration::from_secs(2), reply_rx.recv()).await
1712 {
1713 Ok(Ok(_)) => {
1714 query_count_clone.fetch_add(1, Ordering::Relaxed);
1715 }
1716 _ => {} }
1718 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1719 }
1720 });
1721
1722 const ITERATIONS: usize = 200;
1724 let mut completed = 0usize;
1725 let result = tokio::time::timeout(std::time::Duration::from_secs(30), async {
1726 for i in 0..ITERATIONS {
1727 let name = format!("churn_{}", i);
1728 let handle = proc.spawn_with_label(&name, ExtraActor);
1729 let actor_id = handle.actor_addr().clone();
1730 if let Some(mut status) = proc.stop_actor(actor_id.id(), "churn".to_string()) {
1731 let _ = tokio::time::timeout(
1732 std::time::Duration::from_secs(5),
1733 status.wait_for(ActorStatus::is_terminal),
1734 )
1735 .await;
1736 }
1737 completed += 1;
1738 }
1739 })
1740 .await;
1741
1742 query_task.abort();
1743 let _ = query_task.await; assert!(
1746 result.is_ok(),
1747 "spawn/stop loop stalled after {completed}/{ITERATIONS} iterations — \
1748 DashMap convoy starvation likely"
1749 );
1750 assert_eq!(
1751 completed, ITERATIONS,
1752 "expected {ITERATIONS} completed iterations, got {completed}"
1753 );
1754 assert!(
1755 query_count.load(Ordering::Relaxed) > 0,
1756 "concurrent QueryChild queries never succeeded — query task may not have run"
1757 );
1758
1759 let (reply_port, reply_rx) = client.open_once_port::<IntrospectResult>();
1761 port.post(
1762 &client,
1763 IntrospectMessage::QueryChild {
1764 child_ref: Addr::Proc(proc.proc_addr().clone()),
1765 reply: reply_port.bind(),
1766 },
1767 );
1768 let final_payload =
1769 tokio::time::timeout(std::time::Duration::from_secs(5), reply_rx.recv())
1770 .await
1771 .expect("final QueryChild timed out")
1772 .expect("final QueryChild channel closed");
1773 let attrs: hyperactor_config::Attrs =
1774 serde_json::from_str(&final_payload.attrs).expect("valid attrs JSON");
1775 assert_eq!(
1776 attrs.get(crate::introspect::NODE_TYPE).map(String::as_str),
1777 Some("proc"),
1778 );
1779 }
1780
1781 #[tokio::test]
1782 async fn test_stream_state_and_unsubscribe() {
1783 use hyperactor::Proc;
1784 use hyperactor::actor::ActorStatus;
1785 use hyperactor::channel::ChannelTransport;
1786
1787 use crate::resource::CreateOrUpdateClient;
1788 use crate::resource::GetStateClient;
1789 use crate::resource::StopClient;
1790 use crate::resource::StreamStateClient;
1791
1792 let proc = Proc::direct(ChannelTransport::Unix.any(), "test_proc".to_string()).unwrap();
1793 let agent_handle = ProcAgent::boot_v1(proc.clone(), None).unwrap();
1794 agent_handle
1795 .status()
1796 .wait_for(|s| matches!(s, ActorStatus::Idle))
1797 .await
1798 .unwrap();
1799
1800 let client = proc.client("client");
1801 let agent_ref: ActorRef<ProcAgent> = agent_handle.bind();
1802
1803 let missing_name =
1806 ResourceId::singleton(hyperactor::id::Label::new("missing-actor").unwrap());
1807 let (missing_port, mut missing_rx) =
1808 client.open_port::<resource::RankedState<ActorState>>();
1809 agent_ref
1810 .stream_state(
1811 &client,
1812 missing_name,
1813 resource::Rank::new(6),
1814 missing_port.bind(),
1815 )
1816 .await
1817 .unwrap();
1818 let missing = missing_rx.recv().await.expect("missing state reply");
1819 assert_eq!(missing.rank.unwrap(), 6);
1820 assert_eq!(missing.state.status, resource::Status::NotExist);
1821 assert_eq!(missing.state.state, None);
1822
1823 let actor_type = hyperactor::actor::remote::Remote::collect()
1824 .name_of::<ExtraActor>()
1825 .unwrap()
1826 .to_string();
1827 let actor_params =
1828 bincode::serde::encode_to_vec(&ExtraActor, bincode::config::legacy()).unwrap();
1829 let actor_name = ResourceId::singleton(hyperactor::id::Label::new("test-actor").unwrap());
1830
1831 agent_ref
1833 .create_or_update(
1834 &client,
1835 actor_name.clone(),
1836 resource::Rank::new(0),
1837 ActorSpec {
1838 actor_type: actor_type.clone(),
1839 params_data: actor_params.clone(),
1840 actor_environment: ActorEnvironment::default(),
1841 },
1842 )
1843 .await
1844 .unwrap();
1845
1846 let subscriber_rank = 7;
1848 let (sub_port, mut sub_rx) = client.open_port::<resource::RankedState<ActorState>>();
1849 agent_ref
1850 .stream_state(
1851 &client,
1852 actor_name.clone(),
1853 resource::Rank::new(subscriber_rank),
1854 sub_port.bind(),
1855 )
1856 .await
1857 .unwrap();
1858
1859 let initial = sub_rx.recv().await.expect("subscriber channel error");
1861 assert_eq!(initial.rank.unwrap(), subscriber_rank);
1862 assert_eq!(initial.state.status, resource::Status::Running);
1863 assert!(initial.state.state.is_some());
1864
1865 agent_ref
1867 .stop(&client, actor_name.clone(), "test".to_string())
1868 .await
1869 .unwrap();
1870
1871 let stopping = sub_rx.recv().await.expect("subscriber channel error");
1872 assert_eq!(stopping.rank.unwrap(), subscriber_rank);
1873 assert_eq!(stopping.state.status, resource::Status::Stopping);
1874
1875 let stopped = sub_rx.recv().await.expect("subscriber channel error");
1877 assert_eq!(stopped.rank.unwrap(), subscriber_rank);
1878 assert_eq!(stopped.state.status, resource::Status::Stopped);
1879
1880 let actor_name_2 =
1882 ResourceId::singleton(hyperactor::id::Label::new("test-actor-2").unwrap());
1883 agent_ref
1884 .create_or_update(
1885 &client,
1886 actor_name_2.clone(),
1887 resource::Rank::new(1),
1888 ActorSpec {
1889 actor_type: actor_type.clone(),
1890 params_data: actor_params.clone(),
1891 actor_environment: ActorEnvironment::default(),
1892 },
1893 )
1894 .await
1895 .unwrap();
1896
1897 let (sub_port_2, mut sub_rx_2) = client.open_port::<resource::RankedState<ActorState>>();
1898 agent_ref
1899 .stream_state(
1900 &client,
1901 actor_name_2.clone(),
1902 resource::Rank::new(8),
1903 sub_port_2.bind(),
1904 )
1905 .await
1906 .unwrap();
1907
1908 let initial_2 = sub_rx_2.recv().await.expect("subscriber 2 channel error");
1909 assert_eq!(initial_2.rank.unwrap(), 8);
1910 assert_eq!(initial_2.state.status, resource::Status::Running);
1911
1912 drop(sub_rx_2);
1914
1915 agent_ref
1919 .stop(
1920 &client,
1921 actor_name_2.clone(),
1922 "test unsubscribe".to_string(),
1923 )
1924 .await
1925 .unwrap();
1926
1927 let (sub_port_3, mut sub_rx_3) = client.open_port::<resource::RankedState<ActorState>>();
1929 agent_ref
1930 .stream_state(
1931 &client,
1932 actor_name_2.clone(),
1933 resource::Rank::new(9),
1934 sub_port_3.bind(),
1935 )
1936 .await
1937 .unwrap();
1938 loop {
1939 let state = sub_rx_3.recv().await.expect("subscriber 3 channel error");
1940 assert_eq!(state.rank.unwrap(), 9);
1941 if state.state.status.is_terminating() {
1942 break;
1943 }
1944 }
1945
1946 let state = agent_ref
1948 .get_state(&client, actor_name_2.clone())
1949 .await
1950 .unwrap();
1951 assert!(
1952 state.status.is_terminating(),
1953 "expected terminating status, got {:?}",
1954 state.status,
1955 );
1956 }
1957
1958 #[derive(Debug, Default, Serialize, Deserialize)]
1964 #[hyperactor::export(handlers = [BlockMsg])]
1965 struct BlockActor {
1966 #[serde(skip)]
1967 gate: Option<Arc<tokio::sync::Notify>>,
1968 }
1969 impl hyperactor::Actor for BlockActor {}
1970
1971 #[derive(
1972 Debug,
1973 Clone,
1974 Serialize,
1975 Deserialize,
1976 Named,
1977 hyperactor::Handler,
1978 hyperactor::HandleClient
1979 )]
1980 enum BlockMsg {
1981 Block(),
1983 Noop(),
1985 }
1986 wirevalue::register_type!(BlockMsg);
1987
1988 #[async_trait::async_trait]
1989 #[hyperactor::handle(BlockMsg)]
1990 impl BlockMsgHandler for BlockActor {
1991 async fn block(&mut self, _cx: &hyperactor::Context<Self>) -> Result<(), anyhow::Error> {
1992 if let Some(gate) = &self.gate {
1993 gate.notified().await;
1994 }
1995 Ok(())
1996 }
1997 async fn noop(&mut self, _cx: &hyperactor::Context<Self>) -> Result<(), anyhow::Error> {
1998 Ok(())
1999 }
2000 }
2001
2002 #[tokio::test]
2010 async fn test_query_child_proc_queue_depth_under_pressure() {
2011 use hyperactor::Proc;
2012 use hyperactor::actor::ActorStatus;
2013 use hyperactor::channel::ChannelTransport;
2014 use hyperactor::introspect::IntrospectMessage;
2015 use hyperactor::introspect::IntrospectResult;
2016
2017 let proc = Proc::direct(ChannelTransport::Unix.any(), "qd_proc".to_string()).unwrap();
2018 let agent_handle = ProcAgent::boot_v1(proc.clone(), None).unwrap();
2019
2020 agent_handle
2021 .status()
2022 .wait_for(|s| matches!(s, ActorStatus::Idle))
2023 .await
2024 .unwrap();
2025
2026 let client_proc =
2027 Proc::direct(ChannelTransport::Unix.any(), "qd_client".to_string()).unwrap();
2028 let client = client_proc.client("client");
2029
2030 let gate = Arc::new(tokio::sync::Notify::new());
2032 let blocker = proc.spawn(BlockActor {
2033 gate: Some(Arc::clone(&gate)),
2034 });
2035
2036 blocker.block(&client).await.unwrap();
2038 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2040 blocker.noop(&client).await.unwrap();
2041 blocker.noop(&client).await.unwrap();
2042
2043 let agent_id: ActorAddr = proc.proc_addr().actor_addr(PROC_AGENT_ACTOR_NAME);
2046 let port = agent_id.introspect_port();
2047
2048 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
2050 loop {
2051 let (reply_port, reply_rx) = client.open_once_port::<IntrospectResult>();
2052 port.post(
2053 &client,
2054 IntrospectMessage::QueryChild {
2055 child_ref: Addr::Proc(proc.proc_addr().clone()),
2056 reply: reply_port.bind(),
2057 },
2058 );
2059 let payload = tokio::time::timeout(std::time::Duration::from_secs(3), reply_rx.recv())
2060 .await
2061 .expect("QueryChild timed out")
2062 .expect("reply channel closed");
2063
2064 let attrs: hyperactor_config::Attrs =
2065 serde_json::from_str(&payload.attrs).expect("valid attrs JSON");
2066
2067 let total = attrs
2068 .get(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_TOTAL)
2069 .copied()
2070 .unwrap_or(0);
2071 let max = attrs
2072 .get(crate::introspect::ACTOR_WORK_QUEUE_DEPTH_MAX)
2073 .copied()
2074 .unwrap_or(0);
2075
2076 if total > 0 {
2077 assert!(max > 0, "max should be > 0 when total is {total}");
2078 assert!(max <= total, "PD-1: max ({max}) <= total ({total})");
2079 break;
2080 }
2081
2082 assert!(
2083 tokio::time::Instant::now() < deadline,
2084 "timed out waiting for non-zero queue depth in QueryChild(Proc)",
2085 );
2086 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2087 }
2088
2089 gate.notify_one();
2091 }
2092
2093 hyperactor_config::attrs::declare_attrs! {
2094 attr AENV_SPEC_TAG: u64;
2095 attr AENV_SPEC_LABEL: String;
2096 }
2097
2098 #[test]
2103 fn actor_environment_serializes_in_actor_spec() {
2104 let mut env = ActorEnvironment::default();
2105 env.set(AENV_SPEC_TAG, 7u64).expect("insert tag");
2106 env.set(AENV_SPEC_LABEL, "root".to_string())
2107 .expect("insert label");
2108
2109 let spec = ActorSpec {
2110 actor_type: "probe".to_string(),
2111 params_data: vec![1, 2, 3],
2112 actor_environment: env.clone(),
2113 };
2114
2115 let bytes = bincode::serde::encode_to_vec(&spec, bincode::config::legacy()).unwrap();
2116 let restored: ActorSpec =
2117 bincode::serde::decode_from_slice(&bytes, bincode::config::legacy())
2118 .map(|(v, _)| v)
2119 .unwrap();
2120
2121 assert_eq!(restored.actor_environment.get(AENV_SPEC_TAG), Some(7u64));
2122 assert_eq!(
2123 restored.actor_environment.get(AENV_SPEC_LABEL),
2124 Some("root".to_string())
2125 );
2126 assert_eq!(restored.actor_environment, env);
2127 assert_eq!(restored, spec);
2130 }
2131
2132 async fn boot_client_root() -> (
2136 hyperactor::Proc,
2137 ActorHandle<ProcAgent>,
2138 crate::client_root::ClientRootRef,
2139 ) {
2140 use hyperactor::Proc;
2141 use hyperactor::actor::ActorStatus;
2142 use hyperactor::channel::ChannelTransport;
2143
2144 let proc = Proc::direct(ChannelTransport::Unix.any(), "root".to_string()).unwrap();
2145 let agent = ProcAgent::boot_v1(proc.clone(), None).unwrap();
2146 agent
2147 .status()
2148 .wait_for(|s| matches!(s, ActorStatus::Idle))
2149 .await
2150 .unwrap();
2151 let client_root = crate::client_root::ClientRootRef::bind(&agent);
2152 (proc, agent, client_root)
2153 }
2154
2155 #[tokio::test]
2158 async fn client_root_ref_redacts_and_round_trips() {
2159 use hyperactor_config::AttrValue;
2160
2161 use crate::client_root::ClientRootRef;
2162
2163 let (_proc, _agent, client_root) = boot_client_root().await;
2164
2165 let bytes = bincode::serde::encode_to_vec(&client_root, bincode::config::legacy()).unwrap();
2166 let restored: ClientRootRef =
2167 bincode::serde::decode_from_slice(&bytes, bincode::config::legacy())
2168 .map(|(v, _)| v)
2169 .unwrap();
2170 assert_eq!(
2171 restored, client_root,
2172 "capability must round-trip via serde"
2173 );
2174
2175 assert_eq!(client_root.display(), "<redacted>");
2176 assert_eq!(format!("{:?}", client_root), "ClientRootRef(<redacted>)");
2177 assert!(
2178 ClientRootRef::parse("anything").is_err(),
2179 "a bearer capability must not be reconstructible from text"
2180 );
2181 }
2182
2183 #[tokio::test]
2186 async fn client_root_bind_is_idempotent() {
2187 use crate::client_root::ClientRootRef;
2188
2189 let (_proc, agent, first) = boot_client_root().await;
2190 let second = ClientRootRef::bind(&agent);
2191 assert_eq!(first, second);
2192 }
2193
2194 #[tokio::test]
2198 async fn client_root_ensure_is_root_owned_and_single_identity() {
2199 use crate::client_root::ClientRootService;
2200
2201 let (proc, _agent, client_root) = boot_client_root().await;
2202 let service = ClientRootService::<ExtraActor>::declare("probe-service");
2203
2204 let caller_a = proc.client("caller-a");
2205 let first = service
2206 .ensure(&caller_a, &client_root, ())
2207 .await
2208 .expect("first ensure creates the service");
2209 drop(caller_a);
2213
2214 let caller_b = proc.client("caller-b");
2215 let second = service
2216 .ensure(&caller_b, &client_root, ())
2217 .await
2218 .expect("second ensure reuses the service after requester exit");
2219 assert_eq!(
2220 first.actor_addr(),
2221 second.actor_addr(),
2222 "identical ensures must resolve to the one root-owned service (CROOT-3)"
2223 );
2224 }
2225
2226 #[derive(Debug, Default, Serialize, Deserialize)]
2229 #[hyperactor::export(handlers = [])]
2230 struct OtherActor;
2231 impl hyperactor::Actor for OtherActor {}
2232 hyperactor::register_spawnable!(OtherActor);
2233
2234 #[tokio::test]
2237 async fn client_root_conflicting_type_is_rejected() {
2238 use crate::client_root::ClientRootError;
2239 use crate::client_root::ClientRootService;
2240
2241 let (proc, _agent, client_root) = boot_client_root().await;
2242 let caller = proc.client("caller");
2243
2244 ClientRootService::<ExtraActor>::declare("shared")
2245 .ensure(&caller, &client_root, ())
2246 .await
2247 .expect("first ensure creates the service");
2248
2249 let err = ClientRootService::<OtherActor>::declare("shared")
2250 .ensure(&caller, &client_root, ())
2251 .await
2252 .expect_err("a different actor type for the same name must conflict");
2253 assert!(
2254 matches!(
2255 err,
2256 crate::Error::ClientRootError(ClientRootError::ConflictingSpec { .. })
2257 ),
2258 "expected ClientRootError::ConflictingSpec, got {err:?}"
2259 );
2260 }
2261
2262 #[tokio::test]
2266 async fn client_root_service_uses_instance_id() {
2267 use crate::client_root::ClientRootService;
2268
2269 let (proc, _agent, client_root) = boot_client_root().await;
2270 let caller = proc.client("caller");
2271 let svc = ClientRootService::<ExtraActor>::declare("probe")
2272 .ensure(&caller, &client_root, ())
2273 .await
2274 .expect("ensure creates the service");
2275 assert!(
2276 !svc.actor_addr().is_root(),
2277 "a client-root service must use a fresh instance uid, not a singleton id"
2278 );
2279 }
2280
2281 static COUNTING_ACTOR_BUILDS: std::sync::atomic::AtomicUsize =
2284 std::sync::atomic::AtomicUsize::new(0);
2285
2286 #[derive(Debug)]
2287 #[hyperactor::export(handlers = [])]
2288 #[hyperactor::spawnable]
2289 struct CountingActor;
2290 impl hyperactor::Actor for CountingActor {}
2291
2292 #[async_trait::async_trait]
2293 impl hyperactor::RemoteSpawn for CountingActor {
2294 type Params = ();
2295 async fn new(_params: (), _environment: &ActorEnvironment) -> anyhow::Result<Self> {
2296 COUNTING_ACTOR_BUILDS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2297 Ok(Self)
2298 }
2299 }
2300
2301 #[tokio::test]
2304 async fn client_root_concurrent_ensures_collapse() {
2305 use std::sync::atomic::Ordering;
2306
2307 use crate::client_root::ClientRootService;
2308
2309 COUNTING_ACTOR_BUILDS.store(0, Ordering::SeqCst);
2310 let (proc, _agent, client_root) = boot_client_root().await;
2311 let ensure = |name: &'static str| {
2312 let caller = proc.client(name);
2313 let client_root = &client_root;
2314 async move {
2315 ClientRootService::<CountingActor>::declare("concurrent")
2316 .ensure(&caller, client_root, ())
2317 .await
2318 .expect("ensure succeeds")
2319 .actor_addr()
2320 .clone()
2321 }
2322 };
2323
2324 let (a, b, c, d) = tokio::join!(
2325 ensure("caller-0"),
2326 ensure("caller-1"),
2327 ensure("caller-2"),
2328 ensure("caller-3"),
2329 );
2330 assert_eq!(a, b, "concurrent ensures must resolve to one service");
2331 assert_eq!(a, c, "concurrent ensures must resolve to one service");
2332 assert_eq!(a, d, "concurrent ensures must resolve to one service");
2333 assert_eq!(
2334 COUNTING_ACTOR_BUILDS.load(Ordering::SeqCst),
2335 1,
2336 "single-flight: identical concurrent ensures must construct once"
2337 );
2338 }
2339
2340 #[tokio::test]
2343 async fn client_root_service_result_fails_closed_on_terminal() {
2344 use hyperactor::actor::ActorStatus;
2345
2346 use crate::client_root::ClientRootError;
2347
2348 let (_proc, agent, _client_root) = boot_client_root().await;
2349 let addr = agent.bind::<ProcAgent>().actor_addr().clone();
2350 let name = Label::new("svc").unwrap();
2351 let id = ResourceId::instance(name.clone());
2352
2353 let mk = |spawn: Result<ActorAddr, anyhow::Error>,
2354 stop_initiated: bool,
2355 supervision_event: Option<ActorSupervisionEvent>| {
2356 ActorInstanceState {
2357 create_rank: 0,
2358 spawn,
2359 stop_initiated,
2360 supervision_event,
2361 subscribers: Vec::new(),
2362 expiry_time: None,
2363 generation: 1,
2364 pending_wait_status: Vec::new(),
2365 }
2366 };
2367
2368 let mut states = HashMap::new();
2370 states.insert(id.clone(), mk(Ok(addr.clone()), false, None));
2371 assert_eq!(
2372 ProcAgent::service_result(&states, &id, &name).expect("running service resolves"),
2373 addr
2374 );
2375
2376 let mut states = HashMap::new();
2378 states.insert(id.clone(), mk(Ok(addr.clone()), true, None));
2379 assert!(matches!(
2380 ProcAgent::service_result(&states, &id, &name),
2381 Err(ClientRootError::Unavailable { .. })
2382 ));
2383
2384 let mut states = HashMap::new();
2386 let event = ActorSupervisionEvent::new(
2387 addr.clone(),
2388 None,
2389 ActorStatus::Stopped("done".into()),
2390 None,
2391 );
2392 states.insert(id.clone(), mk(Ok(addr.clone()), false, Some(event)));
2393 assert!(matches!(
2394 ProcAgent::service_result(&states, &id, &name),
2395 Err(ClientRootError::Unavailable { .. })
2396 ));
2397
2398 let mut states = HashMap::new();
2400 states.insert(id.clone(), mk(Err(anyhow::anyhow!("boom")), false, None));
2401 assert!(matches!(
2402 ProcAgent::service_result(&states, &id, &name),
2403 Err(ClientRootError::Spawn { .. })
2404 ));
2405
2406 let states: HashMap<ResourceId, ActorInstanceState> = HashMap::new();
2408 assert!(matches!(
2409 ProcAgent::service_result(&states, &id, &name),
2410 Err(ClientRootError::Spawn { .. })
2411 ));
2412 }
2413
2414 #[tokio::test]
2417 async fn client_root_from_env_absent_fails_closed() {
2418 use crate::client_root::ClientRootRef;
2419
2420 let (proc, _agent, _client_root) = boot_client_root().await;
2421 let caller = proc.client("no-capability");
2422 let environment = hyperactor::context::Actor::instance(&caller).actor_environment();
2423 assert!(
2424 matches!(
2425 ClientRootRef::from_env(environment),
2426 Err(crate::Error::MissingClientRoot)
2427 ),
2428 "a caller without the capability must fail closed with MissingClientRoot"
2429 );
2430 }
2431
2432 async fn boot_client_root_with_shutdown() -> (
2435 hyperactor::Proc,
2436 ActorHandle<ProcAgent>,
2437 crate::client_root::ClientRootRef,
2438 tokio::sync::oneshot::Receiver<i32>,
2439 ) {
2440 use hyperactor::Proc;
2441 use hyperactor::actor::ActorStatus;
2442 use hyperactor::channel::ChannelTransport;
2443
2444 let proc = Proc::direct(ChannelTransport::Unix.any(), "root".to_string()).unwrap();
2445 let (tx, rx) = tokio::sync::oneshot::channel();
2446 let agent = ProcAgent::boot_v1(proc.clone(), Some(tx)).unwrap();
2447 agent
2448 .status()
2449 .wait_for(|s| matches!(s, ActorStatus::Idle))
2450 .await
2451 .unwrap();
2452 let client_root = crate::client_root::ClientRootRef::bind(&agent);
2453 (proc, agent, client_root, rx)
2454 }
2455
2456 #[derive(Debug, Serialize, Deserialize, Named)]
2459 struct ClientRootObservation(Option<crate::client_root::ClientRootRef>);
2460 wirevalue::register_type!(ClientRootObservation);
2461
2462 #[derive(Debug, Serialize, Deserialize, Named)]
2463 struct ObserveClientRoot {
2464 reply: hyperactor::OncePortRef<ClientRootObservation>,
2465 }
2466 wirevalue::register_type!(ObserveClientRoot);
2467
2468 #[derive(Debug, Default, Serialize, Deserialize)]
2469 #[hyperactor::export(handlers = [ObserveClientRoot])]
2470 struct EnvProbeActor;
2471 impl hyperactor::Actor for EnvProbeActor {}
2472 hyperactor::register_spawnable!(EnvProbeActor);
2473
2474 #[async_trait::async_trait]
2475 impl Handler<ObserveClientRoot> for EnvProbeActor {
2476 async fn handle(
2477 &mut self,
2478 cx: &Context<Self>,
2479 msg: ObserveClientRoot,
2480 ) -> anyhow::Result<()> {
2481 let environment = hyperactor::context::Actor::instance(cx).actor_environment();
2482 let observed = crate::client_root::ClientRootRef::from_env(environment).ok();
2483 msg.reply.post(cx, ClientRootObservation(observed));
2484 Ok(())
2485 }
2486 }
2487
2488 #[tokio::test]
2493 async fn client_root_departed_requester_does_not_fault_root() {
2494 use std::time::Duration;
2495
2496 use hyperactor::ActorRef;
2497 use hyperactor::Endpoint;
2498
2499 use crate::client_root::ClientRootApi;
2500 use crate::client_root::ClientRootService;
2501
2502 let (proc, _agent, client_root) = boot_client_root().await;
2503
2504 {
2505 let caller = proc.client("ephemeral");
2506 let (reply_handle, reply_receiver) =
2507 caller.open_once_port::<EnsureClientRootServiceReply>();
2508 let reply = reply_handle.bind();
2509 drop(reply_receiver);
2513 let actor_type = Remote::collect()
2514 .name_of::<ExtraActor>()
2515 .unwrap()
2516 .to_string();
2517 let params = bincode::serde::encode_to_vec((), bincode::config::legacy()).unwrap();
2518 let api = ActorRef::<ClientRootApi>::attest(client_root.addr().clone());
2519 api.post(
2520 &caller,
2521 EnsureClientRootService {
2522 service_name: Label::new("ephemeral-svc").unwrap(),
2523 actor_type,
2524 params,
2525 reply,
2526 },
2527 );
2528 drop(caller);
2529 }
2530
2531 let survivor = proc.client("survivor");
2537 for name in ["after-departure-1", "after-departure-2"] {
2538 tokio::time::timeout(
2539 Duration::from_secs(15),
2540 ClientRootService::<ExtraActor>::declare(name).ensure(&survivor, &client_root, ()),
2541 )
2542 .await
2543 .expect("root must stay responsive after a departed requester")
2544 .expect("root must serve a new ensure after a departed requester");
2545 }
2546 }
2547
2548 static GATE_ENTERED: tokio::sync::Notify = tokio::sync::Notify::const_new();
2553 static GATE_RELEASE: tokio::sync::Notify = tokio::sync::Notify::const_new();
2554
2555 #[derive(
2556 Debug,
2557 Clone,
2558 Serialize,
2559 Deserialize,
2560 Named,
2561 hyperactor::Handler,
2562 hyperactor::HandleClient
2563 )]
2564 enum GateMsg {
2565 Block(),
2566 }
2567 wirevalue::register_type!(GateMsg);
2568
2569 #[derive(Debug, Default, Serialize, Deserialize)]
2570 #[hyperactor::export(handlers = [GateMsg])]
2571 struct GateActor;
2572 impl hyperactor::Actor for GateActor {}
2573 hyperactor::register_spawnable!(GateActor);
2574
2575 #[async_trait::async_trait]
2576 #[hyperactor::handle(GateMsg)]
2577 impl GateMsgHandler for GateActor {
2578 async fn block(&mut self, _cx: &Context<Self>) -> anyhow::Result<()> {
2579 GATE_ENTERED.notify_one();
2580 GATE_RELEASE.notified().await;
2581 Ok(())
2582 }
2583 }
2584
2585 #[tokio::test]
2589 async fn client_root_ensure_rejected_during_shutdown() {
2590 use std::time::Duration;
2591
2592 use hyperactor::Endpoint;
2593
2594 use crate::client_root::ClientRootError;
2595 use crate::client_root::ClientRootService;
2596
2597 let (proc, agent, client_root, shutdown_rx) = boot_client_root_with_shutdown().await;
2598
2599 let keeper = ClientRootService::<GateActor>::declare("keeper")
2602 .ensure(&proc.client("keeper-caller"), &client_root, ())
2603 .await
2604 .expect("keeper service is created before shutdown");
2605 keeper.post(&proc.client("gate-driver"), GateMsg::Block());
2606 GATE_ENTERED.notified().await;
2607
2608 let control = proc.client("control");
2610 agent.bind::<ProcAgent>().post(
2611 &control,
2612 resource::StopAll {
2613 reason: "test shutdown".to_string(),
2614 },
2615 );
2616 let err = tokio::time::timeout(
2617 Duration::from_secs(15),
2618 ClientRootService::<ExtraActor>::declare("too-late").ensure(&control, &client_root, ()),
2619 )
2620 .await
2621 .expect("agent must answer while the keeper defers shutdown")
2622 .expect_err("a new ensure during shutdown must be rejected");
2623 assert!(
2624 matches!(
2625 err,
2626 crate::Error::ClientRootError(ClientRootError::Unavailable { .. })
2627 ),
2628 "ensure during shutdown must be Unavailable, got {err:?}"
2629 );
2630
2631 GATE_RELEASE.notify_one();
2634 let code = tokio::time::timeout(Duration::from_secs(30), shutdown_rx)
2635 .await
2636 .expect("shutdown must complete once the last actor terminates")
2637 .expect("shutdown channel delivered an exit code");
2638 assert_eq!(code, 0, "clean shutdown exit code");
2639 }
2640
2641 #[tokio::test]
2644 async fn client_root_service_observes_inherited_capability() {
2645 use std::time::Duration;
2646
2647 use hyperactor::Endpoint;
2648
2649 use crate::client_root::ClientRootService;
2650
2651 let (proc, _agent, client_root) = boot_client_root().await;
2652 let caller = proc.client("caller");
2653 let probe = ClientRootService::<EnvProbeActor>::declare("env-probe")
2654 .ensure(&caller, &client_root, ())
2655 .await
2656 .expect("ensure creates the probe service");
2657
2658 let (reply_handle, reply_receiver) = caller.open_once_port::<ClientRootObservation>();
2659 probe.post(
2660 &caller,
2661 ObserveClientRoot {
2662 reply: reply_handle.bind(),
2663 },
2664 );
2665 let ClientRootObservation(observed) =
2666 tokio::time::timeout(Duration::from_secs(15), reply_receiver.recv())
2667 .await
2668 .expect("probe must reply")
2669 .expect("reply channel is open");
2670 assert_eq!(
2671 observed,
2672 Some(client_root.clone()),
2673 "a service must inherit the exact client-root capability, not merely some capability"
2674 );
2675 }
2676}