mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-29 01:52:08 +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>
|
<details>
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
|
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
+16
-201
@@ -58,9 +58,7 @@ pub async fn check_singleton() -> Result<SingletonDisposition> {
|
|||||||
if std::time::Instant::now() >= deadline {
|
if std::time::Instant::now() >= deadline {
|
||||||
bail!("another app instance is starting");
|
bail!("another app instance is starting");
|
||||||
}
|
}
|
||||||
// A restarting instance releases this lock only after spawning this
|
// Retry the lock: during restart, the old server stops before releasing it.
|
||||||
// process, and its embedded server is already down, so waiting on
|
|
||||||
// the notify probe alone can never promote this instance.
|
|
||||||
if try_lock_instance(&lock).context("failed to acquire singleton lock")? {
|
if try_lock_instance(&lock).context("failed to acquire singleton lock")? {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -211,15 +209,20 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
|||||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
logging!(info, Type::Window, "检测到从单例模式恢复应用窗口");
|
// Keep blocking UI calls off HTTP workers.
|
||||||
if crate::APP_HANDLE.get().is_some() {
|
AsyncHandler::spawn_blocking(|| {
|
||||||
if !lightweight::exit_lightweight_mode().await {
|
AsyncHandler::block_on(async {
|
||||||
WindowManager::show_main_window().await;
|
logging!(
|
||||||
} else {
|
info,
|
||||||
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
|
Type::Window,
|
||||||
}
|
"Restoring the application window from a secondary instance"
|
||||||
}
|
);
|
||||||
Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
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 {
|
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_config = Config::verge().await;
|
||||||
let verge_data = verge_config.data_arc();
|
let verge_data = verge_config.data_arc();
|
||||||
let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC);
|
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
|
// Port changes disable PAC, so the draft port avoids per-request Core queries.
|
||||||
// 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.
|
|
||||||
let pac_port = MixedPort::desired().await;
|
let pac_port = MixedPort::desired().await;
|
||||||
let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}"));
|
let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}"));
|
||||||
Ok::<_, warp::Rejection>(
|
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")?;
|
std::fs::rename(&temporary, path).context("failed to replace singleton record")?;
|
||||||
Ok(())
|
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