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:
2026-09-19 12:52:03 -04:00
co-authored by Claude Opus 5
parent 464951edf4
commit 84c906fe33
7 changed files with 372 additions and 1 deletions
+83
View File
@@ -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/);
});
+145
View File
@@ -0,0 +1,145 @@
/**
* [Gitea #158] Local-clock-vs-homeserver skew detection.
*
* Incident 2026-09-17: a wrong Windows clock made every MatrixRTC membership
* look expired locally (calls failed, media keys rejected) while the server
* answered 200 to everything, and nothing in the UI hinted at the cause.
*
* Measurement needs no extra requests and no CORS-exposed headers: every event
* a `/sync` delivers carries `origin_server_ts` (stamped by its origin server)
* and `unsigned.age` (= OUR server's `now origin_server_ts` at the moment it
* built the response). So `origin_server_ts + age` is our server's clock at
* response time, and `receivedAt (origin_server_ts + age)` is our skew plus
* the download latency (tens of ms; ignored). matrix-js-sdk already computes
* `localTimestamp = Date.now() age` at event construction, so a sample is
* simply `localTimestamp origin_server_ts`.
*
* Only LIVE events count (`RoomEvent.Timeline` data.liveEvent, which the SDK
* already sets false for events replayed from the IndexedDB cache — those carry
* a stale `age` that would read as hours of skew). The initial network sync's
* events qualify, so a wrong clock is flagged within seconds of startup.
*/
export const SKEW_WARN_MS = 30_000;
export const SKEW_CLEAR_MS = 15_000;
export const SKEW_SAMPLES = 5;
export const SKEW_MIN_SAMPLES = 3;
/**
* Sanity cap on `age`. Old events are still valid samples (the server computes
* `age` at response time, so `ts + age` is its clock regardless of the event's
* own age) — this only rejects garbage.
*/
export const SKEW_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export type ClockSkewState = {
/** Median of the recent samples, ms; positive = local clock is AHEAD. */
skewMs: number | null;
/** Over the threshold (with hysteresis). */
warning: boolean;
};
const median = (xs: number[]): number => {
const s = [...xs].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
};
export class ClockSkewMonitor {
private samples: number[] = [];
private state: ClockSkewState = { skewMs: null, warning: false };
private listeners = new Set<(state: ClockSkewState) => void>();
public getState(): ClockSkewState {
return this.state;
}
public subscribe(cb: (state: ClockSkewState) => void): () => void {
this.listeners.add(cb);
return () => {
this.listeners.delete(cb);
};
}
/**
* Feed one live event. `originServerTs` + `age` come from the event;
* `localTimestamp` is the SDK's `Date.now() age` at construction.
* Returns the new state (unchanged object when nothing moved).
*/
public sample(
originServerTs: number,
age: number | undefined,
localTimestamp: number,
): ClockSkewState {
if (age === undefined || !Number.isFinite(age) || age < 0 || age > SKEW_MAX_AGE_MS) {
return this.state;
}
if (!Number.isFinite(originServerTs) || !Number.isFinite(localTimestamp)) return this.state;
this.samples.push(localTimestamp - originServerTs);
if (this.samples.length > SKEW_SAMPLES) this.samples.shift();
if (this.samples.length < SKEW_MIN_SAMPLES) return this.state;
const skewMs = median(this.samples);
const abs = Math.abs(skewMs);
const warning = this.state.warning ? abs >= SKEW_CLEAR_MS : abs > SKEW_WARN_MS;
if (skewMs === this.state.skewMs && warning === this.state.warning) return this.state;
this.state = { skewMs, warning };
this.listeners.forEach((cb) => cb(this.state));
return this.state;
}
public reset(): void {
this.samples = [];
if (this.state.skewMs !== null || this.state.warning) {
this.state = { skewMs: null, warning: false };
this.listeners.forEach((cb) => cb(this.state));
}
}
}
/** "14 minutes ahead" / "2 hours behind" / "45 seconds ahead". */
export const formatSkew = (skewMs: number): string => {
const abs = Math.abs(skewMs);
const dir = skewMs > 0 ? 'ahead' : 'behind';
const unit = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
if (abs >= 36 * 60 * 60 * 1000)
return `${unit(Math.round(abs / (24 * 60 * 60 * 1000)), 'day')} ${dir}`;
if (abs >= 90 * 60 * 1000) return `${unit(Math.round(abs / (60 * 60 * 1000)), 'hour')} ${dir}`;
if (abs >= 90 * 1000) return `${unit(Math.round(abs / 60_000), 'minute')} ${dir}`;
return `${unit(Math.round(abs / 1000), 'second')} ${dir}`;
};
/** "14 minutes ahead of the server" / "3 hours behind the server". */
export const describeSkewVsServer = (skewMs: number): string =>
formatSkew(skewMs)
.replace(/ ahead$/, ' ahead of the server')
.replace(/ behind$/, ' behind the server');
export type ClockFixPlatform = 'windows' | 'mac' | 'linux' | 'ios' | 'android' | 'other';
export const detectClockFixPlatform = (ua: string): ClockFixPlatform => {
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
if (/Android/i.test(ua)) return 'android';
if (/Windows/i.test(ua)) return 'windows';
if (/Mac OS X|Macintosh/i.test(ua)) return 'mac';
if (/Linux|X11/i.test(ua)) return 'linux';
return 'other';
};
export const clockFixHint = (platform: ClockFixPlatform): string => {
switch (platform) {
case 'windows':
return 'Windows: Settings → Time & language → Date & time → turn on "Set time automatically", then Sync now.';
case 'mac':
return 'macOS: System Settings → General → Date & Time → turn on "Set time and date automatically".';
case 'linux':
return "Linux: enable NTP (e.g. `timedatectl set-ntp true`) or your desktop's Date & Time → Automatic.";
case 'ios':
return 'iOS: Settings → General → Date & Time → Set Automatically.';
case 'android':
return 'Android: Settings → System → Date & time → Set time automatically.';
default:
return "Turn on automatic (network) time in your operating system's date & time settings.";
}
};