Skip to main content

monarch_hyperactor/
testing.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//! Private test-support bindings.
10//!
11//! Three unrelated pieces live here:
12//!
13//! 1. `TestStruct`, a minimal PyO3 struct for testing `@rust_struct` mixin
14//!    patching. The `#[pyclass]` module is set to the Python file that defines
15//!    the `@rust_struct`-decorated class so the name-validation check passes.
16//! 2. `_HandleProbe`, a closed probe that mints a real Rust-produced `Handle`
17//!    for the `Future`/`Handle` contract suite.
18//! 3. `_make_delayed_handle`, a `Handle` that publishes after registering its
19//!    first waiter, for the `returns_future` overhead benchmark.
20//!
21//! Everything here is private support: nothing is re-exported from `monarch`
22//! and no production Python imports it.
23
24use std::sync::Arc;
25use std::sync::Mutex;
26use std::sync::atomic::AtomicBool;
27use std::sync::atomic::Ordering;
28use std::time::Duration;
29use std::time::Instant;
30
31use pyo3::exceptions::PyKeyboardInterrupt;
32use pyo3::exceptions::PyValueError;
33use pyo3::prelude::*;
34use tokio::sync::oneshot;
35use tokio::sync::watch;
36
37use crate::handle::HandleCore;
38use crate::handle::PyHandle;
39use crate::handle::send_result;
40use crate::runtime::get_tokio_runtime;
41use crate::runtime::signal_safe_block_on;
42
43/// The value a successful probe publishes. Fixed, because the probe accepts no
44/// caller-supplied producer or value.
45pub(crate) const PROBE_SUCCESS_VALUE: i64 = 4242;
46
47/// The closed set of terminal outcomes the probe can produce.
48///
49/// Deliberately not a caller-supplied coroutine, awaitable, callable, future or
50/// producer function: the probe exists to put a real `PyHandle` into one of
51/// three reviewed terminal states, not to drive arbitrary work.
52#[derive(Clone, Copy)]
53enum ProbeOutcome {
54    Success,
55    Exception,
56    BaseException,
57}
58
59impl ProbeOutcome {
60    fn parse(name: &str) -> PyResult<Self> {
61        match name {
62            "success" => Ok(Self::Success),
63            "exception" => Ok(Self::Exception),
64            "base_exception" => Ok(Self::BaseException),
65            other => Err(PyValueError::new_err(format!(
66                "unknown probe outcome {other:?}; expected one of \
67                 'success', 'exception', 'base_exception'"
68            ))),
69        }
70    }
71
72    fn into_result(self) -> PyResult<i64> {
73        match self {
74            Self::Success => Ok(PROBE_SUCCESS_VALUE),
75            Self::Exception => Err(PyValueError::new_err("probe failure")),
76            Self::BaseException => Err(PyKeyboardInterrupt::new_err("probe base failure")),
77        }
78    }
79}
80
81/// A closed control object over one Rust-produced `Handle`.
82///
83/// The handle comes from the real [`PyHandle::spawn`] path (HDL-13), never from
84/// `PythonTask.spawn_handle()`, so the contract suite observes a genuine direct
85/// Rust producer. The producer parks on a release gate, so a test decides
86/// exactly when the terminal state becomes observable without sleeping.
87///
88/// Two limits are load-bearing, not stylistic:
89///
90/// * It must never accept arbitrary Python work -- no coroutine, awaitable,
91///   callable, future or producer function. Widening it to take one turns a
92///   contract fixture into a general async escape hatch, and every caller then
93///   depends on this module staying compiled into production.
94/// * It must never be cited as a real producer's oracle. Its timing is a
95///   release gate and its outcomes are three fixed cases, so it says nothing
96///   about when a real producer starts work or whether that work is abandoned
97///   when nobody observes it. Those belong to the producer's own tests.
98#[pyclass(
99    name = "_HandleProbe",
100    module = "monarch._rust_bindings.monarch_hyperactor.testing"
101)]
102pub struct PyHandleProbe {
103    handle: Py<PyHandle>,
104    release: Mutex<Option<oneshot::Sender<()>>>,
105    started: Arc<AtomicBool>,
106    completed: Arc<AtomicBool>,
107}
108
109#[pymethods]
110impl PyHandleProbe {
111    /// The observe-only `Handle` under test. Cloning the reference does not
112    /// consume it, so repeated lookups and observations are safe.
113    #[getter]
114    fn _handle(&self, py: Python<'_>) -> Py<PyHandle> {
115        self.handle.clone_ref(py)
116    }
117
118    /// Whether the producer body has begun.
119    ///
120    /// This can be true immediately after construction: the producer is eager,
121    /// so it enters its body and sets this *before* waiting on `_release`. Only
122    /// completion is gated, so `_completed` is the witness a test can assert a
123    /// negative on.
124    #[getter]
125    fn _started(&self) -> bool {
126        self.started.load(Ordering::SeqCst)
127    }
128
129    /// Whether the producer body has finished.
130    ///
131    /// Set just before the terminal value is published, so it is a witness for
132    /// assertions, not a synchronization primitive; use `_wait_completed` to
133    /// wait for the value to be observable.
134    #[getter]
135    fn _completed(&self) -> bool {
136        self.completed.load(Ordering::SeqCst)
137    }
138
139    /// Open the release gate so the producer can run to its terminal state.
140    ///
141    /// Idempotent: a second call is a no-op, so a test may release
142    /// unconditionally in a fixture teardown.
143    fn _release(&self) {
144        if let Some(tx) = self.release.lock().expect("probe mutex poisoned").take() {
145            let _ = tx.send(());
146        }
147    }
148
149    /// Block until the terminal state is published, discarding it.
150    ///
151    /// This is how a test reaches a genuinely *ready* `Handle` deterministically
152    /// -- the alternative is sleeping, which the contract suite forbids. The
153    /// terminal value is dropped here; the test observes it through the handle.
154    /// Must be called off a Tokio thread, like any other blocking observation.
155    fn _wait_completed(&self, py: Python<'_>) -> PyResult<()> {
156        let waiter = self.handle.bind(py).borrow().wait_completion();
157        // The probe waits for publication; whether the outcome was success or
158        // error is the test's business, observed through the handle.
159        signal_safe_block_on(py, async move {
160            let _ = waiter.await;
161        })
162    }
163}
164
165/// Build a probe whose handle will reach `outcome` once released.
166#[pyfunction]
167fn _make_handle_probe(py: Python<'_>, outcome: &str) -> PyResult<PyHandleProbe> {
168    let outcome = ProbeOutcome::parse(outcome)?;
169    let (tx, rx) = oneshot::channel::<()>();
170    let started = Arc::new(AtomicBool::new(false));
171    let completed = Arc::new(AtomicBool::new(false));
172    let started_producer = Arc::clone(&started);
173    let completed_producer = Arc::clone(&completed);
174
175    let handle = PyHandle::spawn(async move {
176        started_producer.store(true, Ordering::SeqCst);
177        // A dropped sender releases the gate too, so the producer cannot wedge
178        // if the probe is garbage collected mid-test.
179        let _ = rx.await;
180        let result = outcome.into_result();
181        completed_producer.store(true, Ordering::SeqCst);
182        result
183    });
184
185    Ok(PyHandleProbe {
186        handle: Py::new(py, handle)?,
187        release: Mutex::new(Some(tx)),
188        started,
189        completed,
190    })
191}
192
193/// A `Handle` that publishes `value` after registering its first waiter.
194///
195/// The producer publishes through `send_result` rather than
196/// [`PyHandle::spawn`], which takes the GIL to convert its value on
197/// completion, so completion takes no GIL. The core holds one receiver, and
198/// `get()` or `await` clones another; `poll()` does not. Once the producer
199/// detects the first clone, it waits at least `delay` before publishing. A
200/// later observer waits only for the remaining time. The producer yields
201/// between checks, so it remains runnable on Tokio until the first waiter is
202/// registered, and ends if the Handle is dropped unobserved. The time from its
203/// first poll until it detects that waiter is added to the gate totals read by
204/// `_delayed_handle_gate_stats`.
205fn delayed_handle(delay: Duration, value: Py<PyAny>) -> PyHandle {
206    let (tx, rx) = watch::channel(None);
207    get_tokio_runtime().spawn(async move {
208        let first_poll = Instant::now();
209        while tx.receiver_count() < 2 {
210            if tx.is_closed() {
211                return;
212            }
213            tokio::task::yield_now().await;
214        }
215        let gate = u64::try_from(first_poll.elapsed().as_nanos()).unwrap_or(u64::MAX);
216        {
217            let mut stats = GATE_STATS
218                .lock()
219                .expect("should not be poisoned: no holder can panic");
220            stats.0 += 1;
221            stats.1 = stats.1.saturating_add(gate);
222        }
223        if !delay.is_zero() {
224            tokio::time::sleep(delay).await;
225        }
226        send_result(tx, Ok(value), None);
227    });
228    PyHandle::from_core(HandleCore::new(rx, None, None))
229}
230
231/// Build a `Handle` that publishes `None` no sooner than `delay` seconds after
232/// detecting its first registered waiter.
233#[pyfunction]
234fn _make_delayed_handle(py: Python<'_>, delay: f64) -> PyResult<PyHandle> {
235    let delay = Duration::try_from_secs_f64(delay)
236        .map_err(|e| PyValueError::new_err(format!("invalid delay {delay}: {e}")))?;
237    Ok(delayed_handle(delay, py.None()))
238}
239
240/// Delayed Handles whose producer has detected its first waiter, and their
241/// total gate time in nanoseconds, since the last `_delayed_handle_gate_stats`.
242/// One lock guards both, so a read is a snapshot.
243static GATE_STATS: Mutex<(u64, u64)> = Mutex::new((0, 0));
244
245/// Return the number of delayed Handles whose gate opened, and their total
246/// gate time in nanoseconds, since the last call; then reset both.
247///
248/// Gate time is elapsed time, including any time other tasks ran between the
249/// producer's checks, so it is an upper bound on the gate's CPU.
250#[pyfunction]
251fn _delayed_handle_gate_stats() -> (u64, u64) {
252    std::mem::take(
253        &mut *GATE_STATS
254            .lock()
255            .expect("should not be poisoned: no holder can panic"),
256    )
257}
258
259#[pyclass(name = "TestStruct", module = "monarch._src.actor.testing")]
260pub struct PyTestStruct {
261    value: i64,
262}
263
264#[pymethods]
265impl PyTestStruct {
266    #[new]
267    fn new(value: i64) -> Self {
268        Self { value }
269    }
270
271    fn rust_method(&self) -> i64 {
272        self.value
273    }
274
275    fn shared_method(&self) -> String {
276        "from_rust".to_string()
277    }
278}
279
280#[pyfunction]
281fn _make_test_struct(value: i64) -> PyTestStruct {
282    PyTestStruct { value }
283}
284
285pub fn register_python_bindings(module: &Bound<'_, PyModule>) -> PyResult<()> {
286    module.add_class::<PyTestStruct>()?;
287    module.add_function(wrap_pyfunction!(_make_test_struct, module)?)?;
288    module.add_class::<PyHandleProbe>()?;
289    module.add_function(wrap_pyfunction!(_make_handle_probe, module)?)?;
290    module.add("_PROBE_SUCCESS_VALUE", PROBE_SUCCESS_VALUE)?;
291    module.add_function(wrap_pyfunction!(_make_delayed_handle, module)?)?;
292    module.add_function(wrap_pyfunction!(_delayed_handle_gate_stats, module)?)?;
293    crate::actor::register_testing_python_bindings(module)?;
294    Ok(())
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::runtime::GilSite;
301    use crate::runtime::ensure_python;
302    use crate::runtime::monarch_with_gil_blocking;
303
304    const DELAYS: [Duration; 2] = [Duration::ZERO, Duration::from_millis(10)];
305
306    fn none() -> Py<PyAny> {
307        monarch_with_gil_blocking(GilSite::Test, |py| py.None())
308    }
309
310    // With no observer, the producer never starts its delay.
311    #[test]
312    fn delayed_handle_waits_for_an_observer() {
313        ensure_python();
314        for delay in DELAYS {
315            let handle = delayed_handle(delay, none());
316            std::thread::sleep(Duration::from_millis(30));
317            assert!(
318                handle.poll_ready().unwrap().is_none(),
319                "an unobserved Handle with delay {delay:?} should still be pending"
320            );
321        }
322    }
323
324    // Once observed, it publishes `None`, no sooner than the delay.
325    #[test]
326    fn delayed_handle_publishes_after_its_delay() {
327        ensure_python();
328        for delay in DELAYS {
329            let handle = delayed_handle(delay, none());
330            let observed = Instant::now();
331            let wait = handle.wait_future();
332            // Bounded, so a broken gate fails here rather than at the target's
333            // timeout.
334            let value = get_tokio_runtime()
335                .block_on(async { tokio::time::timeout(Duration::from_secs(5), wait).await })
336                .expect("the Handle should publish within 5 s")
337                .unwrap();
338            assert!(
339                observed.elapsed() >= delay,
340                "the Handle published before its delay of {delay:?}"
341            );
342            monarch_with_gil_blocking(GilSite::Test, |py| {
343                assert!(value.is_none(py), "the Handle should publish None");
344            });
345        }
346    }
347}