From 883f4de3209c53c4babc349ddbe341de7173ecf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 04:59:06 +0200 Subject: [PATCH 1/2] test(runtime): isolate process-global fixtures for parallel runs --- changelog.d/9197-isolated-runtime-fixtures.md | 3 ++ crates/perry-runtime/src/box/release_tests.rs | 22 ++++++------ .../src/gc/roots/stack_maps_decode_tests.rs | 12 +++++++ crates/perry-runtime/src/symbol/get.rs | 4 +++ crates/perry-runtime/src/test_support.rs | 35 +++++++++++++++++-- 5 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 changelog.d/9197-isolated-runtime-fixtures.md diff --git a/changelog.d/9197-isolated-runtime-fixtures.md b/changelog.d/9197-isolated-runtime-fixtures.md new file mode 100644 index 0000000000..ad7fb7fdb5 --- /dev/null +++ b/changelog.d/9197-isolated-runtime-fixtures.md @@ -0,0 +1,3 @@ +### Fixed + +- Isolate runtime test fixtures that inspect loaded libraries, process-wide box counters, and the composed symbol cache. Prevent neighboring tests from corrupting their assertions, and tighten the box reuse bound (#9197). diff --git a/crates/perry-runtime/src/box/release_tests.rs b/crates/perry-runtime/src/box/release_tests.rs index acdf163f5e..bd6d8233aa 100644 --- a/crates/perry-runtime/src/box/release_tests.rs +++ b/crates/perry-runtime/src/box/release_tests.rs @@ -511,6 +511,10 @@ fn foreign_pointer_release_is_a_total_noop() { /// asyncpipe_big). #[test] fn completed_activation_residue_is_bounded_not_linear() { + crate::test_support::isolated_test(completed_activation_residue_body); +} + +fn completed_activation_residue_body() { super::test_clear_box_registry(); const TURNS: usize = 100; const ACTIVATIONS_PER_TURN: usize = 20; @@ -544,22 +548,18 @@ fn completed_activation_residue_is_bounded_not_linear() { flush_released_boxes(); } let (a1, r1, _) = box_release_stats(); - // The counters are process-global; sibling tests on other threads - // also allocate boxes, so assert lower bounds and give the residue - // bound slack instead of demanding exact equality. + // These process-global counters now measure only this fixture, so a + // sibling's allocations cannot hide reuse or push residue over the bound. let total_allocs = (a1 - a0) as usize; let residue = total_allocs.saturating_sub((r1 - r0) as usize); let own_allocs = TURNS * ACTIVATIONS_PER_TURN * CELLS_PER_ACTIVATION; - assert!( - total_allocs >= own_allocs, - "every lifecycle allocates its frame ({total_allocs} < {own_allocs})" + assert_eq!( + total_allocs, own_allocs, + "every lifecycle allocates exactly its frame" ); // One turn's working set (the first turn mints real cells; every - // later turn reuses them), plus generous slack for whatever the - // parallel sibling tests allocate (they use a handful of cells - // each). The pre-fix residue is TURNS * the per-turn bound, two - // orders of magnitude past this. - let bound = 4 * ACTIVATIONS_PER_TURN * CELLS_PER_ACTIVATION; + // later turn reuses them). The pre-fix residue is TURNS * this bound. + let bound = ACTIVATIONS_PER_TURN * CELLS_PER_ACTIVATION; assert!( residue <= bound, "malloc residue must be bounded by one turn's working set: \ diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs index b945db9185..347f74c987 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs @@ -247,6 +247,11 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn discovers_a_map_from_a_later_loaded_shared_object() { + crate::test_support::isolated_test(discovers_later_loaded_map_body); + } + + #[cfg(target_os = "linux")] + fn discovers_later_loaded_map_body() { use std::ffi::CString; use std::fmt::Write as _; use std::os::unix::ffi::OsStrExt; @@ -327,6 +332,13 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn rejects_an_unreadable_loaded_shared_object() { + // This fixture deliberately poisons the process's loaded-image set. + // Isolate the writer too, so no sibling stack-map scan can observe it. + crate::test_support::isolated_test(rejects_unreadable_loaded_object_body); + } + + #[cfg(target_os = "linux")] + fn rejects_unreadable_loaded_object_body() { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; use std::process::Command; diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 503d041ccf..a4a7d78148 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -1427,6 +1427,10 @@ mod own_data_ic_tests { #[test] fn composed_symbol_field_cache_reloads_mutated_final_slot() { + crate::test_support::isolated_test(composed_symbol_field_cache_body); + } + + fn composed_symbol_field_cache_body() { let _global = crate::gc::global_side_table_test_lock(); unsafe { crate::gc::gc_suppress(); diff --git a/crates/perry-runtime/src/test_support.rs b/crates/perry-runtime/src/test_support.rs index cf9b396e46..d5666b534e 100644 --- a/crates/perry-runtime/src/test_support.rs +++ b/crates/perry-runtime/src/test_support.rs @@ -1,5 +1,4 @@ -//! Test-only serialization for PROCESS-global state that is neither a runtime -//! side table (those use `gc::global_side_table_test_lock`) nor thread-local. +//! Test-only isolation and serialization for process-global fixtures. //! //! First resident: the process working directory. `std::env::set_current_dir` //! is process-wide, so a test that changes it (`typed_feedback`'s @@ -21,3 +20,35 @@ pub(crate) fn process_cwd_test_lock() -> std::sync::MutexGuard<'static, ()> { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } + +/// Run a libtest case in its own process when its fixture needs exclusive +/// ownership of process-global state (#9197). A lock shared by only a few +/// tests cannot exclude the runtime's other side-table readers or counters. +/// +/// Use the harness's current test name so renaming/moving a test cannot leave +/// a stale filter. Require a marker emitted AFTER the body as well as a clean +/// exit: selecting zero tests or exiting early must not produce a false pass. +pub(crate) fn isolated_test(body: impl FnOnce()) { + const CHILD_ENV: &str = "PERRY_RUNTIME_ISOLATED_TEST_NAME"; + let thread = std::thread::current(); + let name = thread.name().expect("libtest must name the test thread"); + let completed = format!("perry isolated test completed: {name}"); + if std::env::var(CHILD_ENV).ok().as_deref() == Some(name) { + body(); + println!("{completed}"); + return; + } + + let output = std::process::Command::new(std::env::current_exe().expect("current test binary")) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env(CHILD_ENV, name) + .output() + .expect("launch isolated runtime test"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && stdout.lines().any(|line| line.ends_with(&completed)), + "isolated test {name} did not complete: {}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status + ); +} From aa2612814144feff455c3a65510576deb43c7c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 05:00:22 +0200 Subject: [PATCH 2/2] docs: associate isolated runtime fixtures with PR 9767 --- ...ated-runtime-fixtures.md => 9767-isolated-runtime-fixtures.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9197-isolated-runtime-fixtures.md => 9767-isolated-runtime-fixtures.md} (100%) diff --git a/changelog.d/9197-isolated-runtime-fixtures.md b/changelog.d/9767-isolated-runtime-fixtures.md similarity index 100% rename from changelog.d/9197-isolated-runtime-fixtures.md rename to changelog.d/9767-isolated-runtime-fixtures.md