From daae17a40cda6f9f4b7f5eeeb5d89bde4732a858 Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 25 Sep 2026 00:06:59 +0800 Subject: [PATCH] fix(service): surface a service core that stopped for good When the service watchdog gave up or the core vanished, the owner monitor marked the core stopped without telling the user, and on macOS left the system proxy on the dead mixed port (#8029). - On SameOwnerFailure, clear the proxy through the helper while the session is still ours; displaced or unreachable owners still leave the machine-wide setting alone. - Retain the stop as a core failure with its own message, recorded under the lifecycle lock and shown now or on next focus; a running core resets the notice dedup. - Log each watchdog restart the service reports, since launchd discards the service's own log. --- Changelog.md | 3 +- src-tauri/src/cmd/service.rs | 2 +- src-tauri/src/core/manager/mod.rs | 25 +++-- src-tauri/src/core/service.rs | 95 ++++++++++++++----- src-tauri/src/utils/resolve/mod.rs | 3 +- src/locales/ar/settings.json | 1 + src/locales/de/settings.json | 1 + src/locales/en/settings.json | 1 + src/locales/es/settings.json | 1 + src/locales/fa/settings.json | 1 + src/locales/id/settings.json | 1 + src/locales/jp/settings.json | 1 + src/locales/ko/settings.json | 1 + src/locales/ru/settings.json | 1 + src/locales/tr/settings.json | 1 + src/locales/tt/settings.json | 1 + src/locales/zh/settings.json | 1 + src/locales/zhtw/settings.json | 1 + .../_layout/hooks/use-layout-events.test.ts | 34 ++++++- src/pages/_layout/hooks/use-layout-events.ts | 3 + .../_layout/utils/notification-handlers.ts | 32 +++++-- src/services/cmds.ts | 7 +- src/types/generated/i18n-keys.ts | 1 + src/types/generated/i18n-resources.ts | 1 + 24 files changed, 174 insertions(+), 45 deletions(-) diff --git a/Changelog.md b/Changelog.md index 5990e4618..45a5b1446 100644 --- a/Changelog.md +++ b/Changelog.md @@ -16,12 +16,13 @@ - macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题 - macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题 - macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令 +- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
🚀 优化改进 -- 优化内核启动失败提示:显示具体原因,并在窗口恢复后提示尚未解决的启动错误 +- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 923cbe8d4..71a286330 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -115,7 +115,7 @@ pub fn take_service_fallback_notice() -> bool { } #[tauri::command] -pub fn get_core_startup_error() -> Option { +pub fn get_core_startup_error() -> Option { crate::core::CoreManager::global().get_startup_error() } diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index 4af3acdd6..ae5889e7d 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -74,6 +74,14 @@ impl fmt::Display for RunningMode { } } +/// Why the Core is not running, kept until a Core starts again. +#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "detail", rename_all = "camelCase")] +pub enum CoreFailure { + StartFailed(String), + ServiceCoreStopped(String), +} + #[derive(Debug)] pub struct CoreManager { /// The Run State this manager reports transitions to. @@ -87,7 +95,7 @@ pub struct CoreManager { job_handle: ArcSwapOption, config_update_in_progress: AtomicBool, core_readiness_state: AtomicU64, - startup_error: parking_lot::Mutex>, + startup_error: parking_lot::Mutex>, sidecar_exit: tokio::sync::Mutex>>, // 串行化 start/stop/restart 和 sidecar→service 交接。 // 锁序固定为 config_update_in_progress → lifecycle_lock。 @@ -143,14 +151,14 @@ impl CoreManager { self.run_state.mode_arc() } - pub(crate) fn record_startup_error(&self, error: String) { + pub(crate) fn record_startup_error(&self, failure: CoreFailure) { let mut startup_error = self.startup_error.lock(); if matches!(*self.get_running_mode(), RunningMode::NotRunning) { - *startup_error = Some(error); + *startup_error = Some(failure); } } - pub(crate) fn get_startup_error(&self) -> Option { + pub(crate) fn get_startup_error(&self) -> Option { self.startup_error.lock().clone() } @@ -325,13 +333,13 @@ singleton!(CoreManager, CORE_MANAGER); #[cfg(test)] mod startup_error_tests { - use super::{CoreManager, RunningMode}; + use super::{CoreFailure, CoreManager, RunningMode}; #[test] fn recovery_invalidates_an_unread_startup_error_even_after_a_later_exit() { for mode in [RunningMode::Sidecar, RunningMode::Service] { let manager = CoreManager::isolated(); - manager.record_startup_error("startup failed".into()); + manager.record_startup_error(CoreFailure::StartFailed("startup failed".into())); manager.core_started(mode); manager.core_stopped(); assert_eq!(manager.get_startup_error(), None); @@ -341,9 +349,10 @@ mod startup_error_tests { #[test] fn unresolved_startup_error_survives_repeated_reads() { let manager = CoreManager::isolated(); - manager.record_startup_error("startup failed".into()); + let failure = CoreFailure::StartFailed("startup failed".into()); + manager.record_startup_error(failure.clone()); for _ in 0..2 { - assert_eq!(manager.get_startup_error().as_deref(), Some("startup failed")); + assert_eq!(manager.get_startup_error().as_ref(), Some(&failure)); } } } diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index dc684b7c8..ec98dcd0e 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -5,7 +5,7 @@ use crate::{ core::{ CoreManager, handle::Handle, - manager::RunningMode, + manager::{CoreFailure, RunningMode}, owner_identity::current_owner_credentials, proxy_control, runstate::{ @@ -22,8 +22,8 @@ use clash_verge_draft::Draft; use clash_verge_logging::{Type, logging}; use clash_verge_service_ipc::{ MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, - RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, - WriterConfig, + RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot, + StageRuntimeOutcome, StartClashRequest, WriterConfig, }; use once_cell::sync::Lazy; use parking_lot::Mutex; @@ -942,7 +942,7 @@ pub(super) async fn get_clash_logs_by_service() -> Result> { if response.code > 0 { if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { - recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await; + recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await; } let err_msg = response.message; bail!(err_msg); @@ -960,7 +960,7 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result { let response = response.context("无法连接到Clash Verge Service")?; if response.code > 0 { if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { - recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await; + recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await; } bail!(response.message); } @@ -1493,9 +1493,11 @@ struct OwnerRecoveryPolicy { reset_system_proxy: bool, } -const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy { +/// The macOS proxy is machine-wide and only the helper may write it, for the session it still +/// honours; a displaced or unreachable owner must leave it alone. +const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy { OwnerRecoveryPolicy { - reset_system_proxy: !is_macos, + reset_system_proxy: !is_macos || matches!(reason, OwnerRecoveryReason::SameOwnerFailure), } } @@ -1517,6 +1519,7 @@ fn start_owner_monitor() { AsyncHandler::spawn(move || async move { logging!(debug, Type::Service, "owner monitor started (generation {generation})"); let mut watch = OwnerWatch::new(); + let mut core_restarts = None; loop { tokio::time::sleep(OWNER_MONITOR_INTERVAL).await; if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation { @@ -1536,7 +1539,10 @@ fn start_owner_monitor() { break; } - let sample = read_owner_sample().await; + let (sample, status) = read_owner_sample().await; + if let Some(status) = &status { + log_core_restarts(&mut core_restarts, status); + } let mut step = watch.observe(sample); if matches!(step, OwnerStep::VerifyTransport) { if watch.just_became_sustained() { @@ -1552,15 +1558,41 @@ fn start_owner_monitor() { } if let OwnerStep::Recover(reason) = step { - recover_after_owner_loss(generation, reason).await; + recover_after_owner_loss(generation, reason, status.as_ref()).await; break; } } }); } +/// The Service logs its watchdog restarts where the app cannot read them. +fn log_core_restarts(seen: &mut Option, status: &ServiceStatusSnapshot) { + if seen.is_some_and(|seen| status.restart_count > seen) { + logging!( + warn, + Type::Service, + "service restarted the core ({} restarts so far); last exit: {}", + status.restart_count, + status.last_core_exit_reason.as_deref().unwrap_or("unknown") + ); + } + *seen = Some(status.restart_count); +} + +fn report_service_core_stopped(status: &ServiceStatusSnapshot) { + let detail = format!( + "service state {:?}, {} restarts, last exit: {}", + status.service_state, + status.restart_count, + status.last_core_exit_reason.as_deref().unwrap_or("none reported") + ); + logging!(error, Type::Service, "service core stopped: {detail}"); + CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail)); + Handle::notice_message("core_start::error", ""); +} + /// Samples ownership, treating every unusable reply as unreadable. -async fn read_owner_sample() -> OwnerSample { +async fn read_owner_sample() -> (OwnerSample, Option) { let response = match current_owner_credentials() { Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await, Err(error) => Err(error), @@ -1570,12 +1602,12 @@ async fn read_owner_sample() -> OwnerSample { Ok(response) => response, Err(error) => { logging!(debug, Type::Service, "service owner status was unreadable: {error:#}"); - return OwnerSample::Unreadable; + return (OwnerSample::Unreadable, None); } }; if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { - return OwnerSample::NotActive; + return (OwnerSample::NotActive, None); } if response.code != 0 { logging!( @@ -1585,24 +1617,25 @@ async fn read_owner_sample() -> OwnerSample { response.code, response.message ); - return OwnerSample::Unreadable; + return (OwnerSample::Unreadable, None); } let Some(status) = response.data else { logging!(debug, Type::Service, "service owner status omitted data"); - return OwnerSample::Unreadable; + return (OwnerSample::Unreadable, None); }; // A session that no longer matches is another owner's, whatever the flags say. if !session_matches_active_status(status.is_active, status.active_generation) { - return OwnerSample::NotActive; + return (OwnerSample::NotActive, None); } - OwnerSample::Status { + let sample = OwnerSample::Status { is_active: status.is_active, desired_core_should_be_running: status.desired_core_should_be_running, service_state: status.service_state, core_pid: status.core_pid, - } + }; + (sample, Some(status)) } fn session_matches_active_status(is_active: bool, active_generation: Option) -> bool { @@ -1620,7 +1653,12 @@ pub(crate) fn owner_monitor_generation() -> u64 { OWNER_MONITOR_GENERATION.load(Ordering::Acquire) } -async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) { +/// `status` is the sample that led to the recovery, if any; a failed core is reported from it. +async fn recover_after_owner_loss( + generation: u64, + reason: OwnerRecoveryReason, + status: Option<&ServiceStatusSnapshot>, +) { let manager = CoreManager::global(); if !matches!(*manager.get_running_mode(), RunningMode::Service) { return; @@ -1636,6 +1674,10 @@ async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) return; } recover_after_owner_loss_while_locked(reason).await; + // Still under the lifecycle lock, so a later start is the one that clears it. + if let (OwnerRecoveryReason::SameOwnerFailure, Some(status)) = (reason, status) { + report_service_core_stopped(status); + } } fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option { @@ -1660,13 +1702,15 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) { ); mark_service_unavailable_after_owner_loss(&RUN_STATE, reason); proxy_control::stop_guard().await; + // Clear while still in Service mode with the session: on macOS it routes through the helper. + if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy { + clear_proxy_after_owner_loss().await; + } clear_active_service_session(); CoreManager::global().core_stopped(); +} - if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy { - return; - } - +async fn clear_proxy_after_owner_loss() { let mut last_error = None; for attempt in 1..=3 { match proxy_control::clear().await { @@ -2182,13 +2226,16 @@ mod tests { } #[test] - fn macos_recovery_never_resets_machine_wide_proxy() { + fn macos_recovery_resets_machine_wide_proxy_only_for_its_own_failed_core() { for reason in [ OwnerRecoveryReason::Displaced, OwnerRecoveryReason::SameOwnerFailure, OwnerRecoveryReason::TransportFailure, ] { - assert!(!owner_recovery_policy(reason, true).reset_system_proxy); + assert_eq!( + owner_recovery_policy(reason, true).reset_system_proxy, + reason == OwnerRecoveryReason::SameOwnerFailure + ); assert!(owner_recovery_policy(reason, false).reset_system_proxy); } diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 790a4a371..49cd25edb 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -9,6 +9,7 @@ use crate::{ handle::Handle, hotkey::Hotkey, logger, + manager::CoreFailure, service::{SERVICE_MANAGER, ServiceManager}, tray::Tray, }, @@ -221,7 +222,7 @@ async fn init_core_manager() -> bool { Ok(initialized) => initialized, Err(error) => { logging!(error, Type::Setup, "core manager initialization failed: {error:#}"); - CoreManager::global().record_startup_error(format!("{error:#}")); + CoreManager::global().record_startup_error(CoreFailure::StartFailed(format!("{error:#}"))); Handle::notice_message("core_start::error", ""); false } diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 1ec7c838a..7816657b3 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "تعذّر تغيير وضع الوكيل", "restartFailed": "تعذّر إعادة تشغيل النواة", "startFailed": "تعذّر تشغيل النواة", + "serviceCoreStopped": "توقفت النواة التي تشغّلها الخدمة بشكل غير متوقع", "stopFailed": "تعذّر إيقاف النواة" }, "clashService": { diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 37bab10ba..500d5e00a 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Proxy-Modus konnte nicht geändert werden", "restartFailed": "Kern konnte nicht neu gestartet werden", "startFailed": "Kern konnte nicht gestartet werden", + "serviceCoreStopped": "Der vom Dienst ausgeführte Kern wurde unerwartet beendet", "stopFailed": "Kern konnte nicht gestoppt werden" }, "clashService": { diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 328a0e7b0..7b1da70e9 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Failed to change proxy mode", "restartFailed": "Failed to restart the core", "startFailed": "Failed to start the core", + "serviceCoreStopped": "The service's core stopped unexpectedly", "stopFailed": "Failed to stop the core" }, "clashService": { diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index e6c7fa080..61116797c 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "No se pudo cambiar el modo de proxy", "restartFailed": "No se pudo reiniciar el núcleo", "startFailed": "No se pudo iniciar el núcleo", + "serviceCoreStopped": "El núcleo ejecutado por el servicio se detuvo inesperadamente", "stopFailed": "No se pudo detener el núcleo" }, "clashService": { diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index e5c62b553..7f301933e 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "تغییر حالت پروکسی ناموفق بود", "restartFailed": "راه‌اندازی مجدد هسته ناموفق بود", "startFailed": "راه‌اندازی هسته ناموفق بود", + "serviceCoreStopped": "هسته‌ای که سرویس اجرا می‌کرد به‌طور غیرمنتظره متوقف شد", "stopFailed": "توقف هسته ناموفق بود" }, "clashService": { diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 7d758f695..2543b6711 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Gagal mengubah mode proxy", "restartFailed": "Gagal memulai ulang core", "startFailed": "Gagal memulai core", + "serviceCoreStopped": "Core yang dijalankan layanan berhenti secara tak terduga", "stopFailed": "Gagal menghentikan core" }, "clashService": { diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 45bdc6eae..85233efce 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "プロキシモードの変更に失敗しました", "restartFailed": "コアの再起動に失敗しました", "startFailed": "コアの起動に失敗しました", + "serviceCoreStopped": "サービスで実行中のコアが予期せず停止しました", "stopFailed": "コアの停止に失敗しました" }, "clashService": { diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 46524a20c..aec37f65a 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "프록시 모드를 변경하지 못했습니다", "restartFailed": "코어를 재시작하지 못했습니다", "startFailed": "코어를 시작하지 못했습니다", + "serviceCoreStopped": "서비스에서 실행 중인 코어가 예기치 않게 중지되었습니다", "stopFailed": "코어를 중지하지 못했습니다" }, "clashService": { diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 5b808b7f4..f567ec32e 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Не удалось изменить режим прокси", "restartFailed": "Не удалось перезапустить ядро", "startFailed": "Не удалось запустить ядро", + "serviceCoreStopped": "Ядро, запущенное службой, неожиданно остановилось", "stopFailed": "Не удалось остановить ядро" }, "clashService": { diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 52fcad0ff..4c7e9dff3 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Proxy modu değiştirilemedi", "restartFailed": "Çekirdek yeniden başlatılamadı", "startFailed": "Çekirdek başlatılamadı", + "serviceCoreStopped": "Hizmetin çalıştırdığı çekirdek beklenmedik şekilde durdu", "stopFailed": "Çekirdek durdurulamadı" }, "clashService": { diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 5d7476f41..effbd7596 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Прокси режимын үзгәртеп булмады", "restartFailed": "Ядрону яңадан башлап булмады", "startFailed": "Ядрону башлап булмады", + "serviceCoreStopped": "Хезмәт эшләткән ядро көтмәгәндә туктады", "stopFailed": "Ядрону туктатып булмады" }, "clashService": { diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index f6613f085..792b463df 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "切换代理模式失败", "restartFailed": "重启内核失败", "startFailed": "启动内核失败", + "serviceCoreStopped": "服务运行的内核意外停止", "stopFailed": "停止内核失败" }, "clashService": { diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index e33bf6a30..a942e67fa 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "切換代理模式失敗", "restartFailed": "重啟內核失敗", "startFailed": "啟動內核失敗", + "serviceCoreStopped": "服務執行的內核意外停止", "stopFailed": "停止內核失敗" }, "clashService": { diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/pages/_layout/hooks/use-layout-events.test.ts index 1c2c1bd98..ee8392117 100644 --- a/src/pages/_layout/hooks/use-layout-events.test.ts +++ b/src/pages/_layout/hooks/use-layout-events.test.ts @@ -1,6 +1,7 @@ import { beforeEach, expect, it, vi } from 'vitest' import { + type RunState, getCoreStartupError, takeDnsOverrideNotice, takeServiceRepairNotice, @@ -53,7 +54,10 @@ beforeEach(async () => { }) it('does not replay a recovered startup error after WebView recreation', async () => { - vi.mocked(getCoreStartupError).mockResolvedValue('startup failed') + vi.mocked(getCoreStartupError).mockResolvedValue({ + kind: 'startFailed', + detail: 'startup failed', + }) handleNoticeMessage('core_start::error', '', (key) => key, vi.fn()) await new Promise((resolve) => setTimeout(resolve, 0)) expect(showNotice.error).toHaveBeenCalledOnce() @@ -75,7 +79,10 @@ it.each([ const detail = 'startup failed' nativeWindow.isVisible.mockResolvedValue(visible) nativeWindow.isMinimized.mockResolvedValue(minimized) - vi.mocked(getCoreStartupError).mockResolvedValue(detail) + vi.mocked(getCoreStartupError).mockResolvedValue({ + kind: 'startFailed', + detail, + }) useLayoutEvents(([status, message]) => { handleNoticeMessage(status, message, (key) => key, vi.fn()) }) @@ -99,7 +106,10 @@ it.each([ it('delivers an early core startup failure once after listeners mount', async () => { const detail = 'could not open core execution mutex: access denied' - vi.mocked(getCoreStartupError).mockResolvedValue(detail) + vi.mocked(getCoreStartupError).mockResolvedValue({ + kind: 'startFailed', + detail, + }) useLayoutEvents(([status, message]) => { handleNoticeMessage(status, message, (key) => key, vi.fn()) }) @@ -113,6 +123,24 @@ it('delivers an early core startup failure once after listeners mount', async () ) }) +it('shows the same core failure again after the core has started in between', async () => { + const { useLayoutEvents } = await import('./use-layout-events') + const failure = { kind: 'serviceCoreStopped', detail: 'stopped' } as const + vi.mocked(getCoreStartupError).mockResolvedValue(failure) + useLayoutEvents(([status, message]) => { + handleNoticeMessage(status, message, (key) => key, vi.fn()) + }) + const [handlers] = vi.mocked(subscribeVergeEvents).mock.calls[0] + handlers['verge://notice-message']?.(['core_start::error', '']) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The core started and stopped again before the window handled either event. + handlers['verge://run-state-changed']?.({ mode: 'Service' } as RunState) + handlers['verge://notice-message']?.(['core_start::error', '']) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(showNotice.error).toHaveBeenCalledTimes(2) +}) + it('drains a DNS notice after listeners mount without duplicating its live event', async () => { vi.mocked(takeDnsOverrideNotice) .mockResolvedValueOnce(true) diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index c7800d981..ef95262c2 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -7,6 +7,8 @@ import type { RunState } from '@/services/cmds' import { subscribeVergeEvents } from '@/services/events' import { revalidateQueries, setCacheData } from '@/services/query-client' +import { forgetShownStartupError } from '../utils/notification-handlers' + export const useLayoutEvents = ( handleNotice: (payload: [string, string]) => void, ) => { @@ -58,6 +60,7 @@ export const useLayoutEvents = ( // Transitions carry the full run-state snapshot, so write it directly to cache. 'verge://run-state-changed': (payload) => { void setCacheData(runStateQueryKey, payload) + if (payload.mode !== 'NotRunning') forgetShownStartupError() }, 'verge://notice-message': handleNotice, }, diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index 5fd340e32..e45fb5568 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -15,8 +15,17 @@ type NavigateFunction = (path: string, options?: any) => void type TranslateFunction = (key: string) => string let shownStartupError: string | null = null +// Reads can settle out of order; only the newest may act, and a reset counts as one. +let startupErrorReads = 0 +let settledStartupErrorRead = 0 let shownOwnerNotice: number | null = null +/** A running core has resolved every failure shown before it. */ +export const forgetShownStartupError = () => { + shownStartupError = null + settledStartupErrorRead = ++startupErrorReads +} + export const handleNoticeMessage = ( status: string, msg: string, @@ -39,9 +48,12 @@ export const handleNoticeMessage = ( }, 'set_config::error': () => showNotice.error(msg), 'core_start::error': () => { + const read = ++startupErrorReads void getCoreStartupError() - .then(async (error) => { - if (!error) { + .then(async (failure) => { + if (read < settledStartupErrorRead) return + settledStartupErrorRead = read + if (!failure) { shownStartupError = null return } @@ -50,11 +62,19 @@ export const handleNoticeMessage = ( window.isVisible(), window.isMinimized(), ]) - if (visible && !minimized && shownStartupError !== error) { - shownStartupError = error + const shown = `${failure.kind}:${failure.detail}` + if ( + read === settledStartupErrorRead && + visible && + !minimized && + shownStartupError !== shown + ) { + shownStartupError = shown showNotice.error( - 'settings.feedback.errors.clash.startFailed', - error, + failure.kind === 'serviceCoreStopped' + ? 'settings.feedback.errors.clash.serviceCoreStopped' + : 'settings.feedback.errors.clash.startFailed', + failure.detail, ) } }) diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 62b993997..b2a057913 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -190,8 +190,13 @@ export async function takeServiceFallbackNotice() { return invoke('take_service_fallback_notice') } +export interface CoreFailure { + kind: 'startFailed' | 'serviceCoreStopped' + detail: string +} + export async function getCoreStartupError() { - return invoke('get_core_startup_error') + return invoke('get_core_startup_error') } export async function takeServiceRepairNotice() { diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 75e5b7cad..ee97efbc9 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -785,6 +785,7 @@ export const translationKeys = [ 'settings.feedback.errors.clash.modeUpdateFailed', 'settings.feedback.errors.clash.restartFailed', 'settings.feedback.errors.clash.startFailed', + 'settings.feedback.errors.clash.serviceCoreStopped', 'settings.feedback.errors.clash.stopFailed', 'settings.feedback.errors.clashService.installFailed', 'settings.feedback.errors.clashService.uninstallFailed', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 98206d4e4..5d71ad7b1 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -815,6 +815,7 @@ export interface TranslationResources { configUpdateFailed: string modeUpdateFailed: string restartFailed: string + serviceCoreStopped: string startFailed: string stopFailed: string }