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
6 changes: 6 additions & 0 deletions Sources/ThermalForgeApp/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ final class AppState: ObservableObject {
@Published var availableUpdate: AvailableUpdate?

private var monitor: ThermalMonitor?
private var wakeObserver: NSObjectProtocol?
private let executor = PrivilegedExecutor()
private var heartbeatTimer: DispatchSourceTimer?
/// Consecutive failed heartbeats, for debouncing `daemonUnreachable`.
Expand Down Expand Up @@ -178,6 +179,7 @@ final class AppState: ObservableObject {
}

deinit {
if let wakeObserver { NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver) }
heartbeatTimer?.cancel()
}

Expand Down Expand Up @@ -372,6 +374,10 @@ final class AppState: ObservableObject {
}
monitor.start()
self.monitor = monitor
if let wakeObserver { NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver) }
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
) { [weak monitor] _ in monitor?.invalidateHardwareInventory() }
}

// MARK: - Actions
Expand Down
4 changes: 4 additions & 0 deletions Sources/ThermalForgeCore/Daemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ public final class DaemonServer {
// unified log with the daemon's own prefix.
fanControl.logSink = { DaemonLog.notice("ThermalForge daemon: \($0)") }
self.fanControl = fanControl
fanControl.enableKeyInventoryCaching()
self.ownerUID = ownerUID
// Cache fan RPM limits once (fixed hardware constants). Empty on a read failure
// → clamp becomes a no-op and FanControl's own range check stays the backstop.
Expand Down Expand Up @@ -865,6 +866,9 @@ public final class DaemonServer {
}

private func handleWake() {
smcLock.lock()
fanControl.invalidateSMCKeyInventory()
smcLock.unlock()
stateLock.lock()
let heldCommand = hold.command
stateLock.unlock()
Expand Down
7 changes: 7 additions & 0 deletions Sources/ThermalForgeCore/FanControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ public final class FanControl {
}
}

/// Opt into per-connection inventory caching for repeated polling. Discovery
/// is deferred to the next read; one-shot CLI callers retain their old path.
public func enableKeyInventoryCaching() { smc.enableKeyInventoryCaching() }

/// Invalidate discovery after wake, on the caller's serialized SMC queue.
func invalidateSMCKeyInventory() { smc.invalidateKeyInventory() }

// MARK: - Fan Count

public func fanCount() throws -> Int {
Expand Down
27 changes: 26 additions & 1 deletion Sources/ThermalForgeCore/SMCConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,29 @@ struct SMCParamStruct {
public final class SMCConnection {

private let connection: io_connect_t
private var inventory = SMCKeyInventory()
private var usesKeyInventory = false
private var preparingInventory = false
private var inventoryRetryAt: TimeInterval = 0

/// Opt in only for long-lived callers; one-shot CLI reads avoid enumeration.
func enableKeyInventoryCaching() { usesKeyInventory = true }

/// SMC access is externally serialized (monitor queue / daemon smcLock).
func invalidateKeyInventory() {
inventory = SMCKeyInventory()
inventoryRetryAt = 0
}

private func prepareKeyInventory() {
guard !inventory.isComplete, !preparingInventory,
ProcessInfo.processInfo.systemUptime >= inventoryRetryAt else { return }
preparingInventory = true // getKeyCount() reads #KEY through readKey().
defer { preparingInventory = false }
if !inventory.rebuild(count: getKeyCount(), keyAtIndex: getKeyAtIndex) {
inventoryRetryAt = ProcessInfo.processInfo.systemUptime + 5
}
}

public init?() {
var iterator: io_iterator_t = 0
Expand Down Expand Up @@ -108,6 +131,8 @@ public final class SMCConnection {

/// Read raw bytes from an SMC key
public func readKey(_ key: String) -> (success: Bool, bytes: [UInt8], size: UInt32) {
if usesKeyInventory { prepareKeyInventory() }
guard inventory.mayContain(key) else { return (false, [], 0) }
var input = SMCParamStruct()
var output = SMCParamStruct()

Expand Down Expand Up @@ -176,7 +201,7 @@ public final class SMCConnection {
input.data8 = SMCCommand.getKeyFromIndex.rawValue
input.data32 = index

guard callSMC(&input, &output) == kIOReturnSuccess else {
guard callSMC(&input, &output) == kIOReturnSuccess, output.result == 0 else {
return nil
}

Expand Down
28 changes: 28 additions & 0 deletions Sources/ThermalForgeCore/SMCKeyInventory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// A connection-local, firmware-enumerated inventory. Missing keys are skipped
/// only after every index was read successfully; an incomplete enumeration is
/// fail-open. No per-read I/O failure is ever recorded as an absent sensor.
struct SMCKeyInventory {
private var keys: Set<String>?
var isComplete: Bool { keys != nil }

func mayContain(_ key: String) -> Bool {
// #KEY bootstraps the inventory and must remain readable.
key == "#KEY" || (keys?.contains(key) ?? true)
}

@discardableResult
mutating func rebuild(count: UInt32, keyAtIndex: (UInt32) -> String?) -> Bool {
keys = nil
guard count > 0, count < 100_000 else { return false }
var discovered = Set<String>()
for index in 0..<count {
guard let key = keyAtIndex(index), key.utf8.count == 4,
key.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x7e }),
discovered.insert(key).inserted else { return false }
}
keys = discovered
return true
}
}
6 changes: 6 additions & 0 deletions Sources/ThermalForgeCore/ThermalMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public final class ThermalMonitor {

public func start(interval: TimeInterval = 0.1) {
stop()
queue.async { [self] in fanControl.enableKeyInventoryCaching() }

let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: interval)
Expand All @@ -137,6 +138,11 @@ public final class ThermalMonitor {
timer = nil
}

/// Rebuild the connection's sensor inventory after wake without racing reads.
public func invalidateHardwareInventory() {
queue.async { [self] in fanControl.invalidateSMCKeyInventory() }
}

/// Update the active profile.
public func switchProfile(_ profile: FanProfile) {
queue.async { [self] in
Expand Down
61 changes: 61 additions & 0 deletions Tests/ThermalForgeTests/SMCKeyInventoryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Testing
@testable import ThermalForgeCore

@Suite("SMC key inventory")
struct SMCKeyInventoryTests {
@Test("A complete inventory skips absent keys without probing them again")
func missingKeys() {
var inventory = SMCKeyInventory()
#expect(inventory.mayContain("Tp01"))
#expect(inventory.rebuild(count: 2) { ["TCHP", "TG0H"][Int($0)] })
#expect(inventory.isComplete)
#expect(inventory.mayContain("TCHP"))
#expect(inventory.mayContain("TG0H"))
#expect(!inventory.mayContain("Tp01"))
#expect(inventory.mayContain("#KEY"))
}
@Test("Each connection uses its own hardware inventory")
func differentMachines() {
var first = SMCKeyInventory()
var second = SMCKeyInventory()
first.rebuild(count: 1) { _ in "TCHP" }
second.rebuild(count: 1) { _ in "Tp01" }
#expect(!first.mayContain("Tp01"))
#expect(second.mayContain("Tp01"))
#expect(!second.mayContain("TCHP"))
}
@Test("A failed or partial enumeration excludes no sensors and can be retried")
func transientFailures() {
var inventory = SMCKeyInventory()
#expect(!inventory.rebuild(count: 2) { $0 == 0 ? "TCHP" : nil })
#expect(!inventory.isComplete)
#expect(inventory.mayContain("TG0H"))
#expect(inventory.rebuild(count: 2) { ["TCHP", "TG0H"][Int($0)] })
#expect(inventory.mayContain("TG0H"))
}
@Test("Invalid counts, malformed keys and duplicate indices fail open")
func corruptInventory() {
var inventory = SMCKeyInventory()
for count: UInt32 in [0, 100_000] {
#expect(!inventory.rebuild(count: count) { _ in "TCHP" })
#expect(inventory.mayContain("Tp01"))
}
for invalid in ["TC", "TCHPX", "\0\0\0\0", "éé"] {
#expect(!inventory.rebuild(count: 1) { _ in invalid })
#expect(inventory.mayContain("Tp01"))
}
#expect(!inventory.rebuild(count: 2) { _ in "TCHP" })
#expect(inventory.mayContain("Tp01"))
}
@Test("Wake/reconnect invalidation removes exclusions and discovers new keys")
func invalidation() {
var inventory = SMCKeyInventory()
inventory.rebuild(count: 1) { _ in "TCHP" }
#expect(!inventory.mayContain("Tp01"))
inventory = SMCKeyInventory()
#expect(inventory.mayContain("Tp01"))
inventory.rebuild(count: 1) { _ in "Tp01" }
#expect(inventory.mayContain("Tp01"))
#expect(!inventory.mayContain("TCHP"))
}
}
31 changes: 31 additions & 0 deletions Tools/benchmark-smc-status.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Read-only benchmark: no fan writes or thermal workload.
// Build: swift build -c release --target ThermalForgeCore
// swiftc -O -I .build/release/Modules Tools/benchmark-smc-status.swift \
// .build/release/ThermalForgeCore.build/*.o -framework Metal -framework IOKit \
// -o /tmp/thermalforge-status-benchmark
// Run without caching, then with caching: /tmp/thermalforge-status-benchmark [--inventory]
import Foundation
import ThermalForgeCore
let initializationStart = ProcessInfo.processInfo.systemUptime
let control = try FanControl()
let enabled = CommandLine.arguments.contains("--inventory")
if enabled { control.enableKeyInventoryCaching() }
let initializationMS = (ProcessInfo.processInfo.systemUptime - initializationStart) * 1000
var readings: [Double] = []
var sensorKeys: [String] = []
var firstStatusMS: Double = 0
for iteration in 0..<205 {
let start = ProcessInfo.processInfo.systemUptime
let status = try control.status()
let elapsed = ProcessInfo.processInfo.systemUptime - start
if iteration == 0 { firstStatusMS = elapsed * 1000 }
if iteration >= 5 { readings.append(elapsed) }
sensorKeys = status.temperatures.keys.sorted()
}
let ordered = readings.sorted()
let result: [String: Any] = ["inventory_enabled": enabled, "initialization_ms": initializationMS, "first_status_ms": firstStatusMS, "reads": readings.count,
"mean_ms": readings.reduce(0,+) / Double(readings.count) * 1000,
"median_ms": ordered[ordered.count / 2] * 1000,
"p95_ms": ordered[Int(Double(ordered.count - 1) * 0.95)] * 1000,
"sensor_keys": sensorKeys]
print(String(data: try JSONSerialization.data(withJSONObject: result, options: .sortedKeys), encoding: .utf8)!)