diff --git a/frontend/app-bootstrap.js b/frontend/app-bootstrap.js index 398ca77..09b95e4 100644 --- a/frontend/app-bootstrap.js +++ b/frontend/app-bootstrap.js @@ -42,7 +42,8 @@ const previousKey=state && root.AssetSyncCore.fallback(manifest,state.previous,'/app.js'); const previous=previousKey && await cache.match(previousKey); const previousHash=state?.previous?.files['/app.js']?.h; - if(root.navigator?.serviceWorker?.controller && previous?.ok && previous.headers.get('X-Asset-Hash')===previousHash) { + const contractsMatch=state?.previous && Object.entries(manifest.groups).every(([name,group])=>state.previous.groups[name]?.contract===group.contract); + if(contractsMatch && root.navigator?.serviceWorker?.controller && previous?.ok && previous.headers.get('X-Asset-Hash')===previousHash) { await verified(previous,previousHash); delete root.Lazy.appResponse; return external(previousKey); diff --git a/frontend/app-bootstrap.test.js b/frontend/app-bootstrap.test.js index ea11df6..f7ed8c5 100644 --- a/frontend/app-bootstrap.test.js +++ b/frontend/app-bootstrap.test.js @@ -54,3 +54,10 @@ test('missing current app uses verified same-contract N-1 offline through the co test('an incompatible previous core never executes against the new shell',async()=>{ const f=fixture({previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined); }); + + +test('a feature contract change also prevents stale core execution',async()=>{ + const f=fixture({previous:true});f.root.Lazy.manifest.groups['feature:test']={contract:2,files:[]}; + const state=await f.held.get('/__ytp_asset_state').json();state.previous.groups['feature:test']={contract:1,files:[]};f.held.set('/__ytp_asset_state',Response.json(state)); + await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined); +}); diff --git a/perf/README.md b/perf/README.md index 96b74cb..f2dac35 100644 --- a/perf/README.md +++ b/perf/README.md @@ -350,3 +350,7 @@ then use the page FCP to locate the rendering dependency. Pass `--assert-cold` on new builds to require exactly one positive-byte app.js response in every cold sample, including worker installation. Omit this assertion when diagnosing historical regressions. + +Phase 6 counts its fetch-owned app.js response in initial JS/CSS bytes. Since the +bootstrap consumes that response asynchronously, DOMContentLoaded no longer means +app execution is complete; compare the existing actual boot-done landmark. diff --git a/perf/baseline.mjs b/perf/baseline.mjs index b6321db..f1365e6 100755 --- a/perf/baseline.mjs +++ b/perf/baseline.mjs @@ -377,8 +377,8 @@ async function runColdScenario({ browserType, server, proxy, profile }) { const fcp = paint.find((p) => p.name === 'first-contentful-paint'); return { - initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0), - initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})), + initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0), + initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})), fcp: fcp ? Math.round(fcp.startTime) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, @@ -486,8 +486,8 @@ async function runWarmScenario({ browserType, server, proxy, profile }) { const fcp = paint.find((p) => p.name === 'first-contentful-paint'); return { - initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0), - initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})), + initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0), + initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})), fcp: fcp ? Math.round(fcp.startTime) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, @@ -567,8 +567,8 @@ async function runOfflineScenario({ browserType, server, proxy, profile }) { const fcp = paint.find((p) => p.name === 'first-contentful-paint'); return { - initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0), - initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})), + initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0), + initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})), fcp: fcp ? Math.round(fcp.startTime) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, diff --git a/perf/lazy.mjs b/perf/lazy.mjs index b1afee7..77a21de 100644 --- a/perf/lazy.mjs +++ b/perf/lazy.mjs @@ -84,6 +84,23 @@ async function run(name){ await page.evaluate(async()=>{await Lazy.load('feature:export');await Lazy.load('feature:video-edit');}); assert.equal(proxy.getStats().requestCount,before,'all warmed extension assets stay offline'); await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline')); + // Change only app bytes, then prove loss of its current cache entry can boot + // the compatible N-1 core offline without weakening the inline CSP policy. + await stop();writeFileSync(join(cwd,'public/app.js'),readFileSync(join(cwd,'public/app.js'),'utf8')+'\n// phase6 app eviction probe\n');await start(); + const appTarget=await(await fetch(origin+'/api/manifest')).json(); + await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();await r.update();}); + await wait(page,async tag=>{const r=await navigator.serviceWorker.getRegistration();return r?.waiting&&(await SwUpdate.askCacheStatus(r.waiting))?.version===tag;},appTarget.buildTag); + await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});}); + await wait(page,async tag=>{const c=await caches.open('ytplayer-assets');return(await(await c.match('/__ytp_asset_state')).json()).current.buildTag===tag;},appTarget.buildTag); + const appKey='/app.js?v='+appTarget.files['/app.js'].h; + await page.evaluate(async key=>{await(await caches.open('ytplayer-assets')).delete(key);},appKey); + await stop();disconnected=true;await proxy.setOffline(true); + const probe=await context.newPage();probe.on('pageerror',e=>errors.push({message:e.message,disconnected})); + const offlineBefore=proxy.getStats().requestCount;await probe.goto(origin);await probe.waitForFunction(()=>typeof data!=='undefined'&&typeof Player!=='undefined'&&document.querySelector('#carModeBtn')); + assert.equal(await probe.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),appKey),false,'N-1 never poisons the current app key'); + assert.equal(proxy.getStats().requestCount,offlineBefore,'N-1 app boot is entirely offline'); + await probe.close();await start();disconnected=false;await proxy.setOffline(false); + await page.evaluate(async key=>{const r=await fetch(key+'&__ytpfresh=1');if(r.headers.get('X-Asset-Hash')!==key.split('v=')[1])throw Error('Wrong repair body');await(await caches.open('ytplayer-assets')).put(key,r);},appKey); // Keep an old tab whose core and group contracts remain those of build N. const old=await context.newPage();old.on('pageerror',e=>errors.push({message:e.message,disconnected}));await old.goto(origin);await old.waitForSelector('#carModeBtn'); await old.evaluate(async()=>{window.__saveData=true;await Lazy.load('feature:setlist-import');window.__pinned=window.SetlistImport;}); @@ -128,7 +145,7 @@ async function run(name){ const offlineTransportErrors=errors.filter(error=>name==='webkit'&&error.disconnected&&(error.message==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message))); assert.deepEqual(errors.filter(error=>!offlineTransportErrors.includes(error)),[]); if(offlineTransportErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url))); - results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors}); + results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,appEvictionN1Offline:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors}); console.log(name,'PASS'); } finally {await browser?.close();await proxy?.close();await stop();} } diff --git a/perf/results/phase6-lazy-2026-10-08.json b/perf/results/phase6-lazy-2026-10-08.json new file mode 100644 index 0000000..7901450 --- /dev/null +++ b/perf/results/phase6-lazy-2026-10-08.json @@ -0,0 +1,97 @@ +{ + "results": [ + { + "browser": "chromium", + "sourceBuildTag": "41a7ba2c29a3", + "classicSharedDefaults": true, + "cssOrderPreserved": true, + "settingsSearch": true, + "carControls": 4, + "offlineFeature": true, + "firstUseNetwork": 0, + "layoutsOffline": [ + "glass-stage", + "bento-hub", + "f7-swipe", + "classic" + ], + "pinned": true, + "compatibleUnexecutedNewURL": true, + "incompatibleN1ReloadRequired": true, + "stagedN1": true, + "appEvictionN1Offline": true, + "playbackGuard": true, + "prePaintLayout": true, + "offlineSettingsPanels": true, + "scriptErrors": [], + "offlineTransportErrors": [] + }, + { + "browser": "webkit", + "sourceBuildTag": "41a7ba2c29a3", + "classicSharedDefaults": true, + "cssOrderPreserved": true, + "settingsSearch": true, + "carControls": 4, + "offlineFeature": true, + "firstUseNetwork": 0, + "layoutsOffline": [ + "glass-stage", + "bento-hub", + "f7-swipe", + "classic" + ], + "pinned": true, + "compatibleUnexecutedNewURL": true, + "incompatibleN1ReloadRequired": true, + "stagedN1": true, + "appEvictionN1Offline": true, + "playbackGuard": true, + "prePaintLayout": true, + "offlineSettingsPanels": true, + "scriptErrors": [], + "offlineTransportErrors": [ + { + "message": "TypeError: Load failed", + "disconnected": true + }, + { + "message": "/127.0.0.1:36117/api/version.", + "disconnected": true + }, + { + "message": "TypeError: Load failed", + "disconnected": true + }, + { + "message": "/127.0.0.1:36117/api/recommendations?fp=163577c02a53bc40.", + "disconnected": true + }, + { + "message": "TypeError: Load failed", + "disconnected": true + }, + { + "message": "/127.0.0.1:36117/api/version.", + "disconnected": true + }, + { + "message": "TypeError: Load failed", + "disconnected": true + }, + { + "message": "/127.0.0.1:36117/api/recommendations?fp=163577c02a53bc40.", + "disconnected": true + }, + { + "message": "TypeError: Load failed", + "disconnected": true + }, + { + "message": "/127.0.0.1:36117/api/version.", + "disconnected": true + } + ] + } + ] +} diff --git a/plans/phase6-investigation.md b/plans/phase6-investigation.md index ad076c1..c8e7a3e 100644 --- a/plans/phase6-investigation.md +++ b/plans/phase6-investigation.md @@ -76,3 +76,12 @@ all samples. Full-harness configurations now reset to the base tree before each browser/profile, preventing a preceding synthetic update from contaminating the next cold build. The historical invalid media fixture remains unchanged: home launch only, not autoplay/audio latency. + + +Self-review added explicit N-1 app boot after eviction: verify the retained body +and use its exact classic URL through the controlling worker. It never fills the +new key with old bytes. Stale core execution additionally requires every group +contract to match the running shell's expectations; a core or feature contract +change rejects it. Both-engine lazy smoke now changes app bytes, evicts the new +entry, boots N-1 offline, then repairs the current entry before the existing +feature pinning/contract/playback checks. No source in app.js changed.