Skip to content

fix: don't drop fan control when the profile menu re-renders - #44

Open
bakirgdev-symphony wants to merge 2 commits into
ProducerGuy:mainfrom
bakirgdev-symphony:fix/wake-fan-slam
Open

bakirgdev-symphony wants to merge 2 commits into
ProducerGuy:mainfrom
bakirgdev-symphony:fix/wake-fan-slam

Conversation

@bakirgdev-symphony

@bakirgdev-symphony bakirgdev-symphony commented Aug 25, 2026

Copy link
Copy Markdown

Correction (2026-08-26). This PR was originally filed as the fix for "fans slam to 100% after wake". That attribution was wrong and the body below has been rewritten. The 100% events on my machine came from a second SMC writer — Stats.app's root helper — not from ThermalForge. Details in the comment below.

The defect this PR actually fixes is real and independently evidenced, so the code stands: the profile picker silently hands fan control back to Apple on every panel re-render. I have kept the PR open on those merits alone.

What this fixes

MenuBarView drove the profile list through a Picker with a custom Binding. SwiftUI writes a Picker's binding on re-render, not only on a click. Every re-render writes the first row (silent) through set, then re-asserts the real profile, producing this pair in the log:

[2026-08-25T23:21:27Z] [PROFILE] Selected: Silent (Apple Default)
[2026-08-25T23:21:27Z] [PROFILE] Selected: Performance

silent is handsOff, so selectProfile submits .resetAuto (fans back to Apple auto) and ThermalMonitor.switchProfile zeroes lastAppliedRPMPercent, fansCurrentlyRunning and sustainedAboveCount. Eight seconds later the log confirms the state: Fan0: 0 RPM (auto) | Profile: Performance. The user's chosen profile is showing in the menu while the machine is on Apple's curve.

This is not a user clicking twice. selectProfile has exactly one call site, the Picker binding, and at least one of these pairs fired with nobody at the machine — pmset -g log puts 01:21:27 local inside DarkWake (entered 01:21:03), six seconds before DarkWake to FullWake (01:21:33), display off.

Observed on an M5 Pro (Mac17,8), macOS 26.6.2, ThermalForge 0.2.2.

Changes

  • MenuBarView: Button rows instead of a Picker binding. .tag(profile.id) was already present, so tag inference was not the problem — the issue is that a binding's set is not a gesture. A Button action can only originate from one. Same layout, checkmark on the active row (also what UI improvement - LED indicator for selected Smart profile #27 asks for on Smart).
  • AppState.selectProfile: re-selecting the already-active profile is a no-op, so it cannot reach switchProfile or the handsOff reset. An active external hold is the one reason to still run it.
  • ThermalMonitor / FanProfile: new FanProfile.dangerZoneTemp (85°C). Above it, tickCurve and tickSmart drop the sustained trigger and the ramp-up governor. Both are acoustic comfort and neither is worth reaching safetyTempThreshold for. The ramp-down governor is untouched, so this cannot cause fan hunting.

The danger-zone change stands on its own log evidence, independent of the correction above:

21:46:03Z  [FAN]     Fans on: 1350 RPM at 76.0°C [Performance]
21:46:07Z  [SAFETY]  Override triggered: 95.2°C — fans maxed

Four seconds from first engagement to the 95°C override. The governed ramp from 1350 RPM cannot win that race on this hardware, so the override ends up being what spins the fans, from near standstill straight to max. Above 85°C the governor is the wrong tool.

This also takes some of the sting out of #41 — fans engage proportionally on the way up instead of the 95°C override being the first thing that moves them.

Verification

  • swift build -c release clean (Swift 6.3.3, arm64, macOS 26).
  • swift test: 48 tests in 8 suites, all pass.
  • Running the rebuilt bundle on the affected M5 Pro.

Caveats

The MenuBarView change is inference from log/pmset correlation, not a captured SwiftUI trace — I could not make the internal re-render write reproduce on demand. The other two changes are deterministic and stand on their own.

Two unrelated defects I noticed and deliberately left alone:

  • SMCConnection.readKey never checks output.result, while writeKey explicitly does with a comment noting IOKit reports success on firmware-rejected calls. A rejected read currently returns success: true. Did not cause this bug, and tightening it could reject valid reads on hardware I cannot test.
  • ThermalMonitor.captureTopProcesses returned idle for all 10,679 samples in my logs — p_pctcpu reads 0 on macOS 26, so the pre-spike process history is currently empty of signal.

The profile Picker used a custom Binding. SwiftUI writes a Picker's
binding on re-render, not only on a click, so the menu bar panel
re-rendering after a wake wrote the first row (Silent) through `set`
before re-asserting the active profile. Silent is handsOff, so that echo
submitted .resetAuto while switchProfile zeroed the ramp governor: fans
went back to Apple auto with the monitor believing they were off, right
as post-wake background work heated the SoC.

Observed on an M5 Pro (Mac17,8) running 0.2.2, correlated against
`pmset -g log`: one Silent->Performance pair fired 6s before
DarkWake -> FullWake with the display off, so no click was involved.
Fans then sat at 0 RPM from 44C all the way to 95C and the safety
override was what finally spun them, in one step to max.

- MenuBarView: Button rows instead of a Picker binding. A Button action
  can only originate from a gesture.
- AppState.selectProfile: re-selecting the active profile is a no-op, so
  it cannot reach switchProfile or the handsOff reset.
- ThermalMonitor: above FanProfile.dangerZoneTemp (85C), drop the
  sustained trigger and the ramp-up governor. Performance needs 4s of
  trigger plus 8.5s of ramp to reach its target; this hardware covers
  55->95C in 9s, so the governor was losing the race and the 95C
  override became the first thing to move the fans. Ramp-down governor
  is unchanged.
@bakirgdev-symphony

Copy link
Copy Markdown
Author

hi. this was claudecode opus5 on my behalf. thanks for the software. your app suits my needs the best apart from other similar ones. i am using it despite your lack of maintenance of this repo/project.

i had an issue where fans slam 100% after account re-login or after waking from sleep. claude fixed that. out of simplicity i told it to push a pr for this. please review, refine - or scratch this one and make proper solution

@bakirgdev-symphony

bakirgdev-symphony commented Aug 25, 2026

Copy link
Copy Markdown
Author

aslo there was another bug that auto-set apple's default silent mode on clean startup or logout-login of the account.

i dont want to fry my fingers and like to use Performance mode all of the time to control chassis temp. so it was getting deselected with Silent mode on each startup.

claude code fixed that for me. it told that the bug was in the code, a Swift parameter was hardcoded to the apple's silent mode. claude code fixed that and rebuilt the app for me.

i believe other users too would want their preferred mode to stick.

i advise you to fix these 2 issues and improve your project and maybe release to mac app store, since i see potential of wider use. gl and thx

The 100% fan events this was originally filed against were an external SMC
writer, not the 95C override. The echo itself is unchanged and still real:
pmset puts the 01:21:27 pair inside DarkWake, with no user present.
@bakirgdev-symphony bakirgdev-symphony changed the title fix: don't drop fan control when the menu re-renders after wake fix: don't drop fan control when the profile menu re-renders Aug 26, 2026
@bakirgdev-symphony

Copy link
Copy Markdown
Author

Correcting my own report. I filed this as the fix for "fans slam to 100% after wake". It was not ThermalForge. I went back through it properly and the 100% events on my machine came from a second SMC writer.

What actually happened

2026-08-26, lid-close sleep at 02:11:37, lid-open wake at 02:19:55 (pmset -g log).

Time (local) Event
02:11:37 Fan0: 0 RPM (auto) — Performance, 48°C, quiet
02:19:55 Wake
02:19:56 eu.exelban.Stats.SMC.Helper (root) logs a 112-event burst
02:20:11 ThermalForge reads back Fan0: 5349 RPM (manual) at 43.6°C

Stats.app had Sensors_fanControl = 1, fan_0_speed = 5349, fan_1_speed = 5777 — exactly each fan's max RPM, and exactly what ThermalForge then observed in manual mode.

Why ThermalForge is ruled out

  • I queried the daemon live over /var/run/thermalforge.sock with a state frame: {"owner":"none","safetySuspended":false}. No hold, so handleWake() had nothing to re-apply and the thermal floor's guard suspended || heldCommand != nil short-circuits.
  • Zero [FAN] and zero [SAFETY] entries in the app log across the whole wake window. ThermalMonitor issued no command.
  • Across two days of logs, fans reach ≥5200 RPM in exactly two places. The 21:46–21:50 batch has matching [SAFETY] Override triggered: 95.2°C lines at 96–101°C — that is the app working correctly. The 02:20 event has no matching line at all. Every genuine max-fan event is accounted for in ThermalForge's own log; this one is not.
  • That Stats helper is not a poller. In a 50-minute window it logged activity twice: one event at 02:00, and the 112-event burst one second after wake. It only wakes up to do privileged SMC writes.

Disabling Stats' fan control removes the symptom. Same mechanism explains the "after re-login" variant — Stats relaunches at login and re-applies the same configuration.

One loose end I will not overstate: Stats persists fan_0_mode = 0, which reads as automatic in its enum, so the exact path inside Stats is not pinned down. The correlation carries the conclusion, not a read of Stats' source.

What this means for this PR

The Picker echo is a separate, real defect and I have left the PR open on those merits. It is not a 100%-fan bug — it is a silent-loss-of-control bug: the menu shows Performance while the machine is actually on Apple's curve. The pmset DarkWake correlation for the unattended 01:21:27 pair still holds.

I have retitled the PR, rewritten the body, and pushed c241c39 correcting the code comment in MenuBarView.swift, which asserted the wake causation. No functional change.

Worth considering independently of this PR: ThermalForge silently loses arbitration to any other SMC fan writer, and status() will happily report a manual hold it never set. Surfacing "something else is driving the fans" in the menu would have saved me an evening.

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