ghp / packages /client /src /lib /clockSync.ts
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
7.07 kB
/**
* NTP-inspired clock synchronisation engine.
*
* Maintains a running estimate of the offset between the local clock
* (`Date.now()`) and the server clock so that `getServerTime()` returns
* a value aligned with the server's `Date.now()`.
*
* Algorithm:
* 1. Client sends NTP_PING with `clientPingId` (= monotonic counter).
* 2. Server immediately replies NTP_PONG with `{ clientPingId, serverTime }`.
* 3. Client records `t0` (when ping was sent, via `performance.now()`)
* and `t2` (when pong arrived, via `performance.now()`).
* 4. RTT = t2 - t0
* offset = serverTime - (clientLocalTimeAtPing + RTT / 2)
* 5. We keep a sliding window of samples and compute a **time-decayed
* weighted median** offset — recent samples contribute more, giving
* both the robustness of median filtering and fast convergence when
* network conditions change (e.g. WiFi → cellular handoff).
*/
import { NTP } from '@music-together/shared'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NTPSample {
rttMs: number
offsetMs: number
/** `Date.now()` when the sample was recorded */
timestamp: number
}
interface PendingPing {
/** `performance.now()` when the ping was sent */
sentAt: number
/** `Date.now()` when the ping was sent – used for offset calculation */
localTime: number
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Half-life (ms) for exponential decay weighting of NTP samples.
* Samples older than ~30s contribute roughly half the weight of fresh ones. */
const DECAY_HALF_LIFE_MS = 30_000
// ---------------------------------------------------------------------------
// State (module-level singleton – one clock per app)
// ---------------------------------------------------------------------------
const samples: NTPSample[] = []
const pending = new Map<number, PendingPing>()
let pingCounter = 0
let medianOffset = 0
let calibrated = false
// Anchor pair for monotonic getServerTime().
// `performance.now()` is monotonic and immune to system clock adjustments
// (NTP sync, manual time change, sleep/wake). We anchor a known server-time
// to a performance.now() reading and derive future server-times from the
// elapsed monotonic time, eliminating Date.now() jitter from getServerTime().
let anchorPerfNow = performance.now()
let anchorServerTime = Date.now() // uncalibrated initially; updated on each pong
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Compute time-decayed weighted median of offset values.
* Each sample's weight = exp(-age / halfLife), so recent samples dominate.
* We find the offset where cumulative weight first exceeds 50% of total.
*/
function computeWeightedMedian(now: number): number {
if (samples.length === 0) return 0
if (samples.length === 1) return samples[0].offsetMs
// Build (offset, weight) pairs
const pairs: { offset: number; weight: number }[] = []
for (const s of samples) {
const age = now - s.timestamp
const weight = Math.exp((-age * Math.LN2) / DECAY_HALF_LIFE_MS)
pairs.push({ offset: s.offsetMs, weight })
}
// Sort by offset ascending
pairs.sort((a, b) => a.offset - b.offset)
// Find weighted median
let totalWeight = 0
for (const p of pairs) totalWeight += p.weight
const halfWeight = totalWeight / 2
let cumWeight = 0
for (const p of pairs) {
cumWeight += p.weight
if (cumWeight >= halfWeight) return p.offset
}
// Fallback (should not reach here)
return pairs[pairs.length - 1].offset
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Returns the current server time (ms) compensated by the measured offset.
* Uses a monotonic `performance.now()` anchor so the result is immune to
* system clock adjustments (NTP sync, manual time change, sleep/wake). */
export function getServerTime(): number {
return anchorServerTime + (performance.now() - anchorPerfNow)
}
/** Whether the initial calibration phase has completed. */
export function isCalibrated(): boolean {
return calibrated
}
/** Current smoothed RTT (ms) – the median of all stored samples. */
export function getMedianRTT(): number {
if (samples.length === 0) return 0
const sorted = samples.map((s) => s.rttMs).sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
/** Current clock offset (ms). Positive = local clock is behind server. */
export function getClockOffset(): number {
return medianOffset
}
// ---------------------------------------------------------------------------
// Ping / Pong helpers (called by useClockSync hook)
// ---------------------------------------------------------------------------
/**
* Record a pending ping. Returns the `clientPingId` to send to the server.
*/
export function recordPing(): number {
const id = ++pingCounter
const now = Date.now()
pending.set(id, {
sentAt: performance.now(),
localTime: now,
})
// Purge stale entries older than 10 seconds (server never responded)
const staleThreshold = now - 10_000
for (const [k, v] of pending) {
if (v.localTime < staleThreshold) pending.delete(k)
else break // Map preserves insertion order; once we hit a fresh entry, stop
}
return id
}
/**
* Process a pong response from the server.
* Returns the computed RTT so the caller can forward it to the server.
*/
export function processPong(clientPingId: number, serverTime: number): number | null {
const ping = pending.get(clientPingId)
if (!ping) return null
pending.delete(clientPingId)
const t2 = performance.now()
const rttMs = t2 - ping.sentAt
// Discard obviously bad samples (negative or huge RTT)
if (rttMs < 0 || rttMs > 10_000) return null
const now = Date.now()
const oneWay = rttMs / 2
const offsetMs = serverTime - (ping.localTime + oneWay)
samples.push({ rttMs, offsetMs, timestamp: now })
// Keep sliding window bounded
if (samples.length > NTP.MAX_MEASUREMENTS) {
samples.shift()
}
// Recalculate time-decayed weighted median offset
medianOffset = computeWeightedMedian(now)
// Refresh the monotonic anchor so getServerTime() stays accurate
anchorPerfNow = performance.now()
anchorServerTime = Date.now() + medianOffset
if (!calibrated && samples.length >= NTP.MAX_INITIAL_SAMPLES) {
calibrated = true
}
return rttMs
}
/** Reset all state (useful on disconnect). */
export function resetClockSync(): void {
samples.length = 0
pending.clear()
pingCounter = 0
medianOffset = 0
calibrated = false
anchorPerfNow = performance.now()
anchorServerTime = Date.now()
}