From 589462b0dbe36ffd47b0f5678031b7d599412dd4 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 31 Jul 2026 21:35:47 +0200 Subject: [PATCH] fix: pause sliding sync while the app is backgrounded --- src/app/hooks/useBackgroundSyncPause.test.tsx | 107 +++++++++++ src/app/hooks/useBackgroundSyncPause.ts | 41 +++++ src/app/hooks/useNetworkRecovery.test.tsx | 23 ++- src/app/hooks/useNetworkRecovery.ts | 12 +- src/app/pages/client/ClientRoot.tsx | 2 + src/client/initMatrix.test.ts | 39 +++- src/client/initMatrix.ts | 19 +- src/client/slidingSync.test.ts | 168 ++++++++++++++++++ src/client/slidingSync.ts | 81 +++++++++ 9 files changed, 476 insertions(+), 16 deletions(-) create mode 100644 src/app/hooks/useBackgroundSyncPause.test.tsx create mode 100644 src/app/hooks/useBackgroundSyncPause.ts diff --git a/src/app/hooks/useBackgroundSyncPause.test.tsx b/src/app/hooks/useBackgroundSyncPause.test.tsx new file mode 100644 index 0000000000..79362eca88 --- /dev/null +++ b/src/app/hooks/useBackgroundSyncPause.test.tsx @@ -0,0 +1,107 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +const { pause, resume, getSlidingSyncManager, listen, listenOff, mockIsTauri, mockCallEmbed } = + vi.hoisted(() => ({ + pause: vi.fn<() => void>(), + resume: vi.fn<() => void>(), + getSlidingSyncManager: vi.fn<() => unknown>(), + listen: vi.fn<(_event: string, _cb: () => void) => Promise<() => void>>(), + listenOff: vi.fn<() => void>(), + mockIsTauri: { value: false }, + mockCallEmbed: { value: undefined as unknown }, + })); + +vi.mock('$client/initMatrix', () => ({ getSlidingSyncManager })); + +vi.mock('jotai', () => ({ + useAtomValue: () => mockCallEmbed.value, + atom: vi.fn<() => unknown>(), +})); + +vi.mock('../state/callEmbed', () => ({ callEmbedAtom: {} })); + +vi.mock('@tauri-apps/api/event', () => ({ + listen, + TauriEvent: { WINDOW_RESUMED: 'tauri://resumed' }, +})); + +vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => mockIsTauri.value })); + +import { useBackgroundSyncPause } from './useBackgroundSyncPause'; + +const setVisibility = (state: 'visible' | 'hidden') => + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue(state); + +describe('useBackgroundSyncPause', () => { + beforeEach(() => { + vi.restoreAllMocks(); + pause.mockReset(); + resume.mockReset(); + listenOff.mockReset(); + listen.mockReset().mockResolvedValue(listenOff); + getSlidingSyncManager.mockReset().mockReturnValue({ pause, resume }); + mockIsTauri.value = false; + mockCallEmbed.value = undefined; + }); + + it('pauses sync when the app is backgrounded', () => { + setVisibility('visible'); + renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never)); + + setVisibility('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(pause).toHaveBeenCalled(); + }); + + it('resumes sync when the app returns to the foreground', () => { + setVisibility('hidden'); + renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never)); + pause.mockClear(); + + setVisibility('visible'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(resume).toHaveBeenCalled(); + }); + + it('keeps polling in the background during a call', () => { + mockCallEmbed.value = { dispose: vi.fn<() => void>() }; + setVisibility('hidden'); + + renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never)); + + expect(pause).not.toHaveBeenCalled(); + expect(resume).toHaveBeenCalled(); + }); + + it('resumes on tauri://resumed without waiting for visibilitychange', () => { + mockIsTauri.value = true; + setVisibility('hidden'); + renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never)); + resume.mockClear(); + + const [, cb] = listen.mock.calls[0] as [string, () => void]; + cb(); + + expect(resume).toHaveBeenCalled(); + }); + + it('does nothing without a client', () => { + renderHook(() => useBackgroundSyncPause(undefined)); + + expect(pause).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + }); + + it('resumes on unmount so a paused transport is never left parked', () => { + setVisibility('hidden'); + const { unmount } = renderHook(() => useBackgroundSyncPause({ clientRunning: true } as never)); + resume.mockClear(); + + unmount(); + + expect(resume).toHaveBeenCalled(); + }); +}); diff --git a/src/app/hooks/useBackgroundSyncPause.ts b/src/app/hooks/useBackgroundSyncPause.ts new file mode 100644 index 0000000000..21486c9294 --- /dev/null +++ b/src/app/hooks/useBackgroundSyncPause.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { useAtomValue } from 'jotai'; +import { TauriEvent, listen } from '@tauri-apps/api/event'; +import { isTauri } from '@tauri-apps/api/core'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import { callEmbedAtom } from '../state/callEmbed'; + +/** + * Stop polling while the app is backgrounded. wry only calls Android `WebView.onPause()`, + * which does not pause JavaScript, so the long-poll otherwise runs until the OS freezes + * the process. Driven by `visibilitychange` rather than `tauri://suspended`, which on iOS + * maps to applicationWillResignActive. + */ +export const useBackgroundSyncPause = (mx: MatrixClient | undefined): void => { + const callEmbed = useAtomValue(callEmbedAtom); + const callActive = callEmbed !== undefined; + + useEffect(() => { + if (!mx) return undefined; + + const resume = () => getSlidingSyncManager(mx)?.resume(); + const applyVisibility = () => { + const manager = getSlidingSyncManager(mx); + if (!manager) return; + if (document.visibilityState === 'hidden' && !callActive) manager.pause(); + else manager.resume(); + }; + + document.addEventListener('visibilitychange', applyVisibility); + const unlisten = isTauri() ? listen(TauriEvent.WINDOW_RESUMED, resume) : undefined; + + applyVisibility(); + + return () => { + document.removeEventListener('visibilitychange', applyVisibility); + unlisten?.then((off) => off()); + resume(); + }; + }, [mx, callActive]); +}; diff --git a/src/app/hooks/useNetworkRecovery.test.tsx b/src/app/hooks/useNetworkRecovery.test.tsx index 2e26506c2a..40dac044d6 100644 --- a/src/app/hooks/useNetworkRecovery.test.tsx +++ b/src/app/hooks/useNetworkRecovery.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { renderHook } from '@testing-library/react'; const { nudgeReconnect, setOnline, listenOff, listen, mockIsTauri } = vi.hoisted(() => ({ - nudgeReconnect: vi.fn<(mx: unknown, reason: string) => boolean>(), + nudgeReconnect: vi.fn<(mx: unknown, reason: string, opts?: { force?: boolean }) => boolean>(), setOnline: vi.fn<() => void>(), listenOff: vi.fn<() => void>(), listen: vi.fn<(_event: string, _cb: () => void) => Promise<() => void>>(), @@ -59,7 +59,7 @@ describe('useNetworkRecovery', () => { dispatchEvent(new Event('online')); - expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'online'); + expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'online', undefined); expect(setOnline).toHaveBeenCalled(); }); @@ -71,7 +71,7 @@ describe('useNetworkRecovery', () => { vi.advanceTimersByTime(30_001); document.dispatchEvent(new Event('visibilitychange')); - expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'visible'); + expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'visible', undefined); visState.mockRestore(); }); @@ -112,7 +112,7 @@ describe('useNetworkRecovery', () => { vi.advanceTimersByTime(80_000); - expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'stalled'); + expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'stalled', undefined); }); it('does not trigger stalled nudge when Sync events keep arriving', () => { @@ -178,7 +178,7 @@ describe('useNetworkRecovery', () => { dispatchEvent(new Event('online')); expect(nudgeReconnect).toHaveBeenCalledTimes(1); - expect(nudgeReconnect).toHaveBeenLastCalledWith(expect.anything(), 'online'); + expect(nudgeReconnect).toHaveBeenLastCalledWith(expect.anything(), 'online', undefined); vi.advanceTimersByTime(3_000); @@ -213,7 +213,7 @@ describe('useNetworkRecovery', () => { mockIsTauri.value = true; }); - it('nudges on tauri://resumed event', () => { + it('force-nudges on tauri://resumed event', () => { listen.mockResolvedValue(listenOff); renderHook(() => useNetworkRecovery({ clientRunning: true } as never)); @@ -222,7 +222,16 @@ describe('useNetworkRecovery', () => { const [, cb] = listen.mock.calls[0] as [string, () => void]; cb(); - expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'resumed'); + expect(nudgeReconnect).toHaveBeenCalledWith(expect.anything(), 'resumed', { force: true }); + }); + + it('does not subscribe to tauri://suspended', () => { + listen.mockResolvedValue(listenOff); + + renderHook(() => useNetworkRecovery({ clientRunning: true } as never)); + + expect(listen).toHaveBeenCalledOnce(); + expect(listen).not.toHaveBeenCalledWith('tauri://suspended', expect.any(Function)); }); it('calls the listen cleanup on unmount', async () => { diff --git a/src/app/hooks/useNetworkRecovery.ts b/src/app/hooks/useNetworkRecovery.ts index 6bb74c566e..f744f5bf8f 100644 --- a/src/app/hooks/useNetworkRecovery.ts +++ b/src/app/hooks/useNetworkRecovery.ts @@ -26,10 +26,10 @@ export const useNetworkRecovery = (mx: MatrixClient | undefined): void => { }, []); const nudge = useCallback( - (reason: NudgeReason): boolean => { + (reason: NudgeReason, opts?: { force?: boolean }): boolean => { if (!mx) return false; onlineManager.setOnline(true); - return nudgeReconnect(mx, reason); + return nudgeReconnect(mx, reason, opts); }, [mx] ); @@ -45,11 +45,11 @@ export const useNetworkRecovery = (mx: MatrixClient | undefined): void => { // Foreground nudges (resume / visible-stale / online) get one sync verification: // if no Sync arrives within VERIFY_AFTER_NUDGE_MS, retry once past the throttle. const nudgeForeground = useCallback( - (reason: NudgeReason): void => { + (reason: NudgeReason, opts?: { force?: boolean }): void => { cancelVerify(); if (!mx) return; const syncAtNudge = lastSyncAtRef.current; - if (!nudge(reason)) return; + if (!nudge(reason, opts)) return; verifyTimerRef.current = window.setTimeout(() => { verifyTimerRef.current = undefined; if (lastSyncAtRef.current === syncAtNudge) { @@ -82,8 +82,10 @@ export const useNetworkRecovery = (mx: MatrixClient | undefined): void => { if (nudge('stalled')) lastSyncAtRef.current = Date.now(); }, STALL_CHECK_INTERVAL_MS); + // WINDOW_SUSPENDED is skipped on purpose: on iOS it maps to applicationWillResignActive, + // which fires on Control Center, notification banners and other transient interruptions. const unlisten = isTauri() - ? listen(TauriEvent.WINDOW_RESUMED, () => nudgeForeground('resumed')) + ? listen(TauriEvent.WINDOW_RESUMED, () => nudgeForeground('resumed', { force: true })) : undefined; return () => { diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 19389f1fc0..b1128e17e1 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -51,6 +51,7 @@ import { createLogger } from '$utils/debug'; import { useSyncNicknames } from '$hooks/useNickname'; import { useAppVisibility } from '$hooks/useAppVisibility'; import { useNetworkRecovery } from '$hooks/useNetworkRecovery'; +import { useBackgroundSyncPause } from '$hooks/useBackgroundSyncPause'; import { composerIcon, DotsThreeOutlineVerticalIcon } from '$components/icons/phosphor'; import { getHomePath } from '$pages/pathUtils'; import { DIRECT_ROOM_PATH, HOME_ROOM_PATH, SPACE_ROOM_PATH } from '$pages/paths'; @@ -353,6 +354,7 @@ export function ClientRoot({ children }: ClientRootProps) { useLogoutListener(mx); useAppVisibility(mx); useNetworkRecovery(mx); + useBackgroundSyncPause(mx); useCrossSigningResetDetect(mx); useDeviceDisplayName(mx); diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts index db141dd664..6c453bcd63 100644 --- a/src/client/initMatrix.test.ts +++ b/src/client/initMatrix.test.ts @@ -1,7 +1,18 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Session } from '$state/sessions'; +import type * as PlatformModule from '$utils/platform'; import { ACTIVE_SESSION_KEY, MATRIX_SESSIONS_KEY } from '$state/sessions'; -import { ownsActiveMediaSession } from './initMatrix'; + +const { isMobileTauri } = vi.hoisted(() => ({ + isMobileTauri: vi.fn<() => boolean>(), +})); + +vi.mock('$utils/platform', async (importOriginal) => ({ + ...(await importOriginal()), + isMobileTauri, +})); + +import { ownsActiveMediaSession, resolvePollTimeoutMs } from './initMatrix'; const alice = { userId: '@alice:example.org' } as Session; const bob = { userId: '@bob:example.org' } as Session; @@ -24,3 +35,27 @@ describe('ownsActiveMediaSession', () => { expect(ownsActiveMediaSession(alice)).toBe(true); }); }); + +describe('resolvePollTimeoutMs', () => { + beforeEach(() => { + isMobileTauri.mockReset(); + }); + + it('uses the shorter poll on mobile tauri', () => { + isMobileTauri.mockReturnValue(true); + + expect(resolvePollTimeoutMs(undefined)).toBe(30000); + }); + + it('uses the default poll elsewhere', () => { + isMobileTauri.mockReturnValue(false); + + expect(resolvePollTimeoutMs(undefined)).toBe(45000); + }); + + it('prefers an explicitly configured timeout on mobile', () => { + isMobileTauri.mockReturnValue(true); + + expect(resolvePollTimeoutMs(5000)).toBe(5000); + }); +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 5e4cdbcfc9..965ffc14c7 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -26,6 +26,7 @@ import { import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; +import { isMobileTauri } from '$utils/platform'; import * as Sentry from '@sentry/react'; import { pushSessionToSW } from '../sw-session'; import { assertAuthMetadataIssuer, createSessionTokenRefresher } from './oidcTokenRefresher'; @@ -58,6 +59,12 @@ export const ownsActiveMediaSession = (session?: Session): boolean => { }; const presenceStartCleanupByClient = new WeakMap void>(); const SLIDING_SYNC_POLL_TIMEOUT_MS = 45000; +const SLIDING_SYNC_POLL_TIMEOUT_MOBILE_MS = 30000; + +/** Shorter poll on mobile: a wedged long-poll costs more when the OS freezes the webview. */ +export const resolvePollTimeoutMs = (configured?: number): number => + configured ?? + (isMobileTauri() ? SLIDING_SYNC_POLL_TIMEOUT_MOBILE_MS : SLIDING_SYNC_POLL_TIMEOUT_MS); const isInitialSyncReady = (state: string | null): boolean => state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup; @@ -146,7 +153,15 @@ function installSlidingSyncRequestPatch(mx: MatrixClient, manager: SlidingSyncMa const mxWritable = mx as MatrixClientWithWritableSlidingSync; const original = mx.slidingSync.bind(mx) as SlidingSyncMethod; - mxWritable.slidingSync = (reqBody, baseUrl, abortSignal) => { + mxWritable.slidingSync = async (reqBody, baseUrl, abortSignal) => { + // AbortError makes the SDK loop `continue` and reissue at the same `pos`, no sleep. + if (manager.isPaused()) { + await manager.waitForResume(); + const aborted = new Error('Sliding sync paused while backgrounded'); + aborted.name = 'AbortError'; + throw aborted; + } + const req = reqBody as SlidingSyncRequestWithConnId; if (req.conn_id === undefined) { req.conn_id = SLIDING_SYNC_CONN_ID; @@ -437,7 +452,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): startPresenceAfterInitialSync(mx, presenceManager); manager = new SlidingSyncManager(mx, baseUrl, { - pollTimeoutMs: config?.pollTimeoutMs ?? SLIDING_SYNC_POLL_TIMEOUT_MS, + pollTimeoutMs: resolvePollTimeoutMs(config?.pollTimeoutMs), timelineLimit: config?.timelineLimit, initialRoomIds: config?.initialRoomIds, }); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 72fc8089b3..8ebc1a822e 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ off: vi.fn<() => void>(), removeListener: vi.fn<() => void>(), stop: vi.fn<() => void>(), + resend: vi.fn<() => void>(), modifyRoomSubscriptions: vi.fn<() => void>(), modifyRoomSubscriptionInfo: vi.fn<() => void>(), addCustomSubscription: vi.fn<() => void>(), @@ -1024,6 +1025,173 @@ describe('SlidingSyncManager.dispose()', () => { }); }); +describe('SlidingSyncManager poll watchdog', () => { + // DEFAULT_POLL_TIMEOUT_MS + SDK_CLIENT_TIMEOUT_BUFFER_MS + POLL_DEADLINE_MARGIN_MS + const DEFAULT_DEADLINE_MS = 45_000 + 10_000 + 20_000; + + it('cycles the transport when no lifecycle event arrives within the deadline', () => { + vi.useFakeTimers(); + try { + const manager = makeManager(makeMockMx()); + manager.attach(); + + expect(mocks.slidingSyncInstance.resend).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(DEFAULT_DEADLINE_MS); + + expect(mocks.slidingSyncInstance.resend).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('stays quiet while lifecycle events keep arriving', () => { + vi.useFakeTimers(); + try { + const manager = makeManager(makeMockMx()); + manager.attach(); + + for (let i = 0; i < 5; i += 1) { + vi.advanceTimersByTime(DEFAULT_DEADLINE_MS - 1_000); + fireLifecycle(SlidingSyncState.Complete, {}); + } + + expect(mocks.slidingSyncInstance.resend).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps cycling when the replacement poll also wedges', () => { + vi.useFakeTimers(); + try { + const manager = makeManager(makeMockMx()); + manager.attach(); + + vi.advanceTimersByTime(DEFAULT_DEADLINE_MS); + expect(mocks.slidingSyncInstance.resend).toHaveBeenCalledOnce(); + + // No lifecycle event arrives, so the watchdog must re-arm itself. + vi.advanceTimersByTime(DEFAULT_DEADLINE_MS); + expect(mocks.slidingSyncInstance.resend).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(DEFAULT_DEADLINE_MS); + expect(mocks.slidingSyncInstance.resend).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it('does not fire after dispose', () => { + vi.useFakeTimers(); + try { + const manager = makeManager(makeMockMx()); + manager.attach(); + manager.dispose(); + + vi.advanceTimersByTime(DEFAULT_DEADLINE_MS * 2); + + expect(mocks.slidingSyncInstance.resend).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('SlidingSyncManager pause/resume', () => { + const DEFAULT_DEADLINE_MS = 45_000 + 10_000 + 20_000; + + it('aborts the in-flight poll on pause so the radio goes idle', () => { + const manager = makeManager(makeMockMx()); + manager.attach(); + + manager.pause(); + + expect(manager.isPaused()).toBe(true); + expect(mocks.slidingSyncInstance.resend).toHaveBeenCalledOnce(); + }); + + it('does not tear the transport down', () => { + const manager = makeManager(makeMockMx()); + manager.attach(); + + manager.pause(); + + expect(mocks.slidingSyncInstance.stop).not.toHaveBeenCalled(); + }); + + it('holds waitForResume() until resume', async () => { + const manager = makeManager(makeMockMx()); + manager.attach(); + manager.pause(); + + let resolved = false; + const parked = manager.waitForResume().then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + + manager.resume(); + await parked; + + expect(resolved).toBe(true); + expect(manager.isPaused()).toBe(false); + }); + + it('resolves waitForResume() immediately when not paused', async () => { + const manager = makeManager(makeMockMx()); + manager.attach(); + + await expect(manager.waitForResume()).resolves.toBeUndefined(); + }); + + it('silences the poll watchdog while paused', () => { + vi.useFakeTimers(); + try { + const manager = makeManager(makeMockMx()); + manager.attach(); + manager.pause(); + mocks.slidingSyncInstance.resend.mockClear(); + + vi.advanceTimersByTime(DEFAULT_DEADLINE_MS * 3); + + expect(mocks.slidingSyncInstance.resend).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('re-arms the poll watchdog on resume', () => { + vi.useFakeTimers(); + try { + const manager = makeManager(makeMockMx()); + manager.attach(); + manager.pause(); + manager.resume(); + mocks.slidingSyncInstance.resend.mockClear(); + + vi.advanceTimersByTime(DEFAULT_DEADLINE_MS); + + expect(mocks.slidingSyncInstance.resend).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('releases parked requests on dispose so the sync loop can unwind', async () => { + const manager = makeManager(makeMockMx()); + manager.attach(); + manager.pause(); + + const parked = manager.waitForResume(); + manager.dispose(); + + await expect(parked).resolves.toBeUndefined(); + }); +}); + // ── onMembershipLeave: auto-unsubscribe on leave/ban ───────────────────────── /** Fire the RoomMemberEvent.Membership listener registered on mx.on */ diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index c629764c36..8cb513e8f2 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -35,6 +35,9 @@ const LIST_TIMELINE_LIMIT = 1; const LIST_PAGE_SIZE = 30; const STEADY_STATE_DETAILED_ROOMS = 3; const DEFAULT_POLL_TIMEOUT_MS = 45000; +// Mirrors the js-sdk's own BUFFER_PERIOD_MS so our watchdog sits after its `clientTimeout`. +const SDK_CLIENT_TIMEOUT_BUFFER_MS = 10_000; +const POLL_DEADLINE_MARGIN_MS = 20_000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; @@ -344,6 +347,14 @@ export class SlidingSyncManager { /** Wall-clock time recorded in attach() — used to compute true initial-sync latency. */ private attachTime: number | null = null; + private readonly pollDeadlineMs: number; + + private pollWatchdogTimer: ReturnType | undefined; + + private paused = false; + + private readonly resumeWaiters = new Set<() => void>(); + /** Span covering the period from attach() to the first successful complete cycle. */ private initialSyncSpan: ReturnType | null = null; @@ -355,6 +366,7 @@ export class SlidingSyncManager { options: SlidingSyncOptions = {} ) { const pollTimeoutMs = clampPositive(options.pollTimeoutMs, DEFAULT_POLL_TIMEOUT_MS); + this.pollDeadlineMs = pollTimeoutMs + SDK_CLIENT_TIMEOUT_BUFFER_MS + POLL_DEADLINE_MARGIN_MS; const roomTimelineLimit = clampPositive(options.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); this.roomTimelineLimit = roomTimelineLimit; @@ -412,6 +424,8 @@ export class SlidingSyncManager { return; } + this.armPollWatchdog(); + if (state === SlidingSyncState.RequestFinished) { if (!err && resp) { this.responseProcessing = true; @@ -594,9 +608,72 @@ export class SlidingSyncManager { this.mx.on(RoomMemberEvent.Membership, this.onMembershipLeave); this.mx.on(ClientEvent.AccountData, this.onCacheAccountData); + this.armPollWatchdog(); + debugLog.info('sync', 'Sliding sync listeners attached successfully'); } + /** + * Backstop for the SDK's own `clientTimeout`, which is a JS timer and so cannot fire + * while a mobile webview is frozen. Re-arms itself because the SDK's abort path + * `continue`s without emitting a lifecycle event. + */ + private armPollWatchdog(): void { + if (this.disposed) return; + globalThis.clearTimeout(this.pollWatchdogTimer); + this.pollWatchdogTimer = globalThis.setTimeout(() => { + this.pollWatchdogTimer = undefined; + if (this.disposed) return; + debugLog.warn('sync', 'Sliding sync poll exceeded client-side deadline; cycling transport', { + pollDeadlineMs: this.pollDeadlineMs, + syncNumber: this.syncCount, + }); + this.slidingSync.resend(); + this.armPollWatchdog(); + }, this.pollDeadlineMs); + } + + /** + * Stop issuing requests without tearing the transport down. `SlidingSync.stop()` is + * terminal and drops the `pos` token held in `start()`, so stop/start would force a + * full initial sync on every resume; the request patch parks on `waitForResume()` + * instead. + */ + public pause(): void { + if (this.paused || this.disposed) return; + this.paused = true; + globalThis.clearTimeout(this.pollWatchdogTimer); + this.pollWatchdogTimer = undefined; + this.slidingSync.resend(); + debugLog.info('sync', 'Sliding sync paused'); + } + + public resume(): void { + if (!this.paused) return; + this.paused = false; + this.releaseResumeWaiters(); + this.armPollWatchdog(); + debugLog.info('sync', 'Sliding sync resumed'); + } + + public isPaused(): boolean { + return this.paused; + } + + /** Resolves on the next resume(), or immediately when not paused. */ + public waitForResume(): Promise { + if (!this.paused) return Promise.resolve(); + return new Promise((resolve) => { + this.resumeWaiters.add(resolve); + }); + } + + private releaseResumeWaiters(): void { + const waiters = Array.from(this.resumeWaiters); + this.resumeWaiters.clear(); + waiters.forEach((resolve) => resolve()); + } + public dispose(): void { if (this.disposed) return; @@ -619,6 +696,10 @@ export class SlidingSyncManager { this.hydrationStatusListeners.clear(); this.disposed = true; + this.paused = false; + this.releaseResumeWaiters(); + globalThis.clearTimeout(this.pollWatchdogTimer); + this.pollWatchdogTimer = undefined; this.slidingSync.stop(); this.slidingSync.removeListener(SlidingSyncEvent.Lifecycle, this.onLifecycle); this.slidingSync.removeListener(SlidingSyncEvent.RoomData, this.onCacheRoomData);