From 8d52b4e4526ade37642a5f97c729a98f127d2dbf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 24 Oct 2026 13:14:57 -0700 Subject: [PATCH] feat(desktop): capture heaps on an opt-in signal (#58317) --- packages/gui-extensions/src/debug/contract.ts | 5 +- packages/gui-extensions/src/debug/main.ts | 83 +++++++++++++++---- .../gui-extensions/src/debug/signal.test.ts | 16 ++++ packages/gui-extensions/src/debug/signal.ts | 13 +++ packages/util/src/process-signal.test.ts | 38 +++++++++ packages/util/src/process-signal.ts | 23 +++++ 6 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 packages/gui-extensions/src/debug/signal.test.ts create mode 100644 packages/gui-extensions/src/debug/signal.ts create mode 100644 packages/util/src/process-signal.test.ts create mode 100644 packages/util/src/process-signal.ts diff --git a/packages/gui-extensions/src/debug/contract.ts b/packages/gui-extensions/src/debug/contract.ts index 108dc2b53c8..ebf7652aac8 100644 --- a/packages/gui-extensions/src/debug/contract.ts +++ b/packages/gui-extensions/src/debug/contract.ts @@ -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=` - * 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=` turns them on for one + * launch, and the toggle cannot turn that off. */ export const MemoryDiagnostics = Ipc.define({ id: "debug.memory", diff --git a/packages/gui-extensions/src/debug/main.ts b/packages/gui-extensions/src/debug/main.ts index e80f4d8ab8a..56065ae2095 100644 --- a/packages/gui-extensions/src/debug/main.ts +++ b/packages/gui-extensions/src/debug/main.ts @@ -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 = (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 = (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 = (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 = (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 = (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 = (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() diff --git a/packages/gui-extensions/src/debug/signal.test.ts b/packages/gui-extensions/src/debug/signal.test.ts new file mode 100644 index 00000000000..ae50fc4b519 --- /dev/null +++ b/packages/gui-extensions/src/debug/signal.test.ts @@ -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") +}) diff --git a/packages/gui-extensions/src/debug/signal.ts b/packages/gui-extensions/src/debug/signal.ts new file mode 100644 index 00000000000..9bc94df7e99 --- /dev/null +++ b/packages/gui-extensions/src/debug/signal.ts @@ -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" +} diff --git a/packages/util/src/process-signal.test.ts b/packages/util/src/process-signal.test.ts new file mode 100644 index 00000000000..5f9ea420e77 --- /dev/null +++ b/packages/util/src/process-signal.test.ts @@ -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, +) diff --git a/packages/util/src/process-signal.ts b/packages/util/src/process-signal.ts new file mode 100644 index 00000000000..4544ffaa509 --- /dev/null +++ b/packages/util/src/process-signal.ts @@ -0,0 +1,23 @@ +export * as ProcessSignal from "./process-signal.js" + +const handlers = new Map 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) + } +}