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