提交qiming-mcp-proxy
This commit is contained in:
147
qiming-mcp-proxy/voice-cli/src/utils/cleanup.rs
Normal file
147
qiming-mcp-proxy/voice-cli/src/utils/cleanup.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use std::path::Path;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Perform global cleanup operations during shutdown
|
||||
pub async fn perform_shutdown_cleanup() {
|
||||
info!("Starting shutdown cleanup operations");
|
||||
|
||||
// Clean up any remaining temporary files
|
||||
cleanup_temp_directories().await;
|
||||
|
||||
// Flush any remaining logs
|
||||
flush_logs().await;
|
||||
|
||||
info!("Shutdown cleanup completed");
|
||||
}
|
||||
|
||||
/// Clean up temporary directories and files
|
||||
async fn cleanup_temp_directories() {
|
||||
let temp_patterns = [
|
||||
"/tmp/voice-cli-*",
|
||||
"./temp/voice-cli-*",
|
||||
"./tmp/voice-cli-*",
|
||||
];
|
||||
|
||||
for pattern in &temp_patterns {
|
||||
if let Some(parent) = Path::new(pattern).parent() {
|
||||
if parent.exists() {
|
||||
match std::fs::read_dir(parent) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(name) = path.file_name() {
|
||||
if name.to_string_lossy().starts_with("voice-cli-") {
|
||||
if path.is_file() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to cleanup temp file {:?}: {}", path, e);
|
||||
} else {
|
||||
info!("Cleaned up temp file: {:?}", path);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&path) {
|
||||
warn!(
|
||||
"Failed to cleanup temp directory {:?}: {}",
|
||||
path, e
|
||||
);
|
||||
} else {
|
||||
info!("Cleaned up temp directory: {:?}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to read temp directory {:?}: {}", parent, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush any remaining logs
|
||||
async fn flush_logs() {
|
||||
// Give the logging system a moment to flush any remaining logs
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Force flush tracing subscriber if possible
|
||||
// Note: This is a best-effort operation
|
||||
info!("Log flush completed");
|
||||
}
|
||||
|
||||
/// Clean up specific temporary files
|
||||
pub fn cleanup_files(files: &[std::path::PathBuf]) {
|
||||
for file in files {
|
||||
if file.exists() {
|
||||
if let Err(e) = std::fs::remove_file(file) {
|
||||
warn!("Failed to cleanup file {:?}: {}", file, e);
|
||||
} else {
|
||||
info!("Cleaned up file: {:?}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up a specific directory and all its contents
|
||||
pub fn cleanup_directory<P: AsRef<Path>>(dir: P) -> Result<(), std::io::Error> {
|
||||
let dir = dir.as_ref();
|
||||
if dir.exists() && dir.is_dir() {
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
info!("Cleaned up directory: {:?}", dir);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_files() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file1 = temp_dir.path().join("test1.txt");
|
||||
let file2 = temp_dir.path().join("test2.txt");
|
||||
|
||||
// Create test files
|
||||
File::create(&file1).unwrap();
|
||||
File::create(&file2).unwrap();
|
||||
|
||||
assert!(file1.exists());
|
||||
assert!(file2.exists());
|
||||
|
||||
// Cleanup files
|
||||
cleanup_files(&[file1.clone(), file2.clone()]);
|
||||
|
||||
assert!(!file1.exists());
|
||||
assert!(!file2.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_directory() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let test_dir = temp_dir.path().join("test_cleanup");
|
||||
std::fs::create_dir(&test_dir).unwrap();
|
||||
|
||||
// Create a file in the directory
|
||||
let test_file = test_dir.join("test.txt");
|
||||
File::create(&test_file).unwrap();
|
||||
|
||||
assert!(test_dir.exists());
|
||||
assert!(test_file.exists());
|
||||
|
||||
// Cleanup directory
|
||||
cleanup_directory(&test_dir).unwrap();
|
||||
|
||||
assert!(!test_dir.exists());
|
||||
assert!(!test_file.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_perform_shutdown_cleanup() {
|
||||
// This test just ensures the function runs without panicking
|
||||
perform_shutdown_cleanup().await;
|
||||
}
|
||||
}
|
||||
253
qiming-mcp-proxy/voice-cli/src/utils/mime_types.rs
Normal file
253
qiming-mcp-proxy/voice-cli/src/utils/mime_types.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use std::path::Path;
|
||||
use url::Url;
|
||||
|
||||
/// MIME 类型到文件扩展名的映射
|
||||
/// 专注于音视频格式,其他类型统一使用 bin 扩展名
|
||||
pub fn mime_type_to_extension(content_type: &str) -> &'static str {
|
||||
match content_type {
|
||||
// 音频类型
|
||||
"audio/mpeg" => "mp3",
|
||||
"audio/wav" => "wav",
|
||||
"audio/flac" => "flac",
|
||||
"audio/mp4" => "m4a",
|
||||
"audio/ogg" => "ogg",
|
||||
"audio/aac" => "aac",
|
||||
"audio/opus" => "opus",
|
||||
"audio/webm" => "webm",
|
||||
"audio/x-matroska" => "mka",
|
||||
"audio/x-ms-wma" => "wma",
|
||||
"audio/x-wav" => "wav",
|
||||
"audio/vnd.wave" => "wav",
|
||||
"audio/x-aiff" => "aiff",
|
||||
"audio/aiff" => "aiff",
|
||||
"audio/x-caf" => "caf",
|
||||
"audio/x-m4a" => "m4a",
|
||||
"audio/x-mpeg" => "mp3",
|
||||
"audio/x-ogg" => "ogg",
|
||||
|
||||
// 视频类型
|
||||
"video/mp4" => "mp4",
|
||||
"video/webm" => "webm",
|
||||
"video/x-matroska" => "mkv",
|
||||
"video/x-msvideo" => "avi",
|
||||
"video/quicktime" => "mov",
|
||||
"video/x-ms-wmv" => "wmv",
|
||||
"video/x-flv" => "flv",
|
||||
"video/3gpp" => "3gp",
|
||||
"video/3gpp2" => "3g2",
|
||||
"video/mpeg" => "mpeg",
|
||||
"video/x-mpeg" => "mpeg",
|
||||
|
||||
// 其他所有类型统一使用 bin 扩展名
|
||||
_ => {
|
||||
// 对于非音视频类型,直接使用默认扩展名,不输出警告日志
|
||||
"bin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 URL 中提取文件扩展名
|
||||
pub fn extract_extension_from_url(url: &str) -> Option<&'static str> {
|
||||
if let Ok(parsed_url) = Url::parse(url) {
|
||||
parsed_url
|
||||
.path_segments()
|
||||
.and_then(|segments| segments.last())
|
||||
.and_then(|filename| {
|
||||
if let Some(dot_index) = filename.rfind('.') {
|
||||
let ext = &filename[dot_index + 1..];
|
||||
// 将常见扩展名映射为标准格式
|
||||
match ext.to_lowercase().as_str() {
|
||||
"mp3" => Some("mp3"),
|
||||
"wav" => Some("wav"),
|
||||
"flac" => Some("flac"),
|
||||
"m4a" => Some("m4a"),
|
||||
"mp4" => Some("mp4"),
|
||||
"mov" => Some("mov"),
|
||||
"avi" => Some("avi"),
|
||||
"mkv" => Some("mkv"),
|
||||
"webm" => Some("webm"),
|
||||
"ogg" => Some("ogg"),
|
||||
"aac" => Some("aac"),
|
||||
"opus" => Some("opus"),
|
||||
"wma" => Some("wma"),
|
||||
"flv" => Some("flv"),
|
||||
"3gp" => Some("3gp"),
|
||||
"caf" => Some("caf"),
|
||||
"aiff" => Some("aiff"),
|
||||
"bin" => Some("bin"),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 从文件路径推断 MIME 类型
|
||||
pub fn infer_mime_type_from_path<P: AsRef<Path>>(path: P) -> Option<&'static str> {
|
||||
infer::get_from_path(path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|kind| kind.mime_type())
|
||||
}
|
||||
|
||||
/// 从文件扩展名获取 MIME 类型
|
||||
pub fn extension_to_mime_type(extension: &str) -> &'static str {
|
||||
match extension.to_lowercase().as_str() {
|
||||
"mp3" => "audio/mpeg",
|
||||
"wav" => "audio/wav",
|
||||
"flac" => "audio/flac",
|
||||
"m4a" => "audio/mp4",
|
||||
"mp4" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"webm" => "video/webm",
|
||||
"ogg" => "audio/ogg",
|
||||
"aac" => "audio/aac",
|
||||
"opus" => "audio/opus",
|
||||
"wma" => "audio/x-ms-wma",
|
||||
"flv" => "video/x-flv",
|
||||
"3gp" => "video/3gpp",
|
||||
"caf" => "audio/x-caf",
|
||||
"aiff" => "audio/x-aiff",
|
||||
"bin" => "application/octet-stream",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// 智能获取文件扩展名(优先使用 MIME 类型,回退到 URL 扩展名)
|
||||
pub fn get_file_extension(content_type: &str, url: &str) -> &'static str {
|
||||
let extension = mime_type_to_extension(content_type);
|
||||
|
||||
// 如果得到的是默认扩展名,尝试从 URL 中获取更精确的扩展名
|
||||
if extension == "bin" {
|
||||
if let Some(url_extension) = extract_extension_from_url(url) {
|
||||
return url_extension;
|
||||
}
|
||||
}
|
||||
|
||||
extension
|
||||
}
|
||||
|
||||
/// 检查是否为支持的音频格式
|
||||
pub fn is_supported_audio_format(mime_type: &str) -> bool {
|
||||
matches!(
|
||||
mime_type,
|
||||
"audio/mpeg" | // MP3
|
||||
"audio/wav" | // WAV
|
||||
"audio/flac" | // FLAC
|
||||
"audio/mp4" | // M4A
|
||||
"audio/ogg" | // OGG
|
||||
"audio/aac" | // AAC
|
||||
"audio/opus" | // Opus
|
||||
"audio/webm" | // WebM Audio
|
||||
"audio/x-matroska" | // Matroska Audio
|
||||
"audio/x-wav" | // WAV (alternative)
|
||||
"audio/vnd.wave" | // WAV (alternative)
|
||||
"audio/x-aiff" | // AIFF
|
||||
"audio/aiff" | // AIFF (alternative)
|
||||
"audio/x-caf" // CAF
|
||||
)
|
||||
}
|
||||
|
||||
/// 检查是否为支持的视频格式
|
||||
pub fn is_supported_video_format(mime_type: &str) -> bool {
|
||||
matches!(
|
||||
mime_type,
|
||||
"video/mp4" | // MP4
|
||||
"video/webm" | // WebM
|
||||
"video/x-matroska" | // MKV
|
||||
"video/x-msvideo" | // AVI
|
||||
"video/quicktime" | // MOV
|
||||
"video/x-ms-wmv" | // WMV
|
||||
"video/x-flv" | // FLV
|
||||
"video/3gpp" | // 3GP
|
||||
"video/3gpp2" | // 3G2
|
||||
"video/mpeg" | // MPEG
|
||||
"video/x-mpeg" // MPEG (alternative)
|
||||
)
|
||||
}
|
||||
|
||||
/// 检查是否为支持的媒体格式
|
||||
pub fn is_supported_media_format(mime_type: &str) -> bool {
|
||||
is_supported_audio_format(mime_type) || is_supported_video_format(mime_type)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mime_type_to_extension() {
|
||||
assert_eq!(mime_type_to_extension("audio/mpeg"), "mp3");
|
||||
assert_eq!(mime_type_to_extension("audio/wav"), "wav");
|
||||
assert_eq!(mime_type_to_extension("video/mp4"), "mp4");
|
||||
assert_eq!(mime_type_to_extension("unknown/type"), "bin");
|
||||
assert_eq!(mime_type_to_extension("text/plain"), "bin");
|
||||
assert_eq!(mime_type_to_extension("application/json"), "bin");
|
||||
assert_eq!(mime_type_to_extension("image/jpeg"), "bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_extension_from_url() {
|
||||
assert_eq!(
|
||||
extract_extension_from_url("https://example.com/test.mp3"),
|
||||
Some("mp3")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_extension_from_url("https://example.com/test.wav"),
|
||||
Some("wav")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_extension_from_url("https://example.com/test?format=mp3"),
|
||||
None
|
||||
);
|
||||
assert_eq!(extract_extension_from_url("invalid-url"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_to_mime_type() {
|
||||
assert_eq!(extension_to_mime_type("mp3"), "audio/mpeg");
|
||||
assert_eq!(extension_to_mime_type("wav"), "audio/wav");
|
||||
assert_eq!(extension_to_mime_type("mp4"), "video/mp4");
|
||||
assert_eq!(
|
||||
extension_to_mime_type("unknown"),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_extension() {
|
||||
assert_eq!(
|
||||
get_file_extension("audio/mpeg", "https://example.com/test.mp3"),
|
||||
"mp3"
|
||||
);
|
||||
assert_eq!(
|
||||
get_file_extension("unknown/type", "https://example.com/test.wav"),
|
||||
"wav"
|
||||
);
|
||||
assert_eq!(
|
||||
get_file_extension("unknown/type", "https://example.com/test"),
|
||||
"bin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supported_formats() {
|
||||
assert!(is_supported_audio_format("audio/mpeg"));
|
||||
assert!(is_supported_audio_format("audio/wav"));
|
||||
assert!(!is_supported_audio_format("video/mp4"));
|
||||
|
||||
assert!(is_supported_video_format("video/mp4"));
|
||||
assert!(is_supported_video_format("video/webm"));
|
||||
assert!(!is_supported_video_format("audio/mpeg"));
|
||||
|
||||
assert!(is_supported_media_format("audio/mpeg"));
|
||||
assert!(is_supported_media_format("video/mp4"));
|
||||
assert!(!is_supported_media_format("unknown/type"));
|
||||
}
|
||||
}
|
||||
229
qiming-mcp-proxy/voice-cli/src/utils/mod.rs
Normal file
229
qiming-mcp-proxy/voice-cli/src/utils/mod.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
pub mod cleanup;
|
||||
pub mod mime_types;
|
||||
pub mod signal_handling;
|
||||
pub mod task_id;
|
||||
|
||||
use crate::VoiceCliError;
|
||||
use crate::models::Config;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{Level, info};
|
||||
use tracing_appender::rolling::{Builder, Rotation};
|
||||
use tracing_subscriber::{EnvFilter, prelude::*};
|
||||
|
||||
// Re-export signal handling components
|
||||
pub use cleanup::perform_shutdown_cleanup;
|
||||
pub use mime_types::{
|
||||
extension_to_mime_type, extract_extension_from_url, get_file_extension,
|
||||
infer_mime_type_from_path, is_supported_audio_format, is_supported_media_format,
|
||||
is_supported_video_format, mime_type_to_extension,
|
||||
};
|
||||
pub use signal_handling::{
|
||||
create_combined_shutdown_signal, create_service_shutdown_signal, create_shutdown_signal,
|
||||
handle_system_signals,
|
||||
};
|
||||
pub use task_id::generate_task_id;
|
||||
|
||||
/// Initialize logging based on configuration
|
||||
/// The guard is stored globally to ensure logging stays active
|
||||
pub fn init_logging(config: &Config) -> crate::Result<()> {
|
||||
// Check if logging is already initialized
|
||||
if tracing::dispatcher::has_been_set() {
|
||||
// Reset logging to allow proper file logging setup
|
||||
// Use the proper method to clear the global dispatcher
|
||||
tracing::subscriber::set_global_default(tracing::subscriber::NoSubscriber::default()).ok();
|
||||
}
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
let log_dir = config.log_dir_path();
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
|
||||
// Parse log level
|
||||
let level = parse_log_level(&config.logging.level)?;
|
||||
|
||||
// Create file appender with rotation and max_log_files
|
||||
let file_appender = Builder::new()
|
||||
.rotation(Rotation::DAILY)
|
||||
.filename_prefix("voice-cli")
|
||||
.max_log_files(config.logging.max_files as usize)
|
||||
.build(&log_dir)
|
||||
.map_err(|e| {
|
||||
VoiceCliError::Config(format!("Failed to initialize log file appender: {}", e))
|
||||
})?;
|
||||
|
||||
// Create console layer with proper formatting
|
||||
let console_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(true);
|
||||
|
||||
// Create file layer with detailed formatting
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(file_appender)
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false);
|
||||
|
||||
// Create filters based on configured log level
|
||||
let console_filter = EnvFilter::new(&format!("{}", level));
|
||||
let file_filter = EnvFilter::new(&format!("{}", level));
|
||||
|
||||
// Create the subscriber with both layers
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(console_layer.with_filter(console_filter))
|
||||
.with(file_layer.with_filter(file_filter));
|
||||
|
||||
// Set as global default and get the guard
|
||||
let _ = tracing::subscriber::set_global_default(subscriber)
|
||||
.map_err(|e| VoiceCliError::Config(format!("Failed to set global subscriber: {}", e)))?;
|
||||
|
||||
// Store the guard globally to keep logging active
|
||||
|
||||
info!(
|
||||
"Logging initialized - Level: {}, Directory: {:?}",
|
||||
config.logging.level, log_dir
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse log level string to tracing Level
|
||||
fn parse_log_level(level_str: &str) -> crate::Result<Level> {
|
||||
match level_str.to_lowercase().as_str() {
|
||||
"trace" => Ok(Level::TRACE),
|
||||
"debug" => Ok(Level::DEBUG),
|
||||
"info" => Ok(Level::INFO),
|
||||
"warn" | "warning" => Ok(Level::WARN),
|
||||
"error" => Ok(Level::ERROR),
|
||||
_ => Err(VoiceCliError::Config(format!(
|
||||
"Invalid log level: {}. Valid levels: trace, debug, info, warn, error",
|
||||
level_str
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up old log files based on configuration
|
||||
pub fn cleanup_old_logs(config: &Config) -> crate::Result<()> {
|
||||
let log_dir = config.log_dir_path();
|
||||
|
||||
if !log_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let max_files = config.logging.max_files as usize;
|
||||
|
||||
// Get all log files
|
||||
let mut log_files: Vec<_> = std::fs::read_dir(&log_dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext == "log")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by modification time (newest first)
|
||||
log_files.sort_by_key(|entry| {
|
||||
entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
log_files.reverse();
|
||||
|
||||
// Remove old files
|
||||
if log_files.len() > max_files {
|
||||
for old_file in log_files.iter().skip(max_files) {
|
||||
if let Err(e) = std::fs::remove_file(old_file.path()) {
|
||||
tracing::warn!("Failed to remove old log file {:?}: {}", old_file.path(), e);
|
||||
} else {
|
||||
tracing::debug!("Removed old log file: {:?}", old_file.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current executable path
|
||||
pub fn get_current_exe_path() -> crate::Result<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.map_err(|e| VoiceCliError::Config(format!("Failed to get current executable path: {}", e)))
|
||||
}
|
||||
|
||||
/// Check if a port is available
|
||||
pub fn is_port_available(host: &str, port: u16) -> bool {
|
||||
match std::net::TcpListener::bind(format!("{}:{}", host, port)) {
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a safe filename from a string
|
||||
pub fn safe_filename(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
|
||||
_ => '_',
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get system information for debugging
|
||||
pub fn get_system_info() -> SystemInfo {
|
||||
SystemInfo {
|
||||
os: std::env::consts::OS.to_string(),
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
family: std::env::consts::FAMILY.to_string(),
|
||||
exe_suffix: std::env::consts::EXE_SUFFIX.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SystemInfo {
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub family: String,
|
||||
pub exe_suffix: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_log_level() {
|
||||
assert!(matches!(parse_log_level("info"), Ok(Level::INFO)));
|
||||
assert!(matches!(parse_log_level("INFO"), Ok(Level::INFO)));
|
||||
assert!(matches!(parse_log_level("debug"), Ok(Level::DEBUG)));
|
||||
assert!(matches!(parse_log_level("error"), Ok(Level::ERROR)));
|
||||
assert!(parse_log_level("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_filename() {
|
||||
assert_eq!(safe_filename("hello world"), "hello_world");
|
||||
assert_eq!(safe_filename("test-file.mp3"), "test-file.mp3");
|
||||
assert_eq!(safe_filename("special@#$chars"), "special___chars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_port_available() {
|
||||
// Test with a likely available port
|
||||
assert!(is_port_available("127.0.0.1", 0)); // Port 0 should always be available for testing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_system_info() {
|
||||
let info = get_system_info();
|
||||
assert!(!info.os.is_empty());
|
||||
assert!(!info.arch.is_empty());
|
||||
}
|
||||
}
|
||||
206
qiming-mcp-proxy/voice-cli/src/utils/signal_handling.rs
Normal file
206
qiming-mcp-proxy/voice-cli/src/utils/signal_handling.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
//! Shared signal handling utilities
|
||||
//!
|
||||
//! This module provides unified signal handling for background services,
|
||||
//! eliminating duplicate signal handling code across different services.
|
||||
|
||||
use tokio::signal;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// Create a unified shutdown signal handler that listens to multiple sources
|
||||
///
|
||||
/// This function provides consistent signal handling across all services:
|
||||
/// - Ctrl+C (SIGINT)
|
||||
/// - SIGTERM (on Unix platforms)
|
||||
/// - Manual shutdown signals
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// use voice_cli::utils::signal_handling::create_shutdown_signal;
|
||||
/// use tracing::info;
|
||||
///
|
||||
/// # async fn example() {
|
||||
/// let shutdown_signal = create_shutdown_signal();
|
||||
/// tokio::select! {
|
||||
/// _ = async { /* service_work */ } => {},
|
||||
/// _ = shutdown_signal => {
|
||||
/// info!("Received shutdown signal, stopping service");
|
||||
/// }
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn create_shutdown_signal() {
|
||||
handle_system_signals().await
|
||||
}
|
||||
|
||||
/// Handle system signals for graceful shutdown
|
||||
///
|
||||
/// This provides the core signal handling logic used by all services.
|
||||
/// Separated into its own function for reusability and testing.
|
||||
pub async fn handle_system_signals() {
|
||||
let ctrl_c = async {
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
error!("Failed to listen for Ctrl+C: {}", e);
|
||||
} else {
|
||||
info!("Received Ctrl+C signal");
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
match signal(SignalKind::terminate()) {
|
||||
Ok(mut stream) => {
|
||||
stream.recv().await;
|
||||
info!("Received SIGTERM signal");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to listen for SIGTERM: {}", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => debug!("Ctrl+C signal handled"),
|
||||
_ = terminate => debug!("SIGTERM signal handled"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a combined shutdown signal that listens to both system signals and manual channels
|
||||
///
|
||||
/// This is useful for services that need to respond to both system signals (Ctrl+C, SIGTERM)
|
||||
/// and programmatic shutdown requests via channels.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `manual_shutdown` - A future that completes when manual shutdown is requested
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// use voice_cli::utils::signal_handling::create_combined_shutdown_signal;
|
||||
/// use tracing::info;
|
||||
///
|
||||
/// # async fn example() {
|
||||
/// let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
/// let shutdown_signal = create_combined_shutdown_signal(async {
|
||||
/// shutdown_rx.await.ok();
|
||||
/// });
|
||||
///
|
||||
/// tokio::select! {
|
||||
/// _ = async { /* service_work */ } => {},
|
||||
/// _ = shutdown_signal => {
|
||||
/// info!("Received shutdown signal");
|
||||
/// }
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn create_combined_shutdown_signal<F>(manual_shutdown: F)
|
||||
where
|
||||
F: std::future::Future<Output = ()>,
|
||||
{
|
||||
tokio::select! {
|
||||
_ = handle_system_signals() => {
|
||||
info!("Received system shutdown signal");
|
||||
}
|
||||
_ = manual_shutdown => {
|
||||
info!("Received manual shutdown signal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a shutdown signal with service-specific logging
|
||||
///
|
||||
/// This provides service-specific logging messages for better debugging.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `service_name` - Name of the service for logging context
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// use voice_cli::utils::signal_handling::create_service_shutdown_signal;
|
||||
///
|
||||
/// # async fn example() {
|
||||
/// let shutdown_signal = create_service_shutdown_signal("http-server");
|
||||
/// tokio::select! {
|
||||
/// _ = async { /* service_work */ } => {},
|
||||
/// _ = shutdown_signal => {}
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn create_service_shutdown_signal(service_name: &str) {
|
||||
let ctrl_c = async {
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
error!("Failed to listen for Ctrl+C in {}: {}", service_name, e);
|
||||
} else {
|
||||
info!("{} received Ctrl+C signal", service_name);
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
match signal(SignalKind::terminate()) {
|
||||
Ok(mut stream) => {
|
||||
stream.recv().await;
|
||||
info!("{} received SIGTERM signal", service_name);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to listen for SIGTERM in {}: {}", service_name, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => debug!("{} handled Ctrl+C signal", service_name),
|
||||
_ = terminate => debug!("{} handled SIGTERM signal", service_name),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_signal_handling_timeout() {
|
||||
// Test that the signal handlers don't hang indefinitely
|
||||
let result = timeout(Duration::from_millis(100), create_shutdown_signal()).await;
|
||||
|
||||
// Should timeout since no signals are sent
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_combined_shutdown_manual() {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
// Start the combined signal handler
|
||||
let signal_future = create_combined_shutdown_signal(async move {
|
||||
rx.await.ok();
|
||||
});
|
||||
|
||||
// Send manual shutdown signal
|
||||
let _ = tx.send(());
|
||||
|
||||
// Should complete quickly due to manual signal
|
||||
let result = timeout(Duration::from_millis(100), signal_future).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_service_shutdown_signal_timeout() {
|
||||
// Test service-specific signal handling
|
||||
let result = timeout(
|
||||
Duration::from_millis(100),
|
||||
create_service_shutdown_signal("test-service"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Should timeout since no signals are sent
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
54
qiming-mcp-proxy/voice-cli/src/utils/task_id.rs
Normal file
54
qiming-mcp-proxy/voice-cli/src/utils/task_id.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
/// Task ID generation utilities
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Generate a clean task ID with only alphanumeric characters
|
||||
/// Format: "task" + 32-character hexadecimal string (from UUID v7)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use voice_cli::utils::task_id::generate_task_id;
|
||||
///
|
||||
/// let task_id = generate_task_id();
|
||||
/// assert!(task_id.starts_with("task"));
|
||||
/// assert!(!task_id.contains('-'));
|
||||
/// assert!(!task_id.contains('_'));
|
||||
/// assert_eq!(task_id.len(), 36); // "task" + 32 hex chars
|
||||
/// ```
|
||||
pub fn generate_task_id() -> String {
|
||||
let uuid = Uuid::now_v7().to_string();
|
||||
// 移除所有连字符和下划线,只保留字母和数字
|
||||
let cleaned_uuid = uuid.replace(['-', '_'], "");
|
||||
format!("task{}", cleaned_uuid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_task_id_format() {
|
||||
let task_id = generate_task_id();
|
||||
|
||||
// Check format
|
||||
assert!(task_id.starts_with("task"));
|
||||
assert!(!task_id.contains('-'));
|
||||
assert!(!task_id.contains('_'));
|
||||
|
||||
// Check length (task + 32 hex chars)
|
||||
assert_eq!(task_id.len(), 36);
|
||||
|
||||
// Check that it's all alphanumeric after "task" prefix
|
||||
let suffix = &task_id[4..];
|
||||
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_task_id_uniqueness() {
|
||||
let id1 = generate_task_id();
|
||||
let id2 = generate_task_id();
|
||||
|
||||
// Should be different (due to timestamp in UUID v7)
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user