diff --git a/apps/widget-telegram/src/contacts.tsx b/apps/widget-telegram/src/contacts.tsx index 76647b35..20e465ba 100644 --- a/apps/widget-telegram/src/contacts.tsx +++ b/apps/widget-telegram/src/contacts.tsx @@ -223,13 +223,20 @@ export const Contacts = ({ const activeProbe = probe && probeCandidate && probe.identifier.value === probeCandidate.value ? probe : null; + // Latest-wins guard: probes aren't serialized by the UI (editing the query + // re-enables the button while an older probe is still in flight), so a + // slow stale response must not stomp a fresher result or fire a + // misattributed error notice. + const probeSeq = useRef(0); const runProbe = useCallback(() => { if (!probeCandidate) return; + probeSeq.current += 1; + const seq = probeSeq.current; setProbe({ status: 'checking', identifier: probeCandidate }); client .resolveIdentifier(probeCandidate.value) .then((contact) => { - if (!aliveRef.current) return; + if (!aliveRef.current || seq !== probeSeq.current) return; setProbe( contact ? { status: 'found', identifier: probeCandidate, contact } @@ -237,7 +244,7 @@ export const Contacts = ({ ); }) .catch((err) => { - if (!aliveRef.current) return; + if (!aliveRef.current || seq !== probeSeq.current) return; setProbe(null); onError(describeApiError(err, t)); }); diff --git a/apps/widget-telegram/src/errors.ts b/apps/widget-telegram/src/errors.ts index fd6c6cac..176a7788 100644 --- a/apps/widget-telegram/src/errors.ts +++ b/apps/widget-telegram/src/errors.ts @@ -19,6 +19,9 @@ export const describeApiError = (err: unknown, t: T): string => { if (text.includes('PHONE_NUMBER_BANNED')) return t('error.phone-banned'); if (err.errcode === 'FI.MAU.BRIDGE.LOGIN_TIMED_OUT') return t('error.login-timeout'); if (err.errcode === 'FI.MAU.BRIDGE.LOGIN_CANCELLED') return t('error.login-restart'); + // The login step machine advanced without us (a re-poll raced a state + // change, e.g. a background 2FA scan) — desynced beyond recovery. + if (err.errcode === 'M_BAD_STATE') return t('error.login-restart'); if (err.errcode === 'FI.MAU.BRIDGE.TOO_MANY_LOGINS') return t('error.too-many-logins'); // Login process evaporated server-side (bridge deletes it on errors and // after its 30-minute deadline) — the only recovery is starting over. diff --git a/apps/widget-telegram/src/login.tsx b/apps/widget-telegram/src/login.tsx index 57df3ee4..ffce4614 100644 --- a/apps/widget-telegram/src/login.tsx +++ b/apps/widget-telegram/src/login.tsx @@ -20,6 +20,7 @@ import qrcodeGenerator from 'qrcode-generator'; import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min'; import { ProvisioningClient, + ProvisioningError, TG_FLOW_PHONE, TG_FLOW_QR, isNotFound, @@ -76,6 +77,59 @@ export type LoginFlow = { // code (the submit resolved into the code step). const PHONE_COOLDOWN_MS = 60_000; +// --- Wait-loop resilience ---------------------------------------------------- +// The QR long-poll dies whenever Android freezes the backgrounded WebView — +// and scanning the QR with the Telegram app on the SAME phone requires +// leaving Vojo, which kills the TCP connection within ~15 s. Since mautrix +// v0.28.1 the login process lives server-side with a 30-minute TTL +// (provisioninglogin.go: Wait runs on login.Ctx, not the request context), +// so a dropped poll is reattachable: the loop retries transport-shaped +// failures with backoff and wakes early when the tab returns to the +// foreground. + +const WAIT_RETRY_BASE_MS = 2_000; +const WAIT_RETRY_MAX_MS = 15_000; + +// Transport-shaped failures: fetch network death (TypeError), an abort that +// is NOT ours (the frozen WebView tearing the connection down, or the +// per-attempt poll timeout), or an errcode-LESS 5xx — a proxy-shaped +// 502/503/504 from Caddy with a non-JSON body. Anything carrying an errcode +// is a RespError the bridge itself wrote and is an authoritative verdict on +// the login — NOT retryable. Notably M_BAD_STATE (a background QR scan on a +// 2FA account advanced the step machine to the password step while we were +// frozen) and the framework's LOGIN_TIMED_OUT ship as HTTP 500, so a bare +// status check would retry a dead step id forever. +const isTransientWaitError = (err: unknown): boolean => { + if (err instanceof ProvisioningError) { + return err.httpStatus >= 500 && err.errcode === undefined; + } + return err instanceof Error && (err.name === 'TypeError' || err.name === 'AbortError'); +}; + +// Backoff delay that resolves early when the document becomes visible again +// (snappy reattach after returning from the Telegram app) or when the loop +// is aborted (the caller re-checks the signal and exits). +const waitBeforeRetry = (ms: number, signal: AbortSignal): Promise => + new Promise((resolve) => { + let timer: number | null = null; + let cleanup = () => {}; + const done = () => { + cleanup(); + resolve(); + }; + const onVisible = () => { + if (document.visibilityState === 'visible') done(); + }; + cleanup = () => { + if (timer !== null) window.clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); + signal.removeEventListener('abort', done); + }; + timer = window.setTimeout(done, ms); + document.addEventListener('visibilitychange', onVisible); + signal.addEventListener('abort', done); + }); + export const useLoginFlow = ( client: ProvisioningClient, t: T, @@ -116,19 +170,66 @@ export const useLoginFlow = ( waitAbort.current = controller; void (async () => { let stepId = firstStepId; + let retryDelayMs = WAIT_RETRY_BASE_MS; for (;;) { let step: LoginStep; try { // Blocks server-side until the QR token rotates (~30 s), the // scan succeeds, or the login dies. step = await client.loginWait(loginId, stepId, controller.signal); + retryDelayMs = WAIT_RETRY_BASE_MS; } catch (err) { if (gen !== generation.current || controller.signal.aborted) return; + if (isTransientWaitError(err)) { + // The panel stays up; the next attempt either reattaches to + // the still-alive server-side step or gets an authoritative + // 4xx and ends the flow honestly. + await waitBeforeRetry(retryDelayMs, controller.signal); + if (gen !== generation.current || controller.signal.aborted) return; + retryDelayMs = Math.min(retryDelayMs * 2, WAIT_RETRY_MAX_MS); + continue; + } + // A 404 (or ALREADY_FINISHED) can mean two opposite things: the + // window expired, or the login COMPLETED while the WebView was + // frozen — a finished login is removed from the registry, so a + // late re-poll can't tell the difference. whoami is the + // authority on which way it went. + const gone = + isNotFound(err) || + (err instanceof ProvisioningError && + err.errcode === 'FI.MAU.BRIDGE.LOGIN_ALREADY_FINISHED'); + if (gone) { + // The probe itself retries transport blips (a completed login + // must not be reported as «timed out» because one whoami GET + // hit a dead network); any authoritative answer breaks out. + let probeDelayMs = WAIT_RETRY_BASE_MS; + for (;;) { + try { + const whoami = await client.whoami(); + if (gen !== generation.current || controller.signal.aborted) return; + if (whoami.logins && whoami.logins.length > 0) { + setUi({ kind: 'idle' }); + callbacksRef.current.onComplete(); + return; + } + break; // authoritative «no login» — the window really expired + } catch (probeErr) { + if (gen !== generation.current || controller.signal.aborted) return; + if (!isTransientWaitError(probeErr)) break; + await waitBeforeRetry(probeDelayMs, controller.signal); + if (gen !== generation.current || controller.signal.aborted) return; + probeDelayMs = Math.min(probeDelayMs * 2, WAIT_RETRY_MAX_MS); + } + } + } else { + // The login may still be alive server-side (e.g. M_BAD_STATE + // desync after a background 2FA scan) — free the 30-minute + // slot. Best-effort; the cancel route exists on this bridge. + void client.loginCancel(loginId).catch(() => undefined); + } setUi({ kind: 'idle' }); - // A 404 here means the bridge deleted the login process — for a - // QR that's «the sign-in window expired», not a generic failure. callbacksRef.current.onError( - isNotFound(err) ? t('error.login-timeout') : describeApiError(err, t) + gone ? t('error.login-timeout') : describeApiError(err, t) ); return; } @@ -229,6 +330,13 @@ export const useLoginFlow = ( // Previous process died on a terminal error — restart // transparently so «fix the typo and resubmit» just works. const fresh = await client.loginStart(TG_FLOW_PHONE); + if (gen !== generation.current) { + // Cancelled while the restart was in flight — don't go on to + // submit the number and trigger an SMS for a flow nobody is + // looking at. + void client.loginCancel(fresh.login_id).catch(() => undefined); + return; + } const freshField = fresh.user_input?.fields?.[0]; if (fresh.type !== 'user_input' || !freshField) { applyStep(fresh, gen); @@ -242,12 +350,15 @@ export const useLoginFlow = ( [snapshot.fieldId]: value, }); } - if (gen !== generation.current) return; // The phone-number submit resolving into a user_input step means - // Telegram dispatched a code — arm the SMS-resend cooldown. + // Telegram dispatched a code — arm the SMS-resend cooldown BEFORE + // the stale-generation check: a cancel that raced the submit + // doesn't un-send the SMS, and the cooldown must survive + // cancel→retry (that's its whole job). if (snapshot.form === 'phone' && step.type === 'user_input') { setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS); } + if (gen !== generation.current) return; applyStep(step, gen); } catch (err) { if (gen !== generation.current) return; diff --git a/apps/widget-telegram/src/provisioning.ts b/apps/widget-telegram/src/provisioning.ts index dc9364cf..e3765c49 100644 --- a/apps/widget-telegram/src/provisioning.ts +++ b/apps/widget-telegram/src/provisioning.ts @@ -284,14 +284,18 @@ export class ProvisioningClient { } /** Long-poll a display_and_wait step (QR). Resolves with the next step: - * a fresh QR (token rotated), the 2FA password prompt, or complete. */ + * a fresh QR (token rotated), the 2FA password prompt, or complete. The + * 200 s cap sits above every legitimate server-side wait (QR rotations + * ~30 s) — hitting it means the server-side handler is wedged; the wait + * loop folds the resulting AbortError into its retry path, which + * converges to an authoritative answer. */ public loginWait(loginId: string, stepId: string, signal: AbortSignal): Promise { return this.request( 'POST', `/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent( stepId )}/display_and_wait`, - { signal, timeoutMs: null } + { signal, timeoutMs: 200_000 } ); }