From eb645773d156d83b309b55ed9c76f584991d8fc5 Mon Sep 17 00:00:00 2001 From: alexey Date: Tue, 3 Nov 2026 18:18:45 +0200 Subject: [PATCH] fix(tui): keep usage state and polling in plugin setup Preserve usage state and in-flight refresh cycles across panel remounts. Keep existing refresh deadlines and overlap protection. Stop polling and release the slot during plugin cleanup. Add lifecycle reproductions with the OpenTUI renderer and focused refresh tests. --- package.json | 3 +- src/refresh.ts | 77 ++++++++++++ tests/lifecycle.test.tsx | 266 +++++++++++++++++++++++++++++++++++++++ tests/refresh.test.ts | 209 ++++++++++++++++++++++++++++++ tui.tsx | 98 ++++++--------- 5 files changed, 589 insertions(+), 64 deletions(-) create mode 100644 src/refresh.ts create mode 100644 tests/lifecycle.test.tsx create mode 100644 tests/refresh.test.ts diff --git a/package.json b/package.json index 91808d7..520a5c8 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "LICENSE" ], "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test --preload @opentui/solid/preload tests" }, "keywords": [ "opencode", diff --git a/src/refresh.ts b/src/refresh.ts new file mode 100644 index 0000000..087ee65 --- /dev/null +++ b/src/refresh.ts @@ -0,0 +1,77 @@ +/** + * Refresh cycle for one usage poll. + * + * A cycle is bounded by `timeoutMs`, which covers the whole load, credential + * lookup included. A cycle that is still in flight makes the next poll a no-op, + * and a result that arrives after disposal is dropped. The deadline does not + * cancel the load: it only stops the cycle from waiting for it. + */ + +export type RefreshOptions = { + /** Runs one load. Resolves with the value, or rejects. */ + load: () => Promise + /** Upper bound for one cycle, in milliseconds. */ + timeoutMs: number + /** Receives the value of a cycle that finished in time, while live. */ + onValue: (value: Value) => void + /** Receives a user-facing message, while live. */ + onError: (message: string) => void +} + +export type Refresher = { + /** Runs one cycle, unless a cycle is already in flight or the refresher is disposed. */ + refresh: () => Promise + /** Drops later results. Does not cancel an in-flight load. */ + dispose: () => void +} + +/** + * Race a promise against a deadline. Resolves `undefined` when the deadline + * wins, so the caller can tell a timeout apart from a real result. + */ +async function withDeadline(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), ms) + }) + + try { + return await Promise.race([promise, deadline]) + } finally { + clearTimeout(timer) + } +} + +export function createRefresher(options: RefreshOptions): Refresher { + let disposed = false + let refreshing = false + + const refresh = async () => { + if (disposed || refreshing) return + refreshing = true + + try { + const value = await withDeadline(options.load(), options.timeoutMs) + if (disposed) return + + if (value === undefined) { + options.onError("request timed out") + return + } + + options.onValue(value) + } catch { + if (disposed) return + options.onError("unexpected error") + } finally { + refreshing = false + } + } + + return { + refresh, + dispose: () => { + disposed = true + }, + } +} diff --git a/tests/lifecycle.test.tsx b/tests/lifecycle.test.tsx new file mode 100644 index 0000000..e6515db --- /dev/null +++ b/tests/lifecycle.test.tsx @@ -0,0 +1,266 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Lifecycle reproduction against the real plugin setup and a fake host. + * + * The fake host records the slot claim, so a test can mount the claim's render + * function, dispose it, and mount the same claim again. The real OpenTUI test + * renderer provides the Solid root for each mount. + */ +import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import type { Context } from "@opencode/plugin/tui" +import { testRender } from "@opentui/solid" +import * as usageModule from "../src/usage.ts" +import type { UsageResult } from "../src/usage.ts" + +const POLL_MS = 60_000 +const CLOCK_MS = 30_000 + +type Deferred = { + promise: Promise + resolve: (value: Value) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: Value) => void + let reject!: (error: unknown) => void + const promise = new Promise((onValue, onError) => { + resolve = onValue + reject = onError + }) + + return { promise, resolve, reject } +} + +type Scheduled = { id: ReturnType; run: () => void; ms: number } + +const loads: Deferred[] = [] + +mock.module("../src/usage.ts", () => ({ + ...usageModule, + fetchUsage: () => { + const load = deferred() + loads.push(load) + return load.promise + }, +})) + +afterAll(() => { + mock.restore() +}) + +const { default: plugin } = await import("../tui.tsx") + +type Claim = { render: (input: { sessionID: string }) => unknown } + +function fakeHost() { + const claims: Claim[] = [] + let released = 0 + const context = { + theme: {}, + ui: { + slot: (claim: Claim) => { + claims.push(claim) + return () => { + released += 1 + } + }, + }, + } as unknown as Context + + return { + context, + claims, + released: () => released, + } +} + +async function startPlugin() { + const host = fakeHost() + const cleanup = await plugin.setup(host.context) + + return { ...host, cleanup: cleanup as (() => void) | undefined } +} + +async function mount(claim: Claim) { + const view = await testRender(() => claim.render({ sessionID: "session" }) as any, { width: 60, height: 10 }) + await view.flush() + return view +} + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function ready(percent: number): UsageResult { + return { + ok: true, + usage: { + rolling: { percent, resetsAt: new Date(Date.now() + 3_600_000).toISOString() }, + weekly: { percent: 10 }, + monthly: { percent: 5 }, + }, + } +} + +let schedules: Scheduled[] = [] +let cleared: Array> = [] +let intervalSpy: ReturnType +let clearSpy: ReturnType +let dateSpy: ReturnType | undefined + +const realSetInterval = globalThis.setInterval +const realClearInterval = globalThis.clearInterval + +beforeEach(() => { + loads.length = 0 + schedules = [] + cleared = [] + + intervalSpy = spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, ms?: number) => { + const id = realSetInterval(callback, ms) + schedules.push({ id, run: callback, ms: Number(ms) }) + return id + }) as typeof setInterval) + + clearSpy = spyOn(globalThis, "clearInterval").mockImplementation(((id: ReturnType) => { + cleared.push(id) + realClearInterval(id) + }) as typeof clearInterval) +}) + +afterEach(() => { + for (const entry of schedules) realClearInterval(entry.id) + dateSpy?.mockRestore() + dateSpy = undefined + clearSpy.mockRestore() + intervalSpy.mockRestore() +}) + +const polls = () => schedules.filter((entry) => entry.ms === POLL_MS) +const clocks = () => schedules.filter((entry) => entry.ms === CLOCK_MS) + +describe("panel lifecycle", () => { + test("keeps ready state and the in-flight cycle across a panel remount", async () => { + const host = await startPlugin() + expect(host.claims).toHaveLength(1) + + const first = await mount(host.claims[0]!) + expect(loads, "one load after the first panel mount").toHaveLength(1) + expect(first.captureCharFrame()).toContain("\u2026") + await first.dispose() + + expect(loads).toHaveLength(1) + + const second = await mount(host.claims[0]!) + expect(second.captureCharFrame()).toContain("\u2026") + expect(loads, "the panel remount must reuse the in-flight load").toHaveLength(1) + + loads[0]!.resolve(ready(42)) + await settle() + await second.flush() + + const frame = second.captureCharFrame() + expect(frame).toContain("42%") + expect(frame).toContain("10%") + expect(loads).toHaveLength(1) + + expect(polls()).toHaveLength(1) + expect(clocks()).toHaveLength(1) + + host.cleanup?.() + await second.dispose() + }) + + test("keeps error state across a panel remount without a new load", async () => { + const host = await startPlugin() + const first = await mount(host.claims[0]!) + + loads[0]!.resolve({ ok: false, error: "rate limited" }) + await settle() + await first.flush() + expect(first.captureCharFrame(), "error visible before the panel remount").toContain("rate limited") + + await first.dispose() + + const second = await mount(host.claims[0]!) + const frame = second.captureCharFrame() + expect(frame, "error retained after the panel remount").toContain("rate limited") + expect(loads).toHaveLength(1) + + expect(polls()).toHaveLength(1) + expect(clocks()).toHaveLength(1) + + host.cleanup?.() + await second.dispose() + }) + + test("keeps ready state across a remount and follows the setup-owned clock", async () => { + const host = await startPlugin() + const first = await mount(host.claims[0]!) + const base = Date.parse("2030-01-01T00:00:00Z") + + dateSpy = spyOn(Date, "now").mockReturnValue(base) + loads[0]!.resolve({ + ok: true, + usage: { + rolling: { percent: 42, resetsAt: new Date(base + 3_600_000).toISOString() }, + weekly: { percent: 10 }, + monthly: { percent: 5 }, + }, + }) + await settle() + await first.flush() + clocks()[0]!.run() + await settle() + await first.flush() + expect(first.captureCharFrame(), "usage visible before the panel remount").toContain("42%") + expect(first.captureCharFrame()).toContain("1h 0m") + await first.dispose() + + const second = await mount(host.claims[0]!) + expect(second.captureCharFrame(), "usage retained after the panel remount").toContain("42%") + expect(loads).toHaveLength(1) + expect(second.captureCharFrame()).toContain("1h 0m") + + dateSpy.mockReturnValue(base + 1_800_000) + clocks()[0]!.run() + await settle() + await second.flush() + expect(second.captureCharFrame()).toContain("30m") + + dateSpy.mockRestore() + dateSpy = undefined + host.cleanup?.() + await second.dispose() + }) + + test("cleanup stops timers, drops a late write and releases the slot", async () => { + const host = await startPlugin() + const view = await mount(host.claims[0]!) + expect(loads).toHaveLength(1) + expect(polls()).toHaveLength(1) + expect(clocks()).toHaveLength(1) + + host.cleanup?.() + + expect(host.released()).toBe(1) + expect(cleared).toContain(polls()[0]!.id) + expect(cleared).toContain(clocks()[0]!.id) + + // A stale poll callback must not start new work after cleanup. + polls()[0]!.run() + await settle() + expect(loads).toHaveLength(1) + + loads[0]!.resolve(ready(42)) + await settle() + await view.flush() + + const frame = view.captureCharFrame() + expect(frame).toContain("\u2026") + expect(frame).not.toContain("42%") + + await view.dispose() + }) +}) diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts new file mode 100644 index 0000000..5c05d82 --- /dev/null +++ b/tests/refresh.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "bun:test" +import { createRefresher, type RefreshOptions } from "../src/refresh.ts" + +type Deferred = { + promise: Promise + resolve: (value: Value) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: Value) => void + let reject!: (error: unknown) => void + const promise = new Promise((onValue, onError) => { + resolve = onValue + reject = onError + }) + + return { promise, resolve, reject } +} + +/** Records what a refresher reported, in order. */ +function recorder() { + const values: string[] = [] + const errors: string[] = [] + + return { values, errors } +} + +function refresherWith(load: () => Promise, timeoutMs: number, seen: ReturnType) { + const options: RefreshOptions = { + load, + timeoutMs, + onValue: (value) => seen.values.push(value), + onError: (message) => seen.errors.push(message), + } + + return createRefresher(options) +} + +describe("createRefresher", () => { + test("reports the value of a cycle that finishes in time", async () => { + const load = deferred() + const seen = recorder() + const refresher = refresherWith(() => load.promise, 1_000, seen) + + const cycle = refresher.refresh() + load.resolve("usage") + await cycle + + expect(seen.values).toEqual(["usage"]) + expect(seen.errors).toEqual([]) + }) + + test("reports a timeout when the cycle outlives the deadline", async () => { + const seen = recorder() + const refresher = refresherWith(() => new Promise(() => {}), 5, seen) + + await refresher.refresh() + + expect(seen.errors).toEqual(["request timed out"]) + expect(seen.values).toEqual([]) + }) + + test("drops a value that arrives after the deadline", async () => { + const load = deferred() + const seen = recorder() + const refresher = refresherWith(() => load.promise, 5, seen) + + await refresher.refresh() + load.resolve("late") + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(seen.values).toEqual([]) + expect(seen.errors).toEqual(["request timed out"]) + }) + + test("keeps the load running after the deadline and starts a new cycle", async () => { + const first = deferred() + const seen = recorder() + let loads = 0 + const refresher = refresherWith( + () => { + loads += 1 + return loads === 1 ? first.promise : Promise.resolve("second") + }, + 5, + seen, + ) + + await refresher.refresh() + await refresher.refresh() + + // The timed-out load was not cancelled, and its late value is dropped. + first.resolve("late") + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(loads).toBe(2) + expect(seen.errors).toEqual(["request timed out"]) + expect(seen.values).toEqual(["second"]) + }) + + test("starts a new cycle after a timeout", async () => { + const seen = recorder() + let loads = 0 + const refresher = refresherWith( + () => { + loads += 1 + return loads === 1 ? new Promise(() => {}) : Promise.resolve("second") + }, + 5, + seen, + ) + + await refresher.refresh() + await refresher.refresh() + + expect(loads).toBe(2) + expect(seen.errors).toEqual(["request timed out"]) + expect(seen.values).toEqual(["second"]) + }) + + test("keeps a second call from overlapping the cycle in flight", async () => { + const load = deferred() + const seen = recorder() + let loads = 0 + const refresher = refresherWith(() => { + loads += 1 + return load.promise + }, 1_000, seen) + + const cycle = refresher.refresh() + await refresher.refresh() + + expect(loads).toBe(1) + + load.resolve("usage") + await cycle + expect(seen.values).toEqual(["usage"]) + }) + + test("starts a new cycle once the previous one settles", async () => { + const seen = recorder() + let loads = 0 + const refresher = refresherWith(() => { + loads += 1 + return Promise.resolve(`usage ${loads}`) + }, 1_000, seen) + + await refresher.refresh() + await refresher.refresh() + + expect(loads).toBe(2) + expect(seen.values).toEqual(["usage 1", "usage 2"]) + }) + + test("reports an unexpected error when the load rejects", async () => { + const seen = recorder() + const refresher = refresherWith(() => Promise.reject(new Error("boom")), 1_000, seen) + + await refresher.refresh() + + expect(seen.errors).toEqual(["unexpected error"]) + expect(seen.values).toEqual([]) + }) + + test("does not start a load after disposal", async () => { + const seen = recorder() + let loads = 0 + const refresher = refresherWith(() => { + loads += 1 + return Promise.resolve("usage") + }, 1_000, seen) + + refresher.dispose() + await refresher.refresh() + + expect(loads).toBe(0) + expect(seen.values).toEqual([]) + expect(seen.errors).toEqual([]) + }) + + test("drops a value that arrives after disposal", async () => { + const load = deferred() + const seen = recorder() + const refresher = refresherWith(() => load.promise, 1_000, seen) + + const cycle = refresher.refresh() + refresher.dispose() + load.resolve("usage") + await cycle + + expect(seen.values).toEqual([]) + expect(seen.errors).toEqual([]) + }) + + test("drops an error that arrives after disposal", async () => { + const load = deferred() + const seen = recorder() + const refresher = refresherWith(() => load.promise, 1_000, seen) + + const cycle = refresher.refresh() + refresher.dispose() + load.reject(new Error("boom")) + await cycle + + expect(seen.values).toEqual([]) + expect(seen.errors).toEqual([]) + }) +}) diff --git a/tui.tsx b/tui.tsx index 6f73f75..ed8182e 100644 --- a/tui.tsx +++ b/tui.tsx @@ -3,11 +3,16 @@ * TUI entry: renders the OpenCode Go usage windows in the sidebar footer. * * See ../README.md for installation and behaviour details. + * + * State lives in `setup`, not in the component: unmounting and remounting a + * slot panel must keep the current usage state and the refresh cycle alive. + * The panel reads the signals through accessors. */ import { Plugin } from "@opencode/plugin/tui" import type { RGBA } from "@opentui/core" -import { createSignal, onCleanup, onMount, Show } from "solid-js" +import { createSignal, Show } from "solid-js" import { bar, remaining, severity, type Severity } from "./src/format.ts" +import { createRefresher } from "./src/refresh.ts" import { clampPercent, fetchUsage, type Usage, type UsageWindow, type UsageWindowKind } from "./src/usage.ts" const POLL_MS = 60_000 @@ -16,23 +21,6 @@ const CLOCK_MS = 30_000 /** Upper bound for a whole refresh cycle, credential lookup included. */ const REFRESH_TIMEOUT_MS = 15_000 -/** - * Race a promise against a deadline. Resolves `undefined` when the deadline - * wins, so the caller can tell a timeout apart from a real result. - */ -async function withDeadline(promise: Promise, ms: number): Promise { - let timer: ReturnType | undefined - const deadline = new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), ms) - }) - - try { - return await Promise.race([promise, deadline]) - } finally { - clearTimeout(timer) - } -} - const ROWS: { key: UsageWindowKind; label: string }[] = [ { key: "rolling", label: "5h" }, { key: "weekly", label: "7d" }, @@ -94,56 +82,18 @@ const Row = (props: { label: string; window: UsageWindow; now: number; palette: type Status = { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; usage: Usage } -const Panel = (props: { theme: unknown }) => { - const [status, setStatus] = createSignal({ kind: "loading" }) - const [now, setNow] = createSignal(Date.now()) +const Panel = (props: { theme: unknown; status: () => Status; now: () => number }) => { const palette = () => paletteOf(props.theme) - let disposed = false - let refreshing = false - - const refresh = async () => { - if (refreshing) return - refreshing = true - - try { - const result = await withDeadline(fetchUsage(), REFRESH_TIMEOUT_MS) - if (disposed) return - - if (!result) { - setStatus({ kind: "error", message: "request timed out" }) - return - } - - setStatus(result.ok ? { kind: "ready", usage: result.usage } : { kind: "error", message: result.error }) - } catch { - if (disposed) return - setStatus({ kind: "error", message: "unexpected error" }) - } finally { - refreshing = false - } - } - - onMount(() => { - refresh() - const poll = setInterval(refresh, POLL_MS) - const clock = setInterval(() => setNow(Date.now()), CLOCK_MS) - onCleanup(() => { - disposed = true - clearInterval(poll) - clearInterval(clock) - }) - }) - return ( Go usage - + {"\u2026"} { - const current = status() + const current = props.status() return current.kind === "error" ? current : undefined })()} keyed @@ -152,7 +102,7 @@ const Panel = (props: { theme: unknown }) => { { - const current = status() + const current = props.status() return current.kind === "ready" ? current.usage : undefined })()} keyed @@ -160,7 +110,7 @@ const Panel = (props: { theme: unknown }) => { {(usage) => ( {ROWS.map((row) => ( - + ))} )} @@ -172,9 +122,31 @@ const Panel = (props: { theme: unknown }) => { export default Plugin.define({ id: "go-usage-meter", setup(context) { - return context.ui.slot({ + const [status, setStatus] = createSignal({ kind: "loading" }) + const [now, setNow] = createSignal(Date.now()) + + const refresher = createRefresher({ + load: fetchUsage, + timeoutMs: REFRESH_TIMEOUT_MS, + onValue: (result) => + setStatus(result.ok ? { kind: "ready", usage: result.usage } : { kind: "error", message: result.error }), + onError: (message) => setStatus({ kind: "error", message }), + }) + + refresher.refresh() + const poll = setInterval(refresher.refresh, POLL_MS) + const clock = setInterval(() => setNow(Date.now()), CLOCK_MS) + + const release = context.ui.slot({ append: "sidebar.footer", - render: () => , + render: () => , }) + + return () => { + refresher.dispose() + clearInterval(poll) + clearInterval(clock) + release() + } }, })