From 38b0a87d5dcb3bd57020aa2d38d1c1afcac4609d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:08:13 +0300 Subject: [PATCH] refactor(team): extract spawn status backoff --- src/renderer/store/slices/teamSlice.ts | 23 +++--- .../team/teamMemberSpawnStatusBackoff.ts | 39 +++++++++++ .../teamMemberSpawnStatusBackoff.test.ts | 70 +++++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 src/renderer/store/team/teamMemberSpawnStatusBackoff.ts create mode 100644 test/renderer/store/teamMemberSpawnStatusBackoff.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 3a10db66..05e02473 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -51,6 +51,13 @@ import { invalidateTeamLocalStateEpoch, isTeamLocalStateEpochCurrent, } from '../team/teamLocalStateEpoch'; +import { + clearAllMemberSpawnStatusesIpcBackoffs, + clearMemberSpawnStatusesIpcBackoff, + hasMemberSpawnStatusesIpcBackoff, + isMemberSpawnStatusesIpcBackoffActive, + recordMemberSpawnStatusesIpcRetryBackoff, +} from '../team/teamMemberSpawnStatusBackoff'; import { areInboxMessageArraysEquivalent, clearTeamMessageSelectorCaches, @@ -173,7 +180,6 @@ const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); const pendingTeamPendingReplyRefreshTimers = new Map>(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; -const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); const teamRefreshBurstDiagnostics = new Map< string, { windowStartedAt: number; count: number; lastWarnAt: number } @@ -241,7 +247,7 @@ export function __resetTeamSliceModuleStateForTests(): void { clearAllPendingReplyRefreshWaits(); clearAllLastResolvedTeamDataRefreshes(); clearAllTeamLocalStateEpochs(); - memberSpawnStatusesIpcBackoffUntilByTeam.clear(); + clearAllMemberSpawnStatusesIpcBackoffs(); teamRefreshBurstDiagnostics.clear(); memberSpawnUiEqualLastWarnAtByTeam.clear(); resolvedMembersSelectorCache.clear(); @@ -274,7 +280,7 @@ function clearTeamScopedTransientState(teamName: string): void { inFlightTeamMemberActivityMetaRequests.delete(teamName); pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName); clearLastResolvedTeamDataRefreshAt(teamName); - memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); + clearMemberSpawnStatusesIpcBackoff(teamName); teamRefreshBurstDiagnostics.delete(teamName); memberSpawnUiEqualLastWarnAtByTeam.delete(teamName); clearTeamScopedSelectorCaches(teamName); @@ -660,7 +666,7 @@ export function __getTeamScopedTransientStateForTests(teamName: string): { pendingFreshTeamMemberActivityMetaRefreshes.has(teamName), hasLastResolvedTeamDataRefresh: hasLastResolvedTeamDataRefreshAt(teamName), hasCurrentLocalStateEpoch: hasTeamLocalStateEpoch(teamName), - hasMemberSpawnStatusesIpcBackoff: memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName), + hasMemberSpawnStatusesIpcBackoff: hasMemberSpawnStatusesIpcBackoff(teamName), hasTeamRefreshBurstDiagnostics: teamRefreshBurstDiagnostics.has(teamName), hasMemberSpawnUiEqualLastWarn: memberSpawnUiEqualLastWarnAtByTeam.has(teamName), }; @@ -2987,13 +2993,12 @@ export const createTeamSlice: StateCreator = (set, launchParamsByTeam: loadAllLaunchParams(), fetchMemberSpawnStatuses: async (teamName: string) => { if (!api.teams?.getMemberSpawnStatuses) return; - const backoffUntil = memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0; - if (backoffUntil > Date.now()) { + if (isMemberSpawnStatusesIpcBackoffActive(teamName)) { return; } try { const snapshot = await api.teams.getMemberSpawnStatuses(teamName); - memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); + clearMemberSpawnStatusesIpcBackoff(teamName); set((prev) => { if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { return {}; @@ -3057,9 +3062,9 @@ export const createTeamSlice: StateCreator = (set, } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("No handler registered for 'team:memberSpawnStatuses'")) { - memberSpawnStatusesIpcBackoffUntilByTeam.set( + recordMemberSpawnStatusesIpcRetryBackoff( teamName, - Date.now() + MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS + MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS ); } // ignore — spawn statuses are best-effort diff --git a/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts b/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts new file mode 100644 index 00000000..bcb6fc71 --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnStatusBackoff.ts @@ -0,0 +1,39 @@ +const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); + +export function getMemberSpawnStatusesIpcBackoffUntil(teamName: string): number { + return memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0; +} + +export function hasMemberSpawnStatusesIpcBackoff(teamName: string): boolean { + return memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName); +} + +export function isMemberSpawnStatusesIpcBackoffActive( + teamName: string, + now = Date.now() +): boolean { + return getMemberSpawnStatusesIpcBackoffUntil(teamName) > now; +} + +export function recordMemberSpawnStatusesIpcBackoffUntil( + teamName: string, + backoffUntil: number +): void { + memberSpawnStatusesIpcBackoffUntilByTeam.set(teamName, backoffUntil); +} + +export function recordMemberSpawnStatusesIpcRetryBackoff( + teamName: string, + retryBackoffMs: number, + now = Date.now() +): void { + recordMemberSpawnStatusesIpcBackoffUntil(teamName, now + retryBackoffMs); +} + +export function clearMemberSpawnStatusesIpcBackoff(teamName: string): void { + memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); +} + +export function clearAllMemberSpawnStatusesIpcBackoffs(): void { + memberSpawnStatusesIpcBackoffUntilByTeam.clear(); +} diff --git a/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts b/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts new file mode 100644 index 00000000..70ffc747 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnStatusBackoff.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAllMemberSpawnStatusesIpcBackoffs, + clearMemberSpawnStatusesIpcBackoff, + getMemberSpawnStatusesIpcBackoffUntil, + hasMemberSpawnStatusesIpcBackoff, + isMemberSpawnStatusesIpcBackoffActive, + recordMemberSpawnStatusesIpcBackoffUntil, + recordMemberSpawnStatusesIpcRetryBackoff, +} from '../../../src/renderer/store/team/teamMemberSpawnStatusBackoff'; + +afterEach(() => { + vi.useRealTimers(); + clearAllMemberSpawnStatusesIpcBackoffs(); +}); + +describe('teamMemberSpawnStatusBackoff', () => { + it('defaults to no backoff for unknown teams', () => { + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(0); + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 100)).toBe(false); + }); + + it('tracks active backoff deadlines by team', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(150); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 149)).toBe(true); + expect(isMemberSpawnStatusesIpcBackoffActive('my-team', 150)).toBe(false); + expect(isMemberSpawnStatusesIpcBackoffActive('other-team', 249)).toBe(true); + }); + + it('records retry backoff from Date.now by default', () => { + vi.setSystemTime(new Date('2026-05-22T07:00:00.000Z')); + + recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe( + new Date('2026-05-22T07:00:05.000Z').getTime() + ); + }); + + it('records retry backoff from an explicit clock for deterministic callers', () => { + recordMemberSpawnStatusesIpcRetryBackoff('my-team', 5_000, 100); + + expect(getMemberSpawnStatusesIpcBackoffUntil('my-team')).toBe(5_100); + }); + + it('clears one team backoff without touching others', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + clearMemberSpawnStatusesIpcBackoff('my-team'); + + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(getMemberSpawnStatusesIpcBackoffUntil('other-team')).toBe(250); + }); + + it('clears all recorded backoffs', () => { + recordMemberSpawnStatusesIpcBackoffUntil('my-team', 150); + recordMemberSpawnStatusesIpcBackoffUntil('other-team', 250); + + clearAllMemberSpawnStatusesIpcBackoffs(); + + expect(hasMemberSpawnStatusesIpcBackoff('my-team')).toBe(false); + expect(hasMemberSpawnStatusesIpcBackoff('other-team')).toBe(false); + }); +});