From 9a078e4ac85253ea63bb6b3874390e230c928609 Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 29 Apr 2026 23:03:05 -0400 Subject: [PATCH] fix(pointcloud): exponential backoff on unreachable backend + status banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ?backend= pointed at a server that wasn't running (e.g. user forgot to start ruview-pointcloud serve before clicking Connect ESP32), the viewer was retrying 10 Hz forever — flooding the console with ERR_CONNECTION_REFUSED and offering no guidance about what was wrong. Two fixes: 1. Replace setInterval(fetchCloud, 100) with self-rescheduling setTimeout. On success: 250 ms steady cadence. On failure for an explicit backend: 250 ms → 500 → 1 s → 2 s → 4 s → 8 s → 16 s → capped at 30 s. Resets to 250 ms the moment the backend comes back. Auto mode (Pages with no backend) still disables network entirely after the first 404. Strict-live mode (?live=1) also backs off so it doesn't spam. 2. Show an actionable status banner in the info panel when the chosen backend is unreachable: the URL, the actual error string, the next retry time, and the exact `cargo run` command to start the server. Visitor sees the diagnosis instead of staring at a 'demo' badge wondering why their ESP32 feed isn't visible. The scene keeps animating (face mesh / synthetic) while the viewer waits, so the tab never goes blank. Co-Authored-By: claude-flow --- .../wifi-densepose-pointcloud/src/viewer.html | 76 ++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/v2/crates/wifi-densepose-pointcloud/src/viewer.html b/v2/crates/wifi-densepose-pointcloud/src/viewer.html index 2aea08c1..865bcb17 100644 --- a/v2/crates/wifi-densepose-pointcloud/src/viewer.html +++ b/v2/crates/wifi-densepose-pointcloud/src/viewer.html @@ -477,15 +477,31 @@ // Once auto mode confirms there is no /api/splats backend on this origin, // set this flag so we stop hammering the network with 404 fetches every - // tick. Remote (?backend=) and live (?live=1) modes keep retrying so - // a transient outage doesn't permanently downgrade them. + // tick. Console stays clean; demo renders locally. var networkDisabled = false; + // Exponential backoff state for explicit ?backend=. The user's + // local server may be down (ERR_CONNECTION_REFUSED) and we shouldn't + // hammer it 10 Hz indefinitely. After each failure we lengthen the + // delay; on success we snap back to the normal cadence. + var BASE_INTERVAL_MS = 250; + var MAX_INTERVAL_MS = 30000; + var currentIntervalMs = BASE_INTERVAL_MS; + var consecutiveFailures = 0; + var fetchTimer = null; + var lastBackendError = null; + + function scheduleNextFetch(delayMs) { + if (fetchTimer) clearTimeout(fetchTimer); + fetchTimer = setTimeout(fetchCloud, delayMs); + } + async function fetchCloud() { - // Demo-only mode: never hit the network. + // Demo-only mode: never hit the network. Use the normal cadence. if (backendArg === "demo" || networkDisabled) { transportMode = "demo"; handleData(pickDemoFrame()); + scheduleNextFetch(BASE_INTERVAL_MS); return; } try { @@ -493,18 +509,44 @@ if (!resp.ok) throw new Error("HTTP " + resp.status); var data = await resp.json(); transportMode = (backendArg === "auto") ? "live" : "remote"; + consecutiveFailures = 0; + currentIntervalMs = BASE_INTERVAL_MS; + lastBackendError = null; handleData(data); + scheduleNextFetch(BASE_INTERVAL_MS); } catch (err) { + consecutiveFailures += 1; + lastBackendError = err && err.message ? err.message : String(err); if (requireLive) { document.getElementById("stats").innerHTML = - '● OFFLINE
Live backend required (?live=1) but unreachable.
' + (err && err.message ? err.message : err) + ''; + '● OFFLINE
Live backend required (?live=1) but unreachable.
' + lastBackendError + ''; + // Even strict-live: back off so we don't spam. + currentIntervalMs = Math.min(currentIntervalMs * 2, MAX_INTERVAL_MS); + scheduleNextFetch(currentIntervalMs); return; } - // Auto mode + first failure → assume this is a static host (Pages) - // and stop polling. Console stays clean; demo renders locally. - if (backendArg === "auto") networkDisabled = true; + // Auto mode + first failure → assume static host (Pages), disable + // network entirely so the console stays clean. + if (backendArg === "auto") { + networkDisabled = true; + transportMode = "demo"; + handleData(pickDemoFrame()); + scheduleNextFetch(BASE_INTERVAL_MS); + return; + } + // Explicit backend (?backend=) — keep trying with + // exponential backoff: 250 ms → 500 ms → 1 s → 2 s … up to 30 s. + // Render the demo while we wait so the scene stays alive, and + // surface the failure so the user knows the server is down. + currentIntervalMs = Math.min(Math.max(BASE_INTERVAL_MS * Math.pow(2, consecutiveFailures - 1), 1000), MAX_INTERVAL_MS); transportMode = "demo"; - handleData(pickDemoFrame()); + var demoFrame = pickDemoFrame(); + demoFrame._backendUnreachable = true; + demoFrame._backendUrl = backendArg; + demoFrame._backendError = lastBackendError; + demoFrame._retryInMs = currentIntervalMs; + handleData(demoFrame); + scheduleNextFetch(currentIntervalMs); } } @@ -564,6 +606,21 @@ + "Splats: " + splatCount + "
" + "Frame: " + data.frame; + // Unreachable backend banner — explicit ?backend= failed + // to connect. Show actionable guidance instead of leaving the + // user staring at a "demo" badge wondering why their ESP32 + // feed isn't visible. + if (data._backendUnreachable) { + var nextSec = Math.round((data._retryInMs || 1000) / 1000); + html += '
' + + '● ' + data._backendUrl + ' unreachable' + + '
' + (data._backendError || "connection failed") + '' + + '
retry in ' + nextSec + 's' + + '

start the server:' + + '
cargo run -p wifi-densepose-pointcloud --release \\
  -- serve --bind 127.0.0.1:9880
' + + '
'; + } + // CSI frame rate html += '
' + 'CSI Rate: ' @@ -676,8 +733,9 @@ }); })(); + // fetchCloud self-schedules via setTimeout — no setInterval to avoid + // overlapping calls on slow networks and to support exponential backoff. fetchCloud(); - setInterval(fetchCloud, 100); // 10 Hz — denser updates so face mesh feels live and the spiral animates smoothly function updateSplats(splats) { if (pointsMesh) scene.remove(pointsMesh);