mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-12 16:38:39 -04:00
perf(email): make library prewarm idle and bounded (#5925)
* perf(email): make library prewarm idle and bounded * fix(email): preserve idle prewarm and prioritize foreground * fix(email): retry interrupted idle prewarm safely
This commit is contained in:
+297
-132
@@ -1793,11 +1793,18 @@ function _rememberedEmailAccountId() {
|
|||||||
// results and __scheduled__ are deliberately not cached.
|
// results and __scheduled__ are deliberately not cached.
|
||||||
const _libListCache = new Map();
|
const _libListCache = new Map();
|
||||||
const _LIB_CACHE_MAX = 24;
|
const _LIB_CACHE_MAX = 24;
|
||||||
|
const _LIB_INITIAL_PAGE_SIZE = 100;
|
||||||
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
|
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
|
||||||
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
|
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
|
||||||
let _libPrewarmTimer = null;
|
const _LIB_PREWARM_COOLDOWN_MS = 5 * 60 * 1000;
|
||||||
|
let _libPrewarmDelayTimer = null;
|
||||||
|
let _libPrewarmIdleHandle = null;
|
||||||
let _libPrewarmPromise = null;
|
let _libPrewarmPromise = null;
|
||||||
|
let _libPrewarmResolve = null;
|
||||||
|
let _libPrewarmAbortController = null;
|
||||||
|
let _libPrewarmDetachPriorityListeners = null;
|
||||||
|
let _libPrewarmGeneration = 0;
|
||||||
let _libLastPrewarmAt = 0;
|
let _libLastPrewarmAt = 0;
|
||||||
let _libUnreadPrewarmKey = '';
|
let _libUnreadPrewarmKey = '';
|
||||||
let _libUnreadPrewarmAt = 0;
|
let _libUnreadPrewarmAt = 0;
|
||||||
@@ -2108,162 +2115,319 @@ function _isChatInteractionBusy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) {
|
function _canRunEmailPrewarm() {
|
||||||
const run = () => {
|
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
|
||||||
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
|
if (document.visibilityState && document.visibilityState !== 'visible') return false;
|
||||||
if (_isChatInteractionBusy() && retries > 0) {
|
return !_isChatInteractionBusy();
|
||||||
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
|
}
|
||||||
|
|
||||||
|
function _isEmailPrewarmTemporarilyBlocked() {
|
||||||
|
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
|
||||||
|
if (document.visibilityState && document.visibilityState !== 'visible') return false;
|
||||||
|
return _isChatInteractionBusy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isEmailPrewarmCurrent(generation, signal) {
|
||||||
|
return generation === _libPrewarmGeneration
|
||||||
|
&& !signal?.aborted
|
||||||
|
&& _canRunEmailPrewarm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _settleEmailPrewarm(generation, value = false) {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
const resolve = _libPrewarmResolve;
|
||||||
|
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
|
||||||
|
_libPrewarmDelayTimer = null;
|
||||||
|
_libPrewarmIdleHandle = null;
|
||||||
|
_libPrewarmPromise = null;
|
||||||
|
_libPrewarmResolve = null;
|
||||||
|
_libPrewarmAbortController = null;
|
||||||
|
_libPrewarmDetachPriorityListeners = null;
|
||||||
|
detachPriorityListeners?.();
|
||||||
|
resolve?.(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cancelEmailPrewarm() {
|
||||||
|
const resolve = _libPrewarmResolve;
|
||||||
|
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
|
||||||
|
_libPrewarmGeneration += 1;
|
||||||
|
if (_libPrewarmDelayTimer !== null) {
|
||||||
|
clearTimeout(_libPrewarmDelayTimer);
|
||||||
|
}
|
||||||
|
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
|
||||||
|
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
|
||||||
|
}
|
||||||
|
try { _libPrewarmAbortController?.abort(); } catch (_) {}
|
||||||
|
_libPrewarmDelayTimer = null;
|
||||||
|
_libPrewarmIdleHandle = null;
|
||||||
|
_libPrewarmPromise = null;
|
||||||
|
_libPrewarmResolve = null;
|
||||||
|
_libPrewarmAbortController = null;
|
||||||
|
_libPrewarmDetachPriorityListeners = null;
|
||||||
|
detachPriorityListeners?.();
|
||||||
|
resolve?.(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scheduleEmailPrewarm(task, { delay = 0 } = {}) {
|
||||||
|
if (_libPrewarmPromise) return _libPrewarmPromise;
|
||||||
|
// Do not disguise a timer as idle work. Browsers without the genuine idle
|
||||||
|
// callback simply skip this optional optimization and load on demand.
|
||||||
|
if (typeof window.requestIdleCallback !== 'function') return Promise.resolve(false);
|
||||||
|
|
||||||
|
const generation = ++_libPrewarmGeneration;
|
||||||
|
_libPrewarmPromise = new Promise(resolve => { _libPrewarmResolve = resolve; });
|
||||||
|
const promise = _libPrewarmPromise;
|
||||||
|
let attemptPending = false;
|
||||||
|
let retryRequested = false;
|
||||||
|
|
||||||
|
function clearScheduledAttempt() {
|
||||||
|
if (_libPrewarmDelayTimer !== null) clearTimeout(_libPrewarmDelayTimer);
|
||||||
|
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
|
||||||
|
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
|
||||||
|
}
|
||||||
|
_libPrewarmDelayTimer = null;
|
||||||
|
_libPrewarmIdleHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleIdleRetry(delay = 500) {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
retryRequested = true;
|
||||||
|
if (attemptPending || _libPrewarmDelayTimer !== null || _libPrewarmIdleHandle !== null) return;
|
||||||
|
if (document.visibilityState && document.visibilityState !== 'visible') return;
|
||||||
|
_libPrewarmDelayTimer = setTimeout(requestIdle, Math.max(50, Number(delay) || 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePriorityChange() {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
if (_canRunEmailPrewarm()) {
|
||||||
|
scheduleIdleRetry(50);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_loadEmails(options);
|
|
||||||
|
const priorityBlocked = _isChatInteractionBusy()
|
||||||
|
|| (document.visibilityState && document.visibilityState !== 'visible');
|
||||||
|
if (!priorityBlocked) return;
|
||||||
|
|
||||||
|
retryRequested = true;
|
||||||
|
clearScheduledAttempt();
|
||||||
|
const controller = _libPrewarmAbortController;
|
||||||
|
_libPrewarmAbortController = null;
|
||||||
|
try { controller?.abort(); } catch (_) {}
|
||||||
|
// A hidden page waits for visibilitychange. Chat priority also retains the
|
||||||
|
// timer fallback for busy-until windows whose final transition has no event.
|
||||||
|
if (!document.visibilityState || document.visibilityState === 'visible') {
|
||||||
|
scheduleIdleRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('odysseus:chat-busy-change', handlePriorityChange);
|
||||||
|
document.addEventListener('visibilitychange', handlePriorityChange);
|
||||||
|
_libPrewarmDetachPriorityListeners = () => {
|
||||||
|
window.removeEventListener('odysseus:chat-busy-change', handlePriorityChange);
|
||||||
|
document.removeEventListener('visibilitychange', handlePriorityChange);
|
||||||
};
|
};
|
||||||
setTimeout(run, Math.max(0, Number(delay) || 0));
|
|
||||||
|
function requestIdle() {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
_libPrewarmDelayTimer = null;
|
||||||
|
try {
|
||||||
|
_libPrewarmIdleHandle = window.requestIdleCallback((deadline) => {
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
_libPrewarmIdleHandle = null;
|
||||||
|
const hasIdleBudget = Boolean(
|
||||||
|
deadline
|
||||||
|
&& !deadline.didTimeout
|
||||||
|
&& typeof deadline.timeRemaining === 'function'
|
||||||
|
&& deadline.timeRemaining() > 0
|
||||||
|
);
|
||||||
|
if (!_canRunEmailPrewarm()) {
|
||||||
|
if (_isEmailPrewarmTemporarilyBlocked()) {
|
||||||
|
scheduleIdleRetry();
|
||||||
|
} else {
|
||||||
|
_settleEmailPrewarm(generation, false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasIdleBudget) {
|
||||||
|
scheduleIdleRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (generation !== _libPrewarmGeneration) {
|
||||||
|
_settleEmailPrewarm(generation, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
_libPrewarmAbortController = controller;
|
||||||
|
attemptPending = true;
|
||||||
|
retryRequested = false;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => task({ signal: controller.signal, generation }))
|
||||||
|
.then(value => {
|
||||||
|
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
|
||||||
|
_settleEmailPrewarm(generation, Boolean(value));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
|
||||||
|
_settleEmailPrewarm(generation, false);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
attemptPending = false;
|
||||||
|
if (generation !== _libPrewarmGeneration) return;
|
||||||
|
if (retryRequested) scheduleIdleRetry();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
_settleEmailPrewarm(generation, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = Math.max(0, Number(delay) || 0);
|
||||||
|
if (wait > 0) _libPrewarmDelayTimer = setTimeout(requestIdle, wait);
|
||||||
|
else requestIdle();
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
|
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
|
||||||
if (_libPrewarmTimer || _libPrewarmPromise) return;
|
if (_libPrewarmPromise) return _libPrewarmPromise;
|
||||||
const elapsed = Date.now() - _libLastPrewarmAt;
|
const elapsed = Date.now() - _libLastPrewarmAt;
|
||||||
if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return;
|
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
|
||||||
_libPrewarmTimer = setTimeout(() => {
|
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
|
||||||
_libPrewarmTimer = null;
|
|
||||||
_libPrewarmPromise = _prewarmEmailViews()
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => { _libPrewarmPromise = null; });
|
|
||||||
}, Math.max(0, Number(delay) || 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _ensureEmailAccountsForPrewarm() {
|
function _chooseEmailPrewarmAccountId(accounts) {
|
||||||
|
const enabled = Array.isArray(accounts) ? accounts.filter(a => a && a.enabled !== false) : [];
|
||||||
|
const remembered = _rememberedEmailAccountId();
|
||||||
|
const current = String(state._libAccountId || '').trim();
|
||||||
|
const chosen = enabled.find(a => String(a.id || '') === remembered)
|
||||||
|
|| enabled.find(a => String(a.id || '') === current)
|
||||||
|
|| enabled.find(a => a.is_default)
|
||||||
|
|| enabled[0]
|
||||||
|
|| null;
|
||||||
|
return String(chosen?.id || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _ensureEmailAccountsForPrewarm({ signal, generation } = {}) {
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||||
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
|
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
|
||||||
if (Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh) {
|
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
|
||||||
if (!state._libAccountId) {
|
try {
|
||||||
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
|
||||||
state._libAccountId = def?.id || null;
|
credentials: 'same-origin',
|
||||||
_publishActiveAccount();
|
signal,
|
||||||
}
|
});
|
||||||
return;
|
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||||
}
|
if (accountsRes.ok) {
|
||||||
try {
|
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||||
if (!accountsRes.ok) return;
|
if (Array.isArray(accountsData.accounts)) {
|
||||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
state._libAccounts = accountsData.accounts;
|
||||||
if (Array.isArray(accountsData.accounts)) {
|
_libAccountsLoadedAt = Date.now();
|
||||||
state._libAccounts = accountsData.accounts;
|
}
|
||||||
_libAccountsLoadedAt = Date.now();
|
|
||||||
if (!state._libAccountId && state._libAccounts.length) {
|
|
||||||
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
|
||||||
state._libAccountId = def?.id || null;
|
|
||||||
_publishActiveAccount();
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') return null;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
}
|
||||||
|
|
||||||
|
const accountId = _chooseEmailPrewarmAccountId(state._libAccounts);
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||||
|
if (!accountId) return null;
|
||||||
|
if (accountId && state._libAccountId !== accountId) {
|
||||||
|
state._libAccountId = accountId;
|
||||||
|
_publishActiveAccount();
|
||||||
|
}
|
||||||
|
return accountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
||||||
if (state._libOpen) return;
|
return _scheduleEmailPrewarm(
|
||||||
await _ensureEmailAccountsForPrewarm();
|
context => _prewarmUnreadEmailsNow({ limit, maxUid }, context),
|
||||||
if (state._libOpen) return;
|
{ delay: 0 }
|
||||||
const accountId = state._libAccountId || '';
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _prewarmUnreadEmailsNow({ limit = 8, maxUid = 0 } = {}, { signal, generation } = {}) {
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
|
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
|
||||||
|
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
const n = Math.max(1, Math.min(20, Number(limit) || 8));
|
const n = Math.max(1, Math.min(20, Number(limit) || 8));
|
||||||
const key = `${accountId}|${maxUid || 0}|${n}`;
|
const key = `${accountId}|${maxUid || 0}|${n}`;
|
||||||
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return;
|
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
|
||||||
_libUnreadPrewarmKey = key;
|
|
||||||
_libUnreadPrewarmAt = Date.now();
|
|
||||||
try {
|
try {
|
||||||
const folder = 'INBOX';
|
const folder = 'INBOX';
|
||||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||||
folder,
|
folder,
|
||||||
limit: n,
|
limit: n,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
filter: 'unread',
|
filter: 'unread',
|
||||||
account_id: accountId || undefined,
|
account_id: accountId || undefined,
|
||||||
}), { credentials: 'same-origin' });
|
}), {
|
||||||
if (state._libOpen) return;
|
credentials: 'same-origin',
|
||||||
if (!res.ok) return;
|
signal,
|
||||||
|
});
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return;
|
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
|
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return false;
|
||||||
const sync = data.sync || {};
|
const sync = data.sync || {};
|
||||||
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
|
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
|
||||||
emails: data.emails,
|
emails: data.emails,
|
||||||
total: data.total || data.emails.length,
|
total: data.total || data.emails.length,
|
||||||
sync,
|
sync,
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
_libUnreadPrewarmKey = key;
|
||||||
|
_libUnreadPrewarmAt = Date.now();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _sleep(ms) {
|
async function _prewarmEmailViews({ signal, generation } = {}) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
}
|
|
||||||
|
|
||||||
async function _prewarmEmailViews() {
|
|
||||||
if (state._libOpen) return;
|
|
||||||
_libLastPrewarmAt = Date.now();
|
|
||||||
_setEmailSyncStatus({ warming: true });
|
_setEmailSyncStatus({ warming: true });
|
||||||
const folder = 'INBOX';
|
const folder = 'INBOX';
|
||||||
const filter = 'all';
|
const filter = 'all';
|
||||||
|
|
||||||
// The accounts request is cheap and warms the account strip for first open.
|
|
||||||
// Then folder/list requests warm both the client cache and the backend
|
|
||||||
// IMAP/read caches. Failure stays silent: no configured mail should not nag.
|
|
||||||
try {
|
try {
|
||||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
|
||||||
if (accountsRes.ok) {
|
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
const ck = _libCacheKeyFor(accountId, folder, filter, false);
|
||||||
if (Array.isArray(accountsData.accounts)) {
|
if (_libCacheGet(ck)) {
|
||||||
state._libAccounts = accountsData.accounts;
|
_libLastPrewarmAt = Date.now();
|
||||||
_libAccountsLoadedAt = Date.now();
|
return true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : [];
|
// One optional first-page request only. Folder metadata, unread state, and
|
||||||
const preferred = state._libAccountId
|
// other accounts remain demand-driven so startup cannot fan out into IMAP.
|
||||||
|| (accounts.find(a => a.is_default)?.id)
|
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||||
|| (accounts[0]?.id)
|
folder,
|
||||||
|| '';
|
limit: _LIB_INITIAL_PAGE_SIZE,
|
||||||
if (!state._libAccountId && preferred) {
|
offset: 0,
|
||||||
state._libAccountId = preferred;
|
filter,
|
||||||
_publishActiveAccount();
|
account_id: accountId || undefined,
|
||||||
}
|
}), {
|
||||||
const orderedAccountIds = [
|
credentials: 'same-origin',
|
||||||
preferred,
|
signal,
|
||||||
...accounts.map(a => a.id).filter(id => id && id !== preferred),
|
});
|
||||||
].filter((id, idx, arr) => arr.indexOf(id) === idx);
|
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
||||||
if (!orderedAccountIds.length) orderedAccountIds.push('');
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||||
try {
|
if (!data || data.error || !Array.isArray(data.emails)) return false;
|
||||||
for (const accountId of orderedAccountIds.slice(0, 4)) {
|
const sync = data.sync || {};
|
||||||
if (state._libOpen) return;
|
_libCachePut(ck, {
|
||||||
const ck = _libCacheKeyFor(accountId, folder, filter, false);
|
emails: data.emails,
|
||||||
if (_libCacheGet(ck)) continue;
|
total: data.total || 0,
|
||||||
await fetch(emailApiUrl('/api/email/folders', { account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
|
sync,
|
||||||
await fetch(emailApiUrl('/api/email/unread-state', { folder, account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
|
});
|
||||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
_libLastPrewarmAt = Date.now();
|
||||||
folder,
|
_setEmailSyncStatus({
|
||||||
limit: 100,
|
updatedAt: sync.updated_at || new Date().toISOString(),
|
||||||
offset: 0,
|
source: sync.source || '',
|
||||||
filter,
|
warming: true,
|
||||||
account_id: accountId || undefined,
|
});
|
||||||
}), {
|
return true;
|
||||||
credentials: 'same-origin',
|
} catch (_) {
|
||||||
});
|
return false;
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
if (data && !data.error) {
|
|
||||||
const sync = data.sync || {};
|
|
||||||
_libCachePut(ck, {
|
|
||||||
emails: data.emails || [],
|
|
||||||
total: data.total || 0,
|
|
||||||
sync,
|
|
||||||
});
|
|
||||||
_setEmailSyncStatus({
|
|
||||||
updatedAt: sync.updated_at || new Date().toISOString(),
|
|
||||||
source: sync.source || '',
|
|
||||||
warming: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _sleep(900);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
_setEmailSyncStatus({ warming: false });
|
_setEmailSyncStatus({ warming: false });
|
||||||
}
|
}
|
||||||
@@ -2342,10 +2506,10 @@ export function initEmailLibrary(config) {
|
|||||||
export function isOpen() { return state._libOpen; }
|
export function isOpen() { return state._libOpen; }
|
||||||
|
|
||||||
export function openEmailLibrary(opts = {}) {
|
export function openEmailLibrary(opts = {}) {
|
||||||
if (_libPrewarmTimer) {
|
// Foreground email always wins: cancel a delayed/idle callback and abort the
|
||||||
clearTimeout(_libPrewarmTimer);
|
// one optional request if it has already started. Generation checks make a
|
||||||
_libPrewarmTimer = null;
|
// non-abortable response harmless if it races this transition.
|
||||||
}
|
_cancelEmailPrewarm();
|
||||||
// Force-clean any stale state from previous attempts
|
// Force-clean any stale state from previous attempts
|
||||||
const existing = document.getElementById('email-lib-modal');
|
const existing = document.getElementById('email-lib-modal');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
@@ -2977,7 +3141,7 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
}
|
}
|
||||||
const fastAccountAtOpen = state._libAccountId || '';
|
const fastAccountAtOpen = state._libAccountId || '';
|
||||||
if (fastAccountAtOpen) {
|
if (fastAccountAtOpen) {
|
||||||
_loadEmailsWhenChatIdle({ delay: 0 });
|
_loadEmails({ useCache: true });
|
||||||
}
|
}
|
||||||
// If we already know the previous/default account, paint that inbox first
|
// If we already know the previous/default account, paint that inbox first
|
||||||
// from the durable index and validate accounts in parallel. Cold refreshes
|
// from the durable index and validate accounts in parallel. Cold refreshes
|
||||||
@@ -2987,7 +3151,7 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
_loadFolders();
|
_loadFolders();
|
||||||
_loadEmailReminderBellVisibility();
|
_loadEmailReminderBellVisibility();
|
||||||
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
||||||
_loadEmailsWhenChatIdle();
|
_loadEmails({ useCache: true });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -3172,6 +3336,7 @@ export async function openEmailLibrarySettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function closeEmailLibrary() {
|
export function closeEmailLibrary() {
|
||||||
|
_cancelEmailPrewarm();
|
||||||
const modal = document.getElementById('email-lib-modal');
|
const modal = document.getElementById('email-lib-modal');
|
||||||
if (modal) modal.remove();
|
if (modal) modal.remove();
|
||||||
if (_libSyncTicker) {
|
if (_libSyncTicker) {
|
||||||
@@ -4605,7 +4770,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 450);
|
const timer = setTimeout(() => ctrl.abort(), 450);
|
||||||
try {
|
try {
|
||||||
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
|
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
const fastData = await fastRes.json().catch(() => null);
|
const fastData = await fastRes.json().catch(() => null);
|
||||||
@@ -4632,7 +4797,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
|||||||
// opens omit it so rapid close/reopen returns instantly; the
|
// opens omit it so rapid close/reopen returns instantly; the
|
||||||
// Refresh button passes `force: true` to add it back.
|
// Refresh button passes `force: true` to add it back.
|
||||||
const buster = force ? `&_=${Date.now()}` : '';
|
const buster = force ? `&_=${Date.now()}` : '';
|
||||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
|
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
|
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
|
||||||
|
|
||||||
|
|
||||||
|
def _source() -> str:
|
||||||
|
return _EMAIL_LIBRARY.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _function_source(name: str) -> str:
|
||||||
|
"""Return one top-level JS function using balanced braces."""
|
||||||
|
text = _source()
|
||||||
|
markers = (f"function {name}", f"async function {name}", f"export function {name}", f"export async function {name}")
|
||||||
|
starts = [text.find(marker) for marker in markers]
|
||||||
|
starts = [start for start in starts if start >= 0]
|
||||||
|
assert starts, f"missing function {name}"
|
||||||
|
start = min(starts)
|
||||||
|
paren = text.index("(", start)
|
||||||
|
paren_depth = 0
|
||||||
|
quote = None
|
||||||
|
escaped = False
|
||||||
|
for index in range(paren, len(text)):
|
||||||
|
char = text[index]
|
||||||
|
if quote:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == quote:
|
||||||
|
quote = None
|
||||||
|
continue
|
||||||
|
if char in ("'", '"', "`"):
|
||||||
|
quote = char
|
||||||
|
elif char == "(":
|
||||||
|
paren_depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
paren_depth -= 1
|
||||||
|
if paren_depth == 0:
|
||||||
|
brace = text.index("{", index)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"unterminated signature {name}")
|
||||||
|
depth = 0
|
||||||
|
quote = None
|
||||||
|
escaped = False
|
||||||
|
template_depth = 0
|
||||||
|
for index in range(brace, len(text)):
|
||||||
|
char = text[index]
|
||||||
|
if quote:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == quote and template_depth == 0:
|
||||||
|
quote = None
|
||||||
|
elif quote == "`" and char == "$" and index + 1 < len(text) and text[index + 1] == "{":
|
||||||
|
template_depth += 1
|
||||||
|
elif quote == "`" and char == "}" and template_depth:
|
||||||
|
template_depth -= 1
|
||||||
|
continue
|
||||||
|
if char in ("'", '"', "`"):
|
||||||
|
quote = char
|
||||||
|
elif char == "{":
|
||||||
|
depth += 1
|
||||||
|
elif char == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[start:index + 1]
|
||||||
|
raise AssertionError(f"unterminated function {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scheduler_scenario(scenario: str):
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node not on PATH")
|
||||||
|
functions = "\n".join(
|
||||||
|
_function_source(name)
|
||||||
|
for name in (
|
||||||
|
"_isChatInteractionBusy",
|
||||||
|
"_canRunEmailPrewarm",
|
||||||
|
"_isEmailPrewarmTemporarilyBlocked",
|
||||||
|
"_settleEmailPrewarm",
|
||||||
|
"_cancelEmailPrewarm",
|
||||||
|
"_scheduleEmailPrewarm",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
script = f"""
|
||||||
|
let now = 0;
|
||||||
|
Date.now = () => now;
|
||||||
|
const state = {{ _libOpen: false, _libLoading: false }};
|
||||||
|
let _libSearchInFlight = false;
|
||||||
|
let _libPrewarmDelayTimer = null;
|
||||||
|
let _libPrewarmIdleHandle = null;
|
||||||
|
let _libPrewarmPromise = null;
|
||||||
|
let _libPrewarmResolve = null;
|
||||||
|
let _libPrewarmAbortController = null;
|
||||||
|
let _libPrewarmDetachPriorityListeners = null;
|
||||||
|
let _libPrewarmGeneration = 0;
|
||||||
|
let nextHandle = 1;
|
||||||
|
const timers = new Map();
|
||||||
|
const idleCallbacks = new Map();
|
||||||
|
let idleRequestCount = 0;
|
||||||
|
function eventTarget(target) {{
|
||||||
|
const listeners = new Map();
|
||||||
|
target.addEventListener = (type, callback) => {{
|
||||||
|
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||||
|
listeners.get(type).add(callback);
|
||||||
|
}};
|
||||||
|
target.removeEventListener = (type, callback) => listeners.get(type)?.delete(callback);
|
||||||
|
target.dispatchEvent = (event) => {{
|
||||||
|
for (const callback of [...(listeners.get(event.type) || [])]) callback(event);
|
||||||
|
}};
|
||||||
|
target.listenerCount = (type) => listeners.get(type)?.size || 0;
|
||||||
|
return target;
|
||||||
|
}}
|
||||||
|
const document = eventTarget({{ visibilityState: 'visible' }});
|
||||||
|
const window = {{
|
||||||
|
__odysseusChatBusy: false,
|
||||||
|
__odysseusChatBusyUntil: 0,
|
||||||
|
requestIdleCallback(callback) {{
|
||||||
|
const handle = nextHandle++;
|
||||||
|
idleRequestCount += 1;
|
||||||
|
idleCallbacks.set(handle, callback);
|
||||||
|
return handle;
|
||||||
|
}},
|
||||||
|
cancelIdleCallback(handle) {{ idleCallbacks.delete(handle); }},
|
||||||
|
}};
|
||||||
|
eventTarget(window);
|
||||||
|
function setTimeout(callback, delay) {{
|
||||||
|
const handle = nextHandle++;
|
||||||
|
timers.set(handle, {{ callback, at: now + Number(delay || 0) }});
|
||||||
|
return handle;
|
||||||
|
}}
|
||||||
|
function clearTimeout(handle) {{ timers.delete(handle); }}
|
||||||
|
async function flushMicrotasks() {{
|
||||||
|
for (let i = 0; i < 6; i += 1) await Promise.resolve();
|
||||||
|
}}
|
||||||
|
async function advanceTo(target) {{
|
||||||
|
while (true) {{
|
||||||
|
const pending = [...timers.entries()]
|
||||||
|
.filter(([, timer]) => timer.at <= target)
|
||||||
|
.sort((a, b) => a[1].at - b[1].at)[0];
|
||||||
|
if (!pending) break;
|
||||||
|
const [handle, timer] = pending;
|
||||||
|
timers.delete(handle);
|
||||||
|
now = timer.at;
|
||||||
|
timer.callback();
|
||||||
|
await flushMicrotasks();
|
||||||
|
}}
|
||||||
|
now = target;
|
||||||
|
await flushMicrotasks();
|
||||||
|
}}
|
||||||
|
async function fireNextIdle(budget = 5) {{
|
||||||
|
const pending = idleCallbacks.entries().next().value;
|
||||||
|
if (!pending) throw new Error('no idle callback pending');
|
||||||
|
const [handle, callback] = pending;
|
||||||
|
idleCallbacks.delete(handle);
|
||||||
|
callback({{ didTimeout: false, timeRemaining: () => budget }});
|
||||||
|
await flushMicrotasks();
|
||||||
|
}}
|
||||||
|
{functions}
|
||||||
|
{scenario}
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[node, "--input-type=module"],
|
||||||
|
input=script,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_is_genuine_idle_only_and_single_flight():
|
||||||
|
scheduler = _function_source("_scheduleEmailPrewarm")
|
||||||
|
|
||||||
|
assert "if (_libPrewarmPromise) return _libPrewarmPromise;" in scheduler
|
||||||
|
assert "typeof window.requestIdleCallback !== 'function'" in scheduler
|
||||||
|
assert "return Promise.resolve(false);" in scheduler
|
||||||
|
assert "window.requestIdleCallback((deadline)" in scheduler
|
||||||
|
assert "!deadline.didTimeout" in scheduler
|
||||||
|
assert "deadline.timeRemaining() > 0" in scheduler
|
||||||
|
|
||||||
|
idle_callback = scheduler.index("window.requestIdleCallback((deadline)")
|
||||||
|
assert "Promise.resolve()" in scheduler
|
||||||
|
task_start = scheduler.index("task({ signal: controller.signal, generation })")
|
||||||
|
assert idle_callback < task_start, "network work must only be reachable from the idle callback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_temporary_chat_priority_retries_one_single_flight_until_idle():
|
||||||
|
out = _run_scheduler_scenario("""
|
||||||
|
window.__odysseusChatBusyUntil = 10000;
|
||||||
|
let taskCalls = 0;
|
||||||
|
const task = async () => { taskCalls += 1; return true; };
|
||||||
|
const first = _scheduleEmailPrewarm(task, { delay: 1800 });
|
||||||
|
const joined = _scheduleEmailPrewarm(task, { delay: 0 });
|
||||||
|
const samePromise = first === joined;
|
||||||
|
await advanceTo(1800);
|
||||||
|
await fireNextIdle(7);
|
||||||
|
const callsWhileBusy = taskCalls;
|
||||||
|
while (now < 10300) {
|
||||||
|
await advanceTo(now + 500);
|
||||||
|
await fireNextIdle(7);
|
||||||
|
}
|
||||||
|
const result = await first;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
result, samePromise, callsWhileBusy, taskCalls, idleRequestCount,
|
||||||
|
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
assert out == {
|
||||||
|
"result": True,
|
||||||
|
"samePromise": True,
|
||||||
|
"callsWhileBusy": 0,
|
||||||
|
"taskCalls": 1,
|
||||||
|
"idleRequestCount": 18,
|
||||||
|
"timers": 0,
|
||||||
|
"idleCallbacks": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_prewarm_cannot_issue_a_delayed_duplicate():
|
||||||
|
out = _run_scheduler_scenario("""
|
||||||
|
let taskCalls = 0;
|
||||||
|
const pending = _scheduleEmailPrewarm(async () => { taskCalls += 1; return true; }, { delay: 1800 });
|
||||||
|
await advanceTo(1400);
|
||||||
|
_cancelEmailPrewarm();
|
||||||
|
await advanceTo(12000);
|
||||||
|
const result = await pending;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
result, taskCalls, idleRequestCount,
|
||||||
|
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
assert out == {
|
||||||
|
"result": False,
|
||||||
|
"taskCalls": 0,
|
||||||
|
"idleRequestCount": 0,
|
||||||
|
"timers": 0,
|
||||||
|
"idleCallbacks": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("transition", ["busy", "hidden"])
|
||||||
|
def test_active_prewarm_is_aborted_and_retried_once_after_priority_transition(transition):
|
||||||
|
block = (
|
||||||
|
"window.__odysseusChatBusy = true; "
|
||||||
|
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
|
||||||
|
if transition == "busy"
|
||||||
|
else "document.visibilityState = 'hidden'; document.dispatchEvent({ type: 'visibilitychange' });"
|
||||||
|
)
|
||||||
|
unblock = (
|
||||||
|
"window.__odysseusChatBusy = false; window.__odysseusChatBusyUntil = now; "
|
||||||
|
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
|
||||||
|
if transition == "busy"
|
||||||
|
else "document.visibilityState = 'visible'; document.dispatchEvent({ type: 'visibilitychange' });"
|
||||||
|
)
|
||||||
|
out = _run_scheduler_scenario(f"""
|
||||||
|
let taskCalls = 0;
|
||||||
|
let firstSignal = null;
|
||||||
|
let finishFirst;
|
||||||
|
const firstAttempt = new Promise(resolve => {{ finishFirst = resolve; }});
|
||||||
|
const pending = _scheduleEmailPrewarm(async ({{ signal }}) => {{
|
||||||
|
taskCalls += 1;
|
||||||
|
if (taskCalls === 1) {{ firstSignal = signal; return firstAttempt; }}
|
||||||
|
return true;
|
||||||
|
}});
|
||||||
|
await fireNextIdle(7);
|
||||||
|
{block}
|
||||||
|
const aborted = firstSignal.aborted;
|
||||||
|
{unblock}
|
||||||
|
const callsBeforeLateResult = taskCalls;
|
||||||
|
finishFirst(true);
|
||||||
|
await flushMicrotasks();
|
||||||
|
const stillPendingAfterLateResult = _libPrewarmPromise === pending;
|
||||||
|
await advanceTo(now + 500);
|
||||||
|
await fireNextIdle(7);
|
||||||
|
const result = await pending;
|
||||||
|
console.log(JSON.stringify({{
|
||||||
|
result, aborted, callsBeforeLateResult, taskCalls,
|
||||||
|
stillPendingAfterLateResult,
|
||||||
|
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
||||||
|
chatListeners: window.listenerCount('odysseus:chat-busy-change'),
|
||||||
|
visibilityListeners: document.listenerCount('visibilitychange'),
|
||||||
|
}}));
|
||||||
|
""")
|
||||||
|
assert out == {
|
||||||
|
"result": True,
|
||||||
|
"aborted": True,
|
||||||
|
"callsBeforeLateResult": 1,
|
||||||
|
"taskCalls": 2,
|
||||||
|
"stillPendingAfterLateResult": True,
|
||||||
|
"timers": 0,
|
||||||
|
"idleCallbacks": 0,
|
||||||
|
"chatListeners": 0,
|
||||||
|
"visibilityListeners": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_skips_hidden_and_foreground_work():
|
||||||
|
guard = _function_source("_canRunEmailPrewarm")
|
||||||
|
|
||||||
|
assert "state._libOpen" in guard
|
||||||
|
assert "state._libLoading" in guard
|
||||||
|
assert "_libSearchInFlight" in guard
|
||||||
|
assert "document.visibilityState !== 'visible'" in guard
|
||||||
|
assert "!_isChatInteractionBusy()" in guard
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_selects_only_last_used_or_default_account():
|
||||||
|
chooser = _function_source("_chooseEmailPrewarmAccountId")
|
||||||
|
prewarm = _function_source("_prewarmEmailViews")
|
||||||
|
|
||||||
|
assert "_rememberedEmailAccountId()" in chooser
|
||||||
|
assert "a.enabled !== false" in chooser
|
||||||
|
assert "a.is_default" in chooser
|
||||||
|
assert "enabled[0]" in chooser
|
||||||
|
|
||||||
|
assert "for (" not in prewarm
|
||||||
|
assert "orderedAccountIds" not in prewarm
|
||||||
|
assert "slice(0, 4)" not in prewarm
|
||||||
|
assert "/api/email/folders" not in prewarm
|
||||||
|
assert "/api/email/unread-state" not in prewarm
|
||||||
|
assert prewarm.count("/api/email/list") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_account_chooser_rejects_disabled_or_empty_authoritative_inventory():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node not on PATH")
|
||||||
|
chooser = _function_source("_chooseEmailPrewarmAccountId")
|
||||||
|
script = f"""
|
||||||
|
const state = {{ _libAccountId: 'disabled-current' }};
|
||||||
|
function _rememberedEmailAccountId() {{ return 'disabled-remembered'; }}
|
||||||
|
{chooser}
|
||||||
|
const onlyDisabled = _chooseEmailPrewarmAccountId([
|
||||||
|
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
|
||||||
|
{{ id: 'disabled-current', enabled: false }},
|
||||||
|
]);
|
||||||
|
const empty = _chooseEmailPrewarmAccountId([]);
|
||||||
|
const mixed = _chooseEmailPrewarmAccountId([
|
||||||
|
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
|
||||||
|
{{ id: 'enabled-default', enabled: true, is_default: true }},
|
||||||
|
]);
|
||||||
|
console.log(JSON.stringify({{ onlyDisabled, empty, mixed }}));
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[node, "--input-type=module"],
|
||||||
|
input=script,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
assert json.loads(proc.stdout.strip()) == {
|
||||||
|
"onlyDisabled": "",
|
||||||
|
"empty": "",
|
||||||
|
"mixed": "enabled-default",
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_accounts = _function_source("_ensureEmailAccountsForPrewarm")
|
||||||
|
assert "if (!accountId) return null;" in ensure_accounts
|
||||||
|
assert ensure_accounts.index("if (!accountId) return null;") < ensure_accounts.index("_publishActiveAccount();")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prewarm_is_bounded_to_the_interactive_initial_page_size():
|
||||||
|
text = _source()
|
||||||
|
prewarm = _function_source("_prewarmEmailViews")
|
||||||
|
|
||||||
|
assert "const _LIB_INITIAL_PAGE_SIZE = 100;" in text
|
||||||
|
assert "limit: _LIB_INITIAL_PAGE_SIZE" in prewarm
|
||||||
|
assert text.count("limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}") == 2
|
||||||
|
assert "limit: 100" not in prewarm
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_cancels_scheduled_or_inflight_prewarm_first():
|
||||||
|
text = _source()
|
||||||
|
cancel = _function_source("_cancelEmailPrewarm")
|
||||||
|
open_library = _function_source("openEmailLibrary")
|
||||||
|
|
||||||
|
assert "clearTimeout(_libPrewarmDelayTimer)" in cancel
|
||||||
|
assert "window.cancelIdleCallback(_libPrewarmIdleHandle)" in cancel
|
||||||
|
assert "_libPrewarmAbortController?.abort()" in cancel
|
||||||
|
assert "_libPrewarmGeneration += 1" in cancel
|
||||||
|
assert open_library.index("_cancelEmailPrewarm();") < open_library.index("state._libOpen = true;")
|
||||||
|
assert "_loadEmailsWhenChatIdle" not in text
|
||||||
|
assert text.count("_loadEmails({ useCache: true });") >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_cancels_pending_prewarm_cleanup():
|
||||||
|
close_library = _function_source("closeEmailLibrary")
|
||||||
|
|
||||||
|
assert close_library.index("_cancelEmailPrewarm();") < close_library.index("state._libOpen = false;")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unread_warm_joins_the_same_idle_single_flight_gate():
|
||||||
|
unread_entry = _function_source("prewarmUnreadEmails")
|
||||||
|
unread_work = _function_source("_prewarmUnreadEmailsNow")
|
||||||
|
|
||||||
|
assert "_scheduleEmailPrewarm(" in unread_entry
|
||||||
|
assert "fetch(" not in unread_entry
|
||||||
|
assert "_ensureEmailAccountsForPrewarm({ signal, generation })" in unread_work
|
||||||
|
assert "signal" in unread_work
|
||||||
|
assert "Math.min(20" in unread_work
|
||||||
Reference in New Issue
Block a user