diff --git a/packages/ui/src/apps/MobileSessionMetadata.tsx b/packages/ui/src/apps/MobileSessionMetadata.tsx index 3d239d1f..f175b664 100644 --- a/packages/ui/src/apps/MobileSessionMetadata.tsx +++ b/packages/ui/src/apps/MobileSessionMetadata.tsx @@ -332,6 +332,8 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta ), ); const quotaResults = useQuotaStore((state) => state.results); + const quotaRefreshErrors = useQuotaStore((state) => state.refreshErrors); + const quotaRefreshAttempted = React.useRef(false); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); @@ -350,13 +352,20 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta }, [dropdownProviderIds]); React.useEffect(() => { - if (!open || isQuotaLoading) return; + if (!open) { + quotaRefreshAttempted.current = false; + return; + } + if (quotaRefreshAttempted.current || isQuotaLoading) return; const missingEnabledProvider = dropdownProviderIds.some((providerId) => ( - !quotaResults.some((result) => result.providerId === providerId) + !quotaResults.some((result) => result.providerId === providerId) || quotaRefreshErrors[providerId] )); if (!missingEnabledProvider) return; + // Trigger at most one attempt per opening. A failed first load remains + // unknown, not an empty result that can suppress retries. + quotaRefreshAttempted.current = true; void fetchAllQuotas(); - }, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]); + }, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults, quotaRefreshErrors]); const latestMessageModel = React.useMemo(() => { for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { diff --git a/packages/ui/src/components/sections/usage/UsagePage.tsx b/packages/ui/src/components/sections/usage/UsagePage.tsx index 679d73b1..a3dc7d0a 100644 --- a/packages/ui/src/components/sections/usage/UsagePage.tsx +++ b/packages/ui/src/components/sections/usage/UsagePage.tsx @@ -47,6 +47,7 @@ export const UsagePage: React.FC = () => { const isLoading = useQuotaStore((state) => state.isLoading); const lastUpdated = useQuotaStore((state) => state.lastUpdated); const error = useQuotaStore((state) => state.error); + const refreshErrors = useQuotaStore((state) => state.refreshErrors); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const setDropdownProviderIds = useQuotaStore((state) => state.setDropdownProviderIds); const selectedModels = useQuotaStore((state) => state.selectedModels); @@ -76,9 +77,14 @@ export const UsagePage: React.FC = () => { const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId); const providerName = providerMeta?.name ?? selectedProviderId ?? t('settings.usage.sidebar.title'); const usage = selectedResult?.usage; - const selectedProviderError = selectedResult?.configured && !selectedResult.ok - ? selectedResult.error - : null; + const refreshError = selectedProviderId ? refreshErrors[selectedProviderId] : undefined; + const selectedProviderError = refreshError + ? usage + ? t('header.services.usageRefreshFailedStale', { error: refreshError }) + : refreshError + : selectedResult?.configured && !selectedResult.ok + ? selectedResult.error + : null; const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false; const hasCredentialsForm = selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor'; const handleDropdownToggle = React.useCallback((enabled: boolean) => { @@ -190,7 +196,7 @@ export const UsagePage: React.FC = () => { {(error || selectedProviderError) && (

{t('settings.usage.page.state.refreshFailedTitle')}

-

{error ?? selectedProviderError}

+

{selectedProviderError ?? error}

)} diff --git a/packages/ui/src/components/sections/usage/UsageSidebar.tsx b/packages/ui/src/components/sections/usage/UsageSidebar.tsx index 126c7624..83d58a44 100644 --- a/packages/ui/src/components/sections/usage/UsageSidebar.tsx +++ b/packages/ui/src/components/sections/usage/UsageSidebar.tsx @@ -98,7 +98,7 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => { const percent = getUsagePercent(result?.usage); const tone = resolveUsageTone(percent); const isSelected = provider.id === selectedProviderId; - const configured = result?.configured ?? false; + const configured = result?.configured; const statusStyle = !configured ? { backgroundColor: 'var(--surface-muted-foreground)', opacity: 0.4 } @@ -129,7 +129,7 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => { {provider.name} - {!configured && ( + {configured === false && ( {t('settings.usage.sidebar.status.notSet')} )} diff --git a/packages/ui/src/components/usage/usageGroups.ts b/packages/ui/src/components/usage/usageGroups.ts index 68a85bce..774c003e 100644 --- a/packages/ui/src/components/usage/usageGroups.ts +++ b/packages/ui/src/components/usage/usageGroups.ts @@ -28,13 +28,13 @@ export type UsageProviderGroup = { * the two cannot drift on which providers appear, how model rows are filtered, * or what counts as a provider-level status. * - * Only providers the user put in the dropdown *and* that reported themselves as - * configured are included — an unconfigured provider has nothing to say, and - * listing it reads as a fault. + * Include selected, configured providers and first-load failures whose + * configuration is still unknown. Confirmed unconfigured providers stay hidden. */ export const useUsageProviderGroups = (): UsageProviderGroup[] => { const { t } = useI18n(); const quotaResults = useQuotaStore((state) => state.results); + const refreshErrors = useQuotaStore((state) => state.refreshErrors); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const selectedQuotaModels = useQuotaStore((state) => state.selectedModels); @@ -42,9 +42,12 @@ export const useUsageProviderGroups = (): UsageProviderGroup[] => { const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result])); return QUOTA_PROVIDERS .filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id)) - .filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true) + .filter((providerMeta) => { + const result = resultsByProvider.get(providerMeta.id); + return result?.configured === true || (!result && Boolean(refreshErrors[providerMeta.id])); + }) .map((providerMeta) => { - const result = resultsByProvider.get(providerMeta.id)!; + const result = resultsByProvider.get(providerMeta.id); const rows: UsageLimitRow[] = []; for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) { @@ -68,19 +71,25 @@ export const useUsageProviderGroups = (): UsageProviderGroup[] => { }); } - const status = !result.ok && result.error - ? result.error - : rows.length === 0 - ? t('header.services.noRateLimitsReported') - : null; + const refreshError = refreshErrors[providerMeta.id]; + let status: string | null = null; + if (refreshError) { + status = result?.usage + ? t('header.services.usageRefreshFailedStale', { error: refreshError }) + : refreshError; + } else if (!result?.ok && result?.error) { + status = result.error; + } else if (rows.length === 0) { + status = t('header.services.noRateLimitsReported'); + } return { providerId: providerMeta.id, providerName: providerMeta.name, - planLabel: result.planLabel, + planLabel: result?.planLabel, rows, status, }; }); - }, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]); + }, [dropdownProviderIds, quotaResults, refreshErrors, selectedQuotaModels, t]); }; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 403eae57..6acc77b3 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1577,6 +1577,7 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Ratenlimits aktualisieren', 'header.services.noRateLimits': 'Keine Ratenlimits verfügbar.', 'header.services.noRateLimitsReported': 'Keine Ratenlimits berichtet.', + 'header.services.usageRefreshFailedStale': 'Zuvor empfangene Nutzungsdaten werden angezeigt. Aktualisierung fehlgeschlagen: {error}', 'header.services.remoteUpdate.title': 'Remote-Instanz-Update', 'header.services.remoteUpdate.checking': 'Suche nach Updates...', 'header.services.remoteUpdate.upToDate': 'Diese Instanz ist auf dem neuesten Stand.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 12e501d4..ba5702fc 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1777,6 +1777,7 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Refresh rate limits', 'header.services.noRateLimits': 'No rate limits available.', 'header.services.noRateLimitsReported': 'No rate limits reported.', + 'header.services.usageRefreshFailedStale': 'Showing previously received usage. Refresh failed: {error}', 'header.services.remoteUpdate.title': 'Remote instance update', 'header.services.remoteUpdate.checking': 'Looking for updates...', 'header.services.remoteUpdate.upToDate': 'This instance is up to date.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index fd9ea62b..444c721c 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1755,6 +1755,7 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Actualizar límites de tasa", "header.services.noRateLimits": "No hay límites de tasa disponibles.", "header.services.noRateLimitsReported": "No se reportaron límites de tasa.", + "header.services.usageRefreshFailedStale": "Se muestran los datos de uso recibidos anteriormente. No se pudieron actualizar: {error}", "header.services.remoteUpdate.title": "Actualización de instancia remota", "header.services.remoteUpdate.checking": "Buscando actualizaciones...", "header.services.remoteUpdate.upToDate": "Esta instancia está actualizada.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 0531746c..55076e88 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1540,6 +1540,7 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Limites du taux de rafraîchissement', 'header.services.noRateLimits': 'Aucune limite de taux disponible.', 'header.services.noRateLimitsReported': 'Aucune limite de taux signalée.', + 'header.services.usageRefreshFailedStale': 'Les données d’utilisation précédentes sont affichées. Échec de l’actualisation : {error}', 'header.services.used': 'Utilisé', 'header.services.remaining': 'Restant', 'header.services.modelFamily.other': 'Autre', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 226ee92e..f13df2ef 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1773,6 +1773,7 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': 'レート制限を更新', 'header.services.noRateLimits': 'レート制限は利用できません。', 'header.services.noRateLimitsReported': 'レート制限は報告されていません。', + 'header.services.usageRefreshFailedStale': '以前に取得した使用状況を表示しています。更新に失敗しました: {error}', 'header.services.remoteUpdate.title': 'リモートインスタンスの更新', 'header.services.remoteUpdate.checking': '更新を確認中...', 'header.services.remoteUpdate.upToDate': 'このインスタンスは最新です。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 44eb539a..5d75be56 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1779,6 +1779,7 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': '레이트 리밋 새로고침', 'header.services.noRateLimits': '사용 가능한 레이트 리밋이 없습니다.', 'header.services.noRateLimitsReported': '보고된 레이트 리밋이 없습니다.', + 'header.services.usageRefreshFailedStale': '이전에 받은 사용량을 표시하고 있습니다. 새로고침 실패: {error}', 'header.services.remoteUpdate.title': '원격 인스턴스 업데이트', 'header.services.remoteUpdate.checking': '업데이트를 확인하는 중...', 'header.services.remoteUpdate.upToDate': '이 인스턴스는 최신 상태입니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c31f7159..cf344edf 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2539,6 +2539,7 @@ export const dict: Record = { 'header.services.modelFamily.other': 'Inne', 'header.services.noRateLimits': 'Brak dostępnych limitów użycia.', 'header.services.noRateLimitsReported': 'Nie zgłoszono limitów użycia.', + 'header.services.usageRefreshFailedStale': 'Wyświetlane są wcześniej otrzymane dane użycia. Odświeżanie nie powiodło się: {error}', 'header.services.remoteUpdate.title': 'Aktualizacja zdalnej instancji', 'header.services.remoteUpdate.checking': 'Sprawdzanie aktualizacji...', 'header.services.remoteUpdate.upToDate': 'Ta instancja jest aktualna.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 51a1b481..98fa9a9a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1755,6 +1755,7 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Atualizar limites de taxa", "header.services.noRateLimits": "Não há limites de taxa disponíveis.", "header.services.noRateLimitsReported": "Nenhum limite de taxa foi informado.", + "header.services.usageRefreshFailedStale": "Exibindo os dados de uso recebidos anteriormente. Falha ao atualizar: {error}", "header.services.remoteUpdate.title": "Atualização da instância remota", "header.services.remoteUpdate.checking": "Procurando atualizações...", "header.services.remoteUpdate.upToDate": "Esta instância está atualizada.", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index b1a356d1..a5920625 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -1739,6 +1739,7 @@ export const dict = { 'header.services.refreshRateLimitsAria': 'Rate limit\'leri yenile', 'header.services.noRateLimits': 'Kullanılabilir rate limit yok.', 'header.services.noRateLimitsReported': 'Bildirilen rate limit yok.', + 'header.services.usageRefreshFailedStale': 'Daha önce alınan kullanım verileri gösteriliyor. Yenileme başarısız: {error}', 'header.services.remoteUpdate.title': 'Uzak instance güncellemesi', 'header.services.remoteUpdate.checking': 'Güncellemeler aranıyor...', 'header.services.remoteUpdate.upToDate': 'Bu instance güncel.', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 45d038be..ce537831 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1755,6 +1755,7 @@ export const dict: Record = { "header.services.refreshRateLimitsAria": "Оновити ліміти запитів", "header.services.noRateLimits": "Ліміти запитів недоступні.", "header.services.noRateLimitsReported": "Ліміти запитів не надходять.", + "header.services.usageRefreshFailedStale": "Показано раніше отримані дані використання. Не вдалося оновити: {error}", "header.services.remoteUpdate.title": "Оновлення віддаленого інстанса", "header.services.remoteUpdate.checking": "Шукаємо оновлення...", "header.services.remoteUpdate.upToDate": "Цей інстанс уже оновлений.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 774be72d..98628f3e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1743,6 +1743,7 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': '刷新速率限制', 'header.services.noRateLimits': '没有可用的速率限制。', 'header.services.noRateLimitsReported': '未上报速率限制。', + 'header.services.usageRefreshFailedStale': '正在显示之前获取的用量数据。刷新失败:{error}', 'header.services.remoteUpdate.title': '远程实例更新', 'header.services.remoteUpdate.checking': '正在检查更新...', 'header.services.remoteUpdate.upToDate': '此实例已是最新。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index e667260d..781beb4f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1753,6 +1753,7 @@ export const dict: Record = { 'header.services.refreshRateLimitsAria': '重新整理速率限制', 'header.services.noRateLimits': '沒有可用的速率限制。', 'header.services.noRateLimitsReported': '未報告速率限制。', + 'header.services.usageRefreshFailedStale': '正在顯示先前取得的用量資料。重新整理失敗:{error}', 'header.services.used': '已用', 'header.services.remaining': '剩餘', 'header.services.modelFamily.other': '其他', diff --git a/packages/ui/src/lib/quota/fetchQuota.ts b/packages/ui/src/lib/quota/fetchQuota.ts new file mode 100644 index 00000000..462fab65 --- /dev/null +++ b/packages/ui/src/lib/quota/fetchQuota.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import type { ProviderResult, QuotaProviderId } from '@/types'; +import { runtimeFetch } from '@/lib/runtime-fetch'; + +const windowSchema = z.object({ + usedPercent: z.number().nullable(), + remainingPercent: z.number().nullable(), + windowSeconds: z.number().nullable(), + resetAfterSeconds: z.number().nullable(), + resetAt: z.number().nullable(), + resetAtFormatted: z.string().nullable(), + resetAfterFormatted: z.string().nullable(), + valueLabel: z.string().nullable().optional(), +}); +const windowsSchema = z.record(z.string(), windowSchema); + +/** The deadline covers response bodies too, including transports that ignore abort. */ +export const fetchQuota = async ( + providerId: QuotaProviderId, + { signal, timeoutMs = 30_000 }: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise => { + const controller = new AbortController(); + const abort = () => controller.abort(new DOMException('The operation was aborted.', 'AbortError')); + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => controller.abort(new DOMException('Quota request timed out', 'TimeoutError')), timeoutMs); + let rejectAborted: () => void = () => {}; + const aborted = new Promise((_resolve, reject) => { + rejectAborted = () => reject(controller.signal.reason); + if (controller.signal.aborted) rejectAborted(); + else controller.signal.addEventListener('abort', rejectAborted, { once: true }); + }); + const readResult = async () => { + controller.signal.throwIfAborted(); + const response = await runtimeFetch(`/api/quota/${encodeURIComponent(providerId)}`, { signal: controller.signal }); + const payload = await response.json(); + if (!response.ok) { + const failure = z.object({ error: z.string() }).safeParse(payload); + throw new Error(failure.success ? failure.data.error : `Failed to fetch quota (${response.status})`); + } + return z.object({ + providerId: z.literal(providerId), + providerName: z.string(), + ok: z.boolean(), + configured: z.boolean(), + error: z.string().optional(), + planLabel: z.string().nullable().optional(), + usage: z.object({ windows: windowsSchema, models: z.record(z.string(), z.object({ windows: windowsSchema })).optional() }).nullable(), + fetchedAt: z.number(), + }).parse(payload); + }; + try { + return await Promise.race([readResult(), aborted]); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + controller.signal.removeEventListener('abort', rejectAborted); + } +}; diff --git a/packages/ui/src/lib/relay/tunnel-client.test.ts b/packages/ui/src/lib/relay/tunnel-client.test.ts index 4616c7ad..dee41af5 100644 --- a/packages/ui/src/lib/relay/tunnel-client.test.ts +++ b/packages/ui/src/lib/relay/tunnel-client.test.ts @@ -10,7 +10,7 @@ import { type FrameEncryptor, } from './crypto'; import { createHostHandshake } from './handshake'; -import { TunnelFrameType } from './protocol'; +import { RelayCloseCode, TunnelFrameType } from './protocol'; import { isAmbiguousTransportFailure } from './transport-error'; import { createFragmentAssembler, @@ -244,7 +244,7 @@ const setupClient = async ( ): Promise<{ client: RelayTunnelClient; connectionCount: () => number; - killWire: () => void; + killWire: (code?: number) => void; sendTextToClient: (text: string) => void; clientBinaryCount: () => number; }> => { @@ -279,7 +279,7 @@ const setupClient = async ( return { client, connectionCount: () => count, - killWire: () => lastClientEndpoint?.close(1006, 'killed'), + killWire: (code = 1006) => lastClientEndpoint?.close(code, 'killed'), sendTextToClient: (text: string) => lastHostEndpoint?.send(text), clientBinaryCount: () => lastClientEndpoint?.binarySent ?? 0, }; @@ -438,6 +438,55 @@ describe('createRelayTunnelClient', () => { expect(['reconnecting', 'connecting', 'connected', 'error']).toContain(status.state); }); + test('outbound retries cannot hide a silent peer', async () => { + const { client } = await setupClient({ silent: true }, { + batch: false, pingTimeoutMs: 40, reconnectBaseDelayMs: 2000, reconnectMaxDelayMs: 2000, + }); + track(client); + let requests = 0; + const timer = setInterval(() => { + requests++; + void client.fetch('/health').catch(() => undefined); + }, 10); + try { + await wait(250); + expect(requests).toBeGreaterThan(10); + expect(client.getStatus()).toEqual({ state: 'reconnecting', lastError: 'relay keepalive timeout' }); + } finally { + clearInterval(timer); + } + }); + + test('continuing inbound stream data stays healthy without idle pings', async () => { + const frames: TunnelFrame[] = []; + const { client, connectionCount } = await setupClient({ recordFrame: frame => frames.push(frame) }, { batchWindowMs: 5 }); + track(client); + const response = await client.fetch('/never-ends'); + await wait(250); + expect(connectionCount()).toBe(1); + expect(client.getStatus().state).toBe('connected'); + expect(frames.some(frame => frame.frameType === TunnelFrameType.Ping)).toBe(false); + await response.body?.cancel(); + }); + + for (const code of [RelayCloseCode.AuthFailed, RelayCloseCode.DuplicateClient, RelayCloseCode.LimitExceeded]) { + test(`terminal relay rejection ${code} rejects subsequent HTTP and WS opens`, async () => { + const { client, killWire, connectionCount } = await setupClient(); + track(client); + await client.fetch('/health'); + killWire(code); + await wait(10); + expect(client.getStatus().state).toBe('error'); + const reason = client.getStatus().lastError; + await expect(client.fetch('/health')).rejects.toThrow(reason); + const socket = client.openWebSocket('/api/terminal/ws'); + const closed = await new Promise(resolve => { socket.onclose = event => resolve(event.reason); }); + expect(closed).toBe(reason); + await wait(100); + expect(connectionCount()).toBe(1); + }); + } + test('survives duplicate ready frames from a slow first handshake (first-request 500 regression)', async () => { // firstHelloDelayMs > helloRetryMs (20ms): the client retries `hello` // several times, and the host answers every retry with `ready`. The diff --git a/packages/ui/src/lib/relay/tunnel-client.ts b/packages/ui/src/lib/relay/tunnel-client.ts index 8f55a743..53eaeae1 100644 --- a/packages/ui/src/lib/relay/tunnel-client.ts +++ b/packages/ui/src/lib/relay/tunnel-client.ts @@ -29,7 +29,6 @@ import { type OutboundFrameBatcher, type TunnelFrame, } from './tunnel-codec'; -import { TUNNEL_FRAGMENT_FLAG } from './protocol'; import { isHttpResponsePayload, isStreamAbortPayload, @@ -237,6 +236,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela const createWire = options.createWireSocket ?? ((url: string) => wrapNativeWebSocket(new WebSocket(url))); let closed = false; + let terminalError: Error | null = null; let status: RelayTunnelStatus = { state: 'idle' }; // Plain listener set — status must not fan out through shared stores. const statusListeners = new Set<(next: RelayTunnelStatus) => void>(); @@ -388,9 +388,9 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela let acknowledgedBytes = 0; let ackTimer: ReturnType | null = null; let batcher: OutboundFrameBatcher | null = null; - // Idle tracking: updated on any non-Ping/Pong frame in EITHER direction. - // Ping/Pong are excluded so the keepalive can't sustain itself. - let lastActivityAt = Date.now(); + // Only received frames prove peer liveness. Outbound retries may continue + // indefinitely on a half-open socket and must not suppress the probe. + let lastReceivedAt = Date.now(); const cleanupTimers = (): void => { if (ackTimer !== null) { @@ -423,6 +423,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela function failAttemptLocal(error: Error, asErrorState = false, terminal = false): void { if (settled || generation !== attemptGeneration) return; settled = true; + if (terminal) terminalError = error; cleanupTimers(); if (channel) { activeChannel = null; @@ -497,10 +498,6 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela dead: false, send(frame: Uint8Array): void { if (channelObj.dead) return; - const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG; - if (frameType !== TunnelFrameType.Ping && frameType !== TunnelFrameType.Pong && frameType !== TunnelFrameType.DeliveryAck) { - lastActivityAt = Date.now(); - } if (localBatcher) localBatcher.enqueue(frame); else sendEncryptedPlaintext(frame); }, @@ -508,14 +505,13 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela channel = channelObj; activeChannel = channelObj; consecutiveFailures = 0; - lastActivityAt = Date.now(); + lastReceivedAt = Date.now(); setStatus({ state: 'connected' }); resolveWaiters(channelObj); pingTimer = setInterval(() => { const now = Date.now(); - // Only ping when the tunnel has actually been idle; streaming traffic - // keeps lastActivityAt fresh, so sustained bursts send zero pings. - if (now - lastActivityAt < pingIntervalMs) return; + // Slow-but-progressing inbound traffic is healthy, even without Pongs. + if (now - lastReceivedAt < pingIntervalMs) return; channelObj.send(encodeTunnelFrame(TunnelFrameType.Ping, 0, EMPTY_PAYLOAD)); // Expect a Pong (or any frame) before the deadline; otherwise it's dead. if (pongDeadline === null) { @@ -553,6 +549,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela if (ackTimer === null) ackTimer = setTimeout(acknowledgeDelivery, 10); } // Any received frame proves the tunnel is alive — clear the pong deadline. + lastReceivedAt = Date.now(); if (pongDeadline !== null) { clearTimeout(pongDeadline); pongDeadline = null; @@ -562,8 +559,6 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela return; } if (frame.frameType === TunnelFrameType.Pong) return; - // Non-keepalive inbound traffic counts as activity (suppresses our ping). - lastActivityAt = Date.now(); let payload = frame.payload; if (frame.frameType === TunnelFrameType.WsText || frame.frameType === TunnelFrameType.WsBinary) { @@ -685,6 +680,7 @@ export const createRelayTunnelClient = (options: RelayTunnelClientOptions): Rela const waitForChannel = (signal?: AbortSignal): Promise => { if (closed) return Promise.reject(new Error('relay tunnel closed')); if (signal?.aborted) return Promise.reject(abortError()); + if (terminalError) return Promise.reject(terminalError); if (activeChannel && !activeChannel.dead) return Promise.resolve(activeChannel); return new Promise((resolve, reject) => { let onAbort: (() => void) | null = null; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 3205e2f9..130e5b7b 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -29,6 +29,17 @@ These are the most performance-sensitive. These stores act like centralized keyed caches. UI should consume narrow slices from them instead of re-fetching the same data in multiple places. +`useQuotaStore` keeps the last authoritative provider results separately from +`refreshErrors`. Transport failures and configured-provider errors preserve the +last usage sample and its timestamp. An explicit unconfigured response replaces +old configuration; a first-load transport failure leaves it unknown. Concurrent +refreshes share one request per provider. Runtime reset aborts those requests, +and generation checks prevent their completions from changing the next runtime. +`lib/quota/fetchQuota.ts` validates response payloads and bounds the complete +request, including JSON body delivery. Compact usage cards and Settings display +refresh errors alongside retained data. The mobile popover makes at most one +refresh attempt per opening, so a failed first load cannot create a retry loop. + ### UI state stores Examples: diff --git a/packages/ui/src/stores/useQuotaStore.refresh.test.ts b/packages/ui/src/stores/useQuotaStore.refresh.test.ts new file mode 100644 index 00000000..76648f06 --- /dev/null +++ b/packages/ui/src/stores/useQuotaStore.refresh.test.ts @@ -0,0 +1,163 @@ +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import type { ProviderResult, QuotaProviderId } from '@/types'; +import { fetchQuota } from '@/lib/quota/fetchQuota'; +import { useQuotaStore } from './useQuotaStore'; + +const result = (providerId: QuotaProviderId = 'claude'): ProviderResult => ({ + providerId, providerName: providerId, ok: true, configured: true, fetchedAt: 123, + usage: { windows: { session: { + usedPercent: 42, remainingPercent: 58, windowSeconds: 18000, + resetAfterSeconds: 100, resetAt: 1000, resetAtFormatted: null, resetAfterFormatted: null, + } } }, +}); +const json = (body: ProviderResult) => Response.json(body); +const pause = () => new Promise(resolve => setTimeout(resolve, 5)); +const deferredResponse = () => { + let complete: ((response: Response) => void) | undefined; + const promise = new Promise(resolve => { complete = resolve; }); + return { promise, complete: (response: Response) => complete?.(response) }; +}; + +let handleRequest: (url: string, signal?: AbortSignal | null) => Promise; +const network = spyOn(globalThis, 'fetch'); + +beforeEach(() => { + useQuotaStore.getState().resetForRuntimeSwitch(); + handleRequest = async () => json(result()); + network.mockImplementation((input, init) => handleRequest(input.toString(), init?.signal)); +}); +afterEach(() => { + useQuotaStore.getState().resetForRuntimeSwitch(); + network.mockReset(); +}); +afterAll(() => network.mockRestore()); + +describe('quota refresh failure is not empty success', () => { + test('keeps the exact previous snapshot, configuration and timestamp on network failure', async () => { + await useQuotaStore.getState().fetchQuotas(['claude']); + const before = useQuotaStore.getState(); + handleRequest = async () => { throw new Error('network down'); }; + expect(await useQuotaStore.getState().fetchQuotas(['claude'])).toBe(false); + const after = useQuotaStore.getState(); + expect(after.results).toBe(before.results); + expect(after.results[0].configured).toBe(true); + expect(after.results[0].usage?.windows.session.usedPercent).toBe(42); + expect(after.lastUpdated).toBe(before.lastUpdated); + expect(after.refreshErrors.claude).toBe('network down'); + expect(after.isLoading).toBe(false); + }); + + test('a first-load failure leaves provider configuration unknown', async () => { + handleRequest = async () => { throw new Error('offline'); }; + await useQuotaStore.getState().fetchProviderQuota('claude'); + expect(useQuotaStore.getState().results).toEqual([]); + expect(useQuotaStore.getState().refreshErrors.claude).toBe('offline'); + expect(useQuotaStore.getState().lastUpdated).toBeNull(); + }); + + test('another provider succeeding does not clear a failed provider or its error', async () => { + await useQuotaStore.getState().fetchProviderQuota('claude'); + handleRequest = async url => { + if (url.endsWith('/claude')) throw new Error('claude unreachable'); + await pause(); + return json(result('codex')); + }; + expect(await useQuotaStore.getState().fetchQuotas(['claude', 'codex'])).toBe(true); + expect(useQuotaStore.getState().results).toHaveLength(2); + expect(useQuotaStore.getState().refreshErrors).toEqual({ claude: 'claude unreachable' }); + expect(useQuotaStore.getState().error).toBe('claude unreachable'); + handleRequest = async () => json(result()); + await useQuotaStore.getState().fetchProviderQuota('claude'); + expect(useQuotaStore.getState().refreshErrors).toEqual({}); + expect(useQuotaStore.getState().error).toBeNull(); + }); + + test('concurrent refreshes share one provider request', async () => { + const reply = deferredResponse(); + handleRequest = () => reply.promise; + const first = useQuotaStore.getState().fetchProviderQuota('claude'); + const second = useQuotaStore.getState().fetchProviderQuota('claude'); + await pause(); + expect(network.mock.calls).toHaveLength(1); + expect(useQuotaStore.getState().isLoading).toBe(true); + reply.complete(json(result())); + expect(await Promise.all([first, second])).toEqual([true, true]); + expect(useQuotaStore.getState().isLoading).toBe(false); + }); + + test('a runtime reset aborts old work without clearing the new request or its loading state', async () => { + const oldReply = deferredResponse(); + let oldSignal: AbortSignal | null | undefined; + handleRequest = (_url, signal) => { oldSignal = signal; return oldReply.promise; }; + const old = useQuotaStore.getState().fetchProviderQuota('claude'); + await pause(); + useQuotaStore.getState().resetForRuntimeSwitch(); + const newReply = deferredResponse(); + handleRequest = () => newReply.promise; + const current = useQuotaStore.getState().fetchProviderQuota('claude'); + expect(await old).toBe(false); + expect(oldSignal?.aborted).toBe(true); + expect(useQuotaStore.getState().isLoading).toBe(true); + oldReply.complete(json(result())); + await pause(); + expect(useQuotaStore.getState().results).toEqual([]); + newReply.complete(json({ ...result(), fetchedAt: 456 })); + expect(await current).toBe(true); + expect(useQuotaStore.getState().results[0].fetchedAt).toBe(456); + expect(useQuotaStore.getState().refreshErrors).toEqual({}); + }); + + test('malformed success payloads preserve previous data', async () => { + await useQuotaStore.getState().fetchProviderQuota('claude'); + const previous = useQuotaStore.getState().results; + for (const payload of [null, {}, result('codex')]) { + handleRequest = async () => Response.json(payload); + expect(await useQuotaStore.getState().fetchProviderQuota('claude')).toBe(false); + expect(useQuotaStore.getState().results).toBe(previous); + } + }); + + test('authoritative unconfigured success replaces old configuration', async () => { + await useQuotaStore.getState().fetchProviderQuota('claude'); + handleRequest = async () => json({ ...result(), configured: false, usage: null }); + expect(await useQuotaStore.getState().fetchProviderQuota('claude')).toBe(true); + expect(useQuotaStore.getState().results[0].configured).toBe(false); + expect(useQuotaStore.getState().results[0].usage).toBeNull(); + }); + + test('a provider failure reported inside HTTP 200 also preserves the last usage sample', async () => { + await useQuotaStore.getState().fetchProviderQuota('claude'); + const before = useQuotaStore.getState(); + handleRequest = async () => json({ ...result(), ok: false, usage: null, error: 'Provider API unavailable', fetchedAt: 456 }); + // The instance answered, even though its provider did not. + expect(await useQuotaStore.getState().fetchProviderQuota('claude')).toBe(true); + expect(useQuotaStore.getState().results).toBe(before.results); + expect(useQuotaStore.getState().lastUpdated).toBe(before.lastUpdated); + expect(useQuotaStore.getState().refreshErrors.claude).toBe('Provider API unavailable'); + }); +}); + +describe('quota request deadline', () => { + test('bounds a transport that never returns headers, even if it ignores abort', async () => { + let signal: AbortSignal | null | undefined; + const pending = deferredResponse(); + handleRequest = (_url, nextSignal) => { signal = nextSignal; return pending.promise; }; + await expect(fetchQuota('claude', { timeoutMs: 15 })).rejects.toThrow('Quota request timed out'); + expect(signal?.aborted).toBe(true); + pending.complete(json(result())); + }); + + test('the deadline includes an unfinished JSON response body', async () => { + let controller: ReadableStreamDefaultController | undefined; + handleRequest = async () => new Response(new ReadableStream({ start(next) { controller = next; } })); + await expect(fetchQuota('claude', { timeoutMs: 15 })).rejects.toThrow('Quota request timed out'); + controller?.error(new Error('fixture cleanup')); + }); + + test('a pre-aborted request never reaches the network', async () => { + const controller = new AbortController(); + controller.abort(); + await expect(fetchQuota('claude', { signal: controller.signal })).rejects.toThrow('aborted'); + expect(network.mock.calls).toHaveLength(0); + }); +}); diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index 1e4c0bb3..33c16be3 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -6,7 +6,7 @@ import { QUOTA_PROVIDERS } from '@/lib/quota'; import type { DesktopSettings } from '@/lib/desktop'; import { getDefaultModels } from '@/lib/quota/model-families'; import { loadDesktopSettings, updateDesktopSettings } from '@/lib/persistence'; -import { runtimeFetch } from '@/lib/runtime-fetch'; +import { fetchQuota } from '@/lib/quota/fetchQuota'; import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -16,6 +16,7 @@ const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000; // response in flight for the previous instance cannot land in the new one. let quotaGeneration = 0; let inFlightRuntimeLoad: Promise | null = null; +const quotaRequests = new Map }>(); let quotaAutoRefreshConsumers = 0; let quotaAutoRefreshInterval: number | null = null; @@ -35,6 +36,8 @@ interface QuotaStore extends QuotaSettingsState { isFetchingProvider: Record; lastUpdated: number | null; error: string | null; + /** Refresh failures are not authoritative provider configuration or usage. */ + refreshErrors: Partial>; loadSettings: () => Promise; fetchAllQuotas: () => Promise; @@ -105,6 +108,7 @@ export const useQuotaStore = create()( isFetchingProvider: {}, lastUpdated: null, error: null, + refreshErrors: {}, displayMode: 'usage', dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id), selectedModels: {}, @@ -123,21 +127,16 @@ export const useQuotaStore = create()( fetchQuotas: async (providerIds) => { const generation = quotaGeneration; - set({ isLoading: true, error: null }); try { const answered = await Promise.all( providerIds.map((providerId) => get().fetchProviderQuota(providerId)) ); if (generation !== quotaGeneration) return false; - set({ - isLoading: false, - lastUpdated: Date.now() - }); return answered.some(Boolean); } catch (error) { if (generation !== quotaGeneration) return false; const message = error instanceof Error ? error.message : 'Failed to fetch quotas'; - set({ isLoading: false, error: message }); + set({ error: message }); return false; } }, @@ -147,50 +146,53 @@ export const useQuotaStore = create()( }, fetchProviderQuota: async (providerId) => { + const existing = quotaRequests.get(providerId); + if (existing) return existing.promise; const generation = quotaGeneration; - set((state) => ({ - isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true } - })); - try { - const response = await runtimeFetch(`/api/quota/${encodeURIComponent(providerId)}`); - const payload = await response.json().catch(() => null); - if (!response.ok) { - throw new Error(payload?.error || 'Failed to fetch quota'); + const controller = new AbortController(); + const promise = Promise.resolve().then(async () => { + try { + const result = await fetchQuota(providerId, { signal: controller.signal }); + if (generation !== quotaGeneration) return false; + // A reachable instance can still report that its provider request + // failed. Configuration is known, but there is no new usage sample. + if (!result.ok && result.configured) { + const message = result.error || 'Failed to fetch quota'; + set(state => { + const previous = state.results.find(entry => entry.providerId === providerId); + const results = previous?.configured + ? state.results + : [...state.results.filter(entry => entry.providerId !== providerId), result]; + return { results, refreshErrors: { ...state.refreshErrors, [providerId]: message }, error: message }; + }); + return true; + } + set((state) => { + const refreshErrors = { ...state.refreshErrors }; + delete refreshErrors[providerId]; + const results = state.results.filter(entry => entry.providerId !== providerId); + results.push(result); + return { results, refreshErrors, error: Object.values(refreshErrors)[0] ?? null, lastUpdated: Date.now() }; + }); + return true; + } catch (error) { + if (generation !== quotaGeneration) return false; + const message = error instanceof Error ? error.message : 'Failed to fetch quota'; + set(state => ({ refreshErrors: { ...state.refreshErrors, [providerId]: message }, error: message })); + return false; + } finally { + if (quotaRequests.get(providerId)?.controller === controller) quotaRequests.delete(providerId); + if (generation === quotaGeneration) { + set((state) => ({ + isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false }, + isLoading: quotaRequests.size > 0, + })); + } } - - if (generation !== quotaGeneration) return false; - const result = payload as ProviderResult; - set((state) => { - const next = state.results.filter((entry) => entry.providerId !== providerId); - next.push(result); - return { results: next, error: null }; - }); - return true; - } catch (error) { - if (generation !== quotaGeneration) return false; - const message = error instanceof Error ? error.message : 'Failed to fetch quota'; - const fallback: ProviderResult = { - providerId, - providerName: providerId, - ok: false, - configured: false, - error: message, - usage: null, - fetchedAt: Date.now() - }; - set((state) => { - const next = state.results.filter((entry) => entry.providerId !== providerId); - next.push(fallback); - return { results: next, error: message }; - }); - return false; - } finally { - if (generation === quotaGeneration) { - set((state) => ({ - isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false } - })); - } - } + }); + quotaRequests.set(providerId, { controller, promise }); + set(state => ({ isLoading: true, isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true } })); + return promise; }, ensureLoadedForRuntime: async () => { @@ -215,13 +217,15 @@ export const useQuotaStore = create()( // instance was never attempted again — Usage would stay empty until // the three-minute refresh, or forever after a switch. if (answered && generation === quotaGeneration) set({ loadedRuntimeKey: runtimeKey }); - })().finally(() => { inFlightRuntimeLoad = null; }); + })().finally(() => { if (generation === quotaGeneration) inFlightRuntimeLoad = null; }); return inFlightRuntimeLoad; }, resetForRuntimeSwitch: () => { quotaGeneration += 1; + for (const request of quotaRequests.values()) request.controller.abort(); + quotaRequests.clear(); inFlightRuntimeLoad = null; set({ // Display mode, the provider selection and the per-provider model @@ -236,6 +240,7 @@ export const useQuotaStore = create()( isFetchingProvider: {}, lastUpdated: null, error: null, + refreshErrors: {}, }); }, diff --git a/packages/web/server/lib/relay/DOCUMENTATION.md b/packages/web/server/lib/relay/DOCUMENTATION.md index 0049079e..bb54e6e3 100644 --- a/packages/web/server/lib/relay/DOCUMENTATION.md +++ b/packages/web/server/lib/relay/DOCUMENTATION.md @@ -135,6 +135,13 @@ The E2EE and framing logic exists twice: TypeScript in `packages/ui/src/lib/rela ## Runtime integration (client) +Client keepalive liveness comes from received frames only. Outbound HTTP retries +cannot suppress a probe of a silent peer. Any valid inbound traffic, including a +Pong, clears the probe deadline. Terminal relay close codes retain their error +for the lifetime of that client: subsequent HTTP requests and WS opens fail +immediately rather than waiting for a reconnect that will never be scheduled. +Transient failures still use the existing reconnect/backoff path. + Relay mode plugs into the existing client transport layer rather than a parallel path: `runtime-switch` activates the tunnel singleton, `runtime-fetch` routes runtime requests through it, `runtime-url`/`runtime-socket` yield tunnel-backed URLs and sockets, and `runtime-auth` mints the URL-scoped token through the tunnel. Direct-URL connections and the Electron realtime-proxy path are unaffected. ## Design invariants (do not regress)