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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Tunglies
2026-11-09 20:54:32 +08:00
co-authored by Copilot
parent 83dd0ed6d6
commit 9dc4783af4
8 changed files with 138 additions and 10 deletions
+7
View File
@@ -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<String> {
crate::core::service::take_service_upgrade_state_notice()
}
#[tauri::command]
#[specta::specta]
pub fn take_service_owner_notice() -> Option<String> {
+82 -4
View File
@@ -42,6 +42,8 @@ static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
#[cfg(target_os = "linux")]
static PENDING_SERVICE_UPGRADE: Mutex<Option<PendingServiceUpgrade>> = 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<String>,
}
#[cfg(target_os = "linux")]
fn settle_service_upgrade<T>(take: impl FnOnce(&mut PendingServiceUpgrade) -> T) -> Option<T> {
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<String> {
settle_service_upgrade(|upgrade| upgrade.unrestored_state.take()).flatten()
}
#[cfg(not(target_os = "linux"))]
pub(crate) const fn take_service_upgrade_state_notice() -> Option<String> {
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<ServiceFallbackNotice> {
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);
}
}
+1
View File
@@ -56,6 +56,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+2
View File
@@ -111,6 +111,8 @@ export const commands = {
__TAURI_INVOKE<boolean>('take_service_repair_notice'),
takeServiceOwnerNotice: () =>
__TAURI_INVOKE<string | null>('take_service_owner_notice'),
takeServiceUpgradeStateNotice: () =>
__TAURI_INVOKE<string | null>('take_service_upgrade_state_notice'),
takeDiscardedKeysNotice: () =>
__TAURI_INVOKE<string | null>('take_discarded_keys_notice'),
getDnsConfigContent: () =>
@@ -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,
)
})
+1
View File
@@ -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', ''])
}
+4
View File
@@ -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()
}
+19 -6
View File
@@ -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) => {