Compare commits

..

2 Commits

Author SHA1 Message Date
Dax Raad 7f04d1e19c docs: move CPU profiling to dev skill 2026-08-19 10:46:28 -04:00
Dax Raad b52d5a62a8 feat(cli): capture CPU profiles with SIGPROF 2026-08-19 10:43:10 -04:00
18 changed files with 83 additions and 103 deletions
+27
View File
@@ -185,6 +185,33 @@ pmap -x <pid> | sort -k3 -nr | head -25
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## CPU profiles
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.
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:
```bash
opencode2 api get /api/health
```
Use `bun dev api get /api/health` instead when targeting the local/dev channel.
2. Start the capture:
```bash
kill -PROF <server-pid>
```
3. Wait for `CPU profile written` in the channel's log before opening the file. Profiles are written to the same log directory as `cpu-<pid>-<timestamp>.cpuprofile`; the log's `path=` field is authoritative:
```bash
grep 'CPU profile' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'cpu-<server-pid>-*.cpuprofile' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` for a local/dev process. Load the completed `.cpuprofile` in Chrome DevTools or another V8 CPU profile viewer and inspect the hottest functions, call stacks, and self time during the controlled workload.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
@@ -1,6 +1,5 @@
import { useFile } from "@/context/file"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { PROMPT_FILE_DRAG_TYPE } from "@opencode-ai/session-ui/v2/prompt-input/drag"
import "@opencode-ai/ui/v2/file-tree-v2.css"
import {
createEffect,
@@ -93,7 +92,6 @@ const FileTreeNodeV2 = (
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
event.dataTransfer?.setData(PROMPT_FILE_DRAG_TYPE, local.node.path)
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
withFileDragImage(event)
@@ -3,7 +3,6 @@ import { encodeFilePath } from "@/context/file/path"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { PROMPT_FILE_DRAG_TYPE } from "@opencode-ai/session-ui/v2/prompt-input/drag"
import {
createEffect,
createMemo,
@@ -158,7 +157,6 @@ const FileTreeNode = (
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
event.dataTransfer?.setData(PROMPT_FILE_DRAG_TYPE, local.node.path)
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
withFileDragImage(event)
@@ -1,6 +1,5 @@
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { isPromptAttachmentDrag } from "@opencode-ai/session-ui/v2/prompt-input/drag"
import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language"
@@ -165,13 +164,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const handleGlobalDragOver = (event: DragEvent) => {
if (input.isDialogActive()) return
if (!isPromptAttachmentDrag(event)) return
event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
const hasText = event.dataTransfer?.types.includes("text/plain")
if (hasFiles) {
input.setDraggingType("image")
} else {
} else if (hasText) {
input.setDraggingType("@mention")
}
}
@@ -185,7 +184,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const handleGlobalDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
if (!isPromptAttachmentDrag(event)) return
event.preventDefault()
input.setDraggingType(null)
+2 -3
View File
@@ -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
@@ -321,4 +320,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
-12
View File
@@ -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
+28 -2
View File
@@ -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<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
export const listen = Effect.gen(function* () {
const global = yield* Global.Service
if (process.platform === "win32") return
const signals = yield* Queue.dropping<void>(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<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
const target = path.resolve(file)
return Effect.acquireUseRelease(
Effect.gen(function* () {
+2 -19
View File
@@ -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> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -90,21 +87,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): 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)
}),
),
)
+2
View File
@@ -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"),
@@ -59,6 +60,7 @@ const Handlers = Runtime.handlers(Commands, {
Effect.gen(function* () {
yield* Heap.listen
yield* CpuProfile.listen
const runFork = Effect.runForkWith(yield* Effect.context<never>())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
@@ -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] : []),
],
}
})
+18
View File
@@ -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)
})
-23
View File
@@ -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 {
-1
View File
@@ -20,7 +20,6 @@
"./v2/*.css": "./src/v2/components/*.css",
"./v2/*": "./src/v2/components/*.tsx",
"./v2/prompt-input": "./src/v2/components/prompt-input/index.tsx",
"./v2/prompt-input/drag": "./src/v2/components/prompt-input/drag.ts",
"./v2/prompt-input/interaction": "./src/v2/components/prompt-input/interaction.ts",
"./v2/prompt-input/store": "./src/v2/components/prompt-input/store.ts",
"./v2/prompt-input/types": "./src/v2/components/prompt-input/types.ts"
@@ -1,7 +1,6 @@
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types"
import { isPromptAttachmentDrag } from "./drag"
const accepted = [
"image/png",
@@ -178,7 +177,6 @@ export function createPromptInputV2Attachments(
}
const handleDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
if (!isPromptAttachmentDrag(event)) return
event.preventDefault()
input.setDraggingType(null)
const plainText = event.dataTransfer?.getData("text/plain")
@@ -195,10 +193,9 @@ export function createPromptInputV2Attachments(
onMount(() => {
makeEventListener(document, "dragover", (event) => {
if (input.isDialogActive()) return
if (!isPromptAttachmentDrag(event)) return
event.preventDefault()
if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image")
else input.setDraggingType("@mention")
else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
})
makeEventListener(document, "dragleave", (event) => {
if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
@@ -1,18 +0,0 @@
import { describe, expect, test } from "bun:test"
import { isPromptAttachmentDrag, PROMPT_FILE_DRAG_TYPE } from "./drag"
describe("prompt attachment drag", () => {
test("ignores ordinary text and link drags", () => {
expect(drag(["text/plain"])).toBe(false)
expect(drag(["text/plain", "text/uri-list"])).toBe(false)
})
test("accepts files and OpenCode file tree entries", () => {
expect(drag(["Files"])).toBe(true)
expect(drag(["text/plain", PROMPT_FILE_DRAG_TYPE])).toBe(true)
})
})
function drag(types: string[]) {
return isPromptAttachmentDrag({ dataTransfer: { types } as unknown as DataTransfer })
}
@@ -1,7 +0,0 @@
export const PROMPT_FILE_DRAG_TYPE = "application/x-opencode-file"
export function isPromptAttachmentDrag(event: Pick<DragEvent, "dataTransfer">) {
const types = event.dataTransfer?.types
if (!types) return false
return types.includes("Files") || types.includes(PROMPT_FILE_DRAG_TYPE)
}
@@ -114,7 +114,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"outline outline-1 outline-v2-icon-icon-info outline-dashed": state.drag === "active",
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
onSubmit={(event) => {
event.preventDefault()
@@ -2,7 +2,6 @@ import { createEffect, on, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments"
import { isPromptAttachmentDrag } from "./drag"
import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store"
import type {
PromptInputV2Attachment,
@@ -401,19 +400,16 @@ export function createPromptInputV2Controller(input: {
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
},
onDragEnter(event: DragEvent) {
if (attachments && !isPromptAttachmentDrag(event)) return
event.preventDefault()
dispatch({ type: "drag.enter" })
},
onDragOver(event: DragEvent) {
if (attachments && !isPromptAttachmentDrag(event)) return
event.preventDefault()
},
onDragLeave() {
dispatch({ type: "drag.leave" })
},
onDrop(event: DragEvent) {
if (attachments && !isPromptAttachmentDrag(event)) return
event.preventDefault()
dispatch({ type: "drag.leave" })
if (attachments) {