diff --git a/README.md b/README.md index fbfa6ad..aa7f632 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The Homebrew install sets up shell completions (bash/zsh/fish) and a `man smctl` Controlling fans and charging from userspace demands paranoia. smctl treats these as first-class invariants: - **Never leave a brick.** Uninstalling, stopping, or killing the daemon restores system control of fans and charging — enforced by termination hooks, startup reconciliation, and launchd restart. -- **Thermal safety guard.** While fans are under manual control, smctl monitors all temperature sensors every second. Sustained readings above the ceiling force fans back to system control and latch out manual control until things cool down. The base ceiling defaults to 100 °C and is hard-capped at 105 °C; Apple Silicon `Tp*` hot-spot sensors get a narrow allowance up to 110 °C because they can run hotter than board/skin/proximity sensors under ordinary load. The guard cannot be disabled, and losing temperature visibility counts as unsafe. +- **Thermal safety guard.** While fans are under manual control, smctl monitors all temperature sensors every second. Sustained readings above the ceiling — or readings that go unreadable or implausible (corrupt SMC sentinels) — make the guard pin every fan at **maximum RPM under its own control** and latch out new manual commands until the machine recovers, then resume your configured profile automatically. The guard never hands control back to the system and never rewrites your profile: an overheating Mac needs full cooling, not Apple's default curve. The base ceiling defaults to 100 °C and is hard-capped at 105 °C; Apple Silicon hot-spot sensors (`Tp*`, `TC*`, `Tg*`) get a narrow allowance up to 110 °C because they routinely run above 100 °C under ordinary sustained load. The guard cannot be disabled, and losing credible temperature visibility counts as unsafe. - **Verified writes.** Every SMC write is read back and verified (with a settle window for firmware that applies writes asynchronously) — failures surface as errors, never as silent no-ops. - **Graceful degradation.** If a macOS update changes SMC behavior, affected features degrade to read-only with an explicit message instead of pretending to work. @@ -126,7 +126,7 @@ url = "http://gotify.lan/message?token=..." [[alert]] name = "guard-tripped" -on = "guard" # the thermal safety guard forced fans back to auto +on = "guard" # the thermal safety guard engaged (overheat or implausible readings; fans held at max RPM until recovery) action = "exec" command = ["/usr/local/bin/notify.sh", "{name}", "{reason}"] ``` diff --git a/Sources/PolicyEngine/PolicyEngine.swift b/Sources/PolicyEngine/PolicyEngine.swift index d63af91..af1c490 100644 --- a/Sources/PolicyEngine/PolicyEngine.swift +++ b/Sources/PolicyEngine/PolicyEngine.swift @@ -1,7 +1,7 @@ import Foundation public enum PolicyEngine { - public static let version = "0.2.3" + public static let version = "0.2.4" } public protocol PolicyClock: Sendable { @@ -540,26 +540,41 @@ public struct FanCurveEngine: Sendable { } public struct FanSafetyDecision: Equatable, Sendable { - public var forceAuto: Bool + /// The daemon must pin every fan at its maximum RPM right now (fail-safe hold). + /// The landing state for overheat or sensor blindness is *more cooling under the + /// same manual control*, never a hand-back to system defaults: a hand-back also + /// forced the daemon to destroy the user's configured profile (observed in the + /// field as "the fan controller keeps resetting to Apple auto"). + public var holdAtMaxRPM: Bool public var reason: String? - public init(forceAuto: Bool, reason: String? = nil) { - self.forceAuto = forceAuto + public init(holdAtMaxRPM: Bool, reason: String? = nil) { + self.holdAtMaxRPM = holdAtMaxRPM self.reason = reason } } public struct FanSafetyGuard: Codable, Equatable, Sendable { /// Calibrated against field data: Apple Silicon junction hot-spot sensors (Tp0E/Tp3P - /// class) can sit above 100C under ordinary sustained load. Non-hotspot sensors keep - /// the stricter base ceiling; Tp* sensors get a narrow allowance and still hard-trip. + /// class, plus the TC*/Tg* core and GPU sensors) can sit above 100C under ordinary + /// sustained load — Apple's own policy throttles toward ~110C rather than treating + /// 100C as an emergency. Non-hotspot sensors keep the stricter base ceiling; + /// hotspot sensors get a narrow allowance and still hard-trip. public static let defaultCeilingCelsius = 100.0 public static let hardMaximumCeilingCelsius = 105.0 - public static let hotSpotSensorPrefix = "Tp" + public static let hotSpotSensorPrefixes = ["Tp", "Tc", "TC", "Tg"] public static let hotSpotAllowanceCelsius = 10.0 public static let hotSpotHardMaximumCeilingCelsius = 110.0 - /// The latch releases only after cooling this far below the ceiling, so a trip - /// cannot be immediately re-armed into a hot system. + /// Physical operating range of the SMC's float temperature sensors on this + /// platform. Readings outside it are not temperatures — they are sentinels or + /// corrupt data. Field evidence: Ts02 reported 279.56C for two consecutive + /// ticks and tripped the guard on a 66C machine. Such readings are ignored: + /// they never trip the guard, and they never block a latch release. + public static let plausibleMinimumCelsius = -40.0 + public static let plausibleMaximumCelsius = 150.0 + /// The latch releases only after every plausible reading is comfortably below + /// its own effective ceiling, so a trip cannot be immediately re-armed into a + /// hot system. public static let releaseHysteresisCelsius = 5.0 /// Overheat must persist for this many consecutive ticks (1s cadence) to trip — /// debounces momentary hot-spot spikes while keeping reaction time under ~2.5s. @@ -567,7 +582,8 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable { public static let consecutiveTripsRequired = 2 public var configuredCeilingCelsius: Double - /// Set when the guard trips; manual fan control must be rejected while latched. + /// Set when the guard trips; manual fan targets are rejected while latched, + /// and the daemon holds every fan at maximum RPM until the latch releases. public private(set) var isLatched: Bool private var consecutiveOverCeiling: Int @@ -581,14 +597,28 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable { self.consecutiveOverCeiling = 0 } - /// One evaluation tick. Fail-safe semantics: while a manual fan policy is active the - /// guard is the only thermal floor (thermalmonitord is hands-off), so *no readable - /// temperature* counts as unsafe — blind manual control is never allowed. + /// A reading the SMC could plausibly be reporting as a temperature. Anything + /// else (NaN, infinities, 279C sentinels) carries no thermal information and + /// must not influence policy in either direction. + public static func isPlausible(_ sample: FanTemperatureSample) -> Bool { + sample.celsius.isFinite + && sample.celsius >= plausibleMinimumCelsius + && sample.celsius <= plausibleMaximumCelsius + } + + /// One evaluation tick. Fail-safe semantics: while a manual fan policy is active + /// the guard is the only thermal floor (thermalmonitord is hands-off), so it keeps + /// ownership of the fans and drives them to maximum RPM when data is missing or + /// temperatures are genuinely unsafe. The configured profile survives the hold and + /// resumes automatically on release. public mutating func evaluate(samples: [FanTemperatureSample], manualPolicyActive: Bool) -> FanSafetyDecision { + let valid = samples.filter(Self.isPlausible) + // Latch release requires credible readings comfortably below each sensor's - // own effective ceiling. Hotspot-class Tp* readings use a higher ceiling - // than board/skin/proximity sensors. - if isLatched, !samples.isEmpty, samples.allSatisfy({ sample in + // own effective ceiling. Hotspot-class sensors use a higher ceiling than + // board/skin/proximity sensors; implausible readings are excluded so one + // garbage sensor can never pin the latch forever. + if isLatched, !valid.isEmpty, valid.allSatisfy({ sample in sample.celsius <= effectiveCeiling(for: sample) - Self.releaseHysteresisCelsius }) { isLatched = false @@ -598,18 +628,18 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable { guard manualPolicyActive else { // System is in control; nothing to enforce. consecutiveOverCeiling = 0 - return FanSafetyDecision(forceAuto: false) + return FanSafetyDecision(holdAtMaxRPM: false) } - guard !samples.isEmpty else { + guard !valid.isEmpty else { isLatched = true consecutiveOverCeiling = 0 - return FanSafetyDecision( - forceAuto: true, - reason: "no readable temperature sensors while fans are under manual control" - ) + let detail = samples.isEmpty + ? "no readable temperature sensors while fans are under manual control" + : "all temperature readings implausible while fans are under manual control (\(implausibleSummary(samples)))" + return FanSafetyDecision(holdAtMaxRPM: true, reason: detail) } - let offender = samples.compactMap { sample -> (sample: FanTemperatureSample, ceiling: Double)? in + let offender = valid.compactMap { sample -> (sample: FanTemperatureSample, ceiling: Double)? in let ceiling = effectiveCeiling(for: sample) guard sample.celsius >= ceiling else { return nil } return (sample, ceiling) @@ -622,7 +652,7 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable { if consecutiveOverCeiling >= Self.consecutiveTripsRequired { isLatched = true return FanSafetyDecision( - forceAuto: true, + holdAtMaxRPM: true, reason: "temperature \(offender.sample.sensor) \(offender.sample.celsius)C held at/above safety ceiling \(offender.ceiling)C for \(consecutiveOverCeiling) consecutive checks" ) } @@ -631,15 +661,23 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable { } if isLatched { return FanSafetyDecision( - forceAuto: true, - reason: "safety latch active until temperature falls below \(configuredCeilingCelsius - Self.releaseHysteresisCelsius)C" + holdAtMaxRPM: true, + reason: "safety latch active until every plausible sensor falls below its ceiling minus \(Self.releaseHysteresisCelsius)C" ) } - return FanSafetyDecision(forceAuto: false) + return FanSafetyDecision(holdAtMaxRPM: false) + } + + private func implausibleSummary(_ samples: [FanTemperatureSample]) -> String { + samples + .filter { !Self.isPlausible($0) } + .prefix(3) + .map { "\($0.sensor) \($0.celsius)C" } + .joined(separator: ", ") } private func effectiveCeiling(for sample: FanTemperatureSample) -> Double { - guard sample.sensor.hasPrefix(Self.hotSpotSensorPrefix) else { + guard Self.hotSpotSensorPrefixes.contains(where: { sample.sensor.hasPrefix($0) }) else { return configuredCeilingCelsius } return min( diff --git a/Sources/SMCtlDaemonCore/Daemon.swift b/Sources/SMCtlDaemonCore/Daemon.swift index 657757b..0ddc43a 100644 --- a/Sources/SMCtlDaemonCore/Daemon.swift +++ b/Sources/SMCtlDaemonCore/Daemon.swift @@ -312,6 +312,12 @@ public final class SmctlDaemon: @unchecked Sendable { private var runtimeFanProfile: String? // Stateful: carries the safety latch across ticks; ceiling refreshed from config. var safetyGuard = FanSafetyGuard() + // Runtime state of the fail-safe hold: guard latched -> fans pinned at maximum + // under the same manual control. The user's configured profile survives the hold + // untouched and resumes on release; these fields only mark the engage/release + // edge for logging and for `smctl fan status`. + var safetyHoldActive = false + var safetyHoldReason: String? // Stateful: carries per-rule debounce/cooldown across ticks. var alertEngine = AlertEngine() private let alertRunner = AlertActionRunner() @@ -723,6 +729,10 @@ public final class SmctlDaemon: @unchecked Sendable { throw DaemonError.unsupported("AppleSMC is not available.") } guard let group = capabilities.chargingControl else { + // The hardware exposes no charging-control keys at all. "Enabled" is the + // power-on default, so the policy is already satisfied there; throwing on + // every 10s tick buried real fan faults under battery noise in `daemon status`. + if enabled { return } throw DaemonError.unsupported("Charging control keys are not available on this Mac/system.") } for write in enabled ? group.enableWrites : group.disableWrites { @@ -735,6 +745,9 @@ public final class SmctlDaemon: @unchecked Sendable { throw DaemonError.unsupported("AppleSMC is not available.") } guard let group = capabilities.adapterControl else { + // See applyChargingLocked: re-enabling the adapter default is a no-op + // when the hardware has no adapter-control keys. + if enabled { return } throw DaemonError.unsupported("Adapter control keys are not available on this Mac/system.") } for write in enabled ? group.enableWrites : group.disableWrites { @@ -831,20 +844,26 @@ public final class SmctlDaemon: @unchecked Sendable { configuredCeilingCelsius: config.safety.temp_ceiling ).configuredCeilingCelsius let decision = safetyGuard.evaluate(samples: samples, manualPolicyActive: manualPolicyActive) - if decision.forceAuto { - logger.notice("Fan safety guard tripped: \(decision.reason ?? "unknown", privacy: .public); restoring all fans to auto") + if decision.holdAtMaxRPM { + if !safetyHoldActive { + safetyHoldActive = true + safetyHoldReason = decision.reason + logger.notice("Fan safety guard engaged: \(decision.reason ?? "unknown", privacy: .public); holding all fans at maximum RPM (profile '\(profile, privacy: .public)' resumes on release)") + } do { - try restoreFansAutoLocked(indices: capabilities.fans.map(\.index), clearPolicy: true) - config.fan.profile = "auto" - runtimeFanProfile = nil - try Self.writeConfig(config, path: configPath) + try holdFansAtMaxRPMLocked() } catch { lastError = String(describing: error) - logger.error("Fan safety restore failed: \(String(describing: error), privacy: .public)") - SentryReporter.capture(error, context: "Fan safety restore failed") + logger.error("Fan safety hold failed: \(String(describing: error), privacy: .public)") + SentryReporter.capture(error, context: "Fan safety hold failed") } return } + if safetyHoldActive { + safetyHoldActive = false + safetyHoldReason = nil + logger.notice("Fan safety guard released; resuming profile '\(profile, privacy: .public)'") + } guard manualPolicyActive else { return @@ -873,7 +892,7 @@ public final class SmctlDaemon: @unchecked Sendable { let input = AlertConditionInput( samples: samples, guardTripped: latched, - guardReason: latched ? "Fan safety guard latched (overheat or unreadable temperature sensors)" : nil, + guardReason: latched ? "Fan safety guard latched (overheat or implausible temperature readings)" : nil, writeError: lastWriteError ) let events = alertEngine.evaluate(rules: rules, input: input, now: Date()) @@ -952,9 +971,9 @@ public final class SmctlDaemon: @unchecked Sendable { return } throw DaemonError.unsupported( - "Fan safety guard is latched (overheat or unreadable temperature sensors). " - + "Fans stay under system control until temperatures fall at least " - + "\(Int(FanSafetyGuard.releaseHysteresisCelsius))C below the ceiling." + "Fan safety guard is latched (overheat or implausible temperature sensors). " + + "Fans are held at maximum RPM by the guard until every plausible reading " + + "falls at least \(Int(FanSafetyGuard.releaseHysteresisCelsius))C below its ceiling." ) } @@ -971,6 +990,23 @@ public final class SmctlDaemon: @unchecked Sendable { } } + /// Fail-safe landing state: keep manual ownership of every fan and pin it to the + /// SMC-reported maximum. Chosen over handing control back to the system because a + /// hand-back (a) reduces cooling exactly when the machine is hottest if the trip + /// cause was a corrupt reading or a spike the curve was already answering with + /// full RPM, and (b) required rewriting the user's configured profile to "auto", + /// which is how the controller kept "resetting to Apple defaults" in normal use. + private func holdFansAtMaxRPMLocked() throws { + let controller = try fanControllerLocked() + for fan in capabilities.fans { + let maximum = readNumericLocked(fan.maximumKey) ?? 6000 + _ = try writeSMCOperationLocked { + try controller.setManual(index: fan.index, rpm: maximum) + } + ftstGate.enterManual(index: fan.index) + } + } + private func evaluateFanProfileLocked(profile: String, samples: [FanTemperatureSample]) throws { guard !samples.isEmpty else { throw DaemonError.unsupported("No readable temperature sensors are available for fan profile '\(profile)'.") @@ -1163,7 +1199,8 @@ public final class SmctlDaemon: @unchecked Sendable { timestamp: Date(), profile: currentFanProfileLocked(), fans: statuses, - message: statusMessage + message: statusMessage, + safetyHold: safetyHoldActive ? (safetyHoldReason ?? "safety hold active") : nil ) } diff --git a/Sources/SMCtlProtocol/SMCtlProtocol.swift b/Sources/SMCtlProtocol/SMCtlProtocol.swift index 5609cbf..302a19d 100644 --- a/Sources/SMCtlProtocol/SMCtlProtocol.swift +++ b/Sources/SMCtlProtocol/SMCtlProtocol.swift @@ -1,7 +1,7 @@ import Foundation public enum SMCtlProtocolInfo { - public static let version = "0.2.3" + public static let version = "0.2.4" public static let machServiceName = "one.leaper.smctl.daemon" /// Numeric-component semver comparison, tolerant of a leading "v". @@ -231,12 +231,16 @@ public struct FansStatusDTO: Codable, Equatable, Sendable { public var profile: String public var fans: [FanStatusDTO] public var message: String? + /// Non-nil while the fan safety guard holds every fan at maximum RPM. The + /// profile shown above is what will resume when the hold releases. + public var safetyHold: String? - public init(timestamp: Date, profile: String, fans: [FanStatusDTO], message: String?) { + public init(timestamp: Date, profile: String, fans: [FanStatusDTO], message: String?, safetyHold: String? = nil) { self.timestamp = timestamp self.profile = profile self.fans = fans self.message = message + self.safetyHold = safetyHold } } diff --git a/Sources/smctl/SMCtl.swift b/Sources/smctl/SMCtl.swift index 2313e35..00eecb3 100644 --- a/Sources/smctl/SMCtl.swift +++ b/Sources/smctl/SMCtl.swift @@ -855,6 +855,9 @@ private func hoursMinutes(_ minutes: Int) -> String { private func printFansStatus(_ status: FansStatusDTO) { print("Fans") print(" profile: \(status.profile)") + if let hold = status.safetyHold { + print(" SAFETY HOLD: \(hold) (fans pinned at maximum; profile resumes on release)") + } if status.fans.isEmpty { print(" No fans reported by smctld.") } else { diff --git a/Tests/PolicyEngineTests/FanSafetyGuardTests.swift b/Tests/PolicyEngineTests/FanSafetyGuardTests.swift index 17da8d5..c031220 100644 --- a/Tests/PolicyEngineTests/FanSafetyGuardTests.swift +++ b/Tests/PolicyEngineTests/FanSafetyGuardTests.swift @@ -5,31 +5,31 @@ final class FanSafetyGuardTests: XCTestCase { func testCeilingClampAndSustainedTrip() { var defaultGuard = FanSafetyGuard() // ceiling 100 XCTAssertFalse( - defaultGuard.evaluate(samples: [sample(99.9)], manualPolicyActive: true).forceAuto + defaultGuard.evaluate(samples: [sample(99.9)], manualPolicyActive: true).holdAtMaxRPM ) // First over-ceiling tick: debounced, not yet a trip. XCTAssertFalse( - defaultGuard.evaluate(samples: [sample(100)], manualPolicyActive: true).forceAuto + defaultGuard.evaluate(samples: [sample(100)], manualPolicyActive: true).holdAtMaxRPM ) - // Second consecutive tick: trip. + // Second consecutive tick: trip into the max-RPM fail-safe hold. XCTAssertTrue( - defaultGuard.evaluate(samples: [sample(100)], manualPolicyActive: true).forceAuto + defaultGuard.evaluate(samples: [sample(100)], manualPolicyActive: true).holdAtMaxRPM ) var clamped = FanSafetyGuard(configuredCeilingCelsius: 120) XCTAssertEqual(clamped.configuredCeilingCelsius, 105) - XCTAssertFalse(clamped.evaluate(samples: [sample(105)], manualPolicyActive: true).forceAuto) - XCTAssertTrue(clamped.evaluate(samples: [sample(105)], manualPolicyActive: true).forceAuto) + XCTAssertFalse(clamped.evaluate(samples: [sample(105)], manualPolicyActive: true).holdAtMaxRPM) + XCTAssertTrue(clamped.evaluate(samples: [sample(105)], manualPolicyActive: true).holdAtMaxRPM) } func testAppleSiliconHotspotSensorsHaveDedicatedCeiling() { var guardrail = FanSafetyGuard() XCTAssertFalse( - guardrail.evaluate(samples: [sample(105.859375, sensor: "Tp3P")], manualPolicyActive: true).forceAuto + guardrail.evaluate(samples: [sample(105.859375, sensor: "Tp3P")], manualPolicyActive: true).holdAtMaxRPM ) XCTAssertFalse( - guardrail.evaluate(samples: [sample(105.859375, sensor: "Tp3P")], manualPolicyActive: true).forceAuto + guardrail.evaluate(samples: [sample(105.859375, sensor: "Tp3P")], manualPolicyActive: true).holdAtMaxRPM ) XCTAssertFalse(guardrail.isLatched) } @@ -38,61 +38,139 @@ final class FanSafetyGuardTests: XCTestCase { var guardrail = FanSafetyGuard() XCTAssertFalse( - guardrail.evaluate(samples: [sample(110, sensor: "Tp3P")], manualPolicyActive: true).forceAuto + guardrail.evaluate(samples: [sample(110, sensor: "Tp3P")], manualPolicyActive: true).holdAtMaxRPM ) let decision = guardrail.evaluate(samples: [sample(110, sensor: "Tp3P")], manualPolicyActive: true) - XCTAssertTrue(decision.forceAuto) + XCTAssertTrue(decision.holdAtMaxRPM) XCTAssertTrue(guardrail.isLatched) XCTAssertTrue(decision.reason?.contains("Tp3P") ?? false) XCTAssertTrue(decision.reason?.contains("110") ?? false) } + // Field incident (M1 Max, 2026-10-09): TC22 — a die-core hotspot — sat at + // 100.67C under ordinary sustained load while the curve was already commanding + // full RPM. The old guard classified only "Tp" as hotspot and tripped, tearing + // down the user's policy. TC*/Tg* must share the hotspot allowance. + func testCoreAndHotspotSensorsAtFullLoadDoNotTrip() { + var guardrail = FanSafetyGuard() + for sensor in ["TC22", "TC09", "Tg0D"] { + _ = guardrail.evaluate(samples: [], manualPolicyActive: false) + XCTAssertFalse( + guardrail.evaluate(samples: [sample(105, sensor: sensor)], manualPolicyActive: true).holdAtMaxRPM, + "\(sensor) at 105C is normal sustained-load territory") + XCTAssertFalse( + guardrail.evaluate(samples: [sample(105, sensor: sensor)], manualPolicyActive: true).holdAtMaxRPM, + "\(sensor) at 105C must not trip even on consecutive ticks") + XCTAssertFalse(guardrail.isLatched) + } + } + func testNonHotspotSensorsKeepBaseCeiling() { var guardrail = FanSafetyGuard() - XCTAssertFalse(guardrail.evaluate(samples: [sample(100, sensor: "TC0P")], manualPolicyActive: true).forceAuto) - XCTAssertTrue(guardrail.evaluate(samples: [sample(100, sensor: "TC0P")], manualPolicyActive: true).forceAuto) + XCTAssertFalse(guardrail.evaluate(samples: [sample(100, sensor: "Ts04")], manualPolicyActive: true).holdAtMaxRPM) + XCTAssertTrue(guardrail.evaluate(samples: [sample(100, sensor: "Ts04")], manualPolicyActive: true).holdAtMaxRPM) XCTAssertTrue(guardrail.isLatched) } func testTransientSpikeDoesNotTrip() { // A single hot-spot spike between cool readings must not trip the guard. var guardrail = FanSafetyGuard() - XCTAssertFalse(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).forceAuto) - XCTAssertFalse(guardrail.evaluate(samples: [sample(90)], manualPolicyActive: true).forceAuto) - XCTAssertFalse(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).forceAuto) + XCTAssertFalse(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).holdAtMaxRPM) + XCTAssertFalse(guardrail.evaluate(samples: [sample(90)], manualPolicyActive: true).holdAtMaxRPM) + XCTAssertFalse(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).holdAtMaxRPM) + XCTAssertFalse(guardrail.isLatched) + } + + // Field incident (M1 Max, 2026-10-10 08:30): the guard latched because Ts02 + // reported 279.56C — a corrupt SMC sentinel, not a temperature — on a machine + // whose real sensors all sat near 66C. Implausible readings must carry no + // policy weight in either direction. + func testGarbageSentinelReadingDoesNotTrip() { + var guardrail = FanSafetyGuard() + + for tick in 0..<5 { + let decision = guardrail.evaluate( + samples: [sample(81, sensor: "Tp0M"), sample(77, sensor: "TC22"), sample(279.56, sensor: "Ts02")], + manualPolicyActive: true + ) + XCTAssertFalse( + decision.holdAtMaxRPM, + "sentinel on tick \(tick) must not trip the guard" + ) + } + XCTAssertFalse(guardrail.isLatched) + } + + func testSentinelCannotPinTheLatchForever() { + // Trip on genuine heat, then cool everything except one permanently + // garbage sensor. The latch must release; the old allSatisfy over raw + // samples kept the machine locked out of its own profile forever. + var guardrail = FanSafetyGuard() + _ = guardrail.evaluate(samples: [sample(101, sensor: "Ts04")], manualPolicyActive: true) + _ = guardrail.evaluate(samples: [sample(101, sensor: "Ts04")], manualPolicyActive: true) + XCTAssertTrue(guardrail.isLatched) + + let decision = guardrail.evaluate( + samples: [sample(70, sensor: "Ts04"), sample(66, sensor: "Tp0M"), sample(279.56, sensor: "Ts02")], + manualPolicyActive: true + ) + XCTAssertFalse(decision.holdAtMaxRPM) XCTAssertFalse(guardrail.isLatched) } - func testBlindManualControlIsUnsafe() { + func testAllReadingsImplausibleEngagesFailSafeImmediately() { + var guardrail = FanSafetyGuard() + let decision = guardrail.evaluate( + samples: [sample(279.56, sensor: "Ts02"), sample(.nan, sensor: "Tp0M"), sample(-999, sensor: "TC01")], + manualPolicyActive: true + ) + XCTAssertTrue(decision.holdAtMaxRPM, "blind-by-corruption must not be debounced") + XCTAssertTrue(guardrail.isLatched) + XCTAssertTrue(decision.reason?.contains("implausible") ?? false) + XCTAssertTrue(decision.reason?.contains("Ts02") ?? false) + } + + func testPlausibilityPredicate() { + XCTAssertTrue(FanSafetyGuard.isPlausible(sample(66))) + XCTAssertTrue(FanSafetyGuard.isPlausible(sample(-40))) + XCTAssertTrue(FanSafetyGuard.isPlausible(sample(150))) + XCTAssertFalse(FanSafetyGuard.isPlausible(sample(279.56))) + XCTAssertFalse(FanSafetyGuard.isPlausible(sample(-41))) + XCTAssertFalse(FanSafetyGuard.isPlausible(sample(.nan))) + XCTAssertFalse(FanSafetyGuard.isPlausible(sample(.infinity))) + } + + func testBlindManualControlEngagesFailSafe() { // Regression (fail-open): with manual fans active and no readable temperature, - // the guard must force auto immediately — no debounce when blind. + // the guard must hold fans at maximum immediately — no debounce when blind. var guardrail = FanSafetyGuard() let decision = guardrail.evaluate(samples: [], manualPolicyActive: true) - XCTAssertTrue(decision.forceAuto) + XCTAssertTrue(decision.holdAtMaxRPM) XCTAssertTrue(guardrail.isLatched) } func testEmptySamplesWithoutManualPolicyDoesNothing() { var guardrail = FanSafetyGuard() let decision = guardrail.evaluate(samples: [], manualPolicyActive: false) - XCTAssertFalse(decision.forceAuto) + XCTAssertFalse(decision.holdAtMaxRPM) XCTAssertFalse(guardrail.isLatched) } func testLatchHoldsUntilCooledBelowReleaseThreshold() { var guardrail = FanSafetyGuard() // ceiling 100, release at 95 - XCTAssertFalse(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).forceAuto) - XCTAssertTrue(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).forceAuto) + XCTAssertFalse(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).holdAtMaxRPM) + XCTAssertTrue(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).holdAtMaxRPM) XCTAssertTrue(guardrail.isLatched) - // Cooled below ceiling but not below release threshold: still latched. - XCTAssertTrue(guardrail.evaluate(samples: [sample(97)], manualPolicyActive: true).forceAuto) + // Cooled below ceiling but not below release threshold: still holding. + XCTAssertTrue(guardrail.evaluate(samples: [sample(97)], manualPolicyActive: true).holdAtMaxRPM) XCTAssertTrue(guardrail.isLatched) - // Cooled to the release threshold: latch releases, manual allowed again. - XCTAssertFalse(guardrail.evaluate(samples: [sample(95)], manualPolicyActive: true).forceAuto) + // Cooled to the release threshold: latch releases, the configured profile + // resumes, manual targets are accepted again. + XCTAssertFalse(guardrail.evaluate(samples: [sample(95)], manualPolicyActive: true).holdAtMaxRPM) XCTAssertFalse(guardrail.isLatched) } @@ -103,15 +181,15 @@ final class FanSafetyGuardTests: XCTestCase { _ = guardrail.evaluate(samples: [sample(110, sensor: "Tp3P")], manualPolicyActive: true) XCTAssertTrue(guardrail.isLatched) - XCTAssertTrue(guardrail.evaluate(samples: [sample(106, sensor: "Tp3P")], manualPolicyActive: true).forceAuto) + XCTAssertTrue(guardrail.evaluate(samples: [sample(106, sensor: "Tp3P")], manualPolicyActive: true).holdAtMaxRPM) XCTAssertTrue(guardrail.isLatched) - XCTAssertFalse(guardrail.evaluate(samples: [sample(105, sensor: "Tp3P")], manualPolicyActive: true).forceAuto) + XCTAssertFalse(guardrail.evaluate(samples: [sample(105, sensor: "Tp3P")], manualPolicyActive: true).holdAtMaxRPM) XCTAssertFalse(guardrail.isLatched) } func testLatchReleasesWhileSystemInControl() { - // After a trip the daemon clears policy (manualPolicyActive becomes false); + // After a trip the operator may hand control back to the system manually; // the latch must still release on cool readings so the user is not locked out. var guardrail = FanSafetyGuard() _ = guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true) @@ -121,7 +199,7 @@ final class FanSafetyGuardTests: XCTestCase { _ = guardrail.evaluate(samples: [sample(94)], manualPolicyActive: false) XCTAssertFalse(guardrail.isLatched) - XCTAssertFalse(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).forceAuto) + XCTAssertFalse(guardrail.evaluate(samples: [sample(101)], manualPolicyActive: true).holdAtMaxRPM) XCTAssertFalse(guardrail.isLatched) } diff --git a/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift b/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift index 87da987..09c14aa 100644 --- a/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift +++ b/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift @@ -24,11 +24,14 @@ final class SmctlDaemonTests: XCTestCase { // MARK: - Fan safety guard at the integration layer - func testFanGuardForcesAutoWhenSensorsUnreadable() throws { - // Regression for the fail-open found in review: manual fans + zero readable - // temperature sensors must hand control back to the system, at the daemon level. + func testFanGuardHoldsMaxRPMWhenSensorsUnreadable() throws { + // Regression for the fail-open found in review, and for the reset-to-Apple- + // defaults complaint: manual fans + zero readable temperature sensors must + // pin the fans at maximum under the daemon's own control — never hand back + // to system auto, and never rewrite the configured profile. let backend = RecordingBackend() let daemon = makeDaemon(backend: backend, capabilities: .oneFan) + daemon.config.fan.profile = "cooler" try daemon.setFanManual(index: 0, rpm: 2000, force: false) XCTAssertTrue(daemon.ftstGate.hasManualFans) @@ -37,12 +40,17 @@ final class SmctlDaemonTests: XCTestCase { daemon.evaluateFanSubsystemLocked() XCTAssertTrue(daemon.safetyGuard.isLatched, "blind guard must latch") + XCTAssertTrue(daemon.safetyHoldActive, "the hold must be visible to status") XCTAssertTrue( + backend.writes.contains { $0.key == "F0Tg" && $0.bytes == FanController.float32LittleEndianBytes(4900) }, + "fans must be pinned at the SMC maximum; writes: \(backend.writes)" + ) + XCTAssertFalse( backend.writes.contains { $0.key == "F0Md" && $0.bytes == [0] }, - "fans must be restored to auto; writes: \(backend.writes)" + "the fail-safe must never hand the fan back to system control" ) - XCTAssertEqual(daemon.config.fan.profile, "auto") - XCTAssertFalse(daemon.ftstGate.hasManualFans) + XCTAssertEqual(daemon.config.fan.profile, "cooler", "a trip may not destroy the configured profile") + XCTAssertTrue(daemon.ftstGate.hasManualFans) } func testLatchedGuardRejectsManualControl() throws { diff --git a/docs/design.md b/docs/design.md index b765547..67cb420 100644 --- a/docs/design.md +++ b/docs/design.md @@ -117,7 +117,7 @@ locked ──直写 modeKey=1 成功──► manual (M1/M5 路径) 1. **退出钩子**:signal handler(SIGTERM/SIGINT)+ 正常退出路径,best-effort 写 `Ftst=0` + 所有风扇 `modeKey=0` 2. **启动对账**:daemon 由 launchd `KeepAlive` 拉起后,启动时核对「持久化的期望状态」vs「SMC 实际状态」——若发现 Ftst=1 但本地无活跃手动策略(上次 crash 的残留),立即恢复 auto -3. **温度护栏(安全地板)**:手动模式期间 daemon 以 1s 间隔监控关键温度传感器;任一传感器超过安全阈值(默认保守值,可配但有硬下限)→ 强制恢复 auto 并告警。手动模式下 thermalmonitord 不管散热,**这道护栏由我们补上** +3. **温度护栏(安全地板)**:手动模式期间 daemon 以 1s 间隔监控温度传感器;读数超过安全阈值(默认保守值,可配但有硬下限)、不可读或**不可信(物理上不可能的值,如 279 ℃ 的 SMC 哨兵垃圾)** → 护栏把风扇**钉死在最大转速(保持手动控制)并告警**,温度回落后自动恢复原 profile;绝不恢复 auto、绝不改写配置里的 profile——过热时机器需要的是满功率散热,交还系统默认曲线是反向操作。手动模式下 thermalmonitord 不管散热,**这道护栏由我们补上**;`Tp*`/`TC*`/`Tg*` 结温热点传感器按 Apple 规格(~110 ℃)享有窄幅容差,不可信读数在触发与释放两侧都不参与投票 另外:SIGKILL 杀掉 daemon 时 1、2 都来不及/延迟,此时 thermalmonitord 的 reclaim(Ftst≠1 时几秒内收回)+ launchd 秒级重启 + 启动对账兜底——残余风险窗口只剩「Ftst=1 且 daemon 未被重启」,靠 KeepAlive 把这个窗口压到秒级。