Skip to content

Safety Model

Prakhar Yadav edited this page Sep 16, 2026 · 1 revision

Safety Model

Built around one principle: fail toward the firmware. Every failure path — write failure, sensor loss, crash, hang, SIGKILL, start-limit storm, config error — ends with the SMC firmware back in charge, loudly logged.

L1 — per-poll verify / re-assert

Every poll re-reads fan1_manual and fan1_input. Three independent checks with different consequences (RULING F16):

(a) Mode drift (counted). The fan reads Auto while we believe we own it ⇒ re-assert set_mode(Manual) on the verified path.

(b) Tracking (re-assert only, never counted). While armed and the command is unchanged, |actual − last_written| > VERIFY_TOLERANCE_RPM = 150 ⇒ re-issue the write — but never count it as a failure: a fan still moving toward its command (still decelerating, still ramping) is not a broken fan. The re-assert is skipped when this poll already wrote the same value.

(c) Stall detector (direct degradation). A fan whose tach never moves between polls is caught separately, keyed on tach movement between polls (prev_actual vs current, epsilon STALL_TACH_EPSILON_RPM = 50) — a changing command (curve slew, mid-band oscillation) never resets the window — and degrades directly to AUTO + monitor-only after STALL_POLLS = 10 motionless, off-target polls.

Only mode-drift re-assert failures, write-syscall errors, and echo (write-verification) failures feed the WRITE_FAIL_FALLBACK = 3-strike ladder to AUTO + monitor-only (fail_writedegrade_to_auto).

A fan hovering just outside the tolerance band is made visible, not degraded: a warning after OFF_TARGET_WARN_POLLS = 30 off-target polls that repeats every 30 polls for as long as the excursion lasts. Deliberate: the stall detector keys on any tach movement, so a fan that jitters above the movement epsilon while parked off target is reported — repeatedly — not degraded; a per-poll criterion cannot separate "jittering but never converging" from "healthily chasing without converging yet".

L2 — death path

At startup the daemon pre-opens an O_WRONLY fd on fan1_manual (SysfsSmc holds it as Option<File>; MockSmc reports None). A Rust panic hook plus raw SIGSEGV / SIGABRT / SIGTERM / SIGINT handlers (src/safety.rs, the only module allowed unsafe, each block with a // SAFETY: comment) perform exactly one async-signal-safe write(fd, b"0") — restoring AUTO in a single syscall. No allocation, no formatting, no locks, no path construction; failed/short writes are deliberately ignored (best-effort by design); then the handler re-raises with the default disposition so the process dies with the original signal exactly once.

The hidden selftest-panic verb proves it (arms L2 against the --sysfs-root backend, then panic!): exits 101 (deterministic Rust panic code), fan1_manual reads 0 afterwards — on fixtures and on hardware. Uncatchable deaths (SIGKILL/OOM-kill) cannot run L2; the startup reconcile restores AUTO on the restart instead (Supervisor and Run Modes).

L3 — systemd-first

Type=notify + WatchdogSec=15 (a hang gets SIGABRT → L2 → restart), Restart=always, RestartSec=1, StartLimitIntervalSec=0 (crash-loops restart forever rather than dying in a manual/fans-off state — the corrected C1 mechanism), plus sandboxing (ProtectSystem=strict, ReadWritePaths pinned to /sys/devices/platform/applesmc.768, ProtectHome, PrivateTmp, NoNewPrivileges, RuntimeDirectory=afanctl). The watchdog is pinged at the start and end of every poll (RULING F21 R2), so a long poll cannot extend the ping gap beyond max(interval, poll_work). Full unit in systemd and Packaging.

Sensor loss → AUTO

Three consecutive polls with no valid temperature (SENSOR_LOSS_POLLS = 3) and the supervisor returns the fan to the firmware (ReturnToAuto). Interim loss holds the current output and writes nothing new. Outlier readings (< 0 °C, > 120 °C, empty, unparseable) degrade to failed reads per sensor; t_eff still computes from the survivors.

Startup reconcile is unconditional

At (re)start the daemon restores AUTO out of any Manual owner it finds, including a live foreign program such as mbpfan — deliberate (RULING F14): one fan supervisor owns the fan; the firmware is always the fallback.

Failed AUTO restore is never silent

If a fallback's own AUTO restore fails — or an observe command's own release of a Manual fan fails — the daemon stays in charge and re-attempts set_mode(Auto) every poll (log rate-limited to once every 10 polls) until a verified read-back confirms AUTO; the pending state is exposed as auto_restore_pending in state.json and status --json's daemon object, and the daemon truthfully never reports success before that.

Invalid config → refuse to start

A present-but-broken config never silently falls back to defaults; every error names the key and the fix. The daemon exits nonzero. See Configuration.

What L1/L2/L3 do NOT cover (documented residuals)

  • Echo adoption beyond ~3 s on a busy SMC defers the fan to the firmware (fail-safe, but noisy).
  • Transient sysfs read errors during verification degrade per the same ladder.
  • Torn two-file snapshots of mode+rpm self-correct next poll.
  • doctor's stale-binary check detects "unit older than the installed binary", not "package older than repo HEAD" — the reinstall-then-restart discipline covers that gap.

Clone this wiki locally