fix(net): detect a silently-dead live stream via a heartbeat watchdog
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
An idle live stream whose connection dies without an error (airplane mode cutting the radio) left the net-state machine stuck "online": the streaming fetch neither errored nor delivered, no unary call was made, and the OS `navigator` offline hint is unreliable in the Telegram/VK WebViews — so the "Connecting…"/offline state only surfaced on the next foreground resync, not while idle. The gateway already pushes a keep-alive `heartbeat` every 10s, so add a client-side watchdog (streamwatchdog.ts) that resets on every delivered event and, after ~25s of silence, treats the stream as dropped (reportOffline + reconnect). It is background-aware (paused while suspended) so a throttled foreground return does not false-trip. The mock client mirrors the heartbeat so an idle e2e session stays online.
This commit is contained in:
@@ -10,6 +10,7 @@ import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { languageNeedsServerSync } from './language';
|
||||
import { startLocalEvalMetrics } from './localeval-metrics';
|
||||
import { createStreamWatchdog } from './streamwatchdog';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
||||
import {
|
||||
insideTelegram,
|
||||
@@ -215,6 +216,7 @@ function bannerSuppressed(): boolean {
|
||||
|
||||
function goBackground(): void {
|
||||
backgrounded = true;
|
||||
streamWatchdog.pause(); // do not count silence against a suspended (throttled) stream
|
||||
}
|
||||
|
||||
function goForeground(): void {
|
||||
@@ -227,6 +229,7 @@ function goForeground(): void {
|
||||
// The stream stayed alive across the suspend, so the reconnect refetch will not fire — but an
|
||||
// event may have been shed from a full hub buffer while away; nudge an open game to refetch.
|
||||
app.resync++;
|
||||
streamWatchdog.resume(); // resume liveness watching paused on the way to the background
|
||||
}
|
||||
void refreshNotifications();
|
||||
}
|
||||
@@ -353,12 +356,35 @@ function viewingGame(gameId: string): boolean {
|
||||
return router.route.name === 'game' && router.route.params.id === gameId;
|
||||
}
|
||||
|
||||
// The live stream carries a heartbeat every GATEWAY_PUSH_HEARTBEAT_INTERVAL (10 s by default), so a
|
||||
// healthy stream delivers an event well inside this window even when the game is idle. A silence
|
||||
// past it means the stream died without erroring (e.g. airplane mode cut the radio): tear the dead
|
||||
// subscription down and drop, so the machine leaves "online" instead of waiting for the next call.
|
||||
// 25 s tolerates a couple of missed heartbeats; it must stay above the server interval + slack.
|
||||
const STREAM_IDLE_TIMEOUT_MS = 25000;
|
||||
const streamWatchdog = createStreamWatchdog({
|
||||
timeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
||||
onDead: () => {
|
||||
// Do not trip while suspended: timers are throttled and a backgrounded stream is legitimately
|
||||
// silent (the reopen on resume covers a genuine drop).
|
||||
if (backgrounded || documentHidden()) return;
|
||||
// Abort the dead subscription explicitly — an aborted stream skips onError (transport gates on
|
||||
// signal.aborted) — then follow the same drop path as an error close.
|
||||
unsubscribeStream?.();
|
||||
unsubscribeStream = null;
|
||||
app.streamAlive = false;
|
||||
if (!bannerSuppressed()) reportOffline();
|
||||
scheduleReconnect();
|
||||
},
|
||||
});
|
||||
|
||||
function openStream(): void {
|
||||
closeStream();
|
||||
app.streamAlive = true;
|
||||
unsubscribeStream = gateway.subscribe(
|
||||
(e) => {
|
||||
reportOnline(); // a delivered event proves the gateway is reachable
|
||||
streamWatchdog.reset(); // any event (incl. the heartbeat) proves the stream is still live
|
||||
app.lastEvent = e;
|
||||
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
|
||||
// While the player is in that game's comms hub (chat or dictionary tab), neither
|
||||
@@ -457,6 +483,7 @@ function openStream(): void {
|
||||
}
|
||||
},
|
||||
() => {
|
||||
streamWatchdog.clear();
|
||||
app.streamAlive = false;
|
||||
// A background suspend drops the single-shot stream. Keep the indicator hidden while
|
||||
// backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
|
||||
@@ -465,6 +492,7 @@ function openStream(): void {
|
||||
scheduleReconnect();
|
||||
},
|
||||
);
|
||||
streamWatchdog.reset(); // start the liveness watch for this stream (armed by the first heartbeat)
|
||||
}
|
||||
|
||||
/** scheduleReconnect reopens a dropped stream once, after a short delay, while the
|
||||
@@ -529,6 +557,7 @@ export async function refreshFeedbackBadge(): Promise<void> {
|
||||
}
|
||||
|
||||
function closeStream(): void {
|
||||
streamWatchdog.clear();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
|
||||
Reference in New Issue
Block a user