fix(telegram): backport probe and cooldown race fixes and make the QR long-poll survive background WebView freezes
This commit is contained in:
parent
0095df358c
commit
3abbe893e0
4 changed files with 134 additions and 9 deletions
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<void> =>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<LoginStep> {
|
||||
return this.request<LoginStep>(
|
||||
'POST',
|
||||
`/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(
|
||||
stepId
|
||||
)}/display_and_wait`,
|
||||
{ signal, timeoutMs: null }
|
||||
{ signal, timeoutMs: 200_000 }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue