From 96aca5209485a124d63ccb3798f10ce7967b604c Mon Sep 17 00:00:00 2001 From: RaresKeY <158580472+RaresKeY@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:14:10 +0100 Subject: [PATCH] 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 --- static/js/emailLibrary.js | 429 +++++++++++++++++++--------- tests/test_email_library_prewarm.py | 414 +++++++++++++++++++++++++++ 2 files changed, 711 insertions(+), 132 deletions(-) create mode 100644 tests/test_email_library_prewarm.py diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 9425a0181..c56109196 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -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); diff --git a/tests/test_email_library_prewarm.py b/tests/test_email_library_prewarm.py new file mode 100644 index 000000000..42dee3bc6 --- /dev/null +++ b/tests/test_email_library_prewarm.py @@ -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