Enforce stale core contracts in the worker without native startup I/O
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user