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}