AnyBot/src/lock.rs

154 lines
7.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// One AnyBot per data folder. Two copies on one folder each keep their own idea of the chats and
// overwrite each other's saves, and a second copy starting up would take the first one's open
// turns in the work log (oplog.rs) for a crash and close them. So the window, `--serve`, `--ask`
// and `anybot setup` (while it writes) hold `anybot.lock` in the data folder.
//
// It is an OS file lock (flock on Linux and macOS, LockFileEx on Windows), not a file that merely
// exists: it goes away with the process however it ends — closed, crashed, killed — so a copy
// that died never blocks the next one. The holder writes its process id in the file, only to say
// who it is.
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// How long a new copy waits for the folder before giving up: long enough for a copy that is
/// closing (a relaunch, a service restart) to finish.
pub const WAIT: Duration = Duration::from_secs(5);
/// Held while this copy uses the data folder; letting go of it (or the process ending) frees it.
pub struct DataLock {
_file: Option<std::fs::File>,
}
pub fn path() -> PathBuf {
let state = crate::model::State::path();
state.parent().map(|d| d.join("anybot.lock")).unwrap_or_else(|| PathBuf::from("anybot.lock"))
}
/// Take this data folder for this copy, waiting up to `wait` for another copy to let go.
/// Err: what to tell the person (another AnyBot is using the folder).
pub fn take(wait: Duration) -> Result<DataLock, String> {
take_at(&path(), wait)
}
fn take_at(p: &Path, wait: Duration) -> Result<DataLock, String> {
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d);
}
let mut f = match std::fs::OpenOptions::new().create(true).read(true).write(true).truncate(false).open(p) {
Ok(f) => f,
Err(e) => {
// A folder the lock can't be made in can't hold the chats either; say so, but don't
// refuse to start over the lock alone.
eprintln!("AnyBot:沒辦法建立 {}({e}),這次不檢查是否有別的 AnyBot 在用同一個資料夾。", p.display());
return Ok(DataLock { _file: None });
}
};
let until = Instant::now() + wait;
loop {
match f.try_lock() {
Ok(()) => {
let _ = f.set_len(0);
let _ = f.rewind();
let _ = f.write_all(std::process::id().to_string().as_bytes());
let _ = f.sync_data();
return Ok(DataLock { _file: Some(f) });
}
Err(std::fs::TryLockError::WouldBlock) if Instant::now() < until => std::thread::sleep(Duration::from_millis(100)),
Err(std::fs::TryLockError::WouldBlock) => return Err(busy_message(p, &mut f)),
Err(std::fs::TryLockError::Error(e)) => {
// A file system without locks (some network shares): go on unchecked.
eprintln!("AnyBot:{} 不能上鎖({e}),這次不檢查是否有別的 AnyBot 在用同一個資料夾。", p.display());
return Ok(DataLock { _file: None });
}
}
}
}
/// "Another AnyBot (process 123) is using <folder>": what a second copy says.
fn busy_message(p: &Path, f: &mut std::fs::File) -> String {
let mut who = String::new();
let _ = f.rewind();
let _ = f.read_to_string(&mut who);
let who = who.trim();
let dir = p.parent().map(|d| d.display().to_string()).unwrap_or_default();
let pid = if who.chars().all(|c| c.is_ascii_digit()) && !who.is_empty() { format!("(程序 {who})") } else { String::new() };
format!("另一個 AnyBot{pid}已經在使用這個資料夾:\n{dir}\n\n同一個資料夾一次只能有一個 AnyBot(視窗、--serve 或 anybot setup)。請先關掉另一個,再打開這個。")
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("anybot-lock-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d.join("anybot.lock")
}
/// The folder is one copy's at a time: a second take is refused, saying who holds it and
/// which folder; once the first lets go, the next take gets it. Another folder is separate.
#[test]
fn one_copy_per_folder() {
let p = scratch("one");
let first = take_at(&p, Duration::ZERO).expect("first copy");
assert_eq!(std::fs::read_to_string(&p).unwrap(), std::process::id().to_string());
let msg = take_at(&p, Duration::ZERO).err().expect("second copy refused");
assert!(msg.contains(&format!("程序 {}", std::process::id())) && msg.contains(&p.parent().unwrap().display().to_string()), "{msg}");
let other = take_at(&scratch("other"), Duration::ZERO);
assert!(other.is_ok(), "another data folder has its own lock");
drop(first);
assert!(take_at(&p, Duration::ZERO).is_ok());
}
/// A copy that is closing (a relaunch) is waited for.
#[test]
fn a_closing_copy_is_waited_for() {
let p = scratch("wait");
let first = take_at(&p, Duration::ZERO).unwrap();
let t = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
drop(first);
});
let t0 = Instant::now();
assert!(take_at(&p, Duration::from_secs(5)).is_ok());
assert!(t0.elapsed() >= Duration::from_millis(250));
t.join().unwrap();
}
/// Not a test on its own: the process `a_killed_copy_leaves_no_lock` starts, which takes the
/// lock at ANYBOT_LOCK_TEST and waits to be killed.
#[test]
#[ignore]
fn a_helper_holds_the_lock() {
let Ok(p) = std::env::var("ANYBOT_LOCK_TEST") else { return };
let _held = take_at(Path::new(&p), Duration::ZERO).expect("helper takes the lock");
println!("LOCK-HELD");
std::thread::sleep(Duration::from_secs(60));
}
/// A copy that crashed or was killed holds nothing: the lock went with its process.
#[test]
fn a_killed_copy_leaves_no_lock() {
use std::io::BufRead;
let p = scratch("killed");
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "lock::tests::a_helper_holds_the_lock", "--ignored", "--nocapture", "--test-threads=1"])
.env("ANYBOT_LOCK_TEST", &p)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let out = std::io::BufReader::new(child.stdout.take().unwrap());
let held = out.lines().map_while(Result::ok).any(|l| l.contains("LOCK-HELD"));
assert!(held, "the helper never took the lock");
let msg = take_at(&p, Duration::ZERO).err().expect("held by the other process");
assert!(msg.contains(&format!("程序 {}", child.id())), "{msg}");
child.kill().unwrap();
child.wait().unwrap();
assert!(take_at(&p, Duration::from_secs(2)).is_ok(), "a killed copy's lock is gone");
}
}