mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
fix(singleton): acknowledge window restoration before UI work
Dispatch blocking window restoration outside HTTP workers to prevent notification timeouts and repeated window activation in #8118. Remove the misleading failure log, update the changelog, and remove server tests as requested.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+16
-201
@@ -58,9 +58,7 @@ pub async fn check_singleton() -> Result<SingletonDisposition> {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
bail!("another app instance is starting");
|
||||
}
|
||||
// A restarting instance releases this lock only after spawning this
|
||||
// process, and its embedded server is already down, so waiting on
|
||||
// the notify probe alone can never promote this instance.
|
||||
// Retry the lock: during restart, the old server stops before releasing it.
|
||||
if try_lock_instance(&lock).context("failed to acquire singleton lock")? {
|
||||
break;
|
||||
}
|
||||
@@ -211,15 +209,20 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
logging!(info, Type::Window, "检测到从单例模式恢复应用窗口");
|
||||
if crate::APP_HANDLE.get().is_some() {
|
||||
if !lightweight::exit_lightweight_mode().await {
|
||||
WindowManager::show_main_window().await;
|
||||
} else {
|
||||
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
|
||||
}
|
||||
}
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
||||
// Keep blocking UI calls off HTTP workers.
|
||||
AsyncHandler::spawn_blocking(|| {
|
||||
AsyncHandler::block_on(async {
|
||||
logging!(
|
||||
info,
|
||||
Type::Window,
|
||||
"Restoring the application window from a secondary instance"
|
||||
);
|
||||
if crate::APP_HANDLE.get().is_some() && !lightweight::exit_lightweight_mode().await {
|
||||
WindowManager::show_main_window().await;
|
||||
}
|
||||
});
|
||||
});
|
||||
Ok(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
||||
});
|
||||
|
||||
let pac = warp::path!("commands" / "pac").and_then(|| async move {
|
||||
@@ -234,10 +237,7 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
||||
let verge_config = Config::verge().await;
|
||||
let verge_data = verge_config.data_arc();
|
||||
let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC);
|
||||
// Served per browser request, so this stays a configuration read rather than a
|
||||
// round-trip to the Core. It reads the draft layer, which is only correct because
|
||||
// whoever stages a listener port closes this endpoint across the change — see
|
||||
// `MixedPort::desired`. Reaching here at all means the Core is serving.
|
||||
// Port changes disable PAC, so the draft port avoids per-request Core queries.
|
||||
let pac_port = MixedPort::desired().await;
|
||||
let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}"));
|
||||
Ok::<_, warp::Rejection>(
|
||||
@@ -437,188 +437,3 @@ fn write_instance_record(path: &Path, record: &InstanceRecord) -> Result<()> {
|
||||
std::fs::rename(&temporary, path).context("failed to replace singleton record")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
InstanceRecord, PAC_INITIAL_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use super::{open_instance_lock, try_lock_instance};
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use super::{INSTANCE_TOKEN_HEADER, dev_quit_route, release_dev_quit_latch};
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use std::sync::atomic::Ordering;
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize},
|
||||
};
|
||||
#[test]
|
||||
fn pac_is_fail_closed_before_core_readiness() {
|
||||
// Asserts the default rather than the live flag: PAC availability is now derived from
|
||||
// the Running Mode by `core::runstate`, so any test that drives a Core transition
|
||||
// legitimately moves the live flag. That the derivation itself can never disagree with
|
||||
// the Running Mode is covered in `core::runstate` against a fake environment.
|
||||
const { assert!(!PAC_INITIAL_AVAILABLE) }
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dev_quit_route_requires_private_auth_ready_state_and_dispatches_once() -> anyhow::Result<()> {
|
||||
static READY: AtomicBool = AtomicBool::new(false);
|
||||
static REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
READY.store(false, Ordering::Release);
|
||||
REQUESTED.store(false, Ordering::Release);
|
||||
let dispatches = Arc::new(AtomicUsize::new(0));
|
||||
let dispatches_for_route = Arc::clone(&dispatches);
|
||||
let token = "ab".repeat(32);
|
||||
let route = dev_quit_route(token.clone(), &READY, &REQUESTED, move || {
|
||||
dispatches_for_route.fetch_add(1, Ordering::AcqRel);
|
||||
});
|
||||
|
||||
let missing = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(missing.status(), warp::http::StatusCode::NOT_FOUND);
|
||||
|
||||
let wrong_token = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, "cd".repeat(32))
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(wrong_token.status(), warp::http::StatusCode::NOT_FOUND);
|
||||
|
||||
let not_ready = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(not_ready.status(), warp::http::StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
|
||||
let wrong_method = warp::test::request()
|
||||
.method("GET")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(wrong_method.status(), warp::http::StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
|
||||
READY.store(true, Ordering::Release);
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(8));
|
||||
let accepted = futures::future::join_all((0..8).map(|_| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let route = route.clone();
|
||||
let token = token.clone();
|
||||
tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, token)
|
||||
.reply(&route)
|
||||
.await
|
||||
})
|
||||
}))
|
||||
.await;
|
||||
for accepted in accepted {
|
||||
let accepted = accepted?;
|
||||
assert_eq!(accepted.status(), warp::http::StatusCode::ACCEPTED);
|
||||
}
|
||||
assert_eq!(dispatches.load(Ordering::Acquire), 1);
|
||||
|
||||
release_dev_quit_latch(&REQUESTED, clash_verge_signal::ShutdownOutcome::Canceled);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
let retry = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(retry.status(), warp::http::StatusCode::ACCEPTED);
|
||||
assert_eq!(dispatches.load(Ordering::Acquire), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_record_supplies_the_next_primarys_preferred_port() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("singleton-sticky-port-{}", std::process::id()));
|
||||
let path = root.join("instance.json");
|
||||
let reservation = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let preferred_port = reservation.local_addr()?.port();
|
||||
drop(reservation);
|
||||
write_instance_record(
|
||||
&path,
|
||||
&InstanceRecord {
|
||||
port: preferred_port,
|
||||
token: "stale-token-is-not-authority".to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let hint = read_instance_record(&path).ok().map(|record| record.port);
|
||||
let listener = bind_primary_listener(hint).await?;
|
||||
assert_eq!(listener.local_addr()?.port(), preferred_port);
|
||||
drop(listener);
|
||||
super::shutdown_embedded_server();
|
||||
assert!(path.is_file(), "clean shutdown must retain the private port hint");
|
||||
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn occupied_preferred_port_falls_back_without_using_stale_authority() -> anyhow::Result<()> {
|
||||
let occupied = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let preferred_port = occupied.local_addr()?.port();
|
||||
|
||||
let fallback = bind_primary_listener(Some(preferred_port)).await?;
|
||||
assert_ne!(fallback.local_addr()?.port(), preferred_port);
|
||||
assert!(fallback.local_addr()?.ip().is_loopback());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn instance_record_is_private_and_round_trips() -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("singleton-record-{}", std::process::id()));
|
||||
let path = root.join("instance.json");
|
||||
let record = InstanceRecord {
|
||||
port: 42_123,
|
||||
token: "secret".to_string(),
|
||||
};
|
||||
|
||||
write_instance_record(&path, &record)?;
|
||||
|
||||
assert_eq!(read_instance_record(&path)?, record);
|
||||
assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn instance_lock_allows_only_one_primary() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("singleton-lock-{}", std::process::id()));
|
||||
let path = root.join("instance.lock");
|
||||
let first = open_instance_lock(&path)?;
|
||||
let second = open_instance_lock(&path)?;
|
||||
|
||||
assert!(try_lock_instance(&first)?);
|
||||
assert!(!try_lock_instance(&second)?);
|
||||
drop(first);
|
||||
assert!(try_lock_instance(&second)?);
|
||||
|
||||
drop(second);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user