use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::time::Duration; use crate::config; use crate::ipc::{Request, Response}; const STARTUP_TIMEOUT_SECS: u64 = 15; #[cfg(unix)] const STOP_TIMEOUT_MS: u64 = 2_000; #[derive(Debug, Clone, Serialize, Deserialize)] struct PidFile { pid: u32, #[serde(default)] exe: Option, } /// 检查 daemon 是否存活 pub fn is_alive() -> bool { #[cfg(unix)] { ping_unix().unwrap_or(false) } #[cfg(windows)] { ping_windows().unwrap_or(false) } #[cfg(not(any(unix, windows)))] { false } } /// 确保 daemon 运行,必要时自动启动 pub fn ensure_daemon() -> Result<()> { if is_alive() { return Ok(()); } eprintln!("启动 wx-daemon..."); start_daemon()?; Ok(()) } /// 停止 daemon(如果正在运行) pub fn stop_daemon() -> Result<()> { let pid_path = config::pid_path(); let pid_file = read_pid_file(&pid_path)?; let daemon_alive = is_alive(); match pid_file { Some(pid_file) => { let belongs = pid_belongs_to_daemon(&pid_file)?; if daemon_alive && !belongs { bail!( "daemon 正在运行,但 {} 指向的 PID {} 无法确认属于当前 wx-daemon", pid_path.display(), pid_file.pid ); } if belongs { terminate_pid(pid_file.pid)?; } } None if daemon_alive => { bail!( "daemon 正在运行,但 {} 缺失或损坏,无法安全停止", pid_path.display() ); } None => {} } cleanup_ipc_files(); Ok(()) } /// 启动 daemon 前检查 `~/.wx-cli/` 可写,给出比"超时"更明确的错误。 /// /// 典型坑:旧版本 `sudo wx init` 把目录留成 root 属主,非 root 的 daemon /// 连 socket/log 都建不了,会静默失败 15s 超时。 fn preflight_cli_dir_writable() -> Result<()> { let cli_dir = config::cli_dir(); std::fs::create_dir_all(&cli_dir) .with_context(|| format!("创建 {} 失败", cli_dir.display()))?; let probe = cli_dir.join(".daemon_probe"); match std::fs::File::create(&probe) { Ok(_) => { let _ = std::fs::remove_file(&probe); Ok(()) } Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { let dir = cli_dir.display(); if cfg!(unix) { bail!( "无法写入 {dir}(权限不足)\n\n\ 这通常是老版本的 `sudo wx init` 把目录属主留成了 root。\n\ 修复:\n\n \ sudo chown -R $(whoami) {dir}\n\n\ (新版已修复此问题,下次 init 不会再发生)", ) } else { bail!("无法写入 {dir}: {e}") } } Err(e) => bail!("无法写入 {}: {}", cli_dir.display(), e), } } /// 启动 daemon 进程(自身二进制,设置 WX_DAEMON_MODE=1) fn start_daemon() -> Result<()> { let exe = std::env::current_exe().context("无法获取当前可执行文件路径")?; let child_pid: u32; // 预检:当前用户是否能写 ~/.wx-cli/。如果不能,给出可操作的错误信息, // 而不是 spawn 一个注定失败的 daemon 然后超时 15s。 preflight_cli_dir_writable()?; #[cfg(unix)] { use std::os::unix::process::CommandExt; // 日志文件:~/.wx-cli/daemon.log let log_path = config::log_path(); // 确保父目录存在 if let Some(parent) = log_path.parent() { let _ = std::fs::create_dir_all(parent); } let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) .and_then(|f| f.try_clone().map(|g| (f, g))) .map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g))) .unwrap_or_else(|_| (std::process::Stdio::null(), std::process::Stdio::null())); let mut cmd = std::process::Command::new(&exe); cmd.env("WX_DAEMON_MODE", "1") .stdin(std::process::Stdio::null()) .stdout(stdout_stdio) .stderr(stderr_stdio); // SAFETY: setsid() 在 fork 后的子进程中调用,使 daemon 脱离控制终端 unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); } let child = cmd.spawn().context("无法启动 daemon 进程")?; child_pid = child.id(); } #[cfg(windows)] { use std::os::windows::process::CommandExt; let log_path = config::log_path(); if let Some(parent) = log_path.parent() { let _ = std::fs::create_dir_all(parent); } let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) .and_then(|f| f.try_clone().map(|g| (f, g))) .map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g))) .unwrap_or_else(|_| (std::process::Stdio::null(), std::process::Stdio::null())); let child = std::process::Command::new(&exe) .env("WX_DAEMON_MODE", "1") .stdin(std::process::Stdio::null()) .stdout(stdout_stdio) .stderr(stderr_stdio) .creation_flags(0x00000008) // DETACHED_PROCESS .spawn() .context("无法启动 daemon 进程")?; child_pid = child.id(); } // 等待 daemon 就绪(最多 STARTUP_TIMEOUT_SECS 秒) let deadline = std::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS); while std::time::Instant::now() < deadline { std::thread::sleep(Duration::from_millis(300)); if is_alive() { write_pid_file(child_pid, &exe)?; return Ok(()); } } bail!( "wx-daemon 启动超时(>{}s)\n请查看日志: {}", STARTUP_TIMEOUT_SECS, config::log_path().display() ) } fn write_pid_file(pid: u32, exe: &Path) -> Result<()> { if let Some(parent) = config::pid_path().parent() { std::fs::create_dir_all(parent) .with_context(|| format!("创建 {} 失败", parent.display()))?; } let pid_file = PidFile { pid, exe: Some(exe.to_path_buf()), }; let content = serde_json::to_string(&pid_file)?; std::fs::write(config::pid_path(), content) .with_context(|| format!("写入 {} 失败", config::pid_path().display()))?; Ok(()) } fn read_pid_file(path: &Path) -> Result> { let content = match std::fs::read_to_string(path) { Ok(content) => content, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(err) => return Err(err).with_context(|| format!("读取 {} 失败", path.display())), }; if let Ok(pid_file) = serde_json::from_str::(&content) { return Ok(Some(pid_file)); } if let Ok(pid) = content.trim().parse::() { return Ok(Some(PidFile { pid, exe: std::env::current_exe().ok(), })); } bail!("{} 不是合法的 PID 文件", path.display()) } fn cleanup_ipc_files() { let _ = std::fs::remove_file(config::sock_path()); let _ = std::fs::remove_file(config::pid_path()); } #[cfg(unix)] fn ping_unix() -> Result { use std::os::unix::net::UnixStream; let sock_path = config::sock_path(); if !sock_path.exists() { return Ok(false); } let mut stream = UnixStream::connect(&sock_path)?; stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); let req = serde_json::to_string(&Request::Ping)? + "\n"; stream.write_all(req.as_bytes())?; let mut line = String::new(); let mut reader = BufReader::new(&stream); reader.read_line(&mut line)?; let resp: Response = serde_json::from_str(&line)?; Ok(resp.ok && resp.data.get("pong").and_then(|p| p.as_bool()) == Some(true)) } #[cfg(windows)] fn ping_windows() -> Result { use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream}; let name = "wx-cli-daemon".to_ns_name::()?; let stream = Stream::connect(name)?; let mut reader = BufReader::new(stream); let req = serde_json::to_string(&Request::Ping)? + "\n"; reader.get_mut().write_all(req.as_bytes())?; let mut line = String::new(); reader.read_line(&mut line)?; let resp: Response = serde_json::from_str(&line)?; Ok(resp.ok && resp.data.get("pong").and_then(|p| p.as_bool()) == Some(true)) } fn pid_belongs_to_daemon(pid_file: &PidFile) -> Result { let expected_exe = pid_file .exe .clone() .or_else(|| std::env::current_exe().ok()); #[cfg(unix)] { unix_pid_matches_daemon(pid_file.pid, expected_exe.as_deref()) } #[cfg(windows)] { windows_pid_matches_daemon(pid_file.pid, expected_exe.as_deref()) } #[cfg(not(any(unix, windows)))] { let _ = expected_exe; Ok(true) } } #[cfg(unix)] fn unix_pid_matches_daemon(pid: u32, expected_exe: Option<&Path>) -> Result { let Some(expected_exe) = expected_exe else { return Ok(false); }; let output = std::process::Command::new("ps") .args(["-o", "command=", "-p", &pid.to_string()]) .output() .with_context(|| format!("读取 PID {} 的 command 失败", pid))?; if !output.status.success() { return Ok(false); } let command = String::from_utf8_lossy(&output.stdout); let expected = expected_exe.to_string_lossy(); if command.contains(expected.as_ref()) { return Ok(true); } let Some(exe_name) = expected_exe.file_name().and_then(|name| name.to_str()) else { return Ok(false); }; Ok(command .split_whitespace() .any(|part| part == exe_name || part.ends_with(&format!("/{}", exe_name)))) } #[cfg(windows)] fn windows_pid_matches_daemon(pid: u32, expected_exe: Option<&Path>) -> Result { use windows::core::PWSTR; use windows::Win32::Foundation::CloseHandle; use windows::Win32::System::Threading::{ OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION, }; let Some(expected_exe) = expected_exe else { return Ok(false); }; let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } { Ok(handle) => handle, Err(_) => return Ok(false), }; let mut buf = vec![0u16; 260]; let mut len = buf.len() as u32; let actual = unsafe { let result = QueryFullProcessImageNameW( handle, PROCESS_NAME_FORMAT(0), PWSTR(buf.as_mut_ptr()), &mut len, ); let _ = CloseHandle(handle); result }; if actual.is_err() { return Ok(false); } let actual_path = PathBuf::from(String::from_utf16_lossy(&buf[..len as usize])); Ok(normalize_exe_path(&actual_path) == normalize_exe_path(expected_exe)) } #[cfg(windows)] fn normalize_exe_path(path: &Path) -> String { path.to_string_lossy() .replace('\\', "/") .to_ascii_lowercase() } fn terminate_pid(pid: u32) -> Result<()> { #[cfg(unix)] { terminate_pid_unix(pid) } #[cfg(windows)] { terminate_pid_windows(pid) } #[cfg(not(any(unix, windows)))] { let _ = pid; Ok(()) } } #[cfg(unix)] fn terminate_pid_unix(pid: u32) -> Result<()> { let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; if rc != 0 { let err = std::io::Error::last_os_error(); if err.raw_os_error() == Some(libc::ESRCH) { return Ok(()); } bail!("停止 PID {} 失败: {}", pid, err); } let deadline = std::time::Instant::now() + Duration::from_millis(STOP_TIMEOUT_MS); while std::time::Instant::now() < deadline { if !unix_process_exists(pid) { return Ok(()); } std::thread::sleep(Duration::from_millis(50)); } bail!("等待 PID {} 退出超时", pid) } #[cfg(unix)] fn unix_process_exists(pid: u32) -> bool { let rc = unsafe { libc::kill(pid as i32, 0) }; if rc == 0 { return true; } let err = std::io::Error::last_os_error(); err.raw_os_error() == Some(libc::EPERM) } #[cfg(windows)] fn terminate_pid_windows(pid: u32) -> Result<()> { let status = std::process::Command::new("taskkill") .args(["/F", "/PID", &pid.to_string()]) .status() .with_context(|| format!("执行 taskkill /PID {} 失败", pid))?; if !status.success() { bail!("停止 PID {} 失败: taskkill exit {:?}", pid, status.code()); } Ok(()) } /// 向 daemon 发送请求并返回响应 pub fn send(req: Request) -> Result { ensure_daemon()?; #[cfg(unix)] { send_unix(req) } #[cfg(windows)] { send_windows(req) } #[cfg(not(any(unix, windows)))] { bail!("不支持当前平台") } } #[cfg(unix)] fn send_unix(req: Request) -> Result { use std::os::unix::net::UnixStream; let sock_path = config::sock_path(); let mut stream = UnixStream::connect(&sock_path).context("连接 daemon socket 失败")?; stream.set_read_timeout(Some(Duration::from_secs(120))).ok(); stream .set_write_timeout(Some(Duration::from_secs(120))) .ok(); let req_str = serde_json::to_string(&req)? + "\n"; stream.write_all(req_str.as_bytes())?; let mut line = String::new(); let mut reader = BufReader::new(&stream); reader.read_line(&mut line)?; let resp: Response = serde_json::from_str(&line).context("解析 daemon 响应失败")?; if !resp.ok { bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); } Ok(resp) } #[cfg(windows)] fn send_windows(req: Request) -> Result { use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream}; let name = "wx-cli-daemon" .to_ns_name::() .context("构造 pipe name 失败")?; let stream = Stream::connect(name).context("连接 daemon named pipe 失败")?; // interprocess::Stream 同时实现 Read + Write,但需要拆分读写端 let mut reader = BufReader::new(stream); let req_str = serde_json::to_string(&req)? + "\n"; reader.get_mut().write_all(req_str.as_bytes())?; let mut line = String::new(); reader.read_line(&mut line)?; let resp: Response = serde_json::from_str(&line).context("解析 daemon 响应失败")?; if !resp.ok { bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); } Ok(resp) }