diff --git a/packages/app/component-tests/debug-bar.spec.ts b/packages/app/component-tests/debug-bar.spec.ts index 43ad2b90ebd..7afec7b163c 100644 --- a/packages/app/component-tests/debug-bar.spec.ts +++ b/packages/app/component-tests/debug-bar.spec.ts @@ -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) => { diff --git a/packages/gui-plugins/src/context/export.test.ts b/packages/gui-plugins/src/context/export.test.ts new file mode 100644 index 00000000000..8cc9b12e26b --- /dev/null +++ b/packages/gui-plugins/src/context/export.test.ts @@ -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 + + 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]) + }) +}) diff --git a/packages/gui-plugins/src/context/export.ts b/packages/gui-plugins/src/context/export.ts index 88bd32164d6..27aa2c00084 100644 --- a/packages/gui-plugins/src/context/export.ts +++ b/packages/gui-plugins/src/context/export.ts @@ -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 -}) { + signal?: AbortSignal +}): Promise { + 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` diff --git a/packages/gui-plugins/src/context/machine.test.ts b/packages/gui-plugins/src/context/machine.test.ts new file mode 100644 index 00000000000..826aced6d56 --- /dev/null +++ b/packages/gui-plugins/src/context/machine.test.ts @@ -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( + (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( + (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, + ) + }) +}) diff --git a/packages/gui-plugins/src/context/machine.ts b/packages/gui-plugins/src/context/machine.ts new file mode 100644 index 00000000000..e3e1e3186ba --- /dev/null +++ b/packages/gui-plugins/src/context/machine.ts @@ -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> + } + | { 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): 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>, key: string) { + return Object.fromEntries(Object.entries(record).filter(([entry]) => entry !== key)) +} diff --git a/packages/gui-plugins/src/context/tab.tsx b/packages/gui-plugins/src/context/tab.tsx index 8a331bf58a8..de49dc7821d 100644 --- a/packages/gui-plugins/src/context/tab.tsx +++ b/packages/gui-plugins/src/context/tab.tsx @@ -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 ( - requestAnimationFrame(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() + 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: () => , 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 (
{ - 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 }, + }) + } >
@@ -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} > - + }> + + {ctx.t("export.session")}
- {(message) => } + {(message) => ( + dispatchScroll(contentChanged)} time={formatter().time} /> + )}
diff --git a/packages/gui-plugins/src/debug/contract.ts b/packages/gui-plugins/src/debug/contract.ts index dde92128475..02b23562be3 100644 --- a/packages/gui-plugins/src/debug/contract.ts +++ b/packages/gui-plugins/src/debug/contract.ts @@ -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: { diff --git a/packages/gui-plugins/src/debug/heap-machine.test.ts b/packages/gui-plugins/src/debug/heap-machine.test.ts new file mode 100644 index 00000000000..44d8d2f6ba0 --- /dev/null +++ b/packages/gui-plugins/src/debug/heap-machine.test.ts @@ -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( + (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( + (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([]) + }) +}) diff --git a/packages/gui-plugins/src/debug/heap-machine.ts b/packages/gui-plugins/src/debug/heap-machine.ts new file mode 100644 index 00000000000..54bd7db3986 --- /dev/null +++ b/packages/gui-plugins/src/debug/heap-machine.ts @@ -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["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" +} diff --git a/packages/gui-plugins/src/debug/main.ts b/packages/gui-plugins/src/debug/main.ts index f9b1911f8f4..f33c1014f06 100644 --- a/packages/gui-plugins/src/debug/main.ts +++ b/packages/gui-plugins/src/debug/main.ts @@ -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 } +/** 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((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 | 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((_, 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(promise: Promise, ms: number, message: string) { + let deadline: ReturnType | undefined + + return Promise.race([ + promise, + new Promise((_, reject) => { + deadline = setTimeout(() => reject(new Error(message)), ms) + }), + ]).finally(() => clearTimeout(deadline)) +} + function write(directory: string, file: string, value: Value) { return writeFile(path.join(directory, file), JSON.stringify(value, null, 2), { mode: 0o600 }) } diff --git a/packages/gui-plugins/src/debug/memory-log.ts b/packages/gui-plugins/src/debug/memory-log.ts index 9573eb432c8..2629778ca36 100644 --- a/packages/gui-plugins/src/debug/memory-log.ts +++ b/packages/gui-plugins/src/debug/memory-log.ts @@ -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 => { + 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 | 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((_, 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() - let stopped = false - let timer: ReturnType | 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 => { - 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 | 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((_, 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` } diff --git a/packages/gui-plugins/src/debug/memory-machine.test.ts b/packages/gui-plugins/src/debug/memory-machine.test.ts new file mode 100644 index 00000000000..40436425f30 --- /dev/null +++ b/packages/gui-plugins/src/debug/memory-machine.test.ts @@ -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( + (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 }) + }) +}) diff --git a/packages/gui-plugins/src/debug/memory-machine.ts b/packages/gui-plugins/src/debug/memory-machine.ts new file mode 100644 index 00000000000..3499bee2801 --- /dev/null +++ b/packages/gui-plugins/src/debug/memory-machine.ts @@ -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 } + | { + readonly status: "on" + readonly issued: number + readonly run: number + readonly sampling: number | undefined + readonly probing: ReadonlySet + } + | { 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, + } +} diff --git a/packages/gui-plugins/src/debug/renderer.tsx b/packages/gui-plugins/src/debug/renderer.tsx index 3596d0ec2be..bd3c17e5b60 100644 --- a/packages/gui-plugins/src/debug/renderer.tsx +++ b/packages/gui-plugins/src/debug/renderer.tsx @@ -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 = (ctx) => { const DebugBar = lazy(() => import("./bar")) @@ -11,8 +19,6 @@ const setup: Plugin.Setup = (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 = (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 = (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 = (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() + const toasts = new Map>() + + 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 = (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: () => ( <> - + {Motion && ( // The Knobs workspace opens only from the debug bar's KNOBS cell, and shows while the bar does. - +