Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Sources/ThermalForgeApp/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,10 @@ final class AppState: ObservableObject {
// here, on the daemon-confirmed success path, never on a failed reset.
self.persistSelectedProfile(FanProfile.silent.id)
self.monitor?.switchProfile(.silent)
// The daemon confirmed THIS resetAuto, but a fan write already in
// flight can still land after it and re-pin manual — arm the
// bounded watch so the handback is confirmed against hardware.
self.monitor?.armHandbackWatch()
TFLogger.shared.profile("Reset to Default (Silent (Apple Default))")
}
}
Expand All @@ -436,7 +440,13 @@ final class AppState: ObservableObject {
// a CLI hold (so its unsupervised hold isn't orphaned). Otherwise active
// profiles let tick() ramp from the current temperature. Off-main one-shot
// on the pump (never coalesced/reordered).
if profile.curve.handsOff || profile.id == "smart" || profile.id == "silent" || took {
if profile.curve.handsOff {
// No direct resetAuto: a fan write already in flight can land at the
// daemon after it and re-pin manual. The bounded handback watch
// confirms auto against the hardware and retries as needed — it also
// covers a CLI-hold takeover, whose release is the same resetAuto.
monitor?.armHandbackWatch()
} else if profile.id == "smart" || took {
commandPump.submit(.resetAuto)
}
}
Expand Down
68 changes: 57 additions & 11 deletions Sources/ThermalForgeCore/ThermalMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,15 @@ public enum MonitorState: Equatable {
// MARK: - Thermal Monitor

public final class ThermalMonitor {
private let fanControl: FanControl
private var timer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "com.thermalforge.monitor")

/// The monitor's work queue. Internal (not private) so unit tests can run
/// tick() ON the queue and use a sync hop as a barrier behind async work.
let queue = DispatchQueue(label: "com.thermalforge.monitor")

/// Hardware status source — injected so unit tests can script fan modes
/// without SMC; production always passes the fanControl-backed reader.
private let readStatus: () throws -> ThermalStatus

public private(set) var activeProfile: FanProfile
public private(set) var state: MonitorState = .idle
Expand All @@ -75,6 +81,15 @@ public final class ThermalMonitor {

private var lastAppliedRPMPercent: Float = 0
private var fansCurrentlyRunning = false

/// Hands-off handback retry cadence: while the hardware still reads manual
/// on every 100ms tick, retry resetAuto at 1Hz — a healthy handback
/// confirms on the next tick, and a 10Hz retry would only hammer the
/// daemon. Internal for unit tests.
static let handsOffRetryCadence = 10

/// Ticks remaining in an armed handback watch (see armHandbackWatch).
private var handbackTicksLeft = 0
private var sustainedAboveCount = 0

// MARK: - Smart Profile State
Expand Down Expand Up @@ -112,10 +127,15 @@ public final class ThermalMonitor {
/// Called when a fan command needs to be executed (may require privilege)
public var onFanCommand: ((FanCommand) throws -> Void)?

public init(fanControl: FanControl, profile: FanProfile = .silent) {
self.fanControl = fanControl
public convenience init(fanControl: FanControl, profile: FanProfile = .silent) {
self.init(profile: profile, readStatus: { try fanControl.status() })
}

init(profile: FanProfile = .silent,
readStatus: @escaping () throws -> ThermalStatus) {
self.activeProfile = profile
self.tickInterval = 0.1
self.readStatus = readStatus
}

// MARK: - Lifecycle
Expand All @@ -137,6 +157,17 @@ public final class ThermalMonitor {
timer = nil
}

/// Arm a bounded handback watch. For the next `seconds`, hands-off ticks
/// CONFIRM the handback to Apple auto against the hardware fan mode
/// instead of assuming it from our own bookkeeping, retrying resetAuto
/// while any fan still reads manual. Bounded, so a dead daemon or an
/// external tool legitimately holding manual is never fought forever.
public func armHandbackWatch(seconds: Float = 10) {
queue.async { [self] in
handbackTicksLeft = Int(seconds / tickInterval)
}
}

/// Update the active profile.
public func switchProfile(_ profile: FanProfile) {
queue.async { [self] in
Expand Down Expand Up @@ -164,8 +195,10 @@ public final class ThermalMonitor {

// MARK: - Polling

private func tick() {
guard let status = try? fanControl.status() else { return }
/// One thermal tick. Internal (not private) so unit tests can run it ON
/// the monitor queue, where it is designed to run.
func tick() {
guard let status = try? readStatus() else { return }
latestStatus = status

// Peak CPU (TC/Tp) + GPU (TG/Tg) — the shared safety-floor sensor extraction,
Expand Down Expand Up @@ -416,12 +449,25 @@ public final class ThermalMonitor {
let maxRPM = status.fans.first.map { Float($0.maxRPM) } ?? 7826
let minRPM = status.fans.first.map { Float($0.minRPM) } ?? 2317

// Hands-off profiles (Silent): don't control fans, just monitor
// Hands-off profiles (Silent): don't control fans, just monitor. The
// handback to Apple auto is CONFIRMED against the hardware fan mode,
// not assumed from our own bookkeeping: a fan write already in flight
// when the profile switched can land at the daemon AFTER the switch's
// resetAuto and re-pin the fans in manual — switchProfile already
// cleared fansCurrentlyRunning, so a flag-driven branch would never
// correct the orphaned hold, and with the app alive and heartbeating
// the daemon watchdog never reclaims it either: the fans stay pinned
// in manual until quit. Supervision is bounded: the switch arms a
// handback watch, and during the window a manual reading re-sends
// resetAuto (rate-limited) — also catching a re-pin that lands after
// the hardware briefly confirmed auto.
if curve.handsOff {
if fansCurrentlyRunning {
applyCommand(.resetAuto)
fansCurrentlyRunning = false
lastAppliedRPMPercent = 0
let watching = handbackTicksLeft > 0
if watching { handbackTicksLeft -= 1 }
if watching, status.fans.contains(where: { $0.mode == "manual" }) {
if tickCounter % Self.handsOffRetryCadence == 0 {
applyCommand(.resetAuto)
}
state = .idle
}
return
Expand Down
185 changes: 185 additions & 0 deletions Tests/ThermalForgeTests/ThermalMonitorHandbackTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//
// ThermalMonitorHandbackTests.swift
// ThermalForge
//
// Hands-off handback is CONFIRMED against the hardware fan mode, not assumed
// from bookkeeping: a ramp write already in flight when the profile switched
// can land at the daemon AFTER the switch's resetAuto and re-pin the fans in
// manual — both BEFORE the first confirmation tick and after the hardware has
// briefly read auto (the 2026-10-10 race took the second shape). The armed
// handback watch must retry resetAuto until the hardware confirms auto, then
// settle; and it must EXPIRE, so a hands-off monitor never permanently fights
// a dead daemon or an external tool holding manual.
//

import Foundation
import Testing

@testable import ThermalForgeCore

@Suite("ThermalMonitor hands-off handback")
struct ThermalMonitorHandbackTests {

/// Thread-safe command recorder — ticks run on the monitor's queue while
/// assertions read from the test thread.
final class Recorder: @unchecked Sendable {
private let lock = NSLock()
private var _commands: [FanCommand] = []

var commands: [FanCommand] {
lock.lock(); defer { lock.unlock() }
return _commands
}

func record(_ c: FanCommand) {
lock.lock(); defer { lock.unlock() }
_commands.append(c)
}

var resetAutoCount: Int {
commands.filter { $0 == .resetAuto }.count
}
}

/// Scripted status source: one fan whose mode the test flips between
/// manual (a hold is pinning it) and auto (handed back to Apple).
final class StatusScript: @unchecked Sendable {
private let lock = NSLock()
private var _mode: String
private let temp: Float

init(mode: String = "manual", temp: Float = 85) {
_mode = mode
self.temp = temp
}

var mode: String {
get { lock.lock(); defer { lock.unlock() }; return _mode }
set { lock.lock(); defer { lock.unlock() }; _mode = newValue }
}

var status: ThermalStatus {
ThermalStatus(
fans: [.init(index: 0, actualRPM: 4024, targetRPM: 4024,
minRPM: 2317, maxRPM: 7826, mode: mode)],
temperatures: ["TC0p": temp]
)
}
}

private func makeMonitor(profile: FanProfile, script: StatusScript,
rec: Recorder) -> ThermalMonitor {
let m = ThermalMonitor(profile: profile, readStatus: { script.status })
m.onFanCommand = { rec.record($0) }
return m
}

/// Run one tick ON the monitor's queue (where tick() is designed to run),
/// which also acts as a barrier behind any queued switch/watch arm.
private func tick(_ m: ThermalMonitor) {
m.queue.sync { m.tick() }
}

/// Balanced profile driven past its 8-second sustained trigger, fans
/// running: the pre-switch state of the race (a ramp is active, the
/// monitor believes it owns the fans).
private func rampThenSwitchToSilent(_ m: ThermalMonitor, rec: Recorder) {
for _ in 0..<85 { tick(m) } // 80 ticks = balanced's sustained window
#expect(rec.commands.contains { if case .setRPM = $0 { true } else { false } })
m.switchProfile(.silent)
m.armHandbackWatch() // what selectProfile does on a switch
m.queue.sync {} // barrier: let both land
}

@Test("re-pin BEFORE the first hands-off tick: watch retries resetAuto until the hardware confirms auto")
func repinBeforeConfirmIsHealed() {
let script = StatusScript() // manual — the late write already landed
let rec = Recorder()
let m = makeMonitor(profile: .balanced, script: script, rec: rec)

rampThenSwitchToSilent(m, rec: rec)

// Retries are rate-limited to one per handsOffRetryCadence ticks; drive
// enough ticks to cross at least one retry boundary.
let before = rec.resetAutoCount
for _ in 0..<(ThermalMonitor.handsOffRetryCadence * 2 + 2) { tick(m) }
let retried = rec.resetAutoCount - before
#expect(retried >= 1)
#expect(retried <= 3) // rate-limited, not hammered

// The retry landed: hardware reads auto — the loop settles and then
// never sends anything again.
script.mode = "auto"
for _ in 0..<3 { tick(m) }
let settled = rec.commands.count
for _ in 0..<5 { tick(m) }
#expect(rec.commands.count == settled)
}

@Test("re-pin AFTER the hardware confirmed auto: the still-armed watch catches it")
func repinAfterConfirmIsHealed() {
let script = StatusScript()
let rec = Recorder()
let m = makeMonitor(profile: .balanced, script: script, rec: rec)

rampThenSwitchToSilent(m, rec: rec)
script.mode = "auto" // the switch's resetAuto landed fine
tick(m) // hardware-confirmed settle, no send
let settled = rec.commands.count
#expect(rec.resetAutoCount == 0)

// The late ramp write lands NOW — after the confirmed auto. The watch
// is still armed and must correct the re-pin.
script.mode = "manual"
for _ in 0..<(ThermalMonitor.handsOffRetryCadence + 2) { tick(m) }
#expect(rec.resetAutoCount >= 1)

script.mode = "auto" // correction landed — settle again
for _ in 0..<3 { tick(m) }
let settledAgain = rec.commands.count
for _ in 0..<5 { tick(m) }
#expect(rec.commands.count == settledAgain)
}

@Test("clean handback: hardware already auto — no retry, state settles, nothing sent")
func cleanHandbackSettlesImmediately() {
let script = StatusScript()
let rec = Recorder()
let m = makeMonitor(profile: .balanced, script: script, rec: rec)

rampThenSwitchToSilent(m, rec: rec)
script.mode = "auto"

for _ in 0..<6 { tick(m) } // confirms and settles on the first
#expect(rec.resetAutoCount == 0) // no redundant resetAuto
}

@Test("idle hands-off (never controlled) never sends anything — even in manual")
func idleSilentStaysQuiet() {
// E.g. an external tool holds the fans manual; a Silent ThermalForge
// that never controlled them (and never armed a watch) must not fight it.
let script = StatusScript(mode: "manual")
let rec = Recorder()
let m = makeMonitor(profile: .silent, script: script, rec: rec)

for _ in 0..<15 { tick(m) }
#expect(rec.commands.isEmpty)
}

@Test("the handback watch is bounded: retries stop when it expires")
func watchExpiresAndStopsRetrying() {
let script = StatusScript() // manual forever — daemon dead
let rec = Recorder()
let m = makeMonitor(profile: .balanced, script: script, rec: rec)

rampThenSwitchToSilent(m, rec: rec)

// Default window is 10s = 100 ticks at 100ms; at 1 Hz that is ~10
// retries. Run PAST the window, then prove nothing more is sent.
for _ in 0..<110 { tick(m) }
let expired = rec.resetAutoCount
#expect(expired >= 8 && expired <= 12) // retried through the window…
for _ in 0..<30 { tick(m) }
#expect(rec.resetAutoCount == expired) // …and then stopped
}
}