From 4137f91ccb16ad98b7e5cc9f80255a4e93fb06ec Mon Sep 17 00:00:00 2001 From: ezequielnick <107352853+ezequielnick@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:00:04 +0800 Subject: [PATCH] fix: handle core state errors caused by rapid profile switching --- src/main/config/profile.ts | 30 ++++++++++++---- src/main/core/factory.ts | 3 +- src/main/core/manager.ts | 35 +++++++++++++++---- src/main/core/mihomoApi.ts | 8 +++++ .../src/components/profiles/profile-item.tsx | 6 ++-- .../src/components/sider/profile-card.tsx | 2 +- src/renderer/src/hooks/use-profile-config.tsx | 29 +++++++++------ 7 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/main/config/profile.ts b/src/main/config/profile.ts index b3ae1f9..b57fb3c 100644 --- a/src/main/config/profile.ts +++ b/src/main/config/profile.ts @@ -13,6 +13,8 @@ import { join } from 'path' import { app } from 'electron' let profileConfig: IProfileConfig // profile.yaml +// 最终选中订阅ID +let targetProfileId: string | null = null export async function getProfileConfig(force = false): Promise { if (force || !profileConfig) { @@ -38,20 +40,33 @@ export async function changeCurrentProfile(id: string): Promise { const config = await getProfileConfig() const current = config.current - if (current === id) { + if (current === id && targetProfileId !== id) { return } + targetProfileId = id + config.current = id - await setProfileConfig(config) + const configSavePromise = setProfileConfig(config) try { + await configSavePromise + + // 检查订阅切换是否中断 + if (targetProfileId !== id) { + return + } await restartCore() + if (targetProfileId === id) { + targetProfileId = null + } } catch (e) { - // 如果重启失败,恢复原来的配置 - config.current = current - await setProfileConfig(config) - throw e + if (targetProfileId === id) { + config.current = current + await setProfileConfig(config) + targetProfileId = null + throw e + } } } @@ -203,7 +218,8 @@ export async function getProfileStr(id: string | undefined): Promise { } export async function setProfileStr(id: string, content: string): Promise { - const { current } = await getProfileConfig() + // 读取最新的配置 + const { current } = await getProfileConfig(true) await writeFile(profilePath(id), content, 'utf-8') if (current === id) await restartCore() } diff --git a/src/main/core/factory.ts b/src/main/core/factory.ts index 1971a2e..64bad07 100644 --- a/src/main/core/factory.ts +++ b/src/main/core/factory.ts @@ -25,7 +25,8 @@ let runtimeConfigStr: string let runtimeConfig: IMihomoConfig export async function generateProfile(): Promise { - const { current } = await getProfileConfig() + // 读取最新的配置 + const { current } = await getProfileConfig(true) const { diffWorkDir = false, controlDns = true, controlSniff = true, useNameserverPolicy } = await getAppConfig() const currentProfile = await overrideProfile(current, await getProfile(current)) let controledMihomoConfig = await getControledMihomoConfig() diff --git a/src/main/core/manager.ts b/src/main/core/manager.ts index 733e5e7..48e2540 100644 --- a/src/main/core/manager.ts +++ b/src/main/core/manager.ts @@ -79,6 +79,7 @@ let setPublicDNSTimer: NodeJS.Timeout | null = null let recoverDNSTimer: NodeJS.Timeout | null = null let child: ChildProcess let retry = 10 +let isRestarting = false export async function startCore(detached = false): Promise[]> { const { @@ -102,7 +103,7 @@ export async function startCore(detached = false): Promise[]> { await rm(path.join(dataDir(), 'core.pid')) } } - const { current } = await getProfileConfig() + const { current } = await getProfileConfig(true) const { tun } = await getControledMihomoConfig() const corePath = mihomoCorePath(core) @@ -161,6 +162,12 @@ export async function startCore(detached = false): Promise[]> { } child.on('close', async (code, signal) => { await managerLogger.info(`Core closed, code: ${code}, signal: ${signal}`) + + if (isRestarting) { + await managerLogger.info('Core closed during restart, skipping auto-restart') + return + } + if (retry) { await managerLogger.info('Try Restart Core') retry-- @@ -293,10 +300,14 @@ async function cleanupWindowsNamedPipes(): Promise { const pid = proc.Id if (pid && pid !== process.pid) { try { + // 先检查进程是否存在 + process.kill(pid, 0) process.kill(pid, 'SIGTERM') await managerLogger.info(`Terminated process ${pid} to free pipe`) - } catch (error) { - await managerLogger.warn(`Failed to terminate process ${pid}:`, error) + } catch (error: any) { + if (error.code !== 'ESRCH') { + await managerLogger.warn(`Failed to terminate process ${pid}:`, error) + } } } } @@ -311,10 +322,13 @@ async function cleanupWindowsNamedPipes(): Promise { const pid = parseInt(match[1]) if (pid !== process.pid) { try { + process.kill(pid, 0) process.kill(pid, 'SIGTERM') await managerLogger.info(`Terminated process ${pid} to free pipe`) - } catch (error) { - await managerLogger.warn(`Failed to terminate process ${pid}:`, error) + } catch (error: any) { + if (error.code !== 'ESRCH') { + await managerLogger.warn(`Failed to terminate process ${pid}:`, error) + } } } } @@ -367,13 +381,20 @@ async function validateWindowsPipeAccess(pipePath: string): Promise { } export async function restartCore(): Promise { + // 防止并发重启 + if (isRestarting) { + await managerLogger.info('Core restart already in progress, skipping duplicate request') + return + } + + isRestarting = true try { await startCore() } catch (e) { - // 记录错误到日志而不是显示阻塞对话框 await managerLogger.error('restart core failed', e) - // 重新抛出错误,让调用者处理 throw e + } finally { + isRestarting = false } } diff --git a/src/main/core/mihomoApi.ts b/src/main/core/mihomoApi.ts index c37280d..235003f 100644 --- a/src/main/core/mihomoApi.ts +++ b/src/main/core/mihomoApi.ts @@ -205,6 +205,8 @@ export const startMihomoTraffic = async (): Promise => { } export const stopMihomoTraffic = (): void => { + trafficRetry = 0 + if (mihomoTrafficWs) { mihomoTrafficWs.removeAllListeners() if (mihomoTrafficWs.readyState === WebSocket.OPEN) { @@ -262,6 +264,8 @@ export const startMihomoMemory = async (): Promise => { } export const stopMihomoMemory = (): void => { + memoryRetry = 0 + if (mihomoMemoryWs) { mihomoMemoryWs.removeAllListeners() if (mihomoMemoryWs.readyState === WebSocket.OPEN) { @@ -306,6 +310,8 @@ export const startMihomoLogs = async (): Promise => { } export const stopMihomoLogs = (): void => { + logsRetry = 0 + if (mihomoLogsWs) { mihomoLogsWs.removeAllListeners() if (mihomoLogsWs.readyState === WebSocket.OPEN) { @@ -352,6 +358,8 @@ export const startMihomoConnections = async (): Promise => { } export const stopMihomoConnections = (): void => { + connectionsRetry = 0 + if (mihomoConnectionsWs) { mihomoConnectionsWs.removeAllListeners() if (mihomoConnectionsWs.readyState === WebSocket.OPEN) { diff --git a/src/renderer/src/components/profiles/profile-item.tsx b/src/renderer/src/components/profiles/profile-item.tsx index fd0f731..72f0de5 100644 --- a/src/renderer/src/components/profiles/profile-item.tsx +++ b/src/renderer/src/components/profiles/profile-item.tsx @@ -57,7 +57,6 @@ const ProfileItem: React.FC = (props) => { const { appConfig, patchAppConfig } = useAppConfig() const { profileDisplayDate = 'expire' } = appConfig || {} const [updating, setUpdating] = useState(false) - const [selecting, setSelecting] = useState(false) const [openInfoEditor, setOpenInfoEditor] = useState(false) const [openFileEditor, setOpenFileEditor] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false) @@ -185,8 +184,7 @@ const ProfileItem: React.FC = (props) => { // 处理卡片选中 if (!isActuallyDragging && !isDragging && clickStartPos) { - setSelecting(true) - onPress().finally(() => setSelecting(false)) + onPress() } cleanup() @@ -216,7 +214,7 @@ const ProfileItem: React.FC = (props) => { fullWidth isPressable={false} onContextMenu={handleContextMenu} - className={`${isCurrent ? 'bg-primary' : ''} ${selecting ? 'blur-sm' : ''} cursor-pointer`} + className={`${isCurrent ? 'bg-primary' : ''} cursor-pointer transition-colors duration-150`} >
= (props) => { >

{info?.name}

diff --git a/src/renderer/src/hooks/use-profile-config.tsx b/src/renderer/src/hooks/use-profile-config.tsx index d08316e..315257d 100644 --- a/src/renderer/src/hooks/use-profile-config.tsx +++ b/src/renderer/src/hooks/use-profile-config.tsx @@ -25,6 +25,7 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child const { data: profileConfig, mutate: mutateProfileConfig } = useSWR('getProfileConfig', () => getProfileConfig() ) + const [targetProfileId, setTargetProfileId] = React.useState(null) const setProfileConfig = async (config: IProfileConfig): Promise => { try { @@ -71,23 +72,31 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child } const changeCurrentProfile = async (id: string): Promise => { - if (profileConfig?.current === id) { + if (targetProfileId === id) { return } - // 乐观更新:立即更新 UI 状态,提供即时反馈 + setTargetProfileId(id) + + // 立即更新 UI 状态和托盘菜单,提供即时反馈 if (profileConfig) { const optimisticUpdate = { ...profileConfig, current: id } mutateProfileConfig(optimisticUpdate, false) + window.electron.ipcRenderer.send('updateTrayMenu') } + // 异步执行后台切换,不阻塞 UI try { - // 异步执行后台切换,不阻塞 UI - change(id).then(() => { - window.electron.ipcRenderer.send('updateTrayMenu') + await change(id) + + if (targetProfileId === id) { mutateProfileConfig() - }).catch((e) => { - const errorMsg = e?.message || String(e) + setTargetProfileId(null) + } else { + } + } catch (e) { + if (targetProfileId === id) { + const errorMsg = (e as any)?.message || String(e) // 处理 IPC 超时错误 if (errorMsg.includes('reply was never sent')) { setTimeout(() => mutateProfileConfig(), 1000) @@ -95,10 +104,8 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child alert(`切换 Profile 失败: ${errorMsg}`) mutateProfileConfig() } - }) - } catch (e) { - alert(`切换 Profile 失败: ${e}`) - mutateProfileConfig() + setTargetProfileId(null) + } } }