Skip to main content

monarch_hyperactor/
pytokio.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/// `pytokio` is Monarch's Python <-> Tokio async bridge.
10///
11/// It provides a small, *non-asyncio* async world where Python code
12/// can *compose* Rust/Tokio futures using `await`.
13///
14/// ## The core idea
15///
16/// In `pytokio`:
17///
18/// - `PythonTask` = a one-shot Rust/Tokio future that produces a
19///   Python value.
20/// - `from_coroutine` = wraps a Python coroutine as a Rust future
21///   that drives it.
22/// - `Shared` = an awaitable handle to a spawned background Tokio
23///   task.
24///
25/// More concretely:
26///
27/// - Rust bindings return a Python-visible `PythonTask`
28///   (`PyPythonTask`), which wraps a Rust `PythonTask` holding a
29///   boxed Tokio future returning `PyResult<Py<PyAny>>`.
30/// - `PythonTask.from_coroutine(coro)` wraps a *Python coroutine* as
31///   a `PythonTask` by creating a Rust/Tokio future that drives
32///   `coro.__await__()` (via `send`/`throw`) and awaits the
33///   `PythonTask`s it yields.
34/// - Python code may `await` a `PythonTask` / `Shared` **only** when
35///   running under `PythonTask.from_coroutine(...)`. Awaiting
36///   arbitrary Python awaitables (e.g. `asyncio` futures) is an
37///   error.
38/// - Calling `task.spawn()` / `spawn_abortable()` returns a `Shared`
39///   (`PyShared`), which yields the result of the background Tokio
40///   task running the original `PythonTask`.
41///
42/// This is intentionally *not* a general-purpose async bridge: it’s a
43/// way to use Python syntax to drive and compose Tokio futures.
44///
45/// ## Wrapping a Python coroutine
46///
47/// ```ignore
48/// async def work():
49///     x = await some_rust_binding()      # must yield PythonTask / Shared
50///     await PythonTask.sleep(0.1)        # also a PythonTask
51///     return x
52///
53/// task = PythonTask.from_coroutine(work())
54/// result = task.block_on()              # block the calling Python thread while a
55///                                       # Tokio runtime drives the task to completion
56/// ```
57///
58/// `from_coroutine` drives the coroutine by repeatedly resuming it
59/// and awaiting the `PythonTask`s it yields, using a Tokio runtime.
60///
61/// ## Spawning
62///
63/// `spawn()` runs a `PythonTask` on a background Tokio task and
64/// returns a `Shared` handle.
65///
66/// To `await` the handle, you must still be inside a
67/// `from_coroutine`-driven coroutine:
68///
69/// ```ignore
70/// async def work():
71///     task = some_rust_binding()
72///     shared = task.spawn()
73///     # ... do other work ...
74///     result = await shared             # valid here (inside from_coroutine world)
75///     return result
76///
77/// result = PythonTask.from_coroutine(work()).block_on()
78/// ```
79///
80/// In synchronous contexts, you can wait for a spawned task without
81/// `from_coroutine`:
82///
83/// ```ignore
84/// shared = task.spawn()
85/// result = shared.block_on()            # blocks the calling Python thread
86/// ```
87///
88/// If `spawn_abortable()` is used, dropping the returned `Shared`
89/// aborts the underlying Tokio task.
90///
91/// ## Context propagation
92///
93/// `from_coroutine` preserves Monarch’s `context()` across Tokio
94/// thread hops, so code calling `context()` inside a `PythonTask`
95/// sees the same actor context as the call site that constructed the
96/// task.
97use std::future::Future;
98use std::pin::Pin;
99
100use hyperactor_config::CONFIG;
101use hyperactor_config::ConfigAttr;
102use hyperactor_config::attrs::declare_attrs;
103use monarch_types::py_global;
104use pyo3::IntoPyObjectExt;
105use pyo3::exceptions::PyRuntimeError;
106use pyo3::exceptions::PyStopIteration;
107use pyo3::exceptions::PyTimeoutError;
108use pyo3::exceptions::PyValueError;
109use pyo3::prelude::*;
110use pyo3::types::PyNone;
111use pyo3::types::PyTuple;
112use pyo3::types::PyType;
113use tokio::sync::Mutex;
114use tokio::sync::watch;
115
116use crate::handle::HandleCore;
117use crate::handle::PyHandle;
118use crate::handle::send_result;
119use crate::pickle::reduce_shared;
120use crate::runtime::GilSite;
121use crate::runtime::get_tokio_runtime;
122use crate::runtime::monarch_with_gil;
123use crate::runtime::monarch_with_gil_blocking;
124use crate::runtime::signal_safe_block_on;
125
126declare_attrs! {
127    /// If true, capture a Python stack trace at `PythonTask` creation
128    /// time and log it when a spawned task errors but is never
129    /// awaited/polled.
130    @meta(CONFIG = ConfigAttr::new(
131        Some("MONARCH_HYPERACTOR_ENABLE_UNAWAITED_PYTHON_TASK_TRACEBACK".to_string()),
132        Some("enable_unawaited_python_task_traceback".to_string()),
133    ))
134    pub attr ENABLE_UNAWAITED_PYTHON_TASK_TRACEBACK: bool = false;
135}
136
137// Import Python helpers used for actor context propagation.
138// `context()` returns the current Monarch actor context.
139// `actor_mesh` is the module that owns the `_context` contextvar we
140// must manually set/restore when driving coroutines on Tokio threads.
141py_global!(context, "monarch._src.actor.actor_mesh", "context");
142py_global!(actor_mesh_module, "monarch._src.actor", "actor_mesh");
143
144/// Capture the current Python stack trace (creation call site) if
145/// `ENABLE_UNAWAITED_PYTHON_TASK_TRACEBACK` is enabled.
146///
147/// Returns `None` when disabled to avoid the overhead of
148/// `traceback.extract_stack()`.
149fn current_traceback() -> PyResult<Option<Py<PyAny>>> {
150    if hyperactor_config::global::get(ENABLE_UNAWAITED_PYTHON_TASK_TRACEBACK) {
151        monarch_with_gil_blocking(GilSite::Traceback, |py| {
152            Ok(Some(
153                py.import("traceback")?
154                    .call_method0("extract_stack")?
155                    .unbind(),
156            ))
157        })
158    } else {
159        Ok(None)
160    }
161}
162
163/// Helper struct to make a Rust/Tokio future (returning a Python
164/// result) passable in an actor message.
165///
166/// The future resolves to `PyResult<Py<PyAny>>` so it can return a
167/// Python value or raise a Python exception, and it is `Send +
168/// 'static` so it can cross thread/actor boundaries.
169///
170/// Also so that we don't have to write this massive type signature
171/// everywhere.
172pub(crate) struct PythonTask {
173    /// Boxed, pinned Rust/Tokio future producing a Python result,
174    /// protected so it can be taken/consumed exactly once when the
175    /// task is driven.
176    // Type decoder ring:
177    //
178    // Mutex<Pin<Box<dyn Future<Output = PyResult<Py<PyAny>>> + Send + 'static>>>
179    //   │     │   │   │                                        │      │
180    //   │     │   │   │                                        │      └─ owns all data, no dangling refs
181    //   │     │   │   │                                        └─ can cross thread boundaries
182    //   │     │   │   └─ any future type (type-erased)
183    //   │     │   └─ heap-allocated (because unsized)
184    //   │     └─ immovable (safe to poll self-referential futures)
185    //   └─ exclusive access for consumption
186    future: Mutex<Pin<Box<dyn Future<Output = PyResult<Py<PyAny>>> + Send + 'static>>>,
187
188    /// Optional Python stack trace captured at task construction
189    /// time, used to annotate logs when a spawned task errors but
190    /// nobody awaits/polls it.
191    traceback: Option<Py<PyAny>>,
192}
193
194impl PythonTask {
195    /// Construct a `PythonTask` from a Rust/Tokio future and an
196    /// optional captured Python traceback.
197    ///
198    /// The future is boxed and pinned so it can be stored in the
199    /// struct and later driven safely.
200    fn new_with_traceback(
201        fut: impl Future<Output = PyResult<Py<PyAny>>> + Send + 'static,
202        traceback: Option<Py<PyAny>>,
203    ) -> Self {
204        Self {
205            future: Mutex::new(Box::pin(fut)),
206            traceback,
207        }
208    }
209
210    /// Construct a `PythonTask`, capturing a creation-site traceback
211    /// if enabled by `ENABLE_UNAWAITED_PYTHON_TASK_TRACEBACK`.
212    pub(crate) fn new(
213        fut: impl Future<Output = PyResult<Py<PyAny>>> + Send + 'static,
214    ) -> PyResult<Self> {
215        Ok(Self::new_with_traceback(fut, current_traceback()?))
216    }
217
218    /// Return the optional captured creation-site traceback (if
219    /// enabled).
220    fn traceback(&self) -> &Option<Py<PyAny>> {
221        &self.traceback
222    }
223
224    /// Consume the task and return the boxed, pinned future.
225    ///
226    /// This is a one-shot operation: it moves the future out of the
227    /// struct so it can be driven to completion.
228    pub(crate) fn take(
229        self,
230    ) -> Pin<Box<dyn Future<Output = PyResult<Py<PyAny>>> + Send + 'static>> {
231        self.future.into_inner()
232    }
233}
234
235impl std::fmt::Debug for PythonTask {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        f.debug_struct("PythonTask")
238            .field("future", &"<PythonFuture>")
239            .finish()
240    }
241}
242
243/// Python-visible wrapper for a one-shot `PythonTask`.
244///
245/// Exposed to Python as
246/// `monarch._rust_bindings.monarch_hyperactor.pytokio.PythonTask`.
247/// This object owns the underlying Rust task and is *consumed* when
248/// it is run (e.g. via `spawn()`, `spawn_abortable()`, or
249/// `block_on()`), hence `inner: Option<_>`.
250#[pyclass(
251    name = "PythonTask",
252    module = "monarch._rust_bindings.monarch_hyperactor.pytokio"
253)]
254pub struct PyPythonTask {
255    inner: Option<PythonTask>,
256}
257
258impl From<PythonTask> for PyPythonTask {
259    fn from(task: PythonTask) -> Self {
260        Self { inner: Some(task) }
261    }
262}
263
264/// Minimal await-iterator used to implement Python's `__await__`
265/// protocol for pytokio.
266///
267/// This iterator yields the task object exactly once. The Rust-side
268/// coroutine driver (`from_coroutine`) resumes the Python coroutine
269/// and expects it to yield a `PythonTask` (or `Shared`) object back
270/// to Rust.
271#[pyclass(
272    name = "PythonTaskAwaitIterator",
273    module = "monarch._rust_bindings.monarch_hyperactor.pytokio"
274)]
275struct PythonTaskAwaitIterator {
276    value: Option<Py<PyAny>>,
277}
278
279impl PythonTaskAwaitIterator {
280    /// Create an await-iterator that will yield `task` exactly once.
281    fn new(task: Py<PyAny>) -> PythonTaskAwaitIterator {
282        PythonTaskAwaitIterator { value: Some(task) }
283    }
284}
285
286#[pymethods]
287impl PythonTaskAwaitIterator {
288    /// First `send(...)` yields the stored task; subsequent sends
289    /// raise `StopIteration`.
290    ///
291    /// Python's await machinery calls `send(None)` to advance the
292    /// iterator.
293    fn send(&mut self, value: Py<PyAny>) -> PyResult<Py<PyAny>> {
294        self.value
295            .take()
296            .ok_or_else(|| PyStopIteration::new_err((value,)))
297    }
298
299    /// Convert the thrown Python exception value into a `PyErr` and
300    /// surface it to Rust.
301    fn throw(&mut self, value: Py<PyAny>) -> PyResult<Py<PyAny>> {
302        Err(monarch_with_gil_blocking(GilSite::Convert, |py| {
303            PyErr::from_value(value.into_bound(py))
304        }))
305    }
306
307    /// Iterator protocol: `next(it)` is equivalent to
308    /// `it.send(None)`.
309    fn __next__(&mut self, py: Python<'_>) -> PyResult<Py<PyAny>> {
310        self.send(py.None())
311    }
312}
313
314impl PyPythonTask {
315    /// Construct a Python-visible `PythonTask` from a Rust future,
316    /// attaching an explicit creation-site traceback (if provided).
317    ///
318    /// The input future produces a Rust value `T`; on completion we
319    /// reacquire the GIL and convert `T` into a Python object
320    /// (`Py<PyAny>`).
321    fn new_with_traceback<F, T>(fut: F, traceback: Option<Py<PyAny>>) -> PyResult<Self>
322    where
323        F: Future<Output = PyResult<T>> + Send + 'static,
324        T: for<'py> IntoPyObject<'py> + Send,
325    {
326        Ok(PythonTask::new_with_traceback(
327            async {
328                let result = fut.await?;
329                monarch_with_gil(GilSite::Convert, |py| result.into_py_any(py)).await
330            },
331            traceback,
332        )
333        .into())
334    }
335
336    /// Construct a `PythonTask`, capturing a creation-site traceback
337    /// if enabled.
338    ///
339    /// See `new_with_traceback` for conversion semantics (`T` ->
340    /// Python object under the GIL).
341    pub fn new<F, T>(fut: F) -> PyResult<Self>
342    where
343        F: Future<Output = PyResult<T>> + Send + 'static,
344        T: for<'py> IntoPyObject<'py> + Send,
345    {
346        Self::new_with_traceback(fut, current_traceback()?)
347    }
348}
349
350impl PyPythonTask {
351    /// Consume this `PythonTask` and return the underlying Rust
352    /// future.
353    ///
354    /// This is a one-shot operation: after calling `take_task`, the
355    /// `PyPythonTask` is considered *consumed* and cannot be
356    /// spawned/awaited/blocked-on again.
357    pub fn take_task(
358        &mut self,
359    ) -> PyResult<Pin<Box<dyn Future<Output = Result<Py<PyAny>, PyErr>> + Send + 'static>>> {
360        self.inner
361            .take()
362            .map(|task| task.take())
363            .ok_or_else(|| PyValueError::new_err("PythonTask already consumed"))
364    }
365
366    /// Return the captured creation-site traceback (if enabled),
367    /// cloning it under the GIL.
368    ///
369    /// Fails if the task has already been consumed.
370    fn traceback(&self) -> PyResult<Option<Py<PyAny>>> {
371        if let Some(task) = &self.inner {
372            Ok(monarch_with_gil_blocking(GilSite::Traceback, |py| {
373                task.traceback().as_ref().map(|t| t.clone_ref(py))
374            }))
375        } else {
376            Err(PyValueError::new_err("PythonTask already consumed"))
377        }
378    }
379
380    /// Spawn this task onto the Tokio runtime and return a `Shared`
381    /// handle that *aborts on drop*.
382    ///
383    /// Use this when the underlying future is *abort-safe*
384    /// (cancellation-safe): dropping the returned `Shared` will call
385    /// `JoinHandle::abort()`, preventing the background task from
386    /// running forever.
387    ///
388    /// This is especially useful for long-lived or periodic tasks
389    /// (e.g. timers) where "nobody is awaiting the result anymore"
390    /// should stop the work.
391    ///
392    /// Like `spawn()`, this consumes the `PyPythonTask` (it can only
393    /// be spawned once).
394    pub(crate) fn spawn_abortable(&mut self) -> PyResult<PyShared> {
395        Ok(PyShared {
396            core: self.spawn_core(true)?,
397        })
398    }
399
400    /// Spawn this task onto the Tokio runtime and return the shared
401    /// `HandleCore` that observes its completion via the `watch` channel.
402    ///
403    /// `abort` decides whether dropping the core aborts the producing task
404    /// (`spawn_abortable`) or leaves it running (`spawn`/`spawn_handle`).
405    /// Consumes the task. Shared by `spawn`/`spawn_abortable`/`spawn_handle`.
406    fn spawn_core(&mut self, abort: bool) -> PyResult<HandleCore> {
407        let (tx, rx) = watch::channel(None);
408        let traceback = self.traceback()?;
409        // Clone the second owned copy under the same (single) GIL section, and
410        // only when a traceback was actually captured -- avoids a second GIL
411        // round-trip per spawn in the common (capture-disabled) case.
412        let traceback1 = traceback
413            .as_ref()
414            .map(|t| monarch_with_gil_blocking(GilSite::Traceback, |py| t.clone_ref(py)));
415        let task = self.take_task()?;
416        let handle = get_tokio_runtime().spawn(async move {
417            send_result(tx, task.await, traceback1);
418        });
419        Ok(HandleCore::new(
420            rx,
421            abort.then(|| handle.abort_handle()),
422            traceback,
423        ))
424    }
425}
426
427#[pymethods]
428impl PyPythonTask {
429    /// Run this task to completion synchronously on the embedded
430    /// Tokio runtime.
431    ///
432    /// This blocks the calling Python thread until the underlying
433    /// Rust future completes. Consumes the task (like `spawn`): the
434    /// `PyPythonTask` cannot be used again.
435    fn block_on(mut slf: PyRefMut<PyPythonTask>, py: Python<'_>) -> PyResult<Py<PyAny>> {
436        let task = slf.take_task()?;
437
438        // Mutable borrows of Python objects must be dropped before
439        // releasing the GIL. `signal_safe_block_on` releases the GIL;
440        // holding `slf` across that would make other Python access
441        // throw.
442        drop(slf);
443        signal_safe_block_on(py, task)?
444    }
445
446    /// Spawn this task onto the Tokio runtime and return a `Shared`
447    /// handle.
448    ///
449    /// The returned `Shared` is awaitable *inside* the
450    /// `from_coroutine` world, or may be waited on synchronously via
451    /// `Shared.block_on()`. Consumes the task.
452    pub(crate) fn spawn(&mut self) -> PyResult<PyShared> {
453        Ok(PyShared {
454            core: self.spawn_core(false)?,
455        })
456    }
457
458    /// Spawn this task onto the Tokio runtime and return an observe-only
459    /// `Handle`.
460    ///
461    /// Like `spawn`, but hands back the clean `Handle` (`get`/`poll`/
462    /// `as_asyncio`/`await`) rather than `Shared`; non-abortable on drop.
463    /// Consumes the task.
464    pub(crate) fn spawn_handle(&mut self) -> PyResult<PyHandle> {
465        Ok(PyHandle::from_core(self.spawn_core(false)?))
466    }
467
468    /// Implement Python's `await` protocol for `PythonTask`.
469    ///
470    /// This is only supported inside the `pytokio` world driven by
471    /// `PythonTask.from_coroutine`; attempting to `await` a
472    /// `PythonTask` while an `asyncio` event loop is running is an
473    /// error.
474    fn __await__(slf: PyRef<'_, Self>) -> PyResult<PythonTaskAwaitIterator> {
475        let py = slf.py();
476        let l = pyo3_async_runtimes::get_running_loop(py);
477        if l.is_ok() {
478            return Err(PyRuntimeError::new_err(
479                "Attempting to __await__ a PythonTask when the asyncio event loop is active. PythonTask objects should only be awaited in coroutines passed to PythonTask.from_coroutine",
480            ));
481        }
482
483        Ok(PythonTaskAwaitIterator::new(slf.into_py_any(py)?))
484    }
485
486    /// Wrap a Python coroutine into a `PythonTask` that is driven by
487    /// Tokio.
488    ///
489    /// This converts `coro` into its await-iterator
490    /// (`coro.__await__()`), then repeatedly resumes it via
491    /// `send`/`throw`. Whenever the coroutine yields a
492    /// `PythonTask`/`Shared`, we extract its underlying Rust future,
493    /// `await` it on Tokio, and feed the result back into the
494    /// coroutine on the next iteration.
495    ///
496    /// Inside this coroutine, `await` is only supported for pytokio
497    /// values (`PythonTask` / `Shared`). Awaiting arbitrary Python
498    /// awaitables (e.g. `asyncio` futures) is an error.
499    ///
500    /// The current Monarch `context()` is captured at construction
501    /// time and restored while running the coroutine so `context()`
502    /// inside the task reflects the call site that created it (even
503    /// across Tokio thread hops).
504    #[staticmethod]
505    fn from_coroutine(py: Python<'_>, coro: Py<PyAny>) -> PyResult<PyPythonTask> {
506        // context() used inside a PythonTask should inherit the value of
507        // context() from the context in which the PythonTask was constructed.
508        // We need to do this manually because the value of the contextvar isn't
509        // maintained inside the tokio runtime.
510        let monarch_context = context(py).call0()?.unbind();
511        PyPythonTask::new(async move {
512            let (coroutine_iterator, none) = monarch_with_gil(GilSite::AwaitDrive, |py| {
513                coro.into_bound(py)
514                    .call_method0("__await__")
515                    .map(|x| (x.unbind(), py.None()))
516            })
517            .await?;
518            let mut last: PyResult<Py<PyAny>> = Ok(none);
519            enum Action {
520                Return(Py<PyAny>),
521                Wait(Pin<Box<dyn Future<Output = Result<Py<PyAny>, PyErr>> + Send + 'static>>),
522            }
523            loop {
524                let action = monarch_with_gil(GilSite::AwaitDrive, |py| -> PyResult<Action> {
525                    // We may be executing in a new thread at this point, so we need to set the value
526                    // of context().
527                    let _context = actor_mesh_module(py).getattr("_context")?;
528                    let old_context = _context.call_method1("get", (PyNone::get(py),))?;
529                    _context
530                        .call_method1("set", (monarch_context.clone_ref(py),))
531                        .expect("failed to set _context");
532
533                    let result = match last {
534                        Ok(value) => coroutine_iterator.bind(py).call_method1("send", (value,)),
535                        Err(pyerr) => coroutine_iterator
536                            .bind(py)
537                            .call_method1("throw", (pyerr.into_value(py),)),
538                    };
539
540                    // Reset context() so that when this tokio thread yields, it has its original state.
541                    _context
542                        .call_method1("set", (old_context,))
543                        .expect("failed to restore _context");
544                    match result {
545                        Ok(task) => Ok(Action::Wait(
546                            task.extract::<Py<PyPythonTask>>()
547                                .map_err(Into::<PyErr>::into)
548                                .and_then(|t| t.borrow_mut(py).take_task())
549                                .unwrap_or_else(|pyerr| Box::pin(async move { Err(pyerr) })),
550                        )),
551                        Err(err) => {
552                            let err = err.into_pyobject(py)?.into_any();
553                            if err.is_instance_of::<PyStopIteration>() {
554                                Ok(Action::Return(
555                                    err.into_pyobject(py)?.getattr("value")?.unbind(),
556                                ))
557                            } else {
558                                Err(PyErr::from_value(err))
559                            }
560                        }
561                    }
562                })
563                .await?;
564                match action {
565                    Action::Return(x) => {
566                        return Ok(x);
567                    }
568                    Action::Wait(task) => {
569                        last = task.await;
570                    }
571                };
572            }
573        })
574    }
575
576    /// Wrap this task with a timeout and return a new `PythonTask`.
577    ///
578    /// Consumes the original task. If it does not complete within
579    /// `seconds`, the returned task fails with `TimeoutError`.
580    fn with_timeout(&mut self, seconds: f64) -> PyResult<PyPythonTask> {
581        // Reject a negative, NaN, or non-finite timeout with ValueError up front
582        // rather than panicking in Duration::from_secs_f64 on a Tokio worker
583        // thread (matching Handle.get(timeout)).
584        let duration = std::time::Duration::try_from_secs_f64(seconds)
585            .map_err(|e| PyValueError::new_err(format!("invalid timeout {seconds}: {e}")))?;
586        let tb = self.traceback()?;
587        let task = self.take_task()?;
588        PyPythonTask::new_with_traceback(
589            async move {
590                tokio::time::timeout(duration, task)
591                    .await
592                    .map_err(|_| PyTimeoutError::new_err(()))?
593            },
594            tb,
595        )
596    }
597
598    /// Run a Python callable on Tokio's blocking thread pool and
599    /// return a `Shared` handle.
600    ///
601    /// This is for CPU-bound or otherwise blocking Python work that
602    /// must not run on a Tokio async worker thread. The callable `f`
603    /// is executed via `tokio::spawn_blocking`, and its result (or
604    /// raised exception) is delivered through the returned `Shared`.
605    ///
606    /// The current Monarch `context()` is captured and restored while
607    /// running `f` so calls to `context()` from inside `f` see the
608    /// originating actor context.
609    #[staticmethod]
610    fn spawn_blocking(py: Python<'_>, f: Py<PyAny>) -> PyResult<PyShared> {
611        let (tx, rx) = watch::channel(None);
612        let traceback = current_traceback()?;
613        let traceback1 = traceback
614            .as_ref()
615            .map(|t| monarch_with_gil_blocking(GilSite::Traceback, |py| t.clone_ref(py)));
616        let monarch_context = context(py).call0()?.unbind();
617        // The `_context` contextvar needs to be propagated through to the thread that
618        // runs the blocking tokio task. Upon completion, the original value of `_context`
619        // is restored.
620        get_tokio_runtime().spawn_blocking(move || {
621            let result = monarch_with_gil_blocking(GilSite::AwaitDrive, |py| {
622                let _context = actor_mesh_module(py).getattr("_context")?;
623                let old_context = _context.call_method1("get", (PyNone::get(py),))?;
624                _context
625                    .call_method1("set", (monarch_context.clone_ref(py),))
626                    .expect("failed to set _context");
627                let result = f.call0(py);
628                _context
629                    .call_method1("set", (old_context,))
630                    .expect("failed to restore _context");
631                result
632            });
633            send_result(tx, result, traceback1);
634        });
635        Ok(PyShared {
636            core: HandleCore::new(rx, None, traceback),
637        })
638    }
639
640    /// Wait for the first task to complete and return `(result,
641    /// index)`.
642    ///
643    /// This consumes all input tasks (each is `take_task()`'d). The
644    /// returned task resolves to a tuple of the winning task's result
645    /// and its index in the input list.
646    #[staticmethod]
647    fn select_one(mut tasks: Vec<PyRefMut<'_, PyPythonTask>>) -> PyResult<PyPythonTask> {
648        if tasks.is_empty() {
649            return Err(PyValueError::new_err("Cannot select from empty task list"));
650        }
651
652        let mut futures = Vec::new();
653        for task_ref in tasks.iter_mut() {
654            futures.push(task_ref.take_task()?);
655        }
656
657        PyPythonTask::new(async move {
658            let (result, index, _remaining) = futures::future::select_all(futures).await;
659            result.map(|r| (r, index))
660        })
661    }
662
663    /// Sleep for `seconds` on the Tokio runtime.
664    #[staticmethod]
665    fn sleep(seconds: f64) -> PyResult<PyPythonTask> {
666        PyPythonTask::new(async move {
667            tokio::time::sleep(tokio::time::Duration::from_secs_f64(seconds)).await;
668            Ok(())
669        })
670    }
671
672    /// Support `PythonTask[T]` type syntax on the Python side (no
673    /// runtime effect).
674    #[classmethod]
675    fn __class_getitem__(cls: &Bound<'_, PyType>, _arg: Py<PyAny>) -> Py<PyAny> {
676        cls.clone().unbind().into()
677    }
678}
679
680/// Awaitable handle to a spawned background Tokio task.
681///
682/// `Shared` is returned by `PythonTask.spawn()` /
683/// `spawn_abortable()`. It carries a `watch` receiver that is
684/// fulfilled exactly once with the task's `PyResult<Py<PyAny>>`.
685///
686/// Usage:
687///   - `await shared` inside the `PythonTask.from_coroutine(...)`
688///     world, or
689///   - `shared.block_on()` to wait synchronously.
690///
691/// If `abort` is true (from `spawn_abortable()`), dropping this
692/// object aborts the underlying Tokio task via its `JoinHandle`.
693#[pyclass(
694    name = "Shared",
695    module = "monarch._rust_bindings.monarch_hyperactor.pytokio"
696)]
697pub struct PyShared {
698    /// The watch-channel core.
699    core: HandleCore,
700}
701
702impl PyShared {
703    /// Await this `Shared`'s result from Rust, yielding the resolved
704    /// `Py<PyAny>` (or the producer's `PyErr`).
705    ///
706    /// Returns the same future the Python `await` path drives, so a Rust
707    /// caller in another crate can resolve a `Shared[T]` inside an `async fn`
708    /// without a pickle round-trip or a blocking `block_on`.
709    pub fn wait_future(&self) -> impl Future<Output = PyResult<Py<PyAny>>> + Send + 'static {
710        self.core.wait_future()
711    }
712}
713
714#[cfg(test)]
715impl PyShared {
716    /// A `Shared` that stays pending until the returned sender publishes.
717    ///
718    /// Handing back the sender lets a test both control completion and read
719    /// `receiver_count()`. `task()` and `wait_future()` clone the receiver
720    /// eagerly and hold it for the life of the waiter, so the count witnesses
721    /// waiter retention specifically. It is not a witness for observation in
722    /// general: `poll()` reads the watch value through a borrow and clones
723    /// nothing, so it leaves the count unmoved.
724    pub(crate) fn pending() -> (watch::Sender<Option<PyResult<Py<PyAny>>>>, Self) {
725        let (tx, rx) = watch::channel(None);
726        (
727            tx,
728            Self {
729                core: HandleCore::new(rx, None, None),
730            },
731        )
732    }
733}
734
735#[pymethods]
736impl PyShared {
737    /// Convert this `Shared` handle into a `PythonTask` that waits
738    /// for its result.
739    ///
740    /// Internally, this clones the `watch::Receiver` and returns a
741    /// new one-shot task that:
742    ///   1) waits for the sender to publish `Some(result)`, and then
743    ///   2) returns/clones the stored `Py<PyAny>` / `PyErr` under the
744    ///      GIL.
745    ///
746    /// Cloning the receiver allows multiple independent awaiters to
747    /// observe the same completion.
748    pub(crate) fn task(&self) -> PyResult<PyPythonTask> {
749        PyPythonTask::new_with_traceback(self.core.wait_future(), self.core.traceback_clone())
750    }
751
752    /// Implement Python's `await` protocol for `Shared`.
753    ///
754    /// This delegates to `self.task()` (which returns a `PythonTask`
755    /// that waits for the background result) and then returns that
756    /// task's await-iterator.
757    ///
758    /// Note: `await shared` is only supported inside the
759    /// `PythonTask.from_coroutine(...)` world (because it ultimately
760    /// awaits a `PythonTask`).
761    fn __await__(&self, py: Python<'_>) -> PyResult<PythonTaskAwaitIterator> {
762        let task = self.task()?;
763        Ok(PythonTaskAwaitIterator::new(task.into_py_any(py)?))
764    }
765
766    /// Wait synchronously for this `Shared` to resolve.
767    ///
768    /// This blocks the calling Python thread until the underlying
769    /// background task has published its result into the watch
770    /// channel, then returns that `Py<PyAny>` (or raises the stored
771    /// Python exception).
772    ///
773    /// If the value is already available, returns immediately without
774    /// blocking. This is important for cases where `block_on` is called
775    /// from within a tokio runtime (e.g., during unpickling on a worker
776    /// thread) - we can't call `runtime.block_on()` from within a runtime.
777    pub fn block_on(slf: PyRef<PyShared>, py: Python<'_>) -> PyResult<Py<PyAny>> {
778        // Check if value is already available - return immediately if so.
779        // This avoids calling into the tokio runtime when unnecessary,
780        // which is critical when called from within a tokio worker thread.
781        if let Some(value) = slf.poll()? {
782            return Ok(value);
783        }
784
785        // Unlike `Handle::get()`, block_on() deliberately does NOT raise
786        // WouldBlockRuntime for a still-pending value inside a Tokio runtime.
787        // Blocking there panics the runtime loudly, which is preferable to a
788        // silent deadlock for the pending mesh bare-pickle path that relies on
789        // this (`reduce_shared` blocks a pending `Shared` during pickling), and
790        // the common multiprocessing case is unaffected. This trade was
791        // deliberately chosen; do not change it to raise.
792        let wait = slf.core.wait_future();
793        // Explicitly drop the reference so that if another thread attempts to borrow
794        // this object mutably during signal_safe_block_on, it won't throw an exception.
795        drop(slf);
796        // The pending branch is now committed: `poll()` above returned `None` and the
797        // next statement blocks. A characterization test releases its producer here so
798        // that it cannot resolve early and let the ready fast path stand in for a
799        // genuine block.
800        #[cfg(test)]
801        notify_pending_block_on();
802        signal_safe_block_on(py, wait)?
803    }
804
805    /// Support `Shared[T]` type syntax on the Python side (no runtime
806    /// effect).
807    #[classmethod]
808    fn __class_getitem__(cls: &Bound<'_, PyType>, _arg: Py<PyAny>) -> Py<PyAny> {
809        cls.clone().unbind().into()
810    }
811
812    /// Non-blocking check for completion.
813    ///
814    /// Returns:
815    ///   - `Ok(None)` if the background task has not finished yet,
816    ///   - `Ok(Some(obj))` if it completed successfully,
817    ///   - `Err(pyerr)` if it completed with an exception.
818    ///
819    /// This does not wait; it only inspects the current watch value.
820    pub(crate) fn poll(&self) -> PyResult<Option<Py<PyAny>>> {
821        self.core.poll()
822    }
823
824    /// Construct a `Shared` that is already completed with `value`.
825    ///
826    /// This is a convenience for APIs that want to return a `Shared`
827    /// without spawning a background task. The returned handle has no
828    /// `JoinHandle` and will immediately yield `value` via `poll()`,
829    /// `await` (inside `from_coroutine`), or `block_on()`.
830    #[classmethod]
831    fn from_value(_cls: &Bound<'_, PyType>, value: Py<PyAny>) -> PyResult<Self> {
832        Ok(Self {
833            core: HandleCore::from_value(value)?,
834        })
835    }
836
837    /// Pickle protocol support for PyShared.
838    ///
839    /// Delegates to `reduce_shared`: a finished shared pickles as
840    /// `(Shared.from_value, (value,))`; a pending one blocks on the shared and
841    /// then pickles the resolved value. Mesh references do not take this generic
842    /// path -- their own reducers record a `MeshRef` in the message's
843    /// out-of-band `refs` table, a pending mesh's slot filled sender-side (by
844    /// awaiting the handle) before the send.
845    fn __reduce__<'py>(
846        slf: &Bound<'py, Self>,
847        py: Python<'py>,
848    ) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyTuple>)> {
849        reduce_shared(py, slf)
850    }
851}
852
853/// Register the pytokio Python bindings into the given module.
854///
855/// This wires up the legacy `PythonTask` and `Shared` pyclasses.
856pub fn register_python_bindings(hyperactor_mod: &Bound<'_, PyModule>) -> PyResult<()> {
857    hyperactor_mod.add_class::<PyPythonTask>()?;
858    hyperactor_mod.add_class::<PyShared>()?;
859
860    Ok(())
861}
862
863#[cfg(test)]
864pub(crate) use crate::runtime::ensure_python;
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    // with_timeout validates the seconds up front, raising ValueError for a
871    // negative/NaN/non-finite timeout rather than panicking in
872    // Duration::from_secs_f64 on a worker thread (matching Handle.get(timeout)).
873    #[test]
874    fn with_timeout_rejects_invalid_seconds() {
875        ensure_python();
876        monarch_with_gil_blocking(GilSite::Test, |py| {
877            for bad in [-1.0, f64::NAN, f64::INFINITY] {
878                let mut task = PyPythonTask::sleep(3600.0).unwrap();
879                let err = task
880                    .with_timeout(bad)
881                    .err()
882                    .expect("with_timeout should reject an invalid timeout");
883                assert!(
884                    err.is_instance_of::<PyValueError>(py),
885                    "with_timeout({bad}) should raise ValueError, not panic"
886                );
887            }
888        });
889    }
890}
891
892/// Test-only support that must live in this module.
893///
894/// These are associated functions on a type callers already import, so no call
895/// site needs a `crate::pytokio::` path. That is an ergonomic choice, not a
896/// census one: `collapse()` keys `helper_symbol` on (path, captured symbol,
897/// operation), and `actor_mesh.rs` already claims the `PyPythonTask` capture
898/// through its `use`, so a qualified spelling would add no hit either way.
899/// Task *construction* is a counted operation and belongs at its real call
900/// site, not behind a forwarder here.
901#[cfg(test)]
902mod test_support {
903    use std::cell::RefCell;
904    use std::sync::mpsc::SyncSender;
905
906    thread_local! {
907        /// Where the next `PyShared::block_on` pending acknowledgement on this
908        /// thread should go. Caller-thread scoped rather than process-global so
909        /// parallel tests in one binary cannot observe each other's signal.
910        static PENDING_BLOCK_ON: RefCell<Option<SyncSender<()>>> =
911            const { RefCell::new(None) };
912    }
913
914    /// Restores the previous routing when dropped, so a test scope cannot leak
915    /// its sender into an unrelated test that reuses the thread.
916    pub(crate) struct PendingBlockOnGuard {
917        previous: Option<SyncSender<()>>,
918    }
919
920    impl Drop for PendingBlockOnGuard {
921        fn drop(&mut self) {
922            let previous = self.previous.take();
923            PENDING_BLOCK_ON.with(|slot| *slot.borrow_mut() = previous);
924        }
925    }
926
927    pub(crate) fn route_pending_block_on(tx: SyncSender<()>) -> PendingBlockOnGuard {
928        let previous = PENDING_BLOCK_ON.with(|slot| slot.borrow_mut().replace(tx));
929        PendingBlockOnGuard { previous }
930    }
931
932    pub(crate) fn notify_pending_block_on() {
933        PENDING_BLOCK_ON.with(|slot| {
934            if let Some(tx) = slot.borrow().as_ref() {
935                // A full or closed channel means the test is no longer waiting.
936                let _ = tx.try_send(());
937            }
938        });
939    }
940}
941
942#[cfg(test)]
943use test_support::notify_pending_block_on;
944
945#[cfg(test)]
946impl PyPythonTask {
947    /// Route the next pending-`block_on` acknowledgement on this thread to `tx`.
948    ///
949    /// Dropping the returned guard restores the previous routing.
950    pub(crate) fn route_pending_block_on(
951        tx: std::sync::mpsc::SyncSender<()>,
952    ) -> test_support::PendingBlockOnGuard {
953        test_support::route_pending_block_on(tx)
954    }
955
956    /// Make `monarch._rust_bindings.monarch_hyperactor.pytokio.Shared` resolvable
957    /// from `sys.modules` in a Rust unit binary, where the extension module is
958    /// not loaded.
959    ///
960    /// `shared_class()` resolves through `PyOnceLock::import(..).unwrap()`, so a
961    /// missing module aborts the frame rather than raising. Both `shared_class`
962    /// sites cache independently but read the same `sys.modules`, so one install
963    /// serves both.
964    ///
965    /// Conservative by construction: existing parents are reused, only missing
966    /// children are created, nothing already present is replaced, and the
967    /// hierarchy stays resident. The GIL held for the whole walk is what makes
968    /// it atomic against other tests in the same binary.
969    pub(crate) fn install_test_module(py: Python<'_>) -> PyResult<()> {
970        use pyo3::types::PyDict;
971
972        let sys_modules = py
973            .import("sys")?
974            .getattr("modules")?
975            .cast_into::<PyDict>()
976            .map_err(PyErr::from)?;
977
978        let mut path = String::new();
979        let mut parent: Option<Bound<'_, PyModule>> = None;
980        for part in "monarch._rust_bindings.monarch_hyperactor.pytokio".split('.') {
981            if !path.is_empty() {
982                path.push('.');
983            }
984            path.push_str(part);
985
986            let module = match sys_modules.get_item(&path)? {
987                Some(existing) => existing.cast_into::<PyModule>().map_err(PyErr::from)?,
988                None => {
989                    let created = PyModule::new(py, &path)?;
990                    sys_modules.set_item(&path, &created)?;
991                    created
992                }
993            };
994            if let Some(parent) = &parent
995                && parent.getattr(part).is_err()
996            {
997                parent.setattr(part, &module)?;
998            }
999            parent = Some(module);
1000        }
1001
1002        let leaf = parent.expect("the dotted module path is not empty");
1003        let ours = py.get_type::<PyShared>();
1004        match leaf.getattr("Shared") {
1005            Ok(existing) => {
1006                if !existing.is(&ours) {
1007                    return Err(PyRuntimeError::new_err(
1008                        "sys.modules already exposes a different Shared class; this binary's \
1009                         PyShared instances would be rejected by it",
1010                    ));
1011                }
1012            }
1013            Err(_) => leaf.setattr("Shared", &ours)?,
1014        }
1015        Ok(())
1016    }
1017}