From b79f538ebd148d1c26934a6306e6339a0f5bf079 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 2 Apr 2026 22:25:24 -0400 Subject: [PATCH 1/4] feat(cli): add automatic heap snapshots --- packages/opencode/src/cli/cmd/tui/worker.ts | 3 ++ packages/opencode/src/cli/heap.ts | 60 +++++++++++++++++++++ packages/opencode/src/flag/flag.ts | 12 +++++ packages/opencode/src/index.ts | 3 ++ 4 files changed, 78 insertions(+) create mode 100644 packages/opencode/src/cli/heap.ts diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index a83645d8927a..643676e3485d 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -13,6 +13,7 @@ import { Flag } from "@/flag/flag" import { setTimeout as sleep } from "node:timers/promises" import { writeHeapSnapshot } from "node:v8" import { WorkspaceID } from "@/control-plane/schema" +import { Heap } from "@/cli/heap" await Log.init({ print: process.argv.includes("--print-logs"), @@ -23,6 +24,8 @@ await Log.init({ })(), }) +Heap.start() + process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { e: e instanceof Error ? e.message : e, diff --git a/packages/opencode/src/cli/heap.ts b/packages/opencode/src/cli/heap.ts new file mode 100644 index 000000000000..a5c4843de50d --- /dev/null +++ b/packages/opencode/src/cli/heap.ts @@ -0,0 +1,60 @@ +import path from "path" +import { writeHeapSnapshot } from "node:v8" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { Log } from "@/util/log" + +const log = Log.create({ service: "heap" }) +const MINUTE = 60_000 +const LIMIT = 2 * 1024 * 1024 * 1024 + +export namespace Heap { + let timer: Timer | undefined + let lock = false + + export function start() { + if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return + if (timer) return + + const run = async () => { + if (lock) return + + const stat = process.memoryUsage() + if (stat.rss <= LIMIT) return + + lock = true + const file = path.join( + Global.Path.log, + `heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`, + ) + log.warn("heap usage exceeded limit", { + rss: stat.rss, + heap: stat.heapUsed, + file, + }) + + await Promise.resolve() + .then(() => writeHeapSnapshot(file)) + .catch((err) => { + log.error("failed to write heap snapshot", { + error: err instanceof Error ? err.message : String(err), + file, + }) + }) + + lock = false + } + + timer = setInterval(() => { + void run() + }, MINUTE) + timer.unref?.() + } + + export function stop() { + if (!timer) return + clearInterval(timer) + timer = undefined + lock = false + } +} diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 27190f2eb24e..56ed5d824b55 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -12,6 +12,7 @@ function falsy(key: string) { export namespace Flag { export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") + export declare const OPENCODE_AUTO_HEAP_SNAPSHOT: boolean export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export declare const OPENCODE_PURE: boolean @@ -87,6 +88,17 @@ export namespace Flag { } } +// Dynamic getter for OPENCODE_AUTO_HEAP_SNAPSHOT +// This must be evaluated at access time, not module load time, +// because tests may set this env var at runtime. +Object.defineProperty(Flag, "OPENCODE_AUTO_HEAP_SNAPSHOT", { + get() { + return truthy("OPENCODE_AUTO_HEAP_SNAPSHOT") + }, + enumerable: true, + configurable: false, +}) + // Dynamic getter for OPENCODE_DISABLE_PROJECT_CONFIG // This must be evaluated at access time, not module load time, // because external tooling may set this env var at runtime diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index bb14e0588af8..1fa027abf904 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -35,6 +35,7 @@ import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" +import { Heap } from "./cli/heap" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -96,6 +97,8 @@ const cli = yargs(args) })(), }) + Heap.start() + process.env.AGENT = "1" process.env.OPENCODE = "1" process.env.OPENCODE_PID = String(process.pid) From 0da70909dabf94ff5621ddf7983b40500f2016ff Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 2 Apr 2026 22:25:46 -0400 Subject: [PATCH 2/4] fix(cli): avoid repeated automatic heap snapshots --- packages/opencode/src/cli/heap.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/heap.ts b/packages/opencode/src/cli/heap.ts index a5c4843de50d..59d491a1cf20 100644 --- a/packages/opencode/src/cli/heap.ts +++ b/packages/opencode/src/cli/heap.ts @@ -11,6 +11,7 @@ const LIMIT = 2 * 1024 * 1024 * 1024 export namespace Heap { let timer: Timer | undefined let lock = false + let armed = true export function start() { if (!Flag.OPENCODE_AUTO_HEAP_SNAPSHOT) return @@ -20,9 +21,14 @@ export namespace Heap { if (lock) return const stat = process.memoryUsage() - if (stat.rss <= LIMIT) return + if (stat.rss <= LIMIT) { + armed = true + return + } + if (!armed) return lock = true + armed = false const file = path.join( Global.Path.log, `heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`, @@ -56,5 +62,6 @@ export namespace Heap { clearInterval(timer) timer = undefined lock = false + armed = true } } From 88317343e4c23bd14ea55c769a70b6e4fe4ef9c7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 2 Apr 2026 22:27:02 -0400 Subject: [PATCH 3/4] refactor(cli): simplify automatic heap snapshot monitor --- packages/opencode/src/cli/heap.ts | 8 -------- packages/opencode/src/flag/flag.ts | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/heap.ts b/packages/opencode/src/cli/heap.ts index 59d491a1cf20..bb5a3d0937ad 100644 --- a/packages/opencode/src/cli/heap.ts +++ b/packages/opencode/src/cli/heap.ts @@ -56,12 +56,4 @@ export namespace Heap { }, MINUTE) timer.unref?.() } - - export function stop() { - if (!timer) return - clearInterval(timer) - timer = undefined - lock = false - armed = true - } } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 56ed5d824b55..58bb09db092a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -90,7 +90,7 @@ export namespace Flag { // Dynamic getter for OPENCODE_AUTO_HEAP_SNAPSHOT // This must be evaluated at access time, not module load time, -// because tests may set this env var at runtime. +// because external tooling may set this env var at runtime. Object.defineProperty(Flag, "OPENCODE_AUTO_HEAP_SNAPSHOT", { get() { return truthy("OPENCODE_AUTO_HEAP_SNAPSHOT") From 1a18ce1cdbead54ee138ac57f0a848ee4849bc11 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 2 Apr 2026 22:27:52 -0400 Subject: [PATCH 4/4] refactor(flag): inline auto heap snapshot flag --- packages/opencode/src/flag/flag.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 58bb09db092a..1ac52dd17fa1 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -12,7 +12,7 @@ function falsy(key: string) { export namespace Flag { export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") - export declare const OPENCODE_AUTO_HEAP_SNAPSHOT: boolean + export const OPENCODE_AUTO_HEAP_SNAPSHOT = truthy("OPENCODE_AUTO_HEAP_SNAPSHOT") export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export declare const OPENCODE_PURE: boolean @@ -88,17 +88,6 @@ export namespace Flag { } } -// Dynamic getter for OPENCODE_AUTO_HEAP_SNAPSHOT -// This must be evaluated at access time, not module load time, -// because external tooling may set this env var at runtime. -Object.defineProperty(Flag, "OPENCODE_AUTO_HEAP_SNAPSHOT", { - get() { - return truthy("OPENCODE_AUTO_HEAP_SNAPSHOT") - }, - enumerable: true, - configurable: false, -}) - // Dynamic getter for OPENCODE_DISABLE_PROJECT_CONFIG // This must be evaluated at access time, not module load time, // because external tooling may set this env var at runtime