310 lines
16 KiB
JavaScript
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)
|