Files
Termind/Frameworks/TermindApplication/SessionReconnectController.swift
T
2026-09-12 23:57:33 +08:00

147 lines
4.3 KiB
Swift

import Foundation
public nonisolated struct ReconnectPolicy: Sendable, Equatable {
public static let sessionDefault = ReconnectPolicy()
public var delays: [Duration]
public var stableResetInterval: TimeInterval
public init(
delays: [Duration] = [.seconds(1), .seconds(2), .seconds(5), .seconds(10), .seconds(30)],
stableResetInterval: TimeInterval = 60
) {
precondition(!delays.isEmpty)
self.delays = delays
self.stableResetInterval = stableResetInterval
}
public func delay(forAttempt attempt: Int) -> Duration {
delays[min(max(0, attempt - 1), delays.count - 1)]
}
}
public nonisolated struct ReconnectState: Equatable, Sendable {
public var attempt: Int
public var nextRetry: Date
public var lastError: String
public init(attempt: Int, nextRetry: Date, lastError: String) {
self.attempt = attempt
self.nextRetry = nextRetry
self.lastError = lastError
}
}
/// Owns only retry timing and stale-callback rejection. The session models keep
/// transport construction because they also own authentication and host-key UI.
@MainActor
@Observable
public final class SessionReconnectController {
public typealias RetryAction = @MainActor @Sendable () -> Void
public typealias Sleep = @Sendable (Duration) async throws -> Void
public typealias Now = @Sendable () -> Date
public private(set) var state: ReconnectState?
private let policy: ReconnectPolicy
private let sleep: Sleep
private let now: Now
private var retryTask: Task<Void, Never>?
private var retryAction: RetryAction?
private var attempts = 0
private var connectedAt: Date?
private var generation: UInt64 = 0
public init(
policy: ReconnectPolicy = .sessionDefault,
sleep: @escaping Sleep = { try await Task.sleep(for: $0) },
now: @escaping Now = Date.init
) {
self.policy = policy
self.sleep = sleep
self.now = now
}
public func markConnected(initial: Bool) {
cancelScheduledRetry()
state = nil
retryAction = nil
connectedAt = now()
if initial { attempts = 0 }
}
public func schedule(after error: String, retry: @escaping RetryAction) {
cancelScheduledRetry()
if let connectedAt,
now().timeIntervalSince(connectedAt) >= policy.stableResetInterval
{
attempts = 0
}
connectedAt = nil
attempts += 1
retryAction = retry
let delay = policy.delay(forAttempt: attempts)
state = ReconnectState(
attempt: attempts,
nextRetry: now().addingTimeInterval(delay.timeInterval),
lastError: error
)
schedule(delay: delay)
}
public func retryNow() {
guard let retryAction else { return }
cancelScheduledRetry()
generation &+= 1
retryAction()
}
public func stop() {
cancelScheduledRetry()
generation &+= 1
state = nil
retryAction = nil
attempts = 0
connectedAt = nil
}
/// Pauses retry timing without discarding the attempt counter. Used while
/// the connection journey waits for credentials or a host-key decision.
public func pauseForUserInteraction() {
cancelScheduledRetry()
generation &+= 1
state = nil
retryAction = nil
}
private func schedule(delay: Duration) {
generation &+= 1
let expectedGeneration = generation
let sleep = sleep
retryTask = Task.detached { [weak self, sleep] in
do { try await sleep(delay) } catch { return }
await self?.runScheduledRetry(expectedGeneration: expectedGeneration)
}
}
private func runScheduledRetry(expectedGeneration: UInt64) {
guard generation == expectedGeneration, let retryAction else { return }
retryAction()
}
private func cancelScheduledRetry() {
retryTask?.cancel()
retryTask = nil
}
}
nonisolated
extension Duration
{
fileprivate var timeInterval: TimeInterval {
let components = self.components
return TimeInterval(components.seconds)
+ TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000
}
}