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}