Skip to main content

monarch_hyperactor/
actor_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::future::Future;
10use std::ops::Deref;
11use std::pin::Pin;
12use std::sync::Arc;
13use std::thread;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use futures::future;
18use futures::future::FutureExt;
19use futures::future::Shared;
20use hyperactor::Instance;
21use hyperactor::supervision::ActorSupervisionEvent;
22use hyperactor_mesh::actor_mesh::ActorMesh;
23use hyperactor_mesh::actor_mesh::ActorMeshRef;
24use monarch_types::py_global;
25use monarch_types::py_module_add_function;
26use ndslice::view::Ranked;
27use ndslice::view::RankedSliceable;
28use pyo3::IntoPyObjectExt;
29use pyo3::exceptions::PyNotImplementedError;
30use pyo3::exceptions::PyRuntimeError;
31use pyo3::exceptions::PyValueError;
32use pyo3::prelude::*;
33use pyo3::types::PyBytes;
34use pyo3::types::PyTuple;
35use tokio::sync::mpsc::UnboundedSender;
36use tokio::sync::mpsc::unbounded_channel;
37use tracing::Instrument;
38
39use crate::actor::PythonActor;
40use crate::actor::PythonMessage;
41use crate::actor::PythonMessageKind;
42use crate::context::PyInstance;
43use crate::pickle::PendingMessage;
44use crate::proc::PyActorAddr;
45use crate::pytokio::PyPythonTask;
46use crate::runtime::GilSite;
47use crate::runtime::get_tokio_runtime;
48use crate::runtime::monarch_with_gil;
49use crate::runtime::monarch_with_gil_blocking;
50use crate::shape::PyRegion;
51use crate::supervision::Supervisable;
52use crate::supervision::SupervisionError;
53
54py_global!(_pickle, "monarch._src.actor.actor_mesh", "_pickle");
55
56py_global!(
57    shared_class,
58    "monarch._rust_bindings.monarch_hyperactor.pytokio",
59    "Shared"
60);
61
62/// Closed actor-mesh selection surface used by public send APIs.
63///
64/// Public Python APIs expose only `"all"` and `"choose"`. Keep that
65/// invariant explicit here so arbitrary `ndslice::Selection` values stay out
66/// of the public mesh-casting surface.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(crate) enum AllOrChoose {
69    All,
70    Choose,
71}
72
73impl AllOrChoose {
74    pub(crate) fn as_str(self) -> &'static str {
75        match self {
76            Self::All => "all",
77            Self::Choose => "choose",
78        }
79    }
80}
81
82/// Trait defining the common interface for actor mesh, mesh ref and actor mesh implementations.
83/// This corresponds to the Python ActorMeshProtocol ABC.
84pub(crate) trait ActorMeshProtocol: Send + Sync {
85    /// Cast a message to actors selected by the given selection using the specified mailbox.
86    fn cast(
87        &self,
88        message: PythonMessage,
89        selection: AllOrChoose,
90        instance: &Instance<PythonActor>,
91    ) -> PyResult<()>;
92
93    /// Cast a message, merging caller-supplied envelope headers into
94    /// the outbound request. Implementations that reach the real
95    /// envelope emission site override this to thread `caller_headers`
96    /// through `hyperactor_mesh::ActorMeshRef::cast_with_headers`;
97    /// the default collapses to the non-headers path for impls that
98    /// have no envelope access.
99    fn cast_with_headers(
100        &self,
101        message: PythonMessage,
102        selection: AllOrChoose,
103        instance: &Instance<PythonActor>,
104        _caller_headers: hyperactor_config::Flattrs,
105    ) -> PyResult<()> {
106        self.cast(message, selection, instance)
107    }
108
109    /// Cast a pending message (which may contain unresolved async values) to actors.
110    ///
111    /// The default implementation blocks on resolving the message and then calls cast.
112    /// AsyncActorMesh overrides this with an optimized async implementation.
113    fn cast_unresolved(
114        &self,
115        message: PendingMessage,
116        selection: AllOrChoose,
117        instance: &Instance<PythonActor>,
118    ) -> PyResult<()> {
119        let message = get_tokio_runtime().block_on(message.resolve())?;
120        self.cast(message, selection, instance)
121    }
122
123    /// Async counterpart of `cast_with_headers`. The default
124    /// resolves the pending message synchronously and delegates;
125    /// `AsyncActorMesh` overrides this to resolve asynchronously
126    /// and route through `cast_with_headers`.
127    fn cast_unresolved_with_headers(
128        &self,
129        message: PendingMessage,
130        selection: AllOrChoose,
131        instance: &Instance<PythonActor>,
132        caller_headers: hyperactor_config::Flattrs,
133    ) -> PyResult<()> {
134        let message = get_tokio_runtime().block_on(message.resolve())?;
135        self.cast_with_headers(message, selection, instance, caller_headers)
136    }
137
138    fn __reduce__<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)>;
139
140    /// The serializable reference for this mesh, for the out-of-band `refs`
141    /// table. Required so every impl chooses: hold a resolved ref and return
142    /// it, or (a pending mesh) have none and error explicitly.
143    fn mesh_ref(&self) -> PyResult<ActorMeshRef<PythonActor>>;
144
145    /// Stop the actor mesh asynchronously.
146    /// Default implementation raises NotImplementedError for types that don't support stopping.
147    fn stop(&self, _instance: &PyInstance, _reason: String) -> PyResult<PyPythonTask> {
148        Err(PyNotImplementedError::new_err(format!(
149            "stop() is not supported for {}",
150            std::any::type_name::<Self>()
151        )))
152    }
153
154    /// Initialize the actor mesh asynchronously.
155    /// Default implementation returns None (no initialization needed).
156    fn initialized(&self) -> PyResult<PyPythonTask> {
157        PyPythonTask::new(async { Ok(None::<()>) })
158    }
159
160    /// The name of the mesh.
161    fn name(&self) -> PyResult<PyPythonTask>;
162}
163
164pub(crate) trait SupervisableActorMesh: ActorMeshProtocol + Supervisable {
165    fn new_with_region(&self, region: &PyRegion) -> PyResult<Box<dyn SupervisableActorMesh>>;
166}
167
168/// This just forwards to the rust trait that can implement these bindings
169#[pyclass(
170    name = "PythonActorMesh",
171    module = "monarch._rust_bindings.monarch_hyperactor.actor_mesh"
172)]
173#[derive(Clone)]
174pub(crate) struct PythonActorMesh {
175    inner: Arc<dyn SupervisableActorMesh>,
176}
177
178impl PythonActorMesh {
179    pub(crate) fn new<F>(f: F, supervised: bool) -> Self
180    where
181        F: Future<Output = PyResult<Box<dyn SupervisableActorMesh>>> + Send + 'static,
182    {
183        let f = async move { Ok(Arc::from(f.await?)) }.boxed().shared();
184        PythonActorMesh {
185            inner: Arc::new(AsyncActorMesh::new_queue(f, supervised)),
186        }
187    }
188
189    pub(crate) fn from_impl(inner: Arc<dyn SupervisableActorMesh>) -> Self {
190        PythonActorMesh { inner }
191    }
192
193    pub(crate) fn get_inner(&self) -> Arc<dyn SupervisableActorMesh> {
194        self.inner.clone()
195    }
196}
197
198pub(crate) fn to_all_or_choose(selection: &str) -> PyResult<AllOrChoose> {
199    match selection {
200        "choose" => Ok(AllOrChoose::Choose),
201        "all" => Ok(AllOrChoose::All),
202        _ => Err(PyErr::new::<PyValueError, _>(format!(
203            "Invalid selection: {}",
204            selection
205        ))),
206    }
207}
208
209#[pymethods]
210impl PythonActorMesh {
211    #[tracing::instrument(level = "debug", skip_all)]
212    #[pyo3(name = "cast")]
213    fn py_cast(
214        &self,
215        message: &PythonMessage,
216        selection: &str,
217        instance: &PyInstance,
218    ) -> PyResult<()> {
219        let sel = to_all_or_choose(selection)?;
220        self.inner.cast(message.clone(), sel, instance.deref())
221    }
222
223    #[hyperactor::instrument]
224    pub(crate) fn cast_unresolved(
225        &self,
226        message: &mut PendingMessage,
227        selection: &str,
228        instance: &PyInstance,
229    ) -> PyResult<()> {
230        let sel = to_all_or_choose(selection)?;
231        let message = message.take()?;
232        self.inner.cast_unresolved(message, sel, instance)
233    }
234
235    fn new_with_region(&self, region: &PyRegion) -> PyResult<PythonActorMesh> {
236        let inner = self.inner.new_with_region(region)?;
237        Ok(PythonActorMesh {
238            inner: Arc::from(inner),
239        })
240    }
241
242    fn stop(&self, instance: &PyInstance, reason: String) -> PyResult<PyPythonTask> {
243        self.inner.stop(instance, reason)
244    }
245
246    fn initialized(&self) -> PyResult<PyPythonTask> {
247        self.inner.initialized()
248    }
249
250    fn name(&self) -> PyResult<PyPythonTask> {
251        self.inner.name()
252    }
253
254    fn __reduce__<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> {
255        self.inner.__reduce__(py)
256    }
257}
258
259#[derive(Debug)]
260pub(crate) struct ClonePyErr {
261    inner: PyErr,
262}
263
264impl From<ClonePyErr> for PyErr {
265    fn from(value: ClonePyErr) -> PyErr {
266        value.inner
267    }
268}
269impl From<PyErr> for ClonePyErr {
270    fn from(inner: PyErr) -> ClonePyErr {
271        ClonePyErr { inner }
272    }
273}
274
275impl Clone for ClonePyErr {
276    fn clone(&self) -> Self {
277        monarch_with_gil_blocking(GilSite::Convert, |py| self.inner.clone_ref(py).into())
278    }
279}
280
281type ActorMeshResult = Result<Arc<dyn SupervisableActorMesh>, ClonePyErr>;
282type ActorMeshFut = Shared<Pin<Box<dyn Future<Output = ActorMeshResult> + Send + 'static>>>;
283
284pub(crate) struct AsyncActorMesh {
285    mesh: ActorMeshFut,
286    queue: UnboundedSender<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>,
287    supervised: bool,
288}
289
290impl AsyncActorMesh {
291    pub(crate) fn new_queue(f: ActorMeshFut, supervised: bool) -> AsyncActorMesh {
292        let (queue, mut recv) = unbounded_channel();
293
294        get_tokio_runtime().spawn(async move {
295            loop {
296                let r = recv.recv().await;
297                if let Some(r) = r {
298                    r.await;
299                } else {
300                    return;
301                }
302            }
303        });
304
305        let mesh = AsyncActorMesh::new(queue, supervised, f);
306        // Eagerly trigger the mesh initialization by pushing an init task onto
307        // the queue. This ensures actors are spawned immediately rather than
308        // waiting for the first endpoint call, which is critical for:
309        // 1. Tests/code that wait for supervision events from actor __init__
310        //    failures without making any endpoint calls.
311        // 2. Ensuring all meshes on a proc are spawned before any errors occur,
312        //    preventing spawn rejections due to stale supervision events.
313        let f = mesh.mesh.clone();
314        mesh.push(async move {
315            let _ = f.await;
316        });
317        mesh
318    }
319
320    fn new(
321        queue: UnboundedSender<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>,
322        supervised: bool,
323        f: ActorMeshFut,
324    ) -> AsyncActorMesh {
325        AsyncActorMesh {
326            mesh: f,
327            queue,
328            supervised,
329        }
330    }
331
332    fn push<F>(&self, f: F)
333    where
334        F: Future<Output = ()> + Send + 'static,
335    {
336        self.queue.send(f.boxed()).unwrap();
337    }
338
339    pub(crate) fn from_impl(mesh: Arc<dyn SupervisableActorMesh>) -> Self {
340        let fut = future::ready(Ok::<Arc<dyn SupervisableActorMesh>, ClonePyErr>(mesh))
341            .boxed()
342            .shared();
343        // Poll the future so that its result can be observed without blocking the tokio runtime.
344        let _ = futures::executor::block_on(fut.clone());
345        Self::new_queue(fut, true)
346    }
347}
348
349impl ActorMeshProtocol for AsyncActorMesh {
350    fn cast(
351        &self,
352        _message: PythonMessage,
353        _selection: AllOrChoose,
354        _instance: &Instance<PythonActor>,
355    ) -> PyResult<()> {
356        panic!("not implemented")
357    }
358
359    fn cast_unresolved(
360        &self,
361        message: PendingMessage,
362        selection: AllOrChoose,
363        instance: &Instance<PythonActor>,
364    ) -> PyResult<()> {
365        self.cast_unresolved_with_headers(
366            message,
367            selection,
368            instance,
369            hyperactor_config::Flattrs::new(),
370        )
371    }
372
373    fn cast_unresolved_with_headers(
374        &self,
375        message: PendingMessage,
376        selection: AllOrChoose,
377        instance: &Instance<PythonActor>,
378        caller_headers: hyperactor_config::Flattrs,
379    ) -> PyResult<()> {
380        let mesh = self.mesh.clone();
381        let instance = instance.clone_for_py();
382        let port = match &message.kind {
383            PythonMessageKind::CallMethod { response_port, .. } => response_port.clone(),
384            _ => None,
385        };
386        self.push(
387            async move {
388                let result = async {
389                    let resolved = message.resolve().await?;
390                    mesh.await?
391                        .cast_with_headers(resolved, selection, &instance, caller_headers)
392                }
393                .await;
394                if let (Some(mut port_ref), Err(pyerr)) = (port, result) {
395                    let _ = monarch_with_gil(GilSite::Traceback, |py: Python<'_>| {
396                        let exception_str = crate::logging::format_traceback(py, &pyerr);
397                        tracing::error!(
398                            actor_id = instance.self_addr().to_string(),
399                            "error occurred during cast unresolved: {}",
400                            exception_str
401                        );
402
403                        // Endpoint calls create a response port: the
404                        // PortRef is sent to the remote worker (to send
405                        // results back), and collect_valuemesh owns the
406                        // PortReceiver. If mesh.cast() fails here, we try
407                        // to send the exception back to the caller via
408                        // the PortRef ourselves. But a supervision event
409                        // can cause collect_valuemesh to drop the
410                        // PortReceiver (removing the port from the
411                        // mailbox) before we get here. Disable
412                        // return-undeliverable so a delivery failure
413                        // doesn't bounce back and crash the root client.
414                        //
415                        // TODO: Tie the lifetime of this queued work to
416                        // the PortReceiver (e.g. a cancellation token set
417                        // on drop) so we can distinguish
418                        // supervision-caused failures — where the caller
419                        // already knows — from other cast errors where
420                        // the caller actually needs this exception.
421
422                        port_ref.set_return_undeliverable(false);
423
424                        let mut state = crate::pickle::pickle(
425                            py,
426                            pyerr.into_value(py).into_any(),
427                            false,
428                            false,
429                        )?;
430                        let _ = port_ref.post(
431                            &instance,
432                            PythonMessage::new_from_buf(
433                                PythonMessageKind::Exception { rank: Some(0) },
434                                state.take_inner()?.take_buffer(),
435                            ),
436                        );
437
438                        Ok::<_, PyErr>(())
439                    })
440                    .await;
441                }
442            }
443            .instrument(tracing::debug_span!("AsyncActorMesh::cast")),
444        );
445        Ok(())
446    }
447
448    fn __reduce__<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> {
449        let fut = self.mesh.clone();
450        match fut.peek().cloned() {
451            Some(mesh) => mesh?.__reduce__(py),
452            None => {
453                let shared =
454                    PyPythonTask::new(async move { Ok(PythonActorMesh::from_impl(fut.await?)) })?
455                        .spawn_abortable()?;
456                let shared = Py::new(py, shared)?;
457                if crate::pickle::reserve_mesh_reference_if_active(shared.clone_ref(py)) {
458                    let pop_fn = py
459                        .import("monarch._rust_bindings.monarch_hyperactor.pickle")?
460                        .getattr("pop_mesh_reference")?;
461                    return Ok((pop_fn, PyTuple::empty(py).into_any()));
462                }
463                // Get Shared.block_on as an unbound method
464                let block_on = shared_class(py).getattr("block_on")?;
465                let args = PyTuple::new(py, [shared])?;
466                Ok((block_on, args.into_any()))
467            }
468        }
469    }
470
471    fn mesh_ref(&self) -> PyResult<ActorMeshRef<PythonActor>> {
472        // A pending mesh has no serializable ref of its own; the reserve/fill
473        // slot carries it out-of-band. This is a backstop: the happy path never
474        // asks a pending mesh for a ref.
475        Err(pyo3::exceptions::PyRuntimeError::new_err(
476            "pending actor mesh has no serializable ref; it is carried via the reserve/fill slot",
477        ))
478    }
479
480    fn stop(&self, instance: &PyInstance, reason: String) -> PyResult<PyPythonTask> {
481        let mesh = self.mesh.clone();
482        let instance = monarch_with_gil_blocking(GilSite::Stop, |_py| instance.clone());
483        let (tx, rx) = tokio::sync::oneshot::channel();
484        self.push(async move {
485            let result =
486                async move { mesh.await?.stop(&instance, reason)?.take_task()?.await }.await;
487            if tx.send(result).is_err() {
488                panic!("oneshot failed");
489            }
490        });
491        PyPythonTask::new(async move { rx.await.map_err(anyhow::Error::from)? })
492    }
493
494    fn initialized<'py>(&self) -> PyResult<PyPythonTask> {
495        let mesh = self.mesh.clone();
496        PyPythonTask::new(async {
497            mesh.await?;
498            Ok(None::<()>)
499        })
500    }
501
502    fn name(&self) -> PyResult<PyPythonTask> {
503        let mesh = self.mesh.clone();
504        let (tx, rx) = tokio::sync::oneshot::channel();
505        self.push(async move {
506            let result = async move { mesh.await?.name()?.take_task()?.await }.await;
507            if tx.send(result).is_err() {
508                panic!("oneshot failed");
509            }
510        });
511        PyPythonTask::new(async move { rx.await.map_err(anyhow::Error::from)? })
512    }
513}
514
515#[async_trait]
516impl Supervisable for AsyncActorMesh {
517    async fn supervision_event(&self, instance: &Instance<PythonActor>) -> Option<PyErr> {
518        if !self.supervised {
519            return None;
520        }
521        let mesh = self.mesh.clone();
522        match mesh.await {
523            Ok(mesh) => mesh.supervision_event(instance).await,
524            Err(e) => Some(e.into()),
525        }
526    }
527}
528
529impl SupervisableActorMesh for AsyncActorMesh {
530    fn new_with_region(&self, region: &PyRegion) -> PyResult<Box<dyn SupervisableActorMesh>> {
531        let mesh = self.mesh.clone();
532        let region = region.clone();
533        Ok(Box::new(AsyncActorMesh::new(
534            self.queue.clone(),
535            self.supervised,
536            async move { Ok(Arc::from(mesh.await?.new_with_region(&region)?)) }
537                .boxed()
538                .shared(),
539        )))
540    }
541}
542
543#[derive(Debug, Clone)]
544#[pyclass(
545    name = "PyActorMesh",
546    module = "monarch._rust_bindings.monarch_hyperactor.actor_mesh"
547)]
548pub(crate) struct PyActorMesh {
549    mesh: ActorMesh<PythonActor>,
550}
551
552#[derive(Debug, Clone)]
553#[pyclass(
554    name = "PyActorMeshRef",
555    module = "monarch._rust_bindings.monarch_hyperactor.actor_mesh"
556)]
557pub(crate) struct PyActorMeshRef {
558    mesh: ActorMeshRef<PythonActor>,
559}
560
561#[derive(Debug, Clone)]
562#[pyclass(
563    name = "PythonActorMeshImpl",
564    module = "monarch._rust_bindings.monarch_hyperactor.actor_mesh"
565)]
566#[expect(
567    clippy::large_enum_variant,
568    reason = "PyO3 #[pyclass] enum; Box wrapping interacts with PyO3 codegen and Python interop — separate diff"
569)]
570pub(crate) enum PythonActorMeshImpl {
571    Owned(PyActorMesh),
572    Ref(PyActorMeshRef),
573}
574
575impl PythonActorMeshImpl {
576    /// Get a new owned [`PythonActorMeshImpl`].
577    pub(crate) fn new_owned(inner: ActorMesh<PythonActor>) -> Self {
578        PythonActorMeshImpl::Owned(PyActorMesh { mesh: inner })
579    }
580
581    /// Get a new ref-based [`PythonActorMeshImpl`].
582    pub(crate) fn new_ref(inner: ActorMeshRef<PythonActor>) -> Self {
583        PythonActorMeshImpl::Ref(PyActorMeshRef { mesh: inner })
584    }
585
586    fn mesh_ref(&self) -> &ActorMeshRef<PythonActor> {
587        match self {
588            PythonActorMeshImpl::Owned(inner) => &inner.mesh,
589            PythonActorMeshImpl::Ref(inner) => &inner.mesh,
590        }
591    }
592}
593
594#[async_trait]
595impl Supervisable for PythonActorMeshImpl {
596    async fn supervision_event(&self, instance: &Instance<PythonActor>) -> Option<PyErr> {
597        let mesh = self.mesh_ref();
598        match mesh.next_supervision_event(instance).await {
599            Ok(supervision_failure) => Some(SupervisionError::new_err_from(supervision_failure)),
600            Err(e) => Some(PyValueError::new_err(e.to_string())),
601        }
602    }
603}
604
605impl ActorMeshProtocol for PythonActorMeshImpl {
606    fn cast(
607        &self,
608        message: PythonMessage,
609        selection: AllOrChoose,
610        instance: &Instance<PythonActor>,
611    ) -> PyResult<()> {
612        <ActorMeshRef<PythonActor> as ActorMeshProtocol>::cast(
613            self.mesh_ref(),
614            message,
615            selection,
616            instance,
617        )
618    }
619
620    fn cast_with_headers(
621        &self,
622        message: PythonMessage,
623        selection: AllOrChoose,
624        instance: &Instance<PythonActor>,
625        caller_headers: hyperactor_config::Flattrs,
626    ) -> PyResult<()> {
627        <ActorMeshRef<PythonActor> as ActorMeshProtocol>::cast_with_headers(
628            self.mesh_ref(),
629            message,
630            selection,
631            instance,
632            caller_headers,
633        )
634    }
635
636    fn stop(&self, instance: &PyInstance, reason: String) -> PyResult<PyPythonTask> {
637        let (slf, instance) =
638            monarch_with_gil_blocking(GilSite::Stop, |_py| (self.clone(), instance.clone()));
639        match slf {
640            PythonActorMeshImpl::Owned(mut mesh) => PyPythonTask::new(async move {
641                mesh.mesh
642                    .stop(instance.deref(), reason)
643                    .await
644                    .map_err(|err| PyValueError::new_err(err.to_string()))
645            }),
646            PythonActorMeshImpl::Ref(_) => Err(PyNotImplementedError::new_err(
647                "Cannot call stop on an ActorMeshRef, requires an owned ActorMesh",
648            )),
649        }
650    }
651
652    fn __reduce__<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> {
653        self.mesh_ref().__reduce__(py)
654    }
655
656    fn mesh_ref(&self) -> PyResult<ActorMeshRef<PythonActor>> {
657        // `self.mesh_ref()` resolves to the inherent borrow-returning method.
658        Ok(PythonActorMeshImpl::mesh_ref(self).clone())
659    }
660
661    fn name(&self) -> PyResult<PyPythonTask> {
662        let name = self.mesh_ref().id().to_string();
663        PyPythonTask::new(async move { Ok(name) })
664    }
665}
666
667impl SupervisableActorMesh for PythonActorMeshImpl {
668    fn new_with_region(&self, region: &PyRegion) -> PyResult<Box<dyn SupervisableActorMesh>> {
669        assert!(region.as_inner().is_subset(self.mesh_ref().region()));
670        Ok(Box::new(PythonActorMeshImpl::new_ref(
671            self.mesh_ref().sliced(region.as_inner().clone()),
672        )))
673    }
674}
675
676// Convert a hyperactor_mesh::Error to a Python exception. hyperactor_mesh::Error::Supervision becomes a SupervisionError,
677// all others become a RuntimeError.
678fn cast_error_to_py_error(err: hyperactor_mesh::Error) -> PyErr {
679    if let hyperactor_mesh::Error::Supervision(failure) = err {
680        SupervisionError::new_err_from(*failure)
681    } else {
682        PyRuntimeError::new_err(err.to_string())
683    }
684}
685
686impl ActorMeshProtocol for ActorMeshRef<PythonActor> {
687    fn cast(
688        &self,
689        message: PythonMessage,
690        selection: AllOrChoose,
691        instance: &Instance<PythonActor>,
692    ) -> PyResult<()> {
693        <Self as ActorMeshProtocol>::cast_with_headers(
694            self,
695            message,
696            selection,
697            instance,
698            hyperactor_config::Flattrs::new(),
699        )
700    }
701
702    fn cast_with_headers(
703        &self,
704        message: PythonMessage,
705        selection: AllOrChoose,
706        instance: &Instance<PythonActor>,
707        caller_headers: hyperactor_config::Flattrs,
708    ) -> PyResult<()> {
709        match selection {
710            AllOrChoose::All => ActorMeshRef::<PythonActor>::cast_with_headers(
711                self,
712                instance,
713                &caller_headers,
714                message,
715            )
716            .map_err(cast_error_to_py_error),
717            AllOrChoose::Choose => ActorMeshRef::<PythonActor>::cast_choose_with_headers(
718                self,
719                instance,
720                &caller_headers,
721                message,
722            )
723            .map_err(cast_error_to_py_error),
724        }
725    }
726
727    /// Stop the actor mesh asynchronously.
728    fn stop(&self, _instance: &PyInstance, _reason: String) -> PyResult<PyPythonTask> {
729        Err(PyNotImplementedError::new_err(
730            "This cannot be used on ActorMeshRef, only on owned ActorMesh",
731        ))
732    }
733
734    fn __reduce__<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> {
735        if crate::pickle::push_mesh_reference_if_active(crate::actor::MeshRef::Actor(Box::new(
736            self.clone(),
737        ))) {
738            let pop_fn = py
739                .import("monarch._rust_bindings.monarch_hyperactor.pickle")?
740                .getattr("pop_mesh_reference")?;
741            return Ok((pop_fn, pyo3::types::PyTuple::empty(py).into_any()));
742        }
743        let bytes = bincode::serde::encode_to_vec(self, bincode::config::legacy())
744            .map_err(|e| PyValueError::new_err(e.to_string()))?;
745        let py_bytes = (PyBytes::new(py, &bytes),).into_bound_py_any(py).unwrap();
746        let module = py
747            .import("monarch._rust_bindings.monarch_hyperactor.actor_mesh")
748            .unwrap();
749        let from_bytes = module.getattr("py_actor_mesh_from_bytes").unwrap();
750        Ok((from_bytes, py_bytes))
751    }
752
753    fn mesh_ref(&self) -> PyResult<ActorMeshRef<PythonActor>> {
754        Ok(self.clone())
755    }
756
757    fn name(&self) -> PyResult<PyPythonTask> {
758        let name = self.id().to_string();
759        PyPythonTask::new(async move { Ok(name) })
760    }
761}
762
763#[pymethods]
764impl PythonActorMeshImpl {
765    fn get(&self, rank: usize) -> PyResult<Option<PyActorAddr>> {
766        Ok(self
767            .mesh_ref()
768            .get(rank)
769            .map(|r| hyperactor::ActorRef::into_actor_addr(r.clone()))
770            .map(PyActorAddr::from))
771    }
772
773    fn __repr__(&self) -> String {
774        format!("PythonActorMeshImpl({:?})", self.mesh_ref())
775    }
776}
777
778#[pyfunction]
779fn py_actor_mesh_from_bytes(bytes: &Bound<'_, PyBytes>) -> PyResult<PythonActorMesh> {
780    let r: PyResult<ActorMeshRef<PythonActor>> =
781        bincode::serde::decode_from_slice(bytes.as_bytes(), bincode::config::legacy())
782            .map(|(v, _)| v)
783            .map_err(|e| PyValueError::new_err(e.to_string()));
784    r.map(|r| AsyncActorMesh::from_impl(Arc::new(PythonActorMeshImpl::new_ref(r))))
785        .map(|r| PythonActorMesh::from_impl(Arc::from(r)))
786}
787
788#[pyclass(
789    name = "ActorSupervisionEvent",
790    module = "monarch._rust_bindings.monarch_hyperactor.actor_mesh"
791)]
792#[derive(Debug)]
793pub struct PyActorSupervisionEvent {
794    inner: ActorSupervisionEvent,
795}
796
797#[pymethods]
798impl PyActorSupervisionEvent {
799    pub(crate) fn __repr__(&self) -> PyResult<String> {
800        Ok(format!("<PyActorSupervisionEvent: {}>", self.inner))
801    }
802
803    #[getter]
804    pub(crate) fn actor_id(&self) -> PyResult<PyActorAddr> {
805        Ok(PyActorAddr::from(self.inner.actor_id.clone()))
806    }
807
808    #[getter]
809    pub(crate) fn actor_status(&self) -> PyResult<String> {
810        Ok(self.inner.actor_status.to_string())
811    }
812}
813
814impl From<ActorSupervisionEvent> for PyActorSupervisionEvent {
815    fn from(event: ActorSupervisionEvent) -> Self {
816        PyActorSupervisionEvent { inner: event }
817    }
818}
819
820#[pyfunction]
821fn py_identity(obj: Py<PyAny>) -> PyResult<Py<PyAny>> {
822    Ok(obj)
823}
824
825/// Holds the GIL for the specified number of seconds without releasing it.
826///
827/// This is a test utility function that spawns a background thread which
828/// acquires the GIL using Rust's Python::attach and holds it for the
829/// specified duration using thread::sleep. Unlike Python code which
830/// periodically releases the GIL, this function holds it continuously.
831///
832/// We intentionally use `std::thread::sleep` here (not `Clock::sleep` or async sleep)
833/// because the purpose is to simulate a blocking operation that holds the GIL without
834/// releasing it. Using an async sleep would release the GIL periodically, defeating
835/// the purpose of this test utility.
836///
837/// Args:
838///     delay_secs: Seconds to wait before acquiring the GIL
839///     hold_secs: Seconds to hold the GIL
840#[pyfunction]
841#[pyo3(name = "hold_gil_for_test", signature = (delay_secs, hold_secs))]
842pub fn hold_gil_for_test(delay_secs: f64, hold_secs: f64) {
843    thread::spawn(move || {
844        // Wait before grabbing the GIL (blocking sleep is fine here, we're in a spawned thread)
845        thread::sleep(Duration::from_secs_f64(delay_secs));
846        // Acquire and hold the GIL - MUST use blocking sleep to keep GIL held
847        monarch_with_gil_blocking(GilSite::Test, |_py| {
848            tracing::info!("start holding the gil...");
849            thread::sleep(Duration::from_secs_f64(hold_secs));
850            tracing::info!("end holding the gil...");
851        });
852    });
853}
854
855pub fn register_python_bindings(hyperactor_mod: &Bound<'_, PyModule>) -> PyResult<()> {
856    py_module_add_function!(
857        hyperactor_mod,
858        "monarch._rust_bindings.monarch_hyperactor.actor_mesh",
859        py_identity
860    );
861    py_module_add_function!(
862        hyperactor_mod,
863        "monarch._rust_bindings.monarch_hyperactor.actor_mesh",
864        py_actor_mesh_from_bytes
865    );
866    py_module_add_function!(
867        hyperactor_mod,
868        "monarch._rust_bindings.monarch_hyperactor.actor_mesh",
869        hold_gil_for_test
870    );
871    hyperactor_mod.add_class::<PythonActorMesh>()?;
872    hyperactor_mod.add_class::<PythonActorMeshImpl>()?;
873    hyperactor_mod.add_class::<PyActorSupervisionEvent>()?;
874    Ok(())
875}
876
877#[cfg(test)]
878mod tests {
879    use std::sync::OnceLock;
880    use std::time::Duration;
881
882    use async_trait::async_trait;
883    use hyperactor::Actor;
884    use hyperactor::Context;
885    use hyperactor::Endpoint as _;
886    use hyperactor::Handler;
887    use hyperactor::Instance;
888    use hyperactor::Proc;
889    use hyperactor::actor::Signal;
890    use hyperactor::channel::ChannelTransport;
891    use hyperactor::mailbox;
892    use hyperactor::proc::ActorWorkReceiver;
893    use hyperactor::supervision::ActorSupervisionEvent;
894    use hyperactor_mesh::host_mesh::HostMesh;
895    use hyperactor_mesh::mesh_controller::GetSubscriberCount;
896    use hyperactor_mesh::supervision::MeshFailure;
897    use monarch_types::PickledPyObject;
898    use ndslice::extent;
899    use tokio::sync::mpsc;
900
901    use super::*;
902    use crate::actor::PythonActor;
903    use crate::actor::PythonActorParams;
904    use crate::config::ACTOR_QUEUE_DISPATCH;
905
906    /// Minimal root-client actor for test infrastructure.
907    /// Handles MeshFailure by panicking (test failure).
908    #[derive(Debug)]
909    struct TestClient {
910        signal_rx: mpsc::UnboundedReceiver<Signal>,
911        supervision_rx: mpsc::UnboundedReceiver<ActorSupervisionEvent>,
912        work_rx: ActorWorkReceiver<Self>,
913    }
914
915    impl Actor for TestClient {}
916
917    #[async_trait]
918    impl Handler<MeshFailure> for TestClient {
919        async fn handle(
920            &mut self,
921            _cx: &Context<Self>,
922            msg: MeshFailure,
923        ) -> Result<(), anyhow::Error> {
924            panic!("unexpected supervision failure in test: {}", msg);
925        }
926    }
927
928    impl TestClient {
929        fn run(mut self, instance: &'static Instance<Self>) {
930            tokio::spawn(async move {
931                loop {
932                    tokio::select! {
933                        work = self.work_rx.recv() => {
934                            match work {
935                                Some(work) => {
936                                    let _ = work.handle(&mut self, instance).await;
937                                }
938                                None => break,
939                            }
940                        }
941                        Some(_) = self.signal_rx.recv() => {}
942                        Some(event) = self.supervision_rx.recv() => {
943                            let _ = instance
944                                .handle_supervision_event(&mut self, event)
945                                .await;
946                        }
947                    }
948                }
949            });
950        }
951    }
952
953    fn init_test_instance() -> &'static Instance<TestClient> {
954        static INSTANCE: OnceLock<Instance<TestClient>> = OnceLock::new();
955        let proc = Proc::direct(ChannelTransport::Unix.any(), "test_proc".to_string()).unwrap();
956        let ai = proc.actor_instance("test_client").unwrap();
957
958        INSTANCE
959            .set(ai.instance)
960            .map_err(|_| "already initialized")
961            .unwrap();
962        let instance = INSTANCE.get().unwrap();
963
964        TestClient {
965            signal_rx: ai.signal,
966            supervision_rx: ai.supervision,
967            work_rx: ai.work,
968        }
969        .run(instance);
970
971        instance
972    }
973
974    fn test_instance() -> &'static Instance<TestClient> {
975        static INSTANCE: OnceLock<&'static Instance<TestClient>> = OnceLock::new();
976        INSTANCE.get_or_init(init_test_instance)
977    }
978
979    /// Verify that calling `supervision_event` repeatedly through a
980    /// [`PythonActorMesh`] does not increase the subscriber count on the
981    /// controller.  This guards against a regression where each call
982    /// would create a new supervision subscriber.
983    #[tokio::test]
984    async fn test_subscriber_count_stable_across_supervision_calls() {
985        crate::pytokio::ensure_python();
986
987        let instance = test_instance();
988
989        let mut host_mesh = HostMesh::local_in_process().await.unwrap();
990        let proc_mesh = host_mesh
991            .spawn(instance, "test", extent!(replicas = 2), None, None)
992            .await
993            .unwrap();
994
995        // Create a minimal Python class and pickle it so we can spawn
996        // PythonActor instances (mirroring PyProcMesh::spawn_async).
997        // The class must live in __main__'s globals for pickle to find it.
998        let pickled_type = monarch_with_gil_blocking(GilSite::Test, |py| {
999            py.run(c"class MinimalActor: pass", None, None).unwrap();
1000
1001            PickledPyObject::pickle(
1002                &py.import("__main__")
1003                    .unwrap()
1004                    .getattr("MinimalActor")
1005                    .unwrap(),
1006            )
1007            .unwrap()
1008        });
1009
1010        let actor_params = PythonActorParams::new(pickled_type, None, None);
1011        let config_lock = hyperactor_config::global::lock();
1012        let _queue_dispatch_guard = config_lock.override_key(ACTOR_QUEUE_DISPATCH, false);
1013        let actor_mesh = proc_mesh
1014            .spawn::<PythonActor, _>(instance, "test_actors", &actor_params)
1015            .await
1016            .unwrap();
1017        drop(_queue_dispatch_guard);
1018        drop(config_lock);
1019
1020        let controller = actor_mesh.controller().as_ref().unwrap().clone();
1021
1022        // Wrap using the production code path from PyProcMesh::spawn_async.
1023        let mesh_impl =
1024            async move { Ok::<_, PyErr>(Box::new(PythonActorMeshImpl::new_owned(actor_mesh))) };
1025        let python_actor_mesh = PythonActorMesh::new(
1026            async move {
1027                let mesh_impl: Box<dyn SupervisableActorMesh> = mesh_impl.await?;
1028                Ok(mesh_impl)
1029            },
1030            true,
1031        );
1032
1033        // Instance<PythonActor> required by the Supervisable trait
1034        // signature. Only used for subscription routing inside
1035        // next_supervision_event.
1036        let py_ai = Proc::direct(ChannelTransport::Unix.any(), "py_proc".to_string())
1037            .unwrap()
1038            .actor_instance::<PythonActor>("py_client")
1039            .unwrap();
1040        let py_instance = py_ai.instance;
1041
1042        // Query the subscriber count from the controller.
1043        let (port, mut rx) = mailbox::open_port::<usize>(instance);
1044        controller.post(instance, GetSubscriberCount(port.bind()));
1045        let initial_count = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1046            .await
1047            .expect("timed out waiting for subscriber count")
1048            .expect("channel closed");
1049        assert_eq!(initial_count, 0, "should have 0 subscribers initially");
1050
1051        // Call supervision_event through the PythonActorMesh multiple
1052        // times, racing against a short timeout each time.  The mesh is
1053        // healthy so no event fires; we just want to trigger the lazy
1054        // subscriber initialization repeatedly.
1055        for _ in 0..5 {
1056            tokio::select! {
1057                _ = python_actor_mesh.inner.supervision_event(&py_instance) => {
1058                    panic!("unexpected supervision event on healthy mesh");
1059                }
1060                _ = tokio::time::sleep(Duration::from_millis(200)) => {}
1061            }
1062        }
1063
1064        // After 5 calls from the same context, there should be exactly 1
1065        // subscriber (created lazily on the first call, reused thereafter).
1066        let (port, mut rx) = mailbox::open_port::<usize>(instance);
1067        controller.post(instance, GetSubscriberCount(port.bind()));
1068        let after_count = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1069            .await
1070            .expect("timed out waiting for subscriber count")
1071            .expect("channel closed");
1072        assert_eq!(
1073            after_count, 1,
1074            "subscriber count should be exactly 1, not growing with each call"
1075        );
1076
1077        // Do 5 more calls to confirm it stays stable.
1078        for _ in 0..5 {
1079            tokio::select! {
1080                _ = python_actor_mesh.inner.supervision_event(&py_instance) => {
1081                    panic!("unexpected supervision event on healthy mesh");
1082                }
1083                _ = tokio::time::sleep(Duration::from_millis(200)) => {}
1084            }
1085        }
1086
1087        let (port, mut rx) = mailbox::open_port::<usize>(instance);
1088        controller.post(instance, GetSubscriberCount(port.bind()));
1089        let final_count = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1090            .await
1091            .expect("timed out waiting for subscriber count")
1092            .expect("channel closed");
1093        assert_eq!(
1094            final_count, 1,
1095            "subscriber count should still be 1 after repeated calls"
1096        );
1097
1098        let _ = host_mesh.shutdown(instance).await;
1099    }
1100}