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}