From d02796b0eb224918d8f256ce84a50436c8089628 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 21:53:03 +0800 Subject: [PATCH] Retry transient foreground requests before surfacing errors --- frontend/app.js | 18 +++--- frontend/asset-sync-core.js | 4 +- frontend/asset-sync-core.test.js | 7 ++ frontend/async-guard.js | 63 +++++++++++++++++- frontend/async-guard.test.js | 49 ++++++++++++++ frontend/lazy.js | 26 ++++++-- frontend/lazy.test.js | 12 ++-- frontend/sw.js | 7 +- perf/README.md | 14 ++++ perf/retry.mjs | 108 +++++++++++++++++++++++++++++++ 10 files changed, 282 insertions(+), 26 deletions(-) create mode 100755 perf/retry.mjs diff --git a/frontend/app.js b/frontend/app.js index e05ee6b..eb534a0 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -60,12 +60,14 @@ async function call(zeroName, tauriName, payload = {}) { // Generic fetch wrapper — returns parsed JSON or throws with a human message. async function webFetch(path, opts = {}) { + if ((opts.method || 'GET').toUpperCase() === 'GET' && window.AsyncGuard?.getJson) + return AsyncGuard.getJson(path, opts, { + // Some upstream failures are represented by HTTP 200 + {ok:false}; do + // not expose those transient responses as a manual Retry state either. + retryResult: value => value?.ok === false && /\b(?:408|425|429|5\d\d)\b|temporar|timed?\s*out|network|connection|upstream|unavailable|available right now/i.test(String(value.error || value.message || '')), + }); const res = await fetch(path, opts); - if (!res.ok) { - let msg = `HTTP ${res.status}`; - try { const j = await res.json(); msg = j.error || msg; } catch { /* non-JSON */ } - throw new Error(msg); - } + if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } @@ -573,7 +575,7 @@ const API = { ? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`) : call('yt.search', 'yt_search', { query }), related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id) - ? fetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`).then(response => response.json()) + ? webFetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`) : API.search(meta.title || meta.channel, { refresh }), // Videos the server already knows — answers fast while the real search loads. searchLocal: (query) => WEB @@ -8524,9 +8526,7 @@ const RUNNING_BUILD = (() => { let _knownBuildTag = null; async function fetchServerBuild() { - const res = await fetch('/api/version', { cache: 'no-store' }); - if (!res.ok) return null; - const data = await res.json(); + const data = await webFetch('/api/version', { cache: 'no-store' }); return data.buildTag || null; } diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js index 0c6ede2..0aaee25 100644 --- a/frontend/asset-sync-core.js +++ b/frontend/asset-sync-core.js @@ -148,7 +148,7 @@ const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag; async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000, - maxDelay = 30000, notify = () => {}, force = false } = {}) { + maxDelay = 30000, notify = () => {}, force = false, concurrency = 1 } = {}) { const key = completionKey(manifest), saved = await cache.match(key); let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 }; let snapshot = await completeness(manifest, cache); @@ -162,7 +162,7 @@ for (let cycle = 0; cycle < cycles; cycle++) { await publish(true); let error; - try { await syncAssets(manifest, { cache, fetchFn, all: true }); } + try { await syncAssets(manifest, { cache, fetchFn, all: true, concurrency }); } catch (failure) { error = failure; } snapshot = await completeness(manifest, cache); if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; } diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js index 60b366d..7953b1f 100644 --- a/frontend/asset-sync-core.test.js +++ b/frontend/asset-sync-core.test.js @@ -82,6 +82,13 @@ test('overlapping layout and completion jobs share a six-download ceiling',async assert.ok(peak<=6);assert.ok([...calls.values()].every(count=>count===1)); }); +test('offline completion keeps its own network concurrency to one',async()=>{ + const c=cache(),m=manifest();let active=0,peak=0; + for(let i=0;i<12;i++){const p='/complete'+i+'.js';m.files[p]={h:String(i)};} + const fetchFn=async u=>{active++;peak=Math.max(peak,active);await new Promise(r=>setTimeout(r,2));active--;return response(u.split('=')[1]);}; + await core.complete(m,{cache:c,fetchFn});assert.equal(peak,1); +}); + test('blocking commit never waits on an optional file readiness probe',{timeout:1000},async()=>{ const c=cache(),m=manifest();m.files['/extra.js']={h:'extra'};m.groups.extra={files:['/extra.js'],background:true,contract:1}; await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b')); diff --git a/frontend/async-guard.js b/frontend/async-guard.js index 333b226..56b882d 100755 --- a/frontend/async-guard.js +++ b/frontend/async-guard.js @@ -35,7 +35,68 @@ } } - const AsyncGuard = { runExclusive }; + const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + async function retry(task, { attempts = 3, delay = attempt => attempt === 1 ? 350 : 1000, + shouldRetry = () => true, sleep = wait } = {}) { + let last; + for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) { + try { return await task(attempt); } + catch (error) { + last = error; + if (attempt >= attempts || !shouldRetry(error, attempt)) throw error; + await sleep(delay(attempt)); + } + } + throw last; + } + + // Retry only idempotent GETs. Keep the timeout active through JSON parsing so + // a stalled response body is treated like a stalled connection. + async function getJson(url, options = {}, config = {}) { + const fetchFn = config.fetch || root.fetch?.bind(root); + if (!fetchFn) throw new Error('fetch is unavailable'); + if ((options.method || 'GET').toUpperCase() !== 'GET') throw new Error('Retry helper only accepts GET'); + const attempts = config.attempts ?? 3, timeoutMs = config.timeoutMs ?? 20000; + return retry(async () => { + const controller = typeof AbortController === 'function' ? new AbortController() : null; + let timedOut = false, timer, externalAbort; + const external = options.signal; + if (controller && external) { + if (external.aborted) throw external.reason || new DOMException('Aborted', 'AbortError'); + externalAbort = () => controller.abort(external.reason); + external.addEventListener('abort', externalAbort, { once: true }); + } + if (controller && timeoutMs > 0) timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); + try { + const response = await fetchFn(url, { ...options, ...(controller ? { signal: controller.signal } : {}) }); + if ([408, 425, 429].includes(response.status) || response.status >= 500) { + const error = new Error(`HTTP ${response.status}`); error.retryable = true; throw error; + } + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { message = (await response.json()).error || message; } catch {} + const error = new Error(message); error.retryable = false; throw error; + } + const value = await response.json(); + if (config.retryResult?.(value)) { + const error = new Error(value.error || value.message || 'Temporary API failure'); + error.retryable = true; + throw error; + } + return value; + } catch (error) { + if (external?.aborted) throw error; + if (timedOut) { const timeout = new Error('Request timed out'); timeout.retryable = true; throw timeout; } + if (error.retryable === undefined) error.retryable = true; + throw error; + } finally { + if (timer) clearTimeout(timer); + if (external && externalAbort) external.removeEventListener('abort', externalAbort); + } + }, { attempts, shouldRetry: error => error.retryable !== false, sleep: config.sleep || wait }); + } + + const AsyncGuard = { runExclusive, retry, getJson }; if (typeof module !== 'undefined' && module.exports) { module.exports = AsyncGuard; diff --git a/frontend/async-guard.test.js b/frontend/async-guard.test.js index 34fb70c..98b48a8 100755 --- a/frontend/async-guard.test.js +++ b/frontend/async-guard.test.js @@ -60,6 +60,55 @@ test('frees the key after failure so a retry is possible', async () => { assert.strictEqual(out, 'ok'); }); +test('bounded GET retries recover transient network and server failures', async () => { + const { getJson } = require('./async-guard'); + let calls = 0; + const result = await getJson('/api/search', {}, { sleep: async () => {}, fetch: async () => { + calls++; + if (calls === 1) throw new TypeError('connection reset'); + if (calls === 2) return new Response('{}', { status: 503 }); + return Response.json({ ok: true, results: [] }); + } }); + assert.equal(result.ok, true); + assert.equal(calls, 3); +}); + +test('GET retry stops at the bound and does not retry permanent HTTP errors', async () => { + const { getJson } = require('./async-guard'); + let calls = 0; + await assert.rejects(getJson('/api/search', {}, { sleep: async () => {}, fetch: async () => { + calls++; return new Response('{}', { status: 404 }); + } }), /HTTP 404/); + assert.equal(calls, 1); + await assert.rejects(getJson('/api/search', {}, { attempts: 2, sleep: async () => {}, fetch: async () => { + calls++; throw new TypeError('offline'); + } }), /offline/); + assert.equal(calls, 3); +}); + +test('retryable API error payloads recover but permanent application errors do not retry', async () => { + const { getJson } = require('./async-guard'); let calls = 0; + const retryResult = value => value?.ok === false && /temporarily unavailable/i.test(value.error || ''); + const result = await getJson('/api/streams', {}, { retryResult, sleep: async () => {}, fetch: async () => { + calls++; + return Response.json(calls === 1 ? { ok: false, error: 'temporarily unavailable' } : { ok: true, data: [] }); + } }); + assert.equal(result.ok, true); assert.equal(calls, 2); + calls = 0; + const absent = await getJson('/api/streams', {}, { retryResult, sleep: async () => {}, fetch: async () => { calls++; return Response.json({ ok: false, error: 'video not found' }); } }); + assert.equal(absent.ok, false); assert.equal(calls, 1); +}); + +test('timed out GET bodies retry within the configured bound', async () => { + const { getJson } = require('./async-guard'); let calls = 0; + const result = await getJson('/api/related', {}, { attempts: 2, timeoutMs: 5, sleep: async () => {}, fetch: async (_url, { signal }) => { + calls++; + if (calls === 1) return new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true })); + return Response.json({ ok: true }); + } }); + assert.equal(result.ok, true); assert.equal(calls, 2); +}); + test('supports a synchronous fn', async () => { const set = new Set(); const out = await runExclusive(set, 's', () => 7); diff --git a/frontend/lazy.js b/frontend/lazy.js index 81db965..2ee4692 100644 --- a/frontend/lazy.js +++ b/frontend/lazy.js @@ -6,6 +6,7 @@ try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); } catch { return; } // The index must supply this map; never fetch one during boot. const assets = new Map(), groups = new Map(), hooks = new Map(); + let warmScheduled = false; const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss)); const selected = new Map(); const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : './' + path.replace(/^\//,'')); @@ -50,14 +51,18 @@ const style = /\.css$/.test(path); if (style && css.has(path)) return Promise.resolve(); if (!style && !/\.js$/.test(path)) return Promise.resolve(); // workers/imports/fonts are not page scripts - const task = new Promise((resolve, reject) => { + const loadOnce = () => new Promise((resolve, reject) => { const node = doc.createElement(style ? 'link' : 'script'); + const timer = setTimeout(() => { node.remove?.(); reject(new Error('Timed out loading ' + path)); }, 20000); if (style) { node.rel = 'stylesheet'; node.href = url(path); } else { node.async = false; node.src = url(path); } - node.onload = resolve; - node.onerror = () => reject(new Error('Unable to load ' + path)); + node.onload = () => { clearTimeout(timer); resolve(); }; + node.onerror = () => { clearTimeout(timer); node.remove?.(); reject(new Error('Unable to load ' + path)); }; doc.head.append(node); }); + const task = root.AsyncGuard?.retry + ? root.AsyncGuard.retry(loadOnce, { attempts: 3 }) + : loadOnce(); assets.set(path, task); task.catch(() => assets.delete(path)); return task; @@ -100,10 +105,17 @@ else root.navigator.serviceWorker?.getRegistration().then(send).catch(()=>{}); }, warm() { - if (!root.navigator.serviceWorker || root.navigator.onLine === false) return; - root.navigator.serviceWorker.ready.then(reg => { - for (const worker of new Set([reg.active, reg.waiting])) worker?.postMessage({type:'COMPLETE_ASSETS'}); - }).catch(()=>{}); + if (warmScheduled || !root.navigator.serviceWorker || root.navigator.onLine === false) return; + warmScheduled = true; + const start = () => { + warmScheduled = false; + if(root.navigator.onLine === false)return; + root.navigator.serviceWorker.ready.then(reg => { + for (const worker of new Set([reg.active, reg.waiting])) worker?.postMessage({type:'COMPLETE_ASSETS'}); + }).catch(()=>{}); + }; + if (root.requestIdleCallback) root.requestIdleCallback(start, { timeout: 8000 }); + else setTimeout(start, 5000); }, onLoad(name, fn) { if(api.loaded(name)) { Promise.resolve().then(fn).catch(error=>{if(root.CustomEvent)root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error',{detail:{group:name,error}}));}); return; } diff --git a/frontend/lazy.test.js b/frontend/lazy.test.js index e4c6a20..5cdbaab 100644 --- a/frontend/lazy.test.js +++ b/frontend/lazy.test.js @@ -7,7 +7,7 @@ function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/ const manifest = { buildTag: 'own', appCache, groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/app.js':{h:'a'}, '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } }; const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f }; doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } }; - const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, fetch:async key=>{fetched.push(key);return new Response('app');}, setTimeout, clearTimeout, navigator:{userAgent,serviceWorker:{ready:Promise.resolve({}),controller:controlled?{}:null,addEventListener(){}}}, addEventListener(){} }; + const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, AsyncGuard:require('./async-guard'), requestIdleCallback:fn=>{root.__idle=fn;}, fetch:async key=>{fetched.push(key);return new Response('app');}, setTimeout, clearTimeout, navigator:{userAgent,serviceWorker:{ready:Promise.resolve({}),controller:controlled?{}:null,addEventListener(){}}}, addEventListener(){} }; if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) }; root.window=root; root.globalThis=root; vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root); @@ -27,11 +27,13 @@ test('lazy groups execute ordered classic scripts once, sharing concurrent calls await root.Lazy.load('layout:glass-stage'); assert.equal(inserted.filter(n=>n.tagName==='SCRIPT').length,2); }); -test('failed injection can retry and proxy dispatches only after execution', async () => { +test('transient script injection failure retries before proxy dispatch', async () => { const {root,inserted}=fixture(); const append=root.document.head.append; - root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>n.onerror?.());}; - await assert.rejects(root.Lazy.load('feature:test'),/one.js/); + let first=true; + root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>{if(first){first=false;n.onerror?.();}else n.onload?.();});}; + await root.Lazy.load('feature:test'); + assert.equal(inserted.length,3,'first file is retried, then the second file loads'); root.document.head.append=append; root.TestApi={open:value=>value+1}; const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi'); @@ -92,6 +94,8 @@ test('online launches request worker completion even with Save-Data and skip off f.root.navigator.connection={saveData:true};f.root.navigator.onLine=true; f.root.navigator.serviceWorker={ready:Promise.resolve({active,waiting})}; f.root.Lazy.warm();await Promise.resolve(); + assert.equal(sent.length,0,'warming waits until the page is idle'); + f.root.__idle();await Promise.resolve(); assert.deepEqual(sent.map(m=>m.type),['COMPLETE_ASSETS','COMPLETE_ASSETS']); f.root.navigator.onLine=false;f.root.Lazy.warm();await Promise.resolve();assert.equal(sent.length,2); }); diff --git a/frontend/sw.js b/frontend/sw.js index e6bc0b4..a4b1576 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -264,7 +264,8 @@ self.addEventListener('fetch', (e) => { return; } - if (ASSET_SYNC && request.mode === 'navigate') e.waitUntil(completeAssets()); + // The page schedules completion after first paint/idle via Lazy.warm(). Do + // not start a six-file background burst from this foreground fetch event. // Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified if (request.method !== 'GET' && request.method !== 'HEAD') return; @@ -573,8 +574,8 @@ async function activateAssets() { for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req); await self.clients.claim(); if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION}); - // Completion owns a message-event lifetime, not the activation barrier. - // In particular, a legacy client's four-second reload must not wait for it. + // Resume completion after activation, but with one request at a time so + // foreground navigation retains most of the available bandwidth. self.registration.active.postMessage({ type: 'COMPLETE_ASSETS' }); try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {} } diff --git a/perf/README.md b/perf/README.md index a703240..db02018 100644 --- a/perf/README.md +++ b/perf/README.md @@ -484,3 +484,17 @@ remote toggles against a mocked relay. Transport previews and remote screenshots are saved in ignored `plans/transport-shots/`. Run `node perf/ui-geometry.mjs` for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons remain platform-rendered through the existing MediaSession handlers. + +## Transient retry verification + +`retry.mjs` starts the real Bun server behind the local traffic-shaping proxy, then +injects one HTTP 503 into search, one dropped related-video request, and one lazy +chunk load failure. Each path must recover automatically without exposing the +manual related/player Retry affordance. It runs Chromium and WebKit by default; +choose `--browser chromium|webkit` and `--profile lte|lossy|unthrottled` to narrow +it. Retry logic is limited to idempotent GET requests and chunk loads; explicit +save/retry POST actions remain user initiated. + +```bash +node perf/retry.mjs --browser all --profile lossy +``` diff --git a/perf/retry.mjs b/perf/retry.mjs new file mode 100755 index 0000000..9c194e6 --- /dev/null +++ b/perf/retry.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// Foreground transient-failure regression check over the real Bun server and +// shaped local proxy. Injects one 503/connection drop, then requires recovery +// without exposing the application's manual Retry/error affordances. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { cpSync, mkdirSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { createServer } from 'node:net'; +import { chromium, webkit } from 'playwright'; +import { createThrottleProxy } from './proxy.mjs'; + +const repo = resolve(new URL('..', import.meta.url).pathname); +const scratch = join(repo, 'perf/.tmp/retry'); +const profile = process.argv.includes('--profile') ? process.argv[process.argv.indexOf('--profile') + 1] : 'lossy'; +const engines = process.argv.includes('--browser') ? process.argv[process.argv.indexOf('--browser') + 1] : 'all'; +assert.ok(['all', 'chromium', 'webkit'].includes(engines)); +rmSync(scratch, { recursive: true, force: true }); mkdirSync(scratch, { recursive: true }); +const results = []; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function freePort() { const s = createServer(); await new Promise(r => s.listen(0, '127.0.0.1', r)); const p = s.address().port; await new Promise(r => s.close(r)); return p; } +async function poll(fn, timeout = 30000) { const end = Date.now() + timeout; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; } + +for (const engine of engines === 'all' ? ['chromium', 'webkit'] : [engines]) { + const cwd = join(scratch, engine); mkdirSync(cwd); cpSync(join(repo, 'frontend'), join(cwd, 'public'), { recursive: true }); + const port = await freePort(); let log = ''; + const child = spawn('bun', [join(repo, 'server/server.js')], { cwd, env: { ...process.env, PORT: String(port), ASSET_HASHING: '1', ASSET_SYNC: '1', BUILD_TAG: '', DB_PATH: join(cwd, 'db.sqlite'), DATA_DIR: join(cwd, 'data'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads') }, stdio: ['ignore', 'pipe', 'pipe'] }); + for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => log = (log + chunk).slice(-8000)); + let browser, context, proxy; + const result = { engine, profile, injected: [], recovered: [], visibleErrors: [] }; results.push(result); + try { + if (!await poll(async () => { try { return (await fetch(`http://127.0.0.1:${port}/api/version`)).ok; } catch { return false; } })) throw Error(`server failed: ${log}`); + proxy = createThrottleProxy({ targetPort: port, profile }); proxy.startTracking(); + const origin = `http://127.0.0.1:${await proxy.listen()}`; + browser = await ({ chromium, webkit })[engine].launch({ headless: true }); + context = await browser.newContext({ serviceWorkers: 'block' }); + await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort()); + const page = await context.newPage(); + page.on('console', msg => { if (msg.type() === 'error') result.visibleErrors.push(msg.text()); }); + page.on('pageerror', error => result.visibleErrors.push(error.message)); + + const searchUrl = '**/api/search?q=retry-transport-test*'; let searchCalls = 0; + await context.route(searchUrl, async route => { + searchCalls++; result.injected.push({ path: '/api/search', attempt: searchCalls, fault: searchCalls === 1 ? '503' : null }); + if (searchCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary proxy fault' }) }); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: [{ id: 'abcdefghijk', title: 'Retry fixture result', channel: 'Harness', duration: 60, thumbnail: '' }] }) }); + }); + await page.goto(origin); + await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#searchInput')); + let versionCalls = 0; + await context.route('**/api/version', async route => { + versionCalls++; result.injected.push({ path: '/api/version', attempt: versionCalls, fault: versionCalls === 1 ? '503' : null }); + if (versionCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary version check failure' }) }); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ buildTag: 'retry-harness' }) }); + }); + assert.equal(await page.evaluate(() => fetchServerBuild()), 'retry-harness'); + assert.equal(versionCalls, 2, `${engine}: update version check did not retry`); + result.recovered.push({ path: '/api/version', attempts: versionCalls }); + + await page.locator('#searchInput').fill('retry-transport-test'); await page.locator('#searchForm').evaluate(form => form.requestSubmit()); + await page.locator('.card').filter({ hasText: 'Retry fixture result' }).waitFor({ timeout: 25000 }); + result.recovered.push({ path: '/api/search', attempts: searchCalls }); + assert.equal(searchCalls, 2, `${engine}: search did not retry exactly once`); + assert.equal(await page.locator('.retry-btn,.related-retry').count(), 0, `${engine}: transient search exposed a manual retry`); + + let streamCalls = 0; + await context.route('**/api/streams?v=abcdefghijk*', async route => { + streamCalls++; result.injected.push({ path: '/api/streams', attempt: streamCalls, fault: streamCalls === 1 ? '503' : null }); + if (streamCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary upstream failure' }) }); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, data: { meta: { id: 'abcdefghijk', title: 'Stream fixture' }, qualities: [] } }) }); + }); + const streams = await page.evaluate(() => API.getStreams('abcdefghijk')); + assert.equal(streamCalls, 2, `${engine}: stream lookup did not retry`); + assert.equal(streams.ok, true); + result.recovered.push({ path: '/api/streams', attempts: streamCalls }); + + let relatedCalls = 0; + await context.route('**/api/related?*', async route => { + relatedCalls++; result.injected.push({ path: '/api/related', attempt: relatedCalls, fault: relatedCalls === 1 ? 'connection-reset' : null }); + if (relatedCalls === 1) return route.abort('failed'); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: [{ id: 'lmnopqrstuv', title: 'Related recovered', channel: 'Harness' }] }) }); + }); + const relatedState = await page.evaluate(async () => { + current = { meta: { id: 'abcdefghijk', title: 'Retry fixture result', channel: 'Harness' } }; + await loadRelated(); return relatedState.status; + }); + assert.equal(relatedCalls, 2, `${engine}: related discovery did not retry`); + assert.equal(relatedState, 'ready'); + assert.equal(await page.locator('.related-retry').count(), 0, 'transient failure must not expose manual Retry'); + result.recovered.push({ path: '/api/related', attempts: relatedCalls }); + + let chunkCalls = 0; + await context.route('**/video-edit.js?v=*', async route => { + chunkCalls++; result.injected.push({ path: '/video-edit.js', attempt: chunkCalls, fault: chunkCalls === 1 ? 'connection-reset' : null }); + if (chunkCalls === 1) return route.abort('failed'); + return route.continue(); + }); + await page.evaluate(() => Lazy.load('feature:video-edit')); + assert.equal(chunkCalls, 2, `${engine}: lazy chunk did not retry`); + assert.equal(await page.locator('.retry-btn,.related-retry').count(), 0); + result.recovered.push({ path: '/video-edit.js', attempts: chunkCalls }); + const unexpected = result.visibleErrors.filter(message => !/Failed to load resource|Load failed/.test(message)); + assert.deepEqual(unexpected, [], `${engine}: page errors after transient recovery`); + console.log(JSON.stringify(result)); + } catch (error) { console.error(engine, log); throw error; } + finally { await context?.close(); await browser?.close(); await proxy?.close?.(); child.kill('SIGTERM'); } +} +console.log(`Retry recovery passed (${results.length} engine(s), ${profile}).`);