feat(desktop): add opt-in memory logging and remote debugging (#58315)

This commit is contained in:
Kit Langton
2026-10-24 13:04:12 -07:00
committed by GitHub
Unverified
parent 0f379f0a33
commit 64aaf2eaae
15 changed files with 371 additions and 8 deletions
@@ -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)
})
@@ -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
}
@@ -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))
+1
View File
@@ -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": {
+13
View File
@@ -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: {
/>
</Show>
</Show>
<Show when={props.memory}>
{(memory) => (
<ToggleCell
active={memory().on}
inline={props.inline}
label={ctx.t("memory.label")}
tip={ctx.t(memory().env ? "memory.tipEnv" : "memory.tip")}
value={ctx.t(memory().env ? "memory.env" : memory().on ? "focus.on" : "focus.off")}
onClick={() => memory().toggle()}
/>
)}
</Show>
<Show when={props.motion}>
<div class="ms-auto flex items-center gap-[9px]">
{/* The lab's route exists only on the dev server and in Beta. */}
@@ -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=<seconds>`
* 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 },
},
})
@@ -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.",
+4 -2
View File
@@ -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"),
+36 -1
View File
@@ -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<typeof definition> = (ctx) => {
const queue: Queue = { tail: Promise.resolve() }
@@ -148,6 +153,36 @@ const setup: MainSetup<typeof definition> = (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
@@ -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<Electron.WebContents>()
let stopped = false
let timer: ReturnType<typeof setTimeout> | undefined
const sample = async () => {
const metrics = app.getAppMetrics()
const renderers = await Promise.all(
webContents.getAllWebContents().map(async (contents): Promise<RendererRecord> => {
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<typeof setTimeout> | 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<never>((_, 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 })
}
}
@@ -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 },
],
})
})
@@ -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,
})),
}
}
@@ -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)
})
@@ -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
}
+34 -2
View File
@@ -19,6 +19,37 @@ const setup: Setup<typeof Debug> = (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<typeof Debug> = (ctx) => {
at: "window.bottom",
render: () => (
<>
<Show when={visible()}>
<Show when={visible() || memoryState()?.on}>
<Suspense>
<DebugBar
diagnostics={import.meta.env.DEV}
onClose={toggle}
memory={memoryControl()}
onClose={hide}
motion={Motion ? { open: motion(), toggle: toggleMotion } : undefined}
inline
/>