From b45e6c71d3ff07d82532c04e95b70cbd29d48535 Mon Sep 17 00:00:00 2001 From: ChilePiquin Date: Tue, 15 Sep 2026 10:29:38 -0600 Subject: [PATCH] fix(python): serialize runtime.rs tests against shared global state CI runs tests in parallel by default (unlike every local run in this branch's history, which always used --test-threads=1), and the five runtime:: tests all touch the same process-wide statics (RUNTIME, GENERATION, OUTSTANDING). Running concurrently, they interfered with each other: one test's in-flight task got reclaimed by a different test's concurrent shutdown() call, and another observed OUTSTANDING left non-zero by a still-running sibling -- both failed exactly this way in CI. Adds a test-only mutex each test acquires first, so they're mutually exclusive regardless of the harness's thread parallelism, matching the discipline the original single-test version of this suite documented before it was split into several. Verified locally without --test-threads=1 this time: 5/5 pass. Co-Authored-By: Claude Sonnet 5 --- python/src/runtime.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/python/src/runtime.rs b/python/src/runtime.rs index 8e52a85dc..6b316ae73 100644 --- a/python/src/runtime.rs +++ b/python/src/runtime.rs @@ -350,6 +350,22 @@ where mod tests { use super::*; + // RUNTIME, GENERATION, OUTSTANDING, and ATFORK_INSTALLED are process-wide + // statics, and Rust's test harness runs tests in parallel by default, + // so separate test functions below would otherwise race each other + // through this shared state (this reproduced in CI: one test's + // in-flight task got reclaimed by a *different* test's concurrent + // `shutdown()` call, and another observed `OUTSTANDING` left non-zero + // by a still-running sibling). Every test takes this lock first so + // only one of them touches the shared runtime state at a time; a + // poisoned lock (a previous test's genuine failure) is still honored + // rather than cascading into every later test as an unrelated panic. + static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn lock_runtime_state_for_test() -> std::sync::MutexGuard<'static, ()> { + TEST_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + // A task's own completion must never be the final drop of the shared // `Runtime`: tasks carry only an `OutstandingGuard` (a plain counter // token), never an `Arc`, specifically so this can't happen. @@ -361,6 +377,7 @@ mod tests { #[allow(unused_must_use)] // fire-and-forget spawn, same as future_into_py itself fn test_top_level_task_survives_concurrent_shutdown_reclaim() { use std::sync::mpsc; + let _lock = lock_runtime_state_for_test(); for _ in 0..50 { let (tx, rx) = mpsc::channel::<()>(); @@ -381,6 +398,8 @@ mod tests { #[test] fn test_shutdown_stops_and_the_runtime_rebuilds_lazily_after() { + let _lock = lock_runtime_state_for_test(); + // No runtime created yet in this process: shutdown must be a no-op, // not a null-pointer dereference. shutdown(Duration::from_secs(1)); @@ -412,6 +431,7 @@ mod tests { fn test_shutdown_is_safe_concurrently_with_live_callers() { use std::sync::Barrier; use std::sync::atomic::AtomicBool as StopFlag; + let _lock = lock_runtime_state_for_test(); for _ in 0..50 { let barrier = Arc::new(Barrier::new(9)); @@ -453,6 +473,7 @@ mod tests { #[allow(unused_must_use)] // fire-and-forget outer spawn, same as future_into_py itself fn test_nested_spawn_survives_concurrent_shutdown() { use std::sync::mpsc; + let _lock = lock_runtime_state_for_test(); for _ in 0..50 { let stop = Arc::new(AtomicBool::new(false)); @@ -504,6 +525,7 @@ mod tests { #[test] fn test_shutdown_waits_for_a_racing_install() { use std::sync::mpsc; + let _lock = lock_runtime_state_for_test(); // Clean slate: no runtime installed, OUTSTANDING at zero. shutdown(Duration::from_secs(5));