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:
@@ -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) {
|
||||
<PwaInstallFeature />
|
||||
<FaviconUpdater />
|
||||
<PresenceUpdater />
|
||||
<ClockSkewFeature />
|
||||
<MuteTimerRestore />
|
||||
<StatusExpiryMonitor />
|
||||
<InviteNotifications />
|
||||
|
||||
Reference in New Issue
Block a user