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