Keep offline completion outside the activation barrier
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core');
|
||||
function environment(){
|
||||
const stores=new Map(),listeners={},messages=[],skips=[],fetches=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
||||
const stores=new Map(),listeners={},messages=[],skips=[],fetches=[],selfMessages=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}};
|
||||
const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}};
|
||||
const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
|
||||
const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{registration:{active:{postMessage:data=>selfMessages.push(data)}},location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox);
|
||||
async function dispatch(t,e={}){const tasks=[];listeners[t]({...e,waitUntil:v=>tasks.push(v)});await Promise.all(tasks)}
|
||||
async function request(url,mode='cors',clientId='new'){let p;listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:()=>{},respondWith:v=>p=v});return p}
|
||||
return {storage,m,skips,fetches,dispatch,request};
|
||||
async function request(url,mode='cors',clientId='new'){let p;const tasks=[];listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:v=>tasks.push(v),respondWith:v=>p=v});const response=await p;await Promise.all(tasks);return response;}
|
||||
return {storage,m,skips,fetches,dispatch,request,sandbox,selfMessages,async complete(){for(const data of selfMessages.splice(0))await dispatch('message',{data});}};
|
||||
}
|
||||
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');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);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');await e.complete();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');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);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('staged same-contract responses use verified N-1 without poisoning the new URL',async()=>{
|
||||
@@ -16,7 +16,7 @@ test('staged same-contract responses use verified N-1 without poisoning the new
|
||||
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
|
||||
e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'};
|
||||
await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await e.dispatch('install'); await e.dispatch('activate');
|
||||
await e.dispatch('install'); await e.dispatch('activate');await e.complete();
|
||||
assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new');
|
||||
assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
@@ -33,13 +33,26 @@ test('a changed background contract is downloaded before ready and cannot fall b
|
||||
await e.dispatch('install');assert.equal((await core.state(cache)).current.buildTag,'old');
|
||||
assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);
|
||||
await e.dispatch('activate');await cache.delete('/extra.js?v=new');
|
||||
await e.dispatch('activate');await e.complete();await cache.delete('/extra.js?v=new');
|
||||
const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body');
|
||||
});
|
||||
|
||||
test('overlapping idle warm messages share one download pool',async()=>{
|
||||
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||
await e.dispatch('install');await e.dispatch('activate');
|
||||
await e.dispatch('install');await e.dispatch('activate');await e.complete();
|
||||
await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]);
|
||||
assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1);
|
||||
});
|
||||
|
||||
test('waiting status does not start completion and activation delegates it to its own event',async()=>{
|
||||
const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true};
|
||||
await e.dispatch('install');let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});
|
||||
assert.equal(reply.ready,true);assert.equal(reply.offlineReady,false);assert.ok(!e.fetches.includes('/extra.js?v=extra'));
|
||||
let release,started;const gate=new Promise(r=>release=r),begin=new Promise(r=>started=r),fetch=e.sandbox.fetch;
|
||||
e.sandbox.fetch=async url=>{if(url==='/extra.js?v=extra'){started();await gate;}return fetch(url);};
|
||||
await e.dispatch('activate');const cache=await e.storage.open(core.CACHE);
|
||||
assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await core.status(e.m,cache)).ready,true);
|
||||
assert.equal((await core.status(e.m,cache)).offlineReady,false);assert.equal(e.selfMessages[0].type,'COMPLETE_ASSETS');
|
||||
const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job;
|
||||
assert.equal((await core.status(e.m,cache)).offlineReady,true);
|
||||
});
|
||||
|
||||
@@ -308,7 +308,7 @@ function completeAssets(force = false) {
|
||||
if (!ASSET_SYNC) return Promise.resolve();
|
||||
completionTask ||= (async () => {
|
||||
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||
const m = await candidate(cache) || (await AssetSyncCore.state(cache))?.current;
|
||||
const m = (await AssetSyncCore.state(cache))?.current;
|
||||
if (!m || m.buildTag !== VERSION) return;
|
||||
const job = await AssetSyncCore.complete(m, { cache, fetchFn: fetch, force,
|
||||
notify: async status => {
|
||||
@@ -573,7 +573,9 @@ 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});
|
||||
await completeAssets(true);
|
||||
// Completion owns a message-event lifetime, not the activation barrier.
|
||||
// In particular, a legacy client's four-second reload must not wait for it.
|
||||
self.registration.active.postMessage({ type: 'COMPLETE_ASSETS' });
|
||||
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
|
||||
}
|
||||
async function assetFetch(request, clientId) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { chromium, webkit } from 'playwright';
|
||||
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
|
||||
const args = process.argv.slice(2);
|
||||
const targetFrontend = args.includes('--frontend-source') ? resolve(args[args.indexOf('--frontend-source')+1]) : join(repo,'frontend');
|
||||
const profile = args.includes('--profile') ? args[args.indexOf('--profile')+1] : 'lossy';
|
||||
const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all';
|
||||
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
|
||||
@@ -55,7 +56,7 @@ async function run(name) {
|
||||
});
|
||||
}
|
||||
async function start(old) {
|
||||
symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : join(repo, 'frontend'), join(cwd, 'public'));
|
||||
symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : targetFrontend, join(cwd, 'public'));
|
||||
output = '';
|
||||
child = spawn('bun', [old ? join(scratch, 'server/server.js') : join(repo, 'server/server.js')], {
|
||||
cwd, env: { ...process.env, BUILD_TAG: '', ASSET_HASHING: old ? '0' : '1', PORT: String(port),
|
||||
@@ -114,7 +115,7 @@ async function run(name) {
|
||||
const response = await fetch(`${backend}${path}${query}`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('x-asset-hash'), manifest.files[path].h);
|
||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(repo, 'frontend', path.slice(1))));
|
||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(targetFrontend, path.slice(1))));
|
||||
if (query.includes('?v=')) assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -150,6 +151,8 @@ async function run(name) {
|
||||
await oldTab.goto(origin,{waitUntil:'load'});
|
||||
assert.equal(await oldTab.locator('meta[name="ytp-build"]').getAttribute('content'),oldTag);
|
||||
const oldBytes = await oldTab.evaluate(async()=>await (await fetch('/app.js')).text());
|
||||
// Bring the updating page forward after checking the retained old tab.
|
||||
await page.bringToFront();
|
||||
console.log(name,'clicking legacy refresh');
|
||||
await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click({noWaitAfter:true,timeout:15000});
|
||||
console.log(name,'legacy refresh clicked');
|
||||
|
||||
Reference in New Issue
Block a user