From 1d8cf4564b80912f91735f5de609764a6b19253e Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:23:37 +1000 Subject: [PATCH] perf(desktop): move startup work that the first window does not need off its path (#50111) --- packages/desktop/src/main/desktop.ts | 13 +++++--- packages/desktop/src/main/index.ts | 21 ++++++------ .../desktop/src/main/lifecycle/configure.ts | 3 ++ .../main/lifecycle/desktop-initialization.ts | 10 ++++-- .../desktop/src/main/lifecycle/onboarding.ts | 9 +++--- packages/desktop/src/main/native/logging.ts | 12 +++++-- .../src/main/service/background-service.ts | 12 +++++-- .../desktop/src/main/service/desktop-cli.ts | 15 ++++++--- .../desktop/src/main/service/sidecar-probe.ts | 32 +++++++++++++++++++ packages/desktop/src/main/windows/defaults.ts | 11 +++---- 10 files changed, 102 insertions(+), 36 deletions(-) create mode 100644 packages/desktop/src/main/service/sidecar-probe.ts diff --git a/packages/desktop/src/main/desktop.ts b/packages/desktop/src/main/desktop.ts index bf7e72a5b8d..96841bbea7a 100644 --- a/packages/desktop/src/main/desktop.ts +++ b/packages/desktop/src/main/desktop.ts @@ -5,6 +5,7 @@ import { Ipc } from "./ipc" import { DesktopInitialization } from "./lifecycle/desktop-initialization" import { installContextMenu } from "./lifecycle/environment" import { ApplicationLifecycle } from "./lifecycle" +import { DesktopLogging } from "./native/logging" import { BackgroundService } from "./service/background-service" import { DesktopCli } from "./service/desktop-cli" import { UpdaterLive } from "./updater/live" @@ -14,11 +15,16 @@ import { marks } from "./lifecycle/marks" marks.bundle = Date.now() const runIpc = Effect.fn("Desktop.runIpc")(function* () { - const lifecycle = yield* ApplicationLifecycle.Service - marks.layers = Date.now() - yield* Effect.logInfo("layers ready", { marks }) + const lifecycle = yield* ApplicationLifecycle.Service + marks.layers = Date.now() + yield* Effect.logInfo("layers ready", { marks }) const ipc = yield* Ipc.registerIpcHandlers if (lifecycle.restoreWindows().length) ipc.installMenu() + // The first window's renderer now has its IPC port and is hydrating its stores over it. The crash + // reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late. + yield* Effect.sleep("500 millis") + const logging = yield* DesktopLogging.Service + yield* logging.startCrashReporter yield* installContextMenu yield* Effect.callback((resume) => { const quit = () => resume(Effect.void) @@ -38,4 +44,3 @@ runIpc().pipe( Effect.scoped, NodeRuntime.runMain, ) - diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 625be02548d..b989ad73523 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -2,7 +2,8 @@ import { marks } from "./lifecycle/marks" import { app } from "electron" import { acquireApplicationLock, configureApplication } from "./lifecycle/configure" -import { createEarlyWindow } from "./windows/early" +import { startSidecarProbe } from "./service/sidecar-probe" +import { createEarlyWindow } from "./windows/early" import { rendererAssetsServed } from "./windows/protocol" import { registerRendererScheme } from "./windows/scheme" @@ -15,15 +16,15 @@ if (acquireApplicationLock()) { registerRendererScheme() // Window first, then the bundle: starting the import before ready delays ready itself, because the // module graph evaluates on the same thread Chromium needs to finish initialising. - void app.whenReady().then(async () => { - marks.ready = Date.now() - createEarlyWindow() - marks.window = Date.now() - // The window's renderer is already loading. Its HTML and preloaded chunks are served from this - // thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates. - if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 }) - marks.served = Date.now() + void app.whenReady().then(async () => { + marks.ready = Date.now() + createEarlyWindow() + marks.window = Date.now() + startSidecarProbe() + // The window's renderer is already loading. Its HTML and preloaded chunks are served from this + // thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates. + if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 }) + marks.served = Date.now() return import("./desktop") }) } - diff --git a/packages/desktop/src/main/lifecycle/configure.ts b/packages/desktop/src/main/lifecycle/configure.ts index 675af90dcc3..fc78500d931 100644 --- a/packages/desktop/src/main/lifecycle/configure.ts +++ b/packages/desktop/src/main/lifecycle/configure.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" import { mkdirSync, rmSync } from "node:fs" +import { enableCompileCache } from "node:module" import { homedir, tmpdir } from "node:os" import path from "node:path" import { app } from "electron" @@ -31,6 +32,8 @@ export function configureApplication() { app.setPath("sessionData", path.join(testRoot, "session")) if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents")) } + // V8 bytecode for the main bundle survives between launches, like the renderer's code cache. + enableCompileCache(path.join(app.getPath("userData"), "compile-cache")) } export function acquireApplicationLock() { diff --git a/packages/desktop/src/main/lifecycle/desktop-initialization.ts b/packages/desktop/src/main/lifecycle/desktop-initialization.ts index f29bba8209a..1784208b5ea 100644 --- a/packages/desktop/src/main/lifecycle/desktop-initialization.ts +++ b/packages/desktop/src/main/lifecycle/desktop-initialization.ts @@ -4,6 +4,7 @@ import { app } from "electron" import { Context, Effect, Layer } from "effect" import { DesktopLogging } from "../native/logging" import { getStore } from "../storage/store" +import { marks } from "./marks" import { loadProxyEnvironment, preferApplicationEnvironment, @@ -22,12 +23,15 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const logging = yield* DesktopLogging.Service - yield* prepareApplicationEnvironment yield* preferApplicationEnvironment - yield* loadProxyEnvironment + // System certificates, the proxy and the net log serve later network work; the first window and + // its IPC port do not wait for them. + yield* Effect.forkScoped( + prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)), + ) yield* Effect.promise(() => app.whenReady()) - yield* logging.startNetwork yield* prepareDesktop + marks.init = Date.now() return Service.of({ version: app.getVersion(), updaterStore: getStore("opencode.updater"), diff --git a/packages/desktop/src/main/lifecycle/onboarding.ts b/packages/desktop/src/main/lifecycle/onboarding.ts index 39e5437b30c..db1d8a276c4 100644 --- a/packages/desktop/src/main/lifecycle/onboarding.ts +++ b/packages/desktop/src/main/lifecycle/onboarding.ts @@ -8,6 +8,10 @@ import { getStore } from "../storage/store" const DEFAULT_PROJECT_DIR = "Default Project" export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) { + const store = getStore() + const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) + if (typeof current === "boolean") return current + const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : [] @@ -17,11 +21,8 @@ export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize" const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option) return { name, directory: Option.isSome(info) && info.value.type === "Directory" } }), + { concurrency: "unbounded" }, ) - const store = getStore() - const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) - if (typeof current === "boolean") return current - const complete = hasExistingAppState(entries) store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete) return complete diff --git a/packages/desktop/src/main/native/logging.ts b/packages/desktop/src/main/native/logging.ts index 24d8b03b815..c81bf04e55f 100644 --- a/packages/desktop/src/main/native/logging.ts +++ b/packages/desktop/src/main/native/logging.ts @@ -19,6 +19,7 @@ let netLogPath: string | undefined export interface Interface { readonly startNetwork: Effect.Effect + readonly startCrashReporter: Effect.Effect readonly exportDebug: Effect.Effect } @@ -30,7 +31,9 @@ const serviceLayer = Layer.effect( const fs = yield* FileSystem.FileSystem const path = yield* Path.Path yield* initLogging(fs, path).pipe(Effect.orDie) - yield* initCrashReporter(fs, path).pipe(Effect.orDie) + // Old run directories go away in the background; listing them is not worth a wait at startup. + yield* Effect.forkScoped(cleanup(fs, path).pipe(Effect.catch(() => Effect.void))) + marks.logging = Date.now() yield* Effect.logInfo("app starting", { version: VERSION, packaged: app.isPackaged, @@ -42,6 +45,12 @@ const serviceLayer = Layer.effect( startNetwork: startNetLog(path).pipe( Effect.catch((error) => Effect.logWarning("failed to start net log", { error })), ), + // Starting crashpad spawns its handler process, ~60 ms on the main thread, so the first window + // and its IPC port come first. + startCrashReporter: initCrashReporter(fs, path).pipe( + Effect.tap(() => Effect.sync(() => (marks.crash = Date.now()))), + Effect.catch((error) => Effect.logWarning("failed to start crash reporter", { error })), + ), exportDebug, }) }), @@ -99,7 +108,6 @@ function initLogging(fs: FileSystem.FileSystem, path: Path.Path) { log.initialize({ preload: false, spyRendererConsole: true }) initConsoleTransport() }) - yield* cleanup(fs, path) }) } diff --git a/packages/desktop/src/main/service/background-service.ts b/packages/desktop/src/main/service/background-service.ts index ab6ef067767..72aeb3a70e9 100644 --- a/packages/desktop/src/main/service/background-service.ts +++ b/packages/desktop/src/main/service/background-service.ts @@ -3,6 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect" import { BackgroundServiceState } from "./background-service-state" import { cleanStages, DesktopCli } from "./desktop-cli" import { SidecarCredentials } from "./sidecar-credentials" +import { sidecarProbe } from "./sidecar-probe" export * as BackgroundService from "./background-service" @@ -36,7 +37,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial const version = mode === "initial" ? cli.version : undefined if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData") const client = yield* Effect.promise(() => import("@opencode/client/service")) - const service = yield* Effect.tryPromise(() => + const ensure = () => client.Service.ensure({ file: isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local" @@ -46,13 +47,18 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])], onStart: (reason, previousVersion) => runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })), - }), - ) + }) + // A compatible service the entry module already found is adopted at once; ensure() still runs + // afterwards for its side effects (terminal handoff completion), off the renderer's path. + const early = mode === "initial" && !isolated ? yield* Effect.promise(sidecarProbe) : undefined + if (early) yield* Effect.sync(() => void ensure().catch(() => undefined)) + const service = early ?? (yield* Effect.tryPromise(ensure)) if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication") const url = new URL(service.url) if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1" yield* Effect.logInfo("v2 CLI background service ready", { version, + probed: !!early, ...endpoint(url.origin), }) if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie) diff --git a/packages/desktop/src/main/service/desktop-cli.ts b/packages/desktop/src/main/service/desktop-cli.ts index 02cc68c0084..9030cd371f6 100644 --- a/packages/desktop/src/main/service/desktop-cli.ts +++ b/packages/desktop/src/main/service/desktop-cli.ts @@ -1,6 +1,7 @@ export * as DesktopCli from "./desktop-cli" import { execFile, spawn } from "node:child_process" +import { existsSync, readFileSync } from "node:fs" import { promisify } from "node:util" import { app } from "electron" import { Context, Effect, FileSystem, Layer, Option, Path } from "effect" @@ -93,9 +94,15 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const shipped = yield* fs - .readFileString(path.join(path.dirname(bundled), "opencode-cli.version")) - .pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => "")) + // Synchronous on purpose: this sits on the path to the first window's IPC port, and a queued + // async read waits behind everything else the main thread is doing at that moment. + const shipped = yield* Effect.sync(() => { + try { + return readFileSync(path.join(path.dirname(bundled), "opencode-cli.version"), "utf8").trim() + } catch { + return "" + } + }) if (shipped) { yield* Effect.logInfo("v2 CLI version bundled", { version: shipped }) return shipped @@ -147,7 +154,7 @@ const installCli = Effect.fn("DesktopCli.install")(function* (source: string, ve const path = yield* Path.Path const directory = path.join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-")) const destination = path.join(directory, executableName()) - if (yield* fs.exists(destination)) { + if (existsSync(destination)) { yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version }) return destination } diff --git a/packages/desktop/src/main/service/sidecar-probe.ts b/packages/desktop/src/main/service/sidecar-probe.ts new file mode 100644 index 00000000000..d56582eaf66 --- /dev/null +++ b/packages/desktop/src/main/service/sidecar-probe.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs" +import path from "node:path" +import { app } from "electron" +import type { Endpoint } from "@opencode/client/service" + +// The main thread idles between showing the first window and evaluating the main bundle, waiting +// for the renderer's asset requests. That slot is long enough to find out whether a compatible +// background service is already running, so the renderer's first data request is not the first +// moment anyone asks. The probe only looks; a service that has to be started waits for the layers, +// which set the environment the CLI expects. +let probe: Promise | undefined + +export function startSidecarProbe() { + if (!app.isPackaged) return + const version = bundledVersion() + if (!version) return + probe = import("@opencode/client/service") + .then(({ Service }) => Service.discover({ version })) + .catch(() => undefined) +} + +export function sidecarProbe() { + return probe ?? Promise.resolve(undefined) +} + +function bundledVersion() { + try { + return readFileSync(path.join(process.resourcesPath, "opencode-cli.version"), "utf8").trim() + } catch { + return "" + } +} diff --git a/packages/desktop/src/main/windows/defaults.ts b/packages/desktop/src/main/windows/defaults.ts index 6e36c5336c0..013ae7e1821 100644 --- a/packages/desktop/src/main/windows/defaults.ts +++ b/packages/desktop/src/main/windows/defaults.ts @@ -9,10 +9,6 @@ import { getStore } from "../storage/store" // full window setup in appearance.ts, so both draw the same frame. const oc2Theme = oc2ThemeJson as DesktopTheme -const oc2Background = { - light: resolveThemeVariant(oc2Theme.light, false)["background-base"], - dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"], -} // Match the renderer's 36px titlebar plus its former 8px content inset. export const titlebarHeight = 44 @@ -21,10 +17,13 @@ export function tone() { } // The colour the renderer reported on its last run, or the default theme's for the system tone, so -// a window shown before the renderer paints already has the right background. +// a window shown before the renderer paints already has the right background. Resolving a palette +// costs tens of milliseconds before the first window, so it only happens when nothing is stored. export function storedBackgroundColor() { const stored = getStore().get(BACKGROUND_COLOR_KEY) - return typeof stored === "string" ? stored : oc2Background[tone()] + if (typeof stored === "string") return stored + const dark = tone() === "dark" + return resolveThemeVariant(dark ? oc2Theme.dark : oc2Theme.light, dark)["background-base"] } export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {