mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-11 16:08: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.
|
||||
const _libListCache = new Map();
|
||||
const _LIB_CACHE_MAX = 24;
|
||||
const _LIB_INITIAL_PAGE_SIZE = 100;
|
||||
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
|
||||
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
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 _libPrewarmResolve = null;
|
||||
let _libPrewarmAbortController = null;
|
||||
let _libPrewarmDetachPriorityListeners = null;
|
||||
let _libPrewarmGeneration = 0;
|
||||
let _libLastPrewarmAt = 0;
|
||||
let _libUnreadPrewarmKey = '';
|
||||
let _libUnreadPrewarmAt = 0;
|
||||
@@ -2108,162 +2115,319 @@ function _isChatInteractionBusy() {
|
||||
}
|
||||
}
|
||||
|
||||
function _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) {
|
||||
const run = () => {
|
||||
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
|
||||
if (_isChatInteractionBusy() && retries > 0) {
|
||||
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
|
||||
function _canRunEmailPrewarm() {
|
||||
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
|
||||
if (document.visibilityState && document.visibilityState !== 'visible') return false;
|
||||
return !_isChatInteractionBusy();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
_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 } = {}) {
|
||||
if (_libPrewarmTimer || _libPrewarmPromise) return;
|
||||
if (_libPrewarmPromise) return _libPrewarmPromise;
|
||||
const elapsed = Date.now() - _libLastPrewarmAt;
|
||||
if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return;
|
||||
_libPrewarmTimer = setTimeout(() => {
|
||||
_libPrewarmTimer = null;
|
||||
_libPrewarmPromise = _prewarmEmailViews()
|
||||
.catch(() => {})
|
||||
.finally(() => { _libPrewarmPromise = null; });
|
||||
}, Math.max(0, Number(delay) || 0));
|
||||
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
|
||||
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
|
||||
}
|
||||
|
||||
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;
|
||||
if (Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh) {
|
||||
if (!state._libAccountId) {
|
||||
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
||||
state._libAccountId = def?.id || null;
|
||||
_publishActiveAccount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||
if (!accountsRes.ok) return;
|
||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||
if (Array.isArray(accountsData.accounts)) {
|
||||
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();
|
||||
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
|
||||
try {
|
||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||
if (Array.isArray(accountsData.accounts)) {
|
||||
state._libAccounts = accountsData.accounts;
|
||||
_libAccountsLoadedAt = Date.now();
|
||||
}
|
||||
}
|
||||
} 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 } = {}) {
|
||||
if (state._libOpen) return;
|
||||
await _ensureEmailAccountsForPrewarm();
|
||||
if (state._libOpen) return;
|
||||
const accountId = state._libAccountId || '';
|
||||
export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
||||
return _scheduleEmailPrewarm(
|
||||
context => _prewarmUnreadEmailsNow({ limit, maxUid }, context),
|
||||
{ delay: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
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 key = `${accountId}|${maxUid || 0}|${n}`;
|
||||
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return;
|
||||
_libUnreadPrewarmKey = key;
|
||||
_libUnreadPrewarmAt = Date.now();
|
||||
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
|
||||
try {
|
||||
const folder = 'INBOX';
|
||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||
folder,
|
||||
limit: n,
|
||||
offset: 0,
|
||||
filter: 'unread',
|
||||
account_id: accountId || undefined,
|
||||
}), { credentials: 'same-origin' });
|
||||
if (state._libOpen) return;
|
||||
if (!res.ok) return;
|
||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||
folder,
|
||||
limit: n,
|
||||
offset: 0,
|
||||
filter: 'unread',
|
||||
account_id: accountId || undefined,
|
||||
}), {
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
||||
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 || {};
|
||||
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
|
||||
emails: data.emails,
|
||||
total: data.total || data.emails.length,
|
||||
sync,
|
||||
});
|
||||
} catch (_) {}
|
||||
_libUnreadPrewarmKey = key;
|
||||
_libUnreadPrewarmAt = Date.now();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function _prewarmEmailViews() {
|
||||
if (state._libOpen) return;
|
||||
_libLastPrewarmAt = Date.now();
|
||||
async function _prewarmEmailViews({ signal, generation } = {}) {
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
_setEmailSyncStatus({ warming: true });
|
||||
const folder = 'INBOX';
|
||||
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 {
|
||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||
if (Array.isArray(accountsData.accounts)) {
|
||||
state._libAccounts = accountsData.accounts;
|
||||
_libAccountsLoadedAt = Date.now();
|
||||
}
|
||||
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
|
||||
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
const ck = _libCacheKeyFor(accountId, folder, filter, false);
|
||||
if (_libCacheGet(ck)) {
|
||||
_libLastPrewarmAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : [];
|
||||
const preferred = state._libAccountId
|
||||
|| (accounts.find(a => a.is_default)?.id)
|
||||
|| (accounts[0]?.id)
|
||||
|| '';
|
||||
if (!state._libAccountId && preferred) {
|
||||
state._libAccountId = preferred;
|
||||
_publishActiveAccount();
|
||||
}
|
||||
const orderedAccountIds = [
|
||||
preferred,
|
||||
...accounts.map(a => a.id).filter(id => id && id !== preferred),
|
||||
].filter((id, idx, arr) => arr.indexOf(id) === idx);
|
||||
if (!orderedAccountIds.length) orderedAccountIds.push('');
|
||||
|
||||
try {
|
||||
for (const accountId of orderedAccountIds.slice(0, 4)) {
|
||||
if (state._libOpen) return;
|
||||
const ck = _libCacheKeyFor(accountId, folder, filter, false);
|
||||
if (_libCacheGet(ck)) continue;
|
||||
await fetch(emailApiUrl('/api/email/folders', { account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
|
||||
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', {
|
||||
folder,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
filter,
|
||||
account_id: accountId || undefined,
|
||||
}), {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
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);
|
||||
}
|
||||
// One optional first-page request only. Folder metadata, unread state, and
|
||||
// other accounts remain demand-driven so startup cannot fan out into IMAP.
|
||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||
folder,
|
||||
limit: _LIB_INITIAL_PAGE_SIZE,
|
||||
offset: 0,
|
||||
filter,
|
||||
account_id: accountId || undefined,
|
||||
}), {
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
if (!data || data.error || !Array.isArray(data.emails)) return false;
|
||||
const sync = data.sync || {};
|
||||
_libCachePut(ck, {
|
||||
emails: data.emails,
|
||||
total: data.total || 0,
|
||||
sync,
|
||||
});
|
||||
_libLastPrewarmAt = Date.now();
|
||||
_setEmailSyncStatus({
|
||||
updatedAt: sync.updated_at || new Date().toISOString(),
|
||||
source: sync.source || '',
|
||||
warming: true,
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
_setEmailSyncStatus({ warming: false });
|
||||
}
|
||||
@@ -2342,10 +2506,10 @@ export function initEmailLibrary(config) {
|
||||
export function isOpen() { return state._libOpen; }
|
||||
|
||||
export function openEmailLibrary(opts = {}) {
|
||||
if (_libPrewarmTimer) {
|
||||
clearTimeout(_libPrewarmTimer);
|
||||
_libPrewarmTimer = null;
|
||||
}
|
||||
// Foreground email always wins: cancel a delayed/idle callback and abort the
|
||||
// one optional request if it has already started. Generation checks make a
|
||||
// non-abortable response harmless if it races this transition.
|
||||
_cancelEmailPrewarm();
|
||||
// Force-clean any stale state from previous attempts
|
||||
const existing = document.getElementById('email-lib-modal');
|
||||
if (existing) existing.remove();
|
||||
@@ -2977,7 +3141,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
}
|
||||
const fastAccountAtOpen = state._libAccountId || '';
|
||||
if (fastAccountAtOpen) {
|
||||
_loadEmailsWhenChatIdle({ delay: 0 });
|
||||
_loadEmails({ useCache: true });
|
||||
}
|
||||
// If we already know the previous/default account, paint that inbox first
|
||||
// from the durable index and validate accounts in parallel. Cold refreshes
|
||||
@@ -2987,7 +3151,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
_loadFolders();
|
||||
_loadEmailReminderBellVisibility();
|
||||
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
||||
_loadEmailsWhenChatIdle();
|
||||
_loadEmails({ useCache: true });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -3172,6 +3336,7 @@ export async function openEmailLibrarySettings() {
|
||||
}
|
||||
|
||||
export function closeEmailLibrary() {
|
||||
_cancelEmailPrewarm();
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
if (modal) modal.remove();
|
||||
if (_libSyncTicker) {
|
||||
@@ -4605,7 +4770,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 450);
|
||||
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,
|
||||
});
|
||||
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
|
||||
// Refresh button passes `force: true` to add it back.
|
||||
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();
|
||||
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
|
||||
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