Skip to main content

monarch_hyperactor/
endpoint.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9use std::cell::Cell;
10use std::collections::VecDeque;
11use std::sync::Arc;
12use std::sync::atomic::AtomicUsize;
13use std::sync::atomic::Ordering;
14
15use hyperactor::ActorAddr;
16use hyperactor::Instance;
17use hyperactor::accum::Accumulator;
18use hyperactor::accum::CommReducer;
19use hyperactor::accum::ReducerFactory;
20use hyperactor::accum::ReducerSpec;
21use hyperactor::id::Label;
22use hyperactor::mailbox::OncePortReceiver;
23use hyperactor::mailbox::PortReceiver;
24use hyperactor_mesh::value_mesh::ValueOverlay;
25use hyperactor_mesh::value_mesh::rle;
26use monarch_types::py_global;
27use ndslice::Extent;
28use ndslice::Shape;
29use pyo3::prelude::*;
30use pyo3::types::PyDict;
31use pyo3::types::PyTuple;
32use serde_multipart::Part;
33use typeuri::Named;
34
35use crate::actor::MeshRef;
36use crate::actor::MethodSpecifier;
37use crate::actor::PythonActor;
38use crate::actor::PythonMessage;
39use crate::actor::PythonMessageKind;
40use crate::actor::PythonResponseMessage;
41use crate::actor_mesh::AllOrChoose;
42use crate::actor_mesh::PythonActorMesh;
43use crate::actor_mesh::SupervisableActorMesh;
44use crate::actor_mesh::to_all_or_choose;
45use crate::context::PyInstance;
46use crate::mailbox::EitherPortRef;
47use crate::mailbox::PythonOncePortRef;
48use crate::mailbox::PythonPortRef;
49use crate::metrics::ENDPOINT_BROADCAST_ERROR;
50use crate::metrics::ENDPOINT_BROADCAST_THROUGHPUT;
51use crate::metrics::ENDPOINT_CALL_ERROR;
52use crate::metrics::ENDPOINT_CALL_LATENCY_US_HISTOGRAM;
53use crate::metrics::ENDPOINT_CALL_ONE_ERROR;
54use crate::metrics::ENDPOINT_CALL_ONE_LATENCY_US_HISTOGRAM;
55use crate::metrics::ENDPOINT_CALL_ONE_THROUGHPUT;
56use crate::metrics::ENDPOINT_CALL_THROUGHPUT;
57use crate::metrics::ENDPOINT_CHOOSE_ERROR;
58use crate::metrics::ENDPOINT_CHOOSE_LATENCY_US_HISTOGRAM;
59use crate::metrics::ENDPOINT_CHOOSE_THROUGHPUT;
60use crate::metrics::ENDPOINT_MESSAGE_SIZE_HISTOGRAM;
61use crate::metrics::ENDPOINT_STREAM_ERROR;
62use crate::metrics::ENDPOINT_STREAM_LATENCY_US_HISTOGRAM;
63use crate::metrics::ENDPOINT_STREAM_THROUGHPUT;
64use crate::metrics::EndpointAttrs;
65use crate::metrics::UNKNOWN;
66use crate::pickle::PendingMessage;
67use crate::pickle::PicklingState;
68use crate::pytokio::PyPythonTask;
69use crate::pytokio::PythonTask;
70use crate::runtime::GilSite;
71use crate::runtime::monarch_with_gil_blocking;
72use crate::shape::PyExtent;
73use crate::shape::PyShape;
74use crate::supervision::Supervisable;
75use crate::supervision::SupervisionError;
76use crate::value_mesh::PyValueMesh;
77
78py_global!(get_context, "monarch._src.actor.actor_mesh", "context");
79py_global!(
80    create_endpoint_message,
81    "monarch._src.actor.actor_mesh",
82    "_create_endpoint_message"
83);
84py_global!(
85    dispatch_actor_rref,
86    "monarch._src.actor.actor_mesh",
87    "_dispatch_actor_rref"
88);
89py_global!(make_future, "monarch._src.actor.future", "Future");
90
91/// The type of endpoint operation being performed.
92///
93/// Used to select the appropriate telemetry metrics for each operation type.
94#[derive(Clone, Copy, Debug)]
95pub(crate) enum EndpointAdverb {
96    Call,
97    CallOne,
98    Choose,
99    Stream,
100    Broadcast,
101}
102
103impl EndpointAdverb {
104    fn as_str(self) -> &'static str {
105        match self {
106            Self::Call => "call",
107            Self::CallOne => "call_one",
108            Self::Choose => "choose",
109            Self::Stream => "stream",
110            Self::Broadcast => "broadcast",
111        }
112    }
113}
114
115/// RAII guard for recording endpoint call telemetry.
116///
117/// Records latency on drop, similar to Python's `@_with_telemetry` decorator.
118/// Call `mark_error()` before dropping to also record an error.
119pub struct RecordEndpointGuard {
120    start: tokio::time::Instant,
121    attrs: Arc<EndpointAttrs>,
122    adverb: EndpointAdverb,
123    error_occurred: Cell<bool>,
124}
125
126impl RecordEndpointGuard {
127    fn new(start: tokio::time::Instant, attrs: Arc<EndpointAttrs>, adverb: EndpointAdverb) -> Self {
128        let attributes = attrs.as_slice();
129        match adverb {
130            EndpointAdverb::Call => {
131                ENDPOINT_CALL_THROUGHPUT.add(1, attributes);
132            }
133            EndpointAdverb::CallOne => {
134                ENDPOINT_CALL_ONE_THROUGHPUT.add(1, attributes);
135            }
136            EndpointAdverb::Choose => {
137                ENDPOINT_CHOOSE_THROUGHPUT.add(1, attributes);
138            }
139            EndpointAdverb::Stream | EndpointAdverb::Broadcast => {
140                // Throughput already recorded at the call site
141            }
142        }
143
144        Self {
145            start,
146            attrs,
147            adverb,
148            error_occurred: Cell::new(false),
149        }
150    }
151
152    fn mark_error(&self) {
153        self.error_occurred.set(true);
154    }
155}
156
157impl Drop for RecordEndpointGuard {
158    fn drop(&mut self) {
159        let attributes = self.attrs.as_slice();
160        let duration_us = self.start.elapsed().as_micros();
161
162        match self.adverb {
163            EndpointAdverb::Call => {
164                ENDPOINT_CALL_LATENCY_US_HISTOGRAM.record(duration_us as f64, attributes);
165            }
166            EndpointAdverb::CallOne => {
167                ENDPOINT_CALL_ONE_LATENCY_US_HISTOGRAM.record(duration_us as f64, attributes);
168            }
169            EndpointAdverb::Choose => {
170                ENDPOINT_CHOOSE_LATENCY_US_HISTOGRAM.record(duration_us as f64, attributes);
171            }
172            EndpointAdverb::Stream => {
173                ENDPOINT_STREAM_LATENCY_US_HISTOGRAM.record(duration_us as f64, attributes);
174            }
175            EndpointAdverb::Broadcast => {}
176        }
177
178        if self.error_occurred.get() {
179            match self.adverb {
180                EndpointAdverb::Call => {
181                    ENDPOINT_CALL_ERROR.add(1, attributes);
182                }
183                EndpointAdverb::CallOne => {
184                    ENDPOINT_CALL_ONE_ERROR.add(1, attributes);
185                }
186                EndpointAdverb::Choose => {
187                    ENDPOINT_CHOOSE_ERROR.add(1, attributes);
188                }
189                EndpointAdverb::Stream => {
190                    ENDPOINT_STREAM_ERROR.add(1, attributes);
191                }
192                EndpointAdverb::Broadcast => {}
193            }
194        }
195    }
196}
197
198/// Send-safe RAII guard for an OTEL-style endpoint span.
199///
200/// We only need endpoint spans for telemetry slices, not for `tracing` context
201/// propagation. So this guard emits synthetic trace events directly into the
202/// unified telemetry dispatcher instead of holding a real `tracing::Span`
203/// across `.await` points.
204pub(crate) struct SpanGuard {
205    id: u64,
206}
207
208impl SpanGuard {
209    fn actor_endpoint(
210        name: &'static str,
211        actor_id: &ActorAddr,
212        mesh: &str,
213        method: &str,
214        correlation_id: u64,
215    ) -> Self {
216        Self {
217            id: hyperactor_telemetry::start_user_span(
218                name,
219                hyperactor_telemetry::sinks::perfetto::ENDPOINT_TELEMETRY_TARGET,
220                [
221                    (
222                        "actor_id",
223                        hyperactor_telemetry::trace_dispatcher::FieldValue::Str(
224                            actor_id.to_string(),
225                        ),
226                    ),
227                    (
228                        "mesh",
229                        hyperactor_telemetry::trace_dispatcher::FieldValue::Str(mesh.to_string()),
230                    ),
231                    (
232                        "method",
233                        hyperactor_telemetry::trace_dispatcher::FieldValue::Str(method.to_string()),
234                    ),
235                    (
236                        "correlation_id",
237                        hyperactor_telemetry::trace_dispatcher::FieldValue::U64(correlation_id),
238                    ),
239                ],
240            ),
241        }
242    }
243
244    fn remote(
245        name: &'static str,
246        actor_id: &ActorAddr,
247        call_name: &str,
248        correlation_id: u64,
249    ) -> Self {
250        Self {
251            id: hyperactor_telemetry::start_user_span(
252                name,
253                hyperactor_telemetry::sinks::perfetto::ENDPOINT_TELEMETRY_TARGET,
254                [
255                    (
256                        "actor_id",
257                        hyperactor_telemetry::trace_dispatcher::FieldValue::Str(
258                            actor_id.to_string(),
259                        ),
260                    ),
261                    (
262                        "call_name",
263                        hyperactor_telemetry::trace_dispatcher::FieldValue::Str(
264                            call_name.to_string(),
265                        ),
266                    ),
267                    (
268                        "correlation_id",
269                        hyperactor_telemetry::trace_dispatcher::FieldValue::U64(correlation_id),
270                    ),
271                ],
272            ),
273        }
274    }
275}
276
277impl Drop for SpanGuard {
278    fn drop(&mut self) {
279        hyperactor_telemetry::end_user_span(self.id);
280    }
281}
282
283fn supervision_error_to_pyerr(err: PyErr, qualified_endpoint_name: &Option<String>) -> PyErr {
284    match qualified_endpoint_name {
285        Some(endpoint) => monarch_with_gil_blocking(GilSite::Supervise, |py| {
286            SupervisionError::set_endpoint_on_err(py, err, endpoint.clone())
287        }),
288        None => err,
289    }
290}
291
292async fn collect_value(
293    rx: &mut PortReceiver<PythonMessage>,
294    supervision_monitor: &Option<Arc<dyn Supervisable>>,
295    instance: &Instance<PythonActor>,
296    qualified_endpoint_name: &Option<String>,
297) -> PyResult<(Part, Vec<MeshRef>, Option<usize>)> {
298    enum RaceResult {
299        Message(Box<PythonMessage>),
300        SupervisionError(PyErr),
301        RecvError(String),
302    }
303
304    let race_result = match supervision_monitor {
305        Some(sup) => {
306            tokio::select! {
307                biased;
308                result = sup.supervision_event(instance) => {
309                    match result {
310                        Some(err) => RaceResult::SupervisionError(err),
311                        None => {
312                            match rx.recv().await {
313                                Ok(msg) => RaceResult::Message(Box::new(msg)),
314                                Err(e) => RaceResult::RecvError(e.to_string()),
315                            }
316                        }
317                    }
318                }
319                msg = rx.recv() => {
320                    match msg {
321                        Ok(m) => RaceResult::Message(Box::new(m)),
322                        Err(e) => RaceResult::RecvError(e.to_string()),
323                    }
324                }
325            }
326        }
327        _ => match rx.recv().await {
328            Ok(msg) => RaceResult::Message(Box::new(msg)),
329            Err(e) => RaceResult::RecvError(e.to_string()),
330        },
331    };
332
333    match race_result {
334        RaceResult::Message(boxed) => {
335            let PythonMessage {
336                kind,
337                message,
338                refs,
339            } = *boxed;
340            match kind {
341                PythonMessageKind::Result { rank, .. } => Ok((message, refs, rank)),
342                PythonMessageKind::Exception { .. } => {
343                    monarch_with_gil_blocking(GilSite::Traceback, |py| {
344                        let mesh_references: VecDeque<Option<MeshRef>> =
345                            refs.into_iter().map(Some).collect();
346                        let mut state =
347                            PicklingState::from_parts(message, VecDeque::new(), mesh_references);
348                        Err(PyErr::from_value(
349                            state.unpickle_with_receiver(py, instance)?.into_bound(py),
350                        ))
351                    })
352                }
353                other => Err(pyo3::exceptions::PyValueError::new_err(format!(
354                    "unexpected message kind {:?}",
355                    other
356                ))),
357            }
358        }
359        RaceResult::RecvError(e) => Err(pyo3::exceptions::PyEOFError::new_err(format!(
360            "Port closed: {}",
361            e
362        ))),
363        RaceResult::SupervisionError(err) => {
364            Err(supervision_error_to_pyerr(err, qualified_endpoint_name))
365        }
366    }
367}
368
369// HOT PATH: Be mindful of performance when making changes here.
370// To test how performance is affected by a change, run the RPC benchmarks in
371// `monarch/python/benches/`.
372#[tracing::instrument(level = "debug", skip_all)]
373async fn collect_valuemesh(
374    extent: Extent,
375    rx: OncePortReceiver<PythonMessage>,
376    attrs: Arc<EndpointAttrs>,
377    supervision_monitor: Option<Arc<dyn Supervisable>>,
378    instance: &Instance<PythonActor>,
379    qualified_endpoint_name: Option<String>,
380) -> PyResult<Py<PyAny>> {
381    let start = tokio::time::Instant::now();
382
383    let expected_count = extent.num_ranks();
384
385    let record_guard = RecordEndpointGuard::new(start, attrs, EndpointAdverb::Call);
386
387    enum RaceResult {
388        Collected(Box<PythonMessage>),
389        SupervisionError(PyErr),
390        RecvError(String),
391    }
392
393    let race_result = match &supervision_monitor {
394        Some(sup) => {
395            tokio::select! {
396                biased;
397                result = sup.supervision_event(instance) => {
398                    match result {
399                        Some(err) => RaceResult::SupervisionError(err),
400                        None => RaceResult::RecvError(
401                            "supervision monitor closed unexpectedly".to_string()
402                        ),
403                    }
404                }
405                batch = rx.recv() => {
406                    match batch {
407                        Ok(b) => RaceResult::Collected(Box::new(b)),
408                        Err(e) => RaceResult::RecvError(e.to_string()),
409                    }
410                }
411            }
412        }
413        None => match rx.recv().await {
414            Ok(batch) => RaceResult::Collected(Box::new(batch)),
415            Err(e) => RaceResult::RecvError(e.to_string()),
416        },
417    };
418
419    match race_result {
420        RaceResult::Collected(boxed) => {
421            let msg = *boxed;
422            let overlay = msg.into_overlay().map_err(|e| {
423                pyo3::exceptions::PyRuntimeError::new_err(format!(
424                    "failed to extract overlay from collected responses: {e}"
425                ))
426            })?;
427            monarch_with_gil_blocking(GilSite::ReplyConvert, |py| {
428                // Out-of-band mesh refs reunite only while the `PicklingState` is
429                // live, i.e. during decode, so a ref-carrying response must be
430                // decoded here (eagerly, at accumulation): a lazy decode on access
431                // would have no state to reunite against, the REFS-1 failure. But
432                // decoding *every* response here would revert D96180139, which made
433                // valuemesh values unpickle lazily on access so a large
434                // OnceBuffer-accumulated `.call()` does not pay one big unpickle
435                // burst at the end of collection.
436                //
437                // Reconcile the two by gating on refs. A `.call()` fans one
438                // endpoint over the mesh, so the batch is uniform: either every
439                // response carries refs (the endpoint returns a mesh, the minority)
440                // or none does (plain values, the common case). With no refs we
441                // keep the raw parts and let them unpickle on access (D96180139,
442                // preserved); only with refs present do we decode eagerly to
443                // reunite them.
444                let has_refs = overlay.runs().any(|(_, payload)| {
445                    let (PythonResponseMessage::Result { refs, .. }
446                    | PythonResponseMessage::Exception { refs, .. }) = payload;
447                    !refs.is_empty()
448                });
449
450                if !has_refs {
451                    let mut parts = Vec::with_capacity(expected_count);
452                    for (range, payload) in overlay.runs() {
453                        match payload {
454                            PythonResponseMessage::Result { part, .. } => {
455                                parts.extend(range.clone().map(|_| part.clone()));
456                            }
457                            PythonResponseMessage::Exception { .. } => {
458                                record_guard.mark_error();
459                                return Err(PyErr::from_value(
460                                    payload.decode(py, instance)?.into_bound(py),
461                                ));
462                            }
463                        }
464                    }
465                    return Ok(PyValueMesh::build_from_parts(&extent, parts)?
466                        .into_pyobject(py)?
467                        .into_any()
468                        .unbind());
469                }
470
471                let mut objects: Vec<Py<PyAny>> = Vec::with_capacity(expected_count);
472                for (range, payload) in overlay.runs() {
473                    match payload {
474                        PythonResponseMessage::Result { .. } => {
475                            let obj = payload.decode(py, instance)?;
476                            objects.extend(range.clone().map(|_| obj.clone_ref(py)));
477                        }
478                        PythonResponseMessage::Exception { .. } => {
479                            record_guard.mark_error();
480                            return Err(PyErr::from_value(
481                                payload.decode(py, instance)?.into_bound(py),
482                            ));
483                        }
484                    }
485                }
486                Ok(PyValueMesh::build_from_objects(&extent, objects)?
487                    .into_pyobject(py)?
488                    .into_any()
489                    .unbind())
490            })
491        }
492        RaceResult::RecvError(e) => {
493            record_guard.mark_error();
494            Err(pyo3::exceptions::PyEOFError::new_err(format!(
495                "Port closed: {}",
496                e
497            )))
498        }
499        RaceResult::SupervisionError(err) => {
500            record_guard.mark_error();
501            Err(supervision_error_to_pyerr(err, &qualified_endpoint_name))
502        }
503    }
504}
505
506fn value_collector(
507    mut receiver: PortReceiver<PythonMessage>,
508    attrs: Arc<EndpointAttrs>,
509    supervision_monitor: Option<Arc<dyn Supervisable>>,
510    instance: Instance<PythonActor>,
511    qualified_endpoint_name: Option<String>,
512    adverb: EndpointAdverb,
513    span_guard: SpanGuard,
514) -> PyResult<PyPythonTask> {
515    Ok(PythonTask::new(async move {
516        let _span_guard = span_guard;
517        let start = tokio::time::Instant::now();
518
519        let record_guard = RecordEndpointGuard::new(start, attrs, adverb);
520
521        match collect_value(
522            &mut receiver,
523            &supervision_monitor,
524            &instance,
525            &qualified_endpoint_name,
526        )
527        .await
528        {
529            Ok((message, refs, _)) => monarch_with_gil_blocking(GilSite::ReplyConvert, |py| {
530                let mesh_references: VecDeque<Option<MeshRef>> =
531                    refs.into_iter().map(Some).collect();
532                let mut state =
533                    PicklingState::from_parts(message, VecDeque::new(), mesh_references);
534                state.unpickle_with_receiver(py, &instance)
535            }),
536            Err(e) => {
537                record_guard.mark_error();
538                Err(e)
539            }
540        }
541    })?
542    .into())
543}
544
545/// A streaming iterator that yields futures for each response from actors.
546///
547/// Implements Python's iterator protocol (`__iter__`/`__next__`) to yield
548/// `Future` objects that resolve to individual actor responses.
549#[pyclass(
550    name = "ValueStream",
551    module = "monarch._rust_bindings.monarch_hyperactor.endpoint"
552)]
553pub struct PyValueStream {
554    receiver: Arc<tokio::sync::Mutex<PortReceiver<PythonMessage>>>,
555    /// Supervisor for monitoring actor health during streaming.
556    supervision_monitor: Option<Arc<dyn Supervisable>>,
557    instance: Instance<PythonActor>,
558    remaining: AtomicUsize,
559    attrs: Arc<EndpointAttrs>,
560    qualified_endpoint_name: Option<String>,
561    start: tokio::time::Instant,
562    future_class: Py<PyAny>,
563}
564
565#[pymethods]
566impl PyValueStream {
567    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
568        slf
569    }
570
571    fn __next__(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
572        let remaining = self.remaining.load(Ordering::Relaxed);
573        if remaining == 0 {
574            return Ok(None);
575        }
576        self.remaining.store(remaining - 1, Ordering::Relaxed);
577
578        let receiver = self.receiver.clone();
579        let supervision_monitor = self.supervision_monitor.clone();
580        let instance = self.instance.clone_for_py();
581        let qualified_endpoint_name = self.qualified_endpoint_name.clone();
582        let start = self.start;
583        let attrs = self.attrs.clone();
584
585        let task: PyPythonTask = PythonTask::new(async move {
586            let record_guard = RecordEndpointGuard::new(start, attrs, EndpointAdverb::Stream);
587
588            let mut rx_guard = receiver.lock().await;
589
590            match collect_value(
591                &mut rx_guard,
592                &supervision_monitor,
593                &instance,
594                &qualified_endpoint_name,
595            )
596            .await
597            {
598                Ok((message, refs, _)) => monarch_with_gil_blocking(GilSite::ReplyConvert, |py| {
599                    let mesh_references: VecDeque<Option<MeshRef>> =
600                        refs.into_iter().map(Some).collect();
601                    let mut state =
602                        PicklingState::from_parts(message, VecDeque::new(), mesh_references);
603                    state.unpickle_with_receiver(py, &instance)
604                }),
605                Err(e) => {
606                    record_guard.mark_error();
607                    Err(e)
608                }
609            }
610        })?
611        .into();
612
613        let future = self.future_class.call_method1(py, "_from_coro", (task,))?;
614        Ok(Some(future))
615    }
616}
617
618fn wrap_in_future(py: Python<'_>, task: PyPythonTask) -> PyResult<Py<PyAny>> {
619    let future = make_future(py).call_method1("_from_coro", (task,))?;
620    Ok(future.unbind())
621}
622
623/// Trait that defines the core operations an endpoint must provide.
624/// Both ActorEndpoint and RemoteEndpoint implement this trait.
625pub(crate) trait Endpoint {
626    /// Get the extent of the endpoint's targets.
627    fn get_extent(&self, py: Python<'_>) -> PyResult<Extent>;
628
629    /// Get the method name for this endpoint.
630    fn get_method_name(&self) -> &str;
631
632    /// The attributes every metric recorded for this endpoint carries. Built
633    /// once per endpoint; the adverbs hand a clone to the tasks that outlive
634    /// the borrow of `self`.
635    fn metric_attrs(&self) -> &Arc<EndpointAttrs>;
636
637    /// Create and send a message with the given args/kwargs.
638    fn send_message<'py>(
639        &self,
640        py: Python<'py>,
641        args: &Bound<'py, PyTuple>,
642        kwargs: Option<&Bound<'py, PyDict>>,
643        port_ref: Option<EitherPortRef>,
644        selection: AllOrChoose,
645        instance: &Instance<PythonActor>,
646        correlation_id: Option<u64>,
647    ) -> PyResult<()>;
648
649    /// Like `send_message` but stamps `caller_headers` onto the
650    /// outgoing request envelope. Implementations that can carry
651    /// headers override this; the default delegates to `send_message`
652    /// and drops them.
653    fn send_message_with_headers<'py>(
654        &self,
655        py: Python<'py>,
656        args: &Bound<'py, PyTuple>,
657        kwargs: Option<&Bound<'py, PyDict>>,
658        port_ref: Option<EitherPortRef>,
659        selection: AllOrChoose,
660        instance: &Instance<PythonActor>,
661        _caller_headers: hyperactor_config::Flattrs,
662        correlation_id: Option<u64>,
663    ) -> PyResult<()> {
664        self.send_message(
665            py,
666            args,
667            kwargs,
668            port_ref,
669            selection,
670            instance,
671            correlation_id,
672        )
673    }
674
675    /// Build the operation-context envelope headers to stamp on an
676    /// outgoing request for this endpoint invocation. The result is
677    /// empty when the endpoint cannot supply a qualified name
678    /// (e.g. `RemoteEndpoint`'s `get_qualified_name` returns `None`),
679    /// in which case callers see the unchanged dispatch surface.
680    fn build_operation_context_headers(
681        &self,
682        adverb: EndpointAdverb,
683    ) -> hyperactor_config::Flattrs {
684        let adverb_str = adverb.as_str();
685        let attrs = crate::operation_context::build_operation_context_attrs(
686            self.get_qualified_name(),
687            Some(adverb_str),
688        );
689        let mut headers = hyperactor_config::Flattrs::new();
690        crate::operation_context::stamp_operation_context(&mut headers, &attrs);
691        headers
692    }
693
694    /// Get the supervision_monitor for this endpoint (if any).
695    fn get_supervision_monitor(&self) -> Option<Arc<dyn Supervisable>>;
696
697    /// Get the qualified endpoint name for error messages (if any).
698    fn get_qualified_name(&self) -> Option<String>;
699
700    /// Open an OTEL-style span for an endpoint invocation. Each impl attaches
701    /// the fields the perfetto sink needs to synthesize the display name
702    /// (`{mesh}.{method}.{adverb}` for ActorEndpoint, `{call_name}.{adverb}`
703    /// for Remote) and route the slice to an actor-specific track. The adverb is the span name,
704    /// so no formatting happens at the call site and the sink formats only when
705    /// it renders the slice.
706    fn enter_endpoint_span(
707        &self,
708        adverb: EndpointAdverb,
709        actor_id: &ActorAddr,
710        correlation_id: u64,
711    ) -> SpanGuard;
712
713    fn get_current_instance(&self, py: Python<'_>) -> PyResult<Instance<PythonActor>> {
714        let context = get_context(py).call0()?;
715        let py_instance: PyRef<PyInstance> = context.getattr("actor_instance")?.extract()?;
716        Ok(py_instance.clone().into_instance())
717    }
718
719    fn open_response_port(
720        &self,
721        instance: &Instance<PythonActor>,
722    ) -> (PythonPortRef, PortReceiver<PythonMessage>) {
723        let (p, receiver) = instance.mailbox_for_py().open_port::<PythonMessage>();
724        (PythonPortRef { inner: p.bind() }, receiver)
725    }
726
727    fn open_reduce_response_port(
728        &self,
729        instance: &Instance<PythonActor>,
730    ) -> (PythonOncePortRef, OncePortReceiver<PythonMessage>) {
731        let (p, receiver) = instance
732            .mailbox_for_py()
733            .open_reduce_port(PythonResponseMessageAccumulator);
734        (PythonOncePortRef::from(p.bind()), receiver)
735    }
736
737    // HOT PATH: Be mindful of performance when making changes here.
738    // To test how performance is affected by a change, run the RPC benchmarks in
739    // `monarch/python/benches/`.
740    /// Call the endpoint on all actors and collect all responses into a ValueMesh.
741    #[tracing::instrument(level = "debug", skip_all)]
742    fn call<'py>(
743        &self,
744        py: Python<'py>,
745        args: &Bound<'py, PyTuple>,
746        kwargs: Option<&Bound<'py, PyDict>>,
747    ) -> PyResult<Py<PyAny>> {
748        let instance = self.get_current_instance(py)?;
749        let correlation_id: u64 = fastrand::u64(..);
750        let span_guard =
751            self.enter_endpoint_span(EndpointAdverb::Call, instance.self_addr(), correlation_id);
752
753        let extent = self.get_extent(py)?;
754        let attrs = self.metric_attrs().clone();
755        let (port_ref, receiver) = self.open_reduce_response_port(&instance);
756
757        let supervision_monitor = self.get_supervision_monitor();
758        let qualified_endpoint_name = self.get_qualified_name();
759
760        let caller_headers = self.build_operation_context_headers(EndpointAdverb::Call);
761        self.send_message_with_headers(
762            py,
763            args,
764            kwargs,
765            Some(EitherPortRef::Once(port_ref)),
766            AllOrChoose::All,
767            &instance,
768            caller_headers,
769            Some(correlation_id),
770        )?;
771
772        let instance_for_task = instance.clone_for_py();
773        let task: PyPythonTask = PythonTask::new(async move {
774            let _span_guard = span_guard;
775            collect_valuemesh(
776                extent,
777                receiver,
778                attrs,
779                supervision_monitor,
780                &instance_for_task,
781                qualified_endpoint_name,
782            )
783            .await
784        })?
785        .into();
786
787        wrap_in_future(py, task)
788    }
789
790    /// Sends a message to a randomly selected actor and waits for its result.
791    ///
792    /// Each call independently selects an actor uniformly at random. Selection does
793    /// not account for actor load, so calls are balanced only across many calls. Use
794    /// `call_one` on a slice of the mesh when placement must be deterministic.
795    fn choose<'py>(
796        &self,
797        py: Python<'py>,
798        args: &Bound<'py, PyTuple>,
799        kwargs: Option<&Bound<'py, PyDict>>,
800    ) -> PyResult<Py<PyAny>> {
801        let instance = self.get_current_instance(py)?;
802        let correlation_id: u64 = fastrand::u64(..);
803        let span_guard =
804            self.enter_endpoint_span(EndpointAdverb::Choose, instance.self_addr(), correlation_id);
805        let (port_ref, receiver) = self.open_response_port(&instance);
806
807        let caller_headers = self.build_operation_context_headers(EndpointAdverb::Choose);
808        self.send_message_with_headers(
809            py,
810            args,
811            kwargs,
812            Some(EitherPortRef::Unbounded(port_ref)),
813            AllOrChoose::Choose,
814            &instance,
815            caller_headers,
816            Some(correlation_id),
817        )?;
818
819        let task = value_collector(
820            receiver,
821            self.metric_attrs().clone(),
822            self.get_supervision_monitor(),
823            instance.clone_for_py(),
824            self.get_qualified_name(),
825            EndpointAdverb::Choose,
826            span_guard,
827        )?;
828
829        wrap_in_future(py, task)
830    }
831
832    /// Call the endpoint on exactly one actor (the mesh must have exactly one actor).
833    fn call_one<'py>(
834        &self,
835        py: Python<'py>,
836        args: &Bound<'py, PyTuple>,
837        kwargs: Option<&Bound<'py, PyDict>>,
838    ) -> PyResult<Py<PyAny>> {
839        let extent = self.get_extent(py)?;
840
841        if extent.num_ranks() != 1 {
842            return Err(pyo3::exceptions::PyValueError::new_err(format!(
843                "call_one requires exactly 1 actor, but mesh has {}",
844                extent.num_ranks()
845            )));
846        }
847
848        let instance = self.get_current_instance(py)?;
849        let correlation_id: u64 = fastrand::u64(..);
850        let span_guard = self.enter_endpoint_span(
851            EndpointAdverb::CallOne,
852            instance.self_addr(),
853            correlation_id,
854        );
855        let (port_ref, receiver) = self.open_response_port(&instance);
856
857        let caller_headers = self.build_operation_context_headers(EndpointAdverb::CallOne);
858        self.send_message_with_headers(
859            py,
860            args,
861            kwargs,
862            Some(EitherPortRef::Unbounded(port_ref)),
863            AllOrChoose::All,
864            &instance,
865            caller_headers,
866            Some(correlation_id),
867        )?;
868
869        let task = value_collector(
870            receiver,
871            self.metric_attrs().clone(),
872            self.get_supervision_monitor(),
873            instance.clone_for_py(),
874            self.get_qualified_name(),
875            EndpointAdverb::CallOne,
876            span_guard,
877        )?;
878
879        wrap_in_future(py, task)
880    }
881
882    /// Call the endpoint on all actors and return an iterator of Futures.
883    fn stream<'py>(
884        &self,
885        py: Python<'py>,
886        args: &Bound<'py, PyTuple>,
887        kwargs: Option<&Bound<'py, PyDict>>,
888    ) -> PyResult<Py<PyAny>> {
889        let extent = self.get_extent(py)?;
890
891        let instance = self.get_current_instance(py)?;
892        let (port_ref, receiver) = self.open_response_port(&instance);
893
894        let correlation_id: u64 = fastrand::u64(..);
895        // Send-time caller span so the correlation id has a sender for the flow
896        // arrow; without it the receiver spans are the only ones carrying the id.
897        let _span_guard =
898            self.enter_endpoint_span(EndpointAdverb::Stream, instance.self_addr(), correlation_id);
899        let caller_headers = self.build_operation_context_headers(EndpointAdverb::Stream);
900        self.send_message_with_headers(
901            py,
902            args,
903            kwargs,
904            Some(EitherPortRef::Unbounded(port_ref)),
905            AllOrChoose::All,
906            &instance,
907            caller_headers,
908            Some(correlation_id),
909        )?;
910
911        let actor_count = extent.num_ranks();
912        let start = tokio::time::Instant::now();
913        let supervision_monitor = self.get_supervision_monitor();
914        let qualified_endpoint_name = self.get_qualified_name();
915        let future_class = make_future(py).unbind();
916
917        ENDPOINT_STREAM_THROUGHPUT.add(1, self.metric_attrs().as_slice());
918
919        let stream = PyValueStream {
920            receiver: Arc::new(tokio::sync::Mutex::new(receiver)),
921            supervision_monitor,
922            instance: instance.clone_for_py(),
923            remaining: AtomicUsize::new(actor_count),
924            attrs: self.metric_attrs().clone(),
925            qualified_endpoint_name,
926            start,
927            future_class,
928        };
929
930        Ok(stream.into_pyobject(py)?.unbind().into())
931    }
932
933    /// Send a message to all actors without waiting for responses (fire-and-forget).
934    fn broadcast<'py>(
935        &self,
936        py: Python<'py>,
937        args: &Bound<'py, PyTuple>,
938        kwargs: Option<&Bound<'py, PyDict>>,
939    ) -> PyResult<()> {
940        let instance = self.get_current_instance(py)?;
941        let correlation_id: u64 = fastrand::u64(..);
942        let _span_guard = self.enter_endpoint_span(
943            EndpointAdverb::Broadcast,
944            instance.self_addr(),
945            correlation_id,
946        );
947        let attributes = self.metric_attrs().as_slice();
948
949        match self.send_message(
950            py,
951            args,
952            kwargs,
953            None,
954            AllOrChoose::All,
955            &instance,
956            Some(correlation_id),
957        ) {
958            Ok(()) => {
959                ENDPOINT_BROADCAST_THROUGHPUT.add(1, attributes);
960                Ok(())
961            }
962            Err(e) => {
963                ENDPOINT_BROADCAST_ERROR.add(1, attributes);
964                Err(e)
965            }
966        }
967    }
968}
969
970#[pyclass(
971    name = "ActorEndpoint",
972    module = "monarch._rust_bindings.monarch_hyperactor.endpoint"
973)]
974pub struct ActorEndpoint {
975    inner: Arc<dyn SupervisableActorMesh>,
976    shape: Shape,
977    method: MethodSpecifier,
978    mesh_name: String,
979    signature: Option<Py<PyAny>>,
980    proc_mesh: Option<Py<PyAny>>,
981    propagator: Option<Py<PyAny>>,
982    /// An endpoint outlives every invocation on it, so building its attributes
983    /// once here keeps the per-invocation metrics allocation-free.
984    attrs: Arc<EndpointAttrs>,
985}
986
987impl ActorEndpoint {
988    // HOT PATH: Be mindful of performance when making changes here.
989    // To test how performance is affected by a change, run the RPC benchmarks in
990    // `monarch/python/benches/`.
991    fn create_message<'py>(
992        &self,
993        py: Python<'py>,
994        args: &Bound<'py, PyTuple>,
995        kwargs: Option<&Bound<'py, PyDict>>,
996        port_ref: Option<EitherPortRef>,
997        correlation_id: Option<u64>,
998    ) -> PyResult<PendingMessage> {
999        let port_ref_py: Py<PyAny> = match port_ref {
1000            Some(pr) => pr.clone().into_pyobject(py)?.unbind(),
1001            None => py.None(),
1002        };
1003
1004        let result = create_endpoint_message(py).call1((
1005            self.method.clone(),
1006            self.signature
1007                .as_ref()
1008                .map_or_else(|| py.None(), |s| s.clone_ref(py)),
1009            args,
1010            kwargs
1011                .map_or_else(|| PyDict::new(py), |d| d.clone())
1012                .into_any(),
1013            port_ref_py,
1014            self.proc_mesh
1015                .as_ref()
1016                .map_or_else(|| py.None(), |p| p.clone_ref(py)),
1017            correlation_id,
1018        ))?;
1019        let mut pending: PyRefMut<'_, PendingMessage> = result.extract()?;
1020        let message = pending.take()?;
1021
1022        ENDPOINT_MESSAGE_SIZE_HISTOGRAM.record(message.payload_len() as f64, self.attrs.as_slice());
1023
1024        Ok(message)
1025    }
1026}
1027
1028impl Endpoint for ActorEndpoint {
1029    fn get_extent(&self, _py: Python<'_>) -> PyResult<Extent> {
1030        Ok(self.shape.extent())
1031    }
1032
1033    fn get_method_name(&self) -> &str {
1034        self.method.name()
1035    }
1036
1037    fn metric_attrs(&self) -> &Arc<EndpointAttrs> {
1038        &self.attrs
1039    }
1040
1041    fn send_message<'py>(
1042        &self,
1043        py: Python<'py>,
1044        args: &Bound<'py, PyTuple>,
1045        kwargs: Option<&Bound<'py, PyDict>>,
1046        port_ref: Option<EitherPortRef>,
1047        selection: AllOrChoose,
1048        instance: &Instance<PythonActor>,
1049        correlation_id: Option<u64>,
1050    ) -> PyResult<()> {
1051        let message = self.create_message(py, args, kwargs, port_ref, correlation_id)?;
1052        self.inner.cast_unresolved(message, selection, instance)
1053    }
1054
1055    fn send_message_with_headers<'py>(
1056        &self,
1057        py: Python<'py>,
1058        args: &Bound<'py, PyTuple>,
1059        kwargs: Option<&Bound<'py, PyDict>>,
1060        port_ref: Option<EitherPortRef>,
1061        selection: AllOrChoose,
1062        instance: &Instance<PythonActor>,
1063        caller_headers: hyperactor_config::Flattrs,
1064        correlation_id: Option<u64>,
1065    ) -> PyResult<()> {
1066        let message = self.create_message(py, args, kwargs, port_ref, correlation_id)?;
1067        self.inner
1068            .cast_unresolved_with_headers(message, selection, instance, caller_headers)
1069    }
1070
1071    fn get_supervision_monitor(&self) -> Option<Arc<dyn Supervisable>> {
1072        Some(self.inner.clone())
1073    }
1074
1075    fn get_qualified_name(&self) -> Option<String> {
1076        Some(format!("{}.{}()", self.mesh_name, self.method.name()))
1077    }
1078
1079    fn enter_endpoint_span(
1080        &self,
1081        adverb: EndpointAdverb,
1082        actor_id: &ActorAddr,
1083        correlation_id: u64,
1084    ) -> SpanGuard {
1085        let mesh = self.mesh_name.as_str();
1086        let method = self.get_method_name();
1087        SpanGuard::actor_endpoint(adverb.as_str(), actor_id, mesh, method, correlation_id)
1088    }
1089}
1090
1091#[pymethods]
1092impl ActorEndpoint {
1093    /// Create a new ActorEndpoint.
1094    #[new]
1095    #[pyo3(signature = (actor_mesh, method, shape, mesh_name, signature=None, proc_mesh=None, propagator=None))]
1096    fn new(
1097        actor_mesh: PythonActorMesh,
1098        method: MethodSpecifier,
1099        shape: PyShape,
1100        mesh_name: String,
1101        signature: Option<Py<PyAny>>,
1102        proc_mesh: Option<Py<PyAny>>,
1103        propagator: Option<Py<PyAny>>,
1104    ) -> Self {
1105        // `mesh_name` is the raw name Python spawned the mesh under, so it takes
1106        // the same stripping that produced the actors' own label. Only the
1107        // metric is canonicalized; `mesh_name` keeps the user's spelling for
1108        // error messages and spans.
1109        let actor = Label::strip(&mesh_name);
1110        let attrs = Arc::new(EndpointAttrs::new(method.name(), Some(&actor)));
1111        Self {
1112            inner: actor_mesh.get_inner(),
1113            shape: shape.get_inner().clone(),
1114            method,
1115            mesh_name,
1116            signature,
1117            proc_mesh,
1118            propagator,
1119            attrs,
1120        }
1121    }
1122
1123    /// Get the method specifier (used by actor_rref for tensor dispatch).
1124    #[getter]
1125    fn _name(&self) -> MethodSpecifier {
1126        self.method.clone()
1127    }
1128
1129    /// Get the signature (used for argument checking in _dispatch_actor_rref).
1130    #[getter]
1131    fn _signature(&self, py: Python<'_>) -> Py<PyAny> {
1132        self.signature
1133            .clone()
1134            .unwrap_or_else(|| py.None().into_any())
1135    }
1136
1137    /// Get the actor mesh (used by actor_rref for sending messages).
1138    #[getter]
1139    fn _actor_mesh(&self) -> PythonActorMesh {
1140        PythonActorMesh::from_impl(self.inner.clone())
1141    }
1142
1143    /// Propagation method for tensor shape inference.
1144    /// Delegates to Python _do_propagate helper.
1145    fn _propagate<'py>(
1146        &self,
1147        py: Python<'py>,
1148        args: &Bound<'py, PyAny>,
1149        kwargs: &Bound<'py, PyAny>,
1150        fake_args: &Bound<'py, PyAny>,
1151        fake_kwargs: &Bound<'py, PyAny>,
1152    ) -> PyResult<Py<PyAny>> {
1153        let do_propagate = py
1154            .import("monarch._src.actor.endpoint")?
1155            .getattr("_do_propagate")?;
1156        let propagator = self
1157            .propagator
1158            .as_ref()
1159            .map(|p| p.clone_ref(py).into_bound(py))
1160            .unwrap_or_else(|| py.None().into_bound(py));
1161        let cache = PyDict::new(py);
1162        do_propagate
1163            .call1((&propagator, args, kwargs, fake_args, fake_kwargs, cache))?
1164            .extract()
1165            .map_err(Into::into)
1166    }
1167
1168    /// Propagation for fetch operations.
1169    /// Returns None if no propagator is provided, otherwise calls _propagate.
1170    fn _fetch_propagate<'py>(
1171        &self,
1172        py: Python<'py>,
1173        args: &Bound<'py, PyAny>,
1174        kwargs: &Bound<'py, PyAny>,
1175        fake_args: &Bound<'py, PyAny>,
1176        fake_kwargs: &Bound<'py, PyAny>,
1177    ) -> PyResult<Py<PyAny>> {
1178        if self.propagator.is_none() {
1179            return Ok(py.None());
1180        }
1181        self._propagate(py, args, kwargs, fake_args, fake_kwargs)
1182    }
1183
1184    /// Propagation for pipe operations.
1185    /// Requires an explicit callable propagator.
1186    fn _pipe_propagate<'py>(
1187        &self,
1188        py: Python<'py>,
1189        args: &Bound<'py, PyAny>,
1190        kwargs: &Bound<'py, PyAny>,
1191        fake_args: &Bound<'py, PyAny>,
1192        fake_kwargs: &Bound<'py, PyAny>,
1193    ) -> PyResult<Py<PyAny>> {
1194        // Check if propagator is callable
1195        let is_callable = self
1196            .propagator
1197            .as_ref()
1198            .map(|p| p.bind(py).is_callable())
1199            .unwrap_or(false);
1200        if !is_callable {
1201            return Err(pyo3::exceptions::PyValueError::new_err(
1202                "Must specify explicit callable for pipe",
1203            ));
1204        }
1205        self._propagate(py, args, kwargs, fake_args, fake_kwargs)
1206    }
1207
1208    /// Get the rref result by calling the Python dispatch helper.
1209    #[pyo3(signature = (*args, **kwargs))]
1210    fn rref<'py>(
1211        slf: PyRef<'py, Self>,
1212        py: Python<'py>,
1213        args: &Bound<'py, PyTuple>,
1214        kwargs: Option<&Bound<'py, PyDict>>,
1215    ) -> PyResult<Py<PyAny>> {
1216        let kwargs_dict = kwargs.map_or_else(|| PyDict::new(py), |d| d.clone());
1217
1218        // Call _dispatch_actor_rref(endpoint, args, kwargs)
1219        let result = dispatch_actor_rref(py).call1((slf.into_pyobject(py)?, args, kwargs_dict))?;
1220
1221        Ok(result.unbind())
1222    }
1223
1224    /// Call the endpoint on all actors and collect all responses into a ValueMesh.
1225    #[pyo3(signature = (*args, **kwargs), name = "call")]
1226    fn py_call<'py>(
1227        &self,
1228        py: Python<'py>,
1229        args: &Bound<'py, PyTuple>,
1230        kwargs: Option<&Bound<'py, PyDict>>,
1231    ) -> PyResult<Py<PyAny>> {
1232        self.call(py, args, kwargs)
1233    }
1234
1235    /// Sends a message to a randomly selected actor and waits for its result.
1236    ///
1237    /// Each call independently selects an actor uniformly at random. Selection does
1238    /// not account for actor load, so calls are balanced only across many calls. Use
1239    /// `call_one` on a slice of the mesh when placement must be deterministic.
1240    #[pyo3(signature = (*args, **kwargs), name = "choose")]
1241    fn py_choose<'py>(
1242        &self,
1243        py: Python<'py>,
1244        args: &Bound<'py, PyTuple>,
1245        kwargs: Option<&Bound<'py, PyDict>>,
1246    ) -> PyResult<Py<PyAny>> {
1247        self.choose(py, args, kwargs)
1248    }
1249
1250    /// Call the endpoint on exactly one actor (the mesh must have exactly one actor).
1251    #[pyo3(signature = (*args, **kwargs), name = "call_one")]
1252    fn py_call_one<'py>(
1253        &self,
1254        py: Python<'py>,
1255        args: &Bound<'py, PyTuple>,
1256        kwargs: Option<&Bound<'py, PyDict>>,
1257    ) -> PyResult<Py<PyAny>> {
1258        self.call_one(py, args, kwargs)
1259    }
1260
1261    /// Call the endpoint on all actors and return an iterator of Futures.
1262    #[pyo3(signature = (*args, **kwargs), name = "stream")]
1263    fn py_stream<'py>(
1264        &self,
1265        py: Python<'py>,
1266        args: &Bound<'py, PyTuple>,
1267        kwargs: Option<&Bound<'py, PyDict>>,
1268    ) -> PyResult<Py<PyAny>> {
1269        self.stream(py, args, kwargs)
1270    }
1271
1272    /// Send a message to all actors without waiting for responses (fire-and-forget).
1273    #[pyo3(signature = (*args, **kwargs), name = "broadcast")]
1274    fn py_broadcast<'py>(
1275        &self,
1276        py: Python<'py>,
1277        args: &Bound<'py, PyTuple>,
1278        kwargs: Option<&Bound<'py, PyDict>>,
1279    ) -> PyResult<()> {
1280        self.broadcast(py, args, kwargs)
1281    }
1282
1283    /// Send a message with optional port for response (used by actor_mesh.send).
1284    fn _send<'py>(
1285        &self,
1286        py: Python<'py>,
1287        args: &Bound<'py, PyTuple>,
1288        kwargs: &Bound<'py, PyDict>,
1289        port: Option<EitherPortRef>,
1290        selection: &str,
1291    ) -> PyResult<()> {
1292        let instance = self.get_current_instance(py)?;
1293        let sel = to_all_or_choose(selection)?;
1294        let correlation_id: u64 = fastrand::u64(..);
1295        tracing::info!(
1296            target: hyperactor_telemetry::sinks::perfetto::ENDPOINT_TELEMETRY_TARGET,
1297            actor_id = %instance.self_addr(),
1298            mesh = self.mesh_name.as_str(),
1299            method = self.get_method_name(),
1300            correlation_id,
1301            "send"
1302        );
1303        self.send_message(
1304            py,
1305            args,
1306            Some(kwargs),
1307            port,
1308            sel,
1309            &instance,
1310            Some(correlation_id),
1311        )
1312    }
1313}
1314
1315/// A Rust wrapper for Python's RemoteImpl endpoint.
1316///
1317/// This allows us to implement the adverb methods (call, choose, call_one, stream, broadcast)
1318/// in Rust while delegating the actual send logic to the Python RemoteImpl._send() method.
1319#[pyclass(
1320    name = "Remote",
1321    module = "monarch._rust_bindings.monarch_hyperactor.endpoint"
1322)]
1323pub struct Remote {
1324    /// The wrapped Python RemoteImpl object
1325    inner: Py<PyAny>,
1326    attrs: Arc<EndpointAttrs>,
1327}
1328
1329impl Endpoint for Remote {
1330    fn get_extent(&self, py: Python<'_>) -> PyResult<Extent> {
1331        let extent: PyExtent = self.inner.call_method0(py, "_get_extent")?.extract(py)?;
1332        Ok(extent.into())
1333    }
1334
1335    fn get_method_name(&self) -> &str {
1336        UNKNOWN
1337    }
1338
1339    fn metric_attrs(&self) -> &Arc<EndpointAttrs> {
1340        &self.attrs
1341    }
1342
1343    fn send_message<'py>(
1344        &self,
1345        py: Python<'py>,
1346        args: &Bound<'py, PyTuple>,
1347        kwargs: Option<&Bound<'py, PyDict>>,
1348        port_ref: Option<EitherPortRef>,
1349        selection: AllOrChoose,
1350        _instance: &Instance<PythonActor>,
1351        correlation_id: Option<u64>,
1352    ) -> PyResult<()> {
1353        let send_kwargs = PyDict::new(py);
1354        match port_ref {
1355            Some(pr) => send_kwargs.set_item("port", pr.clone())?,
1356            None => send_kwargs.set_item("port", py.None())?,
1357        }
1358
1359        send_kwargs.set_item("selection", selection.as_str())?;
1360        send_kwargs.set_item("correlation_id", correlation_id)?;
1361
1362        let kwargs_dict = kwargs.map_or_else(|| PyDict::new(py), |d| d.clone());
1363        self.inner
1364            .call_method(py, "_send", (args.clone(), kwargs_dict), Some(&send_kwargs))?;
1365
1366        Ok(())
1367    }
1368
1369    fn get_supervision_monitor(&self) -> Option<Arc<dyn Supervisable>> {
1370        None // Remote endpoints don't have supervision_monitors
1371    }
1372
1373    fn get_qualified_name(&self) -> Option<String> {
1374        None // Remote endpoints don't have qualified names
1375    }
1376
1377    fn enter_endpoint_span(
1378        &self,
1379        adverb: EndpointAdverb,
1380        actor_id: &ActorAddr,
1381        correlation_id: u64,
1382    ) -> SpanGuard {
1383        let call_name = monarch_with_gil_blocking(GilSite::DisplayName, |py| {
1384            self.inner
1385                .call_method0(py, "_call_name")
1386                .ok()
1387                .and_then(|v| v.extract::<String>(py).ok())
1388        });
1389        let call_name = call_name.as_deref().unwrap_or("");
1390        SpanGuard::remote(adverb.as_str(), actor_id, call_name, correlation_id)
1391    }
1392}
1393
1394#[pymethods]
1395impl Remote {
1396    /// Create a new Remote wrapping a Python RemoteImpl object.
1397    #[new]
1398    fn new(remote: Py<PyAny>) -> Self {
1399        let attrs = Arc::new(EndpointAttrs::new(UNKNOWN, None));
1400        Self {
1401            inner: remote,
1402            attrs,
1403        }
1404    }
1405
1406    /// Call the endpoint on all actors and collect all responses into a ValueMesh.
1407    #[pyo3(signature = (*args, **kwargs), name = "call")]
1408    fn py_call<'py>(
1409        &self,
1410        py: Python<'py>,
1411        args: &Bound<'py, PyTuple>,
1412        kwargs: Option<&Bound<'py, PyDict>>,
1413    ) -> PyResult<Py<PyAny>> {
1414        self.call(py, args, kwargs)
1415    }
1416
1417    /// Sends a message to a randomly selected actor and waits for its result.
1418    ///
1419    /// Each call independently selects an actor uniformly at random. Selection does
1420    /// not account for actor load, so calls are balanced only across many calls. Use
1421    /// `call_one` on a slice of the mesh when placement must be deterministic.
1422    #[pyo3(signature = (*args, **kwargs), name = "choose")]
1423    fn py_choose<'py>(
1424        &self,
1425        py: Python<'py>,
1426        args: &Bound<'py, PyTuple>,
1427        kwargs: Option<&Bound<'py, PyDict>>,
1428    ) -> PyResult<Py<PyAny>> {
1429        self.choose(py, args, kwargs)
1430    }
1431
1432    /// Call the endpoint on exactly one actor (the mesh must have exactly one actor).
1433    #[pyo3(signature = (*args, **kwargs), name = "call_one")]
1434    fn py_call_one<'py>(
1435        &self,
1436        py: Python<'py>,
1437        args: &Bound<'py, PyTuple>,
1438        kwargs: Option<&Bound<'py, PyDict>>,
1439    ) -> PyResult<Py<PyAny>> {
1440        self.call_one(py, args, kwargs)
1441    }
1442
1443    /// Call the endpoint on all actors and return an iterator of Futures.
1444    #[pyo3(signature = (*args, **kwargs), name = "stream")]
1445    fn py_stream<'py>(
1446        &self,
1447        py: Python<'py>,
1448        args: &Bound<'py, PyTuple>,
1449        kwargs: Option<&Bound<'py, PyDict>>,
1450    ) -> PyResult<Py<PyAny>> {
1451        self.stream(py, args, kwargs)
1452    }
1453
1454    /// Send a message to all actors without waiting for responses (fire-and-forget).
1455    #[pyo3(signature = (*args, **kwargs), name = "broadcast")]
1456    fn py_broadcast<'py>(
1457        &self,
1458        py: Python<'py>,
1459        args: &Bound<'py, PyTuple>,
1460        kwargs: Option<&Bound<'py, PyDict>>,
1461    ) -> PyResult<()> {
1462        self.broadcast(py, args, kwargs)
1463    }
1464
1465    /// Get the rref result by calling the wrapped Remote's rref method.
1466    #[pyo3(signature = (*args, **kwargs))]
1467    fn rref<'py>(
1468        &self,
1469        py: Python<'py>,
1470        args: &Bound<'py, PyTuple>,
1471        kwargs: Option<&Bound<'py, PyDict>>,
1472    ) -> PyResult<Py<PyAny>> {
1473        let kwargs_dict = kwargs.map_or_else(|| PyDict::new(py), |d| d.clone());
1474        self.inner.call_method(py, "rref", args, Some(&kwargs_dict))
1475    }
1476
1477    /// Get the call name by delegating to the wrapped Remote's _call_name.
1478    fn _call_name(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1479        self.inner.call_method0(py, "_call_name")
1480    }
1481
1482    /// Get the maybe_resolvable property from the wrapped RemoteImpl.
1483    #[getter]
1484    fn _maybe_resolvable(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1485        self.inner.getattr(py, "_maybe_resolvable")
1486    }
1487
1488    /// Get the resolvable property from the wrapped RemoteImpl.
1489    #[getter]
1490    fn _resolvable(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1491        self.inner.getattr(py, "_resolvable")
1492    }
1493
1494    /// Get the remote_impl from the wrapped RemoteImpl.
1495    /// This is needed for function_to_import_path() in function.py to work correctly.
1496    #[getter]
1497    fn _remote_impl(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1498        self.inner.getattr(py, "_remote_impl")
1499    }
1500
1501    /// Propagation method for tensor shape inference.
1502    /// Delegates to the wrapped Remote's _propagate.
1503    fn _propagate<'py>(
1504        &self,
1505        py: Python<'py>,
1506        args: &Bound<'py, PyAny>,
1507        kwargs: &Bound<'py, PyAny>,
1508        fake_args: &Bound<'py, PyAny>,
1509        fake_kwargs: &Bound<'py, PyAny>,
1510    ) -> PyResult<Py<PyAny>> {
1511        self.inner
1512            .call_method1(py, "_propagate", (args, kwargs, fake_args, fake_kwargs))
1513    }
1514
1515    /// Propagation for fetch operations.
1516    /// Delegates to the wrapped Remote's _fetch_propagate.
1517    fn _fetch_propagate<'py>(
1518        &self,
1519        py: Python<'py>,
1520        args: &Bound<'py, PyAny>,
1521        kwargs: &Bound<'py, PyAny>,
1522        fake_args: &Bound<'py, PyAny>,
1523        fake_kwargs: &Bound<'py, PyAny>,
1524    ) -> PyResult<Py<PyAny>> {
1525        self.inner.call_method1(
1526            py,
1527            "_fetch_propagate",
1528            (args, kwargs, fake_args, fake_kwargs),
1529        )
1530    }
1531
1532    /// Propagation for pipe operations.
1533    /// Delegates to the wrapped Remote's _pipe_propagate.
1534    fn _pipe_propagate<'py>(
1535        &self,
1536        py: Python<'py>,
1537        args: &Bound<'py, PyAny>,
1538        kwargs: &Bound<'py, PyAny>,
1539        fake_args: &Bound<'py, PyAny>,
1540        fake_kwargs: &Bound<'py, PyAny>,
1541    ) -> PyResult<Py<PyAny>> {
1542        self.inner.call_method1(
1543            py,
1544            "_pipe_propagate",
1545            (args, kwargs, fake_args, fake_kwargs),
1546        )
1547    }
1548
1549    /// Send a message with optional port for response.
1550    /// Delegates to the wrapped RemoteImpl's _send.
1551    fn _send<'py>(
1552        &self,
1553        py: Python<'py>,
1554        args: &Bound<'py, PyTuple>,
1555        kwargs: &Bound<'py, PyDict>,
1556        port: Option<Py<PyAny>>,
1557        selection: &str,
1558    ) -> PyResult<()> {
1559        self.inner.call_method(
1560            py,
1561            "_send",
1562            (args, kwargs),
1563            Some(&{
1564                let d = PyDict::new(py);
1565                d.set_item("port", port.unwrap_or_else(|| py.None()))?;
1566                d.set_item("selection", selection)?;
1567                d
1568            }),
1569        )?;
1570        Ok(())
1571    }
1572
1573    /// Make RemoteEndpoint callable - delegates to rref() like Remote.__call__.
1574    #[pyo3(signature = (*args, **kwargs))]
1575    fn __call__<'py>(
1576        &self,
1577        py: Python<'py>,
1578        args: &Bound<'py, PyTuple>,
1579        kwargs: Option<&Bound<'py, PyDict>>,
1580    ) -> PyResult<Py<PyAny>> {
1581        self.rref(py, args, kwargs)
1582    }
1583}
1584
1585pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
1586    module.add_class::<PyValueStream>()?;
1587    module.add_class::<ActorEndpoint>()?;
1588    module.add_class::<Remote>()?;
1589
1590    Ok(())
1591}
1592
1593#[derive(Named)]
1594struct PythonResponseMessageReducer;
1595
1596impl CommReducer for PythonResponseMessageReducer {
1597    type Update = PythonMessage;
1598
1599    fn reduce(&self, left: Self::Update, right: Self::Update) -> anyhow::Result<Self::Update> {
1600        Ok(ValueOverlay::try_from_runs(rle::merge_value_runs(
1601            left.into_overlay()?.into_runs(),
1602            right.into_overlay()?.into_runs(),
1603        ))?
1604        .into())
1605    }
1606}
1607
1608inventory::submit! {
1609    ReducerFactory {
1610        typehash_f: <PythonResponseMessageReducer as Named>::typehash,
1611        builder_f: |_| Ok(Box::new(PythonResponseMessageReducer)),
1612    }
1613}
1614
1615struct PythonResponseMessageAccumulator;
1616
1617impl Accumulator for PythonResponseMessageAccumulator {
1618    type State = PythonMessage;
1619    type Update = PythonMessage;
1620
1621    fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
1622        *state = ValueOverlay::try_from_runs(rle::merge_value_runs(
1623            std::mem::take(state).into_overlay()?.into_runs(),
1624            update.into_overlay()?.into_runs(),
1625        ))?
1626        .into();
1627
1628        Ok(())
1629    }
1630
1631    fn reducer_spec(&self) -> Option<ReducerSpec> {
1632        Some(ReducerSpec {
1633            typehash: <PythonResponseMessageReducer as Named>::typehash(),
1634            builder_params: None,
1635        })
1636    }
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641    use std::sync::Mutex;
1642    use std::sync::atomic::AtomicUsize;
1643    use std::sync::atomic::Ordering;
1644    use std::time::Duration;
1645
1646    use hyperactor::ActorAddr;
1647    use hyperactor::OncePortRef;
1648    use hyperactor::Proc;
1649    use hyperactor::mailbox::MessageEnvelope;
1650    use hyperactor::mailbox::PortSender as _;
1651    use hyperactor::mailbox::Undeliverable;
1652    use hyperactor::mailbox::headers::OPERATION_ADVERB;
1653    use hyperactor::mailbox::headers::OPERATION_ENDPOINT;
1654    use pyo3::PyTypeInfo;
1655    use pyo3::exceptions::PyValueError;
1656
1657    use super::*;
1658
1659    /// Minimal `Endpoint` impl that only serves `get_qualified_name`.
1660    /// The default `build_operation_context_headers` consults no other
1661    /// method, so the rest are unreachable.
1662    struct TestEndpoint {
1663        qualified_name: Option<String>,
1664    }
1665
1666    impl Endpoint for TestEndpoint {
1667        fn get_extent(&self, _py: Python<'_>) -> PyResult<Extent> {
1668            unreachable!()
1669        }
1670        fn get_method_name(&self) -> &str {
1671            unreachable!()
1672        }
1673        fn metric_attrs(&self) -> &Arc<EndpointAttrs> {
1674            unreachable!()
1675        }
1676        fn send_message<'py>(
1677            &self,
1678            _py: Python<'py>,
1679            _args: &Bound<'py, PyTuple>,
1680            _kwargs: Option<&Bound<'py, PyDict>>,
1681            _port_ref: Option<EitherPortRef>,
1682            _selection: AllOrChoose,
1683            _instance: &Instance<PythonActor>,
1684            _correlation_id: Option<u64>,
1685        ) -> PyResult<()> {
1686            unreachable!()
1687        }
1688        fn get_supervision_monitor(&self) -> Option<Arc<dyn Supervisable>> {
1689            None
1690        }
1691        fn get_qualified_name(&self) -> Option<String> {
1692            self.qualified_name.clone()
1693        }
1694        fn enter_endpoint_span(
1695            &self,
1696            _adverb: EndpointAdverb,
1697            _actor_id: &ActorAddr,
1698            _correlation_id: u64,
1699        ) -> SpanGuard {
1700            unreachable!()
1701        }
1702    }
1703
1704    /// OC-1 request-side producer: each `EndpointAdverb` maps to the
1705    /// expected wire adverb string, and the qualified endpoint name
1706    /// from `get_qualified_name()` flows through to `OPERATION_ENDPOINT`.
1707    #[test]
1708    fn test_rc1_build_operation_context_headers_stamps_each_adverb() {
1709        let ep = TestEndpoint {
1710            qualified_name: Some("training.Philosopher.ping()".to_string()),
1711        };
1712        for (adverb, expected_adverb) in [
1713            (EndpointAdverb::Call, "call"),
1714            (EndpointAdverb::CallOne, "call_one"),
1715            (EndpointAdverb::Choose, "choose"),
1716            (EndpointAdverb::Stream, "stream"),
1717        ] {
1718            let headers = ep.build_operation_context_headers(adverb);
1719            assert_eq!(
1720                headers.get(OPERATION_ENDPOINT).as_deref(),
1721                Some("training.Philosopher.ping()"),
1722                "adverb {:?}: OPERATION_ENDPOINT",
1723                adverb,
1724            );
1725            assert_eq!(
1726                headers.get(OPERATION_ADVERB).as_deref(),
1727                Some(expected_adverb),
1728                "adverb {:?}: OPERATION_ADVERB",
1729                adverb,
1730            );
1731        }
1732    }
1733
1734    /// OC-1 request-side producer: when the endpoint has no
1735    /// qualified name (e.g. `Remote::get_qualified_name` returns
1736    /// `None`), `OPERATION_ENDPOINT` is omitted while `OPERATION_ADVERB`
1737    /// is still stamped.
1738    #[test]
1739    fn test_rc1_build_operation_context_headers_omits_endpoint_when_no_qualified_name() {
1740        let ep = TestEndpoint {
1741            qualified_name: None,
1742        };
1743        let headers = ep.build_operation_context_headers(EndpointAdverb::CallOne);
1744        assert!(
1745            headers.get(OPERATION_ENDPOINT).is_none(),
1746            "OPERATION_ENDPOINT must be absent when endpoint has no qualified name",
1747        );
1748        assert_eq!(
1749            headers.get(OPERATION_ADVERB).as_deref(),
1750            Some("call_one"),
1751            "OPERATION_ADVERB should still be stamped",
1752        );
1753    }
1754
1755    const WAIT: Duration = Duration::from_secs(30);
1756
1757    fn test_instance(name: &str) -> Instance<PythonActor> {
1758        Proc::isolated()
1759            .actor_instance::<PythonActor>(name)
1760            .expect("the test actor instance should be created")
1761            .instance
1762    }
1763
1764    fn result_message(rank: Option<usize>) -> PythonMessage {
1765        PythonMessage {
1766            kind: PythonMessageKind::Result { rank },
1767            ..Default::default()
1768        }
1769    }
1770
1771    fn unexpected_message(name: &str) -> (PythonMessage, String) {
1772        let kind = PythonMessageKind::CallMethod {
1773            name: MethodSpecifier::ReturnsResponse {
1774                name: name.to_string(),
1775            },
1776            response_port: None,
1777            correlation_id: None,
1778        };
1779        let error = format!("unexpected message kind {kind:?}");
1780        (
1781            PythonMessage {
1782                kind,
1783                ..Default::default()
1784            },
1785            error,
1786        )
1787    }
1788
1789    fn test_span(instance: &Instance<PythonActor>, adverb: EndpointAdverb) -> SpanGuard {
1790        SpanGuard::actor_endpoint(
1791            adverb.as_str(),
1792            instance.self_addr(),
1793            "ownership_test_mesh",
1794            "ownership_test",
1795            0,
1796        )
1797    }
1798
1799    #[pyclass]
1800    struct IdentityFuture;
1801
1802    #[pymethods]
1803    impl IdentityFuture {
1804        #[staticmethod]
1805        fn _from_coro(task: Py<PyPythonTask>) -> Py<PyPythonTask> {
1806            task
1807        }
1808    }
1809
1810    // `Endpoint::call` always resolves `Future._from_coro` through its Python
1811    // module. The Rust unit-test target does not package that module, so expose
1812    // only the identity wrapper needed to exercise the real `call` method.
1813    fn install_future_test_module(py: Python<'_>) -> PyResult<()> {
1814        let modules = py
1815            .import("sys")?
1816            .getattr("modules")?
1817            .cast_into::<PyDict>()
1818            .map_err(PyErr::from)?;
1819        let mut path = String::new();
1820        let mut parent: Option<Bound<'_, PyModule>> = None;
1821        for part in "monarch._src.actor.future".split('.') {
1822            if !path.is_empty() {
1823                path.push('.');
1824            }
1825            path.push_str(part);
1826            let module = match modules.get_item(&path)? {
1827                Some(module) => module.cast_into::<PyModule>().map_err(PyErr::from)?,
1828                None => {
1829                    let module = PyModule::new(py, &path)?;
1830                    modules.set_item(&path, &module)?;
1831                    module
1832                }
1833            };
1834            if let Some(parent) = &parent
1835                && parent.getattr(part).is_err()
1836            {
1837                parent.setattr(part, &module)?;
1838            }
1839            parent = Some(module);
1840        }
1841        let module = parent.expect("the dotted module path is not empty");
1842        if module.getattr("Future").is_err() {
1843            module.setattr("Future", py.get_type::<IdentityFuture>())?;
1844        }
1845        Ok(())
1846    }
1847
1848    async fn drive_task(task: Py<PyPythonTask>) -> PyResult<Py<PyAny>> {
1849        let task = monarch_with_gil_blocking(GilSite::Test, |py| task.borrow_mut(py).take_task())
1850            .expect("the task should be unconsumed");
1851        tokio::time::timeout(WAIT, task)
1852            .await
1853            .expect("timed out driving the task")
1854    }
1855
1856    fn assert_message_error(error: PyErr, expected: &str) {
1857        monarch_with_gil_blocking(GilSite::Test, |py| {
1858            assert!(error.get_type(py).is(PyValueError::type_object(py)));
1859            assert_eq!(error.value(py).to_string(), expected);
1860        });
1861    }
1862
1863    async fn assert_task_message_error(task: Py<PyPythonTask>, expected: &str) {
1864        let error = match drive_task(task).await {
1865            Ok(_) => panic!("the unexpected message kind should be rejected"),
1866            Err(error) => error,
1867        };
1868        assert_message_error(error, expected);
1869    }
1870
1871    fn value_stream(
1872        instance: &Instance<PythonActor>,
1873        receiver: PortReceiver<PythonMessage>,
1874        remaining: usize,
1875    ) -> PyValueStream {
1876        let future_class = monarch_with_gil_blocking(GilSite::Test, |py| {
1877            py.get_type::<IdentityFuture>().into_any().unbind()
1878        });
1879        PyValueStream {
1880            receiver: Arc::new(tokio::sync::Mutex::new(receiver)),
1881            supervision_monitor: None,
1882            instance: instance.clone_for_py(),
1883            remaining: AtomicUsize::new(remaining),
1884            attrs: Arc::new(EndpointAttrs::new("ownership_test", None)),
1885            qualified_endpoint_name: None,
1886            start: tokio::time::Instant::now(),
1887            future_class,
1888        }
1889    }
1890
1891    fn next_task(stream: &PyValueStream) -> Option<Py<PyPythonTask>> {
1892        monarch_with_gil_blocking(GilSite::Test, |py| -> PyResult<Option<Py<PyPythonTask>>> {
1893            stream
1894                .__next__(py)?
1895                .map(|task| task.extract(py).map_err(Into::<PyErr>::into))
1896                .transpose()
1897        })
1898        .expect("ValueStream.__next__ should construct its task")
1899    }
1900
1901    #[tokio::test]
1902    async fn value_collector_drop_discards_its_reply() {
1903        pyo3::Python::initialize();
1904        let instance = test_instance("value_collector_drop_discards_its_reply");
1905        let (handle, receiver) = instance.mailbox_for_py().open_port::<PythonMessage>();
1906        handle
1907            .try_post(&instance, result_message(None))
1908            .expect("the positive-control reply should be queued");
1909
1910        let task = value_collector(
1911            receiver,
1912            Arc::new(EndpointAttrs::new("ownership_test", None)),
1913            None,
1914            instance.clone_for_py(),
1915            None,
1916            EndpointAdverb::Choose,
1917            test_span(&instance, EndpointAdverb::Choose),
1918        )
1919        .expect("value_collector should return a task");
1920        drop(task);
1921
1922        assert!(
1923            handle.try_post(&instance, result_message(None)).is_err(),
1924            "dropping the task should close its sole response receiver"
1925        );
1926    }
1927
1928    #[tokio::test]
1929    async fn value_stream_observers_receive_in_observation_order() {
1930        pyo3::Python::initialize();
1931        let instance = test_instance("value_stream_observers_receive_in_observation_order");
1932        let (handle, receiver) = instance.mailbox_for_py().open_port::<PythonMessage>();
1933        let (first_reply, first_error) = unexpected_message("first reply");
1934        let (second_reply, second_error) = unexpected_message("second reply");
1935        handle
1936            .try_post(&instance, first_reply)
1937            .expect("the first reply should be queued");
1938        handle
1939            .try_post(&instance, second_reply)
1940            .expect("the second reply should be queued");
1941        let stream = value_stream(&instance, receiver, 2);
1942
1943        let first = next_task(&stream).expect("the first slot should yield a task");
1944        let second = next_task(&stream).expect("the second slot should yield a task");
1945
1946        assert_task_message_error(second, &first_error).await;
1947        assert_task_message_error(first, &second_error).await;
1948        assert!(
1949            next_task(&stream).is_none(),
1950            "the two constructed observers should exhaust the stream"
1951        );
1952    }
1953
1954    #[tokio::test]
1955    async fn dropped_value_stream_observer_leaves_reply_but_burns_slot_stranding_tail() {
1956        pyo3::Python::initialize();
1957        let instance = test_instance("dropped_value_stream_observer_leaves_reply_but_burns_slot");
1958        let (handle, receiver) = instance.mailbox_for_py().open_port::<PythonMessage>();
1959        let (first_reply, first_error) = unexpected_message("first reply");
1960        let (second_reply, _) = unexpected_message("second reply");
1961        let second_kind = second_reply.kind.clone();
1962        handle
1963            .try_post(&instance, first_reply)
1964            .expect("the first reply should be queued");
1965        handle
1966            .try_post(&instance, second_reply)
1967            .expect("the second reply should be queued");
1968        let stream = value_stream(&instance, receiver, 2);
1969
1970        let discarded = next_task(&stream).expect("the first slot should yield a task");
1971        monarch_with_gil_blocking(GilSite::Test, |_py| drop(discarded));
1972
1973        let retained = next_task(&stream).expect("the second slot should yield a task");
1974        assert_task_message_error(retained, &first_error).await;
1975        assert!(
1976            next_task(&stream).is_none(),
1977            "discarding an observer should still consume its iteration slot"
1978        );
1979
1980        let stranded = stream
1981            .receiver
1982            .lock()
1983            .await
1984            .try_recv()
1985            .expect("the shared receiver should remain usable")
1986            .expect("the final reply should remain queued but unreachable by iteration");
1987        assert_eq!(stranded.kind, second_kind);
1988    }
1989
1990    struct RecordingCallEndpoint {
1991        instance: Instance<PythonActor>,
1992        attrs: Arc<EndpointAttrs>,
1993        submissions: AtomicUsize,
1994        response_ports: Mutex<Vec<OncePortRef<PythonMessage>>>,
1995    }
1996
1997    impl RecordingCallEndpoint {
1998        fn new(instance: Instance<PythonActor>) -> Self {
1999            Self {
2000                instance,
2001                attrs: Arc::new(EndpointAttrs::new("ownership_test", None)),
2002                submissions: AtomicUsize::new(0),
2003                response_ports: Mutex::new(Vec::new()),
2004            }
2005        }
2006
2007        fn take_response_port(&self) -> OncePortRef<PythonMessage> {
2008            self.response_ports
2009                .lock()
2010                .expect("the response-port recorder should not be poisoned")
2011                .pop()
2012                .expect("call should have supplied a response port")
2013        }
2014    }
2015
2016    impl Endpoint for RecordingCallEndpoint {
2017        fn get_extent(&self, _py: Python<'_>) -> PyResult<Extent> {
2018            Ok(Extent::unity())
2019        }
2020
2021        fn get_method_name(&self) -> &str {
2022            "ownership_test"
2023        }
2024
2025        fn metric_attrs(&self) -> &Arc<EndpointAttrs> {
2026            &self.attrs
2027        }
2028
2029        fn send_message<'py>(
2030            &self,
2031            _py: Python<'py>,
2032            _args: &Bound<'py, PyTuple>,
2033            _kwargs: Option<&Bound<'py, PyDict>>,
2034            port_ref: Option<EitherPortRef>,
2035            _selection: AllOrChoose,
2036            _instance: &Instance<PythonActor>,
2037            _correlation_id: Option<u64>,
2038        ) -> PyResult<()> {
2039            let Some(EitherPortRef::Once(PythonOncePortRef { inner: Some(port) })) = port_ref
2040            else {
2041                panic!("call should submit one reducible response port");
2042            };
2043            self.response_ports
2044                .lock()
2045                .expect("the response-port recorder should not be poisoned")
2046                .push(port);
2047            self.submissions.fetch_add(1, Ordering::SeqCst);
2048            Ok(())
2049        }
2050
2051        fn get_supervision_monitor(&self) -> Option<Arc<dyn Supervisable>> {
2052            None
2053        }
2054
2055        fn get_qualified_name(&self) -> Option<String> {
2056            Some("ownership_test.call()".to_string())
2057        }
2058
2059        fn enter_endpoint_span(
2060            &self,
2061            adverb: EndpointAdverb,
2062            actor_id: &ActorAddr,
2063            correlation_id: u64,
2064        ) -> SpanGuard {
2065            SpanGuard::actor_endpoint(
2066                adverb.as_str(),
2067                actor_id,
2068                "ownership_test_mesh",
2069                "ownership_test",
2070                correlation_id,
2071            )
2072        }
2073
2074        fn get_current_instance(&self, _py: Python<'_>) -> PyResult<Instance<PythonActor>> {
2075            Ok(self.instance.clone_for_py())
2076        }
2077    }
2078
2079    fn call_observer(endpoint: &RecordingCallEndpoint) -> Py<PyPythonTask> {
2080        monarch_with_gil_blocking(GilSite::Test, |py| -> PyResult<Py<PyPythonTask>> {
2081            install_future_test_module(py)?;
2082            endpoint
2083                .call(py, &PyTuple::empty(py), None)?
2084                .extract(py)
2085                .map_err(Into::<PyErr>::into)
2086        })
2087        .expect("call should return an observer")
2088    }
2089
2090    #[tokio::test]
2091    async fn call_submits_cast_before_observer_and_drop_closes_response_port() {
2092        pyo3::Python::initialize();
2093        let instance = test_instance("call_submits_cast_before_observer_and_drop_closes_port");
2094        let endpoint = RecordingCallEndpoint::new(instance.clone_for_py());
2095
2096        let retained = call_observer(&endpoint);
2097        assert_eq!(
2098            endpoint.submissions.load(Ordering::SeqCst),
2099            1,
2100            "submission should happen before the observer is driven"
2101        );
2102        let retained_port = endpoint.take_response_port();
2103        let (return_handle, _return_receiver) = instance
2104            .mailbox_for_py()
2105            .open_port::<Undeliverable<MessageEnvelope>>();
2106        instance
2107            .mailbox_for_py()
2108            .serialize_and_send_once(retained_port, result_message(Some(0)), return_handle)
2109            .expect("the retained observer's response should serialize");
2110        let collected = drive_task(retained)
2111            .await
2112            .expect("the retained observer should collect its response");
2113        monarch_with_gil_blocking(GilSite::Test, |py| {
2114            collected
2115                .extract::<Py<PyValueMesh>>(py)
2116                .expect("the retained observer should collect its response");
2117        });
2118
2119        let discarded = call_observer(&endpoint);
2120        assert_eq!(
2121            endpoint.submissions.load(Ordering::SeqCst),
2122            2,
2123            "the second operation should also be submitted before observation"
2124        );
2125        let dropped_port = endpoint.take_response_port();
2126        let dropped_destination = dropped_port.port_addr().clone();
2127        monarch_with_gil_blocking(GilSite::Test, |_py| drop(discarded));
2128
2129        let (return_handle, mut return_receiver) = instance
2130            .mailbox_for_py()
2131            .open_port::<Undeliverable<MessageEnvelope>>();
2132        instance
2133            .mailbox_for_py()
2134            .serialize_and_send_once(dropped_port, result_message(Some(0)), return_handle)
2135            .expect("the post should serialize before delivery is rejected");
2136        let undeliverable = tokio::time::timeout(WAIT, return_receiver.recv())
2137            .await
2138            .expect("timed out waiting for the dropped port's delivery failure")
2139            .expect("the return port should receive the delivery failure");
2140        let Undeliverable::Returned(envelope) = undeliverable else {
2141            panic!("the failed response should retain its original envelope");
2142        };
2143        assert_eq!(envelope.dest(), &dropped_destination);
2144        assert_eq!(endpoint.submissions.load(Ordering::SeqCst), 2);
2145    }
2146}