Skip to content

Commit ede358b

Browse files
authored
fix(fan-curve): keep the firmware watchdog alive at a steady temperature (#36)
* fix(fan-curve): keep the watchdog alive at a steady temperature The firmware watchdog is one-shot: it counts down from the last fan command and hands the fan back to thinkpad_acpi when it reaches zero. arm_fan_watchdog() was called only inside the 'level changed' branch, which reads as correct but inverts the actual risk -- a curve sitting at a steady temperature issues no fan commands at all. So roughly 30s after the temperature settled, the firmware silently reclaimed the fan. last_level still matched the target, so nothing ever rewrote it, and the UI went on emitting fan-curve-update every 2s showing the curve as active. The failure is invisible and the steady state is the common case, not an edge case. Re-arms on a timer at half the watchdog interval, which leaves a full loop tick of slack so one delayed iteration cannot lose the fan. Cleared alongside last_level wherever the curve stops steering, so re-enabling arms immediately rather than inheriting a stale deadline. Verified by mutation: forcing watchdog_due() to false reproduces the old behaviour and both tests fail, one reporting 30s without a re-arm. * fix(fan-curve): say when a computed level was not applied The update event carried only fan_level, taken from last_level.unwrap_or(target_level). When every write fails -- no helper installed, /proc/acpi/ibm/fan not writable -- last_level stays None and the event reported the level the curve *wanted*, byte-identical to one it had actually set. The fan-curve-error toast fires once, guarded by permission_error_reported, and is easy to miss or dismiss. After that the panel looked correct indefinitely while the curve had never touched the fan. Adds a controlling flag alongside the level. The panel appends "(not applied)" and tints the figure when it is false. Compared with === false so an event without the field still reads as controlling.
1 parent 4973219 commit ede358b

3 files changed

Lines changed: 122 additions & 6 deletions

File tree

src-tauri/src/fan_curve.rs

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use serde::{Deserialize, Serialize};
22
use std::sync::{Arc, Mutex};
3-
use std::time::Duration;
3+
use std::time::{Duration, Instant};
44
use tauri::{AppHandle, Emitter, Manager};
55
use tauri_plugin_store::StoreExt;
66
use tokio::time::sleep;
@@ -225,7 +225,8 @@ fn get_cpu_temperature() -> Result<i32, String> {
225225
///
226226
/// Shared with the helper script's whitelist, which only accepts this exact
227227
/// value. thinkpad_acpi's watchdog is one-shot and only rearms when it receives
228-
/// a fan command, so it is re-armed on every level change rather than once.
228+
/// a fan command, so it is re-armed on a timer — see [`watchdog_due`] for why
229+
/// re-arming on level change alone was not enough.
229230
use crate::fan_control::FAN_WATCHDOG_SECS;
230231

231232
/// Hand the fan back to firmware control.
@@ -249,6 +250,21 @@ async fn arm_fan_watchdog() {
249250
let _ = write_fan_command(&format!("watchdog {}", FAN_WATCHDOG_SECS)).await;
250251
}
251252

253+
/// Whether the firmware watchdog is due to be re-armed.
254+
///
255+
/// The watchdog is one-shot: it counts down from the last fan command and hands
256+
/// the fan back to the firmware when it reaches zero. Re-arming only on level
257+
/// change looks right, but a curve sitting at a steady temperature issues no fan
258+
/// commands at all — so the fan silently reverted to automatic control roughly
259+
/// [`FAN_WATCHDOG_SECS`] after the temperature settled, which is the *common*
260+
/// case rather than an edge case. `last_level` still matched the target, so
261+
/// nothing rewrote it and the UI went on reporting the curve as active.
262+
///
263+
/// Re-armed at half the interval so one slow or skipped tick cannot expire it.
264+
fn watchdog_due(since_last_arm: Duration) -> bool {
265+
since_last_arm >= Duration::from_secs((FAN_WATCHDOG_SECS / 2) as u64)
266+
}
267+
252268
/// Synchronous counterpart to [`restore_fan_to_auto`], for the app exit handler.
253269
///
254270
/// Runs unconditionally on shutdown: once this process is gone nothing is left
@@ -334,6 +350,7 @@ async fn write_fan_command(command: &str) -> Result<(), String> {
334350
pub async fn fan_curve_background_task(app: AppHandle) {
335351
let state = app.state::<FanCurveState>();
336352
let mut last_level: Option<i32> = None;
353+
let mut last_armed: Option<Instant> = None;
337354
let mut error_count = 0;
338355
let mut permission_error_reported = false;
339356
const MAX_ERRORS: i32 = 5;
@@ -359,6 +376,7 @@ pub async fn fan_curve_background_task(app: AppHandle) {
359376
restore_fan_to_auto().await;
360377
}
361378
last_level = None;
379+
last_armed = None;
362380
permission_error_reported = false;
363381
continue;
364382
}
@@ -380,6 +398,7 @@ pub async fn fan_curve_background_task(app: AppHandle) {
380398
eprintln!("[Fan Curve] Temperature unreadable — returning fan to auto");
381399
restore_fan_to_auto().await;
382400
last_level = None;
401+
last_armed = None;
383402
let _ = app.emit_to(
384403
"main",
385404
"fan-curve-error",
@@ -408,6 +427,7 @@ pub async fn fan_curve_background_task(app: AppHandle) {
408427
last_level = Some(target_level);
409428
permission_error_reported = false;
410429
arm_fan_watchdog().await;
430+
last_armed = Some(Instant::now());
411431
}
412432
Err(e) => {
413433
eprintln!("[Fan Curve] Failed to set fan speed: {}", e);
@@ -425,16 +445,29 @@ pub async fn fan_curve_background_task(app: AppHandle) {
425445
}
426446
}
427447
}
448+
} else if last_level.is_some() && last_armed.is_none_or(|t| watchdog_due(t.elapsed())) {
449+
// Holding a level still counts as steering the fan, so the watchdog
450+
// has to be kept alive even though nothing is being changed.
451+
arm_fan_watchdog().await;
452+
last_armed = Some(Instant::now());
428453
}
429454

430-
// Always emit temperature and current level to frontend for live UI updates
455+
// Always emit temperature and current level to frontend for live UI updates.
456+
//
457+
// `controlling` says whether that level was actually applied. When every
458+
// write is failing — no helper installed, /proc not writable — last_level
459+
// stays None and this reported the level it *wanted*, indistinguishable
460+
// from one it had set. The fan-curve-error toast fires once and is easy
461+
// to miss or dismiss, after which the display looked correct forever.
462+
let controlling = last_level.is_some();
431463
let display_level = last_level.unwrap_or(target_level);
432464
if let Err(e) = app.emit_to(
433465
"main",
434466
"fan-curve-update",
435467
serde_json::json!({
436468
"temperature": temp,
437469
"fan_level": display_level,
470+
"controlling": controlling,
438471
}),
439472
) {
440473
eprintln!("[Fan Curve] Failed to emit event: {}", e);
@@ -446,6 +479,77 @@ pub async fn fan_curve_background_task(app: AppHandle) {
446479
mod tests {
447480
use super::*;
448481

482+
/// The regression this exists for: the curve re-armed the watchdog only when
483+
/// the level changed, so a machine sitting at a steady temperature issued no
484+
/// fan commands and the firmware took the fan back after FAN_WATCHDOG_SECS.
485+
///
486+
/// The loop ticks every 2s, so this asserts the re-arm lands with room to
487+
/// spare rather than on the exact boundary.
488+
#[test]
489+
fn watchdog_is_rearmed_well_before_the_firmware_gives_up() {
490+
let expiry = Duration::from_secs(FAN_WATCHDOG_SECS as u64);
491+
492+
assert!(
493+
!watchdog_due(Duration::from_secs(0)),
494+
"no re-arm needed immediately after arming"
495+
);
496+
497+
let due_at = (1..=FAN_WATCHDOG_SECS as u64)
498+
.map(Duration::from_secs)
499+
.find(|d| watchdog_due(*d))
500+
.expect("must become due before the firmware watchdog expires");
501+
502+
assert!(
503+
due_at < expiry,
504+
"re-arm becomes due at {:?} but the firmware gives up at {:?}",
505+
due_at,
506+
expiry
507+
);
508+
509+
// At least one full 2s tick has to fit between "due" and "expired",
510+
// otherwise a single slow iteration loses the fan.
511+
assert!(
512+
expiry - due_at >= Duration::from_secs(2),
513+
"only {:?} of slack between due ({:?}) and expiry ({:?}) - one \
514+
delayed tick would let the watchdog fire",
515+
expiry - due_at,
516+
due_at,
517+
expiry
518+
);
519+
}
520+
521+
/// A level held across many ticks is the case that used to silently stop
522+
/// steering the fan, so walk the actual loop cadence rather than a single
523+
/// duration.
524+
#[test]
525+
fn holding_one_level_still_keeps_the_watchdog_alive() {
526+
const TICK: u64 = 2;
527+
let mut since_arm = Duration::from_secs(0);
528+
let mut rearms = 0;
529+
530+
// Five minutes at a dead-steady temperature: no level change, ever.
531+
for _ in 0..(300 / TICK) {
532+
since_arm += Duration::from_secs(TICK);
533+
if watchdog_due(since_arm) {
534+
rearms += 1;
535+
since_arm = Duration::from_secs(0);
536+
}
537+
assert!(
538+
since_arm < Duration::from_secs(FAN_WATCHDOG_SECS as u64),
539+
"watchdog went {:?} without a re-arm - the fan would have \
540+
reverted to firmware control",
541+
since_arm
542+
);
543+
}
544+
545+
assert!(
546+
rearms >= 9,
547+
"expected roughly one re-arm per {}s over 5 minutes, got {}",
548+
FAN_WATCHDOG_SECS / 2,
549+
rearms
550+
);
551+
}
552+
449553
#[test]
450554
fn test_calculate_fan_level() {
451555
let points = vec![

src/js/fanCurve.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,17 @@ export async function startCurveMode() {
6464
const { listen } = window.__TAURI__.event;
6565
if (!window.fanCurveUnlisten) {
6666
window.fanCurveUnlisten = await listen('fan-curve-update', (event) => {
67-
const { temperature, fan_level } = event.payload;
67+
const { temperature, fan_level, controlling } = event.payload;
6868
currentTemp = temperature;
6969

70-
// Update UI
70+
// Update UI. `controlling` is false when the backend computed this level
71+
// but could not apply it, which otherwise looks identical to a level it
72+
// did apply — the error toast fires once and is easy to miss.
7173
document.getElementById('curve-current-temp').textContent = `${temperature}°C`;
72-
document.getElementById('curve-target-speed').textContent = `Level ${fan_level}`;
74+
const speedEl = document.getElementById('curve-target-speed');
75+
speedEl.textContent =
76+
controlling === false ? `Level ${fan_level} (not applied)` : `Level ${fan_level}`;
77+
speedEl.classList.toggle('curve-not-applied', controlling === false);
7378

7479
drawCurve();
7580
});

src/styles/fan.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,13 @@
621621
color: var(--text-primary);
622622
}
623623

624+
/* The curve computed this level but could not apply it — usually no fan helper
625+
installed. Without this the figure is indistinguishable from one that took
626+
effect, and the accompanying error toast only appears once. */
627+
.curve-info-value.curve-not-applied {
628+
color: var(--power-color);
629+
}
630+
624631
.fan-curve-help,
625632
.curve-help {
626633
padding: 12px;

0 commit comments

Comments
 (0)