diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 679ac28509c..cc3629728b2 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -58,6 +58,45 @@ describe('buildRuntimeClientEventEnvironmentKey', () => { }) }) +describe('runtime client-event subscription gate', () => { + it('re-arms after a failed reconcile so the next publication retries', async () => { + let failNextStatusRead = false + const statusReads: string[] = [] + const runtimeStatusByEnvironmentId = new Map() + runtimeStatusByEnvironmentId.get = (environmentId: string) => { + statusReads.push(environmentId) + if (failNextStatusRead) { + failNextStatusRead = false + throw new Error('reconcile failed') + } + return undefined + } + const storeState = createHarnessStoreState({ + tabsByWorktree: {}, + runtimeEnvironments: [{ id: 'env-1' }], + runtimeStatusByEnvironmentId + }) + const harness = await loadIpcEventsHarness(storeState) + const { useAppStore } = await import('../store') + const { bumpRuntimeClientEventSubscriptionGeneration } = + await import('@/runtime/runtime-client-event-subscription-invalidation') + harness.useIpcEvents() + const reconcile = vi.mocked(useAppStore.subscribe).mock.calls[0]?.[0] as ( + state: unknown + ) => void + + bumpRuntimeClientEventSubscriptionGeneration() + failNextStatusRead = true + expect(() => reconcile(storeState)).toThrow('reconcile failed') + const readsAfterFailure = statusReads.length + + // Same store references as the failed publication: only the failure re-arm can drive this. + reconcile(storeState) + + expect(statusReads.length).toBeGreaterThan(readsAfterFailure) + }) +}) + describe('getNewlyConnectedRuntimeEnvironmentIds', () => { it('returns only environments that became connected', () => { expect(getNewlyConnectedRuntimeEnvironmentIds(['env-a'], ['env-a', 'env-b'])).toEqual(['env-b']) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 4a390bd0f2d..f817f6da7d9 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -183,7 +183,10 @@ import { type DirectSshConnectedStateOrigin } from './direct-ssh-state-routing' import { isDirectSshReconnectCoordinatorRoutingEnabled } from './direct-ssh-reconnect-rollout' -import { createRuntimeClientEventSubscriptionInvalidationGate } from '@/runtime/runtime-client-event-subscription-invalidation' +import { + bumpRuntimeClientEventSubscriptionGeneration, + createRuntimeClientEventSubscriptionInvalidationGate +} from '@/runtime/runtime-client-event-subscription-invalidation' function getShortcutPlatform(): NodeJS.Platform { if (navigator.userAgent.includes('Mac')) { @@ -1037,41 +1040,47 @@ export function useIpcEvents(): void { if (!runtimeClientEventSubscriptionInvalidation.changed(state)) { return } - const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds() - const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds) - const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds() - const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds) - if ( - nextKey === runtimeClientEventEnvironmentKey && - nextReachableKey === reachableRuntimeEnvironmentKey - ) { - return + try { + const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds() + const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds) + const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds() + const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds) + if ( + nextKey === runtimeClientEventEnvironmentKey && + nextReachableKey === reachableRuntimeEnvironmentKey + ) { + return + } + for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({ + previousDesired: runtimeClientEventEnvironmentIds, + nextDesired: nextEnvironmentIds, + previousReachable: reachableRuntimeEnvironmentIds, + nextReachable: nextReachableEnvironmentIds + })) { + runtimeProjectRefreshScheduler.request(environmentId) + } + for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds( + reachableRuntimeEnvironmentIds, + nextReachableEnvironmentIds + )) { + // No-op when the environment has no SSH bucket (e.g. web client). + useAppStore.getState().markEnvironmentSshStateStale(environmentId) + } + runtimeClientEventEnvironmentIds = nextEnvironmentIds + reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds + // Nested stale-state writes can advance the out-of-store SSH generation. + runtimeClientEventEnvironmentKey = buildRuntimeClientEventEnvironmentKey( + runtimeClientEventEnvironmentIds + ) + reachableRuntimeEnvironmentKey = buildRuntimeClientEventEnvironmentKey( + reachableRuntimeEnvironmentIds + ) + runtimeClientEventsSync.sync() + } catch (error) { + // Why: the gate already consumed this publication, so re-arm it or the subscription stays stale forever. + bumpRuntimeClientEventSubscriptionGeneration() + throw error } - for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({ - previousDesired: runtimeClientEventEnvironmentIds, - nextDesired: nextEnvironmentIds, - previousReachable: reachableRuntimeEnvironmentIds, - nextReachable: nextReachableEnvironmentIds - })) { - runtimeProjectRefreshScheduler.request(environmentId) - } - for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds( - reachableRuntimeEnvironmentIds, - nextReachableEnvironmentIds - )) { - // No-op when the environment has no SSH bucket (e.g. web client). - useAppStore.getState().markEnvironmentSshStateStale(environmentId) - } - runtimeClientEventEnvironmentIds = nextEnvironmentIds - reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds - // Nested stale-state writes can advance the out-of-store SSH generation. - runtimeClientEventEnvironmentKey = buildRuntimeClientEventEnvironmentKey( - runtimeClientEventEnvironmentIds - ) - reachableRuntimeEnvironmentKey = buildRuntimeClientEventEnvironmentKey( - reachableRuntimeEnvironmentIds - ) - runtimeClientEventsSync.sync() }) unsubs.push(runtimeClientEventsSync.stop) unsubs.push(runtimeProjectRefreshScheduler.stop)