diff --git a/frontend/app-bootstrap.js b/frontend/app-bootstrap.js index 4359af0..9998a2e 100644 --- a/frontend/app-bootstrap.js +++ b/frontend/app-bootstrap.js @@ -27,12 +27,11 @@ return {bytes,digest}; } async function start() { - if(!manifest.appCache || !root.crypto?.subtle || (!root.Lazy.captureApp&&!root.navigator?.serviceWorker?.controller))return external(key); + if(!manifest.appCache || !root.crypto?.subtle || !root.Lazy.captureApp)return external(key); const expected=manifest.files['/app.js'].h; let cache; try { cache=await root.caches?.open('ytplayer-assets'); } catch {} let response=await cache?.match(key); - if(!root.Lazy.captureApp && response?.ok && response.headers.get('X-Asset-Hash')===expected)return external(key); if(!response || response.headers.get('X-Asset-Hash')!==expected) { try { response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'})); } catch {} } @@ -57,7 +56,6 @@ // CacheStorage may share a full quota with saved music. Never remove data // to make room; boot from these verified bytes even if caching is refused. try { await cache?.put(key,response); } catch(error) { root.console.warn('[app-cache]',error.message); } - if(!root.Lazy.captureApp)return external(key); const node=doc.createElement('script');node.textContent=new root.TextDecoder().decode(bytes); execute(node); } diff --git a/frontend/app-bootstrap.test.js b/frontend/app-bootstrap.test.js index d7b5778..d790f97 100644 --- a/frontend/app-bootstrap.test.js +++ b/frontend/app-bootstrap.test.js @@ -11,7 +11,7 @@ function fixture({cached=false,tampered=false,enabled=true,loading=false,quota=f if(cached)held.set(key,response()); if(previous){held.set(oldKey,new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}));held.set('/__ytp_asset_state',Response.json({previous:{files:{'/app.js':{h:oldHash}},groups:{core:{contract,files:['/app.js']}}}}));} const root={crypto:insecure?undefined:webcrypto,TextDecoder,Uint8Array,btoa,console,AssetSyncCore:require('./asset-sync-core'),navigator:{serviceWorker:{controller:controller?{}:null}},Lazy:{captureApp:capture,manifest:{groups:{core:{contract:1,files:['/app.js']}},appCache:enabled,files:{'/app.js':{h:hash.slice(0,10)}}},url:()=>key},fetch:async()=>{calls++;return previous?new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}):response();},caches:{open:async()=>({match:async k=>held.get(k)?.clone(),put:async(k,r)=>{if(quota)throw Error('quota');held.set(k,r.clone());}})}}; - const doc=root.document={readyState:loading?'loading':'complete',querySelector:()=>({content:"script-src 'self' 'sha256-"+Buffer.from(hash,'hex').toString('base64')+"'"}),addEventListener:(name,fn)=>(listeners[name] ||= []).push(fn),createElement:()=>({remove(){this.removed=true;}}),head:{append(node){appended.push(node);if(node.textContent)vm.runInContext(node.textContent,context);else {vm.runInContext(node.src===oldKey?oldSource:source,context);queueMicrotask(()=>node.onload());}}}}; + const doc=root.document={readyState:loading?'loading':'complete',querySelector:()=>({content:"script-src 'self' 'sha256-"+Buffer.from(hash,'hex').toString('base64')+"'"}),addEventListener:(name,fn)=>(listeners[name] ||= []).push(fn),createElement:()=>({remove(){this.removed=true;}}),head:{append(node){appended.push(node);if(node.textContent)vm.runInContext(node.textContent,context);else {queueMicrotask(async()=>{if(previous && node.src===key){const state=await held.get('/__ytp_asset_state').clone().json();if(!root.AssetSyncCore.fallback(root.Lazy.manifest,state.previous,'/app.js'))return node.onerror();vm.runInContext(oldSource,context);}else vm.runInContext(node.src===oldKey?oldSource:source,context);node.onload();});}}}}; root.window=root;const context=vm.createContext(root); vm.runInContext(readFileSync(require.resolve('./app-bootstrap.js'),'utf8'),context); return {root,held,appended,listeners,key,calls:()=>calls,doc,response,oldKey}; @@ -69,7 +69,7 @@ test('native-cache engines use their external script and keep normal code-cache test('native-cache engines still reject an incompatible retained core',async()=>{ - const f=fixture({capture:false,previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined); + const f=fixture({capture:false,previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/Unable to load player/);assert.equal(f.root.appRuns,undefined); }); test('native-cache engines use the validated current cache entry without decoding its body',async()=>{ const f=fixture({capture:false,cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1); diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js index e8d951c..32f4519 100644 --- a/frontend/asset-sync-core.js +++ b/frontend/asset-sync-core.js @@ -41,6 +41,9 @@ const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path)); if (!group || !previous.groups[group]?.files.includes(path) || manifest.groups[group].contract !== previous.groups[group].contract) return null; + // A stale core must understand every group contract in the new shell. + if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) => + previous.groups[name]?.contract !== entry.contract)) return null; return url(path, previous.files[path]); } diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js index 2c9a9d4..a8fbd2f 100644 --- a/frontend/asset-sync-core.test.js +++ b/frontend/asset-sync-core.test.js @@ -45,3 +45,10 @@ test('removing group membership retains the old tab URL even if the physical fil const previous=manifest(),current=manifest();current.groups.core.files=['/index.html']; assert.ok(core.retained(current,previous).has('/a.js?v=a')); }); + + +test('stale app core requires every feature contract to match the new shell',()=>{ + const old={files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}}; + const next=structuredClone(old);next.files['/app.js'].h='new';next.groups.extra.contract=2; + assert.equal(core.fallback(next,old,'/app.js'),null);next.groups.extra.contract=1;assert.equal(core.fallback(next,old,'/app.js'),'/app.js?v=old'); +}); diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js index 7b766a7..1aa6b2c 100644 --- a/frontend/asset-worker.test.js +++ b/frontend/asset-worker.test.js @@ -6,7 +6,7 @@ function environment(){ vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox); async function dispatch(t,e={}){let p;listeners[t]({...e,waitUntil:v=>p=v});await p} async function request(url,mode='cors',clientId='new'){let p;listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,respondWith:v=>p=v});return p} - return {storage,m,skips,fetches,dispatch,request}; + return {storage,m,skips,fetches,dispatch,request,sandbox}; } test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,false);assert.equal(reply.missing,1);assert.equal(reply.version,'next');assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);assert.equal((await e.request('/app.js?v=stale')).status,409);}); test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);}); @@ -51,3 +51,14 @@ test('page-captured app is reused on first install without a worker download',as assert.equal(await(await cache.match('/app.js?v=app')).text(),'page captured app'); assert.equal(e.skips.length,0);assert.equal(await core.state(cache),null); }); + + +test('worker never supplies stale app to a native script when a feature contract changed',async()=>{ + const e=environment(),cache=await e.storage.open(core.CACHE); + const old={buildTag:'old',files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}}; + e.m.groups.core.contract=1;e.m.groups.extra={contract:2,background:true,files:[]}; + await cache.put(core.STATE,Response.json({current:old}));await cache.put('/app.js?v=old',new Response('old app',{headers:{'X-Asset-Hash':'old'}})); + await e.dispatch('install');await e.dispatch('activate');await cache.delete('/app.js?v=app'); + e.sandbox.fetch=async()=>{throw Error('offline')}; + const response=await e.request('/app.js?v=app');assert.equal(response.status,503);assert.equal(await response.text(),'Offline'); +}); diff --git a/perf/results/phase6-lazy-2026-10-08.json b/perf/results/phase6-lazy-2026-10-08.json index b4734bc..5b3d1ef 100644 --- a/perf/results/phase6-lazy-2026-10-08.json +++ b/perf/results/phase6-lazy-2026-10-08.json @@ -2,7 +2,7 @@ "results": [ { "browser": "chromium", - "sourceBuildTag": "562f4031bcb7", + "sourceBuildTag": "22eb7414f94c", "classicSharedDefaults": true, "cssOrderPreserved": true, "settingsSearch": true, @@ -28,7 +28,7 @@ }, { "browser": "webkit", - "sourceBuildTag": "562f4031bcb7", + "sourceBuildTag": "22eb7414f94c", "classicSharedDefaults": true, "cssOrderPreserved": true, "settingsSearch": true, @@ -56,7 +56,7 @@ "disconnected": true }, { - "message": "/127.0.0.1:33967/api/version.", + "message": "/127.0.0.1:33445/api/version.", "disconnected": true }, { @@ -64,7 +64,7 @@ "disconnected": true }, { - "message": "/127.0.0.1:33967/api/recommendations?fp=163577c02a53bc40.", + "message": "/127.0.0.1:33445/api/recommendations?fp=163577c02a53bc40.", "disconnected": true }, { @@ -72,7 +72,7 @@ "disconnected": true }, { - "message": "/127.0.0.1:33967/api/version.", + "message": "/127.0.0.1:33445/api/version.", "disconnected": true }, { @@ -80,7 +80,7 @@ "disconnected": true }, { - "message": "/127.0.0.1:33967/api/recommendations?fp=163577c02a53bc40.", + "message": "/127.0.0.1:33445/api/recommendations?fp=163577c02a53bc40.", "disconnected": true }, { @@ -88,7 +88,7 @@ "disconnected": true }, { - "message": "/127.0.0.1:33967/api/version.", + "message": "/127.0.0.1:33445/api/version.", "disconnected": true } ] diff --git a/plans/phase6-investigation.md b/plans/phase6-investigation.md index 9810696..c089e04 100644 --- a/plans/phase6-investigation.md +++ b/plans/phase6-investigation.md @@ -98,3 +98,11 @@ native cache hits avoid decoding/rehashing; retained core fallback still enforce all group contracts. The new worker marks stale responses no-store and strips decoded transport headers, preventing browser resource-cache poisoning under a new URL. CacheStorage's exact keys and previous entries stay unchanged. + +The native-window contract lookup trial regressed Chromium warm boot (~698 ms). +It is rejected. The worker now owns the all-group stale app contract guard +(pure planner and actual worker VM tests first); native boot has no window-side +CacheStorage or hashing work. WebKit's byte-verified owned response path is +unchanged. The native fixture models the actual worker fallback decision instead +of a second page-side decision. All 221 frontend tests and 184 server tests pass; +both-engine lazy/offline/eviction/pinning/contracts/playback smoke passes.