Skip to content

fix(fan): fail-safe holds fans at max RPM instead of restoring auto - #23

Open
catalinb13 wants to merge 1 commit into
leaperone:mainfrom
catalinb13:fix/fan-safety-hold-at-max
Open

catalinb13 wants to merge 1 commit into
leaperone:mainfrom
catalinb13:fix/fan-safety-hold-at-max

Conversation

@catalinb13

Copy link
Copy Markdown

Problem

The fan safety guard was fail-open. On a trip it handed the fans back to Apple's automatic control and rewrote config.fan.profile = "auto" on disk, permanently destroying the user's curve until it was manually re-applied. This is the reported "smctl sometimes just does not work and resets to Apple default" behavior.

Field incident (M1 Max Mac Studio, October 2026), from the unified log:

2026-10-09 22:04 smctld[319] Fan safety guard tripped: temperature TC22 100.67C held at/above safety ceiling 100.0C for 2 consecutive checks; restoring all fans to auto
2026-10-10 08:30 smctld[322] Fan safety guard tripped: temperature Ts02 279.56C held at/above safety ceiling 100.0C for 2 consecutive checks; restoring all fans to auto
  • TC22 100.67C is a normal Apple Silicon die temperature under sustained load — a false trip.
  • Ts02 279.56C is a corrupt SMC sentinel reading (the sensor reports ~66C) — physically impossible. It not only tripped the guard, it also blocked latch release, pinning the machine on Apple's default curve indefinitely.

Changes

Fail-closed safety hold (holdAtMaxRPM)

FanSafetyDecision.forceAuto → holdAtMaxRPM. A trip now pins every fan at its maximum RPM under daemon control through the normal target key (F0Tg, the same path the curve engine already uses), latches out new manual commands, never touches the persisted profile, and automatically resumes the configured curve when the latch releases. An overheating Mac needs full cooling, not the system default curve — handing control back at the worst possible moment was backwards.

Plausibility filtering

Readings outside the [-40, 150] °C window (NaN, -999, 279.56C sentinels) are implausible: they never trip the guard and never block latch release, in both directions. Being blind to all credible readings while fans are manual trips the hold immediately (fail-closed, not "assume fine").

Hot-spot allowance for die sensors

The Tp* allowance is extended to TC*/Tg* families (junction spec ~110C, allow to ceiling+10, hard max 110C). Ts* skin/proximity sensors keep the base ceiling. Release hysteresis is per-sensor relative to each sensor's effective ceiling.

Observability

  • smctl fan status shows a SAFETY HOLD: <reason> (fans pinned at maximum; profile resumes on release) line while engaged.
  • Engage/release edges are logged; FansStatusDTO carries an optional safetyHold reason.
  • Charging/adapter application no longer throws every tick on systems without the keys when the feature is disabled — this previously polluted smctl daemon status last error on Mac Studio/desktop Macs.

restoreHardwareDefaultsBestEffort on SIGTERM (dead-man) is intentionally kept: an exiting daemon still leaves no fan policy behind.

Tests

  • FanSafetyGuardTests rewritten for the new semantics plus regressions for the exact field incidents: testGarbageSentinelReadingDoesNotTrip, testSentinelCannotPinTheLatchForever, testCoreAndHotspotSensorsAtFullLoadDoNotTrip (TC22/TC09/Tg0D @105C), testAllReadingsImplausibleEngagesFailSafeImmediately, testPlausibilityPredicate.
  • Daemon integration test testFanGuardHoldsMaxRPMWhenSensorsUnreadable asserts the hold writes max-RPM to F0Tg, keeps F0Md manual, does not write auto, and leaves config.fan.profile unchanged.
  • Full suite green; docs (README "Safety by design", docs/design.md §5.3) updated to the new semantics. Version bumped to 0.2.4.

Validation on hardware

Installed over v0.2.2 on the affected M1 Max Mac Studio: daemon restarted via launchctl kickstart -k, user's custom cooler profile re-applied and holding; fans track the curve, last error: -, no guard events, and /etc/smctl/config.toml mtime confirms nothing rewrites the profile anymore.

The thermal safety guard was fail-open: on a trip it handed the fans
back to Apple's automatic control and rewrote config.fan.profile to
"auto", destroying the user's curve. Field incident (M1 Max): a
corrupt SMC sentinel reading (Ts02 279.56C) tripped the guard, and the
impossible value also blocked latch release, pinning the machine on
the system default curve until the profile was manually re-applied.

- FanSafetyDecision.forceAuto -> holdAtMaxRPM: the daemon now pins
  every fan at its max RPM under its own control, never touches the
  persisted profile, and resumes the configured curve automatically
  when the latch releases.
- Plausibility filter: readings outside [-5, 150]C (NaN, sentinels
  like 279.56C/-999) never trip the guard and never block release;
  being blind to *all* credible readings while manual trips it.
- Hot-spot allowance extended to TC*/Tg* die sensors (junction spec
  ~110C); they routinely exceed 100C under normal sustained load and
  were false-tripping the guard (TC22 100.67C).
- Latch releases after sustained clean ticks; safety hold state is
  surfaced in fan status (SAFETY HOLD line) and logged on engage/
  release edges.
- Charging/adapter apply no longer throws every tick on systems
  without the keys when the feature is disabled, which polluted
  daemon status lastError.

Bumps version to 0.2.4. Guard tests rewritten for the new semantics
(garbage sentinel, TC22 trip, blind-by-corruption, release).
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