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}