Compaction keeps tool pairs; MCP locks per server; the chosen computer needs whole words to move

This commit is contained in:
王性驊 2026-11-05 15:48:20 +08:00
parent caa580fce9
commit ca30f708ed
3 changed files with 230 additions and 55 deletions

View File

@ -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<char> = hay.chars().collect();
let nb: Vec<char> = 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<Message>, 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![];

View File

@ -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<String, String>, session: Option<String> },
}
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<Live>,
tools: Vec<ToolInfo>,
config: Server,
}
fn conns() -> &'static Mutex<HashMap<String, Conn>> {
static C: OnceLock<Mutex<HashMap<String, Conn>>> = OnceLock::new();
/// One lock for the table, one lock per server's link.
fn conns() -> &'static Mutex<HashMap<String, Arc<Conn>>> {
static C: OnceLock<Mutex<HashMap<String, Arc<Conn>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
@ -141,7 +148,7 @@ pub fn parse_http_answer(body: &str, id: u64) -> Result<Value, String> {
Err("MCP 伺服器沒有給答案".into())
}
impl Conn {
impl Live {
fn request(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
@ -224,13 +231,13 @@ fn connect(s: &Server) -> Result<Conn, String> {
});
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<String> = 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<Conn, String> {
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<Server> = 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<String>) {
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<String>) {
}
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();
}
}

View File

@ -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<String> = 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<String> {
fn wrapper_for(place: &Place, cmd: &str, interactive: bool, job: &str) -> Option<String> {
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<String, String> {
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<String, String> {
let joined = if p.starts_with('/') { PathBuf::from(p) } else { dir.join(p) };
let mut parts: Vec<std::ffi::OsString> = 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<String, String> {
}
fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result<String, String> {
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<String, String> {
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<Vec<String>>) -> Result<String, String> {
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<String, String> {
fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option<Vec<String>>) -> Result<String, String> {
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<String, String> {
/// 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<String, String> {
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<String, String> {
*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<String, String> {
/// 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;
}
}