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)