Skip to main content

Module handle

Module handle 

Source
Expand description

Handle: an observe-only handle to a background Tokio task.

A Handle wraps a watch channel that a producer fulfills exactly once with the task’s PyResult<Py<PyAny>>. You observe its eventual result: synchronously via get(), on an asyncio loop via as_asyncio()/await, or without blocking via poll(). The channel is multi-observer, so a resolved value stays observable by any number of later observers.

The watch-channel mechanics live in [HandleCore].

§Handle invariants (HDL-*)

Code that relies on or enforces an invariant is tagged // HDL-N, and each #[cfg(test)] test that attests one names it; read the matching entry below before changing a tagged site.

Assumed of the shared core and producer (not enforced here):

  • HDL-1 (single terminal completion). The watch value goes None -> Some(_) exactly once and is never reset (Some -> Some, Some -> None). Relied on by [HandleCore::wait_future] (wait loop + expect) and [HandleCore::poll] (re-borrow across the GIL). Held by convention: every producer sends once, always Some(_). A send-once sender (a newtype consuming the watch::Sender) could make it structural, hardening poll’s re-borrow.
  • HDL-2 (value encoding). None is pending, Some(Ok(_)) success, Some(Err(_)) a producer error.

Guaranteed by this module:

  • HDL-3 (non-consuming reads). Every read clones the value out (clone_ref) rather than moving it, so any number of observers see the same result. Enforced by construction: no read path moves the value.
  • HDL-4 (drop does not cancel; drop never panics). Dropping a Handle never cancels its producer; a Handle observes, it does not own the producer’s lifecycle. Enforced by construction: a Handle’s core has abort_on_drop: None, and HandleCore::Drop aborts only when abort_on_drop is set. The abort is wrapped in catch_unwind, so an abort() that panics at interpreter shutdown (the Tokio runtime already gone) cannot escape Drop and abort the process.
  • HDL-5 (GIL discipline). No Python object is touched without the GIL, and every acquisition goes through monarch_with_gil{,_blocking}. Enforced by the crate’s #![deny(clippy::disallowed_methods)] ban on raw Python::with_gil/attach – a hard compile error, not a debug_assert.
  • HDL-6 (WouldBlockRuntime is get()’s alone). Only get() raises it, and only in a Tokio runtime context; as_asyncio()/__await__ off a loop raise the native RuntimeError instead.
  • HDL-7 (as_asyncio publish). The observer waits borrow-first (via wait_ready, never changed()-first) and sets a result only on a non-cancelled future, swallowing InvalidStateError; any StopIteration (including subclasses) is wrapped in RuntimeError first, since set_exception rejects StopIteration (PEP 479); if the loop has closed it logs rather than panics.
  • HDL-8 (get() loop-warning is call-pattern, not outcome). get() on a running asyncio loop warns regardless of whether the value is ready: it flags the blocking-call-from-a-loop anti-pattern, not whether this call happens to block. A ready value returns after warning; under a warnings-as-errors filter the warning escalates to an error.
  • HDL-9 (GIL/watch borrow discipline). A watch borrow is never held across a GIL acquisition or an .await. poll drops its read guard before taking the GIL and re-borrows under it (GIL-then-watch order, never the inverse against the producer’s write-lock send); the wait_ready/ wait_future loops drop the temporary Ref before each .await; and their returned futures own a cloned receiver (Send + 'static, borrowing nothing from &self), which is what lets get() drop(slf) + release the GIL before blocking and lets as_asyncio spawn the observer. Enforced partly by the compiler (Send bounds, the borrow checker on drop(slf)) and partly by construction; exercised by the multi-observer/blocking tests.
  • HDL-10 (dropped producer surfaces, never hangs). If every producer drops its sender without sending, the wait loop’s changed().await? yields a RecvError turned into a Python exception (to_py_error) on both the sync (get/wait_future) and async (as_asyncio) paths, so an observer raises rather than hanging forever.
  • HDL-11 (Handle is not constructible from Python). The pyclass has no #[new] and the only from_value constructor is #[cfg(test)]-gated, so a live Handle always wraps a producer-supplied core (upholding HDL-1/HDL-2); exposing a Python constructor would let user code mint an unresolvable core.
  • HDL-12 (get() timeout contract). get() validates the timeout up front (rejecting negative/NaN/non-finite as ValueError via try_from_secs_f64, never panicking) and before the ready fast path, so an invalid timeout raises deterministically even for a ready handle. A timeout is non-cancelling: it raises TimeoutError while leaving the producer and every observer untouched, so a later poll()/get()/await still observes completion.

Structs§

PyHandle
The observe-only handle to a background Tokio task.
WouldBlockRuntime
raised when Handle.get() is called from a Tokio runtime context