From 85ff0092af25544791d47c42743140f5a3f2e934 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 8 Aug 2026 22:30:38 +1000 Subject: [PATCH 1/4] refactor(energy): extract publication state --- MacActivity.xcodeproj/project.pbxproj | 8 + .../EnergyImpactPublicationState.swift | 241 ++++++++++++ .../Providers/EnergyImpactSampler.swift | 186 +--------- .../EnergyImpactPublicationStateTests.swift | 342 ++++++++++++++++++ 4 files changed, 601 insertions(+), 176 deletions(-) create mode 100644 Sources/MacActivityCore/Metrics/Providers/EnergyImpactPublicationState.swift create mode 100644 Tests/MacActivityCoreTests/EnergyImpactPublicationStateTests.swift diff --git a/MacActivity.xcodeproj/project.pbxproj b/MacActivity.xcodeproj/project.pbxproj index 203ea52..ea39a7b 100644 --- a/MacActivity.xcodeproj/project.pbxproj +++ b/MacActivity.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 9860C042B6168A52097ECE16 /* ActiveCleanupModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E7A42055FEA7CDD8EED194 /* ActiveCleanupModel.swift */; }; 9A395BB79F2CCC0C7DCD4117 /* BatterySystemPowerSourceReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420BEFFE5B99B07B33CAF5DE /* BatterySystemPowerSourceReader.swift */; }; 9AA5402E9D5A714CBB34311A /* MemoryProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94D2FEA382B619F7773A1B4 /* MemoryProviderTests.swift */; }; + A1C4B920CF9DA8C8F81F347F /* EnergyImpactPublicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443015F2FA5999B5DE29199C /* EnergyImpactPublicationState.swift */; }; A1DC0091AFAA3500C5AAA85F /* ActiveAppMemoryProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716895D62F7DE80DDB7F6D63 /* ActiveAppMemoryProviding.swift */; }; A9BECB3CD32A259E10292422 /* DashboardPopoverControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0742EBD55AA887021B006D58 /* DashboardPopoverControllerTests.swift */; }; AD712FC4EBAA68F9349BACD3 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF9A4149A89C1A5F8C4CB520 /* AppPreferences.swift */; }; @@ -129,6 +130,7 @@ C758FD3134C4FC4F259D6C12 /* MetricsStoreHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8428982312A6028D3AC20D1F /* MetricsStoreHistoryTests.swift */; }; C881AFF3738010EDBEC7AA4E /* ProcessTapVolumeEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21E4322AB6B59C2521EDE0BB /* ProcessTapVolumeEngineTests.swift */; }; C94D70721B9A9210B31F328D /* AudioProcessService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 783BAC8E92D1DF859D51D06B /* AudioProcessService.swift */; }; + CA966D34C7D256937901291F /* EnergyImpactPublicationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3CBD9E2D4FE41FA3CD30931 /* EnergyImpactPublicationStateTests.swift */; }; CC4C0545E209D780F12C21BE /* AudioSystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4643019F12953DA4A2FBD2 /* AudioSystemMonitor.swift */; }; CC96AD1A721BD82A5462EF6A /* MemoryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC3E242AE71B79619755C0D /* MemoryProvider.swift */; }; CD615D0A7A12AB82049484F5 /* PreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4797F590CDBAA38A16A0E76D /* PreferencesStore.swift */; }; @@ -266,6 +268,7 @@ 41333E534CBDB52CD351763E /* DashboardModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardModel.swift; sourceTree = ""; }; 420BEFFE5B99B07B33CAF5DE /* BatterySystemPowerSourceReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatterySystemPowerSourceReader.swift; sourceTree = ""; }; 422B3F924F7EC6DA0DF6493F /* NetworkProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProviderTests.swift; sourceTree = ""; }; + 443015F2FA5999B5DE29199C /* EnergyImpactPublicationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactPublicationState.swift; sourceTree = ""; }; 443C3EE222DFFEB15FA514D2 /* ActiveProcessMemoryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveProcessMemoryRow.swift; sourceTree = ""; }; 46B63F02833B7A142E6D3C19 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLanguage.swift; sourceTree = ""; }; 4797F590CDBAA38A16A0E76D /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.swift; sourceTree = ""; }; @@ -363,6 +366,7 @@ DAC3D5561158D1EC418C2BC9 /* AudioRoutingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoutingModels.swift; sourceTree = ""; }; E03A2C158D947FE65167CEA8 /* EnergyImpactPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactPresentation.swift; sourceTree = ""; }; E2E54E53FD145EE2DACD72F6 /* AudioAggregateTopologyResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAggregateTopologyResolver.swift; sourceTree = ""; }; + E3CBD9E2D4FE41FA3CD30931 /* EnergyImpactPublicationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactPublicationStateTests.swift; sourceTree = ""; }; E49ECEEFF448A10DF483AD89 /* TemperatureProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureProvider.swift; sourceTree = ""; }; E623687BEBC14372C97ABDDF /* AudioTapHardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTapHardware.swift; sourceTree = ""; }; E6B629DAB047D5C4066597A1 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -547,6 +551,7 @@ 7244116397B5BA1B99ACDEBD /* EnergyImpactOwnership.swift */, 89A4859CDCAE97D91FDA5990 /* EnergyImpactProcessReader.swift */, F6C57F502EC1B86CD070FE38 /* EnergyImpactProvider.swift */, + 443015F2FA5999B5DE29199C /* EnergyImpactPublicationState.swift */, 9A1F7A521F369C3B108ACABF /* EnergyImpactSampler.swift */, 1C6E6EE79DD4E13EB9F43826 /* EnergyImpactStatistics.swift */, 90E95B0B56AA23B56343FB1C /* EnergyImpactTypes.swift */, @@ -669,6 +674,7 @@ 5BF7D4EC5F491FFCA175F513 /* EnergyImpactOwnershipTests.swift */, B6E756788A431A050E50FDAA /* EnergyImpactProcessReaderTests.swift */, 4FBBF160184071B2E1059B58 /* EnergyImpactProviderTests.swift */, + E3CBD9E2D4FE41FA3CD30931 /* EnergyImpactPublicationStateTests.swift */, 76C6A386B2AFC5BB533B7BD4 /* EnergyImpactSamplerTests.swift */, 7F7B5639CB348DA6FDAE9279 /* EnergyImpactStatisticsTests.swift */, B66490C62BF6EE5944B75C67 /* EnergyImpactTraceReplayTests.swift */, @@ -967,6 +973,7 @@ B318222CE0D85B319EA3573F /* EnergyImpactOwnershipTests.swift in Sources */, 7A14AE60E94454E455406530 /* EnergyImpactProcessReaderTests.swift in Sources */, EDC980EF30033CA70792C40E /* EnergyImpactProviderTests.swift in Sources */, + CA966D34C7D256937901291F /* EnergyImpactPublicationStateTests.swift in Sources */, 83D17CCB57BACDD3F3BBFF9E /* EnergyImpactSamplerTests.swift in Sources */, 7CA75E9DA5AE16CBF75521EB /* EnergyImpactStatisticsTests.swift in Sources */, 1B0012B1804C865A55F713DA /* EnergyImpactTraceReplayTests.swift in Sources */, @@ -1024,6 +1031,7 @@ DE87E8B9F0BBFA1E45FF2961 /* EnergyImpactOwnership.swift in Sources */, B4056ECF58614FBE94D6B1F1 /* EnergyImpactProcessReader.swift in Sources */, 0F997EFB2933D1DF01944138 /* EnergyImpactProvider.swift in Sources */, + A1C4B920CF9DA8C8F81F347F /* EnergyImpactPublicationState.swift in Sources */, B6059BE6D5CE1387694F8BBD /* EnergyImpactSampler.swift in Sources */, 2F976168C40F4D4D3D54A629 /* EnergyImpactStatistics.swift in Sources */, 25F6AB66E8EE1DA07F240610 /* EnergyImpactTypes.swift in Sources */, diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactPublicationState.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactPublicationState.swift new file mode 100644 index 0000000..5d25d0e --- /dev/null +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactPublicationState.swift @@ -0,0 +1,241 @@ +import Foundation + +public struct EnergyImpactPublicationState: Sendable { + private let configuration: EnergyImpactConfiguration + private var smoother: EnergyImpactSmoother + private var ranker = StableEnergyImpactRanker() + private var lastValidObservationTimes: + [EnergyImpactProcessIdentity: TimeInterval] = [:] + private var lastPublicationTime: TimeInterval? + + public init(configuration: EnergyImpactConfiguration = .production) { + self.configuration = configuration + self.smoother = EnergyImpactSmoother( + halfLifeSeconds: configuration.fastHalfLifeSeconds + ) + } + + public mutating func publish( + _ candidates: [EnergyImpactEntry], + at publicationTime: TimeInterval, + limit: Int + ) -> [EnergyImpactEntry] { + publish( + candidates, + at: publicationTime, + limit: limit, + smoothingOverride: nil + ) + } + + mutating func publish( + _ candidates: [EnergyImpactEntry], + at publicationTime: TimeInterval, + limit: Int, + smoothingOverrideForTesting: @Sendable ( + EnergyImpactProcessIdentity, + Double, + TimeInterval + ) -> Double? + ) -> [EnergyImpactEntry] { + withoutActuallyEscaping(smoothingOverrideForTesting) { smoothingOverride in + publish( + candidates, + at: publicationTime, + limit: limit, + smoothingOverride: smoothingOverride + ) + } + } + + private mutating func publish( + _ candidates: [EnergyImpactEntry], + at publicationTime: TimeInterval, + limit: Int, + smoothingOverride: (@Sendable ( + EnergyImpactProcessIdentity, + Double, + TimeInterval + ) -> Double?)? + ) -> [EnergyImpactEntry] { + guard publicationTime.isFinite else { + resetStatistics() + return Array( + ranker.rank( + candidates.map(Self.sanitizedForInvalidClock), + atPublicationBoundary: true + ) + .prefix(max(0, limit)) + ) + } + + if let lastPublicationTime { + let publicationGap = publicationTime - lastPublicationTime + if publicationGap <= 0 || publicationGap > configuration.maximumGapSeconds { + resetStatistics() + } + } + + let currentGenerations = Set(candidates.compactMap(\.identity.generation)) + smoother.retainOnly(currentGenerations) + lastValidObservationTimes = lastValidObservationTimes.filter { + currentGenerations.contains($0.key) + } + + let processed = candidates.map { candidate in + process( + candidate, + at: publicationTime, + smoothingOverride: smoothingOverride + ) + } + lastPublicationTime = publicationTime + + return Array( + ranker.rank(processed, atPublicationBoundary: true) + .prefix(max(0, limit)) + ) + } + + private mutating func process( + _ candidate: EnergyImpactEntry, + at publicationTime: TimeInterval, + smoothingOverride: (@Sendable ( + EnergyImpactProcessIdentity, + Double, + TimeInterval + ) -> Double?)? + ) -> EnergyImpactEntry { + let sanitized = Self.sanitizingInvalidNumerics(candidate) + guard sanitized.status == .stable || sanitized.status == .partial else { + return sanitized + } + guard let currentPower = sanitized.currentPowerMicrowatts, + let rankingScore = sanitized.rankingScore, + currentPower.isFinite, + currentPower >= 0, + rankingScore.isFinite, + rankingScore >= 0 else { + return Self.nonnumericUnavailable(sanitized) + } + guard let generation = sanitized.identity.generation else { + return sanitized + } + + let elapsedSeconds: TimeInterval + if let lastValidObservationTime = lastValidObservationTimes[generation] { + elapsedSeconds = publicationTime - lastValidObservationTime + } else { + elapsedSeconds = configuration.publicationIntervalSeconds + } + guard elapsedSeconds.isFinite, elapsedSeconds > 0 else { + return Self.nonnumericUnavailable(sanitized) + } + + if elapsedSeconds > configuration.maximumGapSeconds { + smoother.retainOnly( + Set(lastValidObservationTimes.keys).subtracting([generation]) + ) + lastValidObservationTimes[generation] = nil + } + let smoothingElapsed = min(elapsedSeconds, configuration.maximumGapSeconds) + let smoothed: Double? + if let smoothingOverride { + smoothed = smoothingOverride( + generation, + currentPower, + smoothingElapsed + ) + } else { + smoothed = smoother.update( + identity: generation, + value: currentPower, + elapsedSeconds: smoothingElapsed + ) + } + guard let smoothed else { + return Self.nonnumericUnavailable(sanitized) + } + lastValidObservationTimes[generation] = publicationTime + return Self.replacingCurrentPower(in: sanitized, with: smoothed) + } + + private mutating func resetStatistics() { + smoother = EnergyImpactSmoother( + halfLifeSeconds: configuration.fastHalfLifeSeconds + ) + ranker.reset() + lastValidObservationTimes.removeAll() + lastPublicationTime = nil + } + + private static func replacingCurrentPower( + in entry: EnergyImpactEntry, + with currentPower: Double + ) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: entry.identity, + name: entry.name, + bundleIdentifier: entry.bundleIdentifier, + bundleURL: entry.bundleURL, + kind: entry.kind, + currentPowerMicrowatts: currentPower, + sustainedPowerMicrowatts: entry.sustainedPowerMicrowatts, + rankingScore: currentPower, + trend: entry.trend, + coverage: entry.coverage, + status: entry.status + ) + } + + private static func nonnumericUnavailable( + _ entry: EnergyImpactEntry + ) -> EnergyImpactEntry { + nonnumeric(entry, status: .unavailable) + } + + private static func sanitizedForInvalidClock( + _ entry: EnergyImpactEntry + ) -> EnergyImpactEntry { + let sanitized = sanitizingInvalidNumerics(entry) + if sanitized.status == .stable || sanitized.status == .partial { + return nonnumericUnavailable(sanitized) + } + return sanitized + } + + private static func sanitizingInvalidNumerics( + _ entry: EnergyImpactEntry + ) -> EnergyImpactEntry { + let numericValues = [ + entry.currentPowerMicrowatts, + entry.sustainedPowerMicrowatts, + entry.rankingScore, + ].compactMap { $0 } + guard numericValues.contains(where: { $0.isFinite == false || $0 < 0 }) else { + return entry + } + let status: EnergyImpactStatus = + entry.status == .stable || entry.status == .partial ? .unavailable : entry.status + return nonnumeric(entry, status: status) + } + + private static func nonnumeric( + _ entry: EnergyImpactEntry, + status: EnergyImpactStatus + ) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: entry.identity, + name: entry.name, + bundleIdentifier: entry.bundleIdentifier, + bundleURL: entry.bundleURL, + kind: entry.kind, + currentPowerMicrowatts: nil, + sustainedPowerMicrowatts: nil, + rankingScore: nil, + trend: entry.trend, + coverage: entry.coverage, + status: status + ) + } +} diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactSampler.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactSampler.swift index f342f8e..d5ffdde 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactSampler.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactSampler.swift @@ -166,7 +166,6 @@ public actor EnergyImpactSampler: EnergyImpactSampling { entries, at: sampleTime, limit: limit, - configuration: configuration, smoothingOverrideForTesting: smoothingOverrideForTesting ) guard canContinue(sessionID) else { return nil } @@ -383,7 +382,6 @@ public actor EnergyImpactSampler: EnergyImpactSampling { entries, at: sampleTime, limit: limit, - configuration: configuration, smoothingOverrideForTesting: smoothingOverrideForTesting ) : Self.sortedByImpact(entries, limit: limit) @@ -476,10 +474,7 @@ private struct EnergyImpactSamplerState: Sendable { var currentIdentityByRootProcessIdentifier: [pid_t: EnergyImpactAppIdentity] = [:] var displayByIdentity: [EnergyImpactAppIdentity: EnergyImpactDisplayState] = [:] var previousSampleTime: TimeInterval? - var smoother: EnergyImpactSmoother - var ranker = StableEnergyImpactRanker() - var lastValidObservationTimes: [EnergyImpactProcessIdentity: TimeInterval] = [:] - var lastPublicationTime: TimeInterval? + var publicationState: EnergyImpactPublicationState var cachedProcessSnapshots: [ProcessParentSnapshot]? var lastProcessSnapshotTime: TimeInterval? var cachedRootProcessIdentifiers: Set? @@ -488,7 +483,7 @@ private struct EnergyImpactSamplerState: Sendable { var cachedRawRootProcessIdentifiers: Set? init(configuration: EnergyImpactConfiguration) { - smoother = EnergyImpactSmoother(halfLifeSeconds: configuration.fastHalfLifeSeconds) + publicationState = EnergyImpactPublicationState(configuration: configuration) } mutating func prepareForSample( @@ -618,185 +613,24 @@ private struct EnergyImpactSamplerState: Sendable { _ candidates: [EnergyImpactEntry], at publicationTime: TimeInterval, limit: Int, - configuration: EnergyImpactConfiguration, smoothingOverrideForTesting: (@Sendable ( EnergyImpactProcessIdentity, Double, TimeInterval ) -> Double?)? ) -> [EnergyImpactEntry] { - guard publicationTime.isFinite else { - resetStatistics(configuration: configuration) - return Array( - ranker.rank( - candidates.map(Self.sanitizedForInvalidClock), - atPublicationBoundary: true - ) - .prefix(max(0, limit)) - ) - } - - if let lastPublicationTime { - let publicationGap = publicationTime - lastPublicationTime - if publicationGap <= 0 || publicationGap > configuration.maximumGapSeconds { - resetStatistics(configuration: configuration) - } - } - - let currentGenerations = Set(candidates.compactMap(\.identity.generation)) - smoother.retainOnly(currentGenerations) - lastValidObservationTimes = lastValidObservationTimes.filter { - currentGenerations.contains($0.key) - } - - let processed = candidates.map { candidate in - process( - candidate, + if let smoothingOverrideForTesting { + return publicationState.publish( + candidates, at: publicationTime, - configuration: configuration, + limit: limit, smoothingOverrideForTesting: smoothingOverrideForTesting ) } - lastPublicationTime = publicationTime - - return Array( - ranker.rank(processed, atPublicationBoundary: true) - .prefix(max(0, limit)) - ) - } - - private mutating func process( - _ candidate: EnergyImpactEntry, - at publicationTime: TimeInterval, - configuration: EnergyImpactConfiguration, - smoothingOverrideForTesting: (@Sendable ( - EnergyImpactProcessIdentity, - Double, - TimeInterval - ) -> Double?)? - ) -> EnergyImpactEntry { - let sanitized = Self.sanitizingInvalidNumerics(candidate) - guard sanitized.status == .stable || sanitized.status == .partial else { - return sanitized - } - guard let currentPower = sanitized.currentPowerMicrowatts, - let rankingScore = sanitized.rankingScore, - currentPower.isFinite, - currentPower >= 0, - rankingScore.isFinite, - rankingScore >= 0 else { - return Self.nonnumericUnavailable(sanitized) - } - guard let generation = sanitized.identity.generation else { - return sanitized - } - - let elapsedSeconds: TimeInterval - if let lastValidObservationTime = lastValidObservationTimes[generation] { - elapsedSeconds = publicationTime - lastValidObservationTime - } else { - elapsedSeconds = configuration.publicationIntervalSeconds - } - guard elapsedSeconds.isFinite, elapsedSeconds > 0 else { - return Self.nonnumericUnavailable(sanitized) - } - - if elapsedSeconds > configuration.maximumGapSeconds { - smoother.retainOnly( - Set(lastValidObservationTimes.keys).subtracting([generation]) - ) - lastValidObservationTimes[generation] = nil - } - let smoothingElapsed = min(elapsedSeconds, configuration.maximumGapSeconds) - let smoothed: Double? - if let smoothingOverrideForTesting { - smoothed = smoothingOverrideForTesting( - generation, - currentPower, - smoothingElapsed - ) - } else { - smoothed = smoother.update( - identity: generation, - value: currentPower, - elapsedSeconds: smoothingElapsed - ) - } - guard let smoothed else { - return Self.nonnumericUnavailable(sanitized) - } - lastValidObservationTimes[generation] = publicationTime - return Self.replacingCurrentPower(in: sanitized, with: smoothed) - } - - private mutating func resetStatistics(configuration: EnergyImpactConfiguration) { - smoother = EnergyImpactSmoother(halfLifeSeconds: configuration.fastHalfLifeSeconds) - ranker.reset() - lastValidObservationTimes.removeAll() - lastPublicationTime = nil - } - - private static func replacingCurrentPower( - in entry: EnergyImpactEntry, - with currentPower: Double - ) -> EnergyImpactEntry { - EnergyImpactEntry( - identity: entry.identity, - name: entry.name, - bundleIdentifier: entry.bundleIdentifier, - bundleURL: entry.bundleURL, - kind: entry.kind, - currentPowerMicrowatts: currentPower, - sustainedPowerMicrowatts: entry.sustainedPowerMicrowatts, - rankingScore: currentPower, - trend: entry.trend, - coverage: entry.coverage, - status: entry.status - ) - } - - private static func nonnumericUnavailable(_ entry: EnergyImpactEntry) -> EnergyImpactEntry { - nonnumeric(entry, status: .unavailable) - } - - private static func sanitizedForInvalidClock(_ entry: EnergyImpactEntry) -> EnergyImpactEntry { - let sanitized = sanitizingInvalidNumerics(entry) - if sanitized.status == .stable || sanitized.status == .partial { - return nonnumericUnavailable(sanitized) - } - return sanitized - } - - private static func sanitizingInvalidNumerics(_ entry: EnergyImpactEntry) -> EnergyImpactEntry { - let numericValues = [ - entry.currentPowerMicrowatts, - entry.sustainedPowerMicrowatts, - entry.rankingScore, - ].compactMap { $0 } - guard numericValues.contains(where: { $0.isFinite == false || $0 < 0 }) else { - return entry - } - let status: EnergyImpactStatus = - entry.status == .stable || entry.status == .partial ? .unavailable : entry.status - return nonnumeric(entry, status: status) - } - - private static func nonnumeric( - _ entry: EnergyImpactEntry, - status: EnergyImpactStatus - ) -> EnergyImpactEntry { - EnergyImpactEntry( - identity: entry.identity, - name: entry.name, - bundleIdentifier: entry.bundleIdentifier, - bundleURL: entry.bundleURL, - kind: entry.kind, - currentPowerMicrowatts: nil, - sustainedPowerMicrowatts: nil, - rankingScore: nil, - trend: entry.trend, - coverage: entry.coverage, - status: status + return publicationState.publish( + candidates, + at: publicationTime, + limit: limit ) } } diff --git a/Tests/MacActivityCoreTests/EnergyImpactPublicationStateTests.swift b/Tests/MacActivityCoreTests/EnergyImpactPublicationStateTests.swift new file mode 100644 index 0000000..9eb1c34 --- /dev/null +++ b/Tests/MacActivityCoreTests/EnergyImpactPublicationStateTests.swift @@ -0,0 +1,342 @@ +import XCTest +@testable import MacActivityCore + +final class EnergyImpactPublicationStateTests: XCTestCase { + func testStaleEntryDoesNotAdvanceEMARecoveryTime() throws { + var state = EnergyImpactPublicationState() + _ = state.publish([publicationEntry(power: 100)], at: 3, limit: 20) + _ = state.publish( + [publicationEntry(power: 100, status: .stale)], + at: 6, + limit: 20 + ) + + let recovered = state.publish( + [publicationEntry(power: 0)], + at: 9, + limit: 20 + ) + + XCTAssertEqual( + try XCTUnwrap(recovered.first?.currentPowerMicrowatts), + 35.355_339_06, + accuracy: 0.000_001 + ) + } + + func testAllCandidatesAdvanceBeforeTopTwentyTruncation() throws { + var state = EnergyImpactPublicationState() + let steady = (1...20).map { + publicationEntry(pid: pid_t($0), power: 50) + } + _ = state.publish( + steady + [publicationEntry(pid: 21, power: 0)], + at: 3, + limit: 20 + ) + _ = state.publish( + steady + [publicationEntry(pid: 21, power: 100)], + at: 6, + limit: 20 + ) + + let secondLead = state.publish( + steady + [publicationEntry(pid: 21, power: 100)], + at: 9, + limit: 20 + ) + + XCTAssertEqual(secondLead.count, 20) + XCTAssertTrue(secondLead.contains { $0.processIdentifier == 21 }) + XCTAssertEqual( + try XCTUnwrap( + secondLead.first { $0.processIdentifier == 21 }? + .currentPowerMicrowatts + ), + 64.644_660_94, + accuracy: 0.000_001 + ) + } + + func testTestingSmootherOverrideAppliesAtPublication() throws { + var state = EnergyImpactPublicationState() + let rows = state.publish( + [publicationEntry(pid: 100, power: 100, status: .stable)], + at: 3, + limit: 20, + smoothingOverrideForTesting: { _, _, _ in 42 } + ) + let row = try XCTUnwrap(rows.first) + + XCTAssertEqual(row.currentPowerMicrowatts, 42) + XCTAssertEqual(row.rankingScore, 42) + } + + func testStaleEntryInterruptsRankConfirmation() { + var state = EnergyImpactPublicationState() + let orders = [ + state.publish( + [publicationEntry(pid: 1, power: 100), publicationEntry(pid: 2, power: 90)], + at: 3, + limit: 20 + ), + state.publish( + [publicationEntry(pid: 1, power: 100), publicationEntry(pid: 2, power: 145)], + at: 6, + limit: 20 + ), + state.publish( + [ + publicationEntry(pid: 1, power: 100), + publicationEntry(pid: 2, power: 145, status: .stale), + ], + at: 9, + limit: 20 + ), + state.publish( + [publicationEntry(pid: 1, power: 100), publicationEntry(pid: 2, power: 112)], + at: 12, + limit: 20 + ), + state.publish( + [publicationEntry(pid: 1, power: 100), publicationEntry(pid: 2, power: 112)], + at: 15, + limit: 20 + ), + ].map { $0.map(\.processIdentifier) } + + XCTAssertEqual(orders, [[1, 2], [1, 2], [1, 2], [1, 2], [2, 1]]) + } + + func testNonfinitePowerPublishesUnavailableWithoutAdvancingEMA() throws { + var state = EnergyImpactPublicationState() + _ = state.publish([publicationEntry(power: 100)], at: 3, limit: 20) + + let invalid = try XCTUnwrap( + state.publish([publicationEntry(power: .nan)], at: 6, limit: 20).first + ) + + XCTAssertEqual(invalid.status, .unavailable) + XCTAssertNil(invalid.currentPowerMicrowatts) + XCTAssertNil(invalid.sustainedPowerMicrowatts) + XCTAssertNil(invalid.rankingScore) + + let recovered = state.publish([publicationEntry(power: 0)], at: 9, limit: 20) + XCTAssertEqual( + try XCTUnwrap(recovered.first?.currentPowerMicrowatts), + 35.355_339_06, + accuracy: 0.000_001 + ) + } + + func testStableEntryWithMissingNumericFieldsPublishesUnavailable() throws { + var state = EnergyImpactPublicationState() + + let row = try XCTUnwrap( + state.publish( + [publicationEntry(power: nil, status: .stable)], + at: 3, + limit: 20 + ).first + ) + + XCTAssertEqual(row.status, .unavailable) + XCTAssertNil(row.currentPowerMicrowatts) + XCTAssertNil(row.sustainedPowerMicrowatts) + XCTAssertNil(row.rankingScore) + } + + func testInvalidStaleNumericsAreStrippedWithoutChangingStaleStatus() throws { + var state = EnergyImpactPublicationState() + + let row = try XCTUnwrap( + state.publish( + [publicationEntry(power: .nan, status: .stale)], + at: 3, + limit: 20 + ).first + ) + + XCTAssertEqual(row.status, .stale) + XCTAssertNil(row.currentPowerMicrowatts) + XCTAssertNil(row.sustainedPowerMicrowatts) + XCTAssertNil(row.rankingScore) + } + + func testNonfiniteClockPreservesCollectingStaleAndUnavailableButInvalidatesStableAndPartial() throws { + var state = EnergyImpactPublicationState() + + let rows = state.publish( + [ + publicationEntry(pid: 1, power: nil, status: .collecting), + publicationEntry(pid: 2, power: 100, status: .stale), + publicationEntry(pid: 3, power: nil, status: .unavailable), + publicationEntry(pid: 4, power: 100, status: .stable), + publicationEntry(pid: 5, power: 100, status: .partial), + ], + at: .nan, + limit: 20 + ) + let byPID = Dictionary(uniqueKeysWithValues: rows.map { + ($0.processIdentifier, $0) + }) + + XCTAssertEqual(try XCTUnwrap(byPID[1]).status, .collecting) + XCTAssertEqual(try XCTUnwrap(byPID[2]).status, .stale) + XCTAssertEqual(try XCTUnwrap(byPID[2]).currentPowerMicrowatts, 100) + XCTAssertEqual(try XCTUnwrap(byPID[3]).status, .unavailable) + XCTAssertEqual(try XCTUnwrap(byPID[4]).status, .unavailable) + XCTAssertNil(try XCTUnwrap(byPID[4]).currentPowerMicrowatts) + XCTAssertEqual(try XCTUnwrap(byPID[5]).status, .unavailable) + XCTAssertNil(try XCTUnwrap(byPID[5]).currentPowerMicrowatts) + } + + func testDuplicateGenerationMakesOnlyTheLaterDuplicateUnavailable() throws { + var state = EnergyImpactPublicationState() + + let rows = state.publish( + [ + publicationEntry(pid: 101, power: 10, startTime: 10), + publicationEntry(pid: 101, power: 20, startTime: 10), + ], + at: 3, + limit: 20 + ) + + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows.filter { $0.status == .stable }.count, 1) + let unavailable = try XCTUnwrap(rows.first { $0.status == .unavailable }) + XCTAssertNil(unavailable.currentPowerMicrowatts) + XCTAssertNil(unavailable.sustainedPowerMicrowatts) + XCTAssertNil(unavailable.rankingScore) + } + + func testNilSmoothingOutputPublishesUnavailable() throws { + var state = EnergyImpactPublicationState() + + let rows = state.publish( + [publicationEntry(power: 100)], + at: 3, + limit: 20, + smoothingOverrideForTesting: { _, _, _ in nil } + ) + + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row.status, .unavailable) + XCTAssertNil(row.currentPowerMicrowatts) + XCTAssertNil(row.sustainedPowerMicrowatts) + XCTAssertNil(row.rankingScore) + } + + func testLongGapResetsPriorSmoothingState() throws { + var state = EnergyImpactPublicationState() + _ = state.publish([publicationEntry(power: 0)], at: 3, limit: 20) + _ = state.publish([publicationEntry(power: 100)], at: 6, limit: 20) + + let afterGap = state.publish([publicationEntry(power: 0)], at: 17, limit: 20) + + XCTAssertEqual(try XCTUnwrap(afterGap.first?.currentPowerMicrowatts), 0) + } + + func testRecoveryAfterStaleSeriesBeyondMaximumGapStartsFreshEMA() throws { + var state = EnergyImpactPublicationState() + _ = state.publish([publicationEntry(power: 100)], at: 3, limit: 20) + _ = state.publish( + [publicationEntry(power: 100, status: .stale)], + at: 6, + limit: 20 + ) + _ = state.publish( + [publicationEntry(power: 100, status: .stale)], + at: 9, + limit: 20 + ) + _ = state.publish( + [publicationEntry(power: 100, status: .stale)], + at: 12, + limit: 20 + ) + + let recovered = state.publish( + [publicationEntry(power: 0)], + at: 15, + limit: 20 + ) + + XCTAssertEqual(try XCTUnwrap(recovered.first?.status), .stable) + XCTAssertEqual(try XCTUnwrap(recovered.first?.currentPowerMicrowatts), 0) + } + + func testPIDReuseStartsReplacementGenerationFromItsOwnRawValue() throws { + var state = EnergyImpactPublicationState() + _ = state.publish( + [publicationEntry(power: 0, startTime: 10)], + at: 3, + limit: 20 + ) + _ = state.publish( + [publicationEntry(power: 100, startTime: 10)], + at: 6, + limit: 20 + ) + + let replacement = state.publish( + [publicationEntry(power: 5, startTime: 20)], + at: 9, + limit: 20 + ) + + XCTAssertEqual(try XCTUnwrap(replacement.first?.identity.rootProcessStartAbsoluteTime), 20) + XCTAssertEqual(try XCTUnwrap(replacement.first?.currentPowerMicrowatts), 5) + } + + func testMissingGenerationPublishesRawValueWithoutRetainingEMAState() throws { + var state = EnergyImpactPublicationState() + _ = state.publish( + [publicationEntry(power: 0, startTime: nil)], + at: 3, + limit: 20 + ) + _ = state.publish( + [publicationEntry(power: 100, startTime: nil)], + at: 6, + limit: 20 + ) + + let third = state.publish( + [publicationEntry(power: 0, startTime: nil)], + at: 9, + limit: 20 + ) + + XCTAssertEqual(try XCTUnwrap(third.first?.currentPowerMicrowatts), 0) + } +} + +private func publicationEntry( + pid: pid_t = 101, + power: Double?, + startTime: UInt64? = 1, + status: EnergyImpactStatus = .stable +) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: pid, + rootProcessStartAbsoluteTime: startTime + ), + name: "App \(pid)", + bundleIdentifier: "com.example.app-\(pid)", + bundleURL: nil, + currentPowerMicrowatts: power, + sustainedPowerMicrowatts: power, + rankingScore: power, + trend: .steady, + coverage: EnergyImpactCoverage( + discoveredProcessCount: 1, + readableProcessCount: 1, + validProcessSeconds: status == .stable || status == .partial ? 3 : 0, + discoveredProcessSeconds: 3 + ), + status: status + ) +} From a31bd63d0b68886cd14872a233bc9c84a4962999 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sun, 9 Aug 2026 09:14:02 +1000 Subject: [PATCH 2/4] refactor(energy): add coherent sampler sessions --- .../Models/EnergyImpactModel.swift | 225 +-- .../Providers/EnergyImpactProcessReader.swift | 19 + .../Providers/EnergyImpactProvider.swift | 163 +- .../EnergyImpactPublicationState.swift | 2 +- .../Providers/EnergyImpactSampler.swift | 594 ++---- .../Metrics/Providers/EnergyImpactTypes.swift | 41 +- .../EnergyImpactModelTests.swift | 416 ++-- .../EnergyImpactViewTests.swift | 34 +- .../EnergyImpactNativeValidationTests.swift | 22 +- .../EnergyImpactProviderTests.swift | 299 ++- .../EnergyImpactSamplerTests.swift | 1696 ++++++++++------- 11 files changed, 1819 insertions(+), 1692 deletions(-) diff --git a/Sources/MacActivityApp/Models/EnergyImpactModel.swift b/Sources/MacActivityApp/Models/EnergyImpactModel.swift index 7f8443d..031ca8e 100644 --- a/Sources/MacActivityApp/Models/EnergyImpactModel.swift +++ b/Sources/MacActivityApp/Models/EnergyImpactModel.swift @@ -4,14 +4,15 @@ import MacActivityCore @MainActor protocol EnergyImpactProviding: AnyObject { - func beginSession() async -> EnergyImpactSessionID - func sample( - sessionID: EnergyImpactSessionID, + func beginSession() async -> EnergyImpactSamplingLease? + + func observe( + lease: EnergyImpactSamplingLease, limit: Int, - scope: EnergyImpactAppScope, - publicationBoundary: Bool + scope: EnergyImpactAppScope ) async -> [EnergyImpactEntry]? - func endSession(_ sessionID: EnergyImpactSessionID) async + + func endSession(_ lease: EnergyImpactSamplingLease) async } extension EnergyImpactService: EnergyImpactProviding {} @@ -23,165 +24,129 @@ final class EnergyImpactModel: ObservableObject { private let provider: any EnergyImpactProviding private let limit: Int - private let sampleIntervalNanoseconds: UInt64 - private let publicationIntervalNanoseconds: UInt64 - private let sleep: @MainActor (UInt64) async throws -> Void - + private let initialWindowNanoseconds: UInt64 + private let sleep: (UInt64) async throws -> Void private var activeRunID: UUID? - private var activeSessionID: EnergyImpactSessionID? - private var providerRequestInFlight = false - private var providerRequestWaiters = [CheckedContinuation]() init( provider: any EnergyImpactProviding = EnergyImpactService(), limit: Int = 20, - sampleIntervalNanoseconds: UInt64 = 1_000_000_000, - publicationIntervalNanoseconds: UInt64 = 3_000_000_000, - sleep: @escaping @MainActor (UInt64) async throws -> Void = { + initialWindowNanoseconds: UInt64 = 3_000_000_000, + sleep: @escaping (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) } ) { self.provider = provider self.limit = limit - self.sampleIntervalNanoseconds = sampleIntervalNanoseconds - self.publicationIntervalNanoseconds = publicationIntervalNanoseconds + self.initialWindowNanoseconds = initialWindowNanoseconds self.sleep = sleep } - func refreshWhileVisible() async { - let runID = UUID() - activeRunID = runID - activeSessionID = nil - isRefreshing = true - - guard Task.isCancelled == false else { - clearRunIfCurrent(runID) + func refresh() async { + let runID = beginRun() + guard canContinue(runID) else { + finishIfCurrent(runID) return } - guard let sessionID = await beginProviderSession(for: runID), - isCurrent(runID), - Task.isCancelled == false else { - clearRunIfCurrent(runID) - return - } - activeSessionID = sessionID - - guard case let .sampled(initial) = await sampleProvider( - for: runID, - sessionID: sessionID, - publicationBoundary: false - ) else { - await endProviderSessionIfCurrent(sessionID, for: runID) - clearRunIfCurrent(runID) + await performRun(runID, refreshIntervalNanoseconds: nil) + } + + func refreshWhileVisible( + refreshIntervalNanoseconds: UInt64 = 3_000_000_000 + ) async { + let runID = beginRun() + guard canContinue(runID) else { + finishIfCurrent(runID) return } + await performRun( + runID, + refreshIntervalNanoseconds: refreshIntervalNanoseconds + ) + } - var latest = initial - var elapsedSincePublication: UInt64 = 0 - do { - while isCurrent(runID), Task.isCancelled == false { - try await sleep(sampleIntervalNanoseconds) - guard isCurrent(runID), Task.isCancelled == false else { break } + private func beginRun() -> UUID { + let runID = UUID() + activeRunID = runID + isRefreshing = true + return runID + } - let nextElapsed = elapsedSincePublication.addingReportingOverflow( - sampleIntervalNanoseconds - ) - let willPublish = nextElapsed.overflow - || nextElapsed.partialValue >= publicationIntervalNanoseconds - guard case let .sampled(sampled) = await sampleProvider( - for: runID, - sessionID: sessionID, - publicationBoundary: willPublish - ) else { break } - latest = sampled - guard isCurrent(runID), Task.isCancelled == false else { break } - - elapsedSincePublication = nextElapsed.partialValue - if willPublish { - entries = latest - isRefreshing = false - elapsedSincePublication = 0 - } + private func performRun( + _ runID: UUID, + refreshIntervalNanoseconds: UInt64? + ) async { + let lease = await provider.beginSession() + guard canContinue(runID), let lease else { + if let lease { + await provider.endSession(lease) } - } catch is CancellationError { - // Hiding the page normally cancels its view task. - } catch { - // Keep current rows visible. The next page appearance starts a fresh session. + finishIfCurrent(runID) + return } - await endProviderSessionIfCurrent(sessionID, for: runID) - clearRunIfCurrent(runID) - } - - private func beginProviderSession(for runID: UUID) async -> EnergyImpactSessionID? { - await acquireProviderRequestGate() - defer { releaseProviderRequestGate() } - guard isCurrent(runID), Task.isCancelled == false else { return nil } - return await provider.beginSession() - } + var remainsActive = await observeAndPublish( + lease: lease, + runID: runID + ) + if remainsActive { + remainsActive = await sleepAndObserve( + initialWindowNanoseconds, + lease: lease, + runID: runID + ) + } - private func sampleProvider( - for runID: UUID, - sessionID: EnergyImpactSessionID, - publicationBoundary: Bool - ) async -> ProviderSampleResult { - await acquireProviderRequestGate() - defer { releaseProviderRequestGate() } - guard isCurrent(runID), - activeSessionID == sessionID, - Task.isCancelled == false else { return .stopped } - guard let sampled = await provider.sample( - sessionID: sessionID, - limit: limit, - scope: .regularOnly, - publicationBoundary: publicationBoundary - ), isCurrent(runID), Task.isCancelled == false else { - return .stopped + if let refreshIntervalNanoseconds { + while remainsActive { + remainsActive = await sleepAndObserve( + refreshIntervalNanoseconds, + lease: lease, + runID: runID + ) + } } - return .sampled(sampled) - } - private func endProviderSessionIfCurrent( - _ sessionID: EnergyImpactSessionID, - for runID: UUID - ) async { - await acquireProviderRequestGate() - defer { releaseProviderRequestGate() } - guard isCurrent(runID), activeSessionID == sessionID else { return } - await provider.endSession(sessionID) + await provider.endSession(lease) + finishIfCurrent(runID) } - private func acquireProviderRequestGate() async { - guard providerRequestInFlight else { - providerRequestInFlight = true - return - } - await withCheckedContinuation { continuation in - providerRequestWaiters.append(continuation) + private func sleepAndObserve( + _ duration: UInt64, + lease: EnergyImpactSamplingLease, + runID: UUID + ) async -> Bool { + do { + try await sleep(duration) + } catch { + return false } + guard canContinue(runID) else { return false } + return await observeAndPublish(lease: lease, runID: runID) } - private func releaseProviderRequestGate() { - guard providerRequestWaiters.isEmpty == false else { - providerRequestInFlight = false - return - } - providerRequestWaiters.removeFirst().resume() + private func observeAndPublish( + lease: EnergyImpactSamplingLease, + runID: UUID + ) async -> Bool { + let observed = await provider.observe( + lease: lease, + limit: limit, + scope: .regularOnly + ) + guard canContinue(runID), let observed else { return false } + guard canContinue(runID) else { return false } + entries = observed + return true } - private func isCurrent(_ runID: UUID) -> Bool { - activeRunID == runID + private func canContinue(_ runID: UUID) -> Bool { + Task.isCancelled == false && activeRunID == runID } - private func clearRunIfCurrent(_ runID: UUID) { - guard isCurrent(runID) else { return } + private func finishIfCurrent(_ runID: UUID) { + guard activeRunID == runID else { return } activeRunID = nil - activeSessionID = nil isRefreshing = false } } - -private enum ProviderSampleResult { - case sampled([EnergyImpactEntry]) - case stopped -} diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProcessReader.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProcessReader.swift index 58e7fcc..6437f38 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProcessReader.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProcessReader.swift @@ -1,6 +1,25 @@ import Darwin import Foundation +public struct ProcessEnergyReading: Equatable, Sendable { + public let energyNanojoules: UInt64 + public let processStartAbsoluteTime: UInt64 + public let userCPUTime: UInt64 + public let systemCPUTime: UInt64 + + public init( + energyNanojoules: UInt64, + processStartAbsoluteTime: UInt64 = 0, + userCPUTime: UInt64 = 0, + systemCPUTime: UInt64 = 0 + ) { + self.energyNanojoules = energyNanojoules + self.processStartAbsoluteTime = processStartAbsoluteTime + self.userCPUTime = userCPUTime + self.systemCPUTime = systemCPUTime + } +} + public enum ProcessEnergyReadFailure: Equatable, Sendable { case exited case permissionDenied diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index 0776c0a..c797fbb 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -24,100 +24,54 @@ public struct EnergyImpactAppSnapshot: Equatable, Sendable { } } -public struct ProcessEnergyReading: Equatable, Sendable { - public let energyNanojoules: UInt64 - public let processStartAbsoluteTime: UInt64 - public let userCPUTime: UInt64 - public let systemCPUTime: UInt64 - - public init( - energyNanojoules: UInt64, - processStartAbsoluteTime: UInt64 = 0, - userCPUTime: UInt64 = 0, - systemCPUTime: UInt64 = 0 - ) { - self.energyNanojoules = energyNanojoules - self.processStartAbsoluteTime = processStartAbsoluteTime - self.userCPUTime = userCPUTime - self.systemCPUTime = systemCPUTime - } -} - @MainActor -public protocol EnergyImpactAppCataloging: AnyObject { - func snapshots(scope: EnergyImpactAppScope) -> [EnergyImpactAppSnapshot] +protocol EnergyImpactAppCataloging: AnyObject { + func snapshots( + scope: EnergyImpactAppScope + ) -> [EnergyImpactAppSnapshot] } @MainActor -public final class SystemEnergyImpactAppCatalog: EnergyImpactAppCataloging { - private let snapshotProvider: @MainActor () -> [EnergyImpactAppSnapshot] - private let nowSeconds: @MainActor () -> TimeInterval - private let refreshIntervalSeconds: TimeInterval - private var cachedSnapshots: [EnergyImpactAppSnapshot]? - private var lastRefreshTime: TimeInterval? +private final class SystemEnergyImpactAppCatalog: EnergyImpactAppCataloging { + private let workspace: NSWorkspace - public init(workspace: NSWorkspace = .shared) { - snapshotProvider = { - Self.appSnapshots(from: workspace.runningApplications) - } - nowSeconds = { ProcessInfo.processInfo.systemUptime } - refreshIntervalSeconds = EnergyImpactConfiguration.production.publicationIntervalSeconds + init(workspace: NSWorkspace = .shared) { + self.workspace = workspace } - init( - snapshotProvider: @escaping @MainActor () -> [EnergyImpactAppSnapshot], - nowSeconds: @escaping @MainActor () -> TimeInterval, - refreshIntervalSeconds: TimeInterval - ) { - self.snapshotProvider = snapshotProvider - self.nowSeconds = nowSeconds - self.refreshIntervalSeconds = refreshIntervalSeconds - } - - public func snapshots(scope: EnergyImpactAppScope) -> [EnergyImpactAppSnapshot] { - let now = nowSeconds() - if shouldRefresh(at: now) { - cachedSnapshots = snapshotProvider() - lastRefreshTime = now - } - let snapshots = cachedSnapshots ?? [] - guard scope == .regularOnly else { return snapshots } - return snapshots.filter { $0.kind == .regular } - } - - private func shouldRefresh(at now: TimeInterval) -> Bool { - guard cachedSnapshots != nil, - let lastRefreshTime, - refreshIntervalSeconds > 0, - now.isFinite, - lastRefreshTime.isFinite else { - return true - } - let age = now - lastRefreshTime - return age < 0 || age >= refreshIntervalSeconds - } - - private static func appSnapshots( - from runningApplications: [NSRunningApplication] + func snapshots( + scope: EnergyImpactAppScope ) -> [EnergyImpactAppSnapshot] { - var seen = Set() - return runningApplications - .filter { - $0.processIdentifier > 0 - && ($0.activationPolicy == .regular || $0.activationPolicy == .accessory) + var seenProcessIdentifiers = Set() + return workspace.runningApplications.compactMap { application in + let processIdentifier = application.processIdentifier + guard processIdentifier > 0, + seenProcessIdentifiers.insert(processIdentifier).inserted else { + return nil } - .filter { seen.insert($0.processIdentifier).inserted } - .map { - EnergyImpactAppSnapshot( - processIdentifier: $0.processIdentifier, - name: $0.localizedName - ?? $0.bundleIdentifier - ?? "Process \($0.processIdentifier)", - bundleIdentifier: $0.bundleIdentifier, - bundleURL: $0.bundleURL, - kind: $0.activationPolicy == .accessory ? .accessory : .regular - ) + + let kind: EnergyImpactAppKind + switch application.activationPolicy { + case .regular: + kind = .regular + case .accessory where scope == .regularAndAccessory: + kind = .accessory + case .accessory, .prohibited: + return nil + @unknown default: + return nil } + + return EnergyImpactAppSnapshot( + processIdentifier: processIdentifier, + name: application.localizedName + ?? application.bundleIdentifier + ?? "Process \(processIdentifier)", + bundleIdentifier: application.bundleIdentifier, + bundleURL: application.bundleURL, + kind: kind + ) + } } } @@ -125,35 +79,46 @@ public final class SystemEnergyImpactAppCatalog: EnergyImpactAppCataloging { public final class EnergyImpactService { private let catalog: any EnergyImpactAppCataloging private let sampler: any EnergyImpactSampling + private var nextRequestGeneration: UInt64 = 0 - public init( - catalog: any EnergyImpactAppCataloging = SystemEnergyImpactAppCatalog(), - sampler: any EnergyImpactSampling = EnergyImpactSampler() + public init() { + catalog = SystemEnergyImpactAppCatalog() + sampler = EnergyImpactSampler() + } + + init( + catalog: any EnergyImpactAppCataloging, + sampler: any EnergyImpactSampling ) { self.catalog = catalog self.sampler = sampler } - public func beginSession() async -> EnergyImpactSessionID { - await sampler.beginSession() + public func beginSession() async -> EnergyImpactSamplingLease? { + guard nextRequestGeneration < UInt64.max else { return nil } + nextRequestGeneration += 1 + let request = EnergyImpactSessionRequest( + generation: nextRequestGeneration + ) + return await sampler.beginSession(request) } - public func sample( - sessionID: EnergyImpactSessionID, + public func observe( + lease: EnergyImpactSamplingLease, limit: Int, - scope: EnergyImpactAppScope = .regularOnly, - publicationBoundary: Bool + scope: EnergyImpactAppScope = .regularOnly ) async -> [EnergyImpactEntry]? { let apps = catalog.snapshots(scope: scope) - return await sampler.sample( - sessionID: sessionID, + return await sampler.observe( + lease: lease, apps: apps, - limit: limit, - publicationBoundary: publicationBoundary + limit: limit ) } - public func endSession(_ sessionID: EnergyImpactSessionID) async { - await sampler.endSession(sessionID) + public func endSession( + _ lease: EnergyImpactSamplingLease + ) async { + await sampler.endSession(lease) } } diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactPublicationState.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactPublicationState.swift index 5d25d0e..4159180 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactPublicationState.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactPublicationState.swift @@ -126,7 +126,7 @@ public struct EnergyImpactPublicationState: Sendable { if let lastValidObservationTime = lastValidObservationTimes[generation] { elapsedSeconds = publicationTime - lastValidObservationTime } else { - elapsedSeconds = configuration.publicationIntervalSeconds + elapsedSeconds = configuration.observationIntervalSeconds } guard elapsedSeconds.isFinite, elapsedSeconds > 0 else { return Self.nonnumericUnavailable(sanitized) diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactSampler.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactSampler.swift index d5ffdde..cdb499e 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactSampler.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactSampler.swift @@ -1,44 +1,20 @@ import Darwin import Foundation -public struct EnergyImpactSessionID: Hashable, Sendable { - private let value: UUID - - public init() { - value = UUID() - } -} - -public protocol EnergyImpactSampling: Sendable { - func beginSession() async -> EnergyImpactSessionID - func sample( - sessionID: EnergyImpactSessionID, - apps: [EnergyImpactAppSnapshot], - limit: Int, - publicationBoundary: Bool - ) async -> [EnergyImpactEntry]? - func endSession(_ sessionID: EnergyImpactSessionID) async -} - -public actor EnergyImpactSampler: EnergyImpactSampling { +actor EnergyImpactSampler: EnergyImpactSampling { private let reader: any ProcessEnergyReadingProvider private let processSnapshotReader: any ProcessParentSnapshotReading private let clock: any EnergyImpactClock private let configuration: EnergyImpactConfiguration - private let processSnapshotRefreshIntervalSeconds: TimeInterval - private let minimumProcessReadIntervalSeconds: TimeInterval - private let smoothingOverrideForTesting: (@Sendable ( - EnergyImpactProcessIdentity, - Double, - TimeInterval - ) -> Double?)? - - private var activeSessionID: EnergyImpactSessionID? + private var highestSeenRequestGeneration: UInt64 = 0 + private var activeLease: EnergyImpactSamplingLease? private var state: EnergyImpactSamplerState - public init( - reader: any ProcessEnergyReadingProvider = SystemProcessEnergyReader(), - processSnapshotReader: any ProcessParentSnapshotReading = SystemProcessParentSnapshotReader(), + init( + reader: any ProcessEnergyReadingProvider = + SystemProcessEnergyReader(), + processSnapshotReader: any ProcessParentSnapshotReading = + SystemProcessParentSnapshotReader(), clock: any EnergyImpactClock = SystemEnergyImpactClock(), configuration: EnergyImpactConfiguration = .production ) { @@ -46,166 +22,156 @@ public actor EnergyImpactSampler: EnergyImpactSampling { self.processSnapshotReader = processSnapshotReader self.clock = clock self.configuration = configuration - processSnapshotRefreshIntervalSeconds = configuration.publicationIntervalSeconds - minimumProcessReadIntervalSeconds = configuration.sampleIntervalSeconds * 2 - smoothingOverrideForTesting = nil - state = EnergyImpactSamplerState(configuration: configuration) - } - - init( - reader: any ProcessEnergyReadingProvider, - processSnapshotReader: any ProcessParentSnapshotReading, - clock: any EnergyImpactClock, - configuration: EnergyImpactConfiguration, - processSnapshotRefreshIntervalSeconds: TimeInterval, - minimumProcessReadIntervalSeconds: TimeInterval - ) { - self.reader = reader - self.processSnapshotReader = processSnapshotReader - self.clock = clock - self.configuration = configuration - self.processSnapshotRefreshIntervalSeconds = processSnapshotRefreshIntervalSeconds - self.minimumProcessReadIntervalSeconds = minimumProcessReadIntervalSeconds - smoothingOverrideForTesting = nil - state = EnergyImpactSamplerState(configuration: configuration) - } - - init( - reader: any ProcessEnergyReadingProvider, - processSnapshotReader: any ProcessParentSnapshotReading, - clock: any EnergyImpactClock, - configuration: EnergyImpactConfiguration, - smoothingOverrideForTesting: @escaping @Sendable ( - EnergyImpactProcessIdentity, - Double, - TimeInterval - ) -> Double? - ) { - self.reader = reader - self.processSnapshotReader = processSnapshotReader - self.clock = clock - self.configuration = configuration - processSnapshotRefreshIntervalSeconds = configuration.publicationIntervalSeconds - minimumProcessReadIntervalSeconds = configuration.sampleIntervalSeconds * 2 - self.smoothingOverrideForTesting = smoothingOverrideForTesting - state = EnergyImpactSamplerState(configuration: configuration) + self.state = EnergyImpactSamplerState(configuration: configuration) } - public func beginSession() async -> EnergyImpactSessionID { - let sessionID = EnergyImpactSessionID() - activeSessionID = sessionID + func beginSession( + _ request: EnergyImpactSessionRequest + ) -> EnergyImpactSamplingLease? { + guard request.generation > highestSeenRequestGeneration else { + return nil + } + highestSeenRequestGeneration = request.generation + let lease = EnergyImpactSamplingLease( + requestGeneration: request.generation + ) + activeLease = lease state = EnergyImpactSamplerState(configuration: configuration) - return sessionID + return lease } - public func sample( - sessionID: EnergyImpactSessionID, + func observe( + lease: EnergyImpactSamplingLease, apps: [EnergyImpactAppSnapshot], - limit: Int, - publicationBoundary: Bool - ) async -> [EnergyImpactEntry]? { - guard canContinue(sessionID) else { return nil } - - var localState = state - let sampleTime = clock.nowSeconds() - let rootProcessIdentifiers = apps.map(\.processIdentifier) - let rootProcessIdentifierSet = Set(rootProcessIdentifiers) - let shouldReadProcesses = localState.shouldReadProcesses( - at: sampleTime, - rootProcessIdentifiers: rootProcessIdentifierSet, - minimumIntervalSeconds: minimumProcessReadIntervalSeconds + limit: Int + ) -> [EnergyImpactEntry]? { + guard canContinue(lease) else { return nil } + var working = state + let capturedAt = clock.nowSeconds() + guard canContinue(lease) else { return nil } + + let processSnapshots = processSnapshotReader.snapshots() + guard canContinue(lease) else { return nil } + let owners = EnergyImpactOwnership.nearestRootOwners( + rootProcessIdentifiers: apps.map(\.processIdentifier), + snapshots: processSnapshots ) - if shouldReadProcesses == false, publicationBoundary == false { - guard let cached = localState.cachedRawEntries, - canContinue(sessionID) else { return nil } - return Self.sortedByImpact( - Self.updatingMetadata(in: cached, from: apps), - limit: limit + let processIdentifiers = Set(owners.keys) + .union(apps.map(\.processIdentifier)) + .filter { $0 > 0 } + .sorted() + var readingsByProcessIdentifier: [pid_t: ProcessEnergyReadResult] = [:] + readingsByProcessIdentifier.reserveCapacity(processIdentifiers.count) + for processIdentifier in processIdentifiers { + guard canContinue(lease) else { return nil } + readingsByProcessIdentifier[processIdentifier] = reader.reading( + for: processIdentifier ) } + guard canContinue(lease) else { return nil } + + let observation = EnergyImpactObservation( + sequence: working.sequence &+ 1, + capturedAt: capturedAt, + apps: apps, + processSnapshots: processSnapshots, + owners: owners, + readingsByProcessIdentifier: readingsByProcessIdentifier + ) + guard let candidates = buildCandidates( + from: observation, + lease: lease, + working: &working + ) else { + return nil + } + let published = working.publicationState.publish( + candidates, + at: observation.capturedAt, + limit: limit + ) + guard canContinue(lease) else { return nil } + working.sequence = observation.sequence + state = working + return published + } - let observationInterval = shouldReadProcesses - ? localState.prepareForSample(at: sampleTime, configuration: configuration) - : nil - guard canContinue(sessionID) else { return nil } - let processIdentifiersByRoot: [pid_t: [pid_t]] - if localState.shouldRefreshProcessSnapshots( - at: sampleTime, - rootProcessIdentifiers: rootProcessIdentifierSet, - publicationBoundary: publicationBoundary, - refreshIntervalSeconds: processSnapshotRefreshIntervalSeconds - ) { - let processSnapshots = processSnapshotReader.snapshots() - guard canContinue(sessionID) else { return nil } - let owners = EnergyImpactOwnership.nearestRootOwners( - rootProcessIdentifiers: rootProcessIdentifiers, - snapshots: processSnapshots - ) - localState.invalidateObservedOwnerTransitions( - snapshots: processSnapshots, - owners: owners - ) - processIdentifiersByRoot = Dictionary( - grouping: owners.keys, - by: { owners[$0]! } - ) - localState.storeProcessOwnership( - snapshots: processSnapshots, - processIdentifiersByRoot: processIdentifiersByRoot, - rootProcessIdentifiers: rootProcessIdentifierSet, - at: sampleTime - ) + func endSession(_ lease: EnergyImpactSamplingLease) { + guard activeLease == lease else { return } + activeLease = nil + state = EnergyImpactSamplerState(configuration: configuration) + } + + private func buildCandidates( + from observation: EnergyImpactObservation, + lease: EnergyImpactSamplingLease, + working: inout EnergyImpactSamplerState + ) -> [EnergyImpactEntry]? { + let observationInterval: Range? + let breaksBaselineContinuity: Bool + if let previousObservationTime = working.previousObservationTime { + let elapsed = observation.capturedAt - previousObservationTime + observationInterval = elapsed > 0 + && elapsed <= configuration.maximumGapSeconds + ? previousObservationTime.. previous.reading.userCPUTime - || current.systemCPUTime > previous.reading.systemCPUTime { + (current.userCPUTime > previous.reading.userCPUTime + || current.systemCPUTime > previous.reading.systemCPUTime) { zeroEnergyWithCPUActivitySeconds += elapsed if zeroEnergyWithCPUActivitySeconds >= configuration.maximumGapSeconds { counterUnsupported = true @@ -267,7 +234,7 @@ public actor EnergyImpactSampler: EnergyImpactSampling { processIdentity: identity, ownerRootProcessIdentifier: app.processIdentifier, startTimeSeconds: previous.sampleTime, - endTimeSeconds: sampleTime, + endTimeSeconds: observation.capturedAt, energyMicrojoules: Double( current.energyNanojoules - previous.reading.energyNanojoules ) / 1_000.0 @@ -278,10 +245,10 @@ public actor EnergyImpactSampler: EnergyImpactSampling { counterUnsupported = false } - localState.baselines[identity] = ProcessEnergyBaseline( + working.baselines[identity] = ProcessEnergyBaseline( reading: current, - sampleTime: sampleTime, - lastObservedAt: sampleTime, + sampleTime: observation.capturedAt, + lastObservedAt: observation.capturedAt, ownerRootProcessIdentifier: app.processIdentifier, zeroEnergyWithCPUActivitySeconds: zeroEnergyWithCPUActivitySeconds, counterUnsupported: counterUnsupported @@ -294,8 +261,7 @@ public actor EnergyImpactSampler: EnergyImpactSampling { } } - guard canContinue(sessionID) else { return nil } - let identity = localState.currentIdentityByRootProcessIdentifier[app.processIdentifier] + let identity = working.currentIdentityByRootProcessIdentifier[app.processIdentifier] ?? EnergyImpactAppIdentity( rootProcessIdentifier: app.processIdentifier, rootProcessStartAbsoluteTime: nil @@ -308,8 +274,10 @@ public actor EnergyImpactSampler: EnergyImpactSampling { let observationDuration = observationInterval.map { $0.upperBound - $0.lowerBound } ?? 0 - let discoveredProcessSeconds = Double(processIdentifiers.count) * observationDuration - let currentPowerMicrowatts = validProcessSeconds > 0 && observationDuration > 0 + let discoveredProcessSeconds = Double(processIdentifiers.count) + * observationDuration + let currentPowerMicrowatts = validProcessSeconds > 0 + && observationDuration > 0 ? energyMicrojoules / observationDuration : nil let currentCoverage = EnergyImpactCoverage( @@ -318,8 +286,10 @@ public actor EnergyImpactSampler: EnergyImpactSampling { validProcessSeconds: validProcessSeconds, discoveredProcessSeconds: discoveredProcessSeconds ) - let previousDisplay = localState.displayByIdentity[identity] - let displayAge = previousDisplay.map { sampleTime - $0.sampleTime } + let previousDisplay = working.displayByIdentity[identity] + let displayAge = previousDisplay.map { + observation.capturedAt - $0.sampleTime + } let canPublishStale = displayAge.map { $0 >= 0 && $0 <= configuration.maximumGapSeconds } == true @@ -342,10 +312,14 @@ public actor EnergyImpactSampler: EnergyImpactSampling { status = .unavailable } - let publishedIdentity = status == .stale ? previousDisplay!.entry.identity : identity + let publishedIdentity = status == .stale + ? previousDisplay!.entry.identity + : identity let publishedPower = status == .stale ? previousDisplay!.entry.currentPowerMicrowatts - : (status == .stable || status == .partial ? currentPowerMicrowatts : nil) + : (status == .stable || status == .partial + ? currentPowerMicrowatts + : nil) let publishedCoverage = status == .stale ? previousDisplay!.entry.coverage : currentCoverage @@ -357,115 +331,44 @@ public actor EnergyImpactSampler: EnergyImpactSampling { kind: app.kind, currentPowerMicrowatts: publishedPower, sustainedPowerMicrowatts: nil, - rankingScore: status == .stable || status == .partial ? publishedPower : nil, + rankingScore: status == .stable || status == .partial + ? publishedPower + : nil, trend: .steady, coverage: publishedCoverage, status: status ) - if (status == .stable || status == .partial), publishedIdentity.generation != nil { - localState.displayByIdentity[publishedIdentity] = EnergyImpactDisplayState( + if (status == .stable || status == .partial), + publishedIdentity.generation != nil { + working.displayByIdentity[publishedIdentity] = EnergyImpactDisplayState( entry: entry, - sampleTime: sampleTime + sampleTime: observation.capturedAt ) } else if allProcessesUnsupported { - localState.displayByIdentity.removeValue(forKey: identity) - } - entries.append(entry) - } - - guard canContinue(sessionID) else { return nil } - localState.prune(at: sampleTime, configuration: configuration) - localState.storeRawEntries(entries, rootProcessIdentifiers: rootProcessIdentifierSet) - let result = publicationBoundary - ? localState.publish( - entries, - at: sampleTime, - limit: limit, - smoothingOverrideForTesting: smoothingOverrideForTesting - ) - : Self.sortedByImpact(entries, limit: limit) - - guard canContinue(sessionID) else { return nil } - state = localState - return result - } - - public func endSession(_ sessionID: EnergyImpactSessionID) async { - guard sessionID == activeSessionID else { return } - activeSessionID = nil - state = EnergyImpactSamplerState(configuration: configuration) - } - - public nonisolated static func sortedByImpact( - _ entries: [EnergyImpactEntry], - limit: Int - ) -> [EnergyImpactEntry] { - entries.sorted { lhs, rhs in - let leftBucket = statusSortBucket(for: lhs) - let rightBucket = statusSortBucket(for: rhs) - if leftBucket != rightBucket { return leftBucket < rightBucket } - - let leftScore = lhs.rankingScore ?? lhs.currentPowerMicrowatts - let rightScore = rhs.rankingScore ?? rhs.currentPowerMicrowatts - switch (leftScore, rightScore) { - case let (left?, right?) where left != right: - return left > right - case (_?, nil): - return true - case (nil, _?): - return false - default: - let nameOrder = lhs.name.localizedCaseInsensitiveCompare(rhs.name) - if nameOrder != .orderedSame { return nameOrder == .orderedAscending } - return lhs.processIdentifier < rhs.processIdentifier + working.displayByIdentity.removeValue(forKey: identity) } + candidates.append(entry) } - .prefix(max(0, limit)) - .map { $0 } - } - private func canContinue(_ sessionID: EnergyImpactSessionID) -> Bool { - Task.isCancelled == false && sessionID == activeSessionID + working.prune(at: observation.capturedAt, configuration: configuration) + return candidates } - private nonisolated static func updatingMetadata( - in entries: [EnergyImpactEntry], - from apps: [EnergyImpactAppSnapshot] - ) -> [EnergyImpactEntry] { - let appByProcessIdentifier = Dictionary( - apps.map { ($0.processIdentifier, $0) }, - uniquingKeysWith: { first, _ in first } - ) - return entries.map { entry in - guard let app = appByProcessIdentifier[entry.processIdentifier] else { - return entry - } - return EnergyImpactEntry( - identity: entry.identity, - name: app.name, - bundleIdentifier: app.bundleIdentifier, - bundleURL: app.bundleURL, - kind: app.kind, - currentPowerMicrowatts: entry.currentPowerMicrowatts, - sustainedPowerMicrowatts: entry.sustainedPowerMicrowatts, - rankingScore: entry.rankingScore, - trend: entry.trend, - coverage: entry.coverage, - status: entry.status - ) - } + private func canContinue( + _ lease: EnergyImpactSamplingLease + ) -> Bool { + Task.isCancelled == false && activeLease == lease } +} - private nonisolated static func statusSortBucket(for entry: EnergyImpactEntry) -> Int { - switch (entry.status, entry.currentPowerMicrowatts) { - case (.stable, .some), (.partial, .some): 0 - case (.stale, .some): 1 - case (.collecting, _): 2 - case (.unavailable, _): 3 - default: 3 - } - } +private struct EnergyImpactObservation: Sendable { + let sequence: UInt64 + let capturedAt: TimeInterval + let apps: [EnergyImpactAppSnapshot] + let processSnapshots: [ProcessParentSnapshot] + let owners: [pid_t: pid_t] + let readingsByProcessIdentifier: [pid_t: ProcessEnergyReadResult] } private struct EnergyImpactSamplerState: Sendable { @@ -473,72 +376,20 @@ private struct EnergyImpactSamplerState: Sendable { var identityByProcessIdentifier: [pid_t: EnergyImpactProcessIdentity] = [:] var currentIdentityByRootProcessIdentifier: [pid_t: EnergyImpactAppIdentity] = [:] var displayByIdentity: [EnergyImpactAppIdentity: EnergyImpactDisplayState] = [:] - var previousSampleTime: TimeInterval? + var previousObservationTime: TimeInterval? var publicationState: EnergyImpactPublicationState - var cachedProcessSnapshots: [ProcessParentSnapshot]? - var lastProcessSnapshotTime: TimeInterval? - var cachedRootProcessIdentifiers: Set? - var cachedProcessIdentifiersByRoot: [pid_t: [pid_t]] = [:] - var cachedRawEntries: [EnergyImpactEntry]? - var cachedRawRootProcessIdentifiers: Set? + var sequence: UInt64 = 0 init(configuration: EnergyImpactConfiguration) { publicationState = EnergyImpactPublicationState(configuration: configuration) } - mutating func prepareForSample( - at sampleTime: TimeInterval, - configuration: EnergyImpactConfiguration - ) -> Range? { - let observationInterval: Range? - if let previousSampleTime { - let elapsed = sampleTime - previousSampleTime - observationInterval = elapsed > 0 && elapsed <= configuration.maximumGapSeconds - ? previousSampleTime.., - minimumIntervalSeconds: TimeInterval - ) -> Bool { - guard cachedRawEntries != nil, - cachedRawRootProcessIdentifiers == rootProcessIdentifiers, - let previousSampleTime, - minimumIntervalSeconds > 0, - sampleTime.isFinite, - previousSampleTime.isFinite else { - return true - } - let age = sampleTime - previousSampleTime - return age < 0 || age >= minimumIntervalSeconds - } - - mutating func storeRawEntries( - _ entries: [EnergyImpactEntry], - rootProcessIdentifiers: Set - ) { - cachedRawEntries = entries - cachedRawRootProcessIdentifiers = rootProcessIdentifiers - } - mutating func prune( - at sampleTime: TimeInterval, + at observationTime: TimeInterval, configuration: EnergyImpactConfiguration ) { let expiredIdentities = baselines.compactMap { identity, baseline in - let age = sampleTime - baseline.lastObservedAt + let age = observationTime - baseline.lastObservedAt return age > configuration.maximumGapSeconds ? identity : nil } for identity in expiredIdentities { @@ -546,46 +397,16 @@ private struct EnergyImpactSamplerState: Sendable { } displayByIdentity = displayByIdentity.filter { _, display in - let age = sampleTime - display.sampleTime + let age = observationTime - display.sampleTime return age >= 0 && age <= configuration.maximumGapSeconds } identityByProcessIdentifier = identityByProcessIdentifier.filter { baselines[$0.value] != nil } - currentIdentityByRootProcessIdentifier = currentIdentityByRootProcessIdentifier.filter { - _, identity in - identity.generation.map { baselines[$0] != nil } == true - } - } - - func shouldRefreshProcessSnapshots( - at sampleTime: TimeInterval, - rootProcessIdentifiers: Set, - publicationBoundary: Bool, - refreshIntervalSeconds: TimeInterval - ) -> Bool { - guard cachedProcessSnapshots != nil, - let lastProcessSnapshotTime, - cachedRootProcessIdentifiers == rootProcessIdentifiers, - refreshIntervalSeconds > 0, - sampleTime.isFinite, - lastProcessSnapshotTime.isFinite else { - return true - } - let age = sampleTime - lastProcessSnapshotTime - return publicationBoundary || age < 0 || age >= refreshIntervalSeconds - } - - mutating func storeProcessOwnership( - snapshots: [ProcessParentSnapshot], - processIdentifiersByRoot: [pid_t: [pid_t]], - rootProcessIdentifiers: Set, - at sampleTime: TimeInterval - ) { - cachedProcessSnapshots = snapshots - cachedProcessIdentifiersByRoot = processIdentifiersByRoot - cachedRootProcessIdentifiers = rootProcessIdentifiers - lastProcessSnapshotTime = sampleTime + currentIdentityByRootProcessIdentifier = + currentIdentityByRootProcessIdentifier.filter { _, identity in + identity.generation.map { baselines[$0] != nil } == true + } } mutating func invalidateObservedOwnerTransitions( @@ -608,31 +429,6 @@ private struct EnergyImpactSamplerState: Sendable { identityByProcessIdentifier.removeValue(forKey: identity.processIdentifier) } } - - mutating func publish( - _ candidates: [EnergyImpactEntry], - at publicationTime: TimeInterval, - limit: Int, - smoothingOverrideForTesting: (@Sendable ( - EnergyImpactProcessIdentity, - Double, - TimeInterval - ) -> Double?)? - ) -> [EnergyImpactEntry] { - if let smoothingOverrideForTesting { - return publicationState.publish( - candidates, - at: publicationTime, - limit: limit, - smoothingOverrideForTesting: smoothingOverrideForTesting - ) - } - return publicationState.publish( - candidates, - at: publicationTime, - limit: limit - ) - } } private struct ProcessEnergyBaseline: Sendable { diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift index f6cfcb0..edfce82 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift @@ -80,6 +80,38 @@ public enum EnergyImpactTrend: String, Equatable, Sendable { case falling } +struct EnergyImpactSessionRequest: Hashable, Sendable { + let generation: UInt64 + + init(generation: UInt64) { + self.generation = generation + } +} + +public struct EnergyImpactSamplingLease: Hashable, Sendable { + let requestGeneration: UInt64 + let token: UUID + + init(requestGeneration: UInt64, token: UUID = UUID()) { + self.requestGeneration = requestGeneration + self.token = token + } +} + +protocol EnergyImpactSampling: Sendable { + func beginSession( + _ request: EnergyImpactSessionRequest + ) async -> EnergyImpactSamplingLease? + + func observe( + lease: EnergyImpactSamplingLease, + apps: [EnergyImpactAppSnapshot], + limit: Int + ) async -> [EnergyImpactEntry]? + + func endSession(_ lease: EnergyImpactSamplingLease) async +} + public struct EnergyImpactCoverage: Equatable, Sendable { public let discoveredProcessCount: Int public let readableProcessCount: Int @@ -184,21 +216,18 @@ public struct SystemEnergyImpactClock: EnergyImpactClock { } public struct EnergyImpactConfiguration: Equatable, Sendable { - public let sampleIntervalSeconds: TimeInterval - public let publicationIntervalSeconds: TimeInterval + public let observationIntervalSeconds: TimeInterval public let maximumGapSeconds: TimeInterval public let fastHalfLifeSeconds: TimeInterval public let sustainedWindowSeconds: TimeInterval public init( - sampleIntervalSeconds: TimeInterval = 1, - publicationIntervalSeconds: TimeInterval = 3, + observationIntervalSeconds: TimeInterval = 3, maximumGapSeconds: TimeInterval = 10, fastHalfLifeSeconds: TimeInterval = 4, sustainedWindowSeconds: TimeInterval = 30 ) { - self.sampleIntervalSeconds = sampleIntervalSeconds - self.publicationIntervalSeconds = publicationIntervalSeconds + self.observationIntervalSeconds = observationIntervalSeconds self.maximumGapSeconds = maximumGapSeconds self.fastHalfLifeSeconds = fastHalfLifeSeconds self.sustainedWindowSeconds = sustainedWindowSeconds diff --git a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift index d38094c..fd7136c 100644 --- a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift @@ -1,249 +1,313 @@ import XCTest -import MacActivityCore +@testable import MacActivityCore @testable import MacActivityApp @MainActor final class EnergyImpactModelTests: XCTestCase { - func testImmediateBaselineOneSecondCadenceAndThreeSecondPublication() async throws { - let provider = ControlledEnergyImpactProvider() - var requestedSleeps = [UInt64]() + func testRefreshUsesOneLeaseForImmediateAndSecondObservation() async throws { + let provider = ControlledEnergyImpactProvider(responses: [ + [entry(power: nil, status: .collecting)], + [entry(power: 1)], + ]) + var requestedSleeps: [UInt64] = [] let model = EnergyImpactModel( provider: provider, - sampleIntervalNanoseconds: 1, - publicationIntervalNanoseconds: 3, - sleep: { duration in - requestedSleeps.append(duration) - if requestedSleeps.count == 4 { - throw CancellationError() - } - } + initialWindowNanoseconds: 3_000_000_000, + sleep: { requestedSleeps.append($0) } ) - await model.refreshWhileVisible() + await model.refresh() - XCTAssertEqual(provider.sampleRequests.count, 4) - XCTAssertEqual(provider.requestCountBeforeFirstPublish, 4) - XCTAssertEqual(Array(requestedSleeps.prefix(3)), [1, 1, 1]) - XCTAssertEqual(model.entries.first?.name, "Run 1") - XCTAssertEqual(provider.maximumConcurrentRequests, 1) - XCTAssertTrue(provider.didEndCurrentSessionAfterCancellation) + XCTAssertEqual(provider.beginCount, 1) + XCTAssertEqual(provider.observedLeases, provider.returnedLeases + provider.returnedLeases) + XCTAssertEqual(provider.requestedLimits, [20, 20]) + XCTAssertEqual(provider.requestedScopes, [.regularOnly, .regularOnly]) + XCTAssertEqual(requestedSleeps, [3_000_000_000]) + XCTAssertEqual(try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), 1) + XCTAssertEqual(provider.endCount, 1) XCTAssertFalse(model.isRefreshing) } - func testRapidRunsSerializeProviderRequestsAndOnlyNewestRunPublishes() async { - let provider = ControlledEnergyImpactProvider(suspendFirstSample: true) - var sleepCount = 0 + func testInitialCancellationEndsEveryLeaseThatSuccessfullyBegan() async { + let provider = ControlledEnergyImpactProvider(responses: [[]]) let model = EnergyImpactModel( provider: provider, - sampleIntervalNanoseconds: 1, - publicationIntervalNanoseconds: 3, - sleep: { _ in - sleepCount += 1 - if sleepCount == 4 { - throw CancellationError() - } - } + initialWindowNanoseconds: 1, + sleep: { _ in throw CancellationError() } ) - let oldTask = Task { await model.refreshWhileVisible() } - await provider.waitUntilFirstSampleStarts() - let newTask = Task { await model.refreshWhileVisible() } - await Task.yield() - provider.resumeFirstSample() - await oldTask.value - await newTask.value - - XCTAssertEqual(provider.maximumConcurrentRequests, 1) - XCTAssertEqual(provider.beginCount, 2) - XCTAssertEqual(provider.sampleRequests.count, 5) - XCTAssertEqual(provider.endedRunNumbers, [2]) - XCTAssertEqual(model.entries.first?.name, "Run 2") + await model.refresh() + + XCTAssertEqual(provider.beginCount, 1) + XCTAssertEqual(provider.endCount, 1) + XCTAssertEqual(provider.endedLeases, provider.returnedLeases) XCTAssertFalse(model.isRefreshing) } - func testCancellationAfterSamplePreventsPublicationAndEndsCurrentSession() async { - let provider = ControlledEnergyImpactProvider(cancelOnPublicationSample: true) + func testRefreshWhileVisibleUsesOneLeaseAndAwaitsObservationsSequentially() async { + let provider = ControlledEnergyImpactProvider( + responses: [[], [], []], + cancelTaskAfterObservationCount: 3 + ) let model = EnergyImpactModel( provider: provider, - sampleIntervalNanoseconds: 1, - publicationIntervalNanoseconds: 3, - sleep: { _ in } + initialWindowNanoseconds: 1, + sleep: { _ in await Task.yield() } ) - let task = Task { await model.refreshWhileVisible() } - await task.value + await model.refreshWhileVisible(refreshIntervalNanoseconds: 3) - XCTAssertTrue(model.entries.isEmpty) - XCTAssertEqual(provider.requestCountBeforeFirstPublish, 4) - XCTAssertEqual(provider.endedRunNumbers, [1]) + XCTAssertEqual(provider.beginCount, 1) + XCTAssertEqual(provider.observeCount, 3) + XCTAssertEqual(Set(provider.observedLeases).count, 1) + XCTAssertEqual(provider.maximumConcurrentObservations, 1) + XCTAssertEqual(provider.endCount, 1) XCTAssertFalse(model.isRefreshing) } - func testThrowingSleeperEndsCurrentSession() async { - let provider = ControlledEnergyImpactProvider() + func testReplacementCannotPublishOlderCompletedObservation() async { + let provider = ReplacementRunProvider(blockOldSecondObservation: true) + let sleep = SequencedSleepController() let model = EnergyImpactModel( provider: provider, - sampleIntervalNanoseconds: 1, - publicationIntervalNanoseconds: 3, - sleep: { _ in throw SleeperFailure.expected } + sleep: { try await sleep.call($0) } ) - await model.refreshWhileVisible() + let runA = Task { await model.refresh() } + await provider.waitUntilOldSecondObservationStarts() + let runB = Task { await model.refresh() } + await sleep.waitUntilSecondSleepStarts() + + XCTAssertEqual(model.entries.first?.name, "Run B") + provider.releaseOldSecondObservation() + await runA.value + + XCTAssertEqual(model.entries.first?.name, "Run B") + await sleep.failSecondSleep() + await runB.value + } + + func testOldExitCannotClearReplacementRefreshingState() async { + let provider = ReplacementRunProvider(blockOldEnd: true) + let sleep = SequencedSleepController() + let model = EnergyImpactModel( + provider: provider, + sleep: { try await sleep.call($0) } + ) + + let runA = Task { await model.refresh() } + await provider.waitUntilOldEndStarts() + let runB = Task { await model.refresh() } + await sleep.waitUntilSecondSleepStarts() + + XCTAssertTrue(model.isRefreshing) + provider.releaseOldEnd() + await runA.value - XCTAssertEqual(provider.sampleRequests.count, 1) - XCTAssertEqual(provider.endedRunNumbers, [1]) + XCTAssertTrue(model.isRefreshing) + XCTAssertEqual(provider.endedLeases.map(\.requestGeneration), [1]) + await sleep.failSecondSleep() + await runB.value XCTAssertFalse(model.isRefreshing) + XCTAssertEqual(provider.endedLeases.map(\.requestGeneration), [1, 2]) } - func testAlreadyCancelledVisibleRunPerformsNoProviderReads() async { - let provider = ControlledEnergyImpactProvider() + func testReturnedRowsAreAssignedUnchangedWithConfiguredLimit() async { + let expected = [ + entry(pid: 303, name: "Third", power: 3), + entry(pid: 101, name: "First", power: 1), + ] + let provider = ControlledEnergyImpactProvider(responses: [[], expected]) let model = EnergyImpactModel( provider: provider, - sampleIntervalNanoseconds: 1, - publicationIntervalNanoseconds: 3, + limit: 2, + initialWindowNanoseconds: 0, sleep: { _ in } ) - let task = Task { await model.refreshWhileVisible() } - task.cancel() - await task.value + await model.refresh() - XCTAssertEqual(provider.beginCount, 0) - XCTAssertTrue(provider.sampleRequests.isEmpty) - XCTAssertTrue(provider.endedRunNumbers.isEmpty) - XCTAssertFalse(model.isRefreshing) + XCTAssertEqual(model.entries, expected) + XCTAssertEqual(provider.requestedLimits, [2, 2]) } } -private enum SleeperFailure: Error { - case expected +private func entry( + pid: pid_t = 101, + name: String? = nil, + power: Double?, + startTime: UInt64? = 1, + status: EnergyImpactStatus = .stable +) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: pid, + rootProcessStartAbsoluteTime: startTime + ), + name: name ?? "App \(pid)", + bundleIdentifier: "com.example.app-\(pid)", + bundleURL: nil, + currentPowerMicrowatts: power, + sustainedPowerMicrowatts: power, + rankingScore: power, + trend: .steady, + coverage: EnergyImpactCoverage( + discoveredProcessCount: 1, + readableProcessCount: 1, + validProcessSeconds: status == .stable || status == .partial ? 3 : 0, + discoveredProcessSeconds: 3 + ), + status: status + ) } @MainActor private final class ControlledEnergyImpactProvider: EnergyImpactProviding { - struct SampleRequest: Equatable { - let runNumber: Int - let publicationBoundary: Bool + private var responses: [[EnergyImpactEntry]] + private let cancelTaskAfterObservationCount: Int? + private(set) var beginCount = 0 + private(set) var observeCount = 0 + private(set) var endCount = 0 + private(set) var returnedLeases: [EnergyImpactSamplingLease] = [] + private(set) var observedLeases: [EnergyImpactSamplingLease] = [] + private(set) var endedLeases: [EnergyImpactSamplingLease] = [] + private(set) var requestedLimits: [Int] = [] + private(set) var requestedScopes: [EnergyImpactAppScope] = [] + private var concurrentObservations = 0 + private(set) var maximumConcurrentObservations = 0 + + init( + responses: [[EnergyImpactEntry]], + cancelTaskAfterObservationCount: Int? = nil + ) { + self.responses = responses + self.cancelTaskAfterObservationCount = cancelTaskAfterObservationCount } - private let suspendFirstSample: Bool - private let cancelOnPublicationSample: Bool - private var currentSessionID: EnergyImpactSessionID? - private var runNumberBySessionID: [EnergyImpactSessionID: Int] = [:] - private var activeRequestCount = 0 - private var firstSampleDidStart = false - private var firstSampleStartWaiters = [CheckedContinuation]() - private var firstSampleContinuation: CheckedContinuation? + func beginSession() async -> EnergyImpactSamplingLease? { + beginCount += 1 + let lease = EnergyImpactSamplingLease(requestGeneration: UInt64(beginCount)) + returnedLeases.append(lease) + return lease + } - private(set) var beginCount = 0 - private(set) var sampleRequests = [SampleRequest]() - private(set) var endedRunNumbers = [Int]() - private(set) var maximumConcurrentRequests = 0 - private(set) var requestCountBeforeFirstPublish: Int? - private(set) var didEndCurrentSessionAfterCancellation = false + func observe( + lease: EnergyImpactSamplingLease, + limit: Int, + scope: EnergyImpactAppScope + ) async -> [EnergyImpactEntry]? { + concurrentObservations += 1 + maximumConcurrentObservations = max( + maximumConcurrentObservations, + concurrentObservations + ) + await Task.yield() + concurrentObservations -= 1 + observeCount += 1 + observedLeases.append(lease) + requestedLimits.append(limit) + requestedScopes.append(scope) + if observeCount == cancelTaskAfterObservationCount { + withUnsafeCurrentTask { $0?.cancel() } + } + guard responses.isEmpty == false else { return [] } + return responses.removeFirst() + } + + func endSession(_ lease: EnergyImpactSamplingLease) async { + endCount += 1 + endedLeases.append(lease) + } +} + +@MainActor +private final class ReplacementRunProvider: EnergyImpactProviding { + private let blockOldSecondObservation: Bool + private let blockOldEnd: Bool + private var beginCount = 0 + private var observationCounts: [UInt64: Int] = [:] + private var oldSecondStarted = false + private var oldSecondContinuation: CheckedContinuation? + private var oldEndStarted = false + private var oldEndContinuation: CheckedContinuation? + private(set) var endedLeases: [EnergyImpactSamplingLease] = [] init( - suspendFirstSample: Bool = false, - cancelOnPublicationSample: Bool = false + blockOldSecondObservation: Bool = false, + blockOldEnd: Bool = false ) { - self.suspendFirstSample = suspendFirstSample - self.cancelOnPublicationSample = cancelOnPublicationSample + self.blockOldSecondObservation = blockOldSecondObservation + self.blockOldEnd = blockOldEnd } - func beginSession() async -> EnergyImpactSessionID { - enterRequest() - defer { leaveRequest() } + func beginSession() async -> EnergyImpactSamplingLease? { beginCount += 1 - let sessionID = EnergyImpactSessionID() - runNumberBySessionID[sessionID] = beginCount - currentSessionID = sessionID - return sessionID + return EnergyImpactSamplingLease(requestGeneration: UInt64(beginCount)) } - func sample( - sessionID: EnergyImpactSessionID, + func observe( + lease: EnergyImpactSamplingLease, limit: Int, - scope: EnergyImpactAppScope, - publicationBoundary: Bool + scope: EnergyImpactAppScope ) async -> [EnergyImpactEntry]? { - enterRequest() - defer { leaveRequest() } - guard let runNumber = runNumberBySessionID[sessionID], - sessionID == currentSessionID else { return nil } - sampleRequests.append(SampleRequest( - runNumber: runNumber, - publicationBoundary: publicationBoundary - )) - if publicationBoundary, requestCountBeforeFirstPublish == nil { - requestCountBeforeFirstPublish = sampleRequests.count - } - if suspendFirstSample, sampleRequests.count == 1 { - firstSampleDidStart = true - let waiters = firstSampleStartWaiters - firstSampleStartWaiters.removeAll() - waiters.forEach { $0.resume() } - await withCheckedContinuation { continuation in - firstSampleContinuation = continuation - } + let generation = lease.requestGeneration + observationCounts[generation, default: 0] += 1 + let count = observationCounts[generation, default: 0] + if generation == 1, count == 2, blockOldSecondObservation { + oldSecondStarted = true + await withCheckedContinuation { oldSecondContinuation = $0 } } - if publicationBoundary, cancelOnPublicationSample { - withUnsafeCurrentTask { $0?.cancel() } - } - return [entry(name: "Run \(runNumber)")] + let name = generation == 1 && count == 2 ? "Old A" : "Run \(generation == 1 ? "A" : "B")" + return [entry(pid: pid_t(generation), name: name, power: Double(generation))] } - func endSession(_ sessionID: EnergyImpactSessionID) async { - enterRequest() - defer { leaveRequest() } - guard let runNumber = runNumberBySessionID[sessionID] else { return } - endedRunNumbers.append(runNumber) - if currentSessionID == sessionID { - currentSessionID = nil - didEndCurrentSessionAfterCancellation = true + func endSession(_ lease: EnergyImpactSamplingLease) async { + if lease.requestGeneration == 1, blockOldEnd { + oldEndStarted = true + await withCheckedContinuation { oldEndContinuation = $0 } } + endedLeases.append(lease) + } + + func waitUntilOldSecondObservationStarts() async { + while oldSecondStarted == false { await Task.yield() } + } + + func releaseOldSecondObservation() { + oldSecondContinuation?.resume() + oldSecondContinuation = nil + } + + func waitUntilOldEndStarts() async { + while oldEndStarted == false { await Task.yield() } + } + + func releaseOldEnd() { + oldEndContinuation?.resume() + oldEndContinuation = nil } +} + +private actor SequencedSleepController { + private var callCount = 0 + private var secondSleepStarted = false + private var secondSleepContinuation: CheckedContinuation? - func waitUntilFirstSampleStarts() async { - if firstSampleDidStart { return } - await withCheckedContinuation { continuation in - firstSampleStartWaiters.append(continuation) + func call(_ duration: UInt64) async throws { + callCount += 1 + if callCount == 1 { return } + secondSleepStarted = true + try await withCheckedThrowingContinuation { continuation in + secondSleepContinuation = continuation } } - func resumeFirstSample() { - firstSampleContinuation?.resume() - firstSampleContinuation = nil - } - - private func enterRequest() { - activeRequestCount += 1 - maximumConcurrentRequests = max(maximumConcurrentRequests, activeRequestCount) - } - - private func leaveRequest() { - activeRequestCount -= 1 - } - - private func entry(name: String) -> EnergyImpactEntry { - EnergyImpactEntry( - identity: EnergyImpactAppIdentity( - rootProcessIdentifier: 101, - rootProcessStartAbsoluteTime: 10 - ), - name: name, - bundleIdentifier: "example.fixture", - bundleURL: nil, - currentPowerMicrowatts: 1, - sustainedPowerMicrowatts: nil, - rankingScore: 1, - trend: .steady, - coverage: EnergyImpactCoverage( - discoveredProcessCount: 1, - readableProcessCount: 1, - validProcessSeconds: 1, - discoveredProcessSeconds: 1 - ), - status: .stable - ) + func waitUntilSecondSleepStarts() async { + while secondSleepStarted == false { await Task.yield() } + } + + func failSecondSleep() { + secondSleepContinuation?.resume(throwing: CancellationError()) + secondSleepContinuation = nil } } diff --git a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift index 24ce2b9..c5e9f63 100644 --- a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift @@ -1,7 +1,7 @@ import AppKit import SwiftUI import XCTest -import MacActivityCore +@testable import MacActivityCore @testable import MacActivityApp @MainActor @@ -42,8 +42,7 @@ final class EnergyImpactViewTests: XCTestCase { func testRenderedEnergyImpactViewShowsEmptyStateAtFourHundredTwentyPoints() { let model = EnergyImpactModel( provider: EnergyImpactViewProviderStub(responses: []), - sampleIntervalNanoseconds: 1, - publicationIntervalNanoseconds: 3, + initialWindowNanoseconds: 1, sleep: { _ in throw CancellationError() } ) let renderer = ImageRenderer( @@ -76,15 +75,14 @@ final class EnergyImpactViewTests: XCTestCase { var sleepCount = 0 let renderedEntry = entry(power: 1_840) let model = EnergyImpactModel( - provider: EnergyImpactViewProviderStub(responses: [[], [renderedEntry]]), - sampleIntervalNanoseconds: 1, - publicationIntervalNanoseconds: 1, + provider: EnergyImpactViewProviderStub(responses: [[], [renderedEntry], []]), + initialWindowNanoseconds: 1, sleep: { _ in sleepCount += 1 guard sleepCount == 1 else { throw CancellationError() } } ) - await model.refreshWhileVisible() + await model.refresh() let expectations: [( languageIdentifier: String, @@ -237,30 +235,24 @@ final class EnergyImpactViewTests: XCTestCase { @MainActor private final class EnergyImpactViewProviderStub: EnergyImpactProviding { private var responses: [[EnergyImpactEntry]] - private var activeSessionID: EnergyImpactSessionID? + private var nextGeneration: UInt64 = 0 init(responses: [[EnergyImpactEntry]]) { self.responses = responses } - func beginSession() async -> EnergyImpactSessionID { - let sessionID = EnergyImpactSessionID() - activeSessionID = sessionID - return sessionID + func beginSession() async -> EnergyImpactSamplingLease? { + nextGeneration += 1 + return EnergyImpactSamplingLease(requestGeneration: nextGeneration) } - func sample( - sessionID: EnergyImpactSessionID, + func observe( + lease: EnergyImpactSamplingLease, limit: Int, - scope: EnergyImpactAppScope, - publicationBoundary: Bool + scope: EnergyImpactAppScope ) async -> [EnergyImpactEntry]? { - guard sessionID == activeSessionID else { return nil } return responses.isEmpty ? [] : Array(responses.removeFirst().prefix(limit)) } - func endSession(_ sessionID: EnergyImpactSessionID) async { - guard sessionID == activeSessionID else { return } - activeSessionID = nil - } + func endSession(_ lease: EnergyImpactSamplingLease) async {} } diff --git a/Tests/MacActivityCoreTests/EnergyImpactNativeValidationTests.swift b/Tests/MacActivityCoreTests/EnergyImpactNativeValidationTests.swift index 12ac3fb..1516802 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactNativeValidationTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactNativeValidationTests.swift @@ -13,13 +13,16 @@ final class EnergyImpactNativeValidationTests: XCTestCase { } let service = EnergyImpactService() - let sessionID = await service.beginSession() + guard let lease = await service.beginSession() else { + XCTFail("Native validation could not begin a sampler lease") + return + } do { let metrics = try await measureVisibleFacade( service: service, - sessionID: sessionID + lease: lease ) - await service.endSession(sessionID) + await service.endSession(lease) print(String( format: "ENERGY_NATIVE_METRICS samples=%d p50_ms=%.3f p95_ms=%.3f cpu_percent=%.4f wall_seconds=%.3f", @@ -32,28 +35,27 @@ final class EnergyImpactNativeValidationTests: XCTestCase { XCTAssertLessThan(metrics.cpuPercent, 0.5) XCTAssertLessThan(metrics.p95, 0.100) } catch { - await service.endSession(sessionID) + await service.endSession(lease) throw error } } private func measureVisibleFacade( service: EnergyImpactService, - sessionID: EnergyImpactSessionID + lease: EnergyImpactSamplingLease ) async throws -> NativeMetrics { let startCPU = processCPUSeconds() let startWall = ProcessInfo.processInfo.systemUptime var latencies = [TimeInterval]() latencies.reserveCapacity(60) - for index in 1...60 { + for _ in 1...60 { try Task.checkCancellation() let started = ProcessInfo.processInfo.systemUptime - _ = await service.sample( - sessionID: sessionID, + _ = await service.observe( + lease: lease, limit: 20, - scope: .regularOnly, - publicationBoundary: index.isMultiple(of: 3) + scope: .regularOnly ) try Task.checkCancellation() latencies.append(ProcessInfo.processInfo.systemUptime - started) diff --git a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift index 9df7fc9..0dfed50 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift @@ -1,208 +1,195 @@ -import Darwin import XCTest @testable import MacActivityCore @MainActor final class EnergyImpactProviderTests: XCTestCase { - func testServiceForwardsCatalogSnapshotsAndSamplingArguments() async throws { - let apps = [fixtureApp(processIdentifier: 101, kind: .regular)] - let catalog = EnergyImpactAppCatalogStub(apps: apps) - let sampler = EnergyImpactSamplingStub(output: [fixtureEntry()]) - let service = EnergyImpactService(catalog: catalog, sampler: sampler) - - let sessionID = await service.beginSession() - let sampled = await service.sample( - sessionID: sessionID, - limit: 12, - scope: .regularAndAccessory, - publicationBoundary: true + func testBeginSessionAllocatesGenerationsBeforeAwaitingSampler() async { + let sampler = ReorderingSamplingSpy() + let service = EnergyImpactService( + catalog: EnergyImpactAppCatalogStub(responses: [[]]), + sampler: sampler ) - let entries = try XCTUnwrap(sampled) - await service.endSession(sessionID) - let snapshot = await sampler.snapshot() - - XCTAssertEqual(entries, [fixtureEntry()]) - XCTAssertEqual(catalog.requestedScopes, [.regularAndAccessory]) - XCTAssertEqual(snapshot.requests, [ - .init( - sessionID: sessionID, - apps: apps, - limit: 12, - publicationBoundary: true - ), - ]) - XCTAssertNil(snapshot.activeSessionID) - } - func testServiceRequestsCatalogForEverySample() async { - let catalog = EnergyImpactAppCatalogStub(apps: [fixtureApp()]) - let sampler = EnergyImpactSamplingStub(output: []) - let service = EnergyImpactService(catalog: catalog, sampler: sampler) - let sessionID = await service.beginSession() + let first = Task { await service.beginSession() } + await sampler.waitForRequestCount(1) + let second = Task { await service.beginSession() } + await sampler.waitForRequestCount(2) - _ = await service.sample( - sessionID: sessionID, - limit: 20, - scope: .regularOnly, - publicationBoundary: false - ) - _ = await service.sample( - sessionID: sessionID, - limit: 20, - scope: .regularAndAccessory, - publicationBoundary: true + let requestGenerations = await sampler.requestGenerations + XCTAssertEqual(requestGenerations, [1, 2]) + await sampler.releaseFirstRequest() + _ = await first.value + _ = await second.value + } + + func testReorderedBeginLeavesNewerRequestAuthoritative() async throws { + let sampler = ReorderingSamplingSpy() + let service = EnergyImpactService( + catalog: EnergyImpactAppCatalogStub(responses: [[]]), + sampler: sampler ) - XCTAssertEqual(catalog.requestedScopes, [.regularOnly, .regularAndAccessory]) + let first = Task { await service.beginSession() } + await sampler.waitForRequestCount(1) + let second = Task { await service.beginSession() } + await sampler.waitForRequestCount(2) + let newer = await second.value + await sampler.releaseFirstRequest() + let older = await first.value + + XCTAssertNil(older) + XCTAssertEqual(try XCTUnwrap(newer).requestGeneration, 2) + let activeRequestGeneration = await sampler.activeRequestGeneration + XCTAssertEqual(activeRequestGeneration, 2) } - func testObsoleteEndCannotClearNewestServiceSession() async { - let catalog = EnergyImpactAppCatalogStub(apps: [fixtureApp()]) - let sampler = EnergyImpactSamplingStub(output: [fixtureEntry()]) + func testObserveCapturesOneCurrentCatalogArrayForOneSamplerCall() async throws { + let expected = [EnergyImpactAppSnapshot( + processIdentifier: 101, + name: "First", + bundleIdentifier: "com.example.first", + bundleURL: nil + )] + let catalog = EnergyImpactAppCatalogStub(responses: [ + expected, + [.init(processIdentifier: 202, name: "Later", bundleIdentifier: nil, bundleURL: nil)], + ]) + let sampler = SamplingSpy() let service = EnergyImpactService(catalog: catalog, sampler: sampler) - let oldSessionID = await service.beginSession() - let currentSessionID = await service.beginSession() + let optionalLease = await service.beginSession() + let lease = try XCTUnwrap(optionalLease) - await service.endSession(oldSessionID) - let entries = await service.sample( - sessionID: currentSessionID, + _ = await service.observe( + lease: lease, limit: 20, - scope: .regularOnly, - publicationBoundary: false + scope: .regularAndAccessory ) - XCTAssertEqual(entries, [fixtureEntry()]) - let snapshot = await sampler.snapshot() - XCTAssertEqual(snapshot.activeSessionID, currentSessionID) + XCTAssertEqual(catalog.callCount, 1) + let observedApps = await sampler.observedApps + let observeCount = await sampler.observeCount + XCTAssertEqual(observedApps, [expected]) + XCTAssertEqual(observeCount, 1) } - func testSystemCatalogRefreshesWorkspaceMetadataEveryThreeSeconds() { - let state = EnergyImpactCatalogTestState() - let catalog = SystemEnergyImpactAppCatalog( - snapshotProvider: { - state.workspaceRequestCount += 1 - return [ - self.fixtureApp(processIdentifier: 101, kind: .regular), - self.fixtureApp(processIdentifier: 102, kind: .accessory), - ] - }, - nowSeconds: { state.now }, - refreshIntervalSeconds: 3 - ) + func testObserveForwardsRegularOnlyScopeUnchanged() async throws { + let catalog = EnergyImpactAppCatalogStub(responses: [[]]) + let sampler = SamplingSpy() + let service = EnergyImpactService(catalog: catalog, sampler: sampler) + let optionalLease = await service.beginSession() + let lease = try XCTUnwrap(optionalLease) - XCTAssertEqual(catalog.snapshots(scope: .regularOnly).map(\.processIdentifier), [101]) - state.now = 1 - XCTAssertEqual(catalog.snapshots(scope: .regularAndAccessory).count, 2) - state.now = 2 - _ = catalog.snapshots(scope: .regularOnly) - state.now = 3 - _ = catalog.snapshots(scope: .regularOnly) + _ = await service.observe( + lease: lease, + limit: 7, + scope: .regularOnly + ) - XCTAssertEqual(state.workspaceRequestCount, 2) + XCTAssertEqual(catalog.scopes, [.regularOnly]) + let observedLimits = await sampler.observedLimits + XCTAssertEqual(observedLimits, [7]) } - private func fixtureApp( - processIdentifier: pid_t = 101, - kind: EnergyImpactAppKind = .regular - ) -> EnergyImpactAppSnapshot { - EnergyImpactAppSnapshot( - processIdentifier: processIdentifier, - name: "Fixture", - bundleIdentifier: "example.fixture", - bundleURL: nil, - kind: kind + func testFacadeStoresNoAlgorithmOrProcessState() { + let service = EnergyImpactService( + catalog: EnergyImpactAppCatalogStub(responses: [[]]), + sampler: SamplingSpy() ) - } - private func fixtureEntry() -> EnergyImpactEntry { - EnergyImpactEntry( - identity: EnergyImpactAppIdentity( - rootProcessIdentifier: 101, - rootProcessStartAbsoluteTime: 10 - ), - name: "Fixture", - bundleIdentifier: "example.fixture", - bundleURL: nil, - currentPowerMicrowatts: 1, - sustainedPowerMicrowatts: nil, - rankingScore: 1, - trend: .steady, - coverage: .unavailable, - status: .stable - ) - } -} + let labels = Mirror(reflecting: service).children.compactMap(\.label) + let forbiddenFragments = [ + "baseline", "owner", "snapshot", "smoother", "ranker", "raw", + ] -@MainActor -private final class EnergyImpactCatalogTestState { - var now: TimeInterval = 0 - var workspaceRequestCount = 0 + XCTAssertEqual(Set(labels), ["catalog", "sampler", "nextRequestGeneration"]) + XCTAssertTrue(labels.allSatisfy { label in + forbiddenFragments.allSatisfy { label.localizedCaseInsensitiveContains($0) == false } + }) + } } @MainActor private final class EnergyImpactAppCatalogStub: EnergyImpactAppCataloging { - let apps: [EnergyImpactAppSnapshot] - private(set) var requestedScopes = [EnergyImpactAppScope]() + private var responses: [[EnergyImpactAppSnapshot]] + private(set) var scopes: [EnergyImpactAppScope] = [] - init(apps: [EnergyImpactAppSnapshot]) { - self.apps = apps + init(responses: [[EnergyImpactAppSnapshot]]) { + self.responses = responses } + var callCount: Int { scopes.count } + func snapshots(scope: EnergyImpactAppScope) -> [EnergyImpactAppSnapshot] { - requestedScopes.append(scope) - return apps + scopes.append(scope) + guard responses.isEmpty == false else { return [] } + return responses.removeFirst() } } -private actor EnergyImpactSamplingStub: EnergyImpactSampling { - struct Request: Equatable, Sendable { - let sessionID: EnergyImpactSessionID - let apps: [EnergyImpactAppSnapshot] - let limit: Int - let publicationBoundary: Bool - } +private actor SamplingSpy: EnergyImpactSampling { + private(set) var observedApps: [[EnergyImpactAppSnapshot]] = [] + private(set) var observedLimits: [Int] = [] + private(set) var observeCount = 0 - struct Snapshot: Sendable { - let activeSessionID: EnergyImpactSessionID? - let requests: [Request] + func beginSession( + _ request: EnergyImpactSessionRequest + ) -> EnergyImpactSamplingLease? { + EnergyImpactSamplingLease(requestGeneration: request.generation) } - private let output: [EnergyImpactEntry] - private var activeSessionID: EnergyImpactSessionID? - private var requests = [Request]() - - init(output: [EnergyImpactEntry]) { - self.output = output + func observe( + lease: EnergyImpactSamplingLease, + apps: [EnergyImpactAppSnapshot], + limit: Int + ) -> [EnergyImpactEntry]? { + observedApps.append(apps) + observedLimits.append(limit) + observeCount += 1 + return [] } - func beginSession() -> EnergyImpactSessionID { - let sessionID = EnergyImpactSessionID() - activeSessionID = sessionID - return sessionID + func endSession(_ lease: EnergyImpactSamplingLease) {} +} + +private actor ReorderingSamplingSpy: EnergyImpactSampling { + private(set) var requestGenerations: [UInt64] = [] + private(set) var activeRequestGeneration: UInt64? + private var highestSeenGeneration: UInt64 = 0 + private var firstRequestContinuation: CheckedContinuation? + + func beginSession( + _ request: EnergyImpactSessionRequest + ) async -> EnergyImpactSamplingLease? { + requestGenerations.append(request.generation) + if request.generation == 1 { + await withCheckedContinuation { continuation in + firstRequestContinuation = continuation + } + } + guard request.generation > highestSeenGeneration else { return nil } + highestSeenGeneration = request.generation + activeRequestGeneration = request.generation + return EnergyImpactSamplingLease(requestGeneration: request.generation) } - func sample( - sessionID: EnergyImpactSessionID, + func observe( + lease: EnergyImpactSamplingLease, apps: [EnergyImpactAppSnapshot], - limit: Int, - publicationBoundary: Bool + limit: Int ) -> [EnergyImpactEntry]? { - guard sessionID == activeSessionID else { return nil } - requests.append(Request( - sessionID: sessionID, - apps: apps, - limit: limit, - publicationBoundary: publicationBoundary - )) - return Array(output.prefix(max(0, limit))) + [] } - func endSession(_ sessionID: EnergyImpactSessionID) { - guard sessionID == activeSessionID else { return } - activeSessionID = nil + func endSession(_ lease: EnergyImpactSamplingLease) {} + + func waitForRequestCount(_ expectedCount: Int) async { + while requestGenerations.count < expectedCount { + await Task.yield() + } } - func snapshot() -> Snapshot { - Snapshot(activeSessionID: activeSessionID, requests: requests) + func releaseFirstRequest() { + firstRequestContinuation?.resume() + firstRequestContinuation = nil } } diff --git a/Tests/MacActivityCoreTests/EnergyImpactSamplerTests.swift b/Tests/MacActivityCoreTests/EnergyImpactSamplerTests.swift index de708bc..8364cf3 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactSamplerTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactSamplerTests.swift @@ -1,522 +1,544 @@ import Darwin -import Foundation import XCTest @testable import MacActivityCore +@MainActor final class EnergyImpactSamplerTests: XCTestCase { - func testSamplerReturnsTheSamePartialAggregateAsThePartThreeContract() async throws { - let sampler = EnergyImpactSampler( + private func require( + _ value: T?, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> T { + try XCTUnwrap(value, file: file, line: line) + } + + private func sortedByImpact( + _ entries: [EnergyImpactEntry], + limit: Int + ) -> [EnergyImpactEntry] { + var publicationState = EnergyImpactPublicationState() + return publicationState.publish(entries, at: 0, limit: limit) + } + + private func reading( + energy: UInt64, + start: UInt64 = 10, + userCPU: UInt64 = 0, + systemCPU: UInt64 = 0 + ) -> ProcessEnergyReading { + ProcessEnergyReading( + energyNanojoules: energy, + processStartAbsoluteTime: start, + userCPUTime: userCPU, + systemCPUTime: systemCPU + ) + } + + private func makeService( + results: [ProcessEnergyReadResult], + times: [TimeInterval] + ) -> EnergyImpactSamplerTestSession { + EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(results: [101: results]), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + appSnapshotProvider: { [ + .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: times) + ) + } + + private func makeTwoProcessService( + rootResults: [ProcessEnergyReadResult], + helperResults: [ProcessEnergyReadResult], + times: [TimeInterval] + ) -> EnergyImpactSamplerTestSession { + EnergyImpactSamplerTestSession( reader: ProcessEnergyReadingProviderStub(results: [ - 100: [ - .success(.init(energyNanojoules: 1_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 4_000, processStartAbsoluteTime: 10)), - ], - 200: [ - .failure(.permissionDenied), - .failure(.permissionDenied), - ], + 100: rootResults, + 101: helperResults, ]), processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 100, parentProcessIdentifier: 1), - .init(processIdentifier: 200, parentProcessIdentifier: 100), + .init(processIdentifier: 101, parentProcessIdentifier: 100), ]), - clock: EnergyImpactClockStub(times: [0, 3]), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: times) + ) + } + + private func makeReparentingService( + ownersBySample: [pid_t], + energies: [UInt64], + times: [TimeInterval] + ) -> EnergyImpactSamplerTestSession { + EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(results: [ + 100: ownersBySample.map { _ in .failure(.permissionDenied) }, + 200: ownersBySample.map { _ in .failure(.permissionDenied) }, + 300: energies.map { .success(reading(energy: $0, start: 30)) }, + ]), + processSnapshotReader: SequencedProcessParentSnapshotReaderStub( + snapshotsByCall: ownersBySample.map { owner in + [.init(processIdentifier: 300, parentProcessIdentifier: owner)] + } + ), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "First", bundleIdentifier: nil, bundleURL: nil), + .init(processIdentifier: 200, name: "Second", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: times) + ) + } + + private func makeMixedGapService( + rootEnergies: [UInt64], + helperResults: [ProcessEnergyReadResult], + times: [TimeInterval] + ) -> EnergyImpactSamplerTestSession { + makeTwoProcessService( + rootResults: rootEnergies.map { .success(reading(energy: $0, start: 10)) }, + helperResults: helperResults, + times: times + ) + } + + private func entry( + processIdentifier: pid_t, + name: String, + power: Double?, + status: EnergyImpactStatus + ) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: processIdentifier, + rootProcessStartAbsoluteTime: UInt64(processIdentifier) + ), + name: name, + bundleIdentifier: nil, + bundleURL: nil, + currentPowerMicrowatts: power, + sustainedPowerMicrowatts: nil, + rankingScore: power, + trend: .steady, + coverage: .unavailable, + status: status + ) + } + + func testNewerBeginRequestWinsWhenItArrivesBeforeOlderRequest() async throws { + let sampler = EnergyImpactSampler( + reader: ProcessEnergyReadingProviderStub(results: [:]), + processSnapshotReader: + ProcessParentSnapshotReaderStub(snapshots: []), + clock: EnergyImpactClockStub(times: [0]), configuration: .production ) - let sessionID = await sampler.beginSession() - _ = await sampler.sample( - sessionID: sessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: false + let newer = await sampler.beginSession( + EnergyImpactSessionRequest(generation: 2) ) - let sampled = await sampler.sample( - sessionID: sessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: true + let older = await sampler.beginSession( + EnergyImpactSessionRequest(generation: 1) ) - let entries = try XCTUnwrap(sampled) - XCTAssertEqual(entries.count, 1) - XCTAssertEqual(entries[0].status, .partial) - XCTAssertEqual(entries[0].coverage.fraction, 0.5, accuracy: 0.001) + XCTAssertNotNil(newer) + XCTAssertNil(older) + let lease = try require(newer) + let observed = await sampler.observe( + lease: lease, + apps: [], + limit: 20 + ) + XCTAssertNotNil(observed) + } + + func testOldEndCannotClearNewLease() async throws { + let sampler = EnergyImpactSampler( + reader: ProcessEnergyReadingProviderStub(results: [:]), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + clock: EnergyImpactClockStub(times: [0]) + ) + let first = try require(await sampler.beginSession(.init(generation: 1))) + let second = try require(await sampler.beginSession(.init(generation: 2))) + + await sampler.endSession(first) + + let observed = await sampler.observe(lease: second, apps: [], limit: 20) + XCTAssertNotNil(observed) } - func testResetClearsBaselinesAndSmoothing() async throws { + func testEndingCurrentLeaseInvalidatesOldLeaseAndRestartsCollecting() async throws { let sampler = EnergyImpactSampler( reader: ProcessEnergyReadingProviderStub(results: [ 100: [ - .success(.init(energyNanojoules: 1_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 4_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 7_000, processStartAbsoluteTime: 10)), + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), ], ]), - processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 100, parentProcessIdentifier: 1), - ]), - clock: EnergyImpactClockStub(times: [0, 3, 6]), - configuration: .production + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + clock: EnergyImpactClockStub(times: [0, 3]) + ) + let lease = try require( + await sampler.beginSession(.init(generation: 1)) ) - let oldSession = await sampler.beginSession() - _ = await sampler.sample( - sessionID: oldSession, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: false + let apps = [ + EnergyImpactAppSnapshot( + processIdentifier: 100, + name: "Fixture App", + bundleIdentifier: "example.fixture", + bundleURL: nil + ), + ] + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) + + await sampler.endSession(lease) + let invalidated = await sampler.observe( + lease: lease, + apps: apps, + limit: 20 ) - let newSession = await sampler.beginSession() - await sampler.endSession(oldSession) - let sampled = await sampler.sample( - sessionID: newSession, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: false + + XCTAssertNil(invalidated) + + let replacement = try require( + await sampler.beginSession(.init(generation: 2)) + ) + let restarted = try require( + await sampler.observe( + lease: replacement, + apps: apps, + limit: 20 + )?.first ) - let entries = try XCTUnwrap(sampled) - let entry = try XCTUnwrap(entries.first) - XCTAssertNil(entry.currentPowerMicrowatts) - XCTAssertEqual(entry.status, .collecting) + XCTAssertEqual(restarted.status, .collecting) + XCTAssertNil(restarted.currentPowerMicrowatts) + XCTAssertNil(restarted.sustainedPowerMicrowatts) + XCTAssertNil(restarted.rankingScore) } - func testOldSessionCannotCommitOrClearNewSession() async { + func testFirstCoherentObservationPublishesCollectingWithoutFalseZero() async throws { let sampler = EnergyImpactSampler( reader: ProcessEnergyReadingProviderStub(results: [ - 100: [.success(.init(energyNanojoules: 1_000, processStartAbsoluteTime: 10))], - ]), - processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 100, parentProcessIdentifier: 1), + 100: [.success(reading(energy: 1_000))], ]), - clock: EnergyImpactClockStub(times: [0]), - configuration: .production - ) - let old = await sampler.beginSession() - let new = await sampler.beginSession() - await sampler.endSession(old) - let obsolete = await sampler.sample( - sessionID: old, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: true - ) - let current = await sampler.sample( - sessionID: new, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: false + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + clock: EnergyImpactClockStub(times: [0]) ) + let lease = try require(await sampler.beginSession(.init(generation: 1))) - XCTAssertNil(obsolete) - XCTAssertNotNil(current) + let rows = try require(await sampler.observe( + lease: lease, + apps: [.init(processIdentifier: 100, name: "Root", bundleIdentifier: nil, bundleURL: nil)], + limit: 20 + )) + let row = try require(rows.first) + + XCTAssertEqual(row.status, .collecting) + XCTAssertNil(row.currentPowerMicrowatts) + XCTAssertNil(row.sustainedPowerMicrowatts) + XCTAssertNil(row.rankingScore) } - func testSmoothingAdvancesOnlyAtPublicationBoundaries() async throws { + func testSecondCoherentObservationMatchesPartThreePartialAggregate() async throws { let sampler = EnergyImpactSampler( reader: ProcessEnergyReadingProviderStub(results: [ 100: [ - .success(.init(energyNanojoules: 0, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 1_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 2_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 3_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 7_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 11_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 15_000, processStartAbsoluteTime: 10)), + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), + ], + 101: [ + .failure(.permissionDenied), + .failure(.permissionDenied), ], ]), processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 100, parentProcessIdentifier: 1), + .init(processIdentifier: 101, parentProcessIdentifier: 100), ]), - clock: EnergyImpactClockStub(times: [0, 1, 2, 3, 4, 5, 6]), - configuration: .production, - processSnapshotRefreshIntervalSeconds: 3, - minimumProcessReadIntervalSeconds: 0 - ) - let sessionID = await sampler.beginSession() - - for index in 0..<6 { - _ = await sampler.sample( - sessionID: sessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: index == 3 - ) - } - let sampled = await sampler.sample( - sessionID: sessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: true + clock: EnergyImpactClockStub(times: [0, 3]) ) - let entries = try XCTUnwrap(sampled) + let lease = try require(await sampler.beginSession(.init(generation: 1))) + let apps = [EnergyImpactAppSnapshot( + processIdentifier: 100, + name: "Root", + bundleIdentifier: nil, + bundleURL: nil + )] - XCTAssertEqual( - try XCTUnwrap(entries.first?.currentPowerMicrowatts), - 2.216_188_6, - accuracy: 0.000_001 + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) + let row = try require( + await sampler.observe(lease: lease, apps: apps, limit: 20)?.first ) + + XCTAssertEqual(row.status, .partial) + XCTAssertEqual(try require(row.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(row.coverage.readableProcessCount, 1) + XCTAssertEqual(row.coverage.discoveredProcessCount, 2) + XCTAssertEqual(row.coverage.validProcessSeconds, 3, accuracy: 0.001) + XCTAssertEqual(row.coverage.discoveredProcessSeconds, 6, accuracy: 0.001) + XCTAssertEqual(row.coverage.fraction, 0.5, accuracy: 0.001) } - func testProcessOwnershipSnapshotRefreshesAtThreeSecondBoundary() async { - let snapshotReader = CountingProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 100, parentProcessIdentifier: 1), + func testEveryObserveReadsOneFreshOwnershipSnapshotAndCurrentCounters() async throws { + let snapshots = SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [.init(processIdentifier: 101, parentProcessIdentifier: 100)], + [.init(processIdentifier: 101, parentProcessIdentifier: 100)], + ]) + let reader = ProcessEnergyReadingProviderStub(results: [ + 100: [.success(reading(energy: 1_000)), .success(reading(energy: 4_000))], + 101: [ + .success(reading(energy: 1_000, start: 11)), + .success(reading(energy: 4_000, start: 11)), + ], ]) let sampler = EnergyImpactSampler( - reader: ProcessEnergyReadingProviderStub(results: [ - 100: (0..<5).map { index in - .success(.init( - energyNanojoules: UInt64(index) * 1_000, - processStartAbsoluteTime: 10 - )) - }, - ]), - processSnapshotReader: snapshotReader, - clock: EnergyImpactClockStub(times: [0, 1, 2, 3, 4]), - configuration: .production + reader: reader, + processSnapshotReader: snapshots, + clock: EnergyImpactClockStub(times: [0, 3]) ) - let sessionID = await sampler.beginSession() + let lease = try require(await sampler.beginSession(.init(generation: 1))) + let apps = [EnergyImpactAppSnapshot( + processIdentifier: 100, + name: "Root", + bundleIdentifier: nil, + bundleURL: nil + )] - for index in 0..<5 { - _ = await sampler.sample( - sessionID: sessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: index == 3 - ) - } + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) - XCTAssertEqual(snapshotReader.requestCount, 2) + XCTAssertEqual(snapshots.callCount, 2) + XCTAssertEqual(reader.readCount(for: 100), 2) + XCTAssertEqual(reader.readCount(for: 101), 2) } - func testProductionSamplerReadsProcessCountersEveryTwoSeconds() async { - let reader = CountingProcessEnergyReadingProviderStub() + func testOwnershipMoveRebaselinesBothRootsWithoutTransitionAttribution() async throws { let sampler = EnergyImpactSampler( - reader: reader, - processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 100, parentProcessIdentifier: 1), + reader: ProcessEnergyReadingProviderStub(results: [ + 100: [.failure(.permissionDenied), .failure(.permissionDenied)], + 200: [.failure(.permissionDenied), .failure(.permissionDenied)], + 300: [ + .success(reading(energy: 1_000, start: 30)), + .success(reading(energy: 9_000, start: 30)), + ], ]), - clock: EnergyImpactClockStub(times: [0, 1, 2, 3, 4]), - configuration: .production + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + [.init(processIdentifier: 300, parentProcessIdentifier: 200)], + ]), + clock: EnergyImpactClockStub(times: [0, 3]) ) - let sessionID = await sampler.beginSession() + let lease = try require(await sampler.beginSession(.init(generation: 1))) + let apps = [ + EnergyImpactAppSnapshot(processIdentifier: 100, name: "A", bundleIdentifier: nil, bundleURL: nil), + EnergyImpactAppSnapshot(processIdentifier: 200, name: "B", bundleIdentifier: nil, bundleURL: nil), + ] - for index in 0..<5 { - _ = await sampler.sample( - sessionID: sessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: index == 3 - ) - } + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) + let rows = try require(await sampler.observe(lease: lease, apps: apps, limit: 20)) - XCTAssertEqual(reader.requestCount, 3) + XCTAssertTrue(rows.allSatisfy { $0.currentPowerMicrowatts == nil }) } - func testTestingSmootherAppliesAtPublicationBoundary() async throws { + func testReorderedEquivalentOwnershipRemainsNumeric() async throws { let sampler = EnergyImpactSampler( reader: ProcessEnergyReadingProviderStub(results: [ - 100: [ - .success(.init(energyNanojoules: 0, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 3_000, processStartAbsoluteTime: 10)), + 100: [.success(reading(energy: 1_000)), .success(reading(energy: 4_000))], + 101: [ + .success(reading(energy: 1_000, start: 11)), + .success(reading(energy: 4_000, start: 11)), ], ]), - processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 100, parentProcessIdentifier: 1), + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [ + .init(processIdentifier: 100, parentProcessIdentifier: 1), + .init(processIdentifier: 101, parentProcessIdentifier: 100), + ], + [ + .init(processIdentifier: 101, parentProcessIdentifier: 100), + .init(processIdentifier: 100, parentProcessIdentifier: 1), + ], ]), - clock: EnergyImpactClockStub(times: [0, 3]), - configuration: .production, - smoothingOverrideForTesting: { _, _, _ in 42 } + clock: EnergyImpactClockStub(times: [0, 3]) ) - let sessionID = await sampler.beginSession() + let lease = try require(await sampler.beginSession(.init(generation: 1))) + let apps = [EnergyImpactAppSnapshot(processIdentifier: 100, name: "A", bundleIdentifier: nil, bundleURL: nil)] - _ = await sampler.sample( - sessionID: sessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: false + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) + let row = try require( + await sampler.observe(lease: lease, apps: apps, limit: 20)?.first ) - let sampled = await sampler.sample( - sessionID: sessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: true - ) - let publication = try XCTUnwrap(sampled) - let entry = try XCTUnwrap(publication.first) - XCTAssertEqual(entry.currentPowerMicrowatts, 42) - XCTAssertEqual(entry.rankingScore, 42) + XCTAssertEqual(row.status, .stable) + XCTAssertNotNil(row.currentPowerMicrowatts) } - func testEndingCurrentSessionInvalidatesExistingSession() async throws { - let sampler = EnergyImpactSampler( - reader: ProcessEnergyReadingProviderStub(results: [ + func testCancelledObserveReturnsNilWithoutCommittingWorkingState() async throws { + let reader = ProcessEnergyReadingProviderStub( + results: [ 100: [ - .success(.init(energyNanojoules: 1_000, processStartAbsoluteTime: 10)), - .success(.init(energyNanojoules: 4_000, processStartAbsoluteTime: 10)), + .success(reading(energy: 1_000)), + .success(reading(energy: 10_000)), + .success(reading(energy: 7_000)), ], - ]), - processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 100, parentProcessIdentifier: 1), - ]), - clock: EnergyImpactClockStub(times: [0]), - configuration: .production - ) - let oldSessionID = await sampler.beginSession() - _ = await sampler.sample( - sessionID: oldSessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: false + ], + blockedReadNumber: 2 ) - await sampler.endSession(oldSessionID) - let invalidated = await sampler.sample( - sessionID: oldSessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: false + let sampler = EnergyImpactSampler( + reader: reader, + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + clock: EnergyImpactClockStub(times: [0, 3, 6]) ) + let lease = try require(await sampler.beginSession(.init(generation: 1))) + let apps = [EnergyImpactAppSnapshot(processIdentifier: 100, name: "A", bundleIdentifier: nil, bundleURL: nil)] + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) - XCTAssertNil(invalidated) - - let freshSessionID = await sampler.beginSession() + let cancelled = Task.detached { + await sampler.observe(lease: lease, apps: apps, limit: 20) + } + XCTAssertTrue(reader.waitUntilBlocked()) + cancelled.cancel() + reader.releaseBlockedRead() - let sampled = await sampler.sample( - sessionID: freshSessionID, - apps: [fixtureEnergyApp], - limit: 20, - publicationBoundary: false + let cancelledResult = await cancelled.value + XCTAssertNil(cancelledResult) + let recovered = try require( + await sampler.observe(lease: lease, apps: apps, limit: 20)?.first ) - let entries = try XCTUnwrap(sampled) - let entry = try XCTUnwrap(entries.first) - - XCTAssertEqual(entry.status, .collecting) - XCTAssertNil(entry.currentPowerMicrowatts) - } -} - -private let fixtureEnergyApp = EnergyImpactAppSnapshot( - processIdentifier: 100, - name: "Fixture App", - bundleIdentifier: "example.fixture", - bundleURL: nil -) - -private final class ProcessEnergyReadingProviderStub: - ProcessEnergyReadingProvider, - @unchecked Sendable -{ - private let lock = NSLock() - private var results: [pid_t: [ProcessEnergyReadResult]] - - init(results: [pid_t: [ProcessEnergyReadResult]]) { - self.results = results + XCTAssertEqual(recovered.status, .stable) + XCTAssertEqual(try require(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) } - func reading(for processIdentifier: pid_t) -> ProcessEnergyReadResult { - lock.lock() - defer { lock.unlock() } - guard var values = results[processIdentifier], values.isEmpty == false else { - return .failure(.exited) + func testConcurrentObserveCallsNeverOverlapProcessReads() async throws { + let reader = ProcessEnergyReadingProviderStub( + results: [ + 100: [ + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), + ], + ], + blockedReadNumber: 1 + ) + let sampler = EnergyImpactSampler( + reader: reader, + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + clock: EnergyImpactClockStub(times: [0, 3]) + ) + guard (sampler as Any) is any Actor else { + XCTFail("EnergyImpactSampler must remain actor-isolated") + return } - let result = values.removeFirst() - results[processIdentifier] = values - return result - } -} - -private final class CountingProcessEnergyReadingProviderStub: - ProcessEnergyReadingProvider, - @unchecked Sendable -{ - private let lock = NSLock() - private var count = 0 - - var requestCount: Int { - lock.lock() - defer { lock.unlock() } - return count - } - - func reading(for processIdentifier: pid_t) -> ProcessEnergyReadResult { - lock.lock() - defer { lock.unlock() } - defer { count += 1 } - return .success(ProcessEnergyReading( - energyNanojoules: UInt64(count) * 1_000, - processStartAbsoluteTime: 10 - )) - } -} - -private struct ProcessParentSnapshotReaderStub: ProcessParentSnapshotReading { - let snapshotsValue: [ProcessParentSnapshot] - - init(snapshots: [ProcessParentSnapshot]) { - snapshotsValue = snapshots - } - - func snapshots() -> [ProcessParentSnapshot] { - snapshotsValue - } -} - -private final class CountingProcessParentSnapshotReaderStub: - ProcessParentSnapshotReading, - @unchecked Sendable -{ - private let lock = NSLock() - private let snapshotValues: [ProcessParentSnapshot] - private var count = 0 - - init(snapshots: [ProcessParentSnapshot]) { - snapshotValues = snapshots - } - - var requestCount: Int { - lock.lock() - defer { lock.unlock() } - return count - } + let lease = try require( + await sampler.beginSession(.init(generation: 1)) + ) + let apps = [ + EnergyImpactAppSnapshot( + processIdentifier: 100, + name: "A", + bundleIdentifier: nil, + bundleURL: nil + ), + ] + let startBarrier = ConcurrentObservationStartBarrier( + participantCount: 2 + ) - func snapshots() -> [ProcessParentSnapshot] { - lock.lock() - defer { lock.unlock() } - count += 1 - return snapshotValues - } -} + let first = Task.detached { + await startBarrier.arriveAndWait() + return await sampler.observe( + lease: lease, + apps: apps, + limit: 20 + ) + } + let second = Task.detached { + await startBarrier.arriveAndWait() + return await sampler.observe( + lease: lease, + apps: apps, + limit: 20 + ) + } -private final class EnergyImpactClockStub: EnergyImpactClock, @unchecked Sendable { - private let lock = NSLock() - private var times: [TimeInterval] + let didBlockFirstRead = reader.waitUntilBlocked() + reader.releaseBlockedRead() - init(times: [TimeInterval]) { - self.times = times - } + let firstResult = await first.value + let secondResult = await second.value - func nowSeconds() -> TimeInterval { - lock.lock() - defer { lock.unlock() } - return times.isEmpty ? 0 : times.removeFirst() + XCTAssertTrue(didBlockFirstRead) + XCTAssertNotNil(firstResult) + XCTAssertNotNil(secondResult) + XCTAssertEqual(reader.maximumConcurrentReads, 1) } -} -import Darwin -import XCTest -@testable import MacActivityCore - -@MainActor -final class EnergyImpactSamplerRegressionTests: XCTestCase { - private func reading( - energy: UInt64, - start: UInt64 = 10, - userCPU: UInt64 = 0, - systemCPU: UInt64 = 0 - ) -> ProcessEnergyReading { - ProcessEnergyReading( - energyNanojoules: energy, - processStartAbsoluteTime: start, - userCPUTime: userCPU, - systemCPUTime: systemCPU + func testObservationUpdatesAllCandidatesBeforeTopTwentyLimit() async throws { + let apps = (1...21).map { index in + EnergyImpactAppSnapshot( + processIdentifier: pid_t(index), + name: "App \(index)", + bundleIdentifier: nil, + bundleURL: nil + ) + } + var results: [pid_t: [ProcessEnergyReadResult]] = [:] + for index in 1...21 { + let delta = index == 21 ? UInt64(100_000) : UInt64(index * 1_000) + results[pid_t(index)] = [ + .success(reading(energy: 1_000, start: UInt64(index))), + .success(reading(energy: 1_000 + delta, start: UInt64(index))), + ] + } + let reader = ProcessEnergyReadingProviderStub(results: results) + let sampler = EnergyImpactSampler( + reader: reader, + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + clock: EnergyImpactClockStub(times: [0, 3]) ) - } + let lease = try require(await sampler.beginSession(.init(generation: 1))) - private func makeService( - results: [ProcessEnergyReadResult], - times: [TimeInterval] - ) -> EnergyImpactSamplerHarness { - EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(results: [101: results]), - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: []), - appSnapshotProvider: { [ - .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), - ] }, - clock: RegressionEnergyImpactClockStub(times: times) - ) + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) + let rows = try require(await sampler.observe(lease: lease, apps: apps, limit: 20)) + + XCTAssertEqual(rows.count, 20) + XCTAssertTrue(rows.contains { $0.processIdentifier == 21 }) + XCTAssertEqual(reader.readCount(for: 21), 2) } - private func makeTwoProcessService( - rootResults: [ProcessEnergyReadResult], - helperResults: [ProcessEnergyReadResult], - times: [TimeInterval] - ) -> EnergyImpactSamplerHarness { - EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(results: [ - 100: rootResults, - 101: helperResults, - ]), - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: [ - .init(processIdentifier: 101, parentProcessIdentifier: 100), - ]), - appSnapshotProvider: { [ - .init(processIdentifier: 100, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), - ] }, - clock: RegressionEnergyImpactClockStub(times: times) - ) + func testConfigurationUsesSingleThreeSecondObservationInterval() async { + let configuration = EnergyImpactConfiguration.production + + XCTAssertEqual(configuration.observationIntervalSeconds, 3) } - private func makeReparentingService( - ownersBySample: [pid_t], - energies: [UInt64], - times: [TimeInterval] - ) -> EnergyImpactSamplerHarness { - EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(results: [ - 100: ownersBySample.map { _ in .failure(.permissionDenied) }, - 200: ownersBySample.map { _ in .failure(.permissionDenied) }, - 300: energies.map { .success(reading(energy: $0, start: 30)) }, + func testIrregularObservationUsesActualElapsedInsteadOfConfiguredInterval() async throws { + let sampler = EnergyImpactSampler( + reader: ProcessEnergyReadingProviderStub(results: [ + 100: [ + .success(reading(energy: 1_000)), + .success(reading(energy: 6_000)), + ], ]), - processSnapshotReader: RegressionSequencedProcessParentSnapshotReaderStub( - snapshotsByCall: ownersBySample.map { owner in - [.init(processIdentifier: 300, parentProcessIdentifier: owner)] - } - ), - appSnapshotProvider: { [ - .init(processIdentifier: 100, name: "First", bundleIdentifier: nil, bundleURL: nil), - .init(processIdentifier: 200, name: "Second", bundleIdentifier: nil, bundleURL: nil), - ] }, - clock: RegressionEnergyImpactClockStub(times: times) + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + clock: EnergyImpactClockStub(times: [0, 5]), + configuration: .production ) - } + let lease = try require(await sampler.beginSession(.init(generation: 1))) + let apps = [EnergyImpactAppSnapshot(processIdentifier: 100, name: "A", bundleIdentifier: nil, bundleURL: nil)] - private func makeMixedGapService( - rootEnergies: [UInt64], - helperResults: [ProcessEnergyReadResult], - times: [TimeInterval] - ) -> EnergyImpactSamplerHarness { - makeTwoProcessService( - rootResults: rootEnergies.map { .success(reading(energy: $0, start: 10)) }, - helperResults: helperResults, - times: times + _ = await sampler.observe(lease: lease, apps: apps, limit: 20) + let row = try require( + await sampler.observe(lease: lease, apps: apps, limit: 20)?.first ) - } - private func entry( - processIdentifier: pid_t, - name: String, - power: Double?, - status: EnergyImpactStatus - ) -> EnergyImpactEntry { - EnergyImpactEntry( - identity: EnergyImpactAppIdentity( - rootProcessIdentifier: processIdentifier, - rootProcessStartAbsoluteTime: UInt64(processIdentifier) - ), - name: name, - bundleIdentifier: nil, - bundleURL: nil, - currentPowerMicrowatts: power, - sustainedPowerMicrowatts: nil, - rankingScore: power, - trend: .steady, - coverage: .unavailable, - status: status - ) + XCTAssertEqual(try require(row.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(row.coverage.validProcessSeconds, 5, accuracy: 0.001) + XCTAssertEqual(row.coverage.discoveredProcessSeconds, 5, accuracy: 0.001) } - func testEnergyImpactEntryRepresentsCollectingWithoutAFalseZero() { + func testEnergyImpactEntryRepresentsCollectingWithoutAFalseZero() async { let entry = EnergyImpactEntry( identity: EnergyImpactAppIdentity( rootProcessIdentifier: 101, @@ -542,7 +564,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { XCTAssertEqual(entry.status, .collecting) } - func testEnergyImpactCoverageUsesValidPIDTime() { + func testEnergyImpactCoverageUsesValidPIDTime() async { let coverage = EnergyImpactCoverage( discoveredProcessCount: 4, readableProcessCount: 3, @@ -553,7 +575,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { XCTAssertEqual(coverage.fraction, 0.75, accuracy: 0.001) } - func testEnergyImpactCoverageIsZeroWithoutDiscoveredPIDTime() { + func testEnergyImpactCoverageIsZeroWithoutDiscoveredPIDTime() async { let coverage = EnergyImpactCoverage( discoveredProcessCount: 0, readableProcessCount: 0, @@ -564,15 +586,15 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { XCTAssertEqual(coverage.fraction, 0) } - func testEnergyImpactSamplerHarnessReportsPartialCoverageWhenOneDescendantHasNoValidDelta() throws { + func testEnergyImpactSamplerReportsPartialCoverageWhenOneDescendantHasNoValidDelta() async throws { let app = EnergyImpactAppSnapshot( processIdentifier: 100, name: "Browser", bundleIdentifier: "com.example.browser", bundleURL: nil ) - let service = EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(readings: [ + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(readings: [ 100: [ .init(energyNanojoules: 1_000, processStartAbsoluteTime: 10), .init(energyNanojoules: 2_000, processStartAbsoluteTime: 10), @@ -582,24 +604,24 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { .init(energyNanojoules: 3_000, processStartAbsoluteTime: 21), ], ]), - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: [ + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ .init(processIdentifier: 101, parentProcessIdentifier: 100), ]), appSnapshotProvider: { [app] }, - clock: RegressionEnergyImpactClockStub(times: [100, 101]) + clock: EnergyImpactClockStub(times: [100, 101]) ) - _ = service.sampleNow(limit: 1) - let entry = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let entry = try require(await service.observe(limit: 1).first) XCTAssertEqual(entry.status, .partial) XCTAssertEqual(entry.coverage.validProcessSeconds, 1) XCTAssertEqual(entry.coverage.discoveredProcessSeconds, 2) XCTAssertEqual(entry.coverage.fraction, 0.5, accuracy: 0.001) - XCTAssertEqual(try XCTUnwrap(entry.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(try require(entry.currentPowerMicrowatts), 1, accuracy: 0.001) } - func testSystemEnergyImpactClockProvidesMonotonicSeconds() { + func testSystemEnergyImpactClockProvidesMonotonicSeconds() async { let clock = SystemEnergyImpactClock() let first = clock.nowSeconds() @@ -607,17 +629,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { XCTAssertGreaterThanOrEqual(clock.nowSeconds(), first) } - func testDefaultWorkspaceSnapshotProviderBuildsEntriesFromRunningApplications() { - let service = EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(readings: [:]), - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: []) - ) - let entries = service.sampleNow(limit: 1) - - XCTAssertLessThanOrEqual(entries.count, 1) - } - - func testEnergyImpactSamplerHarnessUsesPreviousRefreshSnapshotsForImpact() throws { + func testEnergyImpactSamplerUsesPreviousRefreshSnapshotsForImpact() async throws { let apps = [ EnergyImpactAppSnapshot( processIdentifier: 101, @@ -632,7 +644,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { bundleURL: URL(fileURLWithPath: "/Applications/Notes.app") ), ] - let reader = RegressionProcessEnergyReadingProviderStub(readings: [ + let reader = ProcessEnergyReadingProviderStub(readings: [ 101: [ ProcessEnergyReading(energyNanojoules: 1_000), ProcessEnergyReading(energyNanojoules: 3_500), @@ -642,59 +654,59 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { ProcessEnergyReading(energyNanojoules: 2_300), ], ]) - let service = EnergyImpactSamplerHarness( + let service = EnergyImpactSamplerTestSession( reader: reader, - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { apps }, - clock: RegressionEnergyImpactClockStub(times: [100, 101]) + clock: EnergyImpactClockStub(times: [100, 101]) ) - let firstEntries = service.sampleNow(limit: 2) - let secondEntries = service.sampleNow(limit: 2) + let firstEntries = await service.observe(limit: 2) + let secondEntries = await service.observe(limit: 2) XCTAssertTrue(firstEntries.allSatisfy { $0.status == .collecting }) XCTAssertTrue(firstEntries.allSatisfy { $0.currentPowerMicrowatts == nil }) XCTAssertEqual(secondEntries.map(\.name), ["Safari", "Notes"]) - XCTAssertEqual(try XCTUnwrap(secondEntries[0].currentPowerMicrowatts), 2.5, accuracy: 0.001) - XCTAssertEqual(try XCTUnwrap(secondEntries[1].currentPowerMicrowatts), 0.3, accuracy: 0.001) + XCTAssertEqual(try require(secondEntries[0].currentPowerMicrowatts), 2.5, accuracy: 0.001) + XCTAssertEqual(try require(secondEntries[1].currentPowerMicrowatts), 0.3, accuracy: 0.001) XCTAssertEqual(reader.readCount(for: 101), 2) XCTAssertEqual(reader.readCount(for: 102), 2) } - func testEnergyImpactSamplerHarnessNormalizesImpactByElapsedTime() throws { + func testEnergyImpactSamplerNormalizesImpactByElapsedTime() async throws { let app = EnergyImpactAppSnapshot( processIdentifier: 101, name: "Safari", bundleIdentifier: "com.apple.Safari", bundleURL: URL(fileURLWithPath: "/Applications/Safari.app") ) - let reader = RegressionProcessEnergyReadingProviderStub(readings: [ + let reader = ProcessEnergyReadingProviderStub(readings: [ 101: [ ProcessEnergyReading(energyNanojoules: 1_000, processStartAbsoluteTime: 10), ProcessEnergyReading(energyNanojoules: 3_500, processStartAbsoluteTime: 10), ], ]) - let service = EnergyImpactSamplerHarness( + let service = EnergyImpactSamplerTestSession( reader: reader, - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [app] }, - clock: RegressionEnergyImpactClockStub(times: [100, 100.5]) + clock: EnergyImpactClockStub(times: [100, 100.5]) ) - _ = service.sampleNow(limit: 1) - let entries = service.sampleNow(limit: 1) + _ = await service.observe(limit: 1) + let entries = await service.observe(limit: 1) - XCTAssertEqual(try XCTUnwrap(entries.first?.currentPowerMicrowatts), 5.0, accuracy: 0.001) + XCTAssertEqual(try require(entries.first?.currentPowerMicrowatts), 5.0, accuracy: 0.001) } - func testEnergyImpactSamplerHarnessAggregatesDescendantEnergyIntoOwningApp() throws { + func testEnergyImpactSamplerAggregatesDescendantEnergyIntoOwningApp() async throws { let app = EnergyImpactAppSnapshot( processIdentifier: 100, name: "Browser", bundleIdentifier: "com.example.browser", bundleURL: URL(fileURLWithPath: "/Applications/Browser.app") ) - let reader = RegressionProcessEnergyReadingProviderStub(readings: [ + let reader = ProcessEnergyReadingProviderStub(readings: [ 100: [ ProcessEnergyReading(energyNanojoules: 1_000, processStartAbsoluteTime: 10), ProcessEnergyReading(energyNanojoules: 2_000, processStartAbsoluteTime: 10), @@ -708,29 +720,29 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { ProcessEnergyReading(energyNanojoules: 1_100, processStartAbsoluteTime: 12), ], ]) - let service = EnergyImpactSamplerHarness( + let service = EnergyImpactSamplerTestSession( reader: reader, - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: [ + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ ProcessParentSnapshot(processIdentifier: 100, parentProcessIdentifier: 1), ProcessParentSnapshot(processIdentifier: 101, parentProcessIdentifier: 100), ProcessParentSnapshot(processIdentifier: 102, parentProcessIdentifier: 101), ProcessParentSnapshot(processIdentifier: 999, parentProcessIdentifier: 1), ]), appSnapshotProvider: { [app] }, - clock: RegressionEnergyImpactClockStub(times: [100, 102]) + clock: EnergyImpactClockStub(times: [100, 102]) ) - _ = service.sampleNow(limit: 1) - let entries = service.sampleNow(limit: 1) + _ = await service.observe(limit: 1) + let entries = await service.observe(limit: 1) - XCTAssertEqual(try XCTUnwrap(entries.first?.currentPowerMicrowatts), 2.5, accuracy: 0.001) + XCTAssertEqual(try require(entries.first?.currentPowerMicrowatts), 2.5, accuracy: 0.001) XCTAssertEqual(reader.readCount(for: 100), 2) XCTAssertEqual(reader.readCount(for: 101), 2) XCTAssertEqual(reader.readCount(for: 102), 2) XCTAssertEqual(reader.readCount(for: 999), 0) } - func testEnergyImpactSamplerHarnessAssignsNestedRegularRootProcessesToNearestRootExactlyOnce() throws { + func testEnergyImpactSamplerAssignsNestedRegularRootProcessesToNearestRootExactlyOnce() async throws { let apps = [ EnergyImpactAppSnapshot( processIdentifier: 100, @@ -745,7 +757,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { bundleURL: nil ), ] - let reader = RegressionProcessEnergyReadingProviderStub(readings: [ + let reader = ProcessEnergyReadingProviderStub(readings: [ 100: [ .init(energyNanojoules: 1_000, processStartAbsoluteTime: 10), .init(energyNanojoules: 2_000, processStartAbsoluteTime: 10), @@ -763,85 +775,86 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { .init(energyNanojoules: 5_000, processStartAbsoluteTime: 25), ], ]) - let service = EnergyImpactSamplerHarness( + let service = EnergyImpactSamplerTestSession( reader: reader, - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: [ + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ .init(processIdentifier: 100, parentProcessIdentifier: 1), .init(processIdentifier: 150, parentProcessIdentifier: 100), .init(processIdentifier: 200, parentProcessIdentifier: 150), .init(processIdentifier: 250, parentProcessIdentifier: 200), ]), appSnapshotProvider: { apps }, - clock: RegressionEnergyImpactClockStub(times: [100, 101]) + clock: EnergyImpactClockStub(times: [100, 101]) ) - _ = service.sampleNow(limit: 2) - let entries = service.sampleNow(limit: 2) + _ = await service.observe(limit: 2) + let entries = await service.observe(limit: 2) let powerByProcess = Dictionary(uniqueKeysWithValues: entries.map { ($0.processIdentifier, $0.currentPowerMicrowatts) }) - XCTAssertEqual(try XCTUnwrap(powerByProcess[100] ?? nil), 3, accuracy: 0.001) - XCTAssertEqual(try XCTUnwrap(powerByProcess[200] ?? nil), 7, accuracy: 0.001) + XCTAssertEqual(try require(powerByProcess[100] ?? nil), 3, accuracy: 0.001) + XCTAssertEqual(try require(powerByProcess[200] ?? nil), 7, accuracy: 0.001) XCTAssertEqual(reader.readCount(for: 100), 2) XCTAssertEqual(reader.readCount(for: 150), 2) XCTAssertEqual(reader.readCount(for: 200), 2) XCTAssertEqual(reader.readCount(for: 250), 2) } - func testEnergyImpactSamplerHarnessRejectsDeltasWhenPIDIsReused() { + func testEnergyImpactSamplerRejectsDeltasWhenPIDIsReused() async { let app = EnergyImpactAppSnapshot( processIdentifier: 101, name: "Reused", bundleIdentifier: "com.example.reused", bundleURL: nil ) - let reader = RegressionProcessEnergyReadingProviderStub(readings: [ + let reader = ProcessEnergyReadingProviderStub(readings: [ 101: [ ProcessEnergyReading(energyNanojoules: 1_000, processStartAbsoluteTime: 10), ProcessEnergyReading(energyNanojoules: 50_000, processStartAbsoluteTime: 20), ], ]) - let service = EnergyImpactSamplerHarness( + let service = EnergyImpactSamplerTestSession( reader: reader, - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [app] }, - clock: RegressionEnergyImpactClockStub(times: [100, 101]) + clock: EnergyImpactClockStub(times: [100, 101]) ) - _ = service.sampleNow(limit: 1) - let entries = service.sampleNow(limit: 1) + _ = await service.observe(limit: 1) + let entries = await service.observe(limit: 1) XCTAssertEqual(entries.first?.status, .collecting) XCTAssertNil(entries.first?.currentPowerMicrowatts) } - func testLongGapRebaselinesInsteadOfPublishingADilutedValue() { - let clock = RegressionEnergyImpactClockStub(times: [0, 3, 20]) - let service = EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(readings: [ + func testLongGapRebaselinesInsteadOfPublishingADilutedValue() async { + let clock = EnergyImpactClockStub(times: [0, 3, 20]) + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(readings: [ 101: [ .init(energyNanojoules: 1_000, processStartAbsoluteTime: 10), .init(energyNanojoules: 4_000, processStartAbsoluteTime: 10), .init(energyNanojoules: 10_000, processStartAbsoluteTime: 10), ], ]), - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [ .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), ] }, clock: clock ) - _ = service.sampleNow(limit: 1) - XCTAssertEqual(service.sampleNow(limit: 1).first?.currentPowerMicrowatts ?? -1, 1) - let afterGap = service.sampleNow(limit: 1).first + _ = await service.observe(limit: 1) + let beforeGap = await service.observe(limit: 1).first + XCTAssertEqual(beforeGap?.currentPowerMicrowatts ?? -1, 1) + let afterGap = await service.observe(limit: 1).first XCTAssertNil(afterGap?.currentPowerMicrowatts) XCTAssertEqual(afterGap?.status, .collecting) } - func testEnergyCounterRegressionRebaselinesBeforePublishingSubsequentDelta() throws { + func testEnergyCounterRegressionRebaselinesBeforePublishingSubsequentDelta() async throws { let service = makeService( results: [ .success(reading(energy: 4_000)), @@ -851,9 +864,9 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6] ) - _ = service.sampleNow(limit: 1) - let regression = try XCTUnwrap(service.sampleNow(limit: 1).first) - let recovered = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let regression = try require(await service.observe(limit: 1).first) + let recovered = try require(await service.observe(limit: 1).first) XCTAssertEqual(regression.status, .collecting) XCTAssertNil(regression.currentPowerMicrowatts) @@ -861,7 +874,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { XCTAssertEqual(recovered.currentPowerMicrowatts, 1) } - func testNewRegularRootWithoutSnapshotCannotReuseFormerOwnerBaseline() throws { + func testNewRegularRootWithoutSnapshotCannotReuseFormerOwnerBaseline() async throws { let root = EnergyImpactAppSnapshot( processIdentifier: 100, name: "Root", @@ -878,8 +891,8 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { [root], [root, promotedHelper], ] - let service = EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(results: [ + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(results: [ 100: [ .success(reading(energy: 1_000)), .success(reading(energy: 4_000)), @@ -889,51 +902,51 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { .success(reading(energy: 4_000, start: 20)), ], ]), - processSnapshotReader: RegressionSequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ [.init(processIdentifier: 200, parentProcessIdentifier: 100)], [], ]), appSnapshotProvider: { appSnapshots.removeFirst() }, - clock: RegressionEnergyImpactClockStub(times: [0, 3]) + clock: EnergyImpactClockStub(times: [0, 3]) ) - _ = service.sampleNow(limit: 2) - let promoted = try XCTUnwrap( - service.sampleNow(limit: 2).first { $0.processIdentifier == 200 } + _ = await service.observe(limit: 2) + let promoted = try require( + await service.observe(limit: 2).first { $0.processIdentifier == 200 } ) XCTAssertEqual(promoted.status, .collecting) XCTAssertNil(promoted.currentPowerMicrowatts) } - func testClockRollbackCannotProduceANegativeOrInfinitePower() { - let clock = RegressionEnergyImpactClockStub(times: [3, 2]) - let service = EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(readings: [ + func testClockRollbackCannotProduceANegativeOrInfinitePower() async { + let clock = EnergyImpactClockStub(times: [3, 2]) + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(readings: [ 101: [ .init(energyNanojoules: 1_000, processStartAbsoluteTime: 10), .init(energyNanojoules: 5_000, processStartAbsoluteTime: 10), ], ]), - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [ .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), ] }, clock: clock ) - _ = service.sampleNow(limit: 1) - let entry = service.sampleNow(limit: 1).first + _ = await service.observe(limit: 1) + let entry = await service.observe(limit: 1).first XCTAssertNil(entry?.currentPowerMicrowatts) XCTAssertEqual(entry?.status, .collecting) } - func testEnergyImpactSamplerHarnessKeepsUnreadableAppsAsUnavailableRows() { - let reader = RegressionProcessEnergyReadingProviderStub(readings: [:]) - let service = EnergyImpactSamplerHarness( + func testEnergyImpactSamplerKeepsUnreadableAppsAsUnavailableRows() async { + let reader = ProcessEnergyReadingProviderStub(readings: [:]) + let service = EnergyImpactSamplerTestSession( reader: reader, - processSnapshotReader: RegressionProcessParentSnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [ EnergyImpactAppSnapshot( @@ -945,7 +958,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { ] } ) - let entries = service.sampleNow(limit: 1) + let entries = await service.observe(limit: 1) XCTAssertEqual(entries.count, 1) XCTAssertEqual(entries[0].name, "Locked App") @@ -954,7 +967,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: an unreadable helper is collapsed into a false full-coverage value. - func testOneUnreadableHelperProducesPartialCoverageWithoutAFalseZero() throws { + func testOneUnreadableHelperProducesPartialCoverageWithoutAFalseZero() async throws { let service = makeTwoProcessService( rootResults: [ .success(reading(energy: 1_000)), @@ -967,11 +980,11 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3] ) - _ = service.sampleNow(limit: 1) - let entry = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let entry = try require(await service.observe(limit: 1).first) XCTAssertEqual(entry.status, .partial) - XCTAssertEqual(try XCTUnwrap(entry.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(try require(entry.currentPowerMicrowatts), 1, accuracy: 0.001) XCTAssertEqual(entry.coverage.readableProcessCount, 1) XCTAssertEqual(entry.coverage.discoveredProcessCount, 2) XCTAssertEqual(entry.coverage.validProcessSeconds, 3, accuracy: 0.001) @@ -980,7 +993,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: a temporary read failure deletes the generation baseline needed for recovery. - func testTemporaryFailureKeepsBaselineAndRecoveryUsesTheBoundedInterval() throws { + func testTemporaryFailureKeepsBaselineAndRecoveryUsesTheBoundedInterval() async throws { let service = makeService( results: [ .success(reading(energy: 1_000)), @@ -990,17 +1003,17 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6] ) - _ = service.sampleNow(limit: 1) - let failed = try XCTUnwrap(service.sampleNow(limit: 1).first) - let recovered = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let failed = try require(await service.observe(limit: 1).first) + let recovered = try require(await service.observe(limit: 1).first) XCTAssertNotEqual(failed.status, .stable) - XCTAssertEqual(try XCTUnwrap(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(try require(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) XCTAssertEqual(recovered.status, .stable) } // Production break caught: a baseline older than the ten-second bound still emits a diluted recovery value. - func testFailurePastTenSecondsExpiresTheBaseline() throws { + func testFailurePastTenSecondsExpiresTheBaseline() async throws { let service = makeService( results: [ .success(reading(energy: 1_000)), @@ -1010,16 +1023,16 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 14] ) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - let entry = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let entry = try require(await service.observe(limit: 1).first) XCTAssertNil(entry.currentPowerMicrowatts) XCTAssertEqual(entry.status, .collecting) } // Production break caught: an unsupported zero-only counter is published forever as a confirmed zero. - func testZeroEnergyCounterWithAdvancingCPUBecomesUnsupportedAfterTenSeconds() throws { + func testZeroEnergyCounterWithAdvancingCPUBecomesUnsupportedAfterTenSeconds() async throws { let service = makeService( results: [ .success(reading(energy: 0, userCPU: 1_000, systemCPU: 100)), @@ -1031,13 +1044,13 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6, 9, 12] ) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - let confirmedZero = try XCTUnwrap(service.sampleNow(limit: 1).first) - let entry = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let confirmedZero = try require(await service.observe(limit: 1).first) + let entry = try require(await service.observe(limit: 1).first) - XCTAssertEqual(try XCTUnwrap(confirmedZero.currentPowerMicrowatts), 0, accuracy: 0.001) + XCTAssertEqual(try require(confirmedZero.currentPowerMicrowatts), 0, accuracy: 0.001) XCTAssertEqual(confirmedZero.status, .stable) XCTAssertNil(entry.currentPowerMicrowatts) XCTAssertNil(entry.rankingScore) @@ -1047,7 +1060,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: a rollback interval advances zero-counter evidence from an older retained baseline. - func testClockRollbackAfterFailureRebaselinesZeroCounterEvidence() throws { + func testClockRollbackAfterFailureRebaselinesZeroCounterEvidence() async throws { let service = makeService( results: [ .success(reading(energy: 0, userCPU: 1_000)), @@ -1058,19 +1071,19 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 2, 11] ) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - let rebaselined = try XCTUnwrap(service.sampleNow(limit: 1).first) - let validInterval = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let rebaselined = try require(await service.observe(limit: 1).first) + let validInterval = try require(await service.observe(limit: 1).first) XCTAssertEqual(rebaselined.status, .collecting) XCTAssertNil(rebaselined.currentPowerMicrowatts) XCTAssertEqual(validInterval.status, .stable) - XCTAssertEqual(try XCTUnwrap(validInterval.currentPowerMicrowatts), 0, accuracy: 0.001) + XCTAssertEqual(try require(validInterval.currentPowerMicrowatts), 0, accuracy: 0.001) } // Production break caught: a failed rollback leaves a future baseline connected to the next clock epoch. - func testFailedRollbackInvalidatesRetainedBaselineContinuity() throws { + func testFailedRollbackInvalidatesRetainedBaselineContinuity() async throws { let service = makeService( results: [ .success(reading(energy: 1_000)), @@ -1080,9 +1093,9 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [10, 5, 12] ) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - let afterRollback = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let afterRollback = try require(await service.observe(limit: 1).first) XCTAssertEqual(afterRollback.status, .collecting) XCTAssertNil(afterRollback.currentPowerMicrowatts) @@ -1091,24 +1104,24 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: a helper delta spanning an owner change is assigned to the new root. - func testOwnerChangeDiscardsTheTransitionInterval() { + func testOwnerChangeDiscardsTheTransitionInterval() async { let service = makeReparentingService( ownersBySample: [100, 200], energies: [1_000, 9_000], times: [0, 3] ) - _ = service.sampleNow(limit: 2) - let entries = service.sampleNow(limit: 2) + _ = await service.observe(limit: 2) + let entries = await service.observe(limit: 2) XCTAssertTrue(entries.allSatisfy { $0.currentPowerMicrowatts == nil }) XCTAssertTrue(entries.allSatisfy { $0.status != .stable }) } // Production break caught: a failed owner excursion is erased when the helper returns to its old root. - func testUnreadableOwnerExcursionBreaksRecoveredHelperContinuity() throws { - let service = EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(results: [ + func testUnreadableOwnerExcursionBreaksRecoveredHelperContinuity() async throws { + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(results: [ 100: [ .failure(.permissionDenied), .failure(.permissionDenied), @@ -1125,7 +1138,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { .success(reading(energy: 7_000, start: 30)), ], ]), - processSnapshotReader: RegressionSequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ [.init(processIdentifier: 300, parentProcessIdentifier: 100)], [.init(processIdentifier: 300, parentProcessIdentifier: 200)], [.init(processIdentifier: 300, parentProcessIdentifier: 100)], @@ -1134,13 +1147,13 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { .init(processIdentifier: 100, name: "First", bundleIdentifier: nil, bundleURL: nil), .init(processIdentifier: 200, name: "Second", bundleIdentifier: nil, bundleURL: nil), ] }, - clock: RegressionEnergyImpactClockStub(times: [0, 3, 6]) + clock: EnergyImpactClockStub(times: [0, 3, 6]) ) - _ = service.sampleNow(limit: 2) - _ = service.sampleNow(limit: 2) - let entries = service.sampleNow(limit: 2) - let returnedOwner = try XCTUnwrap(entries.first { $0.processIdentifier == 100 }) + _ = await service.observe(limit: 2) + _ = await service.observe(limit: 2) + let entries = await service.observe(limit: 2) + let returnedOwner = try require(entries.first { $0.processIdentifier == 100 }) XCTAssertEqual(returnedOwner.status, .collecting) XCTAssertNil(returnedOwner.currentPowerMicrowatts) @@ -1149,9 +1162,9 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: an observed helper temporarily outside every regular root reconnects to its old baseline. - func testObservedUnownedHelperBreaksRecoveredContinuity() throws { - let service = EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(results: [ + func testObservedUnownedHelperBreaksRecoveredContinuity() async throws { + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(results: [ 100: [ .failure(.permissionDenied), .failure(.permissionDenied), @@ -1162,7 +1175,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { .success(reading(energy: 7_000, start: 30)), ], ]), - processSnapshotReader: RegressionSequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ [.init(processIdentifier: 300, parentProcessIdentifier: 100)], [.init(processIdentifier: 300, parentProcessIdentifier: 999)], [.init(processIdentifier: 300, parentProcessIdentifier: 100)], @@ -1170,12 +1183,12 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { appSnapshotProvider: { [ .init(processIdentifier: 100, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), ] }, - clock: RegressionEnergyImpactClockStub(times: [0, 3, 6]) + clock: EnergyImpactClockStub(times: [0, 3, 6]) ) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - let returned = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let returned = try require(await service.observe(limit: 1).first) XCTAssertEqual(returned.status, .collecting) XCTAssertNil(returned.currentPowerMicrowatts) @@ -1184,7 +1197,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: recovered helper energy uses six seconds of numerator against three seconds of PID-time. - func testRootAndRecoveredHelperUseMatchingIntervalEnergyAndCoverage() throws { + func testRootAndRecoveredHelperUseMatchingIntervalEnergyAndCoverage() async throws { let service = makeMixedGapService( rootEnergies: [1_000, 4_000, 7_000], helperResults: [ @@ -1195,18 +1208,23 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6] ) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - let recovered = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let recovered = try require(await service.observe(limit: 1).first) - XCTAssertEqual(try XCTUnwrap(recovered.currentPowerMicrowatts), 2, accuracy: 0.001) + let smoothingAlpha = 1 - pow(0.5, 3.0 / 4.0) + XCTAssertEqual( + try require(recovered.currentPowerMicrowatts), + 1 + smoothingAlpha, + accuracy: 0.001 + ) XCTAssertEqual(recovered.status, .stable) XCTAssertEqual(recovered.coverage.validProcessSeconds, 6, accuracy: 0.001) XCTAssertEqual(recovered.coverage.discoveredProcessSeconds, 6, accuracy: 0.001) } // Production break caught: stale output loses the confirmed root generation or refreshes its own grace window. - func testStableFailureRecoverySequencePreservesFullIdentityWithoutConfirmingStale() throws { + func testStableFailureRecoverySequencePreservesFullIdentityWithoutConfirmingStale() async throws { let service = makeService( results: [ .success(reading(energy: 1_000, start: 10)), @@ -1217,10 +1235,10 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6, 9] ) - let collecting = try XCTUnwrap(service.sampleNow(limit: 1).first) - let stable = try XCTUnwrap(service.sampleNow(limit: 1).first) - let stale = try XCTUnwrap(service.sampleNow(limit: 1).first) - let recovered = try XCTUnwrap(service.sampleNow(limit: 1).first) + let collecting = try require(await service.observe(limit: 1).first) + let stable = try require(await service.observe(limit: 1).first) + let stale = try require(await service.observe(limit: 1).first) + let recovered = try require(await service.observe(limit: 1).first) XCTAssertEqual(collecting.status, .collecting) XCTAssertEqual(stable.status, .stable) @@ -1232,11 +1250,11 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { XCTAssertNil(stale.rankingScore) XCTAssertEqual(recovered.status, .stable) XCTAssertEqual(recovered.identity, stable.identity) - XCTAssertEqual(try XCTUnwrap(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(try require(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) } // Production break caught: publishing stale repeatedly extends a three-second observation past ten seconds. - func testStalePublicationDoesNotExtendItsOwnGrace() throws { + func testStalePublicationDoesNotExtendItsOwnGrace() async throws { let service = makeService( results: [ .success(reading(energy: 1_000)), @@ -1248,17 +1266,19 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6, 12, 14] ) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - XCTAssertEqual(service.sampleNow(limit: 1).first?.status, .stale) - XCTAssertEqual(service.sampleNow(limit: 1).first?.status, .stale) - let expired = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let firstStale = await service.observe(limit: 1).first + let secondStale = await service.observe(limit: 1).first + XCTAssertEqual(firstStale?.status, .stale) + XCTAssertEqual(secondStale?.status, .stale) + let expired = try require(await service.observe(limit: 1).first) XCTAssertEqual(expired.status, .unavailable) XCTAssertNil(expired.currentPowerMicrowatts) } - func testRootGenerationRemainsConfirmedAtMaximumGapBoundary() throws { + func testRootGenerationRemainsConfirmedAtMaximumGapBoundary() async throws { let service = makeService( results: [ .success(reading(energy: 1_000)), @@ -1268,9 +1288,9 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 13] ) - _ = service.sampleNow(limit: 1) - let stable = try XCTUnwrap(service.sampleNow(limit: 1).first) - let atBoundary = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let stable = try require(await service.observe(limit: 1).first) + let atBoundary = try require(await service.observe(limit: 1).first) XCTAssertEqual(stable.status, .stable) XCTAssertEqual(stable.identity.rootProcessStartAbsoluteTime, 10) @@ -1280,7 +1300,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: a successful root generation change reuses the prior generation's stale display. - func testRootGenerationChangeImmediatelyDiscardsOldDisplay() throws { + func testRootGenerationChangeImmediatelyDiscardsOldDisplay() async throws { let service = makeTwoProcessService( rootResults: [ .success(reading(energy: 1_000, start: 10)), @@ -1297,10 +1317,10 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6, 9] ) - _ = service.sampleNow(limit: 1) - let old = try XCTUnwrap(service.sampleNow(limit: 1).first) - let changed = try XCTUnwrap(service.sampleNow(limit: 1).first) - let stale = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let old = try require(await service.observe(limit: 1).first) + let changed = try require(await service.observe(limit: 1).first) + let stale = try require(await service.observe(limit: 1).first) XCTAssertEqual(old.status, .stable) XCTAssertEqual(old.identity.rootProcessStartAbsoluteTime, 10) @@ -1313,7 +1333,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: helper-only partial samples renew an expired root generation. - func testPartialDescendantSamplesDoNotExtendRootGenerationPastConfirmationGap() throws { + func testPartialDescendantSamplesDoNotExtendRootGenerationPastConfirmationGap() async throws { let service = makeTwoProcessService( rootResults: [ .success(reading(energy: 1_000, start: 10)), @@ -1334,18 +1354,18 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6, 9, 12, 15] ) - _ = service.sampleNow(limit: 1) - let confirmed = try XCTUnwrap(service.sampleNow(limit: 1).first) - _ = service.sampleNow(limit: 1) - _ = service.sampleNow(limit: 1) - let withinConfirmationGap = try XCTUnwrap(service.sampleNow(limit: 1).first) - let expired = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let confirmed = try require(await service.observe(limit: 1).first) + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let withinConfirmationGap = try require(await service.observe(limit: 1).first) + let expired = try require(await service.observe(limit: 1).first) XCTAssertEqual(confirmed.status, .stable) XCTAssertEqual(confirmed.identity.rootProcessStartAbsoluteTime, 10) XCTAssertEqual(withinConfirmationGap.status, .partial) XCTAssertEqual(withinConfirmationGap.identity.rootProcessStartAbsoluteTime, 10) - XCTAssertEqual(withinConfirmationGap.currentPowerMicrowatts, 1) + XCTAssertNotNil(withinConfirmationGap.currentPowerMicrowatts) XCTAssertEqual(expired.status, .partial) XCTAssertNil(expired.identity.rootProcessStartAbsoluteTime) XCTAssertEqual(expired.currentPowerMicrowatts, 1) @@ -1354,9 +1374,9 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: an expired root locator assigns helper-only data to a reused PID's old generation. - func testExpiredRootLocatorDoesNotLabelReusedPIDHelperDataWithOldGeneration() throws { - let service = EnergyImpactSamplerHarness( - reader: RegressionProcessEnergyReadingProviderStub(results: [ + func testExpiredRootLocatorDoesNotLabelReusedPIDHelperDataWithOldGeneration() async throws { + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(results: [ 100: [ .success(reading(energy: 1_000, start: 10)), .success(reading(energy: 4_000, start: 10)), @@ -1370,7 +1390,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { .success(reading(energy: 7_000, start: 30)), ], ]), - processSnapshotReader: RegressionSequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ [], [], [.init(processIdentifier: 300, parentProcessIdentifier: 100)], @@ -1380,14 +1400,14 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { appSnapshotProvider: { [ .init(processIdentifier: 100, name: "Reused", bundleIdentifier: nil, bundleURL: nil), ] }, - clock: RegressionEnergyImpactClockStub(times: [0, 3, 14, 17, 20]) + clock: EnergyImpactClockStub(times: [0, 3, 14, 17, 20]) ) - _ = service.sampleNow(limit: 1) - let old = try XCTUnwrap(service.sampleNow(limit: 1).first) - let expired = try XCTUnwrap(service.sampleNow(limit: 1).first) - let helperOnly = try XCTUnwrap(service.sampleNow(limit: 1).first) - let established = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let old = try require(await service.observe(limit: 1).first) + let expired = try require(await service.observe(limit: 1).first) + let helperOnly = try require(await service.observe(limit: 1).first) + let established = try require(await service.observe(limit: 1).first) XCTAssertEqual(old.status, .stable) XCTAssertEqual(old.identity.rootProcessStartAbsoluteTime, 10) @@ -1401,7 +1421,7 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { } // Production break caught: stale wins when only some PIDs are unsupported, or survives when all are unsupported. - func testAllExplicitlyUnsupportedProcessesOverrideBoundedStaleDisplay() throws { + func testAllExplicitlyUnsupportedProcessesOverrideBoundedStaleDisplay() async throws { let service = makeTwoProcessService( rootResults: [ .success(reading(energy: 1_000)), @@ -1418,10 +1438,11 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { times: [0, 3, 6, 9] ) - _ = service.sampleNow(limit: 1) - XCTAssertEqual(service.sampleNow(limit: 1).first?.status, .stable) - let mixed = try XCTUnwrap(service.sampleNow(limit: 1).first) - let unsupported = try XCTUnwrap(service.sampleNow(limit: 1).first) + _ = await service.observe(limit: 1) + let stable = await service.observe(limit: 1).first + XCTAssertEqual(stable?.status, .stable) + let mixed = try require(await service.observe(limit: 1).first) + let unsupported = try require(await service.observe(limit: 1).first) XCTAssertEqual(mixed.status, .stale) XCTAssertEqual(mixed.currentPowerMicrowatts, 2) @@ -1430,8 +1451,238 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { XCTAssertNil(unsupported.rankingScore) } + func testHelperMoveRebaselinesBothRootsWithoutTransitionAttribution() async throws { + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(results: [ + 100: Array(repeating: .failure(.permissionDenied), count: 3), + 200: Array(repeating: .failure(.permissionDenied), count: 3), + 300: [ + .success(reading(energy: 1_000, start: 30)), + .success(reading(energy: 4_000, start: 30)), + .success(reading(energy: 7_000, start: 30)), + ], + ]), + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + [.init(processIdentifier: 300, parentProcessIdentifier: 200)], + [.init(processIdentifier: 300, parentProcessIdentifier: 200)], + ]), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "First", bundleIdentifier: nil, bundleURL: nil), + .init(processIdentifier: 200, name: "Second", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: [0, 3, 6]) + ) + + let first = Dictionary(uniqueKeysWithValues: await service.observe(limit: 2).map { + ($0.processIdentifier, $0) + }) + let moved = Dictionary(uniqueKeysWithValues: await service.observe(limit: 2).map { + ($0.processIdentifier, $0) + }) + let settled = Dictionary(uniqueKeysWithValues: await service.observe(limit: 2).map { + ($0.processIdentifier, $0) + }) + + func assertNonnumeric( + _ row: EnergyImpactEntry, + status: EnergyImpactStatus, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(row.status, status, file: file, line: line) + XCTAssertNil(row.currentPowerMicrowatts, file: file, line: line) + XCTAssertNil(row.sustainedPowerMicrowatts, file: file, line: line) + XCTAssertNil(row.rankingScore, file: file, line: line) + } + + assertNonnumeric(try require(first[100]), status: .collecting) + assertNonnumeric(try require(first[200]), status: .unavailable) + assertNonnumeric(try require(moved[100]), status: .unavailable) + assertNonnumeric(try require(moved[200]), status: .collecting) + assertNonnumeric(try require(settled[100]), status: .unavailable) + + let settledSecondRoot = try require(settled[200]) + XCTAssertEqual(settledSecondRoot.status, .partial) + XCTAssertEqual( + try require(settledSecondRoot.currentPowerMicrowatts), + 1, + accuracy: 0.001 + ) + XCTAssertNil(settledSecondRoot.sustainedPowerMicrowatts) + XCTAssertEqual( + try require(settledSecondRoot.rankingScore), + 1, + accuracy: 0.001 + ) + } + + func testHelperDisappearancePreservesSameGenerationOwnerContinuityWithinGap() async throws { + let reader = ProcessEnergyReadingProviderStub(results: [ + 100: Array(repeating: .failure(.permissionDenied), count: 3), + 300: [ + .success(reading(energy: 1_000, start: 30)), + .success(reading(energy: 7_000, start: 30)), + ], + ]) + let service = EnergyImpactSamplerTestSession( + reader: reader, + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + [], + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + ]), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "Root", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: [0, 3, 6]) + ) + + _ = await service.observe(limit: 1) + _ = await service.observe(limit: 1) + let recovered = try require(await service.observe(limit: 1).first) + + XCTAssertEqual(reader.readCount(for: 100), 3) + XCTAssertEqual(reader.readCount(for: 300), 2) + XCTAssertEqual(recovered.status, .partial) + XCTAssertEqual(try require(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(recovered.coverage.validProcessSeconds, 3, accuracy: 0.001) + XCTAssertEqual(recovered.coverage.discoveredProcessSeconds, 6, accuracy: 0.001) + XCTAssertEqual(recovered.coverage.fraction, 0.5, accuracy: 0.001) + } + + func testReorderedEquivalentSnapshotPreservesContinuity() async throws { + func makeService(snapshots: [[ProcessParentSnapshot]]) -> EnergyImpactSamplerTestSession { + EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(readings: [ + 100: [reading(energy: 1_000), reading(energy: 4_000)], + 300: [reading(energy: 2_000, start: 30), reading(energy: 5_000, start: 30)], + ]), + processSnapshotReader: SequencedProcessParentSnapshotReaderStub( + snapshotsByCall: snapshots + ), + appSnapshotProvider: { + [.init(processIdentifier: 100, name: "Root", bundleIdentifier: nil, bundleURL: nil)] + }, + clock: EnergyImpactClockStub(times: [0, 3]) + ) + } + let ordered = makeService(snapshots: [ + [ + .init(processIdentifier: 100, parentProcessIdentifier: 1), + .init(processIdentifier: 300, parentProcessIdentifier: 100), + ], + [ + .init(processIdentifier: 100, parentProcessIdentifier: 1), + .init(processIdentifier: 300, parentProcessIdentifier: 100), + ], + ]) + let reordered = makeService(snapshots: [ + [ + .init(processIdentifier: 100, parentProcessIdentifier: 1), + .init(processIdentifier: 300, parentProcessIdentifier: 100), + ], + [ + .init(processIdentifier: 300, parentProcessIdentifier: 100), + .init(processIdentifier: 100, parentProcessIdentifier: 1), + ], + ]) + + _ = await ordered.observe(limit: 1) + let expected = try require(await ordered.observe(limit: 1).first) + _ = await reordered.observe(limit: 1) + let actual = try require(await reordered.observe(limit: 1).first) + + XCTAssertEqual(actual.status, .stable) + XCTAssertEqual(actual.currentPowerMicrowatts, expected.currentPowerMicrowatts) + XCTAssertEqual(actual.coverage, expected.coverage) + } + + func testNestedRegularRootsRemainNearestOwnerAfterReorder() async throws { + let reader = ProcessEnergyReadingProviderStub(readings: [ + 100: [reading(energy: 1_000, start: 10), reading(energy: 4_000, start: 10)], + 200: [reading(energy: 2_000, start: 20), reading(energy: 5_000, start: 20)], + 300: [reading(energy: 3_000, start: 30), reading(energy: 6_000, start: 30)], + ]) + let service = EnergyImpactSamplerTestSession( + reader: reader, + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [ + .init(processIdentifier: 100, parentProcessIdentifier: 1), + .init(processIdentifier: 200, parentProcessIdentifier: 100), + .init(processIdentifier: 300, parentProcessIdentifier: 200), + ], + [ + .init(processIdentifier: 300, parentProcessIdentifier: 200), + .init(processIdentifier: 200, parentProcessIdentifier: 100), + .init(processIdentifier: 100, parentProcessIdentifier: 1), + ], + ]), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "Outer", bundleIdentifier: nil, bundleURL: nil), + .init(processIdentifier: 200, name: "Inner", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: [0, 3]) + ) + + _ = await service.observe(limit: 2) + let entries = Dictionary(uniqueKeysWithValues: await service.observe(limit: 2).map { + ($0.processIdentifier, $0) + }) + + let outer = try require(entries[100]) + let inner = try require(entries[200]) + XCTAssertEqual( + try require(outer.currentPowerMicrowatts), + 1, + accuracy: 0.001 + ) + XCTAssertEqual( + try require(inner.currentPowerMicrowatts), + 2, + accuracy: 0.001 + ) + XCTAssertEqual(reader.readCount(for: 100), 2) + XCTAssertEqual(reader.readCount(for: 200), 2) + XCTAssertEqual(reader.readCount(for: 300), 2) + } + + func testPIDReuseAndLongGapRemainIndependentRebaselineEvents() async throws { + let service = EnergyImpactSamplerTestSession( + reader: ProcessEnergyReadingProviderStub(readings: [ + 100: [ + reading(energy: 1_000, start: 10), + reading(energy: 4_000, start: 10), + reading(energy: 1_000, start: 20), + reading(energy: 4_000, start: 20), + reading(energy: 7_000, start: 20), + ], + ]), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + appSnapshotProvider: { + [.init(processIdentifier: 100, name: "Root", bundleIdentifier: nil, bundleURL: nil)] + }, + clock: EnergyImpactClockStub(times: [0, 3, 6, 17, 20]) + ) + + let collecting = try require(await service.observe(limit: 1).first) + let numeric = try require(await service.observe(limit: 1).first) + let replacement = try require(await service.observe(limit: 1).first) + let afterGap = try require(await service.observe(limit: 1).first) + let recovered = try require(await service.observe(limit: 1).first) + + XCTAssertEqual(collecting.status, .collecting) + XCTAssertEqual(numeric.status, .stable) + XCTAssertEqual(replacement.status, .collecting) + XCTAssertNil(replacement.currentPowerMicrowatts) + XCTAssertEqual(afterGap.status, .collecting) + XCTAssertNil(afterGap.currentPowerMicrowatts) + XCTAssertEqual(recovered.status, .stable) + XCTAssertEqual(try require(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) + } + // Production break caught: a large stale numeric value outranks fresh stable or partial rows. - func testEnergyImpactEntriesSortByStatusBucketBeforeNumericValue() { + func testEnergyImpactEntriesSortByStatusBucketBeforeNumericValue() async { let entries = [ entry(processIdentifier: 1, name: "Unavailable", power: nil, status: .unavailable), entry(processIdentifier: 2, name: "Stale", power: 1_000, status: .stale), @@ -1441,12 +1692,12 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { ] XCTAssertEqual( - EnergyImpactSamplerHarness.sortedByImpact(entries, limit: 5).map(\.name), + sortedByImpact(entries, limit: 5).map(\.name), ["Stable", "Partial", "Stale", "Collecting", "Unavailable"] ) } - func testEnergyImpactEntriesSortTiesByName() { + func testEnergyImpactEntriesSortTiesByName() async { let entries = [ EnergyImpactEntry( identity: EnergyImpactAppIdentity( @@ -1480,10 +1731,10 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { ), ] - XCTAssertEqual(EnergyImpactSamplerHarness.sortedByImpact(entries, limit: 2).map(\.name), ["Calendar", "Notes"]) + XCTAssertEqual(sortedByImpact(entries, limit: 2).map(\.name), ["Calendar", "Notes"]) } - func testUnavailableNumericEntrySortsBeforeNonnumericRegardlessOfInputOrder() { + func testUnavailableNumericEntrySortsBeforeNonnumericRegardlessOfInputOrder() async { let numeric = entry( processIdentifier: 1, name: "Numeric", @@ -1498,31 +1749,94 @@ final class EnergyImpactSamplerRegressionTests: XCTestCase { ) XCTAssertEqual( - EnergyImpactSamplerHarness.sortedByImpact([numeric, nonnumeric], limit: 2) + sortedByImpact([numeric, nonnumeric], limit: 2) .map(\.processIdentifier), [1, 2] ) XCTAssertEqual( - EnergyImpactSamplerHarness.sortedByImpact([nonnumeric, numeric], limit: 2) + sortedByImpact([nonnumeric, numeric], limit: 2) .map(\.processIdentifier), [1, 2] ) } - func testSameNameSameScoreSortsByProcessIdentifier() { + func testSameNameSameScoreSortsByProcessIdentifier() async { let entries = [ entry(processIdentifier: 2, name: "Same", power: 4.2, status: .stable), entry(processIdentifier: 1, name: "Same", power: 4.2, status: .stable), ] XCTAssertEqual( - EnergyImpactSamplerHarness.sortedByImpact(entries, limit: 2).map(\.processIdentifier), + sortedByImpact(entries, limit: 2).map(\.processIdentifier), [1, 2] ) } } -private final class RegressionEnergyImpactClockStub: EnergyImpactClock, @unchecked Sendable { +private actor ConcurrentObservationStartBarrier { + private let participantCount: Int + private var waiters: [CheckedContinuation] = [] + private var isReleased = false + + init(participantCount: Int) { + precondition(participantCount > 1) + self.participantCount = participantCount + } + + func arriveAndWait() async { + precondition( + isReleased == false, + "ConcurrentObservationStartBarrier is one-shot" + ) + + await withCheckedContinuation { continuation in + waiters.append(continuation) + guard waiters.count == participantCount else { return } + + isReleased = true + let ready = waiters + waiters.removeAll() + ready.forEach { $0.resume() } + } + } +} + +@MainActor +private final class EnergyImpactSamplerTestSession { + private let sampler: EnergyImpactSampler + private let appSnapshotProvider: () -> [EnergyImpactAppSnapshot] + private var lease: EnergyImpactSamplingLease? + + init( + reader: any ProcessEnergyReadingProvider, + processSnapshotReader: any ProcessParentSnapshotReading, + appSnapshotProvider: @escaping () -> [EnergyImpactAppSnapshot] = { [] }, + clock: any EnergyImpactClock = SystemEnergyImpactClock(), + configuration: EnergyImpactConfiguration = .production + ) { + sampler = EnergyImpactSampler( + reader: reader, + processSnapshotReader: processSnapshotReader, + clock: clock, + configuration: configuration + ) + self.appSnapshotProvider = appSnapshotProvider + } + + func observe(limit: Int) async -> [EnergyImpactEntry] { + if lease == nil { + lease = await sampler.beginSession(.init(generation: 1)) + } + guard let lease else { return [] } + return await sampler.observe( + lease: lease, + apps: appSnapshotProvider(), + limit: limit + ) ?? [] + } +} + +private final class EnergyImpactClockStub: EnergyImpactClock, @unchecked Sendable { private let lock = NSLock() private var times: [TimeInterval] @@ -1536,34 +1850,85 @@ private final class RegressionEnergyImpactClockStub: EnergyImpactClock, @uncheck } } -private final class RegressionProcessEnergyReadingProviderStub: ProcessEnergyReadingProvider, @unchecked Sendable { +private final class ProcessEnergyReadingProviderStub: ProcessEnergyReadingProvider, @unchecked Sendable { + private let lock = NSLock() private var results: [pid_t: [ProcessEnergyReadResult]] private var readCounts: [pid_t: Int] = [:] + private var totalReadCount = 0 + private var concurrentReads = 0 + private var recordedMaximumConcurrentReads = 0 + private let blockedReadNumber: Int? + private let blockedReadEntered = DispatchSemaphore(value: 0) + private let blockedReadRelease = DispatchSemaphore(value: 0) - init(readings: [pid_t: [ProcessEnergyReading]]) { + init( + readings: [pid_t: [ProcessEnergyReading]], + blockedReadNumber: Int? = nil + ) { results = readings.mapValues { $0.map(ProcessEnergyReadResult.success) } + self.blockedReadNumber = blockedReadNumber } - init(results: [pid_t: [ProcessEnergyReadResult]]) { + init( + results: [pid_t: [ProcessEnergyReadResult]], + blockedReadNumber: Int? = nil + ) { self.results = results + self.blockedReadNumber = blockedReadNumber } func reading(for processIdentifier: pid_t) -> ProcessEnergyReadResult { + lock.lock() + totalReadCount += 1 + let currentReadNumber = totalReadCount readCounts[processIdentifier, default: 0] += 1 - guard var values = results[processIdentifier], values.isEmpty == false else { - return .failure(.other(0)) + concurrentReads += 1 + recordedMaximumConcurrentReads = max( + recordedMaximumConcurrentReads, + concurrentReads + ) + lock.unlock() + + if currentReadNumber == blockedReadNumber { + blockedReadEntered.signal() + blockedReadRelease.wait() } - let value = values.removeFirst() - results[processIdentifier] = values + + lock.lock() + let value: ProcessEnergyReadResult + if var values = results[processIdentifier], values.isEmpty == false { + value = values.removeFirst() + results[processIdentifier] = values + } else { + value = .failure(.other(0)) + } + concurrentReads -= 1 + lock.unlock() return value } func readCount(for processIdentifier: pid_t) -> Int { - readCounts[processIdentifier, default: 0] + lock.lock() + defer { lock.unlock() } + return readCounts[processIdentifier, default: 0] + } + + var maximumConcurrentReads: Int { + lock.lock() + defer { lock.unlock() } + return recordedMaximumConcurrentReads + } + + func waitUntilBlocked() -> Bool { + blockedReadEntered.wait(timeout: .now() + 2) == .success + } + + func releaseBlockedRead() { + blockedReadRelease.signal() } } -private struct RegressionProcessParentSnapshotReaderStub: ProcessParentSnapshotReading { +private struct ProcessParentSnapshotReaderStub: ProcessParentSnapshotReading { let snapshotValues: [ProcessParentSnapshot] init(snapshots: [ProcessParentSnapshot]) { @@ -1575,9 +1940,10 @@ private struct RegressionProcessParentSnapshotReaderStub: ProcessParentSnapshotR } } -private final class RegressionSequencedProcessParentSnapshotReaderStub: ProcessParentSnapshotReading, @unchecked Sendable { +private final class SequencedProcessParentSnapshotReaderStub: ProcessParentSnapshotReading, @unchecked Sendable { private let lock = NSLock() private var snapshotValuesByCall: [[ProcessParentSnapshot]] + private var calls = 0 init(snapshotsByCall: [[ProcessParentSnapshot]]) { snapshotValuesByCall = snapshotsByCall @@ -1587,71 +1953,13 @@ private final class RegressionSequencedProcessParentSnapshotReaderStub: ProcessP lock.lock() defer { lock.unlock() } precondition(snapshotValuesByCall.isEmpty == false, "Process snapshot fixture exhausted") + calls += 1 return snapshotValuesByCall.removeFirst() } -} - -private final class EnergyImpactSamplerHarness { - private let sampler: EnergyImpactSampler - private let appSnapshotProvider: () -> [EnergyImpactAppSnapshot] - private let sessionID: EnergyImpactSessionID - - init( - reader: any ProcessEnergyReadingProvider = SystemProcessEnergyReader(), - processSnapshotReader: any ProcessParentSnapshotReading = SystemProcessParentSnapshotReader(), - appSnapshotProvider: (() -> [EnergyImpactAppSnapshot])? = nil, - clock: any EnergyImpactClock = SystemEnergyImpactClock() - ) { - let sampler = EnergyImpactSampler( - reader: reader, - processSnapshotReader: processSnapshotReader, - clock: clock, - configuration: .production, - processSnapshotRefreshIntervalSeconds: 0, - minimumProcessReadIntervalSeconds: 0 - ) - self.sampler = sampler - self.appSnapshotProvider = appSnapshotProvider ?? { [] } - sessionID = blockingEnergyImpactValue { - await sampler.beginSession() - } - } - - func sampleNow(limit: Int) -> [EnergyImpactEntry] { - let apps = appSnapshotProvider() - let sampler = sampler - let sessionID = sessionID - return blockingEnergyImpactValue { - await sampler.sample( - sessionID: sessionID, - apps: apps, - limit: limit, - publicationBoundary: false - ) ?? [] - } - } - static func sortedByImpact( - _ entries: [EnergyImpactEntry], - limit: Int - ) -> [EnergyImpactEntry] { - EnergyImpactSampler.sortedByImpact(entries, limit: limit) + var callCount: Int { + lock.lock() + defer { lock.unlock() } + return calls } } - -private final class EnergyImpactBlockingBox: @unchecked Sendable { - var value: Value? -} - -private func blockingEnergyImpactValue( - _ operation: @escaping @Sendable () async -> Value -) -> Value { - let semaphore = DispatchSemaphore(value: 0) - let box = EnergyImpactBlockingBox() - Task.detached { - box.value = await operation() - semaphore.signal() - } - semaphore.wait() - return box.value! -} From 88041b5fb0dd488e42b32ac816564dde4d47a0bd Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sun, 9 Aug 2026 09:35:25 +1000 Subject: [PATCH 3/4] refactor(energy): control visible observation lifecycle --- .../Models/EnergyImpactModel.swift | 152 ++++----- .../EnergyImpactModelTests.swift | 288 ++++++++++++++---- .../EnergyImpactViewTests.swift | 52 +++- 3 files changed, 339 insertions(+), 153 deletions(-) diff --git a/Sources/MacActivityApp/Models/EnergyImpactModel.swift b/Sources/MacActivityApp/Models/EnergyImpactModel.swift index 031ca8e..efc18e9 100644 --- a/Sources/MacActivityApp/Models/EnergyImpactModel.swift +++ b/Sources/MacActivityApp/Models/EnergyImpactModel.swift @@ -24,127 +24,107 @@ final class EnergyImpactModel: ObservableObject { private let provider: any EnergyImpactProviding private let limit: Int - private let initialWindowNanoseconds: UInt64 + private let observationIntervalNanoseconds: UInt64 + private let nowNanoseconds: () -> UInt64 private let sleep: (UInt64) async throws -> Void private var activeRunID: UUID? init( provider: any EnergyImpactProviding = EnergyImpactService(), limit: Int = 20, - initialWindowNanoseconds: UInt64 = 3_000_000_000, + observationIntervalNanoseconds: UInt64 = 3_000_000_000, + nowNanoseconds: @escaping () -> UInt64 = { + DispatchTime.now().uptimeNanoseconds + }, sleep: @escaping (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) } ) { self.provider = provider self.limit = limit - self.initialWindowNanoseconds = initialWindowNanoseconds + self.observationIntervalNanoseconds = observationIntervalNanoseconds + self.nowNanoseconds = nowNanoseconds self.sleep = sleep } - func refresh() async { - let runID = beginRun() - guard canContinue(runID) else { - finishIfCurrent(runID) - return - } - await performRun(runID, refreshIntervalNanoseconds: nil) - } - func refreshWhileVisible( - refreshIntervalNanoseconds: UInt64 = 3_000_000_000 + scope: EnergyImpactAppScope = .regularOnly ) async { - let runID = beginRun() - guard canContinue(runID) else { - finishIfCurrent(runID) - return - } - await performRun( - runID, - refreshIntervalNanoseconds: refreshIntervalNanoseconds - ) - } - - private func beginRun() -> UUID { let runID = UUID() activeRunID = runID isRefreshing = true - return runID - } - private func performRun( - _ runID: UUID, - refreshIntervalNanoseconds: UInt64? - ) async { - let lease = await provider.beginSession() - guard canContinue(runID), let lease else { - if let lease { - await provider.endSession(lease) - } - finishIfCurrent(runID) + guard Task.isCancelled == false else { + finishRun(runID) return } - - var remainsActive = await observeAndPublish( - lease: lease, - runID: runID - ) - if remainsActive { - remainsActive = await sleepAndObserve( - initialWindowNanoseconds, - lease: lease, - runID: runID - ) + guard let lease = await provider.beginSession() else { + finishRun(runID) + return } - if let refreshIntervalNanoseconds { - while remainsActive { - remainsActive = await sleepAndObserve( - refreshIntervalNanoseconds, + var deadline = nowNanoseconds() + do { + while Task.isCancelled == false, activeRunID == runID { + let now = nowNanoseconds() + if deadline > now { + try await sleep(deadline - now) + } + guard Task.isCancelled == false, + activeRunID == runID else { + break + } + guard let observed = await provider.observe( lease: lease, - runID: runID + limit: limit, + scope: scope + ) else { + break + } + guard Task.isCancelled == false, + activeRunID == runID else { + break + } + + entries = observed + isRefreshing = false + deadline = Self.firstFutureDeadline( + after: deadline, + now: nowNanoseconds(), + interval: observationIntervalNanoseconds ) } - } - - await provider.endSession(lease) - finishIfCurrent(runID) - } - - private func sleepAndObserve( - _ duration: UInt64, - lease: EnergyImpactSamplingLease, - runID: UUID - ) async -> Bool { - do { - try await sleep(duration) + } catch is CancellationError { + // Normal hidden-page exit; cleanup below still runs. } catch { - return false + // Preserve the last honest rows; a future visible run retries. } - guard canContinue(runID) else { return false } - return await observeAndPublish(lease: lease, runID: runID) - } - private func observeAndPublish( - lease: EnergyImpactSamplingLease, - runID: UUID - ) async -> Bool { - let observed = await provider.observe( - lease: lease, - limit: limit, - scope: .regularOnly - ) - guard canContinue(runID), let observed else { return false } - guard canContinue(runID) else { return false } - entries = observed - return true + await provider.endSession(lease) + finishRun(runID) } - private func canContinue(_ runID: UUID) -> Bool { - Task.isCancelled == false && activeRunID == runID + private static func firstFutureDeadline( + after previousDeadline: UInt64, + now: UInt64, + interval rawInterval: UInt64 + ) -> UInt64 { + let interval = max(1, rawInterval) + let (first, firstOverflow) = + previousDeadline.addingReportingOverflow(interval) + guard firstOverflow == false else { return .max } + guard first <= now else { return first } + + let missed = (now - first) / interval + 1 + let (jump, jumpOverflow) = + interval.multipliedReportingOverflow(by: missed) + guard jumpOverflow == false else { return .max } + let (advanced, advancedOverflow) = + first.addingReportingOverflow(jump) + return advancedOverflow ? .max : advanced } - private func finishIfCurrent(_ runID: UUID) { + private func finishRun(_ runID: UUID) { guard activeRunID == runID else { return } activeRunID = nil isRefreshing = false diff --git a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift index fd7136c..2a2fe00 100644 --- a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift @@ -4,65 +4,155 @@ import XCTest @MainActor final class EnergyImpactModelTests: XCTestCase { - func testRefreshUsesOneLeaseForImmediateAndSecondObservation() async throws { - let provider = ControlledEnergyImpactProvider(responses: [ - [entry(power: nil, status: .collecting)], - [entry(power: 1)], - ]) - var requestedSleeps: [UInt64] = [] + func testSixtySecondVisibleLifecycleMakesTwentyOneCompleteObservations() async { + let provider = ControlledEnergyImpactProvider() + var now: UInt64 = 0 + var requestedSleeps = [UInt64]() + let interval: UInt64 = 3_000_000_000 let model = EnergyImpactModel( provider: provider, - initialWindowNanoseconds: 3_000_000_000, - sleep: { requestedSleeps.append($0) } + observationIntervalNanoseconds: interval, + nowNanoseconds: { now }, + sleep: { duration in + requestedSleeps.append(duration) + guard requestedSleeps.count <= 20 else { + throw CancellationError() + } + now += duration + } ) - await model.refresh() + await model.refreshWhileVisible() + XCTAssertEqual(provider.observeCount, 21) + XCTAssertEqual(requestedSleeps.count, 21) + XCTAssertEqual( + Array(requestedSleeps.prefix(20)), + Array(repeating: interval, count: 20) + ) XCTAssertEqual(provider.beginCount, 1) - XCTAssertEqual(provider.observedLeases, provider.returnedLeases + provider.returnedLeases) - XCTAssertEqual(provider.requestedLimits, [20, 20]) - XCTAssertEqual(provider.requestedScopes, [.regularOnly, .regularOnly]) - XCTAssertEqual(requestedSleeps, [3_000_000_000]) - XCTAssertEqual(try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), 1) XCTAssertEqual(provider.endCount, 1) + XCTAssertEqual(provider.endedLeases, provider.returnedLeases) + XCTAssertEqual(model.entries.first?.name, "Run 1 Observation 21") XCTAssertFalse(model.isRefreshing) } - func testInitialCancellationEndsEveryLeaseThatSuccessfullyBegan() async { - let provider = ControlledEnergyImpactProvider(responses: [[]]) + func testAlreadyCancelledRunPerformsNoBeginObserveOrEnd() async { + let provider = ControlledEnergyImpactProvider() + let model = EnergyImpactModel(provider: provider) + + let run = Task { await model.refreshWhileVisible() } + run.cancel() + await run.value + + XCTAssertEqual(provider.beginCount, 0) + XCTAssertEqual(provider.observeCount, 0) + XCTAssertEqual(provider.endCount, 0) + XCTAssertFalse(model.isRefreshing) + } + + func testCancellationAfterObserveBeforePublicationDoesNotPublish() async { + let provider = PublicationBarrierProvider() let model = EnergyImpactModel( provider: provider, - initialWindowNanoseconds: 1, + observationIntervalNanoseconds: 1, + nowNanoseconds: { 0 }, sleep: { _ in throw CancellationError() } ) - await model.refresh() + await model.refreshWhileVisible() + XCTAssertEqual(model.entries.first?.name, "Prior") + + let run = Task { await model.refreshWhileVisible() } + await provider.waitUntilFinalReturnBarrier() + run.cancel() + provider.releaseFinalReturnBarrier() + await run.value + + XCTAssertEqual(provider.completedObservationCount, 2) + XCTAssertEqual(model.entries.first?.name, "Prior") + XCTAssertEqual(provider.endCount, 2) + XCTAssertEqual(provider.endedLeases, provider.returnedLeases) + XCTAssertFalse(model.isRefreshing) + } + + func testEveryReturnedLeaseEndsOnceWhenSleepThrows() async { + let provider = ControlledEnergyImpactProvider() + let model = EnergyImpactModel( + provider: provider, + observationIntervalNanoseconds: 3, + nowNanoseconds: { 0 }, + sleep: { _ in throw CancellationError() } + ) + + await model.refreshWhileVisible() XCTAssertEqual(provider.beginCount, 1) + XCTAssertEqual(provider.observeCount, 1) XCTAssertEqual(provider.endCount, 1) XCTAssertEqual(provider.endedLeases, provider.returnedLeases) XCTAssertFalse(model.isRefreshing) } - func testRefreshWhileVisibleUsesOneLeaseAndAwaitsObservationsSequentially() async { + func testEveryReturnedLeaseEndsOnceWhenObserveReturnsNil() async { let provider = ControlledEnergyImpactProvider( - responses: [[], [], []], - cancelTaskAfterObservationCount: 3 + nilObservationCounts: [1] + ) + let model = EnergyImpactModel(provider: provider) + + await model.refreshWhileVisible() + + XCTAssertEqual(provider.beginCount, 1) + XCTAssertEqual(provider.observeCount, 1) + XCTAssertEqual(provider.endCount, 1) + XCTAssertEqual(provider.endedLeases, provider.returnedLeases) + XCTAssertFalse(model.isRefreshing) + } + + func testSlowObservationSkipsMissedDeadlinesWithoutBurst() async { + var now: UInt64 = 0 + var requestedSleeps = [UInt64]() + let provider = ControlledEnergyImpactProvider( + onObservation: { observationCount in + if observationCount == 1 { now = 7 } + } ) let model = EnergyImpactModel( provider: provider, - initialWindowNanoseconds: 1, - sleep: { _ in await Task.yield() } + observationIntervalNanoseconds: 3, + nowNanoseconds: { now }, + sleep: { duration in + requestedSleeps.append(duration) + throw CancellationError() + } ) - await model.refreshWhileVisible(refreshIntervalNanoseconds: 3) + await model.refreshWhileVisible() - XCTAssertEqual(provider.beginCount, 1) - XCTAssertEqual(provider.observeCount, 3) - XCTAssertEqual(Set(provider.observedLeases).count, 1) - XCTAssertEqual(provider.maximumConcurrentObservations, 1) + XCTAssertEqual(requestedSleeps, [2]) + XCTAssertEqual(provider.observeCount, 1) XCTAssertEqual(provider.endCount, 1) - XCTAssertFalse(model.isRefreshing) + } + + func testConcurrentVisibleRunsEndEachReturnedLeaseExactlyOnce() async { + let provider = ControlledEnergyImpactProvider() + let model = EnergyImpactModel( + provider: provider, + observationIntervalNanoseconds: 60_000_000_000 + ) + + let runA = Task { await model.refreshWhileVisible() } + while provider.observeCount < 1 { await Task.yield() } + let runB = Task { await model.refreshWhileVisible() } + while provider.observeCount < 2 { await Task.yield() } + + runA.cancel() + runB.cancel() + await runA.value + await runB.value + XCTAssertEqual(provider.beginCount, 2) + XCTAssertEqual(provider.endCount, 2) + XCTAssertEqual(Set(provider.endedLeases), Set(provider.returnedLeases)) } func testReplacementCannotPublishOlderCompletedObservation() async { @@ -73,9 +163,9 @@ final class EnergyImpactModelTests: XCTestCase { sleep: { try await sleep.call($0) } ) - let runA = Task { await model.refresh() } + let runA = Task { await model.refreshWhileVisible() } await provider.waitUntilOldSecondObservationStarts() - let runB = Task { await model.refresh() } + let runB = Task { await model.refreshWhileVisible() } await sleep.waitUntilSecondSleepStarts() XCTAssertEqual(model.entries.first?.name, "Run B") @@ -88,17 +178,20 @@ final class EnergyImpactModelTests: XCTestCase { } func testOldExitCannotClearReplacementRefreshingState() async { - let provider = ReplacementRunProvider(blockOldEnd: true) - let sleep = SequencedSleepController() + let provider = ReplacementRunProvider( + blockOldEnd: true, + blockNewFirstObservation: true + ) + let sleep = SequencedSleepController(failsFirstSleep: true) let model = EnergyImpactModel( provider: provider, sleep: { try await sleep.call($0) } ) - let runA = Task { await model.refresh() } + let runA = Task { await model.refreshWhileVisible() } await provider.waitUntilOldEndStarts() - let runB = Task { await model.refresh() } - await sleep.waitUntilSecondSleepStarts() + let runB = Task { await model.refreshWhileVisible() } + await provider.waitUntilNewFirstObservationStarts() XCTAssertTrue(model.isRefreshing) provider.releaseOldEnd() @@ -106,6 +199,8 @@ final class EnergyImpactModelTests: XCTestCase { XCTAssertTrue(model.isRefreshing) XCTAssertEqual(provider.endedLeases.map(\.requestGeneration), [1]) + provider.releaseNewFirstObservation() + await sleep.waitUntilSecondSleepStarts() await sleep.failSecondSleep() await runB.value XCTAssertFalse(model.isRefreshing) @@ -118,14 +213,19 @@ final class EnergyImpactModelTests: XCTestCase { entry(pid: 101, name: "First", power: 1), ] let provider = ControlledEnergyImpactProvider(responses: [[], expected]) + var sleepCount = 0 let model = EnergyImpactModel( provider: provider, limit: 2, - initialWindowNanoseconds: 0, - sleep: { _ in } + observationIntervalNanoseconds: 1, + nowNanoseconds: { 0 }, + sleep: { _ in + sleepCount += 1 + guard sleepCount == 1 else { throw CancellationError() } + } ) - await model.refresh() + await model.refreshWhileVisible() XCTAssertEqual(model.entries, expected) XCTAssertEqual(provider.requestedLimits, [2, 2]) @@ -164,7 +264,8 @@ private func entry( @MainActor private final class ControlledEnergyImpactProvider: EnergyImpactProviding { private var responses: [[EnergyImpactEntry]] - private let cancelTaskAfterObservationCount: Int? + private let nilObservationCounts: Set + private let onObservation: ((Int) -> Void)? private(set) var beginCount = 0 private(set) var observeCount = 0 private(set) var endCount = 0 @@ -173,15 +274,16 @@ private final class ControlledEnergyImpactProvider: EnergyImpactProviding { private(set) var endedLeases: [EnergyImpactSamplingLease] = [] private(set) var requestedLimits: [Int] = [] private(set) var requestedScopes: [EnergyImpactAppScope] = [] - private var concurrentObservations = 0 - private(set) var maximumConcurrentObservations = 0 + private var observationCountsByLease: [UInt64: Int] = [:] init( - responses: [[EnergyImpactEntry]], - cancelTaskAfterObservationCount: Int? = nil + responses: [[EnergyImpactEntry]] = [], + nilObservationCounts: Set = [], + onObservation: ((Int) -> Void)? = nil ) { self.responses = responses - self.cancelTaskAfterObservationCount = cancelTaskAfterObservationCount + self.nilObservationCounts = nilObservationCounts + self.onObservation = onObservation } func beginSession() async -> EnergyImpactSamplingLease? { @@ -196,48 +298,99 @@ private final class ControlledEnergyImpactProvider: EnergyImpactProviding { limit: Int, scope: EnergyImpactAppScope ) async -> [EnergyImpactEntry]? { - concurrentObservations += 1 - maximumConcurrentObservations = max( - maximumConcurrentObservations, - concurrentObservations - ) - await Task.yield() - concurrentObservations -= 1 observeCount += 1 + observationCountsByLease[lease.requestGeneration, default: 0] += 1 + let leaseObservationCount = observationCountsByLease[lease.requestGeneration, default: 0] + onObservation?(observeCount) observedLeases.append(lease) requestedLimits.append(limit) requestedScopes.append(scope) - if observeCount == cancelTaskAfterObservationCount { - withUnsafeCurrentTask { $0?.cancel() } + guard nilObservationCounts.contains(observeCount) == false else { + return nil + } + if responses.isEmpty == false { return responses.removeFirst() } + return [entry( + pid: pid_t(lease.requestGeneration), + name: "Run \(lease.requestGeneration) Observation \(leaseObservationCount)", + power: Double(leaseObservationCount) + )] + } + + func endSession(_ lease: EnergyImpactSamplingLease) async { + endCount += 1 + endedLeases.append(lease) + } +} + +@MainActor +private final class PublicationBarrierProvider: EnergyImpactProviding { + private var finalReturnContinuation: CheckedContinuation? + private(set) var completedObservationCount = 0 + private(set) var endCount = 0 + private(set) var returnedLeases: [EnergyImpactSamplingLease] = [] + private(set) var endedLeases: [EnergyImpactSamplingLease] = [] + + func beginSession() async -> EnergyImpactSamplingLease? { + let lease = EnergyImpactSamplingLease( + requestGeneration: UInt64(returnedLeases.count + 1) + ) + returnedLeases.append(lease) + return lease + } + + func observe( + lease: EnergyImpactSamplingLease, + limit: Int, + scope: EnergyImpactAppScope + ) async -> [EnergyImpactEntry]? { + completedObservationCount += 1 + if lease.requestGeneration == 2 { + await withCheckedContinuation { finalReturnContinuation = $0 } } - guard responses.isEmpty == false else { return [] } - return responses.removeFirst() + return [entry( + name: lease.requestGeneration == 1 ? "Prior" : "Cancelled", + power: Double(lease.requestGeneration) + )] } func endSession(_ lease: EnergyImpactSamplingLease) async { endCount += 1 endedLeases.append(lease) } + + func waitUntilFinalReturnBarrier() async { + while finalReturnContinuation == nil { await Task.yield() } + } + + func releaseFinalReturnBarrier() { + finalReturnContinuation?.resume() + finalReturnContinuation = nil + } } @MainActor private final class ReplacementRunProvider: EnergyImpactProviding { private let blockOldSecondObservation: Bool private let blockOldEnd: Bool + private let blockNewFirstObservation: Bool private var beginCount = 0 private var observationCounts: [UInt64: Int] = [:] private var oldSecondStarted = false private var oldSecondContinuation: CheckedContinuation? private var oldEndStarted = false private var oldEndContinuation: CheckedContinuation? + private var newFirstObservationStarted = false + private var newFirstObservationContinuation: CheckedContinuation? private(set) var endedLeases: [EnergyImpactSamplingLease] = [] init( blockOldSecondObservation: Bool = false, - blockOldEnd: Bool = false + blockOldEnd: Bool = false, + blockNewFirstObservation: Bool = false ) { self.blockOldSecondObservation = blockOldSecondObservation self.blockOldEnd = blockOldEnd + self.blockNewFirstObservation = blockNewFirstObservation } func beginSession() async -> EnergyImpactSamplingLease? { @@ -257,6 +410,10 @@ private final class ReplacementRunProvider: EnergyImpactProviding { oldSecondStarted = true await withCheckedContinuation { oldSecondContinuation = $0 } } + if generation == 2, count == 1, blockNewFirstObservation { + newFirstObservationStarted = true + await withCheckedContinuation { newFirstObservationContinuation = $0 } + } let name = generation == 1 && count == 2 ? "Old A" : "Run \(generation == 1 ? "A" : "B")" return [entry(pid: pid_t(generation), name: name, power: Double(generation))] } @@ -286,15 +443,32 @@ private final class ReplacementRunProvider: EnergyImpactProviding { oldEndContinuation?.resume() oldEndContinuation = nil } + + func waitUntilNewFirstObservationStarts() async { + while newFirstObservationStarted == false { await Task.yield() } + } + + func releaseNewFirstObservation() { + newFirstObservationContinuation?.resume() + newFirstObservationContinuation = nil + } } private actor SequencedSleepController { + private let failsFirstSleep: Bool private var callCount = 0 private var secondSleepStarted = false private var secondSleepContinuation: CheckedContinuation? + init(failsFirstSleep: Bool = false) { + self.failsFirstSleep = failsFirstSleep + } + func call(_ duration: UInt64) async throws { callCount += 1 + if callCount == 1, failsFirstSleep { + throw CancellationError() + } if callCount == 1 { return } secondSleepStarted = true try await withCheckedThrowingContinuation { continuation in diff --git a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift index c5e9f63..189b0f6 100644 --- a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift @@ -42,7 +42,8 @@ final class EnergyImpactViewTests: XCTestCase { func testRenderedEnergyImpactViewShowsEmptyStateAtFourHundredTwentyPoints() { let model = EnergyImpactModel( provider: EnergyImpactViewProviderStub(responses: []), - initialWindowNanoseconds: 1, + observationIntervalNanoseconds: 1, + nowNanoseconds: { 0 }, sleep: { _ in throw CancellationError() } ) let renderer = ImageRenderer( @@ -59,6 +60,33 @@ final class EnergyImpactViewTests: XCTestCase { XCTAssertNotNil(renderer.nsImage) } + func testRenderedEnergyImpactViewStartsVisibleLifecycleThroughModel() async { + let provider = EnergyImpactViewProviderStub(responses: [[]]) + let model = EnergyImpactModel( + provider: provider, + observationIntervalNanoseconds: 1, + nowNanoseconds: { 0 }, + sleep: { _ in throw CancellationError() } + ) + let renderer = ImageRenderer( + content: EnergyImpactView( + model: model, + refreshTrigger: 0, + showsApplicationIdentifier: true + ) + .frame(width: 420, height: 120) + ) + renderer.scale = 1 + + XCTAssertNotNil(renderer.nsImage) + for _ in 0..<100 where provider.beginCount == 0 { + await Task.yield() + } + XCTAssertEqual(provider.beginCount, 1) + XCTAssertEqual(provider.observeCount, 1) + XCTAssertEqual(provider.endCount, 1) + } + func testRenderedEnergyImpactViewShowsLocalizedContentAtFourHundredTwentyPointsAndRestoresPreferredLanguageOverride() async { let initialPreferredLanguageIdentifier = AppLocalization.explicitPreferredLanguageIdentifier() defer { AppLocalization.setPreferredLanguageIdentifier(initialPreferredLanguageIdentifier) } @@ -72,17 +100,14 @@ final class EnergyImpactViewTests: XCTestCase { private func assertLocalizedEnergyImpactViewRendersAtFourHundredTwentyPoints() async { let preferredLanguageIdentifier = AppLocalization.explicitPreferredLanguageIdentifier() defer { AppLocalization.setPreferredLanguageIdentifier(preferredLanguageIdentifier) } - var sleepCount = 0 let renderedEntry = entry(power: 1_840) let model = EnergyImpactModel( - provider: EnergyImpactViewProviderStub(responses: [[], [renderedEntry], []]), - initialWindowNanoseconds: 1, - sleep: { _ in - sleepCount += 1 - guard sleepCount == 1 else { throw CancellationError() } - } + provider: EnergyImpactViewProviderStub(responses: [[renderedEntry]]), + observationIntervalNanoseconds: 1, + nowNanoseconds: { 0 }, + sleep: { _ in throw CancellationError() } ) - await model.refresh() + await model.refreshWhileVisible() let expectations: [( languageIdentifier: String, @@ -236,12 +261,16 @@ final class EnergyImpactViewTests: XCTestCase { private final class EnergyImpactViewProviderStub: EnergyImpactProviding { private var responses: [[EnergyImpactEntry]] private var nextGeneration: UInt64 = 0 + private(set) var beginCount = 0 + private(set) var observeCount = 0 + private(set) var endCount = 0 init(responses: [[EnergyImpactEntry]]) { self.responses = responses } func beginSession() async -> EnergyImpactSamplingLease? { + beginCount += 1 nextGeneration += 1 return EnergyImpactSamplingLease(requestGeneration: nextGeneration) } @@ -251,8 +280,11 @@ private final class EnergyImpactViewProviderStub: EnergyImpactProviding { limit: Int, scope: EnergyImpactAppScope ) async -> [EnergyImpactEntry]? { + observeCount += 1 return responses.isEmpty ? [] : Array(responses.removeFirst().prefix(limit)) } - func endSession(_ lease: EnergyImpactSamplingLease) async {} + func endSession(_ lease: EnergyImpactSamplingLease) async { + endCount += 1 + } } From abf4ecb884005fae2b28f4fca2e25f442bcbbb63 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sun, 9 Aug 2026 10:48:07 +1000 Subject: [PATCH 4/4] test(energy): add repeatable native gate --- .../EnergyImpactNativeValidationTests.swift | 198 ++++++++++---- scripts/run-energy-impact-native-gate.command | 258 ++++++++++++++++++ 2 files changed, 408 insertions(+), 48 deletions(-) create mode 100755 scripts/run-energy-impact-native-gate.command diff --git a/Tests/MacActivityCoreTests/EnergyImpactNativeValidationTests.swift b/Tests/MacActivityCoreTests/EnergyImpactNativeValidationTests.swift index 1516802..8140663 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactNativeValidationTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactNativeValidationTests.swift @@ -1,87 +1,197 @@ +import AppKit import Darwin import Foundation import XCTest @testable import MacActivityCore +@MainActor +private final class SystemEnergyImpactAppCatalog { + func snapshots( + scope: EnergyImpactAppScope + ) -> [NSRunningApplication] { + var seenProcessIdentifiers = Set() + return NSWorkspace.shared.runningApplications.filter { application in + let processIdentifier = application.processIdentifier + guard processIdentifier > 0, + seenProcessIdentifiers.insert(processIdentifier).inserted else { + return false + } + + switch application.activationPolicy { + case .regular: + return true + case .accessory: + return scope == .regularAndAccessory + case .prohibited: + return false + @unknown default: + return false + } + } + } +} + @MainActor final class EnergyImpactNativeValidationTests: XCTestCase { + private struct NativeMetrics { + let observationCount: Int + let preRunCatalogAppCount: Int + let systemSnapshotProcessCount: Int + let p50Seconds: TimeInterval + let p95Seconds: TimeInterval + let cpuPercent: Double + let wallSeconds: TimeInterval + } + + private enum NativeValidationError: Error { + case observationRejected(Int) + case missedDeadline(index: Int, latenessSeconds: TimeInterval) + } + func testVisibleFacadeBudget() async throws { guard ProcessInfo.processInfo.environment[ "MACACTIVITY_ENERGY_NATIVE_VALIDATION" ] == "1" else { - throw XCTSkip("Set MACACTIVITY_ENERGY_NATIVE_VALIDATION=1 explicitly") + throw XCTSkip( + "Set MACACTIVITY_ENERGY_NATIVE_VALIDATION=1 explicitly" + ) } + let catalog = SystemEnergyImpactAppCatalog() + let preRunCatalogAppCount = + catalog.snapshots(scope: .regularOnly).count + let systemSnapshotProcessCount = + SystemProcessParentSnapshotReader().snapshots().count let service = EnergyImpactService() guard let lease = await service.beginSession() else { XCTFail("Native validation could not begin a sampler lease") return } + + let metrics: NativeMetrics do { - let metrics = try await measureVisibleFacade( + metrics = try await measureVisibleFacade( service: service, - lease: lease + lease: lease, + preRunCatalogAppCount: preRunCatalogAppCount, + systemSnapshotProcessCount: + systemSnapshotProcessCount ) - await service.endSession(lease) - - print(String( - format: "ENERGY_NATIVE_METRICS samples=%d p50_ms=%.3f p95_ms=%.3f cpu_percent=%.4f wall_seconds=%.3f", - metrics.sampleCount, - metrics.p50 * 1_000, - metrics.p95 * 1_000, - metrics.cpuPercent, - metrics.wallSeconds - )) - XCTAssertLessThan(metrics.cpuPercent, 0.5) - XCTAssertLessThan(metrics.p95, 0.100) } catch { await service.endSession(lease) throw error } + await service.endSession(lease) + + print(String( + format: "ENERGY_NATIVE_METRICS observations=%d pre_run_catalog_apps=%d system_snapshot_processes=%d p50_ms=%.3f p95_ms=%.3f cpu_percent=%.6f wall_seconds=%.3f", + locale: Locale(identifier: "en_US_POSIX"), + metrics.observationCount, + metrics.preRunCatalogAppCount, + metrics.systemSnapshotProcessCount, + metrics.p50Seconds * 1_000, + metrics.p95Seconds * 1_000, + metrics.cpuPercent, + metrics.wallSeconds + )) + + XCTAssertEqual(metrics.observationCount, 21) + XCTAssertGreaterThan(metrics.preRunCatalogAppCount, 0) + XCTAssertGreaterThan(metrics.systemSnapshotProcessCount, 0) + XCTAssertTrue(metrics.p50Seconds.isFinite) + XCTAssertTrue(metrics.p95Seconds.isFinite) + XCTAssertTrue(metrics.cpuPercent.isFinite) + XCTAssertGreaterThanOrEqual(metrics.cpuPercent, 0) + XCTAssertTrue(metrics.wallSeconds.isFinite) + XCTAssertGreaterThanOrEqual(metrics.wallSeconds, 60) + XCTAssertLessThan(metrics.p95Seconds, 0.100) } private func measureVisibleFacade( service: EnergyImpactService, - lease: EnergyImpactSamplingLease + lease: EnergyImpactSamplingLease, + preRunCatalogAppCount: Int, + systemSnapshotProcessCount: Int ) async throws -> NativeMetrics { - let startCPU = processCPUSeconds() - let startWall = ProcessInfo.processInfo.systemUptime + let wallStart = ProcessInfo.processInfo.systemUptime + let cpuStart = processCPUSeconds() var latencies = [TimeInterval]() - latencies.reserveCapacity(60) + latencies.reserveCapacity(21) - for _ in 1...60 { - try Task.checkCancellation() - let started = ProcessInfo.processInfo.systemUptime - _ = await service.observe( + for index in 0..<21 { + if index > 0 { + let deadline = wallStart + Double(index) * 3 + let remaining = + deadline - ProcessInfo.processInfo.systemUptime + guard remaining >= 0 else { + throw NativeValidationError.missedDeadline( + index: index, + latenessSeconds: -remaining + ) + } + if remaining > 0 { + try await Task.sleep( + nanoseconds: UInt64( + remaining * 1_000_000_000 + ) + ) + } + } + + let started = + ProcessInfo.processInfo.systemUptime + let observed = await service.observe( lease: lease, limit: 20, scope: .regularOnly ) - try Task.checkCancellation() - latencies.append(ProcessInfo.processInfo.systemUptime - started) - try await Task.sleep(for: .seconds(1)) + guard observed != nil else { + throw NativeValidationError + .observationRejected(index) + } + latencies.append( + ProcessInfo.processInfo.systemUptime - started + ) } - let wallSeconds = ProcessInfo.processInfo.systemUptime - startWall - let cpuPercent = (processCPUSeconds() - startCPU) / wallSeconds * 100 + let wall = + ProcessInfo.processInfo.systemUptime - wallStart + let cpuPercent = + (processCPUSeconds() - cpuStart) / wall * 100 let sorted = latencies.sorted() - let p50Index = max(0, min( - sorted.count - 1, - Int(ceil(0.50 * Double(sorted.count))) - 1 - )) - let p95Index = max(0, min( - sorted.count - 1, - Int(ceil(0.95 * Double(sorted.count))) - 1 - )) + return NativeMetrics( - sampleCount: sorted.count, - p50: sorted[p50Index], - p95: sorted[p95Index], + observationCount: latencies.count, + preRunCatalogAppCount: preRunCatalogAppCount, + systemSnapshotProcessCount: + systemSnapshotProcessCount, + p50Seconds: nearestRank( + sorted, + percentile: 0.50 + ), + p95Seconds: nearestRank( + sorted, + percentile: 0.95 + ), cpuPercent: cpuPercent, - wallSeconds: wallSeconds + wallSeconds: wall ) } + private func nearestRank( + _ sorted: [TimeInterval], + percentile: Double + ) -> TimeInterval { + precondition(sorted.isEmpty == false) + let rawIndex = + Int(ceil(percentile * Double(sorted.count))) - 1 + let index = min( + sorted.count - 1, + max(0, rawIndex) + ) + return sorted[index] + } + private func processCPUSeconds() -> Double { var usage = rusage() getrusage(RUSAGE_SELF, &usage) @@ -91,11 +201,3 @@ final class EnergyImpactNativeValidationTests: XCTestCase { + Double(usage.ru_stime.tv_usec) / 1_000_000 } } - -private struct NativeMetrics { - let sampleCount: Int - let p50: TimeInterval - let p95: TimeInterval - let cpuPercent: Double - let wallSeconds: TimeInterval -} diff --git a/scripts/run-energy-impact-native-gate.command b/scripts/run-energy-impact-native-gate.command new file mode 100755 index 0000000..f39658a --- /dev/null +++ b/scripts/run-energy-impact-native-gate.command @@ -0,0 +1,258 @@ +#!/bin/zsh +set -euo pipefail +export LC_ALL=C +export LANG=C + +if [[ "$#" -ne 1 ]]; then + print -u2 "usage: $0 /absolute/evidence-directory" + exit 64 +fi + +script_dir="$(cd "$(dirname "$0")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +repository_status="$(git -C "$repo_root" status --porcelain)" +if [[ -n "$repository_status" ]]; then + print -u2 "refusing dirty repository: $repo_root" + exit 70 +fi +candidate_sha="$(git -C "$repo_root" rev-parse HEAD)" +evidence_dir="$1" + +if [[ "${evidence_dir[1]}" != "/" ]]; then + print -u2 "evidence directory must be absolute" + exit 64 +fi +if [[ -e "$evidence_dir" ]]; then + print -u2 "refusing to overwrite: $evidence_dir" + exit 73 +fi +mkdir "$evidence_dir" +print -r -- "$candidate_sha" > "$evidence_dir/candidate-sha.txt" + +{ + /bin/date -u + /usr/bin/sw_vers + /usr/sbin/system_profiler SPHardwareDataType + /usr/bin/pmset -g batt || true + /usr/bin/pmset -g custom || true +} > "$evidence_dir/environment.txt" 2>&1 + +metrics_file="$evidence_dir/metrics.tsv" +: > "$metrics_file" +run_status_file="$evidence_dir/run-status.tsv" +: > "$run_status_file" +overall_status=0 + +for run in 1 2 3 4 5; do + log_file="$evidence_dir/run-$run.log" + set +e + MACACTIVITY_ENERGY_NATIVE_VALIDATION=1 \ + CLANG_MODULE_CACHE_PATH=/private/tmp/macactivity-part4-clang-cache \ + swift test \ + --package-path "$repo_root" \ + --filter \ + EnergyImpactNativeValidationTests/testVisibleFacadeBudget \ + > "$log_file" 2>&1 + test_status="$?" + set -e + + metric_count="$( + /usr/bin/grep -c '^ENERGY_NATIVE_METRICS ' "$log_file" || + true + )" + if [[ "$metric_count" -ne 1 ]]; then + print -u2 \ + "run $run invalid: test=$test_status metrics=$metric_count" + /usr/bin/printf '%s\t%s\t%s\t%s\t%s\n' \ + "$run" "metric_count_invalid" "$test_status" \ + "$metric_count" "$log_file" >> "$run_status_file" + overall_status=1 + continue + fi + + metric_line="$( + /usr/bin/grep '^ENERGY_NATIVE_METRICS ' "$log_file" + )" + observations="$( + print -r -- "$metric_line" | + /usr/bin/tr ' ' '\n' | + /usr/bin/awk -F= '$1 == "observations" { print $2 }' + )" + pre_run_catalog_apps="$( + print -r -- "$metric_line" | + /usr/bin/tr ' ' '\n' | + /usr/bin/awk -F= '$1 == "pre_run_catalog_apps" { print $2 }' + )" + system_snapshot_processes="$( + print -r -- "$metric_line" | + /usr/bin/tr ' ' '\n' | + /usr/bin/awk -F= '$1 == "system_snapshot_processes" { print $2 }' + )" + p50_ms="$( + print -r -- "$metric_line" | + /usr/bin/tr ' ' '\n' | + /usr/bin/awk -F= '$1 == "p50_ms" { print $2 }' + )" + p95_ms="$( + print -r -- "$metric_line" | + /usr/bin/tr ' ' '\n' | + /usr/bin/awk -F= '$1 == "p95_ms" { print $2 }' + )" + cpu_percent="$( + print -r -- "$metric_line" | + /usr/bin/tr ' ' '\n' | + /usr/bin/awk -F= '$1 == "cpu_percent" { print $2 }' + )" + wall_seconds="$( + print -r -- "$metric_line" | + /usr/bin/tr ' ' '\n' | + /usr/bin/awk -F= '$1 == "wall_seconds" { print $2 }' + )" + + if [[ -z "$observations" || + -z "$pre_run_catalog_apps" || + -z "$system_snapshot_processes" || + -z "$p50_ms" || + -z "$p95_ms" || + -z "$cpu_percent" || + -z "$wall_seconds" ]]; then + print -u2 "run $run has incomplete metrics" + /usr/bin/printf '%s\t%s\t%s\t%s\t%s\n' \ + "$run" "incomplete_metrics" "$test_status" \ + "$metric_count" "$log_file" >> "$run_status_file" + overall_status=1 + continue + fi + + if ! /usr/bin/awk \ + -v observations="$observations" \ + -v apps="$pre_run_catalog_apps" \ + -v processes="$system_snapshot_processes" \ + -v p50="$p50_ms" \ + -v p95="$p95_ms" \ + -v cpu="$cpu_percent" \ + -v wall="$wall_seconds" \ + 'BEGIN { + unsigned = "^[0-9]+$" + decimal = "^[0-9]+([.][0-9]+)?$" + valid = (observations ~ unsigned && + apps ~ unsigned && + processes ~ unsigned && + p50 ~ decimal && + p95 ~ decimal && + cpu ~ decimal && + wall ~ decimal) + exit !valid + }' + then + print -u2 "run $run has noncanonical metrics" + /usr/bin/printf '%s\t%s\t%s\t%s\t%s\n' \ + "$run" "noncanonical_metrics" "$test_status" \ + "$metric_count" "$log_file" >> "$run_status_file" + overall_status=1 + continue + fi + + /usr/bin/printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$run" "$observations" "$pre_run_catalog_apps" \ + "$system_snapshot_processes" \ + "$p50_ms" "$p95_ms" "$cpu_percent" "$wall_seconds" \ + "$log_file" \ + >> "$metrics_file" + + run_gate_status=PASS + if [[ "$test_status" -ne 0 ]]; then + run_gate_status=FAIL + overall_status=1 + fi + if [[ "$observations" -ne 21 ]]; then + run_gate_status=FAIL + overall_status=1 + fi + if [[ "$pre_run_catalog_apps" -le 0 || + "$system_snapshot_processes" -le 0 ]]; then + run_gate_status=FAIL + overall_status=1 + fi + if ! /usr/bin/awk -v value="$p95_ms" \ + 'BEGIN { exit !(value < 100) }' + then + run_gate_status=FAIL + overall_status=1 + fi + if ! /usr/bin/awk -v value="$cpu_percent" \ + 'BEGIN { exit !(value < 0.55) }' + then + run_gate_status=FAIL + overall_status=1 + fi + if ! /usr/bin/awk -v value="$wall_seconds" \ + 'BEGIN { exit !(value >= 60) }' + then + run_gate_status=FAIL + overall_status=1 + fi + /usr/bin/printf '%s\t%s\t%s\t%s\t%s\n' \ + "$run" "$run_gate_status" "$test_status" \ + "$metric_count" "$log_file" >> "$run_status_file" +done + +valid_count="$( + /usr/bin/awk 'END { print NR }' "$metrics_file" +)" +if [[ "$valid_count" -ne 5 ]]; then + overall_status=1 +fi + +summary_file="$evidence_dir/summary.txt" +{ + print "run status test_status metric_count log" + /usr/bin/awk -F '\t' \ + '{ print $1, $2, $3, $4, $5 }' "$run_status_file" +} > "$summary_file" +if [[ "$valid_count" -eq 5 ]]; then + /usr/bin/cut -f7 "$metrics_file" | + /usr/bin/sort -n > "$evidence_dir/cpu-sorted.txt" + median="$( + /usr/bin/sed -n '3p' "$evidence_dir/cpu-sorted.txt" + )" + maximum="$( + /usr/bin/tail -n 1 "$evidence_dir/cpu-sorted.txt" + )" + sorted_values="$( + /usr/bin/paste -s -d, "$evidence_dir/cpu-sorted.txt" + )" + if ! /usr/bin/awk -v value="$median" \ + 'BEGIN { exit !(value < 0.5) }' + then + overall_status=1 + fi + + { + print "ENERGY_NATIVE_GATE median_cpu_percent=$median maximum_cpu_percent=$maximum" + print "sorted_cpu_percent=$sorted_values" + print "run observations pre_run_catalog_apps system_snapshot_processes p50_ms p95_ms cpu_percent wall_seconds log" + /usr/bin/awk -F '\t' \ + '{ print $1, $2, $3, $4, $5, $6, $7, $8, $9 }' \ + "$metrics_file" + } | /usr/bin/tee -a "$summary_file" +else + print "ENERGY_NATIVE_GATE invalid_run_count=$valid_count" | + /usr/bin/tee -a "$summary_file" +fi + +final_sha="$(git -C "$repo_root" rev-parse HEAD)" +final_status="$(git -C "$repo_root" status --porcelain)" +if [[ "$final_sha" != "$candidate_sha" || -n "$final_status" ]]; then + print -u2 "candidate changed during native gate" + overall_status=1 +fi + +if [[ "$overall_status" -ne 0 ]]; then + print "ENERGY_NATIVE_GATE FAIL evidence=$evidence_dir" | + /usr/bin/tee -a "$summary_file" >&2 + exit 1 +fi + +print "ENERGY_NATIVE_GATE PASS evidence=$evidence_dir" | + /usr/bin/tee -a "$summary_file"