Skip to main content

monarch_hyperactor/
actor.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::collections::VecDeque;
11use std::error::Error;
12use std::fmt::Debug;
13use std::future::pending;
14use std::ops::Deref;
15use std::sync::Arc;
16use std::sync::Mutex;
17use std::sync::Once;
18use std::sync::OnceLock;
19use std::sync::atomic::AtomicU64;
20use std::sync::atomic::Ordering as AtomicOrdering;
21use std::time::SystemTime;
22
23use async_trait::async_trait;
24use hyperactor::Actor;
25use hyperactor::ActorHandle;
26use hyperactor::Context;
27use hyperactor::Endpoint as _;
28use hyperactor::Handler;
29use hyperactor::Instance;
30use hyperactor::OncePortHandle;
31use hyperactor::Proc;
32use hyperactor::RemoteSpawn;
33use hyperactor::actor::ActorError;
34use hyperactor::actor::ActorErrorKind;
35use hyperactor::actor::ActorStatus;
36use hyperactor::actor::Signal;
37use hyperactor::context::Actor as ContextActor;
38use hyperactor::mailbox::MessageEnvelope;
39use hyperactor::mailbox::Undeliverable;
40use hyperactor::mailbox::UndeliverableMessageError;
41use hyperactor::mailbox::UndeliverableReason;
42use hyperactor::supervision::ActorSupervisionEvent;
43use hyperactor_config::Flattrs;
44use hyperactor_mesh::ProcMeshRef;
45use hyperactor_mesh::actor_mesh::ActorMeshRef;
46use hyperactor_mesh::casting::update_undeliverable_envelope_for_casting;
47use hyperactor_mesh::comm::multicast::CAST_POINT;
48use hyperactor_mesh::comm::multicast::CastInfo;
49use hyperactor_mesh::host_mesh::HostMeshRef;
50use hyperactor_mesh::introspect::ActiveHandler;
51use hyperactor_mesh::introspect::EXECUTION;
52use hyperactor_mesh::introspect::Execution;
53use hyperactor_mesh::supervision::MeshFailure;
54use hyperactor_mesh::transport::default_bind_spec;
55use hyperactor_mesh::value_mesh::ValueOverlay;
56use monarch_types::PickledPyObject;
57use monarch_types::SerializablePyErr;
58use monarch_types::py_global;
59use ndslice::Point;
60use ndslice::extent;
61use pyo3::IntoPyObjectExt;
62use pyo3::exceptions::PyBaseException;
63use pyo3::exceptions::PyRuntimeError;
64use pyo3::exceptions::PyValueError;
65use pyo3::prelude::*;
66use pyo3::types::PyDict;
67use pyo3::types::PyList;
68use pyo3::types::PyType;
69use serde::Deserialize;
70use serde::Serialize;
71use serde_multipart::Part;
72use tokio::sync::mpsc;
73use tokio::sync::oneshot;
74use typeuri::Named;
75
76use crate::buffers::FrozenBuffer;
77use crate::config::ACTOR_QUEUE_DISPATCH;
78use crate::context::PyInstance;
79use crate::local_state_broker::BrokerId;
80use crate::local_state_broker::LocalStateBrokerMessage;
81use crate::mailbox::EitherPortRef;
82use crate::mailbox::PyMailbox;
83use crate::mailbox::PythonUndeliverableMessageEnvelope;
84use crate::metrics::ENDPOINT_ACTOR_COUNT;
85use crate::metrics::ENDPOINT_ACTOR_ERROR;
86use crate::metrics::ENDPOINT_ACTOR_LATENCY_US_HISTOGRAM;
87use crate::metrics::ENDPOINT_ACTOR_PANIC;
88use crate::pickle::PicklingState;
89use crate::pickle::pickle_to_part;
90use crate::proc::PyActorAddr;
91use crate::pympsc;
92use crate::pytokio::PyPythonTask;
93use crate::pytokio::PythonTask;
94use crate::runtime::GilSite;
95use crate::runtime::get_tokio_runtime;
96use crate::runtime::monarch_with_gil;
97use crate::runtime::monarch_with_gil_blocking;
98use crate::supervision::PyMeshFailure;
99
100py_global!(
101    unhandled_fault_hook_exception,
102    "monarch._src.actor.supervision",
103    "UnhandledFaultHookException"
104);
105
106#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
107#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
108pub enum UnflattenArg {
109    Mailbox,
110    PyObject,
111}
112
113#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
114#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
115pub enum MethodSpecifier {
116    /// Call method 'name', send its return value to the response port.
117    ReturnsResponse { name: String },
118    /// Call method 'name', send the response port as the first argument.
119    ExplicitPort { name: String },
120    /// Construct the object
121    Init {},
122}
123
124impl std::fmt::Display for MethodSpecifier {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        write!(f, "{}", self.name())
127    }
128}
129
130#[pymethods]
131impl MethodSpecifier {
132    #[getter(name)]
133    fn py_name(&self) -> &str {
134        self.name()
135    }
136}
137
138impl MethodSpecifier {
139    pub(crate) fn name(&self) -> &str {
140        match self {
141            MethodSpecifier::ReturnsResponse { name } => name,
142            MethodSpecifier::ExplicitPort { name } => name,
143            MethodSpecifier::Init {} => "__init__",
144        }
145    }
146}
147
148/// The payload of a single actor response, without rank information.
149///
150/// The rank is captured by the overlay's range key, so it is stripped
151/// from the value to enable RLE dedup: two ranks returning the same
152/// payload will have byte-identical values and can be coalesced into
153/// a single run.
154#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
155pub enum PythonResponseMessage {
156    Result {
157        part: serde_multipart::Part,
158        refs: Vec<MeshRef>,
159    },
160    Exception {
161        part: serde_multipart::Part,
162        refs: Vec<MeshRef>,
163    },
164}
165
166wirevalue::register_type!(PythonResponseMessage);
167wirevalue::register_type!(ValueOverlay<PythonResponseMessage>);
168
169impl PythonResponseMessage {
170    /// Decode this response's payload, reuniting its out-of-band `refs` table
171    /// so mesh references reconstruct. Mirrors [`PythonMessage::decode`] for the
172    /// accumulated (valuemesh / `.call()`) path.
173    pub(crate) fn decode(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
174        let (part, refs) = match self {
175            PythonResponseMessage::Result { part, refs }
176            | PythonResponseMessage::Exception { part, refs } => (part, refs),
177        };
178        let mesh_references = refs.iter().cloned().map(Some).collect();
179        let mut state = PicklingState::from_parts(part.clone(), VecDeque::new(), mesh_references);
180        state.unpickle(py)
181    }
182}
183
184/// Newtype wrapper around [`ValueOverlay<PythonResponseMessage>`] needed
185/// because `PythonMessageKind` is a `#[pyclass]` enum, requiring all variant
186/// fields to implement PyO3 traits. `ValueOverlay` is defined in another crate
187/// and does not implement `PyClass`.
188#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
189#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
190pub struct AccumulatedResponses(ValueOverlay<PythonResponseMessage>);
191
192#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
193#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq)]
194pub enum PythonMessageKind {
195    CallMethod {
196        name: MethodSpecifier,
197        response_port: Option<EitherPortRef>,
198    },
199    Result {
200        rank: Option<usize>,
201    },
202    Exception {
203        rank: Option<usize>,
204    },
205    Uninit {},
206    CallMethodIndirect {
207        name: MethodSpecifier,
208        local_state_broker: (String, usize),
209        id: usize,
210        // specify whether the argument to unflatten the local mailbox,
211        // or the next argument of the local state.
212        unflatten_args: Vec<UnflattenArg>,
213    },
214    AccumulatedResponses(AccumulatedResponses),
215}
216wirevalue::register_type!(PythonMessageKind);
217
218impl Default for PythonMessageKind {
219    fn default() -> Self {
220        PythonMessageKind::Uninit {}
221    }
222}
223
224fn mailbox<'py, T: Actor>(py: Python<'py>, cx: &Context<'_, T>) -> Bound<'py, PyAny> {
225    let mailbox: PyMailbox = cx.mailbox_for_py().clone().into();
226    mailbox.into_bound_py_any(py).unwrap()
227}
228
229/// A serializable reference to a mesh (actor, proc, or host).
230///
231/// Serialized as a typed multipart part via [`MeshRefRepr`]: under the multipart
232/// serializer each `MeshRef` becomes its own typed part, and inline bincode
233/// elsewhere.
234#[derive(Clone, Debug, Named, PartialEq, Eq)]
235pub enum MeshRef {
236    Actor(Box<ActorMeshRef<PythonActor>>),
237    Proc(Box<ProcMeshRef>),
238    Host(Box<HostMeshRef>),
239}
240
241/// Wire representation of [`MeshRef`] stored in a typed multipart part.
242#[doc(hidden)]
243#[derive(Clone, Debug, Serialize, Deserialize, Named)]
244pub enum MeshRefRepr {
245    Actor(Box<ActorMeshRef<PythonActor>>),
246    Proc(Box<ProcMeshRef>),
247    Host(Box<HostMeshRef>),
248}
249
250impl TryFrom<&MeshRef> for MeshRefRepr {
251    type Error = serde_multipart::Error;
252    fn try_from(m: &MeshRef) -> serde_multipart::Result<Self> {
253        Ok(match m {
254            MeshRef::Actor(r) => MeshRefRepr::Actor(r.clone()),
255            MeshRef::Proc(r) => MeshRefRepr::Proc(r.clone()),
256            MeshRef::Host(r) => MeshRefRepr::Host(r.clone()),
257        })
258    }
259}
260
261impl TryFrom<MeshRefRepr> for MeshRef {
262    type Error = serde_multipart::Error;
263    fn try_from(r: MeshRefRepr) -> serde_multipart::Result<Self> {
264        Ok(match r {
265            MeshRefRepr::Actor(r) => MeshRef::Actor(r),
266            MeshRefRepr::Proc(r) => MeshRef::Proc(r),
267            MeshRefRepr::Host(r) => MeshRef::Host(r),
268        })
269    }
270}
271
272serde_multipart::part_codec! {
273    impl MeshRef
274    {
275        type Repr = MeshRefRepr;
276    }
277}
278
279impl MeshRef {
280    /// Reconstruct the Python mesh wrapper this reference points at.
281    ///
282    /// Mirrors the `py_*_from_bytes` reconstructors, but takes an
283    /// already-deserialized [`MeshRef`] from the message's `refs` table.
284    pub(crate) fn reconstruct(self, py: Python<'_>) -> PyResult<Py<PyAny>> {
285        match self {
286            MeshRef::Proc(r) => {
287                Ok(Py::new(py, crate::proc_mesh::PyProcMesh::new_ref(*r))?.into_any())
288            }
289            MeshRef::Host(r) => {
290                Ok(Py::new(py, crate::host_mesh::PyHostMesh::new_ref(*r))?.into_any())
291            }
292            MeshRef::Actor(r) => {
293                let inner = crate::actor_mesh::PythonActorMeshImpl::new_ref(*r);
294                let async_mesh = crate::actor_mesh::AsyncActorMesh::from_impl(Arc::new(inner));
295                let mesh = crate::actor_mesh::PythonActorMesh::from_impl(Arc::from(async_mesh));
296                Ok(Py::new(py, mesh)?.into_any())
297            }
298        }
299    }
300}
301
302/// An opaque carrier so a `MeshRef` can ride in `PythonMessage.refs` across
303/// the Python boundary (the message getter out, the `PicklingState` ctor in).
304#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
305pub struct PyMeshRef {
306    pub(crate) inner: MeshRef,
307}
308
309impl<'py> IntoPyObject<'py> for MeshRef {
310    type Target = PyMeshRef;
311    type Output = Bound<'py, PyMeshRef>;
312    type Error = PyErr;
313
314    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
315        Bound::new(py, PyMeshRef { inner: self })
316    }
317}
318
319/// Extract the serializable [`MeshRef`] from a resolved mesh wrapper (the
320/// inverse of [`MeshRef::reconstruct`]), for the sender-side pending fill.
321pub(crate) fn mesh_ref_from_pyobject(value: &Bound<'_, PyAny>) -> PyResult<MeshRef> {
322    if let Ok(m) = value.downcast::<crate::proc_mesh::PyProcMesh>() {
323        return Ok(MeshRef::Proc(Box::new(m.borrow().mesh_ref()?)));
324    }
325    if let Ok(m) = value.downcast::<crate::host_mesh::PyHostMesh>() {
326        return Ok(MeshRef::Host(Box::new(m.borrow().mesh_ref().map_err(
327            |e| pyo3::exceptions::PyValueError::new_err(e.to_string()),
328        )?)));
329    }
330    if let Ok(m) = value.downcast::<crate::actor_mesh::PythonActorMesh>() {
331        return Ok(MeshRef::Actor(Box::new(m.borrow().get_inner().mesh_ref()?)));
332    }
333    Err(pyo3::exceptions::PyRuntimeError::new_err(
334        "pending pickle did not resolve to a mesh reference",
335    ))
336}
337
338#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
339#[derive(Clone, Serialize, Deserialize, Named, Default, PartialEq)]
340pub struct PythonMessage {
341    pub kind: PythonMessageKind,
342    pub message: Part,
343    /// Mesh references carried out-of-band from the pickled `message`.
344    pub refs: Vec<MeshRef>,
345}
346
347/// Extract the endpoint method name from a [`PythonMessage`].
348fn python_message_endpoint_name(msg: &PythonMessage) -> Option<String> {
349    match &msg.kind {
350        PythonMessageKind::CallMethod { name, .. }
351        | PythonMessageKind::CallMethodIndirect { name, .. } => Some(name.name().to_string()),
352        _ => None,
353    }
354}
355
356// We use manual `submit!` instead of `register_type!` because PythonMessage is a
357// struct, so the default `endpoint_name` (which delegates to `arm_unchecked`)
358// always returns None. The custom implementation inspects `PythonMessageKind` to
359// extract the method name. This registration handles direct (non-cast) dispatch.
360wirevalue::submit! {
361    wirevalue::TypeInfo {
362        typename: <PythonMessage as wirevalue::Named>::typename,
363        typehash: <PythonMessage as wirevalue::Named>::typehash,
364        typeid: <PythonMessage as wirevalue::Named>::typeid,
365        port: <PythonMessage as wirevalue::Named>::port,
366        dump: Some(<PythonMessage as wirevalue::NamedDumpable>::dump),
367        arm_unchecked: <PythonMessage as wirevalue::Named>::arm_unchecked,
368        endpoint_name: |ptr| {
369            // SAFETY: ptr points to a PythonMessage.
370            let msg = unsafe { &*(ptr as *const PythonMessage) };
371            python_message_endpoint_name(msg)
372        },
373    }
374}
375
376impl From<ValueOverlay<PythonResponseMessage>> for PythonMessage {
377    fn from(overlay: ValueOverlay<PythonResponseMessage>) -> Self {
378        PythonMessage {
379            kind: PythonMessageKind::AccumulatedResponses(AccumulatedResponses(overlay)),
380            message: Default::default(),
381            refs: Vec::new(),
382        }
383    }
384}
385
386impl PythonMessage {
387    /// Consume this message and extract a `ValueOverlay<PythonResponseMessage>`.
388    ///
389    /// Handles both already-collected responses and leaf `Result`/`Exception`
390    /// messages by wrapping them in a single-run overlay.
391    pub fn into_overlay(self) -> anyhow::Result<ValueOverlay<PythonResponseMessage>> {
392        match self.kind {
393            PythonMessageKind::AccumulatedResponses(overlay) => Ok(overlay.0),
394            PythonMessageKind::Result { rank, .. } => {
395                let rank = rank.expect("accumulated response should have a rank");
396                let mut overlay = ValueOverlay::new();
397                overlay.push_run(
398                    rank..rank + 1,
399                    PythonResponseMessage::Result {
400                        part: self.message,
401                        refs: self.refs,
402                    },
403                )?;
404                Ok(overlay)
405            }
406            PythonMessageKind::Exception { rank, .. } => {
407                let rank = rank.expect("accumulated exception should have a rank");
408                let mut overlay = ValueOverlay::new();
409                overlay.push_run(
410                    rank..rank + 1,
411                    PythonResponseMessage::Exception {
412                        part: self.message,
413                        refs: self.refs,
414                    },
415                )?;
416                Ok(overlay)
417            }
418            other => {
419                anyhow::bail!(
420                    "unexpected message kind {:?} in collected responses reducer",
421                    other
422                );
423            }
424        }
425    }
426}
427
428struct ResolvedCallMethod {
429    method: MethodSpecifier,
430    bytes: FrozenBuffer,
431    local_state: Option<Py<PyAny>>,
432    mesh_references: Vec<MeshRef>,
433    /// Implements PortProtocol
434    /// Concretely either a Port, DroppingPort, or LocalPort
435    response_port: ResponsePort,
436}
437
438enum ResponsePort {
439    Dropping,
440    Port(Port),
441    Local(LocalPort),
442}
443
444impl ResponsePort {
445    fn into_py_any(self, py: Python<'_>) -> PyResult<Py<PyAny>> {
446        match self {
447            ResponsePort::Dropping => DroppingPort.into_py_any(py),
448            ResponsePort::Port(port) => port.into_py_any(py),
449            ResponsePort::Local(port) => port.into_py_any(py),
450        }
451    }
452}
453
454/// Message sent through the queue in queue-dispatch mode.
455/// Contains pre-resolved components ready for Python consumption.
456#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
457pub struct QueuedMessage {
458    #[pyo3(get)]
459    pub context: Py<crate::context::PyContext>,
460    #[pyo3(get)]
461    pub method: MethodSpecifier,
462    #[pyo3(get)]
463    pub bytes: FrozenBuffer,
464    #[pyo3(get)]
465    pub local_state: Py<PyAny>,
466    #[pyo3(get)]
467    pub refs: Py<PyAny>,
468    #[pyo3(get)]
469    pub response_port: Py<PyAny>,
470}
471
472impl PythonMessage {
473    pub fn new_from_buf(kind: PythonMessageKind, message: impl Into<Part>) -> Self {
474        Self::new_from_buf_with_refs(kind, message, Vec::new())
475    }
476
477    pub fn new_from_buf_with_refs(
478        kind: PythonMessageKind,
479        message: impl Into<Part>,
480        refs: Vec<MeshRef>,
481    ) -> Self {
482        Self {
483            kind,
484            message: message.into(),
485            refs,
486        }
487    }
488
489    pub fn into_rank(self, rank: usize) -> Self {
490        let rank = Some(rank);
491        match self.kind {
492            PythonMessageKind::Result { .. } => PythonMessage {
493                kind: PythonMessageKind::Result { rank },
494                message: self.message,
495                refs: self.refs,
496            },
497            PythonMessageKind::Exception { .. } => PythonMessage {
498                kind: PythonMessageKind::Exception { rank },
499                message: self.message,
500                refs: self.refs,
501            },
502            _ => panic!("PythonMessage is not a response but {:?}", self),
503        }
504    }
505    async fn resolve_indirect_call(
506        self,
507        cx: &Context<'_, PythonActor>,
508    ) -> anyhow::Result<ResolvedCallMethod> {
509        match self.kind {
510            PythonMessageKind::CallMethodIndirect {
511                name,
512                local_state_broker,
513                id,
514                unflatten_args,
515            } => {
516                let broker = BrokerId::new(local_state_broker).resolve(cx).await;
517                let (send, recv) = cx.open_once_port();
518                broker.post(cx, LocalStateBrokerMessage::Get(id, send));
519                let state = recv.recv().await?;
520                let mut state_it = state.state.into_iter();
521                monarch_with_gil(GilSite::EndpointDispatch, |py| {
522                    let mailbox = mailbox(py, cx);
523                    let local_state = Some(
524                        PyList::new(
525                            py,
526                            unflatten_args.into_iter().map(|x| -> Bound<'_, PyAny> {
527                                match x {
528                                    UnflattenArg::Mailbox => mailbox.clone(),
529                                    UnflattenArg::PyObject => {
530                                        state_it.next().unwrap().into_bound(py)
531                                    }
532                                }
533                            }),
534                        )
535                        .unwrap()
536                        .into(),
537                    );
538                    let response_port = ResponsePort::Local(LocalPort {
539                        instance: cx.into(),
540                        inner: Some(state.response_port),
541                    });
542                    Ok(ResolvedCallMethod {
543                        method: name,
544                        bytes: FrozenBuffer {
545                            inner: self.message.into_bytes(),
546                        },
547                        local_state,
548                        mesh_references: self.refs,
549                        response_port,
550                    })
551                })
552                .await
553            }
554            PythonMessageKind::CallMethod {
555                name,
556                response_port,
557            } => {
558                let method_name = name.name().to_string();
559                let response_port = response_port.map_or(ResponsePort::Dropping, |port_ref| {
560                    let point = cx.cast_point();
561                    // Carry operation context onto the reply: copy
562                    // OPERATION_*-marked keys from the inbound
563                    // envelope, falling back to the method name when
564                    // the caller didn't stamp.
565                    let mut reply_headers = hyperactor_config::Flattrs::new();
566                    hyperactor_config::attrs::copy_marked_flattrs(
567                        &mut reply_headers,
568                        cx.headers(),
569                        hyperactor_config::attrs::OPERATION_CONTEXT_HEADER,
570                    );
571                    if reply_headers
572                        .get(hyperactor::mailbox::headers::OPERATION_ENDPOINT)
573                        .is_none()
574                    {
575                        reply_headers.set(
576                            hyperactor::mailbox::headers::OPERATION_ENDPOINT,
577                            format!("{}()", method_name),
578                        );
579                    }
580                    ResponsePort::Port(Port::with_reply_headers(
581                        port_ref,
582                        cx.instance().clone_for_py(),
583                        Some(point.rank()),
584                        reply_headers,
585                    ))
586                });
587                Ok(ResolvedCallMethod {
588                    method: name,
589                    bytes: FrozenBuffer {
590                        inner: self.message.into_bytes(),
591                    },
592                    local_state: None,
593                    mesh_references: self.refs,
594                    response_port,
595                })
596            }
597            _ => {
598                panic!("unexpected message kind {:?}", self.kind)
599            }
600        }
601    }
602}
603
604impl std::fmt::Debug for PythonMessage {
605    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        f.debug_struct("PythonMessage")
607            .field("kind", &self.kind)
608            .field(
609                "message",
610                &wirevalue::HexFmt(&(*self.message.to_bytes())[..]).to_string(),
611            )
612            .field("refs", &self.refs.len())
613            .finish()
614    }
615}
616
617#[pymethods]
618impl PythonMessage {
619    #[new]
620    #[pyo3(signature = (kind, message, refs))]
621    pub fn new(
622        kind: PythonMessageKind,
623        message: PyRef<'_, FrozenBuffer>,
624        refs: &Bound<'_, PyList>,
625    ) -> PyResult<Self> {
626        let mesh_refs: Vec<MeshRef> = refs
627            .iter()
628            .map(|item| Ok(item.downcast::<PyMeshRef>()?.borrow().inner.clone()))
629            .collect::<PyResult<_>>()?;
630        Ok(PythonMessage::new_from_buf_with_refs(
631            kind,
632            message.inner.clone(),
633            mesh_refs,
634        ))
635    }
636
637    #[getter]
638    fn kind(&self) -> PythonMessageKind {
639        self.kind.clone()
640    }
641
642    /// Decode this message's payload, reuniting the out-of-band `refs` table so
643    /// the `pop_mesh_reference` sentinels in the pickle stream resolve. The raw
644    /// bytes are deliberately not exposed: a payload can only be read back
645    /// through here, so a decode can never silently drop mesh references.
646    #[pyo3(signature = (local_state=None))]
647    fn decode(
648        &self,
649        py: Python<'_>,
650        local_state: Option<&Bound<'_, PyList>>,
651    ) -> PyResult<Py<PyAny>> {
652        let tensor_engine_references: VecDeque<Py<PyAny>> = local_state
653            .map(|list| list.iter().map(|item| item.unbind()).collect())
654            .unwrap_or_default();
655        let mesh_references: VecDeque<Option<MeshRef>> =
656            self.refs.iter().cloned().map(Some).collect();
657        let mut state = PicklingState::from_parts(
658            self.message.clone(),
659            tensor_engine_references,
660            mesh_references,
661        );
662        state.unpickle(py)
663    }
664
665    #[getter]
666    fn refs(&self) -> Vec<MeshRef> {
667        self.refs.clone()
668    }
669}
670
671#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
672pub(super) struct PythonActorHandle {
673    pub(super) inner: ActorHandle<PythonActor>,
674}
675
676#[pymethods]
677impl PythonActorHandle {
678    // TODO: do the pickling in rust
679    fn send(&self, instance: &PyInstance, message: &PythonMessage) -> PyResult<()> {
680        self.inner.post(instance.deref(), message.clone());
681        Ok(())
682    }
683
684    fn bind(&self) -> PyActorAddr {
685        self.inner.bind::<PythonActor>().into_actor_addr().into()
686    }
687}
688
689/// Dispatch mode for Python actors.
690#[derive(Debug)]
691pub enum PythonActorDispatchMode {
692    /// Direct dispatch: Rust acquires the GIL and calls Python handlers directly.
693    Direct,
694    /// Queue dispatch: Rust enqueues messages to a channel; Python dequeues and dispatches.
695    Queue {
696        /// Channel sender for enqueuing messages to Python.
697        sender: pympsc::Sender,
698        /// Channel receiver, taken during Actor::init to start the message loop.
699        receiver: Option<pympsc::PyReceiver>,
700    },
701}
702
703// In-flight handler execution tracking for a Python actor -- the producer
704// side of the mesh `execution` field. Per-actor Rust state, read GIL-free
705// by the introspect seam. Producer invariants (PE-*, monarch_hyperactor-
706// local; documented inline, no registry -- not our crate):
707//   PE-2: only the real user-method invocation is bracketed (in
708//         `_Actor.handle`), never Init/unpickling/plumbing.
709//   PE-3: the snapshot reads `Arc` state only (atomic load + `try_lock`);
710//         the `Mutex` is held only for an insert/remove, never across user
711//         code, so a handler wedged holding the GIL never blocks the
712//         snapshot and the read never touches the GIL.
713//   PE-4: tokens are >= 1; `0` is a reserved no-op sentinel (returned by
714//         the binding when an instance has no tracker), so the
715//         unconditional Python `finally` cannot collide it with a real
716//         token.
717
718/// EX-4 cap: at most this many distinct in-flight handler names are
719/// reported per snapshot; `truncated` is set when exceeded.
720const MAX_ACTIVE_HANDLERS: usize = 64;
721
722/// One in-flight handler invocation.
723#[derive(Debug)]
724struct ActiveEntry {
725    name: String,
726    started_at: SystemTime,
727}
728
729/// Per-actor in-flight handler tracker. Cheap to read concurrently: the
730/// count is a lock-free atomic and the per-handler detail sits behind a
731/// `try_lock` held only for an insert/remove (never across user code), so
732/// a wedged actor stays introspectable (PE-3).
733#[derive(Debug)]
734pub(crate) struct ExecutionTracker {
735    /// Lock-free count of in-flight invocations; always readable.
736    active_count: AtomicU64,
737    /// Monotonic token source, initialized to 1 so issued tokens are
738    /// `>= 1` and `0` stays reserved as the no-op sentinel (PE-4).
739    next_token: AtomicU64,
740    /// token -> entry for the in-flight invocations.
741    handlers: Mutex<HashMap<u64, ActiveEntry>>,
742}
743
744/// Aggregate raw in-flight entries into the reported per-handler view:
745/// grouped by handler name, oldest-first with a stable tie-break on
746/// `name`, capped at `max` (EX-4). Pure, so it can be unit-tested with
747/// explicit timestamps.
748fn aggregate_active(
749    handlers: &HashMap<u64, ActiveEntry>,
750    max: usize,
751) -> (Vec<ActiveHandler>, bool) {
752    let mut by_name: HashMap<&str, (u64, SystemTime)> = HashMap::new();
753    for entry in handlers.values() {
754        let slot = by_name
755            .entry(entry.name.as_str())
756            .or_insert((0, entry.started_at));
757        slot.0 += 1;
758        if entry.started_at < slot.1 {
759            slot.1 = entry.started_at;
760        }
761    }
762    let mut out: Vec<ActiveHandler> = by_name
763        .into_iter()
764        .map(|(name, (active_count, oldest_since))| ActiveHandler {
765            name: name.to_string(),
766            active_count,
767            oldest_since,
768        })
769        .collect();
770    // EX-4: oldest-first, stable tie-break on name.
771    out.sort_by(|a, b| {
772        a.oldest_since
773            .cmp(&b.oldest_since)
774            .then_with(|| a.name.cmp(&b.name))
775    });
776    let truncated = out.len() > max;
777    if truncated {
778        out.truncate(max);
779    }
780    (out, truncated)
781}
782
783impl ExecutionTracker {
784    pub(crate) fn new() -> Self {
785        Self {
786            active_count: AtomicU64::new(0),
787            next_token: AtomicU64::new(1),
788            handlers: Mutex::new(HashMap::new()),
789        }
790    }
791
792    /// Record the start of a handler invocation; returns its token.
793    pub(crate) fn start(&self, name: String) -> u64 {
794        let token = self.next_token.fetch_add(1, AtomicOrdering::Relaxed);
795        self.handlers
796            .lock()
797            .unwrap_or_else(|e| e.into_inner())
798            .insert(
799                token,
800                ActiveEntry {
801                    name,
802                    started_at: SystemTime::now(),
803                },
804            );
805        self.active_count.fetch_add(1, AtomicOrdering::Relaxed);
806        token
807    }
808
809    /// Record the end of a handler invocation. Idempotent (a token is
810    /// removed at most once) and a no-op for the `0` sentinel (PE-4).
811    pub(crate) fn finish(&self, token: u64) {
812        if token == 0 {
813            return;
814        }
815        let removed = self
816            .handlers
817            .lock()
818            .unwrap_or_else(|e| e.into_inner())
819            .remove(&token)
820            .is_some();
821        if removed {
822            self.active_count.fetch_sub(1, AtomicOrdering::Relaxed);
823        }
824    }
825
826    /// Point-in-time snapshot for the introspect seam. Never blocks: the
827    /// count is read lock-free and the per-handler detail is best-effort
828    /// behind `try_lock` (EX-2: a miss yields `complete: false`, it never
829    /// drops the field).
830    pub(crate) fn snapshot(&self) -> Execution {
831        let active_count = self.active_count.load(AtomicOrdering::Relaxed);
832        match self.handlers.try_lock() {
833            Ok(guard) => {
834                let (active_handlers, truncated) = aggregate_active(&guard, MAX_ACTIVE_HANDLERS);
835                Execution {
836                    active_count,
837                    active_handlers,
838                    complete: true,
839                    truncated,
840                }
841            }
842            Err(_) => Execution {
843                active_count,
844                active_handlers: Vec::new(),
845                complete: false,
846                truncated: false,
847            },
848        }
849    }
850}
851
852#[cfg(test)]
853mod execution_tracker_tests {
854    use std::time::Duration;
855    use std::time::UNIX_EPOCH;
856
857    use super::*;
858
859    fn at(secs: u64) -> SystemTime {
860        UNIX_EPOCH + Duration::from_secs(secs)
861    }
862
863    #[test]
864    fn aggregates_by_name_oldest_first() {
865        let mut h = HashMap::new();
866        h.insert(
867            1,
868            ActiveEntry {
869                name: "b".to_string(),
870                started_at: at(10),
871            },
872        );
873        h.insert(
874            2,
875            ActiveEntry {
876                name: "a".to_string(),
877                started_at: at(20),
878            },
879        );
880        h.insert(
881            3,
882            ActiveEntry {
883                name: "a".to_string(),
884                started_at: at(30),
885            },
886        );
887        let (out, truncated) = aggregate_active(&h, MAX_ACTIVE_HANDLERS);
888        assert!(!truncated);
889        assert_eq!(out.len(), 2);
890        // Oldest-first: b (10) before a (20).
891        assert_eq!(out[0].name, "b");
892        assert_eq!(out[0].active_count, 1);
893        assert_eq!(out[0].oldest_since, at(10));
894        // "a" aggregates two invocations; oldest_since is the min (20).
895        assert_eq!(out[1].name, "a");
896        assert_eq!(out[1].active_count, 2);
897        assert_eq!(out[1].oldest_since, at(20));
898    }
899
900    #[test]
901    fn tie_break_on_name_when_same_oldest() {
902        let mut h = HashMap::new();
903        h.insert(
904            1,
905            ActiveEntry {
906                name: "zebra".to_string(),
907                started_at: at(5),
908            },
909        );
910        h.insert(
911            2,
912            ActiveEntry {
913                name: "alpha".to_string(),
914                started_at: at(5),
915            },
916        );
917        let (out, _) = aggregate_active(&h, MAX_ACTIVE_HANDLERS);
918        assert_eq!(out[0].name, "alpha");
919        assert_eq!(out[1].name, "zebra");
920    }
921
922    #[test]
923    fn truncates_to_n_oldest() {
924        let mut h = HashMap::new();
925        for i in 0..(MAX_ACTIVE_HANDLERS as u64 + 6) {
926            h.insert(
927                i,
928                ActiveEntry {
929                    name: format!("h{:03}", i),
930                    started_at: at(i),
931                },
932            );
933        }
934        let (out, truncated) = aggregate_active(&h, MAX_ACTIVE_HANDLERS);
935        assert!(truncated);
936        assert_eq!(out.len(), MAX_ACTIVE_HANDLERS);
937        // Prefix of the N oldest.
938        assert_eq!(out[0].name, "h000");
939        assert_eq!(
940            out[MAX_ACTIVE_HANDLERS - 1].name,
941            format!("h{:03}", MAX_ACTIVE_HANDLERS - 1)
942        );
943    }
944
945    #[test]
946    fn start_assigns_nonzero_distinct_tokens() {
947        let t = ExecutionTracker::new();
948        let a = t.start("a".to_string());
949        let b = t.start("b".to_string());
950        assert!(a >= 1);
951        assert!(b >= 1);
952        assert_ne!(a, b);
953        let snap = t.snapshot();
954        assert_eq!(snap.active_count, 2);
955        assert!(snap.complete);
956        assert_eq!(snap.active_handlers.len(), 2);
957    }
958
959    #[test]
960    fn finish_is_idempotent_and_zero_is_noop() {
961        let t = ExecutionTracker::new();
962        let tok = t.start("a".to_string());
963        t.finish(tok);
964        assert_eq!(t.snapshot().active_count, 0);
965        // Double-finish must not underflow the count.
966        t.finish(tok);
967        assert_eq!(t.snapshot().active_count, 0);
968        // The 0 sentinel is a no-op.
969        t.finish(0);
970        assert_eq!(t.snapshot().active_count, 0);
971    }
972}
973
974/// An actor for which message handlers are implemented in Python.
975#[derive(Debug)]
976#[hyperactor::export(
977    handlers = [
978        PythonMessage,
979        MeshFailure,
980    ],
981)]
982#[hyperactor::spawnable]
983pub struct PythonActor {
984    /// The Python object that we delegate message handling to.
985    actor: Py<PyAny>,
986    /// Stores a reference to the Python event loop to run Python coroutines on.
987    task_locals: pyo3_async_runtimes::TaskLocals,
988    /// Instance object that we keep across handle calls so that we can store
989    /// information from the Init (spawn rank, controller) and provide it to other calls.
990    instance: Option<Py<crate::context::PyInstance>>,
991    /// Dispatch mode for this actor.
992    dispatch_mode: PythonActorDispatchMode,
993    /// The location in the actor mesh at which this actor was spawned.
994    spawn_point: OnceLock<Option<Point>>,
995    /// Initial message to process during PythonActor::init.
996    init_message: Option<PythonMessage>,
997    /// User-provided mesh base-name string plumbed from
998    /// `PythonActorParams`. This is the base name the caller
999    /// supplied when the mesh was spawned, narrowly used to populate
1000    /// `MeshFailure.actor_mesh_name` on the direct actor-handled
1001    /// supervision path without a lookup. It is not actor display
1002    /// text (`display_name` handles that) and it is not a general
1003    /// side channel; downstream code must not consume this field for
1004    /// any other purpose.
1005    mesh_base_name: Option<String>,
1006
1007    /// Per-actor in-flight handler tracker (producer of the mesh
1008    /// `execution` field). Read GIL-free by the introspect seam; a clone
1009    /// of this `Arc` is injected into the actor's `PyInstance` so
1010    /// `_Actor.handle` can bracket each invocation.
1011    execution_tracker: Arc<ExecutionTracker>,
1012}
1013
1014impl PythonActor {
1015    pub(crate) fn new(
1016        actor_type: PickledPyObject,
1017        init_message: Option<PythonMessage>,
1018        spawn_point: Option<Point>,
1019        mesh_base_name: Option<String>,
1020    ) -> Result<Self, anyhow::Error> {
1021        let use_queue_dispatch = hyperactor_config::global::get(ACTOR_QUEUE_DISPATCH);
1022        if !use_queue_dispatch {
1023            static WARNED: Once = Once::new();
1024            WARNED.call_once(|| {
1025                tracing::warn!(
1026                    "actor_queue_dispatch=false is deprecated and direct dispatch will be removed in a future release"
1027                );
1028            });
1029        }
1030
1031        Ok(monarch_with_gil_blocking(
1032            GilSite::ActorConstruct,
1033            |py| -> Result<Self, SerializablePyErr> {
1034                let unpickled = actor_type.unpickle(py)?;
1035                let class_type: &Bound<'_, PyType> = unpickled.downcast()?;
1036                let actor: Py<PyAny> = class_type.call0()?.into_py_any(py)?;
1037
1038                let task_locals = Python::detach(py, create_task_locals);
1039
1040                let dispatch_mode = if use_queue_dispatch {
1041                    let (sender, receiver) = pympsc::channel().map_err(|e| {
1042                        let py_err = PyRuntimeError::new_err(e.to_string());
1043                        SerializablePyErr::from(py, &py_err)
1044                    })?;
1045                    PythonActorDispatchMode::Queue {
1046                        sender,
1047                        receiver: Some(receiver),
1048                    }
1049                } else {
1050                    PythonActorDispatchMode::Direct
1051                };
1052
1053                Ok(Self {
1054                    actor,
1055                    task_locals,
1056                    instance: None,
1057                    dispatch_mode,
1058                    spawn_point: OnceLock::from(spawn_point),
1059                    init_message,
1060                    mesh_base_name,
1061                    execution_tracker: Arc::new(ExecutionTracker::new()),
1062                })
1063            },
1064        )?)
1065    }
1066
1067    fn cancel_tasks_and_stop_python_loop(
1068        py: Python<'_>,
1069        task_locals: &pyo3_async_runtimes::TaskLocals,
1070    ) -> PyResult<()> {
1071        let asyncio = py.import("asyncio")?;
1072        let event_loop = task_locals.event_loop(py);
1073        let tasks = asyncio.call_method1("all_tasks", (&event_loop,))?;
1074        let mut has_tasks = false;
1075        for task in tasks.try_iter()? {
1076            let task = task?;
1077            let cancel = task.getattr("cancel")?;
1078            event_loop.call_method1("call_soon_threadsafe", (cancel,))?;
1079            has_tasks = true;
1080        }
1081        if has_tasks {
1082            asyncio
1083                .call_method1(
1084                    "run_coroutine_threadsafe",
1085                    (asyncio.call_method1("sleep", (0,))?, &event_loop),
1086                )?
1087                .call_method0("result")?;
1088        }
1089        let stop = event_loop.getattr("stop")?;
1090        event_loop.call_method1("call_soon_threadsafe", (stop,))?;
1091        Ok(())
1092    }
1093
1094    fn cancel_pending_python_tasks_and_stop_loop(&self) -> anyhow::Result<()> {
1095        let task_locals = &self.task_locals;
1096        monarch_with_gil_blocking(GilSite::Stop, |py| -> anyhow::Result<()> {
1097            Self::cancel_tasks_and_stop_python_loop(py, task_locals)
1098                .map_err(|err| anyhow::Error::from(SerializablePyErr::from(py, &err)))?;
1099            Ok(())
1100        })
1101    }
1102
1103    /// Get-or-create the actor's cached `PyInstance`, injecting a clone of
1104    /// the execution tracker (PE-1) so `_Actor.handle` can bracket each
1105    /// invocation. All four `self.instance` creation sites route through
1106    /// this so the tracker is never silently absent -- notably the
1107    /// supervision path, which can run before the first endpoint.
1108    fn ensure_py_instance(
1109        &mut self,
1110        py: Python<'_>,
1111        src: impl Into<crate::context::PyInstance>,
1112    ) -> Py<crate::context::PyInstance> {
1113        let tracker = self.execution_tracker.clone();
1114        self.instance
1115            .get_or_insert_with(|| {
1116                let mut inst: crate::context::PyInstance = src.into();
1117                inst.set_execution_tracker(tracker);
1118                inst.into_pyobject(py).unwrap().into()
1119            })
1120            .clone_ref(py)
1121    }
1122
1123    /// Bootstrap the root client actor, creating a new proc for it.
1124    /// This is the legacy entry point that creates its own proc.
1125    pub(crate) fn bootstrap_client(py: Python<'_>) -> (&'static Instance<Self>, ActorHandle<Self>) {
1126        static ROOT_CLIENT_INSTANCE: OnceLock<Instance<PythonActor>> = OnceLock::new();
1127
1128        let client_proc = Proc::direct(
1129            default_bind_spec().binding_addr(),
1130            "mesh_root_client_proc".into(),
1131        )
1132        .unwrap();
1133
1134        Self::bootstrap_client_inner(py, client_proc, &ROOT_CLIENT_INSTANCE)
1135    }
1136
1137    /// Bootstrap the client proc, storing the root client instance in given static.
1138    /// This is passed in because we require storage, as the instance is shared.
1139    /// This can be simplified when we remove v0.
1140    pub(crate) fn bootstrap_client_inner(
1141        py: Python<'_>,
1142        client_proc: Proc,
1143        root_client_instance: &'static OnceLock<Instance<PythonActor>>,
1144    ) -> (&'static Instance<Self>, ActorHandle<Self>) {
1145        let actor_mesh_mod = py
1146            .import("monarch._src.actor.actor_mesh")
1147            .expect("import actor_mesh");
1148        let root_client_class = actor_mesh_mod
1149            .getattr("RootClientActor")
1150            .expect("get RootClientActor");
1151
1152        let actor_type =
1153            PickledPyObject::pickle(&actor_mesh_mod.getattr("_Actor").expect("get _Actor"))
1154                .expect("pickle _Actor");
1155
1156        let init_frozen_buffer: FrozenBuffer = root_client_class
1157            .call_method0("_pickled_init_args")
1158            .expect("call RootClientActor._pickled_init_args")
1159            .extract()
1160            .expect("extract FrozenBuffer from _pickled_init_args");
1161        let init_message = PythonMessage::new_from_buf(
1162            PythonMessageKind::CallMethod {
1163                name: MethodSpecifier::Init {},
1164                response_port: None,
1165            },
1166            init_frozen_buffer,
1167        );
1168
1169        let mut actor = PythonActor::new(
1170            actor_type,
1171            Some(init_message),
1172            Some(extent!().point_of_rank(0).unwrap()),
1173            None, // root client actor has no user-facing mesh name
1174        )
1175        .expect("create client PythonActor");
1176
1177        let ai = client_proc
1178            .actor_instance(
1179                root_client_class
1180                    .getattr("name")
1181                    .expect("get RootClientActor.name")
1182                    .extract()
1183                    .expect("extract RootClientActor.name"),
1184            )
1185            .expect("root instance create");
1186
1187        let handle = ai.handle;
1188        let signal_rx = ai.signal;
1189        let supervision_rx = ai.supervision;
1190        let work_rx = ai.work;
1191
1192        root_client_instance
1193            .set(ai.instance)
1194            .map_err(|_| "already initialized root client instance")
1195            .unwrap();
1196        let instance = root_client_instance.get().unwrap();
1197
1198        // The root client PythonActor uses a custom run loop that
1199        // bypasses Actor::init, so mark it as system explicitly
1200        // (matching GlobalClientActor::fresh_instance).
1201        instance.set_system();
1202
1203        // Bind to ensure the Undeliverable<MessageEnvelope> port is bound.
1204        let _client_ref = handle.bind::<PythonActor>();
1205
1206        get_tokio_runtime().spawn(async move {
1207            // This is gross. Sorry.
1208            actor.init(instance).await.unwrap();
1209
1210            let mut signal_rx = signal_rx;
1211            let mut supervision_rx = supervision_rx;
1212            let mut work_rx = work_rx;
1213            let mut need_drain = false;
1214            let mut err = 'messages: loop {
1215                tokio::select! {
1216                    work = work_rx.recv() => {
1217                        let work = work.expect("inconsistent work queue state");
1218                        if let Err(err) = work.handle(&mut actor, instance).await {
1219                            // Check for UnhandledFaultHookException on the raw
1220                            // anyhow::Error before wrapping in ActorErrorKind.
1221                            // If __supervise__ already processed the supervision
1222                            // event and the hook raised, don't re-handle it via
1223                            // handle_supervision_event — that would call
1224                            // __supervise__ a second time.
1225                            let is_hook_exception = monarch_with_gil(GilSite::Supervise, |py| {
1226                                err.downcast_ref::<pyo3::PyErr>()
1227                                    .is_some_and(|pyerr| {
1228                                        pyerr.is_instance(
1229                                            py,
1230                                            &unhandled_fault_hook_exception(py),
1231                                        )
1232                                    })
1233                            }).await;
1234
1235                            let kind = ActorErrorKind::processing(err);
1236                            let err = ActorError {
1237                                actor_id: Box::new(instance.self_addr().clone()),
1238                                kind: Box::new(kind),
1239                            };
1240
1241                            if is_hook_exception {
1242                                break Some(err);
1243                            }
1244
1245                            // Give the actor a chance to handle the error produced
1246                            // in its own message handler. This is important because
1247                            // we want Undeliverable<MessageEnvelope>, which returns
1248                            // an Err typically, to create a supervision event and
1249                            // call __supervise__.
1250                            let supervision_event = actor_error_to_event(instance, &actor, err);
1251                            // If the immediate supervision event isn't handled, continue with
1252                            // exiting the loop.
1253                            // Else, continue handling messages.
1254                            if let Err(err) = instance.handle_supervision_event(&mut actor, supervision_event).await {
1255                                while let Ok(supervision_event) = supervision_rx.try_recv() {
1256                                    if let Err(err) = instance.handle_supervision_event(&mut actor, supervision_event).await {
1257                                        break 'messages Some(err);
1258                                    }
1259                                }
1260                                break Some(err);
1261                            }
1262                        }
1263                    }
1264                    signal = signal_rx.recv() => {
1265                        tracing::info!(actor_id = %instance.self_addr(), "client received signal {signal:?}");
1266                        match signal {
1267                            Some(signal@(Signal::Stop(_) | Signal::DrainAndStop(_))) => {
1268                                need_drain = matches!(signal, Signal::DrainAndStop(_));
1269                                break None;
1270                            },
1271                            Some(Signal::ExitRequested(_)) => break None,
1272                            Some(Signal::ChildStopped(_)) => {},
1273                            Some(Signal::Kill(reason)) => {
1274                                break Some(ActorError { actor_id: Box::new(instance.self_addr().clone()), kind: Box::new(ActorErrorKind::Aborted(reason)) })
1275                            },
1276                            None => {
1277                                break Some(ActorError {
1278                                    actor_id: Box::new(instance.self_addr().clone()),
1279                                    kind: Box::new(ActorErrorKind::SignalChannelClosed),
1280                                })
1281                            },
1282                        }
1283                    }
1284                    Some(supervision_event) = supervision_rx.recv() => {
1285                        if let Err(err) = instance.handle_supervision_event(&mut actor, supervision_event).await {
1286                            break Some(err);
1287                        }
1288                    }
1289                };
1290            };
1291            if need_drain {
1292                let mut n = 0;
1293                while let Ok(work) = work_rx.try_recv() {
1294                    if let Err(e) = work.handle(&mut actor, instance).await {
1295                        err = Some(ActorError {
1296                            actor_id: Box::new(instance.self_addr().clone()),
1297                            kind: Box::new(ActorErrorKind::processing(e)),
1298                        });
1299                        break;
1300                    }
1301                    n += 1;
1302                }
1303                tracing::debug!(actor_id = %instance.self_addr(), "client drained {} messages before stopping", n);
1304            }
1305            if let Some(err) = err {
1306                let event = actor_error_to_event(instance, &actor, err);
1307                // The proc supervision handler will send to ProcAgent, which
1308                // just records it in v1. We want to crash instead, as nothing will
1309                // monitor the client ProcAgent for now.
1310                tracing::error!(
1311                    actor_id = %instance.self_addr(),
1312                    "could not propagate supervision event {} because it reached the global client: signaling KeyboardInterrupt to main thread",
1313                    event,
1314                );
1315
1316                // This is running in a background thread, and thus cannot run
1317                // Py_FinalizeEx when it exits the process to properly shut down
1318                // all python objects.
1319                // We use _thread.interrupt_main to raise a KeyboardInterrupt
1320                // to the main thread at some point in the future.
1321                // There is no way to propagate the exception message, but it
1322                // will at least run proper shutdown code as long as BaseException
1323                // isn't caught.
1324                monarch_with_gil_blocking(GilSite::Stop, |py| {
1325                    // Use _thread.interrupt_main to force the client to exit if it has an
1326                    // unhandled supervision event.
1327                    let thread_mod = py.import("_thread").expect("import _thread");
1328                    let interrupt_main = thread_mod
1329                        .getattr("interrupt_main")
1330                        .expect("get interrupt_main");
1331
1332                    // Ignore any exception from calling interrupt_main
1333                    if let Err(e) = interrupt_main.call0() {
1334                        tracing::error!("unable to interrupt main, exiting the process instead: {:?}", e);
1335                        eprintln!("unable to interrupt main, exiting the process with code 1 instead: {:?}", e);
1336                        std::process::exit(1);
1337                    }
1338                });
1339            } else {
1340                tracing::info!(actor_id = %instance.self_addr(), "client stopped");
1341                instance.change_status(hyperactor::actor::ActorStatus::Stopped("client stopped".into()));
1342            }
1343        });
1344
1345        (root_client_instance.get().unwrap(), handle)
1346    }
1347}
1348
1349fn actor_error_to_event(
1350    instance: &Instance<PythonActor>,
1351    actor: &PythonActor,
1352    err: ActorError,
1353) -> ActorSupervisionEvent {
1354    match *err.kind {
1355        ActorErrorKind::UnhandledSupervisionEvent(event) => *event,
1356        _ => {
1357            let status = ActorStatus::generic_failure(err.kind.to_string());
1358            ActorSupervisionEvent::new(
1359                instance.self_addr().clone(),
1360                actor.display_name(),
1361                status,
1362                None,
1363            )
1364        }
1365    }
1366}
1367
1368pub(crate) fn root_client_actor(py: Python<'_>) -> &'static Instance<PythonActor> {
1369    static ROOT_CLIENT_ACTOR: OnceLock<&'static Instance<PythonActor>> = OnceLock::new();
1370
1371    // Release the GIL before waiting on ROOT_CLIENT_ACTOR, because PythonActor::bootstrap_client
1372    // may release/reacquire the GIL; if thread 0 holds the GIL blocking on ROOT_CLIENT_ACTOR.get_or_init
1373    // while thread 1 blocks on acquiring the GIL inside PythonActor::bootstrap_client, we get
1374    // a deadlock.
1375    py.detach(|| {
1376        ROOT_CLIENT_ACTOR.get_or_init(|| {
1377            monarch_with_gil_blocking(GilSite::Bootstrap, |py| {
1378                let (client, _handle) = PythonActor::bootstrap_client(py);
1379                client
1380            })
1381        })
1382    })
1383}
1384
1385#[async_trait]
1386impl Actor for PythonActor {
1387    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
1388        // PE-1: install the read side eagerly so the actor reports
1389        // `execution` from its first handled message. The callback runs on
1390        // the introspect task (off the actor loop) and only reads `Arc`
1391        // state (PE-3), so it is `Send + Sync`, non-blocking, and infallible.
1392        let tracker = self.execution_tracker.clone();
1393        this.set_attrs_snapshot(move || {
1394            let mut attrs = hyperactor_config::Attrs::new();
1395            attrs.set(EXECUTION, tracker.snapshot());
1396            attrs
1397        });
1398
1399        if let PythonActorDispatchMode::Queue { receiver, .. } = &mut self.dispatch_mode {
1400            let receiver = receiver.take().unwrap();
1401
1402            monarch_with_gil(GilSite::DispatchInit, |py| {
1403                let self_instance = self.ensure_py_instance(py, this);
1404                let actor_mesh_mod = py.import("monarch._src.actor.actor_mesh")?;
1405
1406                let tl = &self.task_locals;
1407                let awaitable = actor_mesh_mod.call_method(
1408                    "_dispatch_loop",
1409                    (self.actor.clone_ref(py), receiver, self_instance),
1410                    None,
1411                )?;
1412                let future = pyo3_async_runtimes::into_future_with_locals(tl, awaitable)?;
1413                tokio::spawn(async move {
1414                    if let Err(e) = future.await {
1415                        tracing::error!("message loop error: {}", e);
1416                    }
1417                });
1418                Ok::<_, anyhow::Error>(())
1419            })
1420            .await?;
1421        }
1422
1423        if let Some(init_message) = self.init_message.take() {
1424            let spawn_point = self.spawn_point.get().unwrap().as_ref().expect("PythonActor should never be spawned with init_message unless spawn_point also specified").clone();
1425            let mut headers = Flattrs::new();
1426            headers.set(CAST_POINT, spawn_point);
1427            let cx = Context::new(this, headers);
1428            <Self as Handler<PythonMessage>>::handle(self, &cx, init_message).await?;
1429        }
1430
1431        Ok(())
1432    }
1433
1434    async fn cleanup(
1435        &mut self,
1436        this: &Instance<Self>,
1437        err: Option<&ActorError>,
1438    ) -> anyhow::Result<()> {
1439        // Calls the "__cleanup__" method on the python instance to allow the actor
1440        // to control its own cleanup.
1441        // No headers because this isn't in the context of a message.
1442        let cx = Context::new(this, Flattrs::new());
1443        // Turn the ActorError into a representation of the error. We may not
1444        // have an original exception object or traceback, so we just pass in
1445        // the message.
1446        let err_as_str = err.map(|e| e.to_string());
1447        let future = monarch_with_gil(GilSite::EndpointCleanup, |py| {
1448            let py_cx = match &self.instance {
1449                Some(instance) => crate::context::PyContext::new(&cx, instance.clone_ref(py)),
1450                None => {
1451                    let py_instance: crate::context::PyInstance = this.into();
1452                    crate::context::PyContext::new(
1453                        &cx,
1454                        py_instance
1455                            .into_py_any(py)?
1456                            .downcast_bound(py)
1457                            .map_err(PyErr::from)?
1458                            .clone()
1459                            .unbind(),
1460                    )
1461                }
1462            }
1463            .into_bound_py_any(py)?;
1464            let actor = self.actor.bind(py);
1465            // Some tests don't use the Actor base class, so add this check
1466            // to be defensive.
1467            match actor.hasattr("__cleanup__") {
1468                Ok(false) | Err(_) => {
1469                    // No cleanup found, default to returning None
1470                    return Ok(None);
1471                }
1472                _ => {}
1473            }
1474            let awaitable = actor
1475                .call_method("__cleanup__", (&py_cx, err_as_str), None)
1476                .map_err(|err| anyhow::Error::from(SerializablePyErr::from(py, &err)))?;
1477            if awaitable.is_none() {
1478                Ok(None)
1479            } else {
1480                pyo3_async_runtimes::into_future_with_locals(&self.task_locals, awaitable)
1481                    .map(Some)
1482                    .map_err(anyhow::Error::from)
1483            }
1484        })
1485        .await;
1486        let cleanup_result = match future {
1487            Ok(Some(future)) => future.await.map(|_| ()).map_err(anyhow::Error::from),
1488            Ok(None) => Ok(()),
1489            Err(err) => Err(err),
1490        };
1491        let loop_shutdown_result = self.cancel_pending_python_tasks_and_stop_loop();
1492        cleanup_result?;
1493        loop_shutdown_result?;
1494        Ok(())
1495    }
1496
1497    fn display_name(&self) -> Option<String> {
1498        self.instance.as_ref().and_then(|instance| {
1499            monarch_with_gil_blocking(GilSite::DisplayName, |py| {
1500                instance.bind(py).str().ok().map(|s| s.to_string())
1501            })
1502        })
1503    }
1504
1505    async fn handle_undeliverable_message(
1506        &mut self,
1507        ins: &Instance<Self>,
1508        reason: UndeliverableReason,
1509        mut envelope: Undeliverable<MessageEnvelope>,
1510    ) -> Result<(), anyhow::Error> {
1511        if envelope
1512            .as_message()
1513            .is_some_and(|envelope| envelope.sender() != ins.self_addr())
1514        {
1515            // This can happen if the sender is comm. Update the envelope.
1516            envelope = update_undeliverable_envelope_for_casting(envelope);
1517        }
1518        let envelope = match envelope {
1519            Undeliverable::Returned(envelope) => envelope,
1520            Undeliverable::Report(report) => {
1521                return Err(UndeliverableMessageError::Report { report }.into());
1522            }
1523        };
1524        assert_eq!(
1525            envelope.sender(),
1526            ins.self_addr(),
1527            "undeliverable message was returned to the wrong actor. \
1528            Return address = {}, src actor = {}, dest handler port = {}, message type = {}, envelope headers = {}",
1529            envelope.sender(),
1530            ins.self_addr(),
1531            envelope.dest(),
1532            envelope.data().typename().unwrap_or("unknown"),
1533            envelope.headers()
1534        );
1535
1536        let cx = Context::new(ins, envelope.headers().clone());
1537
1538        let (envelope, handled) = monarch_with_gil(GilSite::EndpointDispatch, |py| {
1539            let py_cx = match &self.instance {
1540                Some(instance) => crate::context::PyContext::new(&cx, instance.clone_ref(py)),
1541                None => {
1542                    let py_instance: crate::context::PyInstance = ins.into();
1543                    crate::context::PyContext::new(
1544                        &cx,
1545                        py_instance
1546                            .into_py_any(py)?
1547                            .downcast_bound(py)
1548                            .map_err(PyErr::from)?
1549                            .clone()
1550                            .unbind(),
1551                    )
1552                }
1553            }
1554            .into_bound_py_any(py)?;
1555            let py_envelope = PythonUndeliverableMessageEnvelope {
1556                inner: Some(Undeliverable::Returned(envelope)),
1557            }
1558            .into_bound_py_any(py)?;
1559            let handled = self
1560                .actor
1561                .call_method(
1562                    py,
1563                    "_handle_undeliverable_message",
1564                    (&py_cx, &py_envelope),
1565                    None,
1566                )
1567                .map_err(|err| anyhow::Error::from(SerializablePyErr::from(py, &err)))?
1568                .extract::<bool>(py)?;
1569            Ok::<_, anyhow::Error>((
1570                py_envelope
1571                    .downcast::<PythonUndeliverableMessageEnvelope>()
1572                    .map_err(PyErr::from)?
1573                    .try_borrow_mut()
1574                    .map_err(PyErr::from)?
1575                    .take()?,
1576                handled,
1577            ))
1578        })
1579        .await?;
1580
1581        if !handled {
1582            hyperactor::actor::handle_undeliverable_message(ins, reason, envelope)
1583        } else {
1584            Ok(())
1585        }
1586    }
1587
1588    async fn handle_supervision_event(
1589        &mut self,
1590        this: &Instance<Self>,
1591        event: &ActorSupervisionEvent,
1592    ) -> Result<bool, anyhow::Error> {
1593        let cx = Context::new(this, Flattrs::new());
1594        self.handle(
1595            &cx,
1596            MeshFailure {
1597                // Populate the mesh name from the base-name string
1598                // plumbed through PythonActorParams at spawn time —
1599                // no lookup.
1600                actor_mesh_name: self.mesh_base_name.clone(),
1601                event: event.clone(),
1602                crashed_ranks: vec![],
1603            },
1604        )
1605        .await
1606        .map(|_| true)
1607    }
1608}
1609
1610#[derive(Debug, Clone, Serialize, Deserialize, Named)]
1611pub struct PythonActorParams {
1612    // The pickled actor class to instantiate.
1613    actor_type: PickledPyObject,
1614    // Python message to process as part of the actor initialization.
1615    init_message: Option<PythonMessage>,
1616    // User-provided mesh base-name string under which this actor
1617    // was spawned. The base name the caller passed when the mesh
1618    // was spawned, plumbed through `PythonActor` narrowly to
1619    // populate `MeshFailure.actor_mesh_name` on the direct
1620    // actor-handled supervision path without a lookup. It is not
1621    // actor display text (`display_name` handles that) and it is
1622    // not a general side channel; downstream code must not consume
1623    // this field for any other purpose. Kept separate from
1624    // `supervision_display_name`, which is a rendered supervision
1625    // display string passed through `spawn_with_name(...)`.
1626    mesh_base_name: Option<String>,
1627}
1628
1629impl PythonActorParams {
1630    pub(crate) fn new(
1631        actor_type: PickledPyObject,
1632        init_message: Option<PythonMessage>,
1633        mesh_base_name: Option<String>,
1634    ) -> Self {
1635        Self {
1636            actor_type,
1637            init_message,
1638            mesh_base_name,
1639        }
1640    }
1641}
1642
1643#[async_trait]
1644impl RemoteSpawn for PythonActor {
1645    type Params = PythonActorParams;
1646
1647    async fn new(
1648        PythonActorParams {
1649            actor_type,
1650            init_message,
1651            mesh_base_name,
1652        }: PythonActorParams,
1653        environment: Flattrs,
1654    ) -> Result<Self, anyhow::Error> {
1655        let spawn_point = environment.get(CAST_POINT);
1656        Self::new(actor_type, init_message, spawn_point, mesh_base_name)
1657    }
1658}
1659
1660/// Create a new TaskLocals with its own asyncio event loop in a dedicated thread.
1661fn create_task_locals() -> pyo3_async_runtimes::TaskLocals {
1662    monarch_with_gil_blocking(GilSite::TaskLocals, |py| {
1663        let asyncio = Python::import(py, "asyncio").unwrap();
1664        let event_loop = asyncio.call_method0("new_event_loop").unwrap();
1665        let task_locals = pyo3_async_runtimes::TaskLocals::new(event_loop.clone())
1666            .copy_context(py)
1667            .unwrap();
1668
1669        let kwargs = PyDict::new(py);
1670        let target = event_loop.getattr("run_forever").unwrap();
1671        kwargs.set_item("target", target).unwrap();
1672        // Need to make this a daemon thread, otherwise shutdown will hang.
1673        kwargs.set_item("daemon", true).unwrap();
1674        let thread = py
1675            .import("threading")
1676            .unwrap()
1677            .call_method("Thread", (), Some(&kwargs))
1678            .unwrap();
1679        thread.call_method0("start").unwrap();
1680        task_locals
1681    })
1682}
1683
1684// [Panics in async endpoints]
1685// This class exists to solve a deadlock when an async endpoint calls into some
1686// Rust code that panics.
1687//
1688// When an async endpoint is invoked and calls into Rust, the following sequence happens:
1689//
1690// hyperactor message -> PythonActor::handle() -> call _Actor.handle() in Python
1691//   -> convert the resulting coroutine into a Rust future, but scheduled on
1692//      the Python asyncio event loop (`into_future_with_locals`)
1693//   -> set a callback on Python asyncio loop to ping a channel that fulfills
1694//      the Rust future when the Python coroutine has finished. ('PyTaskCompleter`)
1695//
1696// This works fine for normal results and Python exceptions: we will take the
1697// result of the callback and send it through the channel, where it will be
1698// returned to the `await`er of the Rust future.
1699//
1700// This DOESN'T work for panics. The behavior of a panic in pyo3-bound code is
1701// that it will get caught by pyo3 and re-thrown to Python as a PanicException.
1702// And if that PanicException ever makes it back to Rust, it will get unwound
1703// instead of passed around as a normal PyErr type.
1704//
1705// So:
1706//   - Endpoint panics.
1707//   - This panic is captured as a PanicException in Python and
1708//     stored as the result of the Python asyncio task.
1709//   - When the callback in `PyTaskCompleter` queries the status of the task to
1710//     pass it back to the Rust awaiter, instead of getting a Result type, it
1711//     just starts resumes unwinding the PanicException
1712//   - This triggers a deadlock, because the whole task dies without ever
1713//     pinging the response channel, and the Rust awaiter will never complete.
1714//
1715// We work around this by passing a side-channel to our Python task so that it,
1716// in Python, can catch the PanicException and notify the Rust awaiter manually.
1717// In this way we can guarantee that the awaiter will complete even if the
1718// `PyTaskCompleter` callback explodes.
1719#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
1720struct PanicFlag {
1721    sender: Option<tokio::sync::oneshot::Sender<Py<PyAny>>>,
1722}
1723
1724#[pymethods]
1725impl PanicFlag {
1726    fn signal_panic(&mut self, ex: Py<PyAny>) {
1727        self.sender.take().unwrap().send(ex).unwrap();
1728    }
1729}
1730
1731#[async_trait]
1732impl Handler<PythonMessage> for PythonActor {
1733    #[tracing::instrument(level = "debug", skip_all)]
1734    async fn handle(
1735        &mut self,
1736        cx: &Context<PythonActor>,
1737        message: PythonMessage,
1738    ) -> anyhow::Result<()> {
1739        match &self.dispatch_mode {
1740            PythonActorDispatchMode::Direct => self.handle_direct(cx, message).await,
1741            PythonActorDispatchMode::Queue { sender, .. } => {
1742                let sender = sender.clone();
1743                self.handle_queue(cx, sender, message).await
1744            }
1745        }
1746    }
1747}
1748
1749impl PythonActor {
1750    /// Handle a message using direct dispatch (current behavior).
1751    async fn handle_direct(
1752        &mut self,
1753        cx: &Context<'_, PythonActor>,
1754        message: PythonMessage,
1755    ) -> anyhow::Result<()> {
1756        let resolved = message.resolve_indirect_call(cx).await?;
1757        let endpoint = resolved.method.to_string();
1758
1759        // Create a channel for signaling panics in async endpoints.
1760        // See [Panics in async endpoints].
1761        let (sender, receiver) = oneshot::channel();
1762
1763        let future = monarch_with_gil(
1764            GilSite::EndpointDispatch,
1765            |py| -> Result<_, SerializablePyErr> {
1766                let inst = self.ensure_py_instance(py, cx);
1767
1768                let awaitable = self.actor.call_method(
1769                    py,
1770                    "handle",
1771                    (
1772                        crate::context::PyContext::new(cx, inst.clone_ref(py)),
1773                        resolved.method,
1774                        resolved.bytes,
1775                        PanicFlag {
1776                            sender: Some(sender),
1777                        },
1778                        resolved
1779                            .local_state
1780                            .unwrap_or_else(|| PyList::empty(py).unbind().into()),
1781                        resolved.mesh_references.into_py_any(py)?,
1782                        resolved.response_port.into_py_any(py)?,
1783                    ),
1784                    None,
1785                )?;
1786
1787                pyo3_async_runtimes::into_future_with_locals(
1788                    &self.task_locals,
1789                    awaitable.into_bound(py),
1790                )
1791                .map_err(|err| err.into())
1792            },
1793        )
1794        .await?;
1795
1796        // Spawn a child actor to await the Python handler method.
1797        tokio::spawn(handle_async_endpoint_panic(
1798            cx.signal_sender(),
1799            PythonTask::new(future)?,
1800            receiver,
1801            cx.self_addr().to_string(),
1802            endpoint,
1803        ));
1804        Ok(())
1805    }
1806
1807    /// Handle a message using queue dispatch.
1808    /// Resolves the message on the Rust side and enqueues it for Python to process.
1809    async fn handle_queue(
1810        &mut self,
1811        cx: &Context<'_, PythonActor>,
1812        sender: pympsc::Sender,
1813        message: PythonMessage,
1814    ) -> anyhow::Result<()> {
1815        let resolved = message.resolve_indirect_call(cx).await?;
1816
1817        let queued_msg = monarch_with_gil(
1818            GilSite::QueueDispatch,
1819            |py| -> anyhow::Result<QueuedMessage> {
1820                let inst = self.ensure_py_instance(py, cx);
1821
1822                let py_context = crate::context::PyContext::new(cx, inst.clone_ref(py));
1823                let py_context_obj = Py::new(py, py_context)?;
1824
1825                Ok(QueuedMessage {
1826                    context: py_context_obj,
1827                    method: resolved.method,
1828                    bytes: resolved.bytes,
1829                    local_state: resolved
1830                        .local_state
1831                        .unwrap_or_else(|| PyList::empty(py).unbind().into()),
1832                    refs: resolved.mesh_references.into_py_any(py)?,
1833                    response_port: resolved.response_port.into_py_any(py)?,
1834                })
1835            },
1836        )
1837        .await?;
1838
1839        sender
1840            .send(queued_msg)
1841            .map_err(|_| anyhow::anyhow!("failed to send message to queue"))?;
1842
1843        Ok(())
1844    }
1845}
1846
1847#[async_trait]
1848impl Handler<MeshFailure> for PythonActor {
1849    async fn handle(&mut self, cx: &Context<Self>, message: MeshFailure) -> anyhow::Result<()> {
1850        // If the message is not about a failure, don't call __supervise__.
1851        // This includes messages like "stop", because those are not errors that
1852        // need to be propagated.
1853        if !message.event.actor_status.is_failed() {
1854            tracing::info!(
1855                "ignoring non-failure supervision event from child: {}",
1856                message
1857            );
1858            return Ok(());
1859        }
1860        // TODO: Consider routing supervision messages through the queue for Queue mode.
1861        // For now, supervision is always handled directly since it requires immediate response.
1862
1863        // `_Actor.__supervise__` is `async def`, so calling it returns a
1864        // coroutine, which we schedule on the actor's asyncio event loop --
1865        // the same loop that runs endpoint coroutines. A sync user
1866        // `__supervise__` is dispatched under `fake_sync_state` inside
1867        // `_Actor.__supervise__`, mirroring `__cleanup__`.
1868        let (display_name, fut) = monarch_with_gil(GilSite::Supervise, |py| {
1869            let inst = self.ensure_py_instance(py, cx);
1870            // Compute display_name here since we can't call self.display_name() due to borrow.
1871            let display_name: Option<String> = inst.bind(py).str().ok().map(|s| s.to_string());
1872            let actor_bound = self.actor.bind(py);
1873            // The _Actor class always has a __supervise__ method, so this should
1874            // never happen.
1875            if !actor_bound.hasattr("__supervise__")? {
1876                return Err(anyhow::anyhow!(
1877                    "no __supervise__ method on {:?}",
1878                    actor_bound
1879                ));
1880            }
1881            let awaitable = actor_bound.call_method(
1882                "__supervise__",
1883                (
1884                    crate::context::PyContext::new(cx, inst.clone_ref(py)),
1885                    PyMeshFailure::from(message.clone()),
1886                ),
1887                None,
1888            )?;
1889            let fut = pyo3_async_runtimes::into_future_with_locals(&self.task_locals, awaitable)?;
1890            anyhow::Ok((display_name, fut))
1891        })
1892        .await?;
1893
1894        let awaited = fut.await;
1895
1896        monarch_with_gil(GilSite::Supervise, |py| match awaited {
1897            Ok(s) => {
1898                if s.bind(py).is_truthy()? {
1899                    // If the return value is truthy, then the exception was handled
1900                    // and doesn't need to be propagated.
1901                    // TODO: We also don't want to deliver multiple supervision
1902                    // events from the same mesh if an earlier one is handled.
1903                    tracing::info!(
1904                        name = "ActorMeshStatus",
1905                        status = "SupervisionError::Handled",
1906                        // only care about the event sender when the message is handled
1907                        actor_name = message.actor_mesh_name,
1908                        event = %message.event,
1909                        "__supervise__ on {} handled a supervision event, not reporting any further",
1910                        cx.self_addr(),
1911                    );
1912                    Ok(())
1913                } else {
1914                    // For a falsey return value, we propagate the supervision event
1915                    // to the next owning actor. We do this by returning a new
1916                    // error. This will not set the causal chain for ActorSupervisionEvent,
1917                    // so make sure to include the original event in the error message
1918                    // to provide context.
1919
1920                    // False -- we propagate the event onward, but update it with the fact that
1921                    // this actor is now the event creator.
1922                    for (actor_name, status) in [
1923                        (
1924                            message
1925                                .actor_mesh_name
1926                                .as_deref()
1927                                .unwrap_or_else(|| message.event.actor_id.log_name()),
1928                            "SupervisionError::Unhandled",
1929                        ),
1930                        (cx.self_addr().log_name(), "UnhandledSupervisionEvent"),
1931                    ] {
1932                        tracing::info!(
1933                            name = "ActorMeshStatus",
1934                            status,
1935                            actor_name,
1936                            event = %message.event,
1937                            "__supervise__ on {} did not handle a supervision event, reporting to the next next owner",
1938                            cx.self_addr(),
1939                        );
1940                    }
1941                    let err = ActorErrorKind::UnhandledSupervisionEvent(Box::new(
1942                        ActorSupervisionEvent::new(
1943                            cx.self_addr().clone(),
1944                            display_name.clone(),
1945                            ActorStatus::Failed(ActorErrorKind::UnhandledSupervisionEvent(
1946                                Box::new(message.event.clone()),
1947                            )),
1948                            None,
1949                        ),
1950                    ));
1951                    Err(anyhow::Error::new(err))
1952                }
1953            }
1954            Err(err) => {
1955                // If __supervise__ raised UnhandledFaultHookException,
1956                // return the PyErr directly without wrapping in
1957                // ActorErrorKind. The custom run loop detects this by
1958                // downcasting the anyhow::Error to PyErr.
1959                if err.is_instance(py, &unhandled_fault_hook_exception(py)) {
1960                    return Err(err.into());
1961                }
1962
1963                // Any other exception will supersede in the propagation chain,
1964                // and will become its own supervision failure.
1965                // Include the event it was handling in the error message.
1966
1967                // Add to caused_by chain.
1968                for (actor_name, status) in [
1969                    (
1970                        message
1971                            .actor_mesh_name
1972                            .as_deref()
1973                            .unwrap_or_else(|| message.event.actor_id.log_name()),
1974                        "SupervisionError::__supervise__::exception",
1975                    ),
1976                    (cx.self_addr().log_name(), "UnhandledSupervisionEvent"),
1977                ] {
1978                    tracing::info!(
1979                        name = "ActorMeshStatus",
1980                        status,
1981                        actor_name,
1982                        event = %message.event,
1983                        "__supervise__ on {} threw an exception",
1984                        cx.self_addr(),
1985                    );
1986                }
1987                let err = ActorErrorKind::UnhandledSupervisionEvent(Box::new(
1988                    ActorSupervisionEvent::new(
1989                        cx.self_addr().clone(),
1990                        display_name,
1991                        ActorStatus::Failed(ActorErrorKind::ErrorDuringHandlingSupervision(
1992                            err.to_string(),
1993                            Box::new(message.event.clone()),
1994                        )),
1995                        None,
1996                    ),
1997                ));
1998                Err(anyhow::Error::new(err))
1999            }
2000        })
2001        .await
2002    }
2003}
2004
2005async fn handle_async_endpoint_panic(
2006    panic_sender: mpsc::UnboundedSender<Signal>,
2007    task: PythonTask,
2008    side_channel: oneshot::Receiver<Py<PyAny>>,
2009    actor_id: String,
2010    endpoint: String,
2011) {
2012    // Create attributes for metrics with actor_id and endpoint
2013    let attributes =
2014        hyperactor_telemetry::kv_pairs!("actor_id" => actor_id, "endpoint" => endpoint);
2015
2016    // Record the start time for latency measurement
2017    let start_time = std::time::Instant::now();
2018
2019    // Increment throughput counter
2020    ENDPOINT_ACTOR_COUNT.add(1, attributes);
2021
2022    let err_or_never = async {
2023        // The side channel will resolve with a value if a panic occured during
2024        // processing of the async endpoint, see [Panics in async endpoints].
2025        match side_channel.await {
2026            Ok(value) => {
2027                monarch_with_gil(GilSite::AwaitDrive, |py| -> Option<SerializablePyErr> {
2028                    let err: PyErr = value
2029                        .downcast_bound::<PyBaseException>(py)
2030                        .unwrap()
2031                        .clone()
2032                        .into();
2033                    ENDPOINT_ACTOR_PANIC.add(1, attributes);
2034                    Some(err.into())
2035                })
2036                .await
2037            }
2038            // An Err means that the sender has been dropped without sending.
2039            // That's okay, it just means that the Python task has completed.
2040            // In that case, just never resolve this future. We expect the other
2041            // branch of the select to finish eventually.
2042            Err(_) => pending().await,
2043        }
2044    };
2045    let future = task.take();
2046    if let Some(panic) = tokio::select! {
2047        result = future => {
2048            match result {
2049                Ok(_) => None,
2050                Err(e) => Some(e.into()),
2051            }
2052        },
2053        result = err_or_never => {
2054            result
2055        }
2056    } {
2057        // Record error and panic metrics
2058        ENDPOINT_ACTOR_ERROR.add(1, attributes);
2059        if panic_sender.send(Signal::Kill(panic.to_string())).is_err() {
2060            tracing::warn!("dropped panic signal: actor already stopped: {panic}");
2061        }
2062    }
2063
2064    // Record latency in microseconds
2065    let elapsed_micros = start_time.elapsed().as_micros() as f64;
2066    ENDPOINT_ACTOR_LATENCY_US_HISTOGRAM.record(elapsed_micros, attributes);
2067}
2068
2069#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
2070struct LocalPort {
2071    instance: PyInstance,
2072    inner: Option<OncePortHandle<Result<Py<PyAny>, Py<PyAny>>>>,
2073}
2074
2075impl Debug for LocalPort {
2076    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2077        f.debug_struct("LocalPort")
2078            .field("inner", &self.inner)
2079            .finish()
2080    }
2081}
2082
2083pub(crate) fn to_py_error<T>(e: T) -> PyErr
2084where
2085    T: Error,
2086{
2087    PyErr::new::<PyValueError, _>(e.to_string())
2088}
2089
2090#[pymethods]
2091impl LocalPort {
2092    fn send(&mut self, obj: Py<PyAny>) -> PyResult<()> {
2093        let port = self.inner.take().expect("use local port once");
2094        port.post(self.instance.deref(), Ok(obj));
2095        Ok(())
2096    }
2097    fn resolve_and_send(&mut self, obj: Py<PyAny>) -> PyResult<PyPythonTask> {
2098        self.send(obj)?;
2099        PyPythonTask::new(async { Ok(()) })
2100    }
2101    fn exception(&mut self, e: Py<PyAny>) -> PyResult<()> {
2102        let port = self.inner.take().expect("use local port once");
2103        port.post(self.instance.deref(), Err(e));
2104        Ok(())
2105    }
2106}
2107
2108/// A port that drops all messages sent to it.
2109/// Used when there is no response port for a message.
2110/// Any exceptions sent to it are re-raised in the current actor.
2111#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
2112#[derive(Debug)]
2113pub struct DroppingPort;
2114
2115#[pymethods]
2116impl DroppingPort {
2117    #[new]
2118    fn new() -> Self {
2119        DroppingPort
2120    }
2121
2122    fn send(&self, _obj: Py<PyAny>) -> PyResult<()> {
2123        Ok(())
2124    }
2125
2126    fn resolve_and_send(&self, obj: Py<PyAny>) -> PyResult<PyPythonTask> {
2127        self.send(obj)?;
2128        PyPythonTask::new(async { Ok(()) })
2129    }
2130
2131    fn send_message(&self, _message: PythonMessage) -> PyResult<()> {
2132        Ok(())
2133    }
2134
2135    fn exception(&self, e: Bound<'_, PyAny>) -> PyResult<()> {
2136        // Unwrap ActorError to get the inner exception, matching Python behavior.
2137        let exc = if let Ok(inner) = e.getattr("exception") {
2138            inner
2139        } else {
2140            e
2141        };
2142        Err(PyErr::from_value(exc))
2143    }
2144
2145    #[getter]
2146    fn get_return_undeliverable(&self) -> bool {
2147        true
2148    }
2149
2150    #[setter]
2151    fn set_return_undeliverable(&self, _value: bool) {}
2152}
2153
2154/// A port that sends messages to a remote receiver.
2155/// Wraps an EitherPortRef with the actor instance needed for sending.
2156#[pyclass(module = "monarch._src.actor.actor_mesh")]
2157pub struct Port {
2158    port_ref: EitherPortRef,
2159    instance: Instance<PythonActor>,
2160    rank: Option<usize>,
2161    /// Operation-context headers captured from the inbound request,
2162    /// re-emitted on every reply so failure surfaces can name the
2163    /// operation.
2164    reply_headers: hyperactor_config::Flattrs,
2165}
2166
2167#[pymethods]
2168impl Port {
2169    #[new]
2170    fn new(
2171        port_ref: EitherPortRef,
2172        instance: &crate::context::PyInstance,
2173        rank: Option<usize>,
2174    ) -> Self {
2175        Self {
2176            port_ref,
2177            instance: instance.clone().into_instance(),
2178            rank,
2179            reply_headers: hyperactor_config::Flattrs::new(),
2180        }
2181    }
2182
2183    #[getter("_port_ref")]
2184    fn port_ref_py(&self) -> EitherPortRef {
2185        self.port_ref.clone()
2186    }
2187
2188    #[getter("_rank")]
2189    fn rank_py(&self) -> Option<usize> {
2190        self.rank
2191    }
2192
2193    #[getter]
2194    fn get_return_undeliverable(&self) -> bool {
2195        self.port_ref.get_return_undeliverable()
2196    }
2197
2198    #[setter]
2199    fn set_return_undeliverable(&mut self, value: bool) {
2200        self.port_ref.set_return_undeliverable(value);
2201    }
2202
2203    #[tracing::instrument(level = "debug", skip_all)]
2204    fn send(&mut self, py: Python<'_>, obj: Py<PyAny>) -> PyResult<()> {
2205        let message = PythonMessage::new_from_buf(
2206            PythonMessageKind::Result { rank: self.rank },
2207            pickle_to_part(py, &obj)?,
2208        );
2209
2210        self.port_ref
2211            .post_with_headers(&self.instance, self.reply_headers.clone(), message)
2212            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
2213    }
2214
2215    #[tracing::instrument(level = "debug", skip_all)]
2216    fn send_message(&mut self, message: PythonMessage) -> PyResult<()> {
2217        self.port_ref
2218            .post_with_headers(&self.instance, self.reply_headers.clone(), message)
2219            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
2220    }
2221
2222    fn exception(&mut self, py: Python<'_>, e: Py<PyAny>) -> PyResult<()> {
2223        let message = PythonMessage::new_from_buf(
2224            PythonMessageKind::Exception { rank: self.rank },
2225            pickle_to_part(py, &e)?,
2226        );
2227
2228        self.port_ref
2229            .post_with_headers(&self.instance, self.reply_headers.clone(), message)
2230            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
2231    }
2232}
2233
2234impl Port {
2235    /// Constructor that attaches operation-context headers captured
2236    /// from the inbound request. The Python `#[new]` constructor
2237    /// defaults to empty headers.
2238    pub(crate) fn with_reply_headers(
2239        port_ref: EitherPortRef,
2240        instance: Instance<PythonActor>,
2241        rank: Option<usize>,
2242        reply_headers: hyperactor_config::Flattrs,
2243    ) -> Self {
2244        Self {
2245            port_ref,
2246            instance,
2247            rank,
2248            reply_headers,
2249        }
2250    }
2251}
2252
2253pub fn register_python_bindings(hyperactor_mod: &Bound<'_, PyModule>) -> PyResult<()> {
2254    hyperactor_mod.add_class::<PythonActorHandle>()?;
2255    hyperactor_mod.add_class::<PythonMessage>()?;
2256    hyperactor_mod.add_class::<PyMeshRef>()?;
2257    hyperactor_mod.add_class::<PythonMessageKind>()?;
2258    hyperactor_mod.add_class::<MethodSpecifier>()?;
2259    hyperactor_mod.add_class::<UnflattenArg>()?;
2260    hyperactor_mod.add_class::<PanicFlag>()?;
2261    hyperactor_mod.add_class::<QueuedMessage>()?;
2262    hyperactor_mod.add_class::<DroppingPort>()?;
2263    hyperactor_mod.add_class::<Port>()?;
2264    Ok(())
2265}
2266
2267#[cfg(test)]
2268mod tests {
2269    use hyperactor as reference;
2270    use hyperactor::accum::ReducerSpec;
2271    use hyperactor::accum::StreamingReducerOpts;
2272    use hyperactor::id::Label;
2273    use hyperactor::testing::ids::test_port_id;
2274    use hyperactor_mesh::Error as MeshError;
2275    use hyperactor_mesh::host_mesh::host_agent::ProcState;
2276    use hyperactor_mesh::mesh_id::ResourceId;
2277    use hyperactor_mesh::resource::Status;
2278    use hyperactor_mesh::resource::{self};
2279    use pyo3::PyTypeInfo;
2280
2281    use super::*;
2282    use crate::actor::to_py_error;
2283
2284    #[test]
2285    fn test_python_message_part_codec() {
2286        let reducer_spec = ReducerSpec {
2287            typehash: 123,
2288            builder_params: Some(wirevalue::Any::serialize(&"abcdefg12345".to_string()).unwrap()),
2289        };
2290        let port_ref = hyperactor::PortRef::<PythonMessage>::attest_reducible(
2291            test_port_id("world_0", "client", 123),
2292            Some(reducer_spec),
2293            StreamingReducerOpts::default(),
2294        );
2295        let message = PythonMessage {
2296            kind: PythonMessageKind::CallMethod {
2297                name: MethodSpecifier::ReturnsResponse {
2298                    name: "test".to_string(),
2299                },
2300                response_port: Some(EitherPortRef::Unbounded(port_ref.clone().into())),
2301            },
2302            message: Part::from(vec![1, 2, 3]),
2303            refs: Vec::new(),
2304        };
2305        {
2306            let mut multipart_message =
2307                wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message).unwrap();
2308            let mut ports = vec![];
2309            multipart_message
2310                .visit_multipart_parts_mut::<reference::PortRefRepr, anyhow::Error>(|b| {
2311                    ports.push(b.clone());
2312                    Ok(())
2313                })
2314                .unwrap();
2315            assert_eq!(ports.len(), 1);
2316            assert_eq!(ports[0].port_addr(), port_ref.port_addr());
2317            assert_eq!(ports[0].reducer_spec(), port_ref.reducer_spec());
2318            assert_eq!(
2319                ports[0].get_return_undeliverable(),
2320                port_ref.get_return_undeliverable()
2321            );
2322            assert!(!ports[0].unsplit());
2323            assert_eq!(
2324                message,
2325                multipart_message
2326                    .deserialized_unchecked::<PythonMessage>()
2327                    .unwrap()
2328            );
2329        }
2330
2331        let no_port_message = PythonMessage {
2332            kind: PythonMessageKind::CallMethod {
2333                name: MethodSpecifier::ReturnsResponse {
2334                    name: "test".to_string(),
2335                },
2336                response_port: None,
2337            },
2338            ..message
2339        };
2340        {
2341            let mut multipart_message =
2342                wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&no_port_message)
2343                    .unwrap();
2344            let mut ports = vec![];
2345            multipart_message
2346                .visit_multipart_parts_mut::<reference::PortRefRepr, anyhow::Error>(|b| {
2347                    ports.push(b.clone());
2348                    Ok(())
2349                })
2350                .unwrap();
2351            assert_eq!(ports.len(), 0);
2352            assert_eq!(
2353                no_port_message,
2354                multipart_message
2355                    .deserialized_unchecked::<PythonMessage>()
2356                    .unwrap()
2357            );
2358        }
2359    }
2360
2361    #[test]
2362    fn test_python_message_refs_travel_as_parts() {
2363        // A non-live proc mesh ref, built in-memory from ids (no spawn).
2364        fn proc_mesh_ref(seed: u64, label: &str) -> MeshRef {
2365            let proc_id = hyperactor::ProcId::new(
2366                hyperactor::id::Uid::Instance(seed, None),
2367                Some(Label::new("local").unwrap()),
2368            );
2369            let proc_addr = hyperactor::ProcAddr::new(
2370                proc_id,
2371                hyperactor::channel::ChannelAddr::Local(seed).into(),
2372            );
2373            let agent: hyperactor::ActorRef<hyperactor_mesh::proc_agent::ProcAgent> =
2374                hyperactor::ActorRef::attest(
2375                    proc_addr.actor_addr(hyperactor_mesh::proc_agent::PROC_AGENT_ACTOR_NAME),
2376                );
2377            let proc_ref = hyperactor_mesh::proc_mesh::ProcRef::new(proc_addr, 0, agent);
2378            MeshRef::Proc(Box::new(
2379                hyperactor_mesh::proc_mesh::ProcMeshRef::new_singleton(
2380                    hyperactor_mesh::mesh_id::ProcMeshId::singleton(Label::new(label).unwrap()),
2381                    proc_ref,
2382                )
2383                .unwrap(),
2384            ))
2385        }
2386
2387        let message = PythonMessage {
2388            kind: PythonMessageKind::CallMethod {
2389                name: MethodSpecifier::ReturnsResponse {
2390                    name: "test".to_string(),
2391                },
2392                response_port: None,
2393            },
2394            message: Part::from(vec![1, 2, 3]),
2395            refs: vec![proc_mesh_ref(1, "a"), proc_mesh_ref(2, "b")],
2396        };
2397
2398        let mut multipart_message =
2399            wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message).unwrap();
2400        let mut parts = vec![];
2401        multipart_message
2402            .visit_multipart_parts_mut::<MeshRefRepr, anyhow::Error>(|b| {
2403                parts.push(b.clone());
2404                Ok(())
2405            })
2406            .unwrap();
2407        // Each MeshRef rides as its own typed part on the multipart wire.
2408        assert_eq!(parts.len(), 2);
2409        // And the message round-trips, reuniting the refs from those parts.
2410        assert_eq!(
2411            message,
2412            multipart_message
2413                .deserialized_unchecked::<PythonMessage>()
2414                .unwrap()
2415        );
2416    }
2417
2418    #[test]
2419    fn to_py_error_preserves_proc_creation_message() {
2420        // State<ProcState> w/ `state.is_none()`
2421        let state: resource::State<ProcState> = resource::State {
2422            id: ResourceId::instance(Label::new("my-proc").unwrap()),
2423            status: Status::Failed("boom".into()),
2424            state: None,
2425            generation: 0,
2426            timestamp: std::time::SystemTime::now(),
2427        };
2428
2429        // A ProcCreationError
2430        let mesh_agent: hyperactor::ActorRef<hyperactor_mesh::host_mesh::HostAgent> =
2431            hyperactor::ActorRef::attest(test_port_id("hello_0", "actor", 0).actor_addr());
2432        let expected_prefix = format!(
2433            "error creating proc (host rank 0) on host mesh agent {}",
2434            mesh_agent
2435        );
2436        let err = MeshError::ProcCreationError {
2437            host_rank: 0,
2438            mesh_agent,
2439            state: Box::new(state),
2440        };
2441
2442        let rust_msg = err.to_string();
2443        let pyerr = to_py_error(err);
2444
2445        pyo3::Python::initialize();
2446        monarch_with_gil_blocking(GilSite::Test, |py| {
2447            assert!(pyerr.get_type(py).is(PyValueError::type_object(py)));
2448            let py_msg = pyerr.value(py).to_string();
2449
2450            // 1) Bridge preserves the exact message
2451            assert_eq!(py_msg, rust_msg);
2452            // 2) Contains the structured state and failure status
2453            assert!(py_msg.contains(", state: "));
2454            assert!(py_msg.contains("\"status\":{\"Failed\":\"boom\"}"));
2455            // 3) Starts with the expected prefix
2456            assert!(py_msg.starts_with(&expected_prefix));
2457        });
2458    }
2459}