Skip to content

Commit 220ef7b

Browse files
committed
Recover from route changes and background relaunches
Silent audio also stops when the audio route disappears and when media services reset, and neither posts an interruption notification, so nothing noticed. A day of device logs shows three silent deaths with zero interruptions while the phone moved in and out of CarPlay. Route changes and media services resets are now observed and recover through the same ladder. Recovery runs for a route appearing or disappearing; a category change never recovers, because playAudio sets the category itself and an alarm takes over the session that way. Other reasons are logged so a later log can earn them a recovery. A process launched into the background by BGAppRefreshTask never runs the backgrounding transition, so it had no interruption, route or media services observers and no background alerts armed. Observers are now attached whenever audio is restarted, and a background recovery arms the alerts, which also clears any delivered notification the recovery has just made obsolete. The task scheduler is kicked on recovery so the alerts are re-armed from the moment runtime returns. Alerts are only armed while backgrounded, since the task's work lands on the main queue and the app may have been opened in between. The runtime gap is measured against a monotonic clock, so a wall clock correction cannot hide a stall, and a material difference between the two is reported. A scheduler park that outlives the moment between a task firing and its action rescheduling it is now reported with its duration.
1 parent 3369a0c commit 220ef7b

4 files changed

Lines changed: 170 additions & 17 deletions

File tree

LoopFollow/Controllers/BackgroundAlertManager.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ class BackgroundAlertManager {
6464
func scheduleBackgroundAlert(force: Bool = false) {
6565
guard isAlertScheduled, Storage.shared.backgroundRefreshType.value != .none else { return }
6666

67-
// Throttle execution if not forced: only run once every 10 seconds.
68-
if !force {
69-
let now = Date()
70-
if let lastDate = lastScheduleDate, now.timeIntervalSince(lastDate) < 10 {
71-
return
72-
}
73-
lastScheduleDate = now
67+
// Throttle execution if not forced: only run once every 10 seconds. A forced
68+
// run stamps the date too, so the next tick doesn't immediately repeat the
69+
// remove-and-re-add it just performed.
70+
let now = Date()
71+
if !force, let lastDate = lastScheduleDate, now.timeIntervalSince(lastDate) < 10 {
72+
return
7473
}
74+
lastScheduleDate = now
7575

7676
removeDeliveredNotifications()
7777

LoopFollow/Helpers/BackgroundRefreshManager.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import BackgroundTasks
55
import Foundation
6+
import UIKit
67

78
class BackgroundRefreshManager {
89
static let shared = BackgroundRefreshManager()
@@ -83,6 +84,8 @@ class BackgroundRefreshManager {
8384
// absence of a follow-up line.
8485
guard !backgroundTask.isPlaying else {
8586
LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed")
87+
self.armBackgroundAlerts()
88+
TaskScheduler.shared.checkTasksNow()
8689
complete(true)
8790
return
8891
}
@@ -96,11 +99,29 @@ class BackgroundRefreshManager {
9699
category: .taskScheduler,
97100
message: success ? "audio restart succeeded" : "audio restart failed"
98101
)
102+
// Only on success: a failed restart means suspension is imminent, and
103+
// dispatching fetches that cannot finish helps nothing.
104+
if success {
105+
self.armBackgroundAlerts()
106+
TaskScheduler.shared.checkTasksNow()
107+
}
99108
complete(success)
100109
}
101110
}
102111
}
103112

113+
/// Clears any delivered "App inactive" notification and re-arms the 6/12/18 minute
114+
/// alerts from this moment. A background task only runs while backgrounded, so the
115+
/// alerts belong armed here — and a process launched into the background never ran
116+
/// `appMovedToBackground`, so nothing else would have armed them at all.
117+
private func armBackgroundAlerts() {
118+
// The task fires while backgrounded, but its work lands on the main queue and
119+
// the user may have opened the app in between. Arming then would put an "App
120+
// inactive" notification on screen while they are looking at the app.
121+
guard UIApplication.shared.applicationState == .background else { return }
122+
BackgroundAlertManager.shared.startBackgroundAlert()
123+
}
124+
104125
/// Requests the routine health check, leaving an existing pending request alone
105126
/// when it would run at least as soon. Every background transition calls this,
106127
/// and unconditional resubmission would push the check further out each time.

LoopFollow/Helpers/BackgroundTaskAudio.swift

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,25 @@ class BackgroundTask {
6565
// MARK: - Methods
6666

6767
func startBackgroundTask() {
68-
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
69-
NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
68+
attachObservers()
7069
onMain { self.recover(after: 0, reason: "start") }
7170
}
7271

72+
/// Idempotent, and called from `restartAudio` too: a process launched into the
73+
/// background by `BGAppRefreshTask` never sees a backgrounding transition, so
74+
/// without this it would run the keep-alive with nothing watching the session.
75+
private func attachObservers() {
76+
removeObservers()
77+
NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
78+
// A route disappearing pauses the player without any interruption notification,
79+
// and a media services reset invalidates the session and player outright —
80+
// neither is observable through `interruptionNotification`.
81+
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteChanged), name: AVAudioSession.routeChangeNotification, object: nil)
82+
NotificationCenter.default.addObserver(self, selector: #selector(mediaServicesWereReset), name: AVAudioSession.mediaServicesWereResetNotification, object: nil)
83+
}
84+
7385
func stopBackgroundTask() {
74-
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
86+
removeObservers()
7587
onMain {
7688
self.cancelRecovery()
7789
self.player.stop()
@@ -89,12 +101,80 @@ class BackgroundTask {
89101
/// audio claim that made them necessary.
90102
/// - Parameter completion: Called on the main queue with the final state.
91103
func restartAudio(reason: String, completion: ((Bool) -> Void)? = nil) {
104+
attachObservers()
92105
onMain {
93106
self.player.stop()
94107
self.recover(after: 0, reason: reason, completion: completion)
95108
}
96109
}
97110

111+
private func removeObservers() {
112+
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
113+
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
114+
NotificationCenter.default.removeObserver(self, name: AVAudioSession.mediaServicesWereResetNotification, object: nil)
115+
}
116+
117+
// MARK: - Route and media services handling
118+
119+
@objc private func audioRouteChanged(_ notification: Notification) {
120+
guard let userInfo = notification.userInfo,
121+
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
122+
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
123+
else { return }
124+
125+
let previous = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription
126+
let route = "reason=\(describe(reason)) from=\(portTypes(previous)) to=\(portTypes(AVAudioSession.sharedInstance().currentRoute))"
127+
128+
switch reason {
129+
case .oldDeviceUnavailable, .newDeviceAvailable:
130+
LogManager.shared.log(category: .general, message: "[LA] Audio route changed, restarting silent audio: \(route)")
131+
// Same settle delay as an interruption, for a different reason: CarPlay and
132+
// Bluetooth transitions emit a burst of route changes, and each supersedes
133+
// the last so the ladder runs once against the settled route.
134+
onMain { self.recover(after: self.interruptionSettleDelay, reason: "route change") }
135+
136+
case .categoryChange:
137+
// Never recover here. `playAudio` sets the category itself, so recovering
138+
// would retrigger this notification indefinitely, and an alarm takes over
139+
// the session by changing category — reactivating with `.mixWithOthers`
140+
// mid-alert would strip the alarm's dominance.
141+
LogManager.shared.log(category: .general, message: "[LA] Audio route changed, ignoring: \(route)", isDebug: true)
142+
143+
default:
144+
// Logged but not acted on: no evidence yet ties these to a lost claim, and
145+
// a log line is how the next one earns a recovery.
146+
LogManager.shared.log(category: .general, message: "[LA] Audio route changed, no action: \(route)")
147+
}
148+
}
149+
150+
@objc private func mediaServicesWereReset(_: Notification) {
151+
LogManager.shared.log(category: .general, message: "[LA] Media services were reset — session and player are invalid, rebuilding")
152+
// `playAudio` reconfigures the category, reactivates, and creates a fresh
153+
// player, which is the recovery Apple prescribes for a reset.
154+
onMain { self.recover(after: self.interruptionSettleDelay, reason: "media services reset") }
155+
}
156+
157+
/// Port types only — `portName` carries the user's accessory name, which must not
158+
/// reach a shared log.
159+
private func portTypes(_ route: AVAudioSessionRouteDescription?) -> String {
160+
guard let route, !route.outputs.isEmpty else { return "none" }
161+
return route.outputs.map { $0.portType.rawValue }.joined(separator: "+")
162+
}
163+
164+
private func describe(_ reason: AVAudioSession.RouteChangeReason) -> String {
165+
switch reason {
166+
case .newDeviceAvailable: "newDeviceAvailable"
167+
case .oldDeviceUnavailable: "oldDeviceUnavailable"
168+
case .categoryChange: "categoryChange"
169+
case .override: "override"
170+
case .wakeFromSleep: "wakeFromSleep"
171+
case .noSuitableRouteForCategory: "noSuitableRouteForCategory"
172+
case .routeConfigurationChange: "routeConfigurationChange"
173+
case .unknown: "unknown"
174+
@unknown default: "other"
175+
}
176+
}
177+
98178
// MARK: - Interruption handling
99179

100180
@objc private func interruptedAudio(_ notification: Notification) {

LoopFollow/Task/TaskScheduler.swift

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,20 @@ class TaskScheduler {
3333
/// process was suspended and is the window the background alerts fire in.
3434
private var lastFireDate: Date?
3535

36+
/// Boot-relative counterpart to `lastFireDate`. It includes time asleep and cannot
37+
/// be moved by a clock correction, so it measures the gap even when the wall clock
38+
/// steps — and the difference between the two says a step happened.
39+
private var lastFireUptime: UInt64?
40+
3641
/// Above normal tick jitter, below the 6-minute first background alert.
3742
private let runtimeGapThreshold: TimeInterval = 120
3843

44+
/// Queue-confined park tracking. A normal park clears within milliseconds, so a
45+
/// survivor at this age is wedged or was suspended mid-park.
46+
private var parkedSince: Date?
47+
private var parkedReporter: DispatchWorkItem?
48+
private let parkedReportDelay: TimeInterval = 5
49+
3950
private init() {}
4051

4152
// MARK: - Public API
@@ -80,6 +91,12 @@ class TaskScheduler {
8091
return
8192
}
8293

94+
if earliestTask.nextRun == .distantFuture {
95+
noteTimerParked()
96+
} else {
97+
clearTimerParked()
98+
}
99+
83100
let interval = earliestTask.nextRun.timeIntervalSinceNow
84101
let safeInterval = max(interval, 0)
85102

@@ -117,12 +134,46 @@ class TaskScheduler {
117134
}
118135
}
119136

137+
/// `fireOverdueTasks` parks a task at `.distantFuture` and its action reschedules
138+
/// it asynchronously, so every task being parked at once is normal for the
139+
/// milliseconds in between. Only a park that outlives that is interesting: it means
140+
/// nothing is left to wake the timer. Reported by duration so the routine case
141+
/// stays silent.
142+
private func noteTimerParked() {
143+
guard parkedSince == nil else { return }
144+
let since = Date()
145+
parkedSince = since
146+
let work = DispatchWorkItem { [weak self] in
147+
guard let self, self.parkedSince == since else { return }
148+
LogManager.shared.log(
149+
category: .taskScheduler,
150+
message: "Timer still parked after \(Int(Date().timeIntervalSince(since)))s: every task is awaiting its action to reschedule it"
151+
)
152+
}
153+
parkedReporter = work
154+
queue.asyncAfter(deadline: .now() + parkedReportDelay, execute: work)
155+
}
156+
157+
private func clearTimerParked() {
158+
parkedReporter?.cancel()
159+
parkedReporter = nil
160+
parkedSince = nil
161+
}
162+
120163
/// Records one line per lost-runtime window, so the length of a background stall
121164
/// is readable directly instead of having to be inferred from timestamp gaps.
122165
private func noteRuntimeGap(at now: Date) {
123-
defer { lastFireDate = now }
124-
guard let last = lastFireDate else { return }
125-
let gap = now.timeIntervalSince(last)
166+
// CLOCK_MONOTONIC keeps counting while the device sleeps, unlike
167+
// CLOCK_UPTIME_RAW, so it measures a suspension rather than skipping it.
168+
let uptime = clock_gettime_nsec_np(CLOCK_MONOTONIC)
169+
defer {
170+
lastFireDate = now
171+
lastFireUptime = uptime
172+
}
173+
guard let last = lastFireDate, let lastUptime = lastFireUptime else { return }
174+
// Boot time is authoritative: a wall-clock correction must not hide a stall.
175+
let gap = Double(uptime &- lastUptime) / 1_000_000_000
176+
let wallGap = now.timeIntervalSince(last)
126177
guard gap >= runtimeGapThreshold else { return }
127178
// Silent Tune is the only mode whose invariant is continuous runtime, which is
128179
// what this measures. `.none` is meant to be suspended, and the Bluetooth modes
@@ -133,10 +184,11 @@ class TaskScheduler {
133184
.filter { gap >= $0.rawValue }
134185
.map { "\(Int($0.rawValue / 60))" }
135186
let fired = alerts.isEmpty ? "none" : alerts.joined(separator: "/") + " min"
136-
LogManager.shared.log(
137-
category: .taskScheduler,
138-
message: "Regained runtime after \(Int(gap))s with no scheduler tick; background alerts fired: \(fired)"
139-
)
187+
var message = "Regained runtime after \(Int(gap))s with no scheduler tick; background alerts fired: \(fired)"
188+
if abs(wallGap - gap) >= 5 {
189+
message += "; wall clock moved \(Int(wallGap - gap))s relative to boot time"
190+
}
191+
LogManager.shared.log(category: .taskScheduler, message: message)
140192
}
141193

142194
private func formatTime(_ date: Date) -> String {

0 commit comments

Comments
 (0)