Skip to content
C.W.K.
Stream
Lesson 09 of 10 · published

One Runtime Session, Many Claims

~15 min · on-the-wrist, watchkit, extended-runtime-session, ownership, background-execution

Level 0Bundle Opener
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"The reply was over, and it still said Thinking. I had to press Stop and start again."

Why the Wrist Needs a Session at All

A watch app is suspended when its screen sleeps, and the screen sleeps when the wrist goes down. The conversation app learned this with its voice: a reply played through the watch's speaker stopped the moment the owner lowered his arm. What brought it back was not an audio setting. What keeps a watch app running with the wrist down is a WKExtendedRuntimeSession, with a session type declared in the watch app's WKBackgroundModes. The family uses mindfulness, and with it the reading went on with the wrist down, through the speaker and through AirPods alike. Two rules come with it. A session starts only while the app is active (mustBeActiveToStartOrSchedule otherwise), so it must be started before the wrist drops, not after. And it ends on the system's word, most often because the app stopped being the one in front or because its time ran out.

Three Owners, One Session

By October the watch had three parts that each needed the app alive: the voice turn while it listened and waited, the wait keeper from the previous lesson while it pulled, and the player while a reply was read aloud. Each started its own session, and they handed off by invalidating one and starting the next in the same breath. WatchKit's header says what happens next: "A session for this application has already been started, and is currently running, another session can not be started." The new start landed while the old session was still ending and was refused with sessionInProgress. The code mapped that reason to a catch-all that let the turn carry on with no session at all. With the wrist down the app was suspended, it never heard the phone say the reading was over, and "Thinking" stayed on the screen after she had finished.

Every part was right about needing a session. The bug was that three parts each believed they owned one, when the platform allows an app exactly one.

Claims, Not Sessions

The fix gives the session one owner. The turn, the wait keeper and the player no longer start anything; they claim and release. A pure value holds the claims and answers one question: given whether a session is alive and whether the app is in front, what should the owner do? Start one when somebody needs it, none is alive and the app is in front. Keep it while any claim holds. When the last claim goes, end it five seconds later, unless a claim comes back first, because the next claim usually follows at once (a turn ends while the keeper still waits for the voice; a reading ends inside a turn), and ending and restarting in the same breath is exactly the refusal this design exists to prevent. When the app is not in front, wait for it: raising the wrist starts the session if a claim still needs it.

Ends That Mean Something

Reading the invalidation reason honestly is the other half. resignedFrontmost means the wearer left the app, and the next lesson treats that as the end of the conversation, because a microphone the wearer believes is off must be off. expired means the session's time ran out, and that ends the conversation too. sessionInProgress means an earlier session of this app was still ending, so the owner tries again once, a second later, and never more often than every ten seconds. Anything else means the system would not keep it: the work goes on while the screen is up, and the session starts again the next time the app comes to the front. Before the fix, the first two were already read correctly; sessionInProgress fell into the catch-all, and the catch-all was the most optimistic one.

Pathfinder met the same rule from another side: a watch app that wants to tap the wrist with haptics while the screen is off needs a session too. That story is in the Pathfinder quest.

Code

The claims as a pure value: who needs the app alive, and what the one owner does about it·swift
import Foundation

/// Who needs the watch app running with the wrist down. watchOS runs ONE extended runtime
/// session per app, so the parts never start one; they claim it and release it.
struct RuntimeClaims: Equatable {
    enum Claim: Hashable, Sendable { case turn, wait, reading }

    private(set) var claims: Set<Claim> = []
    var wanted: Bool { !claims.isEmpty }

    mutating func claim(_ claim: Claim) { claims.insert(claim) }
    mutating func release(_ claim: Claim) { claims.remove(claim) }

    enum Step: Equatable { case start, keep, endSoon, waitForFront, nothing }

    /// `alive`: a session this app started that the system has not ended (starting or running).
    func step(alive: Bool, appActive: Bool) -> Step {
        switch (wanted, alive) {
        case (true, true): return .keep
        case (true, false): return appActive ? .start : .waitForFront   // a session starts only in front
        case (false, true): return .endSoon
        case (false, false): return .nothing
        }
    }

    /// Kept this long after the last release: the next claim usually follows at once, and an
    /// end followed by a start in the same breath is refused with sessionInProgress.
    static let grace: TimeInterval = 5

    enum End: Equatable { case leftTheApp, expired, anotherSession, other }

    /// Only the refusal for an earlier session of ours is worth a quick second try.
    static func retries(after end: End) -> Bool { end == .anotherSession }
    static let retryDelay: TimeInterval = 1
    static let retrySpacing: TimeInterval = 10
}
The one owner: starts on the first claim, ends a little after the last, and reads every reason·swift
import WatchKit

@MainActor
final class Runtime {
    static let shared = Runtime()

    private var claims = RuntimeClaims()
    private var session: WKExtendedRuntimeSession?
    private var ending: Task<Void, Never>?
    private var lastRetry: Date?
    private let delegate = Delegate()                 // retained: the session's delegate is weak

    /// The conversation decides whether it ends with the session (it does for leftTheApp and expired).
    var onSystemEnd: (RuntimeClaims.End) -> Void = { _ in }

    private init() {
        delegate.onEnd = { [weak self] id, end in self?.systemEnded(id, end) }
    }

    func claim(_ c: RuntimeClaims.Claim) { claims.claim(c); apply() }
    func release(_ c: RuntimeClaims.Claim) { claims.release(c); apply() }
    func cameToFront() { apply() }                     // a claim made behind the face starts now

    private var alive: Bool { session.map { $0.state != .invalid } ?? false }

    private func apply() {
        let active = WKApplication.shared().applicationState == .active
        switch claims.step(alive: alive, appActive: active) {
        case .start:
            ending?.cancel(); ending = nil
            let s = WKExtendedRuntimeSession()        // the type comes from WKBackgroundModes
            s.delegate = delegate
            s.start()
            session = s
        case .keep:
            ending?.cancel(); ending = nil            // claimed again inside the grace
        case .endSoon:
            guard ending == nil else { return }
            ending = Task { [weak self] in
                try? await Task.sleep(for: .seconds(RuntimeClaims.grace))
                guard let self, !Task.isCancelled, !self.claims.wanted else { return }
                self.ending = nil
                self.session?.invalidate()
                self.session = nil
            }
        case .waitForFront, .nothing:
            break
        }
    }

    private func systemEnded(_ id: ObjectIdentifier, _ end: RuntimeClaims.End) {
        guard let session, ObjectIdentifier(session) == id else { return }   // an older session's word
        self.session = nil
        guard claims.wanted else { return }
        onSystemEnd(end)
        guard RuntimeClaims.retries(after: end) else { return }
        // Once, a second later, and never more often than every ten seconds: a refusal that keeps
        // coming back waits for the app's next return to the front instead of looping.
        if let lastRetry, Date().timeIntervalSince(lastRetry) < RuntimeClaims.retrySpacing { return }
        lastRetry = Date()
        Task { [weak self] in
            try? await Task.sleep(for: .seconds(RuntimeClaims.retryDelay))
            self?.apply()
        }
    }

    private final class Delegate: NSObject, WKExtendedRuntimeSessionDelegate {
        var onEnd: (@MainActor (ObjectIdentifier, RuntimeClaims.End) -> Void)?

        func extendedRuntimeSessionDidStart(_ s: WKExtendedRuntimeSession) {}
        func extendedRuntimeSessionWillExpire(_ s: WKExtendedRuntimeSession) {}
        func extendedRuntimeSession(_ s: WKExtendedRuntimeSession,
                                    didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
                                    error: Error?) {
            let end: RuntimeClaims.End = switch reason {
            case .resignedFrontmost: .leftTheApp
            case .expired: .expired
            case .sessionInProgress: .anotherSession   // never fold this into "other"
            default: .other
            }
            let id = ObjectIdentifier(s), onEnd = onEnd
            Task { @MainActor in onEnd?(id, end) }
        }
    }
}

External links

Exercise

Add RuntimeClaims to a target your test suite can reach and test five cases: the first claim in front starts, a claim behind the face waits for the front, a release followed by a claim within five seconds keeps the session, the last release ends it, and only anotherSession retries. Then reproduce the bug on purpose on a real watch: give the reading and the wait keeper each their own session, hand off between them, and log every invalidation reason. Replace both with claims on Runtime.shared and log again.
Hint
The refusal shows up as an invalidation with sessionInProgress immediately after start(), not as a thrown error, so it is easy to miss unless the delegate logs every reason with the session it belongs to. Remember to declare the session type in the watch app's Info.plist, or the session never starts at all.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.