diff --git a/src/agent.rs b/src/agent.rs index ad13462..094707c 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -84,7 +84,8 @@ fn review_action(job: &Job, review: &Review, action: &str, cancel: &AtomicBool) }; let Ok(out) = providers::run_step(&req, cancel, &|_| {}) else { return ReviewVerdict::NoMatch }; let text = out.text.trim(); - let json_part = text.find('{').and_then(|a| text.rfind('}').map(|b| &text[a..=b])).unwrap_or(text); + // A truncated answer can put the last "}" before the first "{" — slicing then panics. + let json_part = text.find('{').and_then(|a| text.rfind('}').filter(|b| *b >= a).map(|b| &text[a..=b])).unwrap_or(text); let v: serde_json::Value = serde_json::from_str(json_part).unwrap_or_default(); let rule = v["rule"].as_str().unwrap_or("").to_string(); match v["verdict"].as_str() { @@ -140,19 +141,50 @@ fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt { } } +/// Substring search with word boundaries on the ASCII parts: "mac" must not light up inside +/// "macOS", and a device named "Ann" must not light up on "anniversary". Loose matching let +/// unrelated words — including words from page text that arrives inside a message — unlock a +/// computer the person never named, defeating the guard below. +fn finds(hay: &str, needle: &str) -> bool { + let hb: Vec = hay.chars().collect(); + let nb: Vec = needle.chars().collect(); + if nb.is_empty() || nb.len() > hb.len() { + return false; + } + let alnum = |c: char| c.is_ascii_alphanumeric(); + 'start: for i in 0..=hb.len() - nb.len() { + if hb[i] != nb[0] { + continue; + } + for j in 1..nb.len() { + if hb[i + j] != nb[j] { + continue 'start; + } + } + if alnum(nb[0]) && i > 0 && alnum(hb[i - 1]) { + continue; + } + if alnum(*nb.last().unwrap()) && i + nb.len() < hb.len() && alnum(hb[i + nb.len()]) { + continue; + } + return true; + } + false +} + /// Words that mean a computer in the person's message. fn mentions(text: &str, t: &tools::Target) -> bool { let low = text.to_lowercase(); - if low.contains(&t.name.to_lowercase()) { + if finds(&low, &t.name.to_lowercase()) { return true; } let words: &[&str] = match &t.place { _ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"], _ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"], - tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && low.contains(&crate::remote::peer_name(host).to_lowercase()), + tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && finds(&low, &crate::remote::peer_name(host).to_lowercase()), _ => &[], }; - words.iter().any(|w| low.contains(w)) + words.iter().any(|w| finds(&low, w)) } /// A step naming a computer other than the chosen one, which the person didn't ask for, goes @@ -299,8 +331,11 @@ fn maybe_compact(job: &Job, history: &mut Vec, percent: u64, cancel: &A return None; } let summary = Message { role: Role::Assistant, text: out.text.trim().to_string(), summary: true, via: job.via.clone(), at: crate::model::now_ms(), ..Default::default() }; + // The window shows the chat unfiltered (group chats drop some messages on the way here); + // the timestamp of the first kept message is the landmark it can find the cut by. + let boundary = history[cut].at; history.splice(..cut, [summary.clone()]); - send(StreamEvent::Compacted(cut, summary)); + send(StreamEvent::Compacted(cut, summary, boundary)); Some(cut) } @@ -468,12 +503,19 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string(); let local = match target_for(&job, &call.args) { - Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => crate::cloudbox::copy_out(&if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }), + // Files ride to the chat only from this bot's own folder on the box. + Ok(tools::Target { place: tools::Place::Box { dir }, .. }) => match tools::box_path(std::path::Path::new(&dir), &path) { + Ok(p) => crate::cloudbox::copy_out(&p), + Err(e) => Err(e), + }, Ok(tools::Target { place: tools::Place::Sealed { root }, .. }) => Ok(tools::resolve(&root, &path)), Ok(tools::Target { place: tools::Place::Local { workdir }, .. }) => Ok(tools::resolve(&workdir, &path)), Ok(_) => Err("這台電腦上的檔案不能直接傳,請先複製到 Bot 的電腦。".to_string()), Err(e) => Err(e), }; + // Secrets (SSH keys, the app's own data folder, .env…) never ride into the chat, + // same rule as read_file/edit_file. + let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能傳到聊天。".to_string()) } else { Ok(p) }); let (output, ok) = match local.and_then(|p| crate::attach::store(bot_id, &p)) { Ok(a) => { let out = format!("已把「{}」傳到聊天裡({}),使用者點一下就能預覽。", a.name, crate::attach::human(a.bytes)); @@ -507,6 +549,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< (None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)), _ => Err("這台電腦上的檔案不能直接打開給使用者看,請先複製到 Bot 的電腦或這台 Mac。".to_string()), }; + // Secrets never open on the person's screen either. + let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) }); let (output, ok) = match local { Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() { Ok(st) if st.success() => (format!("已在使用者的螢幕上打開 {}。", p.display()), true), @@ -944,6 +988,23 @@ mod tests { use crate::model::ToolCall; use crate::providers::ProviderKind; + #[test] + fn computer_words_need_word_boundaries() { + // "mac" as its own word names this computer; inside longer words (macOS, a device + // named Ann inside "anniversary") it must not — that is what made the guard fire on + // text the person never aimed at any computer. + assert!(finds("用我的 mac 執行", "mac")); + assert!(finds("mac 上開檔案", "mac")); + assert!(!finds("在 macOS 上開檔案", "mac")); + assert!(!finds("telegram 傳給我", "mac")); + assert!(finds("我的電腦好慢", "我的電腦")); + assert!(!finds("你的虛擬電腦很吵", "bot 的電腦")); + assert!(finds("在 bot 的電腦跑", "bot 的電腦")); + let t = tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: Default::default() } }; + assert!(mentions("幫我在 Mac 截圖", &t)); + assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &t)); + } + #[test] fn cut_keeps_tool_pairs_and_recent_messages() { let mut h = vec![]; diff --git a/src/mcp.rs b/src/mcp.rs index 3fd4f9d..7bed146 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeMap, HashMap}; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::mpsc::{channel, Receiver}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; pub const PROTOCOL: &str = "2025-06-18"; @@ -58,15 +58,22 @@ enum Link { Http { url: String, headers: BTreeMap, session: Option }, } -struct Conn { +/// The part a tool call mutates; locked per server, so one slow call (up to two minutes) +/// never blocks other servers, the UI's status line, or the app's own sync. +struct Live { link: Link, next_id: u64, +} + +struct Conn { + live: Mutex, tools: Vec, config: Server, } -fn conns() -> &'static Mutex> { - static C: OnceLock>> = OnceLock::new(); +/// One lock for the table, one lock per server's link. +fn conns() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); C.get_or_init(|| Mutex::new(HashMap::new())) } @@ -141,7 +148,7 @@ pub fn parse_http_answer(body: &str, id: u64) -> Result { Err("MCP 伺服器沒有給答案".into()) } -impl Conn { +impl Live { fn request(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; self.next_id += 1; @@ -224,13 +231,13 @@ fn connect(s: &Server) -> Result { }); Link::Stdio { child, stdin, rx } }; - let mut c = Conn { link, next_id: 1, tools: vec![], config: s.clone() }; - c.request("initialize", json!({"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "AnyBot", "version": "1"}}))?; - c.notify("notifications/initialized"); + let mut live = Live { link, next_id: 1 }; + live.request("initialize", json!({"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "AnyBot", "version": "1"}}))?; + live.notify("notifications/initialized"); let mut tools = vec![]; let mut cursor: Option = None; for _ in 0..20 { - let r = c.request("tools/list", match &cursor { Some(x) => json!({"cursor": x}), None => json!({}) })?; + let r = live.request("tools/list", match &cursor { Some(x) => json!({"cursor": x}), None => json!({}) })?; for t in r["tools"].as_array().into_iter().flatten() { tools.push(ToolInfo { server: s.name.clone(), name: t["name"].as_str().unwrap_or("").into(), description: t["description"].as_str().unwrap_or("").into(), schema: t["inputSchema"].clone() }); } @@ -239,31 +246,37 @@ fn connect(s: &Server) -> Result { break; } } - c.tools = tools; - Ok(c) + Ok(Conn { live: Mutex::new(live), tools, config: s.clone() }) } /// Start (or keep) the enabled servers; stop the rest. Returns errors per server. +/// Connecting happens outside the table's lock (a slow server must not freeze the UI's +/// status line, which reads that lock every second). pub fn sync(servers: &[Server]) -> Vec<(String, String)> { let mut errs = vec![]; - let mut map = conns().lock().unwrap(); - let wanted: Vec<&Server> = servers.iter().filter(|s| s.enabled).collect(); - map.retain(|name, c| { - let keep = wanted.iter().any(|s| &s.name == name && **s == c.config); - if !keep { - if let Link::Stdio { child, .. } = &mut c.link { - let _ = child.kill(); + let mut to_connect: Vec = vec![]; + { + let mut map = conns().lock().unwrap(); + let wanted: Vec<&Server> = servers.iter().filter(|s| s.enabled).collect(); + map.retain(|name, c| { + let keep = wanted.iter().any(|s| &s.name == name && **s == c.config); + if !keep { + if let Link::Stdio { child, .. } = &mut c.live.lock().unwrap().link { + let _ = child.kill(); + } + } + keep + }); + for s in wanted { + if !map.contains_key(&s.name) { + to_connect.push(s.clone()); } } - keep - }); - for s in wanted { - if map.contains_key(&s.name) { - continue; - } - match connect(s) { + } + for s in to_connect { + match connect(&s) { Ok(c) => { - map.insert(s.name.clone(), c); + conns().lock().unwrap().insert(s.name.clone(), Arc::new(c)); } Err(e) => errs.push((s.name.clone(), e)), } @@ -305,9 +318,12 @@ pub fn resolve(name: &str) -> Option<(String, String)> { pub fn call(name: &str, args: &str) -> (String, bool, Option) { let Some((server, tool)) = resolve(name) else { return (format!("沒有「{name}」這個 MCP 工具(伺服器可能已關閉)"), false, None) }; let arguments: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(json!({})); - let mut map = conns().lock().unwrap(); - let Some(c) = map.get_mut(&server) else { return ("MCP 伺服器沒有連線".into(), false, None) }; - match c.request("tools/call", json!({"name": tool, "arguments": arguments})) { + // Copy the server's own handle out of the table, then lock its link alone: a two-minute + // call to one server used to block every other MCP tool and the UI's status line. + let conn = conns().lock().unwrap().get(&server).cloned(); + let Some(conn) = conn else { return ("MCP 伺服器沒有連線".into(), false, None) }; + let mut live = conn.live.lock().unwrap(); + match live.request("tools/call", json!({"name": tool, "arguments": arguments})) { Ok(r) => { let mut text = String::new(); let mut image = None; @@ -336,8 +352,8 @@ pub fn call(name: &str, args: &str) -> (String, bool, Option) { } pub fn shutdown() { - for (_, c) in conns().lock().unwrap().iter_mut() { - if let Link::Stdio { child, .. } = &mut c.link { + for c in conns().lock().unwrap().values() { + if let Link::Stdio { child, .. } = &mut c.live.lock().unwrap().link { let _ = child.kill(); } } diff --git a/src/tools.rs b/src/tools.rs index 71800ea..f5b6cb6 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -244,6 +244,40 @@ fn starts_with_words(cmd: &str, prefix: &str) -> bool { !p.is_empty() && c.len() >= p.len() && c[..p.len()] == p[..] } +/// The denied command hiding inside a wrapper: `(sudo …)`, `FOO=1 sudo …`, `env sudo …`, +/// `nohup sudo …` all run the same thing, so peel the wrappings off before matching. +fn denied_hit(part: &str) -> Option<&'static str> { + let mut toks: Vec = Vec::new(); + for w in words(part) { + let mut w = w; + while matches!(w.as_bytes().first(), Some(b'(') | Some(b'{')) { + w = &w[1..]; + } + if w.is_empty() { + continue; + } + // VAR=val prefixes set the environment; they are not the command. + let is_assign = w.split_once('=').map(|(k, v)| !k.is_empty() && v.len() + 1 < w.len() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')).unwrap_or(false); + if toks.is_empty() && is_assign { + continue; + } + toks.push(w.to_string()); + } + const WRAP: [&str; 7] = ["env", "command", "nohup", "time", "nice", "stdbuf", "timeout"]; + while toks.first().map(|t| WRAP.contains(&t.as_str())).unwrap_or(false) { + toks.remove(0); + while toks.first().map(|t| t.starts_with('-')).unwrap_or(false) { + toks.remove(0); + } + // timeout takes a duration before the command. + if toks.first().map(|t| !t.is_empty() && t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)).unwrap_or(false) { + toks.remove(0); + } + } + let joined = toks.join(" "); + DENIED_COMMANDS.iter().find(|d| starts_with_words(&joined, d)).copied() +} + /// The "always allow" grant for a command: its first word, or first two for git/npm/cargo-style tools. pub fn shell_grant(part: &str) -> String { let w = words(part); @@ -296,7 +330,7 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci }; // deny for part in &parts { - if let Some(d) = DENIED_COMMANDS.iter().find(|d| starts_with_words(part, d)) { + if let Some(d) = denied_hit(part) { return Decision::Deny(format!("「{d}」這類指令一律不允許。")); } } @@ -614,10 +648,10 @@ fn ssh_command(target: &str, dir: &str, cmd: &str) -> String { } /// The local wrapper that runs `cmd` on a remote computer (an SSH host or the bot's box). -fn wrapper_for(place: &Place, cmd: &str, interactive: bool) -> Option { +fn wrapper_for(place: &Place, cmd: &str, interactive: bool, job: &str) -> Option { match place { Place::Ssh { target, dir } => Some(ssh_command(target, dir, cmd)), - Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive)), + Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive, job)), _ => None, } } @@ -680,7 +714,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) -> /// Run a remote command and give back its output (no background hand-off). fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result { use std::io::Write; - let w = wrapper_for(place, cmd, stdin.is_some()).ok_or("不是遠端電腦")?; + let w = wrapper_for(place, cmd, stdin.is_some(), "").ok_or("不是遠端電腦")?; let mut c = build_wrapper_command(&w, "", ""); let mut child = c.spawn().map_err(|e| format!("無法執行:{e}"))?; if let Some(data) = stdin { @@ -732,6 +766,16 @@ pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids: if v.is_null() && !args.trim().is_empty() { return ("參數不是有效的 JSON".into(), false); } + // Box file tools stay inside this bot's own folder: every bot shares one container, + // one user and one disk, so `../` or an absolute path reached (and rewrote) the + // other bots' files — a planted .bashrc runs code on their next command. + let (p, path) = match place { + Place::Box { dir } if name != "shell" => match box_path(std::path::Path::new(dir), &p) { + Ok(s) => (s.clone(), s), + Err(e) => return (e, false), + }, + _ => (p, path), + }; let remote = match name { "shell" if arg_str(&v, "command").trim().is_empty() => return ("請給要執行的指令".into(), false), "shell" => arg_str(&v, "command"), @@ -774,12 +818,45 @@ pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids: "read_terminal" | "write_terminal" => return run(name, args, Path::new("/"), cancel, ids), other => return (format!("沒有「{other}」這個工具"), false), }; - let wrapper = wrapper_for(place, &remote, name == "shell").unwrap_or_default(); - done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids)) + // Tag the in-container command so stopping it here also stops it inside the box + // (killing the local docker client alone left the program running in the box). + let job = crate::httpd::random_hex(8); + let wrapper = wrapper_for(place, &remote, name == "shell", &job).unwrap_or_default(); + let kill = matches!(place, Place::Box { .. }).then(|| crate::cloudbox::exec_kill_argv(&job)); + done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids, kill)) } } } +/// Keep a Box file path inside the bot's own folder: relative paths join the bot's dir, and +/// a normalised result that lands outside it (`../`, absolute paths to other bots' folders) +/// is refused. All bots share one container and one user, so the folder is the only wall. +pub fn box_path(dir: &Path, p: &str) -> Result { + let joined = if p.starts_with('/') { PathBuf::from(p) } else { dir.join(p) }; + let mut parts: Vec = vec![]; + for c in joined.components() { + use std::path::Component::*; + match c { + ParentDir => { + parts.pop(); + } + CurDir => {} + RootDir => parts.push(std::ffi::OsString::from("/")), + Normal(x) => parts.push(x.to_os_string()), + Prefix(x) => parts.push(x.as_os_str().to_os_string()), + } + } + let mut norm = PathBuf::new(); + for x in parts { + norm.push(x); + } + if norm.starts_with(dir) { + Ok(norm.display().to_string()) + } else { + Err(format!("這個檔案不在你的資料夾裡(Bot 的電腦上每個 Bot 只用自己的資料夾):{p}")) + } +} + /// Run a tool that the rules already allowed. Returns (output, ok). pub fn run(name: &str, args: &str, workdir: &Path, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) { let v = args_of(args); @@ -977,15 +1054,15 @@ fn edit_file(p: &Path, old: &str, new: &str) -> Result { } fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result { - run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids) + run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids, None) } /// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output. -pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds) -> Result { - run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids) +pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option>) -> Result { + run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids, kill) } -fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds) -> Result { +fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option>) -> Result { let mut child = command.spawn().map_err(|e| format!("無法執行:{e}"))?; let stdin = Arc::new(Mutex::new(child.stdin.take())); let root = child.id(); @@ -999,7 +1076,11 @@ fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &Atomic if n == 0 { break; } - out.lock().unwrap().push_str(&String::from_utf8_lossy(&chunk[..n])); + // Cap the kept output (keep the newest): `yes` must not eat all the RAM. + let mut out = out.lock().unwrap(); + if out.len() < 4_000_000 { + out.push_str(&String::from_utf8_lossy(&chunk[..n])); + } } }); } @@ -1014,6 +1095,14 @@ fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &Atomic if cancel.load(Ordering::Relaxed) { kill_tree(root); let _ = child.kill(); + // Reap it: a killed child nobody waits for stays a zombie for the app's lifetime. + let _ = child.wait(); + // The remote end (a program running inside the box) is not in this process tree. + if let Some(k) = &kill { + if let Some((head, rest)) = k.split_first() { + let _ = Command::new(head).args(rest).status(); + } + } return Err("已停止(連同它啟動的程式)".into()); } if start.elapsed() >= Duration::from_millis(timeout_ms) { @@ -1054,12 +1143,17 @@ fn read_terminal(id: u64) -> Result { /// Send input to a background command (like typing into its terminal). `close` ends its input. fn write_terminal(id: u64, input: &str, close: bool) -> Result { use std::io::Write; - let t = terminals().lock().unwrap(); - let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?; - if term.exit.lock().unwrap().is_some() { + // Copy the terminal's own handles out first: writing to a pipe whose buffer is full blocks, + // and holding the shared registry lock while blocked stops every other terminal tool. + let (stdin, exit) = { + let t = terminals().lock().unwrap(); + let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?; + (term.stdin.clone(), term.exit.clone()) + }; + if exit.lock().unwrap().is_some() { return Err(format!("背景指令 #{id} 已經結束了")); } - let mut slot = term.stdin.lock().unwrap(); + let mut slot = stdin.lock().unwrap(); if !input.is_empty() { let w = slot.as_mut().ok_or("這個指令的輸入已經關閉了")?; w.write_all(input.as_bytes()).and_then(|_| w.flush()).map_err(|e| format!("送不進去:{e}"))?; @@ -1068,7 +1162,6 @@ fn write_terminal(id: u64, input: &str, close: bool) -> Result { *slot = None; } drop(slot); - drop(t); std::thread::sleep(Duration::from_millis(400)); let out = read_terminal(id)?; Ok(format!("已送出{}{}。\n{out}", if input.is_empty() { String::new() } else { format!(" {} 個字", input.chars().count()) }, if close { ",並結束輸入" } else { "" })) @@ -1077,9 +1170,12 @@ fn write_terminal(id: u64, input: &str, close: bool) -> Result { /// Stop every background command and what it started (when the app quits). pub fn stop_all() { for t in terminals().lock().unwrap().values() { + // child before exit — the watcher thread takes them in this order; the reverse order + // here could wedge both threads forever (AB-BA). + let mut child = t.child.lock().unwrap(); if t.exit.lock().unwrap().is_none() { kill_tree(t.root); - let _ = t.child.lock().unwrap().kill(); + let _ = child.kill(); } } } @@ -1088,9 +1184,11 @@ pub fn stop_all() { pub fn stop_for(conversation: &str) -> usize { let mut n = 0; for t in terminals().lock().unwrap().values().filter(|t| t.conversation == conversation) { + // Same lock order as the watcher thread (child, then exit). + let mut child = t.child.lock().unwrap(); if t.exit.lock().unwrap().is_none() { kill_tree(t.root); - let _ = t.child.lock().unwrap().kill(); + let _ = child.kill(); n += 1; } }