From 14e7066db9750d1ae654c3bed799bdf6da572ff0 Mon Sep 17 00:00:00 2001 From: weidu12123 Date: Tue, 13 Oct 2026 22:12:39 +0800 Subject: [PATCH] fix: close pre-release security holes and correctness bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 静态资源回退存在路径穿越,未鉴权即可读出 secret.key 与 vps-switch.db - MCP 端点默认要求 API Key;删除可被客户端伪造的 x-vps-switch-stdio 豁免头 - 跨源只放行本机回环来源;绑回环时校验 Host,挡 DNS rebinding - 本机命令输出中文乱码(本机与远端共用 EncodedCommand + UTF-8) - 远端拿不到退出码时不再报成功;cross 传输超时不再谎报"后台传输中" - 任务表与单流输出加上限;传输队列双锁死锁改单锁;回收已退出的终端 pane - 前端终端创建改为按主机去重;终端断线自动重连并给出提示 Co-Authored-By: Claude Fable 5 --- frontend/src/App.vue | 14 +- frontend/src/components/TerminalPaneView.vue | 48 +++++ src/api.rs | 9 +- src/db.rs | 6 +- src/files.rs | 52 ++++-- src/main.rs | 62 +++++- src/mcp.rs | 187 ++++++++++++++----- src/terminal.rs | 2 + 8 files changed, 302 insertions(+), 78 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4ec8ec1..5d33526 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -53,7 +53,9 @@ const hosts = ref([]); const loading = ref(false); const checkingAll = ref(false); const checkingIds = ref>(new Set()); -const creatingPane = ref(false); +const paneCreationInFlight = new Set(); +const paneCreationCount = ref(0); +const creatingPane = computed(() => paneCreationCount.value > 0); // 全局昼夜主题 const isDark = ref(false); @@ -763,8 +765,11 @@ async function ensureHostHasPane(tab: OpenSessionTab) { } async function createPaneForTab(tab: OpenSessionTab, cwd?: string | null) { - if (creatingPane.value) return; - creatingPane.value = true; + // 按主机去重:全局布尔会让"主机 A 创建中"静默吞掉"主机 B 的创建请求", + // 导致 B 的标签页永久停在"正在连接"。 + if (paneCreationInFlight.has(tab.hostId)) return; + paneCreationInFlight.add(tab.hostId); + paneCreationCount.value += 1; try { const res = await fetch('/api/terminals', { method: 'POST', @@ -789,7 +794,8 @@ async function createPaneForTab(tab: OpenSessionTab, cwd?: string | null) { } catch (e) { showToast(`启动终端失败: ${(e as Error).message}`, 'err'); } finally { - creatingPane.value = false; + paneCreationInFlight.delete(tab.hostId); + paneCreationCount.value = Math.max(0, paneCreationCount.value - 1); } } diff --git a/frontend/src/components/TerminalPaneView.vue b/frontend/src/components/TerminalPaneView.vue index c154576..fb24919 100644 --- a/frontend/src/components/TerminalPaneView.vue +++ b/frontend/src/components/TerminalPaneView.vue @@ -23,6 +23,7 @@ const emit = defineEmits<{ const containerRef = ref(null); const connected = ref(false); const exited = ref(false); +const disconnected = ref(false); let term: Terminal | null = null; let fitAddon: FitAddon | null = null; @@ -30,6 +31,10 @@ let ws: WebSocket | null = null; let resizeObserver: ResizeObserver | null = null; let fitRaf: number | null = null; let replayingDepth = 0; +let disposed = false; +let reconnectTimer: number | null = null; +let reconnectAttempts = 0; +const MAX_RECONNECT_ATTEMPTS = 6; const textEncoder = new TextEncoder(); // 拦截 xterm.js 自动发出的设备属性应答(如 DA1 \x1b[?1;2c、DA2 \x1b[>0;276;0c),防止在 Shell 提示符上打出 [?1;2c @@ -129,6 +134,8 @@ function connectWebSocket() { ws.onopen = () => { connected.value = true; + disconnected.value = false; + reconnectAttempts = 0; scheduleFit(); }; @@ -164,9 +171,31 @@ function connectWebSocket() { ws.onclose = () => { connected.value = false; + if (disposed) return; + // 后端在新连接上会补发 0x02 历史字节环回放帧,重连即恢复现场; + // 退避重试有上限,避免服务端已关闭该 pane 时无限重连。 + disconnected.value = true; + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return; + const delay = Math.min(400 * 2 ** reconnectAttempts, 4000); + reconnectAttempts += 1; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + if (!disposed) connectWebSocket(); + }, delay); }; } +function manualReconnect() { + if (disposed) return; + if (reconnectTimer != null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + reconnectAttempts = 0; + disconnected.value = false; + connectWebSocket(); +} + watch( () => props.dark, (isDark) => { @@ -243,6 +272,8 @@ onMounted(() => { }); onBeforeUnmount(() => { + disposed = true; + if (reconnectTimer != null) clearTimeout(reconnectTimer); if (fitRaf != null) cancelAnimationFrame(fitRaf); resizeObserver?.disconnect(); if (ws) { @@ -283,6 +314,23 @@ onBeforeUnmount(() => {
+ +
+ {{ + reconnectAttempts >= MAX_RECONNECT_ATTEMPTS ? '终端连接已断开' : '连接中断,正在重连…' + }} + +
+
k, _ => { diff --git a/src/files.rs b/src/files.rs index 178f2f1..d5b7fe0 100644 --- a/src/files.rs +++ b/src/files.rs @@ -68,11 +68,18 @@ pub struct TransferTask { pub ended_at: Option, } +/// 传输任务表与顺序表由同一把锁持有:两把锁交叉获取曾构成 AB-BA 死锁 +/// (create_task 持 order 取 tasks,clear_finished 持 tasks 取 order),单锁后该问题不复存在。 +#[derive(Default)] +struct TransferStore { + tasks: HashMap, + order: Vec, +} + /// 统一传输队列管理器(Web UI 与未来板块六 MCP 工具共用同一套传输状态与引擎) #[derive(Default)] pub struct TransferManager { - tasks: Mutex>, - order: Mutex>, + store: Mutex, } impl TransferManager { @@ -107,19 +114,19 @@ impl TransferManager { started_at: Utc::now().to_rfc3339(), ended_at: None, }; - self.tasks.lock().unwrap().insert(id.clone(), task); - let mut ord = self.order.lock().unwrap(); - ord.insert(0, id.clone()); - if ord.len() > 100 { - if let Some(old_id) = ord.pop() { - self.tasks.lock().unwrap().remove(&old_id); + let mut store = self.store.lock().unwrap(); + store.tasks.insert(id.clone(), task); + store.order.insert(0, id.clone()); + if store.order.len() > 100 { + if let Some(old_id) = store.order.pop() { + store.tasks.remove(&old_id); } } id } pub fn update_progress(&self, id: &str, transferred: u64, total: u64, elapsed_secs: f64) { - if let Some(task) = self.tasks.lock().unwrap().get_mut(id) { + if let Some(task) = self.store.lock().unwrap().tasks.get_mut(id) { task.transferred_bytes = transferred; if total > 0 { task.total_bytes = total; @@ -131,7 +138,7 @@ impl TransferManager { } pub fn finish_task(&self, id: &str, final_bytes: u64, elapsed_secs: f64) { - if let Some(task) = self.tasks.lock().unwrap().get_mut(id) { + if let Some(task) = self.store.lock().unwrap().tasks.get_mut(id) { task.transferred_bytes = final_bytes; if task.total_bytes == 0 { task.total_bytes = final_bytes; @@ -145,7 +152,7 @@ impl TransferManager { } pub fn fail_task(&self, id: &str, err_msg: impl ToString) { - if let Some(task) = self.tasks.lock().unwrap().get_mut(id) { + if let Some(task) = self.store.lock().unwrap().tasks.get_mut(id) { task.state = "failed".to_string(); task.error = Some(err_msg.to_string()); task.ended_at = Some(Utc::now().to_rfc3339()); @@ -153,19 +160,26 @@ impl TransferManager { } pub fn list_tasks(&self, host_id: Option<&str>) -> Vec { - let ord = self.order.lock().unwrap().clone(); - let map = self.tasks.lock().unwrap(); - ord.into_iter() - .filter_map(|id| map.get(&id).cloned()) + let store = self.store.lock().unwrap(); + store + .order + .iter() + .filter_map(|id| store.tasks.get(id)) .filter(|t| host_id.map(|h| t.host_id == h).unwrap_or(true)) + .cloned() .collect() } pub fn clear_finished(&self) { - let mut map = self.tasks.lock().unwrap(); - let mut ord = self.order.lock().unwrap(); - map.retain(|_, t| t.state == "running"); - ord.retain(|id| map.contains_key(id)); + let mut store = self.store.lock().unwrap(); + let keep: std::collections::HashSet = store + .tasks + .iter() + .filter(|(_, t)| t.state == "running") + .map(|(id, _)| id.clone()) + .collect(); + store.tasks.retain(|id, _| keep.contains(id)); + store.order.retain(|id| keep.contains(id)); } } diff --git a/src/main.rs b/src/main.rs index 4f65e1b..7213301 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,9 @@ use anyhow::Result; use api::{build_api_router, AppState}; use axum::{ body::Body, - http::{header, StatusCode, Uri}, + extract::{Request, State}, + http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, + middleware::{self, Next}, response::{IntoResponse, Response}, }; use crypto::MasterKey; @@ -57,6 +59,38 @@ fn resolve_data_dir() -> PathBuf { cwd_data } +/// 静态资源相对路径白名单校验:拒绝 `..`、空段、反斜杠与盘符,杜绝跳出 frontend/dist 读本机任意文件 +fn is_safe_asset_path(rel: &str) -> bool { + !rel.is_empty() + && !rel.contains('\\') + && !rel.contains(':') + && rel + .split('/') + .all(|seg| !seg.is_empty() && seg != "." && seg != "..") +} + +/// 只接受本机回环 Host。桌面 WebView、Vite 开发代理、stdio 桥接全部走 127.0.0.1; +/// 借此挡掉 DNS rebinding(恶意网页把自己的域名解析到 127.0.0.1 后即被浏览器视为"同源")。 +fn host_header_is_loopback(headers: &HeaderMap) -> bool { + let Some(raw) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else { + return false; + }; + let host = raw.trim(); + let name = if let Some(rest) = host.strip_prefix('[') { + rest.split(']').next().unwrap_or("") + } else { + host.split(':').next().unwrap_or("") + }; + matches!(name, "127.0.0.1" | "localhost" | "::1") +} + +async fn require_loopback_host(State(enforce): State, req: Request, next: Next) -> Response { + if enforce && !host_header_is_loopback(req.headers()) { + return (StatusCode::FORBIDDEN, "仅允许通过本机回环地址访问").into_response(); + } + next.run(req).await +} + /// 静态前端资源服务:优先读本机 frontend/dist(方便开发热更新),不存在时直接走二进制内嵌的 EmbeddedFrontend(单文件打包免外部依赖) async fn serve_frontend_asset(uri: Uri) -> Response { let raw_path = uri.path().trim_start_matches('/'); @@ -66,6 +100,10 @@ async fn serve_frontend_asset(uri: Uri) -> Response { raw_path }; + if !is_safe_asset_path(rel_path) { + return (StatusCode::NOT_FOUND, "Not Found").into_response(); + } + let disk_candidate = PathBuf::from("frontend/dist").join(rel_path); if let Ok(bytes) = tokio::fs::read(&disk_candidate).await { let mime = mime_guess::from_path(rel_path).first_or_octet_stream(); @@ -203,14 +241,32 @@ fn main() -> Result<()> { .unwrap_or_else(|| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))); let addr = SocketAddr::new(bind_ip, port); + // 跨源只放行本机回环来源(桌面 WebView 与 Vite 开发端口),不再对任意网站开放读取响应 + let mut allowed_origins: Vec = Vec::new(); + for origin in [ + format!("http://127.0.0.1:{port}"), + format!("http://localhost:{port}"), + "http://127.0.0.1:5173".to_string(), + "http://localhost:5173".to_string(), + ] { + if let Ok(value) = HeaderValue::from_str(&origin) { + allowed_origins.push(value); + } + } let cors = CorsLayer::new() - .allow_origin(Any) + .allow_origin(allowed_origins) .allow_methods(Any) .allow_headers(Any); + // 绑回环时才强制校验 Host;显式设 HOST=0.0.0.0 对外提供服务时交给登录鉴权把关 + let enforce_loopback_host = bind_ip.is_loopback(); let app = build_api_router(state.clone()) .fallback(serve_frontend_asset) - .layer(cors); + .layer(cors) + .layer(middleware::from_fn_with_state( + enforce_loopback_host, + require_loopback_host, + )); #[cfg(windows)] if !is_headless { diff --git a/src/mcp.rs b/src/mcp.rs index cb4c686..8d1dace 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -130,11 +130,52 @@ pub struct ExecTaskManager { tasks: Mutex>>>, } +/// 单条任务 stdout / stderr 各自的上限,超出即截断并显式标注(避免常驻进程被大输出撑爆内存) +const MAX_OUTPUT_BYTES: usize = 1024 * 1024; +/// 任务表保留上限,超出后按开始时间淘汰最旧的已完成任务 +const MAX_TASKS: usize = 60; + +fn append_capped(buf: &mut Vec, chunk: &[u8]) { + if buf.len() >= MAX_OUTPUT_BYTES { + return; + } + let room = MAX_OUTPUT_BYTES - buf.len(); + if chunk.len() <= room { + buf.extend_from_slice(chunk); + } else { + buf.extend_from_slice(&chunk[..room]); + buf.extend_from_slice("\n...[输出超过 1 MiB,已截断]".as_bytes()); + } +} + impl ExecTaskManager { pub fn new() -> Self { Self::default() } + /// 淘汰最旧的已完成任务,保证长驻进程的任务表不会无界增长 + fn evict_old_finished(&self, map: &mut HashMap>>) { + if map.len() <= MAX_TASKS { + return; + } + let mut finished: Vec<(Instant, String)> = map + .iter() + .filter_map(|(id, task)| { + let guard = task.lock().unwrap(); + if guard.state == "running" { + None + } else { + Some((guard.started_inst, id.clone())) + } + }) + .collect(); + finished.sort(); + let remove_n = map.len().saturating_sub(MAX_TASKS); + for (_, id) in finished.into_iter().take(remove_n) { + map.remove(&id); + } + } + pub fn get_task(&self, task_id: &str) -> Option { let map = self.tasks.lock().unwrap(); map.get(task_id).map(|t| t.lock().unwrap().snapshot()) @@ -185,10 +226,11 @@ impl ExecTaskManager { cancel_tx: Some(cancel_tx), })); - self.tasks - .lock() - .unwrap() - .insert(task_id.clone(), inner.clone()); + { + let mut map = self.tasks.lock().unwrap(); + map.insert(task_id.clone(), inner.clone()); + self.evict_old_finished(&mut map); + } let st = state.clone(); let host_clone = host.clone(); @@ -260,8 +302,17 @@ async fn run_local_exec_task( cancel_rx: &mut oneshot::Receiver<()>, ) { use tokio::io::AsyncReadExt; + let encoded = windows_ps_encoded_payload(cmd); let mut ps_cmd = tokio::process::Command::new("powershell.exe"); - ps_cmd.args(["-NoProfile", "-NonInteractive", "-Command", cmd]) + ps_cmd + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encoded.as_str(), + ]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); #[cfg(windows)] @@ -300,7 +351,7 @@ async fn run_local_exec_task( Ok(0) | Err(_) => out_eof = true, Ok(n) => { let mut g = inner.lock().unwrap(); - g.stdout.extend_from_slice(&out_buf[..n]); + append_capped(&mut g.stdout, &out_buf[..n]); g.last_output_inst = Instant::now(); } } @@ -310,7 +361,7 @@ async fn run_local_exec_task( Ok(0) | Err(_) => err_eof = true, Ok(n) => { let mut g = inner.lock().unwrap(); - g.stderr.extend_from_slice(&err_buf[..n]); + append_capped(&mut g.stderr, &err_buf[..n]); g.last_output_inst = Instant::now(); } } @@ -329,12 +380,10 @@ async fn run_local_exec_task( g.ended_ms = Some(g.started_inst.elapsed().as_millis() as u64); } -fn wrap_windows_remote_command(cmd: &str) -> String { - let trimmed = cmd.trim(); - if trimmed.starts_with("powershell") || trimmed.starts_with("cmd.exe") { - return cmd.to_string(); - } - +/// 把任意 PowerShell 脚本编码为 `-EncodedCommand` 所需的 UTF-16LE base64。 +/// 本机与远端两条执行路径共用:统一注入 UTF-8 输出编码(否则中文按 GBK 落管道变乱码)、 +/// 抑制进度条噪音,并把真实退出码原样带出。 +fn windows_ps_encoded_payload(command: &str) -> String { let payload = format!( "$ProgressPreference = 'SilentlyContinue'\n\ try {{ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 }} catch {{}}\n\ @@ -344,7 +393,7 @@ fn wrap_windows_remote_command(cmd: &str) -> String { if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}\n\ if (-not $__vpsOk) {{ exit 1 }}\n\ exit 0", - trimmed + command.trim() ); let utf16_bytes: Vec = payload @@ -352,9 +401,19 @@ fn wrap_windows_remote_command(cmd: &str) -> String { .flat_map(|u| u.to_le_bytes()) .collect(); use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&utf16_bytes); + base64::engine::general_purpose::STANDARD.encode(&utf16_bytes) +} - format!("powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {b64}") +fn wrap_windows_remote_command(cmd: &str) -> String { + let trimmed = cmd.trim(); + if trimmed.starts_with("powershell") || trimmed.starts_with("cmd.exe") { + return cmd.to_string(); + } + + format!( + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {}", + windows_ps_encoded_payload(cmd) + ) } fn clean_clixml_stderr(raw: &[u8]) -> Vec { @@ -435,6 +494,7 @@ async fn run_ssh_exec_task( } let mut exit_code: Option = None; + let mut connection_lost = false; loop { tokio::select! { _ = &mut *cancel_rx => { @@ -445,7 +505,7 @@ async fn run_ssh_exec_task( match msg_opt { Some(ChannelMsg::Data { data }) => { let mut g = inner.lock().unwrap(); - g.stdout.extend_from_slice(&data); + append_capped(&mut g.stdout, &data); g.last_output_inst = Instant::now(); } Some(ChannelMsg::ExtendedData { data, .. }) => { @@ -455,13 +515,18 @@ async fn run_ssh_exec_task( } else { data.to_vec() }; - g.stderr.extend_from_slice(&cleaned); + append_capped(&mut g.stderr, &cleaned); g.last_output_inst = Instant::now(); } Some(ChannelMsg::ExitStatus { exit_status }) => { exit_code = Some(exit_status as i32); } - Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break, + Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) => break, + // russh 在收到对端 CHANNEL_CLOSE 时会先投递显式的 Close/Eof,只有会话整体消失才会得到 None + None => { + connection_lost = true; + break; + } _ => {} } } @@ -470,14 +535,37 @@ async fn run_ssh_exec_task( let mut g = inner.lock().unwrap(); if g.state == "running" { - let code = exit_code.unwrap_or(0); - g.exit_code = Some(code); - g.state = if code == 0 { - "completed".to_string() - } else { - "failed".to_string() - }; g.ended_ms = Some(g.started_inst.elapsed().as_millis() as u64); + match exit_code { + Some(code) => { + g.exit_code = Some(code); + g.state = if code == 0 { + "completed".to_string() + } else { + "failed".to_string() + }; + } + None => { + // 没拿到远端退出码就不能声称成功:连接中断与"服务端未上报退出码"都如实报失败 + g.exit_code = None; + g.state = "failed".to_string(); + if connection_lost { + append_capped( + &mut g.stderr, + "\n[SSH 连接在命令结束前中断,输出可能不完整,退出码未知]".as_bytes(), + ); + g.hint = Some( + "SSH 连接中断,命令结果不完整。请重试,或改用 vps_task 查询后续输出。" + .to_string(), + ); + } else { + g.hint = Some( + "远端未上报退出码,无法确认命令是否成功(已按失败处理,stdout/stderr 仍原样返回)。" + .to_string(), + ); + } + } + } } } @@ -1264,19 +1352,26 @@ async fn tool_vps_transfer(state: &Arc, args: Value) -> Result let src_host = resolve_host(&state.db, src_q)?; let dst_host = resolve_host(&state.db, dst_q)?; let st = state.clone(); + let src_id = src_host.id.clone(); + let dst_id = dst_host.id.clone(); - let fut = files::transfer_remote_to_remote( - &st.ssh_pool, - &st.db, - &st.transfers, - &src_host.id, - &src_path, - &dst_host.id, - &dst_path, - ); - match tokio::time::timeout(Duration::from_secs(wait_seconds), fut).await { - Ok(res) => { - let task = res?; + // 必须 spawn:若把 future 直接交给 timeout,超时会把它 drop 掉(传输被真正取消), + // 却仍对外声称"仍在后台传输",属于谎报。spawn 后超时只是不再等待,传输继续跑。 + let handle = tokio::spawn(async move { + files::transfer_remote_to_remote( + &st.ssh_pool, + &st.db, + &st.transfers, + &src_id, + &src_path, + &dst_id, + &dst_path, + ) + .await + }); + match tokio::time::timeout(Duration::from_secs(wait_seconds), handle).await { + Ok(join_res) => { + let task = join_res??; return Ok(json!({ "ok": true, "transfer": task })); } Err(_) => { @@ -1384,7 +1479,7 @@ async fn tool_vps_task( } } -/// `--mcp-stdio` 本地模式:若 127.0.0.1:3302 主进程正在运行则直接桥接过去(与桌面 UI 共享连接池、探针缓存和终端唤起事件), +/// `--mcp-stdio` 本地模式:若常驻主进程正在监听则直接桥接过去(与桌面 UI 共享连接池、探针缓存和终端唤起事件), /// 若主进程未运行则在当前进程内直接处理。 pub async fn run_stdio_bridge(state: Arc, exec_tasks: Arc, port: u16) -> Result<()> { let stdin = tokio::io::stdin(); @@ -1399,8 +1494,14 @@ pub async fn run_stdio_bridge(state: Arc, exec_tasks: Arc, exec_tasks: Arc Option { +async fn try_forward_to_daemon(port: u16, api_key: &str, raw_json: &str) -> Option { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let addr = format!("127.0.0.1:{port}"); let mut stream = tokio::time::timeout( @@ -1426,7 +1527,7 @@ async fn try_forward_to_daemon(port: u16, raw_json: &str) -> Option { .ok()?; let req_bytes = format!( - "POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nx-vps-switch-stdio: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nAuthorization: Bearer {api_key}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", raw_json.len(), raw_json ); diff --git a/src/terminal.rs b/src/terminal.rs index 8e47746..aa70369 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -679,6 +679,8 @@ impl TerminalManager { let meta = handle.snapshot_meta(); { let mut guard = self.panes.lock().unwrap(); + // 顺手回收该主机下已退出的旧 pane:否则反复打开终端会一直堆积 ring buffer 与终端网格 + guard.retain(|_, p| !(p.host_id == host_id && p.state.lock().unwrap().exited)); guard.insert(pane_id, handle); } Ok(meta)