feat: warn when the local clock is far off the homeserver's (#158)
Incident 2026-09-17: a wrong Windows clock broke calls and media keys while the server answered 200 to everything, with no hint in the UI. Measurement needs no extra requests and no CORS-exposed headers: every live event carries origin_server_ts and unsigned.age (our server's now − ts at response time), so localTimestamp − origin_server_ts is the skew. Only RoomEvent.Timeline live events count (cache replays have stale age and are already flagged liveEvent=false by the SDK); the initial network sync qualifies, so a wrong clock is flagged within seconds of startup. Median of the last 5 samples, ≥3 needed; warn at |skew| > 30 s, clear below 15 s. UI: a banner in the sync-status slot — "Your computer's clock is 14 minutes ahead of the server. Encrypted messages and voice calls will fail until it is fixed." with a per-OS How-to-fix hint and Dismiss for 24 h — plus the same line in the call status bar while in a call. Never auto-corrects anything. Unit-tested (median, hysteresis, stale-age rejection, wording); verified headless with Playwright's clock skewed +14 min and −3 h (banner, hint, in-call line, dismiss) and in sync (nothing shown). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ClockSkewMonitor,
|
||||
clockFixHint,
|
||||
describeSkewVsServer,
|
||||
detectClockFixPlatform,
|
||||
formatSkew,
|
||||
} from './clockSkew';
|
||||
|
||||
// A live event received when the local clock is `skew` ms ahead of the server:
|
||||
// origin_server_ts = T (server clock), age = a, localTimestamp = (T + a + skew) - a.
|
||||
const feed = (m: ClockSkewMonitor, skew: number, age = 500, t = 1_700_000_000_000) =>
|
||||
m.sample(t, age, t + skew);
|
||||
|
||||
test('needs three samples, then reports the median with direction', () => {
|
||||
const m = new ClockSkewMonitor();
|
||||
assert.equal(feed(m, 60_000).skewMs, null);
|
||||
assert.equal(feed(m, 61_000).skewMs, null);
|
||||
const s = feed(m, 59_000);
|
||||
assert.equal(s.skewMs, 60_000);
|
||||
assert.equal(s.warning, true);
|
||||
assert.equal(formatSkew(s.skewMs!), '60 seconds ahead');
|
||||
});
|
||||
|
||||
test('one bad sample cannot trip the warning (median) and hysteresis clears only under 15 s', () => {
|
||||
const m = new ClockSkewMonitor();
|
||||
feed(m, 1000);
|
||||
feed(m, 1500);
|
||||
assert.equal(feed(m, 90_000).warning, false); // outlier
|
||||
assert.equal(m.getState().skewMs, 1500);
|
||||
|
||||
const w = new ClockSkewMonitor();
|
||||
[40_000, 41_000, 39_000, 40_000, 40_000].forEach((s) => feed(w, s));
|
||||
assert.equal(w.getState().warning, true);
|
||||
// drifting down to 20 s: still >= 15 s → stays on
|
||||
[20_000, 20_000, 20_000, 20_000, 20_000].forEach((s) => feed(w, s));
|
||||
assert.equal(w.getState().warning, true);
|
||||
[10_000, 10_000, 10_000, 10_000, 10_000].forEach((s) => feed(w, s));
|
||||
assert.equal(w.getState().warning, false);
|
||||
});
|
||||
|
||||
test('stale or missing age is ignored (cache replay must not read as skew)', () => {
|
||||
const m = new ClockSkewMonitor();
|
||||
const t = 1_700_000_000_000;
|
||||
m.sample(t, undefined, t + 3_600_000);
|
||||
m.sample(t, 40 * 24 * 60 * 60 * 1000, t + 3_600_000);
|
||||
m.sample(t, -5, t);
|
||||
assert.equal(m.getState().skewMs, null);
|
||||
});
|
||||
|
||||
test('subscribe fires on change only; reset clears', () => {
|
||||
const m = new ClockSkewMonitor();
|
||||
const seen: (number | null)[] = [];
|
||||
m.subscribe((s) => seen.push(s.skewMs));
|
||||
feed(m, -120_000);
|
||||
feed(m, -120_000);
|
||||
feed(m, -120_000);
|
||||
feed(m, -120_000);
|
||||
assert.deepEqual(seen, [-120_000]);
|
||||
assert.equal(formatSkew(-120_000), '2 minutes behind');
|
||||
m.reset();
|
||||
assert.deepEqual(seen, [-120_000, null]);
|
||||
});
|
||||
|
||||
test('formatSkew picks a sensible unit', () => {
|
||||
assert.equal(formatSkew(45_000), '45 seconds ahead');
|
||||
assert.equal(formatSkew(-14 * 60_000), '14 minutes behind');
|
||||
assert.equal(formatSkew(3 * 3_600_000), '3 hours ahead');
|
||||
assert.equal(formatSkew(2 * 86_400_000), '2 days ahead');
|
||||
assert.equal(describeSkewVsServer(-3 * 3_600_000), '3 hours behind the server');
|
||||
assert.equal(describeSkewVsServer(14 * 60_000), '14 minutes ahead of the server');
|
||||
});
|
||||
|
||||
test('platform hint', () => {
|
||||
assert.equal(detectClockFixPlatform('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'), 'windows');
|
||||
assert.equal(
|
||||
detectClockFixPlatform('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'),
|
||||
'ios',
|
||||
);
|
||||
assert.equal(detectClockFixPlatform('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)'), 'mac');
|
||||
assert.match(clockFixHint('windows'), /Sync now/);
|
||||
});
|
||||
Reference in New Issue
Block a user