From 40f675afa74e95256ad72a88ce6f2494ff50f823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 22 Oct 2026 20:09:14 +0200 Subject: [PATCH] Log app exit reasons and memory use MetricKit exit counts and crash diagnostics are written to the log when iOS delivers them. The app also logs when it is about to terminate, and the background log line includes the memory footprint and the memory left before the limit. --- LoopFollow/Application/AppDelegate.swift | 2 + LoopFollow/Helpers/ExitDiagnostics.swift | 89 +++++++++++++++++++ .../ViewControllers/MainViewController.swift | 2 +- 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 LoopFollow/Helpers/ExitDiagnostics.swift diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 3cc65b736..dd67f8801 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -11,6 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() + ExitDiagnostics.shared.start() // Notification and calendar permissions are no longer requested here. // They're deferred to the moment the user opts into the feature that @@ -109,6 +110,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationWillTerminate(_: UIApplication) { + LogManager.shared.log(category: .general, message: "App will terminate (\(ExitDiagnostics.memoryDescription()))") #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.endOnTerminate() #endif diff --git a/LoopFollow/Helpers/ExitDiagnostics.swift b/LoopFollow/Helpers/ExitDiagnostics.swift new file mode 100644 index 000000000..8664ef084 --- /dev/null +++ b/LoopFollow/Helpers/ExitDiagnostics.swift @@ -0,0 +1,89 @@ +// LoopFollow +// ExitDiagnostics.swift + +import Foundation +import MetricKit +import os + +/// Logs MetricKit app exit counts and crash diagnostics. iOS delivers these +/// at most once a day, so a log shared the day after a termination shows why +/// the process ended. +final class ExitDiagnostics: NSObject, MXMetricManagerSubscriber { + static let shared = ExitDiagnostics() + + func start() { + MXMetricManager.shared.add(self) + } + + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + guard let exits = payload.applicationExitMetrics else { continue } + let range = "\(payload.timeStampBegin) – \(payload.timeStampEnd)" + + let bg = exits.backgroundExitData + let background = Self.describe([ + ("normal", bg.cumulativeNormalAppExitCount), + ("memoryLimit", bg.cumulativeMemoryResourceLimitExitCount), + ("memoryPressure", bg.cumulativeMemoryPressureExitCount), + ("cpuLimit", bg.cumulativeCPUResourceLimitExitCount), + ("badAccess", bg.cumulativeBadAccessExitCount), + ("illegalInstruction", bg.cumulativeIllegalInstructionExitCount), + ("abnormal", bg.cumulativeAbnormalExitCount), + ("watchdog", bg.cumulativeAppWatchdogExitCount), + ("lockedFile", bg.cumulativeSuspendedWithLockedFileExitCount), + ("taskAssertionTimeout", bg.cumulativeBackgroundTaskAssertionTimeoutExitCount), + ]) + + let fg = exits.foregroundExitData + let foreground = Self.describe([ + ("normal", fg.cumulativeNormalAppExitCount), + ("memoryLimit", fg.cumulativeMemoryResourceLimitExitCount), + ("badAccess", fg.cumulativeBadAccessExitCount), + ("illegalInstruction", fg.cumulativeIllegalInstructionExitCount), + ("abnormal", fg.cumulativeAbnormalExitCount), + ("watchdog", fg.cumulativeAppWatchdogExitCount), + ]) + + LogManager.shared.log(category: .general, message: "App exits \(range): background [\(background)], foreground [\(foreground)]") + } + } + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + for crash in payload.crashDiagnostics ?? [] { + let signal = crash.signal.map { "\($0)" } ?? "-" + let exceptionType = crash.exceptionType.map { "\($0)" } ?? "-" + let exceptionCode = crash.exceptionCode.map { "\($0)" } ?? "-" + let reason = crash.terminationReason ?? "-" + LogManager.shared.log( + category: .general, + message: "Crash reported for \(payload.timeStampBegin) – \(payload.timeStampEnd): version=\(crash.applicationVersion), signal=\(signal), exceptionType=\(exceptionType), exceptionCode=\(exceptionCode), terminationReason=\(reason)" + ) + } + } + } + + /// Current memory footprint and the headroom left before iOS terminates the + /// process for exceeding its memory limit. + static func memoryDescription() -> String { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + let footprint = result == KERN_SUCCESS ? "\(info.phys_footprint / 1_048_576) MB" : "unknown" + #if targetEnvironment(macCatalyst) + return "memoryFootprint=\(footprint)" + #else + let available = os_proc_available_memory() / 1_048_576 + return "memoryFootprint=\(footprint), memoryAvailable=\(available) MB" + #endif + } + + private static func describe(_ counts: [(String, Int)]) -> String { + let nonZero = counts.filter { $0.1 > 0 } + return nonZero.isEmpty ? "none" : nonZero.map { "\($0.0)=\($0.1)" }.joined(separator: ", ") + } +} diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 5c6fb7e4f..70d25543c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -590,7 +590,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { @objc func appMovedToBackground() { LogManager.shared.log( category: .general, - message: "App moved to background (refreshType=\(Storage.shared.backgroundRefreshType.value.rawValue), lowPowerMode=\(ProcessInfo.processInfo.isLowPowerModeEnabled), backgroundRefreshStatus=\(Self.describe(UIApplication.shared.backgroundRefreshStatus)))" + message: "App moved to background (refreshType=\(Storage.shared.backgroundRefreshType.value.rawValue), lowPowerMode=\(ProcessInfo.processInfo.isLowPowerModeEnabled), backgroundRefreshStatus=\(Self.describe(UIApplication.shared.backgroundRefreshStatus)), \(ExitDiagnostics.memoryDescription()))" ) // Allow screen to turn off