Skip to content

Commit fa6d468

Browse files
committed
feat(session-sync): phased SW session resync with experiment variant
Three opt-in phases controlled by sessionSync flags in client config: - phase1ForegroundResync: resync session on app foreground - phase2VisibleHeartbeat: periodic heartbeat while app is visible - phase3AdaptiveBackoffJitter: exponential backoff with jitter Phase flags are automatically set from the sessionSyncStrategy experiment variant so rollout can be controlled via injected client config. Wired into ClientRoot via useAppVisibility.
1 parent 5e44518 commit fa6d468

4 files changed

Lines changed: 220 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add phased service-worker session re-sync controls (foreground resync, visible heartbeat, adaptive backoff/jitter) integrated with experiment-ready config and environment-based overrides.

config.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,29 @@
1313
"webPushAppID": "moe.sable.app.sygnal"
1414
},
1515

16+
"experiments": {
17+
"sessionSyncStrategy": {
18+
"enabled": false,
19+
"rolloutPercentage": 0,
20+
"controlVariant": "control",
21+
"variants": ["session-sync-heartbeat", "session-sync-adaptive"]
22+
}
23+
},
24+
1625
"slidingSync": {
1726
"enabled": true
1827
},
1928

29+
"sessionSync": {
30+
"phase1ForegroundResync": false,
31+
"phase2VisibleHeartbeat": false,
32+
"phase3AdaptiveBackoffJitter": false,
33+
"foregroundDebounceMs": 1500,
34+
"heartbeatIntervalMs": 600000,
35+
"resumeHeartbeatSuppressMs": 60000,
36+
"heartbeatMaxBackoffMs": 1800000
37+
},
38+
2039
"featuredCommunities": {
2140
"openAsDefault": false,
2241
"spaces": [

src/app/hooks/useAppVisibility.ts

Lines changed: 195 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,112 @@
1-
import { useEffect } from 'react';
1+
import { useCallback, useEffect, useRef } from 'react';
22
import { MatrixClient } from '$types/matrix-sdk';
3+
import { Session } from '$state/sessions';
34
import { useAtom } from 'jotai';
45
import { togglePusher } from '../features/settings/notifications/PushNotifications';
56
import { appEvents } from '../utils/appEvents';
6-
import { useClientConfig } from './useClientConfig';
7+
import { useClientConfig, useExperimentVariant } from './useClientConfig';
78
import { useSetting } from '../state/hooks/settings';
89
import { settingsAtom } from '../state/settings';
910
import { pushSubscriptionAtom } from '../state/pushSubscription';
1011
import { mobileOrTablet } from '../utils/user-agent';
1112
import { createDebugLogger } from '../utils/debugLogger';
13+
import { pushSessionToSW } from '../../sw-session';
1214

1315
const debugLog = createDebugLogger('AppVisibility');
1416

15-
export function useAppVisibility(mx: MatrixClient | undefined) {
17+
const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500;
18+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000;
19+
const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000;
20+
const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000;
21+
22+
export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) {
1623
const clientConfig = useClientConfig();
1724
const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
1825
const pushSubAtom = useAtom(pushSubscriptionAtom);
1926
const isMobile = mobileOrTablet();
2027

28+
const sessionSyncConfig = clientConfig.sessionSync;
29+
const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId);
30+
31+
// Derive phase flags from experiment variant; fall back to direct config when not in experiment.
32+
const inSessionSync = sessionSyncVariant.inExperiment;
33+
const syncVariant = sessionSyncVariant.variant;
34+
const phase1ForegroundResync = inSessionSync
35+
? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
36+
: sessionSyncConfig?.phase1ForegroundResync === true;
37+
const phase2VisibleHeartbeat = inSessionSync
38+
? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
39+
: sessionSyncConfig?.phase2VisibleHeartbeat === true;
40+
const phase3AdaptiveBackoffJitter = inSessionSync
41+
? syncVariant === 'session-sync-adaptive'
42+
: sessionSyncConfig?.phase3AdaptiveBackoffJitter === true;
43+
44+
const foregroundDebounceMs = Math.max(
45+
0,
46+
sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS
47+
);
48+
const heartbeatIntervalMs = Math.max(
49+
1000,
50+
sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
51+
);
52+
const resumeHeartbeatSuppressMs = Math.max(
53+
0,
54+
sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS
55+
);
56+
const heartbeatMaxBackoffMs = Math.max(
57+
heartbeatIntervalMs,
58+
sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS
59+
);
60+
61+
const lastForegroundPushAtRef = useRef(0);
62+
const suppressHeartbeatUntilRef = useRef(0);
63+
const heartbeatFailuresRef = useRef(0);
64+
65+
const pushSessionNow = useCallback(
66+
(reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => {
67+
const baseUrl = activeSession?.baseUrl;
68+
const accessToken = activeSession?.accessToken;
69+
const userId = activeSession?.userId;
70+
const canPush =
71+
!!mx &&
72+
typeof baseUrl === 'string' &&
73+
typeof accessToken === 'string' &&
74+
typeof userId === 'string' &&
75+
'serviceWorker' in navigator &&
76+
!!navigator.serviceWorker.controller;
77+
78+
if (!canPush) {
79+
debugLog.warn('network', 'Skipped SW session sync', {
80+
reason,
81+
hasClient: !!mx,
82+
hasBaseUrl: !!baseUrl,
83+
hasAccessToken: !!accessToken,
84+
hasUserId: !!userId,
85+
hasSwController: !!navigator.serviceWorker?.controller,
86+
});
87+
return 'skipped';
88+
}
89+
90+
pushSessionToSW(baseUrl, accessToken, userId);
91+
debugLog.info('network', 'Pushed session to SW', {
92+
reason,
93+
phase1ForegroundResync,
94+
phase2VisibleHeartbeat,
95+
phase3AdaptiveBackoffJitter,
96+
});
97+
return 'sent';
98+
},
99+
[
100+
activeSession?.accessToken,
101+
activeSession?.baseUrl,
102+
activeSession?.userId,
103+
mx,
104+
phase1ForegroundResync,
105+
phase2VisibleHeartbeat,
106+
phase3AdaptiveBackoffJitter,
107+
]
108+
);
109+
21110
useEffect(() => {
22111
const handleVisibilityChange = () => {
23112
const isVisible = document.visibilityState === 'visible';
@@ -29,15 +118,56 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
29118
appEvents.onVisibilityChange?.(isVisible);
30119
if (!isVisible) {
31120
appEvents.onVisibilityHidden?.();
121+
return;
122+
}
123+
124+
if (!phase1ForegroundResync) return;
125+
126+
const now = Date.now();
127+
if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return;
128+
lastForegroundPushAtRef.current = now;
129+
130+
if (
131+
pushSessionNow('foreground') === 'sent' &&
132+
phase3AdaptiveBackoffJitter &&
133+
phase2VisibleHeartbeat
134+
) {
135+
suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs;
136+
}
137+
};
138+
139+
const handleFocus = () => {
140+
if (!phase1ForegroundResync) return;
141+
if (document.visibilityState !== 'visible') return;
142+
143+
const now = Date.now();
144+
if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return;
145+
lastForegroundPushAtRef.current = now;
146+
147+
if (
148+
pushSessionNow('focus') === 'sent' &&
149+
phase3AdaptiveBackoffJitter &&
150+
phase2VisibleHeartbeat
151+
) {
152+
suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs;
32153
}
33154
};
34155

35156
document.addEventListener('visibilitychange', handleVisibilityChange);
157+
window.addEventListener('focus', handleFocus);
36158

37159
return () => {
38160
document.removeEventListener('visibilitychange', handleVisibilityChange);
161+
window.removeEventListener('focus', handleFocus);
39162
};
40-
}, []);
163+
}, [
164+
foregroundDebounceMs,
165+
phase1ForegroundResync,
166+
phase2VisibleHeartbeat,
167+
phase3AdaptiveBackoffJitter,
168+
pushSessionNow,
169+
resumeHeartbeatSuppressMs,
170+
]);
41171

42172
useEffect(() => {
43173
if (!mx) return;
@@ -52,4 +182,65 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
52182
appEvents.onVisibilityChange = null;
53183
};
54184
}, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);
185+
186+
useEffect(() => {
187+
if (!phase2VisibleHeartbeat) return undefined;
188+
189+
// Reset adaptive backoff/suppression so a config or session change starts fresh.
190+
heartbeatFailuresRef.current = 0;
191+
suppressHeartbeatUntilRef.current = 0;
192+
193+
let timeoutId: number | undefined;
194+
195+
const getDelayMs = (): number => {
196+
let delay = heartbeatIntervalMs;
197+
198+
if (phase3AdaptiveBackoffJitter) {
199+
const failures = heartbeatFailuresRef.current;
200+
const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs);
201+
delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor));
202+
203+
// Add +-20% jitter to avoid synchronized heartbeat spikes across many clients.
204+
const jitter = 0.8 + Math.random() * 0.4;
205+
delay = Math.max(1000, Math.round(delay * jitter));
206+
}
207+
208+
return delay;
209+
};
210+
211+
const tick = () => {
212+
const now = Date.now();
213+
214+
if (document.visibilityState !== 'visible' || !navigator.onLine) {
215+
timeoutId = window.setTimeout(tick, getDelayMs());
216+
return;
217+
}
218+
219+
if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) {
220+
timeoutId = window.setTimeout(tick, getDelayMs());
221+
return;
222+
}
223+
224+
const result = pushSessionNow('heartbeat');
225+
if (phase3AdaptiveBackoffJitter) {
226+
// Only reset on a successful send; 'skipped' (prerequisites not ready)
227+
// should not grow the backoff — those aren't push failures.
228+
if (result === 'sent') heartbeatFailuresRef.current = 0;
229+
}
230+
231+
timeoutId = window.setTimeout(tick, getDelayMs());
232+
};
233+
234+
timeoutId = window.setTimeout(tick, getDelayMs());
235+
236+
return () => {
237+
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
238+
};
239+
}, [
240+
heartbeatIntervalMs,
241+
heartbeatMaxBackoffMs,
242+
phase2VisibleHeartbeat,
243+
phase3AdaptiveBackoffJitter,
244+
pushSessionNow,
245+
]);
55246
}

src/app/pages/client/ClientRoot.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ export function ClientRoot({ children }: ClientRootProps) {
254254

255255
useSyncNicknames(mx);
256256
useLogoutListener(mx);
257-
useAppVisibility(mx);
257+
useAppVisibility(mx, activeSession);
258258

259259
useEffect(
260260
() => () => {

0 commit comments

Comments
 (0)