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:
parent
2f603c2362
commit
d3eae415c5
|
|
@ -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`) |
|
||||||
|
|
||||||
寫入時先寫暫存檔再改名,不會寫一半。
|
寫入時先寫暫存檔再改名,不會寫一半。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/main.rs
59
src/main.rs
|
|
@ -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")
|
||||||
|
|
|
||||||
13
src/setup.rs
13
src/setup.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue