From 95cb9d4df28bd6619af31ca6fa6436f2cfbb3339 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:52:40 +0100 Subject: [PATCH] feat: add background retry scheduler for federation password sync Replace the give-up-after-3-retries path with a persistent background scheduler: 10 retries every 30s (5 min), then 12 retries every 5 min (1 hr). Active timers are keyed by origin so rapid password changes cancel and replace the previous loop. All timers are cleared on logout via clearPasswordSyncTimers() called from instanceStore.reset(). --- packages/web/src/stores/instanceStore.ts | 6 ++ packages/web/src/utils/federationOps.ts | 89 +++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index becaae88..02237e6b 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -5,6 +5,10 @@ import { useAuthStore } from './authStore'; import { setApiForOriginResolver, setUserIdForOriginResolver, setOriginFromHostnameResolver, useSpaceStore } from './spaceStore'; import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket'; import { syncProfileToRemote } from '../utils/profileSync'; +// Circular dependency: federationOps imports useInstanceStore, instanceStore imports this. +// Safe because both modules access each other lazily (at call time, not import time). +// clearPasswordSyncTimers itself does not reference useInstanceStore. +import { clearPasswordSyncTimers } from '../utils/federationOps'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -696,6 +700,8 @@ export const useInstanceStore = create((set, get) => ({ // Tear down all remote WebSocket connections disconnectAllRemote(); + clearPasswordSyncTimers(); + set({ instances: [], isLoading: false, error: null, _autoConnectDone: false, pendingSyncOrigins: [] }); // Token cache preserved — scoped per user, survives logout for seamless reconnect }, diff --git a/packages/web/src/utils/federationOps.ts b/packages/web/src/utils/federationOps.ts index bf0bf147..3232afd7 100644 --- a/packages/web/src/utils/federationOps.ts +++ b/packages/web/src/utils/federationOps.ts @@ -29,11 +29,88 @@ async function retryWithBackoff( throw lastError; } +// ─── Background retry state ───────────────────────────────────────────── + +/** Active retry timers per origin — cleared on new password change or logout */ +const activeRetryTimers = new Map>(); + +/** Cancel any active retry loop for the given origin */ +function cancelRetryTimer(origin: string): void { + const timer = activeRetryTimers.get(origin); + if (timer) { + clearTimeout(timer); + activeRetryTimers.delete(origin); + } +} + +/** Cancel all active retry loops (called on logout) */ +export function clearPasswordSyncTimers(): void { + for (const timer of activeRetryTimers.values()) { + clearTimeout(timer); + } + activeRetryTimers.clear(); +} + +/** + * Schedule background retries for a failed password sync. + * Retry schedule: every 30s for 5 min, then every 5 min for 1 hour. + */ +function scheduleBackgroundRetry( + origin: string, + newPassword: string, +): void { + // Build the retry schedule: [delayMs, ...] + const schedule: number[] = [ + ...Array(10).fill(30_000), // 10 × 30s = 5 min + ...Array(12).fill(300_000), // 12 × 5min = 60 min + ]; + + let attempt = 0; + + function tryNext(): void { + if (attempt >= schedule.length) { + // Exhausted — mark as pending and stop + useInstanceStore.getState().setPendingPasswordSync(origin, true); + activeRetryTimers.delete(origin); + return; + } + + const delay = schedule[attempt]!; + attempt++; + + const timer = setTimeout(async () => { + try { + // Look up current instance from store — the original reference may be + // stale (token refreshed, instance reconnected) after minutes/hours + const current = useInstanceStore.getState().instances.find(i => i.origin === origin); + if (!current || current.status !== 'connected') { + // Instance was removed or disconnected — stop retrying + activeRetryTimers.delete(origin); + return; + } + + const response = await current.api.users.changePassword({ newPassword }); + useInstanceStore.getState().updateInstanceToken(origin, response.token); + useInstanceStore.getState().setPendingPasswordSync(origin, false); + activeRetryTimers.delete(origin); + } catch { + // Still failing — schedule next attempt + tryNext(); + } + }, delay); + + activeRetryTimers.set(origin, timer); + } + + tryNext(); +} + // ─── Password change propagation ──────────────────────────────────────── /** * Change password on all connected remote instances. * For federated users, only newPassword is needed (JWT auth is sufficient). + * Failed instances get background retry scheduling. */ export async function changePasswordOnRemotes(newPassword: string): Promise { const { instances } = useInstanceStore.getState(); @@ -41,6 +118,11 @@ export async function changePasswordOnRemotes(newPassword: string): Promise => { try { @@ -52,11 +134,14 @@ export async function changePasswordOnRemotes(newPassword: string): Promise