Enforce stale core contracts in the worker without native startup I/O

This commit is contained in:
Jonathan Sykes
2026-10-08 10:05:38 +08:00
parent 57edb410c3
commit 97eb31e732
7 changed files with 40 additions and 13 deletions

View File

@@ -27,12 +27,11 @@
return {bytes,digest}; return {bytes,digest};
} }
async function start() { 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; const expected=manifest.files['/app.js'].h;
let cache; let cache;
try { cache=await root.caches?.open('ytplayer-assets'); } catch {} try { cache=await root.caches?.open('ytplayer-assets'); } catch {}
let response=await cache?.match(key); 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) { if(!response || response.headers.get('X-Asset-Hash')!==expected) {
try { response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'})); } catch {} 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 // 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. // 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); } 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); const node=doc.createElement('script');node.textContent=new root.TextDecoder().decode(bytes);
execute(node); execute(node);
} }

View File

@@ -11,7 +11,7 @@ function fixture({cached=false,tampered=false,enabled=true,loading=false,quota=f
if(cached)held.set(key,response()); 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']}}}}));} 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 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); root.window=root;const context=vm.createContext(root);
vm.runInContext(readFileSync(require.resolve('./app-bootstrap.js'),'utf8'),context); vm.runInContext(readFileSync(require.resolve('./app-bootstrap.js'),'utf8'),context);
return {root,held,appended,listeners,key,calls:()=>calls,doc,response,oldKey}; 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()=>{ 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()=>{ 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); 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);

View File

@@ -41,6 +41,9 @@
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path)); const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
if (!group || !previous.groups[group]?.files.includes(path) || if (!group || !previous.groups[group]?.files.includes(path) ||
manifest.groups[group].contract !== previous.groups[group].contract) return null; 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]); return url(path, previous.files[path]);
} }

View File

@@ -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']; const previous=manifest(),current=manifest();current.groups.core.files=['/index.html'];
assert.ok(core.retained(current,previous).has('/a.js?v=a')); 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');
});

View File

@@ -6,7 +6,7 @@ function environment(){
vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox); 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 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} 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('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);}); 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(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); 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');
});

View File

@@ -2,7 +2,7 @@
"results": [ "results": [
{ {
"browser": "chromium", "browser": "chromium",
"sourceBuildTag": "562f4031bcb7", "sourceBuildTag": "22eb7414f94c",
"classicSharedDefaults": true, "classicSharedDefaults": true,
"cssOrderPreserved": true, "cssOrderPreserved": true,
"settingsSearch": true, "settingsSearch": true,
@@ -28,7 +28,7 @@
}, },
{ {
"browser": "webkit", "browser": "webkit",
"sourceBuildTag": "562f4031bcb7", "sourceBuildTag": "22eb7414f94c",
"classicSharedDefaults": true, "classicSharedDefaults": true,
"cssOrderPreserved": true, "cssOrderPreserved": true,
"settingsSearch": true, "settingsSearch": true,
@@ -56,7 +56,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33967/api/version.", "message": "/127.0.0.1:33445/api/version.",
"disconnected": true "disconnected": true
}, },
{ {
@@ -64,7 +64,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33967/api/recommendations?fp=163577c02a53bc40.", "message": "/127.0.0.1:33445/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true "disconnected": true
}, },
{ {
@@ -72,7 +72,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33967/api/version.", "message": "/127.0.0.1:33445/api/version.",
"disconnected": true "disconnected": true
}, },
{ {
@@ -80,7 +80,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33967/api/recommendations?fp=163577c02a53bc40.", "message": "/127.0.0.1:33445/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true "disconnected": true
}, },
{ {
@@ -88,7 +88,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33967/api/version.", "message": "/127.0.0.1:33445/api/version.",
"disconnected": true "disconnected": true
} }
] ]

View File

@@ -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 all group contracts. The new worker marks stale responses no-store and strips
decoded transport headers, preventing browser resource-cache poisoning under a decoded transport headers, preventing browser resource-cache poisoning under a
new URL. CacheStorage's exact keys and previous entries stay unchanged. 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.