fix(alerts): skip read-only/small volumes in low-storage rule (Issue #33) #37

Merged
gronod merged 1 commits from fix/33-readonly-storage-alerts into milestone/m6-bugfix-stability 2026-09-08 15:17:05 +01:00
4 changed files with 40 additions and 6 deletions
+3 -1
View File
@@ -702,7 +702,9 @@ public final class SystemTelemetryStore {
freeBytes: $0.freeBytes, freeBytes: $0.freeBytes,
freePercent: $0.totalBytes > 0 freePercent: $0.totalBytes > 0
? Double($0.freeBytes) / Double($0.totalBytes) * 100.0 ? Double($0.freeBytes) / Double($0.totalBytes) * 100.0
: 0 : 0,
totalBytes: $0.totalBytes,
isReadOnly: $0.isReadOnly
) )
} }
context.batteryLevel = self.batteryHealth.maxCapacity > 0 context.batteryLevel = self.batteryHealth.maxCapacity > 0
+4 -1
View File
@@ -163,7 +163,10 @@ public final class AlertEngine {
// 4. Low storage space (per mounted volume) // 4. Low storage space (per mounted volume)
if cfg.storageEnabled { if cfg.storageEnabled {
for volume in ctx.volumes { // Skip read-only mounts (DMGs, snapshots) and trivially small
// volumes where free-space thresholds are meaningless.
let minTotalBytes: UInt64 = 1_073_741_824 // 1 GB
for volume in ctx.volumes where !volume.isReadOnly && volume.totalBytes >= minTotalBytes {
let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024) let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold { if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
alerts.append(SystemAlert( alerts.append(SystemAlert(
+7 -1
View File
@@ -70,12 +70,18 @@ public struct StorageVolumeContext: Equatable, Sendable {
public let volumeName: String public let volumeName: String
public let freeBytes: UInt64 public let freeBytes: UInt64
public let freePercent: Double public let freePercent: Double
public let totalBytes: UInt64
/// Read-only mounts (DMG images, snapshots) are excluded from alerting.
public let isReadOnly: Bool
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double) { public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double,
totalBytes: UInt64 = 0, isReadOnly: Bool = false) {
self.mountPoint = mountPoint self.mountPoint = mountPoint
self.volumeName = volumeName self.volumeName = volumeName
self.freeBytes = freeBytes self.freeBytes = freeBytes
self.freePercent = freePercent self.freePercent = freePercent
self.totalBytes = totalBytes
self.isReadOnly = isReadOnly
} }
} }
+26 -3
View File
@@ -128,8 +128,10 @@ final class MMAlertsTests: XCTestCase {
func testStorageRuleTriggersPerVolume() { func testStorageRuleTriggersPerVolume() {
let volumes = [ let volumes = [
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824, freePercent: 5), StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824,
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50) freePercent: 5, totalBytes: 500 * 1_073_741_824),
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824,
freePercent: 50, totalBytes: 1_000_000_000_000)
] ]
engine.evaluate(context(volumes: volumes)) engine.evaluate(context(volumes: volumes))
XCTAssertEqual(engine.activeAlerts.count, 1) XCTAssertEqual(engine.activeAlerts.count, 1)
@@ -137,11 +139,32 @@ final class MMAlertsTests: XCTestCase {
} }
func testStorageRuleTriggersOnLowPercent() { func testStorageRuleTriggersOnLowPercent() {
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big", freeBytes: 200 * 1_073_741_824, freePercent: 4)] let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big",
freeBytes: 200 * 1_073_741_824, freePercent: 4,
totalBytes: 2_000_000_000_000)]
engine.evaluate(context(volumes: volumes)) engine.evaluate(context(volumes: volumes))
XCTAssertEqual(engine.activeAlerts.count, 1) XCTAssertEqual(engine.activeAlerts.count, 1)
} }
func testStorageRuleSkipsReadOnlyVolumes() {
// A read-only DMG mount (e.g. an app running from a mounted image)
// with ~0% free must not alert issue #33 false positive.
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Ghostty", volumeName: "Ghostty",
freeBytes: 1_048_576, freePercent: 0.1,
totalBytes: 500_000_000_000, isReadOnly: true)]
engine.evaluate(context(volumes: volumes))
XCTAssertTrue(engine.activeAlerts.isEmpty)
XCTAssertTrue(engine.history.isEmpty)
}
func testStorageRuleSkipsTinyVolumes() {
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Small", volumeName: "Small",
freeBytes: 1_048_576, freePercent: 0.5,
totalBytes: 200_000_000, isReadOnly: false)]
engine.evaluate(context(volumes: volumes))
XCTAssertTrue(engine.activeAlerts.isEmpty)
}
// MARK: Battery // MARK: Battery
func testBatteryRuleTriggersOnlyWhileDischarging() { func testBatteryRuleTriggersOnlyWhileDischarging() {