Skip to content

Commit 6102079

Browse files
authored
feat(cli): add debug heap-dump command (#58252)
1 parent d93568f commit 6102079

20 files changed

Lines changed: 601 additions & 50 deletions

File tree

‎.opencode/skills/opencode-dev/SKILL.md‎

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,18 @@ Workflow for interactively exercising the V2 CLI/TUI and server while developing
1212
opencode V2 is a client/server system, not a single monolithic process:
1313

1414
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
15-
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
16-
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
15+
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via `@opencode/client` (`OpenCode.make`).
16+
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid, password}`; the password is used for HTTP Basic auth. Before reusing a registration, the client calls `GET /api/info` to confirm the server is alive, is the registered PID, and is version-compatible. `opencode service status` prints the registered URL.
1717
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
1818
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
1919
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
2020
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
2121

2222
## Starting the dev TUI
2323

24-
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
25-
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
24+
- `packages/cli` is the CLI and TUI entrypoint. `bun dev` from `packages/cli` and the repository-root `bun dev` both run `packages/cli/src/index.ts`.
25+
- `bun dev` connects to the shared local-channel background service (`service-local.json`), starting it if needed. Every worktree on the machine shares that service. Add `--standalone` for a private server tied to this one process.
26+
- Use `bun run dev:live` from the repository root to run this worktree's TUI against the installed `opencode` background server and your real sessions.
2627

2728
## Interactive debugging with termctrl
2829

@@ -57,7 +58,7 @@ termctrl resize opencode-v2-dev --cols 100 --rows 30
5758
termctrl show opencode-v2-dev
5859
```
5960

60-
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
61+
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. Restarting the TUI does not restart the shared background service, so run `bun dev service restart` after server-side changes, or use `--standalone`.
6162
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
6263
- Always clean up the Terminal Control session when the check is complete:
6364

@@ -67,13 +68,13 @@ termctrl stop opencode-v2-dev
6768

6869
## Comparing V2 against the legacy TUI
6970

70-
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
71+
The legacy V1 CLI is still published as `opencode-ai` on npm; V2 is `@opencode/cli`. Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
7172

7273
```bash
7374
# From packages/cli: local V2 TUI
7475
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
7576

76-
# Released legacy TUI behavior reference
77+
# Released legacy V1 TUI behavior reference
7778
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
7879

7980
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
@@ -90,7 +91,7 @@ termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
9091
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
9192

9293
```bash
93-
bun dev api get /health
94+
bun dev api get /api/info
9495
bun dev api get /openapi.json
9596
bun dev api <operationId> --param key=value
9697
```
@@ -99,13 +100,13 @@ bun dev api <operationId> --param key=value
99100
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
100101
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
101102

102-
## Auditing installed `opencode2` sessions
103+
## Auditing installed `opencode` sessions
103104

104-
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
105+
Installed (released) sessions use `~/.local/share/opencode/opencode.db` and `~/.local/share/opencode/log/opencode.log`. A local/dev checkout uses `opencode-local.db` and `opencode-local.log` instead, and `OPENCODE_DB` overrides the database. Run `opencode service status` before `opencode api`, because `opencode api` starts a server when none is healthy.
105106

106107
For a supplied `ses_...` ID, compare three sources:
107108

108-
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
109+
- `opencode api get /api/session/active` and the Session/message endpoints for live server state.
109110
- The database's ordered `event` rows for durable history.
110111
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
111112

@@ -141,16 +142,24 @@ grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
141142

142143
## Heap snapshots
143144

144-
The CLI installs a `SIGUSR1` listener on non-Windows processes in `packages/cli/src/heap.ts`. Use it to capture the installed `opencode2` server without restarting it or attaching an inspector.
145+
On every platform, including Windows, capture the running background server without restarting it or attaching an inspector:
146+
147+
```bash
148+
opencode debug heap-dump
149+
```
150+
151+
It discovers the already-running service (never starts or replaces one), calls `POST /api/debug/heap-dump`, waits until the file is complete, and prints its server-local path. Pass `--server <url>` (with `OPENCODE_PASSWORD`) for a server started with `serve`. A 404 means the running server predates the endpoint.
152+
153+
The Unix `SIGUSR1` listener in `packages/cli/src/heap.ts` shares the same writer (`packages/util/src/heap-snapshot.ts`) and is still useful for a process that is not an HTTP server, such as a TUI client:
145154

146155
1. Find the processes and inspect their roles and memory:
147156

148157
```bash
149-
pgrep -a -f 'opencode2\.exe|opencode2'
158+
pgrep -af opencode
150159
ps -o pid,ppid,rss,vsz,lstart,etime,cmd -p <pid>,<pid>
151160
```
152161

153-
2. Signal the process whose heap needs investigation. For shared-service memory, target the `opencode2.exe serve --service` child, not the short wrapper/TUI process:
162+
2. Signal the process whose heap needs investigation. For shared-service memory, target the `opencode serve --service` process, not a TUI process:
154163

155164
```bash
156165
kill -USR1 <server-pid>
@@ -189,13 +198,13 @@ Heap serialization itself can temporarily increase RSS and allocator high-water
189198

190199
The CLI installs a `SIGPROF` listener on non-Windows processes in `packages/cli/src/cpu-profile.ts`. One signal starts a ten-second CPU profile and stops it automatically; additional signals are ignored while a profile is active. There is no CPU profile CLI flag or environment variable.
191200

192-
1. Get the PID from the health endpoint. For shared-service performance, target the server PID returned here rather than the short wrapper or TUI process:
201+
1. Get the PID from the info endpoint. For shared-service performance, target the server PID returned here rather than a TUI process:
193202

194203
```bash
195-
opencode2 api get /api/health
204+
opencode api get /api/info
196205
```
197206

198-
Use `bun dev api get /api/health` instead when targeting the local/dev channel.
207+
Use `bun dev api get /api/info` instead when targeting the local/dev channel.
199208

200209
2. Start the capture:
201210

‎packages/cli/src/commands/commands.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
117117
commands: [
118118
Spec.make("agents", { description: "List all agents" }),
119119
Spec.make("config", { description: "List configuration sources" }),
120+
Spec.make("heap-dump", {
121+
description: "Write a heap snapshot of the running server and print its path",
122+
params: {
123+
server: Flag.String("server").pipe(
124+
Flag.withDescription("Connect to a server URL instead of the background service"),
125+
Flag.optional,
126+
),
127+
},
128+
}),
120129
Spec.make("paths", {
121130
description: "Show global paths (data, config, cache, state)",
122131
params: {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { ClientError, OpenCode } from "@opencode/client"
2+
import { Service } from "@opencode/client/effect/service"
3+
import { Effect, Option, Predicate } from "effect"
4+
import { EOL } from "node:os"
5+
import { Commands } from "../../commands"
6+
import { Runtime } from "../../../framework/runtime"
7+
import { ServerConnection } from "../../../services/server-connection"
8+
import { ServiceConfig } from "../../../services/service-config"
9+
import { errorMessage } from "../../../util/error"
10+
11+
const handler = Effect.fn("cli.debug.heap-dump")(function* (
12+
input: Runtime.Input<(typeof Commands.commands.debug.commands)["heap-dump"]>,
13+
) {
14+
const endpoint = yield* running(Option.getOrUndefined(input.server))
15+
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
16+
const snapshot = yield* Effect.tryPromise({
17+
try: (signal) => client.debug.heapDump({ signal }),
18+
catch: (cause) =>
19+
cause instanceof ClientError &&
20+
cause.reason === "UnexpectedStatus" &&
21+
Predicate.hasProperty(cause.cause, "status") &&
22+
cause.cause.status === 404
23+
? new Error(`The server at ${endpoint.url} does not support heap dumps. Update and restart it first.`)
24+
: cause,
25+
})
26+
process.stdout.write(snapshot.path + EOL)
27+
})
28+
29+
export default Runtime.handler(Commands.commands.debug.commands["heap-dump"], (input) =>
30+
handler(input).pipe(
31+
Effect.catch((error) =>
32+
Effect.sync(() => {
33+
process.stderr.write(errorMessage(error) + EOL)
34+
process.exitCode = 1
35+
}),
36+
),
37+
),
38+
)
39+
40+
// A fresh server has nothing worth capturing, so only an already-running one is used: never start,
41+
// replace, or restart the background service.
42+
const running = Effect.fnUntraced(function* (server: string | undefined) {
43+
if (server !== undefined) return (yield* ServerConnection.resolve({ server })).endpoint
44+
const options = yield* ServiceConfig.options()
45+
const endpoint = yield* Service.discover({ ...options, version: undefined })
46+
if (endpoint) return endpoint
47+
return yield* Effect.fail(
48+
new Error("No background server is running. Pass --server <url> to capture a server started another way."),
49+
)
50+
})

‎packages/cli/src/heap.ts‎

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Global } from "@opencode/util/global"
2+
import { HeapSnapshot } from "@opencode/util/heap-snapshot"
23
import { Effect, Queue } from "effect"
3-
import path from "node:path"
44

55
export const listen = Effect.gen(function* () {
66
const global = yield* Global.Service
@@ -16,18 +16,9 @@ export const listen = Effect.gen(function* () {
1616
)
1717
yield* Queue.take(signals).pipe(
1818
Effect.andThen(
19-
Effect.suspend(() => {
20-
const file = path.join(
21-
global.log,
22-
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
23-
)
24-
return Effect.gen(function* () {
25-
yield* Effect.logInfo("writing heap snapshot", { path: file })
26-
const { writeHeapSnapshot } = yield* Effect.tryPromise(() => import("node:v8"))
27-
yield* Effect.try(() => writeHeapSnapshot(file))
28-
yield* Effect.logInfo("heap snapshot written", { path: file })
29-
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to write heap snapshot", { path: file, cause })))
30-
}),
19+
HeapSnapshot.write(global.log).pipe(
20+
Effect.catchCause((cause) => Effect.logError("failed to write heap snapshot", { cause })),
21+
),
3122
),
3223
Effect.forever,
3324
Effect.forkScoped({ startImmediately: true }),

‎packages/cli/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const Handlers = Runtime.handlers(Commands, {
4242
debug: {
4343
agents: () => import("./commands/handlers/debug/agents"),
4444
config: () => import("./commands/handlers/debug/config"),
45+
"heap-dump": () => import("./commands/handlers/debug/heap-dump"),
4546
paths: () => import("./commands/handlers/debug/paths"),
4647
},
4748
mcp: {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { expect, test } from "bun:test"
2+
import fs from "node:fs/promises"
3+
import path from "node:path"
4+
import { ServiceConfig } from "../src/services/service-config"
5+
import { OPENCODE_VERSION } from "../src/version"
6+
import { isolatedEnv } from "./fixture/environment"
7+
import { tmpdir } from "./fixture/tmpdir"
8+
9+
// The server owns the snapshot writer (packages/server/test/heap-dump.test.ts); these cover how the
10+
// command finds a server, what it prints, and that it never starts or replaces one.
11+
12+
test("prints the snapshot path from the registered server without replacing it", async () => {
13+
await using root = await tmpdir()
14+
const requests: Array<{ method: string; path: string; authorization: string | null }> = []
15+
using server = fake((request) => {
16+
requests.push({
17+
method: request.method,
18+
path: new URL(request.url).pathname,
19+
authorization: request.headers.get("authorization"),
20+
})
21+
return Response.json({ path: "/server/log/heap-1-2.heapsnapshot", pid: process.pid })
22+
})
23+
const registration = await register(root.path, server.url.href)
24+
25+
const result = await cli(root.path, ["debug", "heap-dump"])
26+
27+
expect({ code: result.code, stderr: result.stderr, stdout: result.stdout.trim() }).toEqual({
28+
code: 0,
29+
stderr: "",
30+
stdout: "/server/log/heap-1-2.heapsnapshot",
31+
})
32+
expect(requests).toEqual([
33+
{ method: "POST", path: "/api/debug/heap-dump", authorization: `Basic ${btoa("opencode:secret")}` },
34+
])
35+
expect(await Bun.file(registration.file).json()).toEqual(registration.info)
36+
})
37+
38+
test("fails without starting a server when none is registered", async () => {
39+
await using root = await tmpdir()
40+
41+
const result = await cli(root.path, ["debug", "heap-dump"])
42+
43+
expect(result.stderr).toContain("No background server is running")
44+
expect(result.code).toBe(1)
45+
expect(await Bun.file(path.join(root.path, "state", "opencode", ServiceConfig.filename())).exists()).toBe(false)
46+
})
47+
48+
test("explains when the server predates heap dumps", async () => {
49+
await using root = await tmpdir()
50+
using server = fake(() => new Response(null, { status: 404 }))
51+
52+
const result = await cli(root.path, ["debug", "heap-dump", "--server", server.url.href], {
53+
OPENCODE_PASSWORD: "secret",
54+
})
55+
56+
expect(result.stderr.trim()).toBe(
57+
`The server at ${server.url.href} does not support heap dumps. Update and restart it first.`,
58+
)
59+
expect(result.code).toBe(1)
60+
})
61+
62+
// Answers the health probe like a ready V2 server and passes every other request to `handle`.
63+
function fake(handle: (request: Request) => Response) {
64+
return Bun.serve({
65+
hostname: "127.0.0.1",
66+
port: 0,
67+
fetch: (request) =>
68+
new URL(request.url).pathname === "/api/info"
69+
? Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
70+
: handle(request),
71+
})
72+
}
73+
74+
async function register(root: string, url: string) {
75+
const file = path.join(root, "state", "opencode", ServiceConfig.filename())
76+
const info = { version: OPENCODE_VERSION, url, pid: process.pid, password: "secret" }
77+
await fs.mkdir(path.dirname(file), { recursive: true })
78+
await fs.writeFile(file, JSON.stringify(info))
79+
return { file, info }
80+
}
81+
82+
async function cli(root: string, args: string[], env: Record<string, string> = {}) {
83+
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
84+
cwd: path.join(import.meta.dir, ".."),
85+
env: isolatedEnv(root, env),
86+
stdout: "pipe",
87+
stderr: "pipe",
88+
})
89+
const [stdout, stderr, code] = await Promise.all([
90+
new Response(child.stdout).text(),
91+
new Response(child.stderr).text(),
92+
child.exited,
93+
])
94+
return { stdout, stderr, code }
95+
}

‎packages/client/src/effect/api/api.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2269,6 +2269,9 @@ export interface VcsApi<E = never> {
22692269
readonly diff: VcsDiffOperation<E>
22702270
}
22712271

2272+
export type DebugHeapDumpOutput = { readonly path: string; readonly pid: number }
2273+
export type DebugHeapDumpOperation<E = never> = () => Effect.Effect<DebugHeapDumpOutput, E>
2274+
22722275
export type DebugLocationListOutput = ReadonlyArray<Location.PublicRef>
22732276
export type DebugLocationListOperation<E = never> = () => Effect.Effect<DebugLocationListOutput, E>
22742277

@@ -2279,6 +2282,7 @@ export type DebugLocationEvictOperation<E = never> = (
22792282
) => Effect.Effect<DebugLocationEvictOutput, E>
22802283

22812284
export interface DebugApi<E = never> {
2285+
readonly heapDump: DebugHeapDumpOperation<E>
22822286
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
22832287
}
22842288

‎packages/client/src/effect/generated/client.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ import type {
266266
VcsBranchListOutput,
267267
VcsDiffInput,
268268
VcsDiffOutput,
269+
DebugHeapDumpOutput,
269270
DebugLocationListOutput,
270271
DebugLocationEvictInput,
271272
DebugLocationEvictOutput,
@@ -1624,6 +1625,9 @@ const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
16241625
diff: EndpointVcsDiff(raw),
16251626
})
16261627

1628+
const EndpointDebugHeapDump = (raw: RawClient["server.debug"]) => () =>
1629+
preserveEffect<DebugHeapDumpOutput>()(raw["debug.heapDump"]({}).pipe(Effect.mapError(mapClientError)))
1630+
16271631
const EndpointDebugLocationList = (raw: RawClient["server.debug"]) => () =>
16281632
preserveEffect<DebugLocationListOutput>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
16291633

@@ -1633,6 +1637,7 @@ const EndpointDebugLocationEvict = (raw: RawClient["server.debug"]) => (input?:
16331637
)
16341638

16351639
const adaptGroupDebug = (raw: RawClient["server.debug"]) => ({
1640+
heapDump: EndpointDebugHeapDump(raw),
16361641
location: { list: EndpointDebugLocationList(raw), evict: EndpointDebugLocationEvict(raw) },
16371642
})
16381643

0 commit comments

Comments
 (0)