Skip to main content

monarch_hyperactor/
runtime.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
9use std::cell::OnceCell as UnsyncOnceCell;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Mutex;
13use std::sync::OnceLock;
14use std::sync::atomic::AtomicUsize;
15use std::sync::atomic::Ordering;
16use std::thread;
17use std::time::Duration;
18use std::time::Instant;
19
20use anyhow::Result;
21use hyperactor::runtime_identity::RuntimeKind;
22use hyperactor::runtime_identity::shutdown_data_plane_runtimes;
23use hyperactor::runtime_identity::tag_current_thread;
24pub use monarch_gil::GilSite;
25pub use monarch_gil::force_unsanctioned_gil_on_control_plane;
26pub use monarch_gil::get_gil_on_control_plane;
27pub use monarch_gil::monarch_with_gil;
28pub use monarch_gil::monarch_with_gil_blocking;
29pub use monarch_gil::reset_gil_on_control_plane;
30use pyo3::PyResult;
31use pyo3::Python;
32use pyo3::exceptions::PyRuntimeError;
33use pyo3::prelude::*;
34use pyo3::types::PyAnyMethods;
35use pyo3_async_runtimes::TaskLocals;
36use tokio::runtime::Handle;
37use tokio::task;
38
39use crate::config::TOKIO_WORKER_THREADS;
40
41/// Attribute holding an actor's event loop on the `threading.Thread` running
42/// it. `threading` already tracks every live thread, so this needs no second
43/// registry and takes no lock on the actor-spawn path.
44const ACTOR_EVENT_LOOP_ATTRIBUTE: &str = "_monarch_actor_event_loop";
45/// Attribute marking the `threading.Thread` that drives a sync actor.
46const ACTOR_DRIVER_ATTRIBUTE: &str = "_monarch_actor_driver";
47const ACTOR_THREAD_REAPER_POLL_INTERVAL: Duration = Duration::from_millis(1);
48const ACTOR_THREAD_REAPER_TIMEOUT: Duration = Duration::from_millis(250);
49
50/// Global tokio runtime container.
51///
52/// `handle` is cheap to clone and is what callers receive from
53/// `get_tokio_runtime()`. Holding a `Handle` does not lock anything, so
54/// concurrent block_on calls from different threads do not contend.
55///
56/// `runtime` exists only so the atexit handler can take ownership and
57/// call `shutdown_timeout`. Under normal operation nothing locks it; the
58/// mutex is uncontended at shutdown.
59struct GlobalRuntime {
60    handle: Handle,
61    runtime: Mutex<Option<tokio::runtime::Runtime>>,
62}
63
64static INSTANCE: OnceLock<GlobalRuntime> = OnceLock::new();
65
66fn global_runtime() -> &'static GlobalRuntime {
67    INSTANCE.get_or_init(|| {
68        let worker_threads = hyperactor_config::global::get(TOKIO_WORKER_THREADS);
69        let mut builder = tokio::runtime::Builder::new_multi_thread();
70        if let Some(worker_threads) = worker_threads {
71            builder.worker_threads(worker_threads.get());
72        }
73        let runtime = builder
74            .thread_name_fn(|| {
75                static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
76                let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);
77                format!("monarch-pytokio-worker-{}", id)
78            })
79            // The shared control-plane runtime: stamp its workers (and
80            // blocking-pool threads) so GIL-entry sites can tell they are on the
81            // control plane. See `hyperactor::runtime_identity`.
82            .on_thread_start(|| tag_current_thread(RuntimeKind::ControlPlane))
83            .enable_all()
84            .build()
85            .unwrap();
86        let handle = runtime.handle().clone();
87        GlobalRuntime {
88            handle,
89            runtime: Mutex::new(Some(runtime)),
90        }
91    })
92}
93
94pub fn get_tokio_runtime() -> Handle {
95    global_runtime().handle.clone()
96}
97
98/// Return whether the current thread has entered any Tokio runtime context.
99///
100/// This deliberately uses Tokio's context rather than Monarch's runtime-kind
101/// tag: synchronous entry must be refused on foreign Tokio workers and blocking
102/// pools as well as Monarch's own runtime.
103#[pyfunction(name = "_is_in_tokio_runtime")]
104pub(crate) fn is_in_tokio_runtime() -> bool {
105    Handle::try_current().is_ok()
106}
107
108/// Ensure the embedded Python interpreter is initialized exactly once for
109/// Rust tests.
110#[cfg(test)]
111pub(crate) fn ensure_python() {
112    static INIT: OnceLock<()> = OnceLock::new();
113    INIT.get_or_init(Python::initialize);
114}
115
116/// Record an actor's event loop on the `threading.Thread` running it, so the
117/// interpreter-exit reaper can find it again.
118///
119/// This does not keep the loop alive any longer than it already lives:
120/// `TaskLocals` holds it for the life of the `PythonActor` regardless. What it
121/// buys is that no separate process-lifetime registry exists.
122pub(crate) fn mark_actor_event_loop_thread(
123    thread: &Bound<'_, PyAny>,
124    event_loop: &Bound<'_, PyAny>,
125) -> PyResult<()> {
126    thread.setattr(ACTOR_EVENT_LOOP_ATTRIBUTE, event_loop)
127}
128
129/// Mark a sync actor's driver thread so interpreter-exit shutdown waits for it
130/// to terminate.
131///
132/// The reaper does not signal this thread. The preceding Tokio shutdown closes
133/// its inbox senders, causing the driver to observe disconnection and return;
134/// the marker lets the reaper wait, with a deadline, for that exit.
135pub(crate) fn mark_actor_driver_thread(thread: &Bound<'_, PyAny>) -> PyResult<()> {
136    thread.setattr(ACTOR_DRIVER_ATTRIBUTE, true)
137}
138
139/// How many marked sync-actor driver threads are still alive.
140fn actor_driver_threads(py: Python<'_>) -> PyResult<usize> {
141    let threading = py.import("threading")?;
142    let mut count = 0;
143    for thread in threading.call_method0("enumerate")?.try_iter()? {
144        if thread?.hasattr(ACTOR_DRIVER_ATTRIBUTE)? {
145            count += 1;
146        }
147    }
148    Ok(count)
149}
150
151fn actor_event_loops(py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
152    let threading = py.import("threading")?;
153    let mut loops = Vec::new();
154    for thread in threading.call_method0("enumerate")?.try_iter()? {
155        let thread = thread?;
156        let Ok(event_loop) = thread.getattr(ACTOR_EVENT_LOOP_ATTRIBUTE) else {
157            continue;
158        };
159        loops.push(event_loop.unbind());
160    }
161    Ok(loops)
162}
163
164/// Stop marked async actor loops and wait for all marked actor threads to exit.
165///
166/// Each pass schedules `stop` on every async actor loop. Sync actor drivers
167/// receive no stop request here: the preceding Tokio shutdown closes their
168/// inbox senders, so this function only waits for those threads to return.
169/// Between passes it releases the GIL and sleeps, allowing both kinds of thread
170/// to finish. It returns zero when none remain, or the number still enumerable
171/// when `timeout` expires.
172///
173/// This is a bounded interpreter-exit backstop, not a process-wide creation
174/// barrier. The first empty snapshot returns, so it does not cover an actor
175/// thread created after that observation; normal actor teardown owns that
176/// lifecycle.
177///
178/// The deadline is checked after each complete pass, so the elapsed time may
179/// exceed `timeout` by one enumeration and one `call_soon_threadsafe` per async
180/// loop. The function deliberately polls instead of calling `Thread.join()` or
181/// `Thread.is_alive()`: on CPython 3.14, `is_alive()` may enter an unbounded OS
182/// thread join after `thread_is_exiting` is set.
183fn stop_actor_loops_and_wait_for_actor_threads(
184    py: Python<'_>,
185    timeout: Duration,
186) -> PyResult<usize> {
187    let deadline = Instant::now() + timeout;
188    loop {
189        let loops = actor_event_loops(py)?;
190        // Interpreter finalization is safe only after neither kind of Python
191        // actor thread can still acquire the GIL.
192        if loops.is_empty() && actor_driver_threads(py)? == 0 {
193            return Ok(0);
194        }
195        for event_loop in &loops {
196            let event_loop = event_loop.bind(py);
197            // `stop` must run on the loop's own thread, so schedule it rather
198            // than call it. A closed loop raises, which is the normal state
199            // for an actor that already drained.
200            let stop_result = event_loop
201                .getattr("stop")
202                .and_then(|stop| event_loop.call_method1("call_soon_threadsafe", (stop,)));
203            if let Err(err) = stop_result {
204                tracing::warn!(error = %err, "failed to stop actor event loop at interpreter exit");
205            }
206        }
207        if Instant::now() >= deadline {
208            // Re-enumerate: threads may have exited while this pass issued its
209            // stops, and the survivor count is what the caller reports.
210            return Ok(actor_event_loops(py)?.len() + actor_driver_threads(py)?);
211        }
212
213        // Releasing the GIL is what lets the loop threads run the scheduled stop,
214        // and the driver threads return.
215        let sleep_for = ACTOR_THREAD_REAPER_POLL_INTERVAL
216            .min(deadline.saturating_duration_since(Instant::now()));
217        py.detach(move || thread::sleep(sleep_for));
218    }
219}
220
221/// Reap async actor-loop and sync actor-driver threads before the interpreter
222/// finalizes.
223///
224/// `Py_FinalizeEx` runs Python `atexit` handlers *before* it publishes the
225/// finalizing thread state. While this handler runs, async loop threads can
226/// take the GIL to process their scheduled `stop`, and sync driver threads can
227/// take it to finish returning. After that point, CPython force-exits a
228/// surviving daemon thread that asks for the GIL with `pthread_exit`; unwinding
229/// that through pyo3's `catch_unwind` aborts the process.
230///
231/// This is an interpreter-exit guard, not the lifecycle repair: an async
232/// actor's cleanup still returns before its loop thread terminates, and the
233/// root client still publishes `Stopped` without running cleanup.
234fn reap_actor_threads(py: Python<'_>, timeout: Duration) {
235    let remaining = match stop_actor_loops_and_wait_for_actor_threads(py, timeout) {
236        Ok(remaining) => remaining,
237        Err(err) => {
238            tracing::warn!(error = %err, "failed to reap actor threads at interpreter exit");
239            return;
240        }
241    };
242    if remaining > 0 {
243        // An async callback that never yields or a sync driver that does not
244        // return can survive the deadline. Report it instead of hanging the
245        // interpreter-exit path.
246        tracing::warn!(
247            remaining,
248            "actor event-loop or driver threads survived interpreter-exit shutdown"
249        );
250    }
251}
252
253/// atexit handler that tears down the data-plane runtimes and the global Tokio runtime.
254///
255/// Callers obtain a cloned `Handle` from `get_tokio_runtime()` rather
256/// than a guard, so the `runtime` mutex is uncontended at shutdown. We
257/// can take ownership of the `Runtime` and call `shutdown_timeout`
258/// directly. If a worker thread is still inside `Handle::block_on` on a
259/// future that never resolves (e.g. a non-main thread that cannot
260/// observe SIGINT), `shutdown_timeout` aborts spawned tasks and returns
261/// after at most one second; the stuck worker is then a daemon thread
262/// that CPython kills on interpreter exit.
263#[pyfunction]
264pub fn shutdown_tokio_runtime(py: Python<'_>) {
265    // Called from Python's atexit, which holds the GIL. Release it so tokio
266    // worker threads can acquire it to complete their Python work.
267    py.detach(|| {
268        // Tear down the data-plane runtimes (e.g. rdma) first, while the
269        // control-plane runtime is still intact, so their GIL-taking workers
270        // stop before Py_Finalize.
271        shutdown_data_plane_runtimes(Duration::from_secs(1));
272        let Some(global) = INSTANCE.get() else {
273            return;
274        };
275        let Some(rt) = global.runtime.lock().unwrap().take() else {
276            return;
277        };
278        rt.shutdown_timeout(Duration::from_secs(1));
279    });
280
281    // Runtime shutdown may bypass normal actor cleanup, so always reap marked
282    // actor threads before Python begins finalization. The deadline bounds the
283    // interpreter-exit delay.
284    reap_actor_threads(py, ACTOR_THREAD_REAPER_TIMEOUT);
285}
286
287/// Stores the native thread ID of the main Python thread.
288/// This is lazily initialized on first call to `is_main_thread`.
289static MAIN_THREAD_NATIVE_ID: OnceLock<i64> = OnceLock::new();
290
291/// Returns the native thread ID of the main Python thread.
292/// On first call, looks it up via `threading.main_thread().native_id`.
293fn get_main_thread_native_id() -> i64 {
294    *MAIN_THREAD_NATIVE_ID.get_or_init(|| {
295        monarch_with_gil_blocking(GilSite::Bootstrap, |py| {
296            let threading = py.import("threading").expect("failed to import threading");
297            let main_thread = threading
298                .call_method0("main_thread")
299                .expect("failed to get main_thread");
300            main_thread
301                .getattr("native_id")
302                .expect("failed to get native_id")
303                .extract::<i64>()
304                .expect("native_id is not an i64")
305        })
306    })
307}
308
309/// Returns the current thread's native ID in a cross-platform way.
310#[cfg(target_os = "linux")]
311fn get_current_thread_id() -> i64 {
312    nix::unistd::gettid().as_raw() as i64
313}
314
315/// Returns the current thread's native ID in a cross-platform way.
316#[cfg(target_os = "macos")]
317fn get_current_thread_id() -> i64 {
318    let mut tid: u64 = 0;
319    // pthread_threadid_np with thread=0 (null pthread_t) gets the current thread's ID.
320    unsafe {
321        let ret = libc::pthread_threadid_np(0, &mut tid);
322        debug_assert_eq!(
323            ret, 0,
324            "pthread_threadid_np failed with error code: {}",
325            ret
326        );
327    }
328    // macOS thread IDs are u64 so we need to convert to i64.
329    debug_assert!(tid <= i64::MAX as u64, "thread ID {} exceeds i64::MAX", tid);
330    tid as i64
331}
332
333/// Returns the current thread's native ID in a cross-platform way.
334#[cfg(not(any(target_os = "linux", target_os = "macos")))]
335compile_error!("get_current_thread_id is only implemented for Linux and macOS");
336
337/// Returns true if the current thread is the main Python thread.
338/// Compares the current thread's native ID against the main Python thread's native ID.
339pub fn is_main_thread() -> bool {
340    let current_tid = get_current_thread_id();
341    current_tid == get_main_thread_native_id()
342}
343
344pub fn initialize(py: Python) -> Result<()> {
345    // Eagerly initialize the main thread ID while we're on the main thread
346    // with the GIL held. If this were lazily initialized on a background
347    // tokio thread during shutdown, the `py.import("threading")` call inside
348    // get_main_thread_native_id() would trigger module_from_spec on a
349    // partially-finalized interpreter, causing a segfault.
350    let _ = get_main_thread_native_id();
351
352    let atexit = py.import("atexit")?;
353    let shutdown_fn = wrap_pyfunction!(shutdown_tokio_runtime, py)?;
354    atexit.call_method1("register", (shutdown_fn,))?;
355    Ok(())
356}
357
358/// Block the current thread on a future, but make sure to check for signals
359/// originating from the Python signal handler.
360///
361/// Python's signal handler just sets a flag that it expects the Python
362/// interpreter to handle later via a call to `PyErr_CheckSignals`. When we
363/// enter into potentially long-running native code, we need to make sure to be
364/// checking for signals frequently, otherwise we will ignore them. This will
365/// manifest as `ctrl-C` not doing anything.
366///
367/// One additional wrinkle is that `PyErr_CheckSignals` only works on the main
368/// Python thread; if it's called on any other thread it silently does nothing.
369/// So, we check if we're on the main thread by comparing native thread IDs.
370pub fn signal_safe_block_on<F>(py: Python, future: F) -> PyResult<F::Output>
371where
372    F: Future + Send + 'static,
373    F::Output: Send + 'static,
374{
375    let runtime = get_tokio_runtime();
376    // Release the GIL, otherwise the work in `future` that tries to acquire the
377    // GIL on another thread may deadlock.
378    py.detach(|| {
379        if is_main_thread() {
380            // Spawn the future onto the tokio runtime
381            let handle = runtime.spawn(future);
382            // Block the current thread on waiting for *either* the future to
383            // complete or a signal.
384            runtime.block_on(async {
385                tokio::select! {
386                    result = handle => result.map_err(|e| PyRuntimeError::new_err(format!("JoinErr: {:?}", e))),
387                    signal = async {
388                        let sleep_for = std::time::Duration::from_millis(100);
389                        loop {
390                            // Acquiring the GIL in a loop is sad, hopefully once
391                            // every 100ms is fine.
392                            monarch_with_gil_blocking(GilSite::AwaitDrive, |py| py.check_signals())?;
393                            tokio::time::sleep(sleep_for).await;
394                        }
395                    } => signal
396                }
397            })
398        } else {
399            // If we're not on the main thread, we can just block it. We've
400            // released the GIL, so the Python main thread will continue on, and
401            // `PyErr_CheckSignals` doesn't do anything anyway.
402            Ok(runtime.block_on(future))
403        }
404    })
405}
406
407/// A test function that sleeps indefinitely in a loop.
408/// This is used for testing signal handling in signal_safe_block_on.
409/// The function will sleep forever until interrupted by a signal.
410#[pyfunction]
411pub fn sleep_indefinitely_for_unit_tests(py: Python) -> PyResult<()> {
412    // Create a future that sleeps indefinitely
413    let future = async {
414        loop {
415            tracing::info!("idef sleeping for 100ms");
416            tokio::time::sleep(Duration::from_millis(100)).await;
417        }
418    };
419
420    // Use signal_safe_block_on to run the future, which should make it
421    // interruptible by signals like SIGINT
422    signal_safe_block_on(py, future)
423}
424
425/// Test hook for the interpreter-exit regression test.
426///
427/// `threading.Event.wait()` releases the GIL, so keeping this PyO3 frame on
428/// the stack while it waits is what puts a `catch_unwind` trampoline in the
429/// path of CPython's finalization unwind. Test-only; not part of any API.
430#[pyfunction]
431#[pyo3(name = "_wait_on_event_for_exit_test")]
432fn wait_on_event_for_exit_test(
433    entered: &Bound<'_, PyAny>,
434    release: &Bound<'_, PyAny>,
435) -> PyResult<()> {
436    entered.call_method0("set")?;
437    release.call_method0("wait")?;
438    Ok(())
439}
440
441/// Initialize the runtime module and expose Python functions
442pub fn register_python_bindings(runtime_mod: &Bound<'_, PyModule>) -> PyResult<()> {
443    let is_in_tokio_runtime_fn = wrap_pyfunction!(is_in_tokio_runtime, runtime_mod.py())?;
444    is_in_tokio_runtime_fn.setattr(
445        "__module__",
446        "monarch._rust_bindings.monarch_hyperactor.runtime",
447    )?;
448    runtime_mod.add_function(is_in_tokio_runtime_fn)?;
449
450    let sleep_indefinitely_fn =
451        wrap_pyfunction!(sleep_indefinitely_for_unit_tests, runtime_mod.py())?;
452    sleep_indefinitely_fn.setattr(
453        "__module__",
454        "monarch._rust_bindings.monarch_hyperactor.runtime",
455    )?;
456    runtime_mod.add_function(sleep_indefinitely_fn)?;
457
458    let wait_on_event_for_exit_test_fn =
459        wrap_pyfunction!(wait_on_event_for_exit_test, runtime_mod.py())?;
460    wait_on_event_for_exit_test_fn.setattr(
461        "__module__",
462        "monarch._rust_bindings.monarch_hyperactor.runtime",
463    )?;
464    runtime_mod.add_function(wait_on_event_for_exit_test_fn)?;
465
466    let get_gil_on_control_plane_fn = wrap_pyfunction!(get_gil_on_control_plane, runtime_mod.py())?;
467    get_gil_on_control_plane_fn.setattr(
468        "__module__",
469        "monarch._rust_bindings.monarch_hyperactor.runtime",
470    )?;
471    runtime_mod.add_function(get_gil_on_control_plane_fn)?;
472
473    let reset_gil_on_control_plane_fn =
474        wrap_pyfunction!(reset_gil_on_control_plane, runtime_mod.py())?;
475    reset_gil_on_control_plane_fn.setattr(
476        "__module__",
477        "monarch._rust_bindings.monarch_hyperactor.runtime",
478    )?;
479    runtime_mod.add_function(reset_gil_on_control_plane_fn)?;
480
481    let force_unsanctioned_gil_on_control_plane_fn =
482        wrap_pyfunction!(force_unsanctioned_gil_on_control_plane, runtime_mod.py())?;
483    force_unsanctioned_gil_on_control_plane_fn.setattr(
484        "__module__",
485        "monarch._rust_bindings.monarch_hyperactor.runtime",
486    )?;
487    runtime_mod.add_function(force_unsanctioned_gil_on_control_plane_fn)?;
488
489    Ok(())
490}
491
492struct SimpleRuntime;
493
494impl pyo3_async_runtimes::generic::Runtime for SimpleRuntime {
495    type JoinError = task::JoinError;
496    type JoinHandle = task::JoinHandle<()>;
497
498    fn spawn<F>(fut: F) -> Self::JoinHandle
499    where
500        F: Future<Output = ()> + Send + 'static,
501    {
502        get_tokio_runtime().spawn(async move {
503            fut.await;
504        })
505    }
506
507    fn spawn_blocking<F>(f: F) -> Self::JoinHandle
508    where
509        F: FnOnce() + Send + 'static,
510    {
511        get_tokio_runtime().spawn_blocking(f)
512    }
513}
514
515tokio::task_local! {
516    static TASK_LOCALS: UnsyncOnceCell<TaskLocals>;
517}
518
519impl pyo3_async_runtimes::generic::ContextExt for SimpleRuntime {
520    fn scope<F, R>(locals: TaskLocals, fut: F) -> Pin<Box<dyn Future<Output = R> + Send>>
521    where
522        F: Future<Output = R> + Send + 'static,
523    {
524        let cell = UnsyncOnceCell::new();
525        cell.set(locals).unwrap();
526
527        Box::pin(TASK_LOCALS.scope(cell, fut))
528    }
529
530    fn get_task_locals() -> Option<TaskLocals> {
531        TASK_LOCALS
532            .try_with(|c| c.get().cloned())
533            .unwrap_or_default()
534    }
535}
536
537pub fn future_into_py<F, T>(py: Python, fut: F) -> PyResult<Bound<PyAny>>
538where
539    F: Future<Output = PyResult<T>> + Send + 'static,
540    T: for<'py> IntoPyObject<'py> + Send + 'static,
541{
542    pyo3_async_runtimes::generic::future_into_py::<SimpleRuntime, F, T>(py, fut)
543}
544
545#[cfg(test)]
546mod tests {
547    use std::time::Instant;
548
549    use hyperactor::runtime_identity::RuntimeKind;
550    use hyperactor::runtime_identity::current_runtime_kind;
551    use pyo3::types::PyDict;
552
553    use super::*;
554
555    // The reaper is process-wide over `threading.enumerate()`, so two of these
556    // running concurrently would see each other's threads. A test-only static
557    // is fine; the no-new-global-state rule is about production code.
558    static ACTOR_THREAD_REAPER_TEST_LOCK: Mutex<()> = Mutex::new(());
559
560    fn start_marked_thread(
561        py: Python<'_>,
562        target: &Bound<'_, PyAny>,
563        event_loop: &Bound<'_, PyAny>,
564    ) {
565        let kwargs = PyDict::new(py);
566        kwargs
567            .set_item("target", target)
568            .expect("thread target should be set");
569        kwargs
570            .set_item("daemon", true)
571            .expect("thread should be made a daemon");
572        let thread = py
573            .import("threading")
574            .expect("threading should import")
575            .call_method("Thread", (), Some(&kwargs))
576            .expect("thread should be created");
577        mark_actor_event_loop_thread(&thread, event_loop)
578            .expect("actor event loop should attach to its owning thread");
579        thread.call_method0("start").expect("thread should start");
580    }
581
582    /// A marked thread actually running `run_forever`, so a scheduled `stop`
583    /// reaches it and the thread exits.
584    fn new_actor_event_loop(py: Python<'_>) -> Py<PyAny> {
585        let event_loop = py
586            .import("asyncio")
587            .expect("asyncio should import")
588            .call_method0("new_event_loop")
589            .expect("event loop should be created");
590        let target = event_loop
591            .getattr("run_forever")
592            .expect("event loop should have run_forever");
593        start_marked_thread(py, &target, &event_loop);
594        event_loop.unbind()
595    }
596
597    /// A marked thread wedged on `Event.wait()`, carrying a loop that never
598    /// runs, so the scheduled `stop` can never execute and the thread outlives
599    /// any deadline.
600    fn new_wedged_actor_event_loop(py: Python<'_>) -> (Py<PyAny>, Py<PyAny>) {
601        let event_loop = py
602            .import("asyncio")
603            .expect("asyncio should import")
604            .call_method0("new_event_loop")
605            .expect("event loop should be created");
606        let release = py
607            .import("threading")
608            .expect("threading should import")
609            .call_method0("Event")
610            .expect("release event should be created");
611        let target = release.getattr("wait").expect("event should have wait");
612        start_marked_thread(py, &target, &event_loop);
613        (event_loop.unbind(), release.unbind())
614    }
615
616    fn marked_loop_count(py: Python<'_>) -> usize {
617        actor_event_loops(py)
618            .expect("actor event-loop threads should enumerate")
619            .len()
620    }
621
622    #[test]
623    fn stop_actor_loops_and_wait_for_actor_threads_reaps_every_thread() {
624        let _guard = ACTOR_THREAD_REAPER_TEST_LOCK
625            .lock()
626            .expect("actor event-loop tests should not poison their lock");
627        pyo3::Python::initialize();
628        monarch_with_gil_blocking(GilSite::Test, |py| {
629            let loops = [new_actor_event_loop(py), new_actor_event_loop(py)];
630
631            assert_eq!(
632                stop_actor_loops_and_wait_for_actor_threads(py, Duration::from_secs(1))
633                    .expect("actor event-loop shutdown should succeed"),
634                0,
635                "all marked actor event-loop threads should exit before the deadline"
636            );
637            assert_eq!(
638                marked_loop_count(py),
639                0,
640                "stopped actor event-loop threads should leave threading.enumerate()"
641            );
642            for event_loop in loops {
643                event_loop
644                    .bind(py)
645                    .call_method0("close")
646                    .expect("stopped event loop should close");
647            }
648        });
649    }
650
651    #[test]
652    fn stop_actor_loops_and_wait_for_actor_threads_returns_at_deadline() {
653        let _guard = ACTOR_THREAD_REAPER_TEST_LOCK
654            .lock()
655            .expect("actor event-loop tests should not poison their lock");
656        pyo3::Python::initialize();
657        monarch_with_gil_blocking(GilSite::Test, |py| {
658            let (event_loop, release) = new_wedged_actor_event_loop(py);
659            let started = Instant::now();
660
661            assert_eq!(
662                stop_actor_loops_and_wait_for_actor_threads(py, Duration::from_millis(10))
663                    .expect("actor event-loop timeout should be observed"),
664                1,
665                "a wedged marked thread should survive the shutdown deadline"
666            );
667            assert!(
668                started.elapsed() < Duration::from_secs(1),
669                "actor event-loop shutdown should return near its deadline"
670            );
671
672            release
673                .bind(py)
674                .call_method0("set")
675                .expect("wedged thread should be released");
676            assert_eq!(
677                stop_actor_loops_and_wait_for_actor_threads(py, Duration::from_secs(1))
678                    .expect("released actor event-loop thread should be observed"),
679                0,
680                "released marked thread should exit"
681            );
682            event_loop
683                .bind(py)
684                .call_method0("close")
685                .expect("unused event loop should close");
686        });
687    }
688
689    /// A marked sync-actor driver thread blocked on `Event.wait()`, standing in
690    /// for a driver waiting on its inbox: it returns only when released.
691    fn new_marked_driver_thread(py: Python<'_>) -> Py<PyAny> {
692        let release = py
693            .import("threading")
694            .expect("threading should import")
695            .call_method0("Event")
696            .expect("release event should be created");
697        let kwargs = PyDict::new(py);
698        kwargs
699            .set_item(
700                "target",
701                release.getattr("wait").expect("event should have wait"),
702            )
703            .expect("thread target should be set");
704        kwargs
705            .set_item("daemon", true)
706            .expect("thread should be made a daemon");
707        let thread = py
708            .import("threading")
709            .expect("threading should import")
710            .call_method("Thread", (), Some(&kwargs))
711            .expect("thread should be created");
712        thread
713            .setattr(ACTOR_DRIVER_ATTRIBUTE, true)
714            .expect("driver thread should be marked");
715        thread.call_method0("start").expect("thread should start");
716        release.unbind()
717    }
718
719    // The pass schedules no `stop` for a driver: it waits for the driver to
720    // return, reports it as a survivor at the deadline, and sees it gone once
721    // it returns.
722    #[test]
723    fn stop_actor_loops_and_wait_for_actor_threads_waits_for_driver_threads() {
724        let _guard = ACTOR_THREAD_REAPER_TEST_LOCK
725            .lock()
726            .expect("actor event-loop tests should not poison their lock");
727        pyo3::Python::initialize();
728        monarch_with_gil_blocking(GilSite::Test, |py| {
729            let release = new_marked_driver_thread(py);
730
731            assert_eq!(
732                stop_actor_loops_and_wait_for_actor_threads(py, Duration::from_millis(10))
733                    .expect("driver-thread timeout should be observed"),
734                1,
735                "a driver that has not returned should survive the deadline"
736            );
737
738            release
739                .bind(py)
740                .call_method0("set")
741                .expect("driver thread should be released");
742            assert_eq!(
743                stop_actor_loops_and_wait_for_actor_threads(py, Duration::from_secs(1))
744                    .expect("released driver thread should be observed"),
745                0,
746                "a driver that returns should be waited for and gone"
747            );
748            assert_eq!(
749                actor_driver_threads(py).expect("driver threads should enumerate"),
750                0,
751                "the returned driver should leave threading.enumerate()"
752            );
753        });
754    }
755
756    // The documented bound is `timeout + one pass`. Measure the one-pass term
757    // with many loops so it is a behaviour rather than a claim, and so a
758    // regression to per-thread timeouts (N * timeout) would be caught.
759    #[test]
760    fn stop_actor_loops_and_wait_for_actor_threads_pass_cost_is_bounded() {
761        const LOOPS: usize = 32;
762        let _guard = ACTOR_THREAD_REAPER_TEST_LOCK
763            .lock()
764            .expect("actor event-loop tests should not poison their lock");
765        pyo3::Python::initialize();
766        monarch_with_gil_blocking(GilSite::Test, |py| {
767            let wedged: Vec<_> = (0..LOOPS)
768                .map(|_| new_wedged_actor_event_loop(py))
769                .collect();
770            let started = Instant::now();
771
772            assert_eq!(
773                stop_actor_loops_and_wait_for_actor_threads(py, Duration::from_millis(50))
774                    .expect("actor event-loop timeout should be observed"),
775                LOOPS,
776                "every wedged marked thread should survive the shutdown deadline"
777            );
778            // Per-thread timeouts would cost LOOPS * 50ms = 1.6s here.
779            assert!(
780                started.elapsed() < Duration::from_secs(1),
781                "one pass over {LOOPS} loops should be small next to the deadline, got {:?}",
782                started.elapsed()
783            );
784
785            for (_, release) in &wedged {
786                release
787                    .bind(py)
788                    .call_method0("set")
789                    .expect("wedged thread should be released");
790            }
791            assert_eq!(
792                stop_actor_loops_and_wait_for_actor_threads(py, Duration::from_secs(5))
793                    .expect("released actor event-loop threads should be observed"),
794                0,
795                "released marked threads should exit"
796            );
797            for (event_loop, _) in wedged {
798                event_loop
799                    .bind(py)
800                    .call_method0("close")
801                    .expect("unused event loop should close");
802            }
803        });
804    }
805
806    // The shared control-plane runtime stamps its worker threads ControlPlane.
807    #[test]
808    fn global_runtime_workers_are_control_plane() {
809        let kind = get_tokio_runtime().block_on(async {
810            tokio::spawn(async { current_runtime_kind() })
811                .await
812                .unwrap()
813        });
814        assert_eq!(kind, Some(RuntimeKind::ControlPlane));
815    }
816
817    // on_thread_start also reaches the blocking pool, so GIL work on a
818    // spawn_blocking thread is still seen as control-plane.
819    #[test]
820    fn global_runtime_blocking_pool_is_control_plane() {
821        let kind = get_tokio_runtime().block_on(async {
822            tokio::task::spawn_blocking(current_runtime_kind)
823                .await
824                .unwrap()
825        });
826        assert_eq!(kind, Some(RuntimeKind::ControlPlane));
827    }
828
829    // Attests HDL-6.
830    #[test]
831    fn runtime_context_predicate_detects_foreign_worker_and_blocking_pool() {
832        assert!(
833            !is_in_tokio_runtime(),
834            "the ordinary test thread should begin outside a Tokio runtime"
835        );
836
837        let runtime = tokio::runtime::Builder::new_multi_thread()
838            .worker_threads(1)
839            .enable_all()
840            .build()
841            .expect("foreign Tokio runtime should build");
842        let worker = runtime.spawn(async { is_in_tokio_runtime() });
843        let blocking = runtime.spawn_blocking(is_in_tokio_runtime);
844        let (worker, blocking) = runtime.block_on(async { tokio::join!(worker, blocking) });
845
846        assert!(
847            worker.expect("foreign runtime worker should join"),
848            "a foreign Tokio worker must be recognized as a runtime context"
849        );
850        assert!(
851            blocking.expect("foreign runtime blocking task should join"),
852            "a foreign Tokio blocking-pool thread must be recognized as a runtime context"
853        );
854    }
855}