From 04167cd7e6b9dea07ce399683f92328fecafc991 Mon Sep 17 00:00:00 2001 From: PentSec <11955573+PentSec@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:33:23 -0400 Subject: [PATCH] fix(tui): bound the refresh cycle and stop overlapping polls The cause of the stuck loading state reported in #1 is unconfirmed. This bounds the refresh cycle (credential lookup included) at 15s, prevents overlapping polls, and reports unexpected throws, so any async stall or throw ends in a visible terminal state. - race the whole fetchUsage() cycle against a REFRESH_TIMEOUT_MS deadline and render "request timed out" when the deadline wins - skip a poll while the previous cycle is still in flight, so a slow cycle cannot queue another one every 60s - report an unexpected throw as "unexpected error" without leaking internals, instead of leaving the row on the loading ellipsis - keep the disposed flag authoritative: no status write after unmount Builds on the Show/keyed markup from #1 (saihgupr), which is unchanged here. --- tui.tsx | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/tui.tsx b/tui.tsx index 8cc2782..6f73f75 100644 --- a/tui.tsx +++ b/tui.tsx @@ -13,6 +13,26 @@ import { clampPercent, fetchUsage, type Usage, type UsageWindow, type UsageWindo const POLL_MS = 60_000 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" }, @@ -80,15 +100,27 @@ const Panel = (props: { theme: unknown }) => { const palette = () => paletteOf(props.theme) let disposed = false + let refreshing = false const refresh = async () => { + if (refreshing) return + refreshing = true + try { - const result = await fetchUsage() + 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 (error) { + } catch { if (disposed) return - setStatus({ kind: "error", message: String(error).slice(0, 80) }) + setStatus({ kind: "error", message: "unexpected error" }) + } finally { + refreshing = false } }