feat(linux): reinstall an existing Service during package upgrades

The deb and rpm packages never refreshed an installed Service, and the
previous package's prerm/%preun uninstalls it unconditionally. When the
Service unit existed before the upgrade, the new postinst/%post now
reinstalls it with its group and restores disabled, enabled-runtime,
masked-runtime and stopped states. First installs and explicit removals
never add a Service.

rpm runs the new %post before the old %preun. %post records the
transaction process (pid and start time) in /run, and
/usr/bin/clash-verge-service-uninstall becomes a wrapper that skips only
that %preun call and otherwise runs the real uninstaller, now under
/usr/lib/clash-verge-rev. deb has no such ordering: an upgrade from a
released version still loses the Service, and later deb upgrades reinstall
it in place only once the prerm upgrade guard from #8240 ships.

The scripts exit 0, because a failing postinst or %post cannot restore the
Service. On failure they write /run/clash-verge-service-upgrade instead;
package script output never reaches the GUI. At startup the app offers a
reinstall if the Service is not ready and warns if its previous state was
not restored. Both notices replay when a window subscribes, and the marker
is acknowledged only after both are taken. In-app updates stop a
Service-run core and hold the core lifecycle until the package manager
exits, including after the 30-second startup timeout.

Refs #8515

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Tunglies
2026-11-09 21:28:46 +08:00
co-authored by Copilot
parent d51e4a95d6
commit d09f78f9b1
50 changed files with 572 additions and 13 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> {
+14
View File
@@ -750,6 +750,20 @@ impl CoreManager {
self.controlled_stop_core_inner().await
}
/// Holds the core lifecycle for a package install, stopping a Service-run core first and returning
/// whether it did, so no core start or restart overlaps the package scripts reinstalling the Service.
#[cfg(target_os = "linux")]
pub(crate) async fn pause_for_package_install(
&'static self,
) -> Result<(tokio::sync::MutexGuard<'static, ()>, bool)> {
let life = self.lifecycle_lock.lock().await;
if !matches!(*self.get_running_mode(), RunningMode::Service) {
return Ok((life, false));
}
self.controlled_stop_core_inner().await?;
Ok((life, true))
}
pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> {
self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await
}
+4 -1
View File
@@ -22,8 +22,11 @@ pub enum NoticeStatus {
#[serde(rename = "service_core::app_data_not_owned")]
ServiceCoreAppDataNotOwned,
#[serde(rename = "service_core::sidecar_fallback")]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))]
ServiceCoreSidecarFallback,
#[serde(rename = "service_core::upgrade_state_not_restored")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
ServiceCoreUpgradeStateNotRestored,
#[serde(rename = "dns_override::auto_disabled")]
DnsOverrideAutoDisabled,
#[serde(rename = "enhance::discarded_keys")]
+161 -1
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)]
@@ -50,6 +52,7 @@ pub enum ServiceFallbackNotice {
Unavailable,
CoreRejected(String),
NotAutoStarted,
UpgradeIncomplete,
}
/// The release installer registers AutoStart.
@@ -66,9 +69,118 @@ pub(crate) fn notify_service_fallback(reason: &str) {
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
/// Written by the Linux packages when an upgrade found the Service and may not have kept it.
#[cfg(target_os = "linux")]
const SERVICE_UPGRADE_MARKER: &str = "/run/clash-verge-service-upgrade";
const SERVICE_UPGRADE_INCOMPLETE: &str = "the package upgrade did not keep the Service ready";
/// Offers a reinstall once per package upgrade whose Service is not ready, since package script output never reaches the app.
#[cfg(target_os = "linux")]
pub(crate) async fn notify_incomplete_service_upgrade() {
let Ok(marker) = std::fs::read_to_string(SERVICE_UPGRADE_MARKER) else {
return;
};
let Some(id) = marker.lines().find_map(|line| line.strip_prefix("id=")) else {
return;
};
let ack = match dirs::app_home_dir() {
Ok(dir) => dir.join("service-upgrade-ack"),
Err(error) => {
logging!(warn, Type::Service, "无法定位服务升级确认文件: {error:#}");
return;
}
};
if std::fs::read_to_string(&ack).is_ok_and(|seen| seen == id) {
return;
}
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());
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
if let Some(state) = notice.unrestored_state {
logging!(warn, Type::Service, "安装包升级后未能恢复服务原状态: {state}");
Handle::notice(NoticeStatus::ServiceCoreUpgradeStateNotRestored, "");
}
}
#[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> {
reinstall: bool,
/// Reported regardless of readiness: a reinstall enables and starts the Service, so it cannot restore this state.
unrestored_state: Option<&'a str>,
}
#[cfg(target_os = "linux")]
fn service_upgrade_notice(marker: &str, ready: bool) -> ServiceUpgradeNotice<'_> {
ServiceUpgradeNotice {
reinstall: !ready,
unrestored_state: marker.lines().find_map(|line| line.strip_prefix("restore=")),
}
}
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
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)
} else if is_not_auto_started(&reason) {
ServiceFallbackNotice::NotAutoStarted
@@ -2124,6 +2236,11 @@ 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,
@@ -2786,4 +2903,47 @@ mod tests {
assert!(!store.state().sidecar_allowed);
assert_eq!(status_of(&store), ServiceStatus::Ready);
}
#[cfg(target_os = "linux")]
#[test]
fn upgrade_marker_reports_unrestored_state_regardless_of_readiness() {
let restored = "id=1\n";
let unrestored = "id=1\nrestore=disabled,active\n";
let notice = |reinstall, unrestored_state| ServiceUpgradeNotice {
reinstall,
unrestored_state,
};
assert_eq!(service_upgrade_notice(restored, true), notice(false, None));
assert_eq!(service_upgrade_notice(restored, false), notice(true, None));
assert_eq!(
service_upgrade_notice(unrestored, true),
notice(false, Some("disabled,active"))
);
assert_eq!(
service_upgrade_notice(unrestored, false),
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);
}
}
+57 -8
View File
@@ -292,8 +292,61 @@ impl SilentUpdater {
Ok(updater_builder.build()?.check().await?)
}
async fn install(update: Update, bytes: Vec<u8>) -> Result<()> {
Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??)
async fn install(update: Update, bytes: Vec<u8>, timeout: Option<Duration>) -> Result<()> {
#[cfg(target_os = "linux")]
let mut install = Self::install_with_core_paused(update, bytes).await?;
#[cfg(not(target_os = "linux"))]
let mut install = tokio::task::spawn_blocking(move || update.install(&bytes));
let joined = match timeout {
Some(limit) => match tokio::time::timeout(limit, &mut install).await {
Ok(joined) => Ok(joined),
Err(_) => {
// The caller resumes the old app; once the package manager succeeds, restart on the new version.
#[cfg(target_os = "linux")]
tokio::spawn(async move {
if matches!(install.await, Ok(Ok(()))) {
crate::feat::restart_app().await;
}
});
Err(anyhow::anyhow!("install timed out ({}s)", limit.as_secs()))
}
},
None => Ok(install.await),
};
joined.and_then(|joined| {
joined??;
Ok(())
})
}
/// The package scripts reinstall an existing Service, killing the core it runs and its session, so the
/// core stays paused until the package manager exits, even after the caller stops waiting.
#[cfg(target_os = "linux")]
async fn install_with_core_paused(update: Update, bytes: Vec<u8>) -> Result<tokio::task::JoinHandle<Result<()>>> {
let manager = crate::core::CoreManager::global();
let (lifecycle, stopped_service_core) = manager
.pause_for_package_install()
.await
.context("failed to stop the Service core before installing the update")?;
let install = tokio::task::spawn_blocking(move || update.install(&bytes));
Ok(tokio::spawn(async move {
let result = match install.await {
Ok(result) => result.map_err(anyhow::Error::from),
Err(error) => Err(error.into()),
};
drop(lifecycle);
if result.is_err()
&& stopped_service_core
&& let Err(error) = manager.start_core().await
{
logging!(
error,
Type::System,
"Failed to restart the core after the update install failed: {error:#}"
);
}
result
}))
}
/// Installs a newer cached update before normal startup, if the user confirms.
@@ -369,11 +422,7 @@ impl SilentUpdater {
Self::show_update_splash(app_handle, &version);
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes));
match install
.await
.unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)")))
{
match Self::install(update, bytes, Some(Duration::from_secs(30))).await {
Ok(()) => {
logging!(info, Type::System, "Update v{version} install triggered at startup");
Self::delete_cache();
@@ -417,7 +466,7 @@ impl SilentUpdater {
};
logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?;
Self::install(update, bytes, None).await?;
Self::delete_cache();
Ok(InstallUpdateOutcome::Installed)
}
+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
@@ -78,6 +78,8 @@ async fn resolve_setup() {
let core_init = AsyncHandler::spawn(|| async {
init_core_manager().await;
#[cfg(target_os = "linux")]
crate::core::service::notify_incomplete_service_upgrade().await;
});
let _ = futures::join!(