본문 바로가기
C.W.K.
Stream
Lesson 09 of 10 · published

런타임 세션은 하나, 요구는 여럿

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

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"응답이 끝났는데도 계속 씽킹으로 떠 있어. 그래서 다시 스탑을 누르고 리스타트를 해야 다시 쓸 수 있거든."

손목엔 왜 세션이 필요할까

워치 앱은 화면이 꺼지면 멈춰. 그리고 손목을 내리면 화면이 꺼져. 대화 앱은 이걸 목소리로 배웠어. 워치 스피커로 나오던 답장이 주인이 팔을 내리는 순간 뚝 끊겼거든. 살려낸 건 오디오 설정이 아니었어. 손목을 내려도 워치 앱이 계속 돌게 해주는 건 WKExtendedRuntimeSession이야. 세션 종류는 워치 앱의 WKBackgroundModes에 선언해. 가족 앱은 mindfulness를 쓰는데, 이걸로 바꾸니 손목을 내려도 읽기가 계속됐어. 스피커로도, AirPods로도. 따라오는 규칙이 둘 있어. 하나, 세션은 앱이 활성 상태일 때만 시작돼. 아니면 mustBeActiveToStartOrSchedule로 거절당해. 그러니 손목이 내려간 뒤가 아니라 내려가기 전에 시작해야 해. 둘, 세션은 시스템이 끝내. 대개는 앱이 더 이상 맨 앞이 아니거나 시간이 다 돼서야.

주인은 셋, 세션은 하나

10월쯤 워치엔 앱이 살아 있어야 하는 부분이 셋 있었어. 듣고 기다리는 동안의 음성 턴, 앞 레슨에서 당기기를 맡은 기다림 담당, 답장을 소리 내 읽는 동안의 재생기. 셋 다 자기 세션을 따로 시작했고, 넘겨줄 땐 하나를 무효화하면서 바로 다음 걸 시작했어. 그다음 일은 WatchKit 헤더에 적혀 있어. "A session for this application has already been started, and is currently running, another session can not be started." 새로 시작한 세션은 이전 세션이 아직 끝나는 중일 때 떨어져서 sessionInProgress로 거절됐어. 코드는 그 이유를 뭉뚱그린 '기타'로 처리했고, 그 '기타'는 턴이 세션 없이 그냥 계속 가게 뒀어. 손목을 내리니 앱은 멈췄고, 폰이 읽기가 끝났다고 한 말을 끝내 못 들었어. 그래서 답이 다 끝났는데도 화면엔 "Thinking"이 남았지.

세션이 필요하다는 판단은 셋 다 맞았어. 버그는 플랫폼이 앱마다 딱 하나만 허락하는 걸 셋이 저마다 자기 것이라 믿은 데 있었어.

세션 말고 요구를 들어

고친 방법은 세션 주인을 하나로 두는 거야. 음성 턴, 기다림 담당, 재생기는 이제 아무것도 시작하지 않아. 요구하고, 내려놓기만 해. 요구들은 순수한 값 하나가 들고 있고, 이 값은 질문 하나에 답해. 살아 있는 세션이 있는지, 앱이 맨 앞인지가 주어지면 주인은 뭘 해야 하지? 누군가 필요로 하는데 살아 있는 세션이 없고 앱이 앞에 있으면 시작해. 요구가 하나라도 남아 있으면 유지해. 마지막 요구가 빠지면 5초 뒤에 끝내되, 그 사이 요구가 돌아오면 안 끝내. 다음 요구는 대개 곧바로 따라오거든. 턴이 끝나도 기다림 담당은 아직 목소리를 기다리고, 읽기는 턴 안에서 끝나. 끝내자마자 다시 시작하는 게 바로 이 설계가 막으려는 그 거절이야. 앱이 앞에 없으면 앞으로 나올 때까지 기다려. 손목을 들었을 때 아직 필요한 요구가 있으면 그때 세션이 시작돼.

끝에도 뜻이 있어

나머지 반은 무효화 이유를 정직하게 읽는 거야. resignedFrontmost는 착용자가 앱을 떠났다는 뜻이야. 다음 레슨은 이걸 대화의 끝으로 봐. 착용자가 꺼졌다고 믿는 마이크는 정말 꺼져 있어야 하니까. expired는 세션 시간이 다 됐다는 뜻이고, 이것도 대화를 끝내. sessionInProgress는 이 앱의 이전 세션이 아직 끝나는 중이었다는 뜻이라서, 주인은 1초 뒤에 한 번 다시 해봐. 다만 10초에 한 번보다 자주 하진 않아. 나머지는 시스템이 세션을 안 붙잡아준 거야. 화면이 켜져 있는 동안 일은 계속되고, 다음에 앱이 앞으로 나올 때 세션을 다시 시작해. 고치기 전에도 앞의 두 이유는 제대로 읽었어. 문제는 sessionInProgress가 '기타'로 떨어졌다는 거였고, 그 '기타'는 하필 가장 낙관적인 쪽이었어.

Pathfinder는 같은 규칙을 다른 쪽에서 만났어. 화면이 꺼진 동안 햅틱으로 손목을 두드리려는 워치 앱에도 세션이 필요해. 그 얘기는 Pathfinder 퀘스트에 있어.

Code

순수한 값으로 든 요구들: 누가 앱이 살아 있길 바라는지, 그리고 주인 하나가 뭘 하는지·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
}
주인 하나: 첫 요구에 시작하고, 마지막 요구 조금 뒤에 끝내고, 이유는 전부 읽기·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

테스트 묶음이 닿는 타깃에 RuntimeClaims를 넣고 다섯 경우를 확인해. 앱이 앞에 있을 때 들어온 첫 요구는 세션을 시작해. 시계 화면 뒤에서 들어온 요구는 앱이 앞으로 나오길 기다려. 내려놓고 5초 안에 다시 요구하면 세션은 그대로 유지돼. 마지막 요구를 내려놓으면 끝나. 다시 시도하는 건 anotherSession 하나뿐이야. 그다음 진짜 워치에서 버그를 일부러 재현해봐. 재생기와 기다림 담당에 세션을 따로따로 주고 서로 넘겨주게 한 다음, 무효화 이유를 전부 로그로 남겨. 둘 다 Runtime.shared에 요구하는 방식으로 바꾸고 다시 로그를 봐.
Hint
거절은 던져지는 오류가 아니라 start() 직후의 sessionInProgress 무효화로 나타나. 델리게이트가 이유마다 어느 세션 건지 같이 로그로 남기지 않으면 놓치기 쉬워. 워치 앱 Info.plist에 세션 종류를 선언하는 것도 잊지 마. 안 그러면 세션은 아예 시작도 안 돼.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.