git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

tests/style_registry_reentrancy.rs (4.7K)

  1 //! No style getter may read the registry while it already holds a read of it.
  2 //!
  3 //! `STYLE_REGISTRY` is a `std::sync::RwLock`, and std's lock queues new readers
  4 //! behind a WAITING writer. A getter written as
  5 //!
  6 //! ```ignore
  7 //! get_style_registry().read().unwrap().get_float("toggle_corner_radius")
  8 //!     .unwrap_or_else(control_corner_radius)
  9 //! ```
 10 //!
 11 //! keeps its guard alive to the end of the statement, so the fallback's own
 12 //! read is a second read on the same thread. A writer that arrives between
 13 //! the two waits for the first, the second waits for the writer, and every
 14 //! reader in the process then queues behind both. That hung
 15 //! `cargo test -p cce-designer` (2026-09-25): ~40 test threads parked in
 16 //! `RwLock::read_contended` on `STYLE_REGISTRY`, the writer being the
 17 //! designer's `reload_config` (one write per config line, on every
 18 //! `State::new`) and the reader any unset corner radius.
 19 //!
 20 //! An integration test on purpose: here cce-ui is built WITHOUT `cfg(test)`,
 21 //! as it is inside every app's suite, so the per-thread style overlay the unit
 22 //! tests get is out of the way and the getters hit the shared lock.
 23 //!
 24 //! The stress runs in a CHILD process — this binary re-executed — because a
 25 //! deadlock on the process-wide registry cannot be recovered from in-process:
 26 //! the parent kills the child at a deadline and fails, rather than hanging.
 27 
 28 use std::process::{Command, Stdio};
 29 use std::sync::atomic::{AtomicBool, Ordering};
 30 use std::sync::Arc;
 31 use std::time::{Duration, Instant};
 32 
 33 use cce_ui::layout;
 34 
 35 const CHILD_ENV: &str = "CCE_UI_REENTRANCY_CHILD";
 36 
 37 /// Every getter whose unset slot falls back to another style getter — the
 38 /// shape that nests. With an empty config each of them takes the fallback.
 39 const FALLBACK_GETTERS: &[fn() -> f32] = &[
 40     layout::toggle_corner_radius,
 41     layout::slider_corner_radius,
 42     layout::color_selector_preview_corner_radius,
 43     layout::color_selector_corner_radius,
 44     layout::button_corner_radius,
 45     layout::spinbox_corner_radius,
 46     layout::textbox_corner_radius,
 47     layout::list_corner_radius,
 48     layout::tree_corner_radius,
 49     layout::font_selector_corner_radius,
 50     layout::menu_corner_radius,
 51     layout::dropdown_corner_radius,
 52 ];
 53 
 54 /// The child: one thread takes and drops the write lock as fast as it can,
 55 /// the others call every fallback getter. Against a nested read this parks
 56 /// within milliseconds; without one it runs out its time and exits.
 57 fn hammer() {
 58     // Initialise (reload_config runs once) before the race starts, so the
 59     // writer is the only writer.
 60     for g in FALLBACK_GETTERS {
 61         g();
 62     }
 63     let stop = Arc::new(AtomicBool::new(false));
 64     let writer = {
 65         let stop = stop.clone();
 66         std::thread::spawn(move || {
 67             while !stop.load(Ordering::Relaxed) {
 68                 drop(layout::get_style_registry().write().unwrap());
 69             }
 70         })
 71     };
 72     let readers: Vec<_> = (0..4)
 73         .map(|_| {
 74             std::thread::spawn(|| {
 75                 let until = Instant::now() + Duration::from_secs(2);
 76                 while Instant::now() < until {
 77                     for g in FALLBACK_GETTERS {
 78                         std::hint::black_box(g());
 79                     }
 80                 }
 81             })
 82         })
 83         .collect();
 84     for r in readers {
 85         r.join().unwrap();
 86     }
 87     stop.store(true, Ordering::Relaxed);
 88     writer.join().unwrap();
 89 }
 90 
 91 #[test]
 92 fn fallback_getters_never_nest_a_registry_read() {
 93     if std::env::var_os(CHILD_ENV).is_some() {
 94         hammer();
 95         return;
 96     }
 97 
 98     // An empty config, so no slot is set and every getter falls back. The
 99     // directory is never created; an absent config.kdl reads as no config.
100     let config_home = std::env::temp_dir().join(format!("cce-ui-reentrancy-{}", std::process::id()));
101     let mut child = Command::new(std::env::current_exe().unwrap())
102         .args(["--exact", "fallback_getters_never_nest_a_registry_read", "--test-threads=1", "-q"])
103         .env(CHILD_ENV, "1")
104         .env("XDG_CONFIG_HOME", &config_home)
105         .stdout(Stdio::null())
106         .stderr(Stdio::inherit())
107         .spawn()
108         .expect("re-execute the test binary");
109 
110     let deadline = Instant::now() + Duration::from_secs(30);
111     loop {
112         if let Some(status) = child.try_wait().unwrap() {
113             assert!(status.success(), "the stress child failed: {status}");
114             return;
115         }
116         if Instant::now() > deadline {
117             let _ = child.kill();
118             let _ = child.wait();
119             panic!(
120                 "style getters deadlocked on STYLE_REGISTRY: a getter read the registry while \
121                  holding a read of it, and a queued writer parked both"
122             );
123         }
124         std::thread::sleep(Duration::from_millis(50));
125     }
126 }