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
9mod async_actor;
10mod sync_actor;
11
12use std::collections::HashMap;
13use std::collections::VecDeque;
14use std::fmt::Debug;
15use std::ops::Deref;
16use std::sync::Arc;
17use std::sync::Mutex;
18use std::sync::OnceLock;
19use std::sync::atomic::AtomicBool;
20use std::sync::atomic::AtomicU64;
21use std::sync::atomic::AtomicUsize;
22use std::sync::atomic::Ordering as AtomicOrdering;
23use std::time::SystemTime;
24
25use async_trait::async_trait;
26use hyperactor::Actor;
27use hyperactor::ActorEnvironment;
28use hyperactor::ActorHandle;
29use hyperactor::Context;
30use hyperactor::Endpoint as _;
31use hyperactor::Handler;
32use hyperactor::Instance;
33use hyperactor::MessageStatusReporting;
34use hyperactor::OncePortHandle;
35use hyperactor::PortHandle;
36use hyperactor::Proc;
37use hyperactor::RemoteSpawn;
38use hyperactor::actor::ActorError;
39use hyperactor::actor::ActorErrorKind;
40use hyperactor::actor::ActorStatus;
41use hyperactor::actor::Signal;
42use hyperactor::context::Actor as ContextActor;
43use hyperactor::mailbox::MessageEnvelope;
44use hyperactor::mailbox::Undeliverable;
45use hyperactor::mailbox::UndeliverableMessageError;
46use hyperactor::mailbox::UndeliverableReason;
47use hyperactor::supervision::ActorSupervisionEvent;
48use hyperactor_config::Flattrs;
49use hyperactor_mesh::ProcMeshRef;
50use hyperactor_mesh::actor_mesh::ACTOR_MESH_ID;
51use hyperactor_mesh::actor_mesh::ActorMeshRef;
52use hyperactor_mesh::casting::CAST_POINT;
53use hyperactor_mesh::casting::CastInfo;
54use hyperactor_mesh::casting::update_undeliverable_envelope_for_casting;
55use hyperactor_mesh::host_mesh::HostMeshRef;
56use hyperactor_mesh::introspect::ActiveHandler;
57use hyperactor_mesh::introspect::EXECUTION;
58use hyperactor_mesh::introspect::Execution;
59use hyperactor_mesh::supervision::MeshFailure;
60use hyperactor_mesh::transport::default_bind_spec;
61use hyperactor_mesh::value_mesh::ValueOverlay;
62use monarch_types::PickledPyObject;
63use monarch_types::SerializablePyErr;
64use ndslice::Point;
65use ndslice::extent;
66use pyo3::IntoPyObjectExt;
67use pyo3::exceptions::PyRuntimeError;
68use pyo3::prelude::*;
69use pyo3::types::PyDict;
70use pyo3::types::PyList;
71use pyo3::types::PyType;
72use serde::Deserialize;
73use serde::Serialize;
74use serde_multipart::Part;
75use typeuri::Named;
76
77use crate::buffers::FrozenBuffer;
78use crate::context::PyContext;
79use crate::context::PyInstance;
80use crate::handle::PyHandleCompleter;
81use crate::handle::handle_pair;
82// Preserve the crate-local conversion path used by host_mesh.
83pub(crate) use crate::handle::to_py_error;
84use crate::local_state_broker::BrokerId;
85use crate::local_state_broker::LocalStateBrokerMessage;
86use crate::mailbox::EitherPortRef;
87use crate::mailbox::PyMailbox;
88use crate::mailbox::PythonUndeliverableMessageEnvelope;
89use crate::pickle::PicklingState;
90use crate::pickle::pickle_to_part;
91use crate::proc::PyActorAddr;
92use crate::pympsc::Sender;
93use crate::runtime::GilSite;
94use crate::runtime::get_tokio_runtime;
95use crate::runtime::mark_actor_driver_thread;
96use crate::runtime::mark_actor_event_loop_thread;
97use crate::runtime::monarch_with_gil;
98use crate::runtime::monarch_with_gil_blocking;
99use crate::supervision::PyMeshFailure;
100
101/// Whether a Python actor is sync (it declares at least one endpoint, and every
102/// endpoint is a plain `def`) or async. Python's `_actor_kind` decides it for a
103/// class spawned on a ProcMesh; every other construction route states it
104/// (SA-5, defined in `monarch._src.actor.actor_mesh`).
105#[pyclass(
106    eq,
107    eq_int,
108    frozen,
109    module = "monarch._rust_bindings.monarch_hyperactor.actor"
110)]
111#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
112pub enum ActorKind {
113    /// An actor with at least one endpoint, all declared with `def`.
114    #[pyo3(name = "SYNC")]
115    Sync,
116    /// Any other actor, including one with no endpoints.
117    #[pyo3(name = "ASYNC")]
118    Async,
119}
120
121#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
122#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
123pub enum UnflattenArg {
124    Mailbox,
125    PyObject,
126}
127
128#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
129#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
130pub enum MethodSpecifier {
131    /// Call method 'name', send its return value to the response port.
132    ReturnsResponse { name: String },
133    /// Call method 'name', send the response port as the first argument.
134    ExplicitPort { name: String },
135    /// Construct the object
136    Init {},
137}
138
139impl std::fmt::Display for MethodSpecifier {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        write!(f, "{}", self.name())
142    }
143}
144
145#[pymethods]
146impl MethodSpecifier {
147    #[getter(name)]
148    fn py_name(&self) -> &str {
149        self.name()
150    }
151}
152
153impl MethodSpecifier {
154    pub(crate) fn name(&self) -> &str {
155        match self {
156            MethodSpecifier::ReturnsResponse { name } => name,
157            MethodSpecifier::ExplicitPort { name } => name,
158            MethodSpecifier::Init {} => "__init__",
159        }
160    }
161}
162
163/// The payload of a single actor response, without rank information.
164///
165/// The rank is captured by the overlay's range key, so it is stripped
166/// from the value to enable RLE dedup: two ranks returning the same
167/// payload will have byte-identical values and can be coalesced into
168/// a single run.
169#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
170pub enum PythonResponseMessage {
171    Result {
172        part: serde_multipart::Part,
173        refs: Vec<MeshRef>,
174    },
175    Exception {
176        part: serde_multipart::Part,
177        refs: Vec<MeshRef>,
178    },
179}
180
181wirevalue::register_type!(PythonResponseMessage);
182wirevalue::register_type!(ValueOverlay<PythonResponseMessage>);
183
184impl PythonResponseMessage {
185    /// Decode this response's payload, reuniting its out-of-band `refs` table
186    /// so mesh references reconstruct. Mirrors [`PythonMessage::decode`] for the
187    /// accumulated (valuemesh / `.call()`) path.
188    /// `instance` is the receiving actor, required so a `Port` in the payload
189    /// reconstructs against it rather than falling back to `context()` on a
190    /// Tokio worker. Mandatory rather than optional: this is only ever called
191    /// from an eager collector, all of which hold the caller instance.
192    pub(crate) fn decode(
193        &self,
194        py: Python<'_>,
195        instance: &Instance<PythonActor>,
196    ) -> PyResult<Py<PyAny>> {
197        let (part, refs) = match self {
198            PythonResponseMessage::Result { part, refs }
199            | PythonResponseMessage::Exception { part, refs } => (part, refs),
200        };
201        let mesh_references = refs.iter().cloned().map(Some).collect();
202        let mut state = PicklingState::from_parts(part.clone(), VecDeque::new(), mesh_references);
203        state.unpickle_with_receiver(py, instance)
204    }
205}
206
207/// Newtype wrapper around [`ValueOverlay<PythonResponseMessage>`] needed
208/// because `PythonMessageKind` is a `#[pyclass]` enum, requiring all variant
209/// fields to implement PyO3 traits. `ValueOverlay` is defined in another crate
210/// and does not implement `PyClass`.
211#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
212#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
213pub struct AccumulatedResponses(ValueOverlay<PythonResponseMessage>);
214
215#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
216#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq)]
217pub enum PythonMessageKind {
218    #[pyo3(constructor = (name, response_port, correlation_id=None))]
219    CallMethod {
220        name: MethodSpecifier,
221        response_port: Option<EitherPortRef>,
222        correlation_id: Option<u64>,
223    },
224    Result {
225        rank: Option<usize>,
226    },
227    Exception {
228        rank: Option<usize>,
229    },
230    Uninit {},
231    #[pyo3(constructor = (name, local_state_broker, id, unflatten_args, correlation_id=None))]
232    CallMethodIndirect {
233        name: MethodSpecifier,
234        local_state_broker: (String, usize),
235        id: usize,
236        // specify whether the argument to unflatten the local mailbox,
237        // or the next argument of the local state.
238        unflatten_args: Vec<UnflattenArg>,
239        correlation_id: Option<u64>,
240    },
241    AccumulatedResponses(AccumulatedResponses),
242}
243wirevalue::register_type!(PythonMessageKind);
244
245impl Default for PythonMessageKind {
246    fn default() -> Self {
247        PythonMessageKind::Uninit {}
248    }
249}
250
251/// A serializable reference to a mesh (actor, proc, or host).
252///
253/// Serialized as a typed multipart part via [`MeshRefRepr`]: under the multipart
254/// serializer each `MeshRef` becomes its own typed part, and inline bincode
255/// elsewhere.
256#[derive(Clone, Debug, Named, PartialEq, Eq)]
257pub enum MeshRef {
258    Actor(Box<ActorMeshRef<PythonActor>>),
259    Proc(Box<ProcMeshRef>),
260    Host(Box<HostMeshRef>),
261}
262
263/// Wire representation of [`MeshRef`] stored in a typed multipart part.
264#[doc(hidden)]
265#[derive(Clone, Debug, Serialize, Deserialize, Named)]
266pub enum MeshRefRepr {
267    Actor(Box<ActorMeshRef<PythonActor>>),
268    Proc(Box<ProcMeshRef>),
269    Host(Box<HostMeshRef>),
270}
271
272impl TryFrom<&MeshRef> for MeshRefRepr {
273    type Error = serde_multipart::Error;
274    fn try_from(m: &MeshRef) -> serde_multipart::Result<Self> {
275        Ok(match m {
276            MeshRef::Actor(r) => MeshRefRepr::Actor(r.clone()),
277            MeshRef::Proc(r) => MeshRefRepr::Proc(r.clone()),
278            MeshRef::Host(r) => MeshRefRepr::Host(r.clone()),
279        })
280    }
281}
282
283impl TryFrom<MeshRefRepr> for MeshRef {
284    type Error = serde_multipart::Error;
285    fn try_from(r: MeshRefRepr) -> serde_multipart::Result<Self> {
286        Ok(match r {
287            MeshRefRepr::Actor(r) => MeshRef::Actor(r),
288            MeshRefRepr::Proc(r) => MeshRef::Proc(r),
289            MeshRefRepr::Host(r) => MeshRef::Host(r),
290        })
291    }
292}
293
294serde_multipart::part_codec! {
295    impl MeshRef
296    {
297        type Repr = MeshRefRepr;
298    }
299}
300
301impl MeshRef {
302    /// Reconstruct the Python mesh wrapper this reference points at.
303    ///
304    /// Mirrors the `py_*_from_bytes` reconstructors, but takes an
305    /// already-deserialized [`MeshRef`] from the message's `refs` table.
306    pub(crate) fn reconstruct(self, py: Python<'_>) -> PyResult<Py<PyAny>> {
307        match self {
308            MeshRef::Proc(r) => {
309                Ok(Py::new(py, crate::proc_mesh::PyProcMesh::new_ref(*r))?.into_any())
310            }
311            MeshRef::Host(r) => {
312                Ok(Py::new(py, crate::host_mesh::PyHostMesh::new_ref(*r))?.into_any())
313            }
314            MeshRef::Actor(r) => {
315                let inner = crate::actor_mesh::PythonActorMeshImpl::new_ref(*r);
316                let async_mesh = crate::actor_mesh::AsyncActorMesh::from_impl(Arc::new(inner));
317                let mesh = crate::actor_mesh::PythonActorMesh::from_impl(Arc::from(async_mesh));
318                Ok(Py::new(py, mesh)?.into_any())
319            }
320        }
321    }
322}
323
324/// An opaque carrier so a `MeshRef` can ride in `PythonMessage.refs` across
325/// the Python boundary (the message getter out, the `PicklingState` ctor in).
326#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
327pub struct PyMeshRef {
328    pub(crate) inner: MeshRef,
329}
330
331impl<'py> IntoPyObject<'py> for MeshRef {
332    type Target = PyMeshRef;
333    type Output = Bound<'py, PyMeshRef>;
334    type Error = PyErr;
335
336    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
337        Bound::new(py, PyMeshRef { inner: self })
338    }
339}
340
341/// Extract the serializable [`MeshRef`] from a resolved mesh wrapper (the
342/// inverse of [`MeshRef::reconstruct`]), for the sender-side pending fill.
343pub(crate) fn mesh_ref_from_pyobject(value: &Bound<'_, PyAny>) -> PyResult<MeshRef> {
344    if let Ok(m) = value.cast::<crate::proc_mesh::PyProcMesh>() {
345        return Ok(MeshRef::Proc(Box::new(m.borrow().mesh_ref()?)));
346    }
347    if let Ok(m) = value.cast::<crate::host_mesh::PyHostMesh>() {
348        return Ok(MeshRef::Host(Box::new(m.borrow().mesh_ref().map_err(
349            |e| pyo3::exceptions::PyValueError::new_err(e.to_string()),
350        )?)));
351    }
352    if let Ok(m) = value.cast::<crate::actor_mesh::PythonActorMesh>() {
353        return Ok(MeshRef::Actor(Box::new(m.borrow().get_inner().mesh_ref()?)));
354    }
355    Err(pyo3::exceptions::PyRuntimeError::new_err(
356        "pending pickle did not resolve to a mesh reference",
357    ))
358}
359
360#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
361#[derive(Clone, Serialize, Deserialize, Named, Default, PartialEq)]
362pub struct PythonMessage {
363    pub kind: PythonMessageKind,
364    pub message: Part,
365    /// Mesh references carried out-of-band from the pickled `message`.
366    pub refs: Vec<MeshRef>,
367}
368
369/// Extract the endpoint method name from a [`PythonMessage`].
370fn python_message_endpoint_name(msg: &PythonMessage) -> Option<String> {
371    match &msg.kind {
372        PythonMessageKind::CallMethod { name, .. }
373        | PythonMessageKind::CallMethodIndirect { name, .. } => Some(name.name().to_string()),
374        _ => None,
375    }
376}
377
378// We use manual `submit!` instead of `register_type!` because PythonMessage is a
379// struct, so the default `endpoint_name` (which delegates to `arm_unchecked`)
380// always returns None. The custom implementation inspects `PythonMessageKind` to
381// extract the method name. This registration handles direct (non-cast) dispatch.
382wirevalue::submit! {
383    wirevalue::TypeInfo {
384        typename: <PythonMessage as wirevalue::Named>::typename,
385        typehash: <PythonMessage as wirevalue::Named>::typehash,
386        typeid: <PythonMessage as wirevalue::Named>::typeid,
387        port: <PythonMessage as wirevalue::Named>::port,
388        dump: Some(<PythonMessage as wirevalue::NamedDumpable>::dump),
389        arm_unchecked: <PythonMessage as wirevalue::Named>::arm_unchecked,
390        endpoint_name: |ptr| {
391            // SAFETY: ptr points to a PythonMessage.
392            let msg = unsafe { &*(ptr as *const PythonMessage) };
393            python_message_endpoint_name(msg)
394        },
395    }
396}
397
398impl From<ValueOverlay<PythonResponseMessage>> for PythonMessage {
399    fn from(overlay: ValueOverlay<PythonResponseMessage>) -> Self {
400        PythonMessage {
401            kind: PythonMessageKind::AccumulatedResponses(AccumulatedResponses(overlay)),
402            message: Default::default(),
403            refs: Vec::new(),
404        }
405    }
406}
407
408impl PythonMessage {
409    /// Consume this message and extract a `ValueOverlay<PythonResponseMessage>`.
410    ///
411    /// Handles both already-collected responses and leaf `Result`/`Exception`
412    /// messages by wrapping them in a single-run overlay.
413    pub fn into_overlay(self) -> anyhow::Result<ValueOverlay<PythonResponseMessage>> {
414        match self.kind {
415            PythonMessageKind::AccumulatedResponses(overlay) => Ok(overlay.0),
416            PythonMessageKind::Result { rank, .. } => {
417                let rank = rank.expect("accumulated response should have a rank");
418                let mut overlay = ValueOverlay::new();
419                overlay.push_run(
420                    rank..rank + 1,
421                    PythonResponseMessage::Result {
422                        part: self.message,
423                        refs: self.refs,
424                    },
425                )?;
426                Ok(overlay)
427            }
428            PythonMessageKind::Exception { rank, .. } => {
429                let rank = rank.expect("accumulated exception should have a rank");
430                let mut overlay = ValueOverlay::new();
431                overlay.push_run(
432                    rank..rank + 1,
433                    PythonResponseMessage::Exception {
434                        part: self.message,
435                        refs: self.refs,
436                    },
437                )?;
438                Ok(overlay)
439            }
440            other => {
441                anyhow::bail!(
442                    "unexpected message kind {:?} in collected responses reducer",
443                    other
444                );
445            }
446        }
447    }
448}
449
450struct ResolvedCallMethod {
451    method: MethodSpecifier,
452    bytes: FrozenBuffer,
453    local_state: PendingLocalState,
454    mesh_references: Vec<MeshRef>,
455    /// Implements PortProtocol
456    /// Concretely either a Port, DroppingPort, or LocalPort
457    response_port: ResponsePort,
458    correlation_id: Option<u64>,
459}
460
461enum ResponsePort {
462    Dropping,
463    Port(Port),
464    Local(LocalPort),
465}
466
467impl ResponsePort {
468    fn into_py_any(self, py: Python<'_>) -> PyResult<Py<PyAny>> {
469        match self {
470            ResponsePort::Dropping => DroppingPort.into_py_any(py),
471            ResponsePort::Port(port) => port.into_py_any(py),
472            ResponsePort::Local(port) => port.into_py_any(py),
473        }
474    }
475}
476
477enum PendingLocalState {
478    Empty,
479    Indirect {
480        mailbox: PyMailbox,
481        unflatten_args: Vec<UnflattenArg>,
482        state: Vec<Py<PyAny>>,
483    },
484}
485
486impl PendingLocalState {
487    fn into_py_any(self, py: Python<'_>) -> PyResult<Py<PyAny>> {
488        match self {
489            PendingLocalState::Empty => Ok(PyList::empty(py).into_any().unbind()),
490            PendingLocalState::Indirect {
491                mailbox,
492                unflatten_args,
493                state,
494            } => {
495                let mailbox = mailbox.into_bound_py_any(py)?;
496                let mut state = state.into_iter();
497                let items = unflatten_args.into_iter().map(|arg| match arg {
498                    UnflattenArg::Mailbox => mailbox.clone(),
499                    UnflattenArg::PyObject => state
500                        .next()
501                        .expect("local state broker should return an object per PyObject arg")
502                        .into_bound(py),
503                });
504                Ok(PyList::new(py, items)?.into_any().unbind())
505            }
506        }
507    }
508}
509
510/// A resolved message queued without taking the GIL. The actor's Python
511/// execution thread converts it into a [`QueuedMessage`]. Taking the GIL on the
512/// Tokio worker instead can leave the runtime's I/O driver unpolled while
513/// another thread holds the GIL, stalling unrelated Rust tasks in the proc
514/// (https://github.com/meta-pytorch/monarch/issues/4938).
515struct PendingMessage {
516    instance: Arc<Py<PyInstance>>,
517    rank: Point,
518    recording_span: tracing::Span,
519    resolved: ResolvedCallMethod,
520    telemetry_message_id: Option<u64>,
521}
522
523impl<'py> IntoPyObject<'py> for PendingMessage {
524    type Target = QueuedMessage;
525    type Output = Bound<'py, QueuedMessage>;
526    type Error = PyErr;
527
528    /// On failure, reports the message as failed: `handle` has already
529    /// returned, and no `QueuedMessage` exists to call `_report_failed`.
530    fn into_pyobject(self, py: Python<'py>) -> PyResult<Self::Output> {
531        let telemetry_message_id = self.telemetry_message_id;
532        self.into_queued(py)
533            .inspect_err(|_| report_message_status(telemetry_message_id, "failed"))
534    }
535}
536
537impl PendingMessage {
538    fn into_queued(self, py: Python<'_>) -> PyResult<Bound<'_, QueuedMessage>> {
539        let context = crate::context::PyContext::from_parts(
540            self.instance.clone_ref(py),
541            self.rank,
542            Some(self.recording_span),
543        );
544        let resolved = self.resolved;
545        Bound::new(
546            py,
547            QueuedMessage {
548                context: Py::new(py, context)?,
549                method: resolved.method,
550                bytes: resolved.bytes,
551                local_state: resolved.local_state.into_py_any(py)?,
552                refs: resolved.mesh_references.into_py_any(py)?,
553                response_port: resolved.response_port.into_py_any(py)?,
554                telemetry_message_id: self.telemetry_message_id,
555                correlation_id: resolved.correlation_id,
556            },
557        )
558    }
559}
560
561/// Message sent through the queue in queue-dispatch mode.
562/// Contains pre-resolved components ready for Python consumption.
563#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
564pub struct QueuedMessage {
565    #[pyo3(get)]
566    pub context: Py<crate::context::PyContext>,
567    #[pyo3(get)]
568    pub method: MethodSpecifier,
569    #[pyo3(get)]
570    pub bytes: FrozenBuffer,
571    #[pyo3(get)]
572    pub local_state: Py<PyAny>,
573    #[pyo3(get)]
574    pub refs: Py<PyAny>,
575    #[pyo3(get)]
576    pub response_port: Py<PyAny>,
577    telemetry_message_id: Option<u64>,
578    #[pyo3(get)]
579    pub correlation_id: Option<u64>,
580}
581
582fn report_message_status(message_id: Option<u64>, status: &str) {
583    if let Some(message_id) = message_id {
584        hyperactor_telemetry::notify_message_status(hyperactor_telemetry::MessageStatusEvent {
585            timestamp: std::time::SystemTime::now(),
586            id: hyperactor_telemetry::generate_status_event_id(message_id),
587            message_id,
588            status: status.to_string(),
589        });
590    }
591}
592
593#[pymethods]
594impl QueuedMessage {
595    fn _report_complete(&self) {
596        report_message_status(self.telemetry_message_id, "complete");
597    }
598
599    fn _report_failed(&self) {
600        report_message_status(self.telemetry_message_id, "failed");
601    }
602}
603
604impl PythonMessage {
605    pub fn new_from_buf(kind: PythonMessageKind, message: impl Into<Part>) -> Self {
606        Self::new_from_buf_with_refs(kind, message, Vec::new())
607    }
608
609    pub fn new_from_buf_with_refs(
610        kind: PythonMessageKind,
611        message: impl Into<Part>,
612        refs: Vec<MeshRef>,
613    ) -> Self {
614        Self {
615            kind,
616            message: message.into(),
617            refs,
618        }
619    }
620
621    pub fn into_rank(self, rank: usize) -> Self {
622        let rank = Some(rank);
623        match self.kind {
624            PythonMessageKind::Result { .. } => PythonMessage {
625                kind: PythonMessageKind::Result { rank },
626                message: self.message,
627                refs: self.refs,
628            },
629            PythonMessageKind::Exception { .. } => PythonMessage {
630                kind: PythonMessageKind::Exception { rank },
631                message: self.message,
632                refs: self.refs,
633            },
634            _ => panic!("PythonMessage is not a response but {:?}", self),
635        }
636    }
637    async fn resolve_indirect_call(
638        self,
639        cx: &Context<'_, PythonActor>,
640    ) -> anyhow::Result<ResolvedCallMethod> {
641        match self.kind {
642            PythonMessageKind::CallMethodIndirect {
643                name,
644                local_state_broker,
645                id,
646                unflatten_args,
647                correlation_id,
648            } => {
649                let broker = BrokerId::new(local_state_broker).resolve(cx).await;
650                let (send, recv) = cx.open_once_port();
651                broker.post(cx, LocalStateBrokerMessage::Get(id, send));
652                let state = recv.recv().await?;
653                Ok(ResolvedCallMethod {
654                    method: name,
655                    bytes: FrozenBuffer {
656                        inner: self.message.into_bytes(),
657                    },
658                    local_state: PendingLocalState::Indirect {
659                        mailbox: cx.mailbox_for_py().clone().into(),
660                        unflatten_args,
661                        state: state.state,
662                    },
663                    mesh_references: self.refs,
664                    response_port: ResponsePort::Local(LocalPort {
665                        instance: cx.into(),
666                        inner: Some(state.response_port),
667                    }),
668                    correlation_id,
669                })
670            }
671            PythonMessageKind::CallMethod {
672                name,
673                response_port,
674                correlation_id,
675            } => {
676                let method_name = name.name().to_string();
677                let response_port = response_port.map_or(ResponsePort::Dropping, |port_ref| {
678                    let point = cx.cast_point();
679                    // Carry operation context onto the reply: copy
680                    // OPERATION_*-marked keys from the inbound
681                    // envelope, falling back to the method name when
682                    // the caller didn't stamp.
683                    let mut reply_headers = hyperactor_config::Flattrs::new();
684                    hyperactor_config::attrs::copy_marked_flattrs(
685                        &mut reply_headers,
686                        cx.headers(),
687                        hyperactor_config::attrs::OPERATION_CONTEXT_HEADER,
688                    );
689                    if reply_headers
690                        .get(hyperactor::mailbox::headers::OPERATION_ENDPOINT)
691                        .is_none()
692                    {
693                        reply_headers.set(
694                            hyperactor::mailbox::headers::OPERATION_ENDPOINT,
695                            format!("{}()", method_name),
696                        );
697                    }
698                    ResponsePort::Port(Port::with_reply_headers(
699                        port_ref,
700                        cx.instance().clone_for_py(),
701                        Some(point.rank()),
702                        reply_headers,
703                    ))
704                });
705                Ok(ResolvedCallMethod {
706                    method: name,
707                    bytes: FrozenBuffer {
708                        inner: self.message.into_bytes(),
709                    },
710                    local_state: PendingLocalState::Empty,
711                    mesh_references: self.refs,
712                    response_port,
713                    correlation_id,
714                })
715            }
716            _ => {
717                panic!("unexpected message kind {:?}", self.kind)
718            }
719        }
720    }
721}
722
723impl std::fmt::Debug for PythonMessage {
724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725        f.debug_struct("PythonMessage")
726            .field("kind", &self.kind)
727            .field(
728                "message",
729                &wirevalue::HexFmt(&(*self.message.to_bytes())[..]).to_string(),
730            )
731            .field("refs", &self.refs.len())
732            .finish()
733    }
734}
735
736#[pymethods]
737impl PythonMessage {
738    #[new]
739    #[pyo3(signature = (kind, message, refs))]
740    pub fn new(
741        kind: PythonMessageKind,
742        message: PyRef<'_, FrozenBuffer>,
743        refs: &Bound<'_, PyList>,
744    ) -> PyResult<Self> {
745        let mesh_refs: Vec<MeshRef> = refs
746            .iter()
747            .map(|item| Ok(item.cast::<PyMeshRef>()?.borrow().inner.clone()))
748            .collect::<PyResult<_>>()?;
749        Ok(PythonMessage::new_from_buf_with_refs(
750            kind,
751            message.inner.clone(),
752            mesh_refs,
753        ))
754    }
755
756    #[getter]
757    fn kind(&self) -> PythonMessageKind {
758        self.kind.clone()
759    }
760
761    /// Decode this message's payload, reuniting the out-of-band `refs` table so
762    /// the `pop_mesh_reference` sentinels in the pickle stream resolve. The raw
763    /// bytes are deliberately not exposed: a payload can only be read back
764    /// through here, so a decode can never silently drop mesh references.
765    #[pyo3(signature = (local_state=None))]
766    fn decode(
767        &self,
768        py: Python<'_>,
769        local_state: Option<&Bound<'_, PyList>>,
770    ) -> PyResult<Py<PyAny>> {
771        let tensor_engine_references: VecDeque<Py<PyAny>> = local_state
772            .map(|list| list.iter().map(|item| item.unbind()).collect())
773            .unwrap_or_default();
774        let mesh_references: VecDeque<Option<MeshRef>> =
775            self.refs.iter().cloned().map(Some).collect();
776        let mut state = PicklingState::from_parts(
777            self.message.clone(),
778            tensor_engine_references,
779            mesh_references,
780        );
781        state.unpickle(py)
782    }
783
784    #[getter]
785    fn refs(&self) -> Vec<MeshRef> {
786        self.refs.clone()
787    }
788}
789
790#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
791pub(super) struct PythonActorHandle {
792    pub(super) inner: ActorHandle<PythonActor>,
793}
794
795#[pymethods]
796impl PythonActorHandle {
797    // TODO: do the pickling in rust
798    fn send(&self, instance: &PyInstance, message: &PythonMessage) -> PyResult<()> {
799        self.inner.post(instance.deref(), message.clone());
800        Ok(())
801    }
802
803    fn bind(&self) -> PyActorAddr {
804        self.inner.bind::<PythonActor>().into_actor_addr().into()
805    }
806}
807
808// In-flight handler execution tracking for a Python actor -- the producer
809// side of the mesh `execution` field. Per-actor Rust state, read GIL-free
810// by the introspect seam. Producer invariants (PE-*, monarch_hyperactor-
811// local; documented inline, no registry -- not our crate):
812//   PE-2: only the real user-method invocation is bracketed (in
813//         `_Actor.handle`), never Init/unpickling/plumbing.
814//   PE-3: the snapshot reads `Arc` state only (atomic load + `try_lock`);
815//         the `Mutex` is held only for an insert/remove, never across user
816//         code, so a handler wedged holding the GIL never blocks the
817//         snapshot and the read never touches the GIL.
818//   PE-4: tokens are >= 1; `0` is a reserved no-op sentinel (returned by
819//         the binding when an instance has no tracker), so the
820//         unconditional Python `finally` cannot collide it with a real
821//         token.
822
823/// EX-4 cap: at most this many distinct in-flight handler names are
824/// reported per snapshot; `truncated` is set when exceeded.
825const MAX_ACTIVE_HANDLERS: usize = 64;
826
827/// One in-flight handler invocation.
828#[derive(Debug)]
829struct ActiveEntry {
830    name: String,
831    started_at: SystemTime,
832}
833
834/// Per-actor in-flight handler tracker. Cheap to read concurrently: the
835/// count is a lock-free atomic and the per-handler detail sits behind a
836/// `try_lock` held only for an insert/remove (never across user code), so
837/// a wedged actor stays introspectable (PE-3).
838#[derive(Debug)]
839pub(crate) struct ExecutionTracker {
840    /// Lock-free count of in-flight invocations; always readable.
841    active_count: AtomicU64,
842    /// Monotonic token source, initialized to 1 so issued tokens are
843    /// `>= 1` and `0` stays reserved as the no-op sentinel (PE-4).
844    next_token: AtomicU64,
845    /// token -> entry for the in-flight invocations.
846    handlers: Mutex<HashMap<u64, ActiveEntry>>,
847}
848
849/// Aggregate raw in-flight entries into the reported per-handler view:
850/// grouped by handler name, oldest-first with a stable tie-break on
851/// `name`, capped at `max` (EX-4). Pure, so it can be unit-tested with
852/// explicit timestamps.
853fn aggregate_active(
854    handlers: &HashMap<u64, ActiveEntry>,
855    max: usize,
856) -> (Vec<ActiveHandler>, bool) {
857    let mut by_name: HashMap<&str, (u64, SystemTime)> = HashMap::new();
858    for entry in handlers.values() {
859        let slot = by_name
860            .entry(entry.name.as_str())
861            .or_insert((0, entry.started_at));
862        slot.0 += 1;
863        if entry.started_at < slot.1 {
864            slot.1 = entry.started_at;
865        }
866    }
867    let mut out: Vec<ActiveHandler> = by_name
868        .into_iter()
869        .map(|(name, (active_count, oldest_since))| ActiveHandler {
870            name: name.to_string(),
871            active_count,
872            oldest_since,
873        })
874        .collect();
875    // EX-4: oldest-first, stable tie-break on name.
876    out.sort_by(|a, b| {
877        a.oldest_since
878            .cmp(&b.oldest_since)
879            .then_with(|| a.name.cmp(&b.name))
880    });
881    let truncated = out.len() > max;
882    if truncated {
883        out.truncate(max);
884    }
885    (out, truncated)
886}
887
888impl ExecutionTracker {
889    pub(crate) fn new() -> Self {
890        Self {
891            active_count: AtomicU64::new(0),
892            next_token: AtomicU64::new(1),
893            handlers: Mutex::new(HashMap::new()),
894        }
895    }
896
897    /// Record the start of a handler invocation; returns its token.
898    pub(crate) fn start(&self, name: String) -> u64 {
899        let token = self.next_token.fetch_add(1, AtomicOrdering::Relaxed);
900        self.handlers
901            .lock()
902            .unwrap_or_else(|e| e.into_inner())
903            .insert(
904                token,
905                ActiveEntry {
906                    name,
907                    started_at: SystemTime::now(),
908                },
909            );
910        self.active_count.fetch_add(1, AtomicOrdering::Relaxed);
911        token
912    }
913
914    /// Record the end of a handler invocation. Idempotent (a token is
915    /// removed at most once) and a no-op for the `0` sentinel (PE-4).
916    pub(crate) fn finish(&self, token: u64) {
917        if token == 0 {
918            return;
919        }
920        let removed = self
921            .handlers
922            .lock()
923            .unwrap_or_else(|e| e.into_inner())
924            .remove(&token)
925            .is_some();
926        if removed {
927            self.active_count.fetch_sub(1, AtomicOrdering::Relaxed);
928        }
929    }
930
931    /// Point-in-time snapshot for the introspect seam. Never blocks: the
932    /// count is read lock-free and the per-handler detail is best-effort
933    /// behind `try_lock` (EX-2: a miss yields `complete: false`, it never
934    /// drops the field).
935    pub(crate) fn snapshot(&self) -> Execution {
936        let active_count = self.active_count.load(AtomicOrdering::Relaxed);
937        match self.handlers.try_lock() {
938            Ok(guard) => {
939                let (active_handlers, truncated) = aggregate_active(&guard, MAX_ACTIVE_HANDLERS);
940                Execution {
941                    active_count,
942                    active_handlers,
943                    complete: true,
944                    truncated,
945                }
946            }
947            Err(_) => Execution {
948                active_count,
949                active_handlers: Vec::new(),
950                complete: false,
951                truncated: false,
952            },
953        }
954    }
955}
956
957#[cfg(test)]
958mod execution_tracker_tests {
959    use std::time::Duration;
960    use std::time::UNIX_EPOCH;
961
962    use super::*;
963
964    fn at(secs: u64) -> SystemTime {
965        UNIX_EPOCH + Duration::from_secs(secs)
966    }
967
968    #[test]
969    fn aggregates_by_name_oldest_first() {
970        let mut h = HashMap::new();
971        h.insert(
972            1,
973            ActiveEntry {
974                name: "b".to_string(),
975                started_at: at(10),
976            },
977        );
978        h.insert(
979            2,
980            ActiveEntry {
981                name: "a".to_string(),
982                started_at: at(20),
983            },
984        );
985        h.insert(
986            3,
987            ActiveEntry {
988                name: "a".to_string(),
989                started_at: at(30),
990            },
991        );
992        let (out, truncated) = aggregate_active(&h, MAX_ACTIVE_HANDLERS);
993        assert!(!truncated);
994        assert_eq!(out.len(), 2);
995        // Oldest-first: b (10) before a (20).
996        assert_eq!(out[0].name, "b");
997        assert_eq!(out[0].active_count, 1);
998        assert_eq!(out[0].oldest_since, at(10));
999        // "a" aggregates two invocations; oldest_since is the min (20).
1000        assert_eq!(out[1].name, "a");
1001        assert_eq!(out[1].active_count, 2);
1002        assert_eq!(out[1].oldest_since, at(20));
1003    }
1004
1005    #[test]
1006    fn tie_break_on_name_when_same_oldest() {
1007        let mut h = HashMap::new();
1008        h.insert(
1009            1,
1010            ActiveEntry {
1011                name: "zebra".to_string(),
1012                started_at: at(5),
1013            },
1014        );
1015        h.insert(
1016            2,
1017            ActiveEntry {
1018                name: "alpha".to_string(),
1019                started_at: at(5),
1020            },
1021        );
1022        let (out, _) = aggregate_active(&h, MAX_ACTIVE_HANDLERS);
1023        assert_eq!(out[0].name, "alpha");
1024        assert_eq!(out[1].name, "zebra");
1025    }
1026
1027    #[test]
1028    fn truncates_to_n_oldest() {
1029        let mut h = HashMap::new();
1030        for i in 0..(MAX_ACTIVE_HANDLERS as u64 + 6) {
1031            h.insert(
1032                i,
1033                ActiveEntry {
1034                    name: format!("h{:03}", i),
1035                    started_at: at(i),
1036                },
1037            );
1038        }
1039        let (out, truncated) = aggregate_active(&h, MAX_ACTIVE_HANDLERS);
1040        assert!(truncated);
1041        assert_eq!(out.len(), MAX_ACTIVE_HANDLERS);
1042        // Prefix of the N oldest.
1043        assert_eq!(out[0].name, "h000");
1044        assert_eq!(
1045            out[MAX_ACTIVE_HANDLERS - 1].name,
1046            format!("h{:03}", MAX_ACTIVE_HANDLERS - 1)
1047        );
1048    }
1049
1050    #[test]
1051    fn start_assigns_nonzero_distinct_tokens() {
1052        let t = ExecutionTracker::new();
1053        let a = t.start("a".to_string());
1054        let b = t.start("b".to_string());
1055        assert!(a >= 1);
1056        assert!(b >= 1);
1057        assert_ne!(a, b);
1058        let snap = t.snapshot();
1059        assert_eq!(snap.active_count, 2);
1060        assert!(snap.complete);
1061        assert_eq!(snap.active_handlers.len(), 2);
1062    }
1063
1064    #[test]
1065    fn finish_is_idempotent_and_zero_is_noop() {
1066        let t = ExecutionTracker::new();
1067        let tok = t.start("a".to_string());
1068        t.finish(tok);
1069        assert_eq!(t.snapshot().active_count, 0);
1070        // Double-finish must not underflow the count.
1071        t.finish(tok);
1072        assert_eq!(t.snapshot().active_count, 0);
1073        // The 0 sentinel is a no-op.
1074        t.finish(0);
1075        assert_eq!(t.snapshot().active_count, 0);
1076    }
1077}
1078
1079/// An actor for which message handlers are implemented in Python.
1080#[derive(Debug)]
1081#[hyperactor::export(
1082    handlers = [
1083        PythonMessage,
1084        MeshFailure,
1085    ],
1086)]
1087#[hyperactor::spawnable]
1088pub struct PythonActor {
1089    /// The Python object that we delegate message handling to.
1090    actor: Py<PyAny>,
1091    /// The event-loop or driver state selected by the actor's kind.
1092    dispatch: Dispatch,
1093    /// Instance object that we keep across handle calls so that we can store
1094    /// information from the Init (spawn rank, controller) and provide it to other calls.
1095    /// The `Arc` lets handlers share it without the GIL.
1096    instance: Option<Arc<Py<crate::context::PyInstance>>>,
1097    /// Enqueues messages for the actor's Python dispatch path.
1098    message_sender: Sender,
1099    /// Enqueues callbacks for `_callback_loop` or the sync driver's control
1100    /// plane, and cleanup for the sync driver.
1101    control_sender: Sender,
1102    /// Number of enqueued callbacks whose verdict has not been reported yet.
1103    /// See [`CallbacksPending`].
1104    callbacks_pending: Arc<AtomicUsize>,
1105    /// Inherited or assigned construction context, not proof of mesh membership.
1106    construction_point: OnceLock<Option<Point>>,
1107    /// Initial message to process during PythonActor::init.
1108    init_message: Option<PythonMessage>,
1109
1110    /// Per-actor in-flight handler tracker (producer of the mesh
1111    /// `execution` field). Read GIL-free by the introspect seam; a clone
1112    /// of this `Arc` is injected into the actor's `PyInstance` so
1113    /// `_Actor.handle` can bracket each invocation.
1114    execution_tracker: Arc<ExecutionTracker>,
1115}
1116
1117/// Kind-specific state for running and stopping a [`PythonActor`].
1118#[derive(Debug)]
1119enum Dispatch {
1120    /// Event-loop-backed execution state.
1121    Async(async_actor::dispatch::State),
1122    /// Blocking-driver execution state.
1123    Sync(sync_actor::dispatch::State),
1124}
1125
1126/// Named result of selecting an actor's dispatch topology in
1127/// [`PythonActor::new`].
1128///
1129/// Both [`ActorKind`] branches produce the two common senders plus their
1130/// kind-specific [`Dispatch`] state. Named fields keep the same-typed message
1131/// and control senders distinct before all three values move into
1132/// [`PythonActor`].
1133struct DispatchParts {
1134    /// Sends endpoint messages to the actor's Python dispatch path.
1135    message_sender: Sender,
1136    /// Sends callback work and, for a sync actor, its cleanup request.
1137    control_sender: Sender,
1138    /// Owns the receivers and the kind-specific loop or driver lifecycle state.
1139    dispatch: Dispatch,
1140}
1141
1142/// Convert a wake-pipe creation failure into the error type returned across
1143/// the GIL-bound `PythonActor` constructor.
1144fn serialize_channel_error(py: Python<'_>, error: nix::Error) -> SerializablePyErr {
1145    let error = PyRuntimeError::new_err(error.to_string());
1146    SerializablePyErr::from(py, &error)
1147}
1148
1149impl DispatchParts {
1150    /// Construct the channels and lifecycle state selected by `kind`.
1151    fn try_new(py: Python<'_>, kind: ActorKind) -> Result<Self, SerializablePyErr> {
1152        match kind {
1153            ActorKind::Async => {
1154                let async_actor::inbox::Inbox {
1155                    message_sender,
1156                    control_sender,
1157                    receiver,
1158                } = async_actor::inbox::new()
1159                    .map_err(|error| serialize_channel_error(py, error))?;
1160                Ok(Self {
1161                    message_sender,
1162                    control_sender,
1163                    dispatch: Dispatch::Async(async_actor::dispatch::State::new(py, receiver)),
1164                })
1165            }
1166            ActorKind::Sync => {
1167                let sync_actor::inbox::Inbox {
1168                    message_sender,
1169                    control_sender,
1170                    receiver,
1171                } = sync_actor::inbox::new().map_err(|error| serialize_channel_error(py, error))?;
1172                Ok(Self {
1173                    message_sender,
1174                    control_sender,
1175                    dispatch: Dispatch::Sync(sync_actor::dispatch::State::new(receiver)),
1176                })
1177            }
1178        }
1179    }
1180}
1181
1182impl PythonActor {
1183    pub(crate) fn new(
1184        actor_type: PickledPyObject,
1185        init_message: Option<PythonMessage>,
1186        construction_point: Option<Point>,
1187        kind: ActorKind,
1188    ) -> Result<Self, anyhow::Error> {
1189        Ok(monarch_with_gil_blocking(
1190            GilSite::ActorConstruct,
1191            |py| -> Result<Self, SerializablePyErr> {
1192                let unpickled = actor_type.unpickle(py)?;
1193                let class_type: &Bound<'_, PyType> = unpickled.cast()?;
1194                let actor: Py<PyAny> = class_type.call0()?.into_py_any(py)?;
1195
1196                let DispatchParts {
1197                    message_sender,
1198                    control_sender,
1199                    dispatch,
1200                } = DispatchParts::try_new(py, kind)?;
1201
1202                Ok(Self {
1203                    actor,
1204                    dispatch,
1205                    instance: None,
1206                    message_sender,
1207                    control_sender,
1208                    callbacks_pending: Arc::new(AtomicUsize::new(0)),
1209                    construction_point: OnceLock::from(construction_point),
1210                    init_message,
1211                    execution_tracker: Arc::new(ExecutionTracker::new()),
1212                })
1213            },
1214        )?)
1215    }
1216
1217    /// Get-or-create the actor's cached `PyInstance`, injecting a clone of
1218    /// the execution tracker (PE-1) so `_Actor.handle` can bracket each
1219    /// invocation. Its only caller is `init`; `handle_queue` and the
1220    /// `MeshFailure` handler expect the instance it created.
1221    fn ensure_py_instance(
1222        &mut self,
1223        py: Python<'_>,
1224        src: impl Into<crate::context::PyInstance>,
1225    ) -> Py<crate::context::PyInstance> {
1226        let tracker = self.execution_tracker.clone();
1227        self.instance
1228            .get_or_insert_with(|| {
1229                let mut inst: crate::context::PyInstance = src.into();
1230                inst.set_execution_tracker(tracker);
1231                Arc::new(inst.into_pyobject(py).unwrap().into())
1232            })
1233            .clone_ref(py)
1234    }
1235
1236    /// Bootstrap the root client actor, creating a new proc for it.
1237    /// This is the legacy entry point that creates its own proc.
1238    pub(crate) fn bootstrap_client(py: Python<'_>) -> (&'static Instance<Self>, ActorHandle<Self>) {
1239        static ROOT_CLIENT_INSTANCE: OnceLock<Instance<PythonActor>> = OnceLock::new();
1240
1241        let client_proc = Proc::direct(
1242            default_bind_spec().binding_addr(),
1243            "mesh_root_client_proc".into(),
1244        )
1245        .unwrap();
1246
1247        // The legacy path seeds no inherited capabilities.
1248        Self::bootstrap_client_inner(
1249            py,
1250            client_proc,
1251            ActorEnvironment::default(),
1252            &ROOT_CLIENT_INSTANCE,
1253        )
1254    }
1255
1256    /// Bootstrap the client proc, storing the root client instance in given static.
1257    /// This is passed in because we require storage, as the instance is shared.
1258    /// This can be simplified when we remove v0.
1259    ///
1260    /// `environment` is the root client's persistent
1261    /// [`ActorEnvironment`](hyperactor::ActorEnvironment): the caller seeds any
1262    /// inherited capabilities into it (e.g. the client-root reference) and
1263    /// descendants inherit them through the environment.
1264    pub(crate) fn bootstrap_client_inner(
1265        py: Python<'_>,
1266        client_proc: Proc,
1267        environment: ActorEnvironment,
1268        root_client_instance: &'static OnceLock<Instance<PythonActor>>,
1269    ) -> (&'static Instance<Self>, ActorHandle<Self>) {
1270        let actor_mesh_mod = py
1271            .import("monarch._src.actor.actor_mesh")
1272            .expect("import actor_mesh");
1273        let root_client_class = actor_mesh_mod
1274            .getattr("RootClientActor")
1275            .expect("get RootClientActor");
1276
1277        let actor_type =
1278            PickledPyObject::pickle(&actor_mesh_mod.getattr("_Actor").expect("get _Actor"))
1279                .expect("pickle _Actor");
1280
1281        let init_frozen_buffer: FrozenBuffer = root_client_class
1282            .call_method0("_pickled_init_args")
1283            .expect("call RootClientActor._pickled_init_args")
1284            .extract()
1285            .expect("extract FrozenBuffer from _pickled_init_args");
1286        let init_message = PythonMessage::new_from_buf(
1287            PythonMessageKind::CallMethod {
1288                name: MethodSpecifier::Init {},
1289                response_port: None,
1290                correlation_id: None,
1291            },
1292            init_frozen_buffer,
1293        );
1294
1295        let mut actor = PythonActor::new(
1296            actor_type,
1297            Some(init_message),
1298            Some(extent!().point_of_rank(0).unwrap()),
1299            ActorKind::Async,
1300        )
1301        .expect("create client PythonActor");
1302
1303        let ai = client_proc
1304            .actor_instance_in_environment(
1305                root_client_class
1306                    .getattr("name")
1307                    .expect("get RootClientActor.name")
1308                    .extract()
1309                    .expect("extract RootClientActor.name"),
1310                environment,
1311            )
1312            .expect("root instance create");
1313
1314        let handle = ai.handle;
1315        let signal_rx = ai.signal;
1316        let supervision_rx = ai.supervision;
1317        let work_rx = ai.work;
1318
1319        root_client_instance
1320            .set(ai.instance)
1321            .map_err(|_| "already initialized root client instance")
1322            .unwrap();
1323        let instance = root_client_instance.get().unwrap();
1324
1325        // The root client PythonActor uses a custom run loop that
1326        // bypasses Actor::init, so mark it as system explicitly
1327        // (matching GlobalClientActor::fresh_instance).
1328        instance.set_system();
1329
1330        // Bind to ensure the Undeliverable<MessageEnvelope> port is bound.
1331        let _client_ref = handle.bind::<PythonActor>();
1332
1333        get_tokio_runtime().spawn(async move {
1334            // This is gross. Sorry.
1335            actor.init(instance).await.unwrap();
1336
1337            let mut signal_rx = signal_rx;
1338            let mut supervision_rx = supervision_rx;
1339            let mut work_rx = work_rx;
1340            let mut need_drain = false;
1341            let mut err = loop {
1342                tokio::select! {
1343                    work = work_rx.recv() => {
1344                        let work = work.expect("inconsistent work queue state");
1345                        if let Err(err) = work.handle(&mut actor, instance).await {
1346                            let kind = ActorErrorKind::processing(err);
1347                            let err = ActorError {
1348                                actor_id: Box::new(instance.self_addr().clone()),
1349                                kind: Box::new(kind),
1350                            };
1351
1352                            // Only a `SupervisionOutcome` verdict fails with
1353                            // `UnhandledSupervisionEvent`. It is `__supervise__`'s
1354                            // final answer (including an `UnhandledFaultHookException`
1355                            // raised by `RootClientActor`), so exit rather than
1356                            // supervising it again, which could loop forever.
1357                            if matches!(*err.kind, ActorErrorKind::UnhandledSupervisionEvent(_)) {
1358                                break Some(err);
1359                            }
1360
1361                            // Give the actor a chance to handle the error produced
1362                            // in its own message handler. This is important because
1363                            // we want an undeliverable message that fails the actor
1364                            // (via `UndeliverableOutcome`) to create a supervision
1365                            // event and call __supervise__. This only enqueues the event, so
1366                            // keep handling messages until the `SupervisionOutcome`
1367                            // carrying the verdict is delivered.
1368                            let supervision_event = actor_error_to_event(instance, &actor, err);
1369                            if let Err(err) = instance.handle_supervision_event(&mut actor, supervision_event).await {
1370                                break Some(err);
1371                            }
1372                        }
1373                    }
1374                    signal = signal_rx.recv() => {
1375                        tracing::info!(actor_id = %instance.self_addr(), "client received signal {signal:?}");
1376                        match signal {
1377                            Some(signal@(Signal::Stop(_) | Signal::DrainAndStop(_))) => {
1378                                need_drain = matches!(signal, Signal::DrainAndStop(_));
1379                                break None;
1380                            },
1381                            Some(Signal::ExitRequested(_)) => break None,
1382                            Some(Signal::ChildStopped(_)) => {},
1383                            Some(Signal::Kill(reason)) => {
1384                                break Some(ActorError { actor_id: Box::new(instance.self_addr().clone()), kind: Box::new(ActorErrorKind::Aborted(reason)) })
1385                            },
1386                            None => {
1387                                break Some(ActorError {
1388                                    actor_id: Box::new(instance.self_addr().clone()),
1389                                    kind: Box::new(ActorErrorKind::SignalChannelClosed),
1390                                })
1391                            },
1392                        }
1393                    }
1394                    Some(supervision_event) = supervision_rx.recv() => {
1395                        if let Err(err) = instance.handle_supervision_event(&mut actor, supervision_event).await {
1396                            break Some(err);
1397                        }
1398                    }
1399                };
1400            };
1401            if need_drain {
1402                let mut n = 0;
1403                while let Ok(work) = work_rx.try_recv() {
1404                    if let Err(e) = work.handle(&mut actor, instance).await {
1405                        err = Some(ActorError {
1406                            actor_id: Box::new(instance.self_addr().clone()),
1407                            kind: Box::new(ActorErrorKind::processing(e)),
1408                        });
1409                        break;
1410                    }
1411                    n += 1;
1412                }
1413                tracing::debug!(actor_id = %instance.self_addr(), "client drained {} messages before stopping", n);
1414            }
1415            if let Some(err) = err {
1416                let event = actor_error_to_event(instance, &actor, err);
1417                // The proc supervision handler will send to ProcAgent, which
1418                // just records it in v1. We want to crash instead, as nothing will
1419                // monitor the client ProcAgent for now.
1420                tracing::error!(
1421                    actor_id = %instance.self_addr(),
1422                    "could not propagate supervision event {} because it reached the global client: signaling KeyboardInterrupt to main thread",
1423                    event,
1424                );
1425
1426                // This is running in a background thread, and thus cannot run
1427                // Py_FinalizeEx when it exits the process to properly shut down
1428                // all python objects.
1429                // We use _thread.interrupt_main to raise a KeyboardInterrupt
1430                // to the main thread at some point in the future.
1431                // There is no way to propagate the exception message, but it
1432                // will at least run proper shutdown code as long as BaseException
1433                // isn't caught.
1434                monarch_with_gil_blocking(GilSite::Stop, |py| {
1435                    // Use _thread.interrupt_main to force the client to exit if it has an
1436                    // unhandled supervision event.
1437                    let thread_mod = py.import("_thread").expect("import _thread");
1438                    let interrupt_main = thread_mod
1439                        .getattr("interrupt_main")
1440                        .expect("get interrupt_main");
1441
1442                    // Ignore any exception from calling interrupt_main
1443                    if let Err(e) = interrupt_main.call0() {
1444                        tracing::error!("unable to interrupt main, exiting the process instead: {:?}", e);
1445                        eprintln!("unable to interrupt main, exiting the process with code 1 instead: {:?}", e);
1446                        std::process::exit(1);
1447                    }
1448                });
1449            } else {
1450                tracing::info!(actor_id = %instance.self_addr(), "client stopped");
1451                instance.change_status(hyperactor::actor::ActorStatus::Stopped("client stopped".into()));
1452            }
1453        });
1454
1455        (root_client_instance.get().unwrap(), handle)
1456    }
1457}
1458
1459fn actor_error_to_event(
1460    instance: &Instance<PythonActor>,
1461    actor: &PythonActor,
1462    err: ActorError,
1463) -> ActorSupervisionEvent {
1464    match *err.kind {
1465        ActorErrorKind::UnhandledSupervisionEvent(event) => *event,
1466        _ => {
1467            let status = ActorStatus::generic_failure(err.kind.to_string());
1468            ActorSupervisionEvent::new(
1469                instance.self_addr().clone(),
1470                actor.display_name(),
1471                status,
1472                None,
1473            )
1474        }
1475    }
1476}
1477
1478pub(crate) fn root_client_actor(py: Python<'_>) -> &'static Instance<PythonActor> {
1479    static ROOT_CLIENT_ACTOR: OnceLock<&'static Instance<PythonActor>> = OnceLock::new();
1480
1481    // Release the GIL before waiting on ROOT_CLIENT_ACTOR, because PythonActor::bootstrap_client
1482    // may release/reacquire the GIL; if thread 0 holds the GIL blocking on ROOT_CLIENT_ACTOR.get_or_init
1483    // while thread 1 blocks on acquiring the GIL inside PythonActor::bootstrap_client, we get
1484    // a deadlock.
1485    py.detach(|| {
1486        ROOT_CLIENT_ACTOR.get_or_init(|| {
1487            monarch_with_gil_blocking(GilSite::Bootstrap, |py| {
1488                let (client, _handle) = PythonActor::bootstrap_client(py);
1489                client
1490            })
1491        })
1492    })
1493}
1494
1495#[async_trait]
1496impl Actor for PythonActor {
1497    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
1498        // PE-1: install the read side eagerly so the actor reports
1499        // `execution` from its first handled message. The callback runs on
1500        // the introspect task (off the actor loop) and only reads `Arc`
1501        // state (PE-3), so it is `Send + Sync`, non-blocking, and infallible.
1502        let tracker = self.execution_tracker.clone();
1503        this.set_attrs_snapshot(move || {
1504            let mut attrs = hyperactor_config::Attrs::new();
1505            attrs.set(EXECUTION, tracker.snapshot());
1506            attrs
1507        });
1508
1509        monarch_with_gil(GilSite::DispatchInit, |py| {
1510            let self_instance = self.ensure_py_instance(py, this);
1511            let actor_mesh_mod = py.import("monarch._src.actor.actor_mesh")?;
1512
1513            match &mut self.dispatch {
1514                Dispatch::Async(state) => state.init(
1515                    py,
1516                    &actor_mesh_mod,
1517                    &self.actor,
1518                    &self_instance,
1519                    self.callbacks_pending.clone(),
1520                )?,
1521                Dispatch::Sync(state) => {
1522                    state.init(py, &actor_mesh_mod, &self.actor, &self_instance)?
1523                }
1524            }
1525            Ok::<_, anyhow::Error>(())
1526        })
1527        .await?;
1528
1529        if let Some(init_message) = self.init_message.take() {
1530            let construction_point = self.construction_point.get().unwrap().as_ref().expect("PythonActor should never be spawned with init_message unless construction_point is also specified").clone();
1531            let mut headers = Flattrs::new();
1532            headers.set(CAST_POINT, construction_point);
1533            let cx = Context::new(this, headers);
1534            <Self as Handler<PythonMessage>>::handle(self, &cx, init_message).await?;
1535        }
1536
1537        Ok(())
1538    }
1539
1540    async fn cleanup(
1541        &mut self,
1542        this: &Instance<Self>,
1543        err: Option<&ActorError>,
1544    ) -> anyhow::Result<()> {
1545        // Calls the "__cleanup__" method on the python instance to allow the actor
1546        // to control its own cleanup.
1547        // No headers because this isn't in the context of a message.
1548        let cx = Context::new(this, Flattrs::new());
1549        // Turn the ActorError into a representation of the error. We may not
1550        // have an original exception object or traceback, so we just pass in
1551        // the message.
1552        let error = err.map(|e| e.to_string());
1553        let actor = &self.actor;
1554        let instance = self.instance.clone();
1555        let control_sender = &self.control_sender;
1556        match &mut self.dispatch {
1557            Dispatch::Async(state) => {
1558                state
1559                    .cleanup(actor, instance.as_deref(), &cx, this, error)
1560                    .await
1561            }
1562            Dispatch::Sync(state) => state.cleanup(control_sender, instance, &cx, error).await,
1563        }
1564    }
1565
1566    fn display_name(&self) -> Option<String> {
1567        self.instance.as_ref().and_then(|instance| {
1568            monarch_with_gil_blocking(GilSite::DisplayName, |py| {
1569                instance.bind(py).str().ok().map(|s| s.to_string())
1570            })
1571        })
1572    }
1573
1574    /// Enqueues the message for `_handle_undeliverable_message` and returns
1575    /// right away, for the same reasons `handle_supervision_event` does not
1576    /// wait. The verdict comes back later as an [`UndeliverableOutcome`],
1577    /// whose handler applies the default handling if the message was not
1578    /// handled. A message still pending when the actor stops is dropped.
1579    async fn handle_undeliverable_message(
1580        &mut self,
1581        ins: &Instance<Self>,
1582        reason: UndeliverableReason,
1583        mut envelope: Undeliverable<MessageEnvelope>,
1584    ) -> Result<(), anyhow::Error> {
1585        if envelope
1586            .as_message()
1587            .is_some_and(|envelope| envelope.sender() != ins.self_addr())
1588        {
1589            // This can happen if the sender is comm. Update the envelope.
1590            envelope = update_undeliverable_envelope_for_casting(envelope);
1591        }
1592        let envelope = match envelope {
1593            Undeliverable::Returned(envelope) => envelope,
1594            Undeliverable::Report(report) => {
1595                return Err(UndeliverableMessageError::Report { report }.into());
1596            }
1597        };
1598        assert_eq!(
1599            envelope.sender(),
1600            ins.self_addr(),
1601            "undeliverable message was returned to the wrong actor. \
1602            Return address = {}, src actor = {}, dest handler port = {}, message type = {}, envelope headers = {}",
1603            envelope.sender(),
1604            ins.self_addr(),
1605            envelope.dest(),
1606            envelope.data().typename().unwrap_or("unknown"),
1607            envelope.headers()
1608        );
1609
1610        let cx = Context::new(ins, envelope.headers().clone());
1611        let pending = PendingUndeliverable {
1612            instance: self
1613                .instance
1614                .clone()
1615                .expect("PythonActor::init should have created the instance"),
1616            rank: cx.cast_point(),
1617            recording_span: cx.recording_span(),
1618            envelope: Undeliverable::Returned(envelope),
1619            reply: UndeliverableReply {
1620                port: ins.port(),
1621                instance: ins.into(),
1622                reason,
1623                _in_flight: InFlight::new(&self.callbacks_pending),
1624            },
1625        };
1626
1627        self.control_sender
1628            .send(pending)
1629            .map_err(|_| anyhow::anyhow!("failed to send undeliverable message to queue"))?;
1630        Ok(())
1631    }
1632
1633    /// Enqueues the event for `__supervise__` and returns `Ok(true)` right
1634    /// away: `true` means "accepted", not "handled". The verdict comes back
1635    /// later as a [`SupervisionOutcome`], whose handler fails the actor if
1636    /// the event was not handled.
1637    ///
1638    /// Waiting for the verdict here is only appropriate when supervision is
1639    /// bounded in time and does not need this actor's loop, as with Rust
1640    /// actors. `__supervise__` is arbitrary user code on the actor's asyncio
1641    /// loop, while this runs on the actor loop, so waiting would stop the
1642    /// actor from handling messages and signals for as long as user code
1643    /// runs, deadlock a `__supervise__` that calls its own actor, and keep a
1644    /// stop from cancelling it.
1645    ///
1646    /// Consequences of not waiting:
1647    /// - The actor keeps handling messages until an unhandled verdict
1648    ///   arrives.
1649    /// - Supervision still pending when the actor stops is cancelled along
1650    ///   with the other asyncio tasks in `cleanup`. This is intentional:
1651    ///   recovering from a child failure (e.g. respawning a mesh) is
1652    ///   pointless when the result is about to be stopped with us. It
1653    ///   includes the events the run loop drains after a handler error, so
1654    ///   the actor fails with its own error rather than theirs.
1655    /// - The root client's custom loop must keep running after this returns
1656    ///   so that it can deliver the `SupervisionOutcome`.
1657    async fn handle_supervision_event(
1658        &mut self,
1659        this: &Instance<Self>,
1660        event: &ActorSupervisionEvent,
1661    ) -> Result<bool, anyhow::Error> {
1662        let cx = Context::new(this, Flattrs::new());
1663        // Events without labels, such as from a managed mesh's controller, are
1664        // reported with no mesh name and against the whole mesh.
1665        let crashed_ranks = event
1666            .labels
1667            .get(CAST_POINT)
1668            .map(|point| point.rank())
1669            .into_iter()
1670            .collect();
1671        self.handle(
1672            &cx,
1673            MeshFailure {
1674                // TODO: Replace with a structural mesh reference once
1675                // `MeshFailure` and `__supervise__` stop identifying meshes by
1676                // name.
1677                actor_mesh_name: event
1678                    .labels
1679                    .get(ACTOR_MESH_ID)
1680                    .map(|mesh_id| mesh_id.to_string()),
1681                event: event.clone(),
1682                crashed_ranks,
1683                // MFCA-4: direct actor-handled supervision conversion, not a
1684                // controller report.
1685                reporting_controller: None,
1686            },
1687        )
1688        .await
1689        .map(|_| true)
1690    }
1691}
1692
1693#[derive(Debug, Clone, Serialize, Deserialize, Named)]
1694pub struct PythonActorParams {
1695    // The pickled actor class to instantiate.
1696    actor_type: PickledPyObject,
1697    // Python message to process as part of the actor initialization.
1698    init_message: Option<PythonMessage>,
1699    kind: ActorKind,
1700}
1701
1702impl PythonActorParams {
1703    pub(crate) fn new(
1704        actor_type: PickledPyObject,
1705        init_message: Option<PythonMessage>,
1706        kind: ActorKind,
1707    ) -> Self {
1708        Self {
1709            actor_type,
1710            init_message,
1711            kind,
1712        }
1713    }
1714}
1715
1716#[async_trait]
1717impl RemoteSpawn for PythonActor {
1718    type Params = PythonActorParams;
1719
1720    async fn new(
1721        PythonActorParams {
1722            actor_type,
1723            init_message,
1724            kind,
1725        }: PythonActorParams,
1726        environment: &ActorEnvironment,
1727    ) -> Result<Self, anyhow::Error> {
1728        let construction_point = environment.get(CAST_POINT);
1729        Self::new(actor_type, init_message, construction_point, kind)
1730    }
1731}
1732
1733#[async_trait]
1734impl Handler<PythonMessage> for PythonActor {
1735    fn message_status_reporting() -> MessageStatusReporting {
1736        MessageStatusReporting::Deferred
1737    }
1738
1739    // HOT PATH: Be mindful of performance when making changes here.
1740    // To test how performance is affected by a change, run the RPC benchmarks in
1741    // `monarch/python/benches/`.
1742    #[tracing::instrument(level = "debug", skip_all)]
1743    async fn handle(
1744        &mut self,
1745        cx: &Context<PythonActor>,
1746        message: PythonMessage,
1747    ) -> anyhow::Result<()> {
1748        self.handle_queue(cx, message).await
1749    }
1750}
1751
1752impl PythonActor {
1753    // HOT PATH: Be mindful of performance when making changes here.
1754    // To test how performance is affected by a change, run the RPC benchmarks in
1755    // `monarch/python/benches/`.
1756    /// Handle a message using queue dispatch.
1757    /// Resolves the message on the Rust side and enqueues it for Python to process.
1758    async fn handle_queue(
1759        &self,
1760        cx: &Context<'_, PythonActor>,
1761        message: PythonMessage,
1762    ) -> anyhow::Result<()> {
1763        let resolved = message.resolve_indirect_call(cx).await?;
1764
1765        let pending = PendingMessage {
1766            instance: self
1767                .instance
1768                .clone()
1769                .expect("PythonActor::init should have created the instance"),
1770            rank: cx.cast_point(),
1771            recording_span: cx.recording_span(),
1772            resolved,
1773            telemetry_message_id: cx
1774                .headers()
1775                .get(hyperactor::mailbox::headers::TELEMETRY_MESSAGE_ID),
1776        };
1777
1778        self.message_sender
1779            .send(pending)
1780            .map_err(|_| anyhow::anyhow!("failed to send message to queue"))?;
1781
1782        Ok(())
1783    }
1784}
1785
1786/// A supervision event enqueued for `_callback_loop`, or for a sync actor's
1787/// driver, which runs `__supervise__` and reports the verdict with `_handled`
1788/// or `_raised`.
1789/// Dropping it without reporting (for example because an async loop was
1790/// cancelled, sync cleanup won the claim, or the inbox was dropped) produces no
1791/// verdict.
1792#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
1793pub struct QueuedSupervision {
1794    #[pyo3(get)]
1795    context: Py<crate::context::PyContext>,
1796    #[pyo3(get)]
1797    failure: Py<PyMeshFailure>,
1798    reply: Option<SupervisionReply>,
1799}
1800
1801struct SupervisionReply {
1802    port: PortHandle<SupervisionOutcome>,
1803    instance: PyInstance,
1804    failure: MeshFailure,
1805    display_name: Option<String>,
1806    _in_flight: InFlight,
1807}
1808
1809#[pymethods]
1810impl QueuedSupervision {
1811    fn _handled(&mut self, handled: bool) {
1812        self.report(if handled {
1813            SupervisionVerdict::Handled
1814        } else {
1815            SupervisionVerdict::Unhandled
1816        });
1817    }
1818
1819    fn _raised(&mut self, exc: Bound<'_, PyAny>) {
1820        self.report(SupervisionVerdict::Raised(
1821            PyErr::from_value(exc).to_string(),
1822        ));
1823    }
1824}
1825
1826impl QueuedSupervision {
1827    fn report(&mut self, verdict: SupervisionVerdict) {
1828        let SupervisionReply {
1829            port,
1830            instance,
1831            failure,
1832            display_name,
1833            _in_flight,
1834        } = self
1835            .reply
1836            .take()
1837            .expect("supervision verdict reported twice");
1838        let outcome = SupervisionOutcome {
1839            failure,
1840            display_name,
1841            verdict,
1842        };
1843        if let Err(err) = port.try_post(instance.deref(), outcome) {
1844            tracing::debug!(
1845                actor_id = %instance.self_addr(),
1846                "dropping supervision verdict, actor is gone: {}",
1847                err
1848            );
1849        }
1850    }
1851}
1852
1853/// Converted into [`QueuedSupervision`] on the actor's Python execution thread,
1854/// so the Tokio worker never takes the GIL (see [`PendingMessage`]).
1855struct PendingSupervision {
1856    instance: Arc<Py<PyInstance>>,
1857    rank: Point,
1858    recording_span: tracing::Span,
1859    reply: SupervisionReply,
1860}
1861
1862impl<'py> IntoPyObject<'py> for PendingSupervision {
1863    type Target = QueuedSupervision;
1864    type Output = Bound<'py, QueuedSupervision>;
1865    type Error = PyErr;
1866
1867    fn into_pyobject(self, py: Python<'py>) -> PyResult<Self::Output> {
1868        let mut reply = self.reply;
1869        reply.display_name = self.instance.bind(py).str().ok().map(|s| s.to_string());
1870        let context = crate::context::PyContext::from_parts(
1871            self.instance.clone_ref(py),
1872            self.rank,
1873            Some(self.recording_span),
1874        );
1875        let failure = PyMeshFailure::from(reply.failure.clone());
1876        Bound::new(
1877            py,
1878            QueuedSupervision {
1879                context: Py::new(py, context)?,
1880                failure: Py::new(py, failure)?,
1881                reply: Some(reply),
1882            },
1883        )
1884    }
1885}
1886
1887/// Counts a callback as pending until dropped.
1888#[derive(Debug)]
1889struct InFlight(Arc<AtomicUsize>);
1890
1891impl InFlight {
1892    fn new(count: &Arc<AtomicUsize>) -> Self {
1893        count.fetch_add(1, AtomicOrdering::Relaxed);
1894        Self(count.clone())
1895    }
1896}
1897
1898impl Drop for InFlight {
1899    fn drop(&mut self) {
1900        self.0.fetch_sub(1, AtomicOrdering::Relaxed);
1901    }
1902}
1903
1904/// Truthy while any callback is waiting on `_callback_loop`.
1905/// `_dispatch_loop` yields to the event loop between messages while this is
1906/// true: `recv` does not yield when a message is already queued, so a
1907/// backlog of messages would otherwise starve `_callback_loop`.
1908#[pyclass(frozen, module = "monarch._rust_bindings.monarch_hyperactor.actor")]
1909pub struct CallbacksPending(Arc<AtomicUsize>);
1910
1911#[pymethods]
1912impl CallbacksPending {
1913    /// A count with no callbacks pending.
1914    #[new]
1915    fn new() -> Self {
1916        Self(Arc::new(AtomicUsize::new(0)))
1917    }
1918
1919    fn __bool__(&self) -> bool {
1920        self.0.load(AtomicOrdering::Relaxed) > 0
1921    }
1922}
1923
1924/// An undeliverable message enqueued for `_callback_loop`, or for a sync
1925/// actor's driver, which runs `_handle_undeliverable_message` and reports the
1926/// verdict with `_handled` or `_raised`. Dropping it without reporting (for
1927/// example because an async loop was cancelled, sync cleanup won the claim, or
1928/// the inbox was dropped) produces no verdict.
1929#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
1930pub struct QueuedUndeliverable {
1931    #[pyo3(get)]
1932    context: Py<crate::context::PyContext>,
1933    #[pyo3(get)]
1934    envelope: Py<PythonUndeliverableMessageEnvelope>,
1935    reply: Option<UndeliverableReply>,
1936}
1937
1938struct UndeliverableReply {
1939    port: PortHandle<UndeliverableOutcome>,
1940    instance: PyInstance,
1941    reason: UndeliverableReason,
1942    _in_flight: InFlight,
1943}
1944
1945#[pymethods]
1946impl QueuedUndeliverable {
1947    fn _handled(&mut self, py: Python<'_>, handled: bool) {
1948        let verdict = if handled {
1949            UndeliverableVerdict::Handled
1950        } else {
1951            match self.envelope.bind(py).try_borrow_mut() {
1952                Ok(mut envelope) => match envelope.take() {
1953                    Ok(envelope) => UndeliverableVerdict::Unhandled(Box::new(envelope)),
1954                    Err(err) => UndeliverableVerdict::Raised(err),
1955                },
1956                Err(err) => UndeliverableVerdict::Raised(PyErr::from(err).into()),
1957            }
1958        };
1959        self.report(verdict);
1960    }
1961
1962    fn _raised(&mut self, exc: Bound<'_, PyAny>) {
1963        let err = SerializablePyErr::from(exc.py(), &PyErr::from_value(exc));
1964        self.report(UndeliverableVerdict::Raised(err.into()));
1965    }
1966}
1967
1968impl QueuedUndeliverable {
1969    fn report(&mut self, verdict: UndeliverableVerdict) {
1970        let UndeliverableReply {
1971            port,
1972            instance,
1973            reason,
1974            _in_flight,
1975        } = self
1976            .reply
1977            .take()
1978            .expect("undeliverable verdict reported twice");
1979        if let Err(err) = port.try_post(instance.deref(), UndeliverableOutcome { reason, verdict })
1980        {
1981            tracing::debug!(
1982                actor_id = %instance.self_addr(),
1983                "dropping undeliverable verdict, actor is gone: {}",
1984                err
1985            );
1986        }
1987    }
1988}
1989
1990/// Converted into [`QueuedUndeliverable`] on the actor's Python execution
1991/// thread, so the Tokio worker never takes the GIL (see [`PendingMessage`]).
1992struct PendingUndeliverable {
1993    instance: Arc<Py<PyInstance>>,
1994    rank: Point,
1995    recording_span: tracing::Span,
1996    envelope: Undeliverable<MessageEnvelope>,
1997    reply: UndeliverableReply,
1998}
1999
2000impl<'py> IntoPyObject<'py> for PendingUndeliverable {
2001    type Target = QueuedUndeliverable;
2002    type Output = Bound<'py, QueuedUndeliverable>;
2003    type Error = PyErr;
2004
2005    fn into_pyobject(self, py: Python<'py>) -> PyResult<Self::Output> {
2006        let context = crate::context::PyContext::from_parts(
2007            self.instance.clone_ref(py),
2008            self.rank,
2009            Some(self.recording_span),
2010        );
2011        let envelope = PythonUndeliverableMessageEnvelope {
2012            inner: Some(self.envelope),
2013        };
2014        Bound::new(
2015            py,
2016            QueuedUndeliverable {
2017                context: Py::new(py, context)?,
2018                envelope: Py::new(py, envelope)?,
2019                reply: Some(self.reply),
2020            },
2021        )
2022    }
2023}
2024
2025/// The verdict of `_handle_undeliverable_message` on a
2026/// [`QueuedUndeliverable`], which the actor posts to itself.
2027#[derive(Debug)]
2028pub struct UndeliverableOutcome {
2029    reason: UndeliverableReason,
2030    verdict: UndeliverableVerdict,
2031}
2032
2033#[derive(Debug)]
2034enum UndeliverableVerdict {
2035    Handled,
2036    Unhandled(Box<Undeliverable<MessageEnvelope>>),
2037    Raised(anyhow::Error),
2038}
2039
2040#[async_trait]
2041impl Handler<UndeliverableOutcome> for PythonActor {
2042    async fn handle(
2043        &mut self,
2044        cx: &Context<Self>,
2045        outcome: UndeliverableOutcome,
2046    ) -> anyhow::Result<()> {
2047        match outcome.verdict {
2048            UndeliverableVerdict::Handled => Ok(()),
2049            UndeliverableVerdict::Unhandled(envelope) => {
2050                hyperactor::actor::handle_undeliverable_message(cx, outcome.reason, *envelope)
2051            }
2052            UndeliverableVerdict::Raised(err) => Err(err),
2053        }
2054    }
2055}
2056
2057/// The verdict of `__supervise__` on a [`QueuedSupervision`], which the
2058/// actor posts to itself.
2059#[derive(Debug)]
2060pub struct SupervisionOutcome {
2061    failure: MeshFailure,
2062    display_name: Option<String>,
2063    verdict: SupervisionVerdict,
2064}
2065
2066#[derive(Debug)]
2067enum SupervisionVerdict {
2068    Handled,
2069    Unhandled,
2070    /// `__supervise__` raised the formatted exception.
2071    Raised(String),
2072}
2073
2074#[async_trait]
2075impl Handler<MeshFailure> for PythonActor {
2076    async fn handle(&mut self, cx: &Context<Self>, message: MeshFailure) -> anyhow::Result<()> {
2077        // If the message is not about a failure, don't call __supervise__.
2078        // This includes messages like "stop", because those are not errors that
2079        // need to be propagated.
2080        if !message.event.actor_status.is_failed() {
2081            tracing::info!(
2082                "ignoring non-failure supervision event from child: {}",
2083                message
2084            );
2085            return Ok(());
2086        }
2087
2088        // Enqueue instead of waiting for `__supervise__`, for the reasons
2089        // given on `Actor::handle_supervision_event`. The verdict arrives as
2090        // a `SupervisionOutcome`.
2091        let pending = PendingSupervision {
2092            instance: self
2093                .instance
2094                .clone()
2095                .expect("PythonActor::init should have created the instance"),
2096            rank: cx.cast_point(),
2097            recording_span: cx.recording_span(),
2098            reply: SupervisionReply {
2099                port: cx.port(),
2100                instance: cx.into(),
2101                failure: message,
2102                display_name: None,
2103                _in_flight: InFlight::new(&self.callbacks_pending),
2104            },
2105        };
2106
2107        self.control_sender
2108            .send(pending)
2109            .map_err(|_| anyhow::anyhow!("failed to send supervision event to queue"))?;
2110        Ok(())
2111    }
2112}
2113
2114#[async_trait]
2115impl Handler<SupervisionOutcome> for PythonActor {
2116    async fn handle(
2117        &mut self,
2118        cx: &Context<Self>,
2119        outcome: SupervisionOutcome,
2120    ) -> anyhow::Result<()> {
2121        let SupervisionOutcome {
2122            failure: message,
2123            display_name,
2124            verdict,
2125        } = outcome;
2126        let (status, description, cause) = match verdict {
2127            SupervisionVerdict::Handled => {
2128                // TODO: We also don't want to deliver multiple supervision
2129                // events from the same mesh if an earlier one is handled.
2130                tracing::info!(
2131                    name = "ActorMeshStatus",
2132                    status = "SupervisionError::Handled",
2133                    // only care about the event sender when the message is handled
2134                    actor_name = message.actor_mesh_name,
2135                    event = %message.event,
2136                    "__supervise__ on {} handled a supervision event, not reporting any further",
2137                    cx.self_addr(),
2138                );
2139                return Ok(());
2140            }
2141            // We propagate the event to the next owning actor by failing
2142            // with a new event that names this actor as its creator. This
2143            // does not set the causal chain for ActorSupervisionEvent, so
2144            // the original event is included in the error.
2145            SupervisionVerdict::Unhandled => (
2146                "SupervisionError::Unhandled",
2147                "did not handle a supervision event, reporting to the next owner",
2148                ActorErrorKind::UnhandledSupervisionEvent(Box::new(message.event.clone())),
2149            ),
2150            SupervisionVerdict::Raised(err) => (
2151                "SupervisionError::__supervise__::exception",
2152                "threw an exception",
2153                ActorErrorKind::ErrorDuringHandlingSupervision(
2154                    err,
2155                    Box::new(message.event.clone()),
2156                ),
2157            ),
2158        };
2159        for (actor_name, status) in [
2160            (
2161                message
2162                    .actor_mesh_name
2163                    .as_deref()
2164                    .unwrap_or_else(|| message.event.actor_id.log_name()),
2165                status,
2166            ),
2167            (cx.self_addr().log_name(), "UnhandledSupervisionEvent"),
2168        ] {
2169            tracing::info!(
2170                name = "ActorMeshStatus",
2171                status,
2172                actor_name,
2173                event = %message.event,
2174                "__supervise__ on {} {}",
2175                cx.self_addr(),
2176                description,
2177            );
2178        }
2179        Err(anyhow::Error::new(
2180            ActorErrorKind::UnhandledSupervisionEvent(Box::new(ActorSupervisionEvent::new(
2181                cx.self_addr().clone(),
2182                display_name,
2183                ActorStatus::Failed(cause),
2184                None,
2185            ))),
2186        ))
2187    }
2188}
2189
2190#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
2191struct LocalPort {
2192    instance: PyInstance,
2193    inner: Option<OncePortHandle<Result<Py<PyAny>, Py<PyAny>>>>,
2194}
2195
2196impl Debug for LocalPort {
2197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2198        f.debug_struct("LocalPort")
2199            .field("inner", &self.inner)
2200            .finish()
2201    }
2202}
2203
2204#[pymethods]
2205impl LocalPort {
2206    fn send(&mut self, obj: Py<PyAny>) -> PyResult<()> {
2207        let port = self.inner.take().expect("use local port once");
2208        port.post(self.instance.deref(), Ok(obj));
2209        Ok(())
2210    }
2211    /// Async-actor reply entry point. A local value needs no mesh-reference
2212    /// resolution, so it can be sent immediately.
2213    fn _resolve_and_send(&mut self, obj: Py<PyAny>) -> PyResult<()> {
2214        self.send(obj)
2215    }
2216    /// Sync-actor reply entry point. A local value needs no asynchronous work,
2217    /// so the blocking path is the same immediate send.
2218    fn _resolve_and_send_blocking(&mut self, obj: Py<PyAny>) -> PyResult<()> {
2219        self.send(obj)
2220    }
2221    fn exception(&mut self, e: Py<PyAny>) -> PyResult<()> {
2222        let port = self.inner.take().expect("use local port once");
2223        port.post(self.instance.deref(), Err(e));
2224        Ok(())
2225    }
2226}
2227
2228/// A port that drops all messages sent to it.
2229/// Used when there is no response port for a message.
2230/// Any exceptions sent to it are re-raised in the current actor.
2231#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.actor")]
2232#[derive(Debug)]
2233pub struct DroppingPort;
2234
2235#[pymethods]
2236impl DroppingPort {
2237    #[new]
2238    fn new() -> Self {
2239        DroppingPort
2240    }
2241
2242    fn send(&self, _obj: Py<PyAny>) -> PyResult<()> {
2243        Ok(())
2244    }
2245
2246    /// Async-actor reply entry point. Dropping the value requires no reference
2247    /// resolution.
2248    fn _resolve_and_send(&self, obj: Py<PyAny>) -> PyResult<()> {
2249        self.send(obj)
2250    }
2251
2252    /// Sync-actor reply entry point. Dropping the value requires no work to
2253    /// wait for, so the blocking path is also immediate.
2254    fn _resolve_and_send_blocking(&self, obj: Py<PyAny>) -> PyResult<()> {
2255        self.send(obj)
2256    }
2257
2258    fn exception(&self, e: Bound<'_, PyAny>) -> PyResult<()> {
2259        // Unwrap ActorError to get the inner exception, matching Python behavior.
2260        let exc = if let Ok(inner) = e.getattr("exception") {
2261            inner
2262        } else {
2263            e
2264        };
2265        Err(PyErr::from_value(exc))
2266    }
2267
2268    #[getter]
2269    fn get_return_undeliverable(&self) -> bool {
2270        true
2271    }
2272
2273    #[setter]
2274    fn set_return_undeliverable(&self, _value: bool) {}
2275}
2276
2277/// A port that sends messages to a remote receiver.
2278/// Wraps an EitherPortRef with the actor instance needed for sending.
2279#[pyclass(module = "monarch._src.actor.actor_mesh")]
2280pub struct Port {
2281    port_ref: EitherPortRef,
2282    instance: Instance<PythonActor>,
2283    rank: Option<usize>,
2284    /// Operation-context headers captured from the inbound request,
2285    /// re-emitted on every reply so failure surfaces can name the
2286    /// operation.
2287    reply_headers: hyperactor_config::Flattrs,
2288}
2289
2290#[pymethods]
2291impl Port {
2292    #[new]
2293    fn new(
2294        port_ref: EitherPortRef,
2295        instance: &crate::context::PyInstance,
2296        rank: Option<usize>,
2297    ) -> Self {
2298        Self {
2299            port_ref,
2300            instance: instance.clone().into_instance(),
2301            rank,
2302            reply_headers: hyperactor_config::Flattrs::new(),
2303        }
2304    }
2305
2306    #[getter("_port_ref")]
2307    fn port_ref_py(&self) -> EitherPortRef {
2308        self.port_ref.clone()
2309    }
2310
2311    #[getter("_rank")]
2312    fn rank_py(&self) -> Option<usize> {
2313        self.rank
2314    }
2315
2316    #[getter]
2317    fn get_return_undeliverable(&self) -> bool {
2318        self.port_ref.get_return_undeliverable()
2319    }
2320
2321    #[setter]
2322    fn set_return_undeliverable(&mut self, value: bool) {
2323        self.port_ref.set_return_undeliverable(value);
2324    }
2325
2326    // HOT PATH: Be mindful of performance when making changes here.
2327    // To test how performance is affected by a change, run the RPC benchmarks in
2328    // `monarch/python/benches/`.
2329    #[tracing::instrument(level = "debug", skip_all)]
2330    fn send(&mut self, py: Python<'_>, obj: Py<PyAny>) -> PyResult<()> {
2331        let message = PythonMessage::new_from_buf(
2332            PythonMessageKind::Result { rank: self.rank },
2333            pickle_to_part(py, &obj)?,
2334        );
2335
2336        self.port_ref
2337            .post_with_headers(&self.instance, self.reply_headers.clone(), message)
2338            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
2339    }
2340
2341    #[tracing::instrument(level = "debug", skip_all)]
2342    fn _send_message(&mut self, message: PythonMessage) -> PyResult<()> {
2343        self.port_ref
2344            .post_with_headers(&self.instance, self.reply_headers.clone(), message)
2345            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
2346    }
2347
2348    fn exception(&mut self, py: Python<'_>, e: Py<PyAny>) -> PyResult<()> {
2349        let message = PythonMessage::new_from_buf(
2350            PythonMessageKind::Exception { rank: self.rank },
2351            pickle_to_part(py, &e)?,
2352        );
2353
2354        self.port_ref
2355            .post_with_headers(&self.instance, self.reply_headers.clone(), message)
2356            .map_err(|e| PyRuntimeError::new_err(e.to_string()))
2357    }
2358}
2359
2360impl Port {
2361    /// Constructor that attaches operation-context headers captured
2362    /// from the inbound request. The Python `#[new]` constructor
2363    /// defaults to empty headers.
2364    pub(crate) fn with_reply_headers(
2365        port_ref: EitherPortRef,
2366        instance: Instance<PythonActor>,
2367        rank: Option<usize>,
2368        reply_headers: hyperactor_config::Flattrs,
2369    ) -> Self {
2370        Self {
2371            port_ref,
2372            instance,
2373            rank,
2374            reply_headers,
2375        }
2376    }
2377}
2378
2379pub fn register_python_bindings(hyperactor_mod: &Bound<'_, PyModule>) -> PyResult<()> {
2380    hyperactor_mod.add_class::<PythonActorHandle>()?;
2381    hyperactor_mod.add_class::<PythonMessage>()?;
2382    hyperactor_mod.add_class::<PyMeshRef>()?;
2383    hyperactor_mod.add_class::<PythonMessageKind>()?;
2384    hyperactor_mod.add_class::<MethodSpecifier>()?;
2385    hyperactor_mod.add_class::<UnflattenArg>()?;
2386    hyperactor_mod.add_class::<QueuedMessage>()?;
2387    hyperactor_mod.add_class::<QueuedSupervision>()?;
2388    hyperactor_mod.add_class::<CallbacksPending>()?;
2389    hyperactor_mod.add_class::<QueuedUndeliverable>()?;
2390    hyperactor_mod.add_class::<DroppingPort>()?;
2391    hyperactor_mod.add_class::<Port>()?;
2392    hyperactor_mod.add_class::<ActorKind>()?;
2393    sync_actor::dispatch::register_python_bindings(hyperactor_mod)?;
2394    sync_actor::inbox::register_python_bindings(hyperactor_mod)?;
2395    Ok(())
2396}
2397
2398/// Register actor-specific Python test seams in the shared testing module.
2399pub(crate) fn register_testing_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
2400    sync_actor::inbox::register_testing_python_bindings(module)
2401}
2402
2403#[cfg(test)]
2404mod tests {
2405    use futures::future::FutureExt;
2406    use hyperactor as reference;
2407    use hyperactor::accum::ReducerSpec;
2408    use hyperactor::accum::StreamingReducerOpts;
2409    use hyperactor::id::Label;
2410    use hyperactor::testing::ids::test_port_id;
2411    use hyperactor_mesh::Error as MeshError;
2412    use hyperactor_mesh::host_mesh::host_agent::ProcState;
2413    use hyperactor_mesh::mesh_id::ResourceId;
2414    use hyperactor_mesh::resource::Status;
2415    use hyperactor_mesh::resource::{self};
2416    use pyo3::PyTypeInfo;
2417    use pyo3::exceptions::PyValueError;
2418    use pyo3::ffi::c_str;
2419    use pyo3::panic::PanicException;
2420
2421    use super::*;
2422    use crate::actor::to_py_error;
2423
2424    #[test]
2425    fn test_call_method_indirect_correlation_id_round_trip() {
2426        let kind = PythonMessageKind::CallMethodIndirect {
2427            name: MethodSpecifier::ReturnsResponse {
2428                name: "forward".to_string(),
2429            },
2430            local_state_broker: ("broker".to_string(), 3),
2431            id: 7,
2432            unflatten_args: vec![UnflattenArg::Mailbox, UnflattenArg::PyObject],
2433            correlation_id: Some(42),
2434        };
2435
2436        let serialized = wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&kind)
2437            .expect("indirect call message should serialize");
2438        let decoded = serialized
2439            .deserialized_unchecked::<PythonMessageKind>()
2440            .expect("serialized indirect call message should deserialize");
2441
2442        assert_eq!(decoded, kind);
2443    }
2444
2445    #[test]
2446    fn test_python_message_part_codec() {
2447        let reducer_spec = ReducerSpec {
2448            typehash: 123,
2449            builder_params: Some(wirevalue::Any::serialize(&"abcdefg12345".to_string()).unwrap()),
2450        };
2451        let port_ref = hyperactor::PortRef::<PythonMessage>::attest_reducible(
2452            test_port_id("world_0", "client", 123),
2453            Some(reducer_spec),
2454            StreamingReducerOpts::default(),
2455        );
2456        let message = PythonMessage {
2457            kind: PythonMessageKind::CallMethod {
2458                name: MethodSpecifier::ReturnsResponse {
2459                    name: "test".to_string(),
2460                },
2461                response_port: Some(EitherPortRef::Unbounded(port_ref.clone().into())),
2462                correlation_id: None,
2463            },
2464            message: Part::from(vec![1, 2, 3]),
2465            refs: Vec::new(),
2466        };
2467        {
2468            let mut multipart_message =
2469                wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message).unwrap();
2470            let mut ports = vec![];
2471            multipart_message
2472                .visit_multipart_parts_mut::<reference::PortRefRepr, anyhow::Error>(|b| {
2473                    ports.push(b.clone());
2474                    Ok(())
2475                })
2476                .unwrap();
2477            assert_eq!(ports.len(), 1);
2478            assert_eq!(ports[0].port_addr(), port_ref.port_addr());
2479            assert_eq!(ports[0].reducer_spec(), port_ref.reducer_spec());
2480            assert_eq!(
2481                ports[0].get_return_undeliverable(),
2482                port_ref.get_return_undeliverable()
2483            );
2484            assert!(!ports[0].unsplit());
2485            assert_eq!(
2486                message,
2487                multipart_message
2488                    .deserialized_unchecked::<PythonMessage>()
2489                    .unwrap()
2490            );
2491        }
2492
2493        let no_port_message = PythonMessage {
2494            kind: PythonMessageKind::CallMethod {
2495                name: MethodSpecifier::ReturnsResponse {
2496                    name: "test".to_string(),
2497                },
2498                response_port: None,
2499                correlation_id: None,
2500            },
2501            ..message
2502        };
2503        {
2504            let mut multipart_message =
2505                wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&no_port_message)
2506                    .unwrap();
2507            let mut ports = vec![];
2508            multipart_message
2509                .visit_multipart_parts_mut::<reference::PortRefRepr, anyhow::Error>(|b| {
2510                    ports.push(b.clone());
2511                    Ok(())
2512                })
2513                .unwrap();
2514            assert_eq!(ports.len(), 0);
2515            assert_eq!(
2516                no_port_message,
2517                multipart_message
2518                    .deserialized_unchecked::<PythonMessage>()
2519                    .unwrap()
2520            );
2521        }
2522    }
2523
2524    #[test]
2525    fn test_python_message_refs_travel_as_parts() {
2526        // A non-live proc mesh ref, built in-memory from ids (no spawn).
2527        fn proc_mesh_ref(seed: u64, label: &str) -> MeshRef {
2528            let proc_id = hyperactor::ProcId::new(
2529                hyperactor::id::Uid::Instance(seed, None),
2530                Some(Label::new("local").unwrap()),
2531            );
2532            let proc_addr = hyperactor::ProcAddr::new(
2533                proc_id,
2534                hyperactor::channel::ChannelAddr::Local(seed).into(),
2535            );
2536            let agent: hyperactor::ActorRef<hyperactor_mesh::proc_agent::ProcAgent> =
2537                hyperactor::ActorRef::attest(
2538                    proc_addr.actor_addr(hyperactor_mesh::proc_agent::PROC_AGENT_ACTOR_NAME),
2539                );
2540            let proc_ref = hyperactor_mesh::proc_mesh::ProcRef::new(proc_addr, 0, agent);
2541            MeshRef::Proc(Box::new(
2542                hyperactor_mesh::proc_mesh::ProcMeshRef::new_singleton(
2543                    hyperactor_mesh::mesh_id::ProcMeshId::singleton(Label::new(label).unwrap()),
2544                    proc_ref,
2545                )
2546                .unwrap(),
2547            ))
2548        }
2549
2550        let message = PythonMessage {
2551            kind: PythonMessageKind::CallMethod {
2552                name: MethodSpecifier::ReturnsResponse {
2553                    name: "test".to_string(),
2554                },
2555                response_port: None,
2556                correlation_id: None,
2557            },
2558            message: Part::from(vec![1, 2, 3]),
2559            refs: vec![proc_mesh_ref(1, "a"), proc_mesh_ref(2, "b")],
2560        };
2561
2562        let mut multipart_message =
2563            wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message).unwrap();
2564        let mut parts = vec![];
2565        multipart_message
2566            .visit_multipart_parts_mut::<MeshRefRepr, anyhow::Error>(|b| {
2567                parts.push(b.clone());
2568                Ok(())
2569            })
2570            .unwrap();
2571        // Each MeshRef rides as its own typed part on the multipart wire.
2572        assert_eq!(parts.len(), 2);
2573        // And the message round-trips, reuniting the refs from those parts.
2574        assert_eq!(
2575            message,
2576            multipart_message
2577                .deserialized_unchecked::<PythonMessage>()
2578                .unwrap()
2579        );
2580    }
2581
2582    #[test]
2583    fn to_py_error_preserves_proc_creation_message() {
2584        // State<ProcState> w/ `state.is_none()`
2585        let state: resource::State<ProcState> = resource::State {
2586            id: ResourceId::instance(Label::new("my-proc").unwrap()),
2587            status: Status::Failed("boom".into()),
2588            state: None,
2589            generation: 0,
2590            timestamp: std::time::SystemTime::now(),
2591        };
2592
2593        // A ProcCreationError
2594        let mesh_agent: hyperactor::ActorRef<hyperactor_mesh::host_mesh::HostAgent> =
2595            hyperactor::ActorRef::attest(test_port_id("hello_0", "actor", 0).actor_addr());
2596        let expected_prefix = format!(
2597            "error creating proc (host rank 0) on host mesh agent {}",
2598            mesh_agent
2599        );
2600        let err = MeshError::ProcCreationError {
2601            host_rank: 0,
2602            mesh_agent,
2603            state: Box::new(state),
2604        };
2605
2606        let rust_msg = err.to_string();
2607        let pyerr = to_py_error(err);
2608
2609        pyo3::Python::initialize();
2610        monarch_with_gil_blocking(GilSite::Test, |py| {
2611            assert!(pyerr.get_type(py).is(PyValueError::type_object(py)));
2612            let py_msg = pyerr.value(py).to_string();
2613
2614            // 1) Bridge preserves the exact message
2615            assert_eq!(py_msg, rust_msg);
2616            // 2) Contains the structured state and failure status
2617            assert!(py_msg.contains(", state: "));
2618            assert!(py_msg.contains("\"status\":{\"Failed\":\"boom\"}"));
2619            // 3) Starts with the expected prefix
2620            assert!(py_msg.starts_with(&expected_prefix));
2621        });
2622    }
2623
2624    // -- direct response ports ------------------------------------------
2625    //
2626    // Both implementations complete their effect before returning. Python sees
2627    // `None`; there is no task to drive or discard.
2628
2629    /// A real `LocalPort` over a live once port, plus its receiver.
2630    ///
2631    /// The proc is deliberately not returned: the `Instance` owns a clone of it
2632    /// and the port owns the `Instance`, so the port keeps the proc alive by
2633    /// itself. It is an isolated proc because delivery never leaves the process
2634    /// -- posting to a once port hands the value to a oneshot sender -- so no
2635    /// served channel is needed, and a direct proc would serve one from a
2636    /// spawned task that outlives the `Proc`.
2637    ///
2638    /// `actor_instance` spawns detached introspect tasks, so callers must be
2639    /// `#[tokio::test]`: cleanup is the per-test runtime being dropped. Driving
2640    /// this fixture from the shared runtime would leak those tasks for the life
2641    /// of the test binary.
2642    fn local_port_fixture() -> (
2643        LocalPort,
2644        hyperactor::mailbox::OncePortReceiver<Result<Py<PyAny>, Py<PyAny>>>,
2645    ) {
2646        let proc = Proc::isolated();
2647        let instance = proc
2648            .actor_instance::<PythonActor>("resolve_and_send_client")
2649            .unwrap()
2650            .instance;
2651        let (handle, receiver) = instance.open_once_port::<Result<Py<PyAny>, Py<PyAny>>>();
2652        let port = LocalPort {
2653            instance: PyInstance::from(instance),
2654            inner: Some(handle),
2655        };
2656        (port, receiver)
2657    }
2658
2659    // The single non-yielding poll is what makes this precise. Awaiting the
2660    // receiver would also accept a post made later by some other task, and
2661    // would hang rather than fail if the value never arrived at all. Requiring
2662    // the value to be there without ever yielding is the actual claim.
2663    #[tokio::test]
2664    async fn local_port_resolve_and_send_posts_before_return() {
2665        pyo3::Python::initialize();
2666        let (mut port, receiver) = local_port_fixture();
2667
2668        monarch_with_gil_blocking(GilSite::Test, |py| {
2669            let value = 41i64.into_py_any(py).unwrap();
2670            port._resolve_and_send(value).unwrap()
2671        });
2672
2673        let received = receiver
2674            .recv()
2675            .now_or_never()
2676            .expect("the value must be posted before _resolve_and_send returns")
2677            .unwrap();
2678        monarch_with_gil_blocking(GilSite::Test, |py| {
2679            assert_eq!(
2680                received.unwrap().extract::<i64>(py).unwrap(),
2681                41,
2682                "the synchronously posted value must be preserved"
2683            );
2684        });
2685    }
2686
2687    // Check the Python-visible return rather than relying only on Rust's unit
2688    // return type: PyO3 must expose direct completion as `None`.
2689    #[tokio::test]
2690    async fn local_port_resolve_and_send_returns_none() {
2691        pyo3::Python::initialize();
2692        let (port, receiver) = local_port_fixture();
2693
2694        monarch_with_gil_blocking(GilSite::Test, |py| {
2695            let port = Py::new(py, port).unwrap();
2696            let returned = port
2697                .bind(py)
2698                .call_method1("_resolve_and_send", (42,))
2699                .unwrap();
2700            assert!(returned.is_none(), "direct completion must return None");
2701        });
2702
2703        let received = receiver
2704            .recv()
2705            .now_or_never()
2706            .expect("the value must be posted before _resolve_and_send returns")
2707            .unwrap();
2708        monarch_with_gil_blocking(GilSite::Test, |py| {
2709            assert_eq!(
2710                received.unwrap().extract::<i64>(py).unwrap(),
2711                42,
2712                "returning None must not change the posted value"
2713            );
2714        });
2715    }
2716
2717    // KNOWN-BAD CURRENT BEHAVIOR: `LocalPort` is one-shot by panic, not by
2718    // error. `send` unwraps the taken handle with `expect("use local port once")`,
2719    // so a second call aborts the frame rather than returning `Err`. This test
2720    // characterizes that behavior; it does not make the panic a contract.
2721    //
2722    // The first call sits outside the `try` so that only the second call can
2723    // satisfy the assertions; a first-call failure surfaces as a test error
2724    // instead of masquerading as the expected one. The rest runs inside Python
2725    // because PyO3 maps the panic to `PanicException` at the boundary but
2726    // resumes it as a Rust panic if it escapes back out, so catching it in
2727    // Python is what makes the production-visible type observable. That type is
2728    // compared by identity against PyO3's own `PanicException`, which derives
2729    // from `BaseException` -- hence the `except BaseException`, and hence a
2730    // caller's ordinary error handling never sees this.
2731    #[tokio::test]
2732    async fn local_port_second_resolve_and_send_raises_panic_exception() {
2733        pyo3::Python::initialize();
2734        let (port, _receiver) = local_port_fixture();
2735
2736        monarch_with_gil_blocking(GilSite::Test, |py| {
2737            let locals = PyDict::new(py);
2738            locals.set_item("port", Py::new(py, port).unwrap()).unwrap();
2739            py.run(
2740                c_str!(
2741                    r#"
2742assert port._resolve_and_send(1) is None
2743try:
2744    port._resolve_and_send(2)
2745except BaseException as err:
2746    raised = type(err)
2747    message = str(err)
2748else:
2749    raise AssertionError("a second _resolve_and_send must fail")
2750"#
2751                ),
2752                None,
2753                Some(&locals),
2754            )
2755            .unwrap();
2756
2757            let raised = locals.get_item("raised").unwrap().unwrap();
2758            let message: String = locals
2759                .get_item("message")
2760                .unwrap()
2761                .unwrap()
2762                .extract()
2763                .unwrap();
2764            assert!(
2765                raised.is(PanicException::type_object(py)),
2766                "a second send must surface as PanicException, got {raised}"
2767            );
2768            assert!(
2769                message.contains("use local port once"),
2770                "expected the one-shot panic message, got {message}"
2771            );
2772        });
2773    }
2774
2775    // `DroppingPort` is stateless, so repeated direct completions return `None`.
2776    #[test]
2777    fn dropping_port_resolve_and_send_returns_none_and_is_idempotent() {
2778        pyo3::Python::initialize();
2779        monarch_with_gil_blocking(GilSite::Test, |py| {
2780            let port = Py::new(py, DroppingPort).unwrap();
2781            for value in [7, 8, 9] {
2782                let returned = port
2783                    .bind(py)
2784                    .call_method1("_resolve_and_send", (value,))
2785                    .unwrap();
2786                assert!(
2787                    returned.is_none(),
2788                    "every DroppingPort completion must return None"
2789                );
2790            }
2791        });
2792    }
2793}