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 Tokio task.
10//!
11//! A `Handle` wraps a `watch` channel that a producer fulfills exactly once with
12//! the task'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`) and
29//!   [`HandleCore::poll`] (re-borrow across the GIL). Held by convention: every
30//!   producer sends once, always `Some(_)`. A send-once sender (a newtype
31//!   consuming the `watch::Sender`) could make it structural, hardening `poll`'s
32//!   re-borrow.
33//! - **HDL-2 (value encoding).** `None` is pending, `Some(Ok(_))` success,
34//!   `Some(Err(_))` a producer error.
35//!
36//! Guaranteed by this module:
37//! - **HDL-3 (non-consuming reads).** Every read clones the value out
38//!   (`clone_ref`) rather than moving it, so any number of observers see the
39//!   same result. Enforced by construction: no read path moves the value.
40//! - **HDL-4 (drop does not cancel; drop never panics).** Dropping a `Handle`
41//!   never cancels its producer; a `Handle` observes, it does not own the
42//!   producer's lifecycle. Enforced by construction: a `Handle`'s core has
43//!   `abort_on_drop: None`, and `HandleCore::Drop` aborts only when
44//!   `abort_on_drop` is set. The abort is wrapped in `catch_unwind`, so an
45//!   `abort()` that panics at interpreter shutdown (the Tokio runtime already
46//!   gone) cannot escape `Drop` and abort the process.
47//! - **HDL-5 (GIL discipline).** No Python object is touched without the GIL,
48//!   and every acquisition goes through `monarch_with_gil{,_blocking}`. Enforced
49//!   by the crate's `#![deny(clippy::disallowed_methods)]` ban on raw
50//!   `Python::with_gil`/`attach` -- a hard compile error, not a `debug_assert`.
51//! - **HDL-6 (`WouldBlockRuntime` is `get()`'s alone).** Only `get()` raises it,
52//!   and only in a Tokio runtime context; `as_asyncio()`/`__await__` off a loop
53//!   raise the native `RuntimeError` instead.
54//! - **HDL-7 (`as_asyncio` publish).** The observer waits borrow-first (via
55//!   `wait_ready`, never `changed()`-first) and sets a result only on a
56//!   non-cancelled future, swallowing `InvalidStateError`; any `StopIteration`
57//!   (including subclasses) is wrapped in `RuntimeError` first, since
58//!   `set_exception` rejects `StopIteration` (PEP 479); if the loop has closed it
59//!   logs rather than panics.
60//! - **HDL-8 (`get()` loop-warning is call-pattern, not outcome).** `get()` on a
61//!   running asyncio loop warns regardless of whether the value is ready: it
62//!   flags the blocking-call-from-a-loop anti-pattern, not whether this call
63//!   happens to block. A ready value returns after warning; under a
64//!   warnings-as-errors filter the warning escalates to an error.
65//! - **HDL-9 (GIL/watch borrow discipline).** A `watch` borrow is never held
66//!   across a GIL acquisition or an `.await`. `poll` drops its read guard before
67//!   taking the GIL and re-borrows under it (GIL-then-watch order, never the
68//!   inverse against the producer's write-lock `send`); the `wait_ready`/
69//!   `wait_future` loops drop the temporary `Ref` before each `.await`; and their
70//!   returned futures own a cloned receiver (`Send + 'static`, borrowing nothing
71//!   from `&self`), which is what lets `get()` `drop(slf)` + release the GIL
72//!   before blocking and lets `as_asyncio` `spawn` the observer. Enforced partly
73//!   by the compiler (`Send` bounds, the borrow checker on `drop(slf)`) and
74//!   partly by construction; exercised by the multi-observer/blocking tests.
75//! - **HDL-10 (dropped producer surfaces, never hangs).** If every producer drops
76//!   its sender without sending, the wait loop's `changed().await?` yields a
77//!   `RecvError` turned into a Python exception (`to_py_error`) on both the sync
78//!   (`get`/`wait_future`) and async (`as_asyncio`) paths, so an observer raises
79//!   rather than hanging forever.
80//! - **HDL-11 (`Handle` is not constructible from Python).** The pyclass has no
81//!   `#[new]` and the only `from_value` constructor is `#[cfg(test)]`-gated, so a
82//!   live `Handle` always wraps a producer-supplied core (upholding HDL-1/HDL-2);
83//!   exposing a Python constructor would let user code mint an unresolvable core.
84//! - **HDL-12 (`get()` timeout contract).** `get()` validates the timeout up
85//!   front (rejecting negative/NaN/non-finite as `ValueError` via
86//!   `try_from_secs_f64`, never panicking) and before the ready fast path, so an
87//!   invalid timeout raises deterministically even for a ready handle. A timeout
88//!   is non-cancelling: it raises `TimeoutError` while leaving the producer and
89//!   every observer untouched, so a later `poll()`/`get()`/`await` still observes
90//!   completion.
91
92use std::future::Future;
93
94use monarch_types::py_global;
95use pyo3::exceptions::PyRuntimeError;
96use pyo3::exceptions::PyStopIteration;
97use pyo3::exceptions::PyTimeoutError;
98use pyo3::exceptions::PyUserWarning;
99use pyo3::exceptions::PyValueError;
100use pyo3::prelude::*;
101use pyo3::sync::PyOnceLock;
102use pyo3::types::PyCFunction;
103use pyo3::types::PyType;
104use tokio::sync::watch;
105use tokio::task::AbortHandle;
106
107use crate::pytokio::is_tokio_thread;
108use crate::pytokio::to_py_error;
109use crate::runtime::GilSite;
110use crate::runtime::get_tokio_runtime;
111use crate::runtime::monarch_with_gil;
112use crate::runtime::monarch_with_gil_blocking;
113use crate::runtime::signal_safe_block_on;
114
115// Held so the completion callback can catch and swallow it: publishing a result
116// onto an asyncio.Future that's already settled (the awaiter cancelled it, or it
117// resolved) raises InvalidStateError, which is benign here.
118py_global!(invalid_state_error, "asyncio", "InvalidStateError");
119
120pyo3::create_exception!(
121    pytokio,
122    WouldBlockRuntime,
123    pyo3::exceptions::PyRuntimeError,
124    "raised when Handle.get() is called from a Tokio runtime context"
125);
126
127/// The watch-channel mechanics behind a `Handle`.
128///
129/// The channel starts as `None` and is fulfilled once with `Some(Ok(obj))` or
130/// `Some(Err(pyerr))`. Cloning the receiver lets multiple observers see the same
131/// completion, so observation is non-consuming.
132pub(crate) struct HandleCore {
133    /// One-shot result channel. `None` until the producer completes.
134    rx: watch::Receiver<Option<PyResult<Py<PyAny>>>>,
135
136    /// When `Some`, dropping the core aborts the producing Tokio task through
137    /// this handle; `None` leaves the producer running (a detached producer, or
138    /// a core built from a ready value). The task's result is observed only via
139    /// `rx`, so only the `AbortHandle` is kept, never the `JoinHandle`.
140    abort_on_drop: Option<AbortHandle>,
141
142    /// Optional creation-site traceback, used when logging un-awaited errors.
143    traceback: Option<Py<PyAny>>,
144}
145
146impl HandleCore {
147    /// Construct a core from its parts.
148    pub(crate) fn new(
149        rx: watch::Receiver<Option<PyResult<Py<PyAny>>>>,
150        abort_on_drop: Option<AbortHandle>,
151        traceback: Option<Py<PyAny>>,
152    ) -> Self {
153        Self {
154            rx,
155            abort_on_drop,
156            traceback,
157        }
158    }
159
160    /// Construct a core that is already resolved with `value`.
161    ///
162    /// There is no producer task, so the core never aborts on drop and carries
163    /// no traceback.
164    pub(crate) fn from_value(value: Py<PyAny>) -> PyResult<Self> {
165        let (tx, rx) = watch::channel(None);
166        tx.send(Some(Ok(value))).map_err(to_py_error)?;
167        Ok(Self {
168            rx,
169            abort_on_drop: None,
170            traceback: None,
171        })
172    }
173
174    /// Non-blocking, non-consuming check for completion.
175    ///
176    /// Returns `Ok(None)` while pending, `Ok(Some(obj))` on success, or
177    /// `Err(pyerr)` if the producer completed with an exception.
178    pub(crate) fn poll(&self) -> PyResult<Option<Py<PyAny>>> {
179        // HDL-9: release the watch read guard before taking the GIL; holding it
180        // across the GIL acquisition would invert lock order against the
181        // producer's write-lock send. Re-borrowing under the GIL is safe by HDL-1
182        // (one-shot: once `Some`, never reset or replaced).
183        if self.rx.borrow().is_none() {
184            return Ok(None);
185        }
186        monarch_with_gil_blocking(GilSite::Convert, |py| {
187            match self
188                .rx
189                .borrow()
190                .as_ref()
191                .expect("HDL-1: value is Some after the is_none check")
192            {
193                Ok(v) => Ok(Some(v.clone_ref(py))),
194                Err(err) => Err(err.clone_ref(py)),
195            }
196        })
197    }
198
199    /// Return a future that observes this core's completion.
200    ///
201    /// The receiver is cloned, so this is non-consuming: any number of waiters
202    /// can observe the same final value. The current value is checked before
203    /// awaiting `changed()`, so a core that already holds its value resolves
204    /// immediately.
205    pub(crate) fn wait_future(&self) -> impl Future<Output = PyResult<Py<PyAny>>> + Send + 'static {
206        // Reuse wait_ready's HDL-1 wait loop (single source of truth), then clone
207        // the value out under the GIL.
208        let ready = self.wait_ready();
209        async move {
210            // HDL-10: a dropped producer surfaces as a Python exception here.
211            let rx = ready.await.map_err(to_py_error)?;
212            monarch_with_gil(GilSite::Convert, |py| {
213                match rx
214                    .borrow()
215                    .as_ref()
216                    .expect("HDL-1: value is Some after wait_ready")
217                {
218                    Ok(v) => Ok(v.clone_ref(py)),
219                    Err(err) => Err(err.clone_ref(py)),
220                }
221            })
222            .await
223        }
224    }
225
226    /// Await this core's completion without touching the GIL.
227    ///
228    /// Yields the cloned receiver positioned at the completed value, or a
229    /// `RecvError` if every producer dropped its sender without sending. Unlike
230    /// `wait_future`, which clones the value out under its own GIL acquisition,
231    /// the caller clones under a GIL it holds anyway (e.g. `as_asyncio`'s
232    /// scheduling), so completion touches the GIL exactly once.
233    pub(crate) fn wait_ready(
234        &self,
235    ) -> impl Future<
236        Output = Result<watch::Receiver<Option<PyResult<Py<PyAny>>>>, watch::error::RecvError>,
237    > + Send
238    + 'static {
239        // HDL-9: the returned future owns a cloned receiver (Send + 'static,
240        // borrowing nothing from &self), so a caller can drop its PyRef / release
241        // the GIL before awaiting and can spawn this future.
242        let mut rx = self.rx.clone();
243        async move {
244            // HDL-1: loop until the value is actually `Some` (one-shot). HDL-9:
245            // the temporary borrow is dropped before each `.await`, never held
246            // across it. HDL-10: a dropped producer makes `changed()` yield a
247            // RecvError, which propagates out rather than hanging.
248            while rx.borrow().is_none() {
249                rx.changed().await?;
250            }
251            Ok(rx)
252        }
253    }
254
255    /// Clone the creation-site traceback under the GIL.
256    pub(crate) fn traceback_clone(&self) -> Option<Py<PyAny>> {
257        self.traceback
258            .as_ref()
259            .map(|t| monarch_with_gil_blocking(GilSite::Traceback, |py| t.clone_ref(py)))
260    }
261}
262
263/// Abort the producing Tokio task on drop when `abort_on_drop` is set.
264///
265/// This stops abandoned background work when no observers remain, guarded against
266/// panics during interpreter shutdown when the Tokio runtime may already be gone.
267/// A `Handle`'s core has `abort_on_drop: None` (HDL-4), so this aborts only for
268/// pytokio's `spawn_abortable`, never for a `Handle`.
269impl Drop for HandleCore {
270    fn drop(&mut self) {
271        if let Some(abort_handle) = self.abort_on_drop.as_ref() {
272            // HDL-4: catch_unwind so an abort() that panics at interpreter
273            // shutdown (the runtime already gone) cannot escape Drop and abort
274            // the process.
275            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
276                abort_handle.abort();
277            }));
278        }
279    }
280}
281
282/// The observe-only handle to a background Tokio task.
283///
284/// Exposed to Python as
285/// `monarch._rust_bindings.monarch_hyperactor.pytokio.Handle`. HDL-11: Python
286/// obtains a `Handle` from a producer and cannot construct one directly -- there
287/// is no `__new__`, and `from_value` is `#[cfg(test)]`-only.
288#[pyclass(
289    name = "Handle",
290    module = "monarch._rust_bindings.monarch_hyperactor.pytokio"
291)]
292pub struct PyHandle {
293    core: HandleCore,
294}
295
296impl PyHandle {
297    /// Wrap an already-built `HandleCore` in a `PyHandle`.
298    ///
299    /// Producers in `pytokio.rs` build the core (watch channel + spawned task)
300    /// and hand back an observe-only `Handle` through this; `PyHandle`'s field
301    /// is private to this module.
302    pub(crate) fn from_core(core: HandleCore) -> Self {
303        Self { core }
304    }
305}
306
307#[cfg(test)]
308impl PyHandle {
309    /// Construct a resolved `Handle` from `value`.
310    ///
311    /// HDL-11: Rust-only test helper (`#[cfg(test)]`), deliberately not a
312    /// Python-visible method, so a `Handle` cannot be constructed from Python.
313    pub(crate) fn from_value(value: Py<PyAny>) -> PyResult<Self> {
314        Ok(Self {
315            core: HandleCore::from_value(value)?,
316        })
317    }
318}
319
320#[pymethods]
321impl PyHandle {
322    /// Block the calling thread until the handle resolves and return its value.
323    ///
324    /// Behavior is keyed to the calling context, not to whether the value
325    /// happens to be ready:
326    ///   - Tokio runtime context: raise `WouldBlockRuntime` unconditionally
327    ///     (blocking there would panic the runtime); use `poll()` or
328    ///     `as_asyncio()` instead.
329    ///   - running `asyncio` loop: warns (get() is a blocking call from a loop),
330    ///     then a ready value returns via the `poll()` fast path and a pending
331    ///     one blocks. Under a warnings-as-errors filter the warning is an error.
332    ///   - sync thread: a ready value fast-paths, a pending one blocks
333    ///     (Ctrl-C-safe on main).
334    ///
335    /// `get()` is non-cancelling. On timeout it raises `TimeoutError`, leaving
336    /// the producer and every observer untouched, so a later
337    /// `poll()`/`get()`/`await` still observes completion.
338    #[pyo3(signature = (timeout = None))]
339    fn get(slf: PyRef<'_, Self>, py: Python<'_>, timeout: Option<f64>) -> PyResult<Py<PyAny>> {
340        // HDL-6: get() is the sole WouldBlockRuntime raiser. It is the blocking
341        // API, and in a Tokio runtime context blocking would panic the runtime,
342        // so refuse unconditionally -- even a ready value -- keying the outcome
343        // to context, not producer timing.
344        if is_tokio_thread() {
345            return Err(WouldBlockRuntime::new_err(
346                "get() cannot be called from a Tokio runtime context; use poll() or as_asyncio()",
347            ));
348        }
349
350        // HDL-12: validate the timeout up front, so an invalid value raises
351        // ValueError deterministically rather than only when the handle happens
352        // to be pending. Reject negative/NaN/non-finite rather than panicking in
353        // Duration::from_secs_f64.
354        let duration = timeout
355            .map(|seconds| {
356                std::time::Duration::try_from_secs_f64(seconds)
357                    .map_err(|e| PyValueError::new_err(format!("invalid timeout {seconds}: {e}")))
358            })
359            .transpose()?;
360
361        // Warn regardless of readiness: get() is a blocking call, and calling it
362        // from a running asyncio loop is the anti-pattern being flagged whether or
363        // not the value happens to be ready this time (a later call may block and
364        // freeze the loop). Under a warnings-as-errors filter this escalates to an
365        // error -- the caller's chosen strictness for the anti-pattern.
366        if pyo3_async_runtimes::get_running_loop(py).is_ok() {
367            let category = py.get_type::<PyUserWarning>();
368            PyErr::warn(
369                py,
370                &category,
371                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.",
372                1,
373            )?;
374        }
375
376        // A ready value returns without blocking, in any non-Tokio context.
377        if let Some(value) = slf.core.poll()? {
378            return Ok(value);
379        }
380
381        let wait = slf.core.wait_future();
382        // HDL-9: drop the PyRef before releasing the GIL in `signal_safe_block_on`
383        // (which blocks with the GIL released), so a concurrent mutable borrow of
384        // this handle does not throw during the blocking window.
385        drop(slf);
386
387        // HDL-12: the timeout drops only the local `wait` future, leaving the
388        // producer and the watch channel untouched, so a timed-out get() stays
389        // observable (non-cancelling).
390        match duration {
391            Some(duration) => signal_safe_block_on(py, async move {
392                tokio::time::timeout(duration, wait)
393                    .await
394                    .map_err(|_| PyTimeoutError::new_err(()))?
395            })?,
396            None => signal_safe_block_on(py, wait)?,
397        }
398    }
399
400    /// Non-blocking, non-consuming check for completion.
401    ///
402    /// Returns `None` while pending, the value on success, or raises the stored
403    /// exception. A ready value stays observable by later observers.
404    fn poll(&self) -> PyResult<Option<Py<PyAny>>> {
405        self.core.poll()
406    }
407
408    /// Return a standard `asyncio.Future` that resolves when the handle does.
409    ///
410    /// Requires a running loop; off a loop this raises the native `RuntimeError`
411    /// from `asyncio.get_running_loop()`. A Tokio observer of the watch channel
412    /// publishes the completion onto the calling loop via
413    /// `loop.call_soon_threadsafe(...)`; it never drives a Python coroutine.
414    ///
415    /// The observer borrows the current watch value first, so a core that
416    /// already holds its value (or one that completes before the observer
417    /// starts) resolves rather than hangs. The result is set in a callback on
418    /// the loop thread. A cancel can race that callback: a cancelled future is
419    /// left alone and the handle continues. If the loop has closed by then,
420    /// `call_soon_threadsafe` raises, and the observer logs rather than panics.
421    ///
422    /// Each call spawns its own observer; cancelling the returned future does
423    /// not stop the observer, which exits when the handle resolves.
424    fn as_asyncio<'py>(slf: PyRef<'_, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
425        // HDL-6: off a loop this yields the native RuntimeError, not WouldBlockRuntime.
426        let event_loop = pyo3_async_runtimes::get_running_loop(py)?;
427        let fut = event_loop.call_method0("create_future")?;
428
429        let loop_handle = event_loop.clone().unbind();
430        let fut_handle = fut.clone().unbind();
431        let wait = slf.core.wait_ready();
432
433        get_tokio_runtime().spawn(async move {
434            let recv = wait.await;
435            let scheduled = monarch_with_gil(GilSite::Convert, move |py| {
436                // HDL-9: one GIL section that borrows the watch value under the
437                // already-held GIL (GIL-then-watch order), clones it out (or turns
438                // a dropped-producer RecvError into a PyErr), and schedules it --
439                // completion touches the GIL exactly once.
440                let result: PyResult<Py<PyAny>> = match recv {
441                    Ok(rx) => match rx
442                        .borrow()
443                        .as_ref()
444                        .expect("HDL-1: value is Some after wait_ready")
445                    {
446                        Ok(v) => Ok(v.clone_ref(py)),
447                        Err(err) => Err(err.clone_ref(py)),
448                    },
449                    // HDL-10: a dropped producer surfaces as a set_exception.
450                    Err(e) => Err(to_py_error(e)),
451                };
452                schedule_completion(py, loop_handle.bind(py), fut_handle.bind(py), result)
453            })
454            .await;
455            if let Err(err) = scheduled {
456                tracing::warn!("as_asyncio: failed to schedule completion on the loop: {err}");
457            }
458        });
459
460        Ok(fut)
461    }
462
463    /// Implement Python's `await` protocol by delegating to `as_asyncio()`.
464    fn __await__<'py>(slf: PyRef<'_, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
465        Self::as_asyncio(slf, py)?.call_method0("__await__")
466    }
467
468    /// Support `Handle[T]` type syntax on the Python side (no runtime effect).
469    #[classmethod]
470    fn __class_getitem__(cls: &Bound<'_, PyType>, _arg: Py<PyAny>) -> Py<PyAny> {
471        cls.clone().unbind().into()
472    }
473}
474
475/// The cached `complete_asyncio_future` callable.
476///
477/// The function is stateless, so it is wrapped once and reused for every
478/// completion rather than rebuilt (and leaked) on each `call_soon_threadsafe`.
479fn cached_completer(py: Python<'_>) -> PyResult<Bound<'_, PyCFunction>> {
480    static COMPLETER: PyOnceLock<Py<PyCFunction>> = PyOnceLock::new();
481    COMPLETER
482        .get_or_try_init(py, || {
483            Ok(wrap_pyfunction!(complete_asyncio_future, py)?.unbind())
484        })
485        .map(|f| f.bind(py).clone())
486}
487
488/// Schedule the handle's completion onto `event_loop` from the Tokio observer.
489///
490/// Runs under the GIL. The caller has already cloned the success value; an error
491/// is turned into its Python exception object here, so no Rust `PyErr` crosses
492/// into the loop callback. `complete_asyncio_future` is then scheduled via
493/// `call_soon_threadsafe`, whose error (e.g. a closed loop) is returned so the
494/// caller can log rather than panic.
495fn schedule_completion(
496    py: Python<'_>,
497    event_loop: &Bound<'_, PyAny>,
498    fut: &Bound<'_, PyAny>,
499    result: PyResult<Py<PyAny>>,
500) -> PyResult<()> {
501    let completer = cached_completer(py)?;
502    let (is_exc, value) = match result {
503        Ok(v) => (false, v),
504        Err(e) => (true, e.into_value(py).into_any()),
505    };
506    event_loop.call_method1("call_soon_threadsafe", (completer, fut, is_exc, value))?;
507    Ok(())
508}
509
510/// Complete an `asyncio.Future` on its loop thread (HDL-7).
511///
512/// The future may already be settled when this runs: a cancelled future is left
513/// alone, and an already-completed one is swallowed. Any other error propagates.
514#[pyfunction]
515fn complete_asyncio_future(fut: &Bound<'_, PyAny>, is_exc: bool, value: Py<PyAny>) -> PyResult<()> {
516    if fut.call_method0("cancelled")?.is_truthy()? {
517        return Ok(());
518    }
519    let py = fut.py();
520    let (method, value) = if is_exc {
521        // asyncio.Future.set_exception rejects a StopIteration (PEP 479) with a
522        // TypeError; running as a call_soon_threadsafe callback it would be
523        // logged and dropped, hanging the awaiter. Wrap any StopIteration
524        // (including subclasses) in a RuntimeError -- how `raise StopIteration`
525        // already surfaces out of a coroutine -- so the future can always settle.
526        let value = if value.bind(py).is_instance_of::<PyStopIteration>() {
527            PyRuntimeError::new_err("coroutine raised StopIteration")
528                .into_value(py)
529                .into_any()
530        } else {
531            value
532        };
533        ("set_exception", value)
534    } else {
535        ("set_result", value)
536    };
537    // HDL-7: publish only to a non-cancelled future (the guard above), and
538    // swallow InvalidStateError from one already settled. Any other error is a
539    // real failure and propagates.
540    match fut.call_method1(method, (value,)) {
541        Ok(_) => Ok(()),
542        Err(e) => {
543            if e.is_instance(py, &invalid_state_error(py)) {
544                Ok(())
545            } else {
546                Err(e)
547            }
548        }
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use pyo3::IntoPyObjectExt;
555    use pyo3::exceptions::PyRuntimeError;
556    use pyo3::exceptions::PyValueError;
557    use pyo3::types::PyModule;
558    use pyo3::types::PyTuple;
559
560    use super::*;
561    use crate::pytokio::ensure_python;
562
563    // Build a `Handle` over a controlled, still-pending watch channel and return
564    // the sender so the test drives completion explicitly.
565    fn pending_handle() -> (watch::Sender<Option<PyResult<Py<PyAny>>>>, PyHandle) {
566        let (tx, rx) = watch::channel(None);
567        let handle = PyHandle {
568            core: HandleCore::new(rx, None, None),
569        };
570        (tx, handle)
571    }
572
573    // A Python helper module providing loop drivers for the `asyncio` tests.
574    fn loop_helper(py: Python<'_>) -> Bound<'_, PyModule> {
575        let code = cr#"
576import asyncio
577import warnings
578
579async def _await(h):
580    return await h
581
582def run_await(h):
583    loop = asyncio.new_event_loop()
584    try:
585        return loop.run_until_complete(_await(h))
586    finally:
587        loop.close()
588
589async def _get_on_loop(h):
590    with warnings.catch_warnings(record=True) as caught:
591        warnings.simplefilter("always")
592        value = h.get()
593    return (value, any(issubclass(w.category, UserWarning) for w in caught))
594
595def run_get_on_loop(h):
596    loop = asyncio.new_event_loop()
597    try:
598        return loop.run_until_complete(_get_on_loop(h))
599    finally:
600        loop.close()
601
602async def _get_on_loop_strict(h):
603    # get() under a warnings-as-errors filter: a ready value must still return
604    # (no-warn fast path), not raise the UserWarning as an error.
605    with warnings.catch_warnings():
606        warnings.simplefilter("error")
607        return h.get()
608
609def run_get_on_loop_strict(h):
610    loop = asyncio.new_event_loop()
611    try:
612        return loop.run_until_complete(_get_on_loop_strict(h))
613    finally:
614        loop.close()
615
616async def _cancel(h):
617    f = h.as_asyncio()
618    f.cancel()
619    await asyncio.sleep(0.05)
620    return (f.cancelled(), h.poll())
621
622def run_cancel(h):
623    loop = asyncio.new_event_loop()
624    try:
625        return loop.run_until_complete(_cancel(h))
626    finally:
627        loop.close()
628
629async def _cancel_then_await(h):
630    # cancel the first observer, then observe again while a live producer runs
631    f1 = h.as_asyncio()
632    f1.cancel()
633    result = await h.as_asyncio()
634    # let the cancelled observer's completion callback drain (it no-ops)
635    await asyncio.sleep(0.05)
636    return (f1.cancelled(), result, h.poll())
637
638def run_cancel_then_await(h):
639    loop = asyncio.new_event_loop()
640    try:
641        return loop.run_until_complete(_cancel_then_await(h))
642    finally:
643        loop.close()
644
645async def _two(h):
646    a = h.as_asyncio()
647    b = h.as_asyncio()
648    # gather returns a list; the Rust side downcasts a tuple
649    return tuple(await asyncio.gather(a, b))
650
651def run_two(h):
652    loop = asyncio.new_event_loop()
653    try:
654        return loop.run_until_complete(_two(h))
655    finally:
656        loop.close()
657"#;
658        PyModule::from_code(py, code, c"handle_test_helper.py", c"handle_test_helper").unwrap()
659    }
660
661    // A ready value is returned via the poll fast path.
662    // Attests HDL-2.
663    #[test]
664    fn get_returns_ready_value() {
665        ensure_python();
666        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
667            let value = 42i64.into_py_any(py).unwrap();
668            let handle = PyHandle::from_value(value).unwrap();
669            let r = Py::new(py, handle).unwrap();
670            let out = PyHandle::get(r.borrow(py), py, None).unwrap();
671            out.extract::<i64>(py).unwrap()
672        });
673        assert_eq!(got, 42, "get() should return the resolved value");
674    }
675
676    // A pending handle blocks get() on a sync thread until a producer completes.
677    // Attests HDL-1, HDL-2.
678    #[test]
679    fn get_blocks_then_returns() {
680        ensure_python();
681        let value = monarch_with_gil_blocking(GilSite::Test, |py| 7i64.into_py_any(py).unwrap());
682        let (tx, handle) = pending_handle();
683        get_tokio_runtime().spawn(async move {
684            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
685            let _ = tx.send(Some(Ok(value)));
686        });
687        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
688            let r = Py::new(py, handle).unwrap();
689            let out = PyHandle::get(r.borrow(py), py, None).unwrap();
690            out.extract::<i64>(py).unwrap()
691        });
692        assert_eq!(got, 7, "get() should block then return the produced value");
693    }
694
695    // poll() transitions from None to the value once the producer completes.
696    // Attests HDL-1, HDL-2.
697    #[test]
698    fn poll_transitions_pending_to_ready() {
699        ensure_python();
700        let (tx, rx) = watch::channel(None);
701        let core = HandleCore::new(rx, None, None);
702        assert!(
703            core.poll().unwrap().is_none(),
704            "poll() should be None while pending"
705        );
706        let value = monarch_with_gil_blocking(GilSite::Test, |py| 1i64.into_py_any(py).unwrap());
707        tx.send(Some(Ok(value))).unwrap();
708        let got = core.poll().unwrap();
709        let extracted =
710            monarch_with_gil_blocking(GilSite::Test, |py| got.unwrap().extract::<i64>(py).unwrap());
711        assert_eq!(extracted, 1, "poll() should observe the produced value");
712    }
713
714    // poll() is non-consuming: after it returns ready, get() and poll() still
715    // observe the same value.
716    // Attests HDL-1, HDL-3.
717    #[test]
718    fn repeated_observation_after_poll() {
719        ensure_python();
720        monarch_with_gil_blocking(GilSite::Test, |py| {
721            let value = 5i64.into_py_any(py).unwrap();
722            let handle = PyHandle::from_value(value).unwrap();
723            let r = Py::new(py, handle).unwrap();
724
725            let first = r.borrow(py).poll().unwrap().unwrap();
726            assert_eq!(first.extract::<i64>(py).unwrap(), 5);
727
728            let via_get = PyHandle::get(r.borrow(py), py, None).unwrap();
729            assert_eq!(via_get.extract::<i64>(py).unwrap(), 5);
730
731            let again = r.borrow(py).poll().unwrap().unwrap();
732            assert_eq!(again.extract::<i64>(py).unwrap(), 5);
733        });
734    }
735
736    // Two independent observers of one still-pending core both resolve; cloning
737    // the receiver never consumes it.
738    // Attests HDL-1, HDL-3.
739    #[test]
740    fn multiple_observers_resolve() {
741        ensure_python();
742        let (tx, rx) = watch::channel(None);
743        let core = HandleCore::new(rx, None, None);
744        let f1 = core.wait_future();
745        let f2 = core.wait_future();
746        let value = monarch_with_gil_blocking(GilSite::Test, |py| 3i64.into_py_any(py).unwrap());
747        tx.send(Some(Ok(value))).unwrap();
748        let (r1, r2) = get_tokio_runtime().block_on(async move { tokio::join!(f1, f2) });
749        monarch_with_gil_blocking(GilSite::Test, |py| {
750            assert_eq!(r1.unwrap().extract::<i64>(py).unwrap(), 3);
751            assert_eq!(r2.unwrap().extract::<i64>(py).unwrap(), 3);
752        });
753    }
754
755    // A still-pending get() in a Tokio runtime context raises WouldBlockRuntime.
756    // Attests HDL-6.
757    #[test]
758    fn get_pending_in_tokio_raises_would_block() {
759        ensure_python();
760        let (_tx, handle) = pending_handle();
761        get_tokio_runtime().block_on(async {
762            monarch_with_gil(GilSite::Test, |py| {
763                let r = Py::new(py, handle).unwrap();
764                let err = PyHandle::get(r.borrow(py), py, None).unwrap_err();
765                assert!(
766                    err.is_instance_of::<WouldBlockRuntime>(py),
767                    "pending get() in a tokio context should raise WouldBlockRuntime"
768                );
769            })
770            .await
771        });
772    }
773
774    // A ready value in a Tokio runtime context still raises: get() is refused by
775    // context, not by whether the value happens to be available (HDL-6).
776    // Attests HDL-6.
777    #[test]
778    fn get_ready_in_tokio_also_raises() {
779        ensure_python();
780        get_tokio_runtime().block_on(async {
781            monarch_with_gil(GilSite::Test, |py| {
782                let value = 9i64.into_py_any(py).unwrap();
783                let handle = PyHandle::from_value(value).unwrap();
784                let r = Py::new(py, handle).unwrap();
785                let err = PyHandle::get(r.borrow(py), py, None).unwrap_err();
786                assert!(
787                    err.is_instance_of::<WouldBlockRuntime>(py),
788                    "get() in a tokio context must raise even for a ready value"
789                );
790            })
791            .await
792        });
793    }
794
795    // get(timeout) raises TimeoutError and leaves the handle pending, so a later
796    // observation still sees completion.
797    // Attests HDL-12.
798    #[test]
799    fn get_timeout_raises_and_leaves_pending() {
800        ensure_python();
801        let (tx, handle) = pending_handle();
802        let r = monarch_with_gil_blocking(GilSite::Test, |py| Py::new(py, handle).unwrap());
803
804        let is_timeout = monarch_with_gil_blocking(GilSite::Test, |py| {
805            let err = PyHandle::get(r.borrow(py), py, Some(0.05)).unwrap_err();
806            err.is_instance_of::<PyTimeoutError>(py)
807        });
808        assert!(is_timeout, "get(timeout) should raise TimeoutError");
809
810        let still_pending =
811            monarch_with_gil_blocking(GilSite::Test, |py| r.borrow(py).poll().unwrap().is_none());
812        assert!(
813            still_pending,
814            "a timed-out get() must not resolve the handle"
815        );
816
817        let value = monarch_with_gil_blocking(GilSite::Test, |py| 4i64.into_py_any(py).unwrap());
818        tx.send(Some(Ok(value))).unwrap();
819        let observed = monarch_with_gil_blocking(GilSite::Test, |py| {
820            r.borrow(py)
821                .poll()
822                .unwrap()
823                .unwrap()
824                .extract::<i64>(py)
825                .unwrap()
826        });
827        assert_eq!(
828            observed, 4,
829            "completion is still observable after a timeout"
830        );
831    }
832
833    // as_asyncio() off a loop raises the native RuntimeError, not
834    // WouldBlockRuntime.
835    // Attests HDL-6.
836    #[test]
837    fn as_asyncio_off_loop_raises_runtime_error() {
838        ensure_python();
839        monarch_with_gil_blocking(GilSite::Test, |py| {
840            let handle = PyHandle::from_value(py.None()).unwrap();
841            let r = Py::new(py, handle).unwrap();
842            let err = PyHandle::as_asyncio(r.borrow(py), py).unwrap_err();
843            assert!(
844                err.is_instance_of::<PyRuntimeError>(py),
845                "off a loop as_asyncio() should raise RuntimeError"
846            );
847            assert!(
848                !err.is_instance_of::<WouldBlockRuntime>(py),
849                "the off-loop error must be the native RuntimeError, not WouldBlockRuntime"
850            );
851        });
852    }
853
854    // __await__ off a loop surfaces the native RuntimeError from as_asyncio().
855    // Attests HDL-6.
856    #[test]
857    fn await_off_loop_raises_runtime_error() {
858        ensure_python();
859        monarch_with_gil_blocking(GilSite::Test, |py| {
860            let handle = PyHandle::from_value(py.None()).unwrap();
861            let r = Py::new(py, handle).unwrap();
862            let err = PyHandle::__await__(r.borrow(py), py).unwrap_err();
863            assert!(
864                err.is_instance_of::<PyRuntimeError>(py),
865                "off a loop __await__ should raise RuntimeError"
866            );
867            assert!(
868                !err.is_instance_of::<WouldBlockRuntime>(py),
869                "__await__ must not raise WouldBlockRuntime"
870            );
871        });
872    }
873
874    // await on a pre-resolved handle resolves on a real loop (borrow-first, no
875    // hang) and yields the value.
876    // Attests HDL-2, HDL-7.
877    #[test]
878    fn await_resolves_ok_on_loop() {
879        ensure_python();
880        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
881            let value = 42i64.into_py_any(py).unwrap();
882            let handle = PyHandle::from_value(value).unwrap();
883            let h = Py::new(py, handle).unwrap();
884            let helper = loop_helper(py);
885            helper
886                .getattr("run_await")
887                .unwrap()
888                .call1((h,))
889                .unwrap()
890                .extract::<i64>()
891                .unwrap()
892        });
893        assert_eq!(got, 42, "await should resolve to the value on a real loop");
894    }
895
896    // await surfaces the stored error as a Python exception on a real loop.
897    // Attests HDL-2, HDL-7.
898    #[test]
899    fn await_resolves_err_on_loop() {
900        ensure_python();
901        let (tx, rx) = watch::channel(None);
902        tx.send(Some(Err(PyValueError::new_err("boom")))).unwrap();
903        let is_value_error = monarch_with_gil_blocking(GilSite::Test, |py| {
904            let handle = PyHandle {
905                core: HandleCore::new(rx, None, None),
906            };
907            let h = Py::new(py, handle).unwrap();
908            let helper = loop_helper(py);
909            let err = helper
910                .getattr("run_await")
911                .unwrap()
912                .call1((h,))
913                .unwrap_err();
914            err.is_instance_of::<PyValueError>(py)
915        });
916        assert!(is_value_error, "await should raise the stored exception");
917    }
918
919    // await resolves a still-pending handle once a producer completes it while
920    // the loop runs.
921    // Attests HDL-1, HDL-7.
922    #[test]
923    fn await_resolves_pending_on_loop() {
924        ensure_python();
925        let value = monarch_with_gil_blocking(GilSite::Test, |py| 11i64.into_py_any(py).unwrap());
926        let (tx, handle) = pending_handle();
927        get_tokio_runtime().spawn(async move {
928            tokio::time::sleep(std::time::Duration::from_millis(30)).await;
929            let _ = tx.send(Some(Ok(value)));
930        });
931        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
932            let h = Py::new(py, handle).unwrap();
933            let helper = loop_helper(py);
934            helper
935                .getattr("run_await")
936                .unwrap()
937                .call1((h,))
938                .unwrap()
939                .extract::<i64>()
940                .unwrap()
941        });
942        assert_eq!(got, 11, "await should resolve once the producer completes");
943    }
944
945    // Cancelling an as_asyncio() future does not cancel the handle. Here the
946    // handle is pre-resolved: the cancelled future's completion callback no-ops
947    // and the value stays observable via poll(). The live-producer case -- one
948    // observer's cancel leaves the producer running and other observers still
949    // resolving -- is `cancel_asyncio_future_does_not_stop_producer_or_observers`.
950    // Attests HDL-3, HDL-7.
951    #[test]
952    fn cancel_asyncio_future_does_not_cancel_handle() {
953        ensure_python();
954        let (cancelled, handle_resolved) =
955            monarch_with_gil_blocking(GilSite::Test, |py| -> (bool, bool) {
956                let value = 42i64.into_py_any(py).unwrap();
957                let handle = PyHandle::from_value(value).unwrap();
958                let h = Py::new(py, handle).unwrap();
959                let helper = loop_helper(py);
960                let res = helper.getattr("run_cancel").unwrap().call1((h,)).unwrap();
961                let tup = res.downcast::<PyTuple>().unwrap();
962                let cancelled = tup.get_item(0).unwrap().extract::<bool>().unwrap();
963                let poll_val = tup.get_item(1).unwrap();
964                (cancelled, !poll_val.is_none())
965            });
966        assert!(cancelled, "the asyncio future should be cancelled");
967        assert!(
968            handle_resolved,
969            "cancelling the future must not cancel the handle"
970        );
971    }
972
973    // The live-producer companion to `cancel_asyncio_future_does_not_cancel_handle`:
974    // with a still-pending handle and a real producer, cancelling one observer
975    // leaves the producer running and other observers still resolving. A second
976    // as_asyncio() future resolves to the value and the handle stays pollable; the
977    // cancelled observer still runs to completion, and the `complete_asyncio_future`
978    // it posts to the loop no-ops on the cancelled future.
979    // Attests HDL-3, HDL-7.
980    #[test]
981    fn cancel_asyncio_future_does_not_stop_producer_or_observers() {
982        ensure_python();
983        let value = monarch_with_gil_blocking(GilSite::Test, |py| 42i64.into_py_any(py).unwrap());
984        let (tx, handle) = pending_handle();
985        get_tokio_runtime().spawn(async move {
986            tokio::time::sleep(std::time::Duration::from_millis(30)).await;
987            let _ = tx.send(Some(Ok(value)));
988        });
989        let (cancelled, awaited, polled) =
990            monarch_with_gil_blocking(GilSite::Test, |py| -> (bool, i64, i64) {
991                let h = Py::new(py, handle).unwrap();
992                let helper = loop_helper(py);
993                let res = helper
994                    .getattr("run_cancel_then_await")
995                    .unwrap()
996                    .call1((h,))
997                    .unwrap();
998                let tup = res.downcast::<PyTuple>().unwrap();
999                (
1000                    tup.get_item(0).unwrap().extract::<bool>().unwrap(),
1001                    tup.get_item(1).unwrap().extract::<i64>().unwrap(),
1002                    tup.get_item(2).unwrap().extract::<i64>().unwrap(),
1003                )
1004            });
1005        assert!(cancelled, "the cancelled future should stay cancelled");
1006        assert_eq!(
1007            awaited, 42,
1008            "a second observer resolves even after the first future is cancelled"
1009        );
1010        assert_eq!(
1011            polled, 42,
1012            "the handle stays observable after one observer is cancelled"
1013        );
1014    }
1015
1016    // Two as_asyncio() futures from one handle both resolve on a real loop.
1017    // Attests HDL-3, HDL-7.
1018    #[test]
1019    fn two_asyncio_observers_resolve_on_loop() {
1020        ensure_python();
1021        let (a, b) = monarch_with_gil_blocking(GilSite::Test, |py| -> (i64, i64) {
1022            let value = 8i64.into_py_any(py).unwrap();
1023            let handle = PyHandle::from_value(value).unwrap();
1024            let h = Py::new(py, handle).unwrap();
1025            let helper = loop_helper(py);
1026            let res = helper.getattr("run_two").unwrap().call1((h,)).unwrap();
1027            let tup = res.downcast::<PyTuple>().unwrap();
1028            (
1029                tup.get_item(0).unwrap().extract::<i64>().unwrap(),
1030                tup.get_item(1).unwrap().extract::<i64>().unwrap(),
1031            )
1032        });
1033        assert_eq!((a, b), (8, 8), "both observers should resolve to the value");
1034    }
1035
1036    // Scheduling completion onto a closed loop returns an error rather than
1037    // panicking; the observer swallows this and logs.
1038    // Attests HDL-7.
1039    #[test]
1040    fn schedule_on_closed_loop_errs_not_panics() {
1041        ensure_python();
1042        monarch_with_gil_blocking(GilSite::Test, |py| {
1043            let asyncio = py.import("asyncio").unwrap();
1044            let event_loop = asyncio.call_method0("new_event_loop").unwrap();
1045            let fut = event_loop.call_method0("create_future").unwrap();
1046            event_loop.call_method0("close").unwrap();
1047            let result = schedule_completion(py, &event_loop, &fut, Ok(py.None()));
1048            assert!(
1049                result.is_err(),
1050                "call_soon_threadsafe on a closed loop should error, not panic"
1051            );
1052        });
1053    }
1054
1055    // A negative, NaN, or non-finite timeout is rejected with ValueError rather
1056    // than panicking in `Duration::from_secs_f64`.
1057    // Attests HDL-12.
1058    #[test]
1059    fn get_invalid_timeout_raises_value_error() {
1060        ensure_python();
1061        let (_tx, handle) = pending_handle();
1062        let r = monarch_with_gil_blocking(GilSite::Test, |py| Py::new(py, handle).unwrap());
1063        monarch_with_gil_blocking(GilSite::Test, |py| {
1064            for bad in [-1.0, f64::NAN, f64::INFINITY] {
1065                let err = PyHandle::get(r.borrow(py), py, Some(bad)).unwrap_err();
1066                assert!(
1067                    err.is_instance_of::<PyValueError>(py),
1068                    "get(timeout={bad}) should raise ValueError, not panic"
1069                );
1070            }
1071        });
1072    }
1073
1074    // On a running asyncio loop, get() warns (UserWarning) regardless of whether
1075    // the value is ready -- calling the blocking get() from a loop is the
1076    // anti-pattern being flagged. A ready value still returns after warning.
1077    // Attests HDL-8.
1078    #[test]
1079    fn get_on_asyncio_loop_warns() {
1080        ensure_python();
1081        let (value, warned) = monarch_with_gil_blocking(GilSite::Test, |py| -> (i64, bool) {
1082            let v = 5i64.into_py_any(py).unwrap();
1083            let handle = PyHandle::from_value(v).unwrap();
1084            let h = Py::new(py, handle).unwrap();
1085            let helper = loop_helper(py);
1086            let res = helper
1087                .getattr("run_get_on_loop")
1088                .unwrap()
1089                .call1((h,))
1090                .unwrap();
1091            let tup = res.downcast::<PyTuple>().unwrap();
1092            (
1093                tup.get_item(0).unwrap().extract::<i64>().unwrap(),
1094                tup.get_item(1).unwrap().extract::<bool>().unwrap(),
1095            )
1096        });
1097        assert_eq!(value, 5, "a ready value still returns after warning");
1098        assert!(
1099            warned,
1100            "get() on a running asyncio loop warns regardless of readiness"
1101        );
1102    }
1103
1104    // A producer error surfaces through the direct read paths: poll() returns it
1105    // and get() raises it (the primary sync API path, previously observed only
1106    // through await/as_asyncio).
1107    // Attests HDL-2.
1108    #[test]
1109    fn poll_and_get_surface_producer_error() {
1110        ensure_python();
1111        let (tx, handle) = pending_handle();
1112        tx.send(Some(Err(PyValueError::new_err("boom")))).unwrap();
1113        monarch_with_gil_blocking(GilSite::Test, |py| {
1114            let r = Py::new(py, handle).unwrap();
1115            let poll_err = r.borrow(py).poll().unwrap_err();
1116            assert!(
1117                poll_err.is_instance_of::<PyValueError>(py),
1118                "poll() should surface the producer error"
1119            );
1120            let get_err = PyHandle::get(r.borrow(py), py, None).unwrap_err();
1121            assert!(
1122                get_err.is_instance_of::<PyValueError>(py),
1123                "get() should raise the producer error"
1124            );
1125        });
1126    }
1127
1128    // A producer that drops its sender without sending surfaces a Python error
1129    // through wait_future (RecvError) rather than hanging; this path also backs
1130    // get()/PyShared.
1131    // Attests HDL-10.
1132    #[test]
1133    fn dropped_producer_surfaces_error() {
1134        ensure_python();
1135        let (tx, handle) = pending_handle();
1136        drop(tx);
1137        monarch_with_gil_blocking(GilSite::Test, |py| {
1138            let r = Py::new(py, handle).unwrap();
1139            let err = PyHandle::get(r.borrow(py), py, None).unwrap_err();
1140            assert!(
1141                err.is_instance_of::<pyo3::exceptions::PyException>(py),
1142                "a dropped producer should surface an exception, not hang"
1143            );
1144        });
1145    }
1146
1147    // complete_asyncio_future swallows InvalidStateError from an already-settled
1148    // (non-cancelled) future, and propagates any other setter error.
1149    // Attests HDL-7.
1150    #[test]
1151    fn complete_asyncio_future_swallows_and_propagates() {
1152        ensure_python();
1153        monarch_with_gil_blocking(GilSite::Test, |py| {
1154            let event_loop = py
1155                .import("asyncio")
1156                .unwrap()
1157                .call_method0("new_event_loop")
1158                .unwrap();
1159            let settled = event_loop.call_method0("create_future").unwrap();
1160            settled.call_method1("set_result", (1i64,)).unwrap();
1161            assert!(
1162                complete_asyncio_future(&settled, false, py.None()).is_ok(),
1163                "InvalidStateError from a settled future should be swallowed"
1164            );
1165            event_loop.call_method0("close").unwrap();
1166
1167            // A fake future whose setter raises a non-InvalidStateError: it must
1168            // propagate rather than be swallowed.
1169            let helper = PyModule::from_code(
1170                py,
1171                cr#"
1172class RaisingFuture:
1173    def cancelled(self):
1174        return False
1175
1176    def set_result(self, value):
1177        raise ValueError("nope")
1178"#,
1179                c"raising_future.py",
1180                c"raising_future",
1181            )
1182            .unwrap();
1183            let fake = helper.getattr("RaisingFuture").unwrap().call0().unwrap();
1184            let err = complete_asyncio_future(&fake, false, py.None()).unwrap_err();
1185            assert!(
1186                err.is_instance_of::<PyValueError>(py),
1187                "a non-InvalidStateError setter error should propagate"
1188            );
1189        });
1190    }
1191
1192    // get(timeout) whose producer resolves before the deadline returns the value
1193    // (the Ok branch of tokio::time::timeout, distinct from the timeout-fires and
1194    // bad-float cases).
1195    // Attests HDL-2, HDL-12.
1196    #[test]
1197    fn get_timeout_returns_value_before_deadline() {
1198        ensure_python();
1199        let value = monarch_with_gil_blocking(GilSite::Test, |py| 13i64.into_py_any(py).unwrap());
1200        let (tx, handle) = pending_handle();
1201        get_tokio_runtime().spawn(async move {
1202            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1203            let _ = tx.send(Some(Ok(value)));
1204        });
1205        let got = monarch_with_gil_blocking(GilSite::Test, |py| -> i64 {
1206            let r = Py::new(py, handle).unwrap();
1207            let out = PyHandle::get(r.borrow(py), py, Some(5.0)).unwrap();
1208            out.extract::<i64>(py).unwrap()
1209        });
1210        assert_eq!(got, 13, "a generous timeout returns the produced value");
1211    }
1212
1213    // Under a warnings-as-errors filter, a get() on a running loop escalates the
1214    // anti-pattern warning to an error -- even for a ready value -- rather than
1215    // silently returning. Warn-regardless means the strict caller sees it.
1216    // Attests HDL-8.
1217    #[test]
1218    fn get_on_loop_under_warnings_as_errors_raises() {
1219        ensure_python();
1220        let raised = monarch_with_gil_blocking(GilSite::Test, |py| -> bool {
1221            let v = 5i64.into_py_any(py).unwrap();
1222            let handle = PyHandle::from_value(v).unwrap();
1223            let h = Py::new(py, handle).unwrap();
1224            let helper = loop_helper(py);
1225            helper
1226                .getattr("run_get_on_loop_strict")
1227                .unwrap()
1228                .call1((h,))
1229                .is_err()
1230        });
1231        assert!(
1232            raised,
1233            "get() on a loop under warnings-as-errors should raise the escalated warning"
1234        );
1235    }
1236
1237    // The timeout is validated before the ready fast path, so an invalid timeout
1238    // raises ValueError even when the handle is already resolved (where get()
1239    // would otherwise return without blocking).
1240    // Attests HDL-12.
1241    #[test]
1242    fn get_invalid_timeout_on_ready_raises() {
1243        ensure_python();
1244        monarch_with_gil_blocking(GilSite::Test, |py| {
1245            let value = 7i64.into_py_any(py).unwrap();
1246            let handle = PyHandle::from_value(value).unwrap();
1247            let r = Py::new(py, handle).unwrap();
1248            let err = PyHandle::get(r.borrow(py), py, Some(f64::NAN)).unwrap_err();
1249            assert!(
1250                err.is_instance_of::<PyValueError>(py),
1251                "an invalid timeout must raise ValueError even for a ready handle"
1252            );
1253        });
1254    }
1255
1256    // A producer that drops its sender without sending surfaces through
1257    // as_asyncio/await (wait_ready's RecvError -> set_exception) as a raised
1258    // exception, not a hang -- the async mirror of dropped_producer_surfaces_error.
1259    // Attests HDL-10.
1260    #[test]
1261    fn as_asyncio_dropped_producer_raises_on_loop() {
1262        ensure_python();
1263        let (tx, handle) = pending_handle();
1264        drop(tx);
1265        let raised = monarch_with_gil_blocking(GilSite::Test, |py| -> bool {
1266            let h = Py::new(py, handle).unwrap();
1267            let helper = loop_helper(py);
1268            helper.getattr("run_await").unwrap().call1((h,)).is_err()
1269        });
1270        assert!(
1271            raised,
1272            "awaiting a handle whose producer dropped its sender should raise, not hang"
1273        );
1274    }
1275
1276    // A StopIteration producer error is wrapped in RuntimeError before
1277    // set_exception, since asyncio.Future.set_exception rejects a StopIteration
1278    // (PEP 479) with a TypeError that would hang the awaiter.
1279    // Attests HDL-7.
1280    #[test]
1281    fn complete_asyncio_future_wraps_stop_iteration() {
1282        ensure_python();
1283        monarch_with_gil_blocking(GilSite::Test, |py| {
1284            let event_loop = py
1285                .import("asyncio")
1286                .unwrap()
1287                .call_method0("new_event_loop")
1288                .unwrap();
1289            let fut = event_loop.call_method0("create_future").unwrap();
1290            let stop_iteration = py.get_type::<PyStopIteration>().call0().unwrap().unbind();
1291            // Must not raise (a raw set_exception(StopIteration) would TypeError).
1292            complete_asyncio_future(&fut, true, stop_iteration).unwrap();
1293            let exc = fut.call_method0("exception").unwrap();
1294            assert!(
1295                exc.is_instance_of::<PyRuntimeError>(),
1296                "a StopIteration producer error should be wrapped in RuntimeError"
1297            );
1298            assert!(
1299                !exc.is_instance_of::<PyStopIteration>(),
1300                "the wrapped error must not remain a StopIteration"
1301            );
1302            event_loop.call_method0("close").unwrap();
1303        });
1304    }
1305
1306    // A StopIteration SUBCLASS is also wrapped in RuntimeError: we wrap any
1307    // StopIteration, not just the exact type, so the awaiter never hits the
1308    // set_exception TypeError hang.
1309    // Attests HDL-7.
1310    #[test]
1311    fn complete_asyncio_future_wraps_stop_iteration_subclass() {
1312        ensure_python();
1313        monarch_with_gil_blocking(GilSite::Test, |py| {
1314            let event_loop = py
1315                .import("asyncio")
1316                .unwrap()
1317                .call_method0("new_event_loop")
1318                .unwrap();
1319            let fut = event_loop.call_method0("create_future").unwrap();
1320            let helper = PyModule::from_code(
1321                py,
1322                cr#"
1323class MyStop(StopIteration):
1324    pass
1325"#,
1326                c"my_stop.py",
1327                c"my_stop",
1328            )
1329            .unwrap();
1330            let subclass_exc = helper.getattr("MyStop").unwrap().call0().unwrap().unbind();
1331            complete_asyncio_future(&fut, true, subclass_exc).unwrap();
1332            let exc = fut.call_method0("exception").unwrap();
1333            assert!(
1334                exc.is_instance_of::<PyRuntimeError>(),
1335                "a StopIteration subclass should be wrapped in RuntimeError"
1336            );
1337            assert!(
1338                !exc.is_instance_of::<PyStopIteration>(),
1339                "the wrapped error must not remain a StopIteration"
1340            );
1341            event_loop.call_method0("close").unwrap();
1342        });
1343    }
1344
1345    // Dropping a core aborts its producing task only when `abort_on_drop` is set:
1346    // spawn_abortable's core aborts abandoned work, while a Handle's core
1347    // (abort_on_drop = None, HDL-4) leaves the producer running.
1348    // Attests HDL-4.
1349    #[test]
1350    fn drop_aborts_producer_only_when_abort_set() {
1351        use std::sync::Arc;
1352        use std::sync::atomic::AtomicBool;
1353        use std::sync::atomic::Ordering;
1354        use std::time::Duration;
1355
1356        for (abort, expect_completed) in [(true, false), (false, true)] {
1357            let completed = Arc::new(AtomicBool::new(false));
1358            let completed_in_task = Arc::clone(&completed);
1359            let (_tx, rx) = watch::channel::<Option<PyResult<Py<PyAny>>>>(None);
1360            let jh = get_tokio_runtime().spawn(async move {
1361                tokio::time::sleep(Duration::from_millis(50)).await;
1362                completed_in_task.store(true, Ordering::SeqCst);
1363            });
1364            let core = HandleCore::new(rx, abort.then(|| jh.abort_handle()), None);
1365            drop(core);
1366            std::thread::sleep(Duration::from_millis(250));
1367            assert_eq!(
1368                completed.load(Ordering::SeqCst),
1369                expect_completed,
1370                "abort={abort}: producer completion after drop should be {expect_completed}"
1371            );
1372        }
1373    }
1374
1375    // A Handle cannot be constructed from Python -- the pyclass has no #[new],
1376    // so calling the type object raises TypeError.
1377    // Attests HDL-11.
1378    #[test]
1379    fn handle_not_constructible_from_python() {
1380        ensure_python();
1381        monarch_with_gil_blocking(GilSite::Test, |py| {
1382            let handle_type = py.get_type::<PyHandle>();
1383            let err = handle_type.call0().unwrap_err();
1384            assert!(
1385                err.is_instance_of::<pyo3::exceptions::PyTypeError>(py),
1386                "constructing Handle() from Python should raise TypeError (no __new__)"
1387            );
1388        });
1389    }
1390}