Beats kommen jetzt mit Server-Wall-Clock-Zeitstempel rein, lokal
gegen monotonic ContinuousClock geschedult — kompensiert WS-Latenz
und Hue-CLIP-Round-Trip.
Was:
- Sources/Core/Sync/ClockSync.swift @MainActor:
- refresh(samples: 5): GET /v1/time fünfmal, Sample mit kleinster
RTT gewinnt (Jitter-resistent); offsetMs = serverTime - midpoint
- localDeadline(forServerTimeMs:): mappt Server-Wall-Clock zurück
auf lokalen ContinuousClock.Instant für Task.sleep
- currentServerTimeMs(): lokaler now + offset
- BeatSubscriber.openOnce(): refresht ClockSync vor dem WS-Connect
- BeatSubscriber.scheduleBeat(beat): Task.sleep bis localDeadline,
dann onBeat-Callback. Falls beat.at fehlt: sofort feuern (V1-Fallback)
- BeatEvent.at type Int → Int64 (passend zu Server-Wire-Format)
Build iOS+macOS BUILD SUCCEEDED, 18/18 Tests grün.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92 lines
3.4 KiB
Swift
92 lines
3.4 KiB
Swift
import Foundation
|
|
import OSLog
|
|
|
|
/// NTP-Style-Clock-Sync gegen den `/v1/time`-Endpoint von
|
|
/// mana-presence. Misst Offset zwischen lokaler
|
|
/// `ContinuousClock.now` und der Server-Wall-Clock (Unix-ms).
|
|
///
|
|
/// Algorithmus:
|
|
/// 1. 5 samples, jeder als `(local_before, server_time, local_after)`
|
|
/// 2. RTT = local_after - local_before
|
|
/// 3. Sample mit niedrigstem RTT gewinnt (geringste Jitter-Verfälschung)
|
|
/// 4. Offset = server_time - (local_before + RTT/2)
|
|
///
|
|
/// Verwendung:
|
|
/// ```swift
|
|
/// let sync = ClockSync(baseURL: AppConfig.presenceBaseURL)
|
|
/// try await sync.refresh()
|
|
/// let deadline = sync.localDeadline(forServerTimeMs: beat.at)
|
|
/// try await Task.sleep(until: deadline, clock: ContinuousClock())
|
|
/// ```
|
|
@MainActor
|
|
final class ClockSync {
|
|
private(set) var offsetMs: Int64 = 0
|
|
private(set) var rttMs: Int64 = 0
|
|
private(set) var lastSyncedAt: ContinuousClock.Instant?
|
|
|
|
private let baseURL: URL
|
|
private let urlSession: URLSession
|
|
private let log = Logger(subsystem: "ev.mana.beats", category: "clock-sync")
|
|
|
|
init(baseURL: URL, session: URLSession = .shared) {
|
|
self.baseURL = baseURL
|
|
self.urlSession = session
|
|
}
|
|
|
|
/// Misst den Offset neu. Idempotent — bei Fehler bleibt der letzte
|
|
/// bekannte Wert aktiv.
|
|
func refresh(samples: Int = 5) async throws {
|
|
struct TimeResponse: Decodable { let serverTime: Int64 }
|
|
var best: (offset: Int64, rtt: Int64)?
|
|
let url = baseURL.appendingPathComponent("/v1/time")
|
|
|
|
for _ in 0..<samples {
|
|
let before = ContinuousClock.now
|
|
let (data, _) = try await urlSession.data(from: url)
|
|
let after = ContinuousClock.now
|
|
let server = try JSONDecoder().decode(TimeResponse.self, from: data)
|
|
let rttDuration = after - before
|
|
let rttMs = Int64(rttDuration.components.seconds * 1000)
|
|
+ Int64(rttDuration.components.attoseconds / 1_000_000_000_000_000)
|
|
let localMidMs = Self.toMs(instant: before) + rttMs / 2
|
|
let offsetMs = server.serverTime - localMidMs
|
|
if best == nil || rttMs < best!.rtt {
|
|
best = (offsetMs, rttMs)
|
|
}
|
|
}
|
|
|
|
guard let chosen = best else { return }
|
|
offsetMs = chosen.offset
|
|
rttMs = chosen.rtt
|
|
lastSyncedAt = ContinuousClock.now
|
|
log.info("Clock-Sync offset=\(self.offsetMs, privacy: .public)ms rtt=\(self.rttMs, privacy: .public)ms")
|
|
}
|
|
|
|
/// Wandelt einen Server-Wall-Clock-Zeitstempel (Unix-ms) in einen
|
|
/// lokalen `ContinuousClock.Instant`, gegen den man `Task.sleep`
|
|
/// scheduln kann.
|
|
func localDeadline(forServerTimeMs serverMs: Int64) -> ContinuousClock.Instant {
|
|
let localMs = serverMs - offsetMs
|
|
let nowMs = Self.toMs(instant: ContinuousClock.now)
|
|
let deltaMs = max(0, localMs - nowMs)
|
|
return ContinuousClock.now.advanced(by: .milliseconds(Int(deltaMs)))
|
|
}
|
|
|
|
/// Aktuelle Server-Wall-Clock-Zeit (Unix-ms) basierend auf lokalem
|
|
/// monotonic-clock + Offset. Nutzbar für Lookahead-Sender.
|
|
func currentServerTimeMs() -> Int64 {
|
|
Self.toMs(instant: ContinuousClock.now) + offsetMs
|
|
}
|
|
|
|
private static func toMs(instant: ContinuousClock.Instant) -> Int64 {
|
|
// ContinuousClock.Instant ist kein epoch-bezogenes Maß. Wir
|
|
// brauchen einen RELATIVEN Diff, also referenzieren wir auf
|
|
// einen fixen Anchor (sharedAnchor); der absolute Wert ist
|
|
// egal solange wir konsistent sind.
|
|
let dur = instant - Self.anchor
|
|
return Int64(dur.components.seconds * 1000)
|
|
+ Int64(dur.components.attoseconds / 1_000_000_000_000_000)
|
|
}
|
|
|
|
private static let anchor: ContinuousClock.Instant = ContinuousClock.now
|
|
}
|