From cc25e58b556aeccd1d1514f2af731cffc8abb98c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 9 Sep 2026 08:01:31 +0300 Subject: [PATCH] fix(relay): handle dead connections and preserve usage on refresh failure Use received frames for peer liveness and reject new requests after terminal relay failures. Keep authoritative usage snapshots separate from refresh errors, bound request lifetime, and guard concurrent refreshes and runtime switches. Add regression coverage for relay liveness, terminal rejections, quota failures, cancellation and request deadlines. Validated with 51 focused tests, workspace type-check and lint, web build and mobile assets. --- .../ui/src/apps/MobileSessionMetadata.tsx | 15 +- .../components/sections/usage/UsagePage.tsx | 14 +- .../sections/usage/UsageSidebar.tsx | 4 +- .../ui/src/components/usage/usageGroups.ts | 33 ++-- packages/ui/src/lib/i18n/messages/de.ts | 1 + packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/tr.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + packages/ui/src/lib/quota/fetchQuota.ts | 59 +++++++ .../ui/src/lib/relay/tunnel-client.test.ts | 55 +++++- packages/ui/src/lib/relay/tunnel-client.ts | 24 ++- packages/ui/src/stores/DOCUMENTATION.md | 11 ++ .../src/stores/useQuotaStore.refresh.test.ts | 163 ++++++++++++++++++ packages/ui/src/stores/useQuotaStore.ts | 105 +++++------ .../web/server/lib/relay/DOCUMENTATION.md | 7 + 23 files changed, 414 insertions(+), 88 deletions(-) create mode 100644 packages/ui/src/lib/quota/fetchQuota.ts create mode 100644 packages/ui/src/stores/useQuotaStore.refresh.test.ts 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)