Verify staged lazy loading and record Phase 3 measurements
This commit is contained in:
@@ -209,6 +209,65 @@ inspect individual responses to distinguish those from app asset downloads.
|
||||
Migration results are `phase2-migration-<profile>-2026-10-07.json`.
|
||||
`ASSET_SYNC=0` selects the legacy path while preserving the persistent utility
|
||||
cache. `ASSET_HASHING=0` also selects legacy sync because single-tag URLs cannot
|
||||
pass per-file hash checks. Current assets.json groups all block; idle loading and
|
||||
lazy parsing await Phase 3. Incremental mode now caches the manifested
|
||||
pass per-file hash checks. In Phase 2 all assets.json groups blocked; Phase 3 selects core plus the active
|
||||
layout and warms background groups at idle. Incremental mode now caches the manifested
|
||||
`piano-engine.mjs` even though it remains absent from the legacy SHELL.
|
||||
|
||||
|
||||
## Phase 3 lazy loading and staged apply
|
||||
|
||||
```bash
|
||||
node perf/lazy.mjs --browser all
|
||||
node perf/migration.mjs --browser all --profile lossy
|
||||
node perf/migration.mjs --browser all --profile unthrottled
|
||||
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all \
|
||||
--out perf/results/phase3-final-2026-10-08.json \
|
||||
--compare perf/results/after-phase0-2026-10-07.json
|
||||
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario cold \
|
||||
--frontend-commit 7bc5b4f8b50d78ece3cca5100d54a97fece2748a \
|
||||
--out perf/results/phase3-phase0-initial-2026-10-08.json
|
||||
ASSET_SYNC=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||
--scenario offline --out perf/results/phase3-sync-rollback-2026-10-08.json
|
||||
ASSET_HASHING=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||
--scenario offline --out perf/results/phase3-hashing-rollback-2026-10-08.json
|
||||
```
|
||||
|
||||
`initialJsCss` sums compressed Resource Timing bodies for page JS/CSS initiated
|
||||
before DOMContentLoaded, excluding SW precache and later idle feature parsing.
|
||||
The saved Phase 0 results lack that metric: `--frontend-commit` reconstructs its
|
||||
frontend in scratch using the current delivery backend for a matched initial-byte
|
||||
comparison. Historical boot/FCP comparisons still use `after-phase0`. Reduced
|
||||
single-run measurements are directional; Phase 5 supplies repeated medians.
|
||||
Update scenarios now wait for every build-N group to finish idle caching before
|
||||
starting the counter, excluding unfinished initial downloads. Waiting-worker
|
||||
bytes represent blocking readiness; an inactive layout's changed CSS downloads
|
||||
later at idle after activation.
|
||||
|
||||
The browser harness checks pre-body layout selection, immediate offline reload,
|
||||
four distinct Car mode controls, optional Settings search, all extension panels,
|
||||
zero network for warmed first use/layout switches, contract guards and pinned
|
||||
instances. Known WebKit disconnected API transport errors are recorded separately;
|
||||
script errors still fail. Save-Data prevents automatic warming.
|
||||
|
||||
The index contains inert JSON and an external head bootstrap under unchanged
|
||||
CSP. Source JSON mirrors assets.json for native/static shells; the server replaces
|
||||
it with hashed build-local URLs. No boot-time manifest network request is added.
|
||||
Shared layout visibility defaults remain eager in layout-base.css.
|
||||
Pure EQ settings metadata and DeviceDB remain eager to preserve synchronous
|
||||
player defaults and offline file bookkeeping. P2P/direct parse on actual use or
|
||||
the existing eight-second sharing startup. Searching Settings explicitly loads
|
||||
optional panels so their original labels remain searchable.
|
||||
|
||||
| JS-requested assets | Phase 3 URL treatment |
|
||||
|---|---|
|
||||
| opfs-worker.js, hash-worker.js, direct-recv-worker.js, p2p-recv-worker.js | Lazy.worker uses embedded per-file URL |
|
||||
| Workers' sha256.js, resume-core.js, sha256-wasm.js | First postMessage carries originating build URLs; importScripts uses them |
|
||||
| piano-engine.mjs | Dynamic import uses Lazy.url; incremental cache warms it |
|
||||
| vendor/framework7-swipe.min.js | Ordered layout group and fallback use Lazy.url |
|
||||
| loudness-wasm.js | Remains an eager stamped script; embedded binary needs no separate URL |
|
||||
| fonts/fonts.css and font preloads; webmanifest/icons | Plain URLs stay aligned with CSS/manifest references; all belong to manifested cache groups |
|
||||
|
||||
Legacy pages cannot report their selected layout. Their one-time migration blocks
|
||||
all layouts to preserve immediate offline selection; modern pages block only the
|
||||
reported layout and its shared controls. Executing groups never re-evaluate live.
|
||||
See plans/phase3-staged-apply-decision.md: Phase 4 must add disposal/state handoff.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* --compare <json-path> Compare results against a previous baseline JSON
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -58,6 +58,7 @@ function parseArgs() {
|
||||
scenario: 'all', // 'cold', 'warm', 'offline', 'update-js', 'update-css', or 'all'
|
||||
out: null,
|
||||
compare: null,
|
||||
frontendCommit: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -67,6 +68,7 @@ function parseArgs() {
|
||||
else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i];
|
||||
else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i];
|
||||
else if (a === '--out' && i + 1 < args.length) options.out = args[++i];
|
||||
else if (a === '--frontend-commit' && i + 1 < args.length) options.frontendCommit = args[++i];
|
||||
else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i];
|
||||
}
|
||||
|
||||
@@ -365,6 +367,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})),
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
@@ -393,6 +397,8 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
|
||||
bootDone: pageMetrics.bootDone,
|
||||
longTaskTotal: pageMetrics.longTaskTotal,
|
||||
requests: stats.requests,
|
||||
initialJsCss: pageMetrics.initialJsCss,
|
||||
initialAssetRequests: pageMetrics.initialAssetRequests,
|
||||
swInstallDuration,
|
||||
swInstallBytes: stats.totalWireBytes,
|
||||
storageEstimate: storage,
|
||||
@@ -465,6 +471,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})),
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
@@ -542,6 +550,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})),
|
||||
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||
@@ -602,6 +612,27 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
// Phase 3 activation precedes idle feature caching. Measure updates from a
|
||||
// genuinely warm build, rather than counting unfinished first-install bytes.
|
||||
if (await page.evaluate(()=>!!window.Lazy && !!window.Lazy.warm)) {
|
||||
await page.evaluate(()=>Lazy.warm());
|
||||
const end=Date.now()+90000;
|
||||
while (true) {
|
||||
const ready=await page.evaluate(async()=>{
|
||||
const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');
|
||||
if(!r)return false;
|
||||
const m=(await r.json()).current;
|
||||
return (await Promise.all(Object.values(m.groups).flatMap(g=>g.files).map(async p=>{
|
||||
const response=await c.match(p+'?v='+m.files[p].h);
|
||||
return response?.headers.get('X-Asset-Hash')===m.files[p].h;
|
||||
}))).every(Boolean);
|
||||
});
|
||||
if(ready)break;
|
||||
if(Date.now()>end)throw Error('Idle warm did not complete before update benchmark');
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
||||
const targetTree = updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
|
||||
await server.restartWithPublic(targetTree);
|
||||
@@ -666,9 +697,14 @@ async function main() {
|
||||
const treeUpdateJs = path.join(tmpRoot, 'tree-update-js');
|
||||
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
|
||||
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeBase, { recursive: true });
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateJs, { recursive: true });
|
||||
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateCss, { recursive: true });
|
||||
let frontendSource=path.join(REPO_ROOT,'frontend');
|
||||
if(options.frontendCommit) {
|
||||
const archive=path.join(tmpRoot,'frontend.tar');
|
||||
writeFileSync(archive,execFileSync('git',['archive',options.frontendCommit,'frontend'],{cwd:REPO_ROOT,maxBuffer:20*1024*1024}));
|
||||
execFileSync('tar',['-xf',archive,'-C',tmpRoot]);
|
||||
frontendSource=path.join(tmpRoot,'frontend');
|
||||
}
|
||||
for(const tree of [treeBase,treeUpdateJs,treeUpdateCss])cpSync(frontendSource,tree,{recursive:true});
|
||||
|
||||
// Append 1 comment line to app.js in treeUpdateJs
|
||||
appendFileSync(path.join(treeUpdateJs, 'app.js'), '\n// baseline bench: 1-line app.js comment\n');
|
||||
@@ -684,6 +720,7 @@ async function main() {
|
||||
|
||||
const results = {
|
||||
date: new Date().toISOString(),
|
||||
frontendCommit: options.frontendCommit,
|
||||
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
|
||||
runsConfigured: options.runs,
|
||||
browsers: {},
|
||||
@@ -729,6 +766,8 @@ async function main() {
|
||||
autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked),
|
||||
wireBytesByType: coldRuns[0]?.wireBytesByType || {},
|
||||
requests: coldRuns[0]?.requests || [],
|
||||
initialJsCss: summarizeList(coldRuns.map(r=>r.initialJsCss)),
|
||||
initialAssetRequests: coldRuns[0]?.initialAssetRequests || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
135
perf/lazy.mjs
Normal file
135
perf/lazy.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 3 checks against the real Bun server, a same-origin restart and real workers.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, cpSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { chromium, webkit } from 'playwright';
|
||||
import { createThrottleProxy } from './proxy.mjs';
|
||||
const repo=resolve(new URL('..',import.meta.url).pathname), scratch=mkdtempSync(join(repo,'perf/.tmp/lazy-'));
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function wait(page,fn,arg,timeout=90000){const end=Date.now()+timeout;while(Date.now()<end){if(await page.evaluate(fn,arg))return;await sleep(100);}throw Error('Timed out: '+fn.toString());}
|
||||
async function checkCssOrder(page){
|
||||
const changes=await page.evaluate(async()=>{
|
||||
await Lazy.load('layout:'+Lazy.layout);
|
||||
const settle=()=>new Promise(resolve=>setTimeout(resolve,300));await settle();
|
||||
const nodes=[...document.querySelectorAll('body,body *')],props=['display','position','color','background-color','font-family','font-size','padding','margin','border-radius','--bg','--text','--radius'];
|
||||
const snapshot=()=>nodes.map(n=>props.map(p=>getComputedStyle(n).getPropertyValue(p)));
|
||||
const links=[...document.querySelectorAll('link[data-lazy-css]')],positions=links.map(n=>[n,n.nextSibling]),before=snapshot();
|
||||
const loaded=()=>new Promise((resolve,reject)=>{let tries=0;function poll(){if(links.every(n=>n.sheet))return resolve();if(tries++>120)return reject(Error('Reordered CSS did not load'));requestAnimationFrame(poll);}poll();});
|
||||
links.forEach(n=>document.head.append(n));await loaded();await settle();const after=snapshot();
|
||||
for(const [n,next] of positions.reverse())document.head.insertBefore(n,next);
|
||||
await loaded();await settle();
|
||||
return before.flatMap((values,i)=>values.flatMap((v,j)=>v===after[i][j]?[]:[{element:nodes[i].id||nodes[i].className,property:props[j],before:v,after:after[i][j]}]));
|
||||
});
|
||||
assert.deepEqual(changes,[],'requesting scoped layout CSS earlier preserves its computed appearance');
|
||||
}
|
||||
const args=process.argv.slice(2), requested=args.includes('--browser')?args[args.indexOf('--browser')+1]:'all';
|
||||
const results=[];
|
||||
async function run(name){
|
||||
const cwd=join(scratch,name);mkdirSync(cwd);cpSync(join(repo,'frontend'),join(cwd,'public'),{recursive:true});
|
||||
const portServer=createServer();await new Promise(r=>portServer.listen(0,'127.0.0.1',r));const port=portServer.address().port;await new Promise(r=>portServer.close(r));
|
||||
let child,browser,proxy,log='';
|
||||
async function stop(){if(!child)return;const p=child;child=null;if(p.exitCode===null){p.kill('SIGTERM');await new Promise(r=>p.once('exit',r));}}
|
||||
async function start(){log='';child=spawn('bun',[join(repo,'server/server.js')],{cwd,env:{...process.env,PORT:String(port),ASSET_HASHING:'1',ASSET_SYNC:'1',BUILD_TAG:'',DB_PATH:join(cwd,'db.sqlite'),DATA_DIR:join(cwd,'data'),MEDIA_DIR:join(cwd,'media'),UPLOAD_DIR:join(cwd,'uploads')},stdio:['ignore','pipe','pipe']});for(const stream of [child.stdout,child.stderr])stream.on('data',c=>log=(log+c).slice(-15000));for(let i=0;i<200;i++){if(child.exitCode!==null)throw Error(log);try{if((await fetch('http://127.0.0.1:'+port+'/api/version')).ok)return;}catch{}await sleep(100);}throw Error(log);}
|
||||
try {
|
||||
await start();proxy=createThrottleProxy({targetPort:port,profile:'unthrottled'});const origin='http://127.0.0.1:'+await proxy.listen();proxy.startTracking();
|
||||
browser=await ({chromium,webkit}[name]).launch({headless:true});const context=await browser.newContext();let disconnected=false;
|
||||
await context.route('**/*',r=>r.request().url().startsWith(origin)?r.continue():r.abort());
|
||||
await context.addInitScript(()=>{window.__saveData=localStorage.getItem('perf-save-data')==='1';const observer=new MutationObserver(()=>{if(document.body){window.__firstBodyLayout=document.documentElement.dataset.layout;observer.disconnect();}});observer.observe(document,{childList:true,subtree:true});Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>localStorage.getItem('perf-offline')!=='1'});Object.defineProperty(navigator,'connection',{configurable:true,value:{get saveData(){return window.__saveData;}}});});
|
||||
const page=await context.newPage(),errors=[],failedRequests=[];page.on('requestfailed',request=>{if(disconnected)failedRequests.push(request.url());});page.on('pageerror',e=>errors.push({message:e.message,disconnected}));page.on('console',m=>{if(m.type()==='error')console.log(name,'console:',m.text());});
|
||||
console.log(name,'initial boot');await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
|
||||
await page.evaluate(()=>Lazy.warm());
|
||||
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json();return !(await Promise.all(Object.values(s.current.groups).flatMap(g=>g.files).map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
|
||||
console.log(name,'warm complete');assert.equal(await page.evaluate(()=>Lazy.loaded('feature:car-mode')),false);
|
||||
assert.equal(await page.evaluate(()=>[...document.querySelectorAll('.stg-overlay,.deck-chips,.vol-pct,.mode-name,.stg-bot,.ch-badge,.upnext-clear,.list-sub,.p2p-strip,.sleep-status .sl-ic,.sleep-status .sl-sub,.mc-tune')].every(n=>getComputedStyle(n).display==='none')),true,'classic retains shared visibility defaults without loading Glass CSS');
|
||||
await checkCssOrder(page);
|
||||
assert.equal(await page.evaluate(()=>Lazy.loaded('feature:piano')),false);
|
||||
assert.equal(await page.evaluate(()=>[typeof newPlaylist,typeof renderSettings,typeof enterServiceMode,typeof Notes.load,typeof LyricsCore.fromText].every(type=>type==='function')),true,'playlist, settings, service mode and lyric core remain in RAM');
|
||||
// Profile/imported settings use applyAppearance, not the Settings select handler.
|
||||
await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});
|
||||
await wait(page,()=>document.documentElement.dataset.layout==='glass-stage'&&Lazy.layout==='glass-stage');
|
||||
await page.evaluate(()=>{data.settings.layout='classic';applyAppearance();});
|
||||
await wait(page,()=>document.documentElement.dataset.layout==='classic'&&Lazy.layout==='classic');
|
||||
// Eager application panels stay usable without executing optional settings modules.
|
||||
await page.click('[data-view="settings"]');await page.click('[data-section="appearance"]');await page.waitForSelector('#setLayout');
|
||||
assert.equal(await page.evaluate(()=>Lazy.loaded('feature:piano')),false);
|
||||
await page.selectOption('#setLayout','glass-stage');await page.waitForFunction(()=>document.documentElement.dataset.layout==='glass-stage');
|
||||
await page.evaluate(()=>{window.__saveData=true;localStorage.setItem('perf-save-data','1');localStorage.setItem('perf-offline','1');});
|
||||
await stop();disconnected=true;await proxy.setOffline(true);
|
||||
console.log(name,'offline reload');await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.dataset.layout),'glass-stage');
|
||||
assert.equal(await page.evaluate(()=>window.__firstBodyLayout),'glass-stage','selected layout is established before body/first paint');
|
||||
await checkCssOrder(page);
|
||||
const before=proxy.getStats().requestCount;
|
||||
await page.getByRole('button',{name:'Car mode',exact:true}).click();await page.waitForSelector('.car-mode');
|
||||
assert.equal(await page.locator('.car-controls button').count(),4,'all transport controls survive lazy setup');
|
||||
assert.equal(await page.locator('.car-controls #voiceSearchBtn').count(),0,'voice search stays outside transport');
|
||||
await page.getByRole('button',{name:'Exit Car mode'}).click();
|
||||
assert.equal(proxy.getStats().requestCount,before,'warmed feature first use reaches no network');
|
||||
await page.click('[data-view="settings"]');await page.click('[data-section="appearance"]');await page.selectOption('#setLayout','bento-hub');await page.waitForFunction(()=>document.documentElement.dataset.layout==='bento-hub');
|
||||
await page.selectOption('#setLayout','f7-swipe');await page.waitForFunction(()=>document.documentElement.dataset.layout==='f7-swipe'&&!!window.Framework7);
|
||||
await page.selectOption('#setLayout','classic');await page.waitForFunction(()=>document.documentElement.dataset.layout==='classic');
|
||||
assert.equal(proxy.getStats().requestCount,before,'offline layout changes reach no network');
|
||||
await page.getByRole('searchbox',{name:'Search settings and controls'}).fill('Enable piano transcription');
|
||||
await page.locator('.settings-results button').first().waitFor();
|
||||
await page.getByRole('searchbox',{name:'Search settings and controls'}).fill('');
|
||||
// Every extension panel renders from disk without executing other panels eagerly.
|
||||
for(const id of ['piano','midi','floating-lyrics','vocal-reducer','equalizer','setlist-import','broadcast']) {
|
||||
await page.evaluate(id=>document.querySelector('[data-section="'+id+'"]').click(),id);
|
||||
await wait(page,async id=>{const section=SettingsSections.get(id);return !section.lazyGroup;},id);
|
||||
}
|
||||
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'));
|
||||
// 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;});
|
||||
await page.evaluate(()=>window.__saveData=true);
|
||||
const previous=await page.evaluate(()=>Lazy.manifest);
|
||||
await stop();
|
||||
for(const file of ['video-edit.js','setlist-import.js','car-mode.js'])writeFileSync(join(cwd,'public',file),readFileSync(join(cwd,'public',file),'utf8')+'\n// phase3 new file\n');
|
||||
const defs=JSON.parse(readFileSync(join(cwd,'public/assets.json')));defs.groups['feature:car-mode'].contract=2;writeFileSync(join(cwd,'public/assets.json'),JSON.stringify(defs));
|
||||
// Production stamping replaces the source-only native map with these updated groups.
|
||||
await start();const target=await(await fetch(origin+'/api/manifest')).json();
|
||||
await page.evaluate(async()=>{window.__saveData=true;const r=await navigator.serviceWorker.getRegistration();await r.update();});
|
||||
await wait(page,async target=>{const r=await navigator.serviceWorker.getRegistration();if(!r?.waiting)return false;return(await SwUpdate.askCacheStatus(r.waiting))?.version===target;},target.buildTag);
|
||||
// No automatic navigation; a real playing flag blocks the real apply flow.
|
||||
let navs=0;page.on('framenavigated',f=>{if(f===page.mainFrame())navs++;});
|
||||
await sleep(1000);assert.equal(navs,0);
|
||||
await page.evaluate(()=>Player._wantsPlaying=true);
|
||||
page.once('dialog',d=>d.dismiss());
|
||||
const guard=await page.evaluate(async()=>{try{await SwUpdate.applyUpdate({getRegistration:()=>navigator.serviceWorker.getRegistration(),askStatus:SwUpdate.askCacheStatus,reload:()=>location.reload()});return false;}catch{return true;}});
|
||||
assert.equal(guard,true);assert.equal(navs,0);
|
||||
await page.evaluate(()=>Player._wantsPlaying=false);
|
||||
// Explicit worker message, then explicit navigation into new stamped core.
|
||||
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;},target.buildTag);
|
||||
await page.reload();await page.waitForSelector('#carModeBtn');assert.equal(await page.evaluate(()=>Lazy.manifest.buildTag),target.buildTag);
|
||||
// No warm: the new page gets a same-contract N-1 file immediately from disk.
|
||||
const cacheKey='/video-edit.js?v='+target.files['/video-edit.js'].h;
|
||||
assert.equal(await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey),false);
|
||||
const stale=await page.evaluate(async key=>{const r=await fetch(key);return {h:r.headers.get('X-Asset-Hash'),body:await r.text()};},cacheKey);
|
||||
assert.equal(stale.h,previous.files['/video-edit.js'].h);assert.ok(!stale.body.includes('// phase3 new file'));
|
||||
await page.evaluate(()=>Lazy.load('feature:video-edit'));const pinned=await page.evaluate(()=>{window.__videoPinned=VideoEdit;return Lazy.loaded('feature:video-edit');});assert.equal(pinned,true);
|
||||
// Different contract is already downloaded before commit, but the old core must keep N-1.
|
||||
const oldRequests=[];old.on('request',r=>oldRequests.push(r.url()));
|
||||
await old.evaluate(()=>Lazy.load('feature:car-mode'));assert.equal(await old.evaluate(()=>Lazy.reloadRequired),true);
|
||||
await old.getByRole('button',{name:'Refresh UI',exact:true}).waitFor();
|
||||
assert.ok(oldRequests.some(u=>u.includes('/car-mode.js?v='+previous.files['/car-mode.js'].h)));
|
||||
await old.evaluate(()=>Lazy.load('feature:setlist-import'));assert.equal(await old.evaluate(()=>window.SetlistImport===window.__pinned),true);
|
||||
// Warm new files explicitly; compatible, unexecuted old-page group selects new cached URL.
|
||||
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.active.postMessage({type:'WARM_ASSETS',saveData:false});});
|
||||
await wait(page,async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey);
|
||||
await old.evaluate(()=>Lazy.load('feature:video-edit'));assert.ok(oldRequests.some(u=>u.includes(cacheKey)));
|
||||
await page.evaluate(()=>Lazy.load('feature:video-edit'));assert.equal(await page.evaluate(()=>window.VideoEdit===window.__videoPinned),true);
|
||||
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});
|
||||
console.log(name,'PASS');
|
||||
} finally {await browser?.close();await proxy?.close();await stop();}
|
||||
}
|
||||
try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});}
|
||||
@@ -153,6 +153,9 @@ async function run(name) {
|
||||
assert.equal(banners, 1, 'no banner after applying the current build');
|
||||
assert.equal(await oldTab.evaluate(async()=>await (await fetch('/app.js')).text()),oldBytes,'open legacy tab keeps N-1 plain assets');
|
||||
await oldTab.close();
|
||||
// Phase 3 commits core + layout first; idle warming must fill the retained full shell before offline feature checks.
|
||||
await page.evaluate(()=>Lazy.warm());
|
||||
await waitAsync(page,async files=>{const cache=await caches.open('ytplayer-assets');return (await Promise.all(Object.keys(files).map(async path=>{const response=await cache.match(path+'?v='+files[path].h);return !!response&&response.headers.get('X-Asset-Hash')===files[path].h;}))).every(Boolean);},Object.fromEntries(shell.map(path=>[path==='/'?'/index.html':path,manifest.files[path==='/'?'/index.html':path]])));
|
||||
const cacheResult = await page.evaluate(async ({ tag, shell, files }) => {
|
||||
const cache = await caches.open('ytplayer-assets');
|
||||
const missing = [], incorrect = [];
|
||||
@@ -231,5 +234,5 @@ async function run(name) {
|
||||
try {
|
||||
const results = [];
|
||||
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
|
||||
writeFileSync(join(repo, `perf/results/phase2-migration-${profile}-2026-10-07.json`), JSON.stringify({ commit, results }, null, 2) + '\n');
|
||||
writeFileSync(join(repo, `perf/results/phase3-migration-${profile}-2026-10-08.json`), JSON.stringify({ commit, results }, null, 2) + '\n');
|
||||
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
||||
|
||||
2349
perf/results/phase3-final-2026-10-08.json
Normal file
2349
perf/results/phase3-final-2026-10-08.json
Normal file
File diff suppressed because it is too large
Load Diff
132
perf/results/phase3-hashing-rollback-2026-10-08.json
Normal file
132
perf/results/phase3-hashing-rollback-2026-10-08.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"date": "2026-10-07T20:10:25.561Z",
|
||||
"frontendCommit": null,
|
||||
"commit": "46ec9ab2ca0f1b5365b5b05b08052df967d6fcfc",
|
||||
"runsConfigured": 1,
|
||||
"browsers": {
|
||||
"chromium": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 48,
|
||||
"min": 48,
|
||||
"max": 48,
|
||||
"values": [
|
||||
48
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 48,
|
||||
"min": 48,
|
||||
"max": 48,
|
||||
"values": [
|
||||
48
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 165,
|
||||
"min": 165,
|
||||
"max": 165,
|
||||
"values": [
|
||||
165
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 170,
|
||||
"min": 170,
|
||||
"max": 170,
|
||||
"values": [
|
||||
170
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 187,
|
||||
"min": 187,
|
||||
"max": 187,
|
||||
"values": [
|
||||
187
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webkit": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 80,
|
||||
"min": 80,
|
||||
"max": 80,
|
||||
"values": [
|
||||
80
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 136,
|
||||
"min": 136,
|
||||
"max": 136,
|
||||
"values": [
|
||||
136
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 134,
|
||||
"min": 134,
|
||||
"max": 134,
|
||||
"values": [
|
||||
134
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 140,
|
||||
"min": 140,
|
||||
"max": 140,
|
||||
"values": [
|
||||
140
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 163,
|
||||
"min": 163,
|
||||
"max": 163,
|
||||
"values": [
|
||||
163
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
perf/results/phase3-lazy-2026-10-08.json
Normal file
79
perf/results/phase3-lazy-2026-10-08.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"browser": "chromium",
|
||||
"sourceBuildTag": "bea1f63eba85",
|
||||
"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": []
|
||||
},
|
||||
{
|
||||
"browser": "webkit",
|
||||
"sourceBuildTag": "bea1f63eba85",
|
||||
"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": [
|
||||
{
|
||||
"message": "TypeError: Load failed",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "/127.0.0.1:38845/api/version.",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "TypeError: Load failed",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "/127.0.0.1:38845/api/recommendations?fp=163577c02a53bc40.",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "TypeError: Load failed",
|
||||
"disconnected": true
|
||||
},
|
||||
{
|
||||
"message": "/127.0.0.1:38845/api/version.",
|
||||
"disconnected": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
41
perf/results/phase3-migration-lossy-2026-10-08.json
Normal file
41
perf/results/phase3-migration-lossy-2026-10-08.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"commit": "b77938a",
|
||||
"results": [
|
||||
{
|
||||
"browser": "chromium",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "bea1f63eba85",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 114,
|
||||
"durationMs": 24356,
|
||||
"wireBytes": 607853,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "lossy",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 0
|
||||
},
|
||||
{
|
||||
"browser": "webkit",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "bea1f63eba85",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 114,
|
||||
"durationMs": 20402,
|
||||
"wireBytes": 729652,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "lossy",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
41
perf/results/phase3-migration-unthrottled-2026-10-08.json
Normal file
41
perf/results/phase3-migration-unthrottled-2026-10-08.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"commit": "b77938a",
|
||||
"results": [
|
||||
{
|
||||
"browser": "chromium",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "bea1f63eba85",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 114,
|
||||
"durationMs": 10864,
|
||||
"wireBytes": 625216,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "unthrottled",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 0
|
||||
},
|
||||
{
|
||||
"browser": "webkit",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "bea1f63eba85",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 114,
|
||||
"durationMs": 9584,
|
||||
"wireBytes": 743322,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "unthrottled",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
2420
perf/results/phase3-phase0-initial-2026-10-08.json
Normal file
2420
perf/results/phase3-phase0-initial-2026-10-08.json
Normal file
File diff suppressed because it is too large
Load Diff
132
perf/results/phase3-sync-rollback-2026-10-08.json
Normal file
132
perf/results/phase3-sync-rollback-2026-10-08.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"date": "2026-10-07T20:10:14.094Z",
|
||||
"frontendCommit": null,
|
||||
"commit": "46ec9ab2ca0f1b5365b5b05b08052df967d6fcfc",
|
||||
"runsConfigured": 1,
|
||||
"browsers": {
|
||||
"chromium": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 44,
|
||||
"min": 44,
|
||||
"max": 44,
|
||||
"values": [
|
||||
44
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 44,
|
||||
"min": 44,
|
||||
"max": 44,
|
||||
"values": [
|
||||
44
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 135,
|
||||
"min": 135,
|
||||
"max": 135,
|
||||
"values": [
|
||||
135
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 140,
|
||||
"min": 140,
|
||||
"max": 140,
|
||||
"values": [
|
||||
140
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 158,
|
||||
"min": 158,
|
||||
"max": 158,
|
||||
"values": [
|
||||
158
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webkit": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 64,
|
||||
"min": 64,
|
||||
"max": 64,
|
||||
"values": [
|
||||
64
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 105,
|
||||
"min": 105,
|
||||
"max": 105,
|
||||
"values": [
|
||||
105
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 103,
|
||||
"min": 103,
|
||||
"max": 103,
|
||||
"values": [
|
||||
103
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 107,
|
||||
"min": 107,
|
||||
"max": 107,
|
||||
"values": [
|
||||
107
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 124,
|
||||
"min": 124,
|
||||
"max": 124,
|
||||
"values": [
|
||||
124
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,29 @@
|
||||
# Phase 3 — decision required before live module replacement
|
||||
# Phase 3 staged apply — accepted owner decision
|
||||
|
||||
COMMON.md says: "If you hit a decision that changes user-visible behaviour or
|
||||
risks data loss that the documents do not answer, write QUESTION ... and end your
|
||||
turn instead." This concerns §2c.5's instruction to switch stale modules when
|
||||
new bytes arrive, after a new build has booted using a same-contract fallback.
|
||||
The owner accepted this decision on 2026-10-08. It refines master §2c.5:
|
||||
background downloading never implies that an executing classic script can be
|
||||
safely replaced.
|
||||
|
||||
Concrete finding: direct-media.js creates private rooms, pending, transfers and
|
||||
waiting maps at module evaluation (lines 4–6). Its public API has no busy,
|
||||
dispose or state handoff method. Re-evaluation replaces window.DirectMedia,
|
||||
while WebRTC callbacks/workers still close over the old maps. Later messages
|
||||
routed by app.js through the new DirectMedia.handle can no longer find those
|
||||
in-flight transfers. p2p-client.js similarly replaces its public singleton but
|
||||
retains old sockets, timers and its visibility listener; P2PTransfer exposes
|
||||
start/download without disposal. Matching contract numbers do not establish
|
||||
safe runtime state migration. Piano/MIDI/floating-window UI also retains closures.
|
||||
1. Already executing groups stay pinned until the session ends or the next
|
||||
user-initiated, playback-guarded reload. P2P/direct and every stateful instance
|
||||
are never re-evaluated live. Phase 4 must provide disposal and state handoff
|
||||
before live replacement is allowed.
|
||||
2. A group that has not executed can immediately use newer verified cached URLs
|
||||
only when its contract and dependency contracts equal those expected by the
|
||||
running core's embedded manifest. For a different contract, keep the running
|
||||
build's N-1 URLs, set `Lazy.reloadRequired`, and request the existing Refresh UI
|
||||
banner through the unchanged meta-build versus `/api/version` comparison.
|
||||
3. New core boot may receive a verified N-1 response for an uncached new URL only
|
||||
when group contracts match. The response retains its actual hash; it is never
|
||||
stored under the new hash. Contract changes join the blocking download set.
|
||||
|
||||
Recommended owner decision: background-download and verify the new URLs
|
||||
immediately, but pin an already-executing stale module until its feature session
|
||||
closes and can be safely recreated. Keep P2P/direct instances until the next
|
||||
explicit user-initiated, playback-guarded page reload. Groups never executed use
|
||||
the newest cached version immediately. In Phase 4, introduce explicit teardown
|
||||
and state handoff seams before enabling live replacement of these instances.
|
||||
This narrows "switch when the new one arrives" for stateful modules; it requires
|
||||
owner approval rather than silently claiming that caching new bytes replaces
|
||||
running code.
|
||||
Reason: direct-media.js owns private room, pending transfer and waiting maps;
|
||||
WebRTC callbacks retain these closures. Re-evaluating the singleton redirects
|
||||
messages away from those transfers. P2P sockets/timers and piano/MIDI/floating
|
||||
windows have similar state. Contract equality alone does not migrate it.
|
||||
|
||||
Completed safe prerequisites: plan commit 895f20a; pure contract fallback and
|
||||
background-group synchronization commit f804a23; ordered build-local loader with
|
||||
unit tests (not yet wired into index.html). No layout or feature was removed
|
||||
from the current eager path. Full browser/layout/performance acceptance remains
|
||||
outstanding. This is a QUESTION checkpoint, not a completed phase report.
|
||||
|
||||
## Accepted owner decision
|
||||
|
||||
The owner accepted pinning on 2026-10-08. Already executing stale groups remain
|
||||
pinned until their session ends or the next explicit playback-guarded reload.
|
||||
P2P/direct and other stateful instances are never re-evaluated live; Phase 4 must
|
||||
add disposal/state handoff before permitting replacement. An unexecuted group
|
||||
may use newly cached URLs only when its contract equals the running core's
|
||||
embedded contract. A different contract keeps the running build's N-1 URLs,
|
||||
marks reload required and prompts the existing build-tag-based Refresh UI banner.
|
||||
Unit tests cover all three cases. Continue Phase 3 under these rules.
|
||||
Implemented in 4853c2c and 5d6a154. Node loader tests cover pinned execution,
|
||||
compatible unexecuted selection, incompatible N-1 plus reload-required, and
|
||||
incompatible dependencies. Worker tests cover same-contract fallback without
|
||||
cache poisoning and changed-contract readiness before commit. `perf/lazy.mjs`
|
||||
checks these decisions with real workers on Chromium and WebKit.
|
||||
|
||||
Reference in New Issue
Block a user