110 lines
16 KiB
JavaScript
110 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const fs=require('fs');
|
|
const os=require('os');
|
|
const path=require('path');
|
|
const http=require('http');
|
|
const crypto=require('crypto');
|
|
const {spawn,spawnSync}=require('child_process');
|
|
const {assert,root}=require('./helpers/app-source');
|
|
|
|
const browserPath=process.env.BEND_FIELD_BROWSER_PATH||process.env.BEND_FIELD_EDGE_PATH||(
|
|
process.platform==='win32'?'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe':'/usr/bin/chromium'
|
|
);
|
|
const requested=(process.env.BEND_FIELD_SCALE_SIZES||'10000,100000,200000').split(',').map(Number).filter(value=>Number.isSafeInteger(value)&&value>0&&value<=200000);
|
|
if(!requested.length)throw new Error('BEND_FIELD_SCALE_SIZES did not contain a supported board count.');
|
|
const worldDbName='bend-field:v30:linkfield-single-world-20260801:world',explicitBenchmarkUrl=process.env.BEND_FIELD_BENCHMARK_URL||'',benchmarkHost=process.env.BEND_FIELD_BENCHMARK_HOST||'localhost',useExtension=process.env.BEND_FIELD_BENCHMARK_EXTENSION==='1';
|
|
const debuggingPort=22000+Math.floor(Math.random()*1000),temporaryRoot=fs.mkdtempSync(path.join(os.tmpdir(),'bend-field-v2-scale-')),profilePath=path.join(temporaryRoot,'browser-profile');
|
|
const sleep=milliseconds=>new Promise(resolve=>setTimeout(resolve,milliseconds));
|
|
function stopBrowserTree(child){
|
|
if(!child?.pid)return;
|
|
if(process.platform==='win32'){
|
|
const taskkill=path.join(process.env.SystemRoot||'C:\\Windows','System32','taskkill.exe'),
|
|
result=spawnSync(taskkill,['/pid',String(child.pid),'/T','/F'],{stdio:'ignore',windowsHide:true});
|
|
if(result.status!==0&&!child.killed)try{child.kill()}catch(_){}
|
|
}else if(!child.killed)child.kill('SIGTERM');
|
|
}
|
|
|
|
|
|
function extensionIdFromPublicKey(der){
|
|
const digest=crypto.createHash('sha256').update(der).digest().subarray(0,16);
|
|
return[...digest].map(byte=>String.fromCharCode(97+(byte>>4),97+(byte&15))).join('');
|
|
}
|
|
function prepareBenchmarkExtension(){
|
|
const directory=path.join(temporaryRoot,'extension');fs.mkdirSync(directory,{recursive:true});
|
|
for(const name of['index.html','style.css','favicon.svg','favicon.ico','puzzle-core.js','app-logic.js','field-persistence.js','field-persistence-worker.js','app.js','puzzle-worker.js'])fs.copyFileSync(path.join(root,name),path.join(directory,name));
|
|
const{publicKey}=crypto.generateKeyPairSync('rsa',{modulusLength:2048}),der=publicKey.export({type:'spki',format:'der'}),key=der.toString('base64'),id=extensionIdFromPublicKey(der);
|
|
fs.writeFileSync(path.join(directory,'manifest.json'),JSON.stringify({manifest_version:3,name:'Bend Field V2 Storage Benchmark',version:'47.36.0',key,permissions:['unlimitedStorage']},null,2));
|
|
return{directory,id,url:`chrome-extension://${id}/index.html`};
|
|
}
|
|
|
|
class CdpClient{
|
|
constructor(url){this.url=url;this.sequence=0;this.pending=new Map();this.socket=null}
|
|
async connect(){
|
|
this.socket=new WebSocket(this.url);
|
|
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('CDP connection timed out')),15000);this.socket.addEventListener('open',()=>{clearTimeout(timer);resolve()},{once:true});this.socket.addEventListener('error',event=>{clearTimeout(timer);reject(event.error||new Error('CDP connection failed'))},{once:true})});
|
|
this.socket.addEventListener('message',event=>{const message=JSON.parse(event.data);if(!message.id)return;const pending=this.pending.get(message.id);if(!pending)return;this.pending.delete(message.id);if(message.error)pending.reject(new Error(`${pending.method}: ${message.error.message}`));else pending.resolve(message.result)});
|
|
}
|
|
send(method,params={}){const id=++this.sequence;return new Promise((resolve,reject)=>{this.pending.set(id,{resolve,reject,method});this.socket.send(JSON.stringify({id,method,params}))})}
|
|
async evaluate(expression){const response=await this.send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true,userGesture:true});if(response.exceptionDetails)throw new Error(response.exceptionDetails.exception?.description||response.exceptionDetails.text||'Browser evaluation failed');return response.result.value}
|
|
close(){try{this.socket?.close()}catch(_){}}
|
|
}
|
|
async function waitFor(check,{timeout=30000,interval=50,label='condition'}={}){const deadline=Date.now()+timeout;let lastError=null;while(Date.now()<deadline){try{const value=await check();if(value)return value}catch(error){lastError=error}await sleep(interval)}throw lastError||new Error(`Timed out waiting for ${label}`)}
|
|
async function endpoint(){return waitFor(async()=>{const response=await fetch(`http://127.0.0.1:${debuggingPort}/json/list`,{signal:AbortSignal.timeout(1500)});if(!response.ok)return null;return(await response.json()).find(target=>target.type==='page'&&target.webSocketDebuggerUrl)},{timeout:20000,label:'browser DevTools endpoint'})}
|
|
async function startServer(){
|
|
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.svg':'image/svg+xml','.ttf':'font/ttf','.ico':'image/x-icon'};
|
|
const server=http.createServer((request,response)=>{const pathname=new URL(request.url,'http://localhost').pathname,relative=pathname==='/'?'index.html':decodeURIComponent(pathname.slice(1)),file=path.resolve(root,relative);if(file!==root&&!file.startsWith(root+path.sep)){response.writeHead(403);response.end();return}fs.readFile(file,(error,body)=>{if(error){response.writeHead(error.code==='ENOENT'?404:500);response.end();return}response.writeHead(200,{'content-type':types[path.extname(file)]||'application/octet-stream','cache-control':'no-store'});response.end(body)})});
|
|
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>{server.off('error',reject);resolve()})});return server;
|
|
}
|
|
const transactionDoneSource=`tx=>new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error)})`;
|
|
async function waitUntilReady(client,timeout=60000){return waitFor(()=>client.evaluate("document.body?.dataset?.ready==='true'&&globalThis.__bendStartupSnapshot"),{timeout,label:'game startup'})}
|
|
async function waitForV2(client){
|
|
return waitFor(()=>client.evaluate(`(async()=>{const db=await new Promise((resolve,reject)=>{const request=indexedDB.open(${JSON.stringify(worldDbName)});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});const tx=db.transaction('control','readonly'),request=tx.objectStore('control').get('active'),control=await new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});db.close();return control?.activeFormat===2&&control.activeEpoch})()`),{timeout:60000,label:'V2 storage migration'});
|
|
}
|
|
async function readStarter(client){
|
|
return client.evaluate(`(async()=>{const done=${transactionDoneSource},read=request=>new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)}),db=await new Promise((resolve,reject)=>{const request=indexedDB.open(${JSON.stringify(worldDbName)});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)});let tx=db.transaction('control','readonly'),control=await read(tx.objectStore('control').get('active'));await done(tx);const epoch=control.activeEpoch;tx=db.transaction(['worlds','boardIndex','boardPuzzles','boardStates'],'readonly');const[world,index,puzzle,state]=await Promise.all([read(tx.objectStore('worlds').get(epoch)),read(tx.objectStore('boardIndex').get([epoch,'B0'])),read(tx.objectStore('boardPuzzles').get([epoch,'B0'])),read(tx.objectStore('boardStates').get([epoch,'B0']))]);await done(tx);db.close();return{control,world,index,puzzle,state}})()`);
|
|
}
|
|
async function seedV2(client,starter,count){
|
|
const expression=`(async()=>{
|
|
const starter=${JSON.stringify(starter)},count=${count},dbName=${JSON.stringify(worldDbName)},batchSize=4000,
|
|
done=${transactionDoneSource},open=()=>new Promise((resolve,reject)=>{const request=indexedDB.open(dbName);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error)}),db=await open(),epoch=starter.control.activeEpoch;
|
|
let tx=db.transaction(['boardIndex','boardPuzzles','boardStates','worlds'],'readwrite');tx.objectStore('boardIndex').clear();tx.objectStore('boardPuzzles').clear();tx.objectStore('boardStates').clear();tx.objectStore('worlds').clear();await done(tx);
|
|
const puzzleValue=starter.puzzle.puzzle,stateValue=starter.state.value,started=performance.now();
|
|
for(let offset=0;offset<count;offset+=batchSize){
|
|
tx=db.transaction(['boardIndex','boardPuzzles','boardStates'],'readwrite');const indexStore=tx.objectStore('boardIndex'),puzzleStore=tx.objectStore('boardPuzzles'),stateStore=tx.objectStore('boardStates'),end=Math.min(count,offset+batchSize);
|
|
for(let number=offset;number<end;number++){
|
|
const id='B'+number,x=number%1000,y=Math.floor(number/1000),metaRev=1000000+number*2,stateRev=metaRev+1,author='scale-benchmark';
|
|
indexStore.put({epoch,id,number,x,y,chunks:[[0,0]],level:1,targetLevel:1,seed:number+1,axis:starter.index.axis,entrySide:null,metaRev,stateRev,revAuthor:author,solved:false,expanded:false,scoreAwarded:0,hasProgress:false,specialFlags:{crossing:false,warp:false,lock:false},shop:null});
|
|
puzzleStore.put({epoch,id,metaRev,revAuthor:author,generatorVersion:starter.puzzle.generatorVersion,puzzle:puzzleValue});
|
|
stateStore.put({epoch,id,stateRev,revAuthor:author,value:{...stateValue,paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,scoreAwarded:0,store:null,rev:stateRev,revAuthor:author}});
|
|
}
|
|
await done(tx);await new Promise(resolve=>setTimeout(resolve,0));
|
|
}
|
|
const global={...starter.world.global,worldEpoch:epoch,nextId:count,solved:0,score:0,globalRev:(starter.world.global.globalRev||0)+count+1,globalRevAuthor:'scale-benchmark',selectedBoardId:'B0',cameraAnchor:null,updatedAt:Date.now()},world={...starter.world,epoch,status:'active',global,boardCount:count,solvedCount:0,score:0,bounds:{minX:0,minY:0,maxX:Math.min(1000,count),maxY:Math.ceil(count/1000)},approximateBytes:count*2048,source:{kind:'benchmark'},progress:null};
|
|
tx=db.transaction(['control','worlds'],'readwrite');tx.objectStore('worlds').put(world);tx.objectStore('control').put({...starter.control,key:'active',activeFormat:2,activeEpoch:epoch,previousEpoch:undefined,activationVerified:true});await done(tx);db.close();localStorage.clear();sessionStorage.clear();return{seedMs:performance.now()-started,count};
|
|
})()`;
|
|
const result=await client.evaluate(expression);assert(result?.count===count,`Only ${result?.count||0} of ${count} V2 boards were seeded`);return result;
|
|
}
|
|
async function measure(client,count){
|
|
const navigationStarted=Date.now();await client.send('Page.reload',{ignoreCache:true});const startup=await waitUntilReady(client,180000),readyMs=Date.now()-navigationStarted;
|
|
const snapshot=await client.evaluate(`(()=>({capture:globalThis.__bendStartupSnapshot,ready:document.body.dataset.ready,loaded:fieldIndexLoadedCount,expected:fieldIndexExpectedCount,complete:fieldIndexComplete,b0:Boolean(data?.metas?.B0?.puzzle),navigation:performance.getEntriesByType('navigation')[0]?.duration||0,memory:performance.memory?.usedJSHeapSize||null}))()`);
|
|
assert(snapshot.ready==='true'&&snapshot.b0,`${count} board V2 startup did not hydrate B0`);assert(snapshot.capture.expected===count,`${count} board V2 startup declared ${snapshot.capture.expected} boards`);assert(snapshot.capture.loaded<=512,`${count} board V2 startup eagerly loaded ${snapshot.capture.loaded} board indexes`);assert(snapshot.capture.complete===false,`${count} board V2 startup completed the full index scan before first paint`);
|
|
const scanStarted=Date.now();await waitFor(()=>client.evaluate('fieldIndexComplete===true&&fieldIndexLoadedCount===fieldIndexExpectedCount'),{timeout:600000,interval:100,label:`${count} board index scan`});const scanMs=Date.now()-scanStarted;
|
|
const final=await client.evaluate(`({loaded:fieldIndexLoadedCount,expected:fieldIndexExpectedCount,metas:Object.keys(data.metas).length,memory:performance.memory?.usedJSHeapSize||null})`);assert(final.loaded===count&&final.metas===count,`${count} board V2 background index scan was incomplete`);
|
|
return{boards:count,readyMs,navigationMs:snapshot.navigation,capturedReadyMs:snapshot.capture.at,initialIndexes:snapshot.capture.loaded,scanMs,heapAtReady:snapshot.memory,heapAfterScan:final.memory};
|
|
}
|
|
async function main(){
|
|
if(!fs.existsSync(browserPath))throw new Error(`Browser was not found at ${browserPath}`);
|
|
const extension=useExtension?prepareBenchmarkExtension():null;console.log(`Launching ${browserPath}`);const server=await startServer(),port=server.address().port,args=['--headless','--no-first-run','--no-sandbox','--enable-unsafe-swiftshader','--allow-file-access-from-files','--disable-dev-shm-usage','--disable-background-networking',`--remote-debugging-port=${debuggingPort}`,'--remote-allow-origins=*',`--user-data-dir=${profilePath}`,'--window-size=1280,900','about:blank'];if(extension){args.splice(-1,0,`--disable-extensions-except=${extension.directory}`,`--load-extension=${extension.directory}`)}const browser=spawn(browserPath,args,{stdio:'ignore'});let client=null;
|
|
try{
|
|
console.log('Waiting for DevTools');const target=await endpoint();console.log('Connecting to page');client=new CdpClient(target.webSocketDebuggerUrl);await client.connect();await client.send('Page.enable');await client.send('Runtime.enable');
|
|
await client.send('Page.addScriptToEvaluateOnNewDocument',{source:`(()=>{const capture=()=>{if(document.body?.dataset?.ready==='true'&&!globalThis.__bendStartupSnapshot)globalThis.__bendStartupSnapshot={at:performance.now(),loaded:typeof fieldIndexLoadedCount==='number'?fieldIndexLoadedCount:null,expected:typeof fieldIndexExpectedCount==='number'?fieldIndexExpectedCount:null,complete:typeof fieldIndexComplete==='boolean'?fieldIndexComplete:null};};const observe=()=>{if(document.documentElement)new MutationObserver(capture).observe(document.documentElement,{subtree:true,attributes:true,attributeFilter:['data-ready']});capture();};if(document.documentElement)observe();else addEventListener('DOMContentLoaded',observe,{once:true})})();`});
|
|
const benchmarkUrl=explicitBenchmarkUrl||(extension?extension.url:`http://${benchmarkHost}:${port}/`);console.log(`Navigating to ${benchmarkUrl}`);await client.send('Page.navigate',{url:benchmarkUrl});try{await waitUntilReady(client,30000)}catch(error){const state=await client.evaluate("({href:location.href,ready:document.body?.dataset?.ready,error:document.body?.dataset?.error,status:document.querySelector('#statusMessage')?.textContent||'',text:document.body?.innerText?.slice(0,800)||'',scripts:[...document.scripts].map(s=>s.src)})").catch(()=>null);throw new Error(`${error.message}: ${JSON.stringify(state)}`)}console.log('Game ready; waiting for V2');await waitForV2(client);console.log('V2 active; reading starter');const starter=await readStarter(client);assert(starter?.index?.id==='B0'&&starter?.puzzle?.puzzle&&starter?.state?.value,'Could not read the V2 starter board');
|
|
const results=[];for(const count of requested){console.log(`Seeding ${count.toLocaleString()} boards`);const seeded=await seedV2(client,starter,count),measured=await measure(client,count),result={...measured,seedMs:seeded.seedMs};results.push(result);console.log(`${count.toLocaleString()} boards | seed ${(seeded.seedMs/1000).toFixed(1)} s | ready ${measured.readyMs} ms (${measured.initialIndexes} indexes) | full scan ${(measured.scanMs/1000).toFixed(1)} s`)}
|
|
console.log(`FIELD_STORAGE_BENCHMARK_JSON=${JSON.stringify(results)}`);console.log('V2 field storage scale benchmark passed');
|
|
}finally{
|
|
client?.close();stopBrowserTree(browser);
|
|
server.closeAllConnections?.();await new Promise(resolve=>server.close(()=>resolve()));for(let attempt=0;attempt<8;attempt++){try{fs.rmSync(temporaryRoot,{recursive:true,force:true});break}catch(error){if(attempt===7)console.warn(`Benchmark cleanup deferred: ${error.message}`);await sleep(200)}}
|
|
}
|
|
}
|
|
main().then(()=>process.exit(0)).catch(error=>{console.error(error);process.exit(1)});
|