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:
wonfen
2026-10-03 10:56:41 +08:00
parent a58cefdc31
commit d87bb7b6a3
2 changed files with 17 additions and 201 deletions
+1
View File
@@ -3,6 +3,7 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
</details>
+14 -199
View File
@@ -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 {
// 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;
} else {
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
}
}
Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
});
});
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(())
}
}