From 16299b4e3f92f08a6b8702c38b1402c81f4660d6 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:22:00 -0400 Subject: [PATCH] =?UTF-8?q?mesh-llm:=20heartbeat=20republish=20=E2=80=94?= =?UTF-8?q?=20keep=20offers=20fresh=20while=20the=20panel=20is=20mounted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without heartbeat, the expires_at TTL added in the previous commit makes offers vanish from consumer UIs 15 min after the user toggles on, even if the publisher is still running fine. The original commit message even noted this gap but didn't implement it. Fixing now. desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts (new): - HEARTBEAT_MS = 5 minutes (~OFFER_TTL_SECS / 3 — so one missed beat still leaves the offer visible; only two consecutive misses drop us). - While { enabled, irohRelayUrl } are both truthy, setInterval calls mesh_publish_offer, which re-stamps expires_at = now + 15 min as a NIP-33 replace under the same (pubkey, d_tag) address. - Errors are logged and ignored — the user isn't waiting in front of the panel; the next tick or an explicit prefs change will surface a fresh error if the relay is durably down. desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx: - New irohRelayUrl state, populated on mount and after every persist(). - useMeshOfferHeartbeat({ enabled, irohRelayUrl }) wired in. - Mount-time relay probe so a user opening the panel with sharing already enabled from a previous session starts heartbeating immediately; failures are non-fatal (console.warn, prefs still editable). pnpm typecheck + pnpm check clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- .../settings/hooks/useMeshOfferHeartbeat.ts | 56 +++++++++++++++++++ .../settings/ui/MeshComputeSettingsCard.tsx | 34 +++++++++-- 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts diff --git a/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts b/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts new file mode 100644 index 0000000000..81e670f7a3 --- /dev/null +++ b/desktop/src/features/settings/hooks/useMeshOfferHeartbeat.ts @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +/** + * Heartbeat interval (ms) for the mesh-LLM offer publisher. + * + * The Rust side stamps `expires_at = now + OFFER_TTL_SECS` (15 min) on + * each publish. We re-publish at ~1/3 of that so even a single missed + * heartbeat leaves the offer visible to consumers; only a crash that + * misses two consecutive heartbeats actually drops us out of the UI. + * + * Kept here rather than computed from a Rust constant because the value + * is fundamentally a *frontend timer* (Tauri-side has no concept of "the + * UI mounted"); we just need it to stay strictly less than the + * Rust-side TTL so the invariant holds. + */ +const HEARTBEAT_MS = 5 * 60 * 1000; + +/** + * While `enabled` is true, periodically re-invoke `mesh_publish_offer` so + * the kind:31990 event's `expires_at` stays fresh. Republishes are NIP-33 + * replaces under the same `(pubkey, d_tag)` address — consumers do not see + * a flicker; they just see the deadline advance. + * + * Errors are logged to the console and dropped on the floor — the user + * is not in front of the settings panel waiting for them, and the next + * heartbeat (or an explicit prefs change) will surface a fresh error if + * the relay is durably down. + */ +export function useMeshOfferHeartbeat(params: { + enabled: boolean; + irohRelayUrl: string | null; +}): void { + const { enabled, irohRelayUrl } = params; + + useEffect(() => { + if (!enabled || irohRelayUrl == null || irohRelayUrl === "") { + return; + } + let cancelled = false; + const tick = async () => { + if (cancelled) return; + try { + await invoke("mesh_publish_offer", { irohRelayUrl }); + } catch (e) { + // Heartbeat failure is non-fatal — log and let the next tick try. + console.warn("mesh-llm heartbeat failed:", e); + } + }; + const id = setInterval(tick, HEARTBEAT_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [enabled, irohRelayUrl]); +} diff --git a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx index ce482802f1..66594886e0 100644 --- a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { Cpu, Users } from "lucide-react"; import { useMeshLlmOffers } from "@/features/settings/hooks/useMeshLlmOffers"; +import { useMeshOfferHeartbeat } from "@/features/settings/hooks/useMeshOfferHeartbeat"; import { Switch } from "@/shared/ui/switch"; import { Input } from "@/shared/ui/input"; @@ -57,11 +58,24 @@ function formatCap(value: number | null): string { export function MeshComputeSettingsCard() { const [prefs, setPrefs] = React.useState(null); const [endpoint, setEndpoint] = React.useState(null); + const [irohRelayUrl, setIrohRelayUrl] = React.useState(null); const [error, setError] = React.useState(null); const [saving, setSaving] = React.useState(false); const { offers, error: offersError } = useMeshLlmOffers(); - // Load the persisted prefs + the iroh endpoint identity on mount. + // While the card is mounted AND the user has compute-sharing enabled, + // re-publish the offer every ~5 min so its `expires_at` doesn't lapse + // (see useMeshOfferHeartbeat for the rationale). The heartbeat hook + // no-ops when `enabled` is false or `irohRelayUrl` is missing. + useMeshOfferHeartbeat({ + enabled: prefs?.enabled === true, + irohRelayUrl, + }); + + // Load the persisted prefs + the iroh endpoint identity + the relay's + // iroh_relay_url on mount. The latter is what the heartbeat republishes + // against, so we have to know it before the heartbeat fires its first + // tick. React.useEffect(() => { let cancelled = false; (async () => { @@ -70,9 +84,20 @@ export function MeshComputeSettingsCard() { invoke("mesh_get_sharing_prefs"), invoke("mesh_get_endpoint_id"), ]); - if (!cancelled) { - setPrefs(p); - setEndpoint(e); + if (cancelled) return; + setPrefs(p); + setEndpoint(e); + // Probe the relay's iroh_relay_url so the heartbeat hook has + // something to publish against. Failures here are non-fatal — + // the user can still see/edit prefs; we just won't heartbeat. + try { + const relayWsUrl = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl, + }); + if (!cancelled) setIrohRelayUrl(irohUrl ?? null); + } catch (probeErr) { + console.warn("mesh-llm iroh url probe failed:", probeErr); } } catch (e) { if (!cancelled) setError(String(e)); @@ -99,6 +124,7 @@ export function MeshComputeSettingsCard() { const irohUrl = await invoke("mesh_relay_iroh_url", { relayWsUrl, }); + setIrohRelayUrl(irohUrl ?? null); if (irohUrl) { await invoke("mesh_publish_offer", { irohRelayUrl: irohUrl }); } else if (next.enabled) {