🎉 live server seems to be working now
This commit is contained in:
132
third_party/sendspin-go/pkg/sync/clock.go
vendored
Normal file
132
third_party/sendspin-go/pkg/sync/clock.go
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
// ABOUTME: Clock synchronization using Kalman time filter
|
||||
// ABOUTME: Tracks offset and drift between client and server clocks
|
||||
package sync
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClockSync manages clock synchronization using a Kalman time filter.
|
||||
type ClockSync struct {
|
||||
mu sync.RWMutex
|
||||
filter *TimeFilter
|
||||
rtt int64
|
||||
quality Quality
|
||||
lastSync time.Time
|
||||
sampleCount int
|
||||
}
|
||||
|
||||
type Quality int
|
||||
|
||||
const (
|
||||
QualityGood Quality = iota
|
||||
QualityDegraded
|
||||
QualityLost
|
||||
)
|
||||
|
||||
// Quality thresholds in microseconds of offset σ (filter.GetError()).
|
||||
// QualityGood: filter has converged; sub-100µs sync uncertainty.
|
||||
// QualityDegraded: filter still useful but uncertainty is large.
|
||||
// QualityLost: no recent sync OR uncertainty so high the estimate is suspect.
|
||||
const (
|
||||
qualityGoodMaxErrorUs = 100
|
||||
qualityDegradedMaxErrorUs = 5000
|
||||
)
|
||||
|
||||
func qualityFromError(errUs int64) Quality {
|
||||
switch {
|
||||
case errUs < qualityGoodMaxErrorUs:
|
||||
return QualityGood
|
||||
case errUs < qualityDegradedMaxErrorUs:
|
||||
return QualityDegraded
|
||||
default:
|
||||
return QualityLost
|
||||
}
|
||||
}
|
||||
|
||||
func NewClockSync() *ClockSync {
|
||||
return NewClockSyncWithConfig(DefaultTimeFilterConfig())
|
||||
}
|
||||
|
||||
// NewClockSyncWithConfig creates a ClockSync with a custom filter configuration.
|
||||
func NewClockSyncWithConfig(cfg TimeFilterConfig) *ClockSync {
|
||||
return &ClockSync{
|
||||
filter: NewTimeFilter(cfg),
|
||||
quality: QualityLost,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessSyncResponse processes a server/time response.
|
||||
// t1: client send (Unix µs), t2: server receive (server µs),
|
||||
// t3: server send (server µs), t4: client receive (Unix µs)
|
||||
func (cs *ClockSync) ProcessSyncResponse(t1, t2, t3, t4 int64) {
|
||||
rtt := (t4 - t1) - (t3 - t2)
|
||||
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
cs.rtt = rtt
|
||||
cs.lastSync = time.Now()
|
||||
|
||||
// NTP-style offset and uncertainty.
|
||||
measurement := ((t2 - t1) + (t3 - t4)) / 2
|
||||
maxError := rtt / 2
|
||||
|
||||
cs.filter.Update(measurement, maxError, t4)
|
||||
cs.quality = qualityFromError(cs.filter.GetError())
|
||||
|
||||
cs.sampleCount++
|
||||
|
||||
if cs.sampleCount <= 5 {
|
||||
filterErr := cs.filter.GetError()
|
||||
log.Printf("Sync #%d: rtt=%dμs, offset=%dμs, error=%dμs",
|
||||
cs.sampleCount, rtt, measurement, filterErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *ClockSync) GetStats() (rtt int64, quality Quality) {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return cs.rtt, cs.quality
|
||||
}
|
||||
|
||||
// CheckQuality updates quality based on time since last sync
|
||||
func (cs *ClockSync) CheckQuality() Quality {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
if time.Since(cs.lastSync) > 5*time.Second {
|
||||
cs.quality = QualityLost
|
||||
}
|
||||
|
||||
return cs.quality
|
||||
}
|
||||
|
||||
// ServerToLocalTime converts server timestamp (µs) to local wall clock time.
|
||||
func (cs *ClockSync) ServerToLocalTime(serverTime int64) time.Time {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
if !cs.filter.Synced() {
|
||||
return time.Unix(0, serverTime*1000)
|
||||
}
|
||||
|
||||
// server→client conversion gives us client Unix µs
|
||||
clientMicros := cs.filter.ComputeClientTime(serverTime)
|
||||
return time.UnixMicro(clientMicros)
|
||||
}
|
||||
|
||||
// ServerMicrosNow returns current time in server's reference frame (us).
|
||||
// This is the instance method equivalent of the deprecated package-level ServerMicrosNow().
|
||||
func (cs *ClockSync) ServerMicrosNow() int64 {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
|
||||
if !cs.filter.Synced() {
|
||||
return time.Now().UnixMicro()
|
||||
}
|
||||
|
||||
return cs.filter.ComputeServerTime(time.Now().UnixMicro())
|
||||
}
|
||||
198
third_party/sendspin-go/pkg/sync/clock_test.go
vendored
Normal file
198
third_party/sendspin-go/pkg/sync/clock_test.go
vendored
Normal file
@@ -0,0 +1,198 @@
|
||||
// ABOUTME: Tests for Kalman-filter-based clock synchronization
|
||||
// ABOUTME: Tests RTT calculation, time conversion, quality tracking
|
||||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRTTCalculation(t *testing.T) {
|
||||
t1 := int64(1000000)
|
||||
t2 := int64(2000)
|
||||
t3 := int64(2500)
|
||||
t4 := int64(1005000)
|
||||
|
||||
cs := NewClockSync()
|
||||
cs.ProcessSyncResponse(t1, t2, t3, t4)
|
||||
|
||||
// RTT = (t4-t1) - (t3-t2) = 5000 - 500 = 4500µs
|
||||
rtt, _ := cs.GetStats()
|
||||
if rtt != 4500 {
|
||||
t.Errorf("expected RTT 4500µs, got %dµs", rtt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncEstablishment(t *testing.T) {
|
||||
cs := NewClockSync()
|
||||
|
||||
if cs.filter.Synced() {
|
||||
t.Error("expected not synced initially")
|
||||
}
|
||||
|
||||
// One low-noise sample is enough to mark the filter Synced.
|
||||
cs.ProcessSyncResponse(1_000_000, 500_000, 500_100, 1_000_200)
|
||||
|
||||
if !cs.filter.Synced() {
|
||||
t.Error("expected synced after first response")
|
||||
}
|
||||
|
||||
// Drive enough low-noise samples to converge to QualityGood.
|
||||
for i := 1; i < 60; i++ {
|
||||
t1 := int64(1_000_000 + i*100_000)
|
||||
cs.ProcessSyncResponse(t1, t1+50, t1+150, t1+200) // ~200µs RTT
|
||||
}
|
||||
_, quality := cs.GetStats()
|
||||
if quality != QualityGood {
|
||||
t.Errorf("expected QualityGood after convergence, got %v", quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerToLocalTimeConversion(t *testing.T) {
|
||||
cs := NewClockSync()
|
||||
|
||||
clientNow := time.Now().UnixMicro()
|
||||
serverTime := int64(5000000) // 5s into server loop
|
||||
|
||||
// Feed several samples to let the filter converge
|
||||
for i := 0; i < 10; i++ {
|
||||
ct := clientNow + int64(i*100000) // 100ms apart
|
||||
st := serverTime + int64(i*100000)
|
||||
cs.ProcessSyncResponse(ct-1000, st, st+50, ct)
|
||||
}
|
||||
|
||||
// Convert a server time 100ms in the future
|
||||
futureServer := serverTime + 10*100000 + 100000
|
||||
localTime := cs.ServerToLocalTime(futureServer)
|
||||
|
||||
expectedLocal := time.UnixMicro(clientNow + 10*100000 + 100000)
|
||||
diff := localTime.Sub(expectedLocal).Microseconds()
|
||||
|
||||
if diff < -50000 || diff > 50000 {
|
||||
t.Errorf("time conversion off by %dµs", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityTracking(t *testing.T) {
|
||||
cs := NewClockSync()
|
||||
|
||||
// Single noisy sample → high σ → not yet QualityGood.
|
||||
cs.ProcessSyncResponse(1000000, 1000, 1100, 1025000)
|
||||
_, quality := cs.GetStats()
|
||||
if quality == QualityGood {
|
||||
t.Errorf("expected non-Good quality on first sample, got %v", quality)
|
||||
}
|
||||
|
||||
// Drive enough low-noise samples to converge below the QualityGood threshold.
|
||||
for i := 1; i < 60; i++ {
|
||||
t1 := int64(1_000_000 + i*100_000)
|
||||
cs.ProcessSyncResponse(t1, t1+50, t1+150, t1+200) // ~200µs RTT
|
||||
}
|
||||
_, quality = cs.GetStats()
|
||||
if quality != QualityGood {
|
||||
t.Errorf("expected QualityGood after convergence, got %v (filter err=%d)",
|
||||
quality, cs.filter.GetError())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityDegradation(t *testing.T) {
|
||||
cs := NewClockSync()
|
||||
|
||||
// Drive enough low-noise samples to reach QualityGood.
|
||||
for i := 0; i < 60; i++ {
|
||||
t1 := int64(1_000_000 + i*100_000)
|
||||
cs.ProcessSyncResponse(t1, t1+50, t1+150, t1+200) // ~200µs RTT
|
||||
}
|
||||
|
||||
quality := cs.CheckQuality()
|
||||
if quality != QualityGood {
|
||||
t.Errorf("expected QualityGood initially, got %v", quality)
|
||||
}
|
||||
|
||||
cs.mu.Lock()
|
||||
cs.lastSync = time.Now().Add(-6 * time.Second)
|
||||
cs.mu.Unlock()
|
||||
|
||||
quality = cs.CheckQuality()
|
||||
if quality != QualityLost {
|
||||
t.Errorf("expected QualityLost after 6s, got %v", quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockSync_ServerMicrosNow(t *testing.T) {
|
||||
cs := NewClockSync()
|
||||
|
||||
// Before sync, should return roughly current Unix micros
|
||||
now1 := cs.ServerMicrosNow()
|
||||
unixNow := time.Now().UnixMicro()
|
||||
if abs64(now1-unixNow) > 1000000 {
|
||||
t.Errorf("before sync: expected ~%d, got %d", unixNow, now1)
|
||||
}
|
||||
|
||||
// After sync, should return server-frame time
|
||||
cs.ProcessSyncResponse(1000, 500000, 500100, 1200)
|
||||
now2 := cs.ServerMicrosNow()
|
||||
if now2 == 0 {
|
||||
t.Error("after sync: got zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClockSyncWithConfig(t *testing.T) {
|
||||
cfg := DefaultTimeFilterConfig()
|
||||
cfg.MaxErrorScale = 0.25
|
||||
|
||||
csDefault := NewClockSync()
|
||||
csScaled := NewClockSyncWithConfig(cfg)
|
||||
|
||||
const samples = 30
|
||||
for i := 0; i < samples; i++ {
|
||||
t1 := int64(1_000_000 + i*100_000)
|
||||
t2 := int64(500_000 + i*100_000)
|
||||
t3 := t2 + 100
|
||||
t4 := t1 + 1000 // ~1ms RTT
|
||||
csDefault.ProcessSyncResponse(t1, t2, t3, t4)
|
||||
csScaled.ProcessSyncResponse(t1, t2, t3, t4)
|
||||
}
|
||||
|
||||
errDefault := csDefault.filter.GetError()
|
||||
errScaled := csScaled.filter.GetError()
|
||||
if !(errScaled < errDefault) {
|
||||
t.Errorf("expected scaled (0.25) error < default (0.5); got scaled=%d default=%d",
|
||||
errScaled, errDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
cs := NewClockSync()
|
||||
|
||||
cs.ProcessSyncResponse(1000000, 1000, 1100, 1025000)
|
||||
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
for j := 0; j < 100; j++ {
|
||||
cs.GetStats()
|
||||
cs.CheckQuality()
|
||||
cs.ServerMicrosNow()
|
||||
cs.ServerToLocalTime(int64(j * 1000))
|
||||
cs.ProcessSyncResponse(
|
||||
int64(1000000+j), int64(1000+j),
|
||||
int64(1100+j), int64(1025000+j),
|
||||
)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
rtt, quality := cs.GetStats()
|
||||
if rtt <= 0 {
|
||||
t.Error("invalid RTT after concurrent access")
|
||||
}
|
||||
if quality == QualityLost {
|
||||
t.Error("unexpected QualityLost after concurrent access")
|
||||
}
|
||||
}
|
||||
11
third_party/sendspin-go/pkg/sync/doc.go
vendored
Normal file
11
third_party/sendspin-go/pkg/sync/doc.go
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
// ABOUTME: Clock synchronization using Kalman time filter
|
||||
// ABOUTME: Provides NTP-style clock sync with offset and drift tracking
|
||||
//
|
||||
// Package sync provides clock synchronization for precise audio timing.
|
||||
//
|
||||
// Uses a two-dimensional Kalman filter to track both clock offset and drift
|
||||
// between client and server, following the Sendspin time filter specification.
|
||||
// NTP-style round-trip time measurements feed the filter for optimal estimation.
|
||||
//
|
||||
// Reference: https://github.com/Sendspin/time-filter
|
||||
package sync
|
||||
239
third_party/sendspin-go/pkg/sync/timefilter.go
vendored
Normal file
239
third_party/sendspin-go/pkg/sync/timefilter.go
vendored
Normal file
@@ -0,0 +1,239 @@
|
||||
// ABOUTME: Kalman filter for NTP-style time synchronization
|
||||
// ABOUTME: Tracks clock offset and drift between client and server
|
||||
package sync
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// TimeFilter is a two-dimensional Kalman filter that tracks clock offset and
|
||||
// drift rate between client and server using NTP-style time messages.
|
||||
// Implements the Sendspin time filter specification.
|
||||
type TimeFilter struct {
|
||||
mu sync.Mutex
|
||||
|
||||
lastUpdate int64
|
||||
|
||||
offset float64
|
||||
drift float64
|
||||
|
||||
offsetCovariance float64
|
||||
offsetDriftCovariance float64
|
||||
driftCovariance float64
|
||||
|
||||
processVariance float64
|
||||
driftProcessVariance float64
|
||||
forgetVarianceFactor float64
|
||||
adaptiveForgettingCutoff float64
|
||||
driftSignificanceThresholdSq float64
|
||||
maxErrorScale float64
|
||||
|
||||
useDrift bool
|
||||
count uint8
|
||||
minSamples uint8
|
||||
}
|
||||
|
||||
// TimeFilterConfig holds configuration for the Kalman filter.
|
||||
type TimeFilterConfig struct {
|
||||
// ProcessStdDev is the standard deviation of offset process noise in µs.
|
||||
ProcessStdDev float64
|
||||
// DriftProcessStdDev is the standard deviation of drift process noise in µs/s.
|
||||
DriftProcessStdDev float64
|
||||
// ForgetFactor (>1) applied to covariances when large residuals are detected.
|
||||
ForgetFactor float64
|
||||
// AdaptiveCutoff is the fraction of max_error (0-1) that triggers forgetting.
|
||||
AdaptiveCutoff float64
|
||||
// MinSamples before adaptive forgetting is enabled.
|
||||
MinSamples uint8
|
||||
// DriftSignificanceThreshold is the SNR threshold for applying drift compensation.
|
||||
DriftSignificanceThreshold float64
|
||||
// MaxErrorScale scales max_error before use as the measurement std dev.
|
||||
// Spec recommends 0.5; values <1 indicate max_error overestimates noise.
|
||||
MaxErrorScale float64
|
||||
}
|
||||
|
||||
// DefaultTimeFilterConfig returns the canonical defaults from the upstream
|
||||
// Sendspin/time-filter reference (PR #6, 2026-04-27).
|
||||
func DefaultTimeFilterConfig() TimeFilterConfig {
|
||||
return TimeFilterConfig{
|
||||
ProcessStdDev: 0.0,
|
||||
DriftProcessStdDev: 1e-11,
|
||||
ForgetFactor: 2.0,
|
||||
AdaptiveCutoff: 3.0,
|
||||
MinSamples: 100,
|
||||
DriftSignificanceThreshold: 2.0,
|
||||
MaxErrorScale: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTimeFilter creates a Kalman filter for time synchronization.
|
||||
func NewTimeFilter(cfg TimeFilterConfig) *TimeFilter {
|
||||
maxErrorScale := cfg.MaxErrorScale
|
||||
if maxErrorScale <= 0 {
|
||||
maxErrorScale = 1.0 // avoid zero variance → div-by-zero in Kalman gain
|
||||
}
|
||||
tf := &TimeFilter{
|
||||
processVariance: cfg.ProcessStdDev * cfg.ProcessStdDev,
|
||||
driftProcessVariance: cfg.DriftProcessStdDev * cfg.DriftProcessStdDev,
|
||||
forgetVarianceFactor: cfg.ForgetFactor * cfg.ForgetFactor,
|
||||
adaptiveForgettingCutoff: cfg.AdaptiveCutoff,
|
||||
driftSignificanceThresholdSq: cfg.DriftSignificanceThreshold * cfg.DriftSignificanceThreshold,
|
||||
maxErrorScale: maxErrorScale,
|
||||
minSamples: cfg.MinSamples,
|
||||
}
|
||||
tf.reset()
|
||||
return tf
|
||||
}
|
||||
|
||||
// Update processes a new time synchronization measurement.
|
||||
//
|
||||
// measurement: ((T2-T1)+(T3-T4))/2 in microseconds
|
||||
// maxError: ((T4-T1)-(T3-T2))/2 in microseconds
|
||||
// timeAdded: client timestamp when measurement was taken, in microseconds
|
||||
func (tf *TimeFilter) Update(measurement, maxError, timeAdded int64) {
|
||||
tf.mu.Lock()
|
||||
defer tf.mu.Unlock()
|
||||
|
||||
if timeAdded <= tf.lastUpdate {
|
||||
return // skip non-monotonic timestamps
|
||||
}
|
||||
|
||||
dt := float64(timeAdded - tf.lastUpdate)
|
||||
dtSq := dt * dt
|
||||
tf.lastUpdate = timeAdded
|
||||
|
||||
updateStdDev := float64(maxError) * tf.maxErrorScale
|
||||
measVar := updateStdDev * updateStdDev
|
||||
|
||||
// First measurement: establish offset baseline
|
||||
if tf.count == 0 {
|
||||
tf.count++
|
||||
tf.offset = float64(measurement)
|
||||
tf.offsetCovariance = measVar
|
||||
tf.drift = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Second measurement: initial drift estimate via finite differences
|
||||
if tf.count == 1 {
|
||||
tf.count++
|
||||
tf.drift = (float64(measurement) - tf.offset) / dt
|
||||
tf.offset = float64(measurement)
|
||||
tf.driftCovariance = (tf.offsetCovariance + measVar) / dtSq
|
||||
tf.offsetCovariance = measVar
|
||||
return
|
||||
}
|
||||
|
||||
// --- Kalman prediction ---
|
||||
predOffset := tf.offset + tf.drift*dt
|
||||
|
||||
driftProcVar := dt * tf.driftProcessVariance
|
||||
newDriftCov := tf.driftCovariance + driftProcVar
|
||||
newOffsetDriftCov := tf.offsetDriftCovariance + tf.driftCovariance*dt
|
||||
offsetProcVar := dt * tf.processVariance
|
||||
newOffsetCov := tf.offsetCovariance + 2*tf.offsetDriftCovariance*dt +
|
||||
tf.driftCovariance*dtSq + offsetProcVar
|
||||
|
||||
// --- Innovation and adaptive forgetting ---
|
||||
residual := float64(measurement) - predOffset
|
||||
cutoff := float64(maxError) * tf.adaptiveForgettingCutoff
|
||||
|
||||
if tf.count < tf.minSamples {
|
||||
tf.count++
|
||||
} else if math.Abs(residual) > cutoff {
|
||||
newDriftCov *= tf.forgetVarianceFactor
|
||||
newOffsetDriftCov *= tf.forgetVarianceFactor
|
||||
newOffsetCov *= tf.forgetVarianceFactor
|
||||
}
|
||||
|
||||
// --- Kalman update ---
|
||||
invS := 1.0 / (newOffsetCov + measVar)
|
||||
offsetGain := newOffsetCov * invS
|
||||
driftGain := newOffsetDriftCov * invS
|
||||
|
||||
tf.offset = predOffset + offsetGain*residual
|
||||
tf.drift += driftGain * residual
|
||||
|
||||
tf.driftCovariance = newDriftCov - driftGain*newOffsetDriftCov
|
||||
tf.offsetDriftCovariance = newOffsetDriftCov - driftGain*newOffsetCov
|
||||
tf.offsetCovariance = newOffsetCov - offsetGain*newOffsetCov
|
||||
|
||||
driftSq := tf.drift * tf.drift
|
||||
tf.useDrift = driftSq > tf.driftSignificanceThresholdSq*tf.driftCovariance
|
||||
}
|
||||
|
||||
// ComputeServerTime converts a client timestamp to server time.
|
||||
func (tf *TimeFilter) ComputeServerTime(clientTime int64) int64 {
|
||||
tf.mu.Lock()
|
||||
defer tf.mu.Unlock()
|
||||
|
||||
dt := float64(clientTime - tf.lastUpdate)
|
||||
effectiveDrift := 0.0
|
||||
if tf.useDrift {
|
||||
effectiveDrift = tf.drift
|
||||
}
|
||||
offset := math.Round(tf.offset + effectiveDrift*dt)
|
||||
return clientTime + int64(offset)
|
||||
}
|
||||
|
||||
// ComputeClientTime converts a server timestamp to client time.
|
||||
func (tf *TimeFilter) ComputeClientTime(serverTime int64) int64 {
|
||||
tf.mu.Lock()
|
||||
defer tf.mu.Unlock()
|
||||
|
||||
effectiveDrift := 0.0
|
||||
if tf.useDrift {
|
||||
effectiveDrift = tf.drift
|
||||
}
|
||||
return int64(math.Round(
|
||||
(float64(serverTime) - tf.offset + effectiveDrift*float64(tf.lastUpdate)) /
|
||||
(1.0 + effectiveDrift)))
|
||||
}
|
||||
|
||||
// GetError returns the estimated standard deviation of the offset in µs.
|
||||
func (tf *TimeFilter) GetError() int64 {
|
||||
tf.mu.Lock()
|
||||
defer tf.mu.Unlock()
|
||||
v := math.Sqrt(tf.offsetCovariance)
|
||||
if math.IsInf(v, 0) || math.IsNaN(v) {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return int64(math.Round(v))
|
||||
}
|
||||
|
||||
// GetCovariance returns the offset variance in µs². Returns MaxInt64 before any update.
|
||||
func (tf *TimeFilter) GetCovariance() int64 {
|
||||
tf.mu.Lock()
|
||||
defer tf.mu.Unlock()
|
||||
v := tf.offsetCovariance
|
||||
if math.IsInf(v, 0) || math.IsNaN(v) {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return int64(math.Round(v))
|
||||
}
|
||||
|
||||
// Synced returns true after at least one measurement has been processed.
|
||||
func (tf *TimeFilter) Synced() bool {
|
||||
tf.mu.Lock()
|
||||
defer tf.mu.Unlock()
|
||||
return tf.count > 0
|
||||
}
|
||||
|
||||
// Reset clears all state.
|
||||
func (tf *TimeFilter) Reset() {
|
||||
tf.mu.Lock()
|
||||
defer tf.mu.Unlock()
|
||||
tf.reset()
|
||||
}
|
||||
|
||||
func (tf *TimeFilter) reset() {
|
||||
tf.count = 0
|
||||
tf.offset = 0
|
||||
tf.drift = 0
|
||||
tf.offsetCovariance = math.Inf(1)
|
||||
tf.offsetDriftCovariance = 0
|
||||
tf.driftCovariance = 0
|
||||
tf.lastUpdate = 0
|
||||
tf.useDrift = false
|
||||
}
|
||||
321
third_party/sendspin-go/pkg/sync/timefilter_test.go
vendored
Normal file
321
third_party/sendspin-go/pkg/sync/timefilter_test.go
vendored
Normal file
@@ -0,0 +1,321 @@
|
||||
// ABOUTME: Tests for Kalman filter time synchronization
|
||||
// ABOUTME: Validates offset tracking, drift compensation, and adaptive forgetting
|
||||
package sync
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTimeFilterFirstMeasurement(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
tf.Update(5000, 100, 1000000)
|
||||
|
||||
if !tf.Synced() {
|
||||
t.Fatal("expected synced after first measurement")
|
||||
}
|
||||
|
||||
// After one sample, server_time = client_time + offset (≈5000)
|
||||
st := tf.ComputeServerTime(1000000)
|
||||
diff := st - (1000000 + 5000)
|
||||
if abs64(diff) > 10 {
|
||||
t.Errorf("expected server time near %d, got %d", 1000000+5000, st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterConvergesOnStableOffset(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
// Feed 50 measurements with constant offset=10000µs, low noise
|
||||
for i := 0; i < 50; i++ {
|
||||
clientTime := int64(1000000 + i*100000) // 100ms apart
|
||||
tf.Update(10000, 50, clientTime)
|
||||
}
|
||||
|
||||
// Should converge close to 10000µs offset.
|
||||
// With canonical MaxErrorScale=0.5 (¼ measurement variance vs legacy 1.0),
|
||||
// observed final offset error is 0 µs after 50 stable samples.
|
||||
clientNow := int64(1000000 + 50*100000)
|
||||
st := tf.ComputeServerTime(clientNow)
|
||||
offset := st - clientNow
|
||||
if abs64(offset-10000) > 50 {
|
||||
t.Errorf("expected offset near 10000, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterDriftTracking(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
// Simulate a clock drifting at 10µs per second (10ppm)
|
||||
// Measurements taken 1s apart, offset increases by 10 each time
|
||||
for i := 0; i < 200; i++ {
|
||||
clientTime := int64(i) * 1000000 // 1s apart
|
||||
trueOffset := int64(5000 + i*10) // drifting 10µs/s
|
||||
tf.Update(trueOffset, 50, clientTime)
|
||||
}
|
||||
|
||||
// Check that drift-compensated conversion is more accurate than offset-only.
|
||||
// With canonical DriftProcessStdDev=1e-11, drift is tracked exactly on this
|
||||
// noise-free synthetic input — observed extrapolation error is 0 µs.
|
||||
futureClient := int64(250 * 1000000) // 50s in the future
|
||||
trueServerTime := futureClient + int64(5000+250*10) // true offset at t=250s
|
||||
predicted := tf.ComputeServerTime(futureClient)
|
||||
|
||||
err := abs64(predicted - trueServerTime)
|
||||
if err > 200 {
|
||||
t.Errorf("drift prediction error %dµs, expected <200µs", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterAdaptiveForgetting(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
// Build up stable estimate at offset=5000
|
||||
for i := 0; i < 150; i++ {
|
||||
clientTime := int64(1000000 + i*100000)
|
||||
tf.Update(5000, 50, clientTime)
|
||||
}
|
||||
|
||||
// Sudden jump to offset=15000 (server clock adjusted)
|
||||
jumpTime := int64(1000000 + 150*100000)
|
||||
tf.Update(15000, 50, jumpTime)
|
||||
|
||||
// After a few more samples at the new offset, should converge
|
||||
for i := 151; i < 200; i++ {
|
||||
clientTime := int64(1000000 + i*100000)
|
||||
tf.Update(15000, 50, clientTime)
|
||||
}
|
||||
|
||||
// With canonical ForgetFactor=2.0 / AdaptiveCutoff=3.0 the filter recovers
|
||||
// from the +10000 µs jump within ~50 samples; observed final error is ~2 µs.
|
||||
clientNow := int64(1000000 + 200*100000)
|
||||
st := tf.ComputeServerTime(clientNow)
|
||||
offset := st - clientNow
|
||||
if abs64(offset-15000) > 200 {
|
||||
t.Errorf("expected offset near 15000 after jump, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterComputeClientTime(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
clientTime := int64(1000000 + i*100000)
|
||||
tf.Update(8000, 50, clientTime)
|
||||
}
|
||||
|
||||
// Round-trip: client→server→client should be identity (within rounding)
|
||||
clientTime := int64(5000000)
|
||||
serverTime := tf.ComputeServerTime(clientTime)
|
||||
backToClient := tf.ComputeClientTime(serverTime)
|
||||
|
||||
if abs64(backToClient-clientTime) > 2 {
|
||||
t.Errorf("round-trip error: %d → %d → %d", clientTime, serverTime, backToClient)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterRejectsNonMonotonic(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
tf.Update(5000, 100, 2000000)
|
||||
tf.Update(5000, 100, 1000000) // earlier timestamp, should be ignored
|
||||
|
||||
// Only one sample counted
|
||||
tf.mu.Lock()
|
||||
count := tf.count
|
||||
tf.mu.Unlock()
|
||||
if count != 1 {
|
||||
t.Errorf("expected count=1 after non-monotonic update, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterReset(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
tf.Update(5000, 100, 1000000)
|
||||
if !tf.Synced() {
|
||||
t.Fatal("expected synced")
|
||||
}
|
||||
|
||||
tf.Reset()
|
||||
if tf.Synced() {
|
||||
t.Fatal("expected not synced after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterGetError(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
// Before any measurements, covariance is Inf → error should be large
|
||||
err0 := tf.GetError()
|
||||
if err0 < 1000000 {
|
||||
t.Errorf("expected very large error before sync, got %d", err0)
|
||||
}
|
||||
|
||||
// After many low-noise measurements, error should be small
|
||||
for i := 0; i < 100; i++ {
|
||||
tf.Update(5000, 20, int64(1000000+i*100000))
|
||||
}
|
||||
err1 := tf.GetError()
|
||||
if err1 > 50 {
|
||||
t.Errorf("expected small error after 100 samples, got %d", err1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterConcurrentAccess(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
clientTime := int64(id*10000000 + j*100000)
|
||||
tf.Update(5000, 50, clientTime)
|
||||
tf.ComputeServerTime(clientTime)
|
||||
tf.ComputeClientTime(clientTime + 5000)
|
||||
tf.GetError()
|
||||
tf.Synced()
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func abs64(x int64) int64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// Verify Inf covariance doesn't cause NaN propagation
|
||||
func TestTimeFilterInitialInfCovariance(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
tf.mu.Lock()
|
||||
if !math.IsInf(tf.offsetCovariance, 1) {
|
||||
t.Error("expected +Inf initial offset covariance")
|
||||
}
|
||||
tf.mu.Unlock()
|
||||
|
||||
// First update should produce finite values
|
||||
tf.Update(5000, 100, 1000000)
|
||||
tf.mu.Lock()
|
||||
if math.IsInf(tf.offsetCovariance, 0) || math.IsNaN(tf.offsetCovariance) {
|
||||
t.Errorf("expected finite covariance after first update, got %f", tf.offsetCovariance)
|
||||
}
|
||||
tf.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestTimeFilterMaxErrorScaleDefault(t *testing.T) {
|
||||
if got := DefaultTimeFilterConfig().MaxErrorScale; got != 0.5 {
|
||||
t.Errorf("expected MaxErrorScale default 0.5, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterMaxErrorScaleConvergence(t *testing.T) {
|
||||
cfgDefault := DefaultTimeFilterConfig() // MaxErrorScale = 0.5
|
||||
cfgTighter := DefaultTimeFilterConfig()
|
||||
cfgTighter.MaxErrorScale = 0.25 // half of default
|
||||
|
||||
tfDefault := NewTimeFilter(cfgDefault)
|
||||
tfTighter := NewTimeFilter(cfgTighter)
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
clientTime := int64(1000000 + i*100000)
|
||||
tfDefault.Update(5000, 50, clientTime)
|
||||
tfTighter.Update(5000, 50, clientTime)
|
||||
}
|
||||
|
||||
if !(tfTighter.GetError() < tfDefault.GetError()) {
|
||||
t.Errorf("expected tighter (0.25) error < default (0.5); got tighter=%d default=%d",
|
||||
tfTighter.GetError(), tfDefault.GetError())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterGetCovariance(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
if got := tf.GetCovariance(); got != math.MaxInt64 {
|
||||
t.Errorf("expected MaxInt64 before any update, got %d", got)
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
tf.Update(5000, 20, int64(1000000+i*100000))
|
||||
}
|
||||
|
||||
cov := tf.GetCovariance()
|
||||
if cov <= 0 {
|
||||
t.Errorf("expected positive covariance after convergence, got %d", cov)
|
||||
}
|
||||
if cov >= 50000 {
|
||||
t.Errorf("expected covariance < 50000 µs² after convergence, got %d", cov)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterRecoveryFromJump(t *testing.T) {
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
// Build a stable estimate at offset=5000 over 150 samples.
|
||||
var clientTime int64 = 1_000_000
|
||||
for i := 0; i < 150; i++ {
|
||||
tf.Update(5000, 50, clientTime)
|
||||
clientTime += 100_000
|
||||
}
|
||||
|
||||
// Server clock jumps +30 ms.
|
||||
tf.Update(35_000, 50, clientTime)
|
||||
clientTime += 100_000
|
||||
|
||||
// Within 30 more samples, error should be < 100 µs.
|
||||
for i := 0; i < 30; i++ {
|
||||
tf.Update(35_000, 50, clientTime)
|
||||
clientTime += 100_000
|
||||
}
|
||||
|
||||
st := tf.ComputeServerTime(clientTime)
|
||||
offset := st - clientTime
|
||||
if abs64(offset-35_000) > 100 {
|
||||
t.Errorf("expected offset within 100µs of 35000 after recovery, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeFilterLongSoak(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping long soak in -short mode")
|
||||
}
|
||||
|
||||
tf := NewTimeFilter(DefaultTimeFilterConfig())
|
||||
|
||||
// 60 minutes of measurements, 1 sample per second.
|
||||
// Synthetic drift varies sinusoidally over the hour to simulate slow
|
||||
// oscillator wander (peak 20 ppm = 20 µs/s).
|
||||
const totalSeconds = 3600
|
||||
var clientTime int64 = 1_000_000
|
||||
var maxAbsError int64
|
||||
|
||||
for i := 0; i < totalSeconds; i++ {
|
||||
// True drift in µs/s, sinusoid with 30-min period
|
||||
truePpm := 20.0 * math.Sin(2*math.Pi*float64(i)/1800.0)
|
||||
trueOffset := int64(5000 + float64(i)*truePpm)
|
||||
tf.Update(trueOffset, 50, clientTime)
|
||||
|
||||
// Measure the offset error after each step.
|
||||
st := tf.ComputeServerTime(clientTime)
|
||||
e := abs64(st - clientTime - trueOffset)
|
||||
if e > maxAbsError {
|
||||
maxAbsError = e
|
||||
}
|
||||
clientTime += 1_000_000 // +1 second
|
||||
}
|
||||
|
||||
// Bound: with 1e-11 drift process noise and 50 µs max_error, peak error
|
||||
// should stay well under 5000 µs even with the changing drift.
|
||||
if maxAbsError > 5000 {
|
||||
t.Errorf("peak offset error during soak = %d µs, expected < 5000", maxAbsError)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user