fix(desktop): recover failed gateway switch setup
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { withTimeout } from './with-timeout'
|
||||
|
||||
describe('withTimeout', () => {
|
||||
it('rejects with an onTimeout exception instead of letting it escape the timer callback', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const callbackFailure = new Error('abort callback failed')
|
||||
|
||||
const result = withTimeout(new Promise<never>(() => undefined), 10, 'work timed out', () => {
|
||||
throw callbackFailure
|
||||
})
|
||||
|
||||
const rejection = expect(result).rejects.toBe(callbackFailure)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await rejection
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,8 @@ export function isTimeoutError(error: unknown): error is TimeoutError {
|
||||
|
||||
/** Settle with `promise`, or reject with a TimeoutError after `ms`.
|
||||
* `onTimeout` runs synchronously before the rejection is published so callers
|
||||
* can revoke ownership of work that would otherwise keep running unowned. */
|
||||
* can revoke ownership of work that would otherwise keep running unowned. If
|
||||
* that callback throws, its error becomes this promise's rejection. */
|
||||
export function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
@@ -24,7 +25,14 @@ export function withTimeout<T>(
|
||||
const timer = setTimeout(() => {
|
||||
const error = new TimeoutError(message)
|
||||
|
||||
onTimeout?.(error)
|
||||
try {
|
||||
onTimeout?.(error)
|
||||
} catch (onTimeoutError) {
|
||||
reject(onTimeoutError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reject(error)
|
||||
}, ms)
|
||||
|
||||
|
||||
@@ -132,6 +132,52 @@ describe('beginGatewaySwitch / endGatewaySwitch — the shared switch commit poi
|
||||
off()
|
||||
})
|
||||
|
||||
it('tears down its barrier when the registered lifecycle throws before the wipe', () => {
|
||||
const failure = new Error('machine-context reset failed')
|
||||
|
||||
const off = registerGatewaySwitchLifecycle({
|
||||
beforeConnectionSwitch: () => {
|
||||
throw failure
|
||||
},
|
||||
refreshSessions: vi.fn(async () => undefined)
|
||||
})
|
||||
|
||||
expect(() => beginGatewaySwitch()).toThrow(failure)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
// No wipe started, so the still-active source remains intact and needs no
|
||||
// repaint. A later switch can acquire and release barrier ownership.
|
||||
expect($activeSessionId.get()).toBe('a93bb39d')
|
||||
expect($sessions.get()).toHaveLength(1)
|
||||
|
||||
off()
|
||||
const next = beginGatewaySwitch()
|
||||
expect($gatewaySwitching.get()).toBe(true)
|
||||
endGatewaySwitch(next)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('recovers the active source and tears down its barrier when the wipe throws partway through', async () => {
|
||||
const failure = new Error('profile fetch invalidation failed')
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const off = registerGatewaySwitchLifecycle({ beforeConnectionSwitch: () => undefined, refreshSessions })
|
||||
|
||||
vi.mocked(invalidateProfileListFetches).mockImplementationOnce(() => {
|
||||
throw failure
|
||||
})
|
||||
|
||||
expect(() => beginGatewaySwitch()).toThrow(failure)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
await vi.waitFor(() => expect(refreshSessions).toHaveBeenCalledTimes(1))
|
||||
expect($sessionsLoading.get()).toBe(false)
|
||||
|
||||
// The failed token cannot strand or lower ownership acquired afterwards.
|
||||
const next = beginGatewaySwitch()
|
||||
expect($gatewaySwitching.get()).toBe(true)
|
||||
endGatewaySwitch(next)
|
||||
expect($gatewaySwitching.get()).toBe(false)
|
||||
off()
|
||||
})
|
||||
|
||||
it('the barrier belongs to the LATEST switch: an older switch ending mid-commit of a newer one is a no-op', () => {
|
||||
const older = beginGatewaySwitch()
|
||||
const newer = beginGatewaySwitch()
|
||||
|
||||
@@ -78,11 +78,37 @@ export function registerGatewaySwitchLifecycle(lifecycle: GatewaySwitchLifecycle
|
||||
*/
|
||||
export function beginGatewaySwitch(): GatewaySwitchToken {
|
||||
const token = ++latestSwitchToken
|
||||
$gatewaySwitching.set(true)
|
||||
switchLifecycle?.beforeConnectionSwitch()
|
||||
wipeSessionListsForGatewaySwitch()
|
||||
let wipeStarted = false
|
||||
|
||||
return token
|
||||
$gatewaySwitching.set(true)
|
||||
|
||||
try {
|
||||
switchLifecycle?.beforeConnectionSwitch()
|
||||
wipeStarted = true
|
||||
wipeSessionListsForGatewaySwitch()
|
||||
|
||||
return token
|
||||
} catch (error) {
|
||||
// No caller received this token, so begin owns cleanup. Token-aware teardown
|
||||
// preserves a newer recursively-started switch, if lifecycle code began one.
|
||||
const stillOwnsSwitch = token === latestSwitchToken
|
||||
|
||||
endGatewaySwitch(token)
|
||||
|
||||
// A synchronous wipe has no rollback: once it starts, some outgoing-source
|
||||
// stores may already be empty. Repaint the still-active source best-effort.
|
||||
// A lifecycle failure happens before the wipe and leaves lists untouched.
|
||||
// If a nested switch superseded this one, its owner is responsible instead.
|
||||
if (wipeStarted && stillOwnsSwitch) {
|
||||
try {
|
||||
recoverActiveSourceAfterFailedGatewaySwitch()
|
||||
} catch {
|
||||
// Recovery must never replace the original commit failure.
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user