mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 07:11:25 +03:00
feat(desktop): add opt-in memory logging and remote debugging (#58315)
This commit is contained in:
committed by
GitHub
Unverified
parent
0f379f0a33
commit
64aaf2eaae
@@ -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))
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user