use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Emitter, Wry};
use crate::EVENT_STALL;
#[derive(Debug, Clone, Copy)]
pub struct StallReport {
pub stalled_ms: u64,
pub threshold_ms: u64,
}
pub type StallCallback = Arc<dyn Fn(&StallReport) + Send + Sync>;
pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(250);
#[derive(Clone)]
pub struct WatchdogConfig {
pub threshold: Duration,
pub heartbeat_interval: Duration,
pub poll_interval: Duration,
pub on_stall: Option<StallCallback>,
pub emit_event: bool,
}
impl Default for WatchdogConfig {
fn default() -> Self {
Self {
threshold: Duration::from_secs(5),
heartbeat_interval: Duration::from_secs(1),
poll_interval: DEFAULT_POLL_INTERVAL,
on_stall: None,
emit_event: true,
}
}
}
#[derive(Debug, Clone)]
pub struct StallDetector {
threshold_ms: u64,
fired: bool,
}
impl StallDetector {
pub fn new(threshold: Duration) -> Self {
Self {
threshold_ms: threshold.as_millis() as u64,
fired: false,
}
}
pub fn observe(&mut self, last_beat_ms: u64, now_ms: u64) -> Option<StallReport> {
if should_fire(last_beat_ms, now_ms, self.fired, self.threshold_ms) {
self.fired = true;
return Some(StallReport {
stalled_ms: now_ms.saturating_sub(last_beat_ms),
threshold_ms: self.threshold_ms,
});
}
if self.fired && now_ms.saturating_sub(last_beat_ms) <= self.threshold_ms {
self.fired = false;
}
None
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn should_fire(last_beat_ms: u64, now_ms: u64, already_fired: bool, threshold_ms: u64) -> bool {
!already_fired && now_ms.saturating_sub(last_beat_ms) > threshold_ms
}
static LAST_BEAT_MS: AtomicU64 = AtomicU64::new(0);
static COUNTERS: PollCounters = PollCounters::new();
pub(crate) struct PollCounters {
stalls: AtomicU64,
emit_failures: AtomicU64,
}
impl PollCounters {
pub(crate) const fn new() -> Self {
Self {
stalls: AtomicU64::new(0),
emit_failures: AtomicU64::new(0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WatchdogStatus {
pub installed: bool,
pub last_beat_age_ms: u64,
pub stalls_total: u64,
pub emit_failures: u64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShellRuntimeStatus {
pub watchdog: WatchdogStatus,
pub process: Option<rightkit_child_process::ProcessUsage>,
}
pub fn status() -> ShellRuntimeStatus {
let beat = LAST_BEAT_MS.load(Ordering::Relaxed);
ShellRuntimeStatus {
watchdog: WatchdogStatus {
installed: beat != 0,
last_beat_age_ms: if beat == 0 {
0
} else {
now_ms().saturating_sub(beat)
},
stalls_total: COUNTERS.stalls.load(Ordering::Relaxed),
emit_failures: COUNTERS.emit_failures.load(Ordering::Relaxed),
},
process: rightkit_child_process::process_usage().ok(),
}
}
const MIN_PERIOD: Duration = Duration::from_millis(10);
const EPISODE_RECHECK: Duration = Duration::from_secs(1);
pub fn install(app: AppHandle<Wry>, cfg: WatchdogConfig) {
let last = Arc::new(AtomicU64::new(now_ms()));
LAST_BEAT_MS.store(now_ms(), Ordering::Relaxed);
let bell = Arc::new(Bell::new());
{
let (app, last, bell, interval) = (
app.clone(),
Arc::clone(&last),
Arc::clone(&bell),
cfg.heartbeat_interval.max(MIN_PERIOD),
);
std::thread::Builder::new()
.name("rk-shell-heartbeat".into())
.spawn(move || {
heartbeat_loop(&bell, interval, || {
let last = Arc::clone(&last);
let bell = Arc::clone(&bell);
app.run_on_main_thread(move || {
let now = now_ms();
last.store(now, Ordering::SeqCst);
LAST_BEAT_MS.store(now, Ordering::Relaxed);
bell.beat();
})
.is_ok()
})
})
.ok();
}
let poll = cfg.poll_interval.max(MIN_PERIOD);
let emit = emitter_for(cfg.emit_event, || {
let app = app.clone();
Box::new(move |report: &StallReport| {
app.emit(
EVENT_STALL,
serde_json::json!({"stalledMs": report.stalled_ms, "thresholdMs": report.threshold_ms}),
)
.map_err(|e| e.to_string())
})
});
std::thread::Builder::new()
.name("rk-shell-watchdog".into())
.spawn(move || {
poll_loop(
&last,
StallDetector::new(cfg.threshold),
poll,
cfg.on_stall.as_ref(),
emit.as_deref(),
&COUNTERS,
&bell,
)
})
.ok();
}
pub(crate) struct Bell {
state: Mutex<BellState>,
watch: Condvar,
pulse: Condvar,
#[cfg(test)]
wakes: AtomicU64,
}
#[derive(Default)]
struct BellState {
stop: bool,
beats: u64,
watching: bool,
}
impl Bell {
pub(crate) fn new() -> Self {
Self {
state: Mutex::new(BellState::default()),
watch: Condvar::new(),
pulse: Condvar::new(),
#[cfg(test)]
wakes: AtomicU64::new(0),
}
}
fn state(&self) -> MutexGuard<'_, BellState> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(test)]
pub(crate) fn stop(&self) {
self.state().stop = true;
self.watch.notify_all();
self.pulse.notify_all();
}
pub(crate) fn is_stopped(&self) -> bool {
self.state().stop
}
pub(crate) fn beat(&self) {
let mut state = self.state();
state.beats += 1;
if state.watching {
self.watch.notify_all();
}
}
pub(crate) fn beats(&self) -> u64 {
self.state().beats
}
pub(crate) fn sleep_unless_stopped(&self, period: Duration) -> bool {
let state = self.state();
let (state, _) = self
.pulse
.wait_timeout_while(state, period, |s| !s.stop)
.unwrap_or_else(|e| e.into_inner());
#[cfg(test)]
self.wakes.fetch_add(1, Ordering::SeqCst);
state.stop
}
pub(crate) fn wait_watch(&self, timeout: Duration, episode: Option<u64>) -> bool {
let mut state = self.state();
state.watching = episode.is_some();
let (mut state, _) = self
.watch
.wait_timeout_while(state, timeout, |s| {
!s.stop && episode.is_none_or(|seen| s.beats == seen)
})
.unwrap_or_else(|e| e.into_inner());
state.watching = false;
#[cfg(test)]
self.wakes.fetch_add(1, Ordering::SeqCst);
state.stop
}
#[cfg(test)]
pub(crate) fn wakes(&self) -> u64 {
self.wakes.load(Ordering::SeqCst)
}
}
pub(crate) fn heartbeat_loop(bell: &Bell, interval: Duration, mut ping: impl FnMut() -> bool) {
while !bell.sleep_unless_stopped(interval) {
if !ping() {
break;
}
}
}
fn until_stall(beat_ms: u64, threshold_ms: u64, now_ms: u64) -> Duration {
let wait = beat_ms
.saturating_add(threshold_ms)
.saturating_add(1)
.saturating_sub(now_ms);
Duration::from_millis(wait.min(threshold_ms.saturating_add(1)))
}
pub(crate) type EmitDyn = dyn Fn(&StallReport) -> Result<(), String> + Send;
pub(crate) type EmitFn = Box<EmitDyn>;
pub(crate) fn emitter_for(emit_event: bool, make: impl FnOnce() -> EmitFn) -> Option<EmitFn> {
emit_event.then(make)
}
pub(crate) fn poll_loop(
last: &AtomicU64,
mut detector: StallDetector,
poll: Duration,
on_stall: Option<&StallCallback>,
emit: Option<&EmitDyn>,
counters: &PollCounters,
bell: &Bell,
) {
let threshold_ms = detector.threshold_ms;
let episode_wait = Duration::from_millis(threshold_ms).max(EPISODE_RECHECK);
let mut reported: Option<u64> = None;
while !bell.is_stopped() {
let seen = bell.beats();
let beat = last.load(Ordering::SeqCst);
if detector.fired && reported != Some(beat) {
detector.fired = false;
}
if let Some(report) = detector.observe(beat, now_ms()) {
reported = Some(beat);
counters.stalls.fetch_add(1, Ordering::Relaxed);
if let Some(cb) = on_stall {
cb(&report);
}
if let Some(emit) = emit {
if emit(&report).is_err() {
counters.emit_failures.fetch_add(1, Ordering::Relaxed);
}
}
}
let stopped = if detector.fired {
bell.wait_watch(episode_wait, Some(seen))
} else {
bell.wait_watch(until_stall(beat, threshold_ms, now_ms()).max(poll), None)
};
if stopped {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
use std::time::Instant;
const POLL: Duration = Duration::from_millis(10);
fn wait_for(what: &str, mut ok: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while !ok() {
assert!(Instant::now() < deadline, "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn a_failing_emit_is_counted_and_the_next_stall_still_fires() {
let last = Arc::new(AtomicU64::new(0)); let counters = Arc::new(PollCounters::new());
let bell = Arc::new(Bell::new());
let fired = Arc::new(AtomicUsize::new(0));
let emits = Arc::new(AtomicUsize::new(0));
let (l, c, b, f, e) = (
last.clone(),
counters.clone(),
bell.clone(),
fired.clone(),
emits.clone(),
);
let thread = std::thread::spawn(move || {
let on_stall: StallCallback = Arc::new(move |_| {
f.fetch_add(1, Ordering::SeqCst);
});
let emit: EmitFn = Box::new(move |_| {
e.fetch_add(1, Ordering::SeqCst);
Err("webview gone".into())
});
poll_loop(
&l,
StallDetector::new(Duration::from_millis(50)),
POLL,
Some(&on_stall),
Some(&*emit),
&c,
&b,
);
});
wait_for("the first stall", || fired.load(Ordering::SeqCst) == 1);
wait_for("its failed emit to be counted", || {
counters.emit_failures.load(Ordering::SeqCst) == 1
});
last.store(now_ms(), Ordering::SeqCst);
bell.beat();
wait_for("the second stall", || fired.load(Ordering::SeqCst) == 2);
wait_for("its failed emit", || {
counters.emit_failures.load(Ordering::SeqCst) == 2
});
assert!(
!thread.is_finished(),
"an emit failure must not end the watchdog"
);
assert_eq!(emits.load(Ordering::SeqCst), 2);
assert_eq!(counters.stalls.load(Ordering::SeqCst), 2);
bell.stop();
thread.join().unwrap();
}
#[test]
fn callback_only_mode_never_builds_or_uses_the_handle() {
let emit = emitter_for(false, || {
panic!("callback-only mode must not touch the app handle")
});
assert!(emit.is_none());
let last = Arc::new(AtomicU64::new(0));
let counters = Arc::new(PollCounters::new());
let bell = Arc::new(Bell::new());
let fired = Arc::new(AtomicUsize::new(0));
let (l, c, b, f) = (last.clone(), counters.clone(), bell.clone(), fired.clone());
let thread = std::thread::spawn(move || {
let on_stall: StallCallback = Arc::new(move |_| {
f.fetch_add(1, Ordering::SeqCst);
});
poll_loop(
&l,
StallDetector::new(Duration::from_millis(50)),
POLL,
Some(&on_stall),
emit.as_deref(),
&c,
&b,
);
});
wait_for("the callback", || fired.load(Ordering::SeqCst) == 1);
assert_eq!(counters.emit_failures.load(Ordering::SeqCst), 0);
bell.stop();
thread.join().unwrap();
let built = std::cell::Cell::new(0);
let emit = emitter_for(true, || {
built.set(built.get() + 1);
Box::new(|_| Ok(()))
});
assert!(emit.is_some());
assert_eq!(built.get(), 1);
}
fn finished(rx: &std::sync::mpsc::Receiver<&'static str>, n: usize) -> Vec<&'static str> {
let mut done: Vec<&'static str> = (0..n)
.map(|_| {
rx.recv_timeout(Duration::from_secs(10))
.expect("a thread did not end after the stop")
})
.collect();
done.sort_unstable();
done
}
#[test]
fn stop_wakes_both_threads_out_of_hour_long_waits() {
let last = Arc::new(AtomicU64::new(now_ms()));
let counters = Arc::new(PollCounters::new());
let bell = Arc::new(Bell::new());
let (tx, rx) = std::sync::mpsc::channel();
let (l, c, b, t) = (last.clone(), counters.clone(), bell.clone(), tx.clone());
let watchdog = std::thread::spawn(move || {
poll_loop(
&l,
StallDetector::new(Duration::from_secs(3600)),
POLL,
None,
None,
&c,
&b,
);
let _ = t.send("watchdog");
});
let (b, t) = (bell.clone(), tx);
let heartbeat = std::thread::spawn(move || {
heartbeat_loop(&b, Duration::from_secs(3600), || true);
let _ = t.send("heartbeat");
});
std::thread::sleep(Duration::from_millis(100));
bell.stop();
assert_eq!(finished(&rx, 2), ["heartbeat", "watchdog"]);
watchdog.join().unwrap();
heartbeat.join().unwrap();
assert_eq!(counters.stalls.load(Ordering::SeqCst), 0);
}
#[test]
fn a_heartbeat_wakes_a_stalled_watchdog_at_once() {
let last = Arc::new(AtomicU64::new(0)); let counters = Arc::new(PollCounters::new());
let bell = Arc::new(Bell::new());
let (l, c, b) = (last.clone(), counters.clone(), bell.clone());
let thread = std::thread::spawn(move || {
poll_loop(
&l,
StallDetector::new(Duration::from_secs(600)),
POLL,
None,
None,
&c,
&b,
);
});
wait_for("the stall", || counters.stalls.load(Ordering::SeqCst) == 1);
let woke = bell.wakes();
last.store(now_ms(), Ordering::SeqCst);
bell.beat();
wait_for("the beat to wake the watchdog", || bell.wakes() > woke);
assert_eq!(counters.stalls.load(Ordering::SeqCst), 1);
bell.stop();
thread.join().unwrap();
}
#[test]
fn a_healthy_watchdog_is_not_woken_by_beats() {
let last = Arc::new(AtomicU64::new(now_ms()));
let counters = Arc::new(PollCounters::new());
let bell = Arc::new(Bell::new());
let (l, c, b) = (last.clone(), counters.clone(), bell.clone());
let thread = std::thread::spawn(move || {
poll_loop(
&l,
StallDetector::new(Duration::from_secs(600)),
POLL,
None,
None,
&c,
&b,
);
});
std::thread::sleep(Duration::from_millis(100));
for _ in 0..50 {
last.store(now_ms(), Ordering::SeqCst);
bell.beat();
}
std::thread::sleep(Duration::from_millis(100));
assert_eq!(
bell.wakes(),
0,
"a beat must not wake a watchdog with no stall open"
);
bell.stop();
thread.join().unwrap();
}
#[test]
fn a_quiet_watchdog_wakes_about_once_per_heartbeat() {
let last = Arc::new(AtomicU64::new(now_ms()));
let counters = Arc::new(PollCounters::new());
let bell = Arc::new(Bell::new());
let (tx, rx) = std::sync::mpsc::channel();
let (l, c, b, t) = (last.clone(), counters.clone(), bell.clone(), tx.clone());
let watchdog = std::thread::spawn(move || {
poll_loop(
&l,
StallDetector::new(Duration::from_secs(5)),
POLL,
None,
None,
&c,
&b,
);
let _ = t.send("watchdog");
});
let (l, b, t) = (last.clone(), bell.clone(), tx);
let heartbeat = std::thread::spawn(move || {
heartbeat_loop(&b, Duration::from_secs(1), || {
l.store(now_ms(), Ordering::SeqCst);
b.beat();
true
});
let _ = t.send("heartbeat");
});
let (start, woke_before) = (Instant::now(), bell.wakes());
std::thread::sleep(Duration::from_secs(2));
let (secs, woke) = (start.elapsed().as_secs_f64(), bell.wakes() - woke_before);
let allowed = 3u64.max(secs.ceil() as u64 + 1);
assert!(woke <= allowed, "woke {woke} times in {secs:.1} s");
assert_eq!(counters.stalls.load(Ordering::SeqCst), 0);
bell.stop();
finished(&rx, 2);
watchdog.join().unwrap();
heartbeat.join().unwrap();
}
#[test]
fn a_beat_that_goes_silent_is_reported_at_the_threshold() {
let last = Arc::new(AtomicU64::new(now_ms()));
let counters = Arc::new(PollCounters::new());
let bell = Arc::new(Bell::new());
let reports = Arc::new(Mutex::new(Vec::<StallReport>::new()));
let (l, c, b, r) = (
last.clone(),
counters.clone(),
bell.clone(),
reports.clone(),
);
let thread = std::thread::spawn(move || {
let on_stall: StallCallback = Arc::new(move |report: &StallReport| {
r.lock().unwrap().push(*report);
});
poll_loop(
&l,
StallDetector::new(Duration::from_secs(1)),
DEFAULT_POLL_INTERVAL,
Some(&on_stall),
None,
&c,
&b,
);
});
wait_for("the stall", || !reports.lock().unwrap().is_empty());
let report = reports.lock().unwrap()[0];
assert_eq!(report.threshold_ms, 1000);
assert!(report.stalled_ms > 1000, "{report:?}");
assert!(report.stalled_ms < 2000, "reported late: {report:?}");
bell.stop();
thread.join().unwrap();
}
#[test]
fn the_wait_to_the_next_possible_stall_follows_the_beat_and_never_exceeds_the_threshold() {
assert_eq!(
until_stall(9_000, 5_000, 10_000),
Duration::from_millis(4_001)
);
assert_eq!(until_stall(1_000, 5_000, 6_001), Duration::ZERO);
assert_eq!(until_stall(1_000, 5_000, 99_000), Duration::ZERO);
assert_eq!(
until_stall(1_000_000, 5_000, 10_000),
Duration::from_millis(5_001)
);
assert_eq!(
until_stall(10_000, 5_000, 10_000),
Duration::from_millis(5_001)
);
}
}