diff --git a/Changelog.md b/Changelog.md
index 0927a0100..2e8f46b64 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -3,6 +3,7 @@
🐞 修复问题
+- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
diff --git a/src-tauri/src/utils/server.rs b/src-tauri/src/utils/server.rs
index 91e9946b5..1acd6d8c5 100644
--- a/src-tauri/src/utils/server.rs
+++ b/src-tauri/src/utils/server.rs
@@ -58,9 +58,7 @@ pub async fn check_singleton() -> Result {
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(())
- }
-}