From 7791cbe9fa470393feeb6a5df91b1583e577ce37 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 30 Oct 2026 17:35:59 +0700 Subject: [PATCH] fix(cordn): back off on relay pressure and surface relay health --- scripts/host/relay-health.sh | 20 ++++++++++ src/components/CoordinatorSettingsCard.tsx | 29 ++++++++++++++ src/components/crew/CrewCoordinatorCard.tsx | 37 ++++++++++++++---- src/hooks/useCordnNative.ts | 43 +++++++++++++++++---- 4 files changed, 114 insertions(+), 15 deletions(-) diff --git a/scripts/host/relay-health.sh b/scripts/host/relay-health.sh index fa0d0ef..3f88fcd 100644 --- a/scripts/host/relay-health.sh +++ b/scripts/host/relay-health.sh @@ -47,6 +47,26 @@ disk=$(df --output=pcent / | tail -1 | tr -d ' %') avail=$(free -m | awk '/Mem:/{print $7}') [ "${avail:-0}" -lt 300 ] && ISSUES+=("low memory: ${avail} MiB available") +# Cordn/Staircase RPC health. Newlay logs one warning per rejected request, so +# a burst is easy to miss in routine log noise. Surface it as an operational +# problem before users experience a stuck private chat. +cordn_rejects=$(docker logs --since 15m newlay-newlay-1 2>&1 | grep -c 'cordn rate limit' || true) +[ "${cordn_rejects:-0}" -ge 40 ] && ISSUES+=("cordn rate limits: ${cordn_rejects} rejects in 15m") + +if ! docker logs --since 20m newlay-newlay-1 2>&1 | grep -q 'cordn announce'; then + ISSUES+=("cordn coordinator has not announced in 20m") +fi + +# TLS expiry (Caddy auto-renews; this catches a broken renewal early). +expiry=$(echo | openssl s_client -servername relay.skate-spots.org -connect relay.skate-spots.org:443 2>/dev/null \ + | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) +if [ -n "$expiry" ]; then + days=$(( ( $(date -d "$expiry" +%s) - $(date +%s) ) / 86400 )) + [ "$days" -lt 14 ] && ISSUES+=("relay TLS expires in ${days}d") +else + ISSUES+=("could not read relay TLS certificate") +fi + sig="" [ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|') prev="" diff --git a/src/components/CoordinatorSettingsCard.tsx b/src/components/CoordinatorSettingsCard.tsx index 99f516c..c5ff61b 100644 --- a/src/components/CoordinatorSettingsCard.tsx +++ b/src/components/CoordinatorSettingsCard.tsx @@ -13,6 +13,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { useRelayStatus } from '@/hooks/useRelayStatus'; import { QRScannerModal } from '@/components/wallet/QRScannerModal'; import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator'; import { useTranslation } from '@/contexts/language'; @@ -27,6 +28,7 @@ import { SKATESPOTS_COORDINATOR, } from '@/lib/coordinator'; import { cn } from '@/lib/utils'; +import { urlDomain } from '@/lib/sanitizeUrl'; function timeAgo(unixSeconds: number, t: (key: string, params?: Record) => string): string { const s = Math.floor(Date.now() / 1000 - unixSeconds); @@ -59,6 +61,28 @@ async function copyText(text: string, copiedText: string, failedTitle: string, f } } +/** Small live health pill for one relay. Probes only while this card is open. */ +function RelayHealthPill({ url }: { url: string }) { + const { status, reason, recheck } = useRelayStatus(url); + const dot = + status === 'connected' + ? 'bg-green-400' + : status === 'connecting' + ? 'bg-blue-400 animate-pulse' + : 'bg-red-400'; + return ( + + ); +} + /** * Settings → Coordinator. * @@ -119,6 +143,11 @@ export function CoordinatorSettingsCard() { : t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })} {lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}

+ {coordinator.relays.length > 0 && ( +
+ {coordinator.relays.map((url) => )} +
+ )} {t('socialUi.crewCoordinator.openInCordnWeb')} {nprofile && ( - + <> + + + + )} + {showQr && nprofile && ( +
+ +

+ {t('socialUi.coordinator.scanToJoin')} +

+
)} ); diff --git a/src/hooks/useCordnNative.ts b/src/hooks/useCordnNative.ts index 5b96eaa..06e458d 100644 --- a/src/hooks/useCordnNative.ts +++ b/src/hooks/useCordnNative.ts @@ -71,7 +71,7 @@ export interface UseCordnNativeResult { /** Send a read receipt for an inbound message. */ markRead: (messageId: string) => Promise; /** Re-fetch messages (and adopt any new commits). */ - refresh: () => Promise; + refresh: () => Promise; /** Founder/admin: create the crew's MLS group. */ createChat: () => Promise; /** Non-admin: ask the coordinator to be added (publishes a key package). */ @@ -110,6 +110,8 @@ export interface UseCordnNativeResult { } const POLL_MS = 6000; +const HIDDEN_POLL_MS = 12000; +const MAX_BACKOFF_MS = 60000; /** * Drives a {@link CordnSession} for one crew: real MLS over the crew's Cordn @@ -231,9 +233,9 @@ export function useCordnNative({ }; }, [enabled, pubkey, signer, coordinator, groupId, reloadKey]); - const refresh = useCallback(async () => { + const refresh = useCallback(async (): Promise => { const session = sessionRef.current; - if (!session || !group) return; + if (!session || !group) return false; try { await session.fetch(group.gid); const next = session.messages(group.gid); @@ -241,20 +243,47 @@ export function useCordnNative({ setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group); setError(null); setLastCheckedAt(Date.now()); + return true; } catch (err) { setError(err instanceof Error ? err.message : String(err)); setLastCheckedAt(Date.now()); + return false; } }, [group]); // Live polling for silent signers only — an interactive signer would pop for // every request, so those users refresh/send manually (same rule as the DM - // and sealed-relay chat hooks). + // and sealed-relay chat hooks). Hidden tabs poll less, and a failure backs + // off exponentially with jitter so a busy/rate-limited coordinator is not + // hammered by every open device. + const pollDelayRef = useRef(POLL_MS); useEffect(() => { if (!ready || !group || interactive || !canBackgroundPoll) return; - const id = setInterval(() => void refresh(), POLL_MS); - return () => clearInterval(id); - }, [ready, group, interactive, canBackgroundPoll, refresh]); + let cancelled = false; + let timer: ReturnType | undefined; + pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS; + + const tick = async () => { + const ok = await refresh(); + if (cancelled) return; + if (ok) { + pollDelayRef.current = active ? POLL_MS : HIDDEN_POLL_MS; + } else { + pollDelayRef.current = Math.min( + MAX_BACKOFF_MS, + Math.max(POLL_MS, Math.round(pollDelayRef.current * 1.7)), + ); + } + const jitter = Math.round(Math.random() * 800); + timer = setTimeout(() => void tick(), pollDelayRef.current + jitter); + }; + + timer = setTimeout(() => void tick(), pollDelayRef.current); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [ready, group, interactive, canBackgroundPoll, active, refresh]); // One coordinator operation at a time. With Amber every coordinator request // is a foreground approval; a double-tap on "Set up" used to start two