moodlit-native/Sources/Core/Sync/ClockSync.swift
till c92995b7e9 feat(beat-sync): μ-10.4 NTP-Style Lookahead-Timing für tight Beat-Sync
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>
2026-05-19 15:44:04 +02:00

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
}