-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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_write → degrade_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".
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).
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.
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.
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.
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.
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.
- 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.
- Home
- Installation
- CLI Reference
- Configuration
- Control Policy
- Supervisor and Run Modes
- Safety Model
- Hardware Interface
- Diagnostics (doctor)
- Plugin Surface (omafan)
- JSON Schemas
- systemd and Packaging
- Development and Testing
- Architecture and Internals
- Troubleshooting and FAQ
- Background and Verification
- afanctl-vs-mbpfan