Skip to content

fix(fan-curve): keep the firmware watchdog alive at a steady temperature - #36

Merged
vietanhdev merged 2 commits into
mainfrom
fix/fan-curve-watchdog-rearm
Jul 20, 2026
Merged

fix(fan-curve): keep the firmware watchdog alive at a steady temperature#36
vietanhdev merged 2 commits into
mainfrom
fix/fan-curve-watchdog-rearm

Conversation

@vietanhdev

Copy link
Copy Markdown
Owner

The bug

arm_fan_watchdog() was called only inside the last_level != Some(target_level) branch — i.e. only when the fan level changes.

But thinkpad_acpi's watchdog is one-shot: it counts down from the last fan command and hands the fan back to firmware control when it hits zero. The file's own doc comment already stated this contract; the code just didn't satisfy it.

A curve sitting at a steady temperature issues no fan commands at all. So:

  1. Temp settles at 42°C, curve writes level 0, arms the 30s watchdog
  2. Next 15 iterations compute target 0, last_level already 0 → no write, no re-arm
  3. At t+30s the firmware watchdog fires and returns the fan to automatic control
  4. last_level is still Some(0), so nothing ever rewrites it

The curve is now inert until the temperature crosses a curve segment. Meanwhile fan-curve-update keeps emitting every 2s, so the UI shows a working curve that hasn't touched the fan in minutes.

The steady state is the common case, which makes this the normal outcome rather than an edge case, and it fails invisibly.

The fix

Re-arm on a timer instead of on level change. watchdog_due() fires at half the watchdog interval, which leaves a full 2s loop tick of slack so one delayed iteration can't lose the fan. last_armed is cleared alongside last_level wherever the curve stops steering (curve disabled, temperature unreadable), so re-enabling arms immediately rather than inheriting a stale deadline.

Verification

Two tests, both mutation-verified — forcing watchdog_due() to false restores the old behaviour and both fail:

watchdog_is_rearmed_well_before_the_firmware_gives_up ... FAILED
  must become due before the firmware watchdog expires
holding_one_level_still_keeps_the_watchdog_alive ... FAILED
  watchdog went 30s without a re-arm - the fan would have reverted to firmware control

The second walks the real 2s loop cadence across five minutes of dead-steady temperature and asserts the gap never reaches the expiry.

99 tests pass, clippy clean.

Found while auditing the backend for runtime defects.

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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
thinkutils 5c0bb06 Jul 20 2026, 03:08 AM

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.
@vietanhdev
vietanhdev merged commit ede358b into main Jul 20, 2026
9 of 10 checks passed
@vietanhdev
vietanhdev deleted the fix/fan-curve-watchdog-rearm branch July 20, 2026 03:18
vietanhdev added a commit that referenced this pull request Jul 20, 2026
The concurrency block intended never to cancel on main -- the comment said
so -- and did the opposite:

  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

The expression renders to the STRING "false", and a non-empty string is
truthy in that position, so main was cancelled like any other ref. It
failed silently for exactly as long as nobody merged twice in quick
succession.

Four main runs were cancelled during this batch of merges (#36, #37, #39,
#32), each with ZERO jobs recorded -- so those commits have no evidence
they ever built. The runs that were supposed to be the record of what
shipped are the ones that got killed.

Encoding the rule in the concurrency GROUP is unambiguous: on main the SHA
gives every run its own group, so there is nothing to supersede; every
other ref keeps a per-ref group, so a force-push still cancels the old run.

tests/workflow_concurrency.rs guards both halves -- an expression-valued
cancel-in-progress, and a group that lost its per-SHA component (which
with cancel-in-progress: true would cancel main on every push, strictly
worse than the bug it replaced). Mutation-verified: restoring the original
two lines fails both.

118 tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant