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 =
|
const activeProbe =
|
||||||
probe && probeCandidate && probe.identifier.value === probeCandidate.value ? probe : null;
|
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(() => {
|
const runProbe = useCallback(() => {
|
||||||
if (!probeCandidate) return;
|
if (!probeCandidate) return;
|
||||||
|
probeSeq.current += 1;
|
||||||
|
const seq = probeSeq.current;
|
||||||
setProbe({ status: 'checking', identifier: probeCandidate });
|
setProbe({ status: 'checking', identifier: probeCandidate });
|
||||||
client
|
client
|
||||||
.resolveIdentifier(probeCandidate.value)
|
.resolveIdentifier(probeCandidate.value)
|
||||||
.then((contact) => {
|
.then((contact) => {
|
||||||
if (!aliveRef.current) return;
|
if (!aliveRef.current || seq !== probeSeq.current) return;
|
||||||
setProbe(
|
setProbe(
|
||||||
contact
|
contact
|
||||||
? { status: 'found', identifier: probeCandidate, contact }
|
? { status: 'found', identifier: probeCandidate, contact }
|
||||||
|
|
@ -237,7 +244,7 @@ export const Contacts = ({
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!aliveRef.current) return;
|
if (!aliveRef.current || seq !== probeSeq.current) return;
|
||||||
setProbe(null);
|
setProbe(null);
|
||||||
onError(describeApiError(err, t));
|
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 (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_TIMED_OUT') return t('error.login-timeout');
|
||||||
if (err.errcode === 'FI.MAU.BRIDGE.LOGIN_CANCELLED') return t('error.login-restart');
|
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');
|
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
|
// Login process evaporated server-side (bridge deletes it on errors and
|
||||||
// after its 30-minute deadline) — the only recovery is starting over.
|
// 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 { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
|
||||||
import {
|
import {
|
||||||
ProvisioningClient,
|
ProvisioningClient,
|
||||||
|
ProvisioningError,
|
||||||
TG_FLOW_PHONE,
|
TG_FLOW_PHONE,
|
||||||
TG_FLOW_QR,
|
TG_FLOW_QR,
|
||||||
isNotFound,
|
isNotFound,
|
||||||
|
|
@ -76,6 +77,59 @@ export type LoginFlow = {
|
||||||
// code (the submit resolved into the code step).
|
// code (the submit resolved into the code step).
|
||||||
const PHONE_COOLDOWN_MS = 60_000;
|
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 = (
|
export const useLoginFlow = (
|
||||||
client: ProvisioningClient,
|
client: ProvisioningClient,
|
||||||
t: T,
|
t: T,
|
||||||
|
|
@ -116,19 +170,66 @@ export const useLoginFlow = (
|
||||||
waitAbort.current = controller;
|
waitAbort.current = controller;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let stepId = firstStepId;
|
let stepId = firstStepId;
|
||||||
|
let retryDelayMs = WAIT_RETRY_BASE_MS;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
let step: LoginStep;
|
let step: LoginStep;
|
||||||
try {
|
try {
|
||||||
// Blocks server-side until the QR token rotates (~30 s), the
|
// Blocks server-side until the QR token rotates (~30 s), the
|
||||||
// scan succeeds, or the login dies.
|
// scan succeeds, or the login dies.
|
||||||
step = await client.loginWait(loginId, stepId, controller.signal);
|
step = await client.loginWait(loginId, stepId, controller.signal);
|
||||||
|
retryDelayMs = WAIT_RETRY_BASE_MS;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (gen !== generation.current || controller.signal.aborted) return;
|
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' });
|
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(
|
callbacksRef.current.onError(
|
||||||
isNotFound(err) ? t('error.login-timeout') : describeApiError(err, t)
|
gone ? t('error.login-timeout') : describeApiError(err, t)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +330,13 @@ export const useLoginFlow = (
|
||||||
// Previous process died on a terminal error — restart
|
// Previous process died on a terminal error — restart
|
||||||
// transparently so «fix the typo and resubmit» just works.
|
// transparently so «fix the typo and resubmit» just works.
|
||||||
const fresh = await client.loginStart(TG_FLOW_PHONE);
|
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];
|
const freshField = fresh.user_input?.fields?.[0];
|
||||||
if (fresh.type !== 'user_input' || !freshField) {
|
if (fresh.type !== 'user_input' || !freshField) {
|
||||||
applyStep(fresh, gen);
|
applyStep(fresh, gen);
|
||||||
|
|
@ -242,12 +350,15 @@ export const useLoginFlow = (
|
||||||
[snapshot.fieldId]: value,
|
[snapshot.fieldId]: value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (gen !== generation.current) return;
|
|
||||||
// The phone-number submit resolving into a user_input step means
|
// 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') {
|
if (snapshot.form === 'phone' && step.type === 'user_input') {
|
||||||
setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS);
|
setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS);
|
||||||
}
|
}
|
||||||
|
if (gen !== generation.current) return;
|
||||||
applyStep(step, gen);
|
applyStep(step, gen);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (gen !== generation.current) return;
|
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:
|
/** 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> {
|
public loginWait(loginId: string, stepId: string, signal: AbortSignal): Promise<LoginStep> {
|
||||||
return this.request<LoginStep>(
|
return this.request<LoginStep>(
|
||||||
'POST',
|
'POST',
|
||||||
`/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(
|
`/v3/login/step/${encodeURIComponent(loginId)}/${encodeURIComponent(
|
||||||
stepId
|
stepId
|
||||||
)}/display_and_wait`,
|
)}/display_and_wait`,
|
||||||
{ signal, timeoutMs: null }
|
{ signal, timeoutMs: 200_000 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue