import type { AppContext, AppModule } from '@/app/app-context'; import { startSmartPollLoop, VisibilityHub, type SmartPollLoopHandle } from '@/services/runtime'; export interface RefreshRegistration { name: string; fn: () => Promise; intervalMs: number; condition?: () => boolean; runImmediately?: boolean; } export class RefreshScheduler implements AppModule { private ctx: AppContext; private refreshRunners = new Map(); private flushTimeoutIds = new Set>(); private hiddenSince = 0; private visibilityHub = new VisibilityHub(); constructor(ctx: AppContext) { this.ctx = ctx; } init(): void {} destroy(): void { for (const timeoutId of this.flushTimeoutIds) { clearTimeout(timeoutId); } this.flushTimeoutIds.clear(); for (const { loop } of this.refreshRunners.values()) { loop.stop(); } this.refreshRunners.clear(); this.visibilityHub.destroy(); } setHiddenSince(ts: number): void { this.hiddenSince = ts; } getHiddenSince(): number { return this.hiddenSince; } scheduleRefresh( name: string, fn: () => Promise, intervalMs: number, condition?: () => boolean, options: { runImmediately?: boolean } = {}, ): void { this.refreshRunners.get(name)?.loop.stop(); const loop = startSmartPollLoop(async () => { if (this.ctx.isDestroyed) return; if (condition && !condition()) return; if (this.ctx.inFlight.has(name)) return; this.ctx.inFlight.add(name); try { return await fn(); } finally { this.ctx.inFlight.delete(name); } }, { intervalMs, pauseWhenHidden: true, refreshOnVisible: false, runImmediately: options.runImmediately ?? false, maxBackoffMultiplier: 4, visibilityHub: this.visibilityHub, onError: (e) => { console.error(`[App] Refresh ${name} failed:`, e); }, }); this.refreshRunners.set(name, { loop, intervalMs }); } flushStaleRefreshes(): void { if (!this.hiddenSince) return; const hiddenMs = Date.now() - this.hiddenSince; this.hiddenSince = 0; for (const timeoutId of this.flushTimeoutIds) { clearTimeout(timeoutId); } this.flushTimeoutIds.clear(); // Collect stale tasks and sort by interval ascending (highest-frequency first) const stale: { loop: SmartPollLoopHandle; intervalMs: number }[] = []; for (const entry of this.refreshRunners.values()) { if (hiddenMs >= entry.intervalMs) { stale.push(entry); } } stale.sort((a, b) => a.intervalMs - b.intervalMs); // Tiered stagger: first 4 gaps are 100ms (covering tasks 1-5), remaining gaps are 300ms const FLUSH_STAGGER_FAST_MS = 100; const FLUSH_STAGGER_SLOW_MS = 300; let stagger = 0; let idx = 0; for (const entry of stale) { const delay = stagger; stagger += (idx < 4) ? FLUSH_STAGGER_FAST_MS : FLUSH_STAGGER_SLOW_MS; idx++; const timeoutId = setTimeout(() => { this.flushTimeoutIds.delete(timeoutId); entry.loop.trigger(); }, delay); this.flushTimeoutIds.add(timeoutId); } } registerAll(registrations: RefreshRegistration[]): void { for (const reg of registrations) { this.scheduleRefresh(reg.name, reg.fn, reg.intervalMs, reg.condition, { runImmediately: reg.runImmediately, }); } } }