Skip to main content

monarch_hyperactor/
handle.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//! `Handle`: an observe-only handle to a background operation.
10//!
11//! A `Handle` wraps a `watch` channel that a producer fulfills exactly once with
12//! the operation's `PyResult<Py<PyAny>>`. You observe its eventual result:
13//! synchronously via `get()`, on an `asyncio` loop via `as_asyncio()`/`await`, or
14//! without blocking via `poll()`. The channel is multi-observer, so a resolved
15//! value stays observable by any number of later observers.
16//!
17//! The watch-channel mechanics live in [`HandleCore`].
18//!
19//! ## Handle invariants (HDL-*)
20//!
21//! Code that relies on or enforces an invariant is tagged `// HDL-N`, and each
22//! `#[cfg(test)]` test that attests one names it; read the matching entry below
23//! before changing a tagged site.
24//!
25//! Assumed of the shared core and producer (not enforced here):
26//! - **HDL-1 (single terminal completion).** The watch value goes `None` ->
27//!   `Some(_)` exactly once and is never reset (`Some` -> `Some`, `Some` ->
28//!   `None`). Relied on by [`HandleCore::wait_future`] (wait loop + `expect`),
29//!   [`HandleCore::poll`] (re-borrow across the GIL), and
30//!   [`PyHandle::wait_completion`] (discriminant check then GIL re-borrow). Held
31//!   by convention: every producer sends once, always `Some(_)` (including the
32//!   direct [`PyHandle::spawn`] producer). A send-once sender (a newtype
33//!   consuming the `watch::Sender`) could make it structural, hardening `poll`'s
34//!   re-borrow.
35//! - **HDL-2 (value encoding).** `None` is pending, `Some(Ok(_))` success,
36//!   `Some(Err(_))` a producer error.
37//!
38//! Guaranteed by this module:
39//! - **HDL-3 (non-consuming reads).** No read moves the value out: a
40//!   value-extracting read clones it (`clone_ref`), and a discriminant-only read
41//!   (`wait_completion`'s success fast path) borrows just the `Ok`/`Err` tag and
42//!   clones nothing. Either way any number of observers see the same result.
43//!   Enforced by construction: no read path moves the value.
44//! - **HDL-4 (drop does not cancel; drop never panics).** Dropping a `Handle`
45//!   never cancels its producer; a `Handle` observes, it does not own the
46//!   producer's lifecycle. Enforced by construction: a `Handle`'s core has
47//!   `abort_on_drop: None`, and `HandleCore::Drop` aborts only when
48//!   `abort_on_drop` is set. The abort is wrapped in `catch_unwind`, so an
49//!   `abort()` that panics at interpreter shutdown (the Tokio runtime already
50//!   gone) cannot escape `Drop` and abort the process.
51//! - **HDL-5 (GIL discipline).** No Python object is touched without the GIL,
52//!   and every acquisition goes through `monarch_with_gil{,_blocking}`. Enforced
53//!   by the crate's `#![deny(clippy::disallowed_methods)]` ban on raw
54//!   `Python::with_gil`/`attach` -- a hard compile error, not a `debug_assert`.
55//! - **HDL-6 (`WouldBlockRuntime` is `get()`'s alone *among the observers*).**
56//!   Of the `Handle` observers only `get()` raises it, and only in a Tokio
57//!   runtime context; `as_asyncio()`/`__await__` off a loop raise the native
58//!   `RuntimeError` instead. The exception type itself is not private to this
59//!   module: it marks synchronous APIs that refuse to enter or block on Tokio
60//!   from an existing Tokio runtime context. The Python layer also raises it for
61//!   fresh root-client bootstrap (`monarch._src.actor.actor_mesh`), the
62//!   blocking worker-loop wrappers (`monarch._src.actor.bootstrap`), and a
63//!   `returns_future` `.get()` that would block a running event loop
64//!   (`monarch._src.actor.returns_future`, RF-7).
65//! - **HDL-7 (`as_asyncio` publish).** The observer waits borrow-first (via
66//!   `wait_ready`, never `changed()`-first). The result is published on the
67//!   loop thread through `complete_asyncio_future`, by the wake reader
68//!   (HDL-17) or by the fallback's `call_soon_threadsafe` callback (HDL-21),
69//!   or, for a Handle already complete, by `as_asyncio` itself (HDL-22).
70//!   It sets a result only on a non-cancelled future, swallowing
71//!   `InvalidStateError`; any `StopIteration` (including subclasses) is wrapped
72//!   in `RuntimeError` first, since `set_exception` rejects `StopIteration`
73//!   (PEP 479). If the loop has closed, the fallback logs rather than panics
74//!   and the wake reader never runs. A wake-reader failure, including a
75//!   future whose `done()` raises, is reported through the future's loop
76//!   exception handler, as asyncio reports an exception from a callback.
77//! - **HDL-8 (`get()` loop-warning is call-pattern, not outcome).** `get()` on a
78//!   running asyncio loop warns regardless of whether the value is ready: it
79//!   flags the blocking-call-from-a-loop anti-pattern, not whether this call
80//!   happens to block. A ready value returns after warning; under a
81//!   warnings-as-errors filter the warning escalates to an error.
82//! - **HDL-9 (GIL/watch borrow discipline).** A `watch` borrow is never held
83//!   across a GIL acquisition or an `.await`. `poll` and `wait_completion`'s error
84//!   path drop their read guard before taking the GIL and re-borrow under it
85//!   (GIL-then-watch order, never the inverse against the producer's write-lock
86//!   `send`); the `wait_ready`/
87//!   `wait_future` loops drop the temporary `Ref` before each `.await`; and their
88//!   returned futures own a cloned receiver (`Send + 'static`, borrowing nothing
89//!   from `&self`), which is what lets `get()` `drop(slf)` + release the GIL
90//!   before blocking and lets `as_asyncio` `spawn` the observer. `as_asyncio`'s
91//!   fallback observer clones the value under the one GIL acquisition it makes
92//!   to schedule; its wake observer never reads the value (HDL-17). Enforced
93//!   partly by the compiler (`Send` bounds, the borrow checker on `drop(slf)`)
94//!   and partly by construction; exercised by the multi-observer/blocking
95//!   tests.
96//! - **HDL-10 (dropped producer surfaces, never hangs).** If every producer drops
97//!   its sender without sending, the wait loop's `changed().await?` yields a
98//!   `RecvError` converted to `RuntimeError("Handle producer ended without
99//!   publishing a result")` on the non-blocking (`poll`), sync
100//!   (`get`/`wait_future`), async (`as_asyncio`), and `wait_completion` paths,
101//!   so every observer receives the same producer-lifecycle failure rather than
102//!   waiting forever.
103//! - **HDL-11 (Python construction is paired).** Neither `Handle` nor
104//!   `_HandleCompleter` has `#[new]`. Internal Python may call the private
105//!   `_new_handle_pair()`, which atomically creates a pending `Handle` and its
106//!   sole producer. The factory initializes the completer with `Some(sender)`.
107//!   `set_exception()` rejects a non-`BaseException` before taking it; the first
108//!   `set_result()` or valid `set_exception()` takes it, producing the only
109//!   state transition to `None`, and any later completion attempt raises. No
110//!   sender clone escapes. Dropping the completer unresolved closes the channel
111//!   and surfaces through HDL-10. Python therefore cannot create a pending
112//!   `Handle` without its producer. Native producers remain the default; this
113//!   private pair is reserved for operations whose semantics and scheduling are
114//!   owned by Python.
115//! - **HDL-12 (`get()` timeout contract).** `get()` validates the timeout up
116//!   front (rejecting negative/NaN/non-finite as `ValueError` via
117//!   `try_from_secs_f64`, never panicking) and before the ready fast path, so an
118//!   invalid timeout raises deterministically even for a ready handle. A timeout
119//!   is non-cancelling: it raises `TimeoutError` while leaving the producer and
120//!   every observer untouched, so a later `poll()`/`get()`/`await` still observes
121//!   completion.
122//! - **HDL-13 (direct Rust producer).** A Rust caller can eagerly produce a
123//!   `Handle` from a `Send + 'static` future via [`PyHandle::spawn`], without a
124//!   `PyPythonTask`: it spawns one producer that converts the terminal value to
125//!   `Py<PyAny>` under `monarch_with_gil` and publishes it once through the shared
126//!   `send_result` helper (same one-shot core and unobserved-error policy as
127//!   pytokio's producers, HDL-1/HDL-2), with `abort_on_drop: None` (HDL-4).
128//!   [`PyHandle::wait_completion`] is the matching observer: it projects the
129//!   terminal value to `PyResult<()>`, touching neither the GIL nor the Python
130//!   value on the ready-success fast path (a discriminant-only read, HDL-3) and
131//!   cloning the `PyErr` under the HDL-9 borrow order only on the error path. Both
132//!   are `pub` Rust-only methods outside `#[pymethods]` (HDL-11).
133//! - **HDL-14 (Rust value observation is owned and non-consuming).**
134//!   [`PyHandle::wait_future`] returns a `Send + 'static` future over a cloned
135//!   watch receiver. The waiter borrows nothing from the `PyHandle`, survives
136//!   the wrapper being dropped, and leaves the terminal value available to
137//!   every other observer.
138//! - **HDL-15 (Rust ready construction starts no work).**
139//!   [`PyHandle::from_value`] constructs an already-complete, non-aborting core
140//!   without spawning a task. It is public to Rust callers but remains outside
141//!   `#[pymethods]`, preserving HDL-11's direct-construction restriction.
142//! - **HDL-16 (permanent Python identity).** `Handle` and `WouldBlockRuntime`
143//!   have the canonical module `monarch._rust_bindings.monarch_hyperactor.handle`.
144//!   The legacy `pytokio` module does not export `Handle`.
145//! - **HDL-17 (GIL-free completion wake).** For a Handle still pending when
146//!   `as_asyncio` is called, on a loop that accepts `add_reader`, the observer
147//!   task holds a `Notifier` (a token,
148//!   an mpsc sender and an `Arc<Waker>`) and the `wait_ready` future. It
149//!   publishes by pushing the token and writing the loop's pipe, never reading
150//!   the value and taking no GIL; the loop thread reads the value from the
151//!   `Handle` and sets it (see [`crate::handle_wake`]). Enforced by
152//!   construction: `Notifier` has no `Py<_>`, and the task body makes no
153//!   `monarch_with_gil` call.
154//! - **HDL-18 (notification ordering under supported pipe outcomes).** A
155//!   notifier pushes its token before writing the pipe, and the reader drains
156//!   the pipe before the queue, never returning between the two; both retry
157//!   `EINTR`. A write that succeeds or returns `EAGAIN` leaves a byte pending,
158//!   so a token pushed after a queue drain is delivered by a later wake.
159//!   `EPIPE` or a disconnected queue means the channel is gone and the token is
160//!   dropped. Any other errno is logged as a broken wake channel, not claimed
161//!   as delivered.
162//! - **HDL-19 (loop not retained).** The wake registry holds each loop as a
163//!   weak key and its channel through a weak reference; the loop's selector,
164//!   through the channel's reader callback, is the channel's only strong
165//!   owner. A channel holds no reference to its loop. It holds each pending
166//!   await's asyncio future strongly until delivery, cancellation or loop
167//!   close, because asyncio holds tasks weakly and that reference is the
168//!   waiting task's only owner outside the task's own cycle; `__traverse__`
169//!   exposes each entry's future and `Handle` object to the garbage collector.
170//!   Closing a loop therefore releases the channel-owned graph (the channel,
171//!   its read end, its entries, their futures and `Handle`s). The write end
172//!   stays open until every `Notifier` is dropped, so a loop closed while an
173//!   await waits on a `Handle` that never completes keeps that descriptor
174//!   (HDL-20). A loop dropped
175//!   without being closed while an await on a pending `Handle` is registered
176//!   is still freed by the collector. Not freed: a loop dropped without being
177//!   closed while an undelivered entry's `Handle` value references it, and any
178//!   independent cycle through a `Handle`, such as a value that references its
179//!   own `Handle`. Both run through `PyHandle`, which the garbage collector
180//!   does not traverse.
181//! - **HDL-20 (late wake is harmless).** A notify after its channel is gone is
182//!   dropped without error or panic: the send fails, or the write gets `EPIPE`.
183//!   This requires `SIGPIPE` to be ignored, as Rust's runtime and CPython set
184//!   it by default; in a process that restores `SIG_DFL`, a completion that
185//!   races loop teardown can kill the process. Every `Notifier` owns the write
186//!   end through its `Arc<Waker>` until its `Handle` completes, so no write
187//!   reaches a reused descriptor.
188//! - **HDL-21 (fallback).** For a Handle still pending, a loop whose
189//!   `add_reader` raises `NotImplementedError` or that cannot be weakly
190//!   referenced uses the `call_soon_threadsafe` path; HDL-7 applies there
191//!   unchanged.
192//! - **HDL-22 (ready shortcut).** `as_asyncio()` on a Handle that already holds
193//!   its outcome, a value, a stored exception or producer loss (HDL-10),
194//!   returns a loop future already completed with it through
195//!   `complete_asyncio_future`, with no observer task and no wake-channel
196//!   registration. Awaiting it returns without giving the loop a turn, as for
197//!   any finished asyncio future.
198
199use std::error::Error;
200use std::future::Future;
201
202use monarch_types::SerializablePyErr;
203use monarch_types::py_global;
204use pyo3::IntoPyObjectExt;
205use pyo3::exceptions::PyBaseException;
206use pyo3::exceptions::PyRuntimeError;
207use pyo3::exceptions::PyStopIteration;
208use pyo3::exceptions::PyTimeoutError;
209use pyo3::exceptions::PyTypeError;
210use pyo3::exceptions::PyUserWarning;
211use pyo3::exceptions::PyValueError;
212use pyo3::prelude::*;
213use pyo3::sync::PyOnceLock;
214use pyo3::types::PyCFunction;
215use pyo3::types::PyString;
216use pyo3::types::PyType;
217use tokio::sync::watch;
218use tokio::task::AbortHandle;
219
220use crate::handle_wake::LoopWake;
221use crate::runtime::GilSite;
222use crate::runtime::get_tokio_runtime;
223use crate::runtime::is_in_tokio_runtime;
224use crate::runtime::monarch_with_gil;
225use crate::runtime::monarch_with_gil_blocking;
226use crate::runtime::signal_safe_block_on;
227
228// Held so the completion callback can catch and swallow it: publishing a result
229// onto an asyncio.Future that's already settled (the awaiter cancelled it, or it
230// resolved) raises InvalidStateError, which is benign here.
231py_global!(invalid_state_error, "asyncio", "InvalidStateError");
232
233pyo3::create_exception!(
234    handle,
235    WouldBlockRuntime,
236    pyo3::exceptions::PyRuntimeError,
237    "raised when a synchronous API refuses to enter or block on Tokio from \
238     an existing Tokio runtime context -- Handle.get(), fresh root-client \
239     bootstrap, or a blocking worker-loop wrapper -- or when a returns_future \
240     .get() would block a running event loop"
241);
242
243/// Convert a Rust error into the generic Python `ValueError` used by Monarch's
244/// native bindings.
245pub fn to_py_error<T>(error: T) -> PyErr
246where
247    T: Error,
248{
249    PyErr::new::<PyValueError, _>(error.to_string())
250}
251
252/// Report that a Handle lost its producer before receiving a terminal value.
253fn handle_producer_ended_error(_: watch::error::RecvError) -> PyErr {
254    PyRuntimeError::new_err("Handle producer ended without publishing a result")
255}
256
257/// Format a captured traceback (from `traceback.extract_stack()`) as a single
258/// string suitable for logging.
259fn format_traceback(py: Python<'_>, traceback: &Py<PyAny>) -> PyResult<String> {
260    let tb = py
261        .import("traceback")?
262        .call_method1("format_list", (traceback,))?;
263    PyString::new(py, "")
264        .call_method1("join", (tb,))?
265        .extract::<String>()
266}
267
268/// Publish a completed background-task result to the shared watch channel.
269///
270/// Shared by `PyPythonTask` spawns, the direct [`PyHandle::spawn`] producer
271/// (HDL-13), and the private Python completion pair (HDL-11). If the receiver
272/// has already been dropped, `watch::Sender::send` returns the unsent value as
273/// `SendError`: no consumer can ever observe it. An unobserved error is logged
274/// with its producer-creation traceback when one was captured.
275pub(crate) fn send_result(
276    tx: watch::Sender<Option<PyResult<Py<PyAny>>>>,
277    result: PyResult<Py<PyAny>>,
278    traceback: Option<Py<PyAny>>,
279) {
280    if let Err(watch::error::SendError(Some(Err(pyerr)))) = tx.send(Some(result)) {
281        monarch_with_gil_blocking(GilSite::Traceback, |py| {
282            let tb = if let Some(tb) = traceback {
283                format_traceback(py, &tb).unwrap()
284            } else {
285                // No creation traceback was captured: either a capture-disabled
286                // PythonTask (the default when the env var is unset) or a direct
287                // Handle producer, which never captures one.
288                "creation traceback unavailable (PythonTask producers can set \
289                 `MONARCH_HYPERACTOR_ENABLE_UNAWAITED_PYTHON_TASK_TRACEBACK=1` to capture one)\n"
290                    .into()
291            };
292            tracing::error!(
293                "a background task errored but is not being awaited; this will not crash your \
294                program, but indicates that something went wrong.\n{}\nTraceback where the task \
295                was created (most recent call last):\n{}",
296                SerializablePyErr::from(py, &pyerr),
297                tb
298            );
299        });
300    }
301}
302
303/// The watch-channel mechanics behind a `Handle`.
304///
305/// The channel starts as `None` and is fulfilled once with `Some(Ok(obj))` or
306/// `Some(Err(pyerr))`. Cloning the receiver lets multiple observers see the same
307/// completion, so observation is non-consuming.
308pub(crate) struct HandleCore {
309    /// One-shot result channel. `None` until the producer completes.
310    rx: watch::Receiver<Option<PyResult<Py<PyAny>>>>,
311
312    /// When `Some`, dropping the core aborts the producing Tokio task through
313    /// this handle; `None` leaves the producer running (a detached producer, or
314    /// a core built from a ready value). The task's result is observed only via
315    /// `rx`, so only the `AbortHandle` is kept, never the `JoinHandle`.
316    abort_on_drop: Option<AbortHandle>,
317
318    /// Optional creation-site traceback, used when logging un-awaited errors.
319    traceback: Option<Py<PyAny>>,
320}
321
322impl HandleCore {
323    /// Construct a core from its parts.
324    pub(crate) fn new(
325        rx: watch::Receiver<Option<PyResult<Py<PyAny>>>>,
326        abort_on_drop: Option<AbortHandle>,
327        traceback: Option<Py<PyAny>>,
328    ) -> Self {
329        Self {
330            rx,
331            abort_on_drop,
332            traceback,
333        }
334    }
335
336    /// Construct a core that is already resolved with `value`.
337    ///
338    /// There is no producer task, so the core never aborts on drop and carries
339    /// no traceback.
340    pub(crate) fn from_value(value: Py<PyAny>) -> PyResult<Self> {
341        let (tx, rx) = watch::channel(None);
342        tx.send(Some(Ok(value))).map_err(to_py_error)?;
343        Ok(Self {
344            rx,
345            abort_on_drop: None,
346            traceback: None,
347        })
348    }
349
350    /// Non-blocking, non-consuming check for completion.
351    ///
352    /// Returns `Ok(None)` while pending, `Ok(Some(obj))` on success, or
353    /// `Err(pyerr)` if the producer completed with an exception or disappeared
354    /// before completion.
355    pub(crate) fn poll(&self) -> PyResult<Option<Py<PyAny>>> {
356        // HDL-9: release the watch read guard before taking the GIL; holding it
357        // across the GIL acquisition would invert lock order against the
358        // producer's write-lock send. Re-borrowing under the GIL is safe by HDL-1
359        // (one-shot: once `Some`, never reset or replaced).
360        if self.rx.borrow().is_none() {
361            #[cfg(test)]
362            if let Some(between) = POLL_BETWEEN_CHECKS.with(|hook| hook.borrow_mut().take()) {
363                between();
364            }
365            // HDL-10: a closed channel with no value is terminal producer loss,
366            // not a still-pending Handle. `has_changed` also reports a channel
367            // closed just after a send, so read the value again after it.
368            let closed = self.rx.has_changed().err();
369            if self.rx.borrow().is_none() {
370                return match closed {
371                    Some(err) => Err(handle_producer_ended_error(err)),
372                    None => Ok(None),
373                };
374            }
375        }
376        monarch_with_gil_blocking(GilSite::Convert, |py| {
377            match self
378                .rx
379                .borrow()
380                .as_ref()
381                .expect("HDL-1: value is Some after the is_none check")
382            {
383                Ok(v) => Ok(Some(v.clone_ref(py))),
384                Err(err) => Err(err.clone_ref(py)),
385            }
386        })
387    }
388
389    /// Return a future that observes this core's completion.
390    ///
391    /// The receiver is cloned, so this is non-consuming: any number of waiters
392    /// can observe the same final value. The current value is checked before
393    /// awaiting `changed()`, so a core that already holds its value resolves
394    /// immediately.
395    pub(crate) fn wait_future(
396        &self,
397    ) -> impl Future<Output = PyResult<Py<PyAny>>> + Send + 'static + use<> {
398        // Reuse wait_ready's HDL-1 wait loop (single source of truth), then clone
399        // the value out under the GIL.
400        let ready = self.wait_ready();
401        async move {
402            let rx = ready.await?;
403            monarch_with_gil(GilSite::Convert, |py| {
404                match rx
405                    .borrow()
406                    .as_ref()
407                    .expect("HDL-1: value is Some after wait_ready")
408                {
409                    Ok(v) => Ok(v.clone_ref(py)),
410                    Err(err) => Err(err.clone_ref(py)),
411                }
412            })
413            .await
414        }
415    }
416
417    /// Await this core's completion without touching the GIL.
418    ///
419    /// Yields the cloned receiver positioned at the completed value, or a
420    /// `RuntimeError` if every producer dropped its sender without sending. Unlike
421    /// `wait_future`, which clones the value out under its own GIL acquisition,
422    /// the caller decides where the value is read: `as_asyncio`'s fallback
423    /// clones it under the GIL it takes to schedule, and its wake path reads it
424    /// on the loop thread instead (HDL-17).
425    pub(crate) fn wait_ready(
426        &self,
427    ) -> impl Future<Output = PyResult<watch::Receiver<Option<PyResult<Py<PyAny>>>>>>
428    + Send
429    + 'static
430    + use<> {
431        // HDL-9: the returned future owns a cloned receiver (Send + 'static,
432        // borrowing nothing from &self), so a caller can drop its PyRef / release
433        // the GIL before awaiting and can spawn this future.
434        let mut rx = self.rx.clone();
435        async move {
436            // HDL-1: loop until the value is actually `Some` (one-shot). HDL-9:
437            // the temporary borrow is dropped before each `.await`, never held
438            // across it. HDL-10: a dropped producer makes `changed()` yield a
439            // RecvError, which becomes the producer-lifecycle error rather than
440            // leaking the watch-channel implementation or hanging.
441            while rx.borrow().is_none() {
442                rx.changed().await.map_err(handle_producer_ended_error)?;
443            }
444            Ok(rx)
445        }
446    }
447
448    /// Clone the creation-site traceback under the GIL.
449    pub(crate) fn traceback_clone(&self) -> Option<Py<PyAny>> {
450        self.traceback
451            .as_ref()
452            .map(|t| monarch_with_gil_blocking(GilSite::Traceback, |py| t.clone_ref(py)))
453    }
454}
455
456/// Abort the producing Tokio task on drop when `abort_on_drop` is set.
457///
458/// This stops abandoned background work when no observers remain, guarded against
459/// panics during interpreter shutdown when the Tokio runtime may already be gone.
460/// A `Handle`'s core has `abort_on_drop: None` (HDL-4), so this aborts only for
461/// pytokio's `spawn_abortable`, never for a `Handle`.
462impl Drop for HandleCore {
463    fn drop(&mut self) {
464        if let Some(abort_handle) = self.abort_on_drop.as_ref() {
465            // HDL-4: catch_unwind so an abort() that panics at interpreter
466            // shutdown (the runtime already gone) cannot escape Drop and abort
467            // the process.
468            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
469                abort_handle.abort();
470            }));
471        }
472    }
473}
474
475/// Private one-shot producer paired with a Python-visible `Handle`.
476///
477/// The class has no Python constructor. `_new_handle_pair()` creates it with
478/// the only sender for its Handle; completion consumes that sender before
479/// publishing, and dropping it unresolved closes the channel (HDL-10/HDL-11).
480#[pyclass(
481    name = "_HandleCompleter",
482    module = "monarch._rust_bindings.monarch_hyperactor.handle"
483)]
484pub(crate) struct PyHandleCompleter {
485    // `Some` from `_new_handle_pair()` until the first valid completion;
486    // `None` thereafter.
487    tx: Option<watch::Sender<Option<PyResult<Py<PyAny>>>>>,
488}
489
490impl PyHandleCompleter {
491    /// Publish the terminal result exactly once.
492    fn complete(&mut self, result: PyResult<Py<PyAny>>) -> PyResult<()> {
493        let tx = self
494            .tx
495            .take()
496            .ok_or_else(|| PyRuntimeError::new_err("Handle has already been completed"))?;
497        send_result(tx, result, None);
498        Ok(())
499    }
500}
501
502#[pymethods]
503impl PyHandleCompleter {
504    /// Resolve the paired Handle successfully.
505    fn set_result(&mut self, value: Py<PyAny>) -> PyResult<()> {
506        self.complete(Ok(value))
507    }
508
509    /// Resolve the paired Handle with a Python exception.
510    fn set_exception(&mut self, error: Bound<'_, PyAny>) -> PyResult<()> {
511        if !error.is_instance_of::<PyBaseException>() {
512            return Err(PyTypeError::new_err("error must be a BaseException"));
513        }
514        self.complete(Err(PyErr::from_value(error)))
515    }
516}
517
518/// The observe-only handle to a background Tokio task or Python-owned operation.
519///
520/// Exposed to Python as
521/// `monarch._rust_bindings.monarch_hyperactor.handle.Handle`. HDL-11: Python
522/// cannot construct one directly. Internal Python may obtain one only from the
523/// private `_new_handle_pair()` factory together with its sole producer.
524#[pyclass(
525    name = "Handle",
526    module = "monarch._rust_bindings.monarch_hyperactor.handle"
527)]
528pub struct PyHandle {
529    core: HandleCore,
530}
531
532/// Atomically create a pending Handle and its sole Python completion endpoint.
533///
534/// Native producers remain the default. This private pair is for operations
535/// whose semantics and scheduling are owned by Python (HDL-11).
536#[pyfunction]
537fn _new_handle_pair() -> (PyHandle, PyHandleCompleter) {
538    let (tx, rx) = watch::channel(None);
539    (
540        PyHandle::from_core(HandleCore::new(rx, None, None)),
541        PyHandleCompleter { tx: Some(tx) },
542    )
543}
544
545/// Create a pending Handle and its sole completer without acquiring the GIL.
546///
547/// This function returns ordinary Rust values and performs no Python
548/// allocation. A caller can retain the Handle for observation and move the
549/// completer with pending work to a Python-owned execution thread.
550/// Under the GIL, that thread allocates a private `_HandleCompleter` Python
551/// object around the Rust completer. Python settles it when the operation
552/// finishes; dropping it unresolved fails the Handle (HDL-10/HDL-11).
553///
554/// For example, sync actor cleanup carries the completer in
555/// `PendingSyncCleanup`; the driver includes it in `SyncCleanup` and settles it
556/// after running `__cleanup__`. Rust cleanup awaits the paired Handle; settling
557/// the completer wakes that future so cleanup can resume and join the driver.
558pub(crate) fn handle_pair() -> (PyHandle, PyHandleCompleter) {
559    _new_handle_pair()
560}
561
562impl PyHandle {
563    /// Wrap an already-built `HandleCore` in a `PyHandle`.
564    ///
565    /// Native producers build the core and hand back an observe-only `Handle`
566    /// through this; `PyHandle`'s field is private to this module.
567    pub(crate) fn from_core(core: HandleCore) -> Self {
568        Self { core }
569    }
570
571    /// Construct an already-complete `Handle` from `value` without spawning a
572    /// producer task (HDL-15).
573    ///
574    /// Rust-only and outside `#[pymethods]`, so making this method public does
575    /// not make `Handle` directly constructible from Python (HDL-11).
576    pub fn from_value(value: Py<PyAny>) -> PyResult<Self> {
577        Ok(Self {
578            core: HandleCore::from_value(value)?,
579        })
580    }
581
582    /// Return an owned, non-consuming Rust observer for this Handle's value
583    /// (HDL-14).
584    ///
585    /// The returned future owns a cloned watch receiver, so it borrows nothing
586    /// from `self`, survives the `PyHandle` being dropped, and can coexist with
587    /// any number of other observers.
588    pub fn wait_future(
589        &self,
590    ) -> impl Future<Output = PyResult<Py<PyAny>>> + Send + 'static + use<> {
591        self.core.wait_future()
592    }
593
594    /// Non-blocking, non-consuming read of this Handle's outcome, for the
595    /// loop-thread wake reader (HDL-17). Rust-only and outside `#[pymethods]`
596    /// (HDL-11).
597    pub(crate) fn poll_ready(&self) -> PyResult<Option<Py<PyAny>>> {
598        self.core.poll()
599    }
600
601    /// Eagerly drive a Rust future to completion and return an observe-only
602    /// `Handle` over its result (HDL-13).
603    ///
604    /// The producer spawns immediately on the shared Tokio runtime; the returned
605    /// `Handle` observes already-running work rather than starting it lazily on
606    /// first observation. On completion the terminal `PyResult<T>` is converted
607    /// to `Py<PyAny>` under `monarch_with_gil` and published exactly once through
608    /// the shared `send_result` helper (HDL-1/HDL-2). The core carries
609    /// `abort_on_drop: None`, so dropping the `Handle` never cancels the producer
610    /// (HDL-4). Rust-only and outside `#[pymethods]`, so Python still cannot mint
611    /// a core (HDL-11).
612    pub fn spawn<F, T>(fut: F) -> Self
613    where
614        F: Future<Output = PyResult<T>> + Send + 'static,
615        T: for<'py> IntoPyObject<'py> + Send,
616    {
617        let (tx, rx) = watch::channel(None);
618        get_tokio_runtime().spawn(async move {
619            // Convert the terminal value to a Python object under the GIL, the
620            // same shape as `PyPythonTask` producers.
621            let result = async {
622                let value = fut.await?;
623                monarch_with_gil(GilSite::Convert, |py| value.into_py_any(py)).await
624            }
625            .await;
626            // HDL-1/HDL-2/HDL-13: publish exactly once via the shared helper; a
627            // direct Rust producer carries no creation traceback.
628            send_result(tx, result, None);
629        });
630        // HDL-4: a `Handle`'s core never aborts its producer on drop.
631        PyHandle::from_core(HandleCore::new(rx, None, None))
632    }
633
634    /// Observe whether this handle completed successfully, projecting the terminal
635    /// value to `PyResult<()>` (HDL-13).
636    ///
637    /// Returns an owned `Send + 'static` future over a cloned receiver (HDL-9),
638    /// so multiple Rust callers can observe the same terminal state and dropping
639    /// the `Handle` after taking an observer does not abort the producer (HDL-4).
640    /// The ready-success fast path touches neither the GIL nor the Python value
641    /// (a discriminant-only read, HDL-3); only the error path clones the stored
642    /// `PyErr`. This is the readiness surface for callers that need only the
643    /// success/error discriminant, not the value.
644    pub fn wait_completion(&self) -> impl Future<Output = PyResult<()>> + Send + 'static + use<> {
645        let ready = self.core.wait_ready();
646        async move {
647            let rx = ready.await?;
648            // Read only the success/error discriminant under a watch borrow that
649            // holds no GIL, then drop it. HDL-1: the value is `Some` and, once
650            // set, never changes.
651            let is_err = rx
652                .borrow()
653                .as_ref()
654                .expect("HDL-1: value is Some after wait_ready")
655                .is_err();
656            if !is_err {
657                return Ok(());
658            }
659            // Error path only. HDL-9: acquire the GIL first, then re-borrow the
660            // watch value (GIL-then-watch order, never the inverse); the
661            // discriminant borrow above is already dropped, so the two are never
662            // held together, and HDL-1 guarantees the same terminal `Err` is still
663            // there to clone.
664            monarch_with_gil(GilSite::Convert, move |py| -> PyResult<()> {
665                Err(rx
666                    .borrow()
667                    .as_ref()
668                    .expect("HDL-1: value is Some after wait_ready")
669                    .as_ref()
670                    .expect_err("HDL-1: error branch, discriminant already checked")
671                    .clone_ref(py))
672            })
673            .await
674        }
675    }
676}
677
678/// Run `operation` only after `ready` resolves `Ok`, returning the readiness
679/// error unchanged if it does not.
680///
681/// Generic over the result and error so the short-circuit law is unit-testable
682/// without pyo3; the RDMA bindings instantiate `E = PyErr` and this helper never
683/// inspects or remaps it. `operation` is a closure, not an already-started
684/// future, so no effect begins before readiness. The typical `ready` input is
685/// [`PyHandle::wait_completion`].
686pub async fn after_ready<T, E, RF, Op, OF>(ready: RF, operation: Op) -> Result<T, E>
687where
688    RF: Future<Output = Result<(), E>>,
689    Op: FnOnce() -> OF,
690    OF: Future<Output = Result<T, E>>,
691{
692    ready.await?;
693    operation().await
694}
695
696#[cfg(test)]
697mod after_ready_tests {
698    use std::future::Future;
699    use std::sync::Arc;
700    use std::sync::atomic::AtomicUsize;
701    use std::sync::atomic::Ordering;
702    use std::task::Context;
703    use std::task::Poll;
704    use std::task::Waker;
705
706    use super::after_ready;
707
708    #[derive(Debug, PartialEq)]
709    struct SentinelErr(&'static str);
710
711    /// After readiness resolves `Ok`, the operation closure runs exactly once
712    /// and its result is returned.
713    #[tokio::test]
714    async fn after_ready_runs_operation_once_on_success() {
715        let calls = Arc::new(AtomicUsize::new(0));
716        let c = calls.clone();
717        let out: Result<u8, SentinelErr> =
718            after_ready(async { Ok::<(), SentinelErr>(()) }, move || {
719                let c = c.clone();
720                async move {
721                    c.fetch_add(1, Ordering::SeqCst);
722                    Ok(7u8)
723                }
724            })
725            .await;
726        assert_eq!(out, Ok(7));
727        assert_eq!(calls.load(Ordering::SeqCst), 1);
728    }
729
730    /// A readiness error is returned unchanged and the operation closure is
731    /// never invoked.
732    #[tokio::test]
733    async fn after_ready_short_circuits_on_readiness_error() {
734        let calls = Arc::new(AtomicUsize::new(0));
735        let c = calls.clone();
736        let out: Result<u8, SentinelErr> = after_ready(
737            async { Err::<(), SentinelErr>(SentinelErr("boom")) },
738            move || {
739                let c = c.clone();
740                async move {
741                    c.fetch_add(1, Ordering::SeqCst);
742                    Ok(7u8)
743                }
744            },
745        )
746        .await;
747        assert_eq!(out, Err(SentinelErr("boom")));
748        assert_eq!(calls.load(Ordering::SeqCst), 0);
749    }
750
751    /// While readiness is pending the composed future cannot complete and the
752    /// operation closure is never invoked.
753    #[test]
754    fn after_ready_does_not_run_operation_while_pending() {
755        let calls = Arc::new(AtomicUsize::new(0));
756        let c = calls.clone();
757        let pending_ready = std::future::pending::<Result<(), SentinelErr>>();
758        let mut fut = Box::pin(after_ready(pending_ready, move || {
759            let c = c.clone();
760            async move {
761                c.fetch_add(1, Ordering::SeqCst);
762                Ok(7u8)
763            }
764        }));
765        let mut cx = Context::from_waker(Waker::noop());
766        assert_eq!(fut.as_mut().poll(&mut cx), Poll::Pending);
767        assert_eq!(calls.load(Ordering::SeqCst), 0);
768    }
769}
770
771#[pymethods]
772impl PyHandle {
773    /// Block the calling thread until the handle resolves and return its value.
774    ///
775    /// Behavior is keyed to the calling context, not to whether the value
776    /// happens to be ready:
777    ///   - Tokio runtime context: raise `WouldBlockRuntime` unconditionally
778    ///     (blocking there would panic the runtime); use `poll()` or
779    ///     `as_asyncio()` instead.
780    ///   - running `asyncio` loop: warns (get() is a blocking call from a loop),
781    ///     then a ready value returns via the `poll()` fast path and a pending
782    ///     one blocks. Under a warnings-as-errors filter the warning is an error.
783    ///   - sync thread: a ready value fast-paths, a pending one blocks
784    ///     (Ctrl-C-safe on main).
785    ///
786    /// `get()` is non-cancelling. On timeout it raises `TimeoutError`, leaving
787    /// the producer and every observer untouched, so a later
788    /// `poll()`/`get()`/`await` still observes completion.
789    #[pyo3(signature = (timeout = None))]
790    fn get(slf: PyRef<'_, Self>, py: Python<'_>, timeout: Option<f64>) -> PyResult<Py<PyAny>> {
791        // HDL-6: among the observers, get() is the sole WouldBlockRuntime
792        // raiser. It is the blocking API, and in a Tokio runtime context
793        // blocking would panic the runtime, so refuse unconditionally -- even
794        // a ready value -- keying the outcome to context, not producer timing.
795        if is_in_tokio_runtime() {
796            return Err(WouldBlockRuntime::new_err(
797                "get() cannot be called from a Tokio runtime context; use poll() or as_asyncio()",
798            ));
799        }
800
801        // HDL-12: validate the timeout up front, so an invalid value raises
802        // ValueError deterministically rather than only when the handle happens
803        // to be pending. Reject negative/NaN/non-finite rather than panicking in
804        // Duration::from_secs_f64.
805        let duration = timeout
806            .map(|seconds| {
807                std::time::Duration::try_from_secs_f64(seconds)
808                    .map_err(|e| PyValueError::new_err(format!("invalid timeout {seconds}: {e}")))
809            })
810            .transpose()?;
811
812        // Warn regardless of readiness: get() is a blocking call, and calling it
813        // from a running asyncio loop is the anti-pattern being flagged whether or
814        // not the value happens to be ready this time (a later call may block and
815        // freeze the loop). Under a warnings-as-errors filter this escalates to an
816        // error -- the caller's chosen strictness for the anti-pattern.
817        if pyo3_async_runtimes::get_running_loop(py).is_ok() {
818            let category = py.get_type::<PyUserWarning>();
819            PyErr::warn(
820                py,
821                &category,
822                c"Blocking get() was called from a running asyncio event loop. It is a synchronous, blocking call that can freeze the loop and deadlock; use as_asyncio() (or await) instead.",
823                1,
824            )?;
825        }
826
827        // A ready value returns without blocking, in any non-Tokio context.
828        if let Some(value) = slf.core.poll()? {
829            return Ok(value);
830        }
831
832        let wait = slf.core.wait_future();
833        // HDL-9: drop the PyRef before releasing the GIL in `signal_safe_block_on`
834        // (which blocks with the GIL released), so a concurrent mutable borrow of
835        // this handle does not throw during the blocking window.
836        drop(slf);
837
838        // HDL-12: the timeout drops only the local `wait` future, leaving the
839        // producer and the watch channel untouched, so a timed-out get() stays
840        // observable (non-cancelling).
841        match duration {
842            Some(duration) => signal_safe_block_on(py, async move {
843                tokio::time::timeout(duration, wait)
844                    .await
845                    .map_err(|_| PyTimeoutError::new_err(()))?
846            })?,
847            None => signal_safe_block_on(py, wait)?,
848        }
849    }
850
851    /// Non-blocking, non-consuming check for completion.
852    ///
853    /// Returns `None` while pending, the value on success, or raises the stored
854    /// exception. If the producer disappears before completion, raises the
855    /// producer-lifecycle `RuntimeError`. A ready value stays observable by
856    /// later observers.
857    fn poll(&self) -> PyResult<Option<Py<PyAny>>> {
858        self.core.poll()
859    }
860
861    /// Return a standard `asyncio.Future` that resolves when the handle does.
862    ///
863    /// Requires a running loop; off a loop this raises the native `RuntimeError`
864    /// from `asyncio.get_running_loop()`. A Tokio observer of the watch channel
865    /// waits for completion; it never drives a Python coroutine. It then wakes
866    /// the loop through the loop's [`LoopWake`] channel without the GIL, and the
867    /// loop thread reads the value and sets the result (HDL-17). It instead
868    /// takes the GIL and publishes via `loop.call_soon_threadsafe(...)` when the
869    /// loop's `add_reader` raises `NotImplementedError`, or when the loop cannot
870    /// be weakly referenced (HDL-21). Any other failure to install the channel
871    /// or register the future is raised.
872    ///
873    /// A Handle that already holds its outcome returns a future already
874    /// completed with it, and starts no observer (HDL-22). For a pending
875    /// Handle, the observer borrows the current watch value first, so a core
876    /// that completes before the observer starts resolves rather than hangs.
877    /// The result is set on the loop thread. A cancel can race that: a
878    /// cancelled future is left alone and the handle continues. If the loop has
879    /// closed by then, the result is never set, and nothing panics.
880    ///
881    /// Each call on a pending Handle spawns its own observer; cancelling the
882    /// returned future does not stop the observer, which exits when the handle
883    /// resolves.
884    fn as_asyncio<'py>(slf: PyRef<'_, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
885        // HDL-6: off a loop this yields the native RuntimeError, not WouldBlockRuntime.
886        let event_loop = pyo3_async_runtimes::get_running_loop(py)?;
887        let fut = event_loop.call_method0("create_future")?;
888
889        // HDL-22: `poll()` registers no waiter. A value that arrives after it
890        // is found by the observer's borrow-first wait below.
891        match slf.core.poll() {
892            Ok(Some(value)) => {
893                complete_asyncio_future(&fut, false, value)?;
894                return Ok(fut);
895            }
896            Err(err) => {
897                complete_asyncio_future(&fut, true, err.into_value(py).into_any())?;
898                return Ok(fut);
899            }
900            Ok(None) => {}
901        }
902        let wait = slf.core.wait_ready();
903
904        if let Some(channel) = LoopWake::for_loop(py, &event_loop)? {
905            let notifier = LoopWake::register(&channel, &fut, slf.into())?;
906            get_tokio_runtime().spawn(async move {
907                // HDL-17: no GIL on this task. Dropping the receiver before the
908                // pipe write means a readable pipe proves the task got past it.
909                drop(wait.await);
910                notifier.notify();
911            });
912            return Ok(fut);
913        }
914
915        // HDL-21: the fallback, for a loop without a channel.
916        let loop_handle = event_loop.clone().unbind();
917        let fut_handle = fut.clone().unbind();
918
919        get_tokio_runtime().spawn(async move {
920            let recv = wait.await;
921            let scheduled = monarch_with_gil(GilSite::Convert, move |py| {
922                // HDL-9: one GIL section that borrows the watch value under the
923                // already-held GIL (GIL-then-watch order), clones it out (or turns
924                // a dropped-producer RecvError into a PyErr), and schedules it --
925                // completion touches the GIL exactly once.
926                let result: PyResult<Py<PyAny>> = match recv {
927                    Ok(rx) => match rx
928                        .borrow()
929                        .as_ref()
930                        .expect("HDL-1: value is Some after wait_ready")
931                    {
932                        Ok(v) => Ok(v.clone_ref(py)),
933                        Err(err) => Err(err.clone_ref(py)),
934                    },
935                    // HDL-10: a dropped producer surfaces as a set_exception.
936                    Err(e) => Err(e),
937                };
938                schedule_completion(py, loop_handle.bind(py), fut_handle.bind(py), result)
939            })
940            .await;
941            if let Err(err) = scheduled {
942                tracing::warn!("as_asyncio: failed to schedule completion on the loop: {err}");
943            }
944        });
945
946        Ok(fut)
947    }
948
949    /// Implement Python's `await` protocol by delegating to `as_asyncio()`.
950    fn __await__<'py>(slf: PyRef<'_, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
951        Self::as_asyncio(slf, py)?.call_method0("__await__")
952    }
953
954    /// Support `Handle[T]` type syntax on the Python side (no runtime effect).
955    #[classmethod]
956    fn __class_getitem__(cls: &Bound<'_, PyType>, _arg: Py<PyAny>) -> Py<PyAny> {
957        cls.clone().unbind().into()
958    }
959}
960
961/// The cached `complete_asyncio_future` callable.
962///
963/// The function is stateless, so it is wrapped once and reused for every
964/// completion rather than rebuilt (and leaked) on each `call_soon_threadsafe`.
965fn cached_completer(py: Python<'_>) -> PyResult<Bound<'_, PyCFunction>> {
966    static COMPLETER: PyOnceLock<Py<PyCFunction>> = PyOnceLock::new();
967    COMPLETER
968        .get_or_try_init(py, || {
969            Ok(wrap_pyfunction!(complete_asyncio_future, py)?.unbind())
970        })
971        .map(|f| f.bind(py).clone())
972}
973
974/// Schedule the handle's completion onto `event_loop` from the Tokio observer.
975///
976/// Runs under the GIL. The caller has already cloned the success value; an error
977/// is turned into its Python exception object here, so no Rust `PyErr` crosses
978/// into the loop callback. `complete_asyncio_future` is then scheduled via
979/// `call_soon_threadsafe`, whose error (e.g. a closed loop) is returned so the
980/// caller can log rather than panic.
981fn schedule_completion(
982    py: Python<'_>,
983    event_loop: &Bound<'_, PyAny>,
984    fut: &Bound<'_, PyAny>,
985    result: PyResult<Py<PyAny>>,
986) -> PyResult<()> {
987    let completer = cached_completer(py)?;
988    let (is_exc, value) = match result {
989        Ok(v) => (false, v),
990        Err(e) => (true, e.into_value(py).into_any()),
991    };
992    event_loop.call_method1("call_soon_threadsafe", (completer, fut, is_exc, value))?;
993    Ok(())
994}
995
996#[cfg(test)]
997thread_local! {
998    /// Runs once inside `HandleCore::poll`, after it finds no value and before
999    /// it checks whether the channel is closed.
1000    static POLL_BETWEEN_CHECKS: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
1001        const { std::cell::RefCell::new(None) };
1002}
1003
1004/// Complete an `asyncio.Future` on its loop thread (HDL-7).
1005///
1006/// The future may already be settled when this runs: a cancelled future is left
1007/// alone, and an already-completed one is swallowed. Any other error propagates.
1008#[pyfunction]
1009pub(crate) fn complete_asyncio_future(
1010    fut: &Bound<'_, PyAny>,
1011    is_exc: bool,
1012    value: Py<PyAny>,
1013) -> PyResult<()> {
1014    if fut.call_method0("cancelled")?.is_truthy()? {
1015        return Ok(());
1016    }
1017    let py = fut.py();
1018    let (method, value) = if is_exc {
1019        // asyncio.Future.set_exception rejects a StopIteration (PEP 479) with a
1020        // TypeError; running as a call_soon_threadsafe callback it would be
1021        // logged and dropped, hanging the awaiter. Wrap any StopIteration
1022        // (including subclasses) in a RuntimeError -- how `raise StopIteration`
1023        // already surfaces out of a coroutine -- so the future can always settle.
1024        let value = if value.bind(py).is_instance_of::<PyStopIteration>() {
1025            PyRuntimeError::new_err("coroutine raised StopIteration")
1026                .into_value(py)
1027                .into_any()
1028        } else {
1029            value
1030        };
1031        ("set_exception", value)
1032    } else {
1033        ("set_result", value)
1034    };
1035    // HDL-7: publish only to a non-cancelled future (the guard above), and
1036    // swallow InvalidStateError from one already settled. Any other error is a
1037    // real failure and propagates.
1038    match fut.call_method1(method, (value,)) {
1039        Ok(_) => Ok(()),
1040        Err(e) => {
1041            if e.is_instance(py, &invalid_state_error(py)) {
1042                Ok(())
1043            } else {
1044                Err(e)
1045            }
1046        }
1047    }
1048}
1049
1050/// Register the permanent Handle Python bindings.
1051pub fn register_python_bindings(handle_mod: &Bound<'_, PyModule>) -> PyResult<()> {
1052    // HDL-16: this is Handle's sole class registration and Python home.
1053    handle_mod.add_class::<PyHandle>()?;
1054    handle_mod.add_class::<PyHandleCompleter>()?;
1055    handle_mod.add_function(wrap_pyfunction!(_new_handle_pair, handle_mod)?)?;
1056    let would_block = handle_mod.py().get_type::<WouldBlockRuntime>();
1057    would_block.setattr(
1058        "__module__",
1059        "monarch._rust_bindings.monarch_hyperactor.handle",
1060    )?;
1061    handle_mod.add("WouldBlockRuntime", would_block)?;
1062    Ok(())
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use std::sync::Arc;
1068    use std::sync::atomic::AtomicBool;
1069    use std::sync::atomic::Ordering;
1070
1071    use pyo3::IntoPyObjectExt;
1072    use pyo3::exceptions::PyRuntimeError;
1073    use pyo3::exceptions::PyValueError;
1074    use pyo3::types::PyModule;
1075    use pyo3::types::PyTuple;
1076
1077    use super::*;
1078    use crate::runtime::ensure_python;
1079
1080    // Build a `Handle` over a controlled, still-pending watch channel and return
1081    // the sender so the test drives completion explicitly.
1082    fn pending_handle() -> (watch::Sender<Option<PyResult<Py<PyAny>>>>, PyHandle) {
1083        let (tx, rx) = watch::channel(None);
1084        let handle = PyHandle {
1085            core: HandleCore::new(rx, None, None),
1086        };
1087        (tx, handle)
1088    }
1089
1090    fn assert_producer_ended_error(py: Python<'_>, error: &PyErr) {
1091        assert!(
1092            error.is_instance_of::<PyRuntimeError>(py),
1093            "a dropped producer should surface a RuntimeError"
1094        );
1095        assert_eq!(
1096            error.to_string(),
1097            "RuntimeError: Handle producer ended without publishing a result"
1098        );
1099    }
1100
1101    // A Python helper module providing loop drivers for the `asyncio` tests.
1102    fn loop_helper(py: Python<'_>) -> Bound<'_, PyModule> {
1103        let code = cr#"
1104import asyncio
1105import warnings
1106
1107async def _await(h):
1108    return await h
1109
1110def run_await(h):
1111    loop = asyncio.new_event_loop()
1112    try:
1113        return loop.run_until_complete(_await(h))
1114    finally:
1115        loop.close()
1116
1117async def _get_on_loop(h):
1118    with warnings.catch_warnings(record=True) as caught:
1119        warnings.simplefilter("always")
1120        value = h.get()
1121    return (value, any(issubclass(w.category, UserWarning) for w in caught))
1122
1123def run_get_on_loop(h):
1124    loop = asyncio.new_event_loop()
1125    try:
1126        return loop.run_until_complete(_get_on_loop(h))
1127    finally:
1128        loop.close()
1129
1130async def _get_on_loop_strict(h):
1131    # get() under a warnings-as-errors filter: a ready value must still return
1132    # (no-warn fast path), not raise the UserWarning as an error.
1133    with warnings.catch_warnings():
1134        warnings.simplefilter("error")
1135        return h.get()
1136
1137def run_get_on_loop_strict(h):
1138    loop = asyncio.new_event_loop()
1139    try:
1140        return loop.run_until_complete(_get_on_loop_strict(h))
1141    finally:
1142        loop.close()
1143
1144async def _cancel(h):
1145    f = h.as_asyncio()
1146    was_done = f.done()
1147    accepted = f.cancel()
1148    await asyncio.sleep(0.05)
1149    return (was_done, accepted, f.cancelled(), h.poll())
1150
1151def run_cancel(h):
1152    loop = asyncio.new_event_loop()
1153    try:
1154        return loop.run_until_complete(_cancel(h))
1155    finally:
1156        loop.close()
1157
1158async def _cancel_then_await(h):
1159    # cancel the first observer, then observe again while a live producer runs
1160    f1 = h.as_asyncio()
1161    f1.cancel()
1162    result = await h.as_asyncio()
1163    # let the cancelled observer's completion callback drain (it no-ops)
1164    await asyncio.sleep(0.05)
1165    return (f1.cancelled(), result, h.poll())
1166
1167def run_cancel_then_await(h):
1168    loop = asyncio.new_event_loop()
1169    try:
1170        return loop.run_until_complete(_cancel_then_await(h))
1171    finally:
1172        loop.close()
1173
1174async def _two(h):
1175    a = h.as_asyncio()
1176    b = h.as_asyncio()
1177    # gather returns a list; the Rust side downcasts a tuple
1178    return tuple(await asyncio.gather(a, b))
1179
1180def run_two(h):
1181    loop = asyncio.new_event_loop()
1182    try:
1183        return loop.run_until_complete(_two(h))
1184    finally:
1185        loop.close()
1186
1187class CountingLoop(asyncio.SelectorEventLoop):
1188    # Counts only the public add_reader, which the wake channel uses; the
1189    # loop's own self-pipe goes through _add_reader.
1190    def __init__(self):
1191        super().__init__()
1192        self.add_reader_calls = 0
1193
1194    def add_reader(self, fd, callback, *args):
1195        self.add_reader_calls += 1
1196        return super().add_reader(fd, callback, *args)
1197
1198def observe_ready(h):
1199    # (done when returned, the outcome or the exception, add_reader calls)
1200    loop = CountingLoop()
1201
1202    async def observe():
1203        fut = h.as_asyncio()
1204        done = fut.done()
1205        try:
1206            outcome = await fut
1207        except BaseException as e:
1208            outcome = e
1209        return (done, outcome, loop.add_reader_calls)
1210
1211    try:
1212        return loop.run_until_complete(observe())
1213    finally:
1214        loop.close()
1215
1216def ready_await_order(h):
1217    order = []
1218
1219    async def a():
1220        order.append("a1")
1221        await h
1222        order.append("a2")
1223
1224    async def b():
1225        order.append("b")
1226
1227    async def main():
1228        await asyncio.gather(a(), b())
1229
1230    loop = asyncio.new_event_loop()
1231    try:
1232        loop.run_until_complete(main())
1233    finally:
1234        loop.close()
1235    return order
1236"#;
1237        PyModule::from_code(py, code, c"handle_test_helper.py", c"handle_test_helper").unwrap()
1238    }
1239
1240    // A ready value is returned via the poll fast path.
1241    // Attests HDL-2.
1242    #[test]
1243    fn get_returns_ready_value() {
1244        ensure_python();
1245        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
1246            let value = 42i64.into_py_any(py).unwrap();
1247            let handle = PyHandle::from_value(value).unwrap();
1248            let r = Py::new(py, handle).unwrap();
1249            let out = PyHandle::get(r.borrow(py), py, None).unwrap();
1250            out.extract::<i64>(py).unwrap()
1251        });
1252        assert_eq!(got, 42, "get() should return the resolved value");
1253    }
1254
1255    // A pending handle blocks get() on a sync thread until a producer completes.
1256    // Attests HDL-1, HDL-2.
1257    #[test]
1258    fn get_blocks_then_returns() {
1259        ensure_python();
1260        let value = monarch_with_gil_blocking(GilSite::Test, |py| 7i64.into_py_any(py).unwrap());
1261        let (tx, handle) = pending_handle();
1262        get_tokio_runtime().spawn(async move {
1263            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1264            let _ = tx.send(Some(Ok(value)));
1265        });
1266        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
1267            let r = Py::new(py, handle).unwrap();
1268            let out = PyHandle::get(r.borrow(py), py, None).unwrap();
1269            out.extract::<i64>(py).unwrap()
1270        });
1271        assert_eq!(got, 7, "get() should block then return the produced value");
1272    }
1273
1274    // poll() transitions from None to the value once the producer completes.
1275    // Attests HDL-1, HDL-2.
1276    #[test]
1277    fn poll_transitions_pending_to_ready() {
1278        ensure_python();
1279        let (tx, rx) = watch::channel(None);
1280        let core = HandleCore::new(rx, None, None);
1281        assert!(
1282            core.poll().unwrap().is_none(),
1283            "poll() should be None while pending"
1284        );
1285        let value = monarch_with_gil_blocking(GilSite::Test, |py| 1i64.into_py_any(py).unwrap());
1286        tx.send(Some(Ok(value))).unwrap();
1287        let got = core.poll().unwrap();
1288        let extracted =
1289            monarch_with_gil_blocking(GilSite::Test, |py| got.unwrap().extract::<i64>(py).unwrap());
1290        assert_eq!(extracted, 1, "poll() should observe the produced value");
1291    }
1292
1293    // poll() is non-consuming: after it returns ready, get() and poll() still
1294    // observe the same value.
1295    // Attests HDL-1, HDL-3.
1296    #[test]
1297    fn repeated_observation_after_poll() {
1298        ensure_python();
1299        monarch_with_gil_blocking(GilSite::Test, |py| {
1300            let value = 5i64.into_py_any(py).unwrap();
1301            let handle = PyHandle::from_value(value).unwrap();
1302            let r = Py::new(py, handle).unwrap();
1303
1304            let first = r.borrow(py).poll().unwrap().unwrap();
1305            assert_eq!(first.extract::<i64>(py).unwrap(), 5);
1306
1307            let via_get = PyHandle::get(r.borrow(py), py, None).unwrap();
1308            assert_eq!(via_get.extract::<i64>(py).unwrap(), 5);
1309
1310            let again = r.borrow(py).poll().unwrap().unwrap();
1311            assert_eq!(again.extract::<i64>(py).unwrap(), 5);
1312        });
1313    }
1314
1315    // Two independent observers of one still-pending core both resolve; cloning
1316    // the receiver never consumes it.
1317    // Attests HDL-1, HDL-3.
1318    #[test]
1319    fn multiple_observers_resolve() {
1320        ensure_python();
1321        let (tx, rx) = watch::channel(None);
1322        let core = HandleCore::new(rx, None, None);
1323        let f1 = core.wait_future();
1324        let f2 = core.wait_future();
1325        let value = monarch_with_gil_blocking(GilSite::Test, |py| 3i64.into_py_any(py).unwrap());
1326        tx.send(Some(Ok(value))).unwrap();
1327        let (r1, r2) = get_tokio_runtime().block_on(async move { tokio::join!(f1, f2) });
1328        monarch_with_gil_blocking(GilSite::Test, |py| {
1329            assert_eq!(r1.unwrap().extract::<i64>(py).unwrap(), 3);
1330            assert_eq!(r2.unwrap().extract::<i64>(py).unwrap(), 3);
1331        });
1332    }
1333
1334    // A still-pending get() in a Tokio runtime context raises WouldBlockRuntime.
1335    // Attests HDL-6.
1336    #[test]
1337    fn get_pending_in_tokio_raises_would_block() {
1338        ensure_python();
1339        let (_tx, handle) = pending_handle();
1340        get_tokio_runtime().block_on(async {
1341            monarch_with_gil(GilSite::Test, |py| {
1342                let r = Py::new(py, handle).unwrap();
1343                let err = PyHandle::get(r.borrow(py), py, None).unwrap_err();
1344                assert!(
1345                    err.is_instance_of::<WouldBlockRuntime>(py),
1346                    "pending get() in a tokio context should raise WouldBlockRuntime"
1347                );
1348            })
1349            .await
1350        });
1351    }
1352
1353    // A ready value in a Tokio runtime context still raises: get() is refused by
1354    // context, not by whether the value happens to be available (HDL-6).
1355    // Attests HDL-6.
1356    #[test]
1357    fn get_ready_in_tokio_also_raises() {
1358        ensure_python();
1359        get_tokio_runtime().block_on(async {
1360            monarch_with_gil(GilSite::Test, |py| {
1361                let value = 9i64.into_py_any(py).unwrap();
1362                let handle = PyHandle::from_value(value).unwrap();
1363                let r = Py::new(py, handle).unwrap();
1364                let err = PyHandle::get(r.borrow(py), py, None).unwrap_err();
1365                assert!(
1366                    err.is_instance_of::<WouldBlockRuntime>(py),
1367                    "get() in a tokio context must raise even for a ready value"
1368                );
1369            })
1370            .await
1371        });
1372    }
1373
1374    // __await__ in a Tokio runtime context finds no asyncio loop to bridge to,
1375    // so it surfaces asyncio's native "no running event loop" RuntimeError --
1376    // NOT WouldBlockRuntime, which among the observers stays specific to get()
1377    // (HDL-6). The refusal consumes nothing: the same handle still observes its
1378    // value afterwards. Lives here rather than in the Python suite so the Handle
1379    // contract does not depend on a PythonTask-driven test helper.
1380    // Attests HDL-6, HDL-7.
1381    #[test]
1382    fn await_in_tokio_raises_native_and_leaves_handle_observable() {
1383        ensure_python();
1384        let handle = monarch_with_gil_blocking(GilSite::Test, |py| {
1385            let value = 11i64.into_py_any(py).unwrap();
1386            Py::new(py, PyHandle::from_value(value).unwrap()).unwrap()
1387        });
1388        let in_tokio = monarch_with_gil_blocking(GilSite::Test, |py| handle.clone_ref(py));
1389
1390        // `block_on` would poll the root future on *this* thread under an
1391        // entered runtime -- `is_in_tokio_runtime()` is true there, but it is not a
1392        // worker and nested blocking is tolerated. Spawn instead, so the
1393        // assertion genuinely runs on a runtime worker, and join from here.
1394        let joined = get_tokio_runtime().spawn(async move {
1395            assert!(
1396                is_in_tokio_runtime(),
1397                "the oracle must observe from a runtime worker"
1398            );
1399            monarch_with_gil(GilSite::Test, |py| {
1400                let err = PyHandle::__await__(in_tokio.borrow(py), py).unwrap_err();
1401                assert!(
1402                    !err.is_instance_of::<WouldBlockRuntime>(py),
1403                    "__await__ must not raise WouldBlockRuntime; among the observers that is get()'s alone"
1404                );
1405                assert!(
1406                    err.is_instance_of::<PyRuntimeError>(py),
1407                    "__await__ off an asyncio loop should raise the native RuntimeError"
1408                );
1409                assert!(
1410                    err.to_string().contains("no running event loop"),
1411                    "expected asyncio's native message, got {err}"
1412                );
1413            })
1414            .await
1415        });
1416        get_tokio_runtime()
1417            .block_on(joined)
1418            .expect("the spawned oracle panicked");
1419
1420        monarch_with_gil_blocking(GilSite::Test, |py| {
1421            let value = PyHandle::get(handle.borrow(py), py, None).unwrap();
1422            assert_eq!(
1423                value.extract::<i64>(py).unwrap(),
1424                11,
1425                "a refused __await__ must not consume the handle"
1426            );
1427        });
1428    }
1429
1430    // get(timeout) raises TimeoutError and leaves the handle pending, so a later
1431    // observation still sees completion.
1432    // Attests HDL-12.
1433    #[test]
1434    fn get_timeout_raises_and_leaves_pending() {
1435        ensure_python();
1436        let (tx, handle) = pending_handle();
1437        let r = monarch_with_gil_blocking(GilSite::Test, |py| Py::new(py, handle).unwrap());
1438
1439        let is_timeout = monarch_with_gil_blocking(GilSite::Test, |py| {
1440            let err = PyHandle::get(r.borrow(py), py, Some(0.05)).unwrap_err();
1441            err.is_instance_of::<PyTimeoutError>(py)
1442        });
1443        assert!(is_timeout, "get(timeout) should raise TimeoutError");
1444
1445        let still_pending =
1446            monarch_with_gil_blocking(GilSite::Test, |py| r.borrow(py).poll().unwrap().is_none());
1447        assert!(
1448            still_pending,
1449            "a timed-out get() must not resolve the handle"
1450        );
1451
1452        let value = monarch_with_gil_blocking(GilSite::Test, |py| 4i64.into_py_any(py).unwrap());
1453        tx.send(Some(Ok(value))).unwrap();
1454        let observed = monarch_with_gil_blocking(GilSite::Test, |py| {
1455            r.borrow(py)
1456                .poll()
1457                .unwrap()
1458                .unwrap()
1459                .extract::<i64>(py)
1460                .unwrap()
1461        });
1462        assert_eq!(
1463            observed, 4,
1464            "completion is still observable after a timeout"
1465        );
1466    }
1467
1468    // as_asyncio() off a loop raises the native RuntimeError, not
1469    // WouldBlockRuntime.
1470    // Attests HDL-6.
1471    #[test]
1472    fn as_asyncio_off_loop_raises_runtime_error() {
1473        ensure_python();
1474        monarch_with_gil_blocking(GilSite::Test, |py| {
1475            let handle = PyHandle::from_value(py.None()).unwrap();
1476            let r = Py::new(py, handle).unwrap();
1477            let err = PyHandle::as_asyncio(r.borrow(py), py).unwrap_err();
1478            assert!(
1479                err.is_instance_of::<PyRuntimeError>(py),
1480                "off a loop as_asyncio() should raise RuntimeError"
1481            );
1482            assert!(
1483                !err.is_instance_of::<WouldBlockRuntime>(py),
1484                "the off-loop error must be the native RuntimeError, not WouldBlockRuntime"
1485            );
1486        });
1487    }
1488
1489    // __await__ off a loop surfaces the native RuntimeError from as_asyncio().
1490    // Attests HDL-6.
1491    #[test]
1492    fn await_off_loop_raises_runtime_error() {
1493        ensure_python();
1494        monarch_with_gil_blocking(GilSite::Test, |py| {
1495            let handle = PyHandle::from_value(py.None()).unwrap();
1496            let r = Py::new(py, handle).unwrap();
1497            let err = PyHandle::__await__(r.borrow(py), py).unwrap_err();
1498            assert!(
1499                err.is_instance_of::<PyRuntimeError>(py),
1500                "off a loop __await__ should raise RuntimeError"
1501            );
1502            assert!(
1503                !err.is_instance_of::<WouldBlockRuntime>(py),
1504                "__await__ must not raise WouldBlockRuntime"
1505            );
1506        });
1507    }
1508
1509    // await on a pre-resolved handle resolves on a real loop and yields the
1510    // value.
1511    // Attests HDL-2, HDL-7.
1512    #[test]
1513    fn await_resolves_ok_on_loop() {
1514        ensure_python();
1515        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
1516            let value = 42i64.into_py_any(py).unwrap();
1517            let handle = PyHandle::from_value(value).unwrap();
1518            let h = Py::new(py, handle).unwrap();
1519            let helper = loop_helper(py);
1520            helper
1521                .getattr("run_await")
1522                .unwrap()
1523                .call1((h,))
1524                .unwrap()
1525                .extract::<i64>()
1526                .unwrap()
1527        });
1528        assert_eq!(got, 42, "await should resolve to the value on a real loop");
1529    }
1530
1531    // await surfaces an error sent while it waits as a Python exception on a
1532    // real loop.
1533    // Attests HDL-2, HDL-7.
1534    #[test]
1535    fn await_resolves_err_on_loop() {
1536        ensure_python();
1537        let (tx, handle) = pending_handle();
1538        let waited = Arc::new(AtomicBool::new(false));
1539        let waited_in_producer = Arc::clone(&waited);
1540        get_tokio_runtime().spawn(async move {
1541            // The Handle holds one receiver and `wait_ready()` clones a second,
1542            // so the error is sent only once the observer is on the pending path.
1543            let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1544            while tx.receiver_count() < 2 && tokio::time::Instant::now() < deadline {
1545                tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1546            }
1547            waited_in_producer.store(tx.receiver_count() >= 2, Ordering::SeqCst);
1548            let _ = tx.send(Some(Err(PyValueError::new_err("boom"))));
1549        });
1550        let is_value_error = monarch_with_gil_blocking(GilSite::Test, |py| {
1551            let h = Py::new(py, handle).unwrap();
1552            let helper = loop_helper(py);
1553            let err = helper
1554                .getattr("run_await")
1555                .unwrap()
1556                .call1((h,))
1557                .unwrap_err();
1558            err.is_instance_of::<PyValueError>(py)
1559        });
1560        assert!(
1561            waited.load(Ordering::SeqCst),
1562            "the error should be sent only after the observer waited"
1563        );
1564        assert!(is_value_error, "await should raise the stored exception");
1565    }
1566
1567    // await resolves a still-pending handle once a producer completes it while
1568    // the loop runs.
1569    // Attests HDL-1, HDL-7.
1570    #[test]
1571    fn await_resolves_pending_on_loop() {
1572        ensure_python();
1573        let value = monarch_with_gil_blocking(GilSite::Test, |py| 11i64.into_py_any(py).unwrap());
1574        let (tx, handle) = pending_handle();
1575        get_tokio_runtime().spawn(async move {
1576            tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1577            let _ = tx.send(Some(Ok(value)));
1578        });
1579        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
1580            let h = Py::new(py, handle).unwrap();
1581            let helper = loop_helper(py);
1582            helper
1583                .getattr("run_await")
1584                .unwrap()
1585                .call1((h,))
1586                .unwrap()
1587                .extract::<i64>()
1588                .unwrap()
1589        });
1590        assert_eq!(got, 11, "await should resolve once the producer completes");
1591    }
1592
1593    // Cancelling an as_asyncio() future does not cancel the handle. Here the
1594    // handle is pre-resolved, so its observer is already done: cancel() is
1595    // refused and the value stays observable via poll(). The live-producer
1596    // case -- one observer's cancel leaves the producer running and other
1597    // observers still resolving -- is
1598    // `cancel_asyncio_future_does_not_stop_producer_or_observers`.
1599    // Attests HDL-3, HDL-22.
1600    #[test]
1601    fn cancel_asyncio_future_does_not_cancel_handle() {
1602        ensure_python();
1603        let (was_done, accepted, cancelled, handle_resolved) =
1604            monarch_with_gil_blocking(GilSite::Test, |py| -> (bool, bool, bool, bool) {
1605                let value = 42i64.into_py_any(py).unwrap();
1606                let handle = PyHandle::from_value(value).unwrap();
1607                let h = Py::new(py, handle).unwrap();
1608                let helper = loop_helper(py);
1609                let res = helper.getattr("run_cancel").unwrap().call1((h,)).unwrap();
1610                let tup = res.cast::<PyTuple>().unwrap();
1611                let flag = |i| tup.get_item(i).unwrap().extract::<bool>().unwrap();
1612                (
1613                    flag(0),
1614                    flag(1),
1615                    flag(2),
1616                    !tup.get_item(3).unwrap().is_none(),
1617                )
1618            });
1619        assert!(was_done, "a ready handle's observer should already be done");
1620        assert!(!accepted, "cancel() on a done observer should return false");
1621        assert!(!cancelled, "a done observer should not become cancelled");
1622        assert!(
1623            handle_resolved,
1624            "cancelling the future must not cancel the handle"
1625        );
1626    }
1627
1628    // Run `observe_ready` on `h`: whether the observer was done when returned,
1629    // its outcome (a value or an exception object), and the add_reader calls.
1630    fn observe_ready(py: Python<'_>, h: Py<PyHandle>) -> (bool, Bound<'_, PyAny>, i64) {
1631        let res = loop_helper(py)
1632            .getattr("observe_ready")
1633            .unwrap()
1634            .call1((h,))
1635            .unwrap();
1636        let tup = res.cast::<PyTuple>().unwrap();
1637        (
1638            tup.get_item(0).unwrap().extract().unwrap(),
1639            tup.get_item(1).unwrap(),
1640            tup.get_item(2).unwrap().extract().unwrap(),
1641        )
1642    }
1643
1644    fn errored_handle(err: PyErr) -> PyHandle {
1645        let (tx, rx) = watch::channel(None);
1646        tx.send(Some(Err(err))).unwrap();
1647        PyHandle {
1648            core: HandleCore::new(rx, None, None),
1649        }
1650    }
1651
1652    // A ready value comes back in an already-done observer, and no wake channel
1653    // is installed.
1654    // Attests HDL-22.
1655    #[test]
1656    fn as_asyncio_ready_value_is_done_without_wake() {
1657        ensure_python();
1658        monarch_with_gil_blocking(GilSite::Test, |py| {
1659            let handle = PyHandle::from_value(42i64.into_py_any(py).unwrap()).unwrap();
1660            let (done, outcome, add_reader_calls) = observe_ready(py, Py::new(py, handle).unwrap());
1661            assert!(done, "a ready handle's observer should already be done");
1662            assert_eq!(outcome.extract::<i64>().unwrap(), 42);
1663            assert_eq!(add_reader_calls, 0, "no wake channel should be installed");
1664        });
1665    }
1666
1667    // A ready error comes back in an already-done observer; a stored
1668    // StopIteration is wrapped in RuntimeError as on the pending path.
1669    // Attests HDL-7, HDL-22.
1670    #[test]
1671    fn as_asyncio_ready_error_is_done_without_wake() {
1672        ensure_python();
1673        monarch_with_gil_blocking(GilSite::Test, |py| {
1674            let h = Py::new(py, errored_handle(PyValueError::new_err("boom"))).unwrap();
1675            let (done, outcome, add_reader_calls) = observe_ready(py, h);
1676            assert!(done, "a ready handle's observer should already be done");
1677            assert!(outcome.is_instance_of::<PyValueError>());
1678            assert_eq!(add_reader_calls, 0, "no wake channel should be installed");
1679
1680            let h = Py::new(py, errored_handle(PyStopIteration::new_err(()))).unwrap();
1681            let (done, outcome, _) = observe_ready(py, h);
1682            assert!(done);
1683            assert!(
1684                outcome.is_instance_of::<PyRuntimeError>()
1685                    && !outcome.is_instance_of::<PyStopIteration>(),
1686                "a stored StopIteration should surface as RuntimeError"
1687            );
1688        });
1689    }
1690
1691    // A producer that ended without publishing is a ready outcome too.
1692    // Attests HDL-10, HDL-22.
1693    #[test]
1694    fn as_asyncio_producer_ended_is_done_without_wake() {
1695        ensure_python();
1696        let (tx, handle) = pending_handle();
1697        drop(tx);
1698        monarch_with_gil_blocking(GilSite::Test, |py| {
1699            let (done, outcome, add_reader_calls) = observe_ready(py, Py::new(py, handle).unwrap());
1700            assert!(done, "a ready handle's observer should already be done");
1701            assert_producer_ended_error(py, &PyErr::from_value(outcome));
1702            assert_eq!(add_reader_calls, 0, "no wake channel should be installed");
1703        });
1704    }
1705
1706    // A producer that publishes and drops its sender between poll()'s empty
1707    // read and its closed check: the observer gets what was published, not
1708    // producer loss.
1709    // Attests HDL-10, HDL-22.
1710    #[test]
1711    fn as_asyncio_reads_an_outcome_sent_just_before_close() {
1712        ensure_python();
1713        monarch_with_gil_blocking(GilSite::Test, |py| {
1714            let outcomes = [
1715                Ok(42i64.into_py_any(py).unwrap()),
1716                Err(PyValueError::new_err("boom")),
1717            ];
1718            for outcome in outcomes {
1719                let expect_value = outcome.is_ok();
1720                let (tx, handle) = pending_handle();
1721                POLL_BETWEEN_CHECKS.with(|hook| {
1722                    *hook.borrow_mut() = Some(Box::new(move || {
1723                        tx.send(Some(outcome)).unwrap();
1724                        drop(tx);
1725                    }));
1726                });
1727                let (done, got, _) = observe_ready(py, Py::new(py, handle).unwrap());
1728                assert!(done, "the published outcome is ready");
1729                if expect_value {
1730                    assert_eq!(got.extract::<i64>().unwrap(), 42);
1731                } else {
1732                    assert!(
1733                        got.is_instance_of::<PyValueError>(),
1734                        "the stored exception should surface, not producer loss: {got}"
1735                    );
1736                }
1737            }
1738        });
1739    }
1740
1741    // Awaiting a ready handle does not give the loop a turn: the awaiting task
1742    // runs on before a task scheduled after it, so the order is a1, a2, b.
1743    // Attests HDL-22.
1744    #[test]
1745    fn await_ready_handle_takes_no_loop_turn() {
1746        ensure_python();
1747        let order = monarch_with_gil_blocking(GilSite::Test, |py| -> Vec<String> {
1748            let handle = PyHandle::from_value(1i64.into_py_any(py).unwrap()).unwrap();
1749            loop_helper(py)
1750                .getattr("ready_await_order")
1751                .unwrap()
1752                .call1((Py::new(py, handle).unwrap(),))
1753                .unwrap()
1754                .extract()
1755                .unwrap()
1756        });
1757        assert_eq!(order, ["a1", "a2", "b"]);
1758    }
1759
1760    // The live-producer companion to `cancel_asyncio_future_does_not_cancel_handle`:
1761    // with a still-pending handle and a real producer, cancelling one observer
1762    // leaves the producer running and other observers still resolving. A second
1763    // as_asyncio() future resolves to the value and the handle stays pollable; the
1764    // cancelled observer still runs to completion, and the `complete_asyncio_future`
1765    // it posts to the loop no-ops on the cancelled future.
1766    // Attests HDL-3, HDL-7.
1767    #[test]
1768    fn cancel_asyncio_future_does_not_stop_producer_or_observers() {
1769        ensure_python();
1770        let value = monarch_with_gil_blocking(GilSite::Test, |py| 42i64.into_py_any(py).unwrap());
1771        let (tx, handle) = pending_handle();
1772        let both_waited = Arc::new(AtomicBool::new(false));
1773        let both_waited_in_producer = Arc::clone(&both_waited);
1774        get_tokio_runtime().spawn(async move {
1775            // Publish only once both observers wait: the Handle holds one
1776            // receiver and each pending `as_asyncio()` clones another. A value
1777            // published first would leave the first observer already done, and
1778            // its cancel refused (HDL-22).
1779            let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1780            while tx.receiver_count() < 3 && tokio::time::Instant::now() < deadline {
1781                tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1782            }
1783            both_waited_in_producer.store(tx.receiver_count() >= 3, Ordering::SeqCst);
1784            let _ = tx.send(Some(Ok(value)));
1785        });
1786        let (cancelled, awaited, polled) =
1787            monarch_with_gil_blocking(GilSite::Test, |py| -> (bool, i64, i64) {
1788                let h = Py::new(py, handle).unwrap();
1789                let helper = loop_helper(py);
1790                let res = helper
1791                    .getattr("run_cancel_then_await")
1792                    .unwrap()
1793                    .call1((h,))
1794                    .unwrap();
1795                let tup = res.cast::<PyTuple>().unwrap();
1796                (
1797                    tup.get_item(0).unwrap().extract::<bool>().unwrap(),
1798                    tup.get_item(1).unwrap().extract::<i64>().unwrap(),
1799                    tup.get_item(2).unwrap().extract::<i64>().unwrap(),
1800                )
1801            });
1802        assert!(
1803            both_waited.load(Ordering::SeqCst),
1804            "the producer should publish only after both observers waited"
1805        );
1806        assert!(cancelled, "the cancelled future should stay cancelled");
1807        assert_eq!(
1808            awaited, 42,
1809            "a second observer resolves even after the first future is cancelled"
1810        );
1811        assert_eq!(
1812            polled, 42,
1813            "the handle stays observable after one observer is cancelled"
1814        );
1815    }
1816
1817    // Two as_asyncio() futures waiting on one handle both resolve on a real
1818    // loop.
1819    // Attests HDL-3, HDL-7.
1820    #[test]
1821    fn two_asyncio_observers_resolve_on_loop() {
1822        ensure_python();
1823        let value = monarch_with_gil_blocking(GilSite::Test, |py| 8i64.into_py_any(py).unwrap());
1824        let (tx, handle) = pending_handle();
1825        let both_waited = Arc::new(AtomicBool::new(false));
1826        let both_waited_in_producer = Arc::clone(&both_waited);
1827        get_tokio_runtime().spawn(async move {
1828            // The Handle holds one receiver and each pending `as_asyncio()`
1829            // clones another.
1830            let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1831            while tx.receiver_count() < 3 && tokio::time::Instant::now() < deadline {
1832                tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1833            }
1834            both_waited_in_producer.store(tx.receiver_count() >= 3, Ordering::SeqCst);
1835            let _ = tx.send(Some(Ok(value)));
1836        });
1837        let (a, b) = monarch_with_gil_blocking(GilSite::Test, |py| -> (i64, i64) {
1838            let h = Py::new(py, handle).unwrap();
1839            let helper = loop_helper(py);
1840            let res = helper.getattr("run_two").unwrap().call1((h,)).unwrap();
1841            let tup = res.cast::<PyTuple>().unwrap();
1842            (
1843                tup.get_item(0).unwrap().extract::<i64>().unwrap(),
1844                tup.get_item(1).unwrap().extract::<i64>().unwrap(),
1845            )
1846        });
1847        assert!(
1848            both_waited.load(Ordering::SeqCst),
1849            "the value should be sent only after both observers waited"
1850        );
1851        assert_eq!((a, b), (8, 8), "both observers should resolve to the value");
1852    }
1853
1854    // Scheduling completion onto a closed loop returns an error rather than
1855    // panicking; the observer swallows this and logs.
1856    // Attests HDL-7.
1857    #[test]
1858    fn schedule_on_closed_loop_errs_not_panics() {
1859        ensure_python();
1860        monarch_with_gil_blocking(GilSite::Test, |py| {
1861            let asyncio = py.import("asyncio").unwrap();
1862            let event_loop = asyncio.call_method0("new_event_loop").unwrap();
1863            let fut = event_loop.call_method0("create_future").unwrap();
1864            event_loop.call_method0("close").unwrap();
1865            let result = schedule_completion(py, &event_loop, &fut, Ok(py.None()));
1866            assert!(
1867                result.is_err(),
1868                "call_soon_threadsafe on a closed loop should error, not panic"
1869            );
1870        });
1871    }
1872
1873    // A negative, NaN, or non-finite timeout is rejected with ValueError rather
1874    // than panicking in `Duration::from_secs_f64`.
1875    // Attests HDL-12.
1876    #[test]
1877    fn get_invalid_timeout_raises_value_error() {
1878        ensure_python();
1879        let (_tx, handle) = pending_handle();
1880        let r = monarch_with_gil_blocking(GilSite::Test, |py| Py::new(py, handle).unwrap());
1881        monarch_with_gil_blocking(GilSite::Test, |py| {
1882            for bad in [-1.0, f64::NAN, f64::INFINITY] {
1883                let err = PyHandle::get(r.borrow(py), py, Some(bad)).unwrap_err();
1884                assert!(
1885                    err.is_instance_of::<PyValueError>(py),
1886                    "get(timeout={bad}) should raise ValueError, not panic"
1887                );
1888            }
1889        });
1890    }
1891
1892    // On a running asyncio loop, get() warns (UserWarning) regardless of whether
1893    // the value is ready -- calling the blocking get() from a loop is the
1894    // anti-pattern being flagged. A ready value still returns after warning.
1895    // Attests HDL-8.
1896    #[test]
1897    fn get_on_asyncio_loop_warns() {
1898        ensure_python();
1899        let (value, warned) = monarch_with_gil_blocking(GilSite::Test, |py| -> (i64, bool) {
1900            let v = 5i64.into_py_any(py).unwrap();
1901            let handle = PyHandle::from_value(v).unwrap();
1902            let h = Py::new(py, handle).unwrap();
1903            let helper = loop_helper(py);
1904            let res = helper
1905                .getattr("run_get_on_loop")
1906                .unwrap()
1907                .call1((h,))
1908                .unwrap();
1909            let tup = res.cast::<PyTuple>().unwrap();
1910            (
1911                tup.get_item(0).unwrap().extract::<i64>().unwrap(),
1912                tup.get_item(1).unwrap().extract::<bool>().unwrap(),
1913            )
1914        });
1915        assert_eq!(value, 5, "a ready value still returns after warning");
1916        assert!(
1917            warned,
1918            "get() on a running asyncio loop warns regardless of readiness"
1919        );
1920    }
1921
1922    // A producer error surfaces through the direct read paths: poll() returns it
1923    // and get() raises it (the primary sync API path, previously observed only
1924    // through await/as_asyncio).
1925    // Attests HDL-2.
1926    #[test]
1927    fn poll_and_get_surface_producer_error() {
1928        ensure_python();
1929        let (tx, handle) = pending_handle();
1930        tx.send(Some(Err(PyValueError::new_err("boom")))).unwrap();
1931        monarch_with_gil_blocking(GilSite::Test, |py| {
1932            let r = Py::new(py, handle).unwrap();
1933            let poll_err = r.borrow(py).poll().unwrap_err();
1934            assert!(
1935                poll_err.is_instance_of::<PyValueError>(py),
1936                "poll() should surface the producer error"
1937            );
1938            let get_err = PyHandle::get(r.borrow(py), py, None).unwrap_err();
1939            assert!(
1940                get_err.is_instance_of::<PyValueError>(py),
1941                "get() should raise the producer error"
1942            );
1943        });
1944    }
1945
1946    // A producer that drops its sender without sending surfaces a Python error
1947    // through wait_future (RecvError) rather than hanging; this path also backs
1948    // get()/PyShared.
1949    // Attests HDL-10.
1950    #[test]
1951    fn dropped_producer_surfaces_error() {
1952        ensure_python();
1953        let (tx, handle) = pending_handle();
1954        drop(tx);
1955        monarch_with_gil_blocking(GilSite::Test, |py| {
1956            let r = Py::new(py, handle).unwrap();
1957            let poll_err = r.borrow(py).poll().unwrap_err();
1958            assert_producer_ended_error(py, &poll_err);
1959            let err = PyHandle::get(r.borrow(py), py, None).unwrap_err();
1960            assert_producer_ended_error(py, &err);
1961        });
1962    }
1963
1964    // A producer that publishes and drops its sender between poll()'s empty
1965    // read and its closed check: poll() and get(), whose ready fast path is
1966    // poll(), return what was published, not producer loss.
1967    // Attests HDL-2, HDL-10.
1968    #[test]
1969    fn poll_and_get_read_an_outcome_sent_just_before_close() {
1970        ensure_python();
1971        monarch_with_gil_blocking(GilSite::Test, |py| {
1972            let publish_then_close = |outcome: PyResult<Py<PyAny>>| {
1973                let (tx, handle) = pending_handle();
1974                POLL_BETWEEN_CHECKS.with(|hook| {
1975                    *hook.borrow_mut() = Some(Box::new(move || {
1976                        tx.send(Some(outcome)).unwrap();
1977                        drop(tx);
1978                    }));
1979                });
1980                Py::new(py, handle).unwrap()
1981            };
1982
1983            let h = publish_then_close(Ok(42i64.into_py_any(py).unwrap()));
1984            let polled = h.borrow(py).poll().unwrap().expect("the published value");
1985            assert_eq!(polled.extract::<i64>(py).unwrap(), 42);
1986
1987            let h = publish_then_close(Err(PyValueError::new_err("boom")));
1988            let err = h.borrow(py).poll().unwrap_err();
1989            assert!(
1990                err.is_instance_of::<PyValueError>(py),
1991                "poll() should surface the stored exception, not producer loss: {err}"
1992            );
1993
1994            let h = publish_then_close(Ok(7i64.into_py_any(py).unwrap()));
1995            let got = PyHandle::get(h.borrow(py), py, None).unwrap();
1996            assert_eq!(got.extract::<i64>(py).unwrap(), 7);
1997
1998            let h = publish_then_close(Err(PyValueError::new_err("boom")));
1999            let err = PyHandle::get(h.borrow(py), py, None).unwrap_err();
2000            assert!(
2001                err.is_instance_of::<PyValueError>(py),
2002                "get() should raise the stored exception, not producer loss: {err}"
2003            );
2004        });
2005    }
2006
2007    // The blocking future path reports producer loss even without poll()/get().
2008    // Attests HDL-10.
2009    #[test]
2010    fn wait_future_dropped_producer_surfaces_runtime_error() {
2011        ensure_python();
2012        let (tx, handle) = pending_handle();
2013        drop(tx);
2014
2015        let err = get_tokio_runtime()
2016            .block_on(handle.core.wait_future())
2017            .unwrap_err();
2018        monarch_with_gil_blocking(GilSite::Test, |py| {
2019            assert_producer_ended_error(py, &err);
2020        });
2021    }
2022
2023    // The Rust-only completion observer reports the same producer-lifecycle
2024    // failure as Python's synchronous and asyncio observers.
2025    // Attests HDL-10.
2026    #[test]
2027    fn wait_completion_dropped_producer_surfaces_runtime_error() {
2028        ensure_python();
2029        let (tx, handle) = pending_handle();
2030        drop(tx);
2031
2032        let err = get_tokio_runtime()
2033            .block_on(handle.wait_completion())
2034            .unwrap_err();
2035        monarch_with_gil_blocking(GilSite::Test, |py| {
2036            assert_producer_ended_error(py, &err);
2037        });
2038    }
2039
2040    // complete_asyncio_future swallows InvalidStateError from an already-settled
2041    // (non-cancelled) future, and propagates any other setter error.
2042    // Attests HDL-7.
2043    #[test]
2044    fn complete_asyncio_future_swallows_and_propagates() {
2045        ensure_python();
2046        monarch_with_gil_blocking(GilSite::Test, |py| {
2047            let event_loop = py
2048                .import("asyncio")
2049                .unwrap()
2050                .call_method0("new_event_loop")
2051                .unwrap();
2052            let settled = event_loop.call_method0("create_future").unwrap();
2053            settled.call_method1("set_result", (1i64,)).unwrap();
2054            assert!(
2055                complete_asyncio_future(&settled, false, py.None()).is_ok(),
2056                "InvalidStateError from a settled future should be swallowed"
2057            );
2058            event_loop.call_method0("close").unwrap();
2059
2060            // A fake future whose setter raises a non-InvalidStateError: it must
2061            // propagate rather than be swallowed.
2062            let helper = PyModule::from_code(
2063                py,
2064                cr#"
2065class RaisingFuture:
2066    def cancelled(self):
2067        return False
2068
2069    def set_result(self, value):
2070        raise ValueError("nope")
2071"#,
2072                c"raising_future.py",
2073                c"raising_future",
2074            )
2075            .unwrap();
2076            let fake = helper.getattr("RaisingFuture").unwrap().call0().unwrap();
2077            let err = complete_asyncio_future(&fake, false, py.None()).unwrap_err();
2078            assert!(
2079                err.is_instance_of::<PyValueError>(py),
2080                "a non-InvalidStateError setter error should propagate"
2081            );
2082        });
2083    }
2084
2085    // get(timeout) whose producer resolves before the deadline returns the value
2086    // (the Ok branch of tokio::time::timeout, distinct from the timeout-fires and
2087    // bad-float cases).
2088    // Attests HDL-2, HDL-12.
2089    #[test]
2090    fn get_timeout_returns_value_before_deadline() {
2091        ensure_python();
2092        let value = monarch_with_gil_blocking(GilSite::Test, |py| 13i64.into_py_any(py).unwrap());
2093        let (tx, handle) = pending_handle();
2094        get_tokio_runtime().spawn(async move {
2095            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2096            let _ = tx.send(Some(Ok(value)));
2097        });
2098        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
2099            let r = Py::new(py, handle).unwrap();
2100            let out = PyHandle::get(r.borrow(py), py, Some(5.0)).unwrap();
2101            out.extract::<i64>(py).unwrap()
2102        });
2103        assert_eq!(got, 13, "a generous timeout returns the produced value");
2104    }
2105
2106    // Under a warnings-as-errors filter, a get() on a running loop escalates the
2107    // anti-pattern warning to an error -- even for a ready value -- rather than
2108    // silently returning. Warn-regardless means the strict caller sees it.
2109    // Attests HDL-8.
2110    #[test]
2111    fn get_on_loop_under_warnings_as_errors_raises() {
2112        ensure_python();
2113        let raised = monarch_with_gil_blocking(GilSite::Test, |py| -> bool {
2114            let v = 5i64.into_py_any(py).unwrap();
2115            let handle = PyHandle::from_value(v).unwrap();
2116            let h = Py::new(py, handle).unwrap();
2117            let helper = loop_helper(py);
2118            helper
2119                .getattr("run_get_on_loop_strict")
2120                .unwrap()
2121                .call1((h,))
2122                .is_err()
2123        });
2124        assert!(
2125            raised,
2126            "get() on a loop under warnings-as-errors should raise the escalated warning"
2127        );
2128    }
2129
2130    // The timeout is validated before the ready fast path, so an invalid timeout
2131    // raises ValueError even when the handle is already resolved (where get()
2132    // would otherwise return without blocking).
2133    // Attests HDL-12.
2134    #[test]
2135    fn get_invalid_timeout_on_ready_raises() {
2136        ensure_python();
2137        monarch_with_gil_blocking(GilSite::Test, |py| {
2138            let value = 7i64.into_py_any(py).unwrap();
2139            let handle = PyHandle::from_value(value).unwrap();
2140            let r = Py::new(py, handle).unwrap();
2141            let err = PyHandle::get(r.borrow(py), py, Some(f64::NAN)).unwrap_err();
2142            assert!(
2143                err.is_instance_of::<PyValueError>(py),
2144                "an invalid timeout must raise ValueError even for a ready handle"
2145            );
2146        });
2147    }
2148
2149    // A producer that drops its sender without sending, once the observer is
2150    // waiting, surfaces through as_asyncio/await (wait_ready's RecvError ->
2151    // set_exception) as a raised exception, not a hang -- the async mirror of
2152    // dropped_producer_surfaces_error. The already-ended case is
2153    // `as_asyncio_producer_ended_is_done_without_wake`.
2154    // Attests HDL-10.
2155    #[test]
2156    fn as_asyncio_dropped_producer_raises_on_loop() {
2157        ensure_python();
2158        let (tx, handle) = pending_handle();
2159        let observed = Arc::new(AtomicBool::new(false));
2160        let gate = observed.clone();
2161        get_tokio_runtime().spawn(async move {
2162            // The Handle holds one receiver and `wait_ready()` clones a second,
2163            // so the producer drops only once the observer is on the pending path.
2164            let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
2165            while tx.receiver_count() < 2 && tokio::time::Instant::now() < deadline {
2166                tokio::time::sleep(std::time::Duration::from_millis(1)).await;
2167            }
2168            gate.store(tx.receiver_count() >= 2, Ordering::SeqCst);
2169            drop(tx);
2170        });
2171        monarch_with_gil_blocking(GilSite::Test, |py| {
2172            let h = Py::new(py, handle).unwrap();
2173            let helper = loop_helper(py);
2174            let err = helper
2175                .getattr("run_await")
2176                .unwrap()
2177                .call1((h,))
2178                .unwrap_err();
2179            assert_producer_ended_error(py, &err);
2180        });
2181        assert!(
2182            observed.load(Ordering::SeqCst),
2183            "the producer should drop only after the observer is waiting"
2184        );
2185    }
2186
2187    // A StopIteration producer error is wrapped in RuntimeError before
2188    // set_exception, since asyncio.Future.set_exception rejects a StopIteration
2189    // (PEP 479) with a TypeError that would hang the awaiter.
2190    // Attests HDL-7.
2191    #[test]
2192    fn complete_asyncio_future_wraps_stop_iteration() {
2193        ensure_python();
2194        monarch_with_gil_blocking(GilSite::Test, |py| {
2195            let event_loop = py
2196                .import("asyncio")
2197                .unwrap()
2198                .call_method0("new_event_loop")
2199                .unwrap();
2200            let fut = event_loop.call_method0("create_future").unwrap();
2201            let stop_iteration = py.get_type::<PyStopIteration>().call0().unwrap().unbind();
2202            // Must not raise (a raw set_exception(StopIteration) would TypeError).
2203            complete_asyncio_future(&fut, true, stop_iteration).unwrap();
2204            let exc = fut.call_method0("exception").unwrap();
2205            assert!(
2206                exc.is_instance_of::<PyRuntimeError>(),
2207                "a StopIteration producer error should be wrapped in RuntimeError"
2208            );
2209            assert!(
2210                !exc.is_instance_of::<PyStopIteration>(),
2211                "the wrapped error must not remain a StopIteration"
2212            );
2213            event_loop.call_method0("close").unwrap();
2214        });
2215    }
2216
2217    // A StopIteration SUBCLASS is also wrapped in RuntimeError: we wrap any
2218    // StopIteration, not just the exact type, so the awaiter never hits the
2219    // set_exception TypeError hang.
2220    // Attests HDL-7.
2221    #[test]
2222    fn complete_asyncio_future_wraps_stop_iteration_subclass() {
2223        ensure_python();
2224        monarch_with_gil_blocking(GilSite::Test, |py| {
2225            let event_loop = py
2226                .import("asyncio")
2227                .unwrap()
2228                .call_method0("new_event_loop")
2229                .unwrap();
2230            let fut = event_loop.call_method0("create_future").unwrap();
2231            let helper = PyModule::from_code(
2232                py,
2233                cr#"
2234class MyStop(StopIteration):
2235    pass
2236"#,
2237                c"my_stop.py",
2238                c"my_stop",
2239            )
2240            .unwrap();
2241            let subclass_exc = helper.getattr("MyStop").unwrap().call0().unwrap().unbind();
2242            complete_asyncio_future(&fut, true, subclass_exc).unwrap();
2243            let exc = fut.call_method0("exception").unwrap();
2244            assert!(
2245                exc.is_instance_of::<PyRuntimeError>(),
2246                "a StopIteration subclass should be wrapped in RuntimeError"
2247            );
2248            assert!(
2249                !exc.is_instance_of::<PyStopIteration>(),
2250                "the wrapped error must not remain a StopIteration"
2251            );
2252            event_loop.call_method0("close").unwrap();
2253        });
2254    }
2255
2256    // Dropping a core aborts its producing task only when `abort_on_drop` is set:
2257    // spawn_abortable's core aborts abandoned work, while a Handle's core
2258    // (abort_on_drop = None, HDL-4) leaves the producer running.
2259    // Attests HDL-4.
2260    #[test]
2261    fn drop_aborts_producer_only_when_abort_set() {
2262        use std::sync::Arc;
2263        use std::sync::atomic::AtomicBool;
2264        use std::sync::atomic::Ordering;
2265        use std::time::Duration;
2266
2267        for (abort, expect_completed) in [(true, false), (false, true)] {
2268            let completed = Arc::new(AtomicBool::new(false));
2269            let completed_in_task = Arc::clone(&completed);
2270            let (_tx, rx) = watch::channel::<Option<PyResult<Py<PyAny>>>>(None);
2271            let jh = get_tokio_runtime().spawn(async move {
2272                tokio::time::sleep(Duration::from_millis(50)).await;
2273                completed_in_task.store(true, Ordering::SeqCst);
2274            });
2275            let core = HandleCore::new(rx, abort.then(|| jh.abort_handle()), None);
2276            drop(core);
2277            std::thread::sleep(Duration::from_millis(250));
2278            assert_eq!(
2279                completed.load(Ordering::SeqCst),
2280                expect_completed,
2281                "abort={abort}: producer completion after drop should be {expect_completed}"
2282            );
2283        }
2284    }
2285
2286    // Neither side of the private completion pair can be constructed directly
2287    // from Python -- both pyclasses have no #[new].
2288    // Attests HDL-11.
2289    #[test]
2290    fn handle_and_completer_not_constructible_from_python() {
2291        ensure_python();
2292        monarch_with_gil_blocking(GilSite::Test, |py| {
2293            let handle_type = py.get_type::<PyHandle>();
2294            let handle_error = handle_type.call0().unwrap_err();
2295            assert!(
2296                handle_error.is_instance_of::<pyo3::exceptions::PyTypeError>(py),
2297                "constructing Handle() from Python should raise TypeError (no __new__)"
2298            );
2299            let completer_type = py.get_type::<PyHandleCompleter>();
2300            let completer_error = completer_type.call0().unwrap_err();
2301            assert!(
2302                completer_error.is_instance_of::<pyo3::exceptions::PyTypeError>(py),
2303                "constructing _HandleCompleter() from Python should raise TypeError (no __new__)"
2304            );
2305        });
2306    }
2307
2308    // A pending Handle releases the GIL while blocking, allowing the paired
2309    // producer on another thread to acquire it, publish, and wake the observer.
2310    // The zero-capacity channel ensures the worker has reached the gate
2311    // immediately before its GIL acquisition when observation starts.
2312    // Attests HDL-1, HDL-2, HDL-9, HDL-11.
2313    #[test]
2314    fn python_pair_resolves_pending_handle_across_threads() {
2315        use std::sync::mpsc::sync_channel;
2316        use std::time::Duration;
2317
2318        ensure_python();
2319        let (worker, observed) = monarch_with_gil_blocking(GilSite::Test, |py| {
2320            let (handle, completer) = _new_handle_pair();
2321            let handle = Py::new(py, handle).unwrap();
2322            let completer = Py::new(py, completer).unwrap();
2323            let (ready_tx, ready_rx) = sync_channel(0);
2324            let worker = std::thread::spawn(move || {
2325                ready_tx.send(()).unwrap();
2326                monarch_with_gil_blocking(GilSite::Test, |py| {
2327                    completer
2328                        .borrow_mut(py)
2329                        .set_result(42i64.into_py_any(py).unwrap())
2330                        .unwrap();
2331                });
2332            });
2333
2334            ready_rx
2335                .recv_timeout(Duration::from_secs(2))
2336                .expect("completion worker should reach its pre-GIL gate");
2337            assert!(
2338                PyHandle::poll(&handle.borrow(py)).unwrap().is_none(),
2339                "the Handle should still be pending before the worker acquires the GIL"
2340            );
2341            let value = PyHandle::get(handle.borrow(py), py, Some(2.0))
2342                .unwrap()
2343                .extract::<i64>(py)
2344                .unwrap();
2345            (worker, value)
2346        });
2347
2348        worker.join().expect("completion worker should exit");
2349        assert_eq!(
2350            observed, 42,
2351            "the pending observer should receive the value"
2352        );
2353    }
2354
2355    // Failure publication follows the same pending cross-thread path and wakes
2356    // the observer with the original Python error.
2357    // Attests HDL-1, HDL-2, HDL-9, HDL-11.
2358    #[test]
2359    fn python_pair_publishes_exception_to_pending_handle_across_threads() {
2360        use std::sync::mpsc::sync_channel;
2361        use std::time::Duration;
2362
2363        ensure_python();
2364        let worker = monarch_with_gil_blocking(GilSite::Test, |py| {
2365            let (handle, completer) = _new_handle_pair();
2366            let handle = Py::new(py, handle).unwrap();
2367            let completer = Py::new(py, completer).unwrap();
2368            let (ready_tx, ready_rx) = sync_channel(0);
2369            let worker = std::thread::spawn(move || {
2370                ready_tx.send(()).unwrap();
2371                monarch_with_gil_blocking(GilSite::Test, |py| {
2372                    let error = PyRuntimeError::new_err("cross-thread failure")
2373                        .into_value(py)
2374                        .into_bound(py)
2375                        .into_any();
2376                    completer.borrow_mut(py).set_exception(error).unwrap();
2377                });
2378            });
2379
2380            ready_rx
2381                .recv_timeout(Duration::from_secs(2))
2382                .expect("completion worker should reach its pre-GIL gate");
2383            assert!(
2384                PyHandle::poll(&handle.borrow(py)).unwrap().is_none(),
2385                "the Handle should still be pending before the worker acquires the GIL"
2386            );
2387            let error = PyHandle::get(handle.borrow(py), py, Some(2.0)).unwrap_err();
2388            assert!(
2389                error.is_instance_of::<PyRuntimeError>(py),
2390                "the pending observer should receive the worker's exception"
2391            );
2392            assert_eq!(error.to_string(), "RuntimeError: cross-thread failure");
2393            worker
2394        });
2395
2396        worker.join().expect("completion worker should exit");
2397    }
2398
2399    // spawn() eagerly runs its producer -- before any observer exists -- and
2400    // resolves the converted value; no PyPythonTask is involved.
2401    // Attests HDL-1, HDL-2, HDL-13.
2402    #[test]
2403    fn rust_spawn_eagerly_resolves_handle() {
2404        ensure_python();
2405        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
2406        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
2407        let handle = PyHandle::spawn(async move {
2408            let _ = started_tx.send(());
2409            let _ = release_rx.await;
2410            Ok::<i64, PyErr>(42)
2411        });
2412        // Eager: the producer starts before we create any observer.
2413        get_tokio_runtime()
2414            .block_on(started_rx)
2415            .expect("spawn() must start its producer eagerly, before observation");
2416        let _ = release_tx.send(());
2417        let out = get_tokio_runtime().block_on(handle.core.wait_future());
2418        let got =
2419            monarch_with_gil_blocking(GilSite::Test, |py| out.unwrap().extract::<i64>(py).unwrap());
2420        assert_eq!(
2421            got, 42,
2422            "spawn() should resolve the converted producer value"
2423        );
2424    }
2425
2426    // Two wait_completion() observers of one handle both see the same terminal
2427    // outcome; observation is non-consuming, and success projects to Ok(()).
2428    // Attests HDL-3, HDL-13.
2429    #[test]
2430    fn rust_wait_completion_is_non_consuming() {
2431        ensure_python();
2432
2433        // Success: two observers of one pending handle both resolve to Ok(()).
2434        let (tx, handle) = pending_handle();
2435        let ok1 = handle.wait_completion();
2436        let ok2 = handle.wait_completion();
2437        let value = monarch_with_gil_blocking(GilSite::Test, |py| 1i64.into_py_any(py).unwrap());
2438        tx.send(Some(Ok(value))).unwrap();
2439        let (r1, r2) = get_tokio_runtime().block_on(async move { tokio::join!(ok1, ok2) });
2440        assert!(
2441            r1.is_ok() && r2.is_ok(),
2442            "both completion observers should project success to Ok(())"
2443        );
2444
2445        // Error: two observers of one failed handle both surface the stored error.
2446        let (etx, ehandle) = pending_handle();
2447        let err1 = ehandle.wait_completion();
2448        let err2 = ehandle.wait_completion();
2449        etx.send(Some(Err(PyValueError::new_err("boom")))).unwrap();
2450        let (e1, e2) = get_tokio_runtime().block_on(async move { tokio::join!(err1, err2) });
2451        monarch_with_gil_blocking(GilSite::Test, |py| {
2452            assert!(
2453                e1.unwrap_err().is_instance_of::<PyValueError>(py),
2454                "the error projection should clone the stored PyErr"
2455            );
2456            assert!(e2.unwrap_err().is_instance_of::<PyValueError>(py));
2457        });
2458    }
2459
2460    // Dropping the returned Handle before the producer completes does not cancel
2461    // it (HDL-4: a spawn()-produced core has abort_on_drop = None).
2462    // Attests HDL-4, HDL-13.
2463    #[test]
2464    fn rust_spawn_survives_handle_drop() {
2465        ensure_python();
2466        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
2467        let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
2468        let handle = PyHandle::spawn(async move {
2469            let _ = release_rx.await;
2470            let _ = done_tx.send(());
2471            Ok::<i64, PyErr>(7)
2472        });
2473        // Drop the observer before releasing the producer.
2474        drop(handle);
2475        let _ = release_tx.send(());
2476        // The producer still runs to completion despite the dropped Handle.
2477        get_tokio_runtime()
2478            .block_on(done_rx)
2479            .expect("dropping the Handle must not cancel the spawn() producer");
2480    }
2481
2482    // The real spawn() -> wait_completion() pair: a spawn()-produced future that
2483    // returns Err surfaces the exact exception (type + message) through
2484    // wait_completion, and observing twice sequentially returns the same cached
2485    // error -- proving non-consuming error projection over a real producer.
2486    // Attests HDL-1, HDL-2, HDL-3, HDL-13.
2487    #[test]
2488    fn rust_spawn_error_observed_by_wait_completion_twice() {
2489        ensure_python();
2490        let handle =
2491            PyHandle::spawn(async move { Err::<(), PyErr>(PyValueError::new_err("boom")) });
2492        // Observe the same terminal error twice, sequentially, over the real
2493        // spawn()-produced core.
2494        let first = get_tokio_runtime().block_on(handle.wait_completion());
2495        let second = get_tokio_runtime().block_on(handle.wait_completion());
2496        monarch_with_gil_blocking(GilSite::Test, |py| {
2497            for (label, res) in [("first", first), ("second", second)] {
2498                let err = res.expect_err("wait_completion should surface the producer error");
2499                assert!(
2500                    err.is_instance_of::<PyValueError>(py),
2501                    "{label}: expected PyValueError"
2502                );
2503                let msg = err.value(py).to_string();
2504                assert!(
2505                    msg.contains("boom"),
2506                    "{label}: cached error message should be preserved, got {msg}"
2507                );
2508            }
2509        });
2510    }
2511}