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.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) && (
|
||||
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
||||
<p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p>
|
||||
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error ?? selectedProviderError}</p>
|
||||
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{selectedProviderError ?? error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ 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<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
|
||||
{provider.name}
|
||||
</span>
|
||||
{!configured && (
|
||||
{configured === false && (
|
||||
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">{t('settings.usage.sidebar.status.notSet')}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -1755,6 +1755,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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.",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1773,6 +1773,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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': 'このインスタンスは最新です。',
|
||||
|
||||
@@ -1779,6 +1779,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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': '이 인스턴스는 최신 상태입니다.',
|
||||
|
||||
@@ -2539,6 +2539,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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.',
|
||||
|
||||
@@ -1755,6 +1755,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -1755,6 +1755,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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": "Цей інстанс уже оновлений.",
|
||||
|
||||
@@ -1743,6 +1743,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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': '此实例已是最新。',
|
||||
|
||||
@@ -1753,6 +1753,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'header.services.refreshRateLimitsAria': '重新整理速率限制',
|
||||
'header.services.noRateLimits': '沒有可用的速率限制。',
|
||||
'header.services.noRateLimitsReported': '未報告速率限制。',
|
||||
'header.services.usageRefreshFailedStale': '正在顯示先前取得的用量資料。重新整理失敗:{error}',
|
||||
'header.services.used': '已用',
|
||||
'header.services.remaining': '剩餘',
|
||||
'header.services.modelFamily.other': '其他',
|
||||
|
||||
@@ -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<ProviderResult> => {
|
||||
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<never>((_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);
|
||||
}
|
||||
};
|
||||
@@ -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<string>(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
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<ActiveChannel> => {
|
||||
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<ActiveChannel>((resolve, reject) => {
|
||||
let onAbort: (() => void) | null = null;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<Response>(resolve => { complete = resolve; });
|
||||
return { promise, complete: (response: Response) => complete?.(response) };
|
||||
};
|
||||
|
||||
let handleRequest: (url: string, signal?: AbortSignal | null) => Promise<Response>;
|
||||
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<Uint8Array> | undefined;
|
||||
handleRequest = async () => new Response(new ReadableStream<Uint8Array>({ 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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> | null = null;
|
||||
const quotaRequests = new Map<QuotaProviderId, { controller: AbortController; promise: Promise<boolean> }>();
|
||||
let quotaAutoRefreshConsumers = 0;
|
||||
let quotaAutoRefreshInterval: number | null = null;
|
||||
|
||||
@@ -35,6 +36,8 @@ interface QuotaStore extends QuotaSettingsState {
|
||||
isFetchingProvider: Record<string, boolean>;
|
||||
lastUpdated: number | null;
|
||||
error: string | null;
|
||||
/** Refresh failures are not authoritative provider configuration or usage. */
|
||||
refreshErrors: Partial<Record<QuotaProviderId, string>>;
|
||||
|
||||
loadSettings: () => Promise<void>;
|
||||
fetchAllQuotas: () => Promise<void>;
|
||||
@@ -105,6 +108,7 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
isFetchingProvider: {},
|
||||
lastUpdated: null,
|
||||
error: null,
|
||||
refreshErrors: {},
|
||||
displayMode: 'usage',
|
||||
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
||||
selectedModels: {},
|
||||
@@ -123,21 +127,16 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
|
||||
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<QuotaStore>()(
|
||||
},
|
||||
|
||||
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<QuotaStore>()(
|
||||
// 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<QuotaStore>()(
|
||||
isFetchingProvider: {},
|
||||
lastUpdated: null,
|
||||
error: null,
|
||||
refreshErrors: {},
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user