434 lines
13 KiB
Rust
434 lines
13 KiB
Rust
//! 跨平台进程管理兼容层
|
||
//!
|
||
//! 提供统一的进程管理抽象,减少平台特定代码的侵入性。
|
||
//!
|
||
//! # 使用方法
|
||
//!
|
||
//! ## 命令检测
|
||
//!
|
||
//! ```ignore
|
||
//! use mcp_common::process_compat::check_windows_command;
|
||
//!
|
||
//! check_windows_command(&config.command);
|
||
//! ```
|
||
//!
|
||
//! ## 进程包装宏
|
||
//!
|
||
//! process-wrap 8.x (TokioCommandWrap):
|
||
//! ```ignore
|
||
//! use mcp_common::process_compat::wrap_process_v8;
|
||
//!
|
||
//! let mut wrapped_cmd = TokioCommandWrap::with_new(...);
|
||
//! wrap_process_v8!(wrapped_cmd);
|
||
//! wrapped_cmd.wrap(KillOnDrop);
|
||
//! ```
|
||
//!
|
||
//! process-wrap 9.x (CommandWrap):
|
||
//! ```ignore
|
||
//! use mcp_common::process_compat::wrap_process_v9;
|
||
//!
|
||
//! let mut wrapped_cmd = CommandWrap::with_new(...);
|
||
//! wrap_process_v9!(wrapped_cmd);
|
||
//! wrapped_cmd.wrap(KillOnDrop);
|
||
//! ```
|
||
|
||
#[cfg(windows)]
|
||
use tracing::{info, warn};
|
||
|
||
/// 检测 Windows 平台上可能导致弹窗的命令格式
|
||
///
|
||
/// 在 Windows 上,运行 `.cmd`、`.bat` 文件或 `npx` 命令可能会弹出 CMD 窗口。
|
||
/// 此函数会检测这些情况并输出警告,建议用户使用替代方案。
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `command` - 要执行的命令字符串
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// use mcp_common::process_compat::check_windows_command;
|
||
///
|
||
/// check_windows_command("npx some-server");
|
||
/// check_windows_command("mcp-server.cmd");
|
||
/// ```
|
||
#[cfg(windows)]
|
||
pub fn check_windows_command(command: &str) {
|
||
use std::path::Path;
|
||
|
||
let cmd_ext = Path::new(command)
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.map(|s| s.to_ascii_lowercase());
|
||
|
||
match cmd_ext.as_deref() {
|
||
Some("cmd" | "bat") => {
|
||
warn!(
|
||
"[MCP] Windows detected .cmd/.bat command: {} - CMD window may pop up!",
|
||
command
|
||
);
|
||
warn!(
|
||
"[MCP] It is recommended to use node.exe to run the JS file directly, or use the full path in the configuration"
|
||
);
|
||
}
|
||
None => {
|
||
// 无扩展名,检查是否是 npx 命令
|
||
if command.contains("npx") {
|
||
warn!(
|
||
"[MCP] Windows detects npx command: {} - CMD window may pop up!",
|
||
command
|
||
);
|
||
warn!("[MCP] It is recommended to use node.exe to run JS files directly");
|
||
}
|
||
}
|
||
_ => {
|
||
info!("[MCP] Windows detected command format: {}", command);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Unix/macOS 平台的空实现
|
||
#[cfg(not(windows))]
|
||
pub fn check_windows_command(_command: &str) {
|
||
// 非 Windows 平台无需检测
|
||
}
|
||
|
||
/// Windows 上解析命令路径,自动添加扩展名
|
||
///
|
||
/// 在 Windows 上,命令如 `npx` 实际上是 `npx.cmd` 批处理文件。
|
||
/// `std::process::Command` 不会自动查找 `.cmd` 扩展名,需要手动指定。
|
||
/// 此函数尝试在 PATH 中查找命令,并返回带扩展名的完整路径或原始命令。
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `command` - 要解析的命令字符串
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// 如果找到,返回带扩展名的命令;否则返回原始命令
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// use mcp_common::process_compat::resolve_windows_command;
|
||
///
|
||
/// let resolved = resolve_windows_command("npx");
|
||
/// // 返回 "npx.cmd" 或 "C:\Program Files\nodejs\npx.cmd"
|
||
/// ```
|
||
#[cfg(target_os = "windows")]
|
||
pub fn resolve_windows_command(command: &str) -> String {
|
||
use std::path::Path;
|
||
|
||
// 如果已经有扩展名,直接返回
|
||
if Path::new(command).extension().is_some() {
|
||
return command.to_string();
|
||
}
|
||
|
||
// 如果是绝对路径,直接返回
|
||
if Path::new(command).is_absolute() {
|
||
return command.to_string();
|
||
}
|
||
|
||
// 获取 PATH 环境变量
|
||
let path_env = match std::env::var("PATH") {
|
||
Ok(p) => p,
|
||
Err(_) => return command.to_string(),
|
||
};
|
||
|
||
// Windows 可执行文件扩展名(按优先级)
|
||
let extensions = [".cmd", ".exe", ".bat", ".ps1"];
|
||
|
||
// 遍历 PATH 中的每个目录
|
||
for dir in path_env.split(';') {
|
||
let dir = dir.trim();
|
||
if dir.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// 尝试每个扩展名
|
||
for ext in &extensions {
|
||
let full_path = Path::new(dir).join(format!("{}{}", command, ext));
|
||
if full_path.exists() {
|
||
tracing::debug!(
|
||
"[MCP] Windows command analysis: {} -> {}",
|
||
command,
|
||
full_path.display()
|
||
);
|
||
// 返回带扩展名的命令(不是完整路径,保持简洁)
|
||
return format!("{}{}", command, ext);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 未找到,返回原始命令
|
||
command.to_string()
|
||
}
|
||
|
||
/// 非 Windows 平台的空实现
|
||
#[cfg(not(target_os = "windows"))]
|
||
pub fn resolve_windows_command(command: &str) -> String {
|
||
command.to_string()
|
||
}
|
||
|
||
/// 确保应用内置运行时路径(NUWAX_APP_RUNTIME_PATH)在 PATH 最前面。
|
||
///
|
||
/// 当应用捆绑了 node/uv 等运行时时,通过 `NUWAX_APP_RUNTIME_PATH` 传递其路径。
|
||
/// 此函数将这些路径插入到给定 PATH 的最前面,确保优先使用应用内置版本,
|
||
/// 即使用户在 MCP 配置的 `env` 中指定了自定义 PATH。
|
||
///
|
||
/// **按段去重**:将 runtime_path 和现有 PATH 拆分为独立条目,
|
||
/// 先放 runtime 段,再追加 PATH 中不在 runtime 里的段,彻底避免重复。
|
||
///
|
||
/// 如果 `NUWAX_APP_RUNTIME_PATH` 未设置或为空,直接返回原始 PATH。
|
||
pub fn ensure_runtime_path(path: &str) -> String {
|
||
if let Ok(runtime_path) = std::env::var("NUWAX_APP_RUNTIME_PATH") {
|
||
let runtime_path = runtime_path.trim();
|
||
if !runtime_path.is_empty() {
|
||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||
|
||
// 将 runtime_path 拆成各段
|
||
let runtime_segments: Vec<&str> =
|
||
runtime_path.split(sep).filter(|s| !s.is_empty()).collect();
|
||
|
||
// 将现有 PATH 拆成各段,去掉已在 runtime 中的
|
||
let existing_segments: Vec<&str> = path
|
||
.split(sep)
|
||
.filter(|s| !s.is_empty() && !runtime_segments.contains(s))
|
||
.collect();
|
||
|
||
let merged: Vec<&str> = runtime_segments
|
||
.iter()
|
||
.copied()
|
||
.chain(existing_segments)
|
||
.collect();
|
||
|
||
let result = merged.join(sep);
|
||
if result != path {
|
||
tracing::info!(
|
||
"[ProcessCompat] Front-end application built-in runtime to PATH: {}",
|
||
runtime_path
|
||
);
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
path.to_string()
|
||
}
|
||
|
||
/// 为 process-wrap 8.x 的 TokioCommandWrap 应用平台特定的包装
|
||
///
|
||
/// 此宏会根据目标平台自动应用正确的进程包装:
|
||
/// - Windows: `CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)` + `JobObject`
|
||
/// - Unix: `ProcessGroup::leader()`
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `$cmd` - 可变的 TokioCommandWrap 实例
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// use process_wrap::tokio::{TokioCommandWrap, KillOnDrop};
|
||
/// use mcp_common::process_compat::wrap_process_v8;
|
||
///
|
||
/// let mut wrapped_cmd = TokioCommandWrap::with_new("node", |cmd| {
|
||
/// cmd.arg("server.js");
|
||
/// });
|
||
/// wrap_process_v8!(wrapped_cmd);
|
||
/// wrapped_cmd.wrap(KillOnDrop);
|
||
/// ```
|
||
#[cfg(unix)]
|
||
#[macro_export]
|
||
macro_rules! wrap_process_v8 {
|
||
($cmd:expr) => {{
|
||
use process_wrap::tokio::ProcessGroup;
|
||
$cmd.wrap(ProcessGroup::leader());
|
||
}};
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[macro_export]
|
||
macro_rules! wrap_process_v8 {
|
||
($cmd:expr) => {{
|
||
use process_wrap::tokio::{CreationFlags, JobObject};
|
||
use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
|
||
$cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
|
||
$cmd.wrap(JobObject);
|
||
}};
|
||
}
|
||
|
||
/// 为 process-wrap 9.x 的 CommandWrap 应用平台特定的包装
|
||
///
|
||
/// 此宏会根据目标平台自动应用正确的进程包装:
|
||
/// - Windows: `CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)` + `JobObject`
|
||
/// - Unix: `ProcessGroup::leader()`
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `$cmd` - 可变的 CommandWrap 实例
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// use process_wrap::tokio::{CommandWrap, KillOnDrop};
|
||
/// use mcp_common::process_compat::wrap_process_v9;
|
||
///
|
||
/// let mut wrapped_cmd = CommandWrap::with_new("node", |cmd| {
|
||
/// cmd.arg("server.js");
|
||
/// });
|
||
/// wrap_process_v9!(wrapped_cmd);
|
||
/// wrapped_cmd.wrap(KillOnDrop);
|
||
/// ```
|
||
#[cfg(unix)]
|
||
#[macro_export]
|
||
macro_rules! wrap_process_v9 {
|
||
($cmd:expr) => {{
|
||
use process_wrap::tokio::ProcessGroup;
|
||
$cmd.wrap(ProcessGroup::leader());
|
||
}};
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[macro_export]
|
||
macro_rules! wrap_process_v9 {
|
||
($cmd:expr) => {{
|
||
use process_wrap::tokio::{CreationFlags, JobObject};
|
||
use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
|
||
$cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
|
||
$cmd.wrap(JobObject);
|
||
}};
|
||
}
|
||
|
||
/// 启动 stderr 日志读取任务
|
||
///
|
||
/// 创建一个异步任务来读取子进程的 stderr 输出并记录到日志。
|
||
/// 这个函数封装了通用的 stderr 读取逻辑。
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `stderr` - stderr 管道(实现 AsyncRead + Unpin + Send)
|
||
/// * `service_name` - MCP 服务名称(用于日志标识)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// 返回 `JoinHandle<()>`,任务会在 stderr 关闭时自动结束
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```ignore
|
||
/// use mcp_common::process_compat::spawn_stderr_reader;
|
||
///
|
||
/// let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
|
||
/// .stderr(Stdio::piped())
|
||
/// .spawn()?;
|
||
///
|
||
/// if let Some(stderr) = child_stderr {
|
||
/// spawn_stderr_reader(stderr, "my-mcp-service".to_string());
|
||
/// }
|
||
/// ```
|
||
pub fn spawn_stderr_reader<T>(stderr: T, service_name: String) -> tokio::task::JoinHandle<()>
|
||
where
|
||
T: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||
{
|
||
tokio::spawn(async move {
|
||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||
|
||
let mut reader = BufReader::new(stderr);
|
||
let mut line = String::new();
|
||
loop {
|
||
line.clear();
|
||
match reader.read_line(&mut line).await {
|
||
Ok(0) => {
|
||
// EOF - stderr 已关闭
|
||
tracing::debug!("[Subprocess stderr][{}] End of read (EOF)", service_name);
|
||
break;
|
||
}
|
||
Ok(_) => {
|
||
let trimmed = line.trim();
|
||
if !trimmed.is_empty() {
|
||
tracing::warn!("[child process stderr][{}] {}", service_name, trimmed);
|
||
}
|
||
}
|
||
Err(e) => {
|
||
tracing::debug!("[Subprocess stderr][{}] Read error: {}", service_name, e);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_check_windows_command_non_windows() {
|
||
// 在非 Windows 平台上,此函数应该不执行任何操作
|
||
check_windows_command("npx some-server");
|
||
check_windows_command("test.cmd");
|
||
}
|
||
|
||
#[test]
|
||
fn test_ensure_runtime_path_no_env() {
|
||
// NUWAX_APP_RUNTIME_PATH 未设置时,返回原始 PATH
|
||
unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
|
||
let result = ensure_runtime_path("/usr/bin:/usr/local/bin");
|
||
assert_eq!(result, "/usr/bin:/usr/local/bin");
|
||
}
|
||
|
||
#[test]
|
||
fn test_ensure_runtime_path_prepend() {
|
||
unsafe {
|
||
std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
|
||
}
|
||
let result = ensure_runtime_path("/usr/bin:/usr/local/bin");
|
||
assert_eq!(result, "/app/node/bin:/app/uv/bin:/usr/bin:/usr/local/bin");
|
||
unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
|
||
}
|
||
|
||
#[test]
|
||
fn test_ensure_runtime_path_dedup() {
|
||
// 模拟:PATH 中已有 runtime 的部分段 → 不应重复
|
||
unsafe {
|
||
std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
|
||
}
|
||
let result = ensure_runtime_path("/app/node/bin:/opt/homebrew/bin:/usr/bin");
|
||
assert_eq!(
|
||
result,
|
||
"/app/node/bin:/app/uv/bin:/opt/homebrew/bin:/usr/bin"
|
||
);
|
||
unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
|
||
}
|
||
|
||
#[test]
|
||
fn test_ensure_runtime_path_all_present() {
|
||
// PATH 已含全部 runtime 段 → 仅调整顺序确保 runtime 在前
|
||
unsafe {
|
||
std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
|
||
}
|
||
let result = ensure_runtime_path("/app/uv/bin:/usr/bin:/app/node/bin");
|
||
assert_eq!(result, "/app/node/bin:/app/uv/bin:/usr/bin");
|
||
unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
|
||
}
|
||
|
||
#[test]
|
||
fn test_ensure_runtime_path_double_node() {
|
||
// 模拟日志中的问题:node/bin 出现两次
|
||
unsafe {
|
||
std::env::set_var(
|
||
"NUWAX_APP_RUNTIME_PATH",
|
||
"/app/node/bin:/app/uv/bin:/app/debug",
|
||
);
|
||
}
|
||
let result = ensure_runtime_path(
|
||
"/app/node/bin:/app/node/bin:/app/uv/bin:/app/debug:/opt/homebrew/bin",
|
||
);
|
||
assert_eq!(
|
||
result,
|
||
"/app/node/bin:/app/uv/bin:/app/debug:/opt/homebrew/bin"
|
||
);
|
||
unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
|
||
}
|
||
}
|