Skip to main content

robit_agent/
lock.rs

1//! 工作目录独占文件锁
2//!
3//! 确保同一工作目录下只能有一个 robit 程序运行。
4
5use std::fs::{File, OpenOptions, remove_file};
6use std::io::{Write, Read, Seek, SeekFrom};
7use std::path::{Path, PathBuf};
8use std::process;
9
10use fs2::FileExt;
11use serde::{Serialize, Deserialize};
12use thiserror::Error;
13use time::OffsetDateTime;
14
15/// 存储在锁文件中的基本信息
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LockInfo {
18    /// 程序名称(如 "robit-tui", "robit-qq", "robit-gui")
19    pub program: String,
20    /// 进程 ID
21    pub pid: u32,
22    /// 启动用户名
23    pub username: String,
24    /// 启动时间(ISO 8601 格式)
25    pub started_at: String,
26}
27
28/// 文件锁错误
29#[derive(Debug, Error)]
30pub enum LockError {
31    /// 目录已被另一个进程锁定
32    #[error("Directory is already locked by {program} (PID {pid}, user {username}, started at {started_at})")]
33    AlreadyLocked {
34        program: String,
35        pid: u32,
36        username: String,
37        started_at: String,
38    },
39    /// IO 错误
40    #[error("IO error: {0}")]
41    Io(#[from] std::io::Error),
42    /// 序列化/反序列化错误
43    #[error("JSON error: {0}")]
44    Json(#[from] serde_json::Error),
45    /// 无法创建 .robit 目录
46    #[error("Failed to create .robit directory: {0}")]
47    CreateDir(std::io::Error),
48}
49
50/// 文件锁守护对象,RAII 模式,析构时自动释放锁
51pub struct DirectoryLock {
52    /// 锁文件路径
53    lock_path: PathBuf,
54    /// 锁文件句柄(保持打开以维持操作系统级锁)
55    file: Option<File>,
56    /// 锁定时的信息
57    info: LockInfo,
58}
59
60impl DirectoryLock {
61    /// 获取工作目录的独占锁
62    ///
63    /// # Arguments
64    /// * `workdir` - 工作目录路径
65    /// * `program_name` - 程序名称(如 "robit-tui")
66    pub fn acquire(workdir: &Path, program_name: &str) -> Result<Self, LockError> {
67        let robit_dir = workdir.join(".robit");
68        let lock_path = robit_dir.join("LOCK");
69
70        tracing::debug!("Acquiring directory lock at: {}", lock_path.display());
71
72        // 确保 .robit 目录存在
73        if !robit_dir.exists() {
74            tracing::debug!("Creating .robit directory at: {}", robit_dir.display());
75            std::fs::create_dir_all(&robit_dir)
76                .map_err(LockError::CreateDir)?;
77        }
78
79        // 获取当前用户名
80        let username = get_username().unwrap_or_else(|| "unknown".to_string());
81
82        // 创建锁信息
83        let info = LockInfo {
84            program: program_name.to_string(),
85            pid: process::id(),
86            username,
87            started_at: OffsetDateTime::now_utc()
88                .format(&time::format_description::well_known::Rfc3339)
89                .unwrap_or_else(|_| "unknown".to_string()),
90        };
91
92        // 尝试获取锁,最多重试一次(处理孤立锁)
93        match Self::try_acquire(&lock_path, &info) {
94            Ok(lock) => Ok(lock),
95            Err(LockError::AlreadyLocked { program, pid, username, started_at }) => {
96                // 检查进程是否还在运行
97                if !is_process_running(pid) {
98                    // 进程不存在,删除旧锁文件并重试
99                    tracing::warn!(
100                        "Found stale lock file from {} (PID {}, user {}, started at {}), cleaning up",
101                        program,
102                        pid,
103                        username,
104                        started_at
105                    );
106                    let _ = remove_file(&lock_path);
107                    Self::try_acquire(&lock_path, &info)
108                } else {
109                    Err(LockError::AlreadyLocked { program, pid, username, started_at })
110                }
111            }
112            Err(e) => Err(e),
113        }
114    }
115
116    /// 尝试获取锁(单次尝试)
117    fn try_acquire(lock_path: &Path, info: &LockInfo) -> Result<Self, LockError> {
118        // 打开或创建锁文件
119        let mut file = OpenOptions::new()
120            .read(true)
121            .write(true)
122            .create(true)
123            .open(lock_path)?;
124
125        // 尝试获取排他锁(非阻塞)
126        match file.try_lock_exclusive() {
127            Ok(_) => {
128                // 获取锁成功,写入信息
129                file.set_len(0)?;
130                file.seek(SeekFrom::Start(0))?;
131                serde_json::to_writer_pretty(&mut file, info)?;
132                file.flush()?;
133                file.sync_data()?;
134
135                tracing::info!("Acquired directory lock at: {}", lock_path.display());
136                tracing::debug!("Lock info: {:?}", info);
137
138                Ok(Self {
139                    lock_path: lock_path.to_path_buf(),
140                    file: Some(file),
141                    info: info.clone(),
142                })
143            }
144            Err(_) => {
145                // 获取锁失败,读取现有锁信息
146                let mut content = String::new();
147                file.seek(SeekFrom::Start(0))?;
148                file.read_to_string(&mut content)?;
149
150                let existing_info: LockInfo = match serde_json::from_str(&content) {
151                    Ok(info) => info,
152                    Err(_) => {
153                        // 锁文件内容损坏,视为可清理
154                        LockInfo {
155                            program: "unknown".to_string(),
156                            pid: 0,
157                            username: "unknown".to_string(),
158                            started_at: "unknown".to_string(),
159                        }
160                    }
161                };
162
163                Err(LockError::AlreadyLocked {
164                    program: existing_info.program,
165                    pid: existing_info.pid,
166                    username: existing_info.username,
167                    started_at: existing_info.started_at,
168                })
169            }
170        }
171    }
172
173    /// 获取当前锁的信息
174    pub fn info(&self) -> &LockInfo {
175        &self.info
176    }
177
178    /// 手动释放锁(通常不需要调用,RAII 会自动处理)
179    pub fn release(&mut self) {
180        if let Some(file) = self.file.take() {
181            let _ = file.unlock();
182        }
183    }
184}
185
186impl Drop for DirectoryLock {
187    fn drop(&mut self) {
188        tracing::debug!("Releasing directory lock at: {}", self.lock_path.display());
189        if let Some(file) = self.file.take() {
190            let _ = file.unlock();
191        }
192        // 删除锁文件
193        let result = std::fs::remove_file(&self.lock_path);
194        match result {
195            Ok(_) => tracing::debug!("Deleted lock file: {}", self.lock_path.display()),
196            Err(e) => tracing::debug!("Failed to delete lock file: {}", e),
197        }
198    }
199}
200
201/// 检测进程是否还在运行(跨平台简化版)
202fn is_process_running(pid: u32) -> bool {
203    #[cfg(unix)]
204    {
205        use std::process::Command;
206        // 使用 `kill -0` 命令检测
207        let output = Command::new("kill")
208            .arg("-0")
209            .arg(pid.to_string())
210            .output();
211        match output {
212            Ok(output) => {
213                // 成功返回 true,或者返回 EPERM(没有权限但进程存在)
214                output.status.success() || output.status.code() == Some(1)
215            }
216            Err(_) => {
217                // 命令失败,保守假设进程存在
218                true
219            }
220        }
221    }
222
223    #[cfg(windows)]
224    {
225        use std::process::Command;
226        // 使用 `tasklist` 命令检测
227        let output = Command::new("tasklist")
228            .arg("/FI")
229            .arg(format!("PID eq {}", pid))
230            .output();
231        match output {
232            Ok(output) => {
233                let output_str = String::from_utf8_lossy(&output.stdout);
234                output_str.contains(&pid.to_string())
235            }
236            Err(_) => {
237                // 命令失败,保守假设进程存在
238                true
239            }
240        }
241    }
242
243    #[cfg(not(any(unix, windows)))]
244    {
245        // 其他平台保守处理:假设进程存在
246        true
247    }
248}
249
250/// 获取当前用户名(跨平台)
251fn get_username() -> Option<String> {
252    // 优先从环境变量获取
253    if let Ok(name) = std::env::var("USER") {
254        if !name.is_empty() {
255            return Some(name);
256        }
257    }
258    if let Ok(name) = std::env::var("USERNAME") {
259        if !name.is_empty() {
260            return Some(name);
261        }
262    }
263
264    // 环境变量都不可用时的备选方案
265    #[cfg(unix)]
266    {
267        use std::process::Command;
268        if let Ok(output) = Command::new("whoami").output() {
269            if output.status.success() {
270                let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
271                if !name.is_empty() {
272                    return Some(name);
273                }
274            }
275        }
276    }
277
278    None
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use tempfile::tempdir;
285
286    #[test]
287    fn test_lock_acquire_and_release() {
288        let dir = tempdir().unwrap();
289        let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
290        assert_eq!(lock.info().program, "test-program");
291        assert!(lock.info().pid > 0);
292    }
293
294    #[test]
295    fn test_username_is_set() {
296        let dir = tempdir().unwrap();
297        let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
298        assert!(!lock.info().username.is_empty());
299    }
300
301    #[test]
302    fn test_started_at_is_set() {
303        let dir = tempdir().unwrap();
304        let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
305        assert!(!lock.info().started_at.is_empty());
306    }
307
308    #[test]
309    fn test_is_process_running_with_our_pid() {
310        // 我们自己的 PID 应该是在运行的
311        assert!(is_process_running(std::process::id()));
312    }
313
314    #[test]
315    fn test_is_process_running_with_invalid_pid() {
316        // 一个很大的 PID 应该不在运行
317        // 注意:这不是 100% 可靠,但对于测试来说足够了
318        assert!(!is_process_running(u32::MAX));
319    }
320}