From 7700faad81f3423ee2b68b1f09d356841c3a264f Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 19 Aug 2026 10:45:05 -0400 Subject: [PATCH] feat(cli): capture CPU profiles with SIGPROF (#43446) --- packages/cli/src/commands/commands.ts | 5 ++-- packages/cli/src/commands/global-flags.ts | 12 --------- packages/cli/src/cpu-profile.ts | 30 +++++++++++++++++++-- packages/cli/src/framework/runtime.ts | 21 ++------------- packages/cli/src/index.ts | 2 ++ packages/cli/src/services/service-config.ts | 1 - packages/cli/test/cpu-profile.test.ts | 18 +++++++++++++ packages/cli/test/service.test.ts | 23 ---------------- packages/core/src/plugin/skill/opencode.md | 18 +++++++++++++ 9 files changed, 70 insertions(+), 60 deletions(-) delete mode 100644 packages/cli/src/commands/global-flags.ts create mode 100644 packages/cli/test/cpu-profile.test.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 12852d7709..384dab4331 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -1,6 +1,5 @@ -import { Argument, Command, Flag } from "effect/unstable/cli" +import { Argument, Flag } from "effect/unstable/cli" import { Spec } from "../framework/spec" -import { GlobalFlags } from "./global-flags" declare const OPENCODE_CLI_NAME: string | undefined @@ -343,4 +342,4 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME ], }) -export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) } +export const Commands = Root diff --git a/packages/cli/src/commands/global-flags.ts b/packages/cli/src/commands/global-flags.ts deleted file mode 100644 index 5b19816357..0000000000 --- a/packages/cli/src/commands/global-flags.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * as GlobalFlags from "./global-flags" - -import { Flag, GlobalFlag } from "effect/unstable/cli" - -export const CpuProfile = GlobalFlag.setting("cpu-profile")({ - flag: Flag.string("cpu-profile").pipe( - Flag.withDescription("Write a CPU profile to this path when the process stops"), - Flag.optional, - ), -}) - -export const all = [CpuProfile] as const diff --git a/packages/cli/src/cpu-profile.ts b/packages/cli/src/cpu-profile.ts index fac1fad7a4..9c7bcf2751 100644 --- a/packages/cli/src/cpu-profile.ts +++ b/packages/cli/src/cpu-profile.ts @@ -1,10 +1,36 @@ export * as CpuProfile from "./cpu-profile" -import { Effect, FileSystem } from "effect" +import { Global } from "@opencode-ai/util/global" +import { Effect, FileSystem, Queue } from "effect" import { Session } from "node:inspector" import path from "node:path" -export function run(file: string, effect: Effect.Effect) { +export const listen = Effect.gen(function* () { + const global = yield* Global.Service + if (process.platform === "win32") return + const signals = yield* Queue.dropping(1) + yield* Effect.acquireRelease( + Effect.sync(() => { + const handler = () => Queue.offerUnsafe(signals, undefined) + process.on("SIGPROF", handler) + return handler + }), + (handler) => Effect.sync(() => process.off("SIGPROF", handler)), + ) + yield* Effect.gen(function* () { + yield* Queue.take(signals) + const file = path.join( + global.log, + `cpu-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.cpuprofile`, + ) + yield* run(file, Effect.sleep("10 seconds")).pipe( + Effect.catchCause((cause) => Effect.logError("Failed to capture CPU profile", { path: file, cause })), + ) + yield* Queue.poll(signals) + }).pipe(Effect.forever, Effect.forkScoped({ startImmediately: true })) +}) + +function run(file: string, effect: Effect.Effect) { const target = path.resolve(file) return Effect.acquireUseRelease( Effect.gen(function* () { diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 89b57ab0d0..63104a6dc9 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -1,13 +1,10 @@ -import { Effect, FileSystem, Option, Scope } from "effect" +import { Effect, FileSystem, Scope } from "effect" import { Command } from "effect/unstable/cli" import { Spec } from "./spec" import { Global } from "@opencode-ai/util/global" import { Updater } from "../services/updater" import { Config } from "../config" import { Npm } from "@opencode-ai/util/npm" -import { GlobalFlags } from "../commands/global-flags" -import { CpuProfile } from "../cpu-profile" -import path from "node:path" export type Input = Value extends Spec.Node @@ -90,21 +87,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray): Provided Command.withHandler((input) => Effect.gen(function* () { const module = yield* Effect.promise(handler.load) - const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile) - if (!cpuProfile) return yield* module.default(input) - const target = path.resolve(cpuProfile) - const previous = process.env.OPENCODE_CPU_PROFILE - process.env.OPENCODE_CPU_PROFILE = target - return yield* ( - node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input) - ).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE - else process.env.OPENCODE_CPU_PROFILE = previous - }), - ), - ) + return yield* module.default(input) }), ), ) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2f2be4dd69..f344f45057 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,7 @@ import { AppProcess } from "@opencode-ai/util/process" import { Config } from "./config" import { Npm } from "@opencode-ai/util/npm" import { Heap } from "./heap" +import { CpuProfile } from "./cpu-profile" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), @@ -61,6 +62,7 @@ const Handlers = Runtime.handlers(Commands, { Effect.gen(function* () { yield* Heap.listen + yield* CpuProfile.listen const runFork = Effect.runForkWith(yield* Effect.context()) const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => { runFork(Effect.logError("uncaught exception", { cause, origin })) diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 9eeebfb164..5b459777e4 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -110,7 +110,6 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi ...selfCommand(), "serve", "--service", - ...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []), ], } }) diff --git a/packages/cli/test/cpu-profile.test.ts b/packages/cli/test/cpu-profile.test.ts new file mode 100644 index 0000000000..e64951193f --- /dev/null +++ b/packages/cli/test/cpu-profile.test.ts @@ -0,0 +1,18 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Global } from "@opencode-ai/util/global" +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { CpuProfile } from "../src/cpu-profile" + +test("subscribes and unsubscribes SIGPROF with the CLI scope", async () => { + const listeners = process.listenerCount("SIGPROF") + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* CpuProfile.listen + expect(process.listenerCount("SIGPROF")).toBe(listeners + (process.platform === "win32" ? 0 : 1)) + }), + ).pipe(Effect.provideService(Global.Service, Global.make()), Effect.provide(NodeFileSystem.layer)), + ) + expect(process.listenerCount("SIGPROF")).toBe(listeners) +}) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index a669e1f5b3..0582417734 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -19,29 +19,6 @@ test("managed service ports are stable per installation channel", () => { expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b")) }) -test("managed service forwards the CPU profile path to the server", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-")) - const profile = path.join(root, "server.cpuprofile") - try { - const previous = process.env.OPENCODE_CPU_PROFILE - process.env.OPENCODE_CPU_PROFILE = profile - try { - const options = await Effect.runPromise( - ServiceConfig.options().pipe( - Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })), - Effect.provide(NodeFileSystem.layer), - ), - ) - expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile]) - } finally { - if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE - else process.env.OPENCODE_CPU_PROFILE = previous - } - } finally { - await fs.rm(root, { recursive: true, force: true }) - } -}) - test("local channel stores service config with the local service filename", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) try { diff --git a/packages/core/src/plugin/skill/opencode.md b/packages/core/src/plugin/skill/opencode.md index c363b51816..c30035ad1a 100644 --- a/packages/core/src/plugin/skill/opencode.md +++ b/packages/core/src/plugin/skill/opencode.md @@ -236,6 +236,24 @@ problem belongs to the client, the shared server, or one project. - Redact API keys, authorization headers, prompts, file contents, and other sensitive data before sharing diagnostics. +### CPU profiles + +On Linux and macOS, send `SIGPROF` to a running OpenCode process to capture its +CPU activity. Get the background server PID from the health endpoint, then send +the signal: + +```sh +opencode2 api get /api/health +kill -SIGPROF +``` + +One signal starts a ten-second profile and stops it automatically. OpenCode +writes the result to its log directory as +`cpu--.cpuprofile` and logs the complete path. Additional +`SIGPROF` signals are ignored while a profile is active. Signal-triggered CPU +profiles are unavailable on Windows. There is no CPU profile CLI flag or +environment variable. + See the [full troubleshooting guide](https://opencode.ai/v2/docs/troubleshooting) for service lifecycle commands, API inspection, log locations, explicit server connections, issue-reporting details, and local development paths.