One AnyBot per data folder: a second copy says so instead of opening

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 for a crash
and close them. The window, `--serve` and `--ask` now hold an OS file lock on anybot.lock in the
data folder (flock; LockFileEx on Windows), and `anybot setup` holds it while it writes the
settings, after stopping the service and before starting it again.

The lock goes with the process however it ends, so a copy that crashed or was killed never blocks
the next one. A new copy waits up to 5 s for one that is closing (a relaunch, a service restart).
Past that, a second window shows a small notice naming the holder's process and the folder, and
`--serve` / `--ask` say it on stderr and exit 1 (launchd's and systemd's restart then retry). Each
ANYBOT_DATA_DIR has its own lock; a screenshot run doesn't take it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
This commit is contained in:
王性驊 2026-11-09 00:49:42 +08:00
parent 2f603c2362
commit d3eae415c5
4 changed files with 225 additions and 1 deletions

View File

@ -67,6 +67,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
| `brain-files/` | 螢幕電腦從大腦下載的附件 | | `brain-files/` | 螢幕電腦從大腦下載的附件 |
| `image-notes.json` | 看圖小幫手對每張圖的描述(不用重看) | | `image-notes.json` | 看圖小幫手對每張圖的描述(不用重看) |
| `oplog/bot-<id>.jsonl` | 每個 Bot 的工作紀錄:回合開始、每次問模型、每個步驟開始和結束、做事時傳的訊息。每行先寫進磁碟才做那件事;AnyBot 在做事時被關掉,下次打開靠它把聊天修好,並留一張「繼續」卡片(`src/oplog.rs`)。回合結束就清空 | | `oplog/bot-<id>.jsonl` | 每個 Bot 的工作紀錄:回合開始、每次問模型、每個步驟開始和結束、做事時傳的訊息。每行先寫進磁碟才做那件事;AnyBot 在做事時被關掉,下次打開靠它把聊天修好,並留一張「繼續」卡片(`src/oplog.rs`)。回合結束就清空 |
| `anybot.lock` | 同一個資料夾一次只給一個 AnyBot 用(視窗、`--serve`、`--ask`、`anybot setup` 寫設定時)。是系統的檔案鎖,程式結束(包括當掉)就放開(`src/lock.rs`) |
寫入時先寫暫存檔再改名,不會寫一半。 寫入時先寫暫存檔再改名,不會寫一半。

153
src/lock.rs Normal file
View File

@ -0,0 +1,153 @@
// 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");
}
}

View File

@ -28,6 +28,7 @@ mod httpd;
mod i18n; mod i18n;
mod i18n_table; mod i18n_table;
mod icons; mod icons;
mod lock;
mod macapps; mod macapps;
mod marks; mod marks;
mod mcp; mod mcp;
@ -184,6 +185,49 @@ fn write_icon(out: &str, px: u32, svg: Option<&str>) -> Result<(), String> {
img.save(out).map_err(|e| e.to_string()) img.save(out).map_err(|e| e.to_string())
} }
/// This data folder for this copy (`--serve`, `--ask`), or a clear word and exit when another
/// AnyBot is using it.
fn take_folder_or_exit() -> lock::DataLock {
match lock::take(lock::WAIT) {
Ok(l) => l,
Err(msg) => {
eprintln!("AnyBot:{msg}");
std::process::exit(1);
}
}
}
/// What a second window shows instead of opening: another AnyBot is using this data folder.
fn busy_window(msg: String) -> eframe::Result<()> {
eprintln!("AnyBot:{msg}");
let viewport = egui::ViewportBuilder::default().with_title("AnyBot").with_inner_size([460.0, 240.0]).with_resizable(false).with_app_id("anybot").with_icon(app_icon());
eframe::run_native(
"AnyBot",
eframe::NativeOptions { viewport, ..Default::default() },
Box::new(move |cc| {
load_fonts(&cc.egui_ctx);
Ok(Box::new(Busy(msg)))
}),
)
}
struct Busy(String);
impl eframe::App for Busy {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.add_space(10.0);
ui.label(egui::RichText::new("AnyBot 已經在執行").size(17.0).strong());
ui.add_space(8.0);
ui.label(&self.0);
ui.add_space(14.0);
if ui.button("關閉").clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
});
}
}
fn main() -> eframe::Result<()> { fn main() -> eframe::Result<()> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from); let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from);
@ -241,20 +285,33 @@ fn main() -> eframe::Result<()> {
} }
std::process::exit(0); std::process::exit(0);
} }
// `anybot setup`: make this computer a brain, or hands for a brain (in a terminal). // `anybot setup`: make this computer a brain, or hands for a brain (in a terminal). It takes
// the data folder only while it writes the settings (see setup.rs).
if args.get(1).map(String::as_str) == Some("setup") { if args.get(1).map(String::as_str) == Some("setup") {
std::process::exit(setup::run(&args[2..])); std::process::exit(setup::run(&args[2..]));
} }
// Everything below uses the data folder (the chats, the work log): one AnyBot at a time.
if args.get(1).map(String::as_str) == Some("--ask") { if args.get(1).map(String::as_str) == Some("--ask") {
let text = args[2..].join(" "); let text = args[2..].join(" ");
let ctx = egui::Context::default(); let ctx = egui::Context::default();
let _folder = take_folder_or_exit();
app::App::ask_cli(&ctx, &text); app::App::ask_cli(&ctx, &text);
} }
// No window: keep the bots working and answer the phone (always-on Mac or server). // No window: keep the bots working and answer the phone (always-on Mac or server).
if args.iter().any(|a| a == "--serve") { if args.iter().any(|a| a == "--serve") {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
let _folder = take_folder_or_exit();
app::App::serve(&ctx); app::App::serve(&ctx);
} }
// (A screenshot run is a tool next to the AnyBot that owns the folder; it doesn't take it.)
let _folder = if screenshot.is_some() {
None
} else {
match lock::take(lock::WAIT) {
Ok(l) => Some(l),
Err(msg) => return busy_window(msg),
}
};
let mut viewport = egui::ViewportBuilder::default() let mut viewport = egui::ViewportBuilder::default()
.with_title("AnyBot") .with_title("AnyBot")

View File

@ -687,6 +687,17 @@ impl Plan {
if self.service { if self.service {
let _ = systemctl(&["stop", UNIT]); let _ = systemctl(&["stop", UNIT]);
} }
// No other AnyBot may be using this folder while the settings are written (a window
// would overwrite them with its own copy). The service was just stopped: give it a
// moment to let go.
let folder = match crate::lock::take(Duration::from_secs(10)) {
Ok(l) => l,
Err(e) => {
say(0, Mark::Fail, &e);
let _ = tx.send(Msg::Finished(vec!["關掉另一個 AnyBot 之後,再執行一次 anybot setup。".into()]));
return;
}
};
let mut st = State::load(); let mut st = State::load();
let mut r = self.settings.clone(); let mut r = self.settings.clone();
match self.role { match self.role {
@ -714,6 +725,8 @@ impl Plan {
} }
st.remote = r.clone(); st.remote = r.clone();
st.save(); st.save();
// Let go before the service starts again (it takes the folder next).
drop(folder);
say(0, Mark::Ok, ""); say(0, Mark::Ok, "");
// 2. Start with the computer. // 2. Start with the computer.