Skip to main content

monarch_hyperactor/
host_mesh.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9use std::collections::HashMap;
10use std::ops::Deref;
11use std::path::PathBuf;
12use std::sync::OnceLock;
13use std::time::Duration;
14
15use hyperactor::ActorHandle;
16use hyperactor::Endpoint as _;
17use hyperactor::Gateway;
18use hyperactor::Instance;
19use hyperactor::Proc;
20use hyperactor::ProcAddr;
21use hyperactor::ProcId;
22use hyperactor::channel::ChannelAddr;
23use hyperactor::id::Label;
24use hyperactor_mesh::ProcMeshRef;
25use hyperactor_mesh::bootstrap::BootstrapCommand;
26use hyperactor_mesh::bootstrap::ProcBind;
27use hyperactor_mesh::bootstrap::host;
28use hyperactor_mesh::host_mesh;
29use hyperactor_mesh::host_mesh::HostMesh;
30use hyperactor_mesh::host_mesh::HostMeshRef;
31use hyperactor_mesh::host_mesh::PerRankBootstrapFn;
32use hyperactor_mesh::host_mesh::host_agent::GetLocalProcClient;
33use hyperactor_mesh::host_mesh::host_agent::HostAgent;
34use hyperactor_mesh::host_mesh::host_agent::ShutdownHost;
35use hyperactor_mesh::mesh_admin::MeshAdminMessageClient;
36use hyperactor_mesh::mesh_id::ActorMeshId;
37use hyperactor_mesh::mesh_id::HostMeshId;
38use hyperactor_mesh::mesh_id::ProcMeshId;
39use hyperactor_mesh::proc_agent::GetProcClient;
40use hyperactor_mesh::proc_mesh::ProcRef;
41use hyperactor_mesh::proc_mesh::telemetry_actor_mesh_id;
42use hyperactor_mesh::shared_cell::SharedCell;
43use hyperactor_mesh::transport::default_bind_spec;
44use hyperactor_telemetry::hash_to_u64;
45use ndslice::View;
46use ndslice::view::RankedSliceable;
47use pyo3::IntoPyObjectExt;
48use pyo3::exceptions::PyException;
49use pyo3::exceptions::PyRuntimeError;
50use pyo3::exceptions::PyValueError;
51use pyo3::prelude::*;
52use pyo3::types::PyBytes;
53use pyo3::types::PyMapping;
54
55use crate::actor::PythonActor;
56use crate::actor::to_py_error;
57use crate::context::PyInstance;
58use crate::context::stop_instance_and_wait;
59use crate::handle::PyHandle;
60use crate::proc_mesh::PyProcMesh;
61use crate::pytokio::PyPythonTask;
62use crate::runtime::GilSite;
63use crate::runtime::monarch_with_gil;
64use crate::runtime::monarch_with_gil_blocking;
65use crate::shape::PyExtent;
66use crate::shape::PyPoint;
67use crate::shape::PyRegion;
68
69/// The command used to start a proc.
70///
71/// Arguments:
72/// - `program`: The program to execute.
73/// - `arg0`: Optionally, the program's arg0.
74/// - `args`: Command line arguments.
75/// - `env`: Environment variables as a mapping. A `None` value removes
76///   the variable from the inherited environment.
77/// - `inherit_env`: If true (the default), the proc inherits the
78///   environment of the host agent that spawns it, and `env` is applied
79///   on top. If false, the proc starts from an empty environment.
80///
81/// Monarch always sets its own launch variables (e.g.,
82/// `HYPERACTOR_MESH_BOOTSTRAP_MODE`) after applying `env`, and some
83/// process launchers add their own (e.g., systemd service metadata).
84///
85/// The `env` property returns a copy: `cmd.env["X"] = "1"` does not
86/// change the command. Use `cmd.with_env({"X": "1"})` to merge, or
87/// assign `cmd.env = {...}` to replace the whole mapping.
88#[pyclass(
89    name = "BootstrapCommand",
90    module = "monarch._rust_bindings.monarch_hyperactor.host_mesh"
91)]
92#[derive(Clone)]
93pub struct PyBootstrapCommand {
94    #[pyo3(get, set)]
95    pub program: String,
96    #[pyo3(get, set)]
97    pub arg0: Option<String>,
98    #[pyo3(get, set)]
99    pub args: Vec<String>,
100    /// Environment overrides; `None` removes the variable from the
101    /// inherited environment.
102    pub env: HashMap<String, Option<String>>,
103    #[pyo3(get, set)]
104    pub inherit_env: bool,
105}
106
107/// Extract environment overrides from any Python mapping of `str` to
108/// `str | None`.
109fn extract_env(env: &Bound<'_, PyAny>) -> PyResult<HashMap<String, Option<String>>> {
110    env.cast::<PyMapping>()?
111        .items()?
112        .iter()
113        .map(|item| item.extract())
114        .collect()
115}
116
117#[pymethods]
118impl PyBootstrapCommand {
119    #[new]
120    #[pyo3(signature = (program, arg0, args, env, inherit_env = true))]
121    fn new(
122        program: String,
123        arg0: Option<String>,
124        args: Vec<String>,
125        env: &Bound<'_, PyAny>,
126        inherit_env: bool,
127    ) -> PyResult<Self> {
128        Ok(Self {
129            program,
130            arg0,
131            args,
132            env: extract_env(env)?,
133            inherit_env,
134        })
135    }
136
137    #[getter]
138    fn env(&self) -> HashMap<String, Option<String>> {
139        self.env.clone()
140    }
141
142    #[setter]
143    fn set_env(&mut self, env: &Bound<'_, PyAny>) -> PyResult<()> {
144        self.env = extract_env(env)?;
145        Ok(())
146    }
147
148    fn __repr__(&self) -> String {
149        format!(
150            "BootstrapCommand(program='{}', args={:?}, env={:?}, inherit_env={})",
151            self.program,
152            self.args,
153            self.env,
154            if self.inherit_env { "True" } else { "False" }
155        )
156    }
157
158    /// Return a copy of this command with `env` merged on top of its
159    /// environment. Keys in `env` override any conflicting keys in the
160    /// existing environment; a `None` value removes the variable.
161    fn with_env(&self, env: &Bound<'_, PyAny>) -> PyResult<Self> {
162        let mut new_env = self.env.clone();
163        new_env.extend(extract_env(env)?);
164        Ok(Self {
165            env: new_env,
166            ..self.clone()
167        })
168    }
169}
170
171impl PyBootstrapCommand {
172    pub fn to_rust(&self) -> BootstrapCommand {
173        BootstrapCommand {
174            program: PathBuf::from(&self.program),
175            arg0: self.arg0.clone(),
176            args: self.args.clone(),
177            env: self
178                .env
179                .iter()
180                .filter_map(|(k, v)| Some((k.clone(), v.clone()?)))
181                .collect(),
182            env_remove: self
183                .env
184                .iter()
185                .filter(|(_, v)| v.is_none())
186                .map(|(k, _)| k.clone())
187                .collect(),
188            inherit_env: self.inherit_env,
189        }
190    }
191}
192
193#[pyclass(
194    name = "HostMesh",
195    module = "monarch._rust_bindings.monarch_hyperactor.host_mesh"
196)]
197#[expect(
198    clippy::large_enum_variant,
199    reason = "PyO3 #[pyclass] enum; Box wrapping interacts with PyO3 codegen and Python interop — separate diff"
200)]
201pub(crate) enum PyHostMesh {
202    Owned(PyHostMeshImpl),
203    Ref(PyHostMeshRefImpl),
204}
205
206impl PyHostMesh {
207    pub(crate) fn new_owned(inner: HostMesh) -> Self {
208        Self::Owned(PyHostMeshImpl(SharedCell::from(inner)))
209    }
210
211    pub(crate) fn new_ref(inner: HostMeshRef) -> Self {
212        Self::Ref(PyHostMeshRefImpl(inner))
213    }
214
215    pub(crate) fn mesh_ref(&self) -> Result<HostMeshRef, anyhow::Error> {
216        match self {
217            PyHostMesh::Owned(inner) => Ok(inner.0.borrow()?.clone()),
218            PyHostMesh::Ref(inner) => Ok(inner.0.clone()),
219        }
220    }
221}
222
223#[pymethods]
224impl PyHostMesh {
225    #[pyo3(signature = (instance, name, per_host, proc_bind = None, per_rank_bootstrap = None))]
226    fn spawn_nonblocking(
227        &self,
228        _py: Python<'_>,
229        instance: &PyInstance,
230        name: String,
231        per_host: &PyExtent,
232        proc_bind: Option<Vec<HashMap<String, String>>>,
233        per_rank_bootstrap: Option<Py<PyAny>>,
234    ) -> PyResult<PyPythonTask> {
235        let host_mesh = self.mesh_ref()?.clone();
236        let per_rank_bootstrap: Option<Box<PerRankBootstrapFn>> = per_rank_bootstrap
237            .map(|callable| -> PyResult<Box<PerRankBootstrapFn>> {
238                Ok(Box::new(move |point| {
239                    monarch_with_gil_blocking(GilSite::Bootstrap, |py| {
240                        let result =
241                            callable
242                                .bind(py)
243                                .call1((PyPoint::from(point),))
244                                .map_err(|e| {
245                                    anyhow::anyhow!("per-rank bootstrap callable raised: {}", e)
246                                })?;
247                        let cmd: PyBootstrapCommand = result.extract().map_err(|e| {
248                            anyhow::anyhow!(
249                                "per-rank bootstrap callable did not return BootstrapCommand: {}",
250                                e
251                            )
252                        })?;
253                        Ok(cmd.to_rust())
254                    })
255                }))
256            })
257            .transpose()?;
258        let instance = instance.clone();
259        let per_host = per_host.clone().into();
260        let proc_bind = proc_bind.map(|v| v.into_iter().map(ProcBind::from).collect());
261        let mesh_impl = async move {
262            let proc_mesh = host_mesh
263                .spawn(
264                    instance.deref(),
265                    &name,
266                    per_host,
267                    proc_bind,
268                    per_rank_bootstrap,
269                )
270                .await
271                .map_err(to_py_error)?;
272            Ok(PyProcMesh::new_owned(proc_mesh))
273        };
274        PyPythonTask::new(mesh_impl)
275    }
276
277    fn with_bootstrap(&self, bootstrap_command: &PyBootstrapCommand) -> PyResult<Self> {
278        match self {
279            PyHostMesh::Owned(inner) => {
280                let cmd = bootstrap_command.to_rust();
281                inner
282                    .0
283                    .try_with_mut(|mesh| mesh.set_bootstrap(cmd))
284                    .map_err(|e| PyException::new_err(e.to_string()))?;
285                Ok(Self::Owned(inner.clone()))
286            }
287            PyHostMesh::Ref(_) => Ok(Self::new_ref(
288                self.mesh_ref()?.with_bootstrap(bootstrap_command.to_rust()),
289            )),
290        }
291    }
292
293    fn sliced(&self, region: &PyRegion) -> PyResult<Self> {
294        Ok(Self::new_ref(
295            self.mesh_ref()?.sliced(region.as_inner().clone()),
296        ))
297    }
298
299    #[getter]
300    fn region(&self) -> PyResult<PyRegion> {
301        Ok(PyRegion::from(self.mesh_ref()?.region()))
302    }
303
304    fn __reduce__<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> {
305        let mesh_ref = self.mesh_ref()?;
306        if crate::pickle::push_mesh_reference_if_active(crate::actor::MeshRef::Host(Box::new(
307            mesh_ref.clone(),
308        ))) {
309            let pop_fn = PyModule::import(py, "monarch._rust_bindings.monarch_hyperactor.pickle")?
310                .getattr("pop_mesh_reference")?;
311            return Ok((pop_fn, pyo3::types::PyTuple::empty(py).into_any()));
312        }
313        let bytes = bincode::serde::encode_to_vec(&mesh_ref, bincode::config::legacy())
314            .map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))?;
315        let py_bytes = (PyBytes::new(py, &bytes),).into_bound_py_any(py).unwrap();
316        let from_bytes =
317            PyModule::import(py, "monarch._rust_bindings.monarch_hyperactor.host_mesh")?
318                .getattr("py_host_mesh_from_bytes")?;
319        Ok((from_bytes, py_bytes))
320    }
321
322    fn __eq__(&self, other: &PyHostMesh) -> PyResult<bool> {
323        Ok(self.mesh_ref()? == other.mesh_ref()?)
324    }
325
326    fn shutdown(&self, instance: &PyInstance) -> PyResult<PyHandle> {
327        match self {
328            PyHostMesh::Owned(inner) => {
329                let instance = instance.clone();
330                let mesh_borrow = inner.0.clone();
331                let fut = async move {
332                    match mesh_borrow.take().await {
333                        Ok(mut mesh) => {
334                            mesh.shutdown(instance.deref()).await?;
335                            Ok(())
336                        }
337                        Err(_) => {
338                            // Don't return an exception, silently ignore the stop request
339                            // because it was already done.
340                            tracing::info!("shutdown was already called on host mesh");
341                            Ok(())
342                        }
343                    }
344                };
345                Ok(PyHandle::spawn(fut))
346            }
347            PyHostMesh::Ref(_) => Err(PyRuntimeError::new_err(
348                "cannot shut down `HostMesh` that is a reference instead of owned",
349            )),
350        }
351    }
352
353    fn stop(&self, instance: &PyInstance) -> PyResult<PyHandle> {
354        match self {
355            PyHostMesh::Owned(inner) => {
356                let instance = instance.clone();
357                let mesh_borrow = inner.0.clone();
358                let fut = async move {
359                    match mesh_borrow.take().await {
360                        Ok(mut mesh) => {
361                            mesh.stop(instance.deref()).await?;
362                            Ok(())
363                        }
364                        Err(_) => {
365                            tracing::info!("stop was already called on host mesh");
366                            Ok(())
367                        }
368                    }
369                };
370                Ok(PyHandle::spawn(fut))
371            }
372            PyHostMesh::Ref(_) => Err(PyRuntimeError::new_err(
373                "cannot stop `HostMesh` that is a reference instead of owned",
374            )),
375        }
376    }
377}
378
379#[derive(Clone)]
380#[pyclass(
381    name = "HostMeshImpl",
382    module = "monarch._rust_bindings.monarch_hyperactor.host_mesh"
383)]
384pub(crate) struct PyHostMeshImpl(SharedCell<HostMesh>);
385
386#[derive(Debug, Clone)]
387#[pyclass(
388    name = "HostMeshRefImpl",
389    module = "monarch._rust_bindings.monarch_hyperactor.host_mesh"
390)]
391pub(crate) struct PyHostMeshRefImpl(HostMeshRef);
392
393impl PyHostMeshRefImpl {
394    fn __repr__(&self) -> PyResult<String> {
395        Ok(format!("<HostMeshRefImpl {:?}>", self.0))
396    }
397}
398
399/// Static storage for the root client instance when using host-based bootstrap.
400static ROOT_CLIENT_INSTANCE_FOR_HOST: OnceLock<Instance<PythonActor>> = OnceLock::new();
401
402/// Static storage for the host mesh agent created by bootstrap_host().
403static HOST_MESH_AGENT_FOR_HOST: OnceLock<ActorHandle<HostAgent>> = OnceLock::new();
404
405/// Static storage for the host shutdown handle created by bootstrap_host().
406/// Used during shutdown_context to join the mailbox server and flush
407/// receive-side acks.
408static HOST_SHUTDOWN_HANDLE: OnceLock<
409    tokio::sync::Mutex<Option<hyperactor_mesh::bootstrap::HostShutdownHandle>>,
410> = OnceLock::new();
411
412/// Bootstrap the client host and root client actor.
413///
414/// This creates a proper Host with BootstrapProcManager, spawns the root client
415/// actor on the Host's local_proc.
416///
417/// The root client actor is the program's singleton root orchestration actor;
418/// it is distinct from the root ProcAgent on the same proc. Before creating the
419/// actor, bootstrap binds the restricted client-root service API on that
420/// ProcAgent and places the resulting `ClientRootRef` in the actor's persistent
421/// environment. Descendants inherit the capability, giving Python and non-Python
422/// actors the same route to services owned by the program root. Worker
423/// ProcAgents do not bind this API.
424///
425/// Returns a tuple of (HostMesh, ProcMesh, PyInstance) where:
426/// - PyHostMesh: the bootstrapped (local) host mesh; and
427/// - PyProcMesh: the local ProcMesh on this HostMesh; and
428/// - PyInstance: the root client actor instance, on the ProcMesh.
429///
430/// The HostMesh is served on the default transport unless ``bind_addr`` is set.
431///
432/// If ``via`` is set to a ZMQ-style address of a remote host's duplex
433/// server, the local host's gateway is attached to that remote
434/// gateway: outbound traffic to unknown destinations is forwarded over
435/// the duplex, and inbound traffic from the duplex is delivered to
436/// local procs by the gateway's routing. The local host still owns
437/// its own frontend address; ``via`` only adds a forwarding path. The
438/// returned ``PyHostMesh`` / ``PyProcMesh`` / ``PyInstance`` all live
439/// on the local host's procs.
440///
441/// If ``bind_addr`` is set, the local host frontend binds that explicit
442/// address instead of the process-wide default transport. This is independent
443/// of ``via`` and does not alter the runtime configuration inherited by other
444/// hosts.
445///
446/// This should be called only once, at process initialization.
447#[pyfunction]
448#[pyo3(signature = (bootstrap_cmd, via=None, bind_addr=None))]
449fn bootstrap_host(
450    bootstrap_cmd: Option<PyBootstrapCommand>,
451    via: Option<&str>,
452    bind_addr: Option<&str>,
453) -> PyResult<PyPythonTask> {
454    let bootstrap_cmd = match bootstrap_cmd {
455        Some(cmd) => cmd.to_rust(),
456        None => BootstrapCommand::current().map_err(|e| PyException::new_err(e.to_string()))?,
457    };
458    let via_addr = via
459        .map(|s| {
460            ChannelAddr::from_zmq_url(s)
461                .map_err(|e| PyValueError::new_err(format!("via address: {}", e)))
462        })
463        .transpose()?;
464    let bind_addr = bind_addr
465        .map(|s| {
466            ChannelAddr::from_zmq_url(s)
467                .map_err(|e| PyValueError::new_err(format!("bind address: {}", e)))
468        })
469        .transpose()?
470        .unwrap_or_else(|| default_bind_spec().binding_addr());
471
472    PyPythonTask::new(async move {
473        // Pass the via address into `host` so the gateway's `serve_via`
474        // attach runs *after* the host's own frontend serve but *before*
475        // any proc or actor ref is minted. That ordering makes the via
476        // session the newest active serve, so refs advertise the
477        // routable `Via` location and out-of-cluster return traffic
478        // flows back over the duplex. The host owns the resulting serve
479        // handle and tears it down on drop.
480        let gateway = Gateway::global().clone();
481        let service_proc_addr = ProcAddr::new(
482            ProcId::singleton(Label::strip(hyperactor::proc::LEGACY_SERVICE_PROC_NAME)),
483            bind_addr.into(),
484        );
485
486        let (host_mesh_agent, shutdown_handle) = host(
487            service_proc_addr,
488            Some(bootstrap_cmd),
489            None,
490            false,
491            None,
492            gateway,
493            via_addr,
494        )
495        .await
496        .map_err(|e| PyException::new_err(e.to_string()))?;
497
498        // Store the agent and shutdown handle for later shutdown
499        HOST_MESH_AGENT_FOR_HOST.set(host_mesh_agent.clone()).ok();
500        HOST_SHUTDOWN_HANDLE.get_or_init(|| tokio::sync::Mutex::new(Some(shutdown_handle)));
501
502        let host_mesh_id = HostMeshId::singleton(Label::new("local").unwrap());
503        let host_mesh = HostMeshRef::from_host_agent(host_mesh_id, host_mesh_agent.bind())
504            .map_err(|e| PyException::new_err(e.to_string()))?;
505
506        // Register C so MeshAdminAgent can discover it ("A/C
507        // invariant" - hyperactor_mesh/src/mesh_admin.rs).
508        hyperactor_mesh::global_context::register_client_host(host_mesh.clone());
509
510        // We require a temporary instance to make a call to the host/proc agent.
511        let temp_proc = Proc::isolated();
512        let temp_instance = temp_proc.client("temp");
513
514        let local_proc_agent: hyperactor::ActorHandle<hyperactor_mesh::proc_agent::ProcAgent> =
515            host_mesh_agent
516                .get_local_proc(&temp_instance)
517                .await
518                .map_err(|e| PyException::new_err(e.to_string()))?;
519
520        let proc_mesh = ProcMeshRef::new_singleton(
521            ProcMeshId::singleton(Label::new("local").unwrap()),
522            ProcRef::new(
523                local_proc_agent.actor_addr().proc_addr(),
524                0,
525                local_proc_agent.bind(),
526            ),
527        )
528        .map_err(|e| PyException::new_err(e.to_string()))?;
529
530        let local_proc = local_proc_agent
531            .get_proc(&temp_instance)
532            .await
533            .map_err(|e| PyException::new_err(e.to_string()))?;
534
535        // Bind the client-root API on this program's one root ProcAgent and seed
536        // the Python root client's environment with the resulting capability, so
537        // descendants inherit it exactly as the Rust root's descendants do.
538        // Worker ProcAgents never bind the API.
539        let client_root = hyperactor_mesh::client_root::ClientRootRef::bind(&local_proc_agent);
540        let mut root_env = hyperactor::ActorEnvironment::default();
541        root_env
542            .set(hyperactor_mesh::client_root::CLIENT_ROOT, client_root)
543            .map_err(|e| PyException::new_err(e.to_string()))?;
544
545        let (instance, _handle) = monarch_with_gil(GilSite::Bootstrap, |py| {
546            PythonActor::bootstrap_client_inner(
547                py,
548                local_proc,
549                root_env,
550                &ROOT_CLIENT_INSTANCE_FOR_HOST,
551            )
552        })
553        .await;
554
555        // Register the client proc mesh for external-runtime discovery
556        // (`python_client_root`). `bootstrap_client_inner` has just set
557        // `ROOT_CLIENT_INSTANCE_FOR_HOST`, so registering here keeps the pair
558        // complete: the root client instance and the client proc are set
559        // together, before `proc_mesh` is moved into `PyProcMesh` below.
560        hyperactor_mesh::global_context::register_client_proc(proc_mesh.clone());
561
562        // Notify telemetry of the bootstrap host mesh, proc mesh, and client actor.
563        {
564            let now = std::time::SystemTime::now();
565
566            let host_name_str = host_mesh.id().to_string();
567            let host_mesh_id = hash_to_u64(host_mesh.id());
568            hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
569                id: host_mesh_id,
570                timestamp: now,
571                class: "Host".to_string(),
572                given_name: host_mesh
573                    .id()
574                    .display_label()
575                    .map(|l| l.as_str())
576                    .unwrap_or("unnamed")
577                    .to_string(),
578                full_name: host_name_str,
579                shape_json: serde_json::to_string(&host_mesh.region().extent()).unwrap_or_default(),
580                parent_mesh_id: None,
581                parent_view_json: None,
582            });
583
584            let host_agent_addr = host_mesh_agent.actor_addr();
585            hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
586                id: hyperactor_telemetry::hash_to_u64(host_agent_addr.id()),
587                timestamp: now,
588                mesh_id: host_mesh_id,
589                rank: 0,
590                full_name: host_agent_addr.to_string(),
591                display_name: None,
592            });
593
594            let proc_id_str = proc_mesh.id().to_string();
595            let proc_mesh_id = hash_to_u64(proc_mesh.id());
596            hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
597                id: proc_mesh_id,
598                timestamp: now,
599                class: "Proc".to_string(),
600                given_name: proc_mesh
601                    .id()
602                    .display_label()
603                    .map(|l| l.as_str())
604                    .unwrap_or("unnamed")
605                    .to_string(),
606                full_name: proc_id_str,
607                shape_json: serde_json::to_string(&proc_mesh.region().extent()).unwrap_or_default(),
608                parent_mesh_id: Some(host_mesh_id),
609                parent_view_json: None,
610            });
611
612            let proc_agent_addr = local_proc_agent.actor_addr();
613            hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
614                id: hyperactor_telemetry::hash_to_u64(proc_agent_addr.id()),
615                timestamp: now,
616                mesh_id: proc_mesh_id,
617                rank: 0,
618                full_name: proc_agent_addr.to_string(),
619                display_name: None,
620            });
621
622            let client_mesh_actor_id = ActorMeshId::singleton(Label::new("client").unwrap());
623            let client_mesh_name = format!("{}/client", proc_mesh.id());
624            let client_mesh_id = telemetry_actor_mesh_id(proc_mesh.id(), &client_mesh_actor_id);
625            hyperactor_telemetry::notify_mesh_created(hyperactor_telemetry::MeshEvent {
626                id: client_mesh_id,
627                timestamp: now,
628                class: <PythonActor as typeuri::Named>::typename().to_string(),
629                given_name: "client".to_string(),
630                full_name: client_mesh_name,
631                shape_json: serde_json::to_string(&proc_mesh.region().extent()).unwrap_or_default(),
632                parent_mesh_id: Some(proc_mesh_id),
633                parent_view_json: None,
634            });
635
636            hyperactor_telemetry::notify_actor_created(hyperactor_telemetry::ActorEvent {
637                id: hyperactor_telemetry::hash_to_u64(instance.self_addr().id()),
638                timestamp: now,
639                mesh_id: client_mesh_id,
640                rank: 0,
641                full_name: instance.self_addr().to_string(),
642                display_name: Some("<root>".to_string()),
643            });
644        }
645
646        Ok((
647            PyHostMesh::new_ref(host_mesh),
648            PyProcMesh::new_ref(proc_mesh),
649            PyInstance::from(instance),
650        ))
651    })
652}
653
654/// The Python-bootstrapped client root: the root client instance plus the
655/// client proc mesh registered by `bootstrap_host`. Discovered by
656/// [`python_client_root`] for callers that need the stable Python root.
657pub struct PythonClientRoot {
658    cx: &'static Instance<PythonActor>,
659    proc_mesh: ProcMeshRef,
660}
661
662impl PythonClientRoot {
663    /// Spawn a controllerless owner actor on the client root's proc mesh,
664    /// using the root's own stable client instance as the caller context, and
665    /// return its typed [`ActorRef<A>`](hyperactor::ActorRef).
666    ///
667    /// The Python client-root proc mesh is a singleton (one proc), so the
668    /// spawned mesh has exactly one rank. This validates that invariant and
669    /// returns rank 0's actor reference, encoding the single-owner contract in
670    /// the return type and letting callers use the generated `RefClient`
671    /// methods. The intermediate `ActorMesh` is dropped once the ref is
672    /// extracted; its `Drop` is inert, so this does not tear the owner down.
673    ///
674    /// Method-only by design: the owner must be spawned with the long-lived
675    /// root `cx`, never an arbitrary (possibly short-lived) caller context.
676    pub async fn spawn_controllerless_owner<A: hyperactor::RemoteSpawn>(
677        &self,
678        name: &str,
679        params: &A::Params,
680    ) -> hyperactor_mesh::Result<hyperactor::ActorRef<A>>
681    where
682        A::Params: hyperactor::RemoteMessage,
683    {
684        let mesh = self
685            .proc_mesh
686            .spawn_controllerless_service(self.cx, name, params)
687            .await?;
688        // `Ranked` is not imported at module scope: it would make the existing
689        // `.region()` calls in this file ambiguous with `View::region`.
690        let actual = ndslice::view::Ranked::region(&*mesh).num_ranks();
691        if actual != 1 {
692            return Err(hyperactor_mesh::Error::InvalidRankCardinality {
693                expected: 1,
694                actual,
695            });
696        }
697        Ok(ndslice::view::Ranked::get(&*mesh, 0)
698            .expect("singleton owner mesh must contain rank 0")
699            .clone())
700    }
701}
702
703/// Discover the Python-bootstrapped client root, if present.
704///
705/// Python-root discovery only: reads `ROOT_CLIENT_INSTANCE_FOR_HOST` and
706/// [`try_registered_client_proc`](hyperactor_mesh::global_context::try_registered_client_proc)
707/// — never `context()` / `this_proc()` / `try_this_host()`, and it does not
708/// consult the Rust `GLOBAL_CONTEXT`.
709///
710/// Returns `Ok(None)` when neither slot is registered, `Ok(Some(_))` when both
711/// are, and `Err` when exactly one is (partial registration — the pair is set
712/// together by `bootstrap_host`).
713pub fn python_client_root() -> Result<Option<PythonClientRoot>, anyhow::Error> {
714    match (
715        ROOT_CLIENT_INSTANCE_FOR_HOST.get(),
716        hyperactor_mesh::global_context::try_registered_client_proc(),
717    ) {
718        (Some(cx), Some(proc_mesh)) => Ok(Some(PythonClientRoot {
719            cx,
720            proc_mesh: proc_mesh.clone(),
721        })),
722        (None, None) => Ok(None),
723        (Some(_), None) => Err(anyhow::anyhow!(
724            "partial Python client root registration: root client instance is registered but client proc mesh is missing"
725        )),
726        (None, Some(_)) => Err(anyhow::anyhow!(
727            "partial Python client root registration: client proc mesh is registered but root client instance is missing"
728        )),
729    }
730}
731
732#[pyfunction]
733fn py_host_mesh_from_bytes(bytes: &Bound<'_, PyBytes>) -> PyResult<PyHostMesh> {
734    let r: PyResult<HostMeshRef> =
735        bincode::serde::decode_from_slice(bytes.as_bytes(), bincode::config::legacy())
736            .map(|(v, _)| v)
737            .map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()));
738    r.map(PyHostMesh::new_ref)
739}
740
741#[pyfunction]
742#[pyo3(signature = (timeout_secs=None))]
743fn shutdown_local_host_mesh(timeout_secs: Option<f64>) -> PyResult<PyHandle> {
744    let timeout = timeout_secs
745        .map(Duration::try_from_secs_f64)
746        .transpose()
747        .map_err(|e| PyValueError::new_err(format!("timeout_secs: {}", e)))?
748        .unwrap_or(Duration::from_secs(10));
749    let agent = HOST_MESH_AGENT_FOR_HOST
750        .get()
751        .ok_or_else(|| PyRuntimeError::new_err("No local host mesh to shutdown"))?
752        .clone();
753
754    Ok(PyHandle::spawn(async move {
755        // Create a temporary instance to send the shutdown message
756        let temp_proc = hyperactor::Proc::isolated();
757        let instance = temp_proc.client("shutdown_requester");
758
759        tracing::info!(
760            "sending shutdown_host request to agent {}",
761            agent.actor_addr()
762        );
763        // Use same defaults as HostMesh::shutdown():
764        // - MESH_TERMINATE_TIMEOUT = 10 seconds
765        // - MESH_TERMINATE_CONCURRENCY = 16
766
767        let (port, _) = instance.open_port::<usize>();
768        let mut port = port.bind();
769        // We don't need the ack, and this temporary proc doesn't have a mailbox
770        // receiver set up anyways. Just ignore the message.
771        port.return_undeliverable(false);
772        agent.post(
773            &instance,
774            ShutdownHost {
775                timeout,
776                max_in_flight: 16,
777                rank: hyperactor_mesh::resource::Rank::new(0),
778                ack: port,
779            },
780        );
781
782        // Like the host's own flush, the root client's final flush must not
783        // outlive the shutdown timeout when the peer is already gone.
784        let flush_timeout =
785            hyperactor_config::global::get(hyperactor::config::FORWARDER_FLUSH_TIMEOUT)
786                .min(timeout);
787        let shutdown = if let Some(lock) = HOST_SHUTDOWN_HANDLE.get() {
788            lock.lock().await.take()
789        } else {
790            None
791        };
792        if let Some(shutdown) = shutdown {
793            if let Some(root) = ROOT_CLIENT_INSTANCE_FOR_HOST.get() {
794                shutdown
795                    .stop_and_join_after_drain(stop_instance_and_wait(
796                        root,
797                        "shutdown".to_string(),
798                        flush_timeout,
799                    ))
800                    .await;
801            } else {
802                tracing::warn!("shutting down partially bootstrapped host without root client");
803                shutdown.stop_and_join().await;
804            }
805        } else if let Some(root) = ROOT_CLIENT_INSTANCE_FOR_HOST.get() {
806            stop_instance_and_wait(root, "shutdown".to_string(), flush_timeout).await;
807        }
808
809        // Python `None`, not unit: PyO3 converts `()` to an empty tuple.
810        Ok(None::<()>)
811    }))
812}
813
814/// Opaque capability token for `ActorRef<MeshAdminAgent>` across the
815/// Python boundary. No methods, no getters — Python never inspects
816/// this. It exists solely to transport the typed ref from
817/// `_spawn_admin` to `_start_periodic_snapshots_http`.
818#[pyclass(
819    name = "PyMeshAdminRef",
820    module = "monarch._rust_bindings.monarch_hyperactor.host_mesh"
821)]
822#[derive(Clone)]
823pub struct PyMeshAdminRef(hyperactor::ActorRef<hyperactor_mesh::mesh_admin::MeshAdminAgent>);
824
825impl PyMeshAdminRef {
826    pub fn actor_ref(&self) -> hyperactor::ActorRef<hyperactor_mesh::mesh_admin::MeshAdminAgent> {
827        self.0.clone()
828    }
829}
830
831/// `MESH_ADMIN_ADDR` config.
832///
833/// Python-facing wrapper around
834/// [`hyperactor_mesh::host_mesh::spawn_admin`].
835#[pyfunction]
836fn _spawn_admin(
837    host_meshes: Vec<PyRef<'_, PyHostMesh>>,
838    instance: &PyInstance,
839    admin_addr: Option<String>,
840    telemetry_url: Option<String>,
841) -> PyResult<PyPythonTask> {
842    if host_meshes.is_empty() {
843        return Err(PyException::new_err("at least one mesh is required"));
844    }
845
846    let admin_addr = admin_addr
847        .map(|s| {
848            s.parse::<std::net::SocketAddr>()
849                .map_err(|e| PyException::new_err(format!("invalid admin_addr '{}': {}", s, e)))
850        })
851        .transpose()?;
852
853    let mesh_refs = host_meshes
854        .iter()
855        .map(|m| -> PyResult<HostMeshRef> { Ok(m.mesh_ref()?.clone()) })
856        .collect::<PyResult<Vec<HostMeshRef>>>()?;
857
858    let instance = instance.clone();
859    PyPythonTask::new(async move {
860        let admin_ref =
861            host_mesh::spawn_admin(&mesh_refs, instance.deref(), admin_addr, telemetry_url)
862                .await
863                .map_err(|e| PyException::new_err(e.to_string()))?;
864        let admin_url = admin_ref
865            .get_admin_addr(instance.deref())
866            .await
867            .map_err(|e| PyException::new_err(e.to_string()))?
868            .addr
869            .ok_or_else(|| PyException::new_err("mesh admin agent did not report an address"))?;
870        Ok((admin_url, PyMeshAdminRef(admin_ref)))
871    })
872}
873
874pub fn register_python_bindings(hyperactor_mod: &Bound<'_, PyModule>) -> PyResult<()> {
875    let f = wrap_pyfunction!(py_host_mesh_from_bytes, hyperactor_mod)?;
876    f.setattr(
877        "__module__",
878        "monarch._rust_bindings.monarch_hyperactor.host_mesh",
879    )?;
880    hyperactor_mod.add_function(f)?;
881
882    let f2 = wrap_pyfunction!(bootstrap_host, hyperactor_mod)?;
883    f2.setattr(
884        "__module__",
885        "monarch._rust_bindings.monarch_hyperactor.host_mesh",
886    )?;
887    hyperactor_mod.add_function(f2)?;
888
889    let f3 = wrap_pyfunction!(shutdown_local_host_mesh, hyperactor_mod)?;
890    f3.setattr(
891        "__module__",
892        "monarch._rust_bindings.monarch_hyperactor.host_mesh",
893    )?;
894    hyperactor_mod.add_function(f3)?;
895
896    let f4 = wrap_pyfunction!(_spawn_admin, hyperactor_mod)?;
897    f4.setattr(
898        "__module__",
899        "monarch._rust_bindings.monarch_hyperactor.host_mesh",
900    )?;
901    hyperactor_mod.add_function(f4)?;
902
903    hyperactor_mod.add_class::<PyHostMesh>()?;
904    hyperactor_mod.add_class::<PyBootstrapCommand>()?;
905    hyperactor_mod.add_class::<PyMeshAdminRef>()?;
906    Ok(())
907}