From 64aaf2eaaeb2ccdd4ae0bbfbdd1f3c8f51f9430b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 24 Oct 2026 13:04:12 -0700 Subject: [PATCH] feat(desktop): add opt-in memory logging and remote debugging (#58315) --- .../src/main/diagnostics/options.test.ts | 27 +++++++ .../desktop/src/main/diagnostics/options.ts | 15 ++++ .../desktop/src/main/lifecycle/configure.ts | 17 +++- packages/gui-extensions/package.json | 1 + packages/gui-extensions/src/debug/bar.tsx | 13 +++ packages/gui-extensions/src/debug/contract.ts | 19 +++++ packages/gui-extensions/src/debug/i18n/en.ts | 5 ++ packages/gui-extensions/src/debug/index.ts | 6 +- packages/gui-extensions/src/debug/main.ts | 37 ++++++++- .../gui-extensions/src/debug/memory-log.ts | 80 +++++++++++++++++++ .../src/debug/memory-record.test.ts | 44 ++++++++++ .../gui-extensions/src/debug/memory-record.ts | 36 +++++++++ .../gui-extensions/src/debug/options.test.ts | 28 +++++++ packages/gui-extensions/src/debug/options.ts | 15 ++++ .../gui-extensions/src/debug/renderer.tsx | 36 ++++++++- 15 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 packages/desktop/src/main/diagnostics/options.test.ts create mode 100644 packages/desktop/src/main/diagnostics/options.ts create mode 100644 packages/gui-extensions/src/debug/memory-log.ts create mode 100644 packages/gui-extensions/src/debug/memory-record.test.ts create mode 100644 packages/gui-extensions/src/debug/memory-record.ts create mode 100644 packages/gui-extensions/src/debug/options.test.ts create mode 100644 packages/gui-extensions/src/debug/options.ts diff --git a/packages/desktop/src/main/diagnostics/options.test.ts b/packages/desktop/src/main/diagnostics/options.test.ts new file mode 100644 index 00000000000..fc4205b0c81 --- /dev/null +++ b/packages/desktop/src/main/diagnostics/options.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test" +import { remoteDebuggingPort } from "./options" + +const builds = { + dev: { packaged: false, channel: "dev" }, + beta: { packaged: true, channel: "beta" }, + stable: { packaged: true, channel: "prod" }, + packagedDev: { packaged: true, channel: "dev" }, +} + +test.each([ + ["dev", undefined, "9222"], + ["dev", "9333", "9333"], + ["dev", "0", "0"], + ["beta", undefined, undefined], + ["beta", "9333", "9333"], + ["beta", "0", "0"], + ["beta", "", undefined], + ["beta", "65536", undefined], + ["beta", "9222junk", undefined], + ["beta", "127.0.0.1:9222", undefined], + ["stable", undefined, undefined], + ["stable", "9333", undefined], + ["packagedDev", "9333", undefined], +] as const)("%s remote port %s opens %s", (build, value, expected) => { + expect(remoteDebuggingPort(builds[build], value)).toBe(expected) +}) diff --git a/packages/desktop/src/main/diagnostics/options.ts b/packages/desktop/src/main/diagnostics/options.ts new file mode 100644 index 00000000000..8880c13a304 --- /dev/null +++ b/packages/desktop/src/main/diagnostics/options.ts @@ -0,0 +1,15 @@ +import { debugBuild } from "@opencode/gui-extensions/debug/options" + +/** + * The remote debugging port, only in debug builds. The dev server opens 9222 unless told otherwise; Beta opens one only + * when `OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT` names it. `0` lets Chromium pick a free port. + */ +export function remoteDebuggingPort(build: { readonly packaged: boolean; readonly channel: string }, value?: string) { + if (!debugBuild(build)) return undefined + + if (value === undefined) return build.packaged ? undefined : "9222" + + if (!/^\d+$/.test(value)) return undefined + + return Number(value) <= 65535 ? String(Number(value)) : undefined +} diff --git a/packages/desktop/src/main/lifecycle/configure.ts b/packages/desktop/src/main/lifecycle/configure.ts index 3a2ca51c3b0..5901a76de3c 100644 --- a/packages/desktop/src/main/lifecycle/configure.ts +++ b/packages/desktop/src/main/lifecycle/configure.ts @@ -4,7 +4,9 @@ import { enableCompileCache } from "node:module" import { homedir, tmpdir } from "node:os" import path from "node:path" import { app } from "electron" -import { APP_ID, APP_NAME } from "../constants" +import { debugBuild, memoryLogInterval } from "@opencode/gui-extensions/debug/options" +import { APP_ID, APP_NAME, CHANNEL } from "../constants" +import { remoteDebuggingPort } from "../diagnostics/options" const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1" @@ -29,8 +31,17 @@ export function configureApplication() { // Chromium animates each mouse-wheel notch on Linux; native Linux apps scroll by the full delta immediately. if (process.platform === "linux") app.commandLine.appendSwitch("disable-smooth-scrolling") - if (!app.isPackaged) - app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222") + const build = { packaged: app.isPackaged, channel: CHANNEL } + const debugPort = remoteDebuggingPort(build, process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT) + + if (debugPort) app.commandLine.appendSwitch("remote-debugging-port", debugPort) + + if (app.commandLine.hasSwitch("remote-debugging-port")) + app.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1") + + // Precise heap sizes need a launch switch. The debug bar's toggle starts the log without one. + if (debugBuild(build) && memoryLogInterval(process.env.OPENCODE_DESKTOP_MEMORY_LOG) !== undefined) + app.commandLine.appendSwitch("enable-precise-memory-info") const testRoot = createTestRoot() app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), APP_ID)) diff --git a/packages/gui-extensions/package.json b/packages/gui-extensions/package.json index ba10620bc3c..974b4c5eedf 100644 --- a/packages/gui-extensions/package.json +++ b/packages/gui-extensions/package.json @@ -12,6 +12,7 @@ "./sdk/main": "./src/sdk/main.ts", "./sdk/bridge": "./src/sdk/bridge.ts", "./terminal": "./src/terminal/index.ts", + "./debug/options": "./src/debug/options.ts", "./updater": "./src/updater/contract.ts" }, "scripts": { diff --git a/packages/gui-extensions/src/debug/bar.tsx b/packages/gui-extensions/src/debug/bar.tsx index e3add246923..3b86ccf9b20 100644 --- a/packages/gui-extensions/src/debug/bar.tsx +++ b/packages/gui-extensions/src/debug/bar.tsx @@ -184,6 +184,7 @@ function ToggleCell(props: { export default function DebugBar(props: { diagnostics?: boolean inline?: boolean + memory?: { on: boolean; env: boolean; toggle: () => void } motion?: { open: boolean; toggle: () => void } onClose?: () => void }) { @@ -721,6 +722,18 @@ export default function DebugBar(props: { /> + + {(memory) => ( + memory().toggle()} + /> + )} +
{/* The lab's route exists only on the dev server and in Beta. */} diff --git a/packages/gui-extensions/src/debug/contract.ts b/packages/gui-extensions/src/debug/contract.ts index 7c364c74cbd..108dc2b53c8 100644 --- a/packages/gui-extensions/src/debug/contract.ts +++ b/packages/gui-extensions/src/debug/contract.ts @@ -23,3 +23,22 @@ export const HeapSnapshots = Ipc.define({ }, }, }) + +/** + * Memory diagnostics, offered only in debug builds (the dev server and Beta) and off until turned on: a JSONL memory + * log in the desktop logs. The debug bar's toggle turns them on and remembers it; `OPENCODE_DESKTOP_MEMORY_LOG=` + * turns them on for one launch, and the toggle cannot turn that off. + */ +export const MemoryDiagnostics = Ipc.define({ + id: "debug.memory", + state: Schema.Struct({ + /** Whether diagnostics are running. */ + on: Schema.Boolean, + /** Whether the environment turned them on. */ + env: Schema.Boolean, + }), + methods: { + /** Turns the stored toggle on or off. */ + set: { input: Schema.Boolean }, + }, +}) diff --git a/packages/gui-extensions/src/debug/i18n/en.ts b/packages/gui-extensions/src/debug/i18n/en.ts index 120cdc5ed61..73e79cb143e 100644 --- a/packages/gui-extensions/src/debug/i18n/en.ts +++ b/packages/gui-extensions/src/debug/i18n/en.ts @@ -37,6 +37,11 @@ export default { "mem.label": "MEM", "mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.", "mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.", + "memory.label": "MEMLOG", + "memory.tip": + "Memory diagnostics: log app memory to the desktop logs every 30 seconds. Stays on across restarts until turned off; the bar stays open while it runs.", + "memory.tipEnv": "Memory diagnostics are on for this launch: OPENCODE_DESKTOP_MEMORY_LOG is set.", + "memory.env": "ENV", "motion.label": "KNOBS", "motion.title": "Knobs", "motion.tip": "Tune live. Changes persist on this device.", diff --git a/packages/gui-extensions/src/debug/index.ts b/packages/gui-extensions/src/debug/index.ts index ed4e073bfe1..c5178559cbd 100644 --- a/packages/gui-extensions/src/debug/index.ts +++ b/packages/gui-extensions/src/debug/index.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { Extension, Store } from "../sdk" -import { HeapSnapshots } from "./contract" +import { HeapSnapshots, MemoryDiagnostics } from "./contract" import en from "./i18n/en" export default Extension.define({ @@ -11,8 +11,10 @@ export default Extension.define({ visible: false, motion: false, }), + // Whether the debug bar turned memory diagnostics on. Read only in debug builds. + memory: Store.main(Schema.Boolean, false), }, - provides: { heap: HeapSnapshots }, + provides: { heap: HeapSnapshots, memory: MemoryDiagnostics }, i18n: { en, am: () => import("./i18n/am"), diff --git a/packages/gui-extensions/src/debug/main.ts b/packages/gui-extensions/src/debug/main.ts index 76024baf889..e80f4d8ab8a 100644 --- a/packages/gui-extensions/src/debug/main.ts +++ b/packages/gui-extensions/src/debug/main.ts @@ -4,9 +4,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import path from "node:path" import { getHeapSpaceStatistics, getHeapStatistics, writeHeapSnapshot } from "node:v8" import type { MainSetup } from "../sdk/main" -import { HeapSnapshots } from "./contract" +import { HeapSnapshots, MemoryDiagnostics } from "./contract" import type definition from "./index" import { summarize, traceConfig } from "./memory-infra" +import { startMemoryLog } from "./memory-log" +import { debugBuild, memoryLogInterval } from "./options" import { captureServer } from "./server-heap" /** A desktop process the manifest lists: the file written, or why there is none. */ @@ -50,6 +52,9 @@ const pageStatsScript = `(() => { } })()` +/** Stops the running memory diagnostics, while they run. */ +type Running = { stop?: () => void } + const setup: MainSetup = (ctx) => { const queue: Queue = { tail: Promise.resolve() } @@ -148,6 +153,36 @@ const setup: MainSetup = (ctx) => { return run }, }) + + if (!debugBuild(ctx.build)) return + + // The environment turns diagnostics on for this launch; otherwise the debug bar's stored toggle does. + const envInterval = memoryLogInterval(process.env.OPENCODE_DESKTOP_MEMORY_LOG) + const stored = ctx.stores.memory + const running: Running = {} + + const sync = () => { + const on = envInterval !== undefined || stored.value + + if (on && !running.stop) running.stop = startMemoryLog(envInterval ?? 30_000, ctx.log) + + if (on || !running.stop) return + running.stop() + running.stop = undefined + } + + // Started before the Ipc is provided, so the first state windows receive already says whether diagnostics run. + sync() + ctx.scope.addFinalizer(() => running.stop?.()) + + const provider = ctx.provide(MemoryDiagnostics, { + state: () => ({ on: running.stop !== undefined, env: envInterval !== undefined }), + set: (enabled) => { + stored.set(enabled) + sync() + provider.changed() + }, + }) } // Chromium's own breakdown of every process's memory: what a heap snapshot cannot show (images, layout, compositor diff --git a/packages/gui-extensions/src/debug/memory-log.ts b/packages/gui-extensions/src/debug/memory-log.ts new file mode 100644 index 00000000000..e939d3ac05f --- /dev/null +++ b/packages/gui-extensions/src/debug/memory-log.ts @@ -0,0 +1,80 @@ +import { appendFile, mkdir } from "node:fs/promises" +import path from "node:path" +import { app, webContents } from "electron" +import { Schema } from "effect" +import type { Log } from "../sdk/main" +import { memoryRecord, RendererMemory, type RendererRecord } from "./memory-record" + +// Runs only while diagnostics are on; no preload hooks, observers, or store subscriptions. +const probe = `(() => { + const memory = performance.memory; + return { + jsHeap: memory ? { used: memory.usedJSHeapSize, total: memory.totalJSHeapSize, limit: memory.jsHeapSizeLimit } : null, + domElements: document.getElementsByTagName('*').length + }; +})()` + +/** Appends a sample every `interval` ms to a new JSONL file in the desktop logs, which exported debug logs include. */ +export function startMemoryLog(interval: number, log: Log) { + const file = path.join( + app.getPath("userData"), + "logs", + `memory-${new Date().toISOString().replace(/[:.]/g, "")}.jsonl`, + ) + + // Without the launch switch, Chromium quantizes renderer heap sizes and refreshes them rarely. + const precise = app.commandLine.hasSwitch("enable-precise-memory-info") + const pending = new WeakSet() + let stopped = false + let timer: ReturnType | undefined + + const sample = async () => { + const metrics = app.getAppMetrics() + + const renderers = await Promise.all( + webContents.getAllWebContents().map(async (contents): Promise => { + const identity = { id: contents.id, pid: contents.getOSProcessId(), type: contents.getType() } + + if (pending.has(contents)) return { ...identity, error: "Previous renderer sample is still pending" } + pending.add(contents) + let deadline: ReturnType | undefined + // A hung renderer must not stop native-memory logging or accumulate evaluation requests. + const reading = contents.executeJavaScript(probe).then(Schema.decodeUnknownSync(RendererMemory)) + void reading.finally(() => pending.delete(contents)).catch(() => undefined) + + return Promise.race([ + reading, + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error("Renderer sample timed out")), 2000) + }), + ]) + .then( + (sample) => ({ ...identity, sample }), + (error: Error) => ({ ...identity, error: error.message }), + ) + .finally(() => clearTimeout(deadline)) + }), + ) + + if (stopped) return + await mkdir(path.dirname(file), { recursive: true }) + await appendFile(file, `${JSON.stringify(memoryRecord(metrics, renderers, precise))}\n`, { mode: 0o600 }) + } + + const tick = () => { + void sample() + .catch((error: Error) => log.write("error", "memory diagnostics sample failed", { error: error.message })) + .finally(() => { + if (!stopped) timer = setTimeout(tick, interval) + }) + } + + log.write("info", "memory diagnostics started", { file, intervalMs: interval, precise }) + timer = setTimeout(tick, interval) + + return () => { + stopped = true + clearTimeout(timer) + log.write("info", "memory diagnostics stopped", { file }) + } +} diff --git a/packages/gui-extensions/src/debug/memory-record.test.ts b/packages/gui-extensions/src/debug/memory-record.test.ts new file mode 100644 index 00000000000..cb4f19f302f --- /dev/null +++ b/packages/gui-extensions/src/debug/memory-record.test.ts @@ -0,0 +1,44 @@ +import { afterEach, expect, setSystemTime, test } from "bun:test" +import { memoryRecord } from "./memory-record" + +afterEach(() => setSystemTime()) + +test("JSONL correlates renderer heaps and OS memory by pid, with explicit units and unavailable values", () => { + setSystemTime(new Date("2026-10-23T00:00:00Z")) + + const record = memoryRecord( + [ + { + pid: 42, + type: "Tab", + creationTime: 1, + cpu: { percentCPUUsage: 0, cumulativeCPUUsage: 0, idleWakeupsPerSecond: 0 }, + memory: { workingSetSize: 2048, peakWorkingSetSize: 4096 }, + }, + ], + [ + { id: 7, pid: 42, type: "window", sample: { jsHeap: { used: 1024, total: 4096, limit: 8192 }, domElements: 15 } }, + { id: 8, pid: 43, type: "window", error: "Renderer sample timed out" }, + ], + true, + ) + + expect(JSON.parse(`${JSON.stringify(record)}\n`)).toEqual({ + timestamp: "2026-10-23T00:00:00.000Z", + mainPid: process.pid, + nativeMemoryUnit: "KiB", + jsHeapUnit: "bytes", + preciseJsHeap: true, + processes: [{ type: "Tab", pid: 42, workingSetSize: 2048, privateBytes: null }], + renderers: [ + { + id: 7, + pid: 42, + type: "window", + sample: { jsHeap: { used: 1024, total: 4096, limit: 8192 }, domElements: 15 }, + nativeMemory: { workingSetSize: 2048, peakWorkingSetSize: 4096 }, + }, + { id: 8, pid: 43, type: "window", error: "Renderer sample timed out", nativeMemory: null }, + ], + }) +}) diff --git a/packages/gui-extensions/src/debug/memory-record.ts b/packages/gui-extensions/src/debug/memory-record.ts new file mode 100644 index 00000000000..34e4f83392f --- /dev/null +++ b/packages/gui-extensions/src/debug/memory-record.ts @@ -0,0 +1,36 @@ +import type { ProcessMetric } from "electron" +import { Schema } from "effect" + +export const RendererMemory = Schema.Struct({ + jsHeap: Schema.NullOr(Schema.Struct({ used: Schema.Number, total: Schema.Number, limit: Schema.Number })), + domElements: Schema.Number, +}) + +export type RendererRecord = { + id: number + pid: number + type: string + sample?: typeof RendererMemory.Type + error?: string +} + +export function memoryRecord(metrics: ProcessMetric[], renderers: RendererRecord[], preciseJsHeap: boolean) { + return { + timestamp: new Date().toISOString(), + mainPid: process.pid, + nativeMemoryUnit: "KiB", + jsHeapUnit: "bytes", + preciseJsHeap, + processes: metrics.map((metric) => ({ + type: metric.type, + pid: metric.pid, + workingSetSize: metric.memory.workingSetSize, + // Electron exposes privateBytes only on Windows. Null means unavailable, not zero. + privateBytes: metric.memory.privateBytes ?? null, + })), + renderers: renderers.map((renderer) => ({ + ...renderer, + nativeMemory: metrics.find((metric) => metric.pid === renderer.pid)?.memory ?? null, + })), + } +} diff --git a/packages/gui-extensions/src/debug/options.test.ts b/packages/gui-extensions/src/debug/options.test.ts new file mode 100644 index 00000000000..ddd97f2b371 --- /dev/null +++ b/packages/gui-extensions/src/debug/options.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { debugBuild, memoryLogInterval } from "./options" + +test.each([ + [{ packaged: false, channel: "dev" }, true], + [{ packaged: false, channel: "prod" }, true], + [{ packaged: true, channel: "beta" }, true], + [{ packaged: true, channel: "dev" }, false], + [{ packaged: true, channel: "prod" }, false], +] as const)("%o is a debug build: %s", (build, expected) => { + expect(debugBuild(build)).toBe(expected) +}) + +test.each([ + [undefined, undefined], + ["", undefined], + ["0", undefined], + ["-1", undefined], + ["NaN", undefined], + ["Infinity", undefined], + ["0.5", undefined], + ["2147484", undefined], + ["1", 1000], + ["2.5", 2500], + ["60", 60000], +] as const)("memory interval %s is bounded", (value, expected) => { + expect(memoryLogInterval(value)).toBe(expected) +}) diff --git a/packages/gui-extensions/src/debug/options.ts b/packages/gui-extensions/src/debug/options.ts new file mode 100644 index 00000000000..e69ab2a9f32 --- /dev/null +++ b/packages/gui-extensions/src/debug/options.ts @@ -0,0 +1,15 @@ +/** + * Builds with the debug tools: the dev server and Beta, as for the debug bar's Motion tools. Stable builds never run + * memory diagnostics or open a debugging port, whatever the environment says. + */ +export function debugBuild(build: { readonly packaged: boolean; readonly channel: string }) { + return !build.packaged || build.channel === "beta" +} + +/** Invalid values disable diagnostics rather than turning a bad interval into a hot timer. */ +export function memoryLogInterval(value: string | undefined) { + if (!value || !/^\d+(\.\d+)?$/.test(value)) return undefined + const seconds = Number(value) + + return seconds >= 1 && seconds <= 2_147_483 ? seconds * 1000 : undefined +} diff --git a/packages/gui-extensions/src/debug/renderer.tsx b/packages/gui-extensions/src/debug/renderer.tsx index bce1938f925..9f40a7a638c 100644 --- a/packages/gui-extensions/src/debug/renderer.tsx +++ b/packages/gui-extensions/src/debug/renderer.tsx @@ -19,6 +19,37 @@ const setup: Setup = (ctx) => { }) } + const hide = () => { + bar.update((draft) => { + draft.visible = false + }) + } + + // Offered by main only in debug builds. The bar stays open while diagnostics run, so they are never silently on. + const memory = ctx.uses.memory + + const memoryState = () => { + const live = memory() + + return live.status === "active" ? live.value.state() : undefined + } + + const memoryControl = () => { + const live = memory() + const state = memoryState() + + if (live.status !== "active" || !state) return + + return { + on: state.on, + env: state.env, + toggle: () => { + // The environment's choice lasts for the launch. + if (!state.env) void live.value.set(!state.on) + }, + } + } + const toggleMotion = () => { bar.update((draft) => { draft.motion = !draft.motion @@ -78,11 +109,12 @@ const setup: Setup = (ctx) => { at: "window.bottom", render: () => ( <> - +