Skip to main content

monarch_hyperactor/
pickle.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
9//! Pickling support for Monarch.
10//!
11//! This module pickles Python objects for actor messages, collecting tensor
12//! engine references and out-of-band mesh references during serialization so
13//! they are restored together on decode.
14//!
15//! ## Out-of-band mesh-reference invariants
16//!
17//! Mesh references (proc/host/actor meshes) are not pickled inline. They ride
18//! *out of band* in a `refs` table carried next to the payload bytes, leaving a
19//! `pop_mesh_reference` sentinel in the pickle stream. Two invariants keep the
20//! table and its payload from ever drifting apart:
21//!
22//! **REFS-1 (ref-aware decode).** Any payload that can carry out-of-band refs
23//! must be decoded only through a ref-aware path: `PicklingState::from_parts`
24//! and `PicklingState::unpickle`, as wrapped by `PythonMessage::decode` and
25//! `PythonResponseMessage::decode`. A bare `pickle.loads`/`cloudpickle.loads`
26//! on the payload bytes drops the table, so a sentinel later pops with no active
27//! state and raises "No active pickling state". The raw payload bytes are
28//! deliberately not exposed on `PythonMessage` (there is no `.message` getter),
29//! so a bare decode is not even expressible from Python.
30//!
31//! **REFS-2 (refs preserved through intermediates).** Any intermediate that
32//! relays a payload -- `PythonResponseMessage` and the `ValueOverlay` behind a
33//! `.call()` valuemesh -- must carry its `refs` beside the bytes, all the way to
34//! the decode site. Dropping refs at a relay boundary reintroduces the REFS-1
35//! failure downstream. Refs are therefore part of `PythonResponseMessage`
36//! equality: two runs with identical bytes but different refs must not coalesce.
37//!
38//! These invariants have one sound exception. A table entry and a
39//! `pop_mesh_reference` sentinel are emitted together during pickling (1:1),
40//! so an empty `refs` table means the payload carries no sentinels: a bare
41//! decode of it pops nothing and is sound. The `.call()` valuemesh collector
42//! (`collect_valuemesh` in `endpoint.rs`) relies on this to preserve
43//! D96180139's lazy unpickle: it decodes a ref-empty batch lazily via
44//! `PyValueMesh::build_from_parts` (`value_mesh.rs`), which resolves on access
45//! outside the ref-aware path, and only a ref-carrying batch eagerly via
46//! `build_from_objects`. That lazy build is the sole bare decode of a payload
47//! that *could* carry refs; see `collect_valuemesh` for the gate itself.
48
49use std::cell::RefCell;
50use std::collections::VecDeque;
51use std::sync::atomic::AtomicUsize;
52use std::sync::atomic::Ordering;
53
54use hyperactor::Instance;
55use monarch_types::py_global;
56use pyo3::IntoPyObjectExt;
57use pyo3::prelude::*;
58use pyo3::types::PyList;
59use pyo3::types::PyTuple;
60use serde_multipart::Part;
61
62use crate::actor::MeshRef;
63use crate::actor::PyMeshRef;
64use crate::actor::PythonActor;
65use crate::actor::PythonMessage;
66use crate::actor::PythonMessageKind;
67use crate::buffers::Buffer;
68use crate::context::PyInstance;
69use crate::handle::PyHandle;
70use crate::pytokio::PyShared;
71use crate::runtime::GilSite;
72use crate::runtime::monarch_with_gil_blocking;
73
74// cloudpickle module for serialization
75py_global!(cloudpickle, "cloudpickle", "cloudpickle");
76
77py_global!(_unpickle, "pickle", "loads");
78
79// Importing monarch._src.actor.pickle applies a monkeypatch to cloudpickle
80// that injects RemoteImportLoader into pickled function globals, enabling
81// source loading for pickle-by-value code on remote hosts (needed for
82// debugger and tracebacks). We access this before pickling to ensure
83// the monkeypatch is applied.
84py_global!(
85    pickle_monkeypatch,
86    "monarch._src.actor.pickle",
87    "_function_getstate"
88);
89
90// Check if torch has been loaded into the current Python process.
91// Returns the torch module if loaded, otherwise None.
92py_global!(maybe_torch_fn, "monarch._src.actor.pickle", "maybe_torch");
93
94// Torch-aware dump function: uses a Pickler subclass with dispatch_table
95// entries for torch storage types (UntypedStorage, TypedStorage, etc.).
96py_global!(torch_dump_fn, "monarch._src.actor.pickle", "torch_dump");
97
98// Torch-aware loads function: wraps cloudpickle.loads with
99// torch.utils._python_dispatch._disable_current_modes().
100py_global!(torch_loads_fn, "monarch._src.actor.pickle", "torch_loads");
101
102// Shared class for pickling PyShared values
103py_global!(
104    shared_class,
105    "monarch._rust_bindings.monarch_hyperactor.pytokio",
106    "Shared"
107);
108
109// Thread-local storage for the active pickling state.
110// Set by pickle/unpickle operations so free functions used in __reduce__
111// implementations can access it.
112//
113// It is thread-local by design: two threads pickling at once (even the same
114// mesh ref) collect into independent `mesh_references` / `pending_mesh_fills`,
115// so there is nothing shared to race on. `pickle()` then moves the state out of
116// the thread-local into an owned `PicklingState` before the GIL-releasing
117// `PicklingState::resolve`, so the sender-side slot fill mutates a single-owner
118// value across its awaits, never this thread-local. Both properties (thread-
119// local, and moved out before resolve) are what make releasing the GIL safe.
120thread_local! {
121    static ACTIVE_PICKLING_STATE: RefCell<Option<ActivePicklingState>> = const { RefCell::new(None) };
122}
123
124/// Counters for tests. `PENDING_RESERVE_COUNT` bumps when a still-pending mesh
125/// reserves an out-of-band slot on the send side; it is pending-specific (a
126/// resolved mesh fills directly), so it proves the pending path ran.
127/// `MESH_POP_COUNT` bumps when any out-of-band mesh reference is reunited on the
128/// decode side; it is not pending-specific, since a resolved mesh also travels
129/// out-of-band, so it proves receive and reconstruct, not pending-ness.
130static PENDING_RESERVE_COUNT: AtomicUsize = AtomicUsize::new(0);
131static MESH_POP_COUNT: AtomicUsize = AtomicUsize::new(0);
132
133/// RAII guard that sets the thread-local `ACTIVE_PICKLING_STATE` on creation
134/// and restores the previous state (if any) on drop. This supports nesting:
135/// if a guard already exists, the new guard saves the old state and restores
136/// it when dropped, even on panic.
137struct ActivePicklingGuard {
138    previous: Option<ActivePicklingState>,
139}
140
141impl ActivePicklingGuard {
142    /// Set `state` as the active pickling state, saving any existing state.
143    fn enter(state: ActivePicklingState) -> Self {
144        let previous = ACTIVE_PICKLING_STATE.with(|cell| cell.borrow_mut().replace(state));
145        Self { previous }
146    }
147}
148
149impl Drop for ActivePicklingGuard {
150    fn drop(&mut self) {
151        ACTIVE_PICKLING_STATE.with(|cell| {
152            *cell.borrow_mut() = self.previous.take();
153        });
154    }
155}
156
157thread_local! {
158    /// The actor that is *receiving* the payload currently being decoded.
159    ///
160    /// A reply is decoded on a Tokio worker with no Monarch context, so
161    /// `Port._reconstruct_port` cannot recover the receiver from `context()`
162    /// without bootstrapping a client in a worker process. The eager decoders
163    /// install the caller instance they already hold here for the duration of
164    /// the decode.
165    ///
166    /// Deliberately a sibling of `ACTIVE_PICKLING_STATE` rather than a field
167    /// on it: the receiver is decode context, not pickling payload state, and
168    /// must never reach `PicklingStateInner` or the serialized bytes.
169    static RECEIVER_INSTANCE: RefCell<Option<Instance<PythonActor>>> =
170        const { RefCell::new(None) };
171}
172
173/// RAII guard installing the receiver for the current decode, restoring the
174/// previous value on drop -- on success, on error, and on panic.
175///
176/// Private: the only way to install a receiver is
177/// [`PicklingState::unpickle_with_receiver`], which cannot span an `await`.
178struct ReceiverInstanceGuard {
179    previous: Option<Instance<PythonActor>>,
180}
181
182impl ReceiverInstanceGuard {
183    /// Install `instance` as the receiver, saving any existing one.
184    ///
185    /// Takes a reference and clones internally so callers keep ownership of
186    /// the instance they are already holding across the collector.
187    fn enter(instance: &Instance<PythonActor>) -> Self {
188        let previous =
189            RECEIVER_INSTANCE.with(|cell| cell.borrow_mut().replace(instance.clone_for_py()));
190        Self { previous }
191    }
192}
193
194impl Drop for ReceiverInstanceGuard {
195    fn drop(&mut self) {
196        RECEIVER_INSTANCE.with(|cell| {
197            *cell.borrow_mut() = self.previous.take();
198        });
199    }
200}
201
202/// Clone of the receiver installed for the current decode, if any.
203///
204/// Clones rather than consumes: one payload may carry several `Port`s and each
205/// must reconstruct against the same receiver.
206fn current_receiver_instance_raw() -> Option<Instance<PythonActor>> {
207    RECEIVER_INSTANCE.with(|cell| cell.borrow().as_ref().map(|i| i.clone_for_py()))
208}
209
210/// The receiver for the decode in progress, or `None` outside one.
211///
212/// `Port._reconstruct_port` prefers this and falls back to `context()`, which
213/// keeps reconstruction outside an endpoint reply decode unchanged.
214#[pyfunction]
215fn _current_receiver_instance() -> Option<PyInstance> {
216    // Clone out of the `RefCell` before building the Python wrapper: the
217    // conversion allocates a Python object, and holding the borrow across that
218    // risks a re-entrant borrow.
219    current_receiver_instance_raw().map(PyInstance::from)
220}
221
222/// State maintained during active pickling/unpickling operations.
223///
224/// This is the thread-local state used while cloudpickle is running.
225/// It collects tensor engine references and pending pickles during serialization.
226struct ActivePicklingState {
227    /// References to tensor engine objects that need special handling.
228    tensor_engine_references: VecDeque<Py<PyAny>>,
229    /// Mesh references collected out-of-band into the message's `refs` table.
230    /// A `None` is a slot reserved for a pending mesh, filled sender-side.
231    mesh_references: VecDeque<Option<MeshRef>>,
232    /// Pending mesh slots awaiting a sender-side fill: (index into
233    /// `mesh_references`, the handle whose resolved mesh supplies the ref).
234    pending_mesh_fills: Vec<(usize, Py<PyShared>)>,
235    /// Whether tensor engine references are allowed in this pickling context.
236    allow_tensor_engine_references: bool,
237    /// Whether mesh references are collected out-of-band in this context.
238    allow_mesh_references: bool,
239}
240
241impl ActivePicklingState {
242    /// Create a new ActivePicklingState.
243    fn new(allow_tensor_engine_references: bool, allow_mesh_references: bool) -> Self {
244        Self {
245            tensor_engine_references: VecDeque::new(),
246            mesh_references: VecDeque::new(),
247            pending_mesh_fills: Vec::new(),
248            allow_tensor_engine_references,
249            allow_mesh_references,
250        }
251    }
252
253    /// Convert this active state into a frozen PicklingState.
254    fn into_pickling_state(self, buffer: Part) -> PicklingStateInner {
255        PicklingStateInner {
256            buffer,
257            tensor_engine_references: self.tensor_engine_references,
258            mesh_references: self.mesh_references,
259            pending_mesh_fills: self.pending_mesh_fills,
260        }
261    }
262}
263
264/// Inner data for a completed pickling operation.
265///
266/// This contains the pickled bytes as a fragmented [`Part`] (zero-copy)
267/// and any collected references.
268pub struct PicklingStateInner {
269    /// The pickled bytes as a fragmented Part (zero-copy).
270    buffer: Part,
271    /// References to tensor engine objects that need special handling.
272    tensor_engine_references: VecDeque<Py<PyAny>>,
273    /// Mesh references carried out-of-band into the message's `refs` table.
274    /// A `None` is a slot reserved for a pending mesh, filled sender-side.
275    mesh_references: VecDeque<Option<MeshRef>>,
276    /// Pending mesh slots awaiting a sender-side fill.
277    pending_mesh_fills: Vec<(usize, Py<PyShared>)>,
278}
279
280impl PicklingStateInner {
281    /// Take the Part (pickled bytes) from this inner state.
282    pub fn take_buffer(self) -> Part {
283        self.buffer
284    }
285
286    /// The size in bytes of the pickled payload.
287    pub fn payload_len(&self) -> usize {
288        self.buffer.len()
289    }
290}
291
292/// Python-visible wrapper for the result of a pickling operation.
293///
294/// Contains the pickled bytes and any tensor engine references or pending
295/// pickles that were collected during serialization.
296#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.pickle")]
297pub struct PicklingState {
298    inner: Option<PicklingStateInner>,
299}
300
301impl PicklingState {
302    pub fn take_inner(&mut self) -> PyResult<PicklingStateInner> {
303        self.inner.take().ok_or_else(|| {
304            pyo3::exceptions::PyRuntimeError::new_err("PicklingState has already been consumed")
305        })
306    }
307
308    fn inner_ref(&self) -> PyResult<&PicklingStateInner> {
309        self.inner.as_ref().ok_or_else(|| {
310            pyo3::exceptions::PyRuntimeError::new_err("PicklingState has already been consumed")
311        })
312    }
313
314    fn has_pending_mesh_fills(&self) -> PyResult<bool> {
315        Ok(!self.inner_ref()?.pending_mesh_fills.is_empty())
316    }
317
318    /// Build a PicklingState directly from already-separated parts: the pickled
319    /// `buffer`, the ordered local-state/tensor-engine list, and the resolved
320    /// mesh-reference table. Used by `PythonMessage::decode` so a message's
321    /// payload is always unpickled together with its `refs`.
322    pub(crate) fn from_parts(
323        buffer: Part,
324        tensor_engine_references: VecDeque<Py<PyAny>>,
325        mesh_references: VecDeque<Option<MeshRef>>,
326    ) -> Self {
327        Self {
328            inner: Some(PicklingStateInner {
329                buffer,
330                tensor_engine_references,
331                mesh_references,
332                pending_mesh_fills: Vec::new(),
333            }),
334        }
335    }
336}
337
338#[pymethods]
339impl PicklingState {
340    /// Create a new PicklingState from a buffer and optional tensor engine references.
341    ///
342    /// This is used for unpickling received messages that may contain tensor engine
343    /// references that need to be restored during deserialization.
344    #[new]
345    #[pyo3(signature = (buffer, tensor_engine_references=None, mesh_references=None))]
346    fn py_new(
347        py: Python<'_>,
348        buffer: PyRef<'_, crate::buffers::FrozenBuffer>,
349        tensor_engine_references: Option<&Bound<'_, PyList>>,
350        mesh_references: Option<Vec<Py<PyMeshRef>>>,
351    ) -> PyResult<Self> {
352        let refs: VecDeque<Py<PyAny>> = tensor_engine_references
353            .map(|list| list.iter().map(|item| item.unbind()).collect())
354            .unwrap_or_default();
355
356        // pyo3 type-checks each element as a `PyMeshRef` during extraction.
357        let mesh_refs: VecDeque<Option<MeshRef>> = mesh_references
358            .map(|list| {
359                list.into_iter()
360                    .map(|m| Some(m.borrow(py).inner.clone()))
361                    .collect()
362            })
363            .unwrap_or_default();
364
365        Ok(Self {
366            inner: Some(PicklingStateInner {
367                buffer: Part::from(buffer.inner.clone()),
368                tensor_engine_references: refs,
369                mesh_references: mesh_refs,
370                pending_mesh_fills: Vec::new(),
371            }),
372        })
373    }
374
375    /// Get a copy of all tensor engine references from this pickling state.
376    ///
377    /// Returns a Python list containing copies of the tensor engine references.
378    fn tensor_engine_references(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
379        let inner = self.inner_ref()?;
380        let refs: Vec<Py<PyAny>> = inner
381            .tensor_engine_references
382            .iter()
383            .map(|r| r.clone_ref(py))
384            .collect();
385        Ok(PyList::new(py, refs)?.unbind())
386    }
387
388    /// Get the buffer from this pickling state.
389    ///
390    /// Returns a FrozenBuffer containing the pickled bytes.
391    /// This does not consume the PicklingState.
392    fn buffer(&self) -> PyResult<crate::buffers::FrozenBuffer> {
393        let inner = self.inner_ref()?;
394        Ok(crate::buffers::FrozenBuffer {
395            inner: inner.buffer.clone().into_bytes(),
396        })
397    }
398
399    /// Unpickle the buffer contents.
400    ///
401    /// This consumes the PicklingState. It will fail if there are any pending
402    /// pickles that haven't been resolved.
403    pub(crate) fn unpickle(&mut self, py: Python<'_>) -> PyResult<Py<PyAny>> {
404        let inner = self.take_inner()?;
405
406        // Set up an active state for unpickling (to handle pop calls).
407        // The guard restores any previous state on drop (including on panic).
408        let mut active = ActivePicklingState::new(false, false);
409        active.tensor_engine_references = inner.tensor_engine_references;
410        active.mesh_references = inner.mesh_references;
411        active.pending_mesh_fills = inner.pending_mesh_fills;
412
413        let _guard = ActivePicklingGuard::enter(active);
414
415        let frozen = crate::buffers::FrozenBuffer {
416            inner: inner.buffer.into_bytes(),
417        };
418
419        // Unpickle the object. If torch is loaded, use torch_loads which
420        // disables dispatch modes during unpickling.
421        let result = if maybe_torch_fn(py).call0()?.is_truthy()? {
422            torch_loads_fn(py).call1((frozen,))
423        } else {
424            cloudpickle(py).getattr("loads")?.call1((frozen,))
425        };
426
427        result.map(|obj| obj.unbind())
428    }
429}
430
431impl PicklingState {
432    /// [`unpickle`](Self::unpickle) with `instance` installed as the receiver.
433    ///
434    /// Rust-only and deliberately unexposed to Python: the guard must not
435    /// outlive the synchronous decode. Every caller is the body of a
436    /// `monarch_with_gil_blocking` closure, so the guard never spans an
437    /// `await` and the thread-local cannot leak to another task.
438    pub(crate) fn unpickle_with_receiver(
439        &mut self,
440        py: Python<'_>,
441        instance: &Instance<PythonActor>,
442    ) -> PyResult<Py<PyAny>> {
443        let _guard = ReceiverInstanceGuard::enter(instance);
444        self.unpickle(py)
445    }
446
447    /// Fill the reserved mesh slots from their pending handles, producing a
448    /// PicklingState whose out-of-band `refs` table is fully populated.
449    ///
450    /// Awaits each pending mesh's init task, extracts its `*MeshRef`, and writes
451    /// it into the reserved slot. The payload bytes are unchanged (no re-pickle).
452    pub async fn resolve(mut self) -> PyResult<PicklingState> {
453        // Take the pending mesh fills out: a plain move, no GIL and no
454        // `clone_ref`. Each is a slot index plus the handle whose resolved mesh
455        // supplies the ref.
456        let fills = std::mem::take(
457            &mut self
458                .inner
459                .as_mut()
460                .ok_or_else(|| {
461                    pyo3::exceptions::PyRuntimeError::new_err(
462                        "PicklingState has already been consumed",
463                    )
464                })?
465                .pending_mesh_fills,
466        );
467
468        for (index, handle) in fills {
469            // Await the mesh's init task (these run concurrently, so the wait
470            // is the slowest init, not the sum).
471            let mut task =
472                monarch_with_gil_blocking(GilSite::AwaitDrive, |py| handle.borrow(py).task())?;
473            task.take_task()?.await?;
474
475            // Extract the `*MeshRef` from the now-resolved mesh and fill the slot.
476            monarch_with_gil_blocking(GilSite::Convert, |py| -> PyResult<()> {
477                let value = handle.borrow(py).poll()?.ok_or_else(|| {
478                    pyo3::exceptions::PyRuntimeError::new_err("pending mesh handle did not resolve")
479                })?;
480                let mesh_ref = crate::actor::mesh_ref_from_pyobject(value.bind(py))?;
481                if let Some(inner) = self.inner.as_mut() {
482                    inner.mesh_references[index] = Some(mesh_ref);
483                }
484                Ok(())
485            })?;
486        }
487
488        Ok(self)
489    }
490}
491
492/// A message whose reserved mesh slots must be filled before it can be sent.
493///
494/// Contains a `PythonMessageKind` and a `PicklingState`. The `PicklingState` may
495/// hold mesh slots reserved for pending meshes, filled sender-side once their
496/// handles resolve, before the message can be converted into a `PythonMessage`.
497#[pyclass(module = "monarch._rust_bindings.monarch_hyperactor.pickle")]
498pub struct PendingMessage {
499    pub(crate) kind: PythonMessageKind,
500    state: PicklingState,
501}
502
503impl PendingMessage {
504    /// Create a new PendingMessage from a kind and pickling state.
505    pub fn new(kind: PythonMessageKind, state: PicklingState) -> Self {
506        Self { kind, state }
507    }
508
509    /// Take ownership of the inner state from a mutable reference.
510    ///
511    /// This is used by pyo3 pymethods that receive `&mut PendingMessage`
512    /// but need to pass ownership to the trait method.
513    pub fn take(&mut self) -> PyResult<PendingMessage> {
514        let inner = self.state.take_inner()?;
515        Ok(PendingMessage {
516            kind: std::mem::take(&mut self.kind),
517            state: PicklingState { inner: Some(inner) },
518        })
519    }
520
521    /// The size in bytes of the pickled payload. Out-of-band mesh references
522    /// are carried beside these bytes, so they do not count towards it.
523    pub(crate) fn payload_len(&self) -> usize {
524        self.state
525            .inner_ref()
526            .expect("should be called before the message is resolved")
527            .payload_len()
528    }
529
530    fn into_python_message(mut self) -> PyResult<PythonMessage> {
531        let inner = self.state.take_inner()?;
532        let refs: Vec<MeshRef> = inner
533            .mesh_references
534            .into_iter()
535            .map(|reference| {
536                reference.ok_or_else(|| {
537                    pyo3::exceptions::PyRuntimeError::new_err(
538                        "mesh reference slot was never filled",
539                    )
540                })
541            })
542            .collect::<PyResult<_>>()?;
543        Ok(PythonMessage::new_from_buf_with_refs(
544            self.kind,
545            inner.buffer,
546            refs,
547        ))
548    }
549
550    /// Convert this message synchronously when no mesh fills are pending.
551    ///
552    /// A pending message is returned unchanged in the inner `Err` so the
553    /// caller can use the existing asynchronous resolution path.
554    /// Every reserved `None` mesh slot is created together with one pending
555    /// fill entry, while resolved and reconstructed slots are always `Some`.
556    /// Therefore a fresh zero-fill message cannot fail assembly due to an
557    /// unfilled slot.
558    pub fn try_resolve_now(self) -> PyResult<Result<PythonMessage, PendingMessage>> {
559        if self.state.has_pending_mesh_fills()? {
560            return Ok(Err(self));
561        }
562
563        Ok(Ok(self.into_python_message()?))
564    }
565
566    /// Resolve any reserved mesh slots and convert this into a PythonMessage.
567    ///
568    /// This is an async method that:
569    /// 1. Fills the reserved mesh slots from their pending handles
570    /// 2. Builds a PythonMessage with the resolved bytes and `refs` table
571    pub async fn resolve(self) -> PyResult<PythonMessage> {
572        let Self { kind, state } = self;
573        let state = state.resolve().await?;
574        Self::new(kind, state).into_python_message()
575    }
576}
577
578#[pymethods]
579impl PendingMessage {
580    /// Create a new PendingMessage from a kind and pickling state.
581    #[new]
582    pub fn py_new(
583        kind: PythonMessageKind,
584        mut state: PyRefMut<'_, PicklingState>,
585    ) -> PyResult<Self> {
586        // Take the inner state from the PicklingState
587        let inner = state.take_inner()?;
588        Ok(Self {
589            kind,
590            state: PicklingState { inner: Some(inner) },
591        })
592    }
593
594    /// Get the message kind.
595    #[getter]
596    fn kind(&self) -> PythonMessageKind {
597        self.kind.clone()
598    }
599
600    /// Return a materialized message when no mesh fills are pending.
601    ///
602    /// A `None` result leaves this object unchanged for `resolve()`.
603    #[pyo3(name = "try_resolve_now")]
604    fn py_try_resolve_now(&mut self) -> PyResult<Option<PythonMessage>> {
605        if self.state.has_pending_mesh_fills()? {
606            return Ok(None);
607        }
608
609        self.take()?.into_python_message().map(Some)
610    }
611
612    /// Fill reserved mesh slots and return a fully materialized PythonMessage.
613    #[pyo3(name = "resolve")]
614    fn py_resolve(&mut self) -> PyResult<PyHandle> {
615        let message = self.take()?;
616        Ok(PyHandle::spawn(async move { message.resolve().await }))
617    }
618}
619
620/// Push a tensor engine reference to the active pickling state if one is active.
621///
622/// This is called from Python during pickling when a tensor engine object
623/// is encountered that needs special handling.
624///
625/// Returns False if there is no active pickling state.
626/// Returns True if the reference was successfully pushed.
627/// Raises an error if tensor engine references are not allowed in the current pickling context.
628#[pyfunction]
629fn push_tensor_engine_reference_if_active(obj: Py<PyAny>) -> PyResult<bool> {
630    ACTIVE_PICKLING_STATE.with(|cell| {
631        let mut state = cell.borrow_mut();
632        match state.as_mut() {
633            Some(s) => {
634                if !s.allow_tensor_engine_references {
635                    return Err(pyo3::exceptions::PyRuntimeError::new_err(
636                        "Tensor engine references are not allowed in the current pickling context",
637                    ));
638                }
639                s.tensor_engine_references.push_back(obj);
640                Ok(true)
641            }
642            None => Ok(false),
643        }
644    })
645}
646
647/// Pop a tensor engine reference from the active pickling state.
648///
649/// This is called from Python during unpickling to retrieve tensor engine
650/// objects in the order they were pushed.
651#[pyfunction]
652fn pop_tensor_engine_reference(py: Python<'_>) -> PyResult<Py<PyAny>> {
653    ACTIVE_PICKLING_STATE
654        .with(|cell| {
655            let mut state = cell.borrow_mut();
656            match state.as_mut() {
657                Some(s) => s.tensor_engine_references.pop_front().ok_or_else(|| {
658                    pyo3::exceptions::PyRuntimeError::new_err(
659                        "No tensor engine references remaining",
660                    )
661                }),
662                None => Err(pyo3::exceptions::PyRuntimeError::new_err(
663                    "No active pickling state",
664                )),
665            }
666        })
667        .map(|obj| obj.clone_ref(py))
668}
669
670/// Pop a mesh reference from the active pickling state and rebuild its
671/// Python mesh wrapper.
672///
673/// Called from the unpickle stream wherever a mesh slot was emitted in
674/// place of inline bytes.
675#[pyfunction]
676fn pop_mesh_reference(py: Python<'_>) -> PyResult<Py<PyAny>> {
677    let mesh_ref = ACTIVE_PICKLING_STATE.with(|cell| {
678        let mut state = cell.borrow_mut();
679        match state.as_mut() {
680            Some(s) => s.mesh_references.pop_front().ok_or_else(|| {
681                pyo3::exceptions::PyRuntimeError::new_err("No mesh references remaining")
682            }),
683            None => Err(pyo3::exceptions::PyRuntimeError::new_err(
684                "No active pickling state",
685            )),
686        }
687    })?;
688    let mesh_ref = mesh_ref.ok_or_else(|| {
689        pyo3::exceptions::PyRuntimeError::new_err("mesh reference slot was never filled")
690    })?;
691    MESH_POP_COUNT.fetch_add(1, Ordering::Relaxed);
692    mesh_ref.reconstruct(py)
693}
694
695/// Push a mesh reference to the active pickling state if mesh-reference
696/// collection is active in this context.
697///
698/// Returns true if the reference was collected (the caller emits a slot);
699/// false otherwise (the caller inlines the reference as usual).
700pub fn push_mesh_reference_if_active(mesh_ref: MeshRef) -> bool {
701    ACTIVE_PICKLING_STATE.with(|cell| {
702        let mut state = cell.borrow_mut();
703        match state.as_mut() {
704            Some(s) if s.allow_mesh_references => {
705                s.mesh_references.push_back(Some(mesh_ref));
706                true
707            }
708            _ => false,
709        }
710    })
711}
712
713/// Reserve a slot for a *pending* mesh whose `*MeshRef` is not available yet,
714/// registering `handle` for a sender-side fill once the mesh resolves.
715///
716/// Returns true if a slot was reserved (the caller emits a `pop_mesh_reference`
717/// placeholder); false otherwise (the caller keeps its current behavior).
718pub fn reserve_mesh_reference_if_active(handle: Py<PyShared>) -> bool {
719    ACTIVE_PICKLING_STATE.with(|cell| {
720        let mut state = cell.borrow_mut();
721        match state.as_mut() {
722            Some(s) if s.allow_mesh_references => {
723                let index = s.mesh_references.len();
724                s.mesh_references.push_back(None);
725                s.pending_mesh_fills.push((index, handle));
726                PENDING_RESERVE_COUNT.fetch_add(1, Ordering::Relaxed);
727                true
728            }
729            _ => false,
730        }
731    })
732}
733
734/// Python-callable wrapper over [`reserve_mesh_reference_if_active`] for the
735/// typed Proc/Host reduces, which reserve their pending slot from Python.
736#[pyfunction]
737fn reserve_mesh_reference(handle: Py<PyShared>) -> bool {
738    reserve_mesh_reference_if_active(handle)
739}
740
741/// Reduce a PyShared for pickling.
742///
743/// This function implements the pickle protocol for PyShared:
744/// 1. If the shared is already finished, return (Shared.from_value, (value,))
745/// 2. Otherwise, block on the shared and return (Shared.from_value, (value,))
746pub fn reduce_shared<'py>(
747    py: Python<'py>,
748    py_shared: &Bound<'py, PyShared>,
749) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyTuple>)> {
750    // First, check if the shared is already finished
751    if let Some(value) = py_shared.borrow().poll()? {
752        let from_value = shared_class(py).getattr("from_value")?;
753        let args = PyTuple::new(py, [value])?;
754        return Ok((from_value, args));
755    }
756
757    // Pending: block on the shared. Meshes ride the out-of-band table via their
758    // own type-specific reducers; this generic path is the fallback for any
759    // other pending `PyShared`.
760    let value = PyShared::block_on(py_shared.borrow(), py)?;
761    let from_value = shared_class(py).getattr("from_value")?;
762    let args = PyTuple::new(py, [value])?;
763    Ok((from_value, args))
764}
765
766/// Pickle a Python object into a [`Buffer`].
767///
768/// This is the shared pickling core. The caller is responsible for setting up
769/// the [`ActivePicklingGuard`] before calling this function.
770fn pickle_into_buffer(py: Python<'_>, obj: &Py<PyAny>, buffer: &Py<Buffer>) -> PyResult<()> {
771    // Ensure the cloudpickle monkeypatch for RemoteImportLoader is applied.
772    pickle_monkeypatch(py);
773
774    // If torch is loaded, use the torch-aware pickler that handles
775    // torch storage types via dispatch_table.
776    if maybe_torch_fn(py).call0()?.is_truthy()? {
777        torch_dump_fn(py).call1((obj, buffer.bind(py)))?;
778    } else {
779        let pickler = cloudpickle(py)
780            .getattr("Pickler")?
781            .call1((buffer.bind(py),))?;
782        pickler.call_method1("dump", (obj,))?;
783    }
784
785    Ok(())
786}
787
788/// Pickle a Python object and return the serialized data as a [`Part`].
789///
790/// This is a simplified variant of [`pickle`] that disallows pending pickles
791/// and tensor engine references, and returns the raw serialized bytes instead
792/// of a [`PicklingState`].
793pub fn pickle_to_part(py: Python<'_>, obj: &Py<PyAny>) -> PyResult<Part> {
794    let active = ActivePicklingState::new(false, false);
795    let buffer = Py::new(py, Buffer::default())?;
796    let _guard = ActivePicklingGuard::enter(active);
797
798    pickle_into_buffer(py, obj, &buffer)?;
799
800    Ok(buffer.borrow_mut(py).take_part())
801}
802
803/// Pickle an object with support for pending pickles and tensor engine references.
804///
805/// This function creates a PicklingState and calls cloudpickle.dumps with
806/// an active thread-local PicklingState, allowing __reduce__ implementations
807/// to push tensor engine references and pending pickles.
808///
809/// # Arguments
810/// * `obj` - The Python object to pickle
811/// * `allow_tensor_engine_references` - If true, allow tensor engine references to be registered
812///
813/// # Returns
814/// A PicklingState containing the pickled buffer and any registered references/pending pickles
815#[pyfunction]
816#[pyo3(signature = (obj, allow_tensor_engine_references=true, allow_mesh_references=false))]
817pub fn pickle(
818    py: Python<'_>,
819    obj: Py<PyAny>,
820    allow_tensor_engine_references: bool,
821    allow_mesh_references: bool,
822) -> PyResult<PicklingState> {
823    let active = ActivePicklingState::new(allow_tensor_engine_references, allow_mesh_references);
824    let buffer = Py::new(py, Buffer::default())?;
825    let _guard = ActivePicklingGuard::enter(active);
826
827    pickle_into_buffer(py, &obj, &buffer)?;
828
829    // Take the state (which may have been modified during pickling).
830    // The guard will restore the previous state on drop.
831    let active = ACTIVE_PICKLING_STATE
832        .with(|cell| cell.borrow_mut().take())
833        .expect("active pickling state should still be set");
834
835    // Take the Part (zero-copy fragmented buffer) directly.
836    let part = buffer.borrow_mut(py).take_part();
837    let inner = active.into_pickling_state(part);
838    Ok(PicklingState { inner: Some(inner) })
839}
840
841pub(crate) fn unpickle(
842    py: Python<'_>,
843    buffer: crate::buffers::FrozenBuffer,
844) -> PyResult<Bound<'_, PyAny>> {
845    _unpickle(py).call1((buffer.into_py_any(py)?,))
846}
847
848/// Test helper: read the pending-mesh reserve counter.
849#[pyfunction]
850fn _get_pending_reserve_count() -> usize {
851    PENDING_RESERVE_COUNT.load(Ordering::Relaxed)
852}
853
854/// Test helper: reset the pending-mesh reserve counter to zero.
855#[pyfunction]
856fn _reset_pending_reserve_count() {
857    PENDING_RESERVE_COUNT.store(0, Ordering::Relaxed);
858}
859
860/// Test helper: read the mesh-pop counter.
861#[pyfunction]
862fn _get_mesh_pop_count() -> usize {
863    MESH_POP_COUNT.load(Ordering::Relaxed)
864}
865
866/// Test helper: reset the mesh-pop counter to zero.
867#[pyfunction]
868fn _reset_mesh_pop_count() {
869    MESH_POP_COUNT.store(0, Ordering::Relaxed);
870}
871
872/// Register the pickle Python bindings into the given module.
873pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
874    module.add_class::<PicklingState>()?;
875    module.add_class::<PendingMessage>()?;
876    module.add_function(wrap_pyfunction!(pickle, module)?)?;
877    module.add_function(wrap_pyfunction!(
878        push_tensor_engine_reference_if_active,
879        module
880    )?)?;
881    module.add_function(wrap_pyfunction!(pop_tensor_engine_reference, module)?)?;
882    module.add_function(wrap_pyfunction!(pop_mesh_reference, module)?)?;
883    module.add_function(wrap_pyfunction!(_current_receiver_instance, module)?)?;
884    module.add_function(wrap_pyfunction!(reserve_mesh_reference, module)?)?;
885    module.add_function(wrap_pyfunction!(_get_pending_reserve_count, module)?)?;
886    module.add_function(wrap_pyfunction!(_reset_pending_reserve_count, module)?)?;
887    module.add_function(wrap_pyfunction!(_get_mesh_pop_count, module)?)?;
888    module.add_function(wrap_pyfunction!(_reset_mesh_pop_count, module)?)?;
889    Ok(())
890}
891
892#[cfg(test)]
893mod tests {
894    use hyperactor::ActorRef;
895    use hyperactor::ProcAddr;
896    use hyperactor::ProcId;
897    use hyperactor::channel::ChannelAddr;
898    use hyperactor::id::Label;
899    use hyperactor::id::Uid;
900    use hyperactor_mesh::mesh_id::ProcMeshId;
901    use hyperactor_mesh::proc_agent::PROC_AGENT_ACTOR_NAME;
902    use hyperactor_mesh::proc_agent::ProcAgent;
903    use hyperactor_mesh::proc_mesh::ProcMeshRef;
904    use hyperactor_mesh::proc_mesh::ProcRef;
905    use pyo3::IntoPyObjectExt;
906    use pyo3::PyTypeInfo;
907
908    use super::*;
909    use crate::proc_mesh::PyProcMesh;
910
911    /// A distinct resolved proc mesh per `(instance, mesh)` pair, so a test can
912    /// tell one filled slot from another.
913    fn proc_mesh_ref(instance: u64, mesh: &str) -> ProcMeshRef {
914        let proc_id = ProcId::new(
915            Uid::Instance(instance, None),
916            Some(Label::new("local").expect("test label should be valid")),
917        );
918        let proc_addr = ProcAddr::new(proc_id, ChannelAddr::Local(1).into());
919        let agent: ActorRef<ProcAgent> =
920            ActorRef::attest(proc_addr.actor_addr(PROC_AGENT_ACTOR_NAME));
921        let proc_ref = ProcRef::new(proc_addr, 0, agent);
922        ProcMeshRef::new_singleton(
923            ProcMeshId::singleton(Label::new(mesh).expect("test label should be valid")),
924            proc_ref,
925        )
926        .expect("test proc mesh should be valid")
927    }
928
929    fn resolved_proc_mesh_ref() -> MeshRef {
930        MeshRef::Proc(Box::new(proc_mesh_ref(1, "mesh")))
931    }
932
933    /// A `Shared` that is already completed with `value`.
934    ///
935    /// Built through the Python classmethod so nothing is spawned: the handle
936    /// is finished before any test looks at it, which keeps slot filling free
937    /// of scheduling order.
938    fn completed_shared(py: Python<'_>, value: Py<PyAny>) -> Py<PyShared> {
939        py.get_type::<PyShared>()
940            .call_method1("from_value", (value,))
941            .expect("Shared.from_value should accept any object")
942            .extract()
943            .expect("Shared.from_value should return a Shared")
944    }
945
946    /// The Python object a resolved pending mesh hands back: the wrapper type
947    /// the sender-side fill knows how to turn into a `MeshRef`.
948    fn mesh_value(py: Python<'_>, mesh: &ProcMeshRef) -> Py<PyAny> {
949        Py::new(py, PyProcMesh::new_ref(mesh.clone()))
950            .expect("PyProcMesh should construct")
951            .into_any()
952    }
953
954    /// `Result::expect_err` for a success type that is not `Debug`.
955    fn expect_error<T>(result: PyResult<T>, context: &str) -> PyErr {
956        match result {
957            Ok(_) => panic!("{context}"),
958            Err(error) => error,
959        }
960    }
961
962    fn pickling_state(
963        buffer: Vec<u8>,
964        mesh_references: Vec<Option<MeshRef>>,
965        pending_mesh_fills: Vec<(usize, Py<PyShared>)>,
966    ) -> PicklingState {
967        PicklingState {
968            inner: Some(PicklingStateInner {
969                buffer: Part::from(buffer),
970                tensor_engine_references: VecDeque::new(),
971                mesh_references: mesh_references.into(),
972                pending_mesh_fills,
973            }),
974        }
975    }
976
977    fn pending_message(
978        kind: PythonMessageKind,
979        buffer: Vec<u8>,
980        refs: Vec<MeshRef>,
981    ) -> PendingMessage {
982        PendingMessage::new(
983            kind,
984            PicklingState {
985                inner: Some(PicklingStateInner {
986                    buffer: Part::from(buffer),
987                    tensor_engine_references: VecDeque::new(),
988                    mesh_references: refs.into_iter().map(Some).collect(),
989                    pending_mesh_fills: Vec::new(),
990                }),
991            },
992        )
993    }
994
995    /// `payload_len` reports the pickled bytes only, so an out-of-band ref does
996    /// not change it: the refs table rides beside the payload, not inside it.
997    #[test]
998    fn payload_len_counts_pickled_bytes_only() {
999        let kind = PythonMessageKind::Result { rank: Some(7) };
1000        let buffer = vec![0, 1, 2, 3, 127, 128, 254, 255];
1001
1002        for refs in [Vec::new(), vec![resolved_proc_mesh_ref()]] {
1003            assert_eq!(
1004                pending_message(kind.clone(), buffer.clone(), refs).payload_len(),
1005                8,
1006                "payload_len should be the length of the pickled buffer"
1007            );
1008        }
1009    }
1010
1011    /// The receiver guard's three contracts: a lookup clones rather than
1012    /// consumes, a nested guard overrides and then restores, and a guard
1013    /// dropped by an unwinding error restores too.
1014    // `#[tokio::test]` because `Proc::direct` needs a runtime. The body has no
1015    // await, so it stays on one thread and the thread-local is observed there.
1016    #[tokio::test]
1017    async fn receiver_instance_guard_clones_nests_and_restores() {
1018        use std::panic::AssertUnwindSafe;
1019
1020        use hyperactor::Proc;
1021        use hyperactor::channel::ChannelTransport;
1022
1023        fn instance(name: &str) -> Instance<PythonActor> {
1024            Proc::direct(ChannelTransport::Unix.any(), format!("{name}_proc"))
1025                .unwrap()
1026                .actor_instance::<PythonActor>(name)
1027                .unwrap()
1028                .instance
1029        }
1030
1031        let outer = instance("outer");
1032        let inner = instance("inner");
1033        let outer_id = outer.self_addr().clone();
1034        let inner_id = inner.self_addr().clone();
1035
1036        assert!(current_receiver_instance_raw().is_none(), "clean to start");
1037
1038        {
1039            let _g = ReceiverInstanceGuard::enter(&outer);
1040
1041            // Non-consuming: a payload with several Ports looks up repeatedly.
1042            for _ in 0..3 {
1043                let got = current_receiver_instance_raw().expect("receiver installed");
1044                assert_eq!(got.self_addr(), &outer_id);
1045            }
1046
1047            {
1048                let _nested = ReceiverInstanceGuard::enter(&inner);
1049                assert_eq!(
1050                    current_receiver_instance_raw().unwrap().self_addr(),
1051                    &inner_id,
1052                );
1053            }
1054            assert_eq!(
1055                current_receiver_instance_raw().unwrap().self_addr(),
1056                &outer_id,
1057                "nested guard must restore its parent",
1058            );
1059
1060            // Drop on unwind restores the parent, not `None`.
1061            let unwound = std::panic::catch_unwind(AssertUnwindSafe(|| {
1062                let _failing = ReceiverInstanceGuard::enter(&inner);
1063                panic!("decode blew up");
1064            }));
1065            assert!(unwound.is_err());
1066            assert_eq!(
1067                current_receiver_instance_raw().unwrap().self_addr(),
1068                &outer_id,
1069                "a guard dropped while unwinding must restore",
1070            );
1071        }
1072
1073        assert!(
1074            current_receiver_instance_raw().is_none(),
1075            "outermost guard must clear the receiver",
1076        );
1077    }
1078
1079    #[tokio::test]
1080    async fn try_resolve_now_matches_async_resolution_with_resolved_refs() {
1081        let kind = PythonMessageKind::Result { rank: Some(7) };
1082        let buffer = vec![0, 1, 2, 3, 127, 128, 254, 255];
1083
1084        for refs in [Vec::new(), vec![resolved_proc_mesh_ref()]] {
1085            let synchronous = match pending_message(kind.clone(), buffer.clone(), refs.clone())
1086                .try_resolve_now()
1087                .expect("zero-pending probe should succeed")
1088            {
1089                Ok(message) => message,
1090                Err(_) => panic!("zero-pending message should resolve synchronously"),
1091            };
1092            let asynchronous = pending_message(kind.clone(), buffer.clone(), refs)
1093                .resolve()
1094                .await
1095                .expect("zero-pending async resolution should succeed");
1096
1097            assert_eq!(
1098                synchronous, asynchronous,
1099                "sync and async resolution should preserve kind, bytes, and refs"
1100            );
1101        }
1102    }
1103
1104    /// `resolve` writes each pending mesh into the slot it reserved, leaving
1105    /// the slots it did not reserve and the pickled bytes alone.
1106    ///
1107    /// The two pending slots are nonadjacent, with an already-resolved slot
1108    /// between them, so filling them in the wrong order or appending instead of
1109    /// writing in place both produce a different table.
1110    #[tokio::test]
1111    async fn resolve_fills_nonadjacent_slots_in_place_without_touching_the_payload() {
1112        pyo3::Python::initialize();
1113
1114        let first = proc_mesh_ref(11, "first");
1115        let already = proc_mesh_ref(22, "already");
1116        let second = proc_mesh_ref(33, "second");
1117        assert_ne!(
1118            first, second,
1119            "the two pending meshes must be distinguishable"
1120        );
1121        let payload: Vec<u8> = vec![0, 1, 2, 3, 127, 128, 254, 255];
1122
1123        let state = monarch_with_gil_blocking(GilSite::Test, |py| {
1124            Ok::<_, PyErr>(pickling_state(
1125                payload.clone(),
1126                vec![None, Some(MeshRef::Proc(Box::new(already.clone()))), None],
1127                // Deliberately out of index order: an implementation that
1128                // ignored the index and filled successive empty slots would
1129                // produce the reverse table.
1130                vec![
1131                    (2, completed_shared(py, mesh_value(py, &second))),
1132                    (0, completed_shared(py, mesh_value(py, &first))),
1133                ],
1134            ))
1135        })
1136        .expect("building the pending state should succeed");
1137
1138        let resolved = state.resolve().await.expect("resolution should succeed");
1139        let inner = resolved
1140            .inner
1141            .expect("a resolved state should still hold its inner");
1142
1143        assert_eq!(
1144            inner.mesh_references,
1145            VecDeque::from(vec![
1146                Some(MeshRef::Proc(Box::new(first))),
1147                Some(MeshRef::Proc(Box::new(already))),
1148                Some(MeshRef::Proc(Box::new(second))),
1149            ]),
1150            "each pending mesh must land in the slot it reserved"
1151        );
1152        assert_eq!(
1153            &inner.take_buffer().into_bytes()[..],
1154            &payload[..],
1155            "resolution must not re-pickle: the payload bytes are carried through"
1156        );
1157    }
1158
1159    /// `py_resolve` consumes its source before returning a Handle.
1160    #[tokio::test]
1161    async fn py_resolve_consumes_the_source_before_returning_its_handle() {
1162        pyo3::Python::initialize();
1163
1164        monarch_with_gil_blocking(GilSite::Test, |py| {
1165            let mesh = proc_mesh_ref(41, "consumed");
1166            let mut message = PendingMessage::new(
1167                PythonMessageKind::Result { rank: Some(7) },
1168                pickling_state(
1169                    vec![1, 2, 3],
1170                    vec![None],
1171                    vec![(0, completed_shared(py, mesh_value(py, &mesh)))],
1172                ),
1173            );
1174
1175            let handle = message
1176                .py_resolve()
1177                .expect("Handle construction should succeed");
1178            drop(handle);
1179
1180            let second = expect_error(
1181                message.py_resolve(),
1182                "the source was consumed by the first call",
1183            );
1184            assert_eq!(
1185                second.value(py).to_string(),
1186                "PicklingState has already been consumed",
1187                "a second resolution must report the consumed source"
1188            );
1189            Ok::<_, PyErr>(())
1190        })
1191        .expect("test body should not fail");
1192    }
1193
1194    /// A fill conversion error surfaces through the Handle, and the source
1195    /// stays consumed afterwards.
1196    #[tokio::test]
1197    async fn py_resolve_handle_surfaces_the_fill_error_and_leaves_the_source_consumed() {
1198        pyo3::Python::initialize();
1199
1200        let (handle, mut message) = monarch_with_gil_blocking(GilSite::Test, |py| {
1201            let not_a_mesh = 41i64.into_py_any(py)?;
1202            let mut message = PendingMessage::new(
1203                PythonMessageKind::Result { rank: Some(7) },
1204                pickling_state(
1205                    vec![1, 2, 3],
1206                    vec![None],
1207                    vec![(0, completed_shared(py, not_a_mesh))],
1208                ),
1209            );
1210            let handle = message.py_resolve()?;
1211            Ok::<_, PyErr>((handle, message))
1212        })
1213        .expect("construction should succeed");
1214
1215        let error = tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait_future())
1216            .await
1217            .expect("the Handle must finish within the test deadline")
1218            .expect_err("observing the Handle must surface the fill failure");
1219
1220        monarch_with_gil_blocking(GilSite::Test, |py| {
1221            assert!(
1222                error
1223                    .get_type(py)
1224                    .is(pyo3::exceptions::PyRuntimeError::type_object(py)),
1225                "the original exception type must survive the Handle boundary exactly, \
1226                 not merely as a subclass"
1227            );
1228            assert_eq!(
1229                error.value(py).to_string(),
1230                "pending pickle did not resolve to a mesh reference",
1231                "the original message must survive the Handle boundary"
1232            );
1233
1234            let second = expect_error(
1235                message.py_resolve(),
1236                "a failed observation must not hand the source back",
1237            );
1238            assert_eq!(
1239                second.value(py).to_string(),
1240                "PicklingState has already been consumed",
1241            );
1242            Ok::<_, PyErr>(())
1243        })
1244        .expect("assertions should not fail");
1245    }
1246
1247    /// A failure raised by a pending mesh producer, rather than by the
1248    /// conversion that follows it, is what the outer Handle reports.
1249    ///
1250    /// The pending `Shared` carries a failure with a message the conversion path
1251    /// cannot produce, so the sibling conversion-error test cannot be what this
1252    /// one is measuring.
1253    #[tokio::test]
1254    async fn py_resolve_handle_surfaces_a_failed_pending_handle_error() {
1255        pyo3::Python::initialize();
1256
1257        let (outer_handle, mut outer) = monarch_with_gil_blocking(GilSite::Test, |py| {
1258            let (sender, pending) = PyShared::pending();
1259            sender
1260                .send(Some(Err(pyo3::exceptions::PyRuntimeError::new_err(
1261                    "pending mesh producer failed",
1262                ))))
1263                .expect("the pending Shared should retain its receiver");
1264            let handle = Py::new(py, pending)?;
1265
1266            let mut outer = PendingMessage::new(
1267                PythonMessageKind::Result { rank: Some(7) },
1268                pickling_state(vec![1, 2, 3], vec![None], vec![(0, handle)]),
1269            );
1270            let handle = outer.py_resolve()?;
1271            Ok::<_, PyErr>((handle, outer))
1272        })
1273        .expect("construction should succeed");
1274
1275        let error = tokio::time::timeout(
1276            std::time::Duration::from_secs(2),
1277            outer_handle.wait_future(),
1278        )
1279        .await
1280        .expect("the outer Handle must finish within the test deadline")
1281        .expect_err("the failed producer must fail the outer resolution");
1282
1283        monarch_with_gil_blocking(GilSite::Test, |py| {
1284            assert!(
1285                error
1286                    .get_type(py)
1287                    .is(pyo3::exceptions::PyRuntimeError::type_object(py)),
1288                "the producer's exception type must survive the Shared and Handle boundaries"
1289            );
1290            assert_eq!(
1291                error.value(py).to_string(),
1292                "pending mesh producer failed",
1293                "the outer Handle must report the producer's own failure, not a \
1294                 conversion failure raised after a successful await"
1295            );
1296
1297            let second = expect_error(
1298                outer.py_resolve(),
1299                "a failed producer must not hand the source back",
1300            );
1301            assert_eq!(
1302                second.value(py).to_string(),
1303                "PicklingState has already been consumed",
1304            );
1305            Ok::<_, PyErr>(())
1306        })
1307        .expect("assertions should not fail");
1308    }
1309
1310    /// An unfilled reserved slot remains an assembly error, and observing that
1311    /// error does not hand the consumed source back.
1312    #[tokio::test]
1313    async fn py_resolve_handle_surfaces_unfilled_slot_error_and_leaves_source_consumed() {
1314        pyo3::Python::initialize();
1315
1316        let (handle, mut message) = monarch_with_gil_blocking(GilSite::Test, |_py| {
1317            let mut message = PendingMessage::new(
1318                PythonMessageKind::Result { rank: Some(1) },
1319                pickling_state(vec![9], vec![None], vec![]),
1320            );
1321            let handle = message.py_resolve()?;
1322            Ok::<_, PyErr>((handle, message))
1323        })
1324        .expect("construction should succeed");
1325
1326        let error = tokio::time::timeout(std::time::Duration::from_secs(2), handle.wait_future())
1327            .await
1328            .expect("the Handle must finish within the test deadline")
1329            .expect_err("the unfilled slot must fail message assembly");
1330
1331        monarch_with_gil_blocking(GilSite::Test, |py| {
1332            assert!(
1333                error
1334                    .get_type(py)
1335                    .is(pyo3::exceptions::PyRuntimeError::type_object(py)),
1336                "the assembly error must remain exactly PyRuntimeError"
1337            );
1338            assert_eq!(
1339                error.value(py).to_string(),
1340                "mesh reference slot was never filled",
1341                "an unfilled reserved slot must retain its diagnostic"
1342            );
1343
1344            let second = expect_error(
1345                message.py_resolve(),
1346                "an assembly failure must not hand the source back",
1347            );
1348            assert_eq!(
1349                second.value(py).to_string(),
1350                "PicklingState has already been consumed",
1351            );
1352            Ok::<_, PyErr>(())
1353        })
1354        .expect("assertions should not fail");
1355    }
1356
1357    /// `py_resolve` starts waiting on a pending handle, and dropping its
1358    /// returned observer does not cancel that wait.
1359    ///
1360    /// The sender's receiver count is the witness: `Shared::task()` clones the
1361    /// watch receiver for the life of the waiter. The original `PyShared` stays
1362    /// alive to keep the baseline stable after the returned observer is
1363    /// dropped.
1364    #[tokio::test]
1365    async fn py_resolve_starts_resolution_and_survives_returned_handle_drop() {
1366        pyo3::Python::initialize();
1367
1368        let (sender, pending_handle, baseline, mut message) =
1369            monarch_with_gil_blocking(GilSite::Test, |py| {
1370                let (sender, pending) = PyShared::pending();
1371                let handle = Py::new(py, pending)?;
1372                let baseline = sender.receiver_count();
1373
1374                let mut message = PendingMessage::new(
1375                    PythonMessageKind::Result { rank: Some(7) },
1376                    pickling_state(vec![1, 2, 3], vec![None], vec![(0, handle.clone_ref(py))]),
1377                );
1378
1379                let observer = message.py_resolve()?;
1380                drop(observer);
1381                Ok::<_, PyErr>((sender, handle, baseline, message))
1382            })
1383            .expect("construction should succeed");
1384
1385        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1386            while sender.receiver_count() == baseline {
1387                tokio::task::yield_now().await;
1388            }
1389        })
1390        .await
1391        .expect("resolution must start and retain a waiter after its observer is dropped");
1392        assert_eq!(
1393            sender.receiver_count(),
1394            baseline + 1,
1395            "one active resolver must retain exactly one additional receiver"
1396        );
1397
1398        let value = monarch_with_gil_blocking(GilSite::Test, |py| {
1399            Ok::<_, PyErr>(mesh_value(py, &proc_mesh_ref(44, "pending")))
1400        })
1401        .expect("the pending mesh value should construct");
1402        sender
1403            .send(Some(Ok(value)))
1404            .expect("the retained pending handle should receive its value");
1405
1406        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1407            while sender.receiver_count() != baseline {
1408                tokio::task::yield_now().await;
1409            }
1410        })
1411        .await
1412        .expect("the detached resolver must finish and release its waiter");
1413
1414        monarch_with_gil_blocking(GilSite::Test, |py| {
1415            let second = expect_error(
1416                message.py_resolve(),
1417                "the source was consumed by the first call",
1418            );
1419            assert_eq!(
1420                second.value(py).to_string(),
1421                "PicklingState has already been consumed",
1422            );
1423        });
1424        drop(pending_handle);
1425    }
1426}