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
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.
83 lines
2.7 KiB
TypeScript
83 lines
2.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createStreamWatchdog } from './streamwatchdog';
|
|
|
|
describe('createStreamWatchdog', () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it('fires onDead after the timeout with no activity', () => {
|
|
const onDead = vi.fn();
|
|
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
|
w.reset();
|
|
vi.advanceTimersByTime(24999);
|
|
expect(onDead).not.toHaveBeenCalled();
|
|
vi.advanceTimersByTime(1);
|
|
expect(onDead).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reset before the timeout re-arms and prevents the fire', () => {
|
|
const onDead = vi.fn();
|
|
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
|
w.reset();
|
|
vi.advanceTimersByTime(20000);
|
|
w.reset(); // a fresh event arrived; the deadline moves out
|
|
vi.advanceTimersByTime(20000);
|
|
expect(onDead).not.toHaveBeenCalled();
|
|
vi.advanceTimersByTime(5000);
|
|
expect(onDead).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('a steady heartbeat keeps it from ever firing', () => {
|
|
const onDead = vi.fn();
|
|
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
|
w.reset();
|
|
// A 10 s server heartbeat resets it well inside the 25 s window, indefinitely.
|
|
for (let i = 0; i < 20; i++) {
|
|
vi.advanceTimersByTime(10000);
|
|
w.reset();
|
|
}
|
|
expect(onDead).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('pause clears the pending timer so it cannot fire', () => {
|
|
const onDead = vi.fn();
|
|
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
|
w.reset();
|
|
w.pause();
|
|
vi.advanceTimersByTime(60000);
|
|
expect(onDead).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resume re-arms after a pause and then fires on continued silence', () => {
|
|
const onDead = vi.fn();
|
|
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
|
w.reset();
|
|
w.pause();
|
|
vi.advanceTimersByTime(60000); // stayed silent while paused: no fire
|
|
expect(onDead).not.toHaveBeenCalled();
|
|
w.resume();
|
|
vi.advanceTimersByTime(24999);
|
|
expect(onDead).not.toHaveBeenCalled();
|
|
vi.advanceTimersByTime(1);
|
|
expect(onDead).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('clear stops a pending fire and does not re-arm', () => {
|
|
const onDead = vi.fn();
|
|
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
|
w.reset();
|
|
w.clear();
|
|
vi.advanceTimersByTime(60000);
|
|
expect(onDead).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reset after a pause resumes watching (unpauses)', () => {
|
|
const onDead = vi.fn();
|
|
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
|
w.pause();
|
|
w.reset(); // a reopen after a suspend must start watching again
|
|
vi.advanceTimersByTime(25000);
|
|
expect(onDead).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|