🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

View 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())
}

View 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
View 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

View 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
}

View 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)
}
}