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}