Files
opencode/packages/desktop/scripts/bench-startup.ts

837 lines
42 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
// Cold-start benchmark for a packaged desktop build.
//
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
// [--trace] [--out <dir>] [--home <dir>] [--window-at x,y]
//
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
// attaches to, restarts or reads the developer's live service or state and does not inherit their
// telemetry configuration. `warm` starts one service from the bundled CLI before the runs and lets
// every launch reuse it; `cold` stops it before each run so the desktop has to spawn it. `--fresh`
// wipes the profile before each launch to measure the first launch after an install. `--compare`
// alternates launches of a second build so machine drift affects both equally, and `--warmup`
// launches are discarded (the first launch of a new binary pays the antivirus scan).
//
// Milestones come from the main log, the renderer's performance timeline and DOM readiness polled
// over CDP, plus Node's own bootstrap timing read from the main process afterwards over --inspect
// (nothing attaches until the run is over), which splits the time before the first log line into
// Electron/Chromium native init, Node bootstrap and our main bundle. `--profile-main` records a
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
//
// What is on screen is sampled from the screen itself (Windows): a helper pins the window topmost
// without activating it the moment it exists, then records when the window's pixels first differ
// from the background colour (`screenPainted`) and when they stop changing (`screenSettled`).
// Renderer paint timing alone is not enough: Chromium stops painting an occluded window, and a
// splash or a fade reads as "painted" long before the interface is on screen. `--window-at` puts
// the window somewhere the developer's foreground window does not cover. BENCH_SCREEN_DUMP=<dir>
// also saves every sample as PNG, BENCH_EXTRA_ARGS passes extra Chromium switches to the app.
import { execFileSync, spawn } from "node:child_process"
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { createServer } from "node:net"
import { tmpdir } from "node:os"
import { basename, dirname, join, relative, resolve } from "node:path"
import { parseArgs } from "node:util"
const args = parseArgs({
args: process.argv.slice(2),
options: {
exe: { type: "string" },
compare: { type: "string" },
runs: { type: "string", default: "5" },
warmup: { type: "string", default: "1" },
service: { type: "string", default: "warm" },
fresh: { type: "boolean", default: false },
offline: { type: "boolean", default: false },
seed: { type: "string" },
home: { type: "string" },
out: { type: "string" },
"profile-main": { type: "boolean", default: false },
"profile-renderer": { type: "boolean", default: false },
trace: { type: "boolean", default: false },
"settle-ms": { type: "string", default: "1500" },
// Restore the bench window at "x,y" (and treat that display as trusted), for example on a display
// that the developer's foreground window does not cover; Chromium stops painting an occluded window.
"window-at": { type: "string" },
},
allowPositionals: true,
})
const packageDir = resolve(import.meta.dirname, "..")
const builds = [
{ label: args.values.compare ? "A" : "", exe: resolve(args.values.exe ?? defaultExe()) },
...(args.values.compare ? [{ label: "B", exe: resolve(args.values.compare) }] : []),
]
const runs = Number(args.values.runs)
const warmup = Number(args.values.warmup)
const service = args.values.service === "cold" ? "cold" : "warm"
const settleMs = Number(args.values["settle-ms"])
const outDir = resolve(args.values.out ?? join(packageDir, "dist", "bench-startup"))
const home = resolve(args.values.home ?? join(tmpdir(), "opencode-bench-startup"))
for (const build of builds) {
if (!existsSync(build.exe))
throw new Error(`Packaged executable not found: ${build.exe}. Run 'bun run build && bun run package:win' (or pass --exe).`)
}
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer")
if (!Number.isSafeInteger(warmup) || warmup < 0) throw new Error("--warmup must be a non-negative integer")
mkdirSync(outDir, { recursive: true })
const appId = appIdFor(builds[0].exe)
if (builds.some((build) => appIdFor(build.exe) !== appId)) throw new Error("Compared builds must be the same channel")
const userData = join(home, "AppData", "Roaming", appId)
const paths = {
home,
appData: join(home, "AppData", "Roaming"),
localAppData: join(home, "AppData", "Local"),
temp: join(home, "AppData", "Local", "Temp"),
db: join(home, ".local", "share", "opencode", "opencode.db"),
config: join(home, ".config", "opencode"),
registration: join(home, ".local", "state", "opencode", "service.json"),
logs: join(userData, "logs"),
}
prepareHome()
// The desktop deletes XDG_STATE_HOME on Windows, so isolation goes through the home directory.
// OPENCODE_* and OTEL_* from the developer's shell would otherwise leak into the measured app and
// its service (an OTLP endpoint alone adds a network round trip to every CLI exit).
const env = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|OTEL_|SENTRY_)/.test(key))),
USERPROFILE: home,
HOME: home,
APPDATA: paths.appData,
LOCALAPPDATA: paths.localAppData,
TEMP: paths.temp,
TMP: paths.temp,
XDG_DATA_HOME: join(home, ".local", "share"),
XDG_CONFIG_HOME: join(home, ".config"),
XDG_CACHE_HOME: join(home, ".cache"),
OPENCODE_DB: paths.db,
OPENCODE_CONFIG_DIR: paths.config,
// Beta and prod builds check for updates on start; a closed proxy port fails that fast and offline.
...(args.values.offline || appId !== "ai.opencode.desktop.dev" ? { HTTPS_PROXY: "http://127.0.0.1:9" } : {}),
}
const cdpPort = await freePort()
// A private service port keeps a cold launch's own service away from the developer's service.
const servicePort = await freePort()
writeFileSync(join(paths.config, "service.json"), JSON.stringify({ port: servicePort }))
const inspectPort = await freePort()
let appPid: number | undefined
let serviceProcess: ReturnType<typeof spawn> | undefined
// Renderer readiness, read over CDP: paint timing plus the DOM states the user actually waits for.
const probe = `(() => ({
origin: performance.timeOrigin,
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
prepaint: !!document.getElementById('oc-prepaint'),
visible: document.visibilityState === 'visible',
shell: !!document.querySelector('#root [data-titlebar-tab-link], #root [data-action="vertical-tabs-home"]'),
editor: !!document.querySelector('#root [data-component="composer-editor"][contenteditable="true"]'),
rows: document.querySelectorAll('#root [data-timeline-row]').length,
home: !!document.querySelector('#root [data-action="home-new-session"], #root [data-action="home-add-project-row"]'),
url: location.pathname + location.search,
}))()`
// Main-process bootstrap timing, read after the run: when the process was created, when Node
// started inside it, and when Node's own bootstrap finished and handed control to the entry module.
const mainTiming = `JSON.stringify({ created: process.getCreationTime(), origin: performance.timeOrigin, ...performance.nodeTiming.toJSON(), cpu: process.cpuUsage(), rss: process.memoryUsage().rss })`
// Renderer navigation and resource timing plus any marks the app emitted, read once the run is settled.
const rendererTimeline = `(() => {
const nav = performance.getEntriesByType('navigation')[0]
const resources = performance.getEntriesByType('resource')
const scripts = resources.filter((r) => r.initiatorType === 'script' || r.name.endsWith('.js'))
return {
navigation: nav && { fetchStart: nav.fetchStart, responseEnd: nav.responseEnd, domInteractive: nav.domInteractive, domContentLoaded: nav.domContentLoadedEventEnd, load: nav.loadEventEnd },
resources: { count: resources.length, scripts: scripts.length, firstStart: Math.min(...resources.map((r) => r.startTime)), lastEnd: Math.max(...resources.map((r) => r.responseEnd)), bytes: resources.reduce((n, r) => n + (r.encodedBodySize || 0), 0) },
paint: Object.fromEntries(performance.getEntriesByType('paint').map((e) => [e.name, e.startTime])),
marks: performance.getEntriesByType('mark').map((e) => [e.name, Math.round(e.startTime)]),
measures: performance.getEntriesByType('measure').map((e) => [e.name, Math.round(e.startTime), Math.round(e.duration)]),
}
})()`
for (const build of builds) console.log(`bench${build.label ? ` ${build.label}` : ""}: ${build.exe}`)
console.log(`home: ${home}`)
console.log(`service: ${service}, runs: ${runs} (+${warmup} warm-up), cdp ${cdpPort}, inspect ${inspectPort}${args.values.fresh ? ", fresh profile per launch" : ""}`)
if (service === "warm") await warmService()
const samples: Sample[] = []
// A launch that never produces a renderer would otherwise leave an instance behind that every later
// launch hands off to through the single-instance lock.
process.on("uncaughtException", async (error) => {
console.error(error)
await killApp()
await stopService()
process.exit(1)
})
for (let run = 1 - warmup; run <= runs; run++) {
for (const build of builds) {
const sample = await launch(build, run)
if (run < 1) {
console.log(`warm-up${build.label ? ` ${build.label}` : ""}: shell ${sample.msSinceSpawn.shellVisible} ms`)
continue
}
samples.push(sample)
console.log(JSON.stringify(sample))
}
}
await killApp()
if (service === "warm") await stopService()
const summaries = Object.fromEntries(
builds.map((build) => {
const own = samples.filter((s) => s.build === build.label)
const timed = own.filter((s) => !s.profiled && !s.traced)
return [build.label || "A", summarize(timed.length ? timed : own)]
}),
)
// Durations between consecutive checkpoints, so "where did the time go" needs no subtraction.
const phaseOrder = [
["electron native init", "processCreated", "nodeStart"],
["node bootstrap", "nodeStart", "nodeBootstrapped"],
["electron js init → entry", "nodeBootstrapped", "entryStart"],
["entry → chromium ready", "entryStart", "electronReady"],
["ready → window shown", "electronReady", "windowVisible"],
["window → renderer assets served", "windowVisible", "rendererAssetsServed"],
["main bundle load + evaluate", "rendererAssetsServed", "bundleEvaluated"],
["bundle → onboarding decided", "bundleEvaluated", "onboardingDecided"],
["onboarding → logging ready", "onboardingDecided", "loggingReady"],
["logging → first log line", "loggingReady", "appStarting"],
["first log line → storage open", "appStarting", "storageOpen"],
["storage → initialization done", "storageOpen", "initializationDone"],
["initialization → layers ready", "initializationDone", "layersReady"],
["layers → renderer process", "appStarting", "rendererProcess"],
["renderer boot → first paint", "rendererProcess", "firstPaint"],
["first paint → shell", "firstPaint", "shellVisible"],
["shell → idle", "shellVisible", "rendererIdle"],
] as const
const phases = Object.fromEntries(
builds.map((build) => {
const own = samples.filter((s) => s.build === build.label && !s.profiled && !s.traced)
const out: Record<string, number> = {}
for (const [name, from, to] of phaseOrder) {
const deltas = own
.map((s) => (s.msSinceSpawn[to] ?? NaN) - (s.msSinceSpawn[from] ?? NaN))
.filter(Number.isFinite)
.sort((a, b) => a - b)
if (deltas.length) out[name] = deltas[Math.floor(deltas.length / 2)]
}
return [build.label || "A", out]
}),
)
const report = { builds, service, runs, warmup, fresh: args.values.fresh, home, summaries, phases, samples }
const reportPath = join(outDir, `startup-${Date.now()}.json`)
writeFileSync(reportPath, JSON.stringify(report, null, 2))
const labels = Object.keys(summaries)
const header = `${"".padEnd(32)}${labels.map((label) => (labels.length > 1 ? label : "").padStart(6).padEnd(22)).join("")}`
console.log(`\n${service} service${args.values.fresh ? ", fresh profile" : ""} — median (min…max) ms since spawn over ${runs} runs`)
console.log(header)
for (const key of [...new Set(labels.flatMap((label) => Object.keys(summaries[label])))]) {
const cells = labels.map((label) => {
const v = summaries[label][key]
return v ? `${String(v.median).padStart(6)} (${v.min}…${v.max})`.padEnd(22) : "".padEnd(22)
})
console.log(`${key.padEnd(32)}${cells.join("")}`)
}
console.log(`\nphases — median ms`)
console.log(header)
for (const [name] of phaseOrder) {
const cells = labels.map((label) => String(phases[label][name] ?? "").padStart(6).padEnd(22))
console.log(`${name.padEnd(32)}${cells.join("")}`)
}
const idle = (label: string) => samples.filter((s) => s.build === label && !s.profiled && !s.traced).at(-1)
console.log(`\nmemory once idle (last run) — working set MB per process, main CPU ms`)
for (const label of labels) {
const s = idle(label === "A" && labels.length === 1 ? "" : label)
if (!s) continue
const total = s.processes.reduce((n, p) => n + p.rssMB, 0)
console.log(`${(labels.length > 1 ? label : "").padEnd(4)}total ${total} MB · main cpu ${s.mainCpuMs ?? "?"} ms · renderer heap ${s.renderer.jsHeapMB} MB, ${s.renderer.domNodes} nodes, layout ${s.renderer.layoutMs} ms/${s.renderer.layouts}×, style ${s.renderer.styleMs} ms/${s.renderer.styleRecalcs}×`)
console.log(` ${s.processes.map((p) => `${p.name} ${p.rssMB}`).join(" · ")}`)
}
console.log(`\nreport: ${reportPath}`)
process.exit(0)
// ---------------------------------------------------------------------------------------------
type Probe = {
origin: number
firstPaint?: number
domInteractive?: number
prepaint: boolean
visible: boolean
shell: boolean
editor: boolean
rows: number
home: boolean
url: string
}
type Sample = {
build: string
run: number
profiled: boolean
traced?: string
mainProfile?: string
rendererProfile?: string
msSinceSpawn: Record<string, number | undefined>
final: { url?: string; timelineRows?: number }
rendererCpu: { taskMs: number; scriptMs: number }
// Renderer main-thread breakdown from Performance.getMetrics at the end of the run.
renderer: Record<string, number>
rendererTimeline: unknown
// Working set per process in the app's tree (main, renderer, GPU, utility) once idle, in MB, and
// the main process's CPU time.
processes: { name: string; pid: number; rssMB: number }[]
mainCpuMs?: number
// Every timestamped line from the run's log directory (main, crash, onboarding, window, …) as
// [ms since spawn, file, message].
timeline: [number, string, string][]
}
async function launch(build: { label: string; exe: string }, run: number): Promise<Sample> {
await killApp()
if (service === "cold") await stopService()
if (args.values.fresh) rmSync(userData, { recursive: true, force: true })
const profile = args.values["profile-main"] && run === 1
const trace = args.values.trace && run === runs
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
const launchArgs = [
...(process.env.BENCH_EXTRA_ARGS?.split(" ").filter(Boolean) ?? []),
`--remote-debugging-port=${cdpPort}`,
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
...(trace
? [
"--trace-startup=*,disabled-by-default-v8.cpu_profiler",
"--trace-startup-duration=6",
"--trace-startup-format=json",
`--trace-startup-file=${tracePath}`,
]
: []),
]
const raiser = await windowRaiser()
const spawnAt = Date.now()
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
child.unref()
appPid = child.pid
raiser.raise(child.pid!)
let mainProfile: Promise<unknown> | undefined
if (profile) mainProfile = profileMain(spawnAt)
const page = await waitFor(
() => targets(cdpPort).then((list) => list.find((t) => t.type === "page" && t.url.startsWith("oc://"))),
60_000,
)
const cdp = await connect(page.webSocketDebuggerUrl)
await cdp.send("Runtime.enable")
await cdp.send("Performance.enable")
const rendererProfile = args.values["profile-renderer"] && run === 1
if (rendererProfile) {
await cdp.send("Profiler.enable")
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
await cdp.send("Profiler.start")
}
// Poll DOM readiness and the renderer's cumulative main-thread task time together. The run ends
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
const seen: Record<string, number> = {}
let last: Probe | undefined
let prepaintSeen = false
let quietSince: number | undefined
let taskMs = 0
let scriptMs = 0
const window: { at: number; task: number }[] = []
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
last = result.result?.result?.value as Probe | undefined
const t = Date.now() - spawnAt
if (last?.prepaint) prepaintSeen = true
// Chromium marks a window it considers occluded hidden and the renderer stops painting.
if (last?.visible && !seen.documentVisible) seen.documentVisible = t
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
if (last?.home && !seen.homeReady) seen.homeReady = t
const metrics = (await cdp.send("Performance.getMetrics")).result?.metrics as { name: string; value: number }[]
const task = (metrics.find((m) => m.name === "TaskDuration")?.value ?? 0) * 1000
scriptMs = (metrics.find((m) => m.name === "ScriptDuration")?.value ?? 0) * 1000
if (process.env.BENCH_DEBUG && task - taskMs > 5) console.log(`busy +${Date.now() - spawnAt} ${Math.round(task - taskMs)} ms`)
taskMs = task
window.push({ at: Date.now(), task })
while (window.length > 1 && window[1].at <= Date.now() - 500) window.shift()
if (task - window[0].task > 50) quietSince = undefined
else quietSince ??= window[0].at
if (seen.shellVisible && quietSince && Date.now() - quietSince >= settleMs) break
await sleep(50)
}
const rendererIdleMs = quietSince ? quietSince - spawnAt : undefined
const rendererProfilePath = rendererProfile ? join(outDir, `renderer-${Date.now()}.cpuprofile`) : undefined
if (rendererProfilePath) {
const stopped = await cdp.send("Profiler.stop")
writeFileSync(rendererProfilePath, JSON.stringify(stopped.result.profile))
console.log("renderer profile:", rendererProfilePath)
}
const finalMetrics = (await cdp.send("Performance.getMetrics")).result?.metrics as { name: string; value: number }[]
const metric = (name: string) => finalMetrics.find((m) => m.name === name)?.value ?? 0
const renderer = {
taskMs: Math.round(metric("TaskDuration") * 1000),
scriptMs: Math.round(metric("ScriptDuration") * 1000),
layoutMs: Math.round(metric("LayoutDuration") * 1000),
styleMs: Math.round(metric("RecalcStyleDuration") * 1000),
layouts: metric("LayoutCount"),
styleRecalcs: metric("RecalcStyleCount"),
domNodes: metric("Nodes"),
jsHeapMB: Math.round(metric("JSHeapUsedSize") / 1048576),
}
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
cdp.close()
const processes = appPid ? await processTree(appPid) : []
const screenChanges = await raiser.screen()
const boot = profile ? undefined : await mainBootTiming()
await sleep(300)
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
if (trace) await waitFor(async () => (existsSync(tracePath) && statSync(tracePath).size > 0 ? true : undefined), 20_000)
const main = mainLog()
const origin = last?.origin ? Math.round(last.origin - spawnAt) : undefined
const sample: Sample = {
build: build.label,
run,
profiled: profile || !!rendererProfile,
traced: trace ? tracePath : undefined,
rendererProfile: rendererProfilePath,
msSinceSpawn: {
processCreated: boot && Math.round(boot.created - spawnAt),
nodeStart: boot && Math.round(boot.origin + boot.nodeStart - spawnAt),
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
entryStart: main.marks.entry && main.marks.entry - spawnAt,
electronReady: main.marks.ready && main.marks.ready - spawnAt,
rendererAssetsServed: main.marks.served && main.marks.served - spawnAt,
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
onboardingDecided: main.marks.onboarding && main.marks.onboarding - spawnAt,
loggingReady: main.marks.logging && main.marks.logging - spawnAt,
crashReporterStarted: main.marks.crash && main.marks.crash - spawnAt,
appStarting: main.appStarting && main.appStarting - spawnAt,
storageOpen: main.marks.storage && main.marks.storage - spawnAt,
initializationDone: main.marks.init && main.marks.init - spawnAt,
layersReady: main.marks.layers && main.marks.layers - spawnAt,
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
serviceReady: main.serviceReady && main.serviceReady - spawnAt,
rendererProcess: origin,
windowVisible: main.windowVisible && main.windowVisible - spawnAt,
domInteractive:
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
// With a shell snapshot in the early document, first paint is the snapshot, not the app.
prepaintVisible:
prepaintSeen && last?.firstPaint !== undefined && origin !== undefined
? Math.round(origin + last.firstPaint)
: undefined,
...seen,
// First sampled screen change inside the window: the ground truth for "the user sees something".
screenPainted: screenChanges.changed[0],
// When the sampled window content stopped changing: the interface, not a splash, is on screen.
screenSettled: screenChanges.settled,
rendererIdle: rendererIdleMs,
},
final: { url: last?.url, timelineRows: last?.rows },
rendererCpu: { taskMs: Math.round(taskMs), scriptMs: Math.round(scriptMs) },
renderer,
rendererTimeline: timelineResult.result?.result?.value,
processes,
mainCpuMs: boot && Math.round((boot.cpu.user + boot.cpu.system) / 1000),
timeline: main.timeline.map(([at, file, message]) => [at - spawnAt, file, message]),
}
if (mainProfile) {
const profilePath = join(outDir, `main-${Date.now()}.cpuprofile`)
writeFileSync(profilePath, JSON.stringify(await mainProfile))
sample.mainProfile = profilePath
console.log("main profile:", profilePath)
}
return sample
}
// Read after the run, so the inspector attach cannot influence what was measured.
async function mainBootTiming() {
const target = (await targets(inspectPort)).find((t) => t.type === "node")
if (!target) return undefined
const cdp = await connect(target.webSocketDebuggerUrl)
const result = await cdp.send("Runtime.evaluate", { expression: mainTiming, returnByValue: true })
cdp.close()
const value = result.result?.result?.value
if (typeof value !== "string") return undefined
return JSON.parse(value) as {
created: number
origin: number
nodeStart: number
bootstrapComplete: number
cpu: { user: number; system: number }
rss: number
}
}
function defaultExe() {
const unpacked = join(packageDir, "dist", process.platform === "win32" ? "win-unpacked" : process.platform === "darwin" ? "mac" : "linux-unpacked")
if (!existsSync(unpacked)) return join(unpacked, "OpenCode Dev.exe")
const candidate = readdirSync(unpacked).find((f) => (process.platform === "win32" ? f.endsWith(".exe") : f.endsWith(".app") || !f.includes(".")))
return join(unpacked, candidate ?? "OpenCode Dev.exe")
}
function appIdFor(executable: string) {
const name = basename(executable, ".exe")
if (/beta/i.test(name)) return "ai.opencode.desktop.beta"
if (/dev/i.test(name)) return "ai.opencode.desktop.dev"
return "ai.opencode.desktop"
}
function prepareHome() {
for (const dir of [paths.appData, paths.temp, dirname(paths.db), paths.config, dirname(paths.registration), join(home, ".cache")]) mkdirSync(dir, { recursive: true })
if (args.values.seed && !existsSync(userData)) {
// Seed only the app's own state (tabs, drafts, settings, window placement); Chromium profile
// data, caches, logs and the staged CLI are recreated by the app.
const seed = resolve(args.values.seed)
const keep = /^(drafts\.sqlite(-wal|-shm)?|opencode\.[a-z]+|\.?window-state.*\.json|opencode)$/
cpSync(seed, userData, {
recursive: true,
filter: (source) => source === seed || keep.test(relative(seed, source).split(/[\\/]/)[0] ?? ""),
})
}
mkdirSync(paths.logs, { recursive: true })
const at = args.values["window-at"]?.split(",").map(Number)
if (at?.length === 2 && existsSync(userData)) {
for (const file of readdirSync(userData).filter((name) => /^window-state-.*\.json$/.test(name))) {
const state = JSON.parse(readFileSync(join(userData, file), "utf8"))
const bounds = displayAt(at[0], at[1])
writeFileSync(join(userData, file), JSON.stringify({ ...state, x: at[0], y: at[1], isMaximized: false, isFullScreen: false, displayBounds: bounds }))
}
}
}
function displayAt(x: number, y: number) {
if (process.platform !== "win32") return undefined
const out = execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { \"$($_.Bounds.X),$($_.Bounds.Y),$($_.Bounds.Width),$($_.Bounds.Height)\" }"], { encoding: "utf8" })
const displays = out.trim().split(/\r?\n/).map((line) => line.split(",").map(Number))
const hit = displays.find(([dx, dy, dw, dh]) => x >= dx && y >= dy && x < dx + dw && y < dy + dh)
return hit ? { x: hit[0], y: hit[1], width: hit[2], height: hit[3] } : undefined
}
async function freePort() {
return new Promise<number>((resolvePort, reject) => {
const server = createServer()
server.listen(0, "127.0.0.1", () => {
const address = server.address()
server.close(() => (typeof address === "object" && address ? resolvePort(address.port) : reject(new Error("no port"))))
})
})
}
async function targets(port: number) {
return fetch(`http://127.0.0.1:${port}/json`)
.then((r) => r.json() as Promise<{ type: string; url: string; webSocketDebuggerUrl: string }[]>)
.catch(() => [] as { type: string; url: string; webSocketDebuggerUrl: string }[])
}
async function waitFor<T>(fn: () => Promise<T | undefined>, timeout: number) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const value = await fn()
if (value) return value
await sleep(25)
}
throw new Error("Timed out waiting for the renderer debug target")
}
async function connect(url: string) {
const ws = new WebSocket(url)
await new Promise((r) => (ws.onopen = r))
let id = 0
const pending = new Map<number, (v: any) => void>()
const events: any[] = []
ws.onmessage = (ev) => {
const msg = JSON.parse(String(ev.data))
if (msg.method) events.push(msg)
if (msg.id && pending.has(msg.id)) {
pending.get(msg.id)!(msg)
pending.delete(msg.id)
}
}
return {
events,
send: (method: string, params: Record<string, unknown> = {}) =>
new Promise<any>((resolvePromise) => {
const n = ++id
pending.set(n, resolvePromise)
ws.send(JSON.stringify({ id: n, method, params }))
}),
close: () => ws.close(),
}
}
// Attach to the main process while it is paused on its first statement, start sampling, release it.
async function profileMain(spawnAt: number) {
const target = await waitFor(() => targets(inspectPort).then((list) => list.find((t) => t.type === "node")), 30_000)
const cdp = await connect(target.webSocketDebuggerUrl)
await cdp.send("Runtime.enable")
await cdp.send("Debugger.enable")
await cdp.send("Profiler.enable")
await cdp.send("Profiler.setSamplingInterval", { interval: 200 })
await cdp.send("Profiler.start")
await cdp.send("Runtime.runIfWaitingForDebugger")
const until = Date.now() + 3000
while (!cdp.events.some((e) => e.method === "Debugger.paused") && Date.now() < until) await sleep(10)
await cdp.send("Debugger.resume")
console.log(`debugger released at +${Date.now() - spawnAt} ms`)
await sleep(8000)
const result = await cdp.send("Profiler.stop")
cdp.close()
return result.result.profile
}
// Every timestamped line of the run's log directory, merged across the main log and the scoped logs
// (crash, onboarding, window, …) that electron-log writes next to it.
function mainLog() {
const dirs = existsSync(paths.logs) ? readdirSync(paths.logs).sort().reverse() : []
const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log")))
const timeline: [number, string, string][] = []
let windowShownAt: number | undefined
// Epoch marks the entry module recorded before any logger existed, reported with "app starting".
const marks: Record<string, number> = {}
for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) {
const text = readFileSync(join(dir!, name), "utf8")
// electron-log wraps long objects onto continuation lines; read them as part of the entry.
for (const entry of text.split(/\r?\n(?=\[\d{4}-)/)) {
const line = entry.split(/\r?\n/)[0]
const m = line.match(/^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]\s+\[\w+\]\s+(?:\([\w-]+\)\s+)?(.*)$/)
if (!m) continue
const message = m[2].replace(/\s*\{.*$/, "").trim()
// A window shown before the logger existed reports when it was shown; the line itself is later.
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
if (shown) windowShownAt = Number(shown)
if (/app starting|layers ready/.test(message))
for (const [, key, value] of entry.matchAll(/\b(\w+): (\d{10,})/g)) marks[key] = Number(value)
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
}
}
timeline.sort((a, b) => a[0] - b[0])
const at = (pattern: RegExp) => timeline.find(([, , message]) => pattern.test(message))?.[0]
return {
timeline,
appStarting: at(/app starting/),
versionStart: at(/v2 CLI command started/),
versionDone: at(/v2 CLI command completed/),
serviceStarting: at(/v2 CLI background service starting/),
serviceReady: at(/background service ready/),
windowVisible: windowShownAt ?? marks.window ?? at(/main window visible/),
marks,
}
}
// Working set of every process in the launched app's tree once it is idle.
// Windows places a window launched from a background process behind the foreground one, and
// Chromium then marks it occluded and the renderer stops painting, so paint timings would depend on
// what else is on screen. A helper started before the app polls for its main window and pins it
// topmost without activating it, so the user keeps their focus and the bench window is visible.
async function windowRaiser(): Promise<{ raise: (pid: number) => void; screen: () => Promise<number[]> }> {
if (process.platform !== "win32") return { raise: () => {}, screen: async () => [] }
const script = join(outDir, "raise-window.ps1")
writeFileSync(
script,
[
`Add-Type -AssemblyName System.Drawing`,
`Add-Type -Namespace Bench -Name User32 -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int z, uint f); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L; public int T; public int R; public int B; } [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);'`,
`[Console]::Out.WriteLine("ready")`,
`$target = [int][Console]::In.ReadLine()`,
`$clock = [Diagnostics.Stopwatch]::StartNew()`,
`$h = 0`,
`for ($i = 0; $i -lt 600; $i++) {`,
` $h = (Get-Process -Id $target -ErrorAction SilentlyContinue).MainWindowHandle`,
` if ($h -and $h -ne 0) { [Bench.User32]::SetWindowPos($h, [IntPtr](-1), 0, 0, 0, 0, 0x13) | Out-Null; [Console]::Out.WriteLine("raised " + $clock.ElapsedMilliseconds); break }`,
` Start-Sleep -Milliseconds 10`,
`}`,
`if (-not $h -or $h -eq 0) { exit }`,
// Sample a 16x16 grid of pixels inside the window: cheap, and enough to tell the background
// colour, a splash and the interface apart.
`$r = New-Object Bench.User32+RECT`,
`while ($clock.ElapsedMilliseconds -lt 3000) {`,
` [Bench.User32]::GetWindowRect($h, [ref]$r) | Out-Null`,
` $w = $r.R - $r.L; $ht = $r.B - $r.T`,
` if ($w -le 48 -or $ht -le 48) { Start-Sleep -Milliseconds 30; continue }`,
` $t = $clock.ElapsedMilliseconds`,
` $bmp = New-Object System.Drawing.Bitmap $w, $ht`,
` $g = [System.Drawing.Graphics]::FromImage($bmp)`,
` try { $g.CopyFromScreen($r.L, $r.T, 0, 0, $bmp.Size) } catch { $g.Dispose(); $bmp.Dispose(); Start-Sleep -Milliseconds 30; continue }`,
` $sum = 0`,
` for ($i = 1; $i -le 16; $i++) { for ($j = 1; $j -le 16; $j++) { $p = $bmp.GetPixel([int]($w * $j / 17), [int]($ht * $i / 17)); $sum += [int]$p.R + [int]$p.G + [int]$p.B } }`,
` [Console]::Out.WriteLine("screen " + $t + " " + $sum)`,
` if ($env:BENCH_SCREEN_DUMP) { $bmp.Save((Join-Path $env:BENCH_SCREEN_DUMP ("screen-" + $t.ToString().PadLeft(4, "0") + ".png"))) }`,
` $g.Dispose(); $bmp.Dispose()`,
` Start-Sleep -Milliseconds 30`,
`}`,
].join("\n"),
)
const helper = spawn("pwsh", ["-NoProfile", "-NonInteractive", "-File", script], { stdio: ["pipe", "pipe", "pipe"] })
const lines: string[] = []
let buffer = ""
await new Promise<void>((resolve) => {
helper.stdout!.on("data", (chunk: Buffer) => {
buffer += chunk.toString()
const parts = buffer.split(/\r?\n/)
buffer = parts.pop() ?? ""
for (const line of parts) {
if (line === "ready") resolve()
else lines.push(line)
}
})
helper.stderr!.on("data", (chunk: Buffer) => console.error(`raise-window: ${chunk.toString().trim()}`))
helper.on("exit", () => resolve())
})
const exited = new Promise<void>((resolve) => helper.on("exit", () => resolve()))
return {
raise: (pid) => helper.stdin!.write(`${pid}\n`),
// Resolves with the times (ms since the pid was sent, ~spawn) at which the sampled screen
// content differed from the first sample, i.e. when something other than the background colour
// was on screen.
screen: async () => {
await exited
const samples = lines
.filter((line) => line.startsWith("screen "))
.map((line) => line.split(" ").slice(1).map(Number) as [number, number])
if (process.env.BENCH_DEBUG) console.log(lines.filter((line) => line.startsWith("raised")).join(" "), `${samples.length} screen samples`)
const first = samples[0]?.[1]
const last = samples.at(-1)?.[1]
const differs = (a: number, b: number) => Math.abs(a - b) > 16 * 16 * 12
const changed = samples.filter(([, sum]) => differs(sum, first)).map(([t]) => t)
// The last sample that still differed from the final content, i.e. when the window stopped changing.
const settledIndex = samples.findLastIndex(([, sum]) => last !== undefined && differs(sum, last))
return { changed, settled: settledIndex >= 0 ? samples[settledIndex + 1]?.[0] : samples[0]?.[0] }
},
}
}
async function processTree(root: number) {
const script =
process.platform === "win32"
? [
"powershell",
[
"-NoProfile",
"-Command",
`$all = Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, Name, WorkingSetSize, CommandLine; $ids = @(${root}); do { $before = $ids.Count; $ids = @($ids + ($all | Where-Object { $ids -contains $_.ParentProcessId } | ForEach-Object ProcessId) | Sort-Object -Unique) } while ($ids.Count -ne $before); $all | Where-Object { $ids -contains $_.ProcessId } | ForEach-Object { '{0}|{1}|{2}|{3}' -f $_.ProcessId, $_.Name, $_.WorkingSetSize, (($_.CommandLine -split ' ' | Where-Object { $_ -like '--type=*' }) -join '') } `,
],
]
: ["sh", ["-c", `ps -eo pid=,ppid=,rss=,comm= | awk -v r=${root} 'BEGIN{ids[r]=1} {p[$1]=$2; rss[$1]=$3; c[$1]=$4} END{for(k=0;k<8;k++) for(i in p) if(p[i] in ids) ids[i]=1; for(i in ids) if(i in rss) print i "|" c[i] "|" rss[i]*1024 "|"}'`]]
const out = await new Promise<string>((done) => {
const child = spawn(script[0] as string, script[1] as string[], { stdio: ["ignore", "pipe", "ignore"] })
let text = ""
child.stdout?.on("data", (chunk) => (text += chunk))
child.on("close", () => done(text))
})
return out
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
const [pid, name, rss, type] = line.split("|")
return { pid: Number(pid), name: `${name}${type ? ` ${type.replace("--type=", "")}` : ""}`, rssMB: Math.round(Number(rss) / 1048576) }
})
}
function summarize(list: Sample[]) {
const values = (s: Sample): Record<string, number | undefined> => ({
...s.msSinceSpawn,
rendererTaskMs: s.rendererCpu.taskMs,
rendererScriptMs: s.rendererCpu.scriptMs,
})
const keys = [...new Set(list.flatMap((s) => Object.keys(values(s))))]
const out: Record<string, { median: number; min: number; max: number }> = {}
for (const key of keys) {
const sorted = list.map((s) => values(s)[key]).filter((v): v is number => Number.isFinite(v)).sort((a, b) => a - b)
if (!sorted.length) continue
out[key] = { median: sorted[Math.floor(sorted.length / 2)], min: sorted[0], max: sorted[sorted.length - 1] }
}
return out
}
// The service must come from the CLI bundled with the executable: the desktop restarts a service
// whose version differs from its bundled CLI, which would turn a warm run into a cold one.
function bundledCli(exe: string) {
const resources = process.platform === "darwin" ? join(dirname(exe), "..", "Resources") : join(dirname(exe), "resources")
return join(resources, process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli")
}
async function warmService() {
await stopService()
const clis = builds.map((build) => bundledCli(build.exe))
const identity = (cli: string) => {
const version = join(dirname(cli), "opencode-cli.version")
return existsSync(version) ? readFileSync(version, "utf8").trim() : String(statSync(cli).size)
}
if (new Set(clis.map(identity)).size > 1)
throw new Error("The compared builds bundle different CLIs; the desktop would restart the service on the mismatch")
serviceProcess = spawn(clis[0], ["serve", "--service"], { env, detached: true, stdio: "ignore" })
serviceProcess.unref()
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
if (existsSync(paths.registration)) {
const registration = JSON.parse(readFileSync(paths.registration, "utf8")) as { url?: string }
if (registration.url && (await fetch(`${registration.url}/api/info`).then((r) => r.status < 500).catch(() => false))) {
console.log(`service warm at ${registration.url}`)
return
}
}
await sleep(200)
}
throw new Error("The bench service did not become ready")
}
async function stopService() {
if (existsSync(paths.registration)) {
const registration = JSON.parse(readFileSync(paths.registration, "utf8")) as { pid?: number }
if (registration.pid) {
try {
process.kill(registration.pid)
} catch {}
}
rmSync(paths.registration, { force: true })
}
if (serviceProcess?.pid) {
try {
process.kill(serviceProcess.pid)
} catch {}
serviceProcess = undefined
}
await sleep(500)
}
// Only ever touches the process this run spawned (a prod-channel build shares its executable name
// with the developer's installed app). Ask it to quit first so it exits the way a user's session
// ends (Node and Chromium flush their caches on a normal exit); force-kill the tree if it lingers.
async function killApp() {
const pid = appPid
if (!pid) return
appPid = undefined
const running = () =>
new Promise<boolean>((done) => {
const check =
process.platform === "win32"
? spawn("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], { stdio: ["ignore", "pipe", "ignore"] })
: spawn("kill", ["-0", String(pid)], { stdio: "ignore" })
let out = ""
check.stdout?.on("data", (chunk) => (out += chunk))
check.on("close", (code) => done(process.platform === "win32" ? out.includes(String(pid)) : code === 0))
})
if (!(await running())) return
if (process.platform === "win32") spawn("taskkill", ["/PID", String(pid)], { stdio: "ignore" })
else process.kill(pid, "SIGTERM")
const deadline = Date.now() + 5000
while (Date.now() < deadline && (await running())) await sleep(100)
if (await running()) {
if (process.platform === "win32") spawn("taskkill", ["/PID", String(pid), "/F", "/T"], { stdio: "ignore" })
else process.kill(pid, "SIGKILL")
await sleep(1000)
}
await sleep(500)
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms))
}