refactor(log): in-place writer lifecycle and span-based core observability (#7889)

* refactor(log): migrate logging to tracing-estuary pipeline

Swap flexi_logger + clash-verge-logger wiring for the tracing-estuary
crate: buffered file sink with explicit panic/exit drains, live level
switching, and tokio-trace/chrome-trace behind crate features.

AsyncLogger moves in-house (VecDeque ring); file format and rotation
stay unchanged.

* fix(log): reset sidecar writer in place and re-pin service-ipc v2.6.7

Rebuilding the sidecar FileLogWriter on config changes leaked flexi's
unstoppable flusher thread plus an fd each time; the writer is now flushed
and reset in place, matching what the service crate does.
get_clash_logs_by_service drops the CompactString conversion layer now that
service-ipc v2.6.7 returns plain Vec<String>, and AsyncLogger is renamed
LogRing to match.

* refactor(log): span-based observability and log noise cleanup

Instrument spans carry the structure flat lines never could: the core-start
spine (mode, readiness/owner generations, pid, config file), proxy apply and
clear routes with guard generations, service IPC sessions with refusal codes
and outcomes, profile-switch terminal outcomes, subscription fetch strategies,
core upgrades with per-proxy version probes, backup flows, timer tasks, config
generation uids, and the startup setup flow; running-mode changes emit one
guarded transition event each.
~90 hand-written noise lines are gone or demoted (entry+success pairs, polled
getter echoes, phase brackets, window sub-steps, platform install chatter);
errors keep their anyhow chain, operation name, and item id, and swallowed
retry results surface per attempt.
This commit is contained in:
Tunglies
2026-09-08 05:51:04 +08:00
committed by GitHub
parent f8b69b5d62
commit 2f370d6490
43 changed files with 807 additions and 797 deletions
+48 -223
View File
@@ -1,236 +1,61 @@
use std::{
str::FromStr as _,
sync::{
Arc,
atomic::{AtomicU64, AtomicUsize, Ordering},
},
};
use anyhow::{Result, bail};
use clash_verge_logging::{Type, logging};
use compact_str::CompactString;
use flexi_logger::{
Cleanup, Criterion, DeferredNow, FileSpec, LogSpecBuilder, LogSpecification, LoggerHandle,
writers::{FileLogWriter, FileLogWriterBuilder, LogWriter as _},
};
use log::{Level, LevelFilter, Record};
use parking_lot::{Mutex, RwLock};
use anyhow::Result;
use clash_verge_logging::{LoggerConfig, Type, logging};
use crate::{
core::{CoreManager, manager::RunningMode, service},
singleton,
utils::dirs::{self, sidecar_log_dir},
utils::dirs,
};
pub use clash_verge_logging::Logger;
const fn should_sync_service_writer(running_mode: RunningMode) -> bool {
matches!(running_mode, RunningMode::Service)
}
pub struct Logger {
handle: Arc<Mutex<Option<LoggerHandle>>>,
sidecar_file_writer: Arc<RwLock<Option<FileLogWriter>>>,
log_level: Arc<RwLock<LevelFilter>>,
log_max_size: AtomicU64,
log_max_count: AtomicUsize,
}
impl Default for Logger {
fn default() -> Self {
Self {
handle: Arc::new(Mutex::new(None)),
sidecar_file_writer: Arc::new(RwLock::new(None)),
log_level: Arc::new(RwLock::new(LevelFilter::Info)),
log_max_size: AtomicU64::new(128),
log_max_count: AtomicUsize::new(8),
}
}
}
singleton!(Logger, LOGGER);
impl Logger {
fn new() -> Self {
Self::default()
}
pub async fn init(&self) -> Result<()> {
let (log_level, log_max_size, log_max_count) = {
let verge_guard = crate::config::Config::verge().await;
let verge = verge_guard.latest_arc();
(
verge.get_log_level(),
verge.app_log_max_size.unwrap_or(128),
verge.app_log_max_count.unwrap_or(8),
)
};
let log_level = std::env::var("RUST_LOG")
.ok()
.and_then(|v| log::LevelFilter::from_str(&v).ok())
.unwrap_or(log_level);
*self.log_level.write() = log_level;
self.log_max_size.store(log_max_size, Ordering::SeqCst);
self.log_max_count.store(log_max_count, Ordering::SeqCst);
#[cfg(not(any(feature = "tauri-dev", feature = "tokio-trace")))]
{
let log_spec = Self::generate_log_spec(log_level);
let log_dir = dirs::app_logs_dir()?;
let logger = flexi_logger::Logger::with(log_spec)
.log_to_file(FileSpec::default().directory(log_dir).basename(""))
.duplicate_to_stdout(log_level.into())
.format(clash_verge_logger::console_format)
.format_for_files(clash_verge_logger::file_format_with_level)
.rotate(
Criterion::Size(log_max_size * 1024),
flexi_logger::Naming::TimestampsCustomFormat {
current_infix: Some("latest"),
format: "%Y-%m-%d_%H-%M-%S",
},
Cleanup::KeepLogFiles(log_max_count),
);
let mut filter_modules = vec!["wry", "tokio_tungstenite", "tungstenite"];
#[cfg(not(feature = "tracing"))]
filter_modules.push("tauri");
#[cfg(feature = "tracing")]
filter_modules.extend(["tauri_plugin_mihomo", "kode_bridge"]);
let logger = logger.filter(Box::new(clash_verge_logging::ModuleFilter::new(
filter_modules,
Some(vec!["tauri_plugin_mihomo"]),
)));
let handle = logger.start()?;
*self.handle.lock() = Some(handle);
}
let sidecar_file_writer = self.generate_sidecar_writer()?;
*self.sidecar_file_writer.write() = Some(sidecar_file_writer);
std::panic::set_hook(Box::new(move |info| {
// Capture both common panic payload types instead of logging String payloads as unknown.
// This global hook covers panics after logger init; early setup panics are handled separately.
let payload = info
.payload()
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "Unknown panic payload".to_string());
let location = info
.location()
.map(|loc| format!("{}:{}", loc.file(), loc.line()))
.unwrap_or_else(|| "Unknown location".to_string());
logging!(error, Type::System, "Panic occurred at {}: {}", location, payload);
if let Some(h) = Self::global().handle.lock().as_ref() {
h.flush();
std::thread::sleep(std::time::Duration::from_millis(100));
}
}));
Ok(())
}
fn generate_log_spec(log_level: LevelFilter) -> LogSpecification {
let mut spec = LogSpecBuilder::new();
let log_level = std::env::var("RUST_LOG")
.ok()
.and_then(|v| log::LevelFilter::from_str(&v).ok())
.unwrap_or(log_level);
spec.default(log_level);
#[cfg(feature = "tracing")]
spec.module("tauri", log::LevelFilter::Debug)
.module("wry", log::LevelFilter::Off)
.module("tauri_plugin_mihomo", log::LevelFilter::Off);
spec.build()
}
fn generate_file_log_writer(&self) -> Result<FileLogWriterBuilder> {
let log_dir = dirs::app_logs_dir()?;
let log_max_size = self.log_max_size.load(Ordering::SeqCst);
let log_max_count = self.log_max_count.load(Ordering::SeqCst);
let flwb = FileLogWriter::builder(FileSpec::default().directory(log_dir).basename("")).rotate(
Criterion::Size(log_max_size * 1024),
flexi_logger::Naming::TimestampsCustomFormat {
current_infix: Some("latest"),
format: "%Y-%m-%d_%H-%M-%S",
},
Cleanup::KeepLogFiles(log_max_count),
);
Ok(flwb)
}
/// only update app log level
pub fn update_log_level(&self, level: LevelFilter) -> Result<()> {
*self.log_level.write() = level;
let log_level = self.log_level.read().to_owned();
if let Some(handle) = self.handle.lock().as_mut() {
let log_spec = Self::generate_log_spec(log_level);
handle.set_new_spec(log_spec);
handle.adapt_duplication_to_stdout(log_level.into())?;
} else {
bail!("failed to get logger handle, make sure it init");
};
Ok(())
}
/// update app and mihomo core log config
pub async fn update_log_config(&self, log_max_size: u64, log_max_count: usize) -> Result<()> {
self.log_max_size.store(log_max_size, Ordering::SeqCst);
self.log_max_count.store(log_max_count, Ordering::SeqCst);
if let Some(handle) = self.handle.lock().as_ref() {
let log_file_writer = self.generate_file_log_writer()?;
handle.reset_flw(&log_file_writer)?;
} else {
bail!("failed to get logger handle, make sure it init");
};
let sidecar_writer = self.generate_sidecar_writer()?;
*self.sidecar_file_writer.write() = Some(sidecar_writer);
// The service writer is auxiliary to the local logger. Synchronize it only
// for an active service session and do not roll back local settings on failure.
if should_sync_service_writer(*CoreManager::global().get_running_mode())
&& let Err(error) = service::update_writer_by_service(&clash_verge_service_ipc::WriterConfig {
directory: String::new(),
max_log_size: log_max_size * 1024,
max_log_files: log_max_count,
})
.await
{
logging!(warn, Type::Service, "failed to update service writer config: {error:#}");
}
Ok(())
}
fn generate_sidecar_writer(&self) -> Result<FileLogWriter> {
let sidecar_log_dir = sidecar_log_dir()?;
let log_max_size = self.log_max_size.load(Ordering::SeqCst);
let log_max_count = self.log_max_count.load(Ordering::SeqCst);
Ok(FileLogWriter::builder(
FileSpec::default()
.directory(sidecar_log_dir)
.basename("sidecar")
.suppress_timestamp(),
async fn logger_config() -> Result<LoggerConfig> {
let (level, max_size, max_count) = {
let verge_guard = crate::config::Config::verge().await;
let verge = verge_guard.latest_arc();
(
verge.get_log_level(),
verge.app_log_max_size.unwrap_or(128),
verge.app_log_max_count.unwrap_or(8),
)
.format(clash_verge_logger::file_format_without_level)
.rotate(
Criterion::Size(log_max_size * 1024),
flexi_logger::Naming::TimestampsCustomFormat {
current_infix: Some("latest"),
format: "%Y-%m-%d_%H-%M-%S",
},
Cleanup::KeepLogFiles(log_max_count),
)
.try_build()?)
};
Ok(LoggerConfig {
level,
max_size_kb: max_size,
max_count,
log_dir: dirs::app_logs_dir()?,
sidecar_log_dir: dirs::sidecar_log_dir()?,
})
}
/// Initializes the logging stack. The devtools plugin installs its own global
/// subscriber; in that mode only the sidecar writer and panic hook are set up.
pub async fn init() -> Result<()> {
let cfg = logger_config().await?;
#[cfg(not(feature = "tauri-dev"))]
Logger::global().init(&cfg)?;
Logger::global().init_sidecar(&cfg)?;
Ok(())
}
pub async fn update_log_config(log_max_size: u64, log_max_count: usize) -> Result<()> {
Logger::global().update_log_config(log_max_size, log_max_count)?;
// The service writer is auxiliary to the local logger. Synchronize it only
// for an active service session and do not roll back local settings on failure.
if should_sync_service_writer(*CoreManager::global().get_running_mode())
&& let Err(error) = service::update_writer_by_service(&clash_verge_service_ipc::WriterConfig {
directory: String::new(),
max_log_size: log_max_size * 1024,
max_log_files: log_max_count,
})
.await
{
logging!(warn, Type::Service, "failed to update service writer config: {error:#}");
}
pub fn writer_sidecar_log(&self, level: Level, message: &CompactString) {
if let Some(writer) = self.sidecar_file_writer.read().as_ref() {
let mut now = DeferredNow::default();
let args = format_args!("{}", message);
let record = Record::builder().args(args).level(level).target("sidecar").build();
let _ = writer.write(&mut now, &record);
} else {
logging!(error, Type::System, "failed to get sidecar file log writer");
}
}
Ok(())
}
+20 -8
View File
@@ -90,7 +90,7 @@ async fn ask_to_stage(path: &std::path::Path) -> StageAttempt {
match crate::core::service::stage_runtime_by_service(path).await {
Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome),
Ok(StageRequest::Refused { code, message }) => {
let message = message.to_string().into();
let message = message.into();
if StageRequest::is_about_the_bundle(code) {
StageAttempt::RefusedTheBundle(message)
} else {
@@ -103,6 +103,7 @@ async fn ask_to_stage(path: &std::path::Path) -> StageAttempt {
/// Confirms one silent request because the service may commit before its reply is lost.
/// Re-staging an already committed bundle is idempotent and bounded by `confirm_within`.
#[tracing::instrument(skip_all, level = "debug", fields(confirm_within = ?confirm_within, asks = 1))]
async fn stage_with_confirmation<Ask, Fut>(confirm_within: Duration, ask: Ask) -> StageAttempt
where
Ask: Fn() -> Fut,
@@ -117,6 +118,7 @@ where
Type::Core,
"Staging did not answer ({first}); asking once more before replacing the core"
);
tracing::Span::current().record("asks", 2);
match tokio::time::timeout(confirm_within, ask()).await {
Ok(StageAttempt::Unanswered(again)) => {
StageAttempt::Unanswered(format!("{first}; asked again: {again}").into())
@@ -150,6 +152,7 @@ impl CoreManager {
self.update_config_with_force(true).await
}
#[tracing::instrument(skip_all, level = "info", fields(force))]
pub async fn update_config_with_force(&self, force: bool) -> Result<ValidationOutcome> {
if handle::Handle::global().is_exiting() {
return Ok(ValidationOutcome::Skipped {
@@ -158,7 +161,7 @@ impl CoreManager {
}
if !self.try_start_config_update() {
logging!(info, Type::Core, "Configuration update is already running");
logging!(debug, Type::Core, "Configuration update is already running");
return Ok(ValidationOutcome::Busy);
}
defer! {
@@ -179,6 +182,7 @@ impl CoreManager {
self.perform_config_update(None).await
}
#[tracing::instrument(skip_all, level = "info", fields(profile = ?candidate.current, outcome = tracing::field::Empty))]
pub(crate) async fn update_config_forced_with_profiles(
&self,
candidate: &IProfiles,
@@ -202,17 +206,23 @@ impl CoreManager {
{
Ok(outcome) => outcome,
Err(error) => {
tracing::Span::current().record("outcome", "rolled_back");
self.restore_profile_config(rollback).await?;
return Err(error);
}
};
if !outcome.is_valid() {
tracing::Span::current().record("outcome", "invalid");
crate::config::profiles::restore_selected_nodes().await;
return Ok(Err(outcome));
}
match candidate.save_file().await {
Ok(()) => Ok(Ok(guard)),
Ok(()) => {
tracing::Span::current().record("outcome", "committed");
Ok(Ok(guard))
}
Err(error) => {
tracing::Span::current().record("outcome", "save_rolled_back");
self.restore_profile_config(rollback).await?;
Err(error)
}
@@ -273,7 +283,7 @@ impl CoreManager {
F: FnOnce(&mut IRuntime),
{
if !self.try_start_config_update() {
logging!(info, Type::Core, "Configuration update is already running");
logging!(debug, Type::Core, "Configuration update is already running");
return Ok(ValidationOutcome::Busy);
}
defer! {
@@ -315,6 +325,7 @@ impl CoreManager {
/// Applies through the service, replacing the core when in-place staging is unavailable.
/// Caller must hold `lifecycle_lock`.
#[tracing::instrument(skip_all, level = "info", fields(path = %path.display(), outcome = tracing::field::Empty))]
async fn apply_config_by_service(&self, path: &std::path::Path) -> Result<()> {
match plan_config_application(&self.attempt_staging(path).await) {
ConfigApplication::Fail(message) => {
@@ -328,7 +339,7 @@ impl CoreManager {
}
ConfigApplication::ReloadFrom(staged) => match self.reload_config(&staged).await {
Ok(()) => {
logging!(info, Type::Core, "Configuration staged and applied by service");
tracing::Span::current().record("outcome", "staged");
return Ok(());
}
Err(err) => logging!(
@@ -341,7 +352,7 @@ impl CoreManager {
}
self.replace_service_core_with_config(path).await?;
logging!(info, Type::Core, "Configuration materialized and applied by service");
tracing::Span::current().record("outcome", "replaced");
Ok(())
}
@@ -353,9 +364,10 @@ impl CoreManager {
}
/// Reload the Core from `path`, and replace the Core if it will not take it.
#[tracing::instrument(skip_all, level = "info", fields(outcome = tracing::field::Empty))]
async fn reload_or_restart(&self, path: &str) -> Result<()> {
let Err(err) = self.reload_config(path).await else {
logging!(info, Type::Core, "Configuration applied");
tracing::Span::current().record("outcome", "reloaded");
return Ok(());
};
@@ -366,7 +378,7 @@ impl CoreManager {
);
match self.restart_core_during_config_update().await {
Ok(_) => {
logging!(info, Type::Core, "Configuration applied after restart");
tracing::Span::current().record("outcome", "restarted");
Ok(())
}
Err(err) => {
+15 -3
View File
@@ -317,6 +317,7 @@ impl CoreManager {
.await
}
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))]
pub async fn continue_with_sidecar(&self) -> Result<()> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
@@ -329,6 +330,7 @@ impl CoreManager {
let config_write = Config::lock_config_write().await;
let _life = self.lifecycle_lock.lock().await;
let status = SERVICE_MANAGER.current().await;
tracing::Span::current().record("status", tracing::field::debug(&status));
let mode = self.get_running_mode();
if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
@@ -342,6 +344,7 @@ impl CoreManager {
.state()
.tun_should_be_disabled(tun_enabled)
{
tracing::Span::current().record("tun_disabled", true);
Config::disable_tun_and_persist().await?;
}
Config::generate().await
@@ -361,6 +364,7 @@ impl CoreManager {
{
anyhow::bail!("Sidecar did not become ready");
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
}
.await;
@@ -376,6 +380,7 @@ impl CoreManager {
Ok(())
}
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
pub async fn uninstall_service_and_start_sidecar(&self) -> Result<()> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
@@ -413,6 +418,7 @@ impl CoreManager {
{
anyhow::bail!("Sidecar did not become ready after service uninstall");
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
},
)
@@ -456,6 +462,7 @@ impl CoreManager {
.await
}
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode(), readiness_generation = self.current_core_readiness_generation(), owner_generation = crate::core::service::owner_monitor_generation()))]
pub(crate) async fn apply_proxy_after_start(&self) -> Result<()> {
let expectation = ProxyRestoreExpectation::capture(
*self.get_running_mode(),
@@ -519,6 +526,7 @@ impl CoreManager {
Ok(())
}
#[tracing::instrument(skip_all, level = "info", fields(decision = tracing::field::Empty))]
async fn start_core_inner(&self) -> Result<()> {
if Handle::global().is_exiting() {
return Ok(());
@@ -526,7 +534,7 @@ impl CoreManager {
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
logging!(
info,
debug,
Type::Core,
"start_core called while a core is running; treated as no-op"
);
@@ -534,6 +542,7 @@ impl CoreManager {
}
let startup = self.prepare_startup().await;
tracing::Span::current().record("decision", tracing::field::debug(&startup));
if matches!(startup, StartupDecision::Wait) {
self.rollback_failed_start().await;
return Ok(());
@@ -563,6 +572,7 @@ impl CoreManager {
result
}
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode()))]
pub async fn stop_core(&self) -> Result<()> {
let _life = self.lifecycle_lock.lock().await;
self.controlled_stop_core_inner().await
@@ -594,7 +604,7 @@ impl CoreManager {
}
async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs().await;
CLASH_LOGGER.clear_logs();
match *self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => {
@@ -605,6 +615,7 @@ impl CoreManager {
}
}
#[tracing::instrument(skip_all, level = "info")]
pub async fn restart_core(&self) -> Result<()> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
@@ -617,7 +628,6 @@ impl CoreManager {
pub(crate) async fn restart_core_during_config_update(&self) -> Result<()> {
let _life = self.lifecycle_lock.lock().await;
logging!(info, Type::Core, "Restarting core");
let proxy_intent = self.proxy_stop_intent().await;
run_core_replacement_transition(
|| self.controlled_stop_core_with_intent(proxy_intent),
@@ -635,6 +645,7 @@ impl CoreManager {
.await
}
#[tracing::instrument(skip_all, level = "info", fields(core = %clash_core))]
pub async fn change_core(&self, clash_core: &String) -> Result<()> {
if !IVerge::VALID_CLASH_CORES.contains(&clash_core.as_str()) {
anyhow::bail!("invalid clash core: {clash_core}");
@@ -757,6 +768,7 @@ impl CoreManager {
}
#[cfg(target_os = "windows")]
#[tracing::instrument(skip_all, level = "debug", fields(outcome = tracing::field::Empty))]
async fn try_handoff_sidecar_to_service(&self) -> HandoffOutcome {
// Before probing: a probe reply records an observation, which clears sidecar_allowed.
if crate::core::runstate::RUN_STATE.state().sidecar_allowed {
+13 -4
View File
@@ -4,8 +4,7 @@ mod state;
use anyhow::Result;
use arc_swap::{ArcSwap, ArcSwapOption};
use clash_verge_logger::AsyncLogger;
use clash_verge_logging::{Type, logging};
use clash_verge_logging::{LogRing, Type, logging};
use once_cell::sync::Lazy;
use std::{
fmt,
@@ -22,7 +21,7 @@ use crate::singleton;
#[cfg(target_os = "windows")]
use std::os::windows::io::OwnedHandle;
pub(crate) static CLASH_LOGGER: Lazy<Arc<AsyncLogger>> = Lazy::new(|| Arc::new(AsyncLogger::new()));
pub(crate) static CLASH_LOGGER: Lazy<Arc<LogRing>> = Lazy::new(|| Arc::new(LogRing::new()));
tokio::task_local! {
static PROFILE_SELECTIONS_PENDING_COMMIT: bool;
@@ -161,6 +160,10 @@ impl CoreManager {
/// Run State derives PAC availability and the outward mode mirror from this; callers must
/// not set those alongside.
pub fn core_started(&self, mode: RunningMode) {
let previous = *self.get_running_mode();
if previous != mode {
logging!(info, Type::Core, "Core running mode changed: {previous} -> {mode}");
}
self.run_state.core_started(mode);
}
@@ -169,6 +172,10 @@ impl CoreManager {
/// Core readiness is invalidated here rather than inside Run State because readiness
/// belongs to the process this manager supervises.
pub fn core_stopped(&self) {
let previous = *self.get_running_mode();
if !matches!(previous, RunningMode::NotRunning) {
logging!(info, Type::Core, "Core running mode changed: {previous} -> NotRunning");
}
self.invalidate_core_readiness();
self.run_state.core_stopped();
}
@@ -244,6 +251,7 @@ impl CoreManager {
self.config_update_in_progress.store(false, Ordering::Release);
}
#[tracing::instrument(skip_all, level = "info", fields(retries = 0))]
pub async fn init(&self) -> Result<bool> {
const MAX_PORT_FALLBACK_RETRIES: usize = 3;
@@ -266,10 +274,11 @@ impl CoreManager {
match crate::config::Config::resolve_startup_mixed_port().await {
Ok(true) => {
retries += 1;
tracing::Span::current().record("retries", retries);
logging!(
warn,
Type::Core,
"Retrying core startup after mixed proxy port fallback ({}/{})",
"Retrying core startup after mixed proxy port fallback ({}/{}): {start_error:#}",
retries,
MAX_PORT_FALLBACK_RETRIES
);
+27 -32
View File
@@ -10,7 +10,6 @@ use crate::{
};
use anyhow::{Context as _, Result};
use clash_verge_logging::Type;
use compact_str::CompactString;
use log::Level;
use scopeguard::defer;
use std::path::Path;
@@ -49,7 +48,16 @@ where
for attempt in 0..max_attempts {
match probe().await {
Ok(()) => return Ok(()),
Err(error) => last_error = Some(error),
Err(error) => {
logging!(
debug,
Type::Core,
"sidecar readiness probe {}/{} failed: {error:#}",
attempt + 1,
max_attempts
);
last_error = Some(error);
}
}
if attempt + 1 < max_attempts {
tokio::time::sleep(retry_delay).await;
@@ -80,16 +88,16 @@ use {
};
impl CoreManager {
pub async fn get_clash_logs(&self) -> Result<Vec<CompactString>> {
pub async fn get_clash_logs(&self) -> Result<Vec<String>> {
match *self.get_running_mode() {
RunningMode::Service => service::get_clash_logs_by_service().await,
RunningMode::Sidecar => Ok(CLASH_LOGGER.get_logs().await),
RunningMode::Sidecar => Ok(CLASH_LOGGER.get_logs()),
RunningMode::NotRunning => Ok(Vec::new()),
}
}
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
logging!(info, Type::Core, "Starting core in sidecar mode");
self.core_stopped();
let sidecar_ipc = dirs::sidecar_ipc_path()?;
@@ -158,7 +166,7 @@ impl CoreManager {
};
let pid = child.pid();
logging!(trace, Type::Core, "Sidecar started with PID: {}", pid);
tracing::Span::current().record("pid", pid);
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
@@ -192,22 +200,22 @@ impl CoreManager {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = CompactString::from(&*String::from_utf8_lossy(&line));
let message = String::from_utf8_lossy(&line).into_owned();
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message).await;
CLASH_LOGGER.append_log(message);
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
CompactString::from(format!("Process terminated with code: {}", code))
format!("Process terminated with code: {}", code)
} else if let Some(signal) = term.signal {
CompactString::from(format!("Process terminated by signal: {}", signal))
format!("Process terminated by signal: {}", signal)
} else {
CompactString::from("Process terminated")
String::from("Process terminated")
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs().await;
CLASH_LOGGER.clear_logs();
manager.clear_terminated_sidecar(pid).await;
break;
}
@@ -222,7 +230,6 @@ impl CoreManager {
/// Terminates the sidecar after its caller has successfully cleared the
/// system proxy.
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
logging!(info, Type::Core, "Stopping sidecar");
defer! {
self.core_stopped();
}
@@ -234,27 +241,16 @@ impl CoreManager {
// Setting the job handle to None clears the stored handle and
// closes the previous Windows job handle in `set_job_handle`.
self.set_job_handle(None);
logging!(
trace,
Type::Core,
"Closed job handle for sidecar process (PID: {})",
pid
);
let _ = pid;
}
let result = child.kill();
logging!(
trace,
Type::Core,
"Sidecar stopped (PID: {:?}, Result: {:?})",
pid,
result
);
if let Err(error) = child.kill() {
logging!(warn, Type::Core, "failed to terminate sidecar PID {pid}: {error:#}");
}
}
}
pub(super) async fn start_core_by_service(&self) -> Result<()> {
logging!(info, Type::Core, "Starting core in service mode");
self.core_starting();
let service_ipc = dirs::ipc_path()?;
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
@@ -265,6 +261,7 @@ impl CoreManager {
self.start_core_by_service_with_config(&config_file).await
}
#[tracing::instrument(skip_all, level = "info", fields(config_file = %config_file.display()))]
pub(super) async fn start_core_by_service_with_config(&self, config_file: &Path) -> Result<()> {
// 交接时等待 sidecar 释放 ext-controller 通道。
#[cfg(target_os = "windows")]
@@ -283,10 +280,9 @@ impl CoreManager {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {}",
"service start attempt {}/{} failed: {e:#}",
attempt + 1,
timing::SERVICE_START_RETRIES,
e
timing::SERVICE_START_RETRIES
);
last_err = Some(e);
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
@@ -307,7 +303,6 @@ impl CoreManager {
}
pub(super) async fn stop_core_by_service(&self) -> Result<()> {
logging!(info, Type::Core, "Stopping service");
service::stop_core_by_service().await?;
self.core_stopped();
Ok(())
+5
View File
@@ -43,6 +43,11 @@ pub fn start() {
};
CFRunLoop::get_main().add_source(&source, unsafe { kCFRunLoopCommonModes });
ARMED.store(true, Ordering::Release);
logging!(
debug,
Type::Core,
"network watch armed; the system proxy is re-applied on network changes"
);
}
fn on_change(_: SCDynamicStore, _: CFArray<CFString>, (): &mut ()) {
+15 -6
View File
@@ -471,10 +471,13 @@ fn table_effect(result: &Result<()>) -> TableEffect<'_> {
}
}
#[tracing::instrument(skip_all, level = "info", fields(route = tracing::field::Empty))]
pub async fn apply() -> Result<()> {
let running_mode = CoreManager::global().get_running_mode();
let verge = Config::verge().await.latest_arc();
let result = match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
let route = proxy_backend_route(cfg!(target_os = "macos"), &running_mode);
tracing::Span::current().record("route", tracing::field::debug(&route));
let result = match route {
ProxyBackendRoute::Local => match Sysopt::global().update_sysproxy().await {
Ok(()) => Ok(()),
Err(error) => Err(classify_local_apply_failure(error).await),
@@ -520,7 +523,7 @@ pub async fn clear() -> Result<()> {
/// A failed system call is usually a transient RPC hiccup, so give it a few tries.
async fn clear_with_retry() -> Result<()> {
for _ in 1..CLEAR_ATTEMPTS {
for attempt in 1..CLEAR_ATTEMPTS {
match clear_inner().await {
Err(error)
if matches!(
@@ -531,7 +534,7 @@ async fn clear_with_retry() -> Result<()> {
logging!(
warn,
Type::Core,
"clearing the system proxy failed; retrying: {error:#}"
"clearing the system proxy failed (attempt {attempt}/{CLEAR_ATTEMPTS}); retrying: {error:#}"
);
tokio::time::sleep(CLEAR_RETRY_DELAY).await;
}
@@ -541,9 +544,12 @@ async fn clear_with_retry() -> Result<()> {
clear_inner().await
}
#[tracing::instrument(skip_all, level = "info", fields(route = tracing::field::Empty))]
async fn clear_inner() -> Result<()> {
let running_mode = CoreManager::global().get_running_mode();
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
let route = proxy_backend_route(cfg!(target_os = "macos"), &running_mode);
tracing::Span::current().record("route", tracing::field::debug(&route));
match route {
ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await.map_err(classify_local_failure),
ProxyBackendRoute::Service => {
SERVICE_PROXY_OPERATIONS
@@ -599,13 +605,16 @@ pub async fn refresh_guard() -> Result<()> {
.await
{
Ok(true) => consecutive_failures = 0,
Ok(false) => break,
Ok(false) => {
logging!(debug, Type::Core, "proxy guard superseded (generation {generation})");
break;
}
Err(error) => {
consecutive_failures += 1;
logging!(
warn,
Type::Core,
"failed to refresh system proxy through Service ({}/{}): {:#}",
"failed to refresh system proxy through Service (generation {generation}, {}/{}): {:#}",
consecutive_failures,
GUARD_FAILURES_BEFORE_STOPPING,
error
+93 -24
View File
@@ -23,7 +23,6 @@ use clash_verge_service_ipc::{
MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome, RuntimeBundle, ServiceErrorCode, StageRuntimeOutcome,
StartClashRequest, WriterConfig,
};
use compact_str::CompactString;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::{
@@ -74,13 +73,19 @@ pub(crate) fn clear_active_service_session() {
}
/// Probes staging support without failing startup when the fast path is unavailable.
#[tracing::instrument(skip_all, level = "info", fields(supported = tracing::field::Empty))]
async fn probe_runtime_staging_support() -> bool {
match clash_verge_service_ipc::get_version().await {
Ok(response) if response.code == 0 => response
.data
.as_ref()
.is_some_and(clash_verge_service_ipc::ProtocolInfo::supports_runtime_staging),
Ok(response) if response.code == 0 => {
let supported = response
.data
.as_ref()
.is_some_and(clash_verge_service_ipc::ProtocolInfo::supports_runtime_staging);
tracing::Span::current().record("supported", supported);
supported
}
Ok(response) => {
tracing::Span::current().record("supported", false);
logging!(
warn,
Type::Service,
@@ -91,6 +96,7 @@ async fn probe_runtime_staging_support() -> bool {
false
}
Err(error) => {
tracing::Span::current().record("supported", false);
logging!(
warn,
Type::Service,
@@ -818,7 +824,7 @@ async fn collect_service_runtime_bundle(config_file: &Path) -> Result<RuntimeBun
/// A staging response whose refusal code tells callers whether a fresh start can help.
pub(super) enum StageRequest {
Refused { code: u16, message: CompactString },
Refused { code: u16, message: String },
Answered(StageRuntimeOutcome),
}
@@ -841,7 +847,7 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
if response.code > 0 {
return Ok(StageRequest::Refused {
code: response.code,
message: response.message.into(),
message: response.message,
});
}
response
@@ -851,8 +857,8 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
}
/// 尝试使用服务启动core
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
logging!(info, Type::Service, "尝试使用现有服务启动核心");
clear_active_service_session();
let credentials = current_owner_credentials()?;
@@ -867,14 +873,23 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
let response = match clash_verge_service_ipc::start_clash(&credentials, &request).await {
Ok(response) => response,
Err(error) => {
tracing::Span::current().record("outcome", "ipc-unreachable");
start_owner_monitor();
return Err(error).context("无法连接到Clash Verge Service");
}
};
if response.code > 0 {
tracing::Span::current().record("code", response.code);
tracing::Span::current().record("outcome", "refused");
let err_msg = response.message;
logging!(error, Type::Service, "启动核心失败: {}", err_msg);
logging!(
error,
Type::Service,
"启动核心失败 (code {}): {}",
response.code,
err_msg
);
start_owner_monitor();
bail!(
"failed to start Service core at {}: {err_msg}",
@@ -883,7 +898,9 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
}
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
tracing::Span::current().record("generation", result.session.generation);
let supports_runtime_staging = probe_runtime_staging_support().await;
tracing::Span::current().record("staging", supports_runtime_staging);
*ACTIVE_SERVICE_SESSION.lock() = Some(ActiveServiceSession {
proof: OwnerSessionProof {
generation: result.session.generation,
@@ -894,14 +911,18 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
start_owner_monitor();
logging!(info, Type::Service, "服务成功启动核心");
tracing::Span::current().record("outcome", "started");
logging!(
info,
Type::Service,
"服务成功启动核心 (session generation {})",
result.session.generation
);
Ok(())
}
// 以服务启动core
pub(super) async fn run_core_by_service(config_file: &Path) -> Result<()> {
logging!(info, Type::Service, "正在尝试通过服务启动核心");
SERVICE_MANAGER.refresh().await?;
let status = SERVICE_MANAGER.current().await;
@@ -922,9 +943,8 @@ where
(captured, operation().await)
}
pub(super) async fn get_clash_logs_by_service() -> Result<Vec<CompactString>> {
logging!(info, Type::Service, "正在获取服务模式下的 Clash 日志");
pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
// Frontend-polled: no per-call logging here.
let credentials = current_owner_credentials()?;
let (generation, response) = capture_generation_before(&OWNER_MONITOR_GENERATION, || {
clash_verge_service_ipc::get_clash_logs(&credentials)
@@ -937,11 +957,9 @@ pub(super) async fn get_clash_logs_by_service() -> Result<Vec<CompactString>> {
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
}
let err_msg = response.message;
logging!(error, Type::Service, "获取服务模式下的 Clash 日志失败: {}", err_msg);
bail!(err_msg);
}
logging!(info, Type::Service, "成功获取服务模式下的 Clash 日志");
Ok(response.data.unwrap_or_default())
}
@@ -970,8 +988,8 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
}
/// 通过服务停止core
#[tracing::instrument(skip_all, level = "info", fields(code = tracing::field::Empty, outcome = tracing::field::Empty))]
pub(super) async fn stop_core_by_service() -> Result<()> {
logging!(info, Type::Service, "通过服务停止核心 (IPC)");
cancel_owner_monitors();
let credentials = match current_owner_credentials() {
@@ -1007,11 +1025,20 @@ pub(super) async fn stop_core_by_service() -> Result<()> {
start_owner_monitor();
}
let err_msg = response.message;
logging!(error, Type::Service, "停止核心失败: {}", err_msg);
tracing::Span::current().record("code", response.code);
tracing::Span::current().record("outcome", "refused");
logging!(
error,
Type::Service,
"停止核心失败 (code {}): {}",
response.code,
err_msg
);
bail!(err_msg);
}
clear_active_service_session();
tracing::Span::current().record("outcome", "stopped");
logging!(info, Type::Service, "服务成功停止核心");
Ok(())
}
@@ -1023,6 +1050,13 @@ pub(crate) async fn update_writer_by_service(writer: &WriterConfig) -> Result<()
.await
.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
logging!(
warn,
Type::Service,
"update writer rejected by service: code={}, {}",
response.code,
response.message
);
bail!(response.message);
}
Ok(())
@@ -1042,6 +1076,13 @@ pub(super) async fn set_system_proxy_by_service_with_session(
.await
.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
logging!(
warn,
Type::Service,
"set system proxy rejected by service: code={}, {}",
response.code,
response.message
);
bail!(response.message);
}
response.data.context("Clash Verge Service 未返回系统代理结果")
@@ -1074,13 +1115,24 @@ const SUSTAINED_OWNER_SAMPLES: u8 = 3;
fn start_owner_monitor() {
let generation = OWNER_MONITOR_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
AsyncHandler::spawn(move || async move {
logging!(debug, Type::Service, "owner monitor started (generation {generation})");
let mut watch = OwnerWatch::new();
loop {
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
logging!(
debug,
Type::Service,
"owner monitor superseded (generation {generation})"
);
break;
}
if !matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
logging!(
debug,
Type::Service,
"owner monitor stopped; core no longer in service mode (generation {generation})"
);
break;
}
@@ -1091,7 +1143,7 @@ fn start_owner_monitor() {
logging!(
warn,
Type::Service,
"service owner status unavailable for {SUSTAINED_OWNER_SAMPLES} samples; \
"service owner status unavailable for {SUSTAINED_OWNER_SAMPLES} samples (generation {generation}); \
preserving local proxy state while the core endpoint still answers"
);
}
@@ -1199,6 +1251,7 @@ fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation:
.map(|_| recovery_generation)
}
#[tracing::instrument(skip_all, level = "info", fields(reason = ?reason))]
async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
logging!(
warn,
@@ -1215,10 +1268,15 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
}
let mut last_error = None;
for _ in 0..3 {
for attempt in 1..=3 {
match proxy_control::clear().await {
Ok(()) => return,
Err(error) => {
logging!(
warn,
Type::Service,
"proxy clear attempt {attempt}/3 after owner loss failed: {error:#}"
);
last_error = Some(error);
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -1228,24 +1286,35 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
logging!(
error,
Type::Service,
"failed to clear local proxy after owner loss: {error}"
"failed to clear local proxy after owner loss: {error:#}"
);
}
}
/// Waits for a repaired service, preserving readable rejection details but classifying sustained
/// silence as unavailable.
#[tracing::instrument(skip_all, level = "info", fields(attempts = tracing::field::Empty, interval_ms = tracing::field::Empty, outcome = tracing::field::Empty))]
async fn wait_for_service_ipc() -> Result<()> {
const CONTEXT: &str = "service IPC did not become available";
let config = ServiceManager::config();
let span = tracing::Span::current();
span.record("attempts", config.max_retries);
span.record("interval_ms", config.retry_delay.as_millis() as u64);
match RUN_STATE.await_ready(config.max_retries, config.retry_delay).await {
Ok(_) => Ok(()),
Ok(_) => {
tracing::Span::current().record("outcome", "ready");
Ok(())
}
Err(ReadyWaitError::Unreachable(error)) => {
tracing::Span::current().record("outcome", "unreachable");
RUN_STATE.observe(ServiceHealth::Unavailable(format!("{CONTEXT}: {error:#}")));
Err(error).context(CONTEXT)
}
Err(ReadyWaitError::Rejected(error)) => Err(error).context(CONTEXT),
Err(ReadyWaitError::Rejected(error)) => {
tracing::Span::current().record("outcome", "rejected");
Err(error).context(CONTEXT)
}
}
}
+5 -11
View File
@@ -132,7 +132,7 @@ impl Timer {
if let Err(e) = self.refresh().await {
self.initialized.store(false, Ordering::SeqCst);
logging_error!(Type::Timer, "Failed to initialize timer: {}", e);
logging!(error, Type::Timer, "Failed to initialize timer: {e:#}");
return Err(e);
}
@@ -424,7 +424,7 @@ impl Timer {
}
fn spawn_update_task(uid: String, command_tx: mpsc::UnboundedSender<TimerCommand>) {
logging!(info, Type::Timer, "Starting timer task: uid={}", uid);
logging!(debug, Type::Timer, "Starting timer task: uid={}", uid);
AsyncHandler::spawn(move || async move {
Self::wait_until_resolve_done(Duration::from_millis(5000)).await;
Self::async_task(&uid).await;
@@ -466,8 +466,8 @@ impl Timer {
}
}
#[tracing::instrument(skip_all, level = "info", fields(uid = %uid))]
async fn async_task(uid: &String) {
let task_start = std::time::Instant::now();
logging!(debug, Type::Timer, "Running timer task for profile: {}", uid);
run_timer_profile_update_transition(
@@ -475,15 +475,9 @@ impl Timer {
|| feat::update_profile(uid, None, false),
|result| match result {
Ok(_) => {
logging!(
info,
Type::Timer,
"Timer task completed for uid: {} (took {}ms)",
uid,
task_start.elapsed().as_millis()
);
logging!(debug, Type::Timer, "timer task completed for uid: {}", uid);
}
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid),
},
|| Self::emit_update_event(uid, false),
)