mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-12 21:52:13 +08:00
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:
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user