1use 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
41const ACTOR_EVENT_LOOP_ATTRIBUTE: &str = "_monarch_actor_event_loop";
45const 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
50struct 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 .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#[pyfunction(name = "_is_in_tokio_runtime")]
104pub(crate) fn is_in_tokio_runtime() -> bool {
105 Handle::try_current().is_ok()
106}
107
108#[cfg(test)]
111pub(crate) fn ensure_python() {
112 static INIT: OnceLock<()> = OnceLock::new();
113 INIT.get_or_init(Python::initialize);
114}
115
116pub(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
129pub(crate) fn mark_actor_driver_thread(thread: &Bound<'_, PyAny>) -> PyResult<()> {
136 thread.setattr(ACTOR_DRIVER_ATTRIBUTE, true)
137}
138
139fn 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
164fn 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 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 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 return Ok(actor_event_loops(py)?.len() + actor_driver_threads(py)?);
211 }
212
213 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
221fn 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 tracing::warn!(
247 remaining,
248 "actor event-loop or driver threads survived interpreter-exit shutdown"
249 );
250 }
251}
252
253#[pyfunction]
264pub fn shutdown_tokio_runtime(py: Python<'_>) {
265 py.detach(|| {
268 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 reap_actor_threads(py, ACTOR_THREAD_REAPER_TIMEOUT);
285}
286
287static MAIN_THREAD_NATIVE_ID: OnceLock<i64> = OnceLock::new();
290
291fn 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#[cfg(target_os = "linux")]
311fn get_current_thread_id() -> i64 {
312 nix::unistd::gettid().as_raw() as i64
313}
314
315#[cfg(target_os = "macos")]
317fn get_current_thread_id() -> i64 {
318 let mut tid: u64 = 0;
319 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 debug_assert!(tid <= i64::MAX as u64, "thread ID {} exceeds i64::MAX", tid);
330 tid as i64
331}
332
333#[cfg(not(any(target_os = "linux", target_os = "macos")))]
335compile_error!("get_current_thread_id is only implemented for Linux and macOS");
336
337pub 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 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
358pub 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 py.detach(|| {
379 if is_main_thread() {
380 let handle = runtime.spawn(future);
382 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 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 Ok(runtime.block_on(future))
403 }
404 })
405}
406
407#[pyfunction]
411pub fn sleep_indefinitely_for_unit_tests(py: Python) -> PyResult<()> {
412 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 signal_safe_block_on(py, future)
423}
424
425#[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
441pub 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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}