feat(desktop): capture heaps on an opt-in signal (#58317)

This commit is contained in:
Kit Langton
2026-10-24 13:14:57 -07:00
committed by GitHub
Unverified
parent 64aaf2eaae
commit 8d52b4e452
6 changed files with 159 additions and 19 deletions
@@ -26,8 +26,9 @@ 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.
* log in the desktop logs, and SIGUSR2 heap snapshots when `OPENCODE_DESKTOP_HEAP_DIRECTORY` names an absolute folder.
* 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",
+66 -17
View File
@@ -1,6 +1,6 @@
import { app, contentTracing, screen, type BrowserWindow } from "electron"
import { Schema } from "effect"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { mkdir, readFile, stat, writeFile } from "node:fs/promises"
import path from "node:path"
import { getHeapSpaceStatistics, getHeapStatistics, writeHeapSnapshot } from "node:v8"
import type { MainSetup } from "../sdk/main"
@@ -10,6 +10,8 @@ import { summarize, traceConfig } from "./memory-infra"
import { startMemoryLog } from "./memory-log"
import { debugBuild, memoryLogInterval } from "./options"
import { captureServer } from "./server-heap"
import { signalIncludesServer, snapshotDirectory } from "./signal"
import { ProcessSignal } from "@opencode/util/process-signal"
/** A desktop process the manifest lists: the file written, or why there is none. */
type Snapshot = { readonly process: "main" | "window"; readonly pid: number; readonly window?: number } & (
@@ -58,15 +60,12 @@ type Running = { stop?: () => void }
const setup: MainSetup<typeof definition> = (ctx) => {
const queue: Queue = { tail: Promise.resolve() }
const capture = async () => {
const directory = path.join(
app.getPath("downloads"),
`opencode-heap-${new Date().toISOString().replace(/[:.]/g, "")}`,
)
const capture = async (root: string, includeServer: boolean) => {
const directory = path.join(root, `opencode-heap-${new Date().toISOString().replace(/[:.]/g, "")}`)
// Snapshots hold prompts, file contents and credentials. Recursive, because Downloads can be missing on Linux.
await mkdir(directory, { recursive: true, mode: 0o700 })
ctx.log.write("info", "capturing heap snapshots", { directory })
ctx.log.write("info", "capturing heap snapshots", { directory, includeServer })
// Measurements first: writing a heap snapshot raises the memory of the process it serializes.
const before = {
@@ -85,7 +84,7 @@ const setup: MainSetup<typeof definition> = (ctx) => {
// snapshot can double a process's memory, and a leaking app may not have room for every window at once. Main goes
// last: writing its own heap blocks the loop that receives the others.
const [serverSnapshot, windowSnapshots] = await Promise.all([
captureServer(ctx.serverEndpoints.get("sidecar"), directory, ctx.scope.signal),
includeServer ? captureServer(ctx.serverEndpoints.get("sidecar"), directory, ctx.scope.signal) : undefined,
(async () => {
const list: Snapshot[] = []
@@ -102,14 +101,31 @@ const setup: MainSetup<typeof definition> = (ctx) => {
(error: Error): Snapshot => ({ process: "main", pid: process.pid, error: error.message }),
)
const snapshots = [serverSnapshot, ...windowSnapshots, main]
const snapshots = await Promise.all(
[...(serverSnapshot ? [serverSnapshot] : []), ...windowSnapshots, main].map(async (snapshot) => {
if ("error" in snapshot) return snapshot
const file = "file" in snapshot ? path.join(directory, snapshot.file) : snapshot.path
const bytes = await stat(file).then(
(info) => info.size,
() => null,
)
ctx.log.write("info", "heap snapshot written", { file, bytes })
return { ...snapshot, bytes }
}),
)
const gpu = await app.getGPUInfo("basic").catch((error: Error) => ({ error: error.message }))
// One readable answer to "where is the memory", per process, in MiB.
await write(directory, "memory.json", {
unit: "MiB",
native: "summary" in native ? native.summary : native,
server: "error" in serverSnapshot || !serverSnapshot.memory ? undefined : mibs(serverSnapshot.memory),
server:
!serverSnapshot || "error" in serverSnapshot || !serverSnapshot.memory
? undefined
: mibs(serverSnapshot.memory),
pages,
note: "native: Chromium's memory-infra dump per process, before the heap snapshots. Some allocators overlap: cc, skia, gpu and web_cache report memory that malloc, discardable or shared_memory also count. pages: what each app window's page held. server: the server's process.memoryUsage().",
})
@@ -117,6 +133,7 @@ const setup: MainSetup<typeof definition> = (ctx) => {
// Written last, so a script can wait for it.
await write(directory, "manifest.json", {
created: new Date().toISOString(),
includeServer,
app: {
name: app.getName(),
version: app.getVersion(),
@@ -144,14 +161,16 @@ const setup: MainSetup<typeof definition> = (ctx) => {
return { directory, complete }
}
const enqueue = (directory: string, includeServer: boolean) => {
const run = queue.tail.then(() => capture(directory, includeServer))
queue.tail = run.catch(() => undefined)
return run
}
ctx.provide(HeapSnapshots, {
// A window that closes meanwhile does not cancel the capture.
write: () => {
const run = queue.tail.then(capture)
queue.tail = run.catch(() => undefined)
return run
},
write: () => enqueue(app.getPath("downloads"), true),
})
if (!debugBuild(ctx.build)) return
@@ -160,11 +179,41 @@ const setup: MainSetup<typeof definition> = (ctx) => {
const envInterval = memoryLogInterval(process.env.OPENCODE_DESKTOP_MEMORY_LOG)
const stored = ctx.stores.memory
const running: Running = {}
const directory = process.platform === "win32" ? undefined : snapshotDirectory(process.env)
let capturing = false
const onSignal = () => {
// Signals coalesce while a capture is running; repeated signals must not fill the disk with queued dumps.
if (!directory || capturing || ctx.scope.signal.aborted) return
capturing = true
void enqueue(directory, signalIncludesServer(process.env))
.catch((error: Error) => ctx.log.write("error", "heap snapshot capture failed", { error: error.message }))
.finally(() => {
capturing = false
})
}
const start = () => {
const stopLog = startMemoryLog(envInterval ?? 30_000, ctx.log)
if (!directory) return stopLog
// Installed the first time diagnostics turn on, and kept: a script's late signal while they are off, or while
// the extension reloads, is logged and ignored instead of killing the app.
ProcessSignal.install("SIGUSR2", () => ctx.log.write("warn", "heap snapshot signal ignored: diagnostics are off"))
const unsubscribe = ProcessSignal.subscribe("SIGUSR2", onSignal)
ctx.log.write("info", "heap snapshot signal enabled", { pid: process.pid, signal: "SIGUSR2", directory })
return () => {
unsubscribe()
stopLog()
}
}
const sync = () => {
const on = envInterval !== undefined || stored.value
if (on && !running.stop) running.stop = startMemoryLog(envInterval ?? 30_000, ctx.log)
if (on && !running.stop) running.stop = start()
if (on || !running.stop) return
running.stop()
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { signalIncludesServer, snapshotDirectory } from "./signal"
test.each([
[undefined, false],
["relative", false],
["", false],
[path.resolve("heaps"), true],
] as const)("signal snapshots require an absolute destination (%s)", (directory, enabled) => {
expect(snapshotDirectory({ OPENCODE_DESKTOP_HEAP_DIRECTORY: directory })).toBe(enabled ? directory : undefined)
})
test.each([undefined, "", "0", "true", "1"])("signal server snapshots require the explicit 1 opt-in (%s)", (value) => {
expect(signalIncludesServer({ OPENCODE_DESKTOP_HEAP_INCLUDE_SERVER: value })).toBe(value === "1")
})
@@ -0,0 +1,13 @@
import path from "node:path"
/** Where SIGUSR2 writes heap snapshots while memory diagnostics are on: a caller-chosen absolute folder, or none. */
export function snapshotDirectory(env: NodeJS.ProcessEnv) {
const directory = env.OPENCODE_DESKTOP_HEAP_DIRECTORY
return directory && path.isAbsolute(directory) ? directory : undefined
}
/** Server heap serialization blocks all of its sessions; signal captures require a separate opt-in. */
export function signalIncludesServer(env: NodeJS.ProcessEnv) {
return env.OPENCODE_DESKTOP_HEAP_INCLUDE_SERVER === "1"
}
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
// Windows does not deliver POSIX user signals.
test.skipIf(process.platform === "win32")(
"an installed signal survives absent and removed subscribers",
async () => {
const child = Bun.spawn(
[
process.execPath,
"-e",
`
import { ProcessSignal } from ${JSON.stringify(new URL("./process-signal.ts", import.meta.url).pathname)};
const deadline = setTimeout(() => process.exit(2), 10000);
let ignored = 0;
ProcessSignal.install("SIGUSR2", () => {
console.log("ignored");
if (++ignored === 2) {
clearTimeout(deadline);
process.exit(0);
}
const remove = ProcessSignal.subscribe("SIGUSR2", () => {
console.log("captured");
remove();
process.kill(process.pid, "SIGUSR2");
});
process.kill(process.pid, "SIGUSR2");
});
process.kill(process.pid, "SIGUSR2");
`,
],
{ stdout: "pipe", stderr: "pipe" },
)
expect(await new Response(child.stderr).text()).toBe("")
expect(await child.exited).toBe(0)
expect((await new Response(child.stdout).text()).trim().split("\n")).toEqual(["ignored", "captured", "ignored"])
},
15000,
)
+23
View File
@@ -0,0 +1,23 @@
export * as ProcessSignal from "./process-signal.js"
const handlers = new Map<NodeJS.Signals, Set<() => void>>()
/** Install once at process startup. Removing subscribers never restores the signal's default action. */
export function install(signal: NodeJS.Signals, unavailable: () => void) {
if (handlers.has(signal)) return
const listeners = new Set<() => void>()
handlers.set(signal, listeners)
process.on(signal, () => {
if (!listeners.size) return unavailable()
listeners.forEach((listener) => listener())
})
}
/** Subscribe only to an installed signal. The returned cleanup removes this subscriber, not the process handler. */
export function subscribe(signal: NodeJS.Signals, listener: () => void) {
const listeners = handlers.get(signal)
listeners?.add(listener)
return () => {
listeners?.delete(listener)
}
}