From e30c6c7950e09619e8b08eb093b2b366a4e3c480 Mon Sep 17 00:00:00 2001 From: Kenneth Langga Date: Mon, 10 Aug 2026 13:26:40 +0800 Subject: [PATCH] Make the Linux test suite pass, and run it in CI The promotion tests now skip when the environment cannot do real-time scheduling on both Linux builds; previously only the native (no-dbus) build had the guard, so machines without a usable rtkit-daemon failed every test. The guard prints the underlying error when it skips, which distinguishes a missing daemon from an exhausted request budget. rtkit-daemon grants at most --actions-per-burst-max (default 25) requests per --actions-burst-sec (default 20 seconds) window per user, and the suite issued 168 promotion requests by construction, so a cold cargo test run could never pass against a stock daemon. The tests now hold a shared lock, probe once per test, and the concurrency test promotes once per thread across four threads on Linux (the other platforms have no budget and keep 32 threads), which measures 16 requests per run including the doctest. The budget is shared with the whole desktop session, so leave about twenty seconds between consecutive runs. Checks that need no real-time permission, argument validation and the thread-info serialization round trips, moved above the guards so they run everywhere, including CI. CI now runs the test step on Linux too, with --nocapture so the skip notices are visible. The runners do have rtkit installed, pulled in by pulseaudio, but its polkit policy only serves processes in an active session, so the promotion tests skip themselves there. --- .github/workflows/rust.yml | 8 +-- src/lib.rs | 99 +++++++++++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 27 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a5b9ebe..8c71d5a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -47,9 +47,11 @@ jobs: - name: Test shell: bash - run: rustup run ${{ matrix.rust }} cargo test --all - # setrlimit64 error in the CI container - if: matrix.os != 'ubuntu-24.04' + # The promotion tests skip themselves on Linux: rtkit is installed (pulled in by + # pulseaudio) but its polkit policy only serves processes in an active session, which a + # runner job is not. The argument-validation and serialization tests still run, and + # --nocapture makes the skip notices visible in the log. + run: rustup run ${{ matrix.rust }} cargo test --all -- --nocapture # Test on Linux, as if it didn't have DBUS, to exercise the fallback path. build-fallback: diff --git a/src/lib.rs b/src/lib.rs index 1531665..86c0bc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -625,11 +625,12 @@ mod tests { #[cfg(feature = "terminal-logging")] use simple_logger; - // On the native (no-dbus) Linux build, promotion actually changes the scheduler, so it needs - // permission to request real-time scheduling. When the environment does not grant it (no - // RLIMIT_RTPRIO budget and not privileged), the promotion tests have nothing to exercise and - // skip rather than fail; CI raises the limit so they run for real. - #[cfg(all(target_os = "linux", not(feature = "dbus")))] + // On Linux, promotion actually changes the scheduler, so it needs permission to request + // real-time scheduling: an RLIMIT_RTPRIO budget or privilege for the native (no-dbus) build, + // and a reachable, willing rtkit-daemon for the dbus build. When the environment does not + // grant it, the promotion tests have nothing to exercise and skip rather than fail; CI for + // the native build raises the limit so they run for real. + #[cfg(target_os = "linux")] fn rt_scheduling_available() -> bool { match promote_current_thread_to_real_time(0, 44100) { Ok(handle) => { @@ -639,22 +640,37 @@ mod tests { .expect("demotion after a successful promotion should succeed"); true } - Err(_) => false, + Err(e) => { + // The error distinguishes a missing rtkit-daemon from an exhausted request + // budget; visible with --nocapture. + eprintln!("real-time scheduling unavailable: {e}"); + false + } } } + // The promotion tests share the finite per-user budget rtkit-daemon grants, so they hold + // this lock to run one at a time; otherwise their combined realtime-thread count can cross + // the daemon's limit and fail promotions that would succeed in isolation. + #[cfg(target_os = "linux")] + static RT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn it_works() { + #[cfg(target_os = "linux")] + let _rt_lock = RT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); #[cfg(feature = "terminal-logging")] simple_logger::init().unwrap(); - #[cfg(all(target_os = "linux", not(feature = "dbus")))] + { + // Argument validation fails before any promotion machinery runs, so this needs no + // real-time permission and works even where the guard below skips. + assert!(promote_current_thread_to_real_time(0, 0).is_err()); + } + #[cfg(target_os = "linux")] if !rt_scheduling_available() { eprintln!("skipping it_works: real-time scheduling is not permitted here"); return; } - { - assert!(promote_current_thread_to_real_time(0, 0).is_err()); - } { match promote_current_thread_to_real_time(0, 44100) { Ok(rt_prio_handle) => { @@ -712,9 +728,37 @@ mod tests { } } + fn promote_and_demote_once() { + match promote_current_thread_to_real_time(0, 44100) { + Ok(handle) => { + demote_current_thread_from_real_time(handle) + .expect("demotion after a successful promotion should succeed"); + } + Err(e) => { + panic!("{}", e); + } + } + } + #[test] fn it_works_in_different_threads() { - let handles: Vec<_> = (0..32).map(|_| std::thread::spawn(it_works)).collect(); + #[cfg(target_os = "linux")] + let _rt_lock = RT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + #[cfg(target_os = "linux")] + if !rt_scheduling_available() { + eprintln!( + "skipping it_works_in_different_threads: real-time scheduling is not permitted here" + ); + return; + } + // Each thread promotes once, and Linux keeps the thread count low: rtkit-daemon allows + // --actions-per-burst-max (default 25) requests per burst window for the whole user + // session, and the suite has to fit inside that budget for a cold `cargo test` run to + // pass. The other platforms have no such limit and keep the original concurrency level. + const THREADS: usize = if cfg!(target_os = "linux") { 4 } else { 32 }; + let handles: Vec<_> = (0..THREADS) + .map(|_| std::thread::spawn(promote_and_demote_once)) + .collect(); for handle in handles { handle.join().unwrap() } @@ -729,7 +773,21 @@ mod tests { #[test] fn test_linux_api() { - #[cfg(not(feature = "dbus"))] + let _rt_lock = RT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // The serialization round trips only read scheduling parameters, so they need no + // real-time permission and work even where the guard below skips. + { + let info = get_current_thread_info().unwrap(); + let bytes = info.serialize(); + let info2 = RtPriorityThreadInfo::deserialize(bytes); + assert!(info == info2); + } + { + let info = get_current_thread_info().unwrap(); + let bytes = thread_info_serialize(info); + let info2 = thread_info_deserialize(bytes); + assert!(info == info2); + } if !rt_scheduling_available() { eprintln!("skipping test_linux_api: real-time scheduling is not permitted here"); return; @@ -743,21 +801,14 @@ mod tests { } } } - { - let info = get_current_thread_info().unwrap(); - let bytes = info.serialize(); - let info2 = RtPriorityThreadInfo::deserialize(bytes); - assert!(info == info2); - } - { - let info = get_current_thread_info().unwrap(); - let bytes = thread_info_serialize(info); - let info2 = thread_info_deserialize(bytes); - assert!(info == info2); - } } #[test] fn test_remote_promotion() { + let _rt_lock = RT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if !rt_scheduling_available() { + eprintln!("skipping test_remote_promotion: real-time scheduling is not permitted here"); + return; + } let (rd, wr) = pipe().unwrap(); match unsafe { fork().expect("fork failed") } {