Compare commits

..

6 commits

Author SHA1 Message Date
ssrlive
7121a80300 Bump version 0.7.8
Some checks are pending
Push or PR / build_n_test (macos-latest) (push) Waiting to run
Push or PR / build_n_test (ubuntu-latest) (push) Waiting to run
Push or PR / build_n_test (windows-latest) (push) Waiting to run
Push or PR / build_n_test_android (push) Waiting to run
Push or PR / build_n_test_ios (push) Waiting to run
Push or PR / Check semver (push) Waiting to run
Integration Tests / Proxy Tests (push) Waiting to run
2025-04-19 17:50:56 +08:00
ssrlive
9e75475a23 force exit process while fatal error
Some checks are pending
Push or PR / Check semver (push) Waiting to run
Push or PR / build_n_test (macos-latest) (push) Waiting to run
Push or PR / build_n_test (ubuntu-latest) (push) Waiting to run
Push or PR / build_n_test (windows-latest) (push) Waiting to run
Push or PR / build_n_test_android (push) Waiting to run
Push or PR / build_n_test_ios (push) Waiting to run
Integration Tests / Proxy Tests (push) Waiting to run
2025-04-18 16:09:35 +08:00
ssrlive
7657f1603f Bump version 0.7.7
Some checks failed
Push or PR / build_n_test (macos-latest) (push) Has been cancelled
Push or PR / build_n_test (ubuntu-latest) (push) Has been cancelled
Push or PR / build_n_test (windows-latest) (push) Has been cancelled
Push or PR / build_n_test_android (push) Has been cancelled
Push or PR / build_n_test_ios (push) Has been cancelled
Push or PR / Check semver (push) Has been cancelled
Integration Tests / Proxy Tests (push) Has been cancelled
2025-03-28 20:23:47 +08:00
ssrlive
a380817951 update hickory-proto (DNS parser)
Some checks failed
Push or PR / build_n_test (macos-latest) (push) Has been cancelled
Push or PR / build_n_test (ubuntu-latest) (push) Has been cancelled
Push or PR / build_n_test (windows-latest) (push) Has been cancelled
Push or PR / build_n_test_android (push) Has been cancelled
Push or PR / build_n_test_ios (push) Has been cancelled
Push or PR / Check semver (push) Has been cancelled
Integration Tests / Proxy Tests (push) Has been cancelled
2025-03-19 08:36:29 +08:00
ssrlive
a2399c8b28 log ipstack info adjusted
Some checks failed
Push or PR / build_n_test (macos-latest) (push) Has been cancelled
Push or PR / build_n_test (ubuntu-latest) (push) Has been cancelled
Push or PR / build_n_test (windows-latest) (push) Has been cancelled
Push or PR / build_n_test_android (push) Has been cancelled
Push or PR / build_n_test_ios (push) Has been cancelled
Push or PR / Check semver (push) Has been cancelled
Integration Tests / Proxy Tests (push) Has been cancelled
2025-03-12 11:18:47 +08:00
ssrlive
61bbafcf82 version_info method
Some checks are pending
Push or PR / build_n_test (macos-latest) (push) Waiting to run
Push or PR / build_n_test (ubuntu-latest) (push) Waiting to run
Push or PR / build_n_test (windows-latest) (push) Waiting to run
Push or PR / build_n_test_android (push) Waiting to run
Push or PR / build_n_test_ios (push) Waiting to run
Push or PR / Check semver (push) Waiting to run
Integration Tests / Proxy Tests (push) Waiting to run
2025-03-11 12:41:57 +08:00
9 changed files with 92 additions and 48 deletions

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tun2proxy" name = "tun2proxy"
version = "0.7.6" version = "0.7.8"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/tun2proxy/tun2proxy" repository = "https://github.com/tun2proxy/tun2proxy"
@ -36,9 +36,9 @@ digest_auth = "0.3"
dotenvy = "0.15" dotenvy = "0.15"
env_logger = "0.11" env_logger = "0.11"
hashlink = "0.10" hashlink = "0.10"
hickory-proto = "0.24" hickory-proto = "0.25"
httparse = "1" httparse = "1"
ipstack = { version = "0.2" } ipstack = { version = "0.3", git = "https://github.com/ssrlive/ipstack.git", rev = "53c648e" }
log = { version = "0.4", features = ["std"] } log = { version = "0.4", features = ["std"] }
mimalloc = { version = "0.1", default-features = false, optional = true } mimalloc = { version = "0.1", default-features = false, optional = true }
percent-encoding = "2" percent-encoding = "2"
@ -56,6 +56,7 @@ unicase = "2"
url = "2" url = "2"
[build-dependencies] [build-dependencies]
chrono = "0.4"
serde_json = "1" serde_json = "1"
[target.'cfg(target_os="linux")'.dependencies] [target.'cfg(target_os="linux")'.dependencies]
@ -77,5 +78,5 @@ nix = { version = "0.29", default-features = false, features = [
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
windows-service = "0.8" windows-service = "0.8"
[profile.release] # [profile.release]
strip = "symbols" # strip = "symbols"

View file

@ -1,4 +1,13 @@
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Ok(git_hash) = get_git_hash() {
// Set the environment variables
println!("cargo:rustc-env=GIT_HASH={}", git_hash.trim());
}
// Get the build time
let build_time = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
println!("cargo:rustc-env=BUILD_TIME={}", build_time);
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if let Ok(cargo_target_dir) = get_cargo_target_dir() { if let Ok(cargo_target_dir) = get_cargo_target_dir() {
let mut f = std::fs::File::create(cargo_target_dir.join("build.log"))?; let mut f = std::fs::File::create(cargo_target_dir.join("build.log"))?;
@ -85,3 +94,10 @@ fn get_crate_dir(crate_name: &str) -> Result<std::path::PathBuf, Box<dyn std::er
} }
Ok(crate_dir.ok_or("crate_dir")?) Ok(crate_dir.ok_or("crate_dir")?)
} }
fn get_git_hash() -> std::io::Result<String> {
use std::process::Command;
let git_hash = Command::new("git").args(["rev-parse", "--short", "HEAD"]).output()?.stdout;
let git_hash = String::from_utf8(git_hash).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(git_hash)
}

View file

@ -9,7 +9,7 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::str::FromStr; use std::str::FromStr;
#[derive(Debug, Clone, clap::Parser)] #[derive(Debug, Clone, clap::Parser)]
#[command(author, version, about = "Tunnel interface to proxy.", long_about = None)] #[command(author, version = version_info(), about = "Tunnel interface to proxy.", long_about = None)]
pub struct Args { pub struct Args {
/// Proxy URL in the form proto://[username[:password]@]host:port, /// Proxy URL in the form proto://[username[:password]@]host:port,
/// where proto is one of socks4, socks5, http. /// where proto is one of socks4, socks5, http.
@ -127,6 +127,10 @@ pub struct Args {
pub udpgw_keepalive: Option<u64>, pub udpgw_keepalive: Option<u64>,
} }
fn version_info() -> &'static str {
concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), " ", env!("BUILD_TIME"), ")")
}
fn validate_tun(p: &str) -> Result<String> { fn validate_tun(p: &str) -> Result<String> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if p.len() <= 4 || &p[..4] != "utun" { if p.len() <= 4 || &p[..4] != "utun" {

View file

@ -1,4 +1,4 @@
use tun2proxy::{Args, BoxError}; use tun2proxy::{ArgVerbosity, Args, BoxError};
fn main() -> Result<(), BoxError> { fn main() -> Result<(), BoxError> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
@ -28,11 +28,16 @@ fn main() -> Result<(), BoxError> {
} }
async fn main_async(args: Args) -> Result<(), BoxError> { async fn main_async(args: Args) -> Result<(), BoxError> {
let default = format!("{:?},hickory_proto=warn", args.verbosity); let ipstack = match args.verbosity {
ArgVerbosity::Trace => ArgVerbosity::Debug,
_ => args.verbosity,
};
let default = format!("{:?},hickory_proto=warn,ipstack={:?}", args.verbosity, ipstack);
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default)).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default)).init();
let shutdown_token = tokio_util::sync::CancellationToken::new(); let shutdown_token = tokio_util::sync::CancellationToken::new();
let main_loop_handle = tokio::spawn({ let main_loop_handle = tokio::spawn({
let args = args.clone();
let shutdown_token = shutdown_token.clone(); let shutdown_token = shutdown_token.clone();
async move { async move {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@ -40,7 +45,7 @@ async fn main_async(args: Args) -> Result<(), BoxError> {
if let Err(err) = namespace_proxy_main(args, shutdown_token).await { if let Err(err) = namespace_proxy_main(args, shutdown_token).await {
log::error!("namespace proxy error: {}", err); log::error!("namespace proxy error: {}", err);
} }
return; return Ok(0);
} }
unsafe extern "C" fn traffic_cb(status: *const tun2proxy::TrafficStatus, _: *mut std::ffi::c_void) { unsafe extern "C" fn traffic_cb(status: *const tun2proxy::TrafficStatus, _: *mut std::ffi::c_void) {
@ -49,9 +54,11 @@ async fn main_async(args: Args) -> Result<(), BoxError> {
} }
unsafe { tun2proxy::tun2proxy_set_traffic_status_callback(1, Some(traffic_cb), std::ptr::null_mut()) }; unsafe { tun2proxy::tun2proxy_set_traffic_status_callback(1, Some(traffic_cb), std::ptr::null_mut()) };
if let Err(err) = tun2proxy::general_run_async(args, tun::DEFAULT_MTU, cfg!(target_os = "macos"), shutdown_token).await { let ret = tun2proxy::general_run_async(args, tun::DEFAULT_MTU, cfg!(target_os = "macos"), shutdown_token).await;
log::error!("main loop error: {}", err); if let Err(err) = &ret {
log::error!("main loop error: {err}");
} }
ret
} }
}); });
@ -64,13 +71,19 @@ async fn main_async(args: Args) -> Result<(), BoxError> {
}) })
.await; .await;
main_loop_handle.await?; let tasks = main_loop_handle.await??;
if ctrlc_fired.load(std::sync::atomic::Ordering::SeqCst) { if ctrlc_fired.load(std::sync::atomic::Ordering::SeqCst) {
log::info!("Ctrl-C fired, waiting the handler to finish..."); log::info!("Ctrl-C fired, waiting the handler to finish...");
ctrlc_handel.await.map_err(|err| err.to_string())?; ctrlc_handel.await.map_err(|err| err.to_string())?;
} }
if args.exit_on_fatal_error && tasks >= args.max_sessions {
// Because `main_async` function perhaps stuck in `await` state, so we need to exit the process forcefully
log::info!("Internal fatal error, max sessions reached ({tasks}/{})", args.max_sessions);
std::process::exit(-1);
}
Ok(()) Ok(())
} }

View file

@ -1,21 +1,16 @@
use hickory_proto::{ use hickory_proto::{
op::{Message, MessageType, ResponseCode}, op::{Message, MessageType, ResponseCode},
rr::{Name, RData, Record, record_type::RecordType}, rr::{
Name, RData, Record,
rdata::{A, AAAA},
},
}; };
use std::{net::IpAddr, str::FromStr}; use std::{net::IpAddr, str::FromStr};
pub fn build_dns_response(mut request: Message, domain: &str, ip: IpAddr, ttl: u32) -> Result<Message, String> { pub fn build_dns_response(mut request: Message, domain: &str, ip: IpAddr, ttl: u32) -> Result<Message, String> {
let record = match ip { let record = match ip {
IpAddr::V4(ip) => { IpAddr::V4(ip) => Record::from_rdata(Name::from_str(domain)?, ttl, RData::A(A(ip))),
let mut record = Record::with(Name::from_str(domain)?, RecordType::A, ttl); IpAddr::V6(ip) => Record::from_rdata(Name::from_str(domain)?, ttl, RData::AAAA(AAAA(ip))),
record.set_data(Some(RData::A(ip.into())));
record
}
IpAddr::V6(ip) => {
let mut record = Record::with(Name::from_str(domain)?, RecordType::AAAA, ttl);
record.set_data(Some(RData::AAAA(ip.into())));
record
}
}; };
// We must indicate that this message is a response. Otherwise, implementations may not // We must indicate that this message is a response. Otherwise, implementations may not
@ -27,9 +22,7 @@ pub fn build_dns_response(mut request: Message, domain: &str, ip: IpAddr, ttl: u
} }
pub fn remove_ipv6_entries(message: &mut Message) { pub fn remove_ipv6_entries(message: &mut Message) {
message message.answers_mut().retain(|answer| !matches!(answer.data(), RData::AAAA(_)));
.answers_mut()
.retain(|answer| !matches!(answer.data(), Some(RData::AAAA(_))));
} }
pub fn extract_ipaddr_from_dns_message(message: &Message) -> Result<IpAddr, String> { pub fn extract_ipaddr_from_dns_message(message: &Message) -> Result<IpAddr, String> {
@ -38,7 +31,7 @@ pub fn extract_ipaddr_from_dns_message(message: &Message) -> Result<IpAddr, Stri
} }
let mut cname = None; let mut cname = None;
for answer in message.answers() { for answer in message.answers() {
match answer.data().ok_or("DNS response not contains answer data")? { match answer.data() {
RData::A(addr) => { RData::A(addr) => {
return Ok(IpAddr::V4((*addr).into())); return Ok(IpAddr::V4((*addr).into()));
} }

View file

@ -26,7 +26,7 @@ pub enum Error {
IpStack(#[from] ipstack::IpStackError), IpStack(#[from] ipstack::IpStackError),
#[error("DnsProtoError {0:?}")] #[error("DnsProtoError {0:?}")]
DnsProto(#[from] hickory_proto::error::ProtoError), DnsProto(#[from] hickory_proto::ProtoError),
#[error("httparse::Error {0:?}")] #[error("httparse::Error {0:?}")]
Httparse(#[from] httparse::Error), Httparse(#[from] httparse::Error),

View file

@ -120,11 +120,18 @@ pub fn general_run_for_api(args: Args, tun_mtu: u16, packet_information: bool) -
return -3; return -3;
}; };
match rt.block_on(async move { match rt.block_on(async move {
if let Err(err) = general_run_async(args, tun_mtu, packet_information, shutdown_token).await { let ret = general_run_async(args.clone(), tun_mtu, packet_information, shutdown_token).await;
log::error!("main loop error: {}", err); match &ret {
return Err(err); Ok(sessions) => {
if args.exit_on_fatal_error && *sessions >= args.max_sessions {
log::error!("Forced exit due to max sessions reached ({sessions}/{})", args.max_sessions);
std::process::exit(-1);
}
log::debug!("tun2proxy exited normally, current sessions: {sessions}");
}
Err(err) => log::error!("main loop error: {err}"),
} }
Ok(()) ret
}) { }) {
Ok(_) => 0, Ok(_) => 0,
Err(e) => { Err(e) => {
@ -140,7 +147,7 @@ pub async fn general_run_async(
tun_mtu: u16, tun_mtu: u16,
_packet_information: bool, _packet_information: bool,
shutdown_token: tokio_util::sync::CancellationToken, shutdown_token: tokio_util::sync::CancellationToken,
) -> std::io::Result<()> { ) -> std::io::Result<usize> {
let mut tun_config = tun::Configuration::default(); let mut tun_config = tun::Configuration::default();
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]

View file

@ -64,7 +64,7 @@ pub mod win_svc;
const DNS_PORT: u16 = 53; const DNS_PORT: u16 = 53;
static TASK_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static TASK_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::Ordering::Relaxed;
#[allow(unused)] #[allow(unused)]
@ -154,7 +154,9 @@ async fn create_udp_stream(socket_queue: &Option<Arc<SocketQueue>>, peer: Socket
/// * `mtu` - The MTU of the network device /// * `mtu` - The MTU of the network device
/// * `args` - The arguments to use /// * `args` - The arguments to use
/// * `shutdown_token` - The token to exit the server /// * `shutdown_token` - The token to exit the server
pub async fn run<D>(device: D, mtu: u16, args: Args, shutdown_token: CancellationToken) -> crate::Result<()> /// # Returns
/// * The number of sessions while exiting
pub async fn run<D>(device: D, mtu: u16, args: Args, shutdown_token: CancellationToken) -> crate::Result<usize>
where where
D: AsyncRead + AsyncWrite + Unpin + Send + 'static, D: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{ {
@ -265,10 +267,10 @@ where
ip_stack_stream? ip_stack_stream?
} }
}; };
let max_sessions = args.max_sessions as u64; let max_sessions = args.max_sessions;
match ip_stack_stream { match ip_stack_stream {
IpStackStream::Tcp(tcp) => { IpStackStream::Tcp(tcp) => {
if TASK_COUNT.load(Relaxed) > max_sessions { if TASK_COUNT.load(Relaxed) >= max_sessions {
if args.exit_on_fatal_error { if args.exit_on_fatal_error {
log::info!("Too many sessions that over {max_sessions}, exiting..."); log::info!("Too many sessions that over {max_sessions}, exiting...");
break; break;
@ -276,7 +278,7 @@ where
log::warn!("Too many sessions that over {max_sessions}, dropping new session"); log::warn!("Too many sessions that over {max_sessions}, dropping new session");
continue; continue;
} }
log::trace!("Session count {}", TASK_COUNT.fetch_add(1, Relaxed) + 1); log::trace!("Session count {}", TASK_COUNT.fetch_add(1, Relaxed).saturating_add(1));
let info = SessionInfo::new(tcp.local_addr(), tcp.peer_addr(), IpProtocol::Tcp); let info = SessionInfo::new(tcp.local_addr(), tcp.peer_addr(), IpProtocol::Tcp);
let domain_name = if let Some(virtual_dns) = &virtual_dns { let domain_name = if let Some(virtual_dns) = &virtual_dns {
let mut virtual_dns = virtual_dns.lock().await; let mut virtual_dns = virtual_dns.lock().await;
@ -291,11 +293,11 @@ where
if let Err(err) = handle_tcp_session(tcp, proxy_handler, socket_queue).await { if let Err(err) = handle_tcp_session(tcp, proxy_handler, socket_queue).await {
log::error!("{} error \"{}\"", info, err); log::error!("{} error \"{}\"", info, err);
} }
log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed) - 1); log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed).saturating_sub(1));
}); });
} }
IpStackStream::Udp(udp) => { IpStackStream::Udp(udp) => {
if TASK_COUNT.load(Relaxed) > max_sessions { if TASK_COUNT.load(Relaxed) >= max_sessions {
if args.exit_on_fatal_error { if args.exit_on_fatal_error {
log::info!("Too many sessions that over {max_sessions}, exiting..."); log::info!("Too many sessions that over {max_sessions}, exiting...");
break; break;
@ -303,7 +305,7 @@ where
log::warn!("Too many sessions that over {max_sessions}, dropping new session"); log::warn!("Too many sessions that over {max_sessions}, dropping new session");
continue; continue;
} }
log::trace!("Session count {}", TASK_COUNT.fetch_add(1, Relaxed) + 1); log::trace!("Session count {}", TASK_COUNT.fetch_add(1, Relaxed).saturating_add(1));
let mut info = SessionInfo::new(udp.local_addr(), udp.peer_addr(), IpProtocol::Udp); let mut info = SessionInfo::new(udp.local_addr(), udp.peer_addr(), IpProtocol::Udp);
if info.dst.port() == DNS_PORT { if info.dst.port() == DNS_PORT {
if is_private_ip(info.dst.ip()) { if is_private_ip(info.dst.ip()) {
@ -317,7 +319,7 @@ where
if let Err(err) = handle_dns_over_tcp_session(udp, proxy_handler, socket_queue, ipv6_enabled).await { if let Err(err) = handle_dns_over_tcp_session(udp, proxy_handler, socket_queue, ipv6_enabled).await {
log::error!("{} error \"{}\"", info, err); log::error!("{} error \"{}\"", info, err);
} }
log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed) - 1); log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed).saturating_sub(1));
}); });
continue; continue;
} }
@ -328,7 +330,7 @@ where
log::error!("{} error \"{}\"", info, err); log::error!("{} error \"{}\"", info, err);
} }
} }
log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed) - 1); log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed).saturating_sub(1));
}); });
continue; continue;
} }
@ -359,7 +361,7 @@ where
if let Err(e) = handle_udp_gateway_session(udp, udpgw, &dst_addr, proxy_handler, queue, ipv6_enabled).await { if let Err(e) = handle_udp_gateway_session(udp, udpgw, &dst_addr, proxy_handler, queue, ipv6_enabled).await {
log::info!("Ending {} with \"{}\"", info, e); log::info!("Ending {} with \"{}\"", info, e);
} }
log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed) - 1); log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed).saturating_sub(1));
}); });
continue; continue;
} }
@ -371,7 +373,7 @@ where
if let Err(err) = handle_udp_associate_session(udp, ty, proxy_handler, socket_queue, ipv6_enabled).await { if let Err(err) = handle_udp_associate_session(udp, ty, proxy_handler, socket_queue, ipv6_enabled).await {
log::info!("Ending {} with \"{}\"", info, err); log::info!("Ending {} with \"{}\"", info, err);
} }
log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed) - 1); log::trace!("Session count {}", TASK_COUNT.fetch_sub(1, Relaxed).saturating_sub(1));
}); });
} }
Err(e) => { Err(e) => {
@ -390,7 +392,7 @@ where
} }
} }
} }
Ok(()) Ok(TASK_COUNT.load(Relaxed))
} }
async fn handle_virtual_dns_session(mut udp: IpStackUdpStream, dns: Arc<Mutex<VirtualDns>>) -> crate::Result<()> { async fn handle_virtual_dns_session(mut udp: IpStackUdpStream, dns: Arc<Mutex<VirtualDns>>) -> crate::Result<()> {

View file

@ -78,8 +78,16 @@ fn run_service(_arguments: Vec<std::ffi::OsString>) -> Result<(), crate::BoxErro
} }
unsafe { crate::tun2proxy_set_traffic_status_callback(1, Some(traffic_cb), std::ptr::null_mut()) }; unsafe { crate::tun2proxy_set_traffic_status_callback(1, Some(traffic_cb), std::ptr::null_mut()) };
if let Err(err) = crate::general_run_async(args, tun::DEFAULT_MTU, false, shutdown_token).await { let ret = crate::general_run_async(args.clone(), tun::DEFAULT_MTU, false, shutdown_token).await;
log::error!("main loop error: {}", err); match &ret {
Ok(sessions) => {
if args.exit_on_fatal_error && *sessions >= args.max_sessions {
log::error!("Forced exit due to max sessions reached ({sessions}/{})", args.max_sessions);
std::process::exit(-1);
}
log::debug!("tun2proxy exited normally, current sessions: {sessions}");
}
Err(err) => log::error!("main loop error: {err}"),
} }
Ok::<(), crate::Error>(()) Ok::<(), crate::Error>(())
})?; })?;