Skip to content
Closed
5 changes: 5 additions & 0 deletions .changeset/sw-session-resync-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add phased service-worker session re-sync controls (foreground resync, visible heartbeat, adaptive backoff/jitter).
19 changes: 19 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,29 @@

"settingsLinkBaseUrl": "https://app.sable.moe",

"experiments": {
"sessionSyncStrategy": {
"enabled": false,
"rolloutPercentage": 0,
"controlVariant": "control",
"variants": ["session-sync-heartbeat", "session-sync-adaptive"]
}
},

"slidingSync": {
"enabled": true
},

"sessionSync": {
"phase1ForegroundResync": false,
"phase2VisibleHeartbeat": false,
"phase3AdaptiveBackoffJitter": false,
"foregroundDebounceMs": 1500,
"heartbeatIntervalMs": 600000,
"resumeHeartbeatSuppressMs": 60000,
"heartbeatMaxBackoffMs": 1800000
},

"featuredCommunities": {
"openAsDefault": false,
"spaces": [
Expand Down
213 changes: 209 additions & 4 deletions src/app/hooks/useAppVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,112 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { MatrixClient } from '$types/matrix-sdk';
import { Session } from '$state/sessions';
import { useAtom } from 'jotai';
import { togglePusher } from '../features/settings/notifications/PushNotifications';
import { appEvents } from '../utils/appEvents';
import { useClientConfig } from './useClientConfig';
import { useClientConfig, useExperimentVariant } from './useClientConfig';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { pushSubscriptionAtom } from '../state/pushSubscription';
import { mobileOrTablet } from '../utils/user-agent';
import { createDebugLogger } from '../utils/debugLogger';
import { pushSessionToSW } from '../../sw-session';

const debugLog = createDebugLogger('AppVisibility');

export function useAppVisibility(mx: MatrixClient | undefined) {
const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500;
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000;
const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000;
const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000;

export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) {
const clientConfig = useClientConfig();
const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
const pushSubAtom = useAtom(pushSubscriptionAtom);
const isMobile = mobileOrTablet();

const sessionSyncConfig = clientConfig.sessionSync;
const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId);

// Derive phase flags from experiment variant; fall back to direct config when not in experiment.
const inSessionSync = sessionSyncVariant.inExperiment;
const syncVariant = sessionSyncVariant.variant;
const phase1ForegroundResync = inSessionSync
? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
: sessionSyncConfig?.phase1ForegroundResync === true;
const phase2VisibleHeartbeat = inSessionSync
? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
: sessionSyncConfig?.phase2VisibleHeartbeat === true;
const phase3AdaptiveBackoffJitter = inSessionSync
? syncVariant === 'session-sync-adaptive'
: sessionSyncConfig?.phase3AdaptiveBackoffJitter === true;

const foregroundDebounceMs = Math.max(
0,
sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS
);
const heartbeatIntervalMs = Math.max(
1000,
sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
);
const resumeHeartbeatSuppressMs = Math.max(
0,
sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS
);
const heartbeatMaxBackoffMs = Math.max(
heartbeatIntervalMs,
sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS
);

const lastForegroundPushAtRef = useRef(0);
const suppressHeartbeatUntilRef = useRef(0);
const heartbeatFailuresRef = useRef(0);

const pushSessionNow = useCallback(
(reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => {
const baseUrl = activeSession?.baseUrl;
const accessToken = activeSession?.accessToken;
const userId = activeSession?.userId;
const canPush =
!!mx &&
typeof baseUrl === 'string' &&
typeof accessToken === 'string' &&
typeof userId === 'string' &&
'serviceWorker' in navigator &&
!!navigator.serviceWorker.controller;

if (!canPush) {
debugLog.warn('network', 'Skipped SW session sync', {
reason,
hasClient: !!mx,
hasBaseUrl: !!baseUrl,
hasAccessToken: !!accessToken,
hasUserId: !!userId,
hasSwController: !!navigator.serviceWorker?.controller,
});
return 'skipped';
}

pushSessionToSW(baseUrl, accessToken, userId);
debugLog.info('network', 'Pushed session to SW', {
reason,
phase1ForegroundResync,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
});
return 'sent';
},
[
activeSession?.accessToken,
activeSession?.baseUrl,
activeSession?.userId,
mx,
phase1ForegroundResync,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
]
);

useEffect(() => {
const handleVisibilityChange = () => {
const isVisible = document.visibilityState === 'visible';
Expand All @@ -29,15 +118,65 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
appEvents.onVisibilityChange?.(isVisible);
if (!isVisible) {
appEvents.onVisibilityHidden?.();
return;
}

// Always kick the sync loop on foreground regardless of phase flags β€”
// the SDK may be sitting in exponential backoff after iOS froze the tab.
mx?.retryImmediately();

if (!phase1ForegroundResync) return;

const now = Date.now();
if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return;
lastForegroundPushAtRef.current = now;

if (
pushSessionNow('foreground') === 'sent' &&
phase3AdaptiveBackoffJitter &&
phase2VisibleHeartbeat
) {
suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs;
}
};

const handleFocus = () => {
if (document.visibilityState !== 'visible') return;

// Always kick the sync loop on focus for the same reason as above.
mx?.retryImmediately();

if (!phase1ForegroundResync) return;

const now = Date.now();
if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return;
lastForegroundPushAtRef.current = now;

if (
pushSessionNow('focus') === 'sent' &&
phase3AdaptiveBackoffJitter &&
phase2VisibleHeartbeat
) {
suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs;
}
};

document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);

return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
};
}, []);
}, [
foregroundDebounceMs,
mx,
phase1ForegroundResync,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
pushSessionNow,
resumeHeartbeatSuppressMs,
]);

useEffect(() => {
if (!mx) return;
Expand All @@ -52,4 +191,70 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
appEvents.onVisibilityChange = null;
};
}, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);

useEffect(() => {
if (!phase2VisibleHeartbeat) return undefined;

// Reset adaptive backoff/suppression so a config or session change starts fresh.
heartbeatFailuresRef.current = 0;
suppressHeartbeatUntilRef.current = 0;

let timeoutId: number | undefined;

const getDelayMs = (): number => {
let delay = heartbeatIntervalMs;

if (phase3AdaptiveBackoffJitter) {
const failures = heartbeatFailuresRef.current;
const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs);
delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor));

// Add +-20% jitter to avoid synchronized heartbeat spikes across many clients.
const jitter = 0.8 + Math.random() * 0.4;
delay = Math.max(1000, Math.round(delay * jitter));
}

return delay;
};

const tick = () => {
const now = Date.now();

if (document.visibilityState !== 'visible' || !navigator.onLine) {
timeoutId = window.setTimeout(tick, getDelayMs());
return;
}

if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) {
timeoutId = window.setTimeout(tick, getDelayMs());
return;
}

const result = pushSessionNow('heartbeat');
if (phase3AdaptiveBackoffJitter) {
if (result === 'sent') {
// Successful push β€” reset backoff so next interval is the base rate.
heartbeatFailuresRef.current = 0;
}
// 'skipped' means prerequisites (SW controller, session) aren't ready yet.
// Do NOT increment failures here: the app may simply be starting up and we
// do not want startup latency to drive exponential backoff that persists
// long after the prerequisites become available.
}

timeoutId = window.setTimeout(tick, getDelayMs());
};

timeoutId = window.setTimeout(tick, getDelayMs());

return () => {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [
heartbeatIntervalMs,
heartbeatMaxBackoffMs,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
pushSessionNow,
]);
}
Loading
Loading