From d3eae415c50f7295748b9d58751b16609aacb75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Mon, 9 Nov 2026 00:49:42 +0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS --- docs/architecture.md | 1 + src/lock.rs | 153 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 59 ++++++++++++++++- src/setup.rs | 13 ++++ 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/lock.rs diff --git a/docs/architecture.md b/docs/architecture.md index 3cbb952..9c016af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,6 +67,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一 | `brain-files/` | 螢幕電腦從大腦下載的附件 | | `image-notes.json` | 看圖小幫手對每張圖的描述(不用重看) | | `oplog/bot-.jsonl` | 每個 Bot 的工作紀錄:回合開始、每次問模型、每個步驟開始和結束、做事時傳的訊息。每行先寫進磁碟才做那件事;AnyBot 在做事時被關掉,下次打開靠它把聊天修好,並留一張「繼續」卡片(`src/oplog.rs`)。回合結束就清空 | +| `anybot.lock` | 同一個資料夾一次只給一個 AnyBot 用(視窗、`--serve`、`--ask`、`anybot setup` 寫設定時)。是系統的檔案鎖,程式結束(包括當掉)就放開(`src/lock.rs`) | 寫入時先寫暫存檔再改名,不會寫一半。 diff --git a/src/lock.rs b/src/lock.rs new file mode 100644 index 0000000..7a92725 --- /dev/null +++ b/src/lock.rs @@ -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, +} + +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 { + take_at(&path(), wait) +} + +fn take_at(p: &Path, wait: Duration) -> Result { + 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 ": 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"); + } +} diff --git a/src/main.rs b/src/main.rs index 3fafa0c..fe65326 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ mod httpd; mod i18n; mod i18n_table; mod icons; +mod lock; mod macapps; mod marks; 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()) } +/// 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<()> { let args: Vec = std::env::args().collect(); 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); } - // `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") { 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") { let text = args[2..].join(" "); let ctx = egui::Context::default(); + let _folder = take_folder_or_exit(); app::App::ask_cli(&ctx, &text); } // No window: keep the bots working and answer the phone (always-on Mac or server). if args.iter().any(|a| a == "--serve") { let ctx = egui::Context::default(); + let _folder = take_folder_or_exit(); 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() .with_title("AnyBot") diff --git a/src/setup.rs b/src/setup.rs index 53673a5..3894d2d 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -687,6 +687,17 @@ impl Plan { if self.service { 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 r = self.settings.clone(); match self.role { @@ -714,6 +725,8 @@ impl Plan { } st.remote = r.clone(); st.save(); + // Let go before the service starts again (it takes the folder next). + drop(folder); say(0, Mark::Ok, ""); // 2. Start with the computer.