fix: report fatal singleton startup errors

This commit is contained in:
wonfen
2026-08-17 07:18:34 +08:00
parent 3618ee1e05
commit 4869e362e3
7 changed files with 171 additions and 20 deletions
Generated
+1
View File
@@ -1146,6 +1146,7 @@ dependencies = [
"regex",
"reqwest 0.13.4",
"reqwest_dav",
"rfd",
"runas",
"rustls",
"scopeguard",
+1
View File
@@ -60,6 +60,7 @@ bitflags = { workspace = true }
socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] }
open = "5.4.1"
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
dunce = "1.0.5"
nanoid = "0.5"
chrono = { workspace = true }
+55 -7
View File
@@ -31,11 +31,10 @@ mod app_init {
use super::*;
/// Initialize singleton monitoring for other instances
pub fn init_singleton_check() -> Result<()> {
pub fn init_singleton_check() -> Result<server::SingletonDisposition> {
AsyncHandler::block_on(async move {
logging!(info, Type::Setup, "开始检查单例实例...");
server::check_singleton().await?;
Ok(())
server::check_singleton().await
})
}
@@ -213,17 +212,40 @@ mod app_init {
}
}
pub fn run() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartupAction {
Continue,
ExitSuccess,
ExitFailure,
}
fn handle_singleton_startup(
result: Result<server::SingletonDisposition>,
report_error: impl FnOnce(&anyhow::Error),
) -> StartupAction {
match result {
Ok(server::SingletonDisposition::Primary) => StartupAction::Continue,
Ok(server::SingletonDisposition::Secondary) => StartupAction::ExitSuccess,
Err(error) => {
report_error(&error);
StartupAction::ExitFailure
}
}
}
pub fn run() -> std::process::ExitCode {
#[cfg(all(target_os = "macos", not(debug_assertions), not(test), not(feature = "verge-dev")))]
if utils::macos_launch_guard::enforce_before_initialization() == utils::macos_launch_guard::LaunchDisposition::Exit
{
return;
return std::process::ExitCode::SUCCESS;
}
let _ = utils::dirs::init_portable_flag();
if app_init::init_singleton_check().is_err() {
return;
match handle_singleton_startup(app_init::init_singleton_check(), utils::startup::report_error) {
StartupAction::Continue => {}
StartupAction::ExitSuccess => return std::process::ExitCode::SUCCESS,
StartupAction::ExitFailure => return std::process::ExitCode::FAILURE,
}
#[cfg(target_os = "linux")]
@@ -483,4 +505,30 @@ pub fn run() {
},
_ => {}
});
std::process::ExitCode::SUCCESS
}
#[cfg(test)]
mod startup_tests {
use super::{StartupAction, handle_singleton_startup};
use crate::utils::server::SingletonDisposition;
use std::cell::Cell;
#[test]
fn secondary_instance_exits_quietly_and_successfully() {
let reported = Cell::new(false);
let action = handle_singleton_startup(Ok(SingletonDisposition::Secondary), |_| reported.set(true));
assert_eq!(action, StartupAction::ExitSuccess);
assert!(!reported.get());
}
#[test]
fn fatal_singleton_error_is_reported_and_fails_startup() {
let reported = Cell::new(false);
let action = handle_singleton_startup(Err(anyhow::anyhow!("listener failed")), |_| reported.set(true));
assert_eq!(action, StartupAction::ExitFailure);
assert!(reported.get());
}
}
+6 -3
View File
@@ -1,8 +1,11 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::{
process::ExitCode,
sync::atomic::{AtomicUsize, Ordering},
};
fn main() {
fn main() -> ExitCode {
let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let worker_limit = std::cmp::min(default_parallelism, 8);
let blocking_limit = 2 * worker_limit;
@@ -25,5 +28,5 @@ fn main() {
#[cfg(feature = "tokio-trace")]
console_subscriber::init();
app_lib::run();
app_lib::run()
}
+1
View File
@@ -16,6 +16,7 @@ pub mod schtasks;
pub mod server;
pub mod singleton;
pub mod speed;
pub(crate) mod startup;
pub mod tmpl;
#[cfg(target_os = "macos")]
pub mod tray_speed;
+22 -10
View File
@@ -42,16 +42,23 @@ static COMMANDS_READY: AtomicBool = AtomicBool::new(false);
#[cfg(feature = "verge-dev")]
static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
pub async fn check_singleton() -> Result<()> {
let record_path = instance_record_path()?;
let lock = open_instance_lock(&record_path.with_file_name(INSTANCE_LOCK_FILE))?;
if !try_lock_instance(&lock)? {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SingletonDisposition {
Primary,
Secondary,
}
pub async fn check_singleton() -> Result<SingletonDisposition> {
let record_path = instance_record_path().context("failed to resolve singleton instance record path")?;
let lock = open_instance_lock(&record_path.with_file_name(INSTANCE_LOCK_FILE))
.context("failed to initialize singleton lock")?;
if !try_lock_instance(&lock).context("failed to acquire singleton lock")? {
let deadline = std::time::Instant::now() + Duration::from_secs(20);
while std::time::Instant::now() < deadline {
if let Ok(record) = read_instance_record(&record_path)
&& notify_existing_instance(&record).await
{
bail!("app exists");
return Ok(SingletonDisposition::Secondary);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
@@ -59,19 +66,24 @@ pub async fn check_singleton() -> Result<()> {
}
let preferred = read_instance_record(&record_path).ok().map(|record| record.port);
let listener = bind_primary_listener(preferred).await?;
let port = listener.local_addr()?.port();
let listener = bind_primary_listener(preferred)
.await
.context("failed to reserve embedded server port")?;
let port = listener
.local_addr()
.context("failed to read embedded server address")?
.port();
let record = InstanceRecord {
port,
token: random_token()?,
token: random_token().context("failed to generate singleton instance token")?,
};
write_instance_record(&record_path, &record)?;
write_instance_record(&record_path, &record).context("failed to persist singleton instance record")?;
INSTANCE_LOCK
.set(lock)
.map_err(|_| anyhow::anyhow!("singleton lock is already initialized"))?;
let _ = EMBEDDED_PORT.set(port);
start_embedded_server(listener, record.token);
Ok(())
Ok(SingletonDisposition::Primary)
}
async fn bind_primary_listener(preferred: Option<u16>) -> Result<tokio::net::TcpListener> {
+85
View File
@@ -0,0 +1,85 @@
use crate::utils::dirs;
use anyhow::{Context as _, Result};
use chrono::Local;
use std::{
fs::OpenOptions,
io::Write as _,
path::{Path, PathBuf},
};
const STARTUP_LOG_FILE: &str = "startup.log";
pub(crate) fn report_error(error: &anyhow::Error) {
let detail = format!("{error:#}");
eprintln!("[clash-verge] startup failed: {detail}");
let log_result = startup_log_path().and_then(|path| {
append_error(&path, &detail)?;
Ok(path)
});
let message = match log_result {
Ok(path) => format!(
"Clash Verge could not start.\n\n{detail}\n\nDiagnostic log:\n{}",
path.display()
),
Err(log_error) => {
eprintln!("[clash-verge] failed to write startup log: {log_error:#}");
format!(
"Clash Verge could not start.\n\n{detail}\n\nThe diagnostic log could not be written:\n{log_error:#}"
)
}
};
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("Clash Verge startup failed")
.set_description(message)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
fn startup_log_path() -> Result<PathBuf> {
Ok(dirs::preinit_app_data_dir()?.join("logs").join(STARTUP_LOG_FILE))
}
fn append_error(path: &Path, detail: &str) -> Result<()> {
let parent = path.parent().context("startup log has no parent directory")?;
std::fs::create_dir_all(parent).context("failed to create startup log directory")?;
let mut options = OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(path).context("failed to open startup log")?;
writeln!(
file,
"[{}] ERROR [Startup] {detail}",
Local::now().format("%Y-%m-%d %H:%M:%S%.3f")
)
.context("failed to write startup log")?;
file.flush().context("failed to flush startup log")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::append_error;
#[test]
fn startup_log_is_created_and_appended() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!("startup-error-log-{}", std::process::id()));
let path = root.join("nested/startup.log");
append_error(&path, "first failure")?;
append_error(&path, "second failure")?;
let content = std::fs::read_to_string(&path)?;
assert!(content.contains("ERROR [Startup] first failure"));
assert!(content.contains("ERROR [Startup] second failure"));
std::fs::remove_dir_all(root)?;
Ok(())
}
}