From d83d78d8d768292a5c152b5beda81338c8766edc Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:54:32 +0800 Subject: [PATCH] fix(linux): keep Service upgrade notices until the frontend takes them Silent start creates no window, so emitted notices were dropped while the upgrade marker was already acknowledged. Keep both upgrade notices in backend state, replay the state warning on subscription, and acknowledge the marker only after every notice has been taken. Refs #8515 --- src-tauri/src/cmd/service.rs | 7 ++ src-tauri/src/core/service.rs | 86 +++++++++++++++++++- src-tauri/src/ipc.rs | 1 + src/services/bindings.ts | 2 + src/services/bus/create-bus-handlers.test.ts | 22 +++++ src/services/bus/create-bus-handlers.ts | 1 + src/services/cmds.ts | 4 + src/services/notice-handlers.ts | 25 ++++-- 8 files changed, 138 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 592df0417..f081c4efd 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -141,6 +141,13 @@ pub fn take_service_repair_notice() -> bool { crate::core::service::take_service_repair_notice() } +#[tauri::command] +#[specta::specta] +#[cfg_attr(not(target_os = "linux"), allow(clippy::missing_const_for_fn))] +pub fn take_service_upgrade_state_notice() -> Option { + crate::core::service::take_service_upgrade_state_notice() +} + #[tauri::command] #[specta::specta] pub fn take_service_owner_notice() -> Option { diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index c1bc9bab4..f27b71384 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -42,6 +42,8 @@ static ACTIVE_SERVICE_SESSION: Mutex> = Mutex::new( static PENDING_SERVICE_FALLBACK_NOTICE: Mutex> = Mutex::new(None); static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false); static PENDING_SERVICE_OWNER_NOTICE: Mutex> = Mutex::new(None); +#[cfg(target_os = "linux")] +static PENDING_SERVICE_UPGRADE: Mutex> = Mutex::new(None); /// Why the Service was unavailable when the core fell back to Sidecar. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)] @@ -93,6 +95,17 @@ pub(crate) async fn notify_incomplete_service_upgrade() { } let status = SERVICE_MANAGER.current().await; let notice = service_upgrade_notice(&marker, matches!(status, ServiceStatus::Ready)); + if !notice.reinstall && notice.unrestored_state.is_none() { + acknowledge_service_upgrade(&ack, id); + return; + } + // Without a window (silent start) the events are dropped; the frontend replays them on subscription. + *PENDING_SERVICE_UPGRADE.lock() = Some(PendingServiceUpgrade { + ack, + id: id.to_owned(), + reinstall: notice.reinstall, + unrestored_state: notice.unrestored_state.map(str::to_owned), + }); if notice.reinstall { logging!(warn, Type::Service, "安装包升级后服务未就绪: {status:?}"); *PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(SERVICE_UPGRADE_INCOMPLETE.to_owned()); @@ -100,13 +113,51 @@ pub(crate) async fn notify_incomplete_service_upgrade() { } if let Some(state) = notice.unrestored_state { logging!(warn, Type::Service, "安装包升级后未能恢复服务原状态: {state}"); - Handle::notice(NoticeStatus::ServiceCoreUpgradeStateNotRestored, state); + Handle::notice(NoticeStatus::ServiceCoreUpgradeStateNotRestored, ""); } - if let Err(error) = std::fs::write(&ack, id) { +} + +#[cfg(target_os = "linux")] +fn acknowledge_service_upgrade(ack: &Path, id: &str) { + if let Err(error) = std::fs::write(ack, id) { logging!(warn, Type::Service, "无法记录服务升级确认: {error}"); } } +/// An upgrade whose notices are acknowledged on disk only after the frontend has taken all of them. +#[cfg(target_os = "linux")] +struct PendingServiceUpgrade { + ack: PathBuf, + id: String, + reinstall: bool, + unrestored_state: Option, +} + +#[cfg(target_os = "linux")] +fn settle_service_upgrade(take: impl FnOnce(&mut PendingServiceUpgrade) -> T) -> Option { + let (taken, settled) = { + let mut pending = PENDING_SERVICE_UPGRADE.lock(); + let upgrade = pending.as_mut()?; + let taken = take(upgrade); + let settled = !upgrade.reinstall && upgrade.unrestored_state.is_none(); + (taken, if settled { pending.take() } else { None }) + }; + if let Some(upgrade) = settled { + acknowledge_service_upgrade(&upgrade.ack, &upgrade.id); + } + Some(taken) +} + +#[cfg(target_os = "linux")] +pub(crate) fn take_service_upgrade_state_notice() -> Option { + settle_service_upgrade(|upgrade| upgrade.unrestored_state.take()).flatten() +} + +#[cfg(not(target_os = "linux"))] +pub(crate) const fn take_service_upgrade_state_notice() -> Option { + None +} + #[cfg(target_os = "linux")] #[derive(Debug, PartialEq, Eq)] struct ServiceUpgradeNotice<'a> { @@ -126,6 +177,8 @@ fn service_upgrade_notice(marker: &str, ready: bool) -> ServiceUpgradeNotice<'_> pub(crate) fn take_service_fallback_notice() -> Option { let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?; Some(if reason == SERVICE_UPGRADE_INCOMPLETE { + #[cfg(target_os = "linux")] + settle_service_upgrade(|upgrade| upgrade.reinstall = false); ServiceFallbackNotice::UpgradeIncomplete } else if reason.starts_with(CORE_REJECTED_PREFIX) { ServiceFallbackNotice::CoreRejected(reason) @@ -2183,13 +2236,16 @@ pub static SERVICE_MANAGER: ServiceManager = ServiceManager; #[cfg(test)] #[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] mod tests { + #[cfg(target_os = "linux")] + use super::{ + PENDING_SERVICE_UPGRADE, PendingServiceUpgrade, ServiceUpgradeNotice, service_upgrade_notice, + settle_service_upgrade, take_service_upgrade_state_notice, + }; use super::{ ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation, generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy, service_core_path_for, session_matches_status, }; - #[cfg(target_os = "linux")] - use super::{ServiceUpgradeNotice, service_upgrade_notice}; #[cfg(unix)] use super::{service_core_path_for_with_publisher, service_tool_path_for}; use crate::core::runstate::{FakeEnv, OwnerRecoveryReason, PendingAction, RunStateStore}; @@ -2868,4 +2924,26 @@ mod tests { notice(true, Some("disabled,active")) ); } + + #[cfg(target_os = "linux")] + #[test] + fn upgrade_marker_is_acknowledged_only_after_every_notice_is_taken() { + let ack = std::env::temp_dir().join(format!("verge-service-upgrade-ack-{}", std::process::id())); + let _ = std::fs::remove_file(&ack); + *PENDING_SERVICE_UPGRADE.lock() = Some(PendingServiceUpgrade { + ack: ack.clone(), + id: "7".to_owned(), + reinstall: true, + unrestored_state: Some("disabled,active".to_owned()), + }); + + assert_eq!(take_service_upgrade_state_notice().as_deref(), Some("disabled,active")); + assert!(!ack.exists()); + assert_eq!(take_service_upgrade_state_notice(), None); + + settle_service_upgrade(|upgrade| upgrade.reinstall = false); + assert_eq!(std::fs::read_to_string(&ack).ok().as_deref(), Some("7")); + assert!(PENDING_SERVICE_UPGRADE.lock().is_none()); + let _ = std::fs::remove_file(&ack); + } } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 334ef1ccb..24d5dfdf0 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -56,6 +56,7 @@ pub fn builder() -> tauri_specta::Builder { cmd::get_core_startup_error, cmd::take_service_repair_notice, cmd::take_service_owner_notice, + cmd::take_service_upgrade_state_notice, cmd::take_discarded_keys_notice, cmd::get_dns_config_content, cmd::validate_dns_config, diff --git a/src/services/bindings.ts b/src/services/bindings.ts index 1db027d5b..f08e1951f 100644 --- a/src/services/bindings.ts +++ b/src/services/bindings.ts @@ -111,6 +111,8 @@ export const commands = { __TAURI_INVOKE('take_service_repair_notice'), takeServiceOwnerNotice: () => __TAURI_INVOKE('take_service_owner_notice'), + takeServiceUpgradeStateNotice: () => + __TAURI_INVOKE('take_service_upgrade_state_notice'), takeDiscardedKeysNotice: () => __TAURI_INVOKE('take_discarded_keys_notice'), getDnsConfigContent: () => diff --git a/src/services/bus/create-bus-handlers.test.ts b/src/services/bus/create-bus-handlers.test.ts index 844e40d33..5f394c321 100644 --- a/src/services/bus/create-bus-handlers.test.ts +++ b/src/services/bus/create-bus-handlers.test.ts @@ -6,6 +6,7 @@ import { takeDnsOverrideNotice, takeServiceFallbackNotice, takeServiceRepairNotice, + takeServiceUpgradeStateNotice, } from '@/services/cmds' import { showNotice } from '@/services/notice-service' import { revalidateQueries } from '@/services/query-client' @@ -27,6 +28,7 @@ vi.mock('@/services/cmds', () => ({ takeServiceFallbackNotice: vi.fn().mockResolvedValue(null), takeServiceRepairNotice: vi.fn().mockResolvedValue(false), takeServiceOwnerNotice: vi.fn().mockResolvedValue(null), + takeServiceUpgradeStateNotice: vi.fn().mockResolvedValue(null), })) vi.mock('@/services/service-request', () => ({ requestService: vi.fn() })) vi.mock('@/services/notice-service', () => ({ @@ -198,3 +200,23 @@ it('explains a startup core rejection instead of the generic fallback notice', a 0, ) }) + +it('replays a pending Service upgrade state warning once the frontend subscribes', async () => { + vi.mocked(takeServiceUpgradeStateNotice) + .mockResolvedValueOnce('disabled,active') + .mockResolvedValue(null) + + const bus = await mountBus() + bus.onSubscribed() + bus.handlers['verge://notice-message']([ + 'service_core::upgrade_state_not_restored', + '', + ]) + await flush() + + expect(showNotice.warning).toHaveBeenCalledExactlyOnceWith( + 'settings.feedback.notifications.clashService.upgradeStateNotRestored', + { state: 'disabled,active' }, + 0, + ) +}) diff --git a/src/services/bus/create-bus-handlers.ts b/src/services/bus/create-bus-handlers.ts index c41442704..59f0c045c 100644 --- a/src/services/bus/create-bus-handlers.ts +++ b/src/services/bus/create-bus-handlers.ts @@ -105,6 +105,7 @@ export const createEventBus = (deps: BusHandlerDeps) => { deps.handleNotice(['service_core::sidecar_fallback', '']) deps.handleNotice(['service_core::repair_required', '']) deps.handleNotice(['service_core::app_data_not_owned', '']) + deps.handleNotice(['service_core::upgrade_state_not_restored', '']) deps.handleNotice(['core_start::error', '']) } diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 6b118d34a..ddc48ba64 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -248,6 +248,10 @@ export async function takeServiceOwnerNotice() { return commands.takeServiceOwnerNotice() } +export async function takeServiceUpgradeStateNotice() { + return commands.takeServiceUpgradeStateNotice() +} + export async function takeDiscardedKeysNotice() { return commands.takeDiscardedKeysNotice() } diff --git a/src/services/notice-handlers.ts b/src/services/notice-handlers.ts index e9051ce38..19dbf3a29 100644 --- a/src/services/notice-handlers.ts +++ b/src/services/notice-handlers.ts @@ -7,6 +7,7 @@ import { takeServiceFallbackNotice, takeServiceOwnerNotice, takeServiceRepairNotice, + takeServiceUpgradeStateNotice, } from '@/services/cmds' import type { NoticeStatus } from '@/services/contract' import { hideNotice, showNotice } from '@/services/notice-service' @@ -140,12 +141,24 @@ const handlers: Record< ) }) }, - 'service_core::upgrade_state_not_restored': (msg) => - showNotice.warning( - 'settings.feedback.notifications.clashService.upgradeStateNotRestored', - { state: msg }, - 0, - ), + 'service_core::upgrade_state_not_restored': () => { + void takeServiceUpgradeStateNotice() + .then((state) => { + if (state) { + showNotice.warning( + 'settings.feedback.notifications.clashService.upgradeStateNotRestored', + { state }, + 0, + ) + } + }) + .catch((error) => { + console.error( + 'Failed to read the pending service upgrade state notice', + error, + ) + }) + }, 'dns_override::auto_disabled': () => { void takeDnsOverrideNotice() .then((pending) => {