/// Decides when a receiver counts as stalled and when to try reconnecting. /// /// Frames and a confirmed connection both count as signs of life: some senders (e.g. NDI Test Patterns) /// send a still image only once, which is not an outage. public struct ConnectionWatchdog: Sendable, Equatable { public enum State: Equatable, Sendable { case connecting case live /// No frames for a while. `retryIn` is the time until the next reconnect attempt. case stalled(retryIn: Double) } public var stallAfter: Double public var retryInterval: Double private var lastFrame: Double? private var lastAlive: Double? private var lastRetry: Double? private let startedAt: Double public init(now: Double, stallAfter: Double = 2, retryInterval: Double = 3) { self.startedAt = now self.stallAfter = stallAfter self.retryInterval = retryInterval } public mutating func frameArrived(at now: Double) { lastFrame = now connectionConfirmed(at: now) } /// The sender still holds a connection to us, even if no new frames arrive. public mutating func connectionConfirmed(at now: Double) { lastAlive = now lastRetry = nil } public func state(at now: Double) -> State { let reference = lastAlive ?? startedAt guard now - reference >= stallAfter else { return lastFrame == nil ? .connecting : .live } let since = lastRetry ?? reference + stallAfter return .stalled(retryIn: max(0, retryInterval - (now - since))) } /// Returns true when a reconnect should be attempted now, and records the attempt. public mutating func shouldRetry(at now: Double) -> Bool { guard case .stalled(let retryIn) = state(at: now), retryIn == 0 else { return false } lastRetry = now return true } }