fix(renderer): re-arm runtime event gate when reconcile throws

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-10 23:20:50 -07:00
co-authored by Orca
parent 756a24dd73
commit f1bb98f1ef
2 changed files with 83 additions and 35 deletions
@@ -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<string, { status: string } | null>()
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'])
+44 -35
View File
@@ -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)