Files
scrabble-game/ui/src/lib/streamwatchdog.ts
T
Ilia Denisov 25381f70a3
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
fix(net): detect a silently-dead live stream via a heartbeat watchdog
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.
2026-07-14 08:27:38 +02:00

64 lines
2.3 KiB
TypeScript

// A liveness watchdog for the live event stream. The gateway pushes a `heartbeat` event on a
// fixed cadence (GATEWAY_PUSH_HEARTBEAT_INTERVAL, default 10 s), so a healthy stream delivers at
// least one event well inside the timeout even when the game itself is idle. A network that dies
// silently — e.g. airplane mode cutting the radio — leaves the streaming fetch neither erroring
// nor delivering; without this watchdog the machine would stay "online" until the next active
// call fails (often only on a foreground resync). The watchdog resets on every delivered event
// and fires onDead once the stream has been silent for the whole timeout, so the app can report
// the drop and reconnect.
/** StreamWatchdog is the control surface returned by {@link createStreamWatchdog}. */
export interface StreamWatchdog {
/** reset (re)starts the countdown; call on stream open and on every delivered event. It also
* clears a prior pause, so a reopen after a suspend resumes watching. */
reset(): void;
/** pause stops the countdown while the app is backgrounded (timers are throttled while hidden,
* and a suspended stream legitimately delivers nothing — neither should trip a false drop). */
pause(): void;
/** resume restarts the countdown after a pause (on returning to the foreground). */
resume(): void;
/** clear stops the countdown without re-arming; call when the stream is closed or has errored. */
clear(): void;
}
/**
* createStreamWatchdog builds a watchdog that calls onDead once no reset has happened for
* timeoutMs. timeoutMs must exceed the server heartbeat interval plus proxy/jitter slack, or a
* healthy but idle stream would trip it.
*/
export function createStreamWatchdog(opts: { timeoutMs: number; onDead: () => void }): StreamWatchdog {
let timer: ReturnType<typeof setTimeout> | null = null;
let paused = false;
function stop(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
function arm(): void {
stop();
timer = setTimeout(opts.onDead, opts.timeoutMs);
}
return {
reset(): void {
paused = false;
arm();
},
pause(): void {
paused = true;
stop();
},
resume(): void {
if (paused) {
paused = false;
arm();
}
},
clear(): void {
stop();
},
};
}