fix(gui-plugins): drive context export and scroll, heap capture and memory diagnostics through state machines (#62001)

This commit is contained in:
Luke Parker
2026-11-05 01:18:42 -06:00
committed by GitHub
Unverified
parent 621f0ee53f
commit 026dedb16e
14 changed files with 1737 additions and 220 deletions
@@ -52,6 +52,37 @@ story("the debug bar stays open across a remount until its close button hides it
expect(result).toEqual({ opened: true, saved: { visible: false, motion: false }, reopened: false })
})
story("running memory diagnostics pin the bar without a close button, and its Knobs still open", async ({ page }) => {
const result = await page.evaluate(async (modules) => {
const { mountPlugins, until } = await import(modules.fixture)
const debug = await import(modules.debug)
const definition = { ...debug.default, id: "debug", windowEntry: () => import(modules.renderer) }
const memory = { state: () => ({ on: true, env: false }), set: async () => undefined, on: () => () => {} }
// The user hid the bar and left the Knobs open before turning diagnostics on.
const host = mountPlugins({
definitions: [definition],
stored: { "plugin.debug.bar": { visible: false, motion: true } },
ipc: (token: { readonly id: string }) => (token.id === "debug.memory" ? memory : undefined),
})
host.release()
await until(() => host.container.querySelector("aside") !== null)
await until(() => document.querySelector('button[aria-label="Close Knobs"]') !== null)
const view = {
close: host.container.querySelector('button[aria-label="Hide debug bar"]') !== null,
knobs: document.querySelector('button[aria-label="Close Knobs"]') !== null,
}
host.unmount()
return view
}, modules)
expect(result).toEqual({ close: false, knobs: true })
})
story("a feature page's Enabled switch turns that feature on after another feature's page", async ({ page }) => {
const result = await page.evaluate(
async (modules) => {
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeClient } from "@opencode/client/promise"
import { fetchSessionExport, sessionExportFilename } from "./export"
/* oxlint-disable anti-slop/require-safety-comment-for-type-assertion, anti-slop/no-chained-type-assertions -- partial fixtures stand in for the client methods the export calls */
describe("sessionExportFilename", () => {
test("shortens long titles to fit the filename limit", () => {
expect(sessionExportFilename({ id: "ses_123", title: "a".repeat(300) })).toBe(`${"a".repeat(250)}.json`)
})
})
describe("fetchSessionExport", () => {
test("cancelling the export aborts every request it sent, on every page", async () => {
const controller = new AbortController()
const signals: AbortSignal[] = []
const api = {
session: {
get: async (_input: { sessionID: string }, options: { signal: AbortSignal }) => {
signals.push(options.signal)
return { id: "ses_1" }
},
},
message: {
list: async (input: { cursor?: string }, options: { signal: AbortSignal }) => {
signals.push(options.signal)
return input.cursor ? { data: [], cursor: {} } : { data: [], cursor: { next: "page-2" } }
},
},
} as unknown as Pick<OpenCodeClient, "session" | "message">
await fetchSessionExport({ sessionID: "ses_1", api, signal: controller.signal })
expect(signals.map((signal) => signal.aborted)).toEqual([false, false, false])
controller.abort()
expect(signals.map((signal) => signal.aborted)).toEqual([true, true, true])
})
})
+27 -9
View File
@@ -1,23 +1,40 @@
import type { OpenCodeClient } from "@opencode/client/promise"
import type { OpenCodeClient, SessionInfo, SessionMessageInfo } from "@opencode/client/promise"
export type SessionExportData = {
info: SessionInfo
messages: SessionMessageInfo[]
}
/** Bounds each request rather than the whole export, so a long history still exports. */
export const EXPORT_REQUEST_TIMEOUT_MS = 60_000
export async function fetchSessionExport(input: {
sessionID: string
api: Pick<OpenCodeClient, "session" | "message">
}) {
signal?: AbortSignal
}): Promise<SessionExportData> {
const signal = () =>
input.signal
? AbortSignal.any([input.signal, AbortSignal.timeout(EXPORT_REQUEST_TIMEOUT_MS)])
: AbortSignal.timeout(EXPORT_REQUEST_TIMEOUT_MS)
const [info, first] = await Promise.all([
input.api.session.get({ sessionID: input.sessionID }),
input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }),
input.api.session.get({ sessionID: input.sessionID }, { signal: signal() }),
input.api.message.list({ sessionID: input.sessionID, limit: 200, order: "asc" }, { signal: signal() }),
])
const pages = [first]
while (pages.at(-1)?.cursor.next) {
pages.push(
await input.api.message.list({
sessionID: input.sessionID,
limit: 200,
cursor: pages.at(-1)!.cursor.next ?? undefined,
}),
await input.api.message.list(
{
sessionID: input.sessionID,
limit: 200,
cursor: pages.at(-1)!.cursor.next ?? undefined,
},
{ signal: signal() },
),
)
}
@@ -33,6 +50,7 @@ export function sessionExportFilename(session: { id: string; title?: string; slu
const clean = name
.toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-")
.slice(0, 250)
.replace(/^-+|-+$/g, "")
return `${clean || session.id}.json`
@@ -0,0 +1,225 @@
import { describe, expect, test } from "bun:test"
import type { SessionExportData } from "./export"
import {
createContextScrollState,
createSessionExportState,
selectSessionExporting,
transitionContextScroll,
transitionSessionExport,
type ContextScrollEvent,
type ContextScrollState,
type SessionExportEvent,
type SessionExportState,
} from "./machine"
/* oxlint-disable anti-slop/require-safety-comment-for-type-assertion -- the export only passes this record through */
const data: SessionExportData = { info: { id: "ses_1" } as SessionExportData["info"], messages: [] }
function exportAfter(...events: SessionExportEvent[]) {
return events.reduce<SessionExportState>(
(state, event) => transitionSessionExport(state, event).state,
createSessionExportState(),
)
}
describe("transitionSessionExport", () => {
test("a second click while the export loads or the save dialog is open does nothing", () => {
const requested: SessionExportEvent = { type: "export.requested", sessionKey: "a", sessionID: "ses_1" }
const loading = exportAfter(requested)
expect(transitionSessionExport(loading, requested)).toEqual({ state: loading, effects: [] })
expect(transitionSessionExport(loading, requested).state).toBe(loading)
expect(selectSessionExporting(loading, "a")).toBe(true)
const saving = transitionSessionExport(loading, { type: "export.fetched", attempt: 1, sessionKey: "a", data })
expect(saving.state).toBe(loading)
expect(saving.effects).toEqual([{ type: "export.save", attempt: 1, sessionKey: "a", data }])
expect(transitionSessionExport(saving.state, requested).state).toBe(loading)
})
test("another session exports alongside, and each result settles only its own", () => {
const state = exportAfter(
{ type: "export.requested", sessionKey: "a", sessionID: "ses_1" },
{ type: "export.requested", sessionKey: "b", sessionID: "ses_2" },
)
expect(state).toEqual({ status: "active", issued: 2, exporting: { a: 1, b: 2 } })
const saved = transitionSessionExport(state, {
type: "export.saved",
attempt: 2,
sessionKey: "b",
filename: "b.json",
saved: true,
})
expect(saved.effects).toEqual([{ type: "toast.exported", filename: "b.json" }])
expect(selectSessionExporting(saved.state, "a")).toBe(true)
expect(selectSessionExporting(saved.state, "b")).toBe(false)
})
test("a cancelled save dialog ends the export without a toast; a failure toasts its message", () => {
const loading = exportAfter({ type: "export.requested", sessionKey: "a", sessionID: "ses_1" })
const cancelled = transitionSessionExport(loading, {
type: "export.saved",
attempt: 1,
sessionKey: "a",
filename: "a.json",
saved: false,
})
const failed = transitionSessionExport(loading, {
type: "export.failed",
attempt: 1,
sessionKey: "a",
message: "The operation timed out.",
})
expect(cancelled.effects).toEqual([])
expect(selectSessionExporting(cancelled.state, "a")).toBe(false)
expect(failed.effects).toEqual([{ type: "toast.export-failed", message: "The operation timed out." }])
expect(selectSessionExporting(failed.state, "a")).toBe(false)
})
test("a result of an earlier export is ignored", () => {
const state = exportAfter(
{ type: "export.requested", sessionKey: "a", sessionID: "ses_1" },
{ type: "export.failed", attempt: 1, sessionKey: "a" },
{ type: "export.requested", sessionKey: "a", sessionID: "ses_1" },
)
expect(transitionSessionExport(state, { type: "export.fetched", attempt: 1, sessionKey: "a", data })).toEqual({
state,
effects: [],
})
})
test("closing the tab aborts every export and silences late results", () => {
const state = exportAfter(
{ type: "export.requested", sessionKey: "a", sessionID: "ses_1" },
{ type: "export.requested", sessionKey: "b", sessionID: "ses_2" },
)
const disposed = transitionSessionExport(state, { type: "disposed" })
expect(disposed.effects).toEqual([
{ type: "export.abort", attempt: 1 },
{ type: "export.abort", attempt: 2 },
])
expect(
transitionSessionExport(disposed.state, { type: "export.failed", attempt: 1, sessionKey: "a", message: "abort" }),
).toEqual({ state: disposed.state, effects: [] })
})
})
const showA: ContextScrollEvent = { type: "target.synced", sessionKey: "a", active: true }
function scrollAfter(...events: ContextScrollEvent[]) {
return events.reduce<ContextScrollState>(
(state, event) => transitionContextScroll(state, event).state,
createContextScrollState(),
)
}
describe("transitionContextScroll", () => {
test("restores a frame after the session shows", () => {
expect(transitionContextScroll(createContextScrollState(), showA)).toEqual({
state: { status: "restoring", sessionKey: "a", attempt: 1, frame: true, applied: undefined },
effects: [{ type: "frame.schedule", kind: "restore", attempt: 1 }],
})
})
test("a message that renders after the user scrolled does not jump back to the stored offset", () => {
const state = scrollAfter(
showA,
{ type: "restore.applied", attempt: 1, applied: undefined },
{ type: "interacted" },
{ type: "scrolled", position: { x: 0, y: 500 } },
)
const rendered = transitionContextScroll(state, { type: "content.changed" })
expect(rendered).toEqual({ state, effects: [] })
expect(state.status === "tracking" && state.attempt).toBe(3)
expect(transitionContextScroll(state, { type: "save.flushed", attempt: 3 }).effects).toEqual([
{ type: "save.write", sessionKey: "a", position: { x: 0, y: 500 } },
])
})
test("switching session stores the last offset for the session left, then restores the next", () => {
const state = scrollAfter(showA, { type: "scrolled", position: { x: 0, y: 800 } })
expect(transitionContextScroll(state, { type: "target.synced", sessionKey: "b", active: true })).toEqual({
state: { status: "restoring", sessionKey: "b", attempt: 3, frame: true, applied: undefined },
effects: [
{ type: "frame.cancel" },
{ type: "save.write", sessionKey: "a", position: { x: 0, y: 800 } },
{ type: "frame.schedule", kind: "restore", attempt: 3 },
],
})
})
test("closing the tab stores the offset the save frame had not written yet", () => {
const state = scrollAfter(showA, { type: "interacted" }, { type: "scrolled", position: { x: 0, y: 420 } })
expect(transitionContextScroll(state, { type: "disposed" })).toEqual({
state: { status: "disposed" },
effects: [{ type: "frame.cancel" }, { type: "save.write", sessionKey: "a", position: { x: 0, y: 420 } }],
})
})
test("the restore's own scroll event is not the user's, so a clamped restore retries as content grows", () => {
const applied = scrollAfter(showA, { type: "restore.applied", attempt: 1, applied: { x: 0, y: 200 } })
const echoed = transitionContextScroll(applied, { type: "scrolled", position: { x: 0, y: 200 } })
expect(echoed).toEqual({
state: { status: "restoring", sessionKey: "a", attempt: 1, frame: false, applied: undefined },
effects: [],
})
expect(transitionContextScroll(echoed.state, { type: "content.changed" }).effects).toEqual([
{ type: "frame.schedule", kind: "restore", attempt: 2 },
])
})
test("user input cancels a scheduled restore", () => {
expect(transitionContextScroll(scrollAfter(showA), { type: "interacted" })).toEqual({
state: { status: "tracking", sessionKey: "a", attempt: 2, pending: undefined },
effects: [{ type: "frame.cancel" }],
})
})
test("frames of an earlier attempt are ignored", () => {
const restoring = scrollAfter(
showA,
{ type: "restore.applied", attempt: 1, applied: undefined },
{ type: "content.changed" },
)
expect(transitionContextScroll(restoring, { type: "restore.applied", attempt: 1, applied: undefined }).state).toBe(
restoring,
)
const tracking = scrollAfter(
showA,
{ type: "scrolled", position: { x: 0, y: 10 } },
{ type: "save.flushed", attempt: 2 },
)
expect(transitionContextScroll(tracking, { type: "save.flushed", attempt: 2 }).state).toBe(tracking)
})
test("a session whose layout cannot be read stores its pending offset and ignores scrolling", () => {
const state = scrollAfter(showA, { type: "scrolled", position: { x: 0, y: 30 } })
const hidden = transitionContextScroll(state, { type: "target.synced", sessionKey: "a", active: false })
expect(hidden).toEqual({
state: { status: "inactive", attempt: 3 },
effects: [{ type: "frame.cancel" }, { type: "save.write", sessionKey: "a", position: { x: 0, y: 30 } }],
})
expect(transitionContextScroll(hidden.state, { type: "scrolled", position: { x: 0, y: 40 } }).state).toBe(
hidden.state,
)
})
})
+271
View File
@@ -0,0 +1,271 @@
import type { SessionExportData } from "./export"
// --- Export session ---
/**
* The Context tab stays mounted while the routed session changes, so exports are keyed by the session they run for
* (`SessionRef.key`). An export holds its key from the first request until the save dialog answers.
*/
export type SessionExportState =
| {
readonly status: "active"
readonly issued: number
readonly exporting: Readonly<Record<string, number>>
}
| { readonly status: "disposed" }
export type SessionExportEvent =
| { readonly type: "export.requested"; readonly sessionKey: string; readonly sessionID: string }
| {
readonly type: "export.fetched"
readonly attempt: number
readonly sessionKey: string
readonly data: SessionExportData
}
| {
readonly type: "export.saved"
readonly attempt: number
readonly sessionKey: string
readonly filename: string
readonly saved: boolean
}
| { readonly type: "export.failed"; readonly attempt: number; readonly sessionKey: string; readonly message?: string }
| { readonly type: "disposed" }
export type SessionExportEffect =
| { readonly type: "export.fetch"; readonly attempt: number; readonly sessionKey: string; readonly sessionID: string }
| {
readonly type: "export.save"
readonly attempt: number
readonly sessionKey: string
readonly data: SessionExportData
}
| { readonly type: "export.abort"; readonly attempt: number }
| { readonly type: "toast.exported"; readonly filename: string }
| { readonly type: "toast.export-failed"; readonly message?: string }
export type SessionExportTransition = {
readonly state: SessionExportState
readonly effects: readonly SessionExportEffect[]
}
export function createSessionExportState(): SessionExportState {
return { status: "active", issued: 0, exporting: {} }
}
export function transitionSessionExport(state: SessionExportState, event: SessionExportEvent): SessionExportTransition {
if (state.status === "disposed") return { state, effects: [] }
if (event.type === "disposed") {
return {
state: { status: "disposed" },
effects: Object.values(state.exporting).map((attempt) => ({ type: "export.abort", attempt })),
}
}
if (event.type === "export.requested") {
// A second click while the pages load or the save dialog is open would export, and ask where to save, again.
if (state.exporting[event.sessionKey] !== undefined) return { state, effects: [] }
const attempt = state.issued + 1
return {
state: { ...state, issued: attempt, exporting: { ...state.exporting, [event.sessionKey]: attempt } },
effects: [{ type: "export.fetch", attempt, sessionKey: event.sessionKey, sessionID: event.sessionID }],
}
}
if (state.exporting[event.sessionKey] !== event.attempt) return { state, effects: [] }
if (event.type === "export.fetched") {
return {
state,
effects: [{ type: "export.save", attempt: event.attempt, sessionKey: event.sessionKey, data: event.data }],
}
}
const next = { ...state, exporting: omit(state.exporting, event.sessionKey) }
if (event.type === "export.failed") {
return { state: next, effects: [{ type: "toast.export-failed", message: event.message }] }
}
// A cancelled save dialog is not a failure.
return { state: next, effects: event.saved ? [{ type: "toast.exported", filename: event.filename }] : [] }
}
/** Whether the session's export is loading or waiting for the save dialog. */
export function selectSessionExporting(state: SessionExportState, sessionKey: string) {
return state.status === "active" && state.exporting[sessionKey] !== undefined
}
// --- Scroll offset of the tab ---
export type ScrollPosition = {
readonly x: number
readonly y: number
}
/**
* The tab's scroll offset, stored per session. `restoring` applies the stored offset a frame after the session shows
* and again after each content change, because the content renders progressively and clamps the first restore.
* `applied` is the offset the last restore left, so its own scroll event is not taken for the user's. The first
* scroll or input of the user moves to `tracking`, which stores the user's offset once per frame and never restores
* over it. `inactive` covers a session whose layout cannot be read yet.
*/
export type ContextScrollState =
| { readonly status: "inactive"; readonly attempt: number }
| {
readonly status: "restoring"
readonly sessionKey: string
readonly attempt: number
readonly frame: boolean
readonly applied: ScrollPosition | undefined
}
| {
readonly status: "tracking"
readonly sessionKey: string
readonly attempt: number
readonly pending: ScrollPosition | undefined
}
| { readonly status: "disposed" }
export type ContextScrollEvent =
/** The routed session, or whether its layout can be read (`active`), changed. */
| { readonly type: "target.synced"; readonly sessionKey: string; readonly active: boolean }
| { readonly type: "content.changed" }
| { readonly type: "restore.applied"; readonly attempt: number; readonly applied: ScrollPosition | undefined }
/** Wheel, pointer, touch or key input on the scroller. */
| { readonly type: "interacted" }
| { readonly type: "scrolled"; readonly position: ScrollPosition }
| { readonly type: "save.flushed"; readonly attempt: number }
| { readonly type: "disposed" }
export type ContextScrollEffect =
| { readonly type: "frame.schedule"; readonly kind: "restore" | "save"; readonly attempt: number }
| { readonly type: "frame.cancel" }
| { readonly type: "save.write"; readonly sessionKey: string; readonly position: ScrollPosition }
export type ContextScrollTransition = {
readonly state: ContextScrollState
readonly effects: readonly ContextScrollEffect[]
}
export function createContextScrollState(): ContextScrollState {
return { status: "inactive", attempt: 0 }
}
const NO_SCROLL_EFFECTS: readonly ContextScrollEffect[] = []
export function transitionContextScroll(state: ContextScrollState, event: ContextScrollEvent): ContextScrollTransition {
if (state.status === "disposed") return { state, effects: NO_SCROLL_EFFECTS }
if (event.type === "disposed") return { state: { status: "disposed" }, effects: leave(state) }
if (event.type === "target.synced") {
if (!event.active) {
if (state.status === "inactive") return { state, effects: NO_SCROLL_EFFECTS }
return { state: { status: "inactive", attempt: state.attempt + 1 }, effects: leave(state) }
}
if (state.status !== "inactive" && state.sessionKey === event.sessionKey) {
return { state, effects: NO_SCROLL_EFFECTS }
}
// The previous session keeps the offset the user left it at; the next one restores its own.
const attempt = state.attempt + 1
return {
state: { status: "restoring", sessionKey: event.sessionKey, attempt, frame: true, applied: undefined },
effects: [...leave(state), { type: "frame.schedule", kind: "restore", attempt }],
}
}
if (state.status === "inactive") return { state, effects: NO_SCROLL_EFFECTS }
if (state.status === "restoring") {
if (event.type === "content.changed") {
if (state.frame) return { state, effects: NO_SCROLL_EFFECTS }
const attempt = state.attempt + 1
return {
state: { ...state, attempt, frame: true },
effects: [{ type: "frame.schedule", kind: "restore", attempt }],
}
}
if (event.type === "restore.applied") {
if (!state.frame || event.attempt !== state.attempt) return { state, effects: NO_SCROLL_EFFECTS }
return { state: { ...state, frame: false, applied: event.applied }, effects: NO_SCROLL_EFFECTS }
}
if (event.type === "interacted") {
return {
state: { status: "tracking", sessionKey: state.sessionKey, attempt: state.attempt + 1, pending: undefined },
effects: state.frame ? [{ type: "frame.cancel" }] : NO_SCROLL_EFFECTS,
}
}
if (event.type === "scrolled") {
if (state.applied && same(state.applied, event.position)) {
return { state: { ...state, applied: undefined }, effects: NO_SCROLL_EFFECTS }
}
const attempt = state.attempt + 1
const save: ContextScrollEffect = { type: "frame.schedule", kind: "save", attempt }
return {
state: { status: "tracking", sessionKey: state.sessionKey, attempt, pending: event.position },
effects: state.frame ? [{ type: "frame.cancel" }, save] : [save],
}
}
return { state, effects: NO_SCROLL_EFFECTS }
}
if (event.type === "scrolled") {
if (!state.pending) {
const attempt = state.attempt + 1
return {
state: { ...state, attempt, pending: event.position },
effects: [{ type: "frame.schedule", kind: "save", attempt }],
}
}
if (same(state.pending, event.position)) return { state, effects: NO_SCROLL_EFFECTS }
// The save frame already scheduled stores the latest offset.
return { state: { ...state, pending: event.position }, effects: NO_SCROLL_EFFECTS }
}
if (event.type === "save.flushed") {
if (!state.pending || event.attempt !== state.attempt) return { state, effects: NO_SCROLL_EFFECTS }
return {
state: { ...state, pending: undefined },
effects: [{ type: "save.write", sessionKey: state.sessionKey, position: state.pending }],
}
}
// The DOM holds the user's offset now: content changes no longer restore over it.
return { state, effects: NO_SCROLL_EFFECTS }
}
/** Cancels the scheduled frame and stores the offset still waiting for it. */
function leave(state: Exclude<ContextScrollState, { status: "disposed" }>): readonly ContextScrollEffect[] {
if (state.status === "restoring" && state.frame) return [{ type: "frame.cancel" }]
if (state.status !== "tracking" || !state.pending) return NO_SCROLL_EFFECTS
return [{ type: "frame.cancel" }, { type: "save.write", sessionKey: state.sessionKey, position: state.pending }]
}
function same(a: ScrollPosition, b: ScrollPosition) {
return a.x === b.x && a.y === b.y
}
function omit(record: Readonly<Record<string, number>>, key: string) {
return Object.fromEntries(Object.entries(record).filter(([entry]) => entry !== key))
}
+170 -62
View File
@@ -1,8 +1,10 @@
import { createMemo, on, onCleanup, onMount, For, Show } from "solid-js"
import { createMemo, createSignal, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { checksum } from "@opencode/util/encode"
import { Icon } from "@opencode/ui/icon"
import { Button } from "@opencode/ui/button"
import { Loader } from "@opencode/ui/loader"
import { Accordion } from "@opencode/ui/accordion"
import { StickyAccordionHeader } from "@opencode/ui/sticky-accordion-header"
import { showToast } from "@opencode/ui/toast"
@@ -15,6 +17,17 @@ import { createKeyed, usePlugin, type MountedSession } from "@opencode/plugin/gu
import { catalogModel, syncCatalog } from "./catalog"
import { fetchSessionExport, sessionExportFilename } from "./export"
import { createSessionContextFormatter } from "./format"
import {
createContextScrollState,
createSessionExportState,
selectSessionExporting,
transitionContextScroll,
transitionSessionExport,
type ContextScrollEffect,
type ContextScrollEvent,
type SessionExportEffect,
type SessionExportEvent,
} from "./machine"
function Stat(props: { label: string; value: JSX.Element }) {
return (
@@ -37,13 +50,7 @@ function RawMessageContent(props: { message: SessionMessageInfo; onRendered: ()
})
return (
<File
mode="text"
file={file()}
overflow="wrap"
class="select-text"
onRendered={() => requestAnimationFrame(props.onRendered)}
/>
<File mode="text" file={file()} overflow="wrap" class="select-text" onRendered={props.onRendered} />
)
}
@@ -201,95 +208,191 @@ export default function SessionContextTab(props: { session: MountedSession }) {
{ label: "stats.lastActivity", value: () => formatter().time(context()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
const exportSession = async () => {
const sessionID = props.session.id
const [exportState, setExportState] = createSignal(createSessionExportState())
const exportControllers = new Map<number, AbortController>()
const exporting = () => selectSessionExporting(exportState(), props.session.key)
if (!sessionID) return
const dispatchExport = (event: SessionExportEvent) => {
const next = transitionSessionExport(exportState(), event)
try {
const data = await fetchSessionExport({
sessionID,
api: props.session.server.client,
})
setExportState(next.state)
next.effects.forEach(
(effect) =>
void Promise.try(() => runExport(effect)).catch((cause: unknown) => console.error("[context] export", cause)),
)
}
const filename = sessionExportFilename(data.info)
const runExport = (effect: SessionExportEffect) => {
if (effect.type === "export.fetch") {
const controller = new AbortController()
if (!(await system.save({ name: filename, content: JSON.stringify(data, null, 2) }))) return
showToast({
const failed = (cause: unknown) =>
dispatchExport({
type: "export.failed",
attempt: effect.attempt,
sessionKey: effect.sessionKey,
message: cause instanceof Error ? cause.message : undefined,
})
exportControllers.set(effect.attempt, controller)
// Runs inside the click that asked for it, so the routed session is the one being exported.
const api = props.session.server.client
void Promise.try(() => fetchSessionExport({ sessionID: effect.sessionID, api, signal: controller.signal }))
.then(
(data) =>
dispatchExport({ type: "export.fetched", attempt: effect.attempt, sessionKey: effect.sessionKey, data }),
failed,
)
.finally(() => exportControllers.delete(effect.attempt))
return
}
if (effect.type === "export.save") {
const filename = sessionExportFilename(effect.data.info)
void Promise.try(() => system.save({ name: filename, content: JSON.stringify(effect.data, null, 2) })).then(
(saved) =>
dispatchExport({
type: "export.saved",
attempt: effect.attempt,
sessionKey: effect.sessionKey,
filename,
saved,
}),
(cause: unknown) =>
dispatchExport({
type: "export.failed",
attempt: effect.attempt,
sessionKey: effect.sessionKey,
message: cause instanceof Error ? cause.message : undefined,
}),
)
return
}
if (effect.type === "export.abort") return exportControllers.get(effect.attempt)?.abort()
if (effect.type === "toast.exported") {
return void showToast({
variant: "success",
// Solid resolves JSX accessors under the toast's render owner, not this imperative call site.
icon: () => <Icon name="circle-check" />,
title: ctx.t("export.success.title"),
description: ctx.t("export.success.description", { filename }),
})
} catch (err) {
showToast({
variant: "error",
title: ctx.t("export.failed.title"),
description: err instanceof Error ? err.message : ctx.t("export.failed.description"),
description: ctx.t("export.success.description", { filename: effect.filename }),
})
}
showToast({
variant: "error",
title: ctx.t("export.failed.title"),
description: effect.message ?? ctx.t("export.failed.description"),
})
}
let scroll: HTMLDivElement | undefined
const exportSession = () => {
if (!props.session.id) return
dispatchExport({ type: "export.requested", sessionKey: props.session.key, sessionID: props.session.id })
}
onCleanup(() => dispatchExport({ type: "disposed" }))
let scrollState = createContextScrollState()
let scroller: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
// The session the scroll machine last targeted; a switch writes its final offset after props.session moved on.
let scrollSession: MountedSession | undefined
const contentChanged: ContextScrollEvent = { type: "content.changed" }
const interacted: ContextScrollEvent = { type: "interacted" }
const restoreScroll = () => {
const el = scroll
const dispatchScroll = (event: ContextScrollEvent) => {
const next = transitionContextScroll(scrollState, event)
if (!el) return
const s = layout.scroll.get(props.session, "context")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
scrollState = next.state
next.effects.forEach(
(effect) =>
void Promise.try(() => runScroll(effect)).catch((cause: unknown) => console.error("[context] scroll", cause)),
)
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
pending = {
x: event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
const runScroll = (effect: ContextScrollEffect) => {
if (effect.type === "save.write") {
const session = props.session.key === effect.sessionKey ? props.session : scrollSession
if (session?.key === effect.sessionKey) layout.scroll.set(session, "context", effect.position)
return
}
if (frame !== undefined) return
if (frame !== undefined) cancelAnimationFrame(frame)
frame = undefined
if (effect.type === "frame.cancel") return
frame = requestAnimationFrame(() => {
frame = undefined
const next = pending
pending = undefined
if (effect.kind === "save") return dispatchScroll({ type: "save.flushed", attempt: effect.attempt })
if (!next) return
layout.scroll.set(props.session, "context", next)
dispatchScroll({ type: "restore.applied", attempt: effect.attempt, applied: restoreScroll() })
})
}
// Restores the stored scroll a frame after the messages change; on mount the viewport ref restores it.
/** Applies the stored offset, or the top for a session without one, and returns where the scroller moved to. */
const restoreScroll = () => {
const el = scroller
if (!el) return
const stored = layout.scroll.get(props.session, "context") ?? { x: 0, y: 0 }
const before = { x: el.scrollLeft, y: el.scrollTop }
if (el.scrollTop !== stored.y) el.scrollTop = stored.y
if (el.scrollLeft !== stored.x) el.scrollLeft = stored.x
if (el.scrollTop === before.y && el.scrollLeft === before.x) return
return { x: el.scrollLeft, y: el.scrollTop }
}
// Follows the routed session and whether its stored layout can be read.
createKeyed(
createMemo(on(messages, (list) => list, { defer: true })),
() => void requestAnimationFrame(restoreScroll),
() => ({ session: props.session, active: layout.ready() && props.session.location !== undefined }),
(target) => {
dispatchScroll({ type: "target.synced", sessionKey: target.session.key, active: target.active })
scrollSession = target.session
},
{ equals: (previous, next) => previous.session.key === next.session.key && previous.active === next.active },
)
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
// New messages grow the content, so a restore clamped by shorter content can now reach its offset.
createKeyed(
createMemo(on(messages, (list) => list, { defer: true })),
() => dispatchScroll(contentChanged),
)
// Writes the offset the save frame has not stored yet.
onCleanup(() => dispatchScroll({ type: "disposed" }))
return (
<div
class="@container h-full overflow-y-auto"
data-scrollable
ref={(el) => {
scroll = el
// The ref runs before the content renders into the element.
onMount(restoreScroll)
scroller = el
// Input of the user's own takes over from restoring, even before it scrolls.
makeEventListener(el, "wheel", () => dispatchScroll(interacted), { passive: true })
makeEventListener(el, "pointerdown", () => dispatchScroll(interacted), { passive: true })
makeEventListener(el, "touchstart", () => dispatchScroll(interacted), { passive: true })
makeEventListener(el, "keydown", () => dispatchScroll(interacted))
}}
onScroll={handleScroll}
onScroll={(event) =>
dispatchScroll({
type: "scrolled",
position: { x: event.currentTarget.scrollLeft, y: event.currentTarget.scrollTop },
})
}
>
<div data-slot="session-usage-content" class="px-4 pt-4 pb-6 flex flex-col gap-6 md:px-6 md:pb-10 md:gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
@@ -314,15 +417,20 @@ export default function SessionContextTab(props: { session: MountedSession }) {
size="small"
variant="ghost"
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
disabled={!props.session.id || exporting()}
onClick={exportSession}
>
<Icon name="download" size="small" />
<Show when={exporting()} fallback={<Icon name="download" size="small" />}>
<Loader class="size-4 shrink-0" />
</Show>
<span>{ctx.t("export.session")}</span>
</Button>
</div>
<Accordion multiple>
<For each={messages()}>
{(message) => <RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />}
{(message) => (
<RawMessage message={message} onRendered={() => dispatchScroll(contentChanged)} time={formatter().time} />
)}
</For>
</Accordion>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ export const HeapSnapshots = Ipc.define({
methods: {
/**
* Writes heap snapshots of the desktop's own server, every app window and the main process into a new folder,
* with a `manifest.json` written last. One capture runs at a time; a second call waits for the first. A snapshot
* with a `manifest.json` written last. One capture runs at a time; a call while one runs gets that capture's result. A snapshot
* that fails is recorded in the manifest and the others are still written.
*/
write: {
@@ -0,0 +1,172 @@
import { describe, expect, test } from "bun:test"
import {
createHeapCaptureState,
createHeapCommandState,
isHeapCommandEnabled,
transitionHeapCapture,
transitionHeapCommand,
type HeapCaptureEvent,
type HeapCaptureReply,
type HeapCaptureState,
type HeapCommandEvent,
type HeapCommandState,
} from "./heap-machine"
const reply = (): HeapCaptureReply => ({ resolve: () => undefined, reject: () => undefined })
const result = { directory: "/downloads/opencode-heap-1", complete: true }
const command = (value = reply()): HeapCaptureEvent => ({
type: "command.requested",
root: "/downloads",
includeServer: true,
reply: value,
})
const signal: HeapCaptureEvent = { type: "signal.received", root: "/heap", includeServer: false }
function captureAfter(...events: HeapCaptureEvent[]) {
return events.reduce<HeapCaptureState>(
(state, event) => transitionHeapCapture(state, event).state,
createHeapCaptureState(),
)
}
describe("transitionHeapCapture", () => {
test("a second command while one captures joins it instead of writing another folder", () => {
const first = reply()
const second = reply()
const started = transitionHeapCapture(createHeapCaptureState(), command(first))
const joined = transitionHeapCapture(started.state, command(second))
expect(started.effects).toEqual([
{ type: "capture.run", attempt: 1, root: "/downloads", includeServer: true },
])
expect(joined.effects).toEqual([])
expect(transitionHeapCapture(joined.state, { type: "capture.succeeded", attempt: 1, result })).toEqual({
state: { status: "idle", attempt: 1 },
effects: [{ type: "replies.settle", replies: [first, second], outcome: { ok: true, result } }],
})
})
test("repeated signals coalesce, and each source queues at most one job behind the other", () => {
const signals = captureAfter(signal)
expect(transitionHeapCapture(signals, signal).state).toBe(signals)
const first = reply()
const second = reply()
const behindSignal = captureAfter(signal, command(first), command(second))
expect(behindSignal).toMatchObject({
active: { source: "signal" },
pending: { source: "command", replies: [first, second] },
})
expect(transitionHeapCapture(behindSignal, signal).state).toBe(behindSignal)
const behindCommand = captureAfter(command(), signal)
expect(behindCommand).toMatchObject({ active: { source: "command" }, pending: { source: "signal" } })
expect(transitionHeapCapture(behindCommand, signal).state).toBe(behindCommand)
})
test("a failed capture still starts the queued one", () => {
const first = reply()
const error = new Error("ENOSPC")
const state = captureAfter(command(first), signal)
expect(transitionHeapCapture(state, { type: "capture.failed", attempt: 1, error })).toEqual({
state: {
status: "capturing",
attempt: 2,
active: { source: "signal", root: "/heap", includeServer: false },
pending: undefined,
},
effects: [
{ type: "replies.settle", replies: [first], outcome: { ok: false, error } },
{ type: "capture.run", attempt: 2, root: "/heap", includeServer: false },
],
})
})
test("a queued capture starts only once the running one settles, however long it runs", () => {
const queued = reply()
const running = transitionHeapCapture(captureAfter(signal), command(queued))
expect(running.effects).toEqual([])
expect(transitionHeapCapture(running.state, command()).effects).toEqual([])
const failed = transitionHeapCapture(running.state, { type: "capture.failed", attempt: 1, error: new Error("EIO") })
expect(failed.state).toMatchObject({ status: "capturing", attempt: 2, active: { replies: [queued] } })
expect(failed.effects).toEqual([
{ type: "signal.failed", error: "EIO" },
{ type: "capture.run", attempt: 2, root: "/downloads", includeServer: true },
])
expect(transitionHeapCapture(failed.state, { type: "capture.succeeded", attempt: 1, result }).state).toBe(
failed.state,
)
})
test("disposing rejects every waiting command, and later commands at once", () => {
const first = reply()
const second = reply()
const disposed = transitionHeapCapture(captureAfter(signal, command(first), command(second)), { type: "disposed" })
expect(disposed).toEqual({
state: { status: "disposed" },
effects: [
{
type: "replies.settle",
replies: [first, second],
outcome: { ok: false, error: new Error("Heap capture disposed") },
},
],
})
const late = reply()
expect(transitionHeapCapture(disposed.state, command(late)).effects).toEqual([
{ type: "replies.settle", replies: [late], outcome: { ok: false, error: new Error("Heap capture disposed") } },
])
})
})
function commandAfter(...events: HeapCommandEvent[]) {
return events.reduce<HeapCommandState>(
(state, event) => transitionHeapCommand(state, event).state,
createHeapCommandState(),
)
}
describe("transitionHeapCommand", () => {
test("running the command again while it captures shows no second toast", () => {
const capturing = commandAfter({ type: "run" })
expect(transitionHeapCommand(capturing, { type: "run" })).toEqual({ state: capturing, effects: [] })
expect(isHeapCommandEnabled(capturing)).toBe(false)
})
test("each outcome replaces the loading toast and offers the command again", () => {
const capturing = commandAfter({ type: "run" })
const succeeded = transitionHeapCommand(capturing, { type: "succeeded", attempt: 1, result })
const failed = transitionHeapCommand(capturing, { type: "failed", attempt: 1 })
expect(succeeded).toEqual({
state: { status: "idle", issued: 1 },
effects: [{ type: "toast.result", attempt: 1, result }],
})
expect(failed.effects).toEqual([{ type: "toast.failed", attempt: 1 }])
expect(isHeapCommandEnabled(succeeded.state)).toBe(true)
expect(transitionHeapCommand(commandAfter({ type: "run" }, { type: "failed", attempt: 1 }), { type: "run" })).toEqual(
{ state: { status: "capturing", attempt: 2 }, effects: [{ type: "capture.start", attempt: 2 }] },
)
})
test("disposing dismisses the loading toast and silences a late failure", () => {
const disposed = transitionHeapCommand(commandAfter({ type: "run" }), { type: "disposed" })
expect(disposed).toEqual({ state: { status: "disposed" }, effects: [{ type: "capture.abort", attempt: 1 }] })
expect(transitionHeapCommand(disposed.state, { type: "failed", attempt: 1 }).effects).toEqual([])
})
})
@@ -0,0 +1,229 @@
export type HeapCaptureResult = {
readonly directory: string
readonly complete: boolean
}
/** Answers one `HeapSnapshots.write` call. */
export type HeapCaptureReply = {
readonly resolve: (result: HeapCaptureResult) => void
readonly reject: (error: Error) => void
}
/** A command capture answers every caller that joined it; a signal capture answers nobody and logs a failure. */
export type HeapCaptureJob =
| {
readonly source: "command"
readonly root: string
readonly includeServer: boolean
readonly replies: readonly HeapCaptureReply[]
}
| { readonly source: "signal"; readonly root: string; readonly includeServer: boolean }
/**
* Main's heap snapshot queue. One capture runs (`active`) and at most one of the other source waits (`pending`):
* a repeated command joins the command job, and a repeated `SIGUSR2` joins the signal job, so repeats never fill the
* disk with queued dumps. `pending` starts only once the running capture settles, so two never run at once. The capture
* has no deadline of its own: each step that waits on another process is bounded and records a timeout as a failed
* snapshot, so a capture always settles through its steps.
*/
export type HeapCaptureState =
| { readonly status: "idle"; readonly attempt: number }
| {
readonly status: "capturing"
readonly attempt: number
readonly active: HeapCaptureJob
readonly pending: HeapCaptureJob | undefined
}
| { readonly status: "disposed" }
export type HeapCaptureEvent =
| {
readonly type: "command.requested"
readonly root: string
readonly includeServer: boolean
readonly reply: HeapCaptureReply
}
| { readonly type: "signal.received"; readonly root: string; readonly includeServer: boolean }
| { readonly type: "capture.succeeded"; readonly attempt: number; readonly result: HeapCaptureResult }
| { readonly type: "capture.failed"; readonly attempt: number; readonly error: Error }
| { readonly type: "disposed" }
export type HeapCaptureEffect =
| { readonly type: "capture.run"; readonly attempt: number; readonly root: string; readonly includeServer: boolean }
| {
readonly type: "replies.settle"
readonly replies: readonly HeapCaptureReply[]
readonly outcome:
| { readonly ok: true; readonly result: HeapCaptureResult }
| { readonly ok: false; readonly error: Error }
}
| { readonly type: "signal.failed"; readonly error: string }
export type HeapCaptureTransition = {
readonly state: HeapCaptureState
readonly effects: readonly HeapCaptureEffect[]
}
export function createHeapCaptureState(): HeapCaptureState {
return { status: "idle", attempt: 0 }
}
export function transitionHeapCapture(state: HeapCaptureState, event: HeapCaptureEvent): HeapCaptureTransition {
if (state.status === "disposed") {
if (event.type !== "command.requested") return { state, effects: [] }
return { state, effects: [settle([event.reply], { ok: false, error: new Error("Heap capture disposed") })] }
}
if (event.type === "disposed") {
if (state.status === "idle") return { state: { status: "disposed" }, effects: [] }
const replies = [state.active, state.pending].flatMap((job) => (job?.source === "command" ? job.replies : []))
return {
state: { status: "disposed" },
effects: replies.length ? [settle(replies, { ok: false, error: new Error("Heap capture disposed") })] : [],
}
}
if (event.type === "command.requested") {
if (state.status === "idle") {
return start(state.attempt + 1, {
source: "command",
root: event.root,
includeServer: event.includeServer,
replies: [event.reply],
})
}
if (state.active.source === "command") {
return {
state: { ...state, active: { ...state.active, replies: [...state.active.replies, event.reply] } },
effects: [],
}
}
// A signal capture is running: the command waits for it, and later commands join it there.
return {
state: {
...state,
pending: {
source: "command",
root: event.root,
includeServer: event.includeServer,
replies: state.pending?.source === "command" ? [...state.pending.replies, event.reply] : [event.reply],
},
},
effects: [],
}
}
if (event.type === "signal.received") {
if (state.status === "idle") {
return start(state.attempt + 1, { source: "signal", root: event.root, includeServer: event.includeServer })
}
if (state.active.source === "signal" || state.pending) return { state, effects: [] }
return {
state: { ...state, pending: { source: "signal", root: event.root, includeServer: event.includeServer } },
effects: [],
}
}
if (state.status === "idle" || event.attempt !== state.attempt) return { state, effects: [] }
const outcome =
event.type === "capture.succeeded"
? { ok: true as const, result: event.result }
: { ok: false as const, error: event.error }
const settled: HeapCaptureEffect[] =
state.active.source === "command"
? [settle(state.active.replies, outcome)]
: outcome.ok
? []
: [{ type: "signal.failed", error: outcome.error.message }]
if (!state.pending) return { state: { status: "idle", attempt: state.attempt }, effects: settled }
const next = start(state.attempt + 1, state.pending)
return { state: next.state, effects: [...settled, ...next.effects] }
}
function start(attempt: number, job: HeapCaptureJob): HeapCaptureTransition {
return {
state: { status: "capturing", attempt, active: job, pending: undefined },
effects: [{ type: "capture.run", attempt, root: job.root, includeServer: job.includeServer }],
}
}
function settle(
replies: readonly HeapCaptureReply[],
outcome: Extract<HeapCaptureEffect, { type: "replies.settle" }>["outcome"],
): HeapCaptureEffect {
return { type: "replies.settle", replies, outcome }
}
// --- The palette command in a window ---
/** One window's "Capture heap snapshots" command: a run while one is in flight shows nothing new. */
export type HeapCommandState =
| { readonly status: "idle"; readonly issued: number }
| { readonly status: "capturing"; readonly attempt: number }
| { readonly status: "disposed" }
export type HeapCommandEvent =
| { readonly type: "run" }
| { readonly type: "succeeded"; readonly attempt: number; readonly result: HeapCaptureResult }
| { readonly type: "failed"; readonly attempt: number }
| { readonly type: "disposed" }
export type HeapCommandEffect =
/** Shows the loading toast and asks main for the capture. */
| { readonly type: "capture.start"; readonly attempt: number }
/** Dismisses the loading toast and stops waiting for main. */
| { readonly type: "capture.abort"; readonly attempt: number }
| { readonly type: "toast.result"; readonly attempt: number; readonly result: HeapCaptureResult }
| { readonly type: "toast.failed"; readonly attempt: number }
export type HeapCommandTransition = {
readonly state: HeapCommandState
readonly effects: readonly HeapCommandEffect[]
}
export function createHeapCommandState(): HeapCommandState {
return { status: "idle", issued: 0 }
}
export function transitionHeapCommand(state: HeapCommandState, event: HeapCommandEvent): HeapCommandTransition {
if (state.status === "disposed") return { state, effects: [] }
if (event.type === "disposed") {
if (state.status === "idle") return { state: { status: "disposed" }, effects: [] }
return { state: { status: "disposed" }, effects: [{ type: "capture.abort", attempt: state.attempt }] }
}
if (event.type === "run") {
if (state.status === "capturing") return { state, effects: [] }
const attempt = state.issued + 1
return { state: { status: "capturing", attempt }, effects: [{ type: "capture.start", attempt }] }
}
if (state.status === "idle" || event.attempt !== state.attempt) return { state, effects: [] }
return {
state: { status: "idle", issued: state.attempt },
effects: [
event.type === "succeeded"
? { type: "toast.result", attempt: event.attempt, result: event.result }
: { type: "toast.failed", attempt: event.attempt },
],
}
}
/** Whether the palette offers the command: not while this window's capture runs. */
export function isHeapCommandEnabled(state: HeapCommandState) {
return state.status === "idle"
}
+180 -58
View File
@@ -1,12 +1,27 @@
import { app, contentTracing, screen, type BrowserWindow } from "electron"
import { Schema } from "effect"
import { mkdir, readFile, stat, writeFile } from "node:fs/promises"
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"
import path from "node:path"
import { getHeapSpaceStatistics, getHeapStatistics, writeHeapSnapshot } from "node:v8"
import type { Plugin } from "@opencode/plugin/gui/main"
import { HeapSnapshots, MemoryDiagnostics } from "./contract"
import {
createHeapCaptureState,
transitionHeapCapture,
type HeapCaptureEffect,
type HeapCaptureEvent,
type HeapCaptureResult,
} from "./heap-machine"
import { summarize, traceConfig } from "./memory-infra"
import { startMemoryLog } from "./memory-log"
import { collectMemorySample, memoryLogFile } from "./memory-log"
import {
createMemoryDiagnosticsState,
isMemoryDiagnosticsOn,
isRendererProbing,
transitionMemoryDiagnostics,
type MemoryDiagnosticsEffect,
type MemoryDiagnosticsEvent,
} from "./memory-machine"
import { debugBuild, memoryLogInterval } from "./options"
import { captureServer } from "./server-heap"
import { signalIncludesServer, snapshotDirectory } from "./signal"
@@ -18,8 +33,8 @@ type Snapshot = { readonly process: "main" | "window"; readonly pid: number; rea
| { readonly error: string }
)
/** The last capture; the next one starts after it settles. */
type Queue = { tail: Promise<unknown> }
/** How long a step that waits on another process may take before the manifest records it as failed. */
const SNAPSHOT_TIMEOUT_MS = 5 * 60_000
/** What a window's page holds, read from inside it. */
const PageStats = Schema.Struct({
@@ -53,12 +68,7 @@ const pageStatsScript = `(() => {
}
})()`
/** Stops the running memory diagnostics, while they run. */
type Running = { stop?: () => void }
const setup: Plugin.MainSetup = (ctx) => {
const queue: Queue = { tail: Promise.resolve() }
const capture = async (root: string, includeServer: boolean) => {
const directory = path.join(root, `opencode-heap-${new Date().toISOString().replace(/[:.]/g, "")}`)
@@ -83,7 +93,13 @@ const setup: Plugin.MainSetup = (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([
includeServer ? captureServer(ctx.serverEndpoints.get("sidecar"), directory, ctx.signal) : undefined,
includeServer
? captureServer(
ctx.serverEndpoints.get("sidecar"),
directory,
AbortSignal.any([ctx.signal, AbortSignal.timeout(SNAPSHOT_TIMEOUT_MS)]),
)
: undefined,
(async () => {
const list: Snapshot[] = []
@@ -116,7 +132,9 @@ const setup: Plugin.MainSetup = (ctx) => {
}),
)
const gpu = await app.getGPUInfo("basic").catch((error: Error) => ({ error: error.message }))
const gpu = await within(app.getGPUInfo("basic"), 15_000, "The GPU process did not answer within 15 seconds.").catch(
(error: Error) => ({ error: error.message }),
)
// One readable answer to "where is the memory", per process, in MiB.
await write(directory, "memory.json", {
@@ -161,72 +179,159 @@ const setup: Plugin.MainSetup = (ctx) => {
return { directory, complete }
}
const enqueue = (directory: string, includeServer: boolean) => {
const run = queue.tail.then(() => capture(directory, includeServer))
queue.tail = run.catch(() => undefined)
let heapState = createHeapCaptureState()
return run
const dispatchHeap = (event: HeapCaptureEvent) => {
const next = transitionHeapCapture(heapState, event)
heapState = next.state
next.effects.forEach(
(effect) =>
void Promise.try(() => runHeap(effect)).catch((cause: unknown) =>
ctx.log.write("error", "heap snapshot queue effect failed", { error: String(cause) }),
),
)
}
const runHeap = (effect: HeapCaptureEffect) => {
if (effect.type === "replies.settle") {
const outcome = effect.outcome
return effect.replies.forEach((reply) => (outcome.ok ? reply.resolve(outcome.result) : reply.reject(outcome.error)))
}
if (effect.type === "signal.failed") {
return ctx.log.write("error", "heap snapshot capture failed", { error: effect.error })
}
void Promise.try(() => capture(effect.root, effect.includeServer)).then(
(result) => dispatchHeap({ type: "capture.succeeded", attempt: effect.attempt, result }),
(cause: unknown) =>
dispatchHeap({
type: "capture.failed",
attempt: effect.attempt,
error: cause instanceof Error ? cause : new Error(String(cause)),
}),
)
}
ctx.onCleanup(() => dispatchHeap({ type: "disposed" }))
ctx.provide(HeapSnapshots, {
// A window that closes meanwhile does not cancel the capture.
write: () => enqueue(app.getPath("downloads"), true),
write: () =>
new Promise<HeapCaptureResult>((resolve, reject) =>
dispatchHeap({
type: "command.requested",
root: app.getPath("downloads"),
includeServer: true,
reply: { resolve, reject },
}),
),
})
if (!debugBuild(ctx.app)) return
// The environment turns diagnostics on for this launch; otherwise the debug bar's stored toggle does.
const envInterval = memoryLogInterval(process.env.OPENCODE_DESKTOP_MEMORY_LOG)
const interval = envInterval ?? 30_000
// Without the launch switch, Chromium quantizes renderer heap sizes and refreshes them rarely.
const precise = app.commandLine.hasSwitch("enable-precise-memory-info")
// Whether the debug bar turned memory diagnostics on.
const stored = ctx.storage.store("memory", { schema: Schema.Boolean, initial: false })
const running: Running = {}
const directory = process.platform === "win32" ? undefined : snapshotDirectory(process.env)
let capturing = false
let memoryState = createMemoryDiagnosticsState()
let memoryTimer: ReturnType<typeof setTimeout> | undefined
let memoryFile = ""
let unsubscribeSignal: (() => void) | undefined
// Signals coalesce in the queue; repeated signals must not fill the disk with queued dumps.
const onSignal = () => {
// Signals coalesce while a capture is running; repeated signals must not fill the disk with queued dumps.
if (!directory || capturing || ctx.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
})
if (!directory) return
dispatchHeap({ type: "signal.received", root: directory, includeServer: signalIncludesServer(process.env) })
}
const start = () => {
const stopLog = startMemoryLog(envInterval ?? 30_000, ctx.log)
const dispatchMemory = (event: MemoryDiagnosticsEvent) => {
const next = transitionMemoryDiagnostics(memoryState, event)
if (!directory) return stopLog
memoryState = next.state
next.effects.forEach(
(effect) =>
void Promise.try(() => runMemory(effect)).catch((cause: unknown) =>
ctx.log.write("error", "memory diagnostics effect failed", { error: String(cause) }),
),
)
}
// Installed the first time diagnostics turn on, and kept: a script's late signal while they are off, or while
// the plugin 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 })
const runMemory = (effect: MemoryDiagnosticsEffect) => {
if (effect.type === "log.start") {
memoryFile = memoryLogFile()
ctx.log.write("info", "memory diagnostics started", { file: memoryFile, intervalMs: interval, precise })
return () => {
unsubscribe()
stopLog()
if (!directory) return
// Installed the first time diagnostics turn on, and kept: a script's late signal while they are off, or while
// the plugin reloads, is logged and ignored instead of killing the app.
ProcessSignal.install("SIGUSR2", () => ctx.log.write("warn", "heap snapshot signal ignored: diagnostics are off"))
unsubscribeSignal = ProcessSignal.subscribe("SIGUSR2", onSignal)
ctx.log.write("info", "heap snapshot signal enabled", { pid: process.pid, signal: "SIGUSR2", directory })
return
}
if (effect.type === "log.stop") {
unsubscribeSignal?.()
unsubscribeSignal = undefined
ctx.log.write("info", "memory diagnostics stopped", { file: memoryFile })
return
}
if (effect.type === "timer.schedule") {
memoryTimer = setTimeout(() => dispatchMemory({ type: "tick", run: effect.run }), interval)
return
}
if (effect.type === "timer.cancel") return clearTimeout(memoryTimer)
if (effect.type === "sample.report") {
return ctx.log.write("error", "memory diagnostics sample failed", { error: effect.error })
}
const failed = (cause: unknown) =>
dispatchMemory({
type: "sample.failed",
attempt: effect.attempt,
error: cause instanceof Error ? cause.message : String(cause),
})
if (effect.type === "sample.write") {
void Promise.try(() => appendFile(memoryFile, effect.line, { mode: 0o600 })).then(
() => dispatchMemory({ type: "sample.written", attempt: effect.attempt }),
failed,
)
return
}
void Promise.try(() =>
collectMemorySample(memoryFile, precise, {
pending: (contentsId) => isRendererProbing(memoryState, contentsId),
started: (contentsId) => dispatchMemory({ type: "probe.started", contentsId }),
settled: (contentsId) => dispatchMemory({ type: "probe.settled", contentsId }),
}),
).then((line) => dispatchMemory({ type: "sample.collected", attempt: effect.attempt, line }), failed)
}
const sync = () => {
const on = envInterval !== undefined || stored.value
if (on && !running.stop) running.stop = start()
if (on || !running.stop) return
running.stop()
running.stop = undefined
}
const sync = () => dispatchMemory({ type: "synced", enabled: envInterval !== undefined || stored.value })
// Started before the Ipc is provided, so the first state windows receive already says whether diagnostics run.
sync()
ctx.onCleanup(() => running.stop?.())
ctx.onCleanup(() => dispatchMemory({ type: "disposed" }))
const provider = ctx.provide(MemoryDiagnostics, {
state: () => ({ on: running.stop !== undefined, env: envInterval !== undefined }),
state: () => ({ on: isMemoryDiagnosticsOn(memoryState), env: envInterval !== undefined }),
set: (enabled) => {
stored.set(enabled)
sync()
@@ -240,27 +345,27 @@ const setup: Plugin.MainSetup = (ctx) => {
async function recordNative(directory: string) {
const file = path.join(directory, "memory-infra.json")
return (
return within(
contentTracing
.startRecording(traceConfig)
// The first detailed dump arrives within a periodic interval of starting.
.then(() => new Promise((resolve) => setTimeout(resolve, 2_500)))
.then(() => contentTracing.stopRecording(file))
.then(async () => ({ file: path.basename(file), summary: summarize(await readFile(file, "utf8")) }))
.catch((error: Error) => ({ error: error.message }))
)
.then(async () => ({ file: path.basename(file), summary: summarize(await readFile(file, "utf8")) })),
15_000,
"The memory trace did not finish within 15 seconds.",
).catch((error: Error) => ({ error: error.message }))
}
async function describeWindow(window: BrowserWindow) {
const contents = window.webContents
// A hung or busy renderer never answers, and the snapshots wait for this step, so give up after a few seconds.
const page = await Promise.race([
const page = await within(
contents.executeJavaScript(pageStatsScript).then(decodePageStats),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("The window did not answer within 3 seconds.")), 3_000),
),
]).catch((error: Error) => ({ error: error.message }))
3_000,
"The window did not answer within 3 seconds.",
).catch((error: Error) => ({ error: error.message }))
return {
window: window.id,
@@ -278,12 +383,29 @@ function captureWindow(window: BrowserWindow, directory: string) {
const pid = contents.getOSProcessId()
const file = `window-${window.id}-${pid}.heapsnapshot`
return contents.takeHeapSnapshot(path.join(directory, file)).then(
// A hung renderer is recorded as failed, so the other windows and main are still captured.
return within(
contents.takeHeapSnapshot(path.join(directory, file)),
SNAPSHOT_TIMEOUT_MS,
"The window did not write its heap snapshot within 5 minutes.",
).then(
(): Snapshot => ({ process: "window", pid, window: window.id, file }),
(error: Error): Snapshot => ({ process: "window", pid, window: window.id, error: error.message }),
)
}
/** Rejects with `message` when `promise` takes longer than `ms`. */
function within<Value>(promise: Promise<Value>, ms: number, message: string) {
let deadline: ReturnType<typeof setTimeout> | undefined
return Promise.race([
promise,
new Promise<never>((_, reject) => {
deadline = setTimeout(() => reject(new Error(message)), ms)
}),
]).finally(() => clearTimeout(deadline))
}
function write<Value>(directory: string, file: string, value: Value) {
return writeFile(path.join(directory, file), JSON.stringify(value, null, 2), { mode: 0o600 })
}
+47 -62
View File
@@ -1,10 +1,11 @@
import { appendFile, mkdir } from "node:fs/promises"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { app, webContents } from "electron"
import { Schema } from "effect"
import type { Log } from "@opencode/plugin/gui/main"
import { memoryRecord, RendererMemory, type RendererRecord } from "./memory-record"
const RENDERER_PROBE_TIMEOUT_MS = 2_000
// Runs only while diagnostics are on; no preload hooks, observers, or store subscriptions.
const probe = `(() => {
const memory = performance.memory;
@@ -14,67 +15,51 @@ const probe = `(() => {
};
})()`
/** Appends a sample every `interval` ms to a new JSONL file in the desktop logs, which exported debug logs include. */
export function startMemoryLog(interval: number, log: Log) {
const file = path.join(
app.getPath("userData"),
"logs",
`memory-${new Date().toISOString().replace(/[:.]/g, "")}.jsonl`,
/** A new JSONL file in the desktop logs, which exported debug logs include. */
export function memoryLogFile() {
return path.join(app.getPath("userData"), "logs", `memory-${new Date().toISOString().replace(/[:.]/g, "")}.jsonl`)
}
/**
* Collects one sample line for `file`, creating its folder, so appending it is the only step left. A renderer whose
* earlier probe has not settled is skipped, and a probe that takes too long is reported without waiting for it.
*/
export async function collectMemorySample(
file: string,
precise: boolean,
probes: {
readonly pending: (contentsId: number) => boolean
readonly started: (contentsId: number) => void
readonly settled: (contentsId: number) => void
},
) {
const metrics = app.getAppMetrics()
const renderers = await Promise.all(
webContents.getAllWebContents().map(async (contents): Promise<RendererRecord> => {
const identity = { id: contents.id, pid: contents.getOSProcessId(), type: contents.getType() }
if (probes.pending(contents.id)) return { ...identity, error: "Previous renderer sample is still pending" }
probes.started(contents.id)
let deadline: ReturnType<typeof setTimeout> | undefined
const reading = contents.executeJavaScript(probe).then(Schema.decodeUnknownSync(RendererMemory))
void reading.finally(() => probes.settled(contents.id)).catch(() => undefined)
return Promise.race([
reading,
new Promise<never>((_, reject) => {
deadline = setTimeout(() => reject(new Error("Renderer sample timed out")), RENDERER_PROBE_TIMEOUT_MS)
}),
])
.then(
(sample) => ({ ...identity, sample }),
(error: Error) => ({ ...identity, error: error.message }),
)
.finally(() => clearTimeout(deadline))
}),
)
// Without the launch switch, Chromium quantizes renderer heap sizes and refreshes them rarely.
const precise = app.commandLine.hasSwitch("enable-precise-memory-info")
const pending = new WeakSet<Electron.WebContents>()
let stopped = false
let timer: ReturnType<typeof setTimeout> | undefined
await mkdir(path.dirname(file), { recursive: true })
const sample = async () => {
const metrics = app.getAppMetrics()
const renderers = await Promise.all(
webContents.getAllWebContents().map(async (contents): Promise<RendererRecord> => {
const identity = { id: contents.id, pid: contents.getOSProcessId(), type: contents.getType() }
if (pending.has(contents)) return { ...identity, error: "Previous renderer sample is still pending" }
pending.add(contents)
let deadline: ReturnType<typeof setTimeout> | undefined
// A hung renderer must not stop native-memory logging or accumulate evaluation requests.
const reading = contents.executeJavaScript(probe).then(Schema.decodeUnknownSync(RendererMemory))
void reading.finally(() => pending.delete(contents)).catch(() => undefined)
return Promise.race([
reading,
new Promise<never>((_, reject) => {
deadline = setTimeout(() => reject(new Error("Renderer sample timed out")), 2000)
}),
])
.then(
(sample) => ({ ...identity, sample }),
(error: Error) => ({ ...identity, error: error.message }),
)
.finally(() => clearTimeout(deadline))
}),
)
if (stopped) return
await mkdir(path.dirname(file), { recursive: true })
await appendFile(file, `${JSON.stringify(memoryRecord(metrics, renderers, precise))}\n`, { mode: 0o600 })
}
const tick = () => {
void sample()
.catch((error: Error) => log.write("error", "memory diagnostics sample failed", { error: error.message }))
.finally(() => {
if (!stopped) timer = setTimeout(tick, interval)
})
}
log.write("info", "memory diagnostics started", { file, intervalMs: interval, precise })
timer = setTimeout(tick, interval)
return () => {
stopped = true
clearTimeout(timer)
log.write("info", "memory diagnostics stopped", { file })
}
return `${JSON.stringify(memoryRecord(metrics, renderers, precise))}\n`
}
@@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test"
import {
createMemoryDiagnosticsState,
debugBarView,
isMemoryDiagnosticsOn,
isRendererProbing,
transitionMemoryDiagnostics,
type MemoryDiagnosticsEvent,
type MemoryDiagnosticsState,
} from "./memory-machine"
const on: MemoryDiagnosticsEvent = { type: "synced", enabled: true }
const off: MemoryDiagnosticsEvent = { type: "synced", enabled: false }
function after(...events: MemoryDiagnosticsEvent[]) {
return events.reduce<MemoryDiagnosticsState>(
(state, event) => transitionMemoryDiagnostics(state, event).state,
createMemoryDiagnosticsState(),
)
}
describe("transitionMemoryDiagnostics", () => {
test("turning on opens a run and waits an interval for the first sample", () => {
const started = transitionMemoryDiagnostics(createMemoryDiagnosticsState(), on)
expect(started.effects).toEqual([
{ type: "log.start", run: 1 },
{ type: "timer.schedule", run: 1 },
])
expect(isMemoryDiagnosticsOn(started.state)).toBe(true)
expect(transitionMemoryDiagnostics(started.state, on).state).toBe(started.state)
})
test("a sample still collecting when diagnostics turn off writes nothing and schedules nothing", () => {
const sampling = after(on, { type: "tick", run: 1 })
const stopped = transitionMemoryDiagnostics(sampling, off)
expect(stopped.effects).toEqual([{ type: "timer.cancel" }, { type: "log.stop", run: 1 }])
expect(
transitionMemoryDiagnostics(stopped.state, { type: "sample.collected", attempt: 2, line: "{}\n" }).effects,
).toEqual([])
expect(transitionMemoryDiagnostics(stopped.state, { type: "sample.written", attempt: 2 }).effects).toEqual([])
// Nor once diagnostics are on again: the sample belongs to the earlier run.
const restarted = after(on, { type: "tick", run: 1 }, off, on)
expect(
transitionMemoryDiagnostics(restarted, { type: "sample.collected", attempt: 2, line: "{}\n" }).state,
).toBe(restarted)
})
test("a hung renderer stays skipped across off and on until its probe settles", () => {
const state = after(on, { type: "probe.started", contentsId: 7 }, off, on)
expect(isRendererProbing(state, 7)).toBe(true)
expect(isRendererProbing(transitionMemoryDiagnostics(state, { type: "probe.settled", contentsId: 7 }).state, 7)).toBe(
false,
)
})
test("a sample writes its line, then waits for the next interval; a failure is logged and waits too", () => {
const sampling = transitionMemoryDiagnostics(after(on), { type: "tick", run: 1 })
expect(sampling.effects).toEqual([{ type: "sample.collect", attempt: 2 }])
expect(transitionMemoryDiagnostics(sampling.state, { type: "tick", run: 1 }).state).toBe(sampling.state)
const collected = transitionMemoryDiagnostics(sampling.state, {
type: "sample.collected",
attempt: 2,
line: "{}\n",
})
expect(collected.effects).toEqual([{ type: "sample.write", attempt: 2, line: "{}\n" }])
expect(transitionMemoryDiagnostics(collected.state, { type: "sample.written", attempt: 2 }).effects).toEqual([
{ type: "timer.schedule", run: 1 },
])
expect(
transitionMemoryDiagnostics(sampling.state, { type: "sample.failed", attempt: 2, error: "EACCES" }).effects,
).toEqual([
{ type: "sample.report", error: "EACCES" },
{ type: "timer.schedule", run: 1 },
])
})
test("disposing stops a running log", () => {
expect(transitionMemoryDiagnostics(after(on), { type: "disposed" })).toEqual({
state: { status: "disposed" },
effects: [{ type: "timer.cancel" }, { type: "log.stop", run: 1 }],
})
expect(transitionMemoryDiagnostics(after(), { type: "disposed" }).effects).toEqual([])
})
})
describe("debugBarView", () => {
test("running diagnostics pin the bar without a close button, and the Knobs open from it", () => {
expect(debugBarView({ visible: false, motion: true }, { on: true })).toEqual({
visibility: "pinned",
open: true,
closable: false,
togglable: false,
motion: true,
})
})
test("otherwise the stored visibility decides", () => {
expect(debugBarView({ visible: true, motion: false }, { on: false })).toEqual({
visibility: "shown",
open: true,
closable: true,
togglable: true,
motion: false,
})
expect(debugBarView({ visible: false, motion: true }, undefined)).toMatchObject({ open: false, motion: false })
})
})
@@ -0,0 +1,151 @@
/**
* Main's memory diagnostics. `on` writes one sample per interval to the run's JSONL file: `waiting` for the timer,
* then `sampling` while the sample is collected and appended. A sample of an earlier attempt, or of a run that turned
* off, writes nothing. `probing` lists the `WebContents.id`s whose renderer probe has not settled, across runs, so a
* hung renderer gets one evaluation at a time however often diagnostics turn off and on. `issued` numbers runs and
* samples alike.
*/
export type MemoryDiagnosticsState =
| { readonly status: "off"; readonly issued: number; readonly probing: ReadonlySet<number> }
| {
readonly status: "on"
readonly issued: number
readonly run: number
readonly sampling: number | undefined
readonly probing: ReadonlySet<number>
}
| { readonly status: "disposed" }
export type MemoryDiagnosticsEvent =
/** Whether diagnostics should run: the environment asks for them, or the debug bar's stored toggle does. */
| { readonly type: "synced"; readonly enabled: boolean }
| { readonly type: "tick"; readonly run: number }
| { readonly type: "probe.started"; readonly contentsId: number }
| { readonly type: "probe.settled"; readonly contentsId: number }
| { readonly type: "sample.collected"; readonly attempt: number; readonly line: string }
| { readonly type: "sample.written"; readonly attempt: number }
| { readonly type: "sample.failed"; readonly attempt: number; readonly error: string }
| { readonly type: "disposed" }
export type MemoryDiagnosticsEffect =
/** Opens the run's file and, where the platform has it, the heap snapshot signal. */
| { readonly type: "log.start"; readonly run: number }
| { readonly type: "log.stop"; readonly run: number }
| { readonly type: "timer.schedule"; readonly run: number }
| { readonly type: "timer.cancel" }
| { readonly type: "sample.collect"; readonly attempt: number }
| { readonly type: "sample.write"; readonly attempt: number; readonly line: string }
| { readonly type: "sample.report"; readonly error: string }
export type MemoryDiagnosticsTransition = {
readonly state: MemoryDiagnosticsState
readonly effects: readonly MemoryDiagnosticsEffect[]
}
export function createMemoryDiagnosticsState(): MemoryDiagnosticsState {
return { status: "off", issued: 0, probing: new Set() }
}
export function transitionMemoryDiagnostics(
state: MemoryDiagnosticsState,
event: MemoryDiagnosticsEvent,
): MemoryDiagnosticsTransition {
if (state.status === "disposed") return { state, effects: [] }
if (event.type === "disposed") {
return { state: { status: "disposed" }, effects: state.status === "on" ? stop(state.run) : [] }
}
if (event.type === "probe.started") {
if (state.probing.has(event.contentsId)) return { state, effects: [] }
return { state: { ...state, probing: new Set([...state.probing, event.contentsId]) }, effects: [] }
}
if (event.type === "probe.settled") {
if (!state.probing.has(event.contentsId)) return { state, effects: [] }
return {
state: { ...state, probing: new Set([...state.probing].filter((id) => id !== event.contentsId)) },
effects: [],
}
}
if (event.type === "synced") {
if (event.enabled === (state.status === "on")) return { state, effects: [] }
if (state.status === "on") {
return { state: { status: "off", issued: state.issued, probing: state.probing }, effects: stop(state.run) }
}
const run = state.issued + 1
return {
state: { status: "on", issued: run, run, sampling: undefined, probing: state.probing },
effects: [
{ type: "log.start", run },
{ type: "timer.schedule", run },
],
}
}
if (state.status === "off") return { state, effects: [] }
if (event.type === "tick") {
if (event.run !== state.run || state.sampling !== undefined) return { state, effects: [] }
const attempt = state.issued + 1
return { state: { ...state, issued: attempt, sampling: attempt }, effects: [{ type: "sample.collect", attempt }] }
}
if (event.attempt !== state.sampling) return { state, effects: [] }
if (event.type === "sample.collected") {
return { state, effects: [{ type: "sample.write", attempt: event.attempt, line: event.line }] }
}
return {
state: { ...state, sampling: undefined },
effects: [
...(event.type === "sample.failed" ? [{ type: "sample.report" as const, error: event.error }] : []),
{ type: "timer.schedule", run: state.run },
],
}
}
/** Whether diagnostics run, as the debug bar shows them. */
export function isMemoryDiagnosticsOn(state: MemoryDiagnosticsState) {
return state.status === "on"
}
/** Whether a renderer's earlier probe is still unanswered, so the next sample skips it. */
export function isRendererProbing(state: MemoryDiagnosticsState, contentsId: number) {
return state.status !== "disposed" && state.probing.has(contentsId)
}
function stop(run: number): MemoryDiagnosticsEffect[] {
return [{ type: "timer.cancel" }, { type: "log.stop", run }]
}
// --- The debug bar in a window ---
/**
* `pinned` keeps the bar open while memory diagnostics run, so they are never silently on; it has no close button
* then, and the Knobs workspace follows the bar rather than the stored visibility.
*/
export type DebugBarVisibility = "hidden" | "shown" | "pinned"
export function debugBarView(
bar: { readonly visible: boolean; readonly motion: boolean },
memory: { readonly on: boolean } | undefined,
) {
const visibility: DebugBarVisibility = memory?.on ? "pinned" : bar.visible ? "shown" : "hidden"
return {
visibility,
open: visibility !== "hidden",
closable: visibility === "shown",
togglable: visibility !== "pinned",
motion: visibility !== "hidden" && bar.motion,
}
}
+76 -28
View File
@@ -1,8 +1,16 @@
import { showToast, toaster } from "@opencode/ui/toast"
import { lazy, Show, Suspense } from "solid-js"
import { createMemo, createSignal, lazy, onCleanup, Show, Suspense } from "solid-js"
import { createKeyed, Command, Slot, type Plugin } from "@opencode/plugin/gui"
import type Debug from "./index"
import { HeapSnapshots, MemoryDiagnostics } from "./contract"
import {
createHeapCommandState,
isHeapCommandEnabled,
transitionHeapCommand,
type HeapCommandEffect,
type HeapCommandEvent,
} from "./heap-machine"
import { debugBarView } from "./memory-machine"
const setup: Plugin.Setup<typeof Debug> = (ctx) => {
const DebugBar = lazy(() => import("./bar"))
@@ -11,8 +19,6 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
const motionTools = import.meta.env.DEV || import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const Motion = motionTools ? lazy(() => import("./motion")) : undefined
const bar = ctx.stores.bar
const visible = () => bar.value.visible
const motion = () => bar.value.motion
const toggle = () => {
bar.update((draft) => {
@@ -35,6 +41,8 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
return live.status === "active" ? live.value.state() : undefined
}
const barView = createMemo(() => debugBarView(bar.value, memoryState()))
const memoryControl = () => {
const live = memory()
const state = memoryState()
@@ -64,6 +72,8 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
title: ctx.t("command.toggle"),
group: ctx.t("command.category.view"),
keywords: "dev bar developer tools diagnostics",
// Running diagnostics keep the bar open.
enabled: barView().togglable,
run: toggle,
}),
)
@@ -72,7 +82,60 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
// Offered only while main answers: never on the web, and not while the plugin restarts.
if (desktop)
createKeyed(ctx.use(HeapSnapshots), (heap) =>
createKeyed(ctx.use(HeapSnapshots), (heap) => {
const [heapState, setHeapState] = createSignal(createHeapCommandState())
const controllers = new Map<number, AbortController>()
const toasts = new Map<number, ReturnType<typeof showToast>>()
const dispatch = (event: HeapCommandEvent) => {
const next = transitionHeapCommand(heapState(), event)
setHeapState(next.state)
next.effects.forEach(
(effect) =>
void Promise.try(() => run(effect)).catch((cause: unknown) => console.error("[debug] heap", cause)),
)
}
const run = (effect: HeapCommandEffect) => {
if (effect.type === "capture.start") {
const controller = new AbortController()
controllers.set(effect.attempt, controller)
toasts.set(effect.attempt, showToast({ variant: "loading", title: ctx.t("heap.loading"), persistent: true }))
void Promise.try(() => heap.write({ signal: AbortSignal.any([ctx.signal, controller.signal]) })).then(
(result) => dispatch({ type: "succeeded", attempt: effect.attempt, result }),
() => dispatch({ type: "failed", attempt: effect.attempt }),
)
return
}
const loading = toasts.get(effect.attempt)
controllers.get(effect.attempt)?.abort()
controllers.delete(effect.attempt)
toasts.delete(effect.attempt)
// Without an id, dismiss clears every toast.
if (loading !== undefined) toaster.dismiss(loading)
if (effect.type === "capture.abort") return
if (effect.type === "toast.failed") return void showToast({ variant: "error", title: ctx.t("heap.failed") })
const result = effect.result
showToast({
variant: result.complete ? "success" : "error",
title: ctx.t(result.complete ? "heap.done" : "heap.partial"),
description: result.directory,
persistent: true,
actions: [{ label: ctx.t("heap.reveal"), onClick: () => void desktop.reveal(result.directory) }],
})
}
onCleanup(() => dispatch({ type: "disposed" }))
ctx.add(Command, {
id: "heap",
get title() {
@@ -84,46 +147,31 @@ const setup: Plugin.Setup<typeof Debug> = (ctx) => {
get group() {
return ctx.t("command.category.settings")
},
run: async () => {
const loading = showToast({ variant: "loading", title: ctx.t("heap.loading"), persistent: true })
const result = await heap.write({ signal: ctx.signal }).catch(() => undefined)
toaster.dismiss(loading)
if (ctx.signal.aborted) return
if (!result) return void showToast({ variant: "error", title: ctx.t("heap.failed") })
showToast({
variant: result.complete ? "success" : "error",
title: ctx.t(result.complete ? "heap.done" : "heap.partial"),
description: result.directory,
persistent: true,
actions: [{ label: ctx.t("heap.reveal"), onClick: () => void desktop.reveal(result.directory) }],
})
get enabled() {
return isHeapCommandEnabled(heapState())
},
}),
)
run: () => dispatch({ type: "run" }),
})
})
ctx.add(Slot, {
at: "window.bottom",
render: () => (
<>
<Show when={visible() || memoryState()?.on}>
<Show when={barView().open}>
<Suspense>
<DebugBar
devTools={import.meta.env.DEV}
memory={memoryControl()}
onClose={hide}
motion={Motion ? { open: motion(), toggle: toggleMotion } : undefined}
onClose={barView().closable ? hide : undefined}
motion={Motion ? { open: barView().motion, toggle: toggleMotion } : undefined}
inline
/>
</Suspense>
</Show>
{Motion && (
// The Knobs workspace opens only from the debug bar's KNOBS cell, and shows while the bar does.
<Show when={visible() && motion()}>
<Show when={barView().motion}>
<Suspense>
<Motion title={ctx.t("motion.title")} onClose={toggleMotion} />
</Suspense>