Skip to main content

frust_reactive/
runtime.rs

1//! [`ReactiveRuntime`]: the process-wide reactive substrate.
2//!
3//! It owns a background tokio runtime, installs the custom [`any_spawner`]
4//! executor, holds the root reactive [`Owner`], and carries a swappable
5//! [`FrameWaker`] the executor fires to nudge the shell into pumping the
6//! UI-thread local task queue.
7//!
8//! **wasm32 arm.** `wasm32-unknown-unknown` has no OS threads and no
9//! mio-backed reactor, so the native background runtime's `rt-multi-thread`
10//! and `net` tokio features fail to compile there (see `Cargo.toml`'s
11//! target-gated `tokio` rows). [`ReactiveRuntime::init`] therefore builds a
12//! bare current-thread runtime instead of the multi-thread pool, and
13//! installs a wasm [`CustomExecutor`](any_spawner::CustomExecutor) (built
14//! inline here, not the native [`ForgeExecutor`](crate::executor::ForgeExecutor))
15//! whose `spawn` **and** `spawn_local` both route straight to
16//! `wasm_bindgen_futures::spawn_local` — a browser has one JS thread, so
17//! "spawn on a thread pool" and "spawn on the local microtask queue" are the
18//! same operation there, and `Send` futures need no different handling than
19//! `!Send` ones. This is the one call site that makes `wasm-bindgen-futures`
20//! (`Cargo.toml`'s wasm-only rows) a real direct dependency rather than a
21//! transitively-resolved one. Because both paths are driven by the browser's
22//! own microtask queue, [`pump_local`](ReactiveRuntime::pump_local) has
23//! nothing of this crate's own to drain on this arm, and `poll_local` is a
24//! no-op. The current-thread runtime is never driven (nothing calls
25//! `block_on`), so it exists only to give
26//! [`handle`](ReactiveRuntime::handle)/[`spawn_blocking`] a real
27//! `tokio::runtime::Handle` to type-check against.
28//!
29//! **What still doesn't work on wasm32.** `frust::spawn`/`frust::spawn_local`
30//! now run for real (above). [`spawn_blocking`] and `use_task`'s background
31//! half do not: `wasm32-unknown-unknown` has no OS threads, so there is no
32//! blocking pool to hand CPU-bound work to. `spawn_blocking` fails loudly
33//! (panics, naming the API and target) on this target rather than silently
34//! swallowing the closure — see its own docs. Treat that as an open gap, not
35//! a proven path.
36
37use std::sync::atomic::{AtomicBool, Ordering};
38use std::sync::{Arc, Mutex, OnceLock};
39
40use any_spawner::Executor;
41#[cfg(target_family = "wasm")]
42use any_spawner::{CustomExecutor, PinnedFuture, PinnedLocalFuture};
43use reactive_graph::owner::Owner;
44use tokio::runtime::{Builder, Handle, Runtime};
45
46use crate::executor;
47#[cfg(not(target_family = "wasm"))]
48use crate::executor::ForgeExecutor;
49#[cfg(not(target_family = "wasm"))]
50use crate::thread_policy::{self, ThreadClass};
51
52/// The wasm [`any_spawner`] executor (see the module docs for why it exists
53/// instead of `any_spawner::Executor::init_wasm_bindgen()` and instead of the
54/// native [`ForgeExecutor`](crate::executor::ForgeExecutor)). A unit struct —
55/// nothing to hold, since `wasm_bindgen_futures::spawn_local` is a free
56/// function reachable from anywhere.
57#[cfg(target_family = "wasm")]
58struct WasmExecutor;
59
60#[cfg(target_family = "wasm")]
61impl CustomExecutor for WasmExecutor {
62    /// `Send` futures also go to `wasm_bindgen_futures::spawn_local`: a
63    /// browser has one JS thread, so there is no separate thread-pool
64    /// destination to route `Send` futures to the way the native
65    /// [`ForgeExecutor`](crate::executor::ForgeExecutor) does. This is the
66    /// call site that makes `wasm-bindgen-futures` a real direct dependency
67    /// (see `Cargo.toml`'s wasm-only rows).
68    fn spawn(&self, fut: PinnedFuture<()>) {
69        wasm_bindgen_futures::spawn_local(fut);
70    }
71
72    /// `!Send` futures route the same way — `wasm_bindgen_futures::spawn_local`
73    /// requires only `Future<Output = ()> + 'static`, which `PinnedFuture`
74    /// (this crate's `spawn` path) already satisfies too.
75    fn spawn_local(&self, fut: PinnedLocalFuture<()>) {
76        wasm_bindgen_futures::spawn_local(fut);
77    }
78
79    /// Nothing to drain: every task spawned above is driven by the browser's
80    /// own microtask queue, not a queue this crate owns (see the module
81    /// docs).
82    fn poll_local(&self) {}
83}
84
85/// A thread-safe, cheaply-cloneable "wake up and pump soon" callback. On
86/// desktop this is a winit `EventLoopProxy` send; on mobile (continuous frame
87/// loop) it is a no-op. It must be callable from any thread, since the executor
88/// fires it and background tasks may drive signal writes.
89pub type FrameWaker = Arc<dyn Fn() + Send + Sync>;
90
91/// Builds the one background runtime [`ReactiveRuntime::init`] installs.
92///
93/// Native: the real multi-thread worker pool with the time + IO drivers
94/// enabled. Unless `FRUST_NO_THREAD_TUNING` is set
95/// ([`thread_policy::thread_tuning_enabled`]) the calling (UI) thread's
96/// priority is recorded as the reference the background class is measured
97/// from, and the idle blocking-thread keep-alive is stated explicitly
98/// ([`thread_policy::BLOCKING_KEEP_ALIVE`]). The two async workers keep the
99/// class they inherit; only blocking threads are lowered, by
100/// [`spawn_blocking`]. The blocking pool is deliberately left at tokio's
101/// default size: it carries parked plugin calls (a camera permission prompt
102/// for up to 120 s, a purchase flow for 60 s, database work) beside image
103/// decode and DNS, so a small cap would starve the quick jobs behind the
104/// parked ones. With the switch set the builder is exactly tokio's defaults
105/// plus the worker count, drivers and name.
106#[cfg(not(target_family = "wasm"))]
107fn build_background_runtime() -> Runtime {
108    let mut builder = Builder::new_multi_thread();
109    builder
110        .worker_threads(2)
111        .enable_time()
112        // IO driver: `frust::spawn`'s background tasks (tonic/hyper socket
113        // work) need a live reactor, not just the timer driver above.
114        .enable_io()
115        .thread_name("frust-reactive");
116    if thread_policy::thread_tuning_enabled() {
117        thread_policy::record_reference_thread();
118        builder.thread_keep_alive(thread_policy::BLOCKING_KEEP_ALIVE);
119    }
120    builder
121        .build()
122        .expect("frust-reactive: failed to build the background tokio runtime")
123}
124
125/// Wasm: a bare current-thread runtime with no driver enabled (see the module
126/// docs for why) — nothing ever calls `block_on` on it, so it exists purely
127/// to give [`ReactiveRuntime::handle`] a real `tokio::runtime::Handle` to
128/// type-check against, not a functioning executor. [`spawn_blocking`] never
129/// reaches this handle on wasm32 (it panics first — see its own docs).
130#[cfg(target_family = "wasm")]
131fn build_background_runtime() -> Runtime {
132    Builder::new_current_thread()
133        .thread_name("frust-reactive")
134        .build()
135        .expect("frust-reactive: failed to build the wasm placeholder tokio runtime")
136}
137
138/// The one process-wide runtime. Installed on first [`ReactiveRuntime::init`]
139/// and never torn down (process lifetime — the matrix-rust-sdk precedent).
140static RUNTIME: OnceLock<ReactiveRuntime> = OnceLock::new();
141
142/// The process-wide reactive runtime. Construct via [`ReactiveRuntime::init`]
143/// (once, on the UI thread) and reach later calls via [`ReactiveRuntime::get`].
144pub struct ReactiveRuntime {
145    // Kept alive for the process lifetime; the static is never dropped, so the
146    // runtime never shuts down. Read only through `handle`.
147    _runtime: Runtime,
148    handle: Handle,
149    root: Owner,
150    waker: Mutex<FrameWaker>,
151    /// Process-wide "did any tracked signal change since I last asked" flag —
152    /// the reactive input to the mobile frame gate. Set by
153    /// [`crate::tracked::TrackedScope`]'s dirty path (any tracked scope's
154    /// invalidation trips it, coalesced by nature since it's a bool, not a
155    /// counter); drained by [`take_signals_dirty`](Self::take_signals_dirty).
156    signals_dirty: AtomicBool,
157}
158
159impl ReactiveRuntime {
160    /// Process-wide initialization (idempotent).
161    ///
162    /// **Must be called on the UI thread** — the calling thread claims itself as
163    /// the owner of the local task queue that [`pump_local`](Self::pump_local)
164    /// drains. On the first call it builds the background tokio runtime, the
165    /// custom executor, and the root [`Owner`]. A second call (e.g. a relaunched
166    /// shell in the same process) does **not** rebuild anything: it re-marks the
167    /// current thread as the UI thread, **replaces the waker** so the new shell
168    /// re-owns wake-up, and returns the existing runtime. `any_spawner`'s
169    /// `AlreadySet` on a repeated executor install is expected and benign.
170    pub fn init(waker: FrameWaker) -> &'static ReactiveRuntime {
171        // Claim the UI thread on every call: the fast path below must still
172        // (re-)mark a relaunched shell's thread.
173        executor::init_ui_thread();
174
175        if let Some(existing) = RUNTIME.get() {
176            existing.set_waker(waker);
177            return existing;
178        }
179
180        let runtime = build_background_runtime();
181        let handle = runtime.handle().clone();
182        let root = Owner::new();
183
184        let candidate = ReactiveRuntime {
185            _runtime: runtime,
186            handle: handle.clone(),
187            root,
188            waker: Mutex::new(waker),
189            signals_dirty: AtomicBool::new(false),
190        };
191
192        match RUNTIME.set(candidate) {
193            Ok(()) => {
194                let rt = RUNTIME.get().expect("runtime was just installed");
195                // Install the executor AFTER the runtime is reachable, so the
196                // executor's `spawn_local` can fire the waker via `get()`.
197                // `AlreadySet` (a prior shell already installed it) is benign.
198                #[cfg(not(target_family = "wasm"))]
199                {
200                    let _ = Executor::init_custom_executor(ForgeExecutor::new(handle));
201                }
202                // No automatic wasm default exists (see module docs); install
203                // the wasm `CustomExecutor` explicitly. `AlreadySet` on a
204                // repeated install (e.g. a relaunched shell in the same
205                // process) is handled the same benign way as the native arm.
206                #[cfg(target_family = "wasm")]
207                {
208                    let _ = Executor::init_custom_executor(WasmExecutor);
209                }
210                rt
211            }
212            Err(candidate) => {
213                // Lost a concurrent init race (init is documented single-thread,
214                // so this is defensive): adopt the winner, swap our waker in.
215                let winner = RUNTIME.get().expect("runtime is set on the Err path");
216                let waker = candidate
217                    .waker
218                    .into_inner()
219                    .expect("candidate waker mutex is uncontended");
220                winner.set_waker(waker);
221                winner
222            }
223        }
224    }
225
226    /// The installed runtime, if [`init`](Self::init) has run.
227    pub fn get() -> Option<&'static ReactiveRuntime> {
228        RUNTIME.get()
229    }
230
231    /// Runs `f` under the root reactive [`Owner`]. Shells wrap their per-frame
232    /// rebuild in this so signals created during a rebuild are owned by the root
233    /// (and disposed only at process end).
234    pub fn with_owner<R>(&self, f: impl FnOnce() -> R) -> R {
235        self.root.with(f)
236    }
237
238    /// Drains the UI-thread local task queue until it stalls, inside a tokio
239    /// runtime context so `tokio::time::sleep` in a local task registers with
240    /// the background runtime's timer driver. Shells call this each loop turn.
241    ///
242    /// Must be called on the UI thread (the one [`init`](Self::init) ran on);
243    /// off-thread it pumps a distinct, empty queue.
244    ///
245    /// **Wasm:** a harmless no-op drain of an always-empty queue. On this
246    /// target `spawn_local` routes to `wasm_bindgen_futures::spawn_local`
247    /// rather than this crate's own local pool (see the module docs), so
248    /// there is nothing of this crate's own for a shell to pump — it is still
249    /// safe (and, for cross-platform shell code, simplest) to call this every
250    /// loop turn on wasm too.
251    pub fn pump_local(&self) {
252        debug_assert!(
253            executor::is_ui_thread(),
254            "frust-reactive: pump_local called off the UI thread — this pumps \
255             an unrelated empty queue and is a wiring bug"
256        );
257        let _guard = self.handle.enter();
258        executor::run_until_stalled();
259    }
260
261    /// Replaces the frame waker (a relaunched shell re-owns wake-up).
262    pub fn set_waker(&self, waker: FrameWaker) {
263        *self
264            .waker
265            .lock()
266            .expect("frust-reactive: waker mutex poisoned") = waker;
267    }
268
269    /// Fires the current frame waker. Used by the executor's `spawn_local` and
270    /// by the signals dirty-bridge.
271    pub fn wake(&self) {
272        // Clone the Arc out before invoking so the lock is not held across the
273        // callback (which may re-enter the runtime).
274        let waker = self
275            .waker
276            .lock()
277            .expect("frust-reactive: waker mutex poisoned")
278            .clone();
279        waker();
280    }
281
282    /// The background runtime handle. `pub(crate)` so the heavy-work idiom
283    /// ([`crate::task`]) can `spawn`/`spawn_blocking` onto the background
284    /// runtime, and so the test that asserts a repeated executor install is
285    /// benign can rebuild the executor. Deliberately not part of the public
286    /// API — app code routes through `spawn`/`spawn_local`/`spawn_blocking`
287    /// rather than naming a raw `tokio::runtime::Handle`.
288    pub(crate) fn handle(&self) -> Handle {
289        self.handle.clone()
290    }
291
292    /// Trips the process-wide signals-dirty flag. Called from
293    /// [`crate::tracked::TrackedScope`]'s dirty-notification path — any
294    /// tracked scope's invalidation trips this, not just the coalesced
295    /// frame-waker edge, so a shell can ask "did *anything* change" cheaply
296    /// once per frame.
297    pub(crate) fn mark_signals_dirty(&self) {
298        self.signals_dirty.store(true, Ordering::SeqCst);
299    }
300
301    /// Drains the signals-dirty flag: returns whether any tracked signal
302    /// changed since the last `take_signals_dirty` call, and clears it.
303    ///
304    /// **Ordering contract for shells:** pump local tasks
305    /// ([`pump_local`](Self::pump_local)) FIRST, then call this — a
306    /// `spawn_local` continuation that writes a signal during the pump must
307    /// be observed by the *same* frame's dirty check. Calling this before the
308    /// pump can miss a write a just-drained local task makes.
309    pub fn take_signals_dirty(&self) -> bool {
310        self.signals_dirty.swap(false, Ordering::SeqCst)
311    }
312
313    /// Non-draining peek at the signals-dirty flag (does not clear it).
314    /// Prefer [`take_signals_dirty`](Self::take_signals_dirty) for the actual
315    /// once-per-frame gate check; this is for tests/diagnostics that want to
316    /// observe the flag without consuming it.
317    pub fn signals_dirty(&self) -> bool {
318        self.signals_dirty.load(Ordering::SeqCst)
319    }
320}
321
322/// Runs a one-off, blocking CPU workload on the background runtime's blocking
323/// thread pool, returning a [`JoinHandle`](tokio::task::JoinHandle) to `.await`
324/// its result.
325///
326/// This is the CPU-bound entry point of the heavy-work routing convention:
327///
328/// | Call | Use for |
329/// |---|---|
330/// | `frust::spawn` | `Send` async IO-bound work |
331/// | `frust::spawn_local` | `!Send` work that must stay on the UI thread |
332/// | `frust::spawn_blocking` | one-off **CPU-bound** blocking work (JSON parse, decode, hashing) |
333/// | `rayon` | data-parallel compute — an **app-level** choice, deliberately not bundled |
334///
335/// The pool already exists (the runtime is built `rt-multi-thread`), so this
336/// is a thin facade over [`tokio::runtime::Handle::spawn_blocking`]. Compose it
337/// inside a [`use_task`](crate::use_task) fetcher —
338/// `use_task(|| async { spawn_blocking(parse).await })` — to get load/error
339/// states and cancellation for free.
340///
341/// **Priority:** under the thread policy the first job on each blocking
342/// thread lowers that thread to [`ThreadClass::Background`] once (later jobs
343/// make no syscall); the async workers keep the class they inherit. Only
344/// closures submitted through this function are lowered — a raw
345/// `tokio::task::spawn_blocking` caller is not.
346///
347/// **Cancellation limitation:** dropping/aborting the returned handle stops the
348/// result from being delivered, but a blocking closure *already running*
349/// cannot be interrupted (there is no safe way to unwind arbitrary blocking
350/// code) — the same limitation every runtime has.
351///
352/// **No working wasm equivalent yet — fails loudly.** `wasm32-unknown-unknown`
353/// has no OS threads, so there is no blocking pool for `Handle::spawn_blocking`
354/// to hand work to (see the module docs). Rather than returning an inert
355/// `JoinHandle` that silently never resolves, this function panics up front
356/// on that target (see Panics below), before ever reaching tokio. Treat this
357/// as an open gap on wasm, not a proven path — the eventual fix is a
358/// browser-thread/Web-Worker-backed `JoinHandle`, not yet built.
359///
360/// # Panics
361///
362/// - Panics if [`ReactiveRuntime::init`] has not run yet — the same
363///   wiring-bug-not-runtime-condition contract as `spawn_local` off the UI
364///   thread.
365/// - **Always panics on `wasm32`** (see above), independent of `init` state.
366pub fn spawn_blocking<F, R>(f: F) -> tokio::task::JoinHandle<R>
367where
368    F: FnOnce() -> R + Send + 'static,
369    R: Send + 'static,
370{
371    #[cfg(target_arch = "wasm32")]
372    {
373        let _ = f;
374        panic!(
375            "frust-reactive: spawn_blocking called on wasm32 — \
376             wasm32-unknown-unknown has no OS threads, so there is no \
377             blocking thread pool to hand this closure to. This is an \
378             unimplemented gap on this target, not a wiring bug: route the \
379             work through a different mechanism until a \
380             browser-thread/Web-Worker-backed JoinHandle lands for \
381             `frust::spawn_blocking`."
382        );
383    }
384
385    #[cfg(not(target_arch = "wasm32"))]
386    {
387        let rt = ReactiveRuntime::get().expect(
388            "frust-reactive: spawn_blocking called before ReactiveRuntime::init — \
389             this is a wiring bug: initialize the reactive runtime (the shell does \
390             this on startup) before spawning work",
391        );
392        if thread_policy::thread_tuning_enabled() {
393            rt.handle.spawn_blocking(move || {
394                lower_blocking_thread_once();
395                f()
396            })
397        } else {
398            rt.handle.spawn_blocking(f)
399        }
400    }
401}
402
403/// Lowers the calling blocking-pool thread to [`ThreadClass::Background`] the
404/// first time it runs a job; a thread-local flag makes later jobs free.
405#[cfg(not(target_arch = "wasm32"))]
406fn lower_blocking_thread_once() {
407    thread_local! {
408        static LOWERED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
409    }
410    if !LOWERED.with(|l| l.replace(true)) {
411        // Best-effort: a thread the OS refuses to lower keeps the priority it
412        // inherited.
413        let _ = thread_policy::set_current_thread_class(ThreadClass::Background);
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::tracked::TrackedScope;
421    use any_spawner::Executor;
422    use reactive_graph::signal::RwSignal;
423    use reactive_graph::traits::{Get, Set};
424
425    fn noop_waker() -> FrameWaker {
426        Arc::new(|| {})
427    }
428
429    /// Criterion 1: a write inside a tracked rebuild trips the process-wide
430    /// signals-dirty flag; `take_signals_dirty` drains it (true once, then
431    /// false with no intervening write).
432    #[test]
433    fn signals_dirty_set_by_tracked_write_and_drained_by_take() {
434        let _guard = crate::WAKER_TEST_LOCK
435            .lock()
436            .unwrap_or_else(|e| e.into_inner());
437
438        let rt = ReactiveRuntime::init(noop_waker());
439        // Drain any flag left dirty by a previous test sharing this
440        // process-wide runtime.
441        rt.take_signals_dirty();
442
443        let scope = TrackedScope::new();
444        let sig = rt.with_owner(|| RwSignal::new(0));
445        scope.track(|| sig.get());
446        assert!(!rt.signals_dirty(), "no write yet — flag must be clean");
447
448        sig.set(1);
449        assert!(
450            rt.signals_dirty(),
451            "a write to a tracked signal must trip the process-wide flag"
452        );
453        assert!(
454            rt.take_signals_dirty(),
455            "take must observe the dirty flag and drain it"
456        );
457        assert!(
458            !rt.take_signals_dirty(),
459            "a second take with no intervening write must return false"
460        );
461    }
462
463    /// Criterion: writes from multiple distinct tracked scopes still coalesce
464    /// onto the one process-wide flag (a bool, not a counter) — one drain
465    /// clears every pending scope's contribution at once.
466    #[test]
467    fn signals_dirty_coalesces_across_scopes() {
468        let _guard = crate::WAKER_TEST_LOCK
469            .lock()
470            .unwrap_or_else(|e| e.into_inner());
471
472        let rt = ReactiveRuntime::init(noop_waker());
473        rt.take_signals_dirty();
474
475        let scope_a = TrackedScope::new();
476        let scope_b = TrackedScope::new();
477        let sig_a = rt.with_owner(|| RwSignal::new(0));
478        let sig_b = rt.with_owner(|| RwSignal::new(0));
479        scope_a.track(|| sig_a.get());
480        scope_b.track(|| sig_b.get());
481
482        sig_a.set(1);
483        sig_b.set(1);
484        sig_a.set(2);
485
486        assert!(
487            rt.take_signals_dirty(),
488            "multiple writes across multiple scopes must still trip the flag"
489        );
490        assert!(
491            !rt.take_signals_dirty(),
492            "drain must clear every pending contribution at once"
493        );
494    }
495
496    /// The ordering contract: a spawned local task that writes a tracked
497    /// signal *during* `pump_local` must be observed by a `take_signals_dirty`
498    /// call that runs after the pump — the documented pump-first contract
499    /// shells rely on.
500    #[test]
501    fn signals_dirty_pump_then_take_ordering() {
502        let _guard = crate::WAKER_TEST_LOCK
503            .lock()
504            .unwrap_or_else(|e| e.into_inner());
505
506        let rt = ReactiveRuntime::init(noop_waker());
507        rt.take_signals_dirty();
508
509        let scope = TrackedScope::new();
510        let sig = rt.with_owner(|| RwSignal::new(0));
511        scope.track(|| sig.get());
512
513        Executor::spawn_local(async move {
514            sig.set(1);
515        });
516        assert!(
517            !rt.signals_dirty(),
518            "the local task has not run yet — spawning it must not itself \
519             dirty the flag"
520        );
521
522        rt.pump_local();
523        assert!(
524            rt.take_signals_dirty(),
525            "a signal write from a pumped local task must be observed by \
526             take_signals_dirty called after the pump — the pump-first \
527             ordering contract"
528        );
529    }
530
531    /// The runtime's IO driver must actually be live on the `frust::spawn`
532    /// path (`Executor::spawn` -> `ForgeExecutor::spawn` -> `Handle::spawn`,
533    /// the same route real async IO takes), not just the timer driver the
534    /// other tests in this module exercise: a spawned task must be able to
535    /// bind a socket and complete an accept/connect round-trip.
536    #[test]
537    fn spawn_reaches_io_driver() {
538        let _guard = crate::WAKER_TEST_LOCK
539            .lock()
540            .unwrap_or_else(|e| e.into_inner());
541
542        ReactiveRuntime::init(noop_waker());
543
544        let (tx, rx) = std::sync::mpsc::channel();
545        Executor::spawn(async move {
546            let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
547                .await
548                .expect("bind must succeed with the IO driver enabled");
549            let addr = listener.local_addr().expect("listener has a local addr");
550
551            let accept = tokio::spawn(async move { listener.accept().await });
552            tokio::net::TcpStream::connect(addr)
553                .await
554                .expect("connect must succeed with the IO driver enabled");
555            accept
556                .await
557                .expect("accept task must not panic")
558                .expect("accept must succeed");
559
560            tx.send(()).expect("test receiver must still be alive");
561        });
562
563        rx.recv_timeout(std::time::Duration::from_secs(5)).expect(
564            "a spawned task must complete an accept/connect round-trip \
565             through the IO driver within 5s",
566        );
567    }
568
569    /// Under the thread policy a blocking-pool thread runs at the background
570    /// target for the UI (builder) thread's nice value, while an async worker
571    /// keeps the class it inherited from the UI thread (Linux kernels; the
572    /// test threads all share the process's nice value, so the runner thread
573    /// stands in for the UI thread whichever test built the runtime).
574    #[cfg(any(target_os = "android", target_os = "linux"))]
575    #[test]
576    fn only_blocking_threads_run_below_the_thread_that_built_the_runtime() {
577        if !thread_policy::thread_tuning_enabled() {
578            return;
579        }
580        let _guard = crate::WAKER_TEST_LOCK
581            .lock()
582            .unwrap_or_else(|e| e.into_inner());
583        ReactiveRuntime::init(noop_waker());
584
585        let ui = thread_policy::current_thread_nice().expect("nice is readable on Linux");
586        let blocking = ReactiveRuntime::get()
587            .expect("runtime was just initialized")
588            .handle()
589            .block_on(spawn_blocking(thread_policy::current_thread_nice))
590            .expect("blocking probe must not panic")
591            .expect("nice is readable on a pool thread");
592
593        let (tx, rx) = std::sync::mpsc::channel();
594        Executor::spawn(async move {
595            tx.send(thread_policy::current_thread_nice())
596                .expect("test receiver must still be alive");
597        });
598        let worker = rx
599            .recv_timeout(std::time::Duration::from_secs(5))
600            .expect("a spawned task must run within 5s")
601            .expect("nice is readable on a worker thread");
602
603        assert_eq!(
604            blocking,
605            thread_policy::background_target(ui, Some(ui)).unwrap_or(ui),
606            "blocking-pool nice must be the background target for the UI thread's {ui}"
607        );
608        assert_eq!(
609            worker, ui,
610            "an async worker keeps the UI thread's nice {ui}, got {worker}"
611        );
612    }
613
614    /// The runtime-side probe behind
615    /// [`the_kill_switch_leaves_every_pool_thread_at_the_inherited_nice`]:
616    /// run in a child process whose environment engages the kill switch, it
617    /// builds the runtime and checks that a blocking-pool thread and an async
618    /// worker both report the nice value the UI (builder) thread has, i.e.
619    /// that nothing was written over the inherited priority. A no-op unless the switch is engaged in its own process.
620    #[cfg(any(target_os = "android", target_os = "linux"))]
621    #[test]
622    #[ignore = "driven by the_kill_switch_leaves_every_pool_thread_at_the_inherited_nice"]
623    fn kill_switch_probe_pool_threads_keep_the_inherited_nice() {
624        if thread_policy::thread_tuning_enabled() {
625            // Run directly (for example by `--include-ignored`) without the
626            // kill switch: nothing to check; the driving test sets it.
627            return;
628        }
629        let _guard = crate::WAKER_TEST_LOCK
630            .lock()
631            .unwrap_or_else(|e| e.into_inner());
632        ReactiveRuntime::init(noop_waker());
633
634        let ui = thread_policy::current_thread_nice().expect("nice is readable on Linux");
635        let blocking = ReactiveRuntime::get()
636            .expect("runtime was just initialized")
637            .handle()
638            .block_on(spawn_blocking(thread_policy::current_thread_nice))
639            .expect("blocking probe must not panic")
640            .expect("nice is readable on a pool thread");
641
642        let (tx, rx) = std::sync::mpsc::channel();
643        Executor::spawn(async move {
644            tx.send(thread_policy::current_thread_nice())
645                .expect("test receiver must still be alive");
646        });
647        let worker = rx
648            .recv_timeout(std::time::Duration::from_secs(5))
649            .expect("a spawned task must run within 5s")
650            .expect("nice is readable on a worker thread");
651
652        assert_eq!(
653            (blocking, worker),
654            (ui, ui),
655            "with the kill switch engaged every pool thread keeps the UI thread's nice {ui} \
656             (blocking {blocking}, worker {worker})"
657        );
658    }
659
660    /// With `FRUST_NO_THREAD_TUNING` engaged the runtime is built with tokio's
661    /// defaults and no thread's priority is touched. The switch is read from
662    /// the process environment when the runtime is built, so the check runs
663    /// in a child process: this test binary re-executed with the variable set,
664    /// running only the ignored probe above.
665    #[cfg(any(target_os = "android", target_os = "linux"))]
666    #[test]
667    fn the_kill_switch_leaves_every_pool_thread_at_the_inherited_nice() {
668        if !thread_policy::thread_tuning_enabled() {
669            // Already engaged (compile-time define or the environment): the
670            // probe is this process's own run, nothing to drive.
671            return;
672        }
673        let exe = std::env::current_exe().expect("test binary path is known");
674        let mut child = std::process::Command::new(exe)
675            .args([
676                "--exact",
677                "runtime::tests::kill_switch_probe_pool_threads_keep_the_inherited_nice",
678                "--ignored",
679                "--test-threads=1",
680            ])
681            .env(thread_policy::NO_THREAD_TUNING_VAR, "1")
682            .stdout(std::process::Stdio::piped())
683            .stderr(std::process::Stdio::piped())
684            .spawn()
685            .expect("the test binary re-executes");
686        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
687        let status = loop {
688            match child.try_wait().expect("child status is readable") {
689                Some(status) => break status,
690                None if std::time::Instant::now() >= deadline => {
691                    let _ = child.kill();
692                    let _ = child.wait();
693                    panic!("kill-switch probe did not finish within 60s");
694                }
695                None => std::thread::sleep(std::time::Duration::from_millis(50)),
696            }
697        };
698        let mut stdout = String::new();
699        let mut stderr = String::new();
700        {
701            use std::io::Read;
702            let _ = child
703                .stdout
704                .take()
705                .map(|mut o| o.read_to_string(&mut stdout));
706            let _ = child
707                .stderr
708                .take()
709                .map(|mut o| o.read_to_string(&mut stderr));
710        }
711        assert!(
712            status.success() && stdout.contains("1 passed"),
713            "kill-switch probe failed in the child process:\n{stdout}\n{stderr}"
714        );
715    }
716}