fix: don't drop fan control when the profile menu re-renders - #44
bakirgdev-symphony wants to merge 2 commits into
Conversation
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.
|
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 |
|
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.
|
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
Stats.app had Why ThermalForge is ruled out
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 What this means for this PRThe 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 I have retitled the PR, rewritten the body, and pushed Worth considering independently of this PR: ThermalForge silently loses arbitration to any other SMC fan writer, and |
What this fixes
MenuBarViewdrove the profile list through aPickerwith a customBinding. SwiftUI writes aPicker's binding on re-render, not only on a click. Every re-render writes the first row (silent) throughset, then re-asserts the real profile, producing this pair in the log:silentishandsOff, soselectProfilesubmits.resetAuto(fans back to Apple auto) andThermalMonitor.switchProfilezeroeslastAppliedRPMPercent,fansCurrentlyRunningandsustainedAboveCount. 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.
selectProfilehas exactly one call site, the Picker binding, and at least one of these pairs fired with nobody at the machine —pmset -g logputs 01:21:27 local insideDarkWake(entered 01:21:03), six seconds beforeDarkWake to FullWake(01:21:33), display off.Observed on an M5 Pro (Mac17,8), macOS 26.6.2, ThermalForge 0.2.2.
Changes
MenuBarView:Buttonrows instead of aPickerbinding..tag(profile.id)was already present, so tag inference was not the problem — the issue is that a binding'ssetis not a gesture. AButtonaction 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 reachswitchProfileor thehandsOffreset. An active external hold is the one reason to still run it.ThermalMonitor/FanProfile: newFanProfile.dangerZoneTemp(85°C). Above it,tickCurveandtickSmartdrop the sustained trigger and the ramp-up governor. Both are acoustic comfort and neither is worth reachingsafetyTempThresholdfor. 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:
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 releaseclean (Swift 6.3.3, arm64, macOS 26).swift test: 48 tests in 8 suites, all pass.Caveats
The
MenuBarViewchange is inference from log/pmsetcorrelation, 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.readKeynever checksoutput.result, whilewriteKeyexplicitly does with a comment noting IOKit reports success on firmware-rejected calls. A rejected read currently returnssuccess: true. Did not cause this bug, and tightening it could reject valid reads on hardware I cannot test.ThermalMonitor.captureTopProcessesreturnedidlefor all 10,679 samples in my logs —p_pctcpureads 0 on macOS 26, so the pre-spike process history is currently empty of signal.