From e8fd0a1112a4d94b25073749ee4b9b3c8f48e786 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:29:01 -0400 Subject: [PATCH] fix(status): show a strip when the homeserver hangs, not 110 s later (#317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hung Synapse (accepts connections, never answers) kept sync SYNCING for up to 110 s (pollTimeout 30 s + the SDK's 80 s buffer), so no strip showed and Kuma was never asked. A watchdog now notices a /sync overdue by 45 s, confirms with a 5 s GET /_matrix/client/versions, then shows "Lotus Chat's server isn't responding…" and polls Kuma at once. Fixes #317 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../server-status/ServerStatusBanner.tsx | 8 ++- src/app/hooks/useHomeserverStall.test.ts | 16 ++++++ src/app/hooks/useHomeserverStall.ts | 57 +++++++++++++++++++ src/app/pages/client/SyncStatus.tsx | 38 ++++++++++--- 4 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 src/app/hooks/useHomeserverStall.test.ts create mode 100644 src/app/hooks/useHomeserverStall.ts diff --git a/src/app/features/server-status/ServerStatusBanner.tsx b/src/app/features/server-status/ServerStatusBanner.tsx index b59e45fde..f98b62fe1 100644 --- a/src/app/features/server-status/ServerStatusBanner.tsx +++ b/src/app/features/server-status/ServerStatusBanner.tsx @@ -9,6 +9,7 @@ import { useServerStatus } from '../../hooks/useServerStatus'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { serverStatusAtom } from '../../state/serverStatus'; import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus'; +import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall'; const DISMISS_KEY = 'lotus-status-banner-dismissed'; const DISMISS_MAX = 50; @@ -163,7 +164,12 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) { }); }, []), ); - const syncLost = lostAt !== null; + // [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall + // watchdog flags it sooner, and Kuma is asked at once. + const stalled = useHomeserverStall(mx); + const setStalled = useSetAtom(homeserverStalledAtom); + useEffect(() => setStalled(stalled), [setStalled, stalled]); + const syncLost = lostAt !== null || stalled; const status = useServerStatus(page, syncLost); const setAtom = useSetAtom(serverStatusAtom); useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]); diff --git a/src/app/hooks/useHomeserverStall.test.ts b/src/app/hooks/useHomeserverStall.test.ts new file mode 100644 index 000000000..4440f1c3c --- /dev/null +++ b/src/app/hooks/useHomeserverStall.test.ts @@ -0,0 +1,16 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SyncState } from 'matrix-js-sdk'; +import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall'; + +test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => { + const now = 1_000_000; + assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true); + assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll'); + assert.equal( + syncOverdue(now - 120_000, now, SyncState.Reconnecting), + false, + 'SyncStatus already shows that', + ); + assert.equal(syncOverdue(now - 120_000, now, null), false); +}); diff --git a/src/app/hooks/useHomeserverStall.ts b/src/app/hooks/useHomeserverStall.ts new file mode 100644 index 000000000..458bd1560 --- /dev/null +++ b/src/app/hooks/useHomeserverStall.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { atom } from 'jotai'; +import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk'; + +/** + * [Gitea #317] A hung homeserver (accepts connections, never answers) went + * unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after + * pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays + * SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a + * /sync that's overdue and confirms with a short probe of /versions. + */ +export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s +const CHECK_EVERY_MS = 10_000; +const PROBE_TIMEOUT_MS = 5_000; + +export const homeserverStalledAtom = atom(false); + +/** Pure: should we probe, given the last sync response time? */ +export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean => + state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS; + +export function useHomeserverStall(mx: MatrixClient): boolean { + const [stalled, setStalled] = useState(false); + useEffect(() => { + let lastSyncAt = Date.now(); + let probing = false; + const onSync = () => { + lastSyncAt = Date.now(); + setStalled(false); + }; + const check = async () => { + if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return; + probing = true; + let answered = false; + try { + const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, { + cache: 'no-store', + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + answered = res.ok; + } catch { + answered = false; + } + probing = false; + // Still overdue and the server didn't answer a trivial request: stalled. + if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true); + if (answered) setStalled(false); + }; + mx.on(ClientEvent.Sync, onSync); + const id = setInterval(check, CHECK_EVERY_MS); + return () => { + mx.removeListener(ClientEvent.Sync, onSync); + clearInterval(id); + }; + }, [mx]); + return stalled; +} diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 25d1042af..a6ae01056 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -6,6 +6,7 @@ import { useSyncState } from '../../hooks/useSyncState'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { serverStatusAtom } from '../../state/serverStatus'; import { serverConfirmedUpSince } from '../../utils/kumaStatus'; +import { homeserverStalledAtom } from '../../hooks/useHomeserverStall'; type StateData = { current: SyncState | null; @@ -17,6 +18,7 @@ type SyncStatusProps = { }; export function SyncStatus({ mx }: SyncStatusProps) { const serverStatus = useAtomValue(serverStatusAtom); + const stalled = useAtomValue(homeserverStalledAtom); const [stateData, setStateData] = useState({ current: null, previous: undefined, @@ -34,6 +36,35 @@ export function SyncStatus({ mx }: SyncStatusProps) { }, []), ); + // [Gitea #124] When Kuma confirms the homeserver is down, the status banner + // says so ("Lotus Chat's server is down…") instead of "Connection Lost!". + const serverDown = serverStatus.status?.homeserver === 'down'; + // Only blame the user's connection when Kuma has checked the server since + // the drop and it was fine (a stale "up" from before proves nothing). + const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt); + + // [Gitea #317] Sync still says SYNCING, but the server has stopped answering. + if (stalled && stateData.current === SyncState.Syncing && !serverDown) { + return ( + + + + Lotus Chat's server isn't responding. Messages will send when it's back. + Still trying… + + + + + ); + } + if ( (stateData.current === SyncState.Prepared || stateData.current === SyncState.Syncing || @@ -57,13 +88,6 @@ export function SyncStatus({ mx }: SyncStatusProps) { ); } - // [Gitea #124] When Kuma confirms the homeserver is down, the status banner - // says so ("Lotus Chat's server is down…") instead of "Connection Lost!". - const serverDown = serverStatus.status?.homeserver === 'down'; - // Only blame the user's connection when Kuma has checked the server since - // the drop and it was fine (a stale "up" from before proves nothing). - const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt); - if (stateData.current === SyncState.Reconnecting && !serverDown) { return (