fix(step8): stop resend retry loop during in-place recovery
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
reuseOrCreateTab,
|
reuseOrCreateTab,
|
||||||
setState,
|
setState,
|
||||||
shouldUseCustomRegistrationEmail,
|
shouldUseCustomRegistrationEmail,
|
||||||
|
sleepWithStop,
|
||||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
|
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
|
||||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS,
|
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS,
|
||||||
throwIfStopped,
|
throwIfStopped,
|
||||||
@@ -167,10 +168,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runStep8Attempt(state) {
|
function getStep8ResendIntervalMs(state = {}) {
|
||||||
|
const mail = getMailConfig(state);
|
||||||
|
if (mail?.provider === LUCKMAIL_PROVIDER) {
|
||||||
|
return 15000;
|
||||||
|
}
|
||||||
|
if (mail?.provider === HOTMAIL_PROVIDER || mail?.provider === '2925') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, Number(STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStep8Attempt(state, runtime = {}) {
|
||||||
const visibleStep = getVisibleStep(state, 8);
|
const visibleStep = getVisibleStep(state, 8);
|
||||||
const mail = getMailConfig(state);
|
const mail = getMailConfig(state);
|
||||||
if (mail.error) throw new Error(mail.error);
|
if (mail.error) throw new Error(mail.error);
|
||||||
|
const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
|
||||||
|
let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt);
|
||||||
|
const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function'
|
||||||
|
? runtime.onResendRequestedAt
|
||||||
|
: null;
|
||||||
|
|
||||||
const stepStartedAt = Date.now();
|
const stepStartedAt = Date.now();
|
||||||
const verificationFilterAfterTimestamp = mail.provider === '2925'
|
const verificationFilterAfterTimestamp = mail.provider === '2925'
|
||||||
@@ -267,6 +284,16 @@
|
|||||||
disableTimeBudgetCap: mail.provider === '2925',
|
disableTimeBudgetCap: mail.provider === '2925',
|
||||||
getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep),
|
getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep),
|
||||||
requestFreshCodeFirst: false,
|
requestFreshCodeFirst: false,
|
||||||
|
lastResendAt: latestResendAt,
|
||||||
|
onResendRequestedAt: async (requestedAt) => {
|
||||||
|
const numericRequestedAt = Number(requestedAt) || 0;
|
||||||
|
if (numericRequestedAt > 0) {
|
||||||
|
latestResendAt = Math.max(latestResendAt, numericRequestedAt);
|
||||||
|
}
|
||||||
|
if (notifyResendRequestedAt) {
|
||||||
|
await notifyResendRequestedAt(latestResendAt);
|
||||||
|
}
|
||||||
|
},
|
||||||
targetEmail: fixedTargetEmail,
|
targetEmail: fixedTargetEmail,
|
||||||
resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER
|
resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER
|
||||||
? 15000
|
? 15000
|
||||||
@@ -274,6 +301,9 @@
|
|||||||
? 0
|
? 0
|
||||||
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS),
|
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS),
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
lastResendAt: latestResendAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStep8RestartStep7Error(error) {
|
function isStep8RestartStep7Error(error) {
|
||||||
@@ -285,10 +315,22 @@
|
|||||||
let currentState = state;
|
let currentState = state;
|
||||||
let mailPollingAttempt = 1;
|
let mailPollingAttempt = 1;
|
||||||
let lastMailPollingError = null;
|
let lastMailPollingError = null;
|
||||||
|
let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
await runStep8Attempt(currentState);
|
const result = await runStep8Attempt(currentState, {
|
||||||
|
stickyLastResendAt,
|
||||||
|
onResendRequestedAt: async (requestedAt) => {
|
||||||
|
const numericRequestedAt = Number(requestedAt) || 0;
|
||||||
|
if (numericRequestedAt > 0) {
|
||||||
|
stickyLastResendAt = Math.max(stickyLastResendAt, numericRequestedAt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (Number(result?.lastResendAt) > 0) {
|
||||||
|
stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const visibleStep = getVisibleStep(currentState, 8);
|
const visibleStep = getVisibleStep(currentState, 8);
|
||||||
@@ -323,7 +365,29 @@
|
|||||||
`步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}。`,
|
`步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}。`,
|
||||||
'warn'
|
'warn'
|
||||||
);
|
);
|
||||||
currentState = await getState();
|
const latestState = await getState();
|
||||||
|
const latestStateResendAt = Number(latestState?.loginVerificationRequestedAt) || 0;
|
||||||
|
if (latestStateResendAt > 0) {
|
||||||
|
stickyLastResendAt = Math.max(stickyLastResendAt, latestStateResendAt);
|
||||||
|
}
|
||||||
|
currentState = latestState;
|
||||||
|
if (stickyLastResendAt > 0 && (!latestStateResendAt || latestStateResendAt < stickyLastResendAt)) {
|
||||||
|
currentState = {
|
||||||
|
...latestState,
|
||||||
|
loginVerificationRequestedAt: stickyLastResendAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const resendIntervalMs = getStep8ResendIntervalMs(currentState);
|
||||||
|
const remainingBeforeRetryMs = stickyLastResendAt > 0 && resendIntervalMs > 0
|
||||||
|
? Math.max(0, resendIntervalMs - (Date.now() - stickyLastResendAt))
|
||||||
|
: 0;
|
||||||
|
if (remainingBeforeRetryMs > 0 && typeof sleepWithStop === 'function') {
|
||||||
|
await addLog(
|
||||||
|
`步骤 ${visibleStep}:上轮已触发重发验证码,为避免重复重发,先等待 ${Math.ceil(remainingBeforeRetryMs / 1000)} 秒后继续当前链路重试。`,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
await sleepWithStop(Math.min(remainingBeforeRetryMs, 3000));
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await addLog(
|
await addLog(
|
||||||
|
|||||||
@@ -920,8 +920,18 @@
|
|||||||
const maxSubmitAttempts = mail.provider === LUCKMAIL_PROVIDER ? 3 : 15;
|
const maxSubmitAttempts = mail.provider === LUCKMAIL_PROVIDER ? 3 : 15;
|
||||||
const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0);
|
const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0);
|
||||||
let lastResendAt = Number(options.lastResendAt) || 0;
|
let lastResendAt = Number(options.lastResendAt) || 0;
|
||||||
|
const externalOnResendRequestedAt = typeof options.onResendRequestedAt === 'function'
|
||||||
|
? options.onResendRequestedAt
|
||||||
|
: null;
|
||||||
|
|
||||||
const updateFilterAfterTimestampForVerificationStep = async (_requestedAt) => {
|
const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => {
|
||||||
|
if (externalOnResendRequestedAt) {
|
||||||
|
try {
|
||||||
|
await externalOnResendRequestedAt(requestedAt);
|
||||||
|
} catch (_) {
|
||||||
|
// Keep resend flow best-effort; state sync callback failures should not break verification.
|
||||||
|
}
|
||||||
|
}
|
||||||
return nextFilterAfterTimestamp;
|
return nextFilterAfterTimestamp;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,78 @@ test('step 8 retries in-place when polling fails but auth page still stays on ve
|
|||||||
assert.equal(events.ensureCalls >= 3, true);
|
assert.equal(events.ensureCalls >= 3, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('step 8 keeps resend cooldown timestamp across in-place retries to avoid repeated resend storms', async () => {
|
||||||
|
const events = {
|
||||||
|
resolveCalls: 0,
|
||||||
|
resolveLastResendAts: [],
|
||||||
|
sleepMs: [],
|
||||||
|
};
|
||||||
|
const realDateNow = Date.now;
|
||||||
|
Date.now = () => 230000;
|
||||||
|
|
||||||
|
const executor = api.createStep8Executor({
|
||||||
|
addLog: async () => {},
|
||||||
|
chrome: {
|
||||||
|
tabs: {
|
||||||
|
update: async () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||||
|
completeStepFromBackground: async () => {},
|
||||||
|
confirmCustomVerificationStepBypass: async () => {},
|
||||||
|
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }),
|
||||||
|
rerunStep7ForStep8Recovery: async () => {},
|
||||||
|
getOAuthFlowRemainingMs: async () => 9000,
|
||||||
|
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 9000),
|
||||||
|
getMailConfig: () => ({
|
||||||
|
provider: 'qq',
|
||||||
|
label: 'QQ 邮箱',
|
||||||
|
source: 'mail-qq',
|
||||||
|
url: 'https://mail.qq.com',
|
||||||
|
navigateOnReuse: false,
|
||||||
|
}),
|
||||||
|
getState: async () => ({ email: 'user@example.com', password: 'secret', loginVerificationRequestedAt: null }),
|
||||||
|
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
|
||||||
|
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||||
|
isTabAlive: async () => true,
|
||||||
|
isVerificationMailPollingError: (error) => /页面通信异常|did not respond/i.test(String(error?.message || error || '')),
|
||||||
|
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||||
|
resolveVerificationStep: async (_step, _state, _mail, options) => {
|
||||||
|
events.resolveCalls += 1;
|
||||||
|
events.resolveLastResendAts.push(Number(options?.lastResendAt) || 0);
|
||||||
|
if (events.resolveCalls === 1) {
|
||||||
|
await options.onResendRequestedAt(222000);
|
||||||
|
throw new Error('步骤 8:页面通信异常 did not respond in 1s');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reuseOrCreateTab: async () => {},
|
||||||
|
setState: async () => {},
|
||||||
|
setStepStatus: async () => {},
|
||||||
|
shouldUseCustomRegistrationEmail: () => false,
|
||||||
|
sleepWithStop: async (ms) => {
|
||||||
|
events.sleepMs.push(ms);
|
||||||
|
},
|
||||||
|
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||||
|
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||||
|
throwIfStopped: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executor.executeStep8({
|
||||||
|
email: 'user@example.com',
|
||||||
|
password: 'secret',
|
||||||
|
oauthUrl: 'https://oauth.example/latest',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
Date.now = realDateNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(events.resolveCalls, 2);
|
||||||
|
assert.deepStrictEqual(events.resolveLastResendAts, [0, 222000]);
|
||||||
|
assert.equal(events.sleepMs.length >= 1, true);
|
||||||
|
assert.equal(events.sleepMs[0], 3000);
|
||||||
|
});
|
||||||
|
|
||||||
test('step 8 completes when polling fails but recovery probe shows oauth consent page', async () => {
|
test('step 8 completes when polling fails but recovery probe shows oauth consent page', async () => {
|
||||||
const events = {
|
const events = {
|
||||||
ensureCalls: 0,
|
ensureCalls: 0,
|
||||||
|
|||||||
@@ -1030,6 +1030,77 @@ test('verification flow waits during resend cooldown instead of tight-looping',
|
|||||||
assert.ok(sleepCalls[0] >= 1000);
|
assert.ok(sleepCalls[0] >= 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('verification flow notifies onResendRequestedAt when resend is triggered', async () => {
|
||||||
|
const resendRequestedAtCalls = [];
|
||||||
|
const stateUpdates = [];
|
||||||
|
let pollCalls = 0;
|
||||||
|
|
||||||
|
const helpers = api.createVerificationFlowHelpers({
|
||||||
|
addLog: async () => {},
|
||||||
|
chrome: { tabs: { update: async () => {} } },
|
||||||
|
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||||
|
completeStepFromBackground: async () => {},
|
||||||
|
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||||
|
getHotmailVerificationPollConfig: () => ({}),
|
||||||
|
getHotmailVerificationRequestTimestamp: () => 0,
|
||||||
|
getState: async () => ({}),
|
||||||
|
getTabId: async () => 1,
|
||||||
|
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||||
|
isStopError: () => false,
|
||||||
|
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||||
|
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||||
|
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||||
|
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||||
|
pollHotmailVerificationCode: async () => ({}),
|
||||||
|
pollLuckmailVerificationCode: async () => ({}),
|
||||||
|
sendToContentScript: async (_source, message) => {
|
||||||
|
if (message.type === 'RESEND_VERIFICATION_CODE') {
|
||||||
|
return { resent: true };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
sendToMailContentScriptResilient: async (_mail, message) => {
|
||||||
|
if (message.type !== 'POLL_EMAIL') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
pollCalls += 1;
|
||||||
|
return pollCalls === 1
|
||||||
|
? {}
|
||||||
|
: { code: '654321', emailTimestamp: 123 };
|
||||||
|
},
|
||||||
|
setState: async (payload) => {
|
||||||
|
stateUpdates.push(payload);
|
||||||
|
},
|
||||||
|
setStepStatus: async () => {},
|
||||||
|
sleepWithStop: async () => {},
|
||||||
|
throwIfStopped: () => {},
|
||||||
|
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await helpers.resolveVerificationStep(
|
||||||
|
8,
|
||||||
|
{
|
||||||
|
email: 'user@example.com',
|
||||||
|
lastLoginCode: null,
|
||||||
|
},
|
||||||
|
{ provider: 'qq', label: 'QQ 邮箱' },
|
||||||
|
{
|
||||||
|
maxResendRequests: 1,
|
||||||
|
resendIntervalMs: 25000,
|
||||||
|
onResendRequestedAt: async (requestedAt) => {
|
||||||
|
resendRequestedAtCalls.push(Number(requestedAt) || 0);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(resendRequestedAtCalls.length >= 1, true);
|
||||||
|
assert.equal(resendRequestedAtCalls[0] > 0, true);
|
||||||
|
assert.equal(
|
||||||
|
stateUpdates.some((payload) => Number(payload?.loginVerificationRequestedAt) > 0),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('verification flow uses resilient signup-page transport when submitting verification code', async () => {
|
test('verification flow uses resilient signup-page transport when submitting verification code', async () => {
|
||||||
const resilientCalls = [];
|
const resilientCalls = [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user