From 25e00792de036cc74e5500efa680a3ae5c0027ac Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:24:56 +0800 Subject: [PATCH 1/6] fix(config): retain runtime after an applied core replacement --- src-tauri/src/core/manager/config.rs | 47 ++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index 437b6daf1..56e725d61 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -322,7 +322,7 @@ impl CoreManager { self.validate_and_apply(transaction).await } - /// Validates and applies the caller's transaction, committing only on success. + /// Commits the applied runtime even if restoring the system proxy subsequently fails. async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result { let outcome = self.validate_and_apply_draft().await?; if outcome.is_valid() { @@ -342,7 +342,10 @@ impl CoreManager { } let run_path = Config::write_runtime_file(&yaml).await?; - self.apply_config(run_path).await?; + let previous = self.current_core_readiness_generation(); + let result = self.apply_config(run_path).await; + self.retain_applied_runtime(&Config::runtime().await, previous, &result); + result?; // Under the lifecycle lock, so a stop for exit waits for this write instead of outrunning it. #[cfg(target_os = "macos")] { @@ -352,6 +355,21 @@ impl CoreManager { Ok(ValidationOutcome::Valid) } + fn retain_applied_runtime( + &self, + runtime: &clash_verge_draft::Draft, + previous: Option, + result: &Result<()>, + ) { + if result.is_ok() + || self + .current_core_readiness_generation() + .is_some_and(|current| Some(current) != previous) + { + runtime.apply(); + } + } + /// Applies a generated configuration through the active core owner. async fn apply_config(&self, path: PathBuf) -> Result<()> { if matches!(*self.get_running_mode(), RunningMode::Service) { @@ -443,6 +461,31 @@ mod tests { use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome}; use std::{cell::Cell, time::Duration}; + #[test] + fn a_ready_replacement_keeps_the_applied_runtime_when_proxy_restore_fails() -> anyhow::Result<()> { + use crate::{config::runtime::IRuntime, core::CoreManager}; + use clash_verge_draft::{Draft, DraftTransaction}; + + for replaced in [false, true] { + let manager = CoreManager::default(); + manager.mark_core_ready(); + let previous = manager.current_core_readiness_generation(); + let runtime = Draft::new(IRuntime::default()); + let transaction = DraftTransaction::begin(vec![&runtime])?; + runtime.edit_draft(|draft| { + draft.exists_keys.insert("replacement".into()); + }); + if replaced { + manager.mark_core_ready(); + } + let result = Err(anyhow::anyhow!("system proxy restore failed")); + manager.retain_applied_runtime(&runtime, previous, &result); + transaction.rollback(); + assert_eq!(runtime.data_arc().exists_keys.contains("replacement"), replaced); + } + Ok(()) + } + const CONFIRM_WITHIN: Duration = Duration::from_secs(5); fn staged() -> StageAttempt { From c9a54ecd3840a37f55ffba78c3816e83e7bdd8d1 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:27:36 +0800 Subject: [PATCH 2/6] fix(frontend): preserve queued intents and fresh runtime snapshots --- .../layout/service-migration-dialog.test.ts | 2 +- .../layout/service-migration-dialog.tsx | 8 +- .../layout/sysproxy-privilege-dialog.tsx | 5 +- src/components/proxy/proxy-chain.tsx | 6 +- .../setting/mods/backup-history-viewer.tsx | 5 +- src/components/setting/mods/dns-viewer.tsx | 11 ++- src/components/setting/mods/layout-viewer.tsx | 14 ++- .../setting/mods/sysproxy-viewer.tsx | 12 ++- src/hooks/use-profiles.ts | 6 +- src/hooks/use-proxy-selection.test.ts | 51 +++++++++++ src/hooks/use-proxy-selection.ts | 19 ++-- src/hooks/use-record-selection.ts | 4 +- src/hooks/use-system-state.ts | 17 +--- src/services/bus/use-event-bus.test.ts | 3 + src/services/bus/use-event-bus.ts | 31 +------ src/services/mutate.test.ts | 33 +++++++ src/services/mutate.ts | 18 ++-- src/store/app-store-context.ts | 11 +++ src/store/app-store.tsx | 60 ++++++------ src/store/store-reads.test.ts | 91 +++++++++++++++++++ src/store/store-reads.ts | 71 +++++++++++++++ 21 files changed, 370 insertions(+), 108 deletions(-) create mode 100644 src/hooks/use-proxy-selection.test.ts create mode 100644 src/services/mutate.test.ts create mode 100644 src/store/store-reads.test.ts create mode 100644 src/store/store-reads.ts diff --git a/src/components/layout/service-migration-dialog.test.ts b/src/components/layout/service-migration-dialog.test.ts index c05b9b508..8c292864d 100644 --- a/src/components/layout/service-migration-dialog.test.ts +++ b/src/components/layout/service-migration-dialog.test.ts @@ -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', () => ({ diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index 084a28fe4..756ceff24 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -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) { diff --git a/src/components/layout/sysproxy-privilege-dialog.tsx b/src/components/layout/sysproxy-privilege-dialog.tsx index 87a540a6d..fb493157c 100644 --- a/src/components/layout/sysproxy-privilege-dialog.tsx +++ b/src/components/layout/sysproxy-privilege-dialog.tsx @@ -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' && diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index d29d9ffc5..69e718486 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -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, }, ) diff --git a/src/components/setting/mods/backup-history-viewer.tsx b/src/components/setting/mods/backup-history-viewer.tsx index 64522d496..6147ab4be 100644 --- a/src/components/setting/mods/backup-history-viewer.tsx +++ b/src/components/setting/mods/backup-history-viewer.tsx @@ -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) }) diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 0e9a2bb6b..15493da24 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -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 }) { config = parsedConfig as Record } - 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 }) { } if (dnsEnabled) { - await applyDnsConfig(true) + await mutate(() => applyDnsConfig(true), { + id: 'apply-dns-config', + errorNotice: false, + }) mutateClash() } diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index df13cc45c..f818d61de 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -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((_, 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((_, 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((_, 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 }) diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 9545d8dd6..8c4db006c 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -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((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() } } diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 8b8e08dc3..8cf654765 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -74,7 +74,11 @@ export const useProfiles = () => { const patchCurrent = useCallback( async (value: Partial) => { 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() } }, diff --git a/src/hooks/use-proxy-selection.test.ts b/src/hooks/use-proxy-selection.test.ts new file mode 100644 index 000000000..d176baf66 --- /dev/null +++ b/src/hooks/use-proxy-selection.test.ts @@ -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() +}) diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index 8300c3bf2..e3a1d40e6 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -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) { diff --git a/src/hooks/use-record-selection.ts b/src/hooks/use-record-selection.ts index 6e0af590c..66e5736a8 100644 --- a/src/hooks/use-record-selection.ts +++ b/src/hooks/use-record-selection.ts @@ -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) { diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 15aa4ceee..a2079c882 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -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, } } diff --git a/src/services/bus/use-event-bus.test.ts b/src/services/bus/use-event-bus.test.ts index a0deed42c..13ba8f138 100644 --- a/src/services/bus/use-event-bus.test.ts +++ b/src/services/bus/use-event-bus.test.ts @@ -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([]), diff --git a/src/services/bus/use-event-bus.ts b/src/services/bus/use-event-bus.ts index 4a3fe2bb0..8c22e10ed 100644 --- a/src/services/bus/use-event-bus.ts +++ b/src/services/bus/use-event-bus.ts @@ -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]) } diff --git a/src/services/mutate.test.ts b/src/services/mutate.test.ts new file mode 100644 index 000000000..ce6299340 --- /dev/null +++ b/src/services/mutate.test.ts @@ -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((_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() +}) diff --git a/src/services/mutate.ts b/src/services/mutate.ts index 546a62056..ec59552ea 100644 --- a/src/services/mutate.ts +++ b/src/services/mutate.ts @@ -6,7 +6,7 @@ import type { AppStoreAction } from '@/store/app-state' type MutateNoticeInput = Parameters[0] export interface MutateOptions { - /** 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() +const inFlight = new Map>() 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( options: MutateOptions, ): Promise> { 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((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( } throw error } finally { - inFlight.delete(id) + release() + if (inFlight.get(id) === settled) inFlight.delete(id) storeDispatch?.({ type: 'busy/settled', id }) } } diff --git a/src/store/app-store-context.ts b/src/store/app-store-context.ts index 3e9ae1f72..aa2ca7351 100644 --- a/src/store/app-store-context.ts +++ b/src/store/app-store-context.ts @@ -5,11 +5,22 @@ import type { AppStoreState, ProfileUpdatesState, } from './app-state' +import type { createStoreReads } from './store-reads' export const AppStateContext = createContext(null) export const AppDispatchContext = createContext(null) +export const AppReadsContext = createContext | 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 = (select: (state: AppStoreState) => T): T => { diff --git a/src/store/app-store.tsx b/src/store/app-store.tsx index b053b910e..5571e8de4 100644 --- a/src/store/app-store.tsx +++ b/src/store/app-store.tsx @@ -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 ( - {children} + + + {children} + + ) } diff --git a/src/store/store-reads.test.ts b/src/store/store-reads.test.ts new file mode 100644 index 000000000..c8eebd51e --- /dev/null +++ b/src/store/store-reads.test.ts @@ -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>, + ) => 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([]) +}) diff --git a/src/store/store-reads.ts b/src/store/store-reads.ts new file mode 100644 index 000000000..3af3e1a03 --- /dev/null +++ b/src/store/store-reads.ts @@ -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 | null = null + + const acceptRunState = (runState: RunState) => { + runRevision += 1 + latestRead = null + current = runState + dispatch({ type: 'runState/loaded', runState }) + } + + const readRunState = (): Promise => { + const revision = ++runRevision + const read: Promise = 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 + }, + } +} From dfc729a1705194916c11bfff61017bcb3b8c10c6 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:32:59 +0800 Subject: [PATCH 3/6] refactor(backend): flatten refresh publication and retry policies --- src-tauri/src/core/notify/announce.rs | 53 ++------------------------- src-tauri/src/core/notify/mod.rs | 2 +- src-tauri/src/feat/executor.rs | 18 +++------ src-tauri/src/utils/retry.rs | 48 +++--------------------- 4 files changed, 15 insertions(+), 106 deletions(-) diff --git a/src-tauri/src/core/notify/announce.rs b/src-tauri/src/core/notify/announce.rs index b6fb11234..9b727e681 100644 --- a/src-tauri/src/core/notify/announce.rs +++ b/src-tauri/src/core/notify/announce.rs @@ -1,7 +1,6 @@ use super::{handle::Handle, notification::FrontendEvent}; -use anyhow::Result; use parking_lot::Mutex; -use std::{cell::RefCell, future::Future, time::Duration}; +use std::time::Duration; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Refresh { @@ -11,10 +10,6 @@ pub enum Refresh { Proxies, } -tokio::task_local! { - static PENDING: RefCell>; -} - const REFRESH_WINDOW: Duration = Duration::from_millis(20); static BATCH: RefreshBatch = RefreshBatch(Mutex::new(Vec::new())); @@ -42,7 +37,7 @@ impl RefreshBatch { } pub fn announce(refresh: Refresh) { - if PENDING.try_with(|pending| pending.borrow_mut().push(refresh)).is_err() && BATCH.enqueue(refresh) { + if BATCH.enqueue(refresh) { crate::AsyncHandler::spawn(|| BATCH.flush(emit)); } } @@ -56,54 +51,12 @@ fn emit(refresh: Refresh) { }); } -async fn collect_committed(operation: impl Future> + Send) -> Result<(T, Vec)> { - PENDING - .scope(RefCell::new(Vec::new()), async { - let value = operation.await?; - let refreshes = PENDING.with(|pending| pending.take()); - Ok((value, refreshes)) - }) - .await -} - -pub async fn after_commit(operation: impl Future> + Send) -> Result { - let (value, refreshes) = collect_committed(operation).await?; - // Nested transactions hand their notifications to the outer transaction. - for refresh in refreshes { - announce(refresh); - } - Ok(value) -} - #[cfg(test)] mod tests { use super::*; #[tokio::test] - async fn only_successful_transactions_publish_refreshes() -> Result<()> { - let (_, refreshes) = collect_committed(async { - announce(Refresh::Clash); - after_commit(async { - announce(Refresh::Verge); - Ok(()) - }) - .await?; - let failed = after_commit(async { - announce(Refresh::Profiles); - Err::<(), _>(anyhow::anyhow!("persistence failed")) - }) - .await; - assert!(failed.is_err()); - announce(Refresh::Clash); - Ok(()) - }) - .await?; - assert_eq!(refreshes, [Refresh::Clash, Refresh::Verge, Refresh::Clash]); - Ok(()) - } - - #[tokio::test] - async fn bursts_keep_each_refresh_kind_and_rearm_during_delivery() -> Result<()> { + async fn bursts_keep_each_refresh_kind_and_rearm_during_delivery() -> anyhow::Result<()> { let batch = RefreshBatch::default(); let delivered = Mutex::new(Vec::new()); assert!(batch.enqueue(Refresh::Clash)); diff --git a/src-tauri/src/core/notify/mod.rs b/src-tauri/src/core/notify/mod.rs index cc59f0b36..55d3a43c3 100644 --- a/src-tauri/src/core/notify/mod.rs +++ b/src-tauri/src/core/notify/mod.rs @@ -1,5 +1,5 @@ mod announce; -pub use announce::{Refresh, after_commit, announce}; +pub use announce::{Refresh, announce}; pub mod desktop; pub mod handle; pub mod notification; diff --git a/src-tauri/src/feat/executor.rs b/src-tauri/src/feat/executor.rs index 471f265b7..c301a6975 100644 --- a/src-tauri/src/feat/executor.rs +++ b/src-tauri/src/feat/executor.rs @@ -63,8 +63,6 @@ async fn ensure_effect( } Effect::ClashConfig => { manager.update_config_in_patch(update).await?; - Config::runtime().await.apply(); - announce(Refresh::Clash); } Effect::Autostart => autostart::update_launch().await?, Effect::Language => { @@ -123,7 +121,7 @@ async fn ensure_effect( Ok(()) } -async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { +pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { let manager = CoreManager::global(); let update = manager.claim_config_update(config_write)?; let clash = Config::clash().await; @@ -167,6 +165,7 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect if let Err(error) = result { transaction.rollback(); if !Arc::ptr_eq(&original_runtime, &runtime.data_arc()) { + announce(Refresh::Clash); // The Core already loaded this runtime; restoring its file would only hide that state. let runtime_path = crate::utils::dirs::app_home_dir()?.join(crate::constants::files::RUNTIME_CONFIG); snapshots.retain(|snapshot| snapshot.path != runtime_path); @@ -179,6 +178,9 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect transaction.commit(); match patch { Patch::Verge { .. } => { + if effects.contains(&Effect::ClashConfig) { + announce(Refresh::Clash); + } logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await); announce(Refresh::Verge); } @@ -187,16 +189,6 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect Ok(()) } -pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { - let runtime = Config::runtime().await; - let original = runtime.data_arc(); - let result = crate::core::notify::after_commit(apply_inner(config_write, patch, effects)).await; - if result.is_err() && !Arc::ptr_eq(&original, &runtime.data_arc()) { - announce(Refresh::Clash); - } - result -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/utils/retry.rs b/src-tauri/src/utils/retry.rs index 5becccac1..72c79017b 100644 --- a/src-tauri/src/utils/retry.rs +++ b/src-tauri/src/utils/retry.rs @@ -1,46 +1,18 @@ -use backon::{BackoffBuilder as _, ExponentialBackoff, ExponentialBuilder}; use std::{future::Future, num::NonZeroUsize, time::Duration}; -#[derive(Clone, Copy, Debug)] -pub enum Backoff { - Fixed(Duration), - #[allow(dead_code)] - Exponential { - base: Duration, - cap: Duration, - }, -} - #[derive(Clone, Copy, Debug)] pub struct RetryPolicy { pub attempts: NonZeroUsize, - pub backoff: Backoff, - pub jitter: bool, + delay: Duration, } impl RetryPolicy { pub const fn fixed(attempts: NonZeroUsize, delay: Duration) -> Self { - Self { - attempts, - backoff: Backoff::Fixed(delay), - jitter: false, - } + Self { attempts, delay } } - fn delays(self) -> ExponentialBackoff { - let (base, cap, factor) = match self.backoff { - Backoff::Fixed(delay) => (delay, delay, 1.0), - Backoff::Exponential { base, cap } => (base, cap, 2.0), - }; - let mut builder = ExponentialBuilder::default() - .with_min_delay(base) - .with_max_delay(cap) - .with_factor(factor) - .with_max_times(self.attempts.get() - 1); - if self.jitter { - builder = builder.with_jitter(); - } - builder.build() + fn delays(self) -> impl Iterator { + std::iter::repeat_n(self.delay, self.attempts.get() - 1) } } @@ -82,11 +54,7 @@ pub async fn try_strategies< mut operation: impl FnMut(S) -> Fut + Send, ) -> Result<(S, T), E> { retry( - RetryPolicy { - attempts: NonZeroUsize::MIN.saturating_add(N), - backoff: Backoff::Fixed(Duration::ZERO), - jitter: false, - }, + RetryPolicy::fixed(NonZeroUsize::MIN.saturating_add(N), Duration::ZERO), move |attempt| { let strategy = if attempt == 0 { first } else { rest[attempt - 1] }; let future = operation(strategy); @@ -145,11 +113,7 @@ mod tests { #[tokio::test] async fn retries_stop_at_success_terminal_error_or_attempt_limit() { - let policy = RetryPolicy { - attempts: NonZeroUsize::MIN.saturating_add(2), - backoff: Backoff::Fixed(Duration::ZERO), - jitter: false, - }; + let policy = RetryPolicy::fixed(NonZeroUsize::MIN.saturating_add(2), Duration::ZERO); let mut calls = 0; let success = retry(policy, |attempt| { calls += 1; From ae679cad3fb25cd94855c74c49e253f16f5f76fd Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:33:00 +0800 Subject: [PATCH 4/6] refactor(frontend): trim unused mutation and busy state APIs --- src/services/mutate.ts | 28 ---------------------------- src/store/app-state.ts | 17 ----------------- src/store/app-store.tsx | 7 ------- 3 files changed, 52 deletions(-) diff --git a/src/services/mutate.ts b/src/services/mutate.ts index ec59552ea..269629a90 100644 --- a/src/services/mutate.ts +++ b/src/services/mutate.ts @@ -1,6 +1,5 @@ import { showNotice } from '@/services/notice-service' import { revalidateQueries } from '@/services/query-client' -import type { AppStoreAction } from '@/store/app-state' /** Anything a notice call accepts: translation key, ReactNode, or failure payload. */ type MutateNoticeInput = Parameters[0] @@ -8,18 +7,12 @@ type MutateNoticeInput = Parameters[0] export interface MutateOptions { /** Commands with the same identity execute in request order. */ id: string - /** Applied before the command runs; undone by `rollback` on failure. */ - optimistic?: () => void - /** Restores pre-optimistic state when the command rejects. */ - rollback?: () => void | Promise /** Runs after a fulfilled (non-busy) result, e.g. cache merges. */ onFulfilled?: (value: T) => void /** SWR keys revalidated after a fulfilled result. */ revalidate?: readonly (readonly unknown[] | string)[] /** Shown after a fulfilled result (most flows leave this to backend notices). */ successNotice?: MutateNoticeInput - /** Shown when the command resolves with `{ status: 'busy' }`. */ - busyNotice?: MutateNoticeInput /** `false` suppresses the default error toast. */ errorNotice?: false | MutateNoticeInput } @@ -35,20 +28,6 @@ const isBusyOutcome = (value: unknown): boolean => (value as { status?: unknown }).status === 'busy' const inFlight = new Map>() -let storeDispatch: ((action: AppStoreAction) => void) | null = null - -/** The app store registers its dispatcher so the funnel can record busy state. */ -export const bindStoreDispatch = ( - dispatch: ((action: AppStoreAction) => void) | null, -) => { - storeDispatch = dispatch -} - -/** - * The single funnel for "user intent → backend command": optimistic apply, - * invoke through the services layer, Busy handling, failure rollback, and the - * error toast, in that order, for every mutating call site. - */ export async function mutate( invoke: () => Promise, options: MutateOptions, @@ -61,15 +40,10 @@ export async function mutate( }) inFlight.set(id, settled) if (previous) await previous - storeDispatch?.({ type: 'busy/started', id }) try { - options.optimistic?.() const value = await invoke() if (isBusyOutcome(value)) { - if (options.busyNotice !== undefined) { - showNotice.warning(options.busyNotice) - } return { ok: false, busy: true, value } } options.onFulfilled?.(value) @@ -81,7 +55,6 @@ export async function mutate( } return { ok: true, value } } catch (error) { - await options.rollback?.() if (options.errorNotice !== false) { showNotice.error( options.errorNotice !== undefined ? options.errorNotice : error, @@ -91,6 +64,5 @@ export async function mutate( } finally { release() if (inFlight.get(id) === settled) inFlight.delete(id) - storeDispatch?.({ type: 'busy/settled', id }) } } diff --git a/src/store/app-state.ts b/src/store/app-state.ts index 8493c7769..c0e143308 100644 --- a/src/store/app-state.ts +++ b/src/store/app-state.ts @@ -20,8 +20,6 @@ export interface AppStoreState { readonly profileUpdates: ProfileUpdatesState /** Bumped once per test-all request; test items run their delay test per bump. */ readonly testAllCounter: number - /** Mutate-funnel bookkeeping: in-flight intent ids. */ - readonly busy: ReadonlySet } export const initialAppStoreState: AppStoreState = { @@ -34,7 +32,6 @@ export const initialAppStoreState: AppStoreState = { timerRevisions: new Map(), }, testAllCounter: 0, - busy: new Set(), } export type AppStoreAction = @@ -46,8 +43,6 @@ export type AppStoreAction = | { type: 'profileUpdate/timerTick'; uid: string } | { type: 'profileLoading/set'; uids: readonly string[]; loading: boolean } | { type: 'testAll/requested' } - | { type: 'busy/started'; id: string } - | { type: 'busy/settled'; id: string } const bumpRevision = ( map: ReadonlyMap, @@ -125,17 +120,5 @@ export function appStoreReducer( return withLoading(state, action.uids, action.loading) case 'testAll/requested': return { ...state, testAllCounter: state.testAllCounter + 1 } - case 'busy/started': { - if (state.busy.has(action.id)) return state - const busy = new Set(state.busy) - busy.add(action.id) - return { ...state, busy } - } - case 'busy/settled': { - if (!state.busy.has(action.id)) return state - const busy = new Set(state.busy) - busy.delete(action.id) - return { ...state, busy } - } } } diff --git a/src/store/app-store.tsx b/src/store/app-store.tsx index 5571e8de4..b84f404c1 100644 --- a/src/store/app-store.tsx +++ b/src/store/app-store.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useMemo, useReducer } from 'react' import type { ReactNode } from 'react' -import { bindStoreDispatch } from '@/services/mutate' - import { type AppStoreAction, appStoreReducer, @@ -34,11 +32,6 @@ export const AppStoreProvider = ({ children }: { children?: ReactNode }) => { [reads], ) - useEffect(() => { - bindStoreDispatch(dispatch) - return () => bindStoreDispatch(null) - }, []) - useEffect(() => { const readRunState = () => { void reads.readRunState().catch(() => {}) From 40125d15bd99fe8ed434cc65536b0bac58761760 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:37:24 +0800 Subject: [PATCH 5/6] docs(backend): focus guardrails on architecture ownership --- src-tauri/AGENTS.md | 56 ++++++++++++++++----------------------------- 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/src-tauri/AGENTS.md b/src-tauri/AGENTS.md index 5314339b8..a9356e9fc 100644 --- a/src-tauri/AGENTS.md +++ b/src-tauri/AGENTS.md @@ -2,39 +2,23 @@ These rules supplement the repository's root `AGENTS.md`. -1. **Keep configuration ownership in `config`.** Configuration writes must use - its draft and persistence APIs. Patch orchestration must use - `feat/effects.rs` and `feat/executor.rs`; refresh notifications must use - `core::notify::announce` after successful persistence and commit. This keeps - rolled-back transactions from publishing state. If runtime and memory state - are already committed and cannot be rolled back, announce that state even - when persistence fails, while preserving the persistence error. Independent - transactions retain their boundaries and announce their own committed - results; runtime-only profile activation announces after successful - application. Cross-layer operations must preserve these ownership and - transaction boundaries. -2. **Register every new `IVerge` field.** Register the field in the - exhaustive effect registry, including an explicit `NoEffect` where - appropriate, and preserve its platform attributes. Manual field-to-flag - chains hide missing effects and are forbidden. -3. **Use `utils::retry` for bounded retries.** Do not introduce handwritten retry - loops, including `loop` plus `sleep`; preserve attempt budgets, delays and - terminal errors. Permanent watchers are the exception and must document - their exit conditions. Traversal of distinct data or port candidates is not - a retry policy. -4. **Keep notifications typed.** Send notice statuses through `NoticeStatus` and - use `Arc` for notice message payloads. Preserve the event names and - status strings as a shared wire contract. Contract changes must keep - frontend consumers and backend producers aligned. -5. **Preserve lock order.** The order is `config_write` before - `config_update_in_progress` before `lifecycle_lock`. Patch callers must use - the executor's guarded claim instead of independently combining these locks; - this preserves Busy rejection and prevents inverted acquisition. Existing - lifecycle and independent port transactions keep their established locking - boundaries. A new lock requires registering its position in this rule before - introducing acquisition sites. -6. **Prefer reconciliation.** Express new effects as idempotent `ensure` - operations in `EFFECT_ORDER` so repeated application converges on the same - state. A trigger chain is an exception only with a code comment explaining - why the operation cannot be idempotent. Configuration rollback does not - authorize reverse OS effects. +- **Configuration ownership.** `config` owns source configuration, drafts and + persistence. The effects layer owns patch orchestration. Rollbackable + transactions publish state after persistence and commit; already applied + runtime and independently committed state remain authoritative even when a + later step fails. Preserve that state, its notifications and the error. +- **Effect ownership.** The declarative registry classifies every Verge patch field, + including fields without effects and platform-specific fields. Effects + reconcile toward the requested state with explicit dependencies and ordering. + Configuration rollback does not imply reversal of OS or lifecycle effects. +- **Shared wire contract.** Notifications are typed. Event names, status values + and payload shapes stay compatible with frontend producers and consumers. +- **Synchronization ownership.** Configuration-write serialization precedes + configuration-update ownership, which precedes Core lifecycle ownership. + When both are held, the lifecycle lock precedes the DNS lock. Independent + transactions retain their ownership boundaries; asynchronous work does not + hold synchronous data guards across suspension. +- **Retry ownership.** Bounded retries share backend policy while preserving + attempts, delays and terminal errors. Background monitors own their lifetime + and cancellation separately; waiting for a condition or traversing distinct + candidates is not a retry policy. From 7711a072ce786abd6efa036a83cf6739e1ea8fec Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:37:24 +0800 Subject: [PATCH 6/6] docs(frontend): focus guardrails on architecture ownership --- src/AGENTS.md | 49 ++++++++++++++++++++----------------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 38528e0e7..51a3b125d 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,32 +1,23 @@ # Frontend Architecture Guardrails -These rules supplement the repository's root `AGENTS.md` and define the -frontend's ownership and module boundaries. +These rules supplement the repository's root `AGENTS.md`. -1. **Contract is generated, never hand-typed.** Event names, payload shapes, - and the `NoticeStatus` union come from `src/services/contract.ts` - (`@generated`) from the backend wire contract. Keep backend producers, - generated types, and the exhaustive notice table - (`src/services/notice-handlers.ts`) aligned; `Record` - makes notice handling part of that contract. -2. **`listen(`/`emit(` live only in the event bus.** All Tauri event - subscriptions and emissions go through `src/services/bus/`. The shared - bus owns subscription registration and teardown at the layout root. - Components and pages never touch `@tauri-apps/api/event`. - Window-lifecycle helpers that do not use the event-module API may stay - with their owning component. -3. **Event-derived state lives in the app store.** Cross-component state - produced by events is written only through pure reducer actions dispatched - by the bus or the funnel (`src/store/`). Handlers may perform documented - side effects (SWR revalidation, notices) but must not write store state - directly. New event-consumed state needs a reducer action first, not a - local `useState` in a page. -4. **User intent funnels through `mutate`.** Every mutating backend command - call goes through `src/services/mutate.ts` — components, hooks, and - dialogs included. Give each intent a stable `id`; express optimistic - writes as `optimistic`/`rollback` and success refreshes as `revalidate`/ - `onFulfilled`. Flows with bespoke error UX keep it via `errorNotice: - false`; suppressing both the funnel's and the site's handling is a bug. -5. **`invoke(` lives only in services.** Backend commands are wrapped in - `src/services/cmds.ts`; mihomo-plugin mutations use the same intent funnel. - Pages and components import the wrapper, never `@tauri-apps/api/core`. +- **Contract ownership.** Generated event and notice contracts are owned by the + backend wire contract. Handwritten payload types are acceptable when they + match their producers. Event names, payloads and the typed notice table stay + aligned across backend and frontend. +- **Event ownership.** `services/bus` owns event subscriptions, emissions and + their lifetime. Components and pages consume the bus rather than subscribing + independently; window lifecycle remains with its owning component. +- **State ownership.** `store` owns shared event state and the snapshot reads that + reconcile it through pure reducer actions. Query caches retain ordinary + configuration and profile reads. Reads and live events share an ordering + boundary so an older reply cannot replace newer authoritative state. + Components keep local UI state. +- **Intent ownership.** Mutating commands pass through the shared intent funnel. + Resource ownership determines serialization; distinct requests are preserved, + and success effects follow actual command success. Bespoke error flows retain + their handling without duplicate or suppressed errors. +- **Command ownership.** `services` owns backend command invocation and transport + wrappers. Components and hooks use those boundaries; plugin mutations follow + the same intent ownership as backend commands.