Expand description
Handle: an observe-only handle to a background operation.
A Handle wraps a watch channel that a producer fulfills exactly once with
the operation’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), [HandleCore::poll] (re-borrow across the GIL), andPyHandle::wait_completion(discriminant check then GIL re-borrow). Held by convention: every producer sends once, alwaysSome(_)(including the directPyHandle::spawnproducer). A send-once sender (a newtype consuming thewatch::Sender) could make it structural, hardeningpoll’s re-borrow. - HDL-2 (value encoding).
Noneis pending,Some(Ok(_))success,Some(Err(_))a producer error.
Guaranteed by this module:
- HDL-3 (non-consuming reads). No read moves the value out: a
value-extracting read clones it (
clone_ref), and a discriminant-only read (wait_completion’s success fast path) borrows just theOk/Errtag and clones nothing. Either way 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
Handlenever cancels its producer; aHandleobserves, it does not own the producer’s lifecycle. Enforced by construction: aHandle’s core hasabort_on_drop: None, andHandleCore::Dropaborts only whenabort_on_dropis set. The abort is wrapped incatch_unwind, so anabort()that panics at interpreter shutdown (the Tokio runtime already gone) cannot escapeDropand 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 rawPython::with_gil/attach– a hard compile error, not adebug_assert. - HDL-6 (
WouldBlockRuntimeisget()’s alone among the observers). Of theHandleobservers onlyget()raises it, and only in a Tokio runtime context;as_asyncio()/__await__off a loop raise the nativeRuntimeErrorinstead. The exception type itself is not private to this module: it marks synchronous APIs that refuse to enter or block on Tokio from an existing Tokio runtime context. The Python layer also raises it for fresh root-client bootstrap (monarch._src.actor.actor_mesh), the blocking worker-loop wrappers (monarch._src.actor.bootstrap), and areturns_future.get()that would block a running event loop (monarch._src.actor.returns_future, RF-7). - HDL-7 (
as_asynciopublish). The observer waits borrow-first (viawait_ready, neverchanged()-first). The result is published on the loop thread throughcomplete_asyncio_future, by the wake reader (HDL-17) or by the fallback’scall_soon_threadsafecallback (HDL-21), or, for a Handle already complete, byas_asyncioitself (HDL-22). It sets a result only on a non-cancelled future, swallowingInvalidStateError; anyStopIteration(including subclasses) is wrapped inRuntimeErrorfirst, sinceset_exceptionrejectsStopIteration(PEP 479). If the loop has closed, the fallback logs rather than panics and the wake reader never runs. A wake-reader failure, including a future whosedone()raises, is reported through the future’s loop exception handler, as asyncio reports an exception from a callback. - 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
watchborrow is never held across a GIL acquisition or an.await.pollandwait_completion’s error path drop their read guard before taking the GIL and re-borrow under it (GIL-then-watch order, never the inverse against the producer’s write-locksend); thewait_ready/wait_futureloops drop the temporaryRefbefore each.await; and their returned futures own a cloned receiver (Send + 'static, borrowing nothing from&self), which is what letsget()drop(slf)+ release the GIL before blocking and letsas_asynciospawnthe observer.as_asyncio’s fallback observer clones the value under the one GIL acquisition it makes to schedule; its wake observer never reads the value (HDL-17). Enforced partly by the compiler (Sendbounds, the borrow checker ondrop(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 aRecvErrorconverted toRuntimeError("Handle producer ended without publishing a result")on the non-blocking (poll), sync (get/wait_future), async (as_asyncio), andwait_completionpaths, so every observer receives the same producer-lifecycle failure rather than waiting forever. - HDL-11 (Python construction is paired). Neither
Handlenor_HandleCompleterhas#[new]. Internal Python may call the private_new_handle_pair(), which atomically creates a pendingHandleand its sole producer. The factory initializes the completer withSome(sender).set_exception()rejects a non-BaseExceptionbefore taking it; the firstset_result()or validset_exception()takes it, producing the only state transition toNone, and any later completion attempt raises. No sender clone escapes. Dropping the completer unresolved closes the channel and surfaces through HDL-10. Python therefore cannot create a pendingHandlewithout its producer. Native producers remain the default; this private pair is reserved for operations whose semantics and scheduling are owned by Python. - HDL-12 (
get()timeout contract).get()validates the timeout up front (rejecting negative/NaN/non-finite asValueErrorviatry_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 raisesTimeoutErrorwhile leaving the producer and every observer untouched, so a laterpoll()/get()/awaitstill observes completion. - HDL-13 (direct Rust producer). A Rust caller can eagerly produce a
Handlefrom aSend + 'staticfuture viaPyHandle::spawn, without aPyPythonTask: it spawns one producer that converts the terminal value toPy<PyAny>undermonarch_with_giland publishes it once through the sharedsend_resulthelper (same one-shot core and unobserved-error policy as pytokio’s producers, HDL-1/HDL-2), withabort_on_drop: None(HDL-4).PyHandle::wait_completionis the matching observer: it projects the terminal value toPyResult<()>, touching neither the GIL nor the Python value on the ready-success fast path (a discriminant-only read, HDL-3) and cloning thePyErrunder the HDL-9 borrow order only on the error path. Both arepubRust-only methods outside#[pymethods](HDL-11). - HDL-14 (Rust value observation is owned and non-consuming).
PyHandle::wait_futurereturns aSend + 'staticfuture over a cloned watch receiver. The waiter borrows nothing from thePyHandle, survives the wrapper being dropped, and leaves the terminal value available to every other observer. - HDL-15 (Rust ready construction starts no work).
PyHandle::from_valueconstructs an already-complete, non-aborting core without spawning a task. It is public to Rust callers but remains outside#[pymethods], preserving HDL-11’s direct-construction restriction. - HDL-16 (permanent Python identity).
HandleandWouldBlockRuntimehave the canonical modulemonarch._rust_bindings.monarch_hyperactor.handle. The legacypytokiomodule does not exportHandle. - HDL-17 (GIL-free completion wake). For a Handle still pending when
as_asynciois called, on a loop that acceptsadd_reader, the observer task holds aNotifier(a token, an mpsc sender and anArc<Waker>) and thewait_readyfuture. It publishes by pushing the token and writing the loop’s pipe, never reading the value and taking no GIL; the loop thread reads the value from theHandleand sets it (seecrate::handle_wake). Enforced by construction:Notifierhas noPy<_>, and the task body makes nomonarch_with_gilcall. - HDL-18 (notification ordering under supported pipe outcomes). A
notifier pushes its token before writing the pipe, and the reader drains
the pipe before the queue, never returning between the two; both retry
EINTR. A write that succeeds or returnsEAGAINleaves a byte pending, so a token pushed after a queue drain is delivered by a later wake.EPIPEor a disconnected queue means the channel is gone and the token is dropped. Any other errno is logged as a broken wake channel, not claimed as delivered. - HDL-19 (loop not retained). The wake registry holds each loop as a
weak key and its channel through a weak reference; the loop’s selector,
through the channel’s reader callback, is the channel’s only strong
owner. A channel holds no reference to its loop. It holds each pending
await’s asyncio future strongly until delivery, cancellation or loop
close, because asyncio holds tasks weakly and that reference is the
waiting task’s only owner outside the task’s own cycle;
__traverse__exposes each entry’s future andHandleobject to the garbage collector. Closing a loop therefore releases the channel-owned graph (the channel, its read end, its entries, their futures andHandles). The write end stays open until everyNotifieris dropped, so a loop closed while an await waits on aHandlethat never completes keeps that descriptor (HDL-20). A loop dropped without being closed while an await on a pendingHandleis registered is still freed by the collector. Not freed: a loop dropped without being closed while an undelivered entry’sHandlevalue references it, and any independent cycle through aHandle, such as a value that references its ownHandle. Both run throughPyHandle, which the garbage collector does not traverse. - HDL-20 (late wake is harmless). A notify after its channel is gone is
dropped without error or panic: the send fails, or the write gets
EPIPE. This requiresSIGPIPEto be ignored, as Rust’s runtime and CPython set it by default; in a process that restoresSIG_DFL, a completion that races loop teardown can kill the process. EveryNotifierowns the write end through itsArc<Waker>until itsHandlecompletes, so no write reaches a reused descriptor. - HDL-21 (fallback). For a Handle still pending, a loop whose
add_readerraisesNotImplementedErroror that cannot be weakly referenced uses thecall_soon_threadsafepath; HDL-7 applies there unchanged. - HDL-22 (ready shortcut).
as_asyncio()on a Handle that already holds its outcome, a value, a stored exception or producer loss (HDL-10), returns a loop future already completed with it throughcomplete_asyncio_future, with no observer task and no wake-channel registration. Awaiting it returns without giving the loop a turn, as for any finished asyncio future.
Structs§
- PyHandle
- The observe-only handle to a background Tokio task or Python-owned operation.
- Would
Block Runtime - raised when a synchronous API refuses to enter or block on Tokio from an existing Tokio runtime context – Handle.get(), fresh root-client bootstrap, or a blocking worker-loop wrapper – or when a returns_future .get() would block a running event loop
Functions§
- after_
ready - Run
operationonly afterreadyresolvesOk, returning the readiness error unchanged if it does not. - register_
python_ bindings - Register the permanent Handle Python bindings.
- to_
py_ error - Convert a Rust error into the generic Python
ValueErrorused by Monarch’s native bindings.