diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 4b9a926b868..0d49419d095 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -39,3 +39,39 @@ CLI preparation uses these channel rules: `bun dev` is separate from packaging: it uses local renderer/server mode, the dev app identity, and the CLI source by default. `bun dev --download-server ` instead downloads that CLI version for local development. Neither path requires `OPENCODE_CLI_DIST` or runs the production prebuild. + +## Startup benchmark + +`bun run bench:startup` measures a **packaged** build from process spawn to the restored tab being ready and the +renderer going idle, so dev-server and bundling costs are not part of the numbers. + +```bash +OPENCODE_CHANNEL=dev bun run build && bunx electron-builder --win --dir --config electron-builder.config.ts +bun run bench:startup -- --runs 5 # warm service (started once, reused by every launch) +bun run bench:startup -- --runs 5 --compare dist/other/OpenCode\ Dev.exe # A/B: alternate launches of two builds +bun run bench:startup -- --service cold # each launch spawns the service +bun run bench:startup -- --fresh # first launch after an install (profile wiped each time) +bun run bench:startup -- --profile-main --profile-renderer --trace # CPU profiles and a Chromium startup trace +bun run bench:startup -- --seed "%APPDATA%\ai.opencode.desktop.dev" # restore tabs and drafts from an existing profile +``` + +The app runs in an isolated home (`%TEMP%\opencode-bench-startup`): its own `%APPDATA%`, XDG directories, OpenCode +database, config and service registration, with the developer's `OPENCODE_*` and `OTEL_*` environment stripped +(an inherited OTLP endpoint alone adds a network round trip to every CLI exit). It never attaches to or restarts the +developer's live service, and only ever kills the process tree it spawned. `--service cold` stops the service before +each launch so the desktop has to spawn it; the isolated config directory gives that service a private port. One +`--warmup` launch per build is discarded by default because the first launch of a new binary pays the antivirus scan. +`--compare` alternates two builds so machine drift affects both equally; they must bundle the same CLI or the desktop +restarts the service on the version mismatch. + +Milestones (ms since spawn) come from the main log, the renderer's performance timeline and DOM readiness polled over +CDP. Node's bootstrap timing is read from the main process after each run over `--inspect` (nothing attaches until the +run is over): `processCreated` → `nodeStart` is Electron's native init, `nodeStart` → `nodeBootstrapped` is Node +itself, and `nodeBootstrapped` → `appStarting` is Electron's JavaScript init plus our main bundle up to its first +log line. Renderer idle is the start of the first 500 ms window with under 10 % main-thread task time that stays quiet +for `--settle-ms`; `rendererTaskMs` is the renderer's total main-thread task time until then. Raw samples are +written to `dist/bench-startup`. + +A packaged beta or prod build registers itself as the `opencode://` handler when it starts, even from the bench; the +installed app takes the registration back on its next launch. Those channels run with `HTTPS_PROXY` pointed at a +closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub. \ No newline at end of file diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 39b5c965fde..1db55a02e90 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,7 +20,8 @@ "package": "electron-builder --config electron-builder.config.ts", "package:mac": "electron-builder --mac --config electron-builder.config.ts", "package:win": "electron-builder --win --config electron-builder.config.ts", - "package:linux": "electron-builder --linux --config electron-builder.config.ts" + "package:linux": "electron-builder --linux --config electron-builder.config.ts", + "bench:startup": "bun ./scripts/bench-startup.ts" }, "main": "./out/main/index.js", "dependencies": { diff --git a/packages/desktop/scripts/bench-startup.ts b/packages/desktop/scripts/bench-startup.ts new file mode 100644 index 00000000000..764e039be04 --- /dev/null +++ b/packages/desktop/scripts/bench-startup.ts @@ -0,0 +1,664 @@ +#!/usr/bin/env bun +// Cold-start benchmark for a packaged desktop build. +// +// bun run bench:startup -- [--exe ] [--compare ] [--runs 5] [--warmup 1] [--service warm|cold] +// [--fresh] [--offline] [--seed ] [--profile-main] [--profile-renderer] +// [--trace] [--out ] [--home ] +// +// 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. +import { 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" }, + }, + 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 | 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, + shell: !!document.querySelector('[data-titlebar-tab-link], [data-action="vertical-tabs-home"]'), + editor: !!document.querySelector('[data-component="composer-editor"][contenteditable="true"]'), + rows: document.querySelectorAll('[data-timeline-row]').length, + home: !!document.querySelector('[data-action="home-new-session"], [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[] = [] +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 + main bundle", "nodeBootstrapped", "appStarting"], + ["main layers → window created", "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 = {} + 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 + 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 + 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 + 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 { + 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 = [ + `--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 spawnAt = Date.now() + const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" }) + child.unref() + appPid = child.pid + + let mainProfile: Promise | 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 = {} + let last: Probe | undefined + 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?.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 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), + appStarting: main.appStarting && main.appStarting - 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, + ...seen, + 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 }) +} + +async function freePort() { + return new Promise((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(fn: () => Promise, 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 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 = {}) => + new Promise((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][] = [] + for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) { + for (const line of readFileSync(join(dir!, name), "utf8").split(/\r?\n/)) { + 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() + 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: at(/main window visible/), + } +} + +// Working set of every process in the launched app's tree once it is idle. +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((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 => ({ + ...s.msSinceSpawn, + rendererTaskMs: s.rendererCpu.taskMs, + rendererScriptMs: s.rendererCpu.scriptMs, + }) + const keys = [...new Set(list.flatMap((s) => Object.keys(values(s))))] + const out: Record = {} + 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)) + if (new Set(clis.map((cli) => statSync(cli).size)).size > 1) + console.warn("warning: the compared builds bundle different CLIs; the desktop will 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((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)) +}