fix(frontend): preserve queued intents and fresh runtime snapshots

This commit is contained in:
Tunglies
2026-10-06 14:27:36 +08:00
parent 897d030cac
commit c9a54ecd38
21 changed files with 370 additions and 108 deletions
@@ -23,7 +23,7 @@ vi.mock('react-i18next', () => ({
vi.mock('@mui/material', () => ({ Alert: 'div' }))
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
vi.mock('@/store/app-store-context', () => ({
useAppDispatch: () => store.dispatch,
useAppReads: () => ({ readRunState: getRuntimeState }),
useRunState: () => store.runState,
}))
vi.mock('@/services/cmds', () => ({
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
import {
continueWithSidecar,
getRuntimeState,
installService,
reinstallService,
repairService,
@@ -14,14 +13,14 @@ import {
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useAppDispatch, useRunState } from '@/store/app-store-context'
import { useAppReads, useRunState } from '@/store/app-store-context'
export const ServiceMigrationDialog = () => {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [stateRefreshFailed, setStateRefreshFailed] = useState(false)
const [workflowIncomplete, setWorkflowIncomplete] = useState(false)
const dispatch = useAppDispatch()
const { readRunState } = useAppReads()
const runState = useRunState()
// Whether the service needs a decision is derived once, in Rust, and travels with the
// snapshot; a failed refresh is treated as needing one, since we cannot tell otherwise.
@@ -59,8 +58,7 @@ export const ServiceMigrationDialog = () => {
// One store entry to refresh, so there is nothing left to keep coherent by hand.
const refreshRunState = async () => {
try {
const data = await getRuntimeState()
dispatch({ type: 'runState/loaded', runState: data })
const data = await readRunState()
setStateRefreshFailed(false)
return data
} catch (error) {
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
import { useDialogFailure } from '@/pages/_layout/hooks'
import {
getRuntimeState,
installService,
patchVergeConfig,
reinstallService,
@@ -23,6 +22,7 @@ import {
type ServiceRequest,
type ServiceRequestReason,
} from '@/services/service-request'
import { useAppReads } from '@/store/app-store-context'
import getSystem from '@/utils/get-system'
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
@@ -84,6 +84,7 @@ const STEP_MESSAGE = {
} as const
export const SysproxyPrivilegeDialog = () => {
const { readRunState } = useAppReads()
const { t } = useTranslation()
const { failure, dismiss } = useDialogFailure()
const asked = useSyncExternalStore(subscribeServiceRequest, getServiceRequest)
@@ -135,7 +136,7 @@ export const SysproxyPrivilegeDialog = () => {
errorNotice: false,
})
const runState = await getRuntimeState()
const runState = await readRunState()
const usingAdminFallback =
remedy !== 'reinstallAndRestart' &&
getSystem() === 'windows' &&
+3 -3
View File
@@ -458,7 +458,7 @@ export const ProxyChain = ({
if (targetGroup) {
try {
await mutate(() => selectNodeForGroup(targetGroup, 'DIRECT'), {
id: 'select-proxy-node',
id: `select-proxy-node:${targetGroup}`,
errorNotice: false,
})
recordSelection(targetGroup, 'DIRECT')
@@ -469,7 +469,7 @@ export const ProxyChain = ({
() =>
selectNodeForGroup(targetGroup, currentProxyChain[0].name),
{
id: 'select-proxy-node',
id: `select-proxy-node:${targetGroup}`,
errorNotice: false,
},
)
@@ -539,7 +539,7 @@ export const ProxyChain = ({
await mutate(
() => selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name),
{
id: 'select-proxy-node',
id: `select-proxy-node:${targetGroup || 'GLOBAL'}`,
errorNotice: false,
},
)
@@ -257,7 +257,10 @@ export const BackupHistoryViewer = ({
showNotice.success('settings.modals.backup.messages.restoreSuccess')
setIsRestarting(true)
window.setTimeout(() => {
void restartApp().catch((err: unknown) => {
void mutate(() => restartApp(), {
id: 'restart-app',
errorNotice: false,
}).catch((err: unknown) => {
setIsRestarting(false)
showNotice.error(err)
})
+9 -2
View File
@@ -41,6 +41,7 @@ import {
saveDnsConfig,
validateDnsConfig,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { MonacoEditorInstance } from '@/types/monaco'
@@ -519,7 +520,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
config = parsedConfig as Record<string, any>
}
await saveDnsConfig(config)
await mutate(() => saveDnsConfig(config), {
id: 'save-dns-config',
errorNotice: false,
})
const validation = await validateDnsConfig()
@@ -558,7 +562,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
if (dnsEnabled) {
await applyDnsConfig(true)
await mutate(() => applyDnsConfig(true), {
id: 'apply-dns-config',
errorNotice: false,
})
mutateClash()
}
+11 -3
View File
@@ -22,6 +22,7 @@ import { DEFAULT_HOVER_DELAY } from '@/components/proxy/proxy-group-navigator'
import { useVerge } from '@/hooks/use-verge'
import { useWindowDecorations } from '@/hooks/use-window'
import { copyIconFile, getAppDir } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system'
@@ -536,7 +537,9 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
})
if (selected) {
await copyIconFile(`${selected}`, 'common')
await mutate(() => copyIconFile(`${selected}`, 'common'), {
id: 'copy-icon:common',
})
await initIconPath()
onChangeData({ common_tray_icon: true })
patchVerge({ common_tray_icon: true })
@@ -588,7 +591,10 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
],
})
if (selected) {
await copyIconFile(`${selected}`, 'sysproxy')
await mutate(
() => copyIconFile(`${selected}`, 'sysproxy'),
{ id: 'copy-icon:sysproxy' },
)
await initIconPath()
onChangeData({ sysproxy_tray_icon: true })
patchVerge({ sysproxy_tray_icon: true })
@@ -636,7 +642,9 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
],
})
if (selected) {
await copyIconFile(`${selected}`, 'tun')
await mutate(() => copyIconFile(`${selected}`, 'tun'), {
id: 'copy-icon:tun',
})
await initIconPath()
onChangeData({ tun_tray_icon: true })
patchVerge({ tun_tray_icon: true })
@@ -43,6 +43,7 @@ import {
getSystemProxy,
patchVergeConfig,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
import getSystem from '@/utils/get-system'
@@ -374,9 +375,14 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
: currentSysProxy?.enable
if (isProxyActive) {
await patchVergeConfig({ enable_system_proxy: false })
await sleep(50)
await patchVergeConfig({ enable_system_proxy: true })
await mutate(
async () => {
await patchVergeConfig({ enable_system_proxy: false })
await sleep(50)
await patchVergeConfig({ enable_system_proxy: true })
},
{ id: 'patch-verge-config', errorNotice: false },
)
await invalidateProxyState()
}
}
+5 -1
View File
@@ -74,7 +74,11 @@ export const useProfiles = () => {
const patchCurrent = useCallback(
async (value: Partial<IProfileItem>) => {
if (profiles?.current) {
await patchProfile(profiles.current, value)
const uid = profiles.current
await mutate(() => patchProfile(uid, value), {
id: `patch-profile:${uid}`,
errorNotice: false,
})
void mutateProfiles()
}
},
+51
View File
@@ -0,0 +1,51 @@
import { selectNodeForGroup } from 'tauri-plugin-mihomo-api'
import { expect, it, vi } from 'vitest'
import { useProxySelection } from './use-proxy-selection'
const record = vi.hoisted(() => vi.fn())
vi.mock('react', () => ({
useCallback: (callback: unknown) => callback,
useRef: (current: unknown) => ({ current }),
}))
vi.mock('@/hooks/use-record-selection', () => ({
useRecordSelection: () => record,
useForgetSelection: () => vi.fn(),
}))
vi.mock('@/hooks/use-verge', () => ({ useVerge: () => ({ verge: {} }) }))
vi.mock('tauri-plugin-mihomo-api', () => ({
selectNodeForGroup: vi.fn(),
unfixedProxy: vi.fn(),
}))
vi.mock('@/services/cmds', () => ({ syncTrayProxySelection: vi.fn() }))
vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } }))
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
it('applies and records independent group selections while another group is pending', async () => {
let finishFirst!: () => void
vi.mocked(selectNodeForGroup)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirst = resolve
}),
)
.mockResolvedValueOnce(undefined)
const firstSuccess = vi.fn()
const secondSuccess = vi.fn()
const first = useProxySelection({ onSuccess: firstSuccess })
const second = useProxySelection({ onSuccess: secondSuccess })
first.changeProxy('Group A', 'Node A')
second.changeProxy('Group B', 'Node B')
await vi.waitFor(() =>
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
)
expect(selectNodeForGroup).toHaveBeenCalledTimes(2)
expect(firstSuccess).not.toHaveBeenCalled()
expect(secondSuccess).toHaveBeenCalledOnce()
finishFirst()
await vi.waitFor(() =>
expect(record).toHaveBeenCalledWith('Group A', 'Node A'),
)
expect(firstSuccess).toHaveBeenCalledOnce()
})
+8 -11
View File
@@ -78,17 +78,14 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
}
try {
if (isFixedProxy) {
await mutate(() => unfixedProxy(groupName), {
id: 'select-proxy-node',
errorNotice: false,
})
} else {
await mutate(() => selectNodeForGroup(groupName, proxyName), {
id: 'select-proxy-node',
errorNotice: false,
})
}
const result = await mutate(
() =>
isFixedProxy
? unfixedProxy(groupName)
: selectNodeForGroup(groupName, proxyName),
{ id: `select-proxy-node:${groupName}`, errorNotice: false },
)
if (!result.ok) return
onSuccess?.()
syncTraySelection()
if (isFixedProxy) {
+2 -2
View File
@@ -11,7 +11,7 @@ export const useRecordSelection = () => {
return useCallback(async (groupName: string, proxyName: string) => {
try {
await mutate(() => recordSelectedNode(groupName, proxyName), {
id: 'record-selected-node',
id: `persist-selected-node:${groupName}`,
errorNotice: false,
})
} catch (error) {
@@ -24,7 +24,7 @@ export const useForgetSelection = () => {
return useCallback(async (groupName: string) => {
try {
await mutate(() => forgetSelectedNode(groupName), {
id: 'forget-selected-node',
id: `persist-selected-node:${groupName}`,
errorNotice: false,
})
} catch (error) {
+4 -13
View File
@@ -1,11 +1,6 @@
import {
getAppUptime,
getRuntimeState,
type RunState,
type RunningMode,
} from '@/services/cmds'
import { getAppUptime, type RunState, type RunningMode } from '@/services/cmds'
import { useQuery } from '@/services/query-client'
import { useAppDispatch, useRunState } from '@/store/app-store-context'
import { useAppReads, useRunState } from '@/store/app-store-context'
const appUptimeQueryKey = ['appUptime'] as const
@@ -25,7 +20,7 @@ const unknownRunState: RunState = {
/** Event-driven run state; Rust owns all derived availability decisions. */
export function useSystemState() {
const dispatch = useAppDispatch()
const { readRunState } = useAppReads()
const snapshot = useRunState()
const runState = snapshot ?? unknownRunState
@@ -37,11 +32,7 @@ export function useSystemState() {
isServiceMode: runState.mode === 'Service',
isTunModeAvailable: runState.tunCapable,
serviceNeedsAttention: runState.serviceNeedsAttention,
mutateSystemState: async () => {
const fresh = await getRuntimeState()
dispatch({ type: 'runState/loaded', runState: fresh })
return fresh
},
mutateSystemState: readRunState,
isLoading: snapshot == null,
}
}
+3
View File
@@ -1,5 +1,7 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createStoreReads } from '@/store/store-reads'
import { useEventBus } from './use-event-bus'
const listen = vi.hoisted(() =>
@@ -25,6 +27,7 @@ vi.mock('react', async (importOriginal) => {
})
vi.mock('@/store/app-store-context', () => ({
useAppDispatch: () => dispatch,
useAppReads: () => createStoreReads(dispatch),
}))
vi.mock('@/services/cmds', () => ({
getPendingFailures: vi.fn().mockResolvedValue([]),
+5 -26
View File
@@ -2,13 +2,8 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEffect } from 'react'
import {
getPendingFailures,
getRuntimeState,
getSidecarFailure,
} from '@/services/cmds'
import { revalidateQueries } from '@/services/query-client'
import { useAppDispatch } from '@/store/app-store-context'
import { useAppDispatch, useAppReads } from '@/store/app-store-context'
import { createEventBus } from './create-bus-handlers'
@@ -21,31 +16,15 @@ export const useEventBus = (
handleNotice: (payload: [string, string]) => void,
) => {
const dispatch = useAppDispatch()
const reads = useAppReads()
useEffect(() => {
const revalidateKeys = (keys: readonly string[]) => {
void revalidateQueries(keys.map((key) => [key]))
}
const readPendingFailures = () => {
getSidecarFailure()
.then((snapshot) =>
dispatch({ type: 'sidecarFailure/loaded', snapshot }),
)
.catch((error) => {
console.warn('[bus] Sidecar failure could not be read:', error)
})
getPendingFailures()
.then((failures) =>
dispatch({ type: 'pendingFailures/loaded', failures }),
)
.catch((error) => {
console.warn('[bus] pending failures could not be read:', error)
})
}
const { readPendingFailures } = reads
const readRunState = () => {
getRuntimeState()
.then((runState) => dispatch({ type: 'runState/loaded', runState }))
.catch(() => {})
void reads.readRunState().catch(() => {})
}
const bus = createEventBus({
@@ -91,5 +70,5 @@ export const useEventBus = (
for (const unlisten of unlisteners.splice(0)) unlisten()
void unlistenFocus.then((unlisten) => unlisten())
}
}, [dispatch, handleNotice])
}, [dispatch, handleNotice, reads])
}
+33
View File
@@ -0,0 +1,33 @@
import { expect, it, vi } from 'vitest'
import { mutate } from './mutate'
vi.mock('@/services/notice-service', () => ({
showNotice: { error: vi.fn(), warning: vi.fn(), success: vi.fn() },
}))
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
it('executes every queued setting write even when its predecessor fails', async () => {
let reject!: (error: Error) => void
const first = mutate(
() =>
new Promise<void>((_resolve, rejectFirst) => {
reject = rejectFirst
}),
{ id: 'patch-verge-config', errorNotice: false },
).catch((error) => error)
const writeSecond = vi.fn().mockResolvedValue(undefined)
const onFulfilled = vi.fn()
const second = mutate(writeSecond, {
id: 'patch-verge-config',
onFulfilled,
errorNotice: false,
})
expect(writeSecond).not.toHaveBeenCalled()
const error = new Error('first write failed')
reject(error)
expect(await first).toBe(error)
expect(await second).toEqual({ ok: true, value: undefined })
expect(writeSecond).toHaveBeenCalledOnce()
expect(onFulfilled).toHaveBeenCalledOnce()
})
+12 -6
View File
@@ -6,7 +6,7 @@ import type { AppStoreAction } from '@/store/app-state'
type MutateNoticeInput = Parameters<typeof showNotice.error>[0]
export interface MutateOptions<T> {
/** Busy/in-flight identity; a second intent with the same id resolves `{ busy: true }`. */
/** Commands with the same identity execute in request order. */
id: string
/** Applied before the command runs; undone by `rollback` on failure. */
optimistic?: () => void
@@ -34,7 +34,7 @@ const isBusyOutcome = (value: unknown): boolean =>
value !== null &&
(value as { status?: unknown }).status === 'busy'
const inFlight = new Set<string>()
const inFlight = new Map<string, Promise<void>>()
let storeDispatch: ((action: AppStoreAction) => void) | null = null
/** The app store registers its dispatcher so the funnel can record busy state. */
@@ -54,12 +54,17 @@ export async function mutate<T>(
options: MutateOptions<T>,
): Promise<MutateResult<T>> {
const { id } = options
if (inFlight.has(id)) return { ok: false, busy: true }
inFlight.add(id)
const previous = inFlight.get(id)
let release!: () => void
const settled = new Promise<void>((resolve) => {
release = resolve
})
inFlight.set(id, settled)
if (previous) await previous
storeDispatch?.({ type: 'busy/started', id })
options.optimistic?.()
try {
options.optimistic?.()
const value = await invoke()
if (isBusyOutcome(value)) {
if (options.busyNotice !== undefined) {
@@ -84,7 +89,8 @@ export async function mutate<T>(
}
throw error
} finally {
inFlight.delete(id)
release()
if (inFlight.get(id) === settled) inFlight.delete(id)
storeDispatch?.({ type: 'busy/settled', id })
}
}
+11
View File
@@ -5,11 +5,22 @@ import type {
AppStoreState,
ProfileUpdatesState,
} from './app-state'
import type { createStoreReads } from './store-reads'
export const AppStateContext = createContext<AppStoreState | null>(null)
export const AppDispatchContext =
createContext<AppStoreActionDispatcher | null>(null)
export const AppReadsContext = createContext<ReturnType<
typeof createStoreReads
> | null>(null)
export const useAppReads = () => {
const reads = use(AppReadsContext)
if (!reads) throw new Error('app store is missing its provider')
return reads
}
export type AppStoreActionDispatcher = (action: AppStoreAction) => void
const useSelector = <T>(select: (state: AppStoreState) => T): T => {
+31 -29
View File
@@ -1,15 +1,19 @@
import { useEffect, useReducer } from 'react'
import { useCallback, useEffect, useMemo, useReducer } from 'react'
import type { ReactNode } from 'react'
import {
getPendingFailures,
getRuntimeState,
getSidecarFailure,
} from '@/services/cmds'
import { bindStoreDispatch } from '@/services/mutate'
import { appStoreReducer, initialAppStoreState } from './app-state'
import { AppDispatchContext, AppStateContext } from './app-store-context'
import {
type AppStoreAction,
appStoreReducer,
initialAppStoreState,
} from './app-state'
import {
AppDispatchContext,
AppReadsContext,
AppStateContext,
} from './app-store-context'
import { createStoreReads } from './store-reads'
/**
* Single app store (React context + reducer, per the frozen design). Event-bus
@@ -20,6 +24,16 @@ import { AppDispatchContext, AppStateContext } from './app-store-context'
export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
const [state, dispatch] = useReducer(appStoreReducer, initialAppStoreState)
const reads = useMemo(() => createStoreReads(dispatch), [dispatch])
const dispatchEvent = useCallback(
(action: AppStoreAction) => {
if (action.type === 'runState/loaded')
reads.acceptRunState(action.runState)
else dispatch(action)
},
[reads],
)
useEffect(() => {
bindStoreDispatch(dispatch)
return () => bindStoreDispatch(null)
@@ -27,26 +41,9 @@ export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
useEffect(() => {
const readRunState = () => {
getRuntimeState()
.then((runState) => dispatch({ type: 'runState/loaded', runState }))
.catch(() => {})
}
const readPendingFailures = () => {
getSidecarFailure()
.then((snapshot) =>
dispatch({ type: 'sidecarFailure/loaded', snapshot }),
)
.catch((error) => {
console.warn('[app-store] Sidecar failure could not be read:', error)
})
getPendingFailures()
.then((failures) =>
dispatch({ type: 'pendingFailures/loaded', failures }),
)
.catch((error) => {
console.warn('[app-store] pending failures could not be read:', error)
})
void reads.readRunState().catch(() => {})
}
const { readPendingFailures } = reads
readRunState()
readPendingFailures()
@@ -70,12 +67,17 @@ export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
window.removeEventListener('focus', readWhenFocused)
document.removeEventListener('visibilitychange', readWhenFocused)
window.clearInterval(timer)
reads.invalidate()
}
}, [])
}, [reads])
return (
<AppStateContext value={state}>
<AppDispatchContext value={dispatch}>{children}</AppDispatchContext>
<AppReadsContext value={reads}>
<AppDispatchContext value={dispatchEvent}>
{children}
</AppDispatchContext>
</AppReadsContext>
</AppStateContext>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { beforeEach, expect, it, vi } from 'vitest'
import {
getPendingFailures,
getRuntimeState,
type RunState,
} from '@/services/cmds'
import { appStoreReducer, initialAppStoreState } from './app-state'
import { createStoreReads } from './store-reads'
vi.mock('@/services/cmds', () => ({
getRuntimeState: vi.fn(),
getPendingFailures: vi.fn().mockResolvedValue([]),
getSidecarFailure: vi.fn().mockResolvedValue({ revision: 0, detail: null }),
}))
beforeEach(() => vi.clearAllMocks())
it('keeps a newer run-state event when an older snapshot returns', async () => {
let resolve!: (state: RunState) => void
vi.mocked(getRuntimeState).mockImplementationOnce(
() =>
new Promise((r) => {
resolve = r
}),
)
let state = initialAppStoreState
const reads = createStoreReads((action) => {
state = appStoreReducer(state, action)
})
const pending = reads.readRunState()
const running = { mode: 'Service' } as RunState
reads.acceptRunState(running)
resolve({ mode: 'NotRunning' } as RunState)
expect(await pending).toBe(running)
expect(state.runState).toBe(running)
})
it('keeps the newer read when run-state replies arrive in reverse order', async () => {
let resolveFirst!: (state: RunState) => void
vi.mocked(getRuntimeState)
.mockImplementationOnce(
() =>
new Promise((r) => {
resolveFirst = r
}),
)
.mockResolvedValueOnce({ mode: 'Service' } as RunState)
let state = initialAppStoreState
const reads = createStoreReads((action) => {
state = appStoreReducer(state, action)
})
const first = reads.readRunState()
const second = reads.readRunState()
await second
resolveFirst({ mode: 'NotRunning' } as RunState)
expect((await first).mode).toBe('Service')
expect(state.runState?.mode).toBe('Service')
})
it('does not restore cleared pending failures from an older read', async () => {
let resolveFirst!: (
failures: Awaited<ReturnType<typeof getPendingFailures>>,
) => void
vi.mocked(getPendingFailures)
.mockImplementationOnce(
() =>
new Promise((r) => {
resolveFirst = r
}),
)
.mockResolvedValueOnce([])
let state = initialAppStoreState
const reads = createStoreReads((action) => {
state = appStoreReducer(state, action)
})
reads.readPendingFailures()
reads.readPendingFailures()
await Promise.resolve()
resolveFirst([
{
code: 'proxy',
detail: 'old',
operation: 'systemProxyEnable',
sequence: 1,
},
])
await Promise.resolve()
expect(state.pendingFailures).toEqual([])
})
+71
View File
@@ -0,0 +1,71 @@
import {
getPendingFailures,
getRuntimeState,
getSidecarFailure,
type RunState,
} from '@/services/cmds'
import type { AppStoreAction } from './app-state'
export const createStoreReads = (
dispatch: (action: AppStoreAction) => void,
) => {
let runRevision = 0
let failureRevision = 0
let current: RunState | null = null
let latestRead: Promise<RunState> | null = null
const acceptRunState = (runState: RunState) => {
runRevision += 1
latestRead = null
current = runState
dispatch({ type: 'runState/loaded', runState })
}
const readRunState = (): Promise<RunState> => {
const revision = ++runRevision
const read: Promise<RunState> = getRuntimeState().then((runState) => {
if (revision === runRevision) {
current = runState
dispatch({ type: 'runState/loaded', runState })
}
if (latestRead && latestRead !== read) return latestRead
return current ?? runState
})
latestRead = read
return read
}
const readPendingFailures = () => {
const revision = ++failureRevision
getSidecarFailure()
.then((snapshot) => {
if (revision === failureRevision) {
dispatch({ type: 'sidecarFailure/loaded', snapshot })
}
})
.catch((error) => {
console.warn('[app-store] Sidecar failure could not be read:', error)
})
getPendingFailures()
.then((failures) => {
if (revision === failureRevision) {
dispatch({ type: 'pendingFailures/loaded', failures })
}
})
.catch((error) => {
console.warn('[app-store] pending failures could not be read:', error)
})
}
return {
acceptRunState,
readRunState,
readPendingFailures,
invalidate: () => {
runRevision += 1
failureRevision += 1
latestRead = null
},
}
}