Files
goodbuddy/tests/support/startup-perf-driver.mjs

310 lines
16 KiB
JavaScript

// Cold-start measurement driver (PERF-01). Launched by
// build/run-startup-perf.cjs, once per measured launch. Runs inside the
// Electron main process: isolates the profile, blocks every non-loopback
// request, imports the current production Main and records when startup
// milestones happen. All times are epoch milliseconds (timeOrigin + now) so the
// supervisor can relate them to its spawn time. The report holds only phase
// names, times and counts; never conversation text or file paths.
//
// GB_STARTUP_MODE=prepare builds a reusable profile instead of measuring:
// acknowledges the release notes, disables the startup update check, seeds
// GB_STARTUP_SEED synthetic conversations, then quits through the normal
// shutdown path so the databases are checkpointed and closed.
import { app, BrowserWindow, session } from 'electron'
import { randomUUID } from 'node:crypto'
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
import process from 'node:process'
import { clearTimeout, setTimeout } from 'node:timers'
import { setTimeout as sleep } from 'node:timers/promises'
import { pathToFileURL, URL } from 'node:url'
const now = () => performance.timeOrigin + performance.now()
const driverStart = now()
const directory = process.env.GB_STARTUP_DIR
const resultPath = process.env.GB_STARTUP_RESULT
const root = process.env.GB_PERF_ROOT
if (!directory || !resultPath || !root) throw new Error('Run through build/run-startup-perf.cjs')
const mode = process.env.GB_STARTUP_MODE === 'prepare' ? 'prepare' : 'measure'
const profilePath = process.env.GB_STARTUP_CPU_PROFILE
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
// Same isolation as tests/support/app-perf-driver.mjs; it must happen before
// the production Main module loads (single-instance lock, storage paths).
app.setAppPath(root)
app.setName(packageJson.name)
app.setVersion(packageJson.version)
app.setPath('userData', join(directory, 'profile'))
app.setPath('sessionData', join(directory, 'session'))
app.setAppLogsPath(join(directory, 'logs'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
const report = { mode, status: 'running', times: { driverStart }, blockedRequests: { fetch: [], renderer: [] }, renderer: {}, flags: {} }
const times = report.times
const mark = name => { if (times[name] === undefined) times[name] = now() }
const writeReport = () => writeFileSync(resultPath, JSON.stringify(report, null, 2))
const mainDelay = monitorEventLoopDelay({ resolution: 5 })
mainDelay.enable()
const deadline = setTimeout(() => {
report.status = 'failed'
report.error = 'Driver timeout'
writeReport()
app.exit(1)
}, Number(process.env.GB_STARTUP_DRIVER_TIMEOUT_MS || 120_000))
// No external requests: the fake model and everything else must be loopback.
const isLoopback = url => {
try {
const { hostname, protocol } = new URL(url)
if (!['http:', 'https:', 'ws:', 'wss:'].includes(protocol)) return true
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
} catch { return true }
}
const hostOf = url => { try { return new URL(url).hostname } catch { return 'invalid' } }
const originalFetch = globalThis.fetch
globalThis.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input?.url
if (url && !isLoopback(url)) {
report.blockedRequests.fetch.push(hostOf(url))
return Promise.reject(new TypeError('blocked by startup benchmark'))
}
return originalFetch(input, init)
}
let profiler
if (profilePath) {
const inspector = await import('node:inspector')
profiler = new inspector.Session()
profiler.connect()
const post = (method, params) => new Promise((resolve, reject) => profiler.post(method, params, (error, result) => error ? reject(error) : resolve(result)))
profiler.postAsync = post
await post('Profiler.enable')
await post('Profiler.setSamplingInterval', { interval: 250 })
await post('Profiler.start')
}
app.once('ready', () => mark('appReady'))
// Registered before Main imports, so it runs before Main's whenReady handler.
void app.whenReady().then(() => {
session.defaultSession.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, (details, callback) => {
if (isLoopback(details.url)) { callback({}); return }
report.blockedRequests.renderer.push(hostOf(details.url))
callback({ cancel: true })
})
})
let win
app.on('browser-window-created', (_event, created) => {
if (win) return
win = created
mark('windowCreated')
created.once('ready-to-show', () => mark('readyToShow'))
created.once('show', () => mark('windowShown'))
created.webContents.once('did-start-loading', () => mark('didStartLoading'))
created.webContents.once('dom-ready', () => { mark('domReady'); void installRendererProbe() })
created.webContents.once('did-finish-load', () => mark('didFinishLoad'))
created.webContents.on('render-process-gone', (_e, details) => { report.error = `render-process-gone ${details.reason}` })
})
mark('mainImportStart')
await import(pathToFileURL(join(root, 'out', 'main', 'index.js')).href)
mark('mainImportEnd')
const js = code => win.webContents.executeJavaScript(code, true)
// Composer is interactive when it exists, is enabled, laid out, and not inside
// an inert or hidden subtree (a modal dialog makes the app shell inert).
const composerSelector = '.composer__input textarea'
const expectedConversations = mode === 'measure' ? Math.max(0, Number(process.env.GB_STARTUP_EXPECT_CONVERSATIONS || 0)) : 0
const rendererProbe = `new Promise(resolve => {
const origin = performance.timeOrigin;
const at = () => origin + performance.now();
const r = { installedAt: at(), visibleAtInstall: document.visibilityState === 'visible' };
// Chromium does not report first-contentful-paint when the first paint
// happens while the page is hidden, and the main window is created hidden
// until ready-to-show. "First visible frame" is the second animation frame
// after the page becomes visible, i.e. a frame actually presented on screen.
let frameDone = false;
const firstVisibleFrame = () => {
r.visibleAt = at();
requestAnimationFrame(() => requestAnimationFrame(() => { r.firstVisibleFrame = at(); frameDone = true; if (check()) finish(); }));
};
if (document.visibilityState === 'visible') firstVisibleFrame();
else document.addEventListener('visibilitychange', function onVisible() { if (document.visibilityState !== 'visible') return; document.removeEventListener('visibilitychange', onVisible); firstVisibleFrame(); });
const check = () => {
const c = document.querySelector(${JSON.stringify(composerSelector)});
if (c && r.composerPresent === undefined) r.composerPresent = at();
if (!r.firstConversationRow && document.querySelector('button.conversation-item')) r.firstConversationRow = at();
if (!r.startupDialog && document.querySelector('section.release-notes-dialog')) r.startupDialog = at();
if (c && r.composerInteractive === undefined && !c.disabled && c.getClientRects().length > 0 && !c.closest('[inert], [hidden], [aria-hidden="true"]')) r.composerInteractive = at();
// Seeded profiles: the sidebar is populated once the virtualized list
// reports the seeded total (or mounts enough rows to fill the viewport).
if (${expectedConversations} > 0 && r.sidebarPopulated === undefined) {
const rows = document.querySelectorAll('button.conversation-item').length;
const entry = document.querySelector('.conversation-list [aria-setsize]');
const total = entry ? Number(entry.getAttribute('aria-setsize')) : rows;
if (total >= ${expectedConversations} && rows >= ${Math.min(10, expectedConversations)}) r.sidebarPopulated = at();
}
const sidebarDone = ${expectedConversations} === 0 || r.sidebarPopulated !== undefined;
return frameDone && sidebarDone && (r.composerInteractive !== undefined || (r.composerPresent !== undefined && r.startupDialog !== undefined));
};
let observer;
let settled = false;
const finish = () => { if (settled) return; settled = true; observer?.disconnect(); resolve(r); };
if (check()) { finish(); return; }
observer = new MutationObserver(() => { if (check()) finish(); });
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['inert', 'hidden', 'disabled', 'aria-hidden', 'class'] });
setTimeout(() => { r.timedOut = true; finish(); }, 60000);
})`
let probeResult
async function installRendererProbe() {
try { probeResult = await js(rendererProbe) } catch (error) { report.error = `renderer probe: ${error instanceof Error ? error.message : error}` }
}
async function collectRenderer() {
const data = await js(`(() => {
const origin = performance.timeOrigin;
const nav = performance.getEntriesByType('navigation')[0];
const paint = Object.fromEntries(performance.getEntriesByType('paint').map(e => [e.name, origin + e.startTime]));
const scripts = performance.getEntriesByType('resource').filter(e => e.initiatorType === 'script' || /\\.js$/.test(e.name));
return {
timeOrigin: origin,
navigationStart: nav ? origin + nav.startTime : null,
responseEnd: nav ? origin + nav.responseEnd : null,
domInteractive: nav ? origin + nav.domInteractive : null,
domContentLoaded: nav ? origin + nav.domContentLoadedEventEnd : null,
loadEventEnd: nav ? origin + nav.loadEventEnd : null,
firstPaint: paint['first-paint'] ?? null,
firstContentfulPaint: paint['first-contentful-paint'] ?? null,
scriptResources: scripts.length,
scriptBytes: scripts.reduce((a, e) => a + (e.decodedBodySize || 0), 0),
jsHeapMB: performance.memory ? performance.memory.usedJSHeapSize / 1048576 : null,
domNodes: document.getElementsByTagName('*').length,
conversationRows: document.querySelectorAll('button.conversation-item').length
};
})()`)
return data
}
// Long animation frames are buffered by Chromium, so the ones during startup
// are visible even though the observer is installed after dom-ready.
const loafProbe = `new Promise(resolve => {
const items = [];
try {
new PerformanceObserver(list => { for (const e of list.getEntries()) items.push(e); }).observe({ type: 'long-animation-frame', buffered: true });
} catch { resolve(null); return; }
setTimeout(() => {
const origin = performance.timeOrigin;
resolve({ count: items.length, totalMs: items.reduce((a, e) => a + e.duration, 0), blockingMs: items.reduce((a, e) => a + e.blockingDuration, 0),
maxMs: items.reduce((a, e) => Math.max(a, e.duration), 0),
scriptMs: items.reduce((a, e) => a + e.scripts.reduce((s, x) => s + x.duration, 0), 0),
frames: items.slice(0, 20).map(e => ({ at: origin + e.startTime, duration: e.duration, scriptMs: e.scripts.reduce((s, x) => s + x.duration, 0) })) });
}, 50);
})`
function profileHotspots(profile) {
const byId = new Map(profile.nodes.map(node => [node.id, node]))
const parent = new Map()
for (const node of profile.nodes) for (const child of node.children ?? []) parent.set(child, node.id)
const self = new Map()
for (let index = 0; index < profile.samples.length; index += 1) {
const id = profile.samples[index]
self.set(id, (self.get(id) ?? 0) + (profile.timeDeltas[index] ?? 0))
}
const keyOf = frame => {
const file = frame.url ? frame.url.replace(/^file:\/\/\/?/, '').split(/[\\/]/).slice(-3).join('/') : `(${frame.functionName || 'native'})`
return `${frame.functionName || '(anonymous)'} ${file}:${frame.lineNumber + 1}`
}
const selfByKey = new Map()
const inclusiveByKey = new Map()
let total = 0
for (const [id, micros] of self) {
total += micros
const key = keyOf(byId.get(id).callFrame)
selfByKey.set(key, (selfByKey.get(key) ?? 0) + micros)
const seen = new Set()
for (let cursor = id; cursor !== undefined; cursor = parent.get(cursor)) {
const k = keyOf(byId.get(cursor).callFrame)
if (seen.has(k)) continue
seen.add(k)
inclusiveByKey.set(k, (inclusiveByKey.get(k) ?? 0) + micros)
}
}
const top = (map, count, filter = () => true) => [...map].filter(([key]) => filter(key)).sort((a, b) => b[1] - a[1]).slice(0, count)
.map(([key, micros]) => ({ key, ms: Math.round(micros / 100) / 10 }))
const own = key => /out\/main\//.test(key)
return {
sampledMs: Math.round(total / 1000),
selfTop: top(selfByKey, 30),
inclusiveOutMain: top(inclusiveByKey, 40, own)
}
}
async function prepare() {
await js(`window.goodbuddy.updates.updateSettings({ checkUpdatesOnStartup: false }).then(() => true)`)
await js(`window.goodbuddy.releaseNotes.getPending().then(s => s.releases.some(r => r.version === s.currentVersion) ? window.goodbuddy.releaseNotes.acknowledge(s.currentVersion) : undefined).then(() => true)`)
const count = Number(process.env.GB_STARTUP_SEED || 0)
const perConversation = Number(process.env.GB_STARTUP_SEED_MESSAGES || 20)
if (count > 0) {
const projectId = await js(`window.goodbuddy.projects.list(false).then(list => list.find(p => p.kind === 'user' && p.executionSpace.kind === 'local')?.id)`)
if (!projectId) throw new Error('No local project to seed')
const base = Date.now()
const items = []
for (let index = 0; index < count; index += 1) {
const updatedAt = base - (index + 1) * 60_000
const messages = []
for (let m = 0; m < perConversation; m += 1) {
const role = m % 2 === 0 ? 'user' : 'assistant'
const content = role === 'user'
? `Question ${m}: how should the benchmark handle item ${m}?`
: `## Answer ${m}\n\nThe benchmark answer for item ${m} contains **formatting**, \`inline code\`, and a list:\n\n- point one\n- point two\n\n\`\`\`ts\nconst value${m} = ${m}\n\`\`\``
messages.push({ id: randomUUID(), role, content, createdAt: updatedAt - (perConversation - m) * 1000, state: 'complete' })
}
items.push({ header: { id: randomUUID(), projectId, title: `Perf conversation ${String(index + 1).padStart(4, '0')}`, updatedAt, knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' }, messages })
}
for (let offset = 0; offset < items.length; offset += 100) {
await js(`window.goodbuddy.conversations.saveLocal(${JSON.stringify(items.slice(offset, offset + 100))}).then(() => true)`)
}
report.seeded = { conversations: count, messagesPerConversation: perConversation }
}
}
async function run() {
try {
for (let i = 0; i < 600 && !win; i += 1) await sleep(50)
if (!win) throw new Error('Main window was not created')
for (let i = 0; i < 1200 && probeResult === undefined; i += 1) await sleep(25)
if (!probeResult) throw new Error(report.error ?? 'Renderer probe did not finish')
mark('probeResolvedInMain')
if (profiler) {
const { profile } = await profiler.postAsync('Profiler.stop')
writeFileSync(profilePath, JSON.stringify(profile))
report.cpuProfile = profileHotspots(profile)
}
Object.assign(report.renderer, probeResult)
Object.assign(report.renderer, await collectRenderer())
report.renderer.longAnimationFrames = await js(loafProbe).catch(() => null)
report.startupMarks = (globalThis.__goodbuddyStartupMarks ?? []).map(({ name, at }) => ({ name, at: performance.timeOrigin + at }))
report.mainEventLoopDelay = { maxMs: mainDelay.max / 1e6, p99Ms: mainDelay.percentile(99) / 1e6, meanMs: mainDelay.mean / 1e6 }
report.flags.windowCount = BrowserWindow.getAllWindows().length
if (mode === 'prepare') await prepare()
report.status = 'passed'
writeReport()
clearTimeout(deadline)
if (mode === 'prepare') app.quit()
else app.exit(0)
} catch (error) {
report.status = 'failed'
report.error = error instanceof Error ? error.message : String(error)
writeReport()
clearTimeout(deadline)
app.exit(1)
}
}
// Electron emits `ready` only after this ESM entry finishes evaluating.
void app.whenReady().then(run)