From 0ccf4ed6b57121e31a93a0e071249e917d3c67cd Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 12 Apr 2026 09:18:38 +0400 Subject: [PATCH] =?UTF-8?q?feat(call):=20media=20health=20watchdog=20?= =?UTF-8?q?=E2=80=94=20warn=20user=20when=20no=20audio=20arrives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a P2P direct call establishes successfully but the underlying network path dies (phone switched from WiFi to LTE mid-call, or cross-relay media forwarding isn't working), the call stays up silently with recv_fr frozen at 0. No feedback to the user. New watchdog in the Android recv task: tracks consecutive heartbeat ticks (2s each) where recv_fr hasn't advanced. After 3 ticks (6s) with no new packets, emits: - call-event { kind: "media-degraded" } — user-facing warning banner: "No audio — connection may be lost. Try hanging up and reconnecting, or switch to a different relay." - call-debug media:no_recv_timeout for the debug log If packets resume (recv_fr advances), clears the banner via: - call-event { kind: "media-recovered" } JS listener creates/removes a red-tinted banner dynamically at the top of the call screen. Banner is also cleaned up on showConnectScreen (call end). This covers: - Direct P2P that established on WiFi but died when the phone switched to LTE (stale NAT mapping, unreachable peer) - Cross-relay calls where federation media isn't forwarding (relay not upgraded, not federated, etc.) - Any other "connected but silent" scenario Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/src/engine.rs | 64 +++++++++++++++++++++++++++++++++ desktop/src/main.ts | 39 ++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 205d3c7..9b951c4 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -653,6 +653,17 @@ impl CallEngine { let mut last_written: usize = 0; let mut decode_errs: u64 = 0; let mut first_packet_logged = false; + // Phase 5.6: media health watchdog — track consecutive + // heartbeat ticks where recv_fr hasn't advanced. If + // media doesn't arrive for 3 consecutive heartbeats + // (6s), emit a user-facing "media-degraded" call-event + // so the UI can show a warning like "No audio — try + // reconnecting?". Covers the case where P2P direct + // established but the underlying network path died + // (e.g., phone switched from WiFi to LTE mid-call). + let mut last_recv_fr_for_watchdog: u64 = 0; + let mut no_recv_ticks: u32 = 0; + let mut media_degraded_emitted = false; loop { if !recv_r.load(Ordering::Relaxed) { @@ -905,6 +916,59 @@ impl CallEngine { "codec": format!("{:?}", current_codec), }), ); + + // Media health watchdog: if recv_fr hasn't + // advanced in 3 consecutive heartbeats (6s) and + // we've been "connected" for at least 4s (give + // the first few frames time to arrive), emit a + // user-facing "media-degraded" event so the UI + // can show "No audio — connection may be lost". + if fr == last_recv_fr_for_watchdog { + no_recv_ticks += 1; + } else { + no_recv_ticks = 0; + if media_degraded_emitted { + // Was degraded but recovered — clear + // the banner. + media_degraded_emitted = false; + let _ = recv_app.emit( + "call-event", + serde_json::json!({ + "kind": "media-recovered", + }), + ); + crate::emit_call_debug( + &recv_app, + "media:recovered", + serde_json::json!({}), + ); + } + } + last_recv_fr_for_watchdog = fr; + + if no_recv_ticks >= 3 && !media_degraded_emitted { + media_degraded_emitted = true; + tracing::warn!( + recv_fr = fr, + no_recv_ticks, + "media watchdog: no inbound packets for 6s" + ); + let _ = recv_app.emit( + "call-event", + serde_json::json!({ + "kind": "media-degraded", + }), + ); + crate::emit_call_debug( + &recv_app, + "media:no_recv_timeout", + serde_json::json!({ + "recv_fr": fr, + "no_recv_ticks": no_recv_ticks, + }), + ); + } + heartbeat = std::time::Instant::now(); } } diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 4897f70..8796faf 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -887,6 +887,9 @@ function showConnectScreen() { connectBtn.textContent = "Connect"; levelBar.style.width = "0%"; directCallPeer = null; + // Clear the media-degraded banner if present + const banner = document.getElementById("media-degraded-banner"); + if (banner) banner.remove(); if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } } @@ -1088,6 +1091,42 @@ listen("call-event", (event: any) => { const { kind } = event.payload; if (kind === "room-update") pollStatus(); if (kind === "disconnected" && !userDisconnected) pollStatus(); + + // Phase 5.6: media health watchdog — show/clear a warning + // banner when the media path dies (e.g., P2P direct + // established but the network path changed, or cross-relay + // media forwarding isn't working). + if (kind === "media-degraded") { + // Show a warning banner on the call screen. Don't auto- + // disconnect — the user might be on a briefly-unstable + // network and recovery is possible (the engine tracks + // "media-recovered" and clears the banner if packets + // resume). + let banner = document.getElementById("media-degraded-banner"); + if (!banner) { + banner = document.createElement("div"); + banner.id = "media-degraded-banner"; + banner.style.cssText = + "background:rgba(239,68,68,0.15);color:var(--red);padding:8px 12px;" + + "border-radius:8px;text-align:center;font-size:13px;margin:8px 0;"; + banner.innerHTML = + '⚠ No audio — connection may be lost.
' + + 'Try hanging up and reconnecting, or switch to a different relay.'; + // Insert at the top of the call screen, below the header + const participants = document.getElementById("participants"); + const directView = document.getElementById("direct-call-view"); + const insertBefore = (directView && !directView.classList.contains("hidden")) + ? directView + : participants; + if (insertBefore?.parentNode) { + insertBefore.parentNode.insertBefore(banner, insertBefore); + } + } + } + if (kind === "media-recovered") { + const banner = document.getElementById("media-degraded-banner"); + if (banner) banner.remove(); + } }); // ── Settings ──