From 33e5f0ebf70309c946060543fd67102a6632ef94 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 3 Nov 2026 12:01:38 +0000 Subject: [PATCH] client: nudge sync on resume so a backgrounded tab stops waiting out the 30 s poll On visible (after >=5 s hidden), pageshow(persisted) and online, call retryImmediately; if sync is not Prepared/Syncing/Catchup 3 s later, one non-overlapping stop/start. Debounced, listeners disposable, started once after startClient. Co-Authored-By: Claude Sonnet 5.5 --- src/client/initMatrix.ts | 9 ++- src/client/resumeSync.test.ts | 132 ++++++++++++++++++++++++++++++++++ src/client/resumeSync.ts | 86 ++++++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/client/resumeSync.test.ts create mode 100644 src/client/resumeSync.ts diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 498d4f75..871a7625 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -3,6 +3,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; +import { startResumeSync } from './resumeSync'; type Session = { baseUrl: string; @@ -40,10 +41,14 @@ export const initClient = async (session: Session): Promise => { return mx; }; -export const startClient = async (mx: MatrixClient) => { - await mx.startClient({ +const startSync = (mx: MatrixClient) => + mx.startClient({ lazyLoadMembers: true, }); + +export const startClient = async (mx: MatrixClient) => { + await startSync(mx); + startResumeSync(mx, { restart: startSync }); }; export const clearCacheAndReload = async (mx: MatrixClient) => { diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts new file mode 100644 index 00000000..eada97af --- /dev/null +++ b/src/client/resumeSync.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; + +class Target extends EventTarget { + visibilityState: 'visible' | 'hidden' = 'visible'; +} + +const setup = (state: string | null = 'SYNCING') => { + const doc = new Target(); + const win = new Target(); + const mx = { + state, + getSyncState: () => mx.state, + retryImmediately: vi.fn(() => true), + stopClient: vi.fn(), + }; + const restart = vi.fn(async () => {}); + const stop = startResumeSync(mx, { + restart, + doc: doc as never, + win: win as never, + }); + const hide = () => { + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + }; + const show = () => { + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + }; + return { doc, win, mx, restart, stop, hide, show }; +}; + +const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); + +describe('startResumeSync', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('nudges the poll when the tab returns after a real absence', async () => { + const t = setup(); + t.hide(); + await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS); + t.show(); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); + + it('ignores a hide shorter than the minimum', async () => { + const t = setup(); + t.hide(); + await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1); + t.show(); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + }); + + it('handles pageshow only when restored from bfcache, and online always', async () => { + const t = setup(); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: false })); + await settle(); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + }); + + it('debounces a burst of triggers into one nudge', async () => { + const t = setup(); + t.win.dispatchEvent(new Event('online')); + t.win.dispatchEvent(new Event('online')); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); + + it('does not restart when sync is healthy after the check window', async () => { + for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) { + const t = setup(state); + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.restart).not.toHaveBeenCalled(); + t.stop(); + } + }); + + it('does one stop/start when sync is still unhealthy, never overlapping', async () => { + const t = setup('RECONNECTING'); + let release!: () => void; + t.restart.mockImplementation( + () => + new Promise((r) => { + release = r; + }) + ); + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.stopClient).toHaveBeenCalledTimes(1); + expect(t.restart).toHaveBeenCalledTimes(1); + // a trigger while the restart is in flight does nothing + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + expect(t.restart).toHaveBeenCalledTimes(1); + release(); + await vi.advanceTimersByTimeAsync(0); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + }); + + it('removes listeners and cancels pending work on dispose', async () => { + const t = setup('ERROR'); + t.win.dispatchEvent(new Event('online')); + await settle(); + t.stop(); + await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); + expect(t.restart).not.toHaveBeenCalled(); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts new file mode 100644 index 00000000..5f81547b --- /dev/null +++ b/src/client/resumeSync.ts @@ -0,0 +1,86 @@ +/** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */ +export type SyncHost = { + getSyncState(): string | null; + retryImmediately(): boolean; + stopClient(): void; +}; + +// matrix-js-sdk SyncState values for a live sync loop. +const HEALTHY: ReadonlySet = new Set(['PREPARED', 'SYNCING', 'CATCHUP']); + +export const RESUME_DEBOUNCE_MS = 250; +export const MIN_HIDDEN_MS = 5000; +export const HEALTH_CHECK_MS = 3000; + +type ResumeSyncOptions = { + /** Event sources and visibility; default to the browser globals. */ + doc?: Pick; + win?: Pick; + /** Restart the client after stopClient; must resolve once startClient has been issued. */ + restart: (mx: Host) => Promise; +}; + +/** + * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. + * On return (visible after a real absence, pageshow from bfcache, online) nudge the + * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one + * stop/start. Returns a disposer that removes every listener and timer. + */ +export const startResumeSync = ( + mx: Host, + { restart, doc = document, win = window }: ResumeSyncOptions +): (() => void) => { + let hiddenAt: number | undefined; + let debounce: ReturnType | undefined; + let healthCheck: ReturnType | undefined; + let restarting = false; + let disposed = false; + + const check = async () => { + healthCheck = undefined; + if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return; + restarting = true; + try { + mx.stopClient(); + await restart(mx); + } finally { + restarting = false; + } + }; + + const resume = () => { + if (debounce !== undefined) clearTimeout(debounce); + debounce = setTimeout(() => { + debounce = undefined; + if (disposed || restarting || healthCheck !== undefined) return; + mx.retryImmediately(); + healthCheck = setTimeout(check, HEALTH_CHECK_MS); + }, RESUME_DEBOUNCE_MS); + }; + + const onVisibility = () => { + if (doc.visibilityState === 'hidden') { + hiddenAt = Date.now(); + return; + } + const away = hiddenAt === undefined ? Infinity : Date.now() - hiddenAt; + hiddenAt = undefined; + if (away >= MIN_HIDDEN_MS) resume(); + }; + const onPageShow = (e: Event) => { + if ((e as PageTransitionEvent).persisted) resume(); + }; + + doc.addEventListener('visibilitychange', onVisibility); + win.addEventListener('pageshow', onPageShow); + win.addEventListener('online', resume); + + return () => { + disposed = true; + if (debounce !== undefined) clearTimeout(debounce); + if (healthCheck !== undefined) clearTimeout(healthCheck); + doc.removeEventListener('visibilitychange', onVisibility); + win.removeEventListener('pageshow', onPageShow); + win.removeEventListener('online', resume); + }; +}; -- 2.47.3