From 84c906fe332514094cbdd99ff7b2b74c9f19552a Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 12:52:03 -0400 Subject: [PATCH] feat: warn when the local clock is far off the homeserver's (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/call-status/CallStatus.tsx | 20 ++- src/app/pages/client/ClientNonUIFeatures.tsx | 36 +++++ src/app/pages/client/ClientRoot.tsx | 2 + src/app/pages/client/ClockSkewBanner.tsx | 82 +++++++++++ src/app/state/clockSkew.ts | 5 + src/app/utils/clockSkew.test.ts | 83 +++++++++++ src/app/utils/clockSkew.ts | 145 +++++++++++++++++++ 7 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/app/pages/client/ClockSkewBanner.tsx create mode 100644 src/app/state/clockSkew.ts create mode 100644 src/app/utils/clockSkew.test.ts create mode 100644 src/app/utils/clockSkew.ts diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index fd96f2166..8ebbf3613 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Box, Spinner } from 'folds'; +import { Box, Spinner, Text, color } from 'folds'; +import { useAtomValue } from 'jotai'; import classNames from 'classnames'; import { LiveChip } from './LiveChip'; import * as css from './styles.css'; @@ -14,6 +15,8 @@ import { CallEmbed } from '../../plugins/call/CallEmbed'; import { useCallJoined } from '../../hooks/useCallEmbed'; import { useCallSpeakers } from '../../hooks/useCallSpeakers'; import { MemberSpeaking } from './MemberSpeaking'; +import { clockSkewAtom } from '../../state/clockSkew'; +import { describeSkewVsServer } from '../../utils/clockSkew'; type CallStatusProps = { callEmbed: CallEmbed; @@ -26,6 +29,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) { const screenSize = useScreenSize(); const callJoined = useCallJoined(callEmbed); const speakers = useCallSpeakers(callEmbed); + // [Gitea #158] Same warning as the top banner, where the user is looking during a call. + const clockSkew = useAtomValue(clockSkewAtom); const compact = screenSize === ScreenSize.Mobile; @@ -51,6 +56,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) { {!compact && ( <> + {clockSkew.warning && clockSkew.skewMs !== null && ( + <> + + + Clock {describeSkewVsServer(clockSkew.skewMs)} — calls will fail + + + )} {speakers.size > 0 && ( <> diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 9c520f207..cb5f906c5 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -31,6 +31,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts'; import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation'; +import { ClockSkewMonitor } from '../../utils/clockSkew'; +import { clockSkewAtom } from '../../state/clockSkew'; import { getDirectRoomPath, getHomeRoomPath, @@ -934,6 +936,39 @@ function SearchCacheInvalidationFeature(): null { return null; } +/** + * [Gitea #158] Feeds every LIVE timeline event (origin_server_ts + unsigned.age + * vs the SDK's localTimestamp) to the skew monitor. `data.liveEvent` is false + * for events replayed from the IndexedDB cache — whose `age` is stale — so + * only network deliveries (including the initial sync) count. + */ +function ClockSkewFeature() { + const mx = useMatrixClient(); + const setSkew = useSetAtom(clockSkewAtom); + + useEffect(() => { + const monitor = new ClockSkewMonitor(); + const unsub = monitor.subscribe(setSkew); + const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = ( + mEvent, + _room, + _toStart, + _removed, + data, + ) => { + if (!data.liveEvent) return; + monitor.sample(mEvent.getTs(), mEvent.getAge(), mEvent.localTimestamp); + }; + mx.on(RoomEvent.Timeline, onTimeline); + return () => { + mx.off(RoomEvent.Timeline, onTimeline); + unsub(); + }; + }, [mx, setSkew]); + + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { return ( <> @@ -947,6 +982,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 21753d080..934b7dc28 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -43,6 +43,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; +import { ClockSkewBanner } from './ClockSkewBanner'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession, removeFallbackSession } from '../../state/sessions'; import { pushSessionToSW } from '../../../sw-session'; @@ -246,6 +247,7 @@ export function ClientRoot({ children }: ClientRootProps) { {mx && !syncError && } + {mx && !syncError && } {loading && } {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error || diff --git a/src/app/pages/client/ClockSkewBanner.tsx b/src/app/pages/client/ClockSkewBanner.tsx new file mode 100644 index 000000000..0c39d3f7d --- /dev/null +++ b/src/app/pages/client/ClockSkewBanner.tsx @@ -0,0 +1,82 @@ +import React, { useCallback, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { Box, Button, config, Line, Text } from 'folds'; +import { clockSkewAtom } from '../../state/clockSkew'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import { clockFixHint, describeSkewVsServer, detectClockFixPlatform } from '../../utils/clockSkew'; + +const DISMISS_KEY = 'lotus-clock-skew-dismissed-until'; +const DISMISS_MS = 24 * 60 * 60 * 1000; + +const readDismissedUntil = (): number => { + try { + const raw = localStorage.getItem(DISMISS_KEY); + const n = raw ? Number(raw) : 0; + return Number.isFinite(n) ? n : 0; + } catch { + return 0; + } +}; + +/** + * [Gitea #158] "Your computer's clock is 14 minutes ahead of the server." + * Same slot and style as the sync banners. Shown while the skew monitor is + * over its threshold; the direction matters, so it is said. Dismissable for + * 24 h; never auto-corrects anything. + */ +export function ClockSkewBanner() { + const { skewMs, warning } = useAtomValue(clockSkewAtom); + const [dismissedUntil, setDismissedUntil] = useState(readDismissedUntil); + const [showHint, setShowHint] = useState(false); + + const dismiss = useCallback(() => { + const until = Date.now() + DISMISS_MS; + try { + localStorage.setItem(DISMISS_KEY, String(until)); + } catch { + // storage unavailable — dismiss for this session only + } + setDismissedUntil(until); + }, []); + + if (!warning || skewMs === null || Date.now() < dismissedUntil) return null; + + return ( + + + + + Your computer's clock is {describeSkewVsServer(skewMs)}. Encrypted messages + and voice calls will fail until it is fixed. + + + + + {showHint && ( + + {clockFixHint(detectClockFixPlatform(navigator.userAgent))} + + )} + + + + ); +} diff --git a/src/app/state/clockSkew.ts b/src/app/state/clockSkew.ts new file mode 100644 index 000000000..87710ef40 --- /dev/null +++ b/src/app/state/clockSkew.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai'; +import { ClockSkewState } from '../utils/clockSkew'; + +/** [Gitea #158] Latest local-vs-homeserver clock skew reading. */ +export const clockSkewAtom = atom({ skewMs: null, warning: false }); diff --git a/src/app/utils/clockSkew.test.ts b/src/app/utils/clockSkew.test.ts new file mode 100644 index 000000000..df7985556 --- /dev/null +++ b/src/app/utils/clockSkew.test.ts @@ -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/); +}); diff --git a/src/app/utils/clockSkew.ts b/src/app/utils/clockSkew.ts new file mode 100644 index 000000000..011e3112d --- /dev/null +++ b/src/app/utils/clockSkew.ts @@ -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."; + } +};