CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m41s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m54s
When the server keeps rejecting this device's /keys/upload with 400 "One time key ... already exists", the device's encryption store has diverged and the SDK retries forever (matrix-rust-sdk#5200, still open). Synapse saw 474k of these from May 30 to Jul 8 2026. The fix is a fresh login, so: - keyUploadWatch observes /keys/upload responses through the client's fetchFn (the response the SDK gets is untouched; a clone is read). Three conflicts within 15 min = stuck; any successful upload clears it. - KeyUploadBanner (critical strip, same slot as the sync/clock banners): "Encryption on this device is out of sync" with Details and "Fix: sign out…", which opens the normal logout dialog (it already warns when the device isn't verified / backed up). Server side, Grafana now alerts on the same signal (E2EE Key Upload Conflicts) and on delayed-event 5xx (Call Delayed-Event Errors). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
103 lines
3.6 KiB
TypeScript
103 lines
3.6 KiB
TypeScript
/**
|
|
* [Gitea #201 KE-1] Notice when this device's encryption store has diverged
|
|
* from the server: every POST /keys/upload answers 400 "One time key ...
|
|
* already exists", and the SDK retries forever (matrix-rust-sdk#5200, open),
|
|
* so new encrypted messages and calls quietly degrade. The server saw 474k of
|
|
* these between May 30 and Jul 8 2026. The only fix is a fresh login on that
|
|
* device, so we tell the user (KeyUploadBanner) instead of looping silently.
|
|
*
|
|
* Watched at the fetch layer (MatrixClient `fetchFn`), so it doesn't depend on
|
|
* SDK log wording. Pure state + a tiny subscribe API; no network I/O of its own.
|
|
*/
|
|
|
|
/** Conflicts needed before we call the device stuck. The SDK retries within
|
|
* seconds, so a real divergence crosses this quickly; a one-off race doesn't. */
|
|
export const STUCK_AFTER = 3;
|
|
/** Conflicts older than this don't count (a long-ago blip isn't "now"). */
|
|
export const WINDOW_MS = 15 * 60 * 1000;
|
|
|
|
export type KeyUploadWatchState = { stuck: boolean; conflicts: number; lastError?: string };
|
|
|
|
let conflictTimes: number[] = [];
|
|
let lastError: string | undefined;
|
|
let state: KeyUploadWatchState = { stuck: false, conflicts: 0 };
|
|
const listeners = new Set<() => void>();
|
|
|
|
const publish = (next: KeyUploadWatchState) => {
|
|
if (next.stuck === state.stuck && next.conflicts === state.conflicts) return;
|
|
state = next;
|
|
listeners.forEach((l) => l());
|
|
};
|
|
|
|
export const isKeyUploadUrl = (url: string): boolean => {
|
|
try {
|
|
return /\/_matrix\/client\/[^/]+\/keys\/upload\/?$/.test(new URL(url, 'http://x').pathname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** The 400 body that marks a diverged one-time-key store. */
|
|
export const isOtkConflict = (status: number, body: string): boolean =>
|
|
status === 400 && /one time key .*already exists/i.test(body);
|
|
|
|
/** Feed one /keys/upload response. `now` is injectable for tests. */
|
|
export const recordKeyUpload = (status: number, body: string, now = Date.now()): void => {
|
|
if (status >= 200 && status < 300) {
|
|
// A successful upload means the stores agree again.
|
|
conflictTimes = [];
|
|
lastError = undefined;
|
|
publish({ stuck: false, conflicts: 0 });
|
|
return;
|
|
}
|
|
if (!isOtkConflict(status, body)) return;
|
|
conflictTimes = [...conflictTimes, now].filter((t) => now - t <= WINDOW_MS);
|
|
lastError = body.slice(0, 300);
|
|
publish({
|
|
stuck: conflictTimes.length >= STUCK_AFTER,
|
|
conflicts: conflictTimes.length,
|
|
lastError,
|
|
});
|
|
};
|
|
|
|
export const getKeyUploadWatch = (): KeyUploadWatchState => state;
|
|
|
|
export const subscribeKeyUploadWatch = (cb: () => void): (() => void) => {
|
|
listeners.add(cb);
|
|
return () => listeners.delete(cb);
|
|
};
|
|
|
|
/** Test/logout helper: forget everything (a new session starts clean). */
|
|
export const resetKeyUploadWatch = (): void => {
|
|
conflictTimes = [];
|
|
lastError = undefined;
|
|
publish({ stuck: false, conflicts: 0 });
|
|
};
|
|
|
|
const urlOf = (input: RequestInfo | URL): string =>
|
|
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
|
|
/**
|
|
* Wrap a fetch so /keys/upload responses are observed. The response the SDK
|
|
* gets is untouched; a clone is read in the background, never awaited.
|
|
*/
|
|
export const watchKeyUploads =
|
|
(inner: typeof fetch): typeof fetch =>
|
|
async (input, init) => {
|
|
const res = await inner(input, init);
|
|
try {
|
|
if (isKeyUploadUrl(urlOf(input))) {
|
|
if (res.ok) recordKeyUpload(res.status, '');
|
|
else if (res.status === 400)
|
|
res
|
|
.clone()
|
|
.text()
|
|
.then((body) => recordKeyUpload(res.status, body))
|
|
.catch(() => undefined);
|
|
}
|
|
} catch {
|
|
// observation must never break the request
|
|
}
|
|
return res;
|
|
};
|