feat(e2ee): tell the user when their device's key uploads are stuck (#201 KE-1)
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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45c3b16694
commit
c2c43eda99
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getKeyUploadWatch,
|
||||
isKeyUploadUrl,
|
||||
isOtkConflict,
|
||||
recordKeyUpload,
|
||||
resetKeyUploadWatch,
|
||||
STUCK_AFTER,
|
||||
subscribeKeyUploadWatch,
|
||||
WINDOW_MS,
|
||||
watchKeyUploads,
|
||||
} from './keyUploadWatch';
|
||||
|
||||
const CONFLICT = JSON.stringify({
|
||||
errcode: 'M_UNKNOWN',
|
||||
error:
|
||||
'One time key signed_curve25519:AAAAAAAAAA0 already exists. Old key: {...}; new key: {...}',
|
||||
});
|
||||
|
||||
test('[#201] recognises the upload endpoint and the conflict body', () => {
|
||||
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/upload'), true);
|
||||
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/upload/'), true);
|
||||
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/keys/query'), false);
|
||||
assert.equal(isKeyUploadUrl('https://hs.example/_matrix/client/v3/room_keys/keys'), false);
|
||||
assert.equal(isOtkConflict(400, CONFLICT), true);
|
||||
assert.equal(isOtkConflict(400, '{"errcode":"M_BAD_JSON"}'), false);
|
||||
assert.equal(isOtkConflict(500, CONFLICT), false);
|
||||
});
|
||||
|
||||
test('[#201] stuck only after repeated conflicts inside the window', () => {
|
||||
resetKeyUploadWatch();
|
||||
const t0 = 1_000_000;
|
||||
for (let i = 0; i < STUCK_AFTER - 1; i += 1) recordKeyUpload(400, CONFLICT, t0 + i * 1000);
|
||||
assert.equal(getKeyUploadWatch().stuck, false, 'a couple of races is not divergence');
|
||||
recordKeyUpload(400, CONFLICT, t0 + 5000);
|
||||
assert.equal(getKeyUploadWatch().stuck, true);
|
||||
assert.match(getKeyUploadWatch().lastError ?? '', /already exists/);
|
||||
});
|
||||
|
||||
test('[#201] old conflicts age out; other 400s are ignored', () => {
|
||||
resetKeyUploadWatch();
|
||||
recordKeyUpload(400, CONFLICT, 0);
|
||||
recordKeyUpload(400, CONFLICT, 1000);
|
||||
recordKeyUpload(400, CONFLICT, WINDOW_MS + 60_000);
|
||||
assert.equal(getKeyUploadWatch().stuck, false);
|
||||
recordKeyUpload(400, '{"errcode":"M_BAD_JSON"}', WINDOW_MS + 61_000);
|
||||
assert.equal(getKeyUploadWatch().conflicts, 1);
|
||||
});
|
||||
|
||||
test('[#201] a successful upload clears it and notifies', () => {
|
||||
resetKeyUploadWatch();
|
||||
let calls = 0;
|
||||
const off = subscribeKeyUploadWatch(() => {
|
||||
calls += 1;
|
||||
});
|
||||
for (let i = 0; i < STUCK_AFTER; i += 1) recordKeyUpload(400, CONFLICT, i);
|
||||
assert.equal(getKeyUploadWatch().stuck, true);
|
||||
recordKeyUpload(200, '');
|
||||
assert.equal(getKeyUploadWatch().stuck, false);
|
||||
assert.ok(calls >= 2);
|
||||
off();
|
||||
});
|
||||
|
||||
test('[#201] the fetch wrapper observes without changing the response', async () => {
|
||||
resetKeyUploadWatch();
|
||||
const fake: typeof fetch = async (input) =>
|
||||
String(input).endsWith('/keys/upload')
|
||||
? new Response(CONFLICT, { status: 400 })
|
||||
: new Response('{}', { status: 200 });
|
||||
const f = watchKeyUploads(fake);
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: STUCK_AFTER }, () =>
|
||||
f('https://hs.example/_matrix/client/v3/keys/upload', { method: 'POST' }),
|
||||
),
|
||||
);
|
||||
const bodies = await Promise.all(responses.map((res) => res.text()));
|
||||
responses.forEach((res) => assert.equal(res.status, 400));
|
||||
bodies.forEach((b) => assert.match(b, /already exists/, 'the SDK can still read the body'));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, 10);
|
||||
});
|
||||
assert.equal(getKeyUploadWatch().stuck, true);
|
||||
await f('https://hs.example/_matrix/client/v3/sync');
|
||||
assert.equal(getKeyUploadWatch().stuck, true, 'unrelated requests change nothing');
|
||||
});
|
||||
Reference in New Issue
Block a user