mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
feat(desktop): capture heaps on an opt-in signal (#58317)
This commit is contained in:
committed by
GitHub
Unverified
parent
64aaf2eaae
commit
8d52b4e452
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user