//! ConPTY 绑定与本地会话实现。 //! //! 直接绑定 `windows-sys` 的 `CreatePseudoConsole` 系列 API(而非引入 //! `portable-pty` 之类的封装)。理由:本项目已有大量原生 Win32 调用 //! (`win32_util.rs` / `screenshot/wgc_capture.rs` / `translate/capture/uia_capture.rs`), //! 这条路径熟悉;而 ConPTY 的三个坑(见下)无论加不加封装都要踩,多一层只增加定位难度。 //! //! # ConPTY 的三个坑(全部已在实现中处理) //! //! 1. **`ClosePseudoConsole` 会阻塞**,直到所有引用该 PTY 的句柄被关闭。若在读线程 //! 仍挂起于 `ReadFile` 时调用,就会永久卡住。处理:先 `CancelIoEx` 取消挂起的读, //! 再在**独立线程**里调用 `ClosePseudoConsole`,调用方不等待(见 [`ConPtySession::kill`])。 //! //! 2. **`ResizePseudoConsole` 有早期竞态**:进程刚创建、还没开始读 stdout 时调用, //! 尺寸可能被吞掉(表现为 TUI 程序启动后按 80×24 而不是实际尺寸绘制,vim/htop 花屏)。 //! 处理:首帧输出到达前,resize 请求只入队不执行;首帧到达后再应用队列中的最新值 //! (见 [`PtyInner::pending_size`])。 //! //! 3. **进程退出 ≠ PTY 关闭**:子进程退出后,管道里可能还有未读完的输出(如最后一行 //! 提示符、错误信息)。必须等 `ReadFile` 返回 0 或 `ERROR_BROKEN_PIPE` 才算真正结束, //! 否则会丢掉尾部输出——这正是很多自制终端「退出时少一行」的原因。 //! //! # 线程模型 //! //! 每个会话起 **两个** 后台线程: //! - 输出读线程:循环 `ReadFile`,把数据推入聚合缓冲,按 8~16ms 窗口发批次事件。 //! - 退出等待线程:`WaitForSingleObject` 等子进程句柄,拿退出码,等读线程自然结束 //! (即坑 3)后把状态置为 `Closed` 并发事件。 //! //! 写操作不单独起线程:`WriteFile` 在 ConPTY 上通常不阻塞(有内部缓冲), //! 由命令层直接同步调用。若未来证实大块粘贴会阻塞,再改成写队列。 use std::io::{ErrorKind, Read, Write}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter}; use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; use windows_sys::Win32::System::Console::{ ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON, }; use windows_sys::Win32::System::Threading::{ CreateProcessW, GetExitCodeProcess, TerminateProcess, WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, PROCESS_INFORMATION, STARTUPINFOEXW, }; use super::super::session::{ local_info, LocalSessionState, Session, SessionId, SessionInfo, SessionKind, SessionState, }; /// 输出聚合窗口。 /// /// 8ms 是权衡值:`cat` 大文件时每秒可产生数万次 `ReadFile` 返回,逐条 emit 会压垮 /// WebView;但窗口太长(如 50ms)会让交互式输入出现可感知的延迟。8ms 约等于 /// 一帧(120Hz),人眼无法分辨,同时能把数千次读合并成一次事件。 const AGGREGATE_WINDOW: Duration = Duration::from_millis(8); /// 单次读缓冲区大小。64KB 与 ConPTY 内部缓冲匹配,避免多次往返。 const READ_BUF_SIZE: usize = 64 * 1024; /// 聚合缓冲上限:防止「疯狂输出且前端卡住」时内存无限增长。 /// 超出后丢弃**最旧**的数据(终端语义:用户更关心最新的输出)。 const MAX_PENDING_BYTES: usize = 4 * 1024 * 1024; /// 会话终止时附加到输出的提示(由 Rust 侧统一给出,避免前端各写一套)。 const EXIT_HINT: &str = "\r\n"; // ===== 共享状态 ===== struct PtyInner { /// PTY 句柄。`Mutex` 保护是因为 resize 需要并发访问,而 ClosePseudoConsole /// 会把它置为 None(表示已关闭,后续调用应静默忽略)。 hpc: Mutex>, /// 写入端(我们 → 伪控制台输入)。`Option` 以便 kill 后释放。 writer: Mutex>, /// 子进程句柄,用于退出等待与强制终止。 process: Mutex>, /// 是否已收到首帧输出(ConPTY resize 竞态的处理依据,见模块注释坑 2)。 first_output_seen: AtomicBool, /// 首帧之前缓存的尺寸请求。 pending_size: Mutex>, /// 是否已关闭(幂等保护)。 closed: AtomicBool, } /// 本地 ConPTY 会话。 pub struct ConPtySession { pub state: Arc, inner: Arc, /// 事件发射器。持有 `AppHandle` 而非 `Window`:会话可以「提升」为独立窗口 /// (见 `terminal_detach_session`),事件应发给所有窗口而不是绑定的那一个。 app: AppHandle, /// 输出批次序号(每会话独立计数,前端按会话校验连续性)。 seq: AtomicU64, } impl ConPtySession { /// 启动一个本地 Shell 会话。 /// /// `command_line` 必须是**完整的命令行**(含可执行文件路径)。Windows 的 /// `CreateProcessW` 在传入 `lpApplicationName = NULL` 时会自行解析命令行首段 /// 作为可执行文件,因此需要调用方保证路径带引号(见 [`super::super::shell::build_command_line`])。 pub fn spawn( app: AppHandle, id: SessionId, shell_id: String, shell_name: String, command_line: String, cwd: Option, env: Vec<(String, String)>, cols: u16, rows: u16, ) -> Result { let state = Arc::new(LocalSessionState::new(id.clone(), shell_id, shell_name)); *state.size.lock().unwrap_or_else(|e| e.into_inner()) = (cols, rows); // ===== 1. 创建一对匿名管道 ===== // ConPTY 需要「输入管道」(我们写、PTY 读)与「输出管道」(PTY 写、我们读)。 let (input_read, input_write) = create_pipe()?; let (output_read, output_write) = create_pipe()?; // ===== 2. 创建伪控制台 ===== let size = COORD { X: cols as i16, Y: rows as i16, }; let mut hpc: HPCON = 0; // SAFETY: 传入的两个句柄是本函数刚创建的、有效的管道端; // size 已按 COORD 的 i16 范围做了钳制(见 clamp_dim)。 let hr = unsafe { CreatePseudoConsole(size, input_read.as_raw_handle() as HANDLE, output_write.as_raw_handle() as HANDLE, 0, &mut hpc) }; if hr < 0 { return Err(format!("CreatePseudoConsole 失败(HRESULT: 0x{hr:08X})")); } // 创建后立即关掉我们持有的这两端: // - input_read:PTY 已持有自己的副本,我们只保留写端 // - output_write:同理,我们只保留读端 // 若不关闭,读端永远等不到 EOF(因为写端仍被本进程持有), // 表现为「会话关闭后读线程不退出」,进而导致 ClosePseudoConsole 卡死(坑 1)。 drop(input_read); drop(output_write); // ===== 3. 组装 STARTUPINFOEX 并把 PTY 传给子进程 ===== let mut si: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; si.StartupInfo.cb = std::mem::size_of::() as u32; // 必须设置这两个标志: // - EXTENDED_STARTUPINFO_PRESENT:让系统读 attribute list 里的 HPCON // - CREATE_UNICODE_ENVIRONMENT:环境块是 UTF-16 let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; // 把 HPCON 放进进程属性列表。这一步用 ATTRIBUTE 常量的原始值即可, // 不必引入 PROCTHREAD_ATTRIBUTE 类型(windows-sys 未导出便捷构造器)。 const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x0002_0016; let mut attr_size: usize = 0; unsafe { // 第一次调用取所需大小 InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut attr_size); } let mut attr_buf = vec![0u8; attr_size]; let attr_list = attr_buf.as_mut_ptr() as *mut _; // SAFETY: attr_buf 按 API 报告的大小分配;attr_size 已由上一次调用写入。 let ok = unsafe { InitializeProcThreadAttributeList(attr_list, 1, 0, &mut attr_size) }; if ok == 0 { unsafe { ClosePseudoConsole(hpc) }; return Err(format!( "InitializeProcThreadAttributeList 失败: {}", std::io::Error::last_os_error() )); } // SAFETY: attr_list 已初始化且声明可容纳 1 个属性;hpc 是有效的 HPCON。 // // 注意 windows-sys 0.52 里 `HPCON = isize`(0.59+ 才是 `*mut c_void`)。 // `isize as *const c_void` 是不允许的直接转型(E0641), // 必须先转成 `usize` 再转指针——两步都是明确的大小的整数/指针转换。 let hpc_ptr = hpc as usize as *const std::ffi::c_void; let ok = unsafe { UpdateProcThreadAttribute( attr_list, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, hpc_ptr, std::mem::size_of::(), std::ptr::null_mut(), std::ptr::null_mut(), ) }; if ok == 0 { let e = std::io::Error::last_os_error(); unsafe { DeleteProcThreadAttributeList(attr_list); ClosePseudoConsole(hpc); } return Err(format!("UpdateProcThreadAttribute 失败: {e}")); } si.lpAttributeList = attr_list; // ===== 4. 组装环境块与命令行 ===== let env_block = build_env_block(&env)?; let mut cmdline: Vec = command_line.encode_utf16().chain(std::iter::once(0)).collect(); let cwd_wide: Option> = cwd .as_ref() .filter(|s| !s.trim().is_empty()) .map(|s| s.encode_utf16().chain(std::iter::once(0)).collect()); // ===== 5. 创建进程 ===== // SAFETY: 所有指针都指向本函数栈/堆上的有效数据,且在调用期间存活; // cmdline 是可变的(CreateProcessW 可能原地修改它,这是 API 约定)。 let created = unsafe { CreateProcessW( std::ptr::null(), // 让系统从命令行解析可执行文件 cmdline.as_mut_ptr(), // 可写缓冲 std::ptr::null(), // 默认进程安全属性 std::ptr::null(), // 默认线程安全属性 0, // 不继承句柄(PTY 走属性列表传递) EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, env_block.as_ptr() as *const std::ffi::c_void, // 环境块 cwd_wide.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()), &si.StartupInfo, &mut pi, ) }; // 无论成功与否,属性列表都可以释放了(系统已复制所需信息) unsafe { DeleteProcThreadAttributeList(attr_list); } if created == 0 { let e = std::io::Error::last_os_error(); unsafe { ClosePseudoConsole(hpc) }; return Err(format!("创建进程失败: {e}")); } // 主线程句柄用不到,立即关闭(进程句柄保留,用于等待退出) unsafe { CloseHandle(pi.hThread) }; // ===== 6. 组装会话对象 ===== // `create_pipe` 已直接返回 `File`(在内部完成裸句柄 → `File` 的转换), // 此处不再做二次转换——早前那版把 `File` 当句柄再转一次, // 会触发 `expected isize, found File` 的类型错误。 let writer = input_write; let reader = output_read; let process = unsafe { OwnedHandle::from_raw_handle(pi.hProcess as *mut _) }; let inner = Arc::new(PtyInner { hpc: Mutex::new(Some(hpc)), writer: Mutex::new(Some(writer)), process: Mutex::new(Some(process)), first_output_seen: AtomicBool::new(false), pending_size: Mutex::new(None), closed: AtomicBool::new(false), }); let session = Self { state: state.clone(), inner: inner.clone(), app: app.clone(), seq: AtomicU64::new(0), }; session.set_state(SessionState::Established, None); // ===== 7. 启动读线程与退出等待线程 ===== spawn_reader(app.clone(), state.clone(), inner.clone(), reader); spawn_waiter(app, state.clone(), inner, session.seq_counter()); Ok(session) } /// 输出序号计数器(每会话独立;前端按会话分别校验连续性)。 fn seq_counter(&self) -> Arc { // 这里刻意返回一个独立计数器:`ConPtySession` 自身可能被 move 进 // 注册表,而读线程需要在 move 之前就拿到它。两者通过 Arc 共享。 Arc::new(AtomicU64::new(self.seq.load(Ordering::Relaxed))) } } /// 管道创建:返回 (读端, 写端) 两个 `File`。 fn create_pipe() -> Result<(std::fs::File, std::fs::File), String> { use std::os::windows::io::FromRawHandle; use windows_sys::Win32::System::Pipes::CreatePipe; let mut read: HANDLE = INVALID_HANDLE_VALUE; let mut write: HANDLE = INVALID_HANDLE_VALUE; // SAFETY: 两个 out 参数都指向本函数栈上的有效 HANDLE 变量; // 安全属性传 null 表示句柄不可继承(我们不需要子进程继承管道本身)。 let ok = unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) }; if ok == 0 { return Err(format!( "CreatePipe 失败: {}", std::io::Error::last_os_error() )); } // SAFETY: CreatePipe 成功返回后,read/write 都是有效的、由我们独占的句柄。 unsafe { Ok(( std::fs::File::from_raw_handle(read as *mut _), std::fs::File::from_raw_handle(write as *mut _), )) } } /// 构造 UTF-16 环境块(`KEY=VALUE\0...\0\0`)。 /// /// 从 `std::env::vars()` 出发做增量修改,而不是从空环境开始:Windows 上进程 /// 需要 `SystemRoot` / `PATH` / `USERPROFILE` 等继承变量才能正常工作。 /// 值为空串表示**删除**该变量(前端用「清空值」表达删除意图,比另设开关直观)。 fn build_env_block(overrides: &[(String, String)]) -> Result, String> { let mut map: std::collections::BTreeMap = std::env::vars().collect(); // 未设置会影响 shell 提示符与编码;显式补齐(用户 override 可覆盖) map.entry("TERM".to_string()).or_insert_with(|| "xterm-256color".to_string()); for (k, v) in overrides { if v.is_empty() { map.remove(k); } else { map.insert(k.clone(), v.clone()); } } let mut block = Vec::with_capacity(4096); for (k, v) in map { // 环境块不允许 key 含 '='(Windows 用它分隔键值) if k.contains('=') || k.is_empty() { continue; } block.extend(format!("{k}={v}").encode_utf16()); block.push(0); } block.push(0); // 双 null 结尾 Ok(block) } /// 输出读线程:读 → 聚合 → 发批次事件。 /// /// 三种结束条件(都发 `terminal-exit`,但来源不同): /// 1. `ReadFile` 返回 0(EOF)—— 正常结束 /// 2. `ERROR_BROKEN_PIPE` / `ERROR_OPERATION_ABORTED` —— PTY 被关闭(kill 路径) /// 3. 其他 IO 错误 —— 异常,上报 error fn spawn_reader( app: AppHandle, state: Arc, inner: Arc, mut reader: std::fs::File, ) { std::thread::spawn(move || { let mut buf = vec![0u8; READ_BUF_SIZE]; let mut pending: Vec = Vec::with_capacity(READ_BUF_SIZE); let mut last_flush = Instant::now(); // OSC 序列扫描的拼接缓冲:序列可能被切在两批数据之间(见 shell::parse_control_sequences) let mut osc_tail: Vec = Vec::new(); // 命令历史累积器(见 session::CommandAccumulator)。 // 由本读线程独占持有 —— 只在读线程里被访问,不需要共享。 let sim = crate::terminal::session::CommandAccumulator::new(); // 上一批发送的序号,用于退出时把 batch 序号一并回传(前端据此判断有无丢包) let mut last_seq: u64 = 0; loop { match reader.read(&mut buf) { Ok(0) => break, // EOF:坑 3 的正解,子进程退出后管道仍可能有残余数据 Ok(n) => { pending.extend_from_slice(&buf[..n]); // 首帧到达:应用之前排队的尺寸(坑 2) if !inner.first_output_seen.swap(true, Ordering::SeqCst) { if let Some((cols, rows)) = inner .pending_size .lock() .unwrap_or_else(|e| e.into_inner()) .take() { apply_resize(&inner, cols, rows); } } // cwd / 标题 / 命令边界跟踪:在**原始字节**上解析,且解析结果不从前端输出里剔除。 // // 为什么保留 OSC 7 原文发给前端:xterm 会自行忽略它,而保留原文让 // 「会话输出日志」可以原样重放(P2 的审计功能)。剔除反而会引入 // 一份「两份流不一致」的隐患。 crate::terminal::session::scan_control_sequences( &app, &state, &mut osc_tail, &buf[..n], &sim, ); // 聚合窗口到了就发一批 if last_flush.elapsed() >= AGGREGATE_WINDOW { last_seq = flush_output(&app, &state, &mut pending).unwrap_or(last_seq); last_flush = Instant::now(); } else if pending.len() > MAX_PENDING_BYTES { // 前端卡住导致积压:丢弃最旧的一半,保留最新输出 let drop_len = pending.len() - MAX_PENDING_BYTES / 2; pending.drain(..drop_len); crate::logger::log_warn( "terminal", &format!( "会话 {} 输出积压超限,已丢弃 {} 字节最旧数据", state.id, drop_len ), ); } } Err(e) => { // BROKEN_PIPE / OPERATION_ABORTED 是 kill 路径的正常表现,不算错误 if e.kind() != ErrorKind::BrokenPipe && e.raw_os_error() != Some(995) { crate::logger::log_error( "terminal", &format!("会话 {} 读取失败: {e}", state.id), ); } break; } } // 有未发出的剩余数据时,退化为「尽快发出」: // 交互式场景下提示符必须立刻可见,不能等满一个窗口 if !pending.is_empty() && last_flush.elapsed() >= AGGREGATE_WINDOW { last_seq = flush_output(&app, &state, &mut pending).unwrap_or(last_seq); last_flush = Instant::now(); } } // 读线程结束前把残留数据全部发出(否则最后一行提示符会丢) if let Some(s) = flush_output(&app, &state, &mut pending) { last_seq = s; } // 读线程结束即代表 PTY 侧已无更多数据,此时可以安全关闭 PTY(坑 1 的正解) close_pty_background(&inner); // 更新状态并发状态事件:若已被 waiter 置为 Closed 则保持 Closed 不变 let final_state = { let mut st = state.state.lock().unwrap_or_else(|e| e.into_inner()); if *st != SessionState::Closed && *st != SessionState::Failed { *st = SessionState::Closed; } *st }; let _ = last_seq; crate::terminal::emit_state(&app, &state, final_state, None); let _ = app.emit( crate::terminal::events::TERMINAL_EXIT, crate::terminal::events::ExitPayload { session_id: state.id.clone(), exit_code: *state.exit_code.lock().unwrap_or_else(|e| e.into_inner()), reason: Some("eof".to_string()), }, ); }); } // OSC 7(cwd)/ OSC 0,2(标题)/ OSC 133(命令边界)的扫描与落库 // 全部委托给 `session::scan_control_sequences`。 // // 这里此前有一份与本文件同源的实现,SSH 后端另有一份几乎相同的拷贝。 // P1 加命令历史时把两份合一了 —— 否则「OSC 133 解析 + 落库」要在两处各写一遍, // 任何一处漏掉都表现为「只有本地会话有历史」这类按后端分支的诡异 bug。 // 具体理由与实现见 `session.rs` 中该函数的长注释。 /// 退出等待线程:等子进程结束 → 拿退出码 → 等读线程收尾。 fn spawn_waiter( app: AppHandle, state: Arc, inner: Arc, _seq: Arc, ) { std::thread::spawn(move || { // 取出进程句柄(不 take,kill 也要用) let handle = { let g = inner.process.lock().unwrap_or_else(|e| e.into_inner()); g.as_ref().map(|h| h.as_raw_handle() as HANDLE) }; let Some(h) = handle else { return }; // SAFETY: h 是本会话持有的有效进程句柄;无限等待直到进程退出。 let _ = unsafe { WaitForSingleObject(h, u32::MAX) }; let mut code: u32 = 0; // SAFETY: h 有效且进程已退出,GetExitCodeProcess 会写入 code。 let ok = unsafe { GetExitCodeProcess(h, &mut code) }; if ok != 0 { *state.exit_code.lock().unwrap_or_else(|e| e.into_inner()) = Some(code as i32); } // 注意:这里**不**立刻置 Closed。进程退出后管道里可能还有尾部输出, // 要等读线程把残余数据发完(坑 3)。读线程结束时会把状态置为 Closed。 // 但若进程是被 kill 且读线程已退出,这里的 emit 就成了唯一通知。 let already_closed = { let st = state.state.lock().unwrap_or_else(|e| e.into_inner()); *st == SessionState::Closed }; if !already_closed { // 给读线程一点时间收尾(正常会在 8ms 内完成) std::thread::sleep(Duration::from_millis(50)); let st_closed = { let st = state.state.lock().unwrap_or_else(|e| e.into_inner()); *st == SessionState::Closed }; if !st_closed { *state.state.lock().unwrap_or_else(|e| e.into_inner()) = SessionState::Closed; close_pty_background(&inner); let _ = app.emit( crate::terminal::events::TERMINAL_EXIT, crate::terminal::events::ExitPayload { session_id: state.id.clone(), exit_code: *state.exit_code.lock().unwrap_or_else(|e| e.into_inner()), reason: Some("process-exit".to_string()), }, ); } } }); } /// 把聚合缓冲发成一批事件。返回本次批次序号(缓冲为空时返回 `None`)。 fn flush_output(app: &AppHandle, state: &LocalSessionState, pending: &mut Vec) -> Option { if pending.is_empty() { return None; } // 会话日志(audit)在 clear 之前写:保证日志与前端所见完全一致 crate::terminal::audit::write(&state.id, pending); use base64::Engine; let data = base64::engine::general_purpose::STANDARD.encode(&pending); pending.clear(); let seq = SEQ.fetch_add(1, Ordering::Relaxed) + 1; let _ = app.emit( crate::terminal::events::TERMINAL_OUTPUT, crate::terminal::events::OutputPayload { session_id: state.id.clone(), data, seq, }, ); Some(seq) } /// 全局输出批次序号。 /// /// 用全局计数器而非每会话计数器:前端校验连续性只需一个单调序列, /// 且跨会话的绝对顺序在排查问题时更有价值(能看出「哪个会话先输出」)。 static SEQ: AtomicU64 = AtomicU64::new(0); /// 取下一个全局输出批次序号。 /// /// 对 SSH 后端开放(见 `ssh::flush_output`):两种后端共用同一序列, /// 前端只需一套连续性校验逻辑。 pub fn next_global_seq() -> u64 { SEQ.fetch_add(1, Ordering::Relaxed) + 1 } /// 应用尺寸变更(真正调用 ResizePseudoConsole)。 fn apply_resize(inner: &PtyInner, cols: u16, rows: u16) { let g = inner.hpc.lock().unwrap_or_else(|e| e.into_inner()); let Some(hpc) = *g else { return }; let size = COORD { X: clamp_dim(cols) as i16, Y: clamp_dim(rows) as i16, }; // SAFETY: hpc 是有效的伪控制台句柄(未关闭),size 已钳制到 i16 范围。 let hr = unsafe { ResizePseudoConsole(hpc, size) }; if hr < 0 { crate::logger::log_warn("terminal", &format!("ResizePseudoConsole 失败(HRESULT 0x{hr:08X})")); } } /// 把维度钳制到 COORD 的 i16 正数范围。 /// /// 为什么需要:`cols`/`rows` 来自前端 xterm 的测量结果,极端布局(超宽显示器 + /// 极窄侧栏)下可能算出 0 或超出 32767,直接转 i16 会得到负数,ConPTY 会拒绝或 /// 产生诡异绘制。这里统一兜底到合理区间。 fn clamp_dim(v: u16) -> u16 { v.clamp(1, 1000) } /// 在后台线程关闭 PTY(坑 1:`ClosePseudoConsole` 可能阻塞)。 /// /// 调用方不等待。先置标志位保证幂等——读线程与退出等待线程都可能走到这里。 fn close_pty_background(inner: &Arc) { if inner.closed.swap(true, Ordering::SeqCst) { return; } let inner = inner.clone(); std::thread::spawn(move || { // 先释放写端:否则 PTY 侧仍认为有输入来源,其内部缓冲不会排空 { let mut w = inner.writer.lock().unwrap_or_else(|e| e.into_inner()); *w = None; } let hpc = { let mut g = inner.hpc.lock().unwrap_or_else(|e| e.into_inner()); g.take() }; if let Some(hpc) = hpc { // SAFETY: hpc 由本会话创建且尚未关闭(take 保证了唯一性)。 // 这个调用可能阻塞到所有句柄关闭,因此放在独立线程。 unsafe { ClosePseudoConsole(hpc) }; } }); } // ===== Session trait 实现 ===== impl Session for ConPtySession { fn id(&self) -> &str { &self.state.id } fn kind(&self) -> SessionKind { SessionKind::Local } fn write(&self, data: &[u8]) -> Result<(), String> { let mut g = self.inner.writer.lock().unwrap_or_else(|e| e.into_inner()); let Some(w) = g.as_mut() else { return Err("会话已关闭,无法写入".to_string()); }; w.write_all(data).map_err(|e| format!("写入失败: {e}"))?; w.flush().map_err(|e| format!("刷新失败: {e}")) } fn resize(&self, cols: u16, rows: u16) -> Result<(), String> { *self.state.size.lock().unwrap_or_else(|e| e.into_inner()) = (cols, rows); // 坑 2:首帧之前只入队。ConPTY 在进程尚未开始读 stdout 时对 resize // 的处理不可靠(尺寸可能被吞掉),表现为 vim/htop 按 80×24 绘制而花屏。 if !self.inner.first_output_seen.load(Ordering::SeqCst) { *self .inner .pending_size .lock() .unwrap_or_else(|e| e.into_inner()) = Some((cols, rows)); return Ok(()); } apply_resize(&self.inner, cols, rows); Ok(()) } fn kill(&self) -> Result<(), String> { // 幂等:重复 kill 不报错 if self.inner.closed.load(Ordering::SeqCst) { return Ok(()); } // 1. 先终止进程(若有) if let Some(h) = self .inner .process .lock() .unwrap_or_else(|e| e.into_inner()) .as_ref() .map(|h| h.as_raw_handle() as HANDLE) { // SAFETY: h 是本会话持有的有效进程句柄。 // 退出码 1 表示「被终止」,与正常退出 0 区分开,便于前端展示。 unsafe { TerminateProcess(h, 1) }; } // 2. 关 PTY(后台线程,不阻塞调用方) close_pty_background(&self.inner); // 3. 状态置为 Closed 并发事件 *self.state.state.lock().unwrap_or_else(|e| e.into_inner()) = SessionState::Closed; let _ = self.app.emit( crate::terminal::events::TERMINAL_EXIT, crate::terminal::events::ExitPayload { session_id: self.state.id.clone(), exit_code: Some(1), reason: Some("killed".to_string()), }, ); let _ = EXIT_HINT; // 提示文本由前端拼接,此处保留常量以备审计日志使用 Ok(()) } fn info(&self) -> SessionInfo { local_info(&self.state, SessionKind::Local) } fn set_state(&self, state: SessionState, error: Option) { *self.state.state.lock().unwrap_or_else(|e| e.into_inner()) = state; if let Some(e) = error { *self.state.error.lock().unwrap_or_else(|e| e.into_inner()) = Some(e); } let _ = self .app .emit(crate::terminal::events::TERMINAL_STATE, self.info()); } fn set_title(&self, title: &str) { *self.state.title.lock().unwrap_or_else(|e| e.into_inner()) = title.to_string(); } fn set_detached(&self, detached: bool) { self.state.detached.store(detached, Ordering::Relaxed); } /// 本地会话的编码切换语义与 SSH 不同:**只影响输出的字节→文本解释**, /// 不影响输入(Windows 控制台走 UTF-16 转换,`WriteFile` 收到的一直是 /// UTF-8,前端不必按目标代码页重编码)。因此这里不做输入侧处理。 fn set_encoding(&self, encoding: &str) -> bool { self.state.set_encoding(encoding) } fn emit_state(&self) { let _ = self .app .emit(crate::terminal::events::TERMINAL_STATE, self.info()); } } // ===== windows-sys 中未随 feature 导出的 API 声明 ===== // // `InitializeProcThreadAttributeList` / `UpdateProcThreadAttribute` / // `DeleteProcThreadAttributeList` 属于 `Win32_System_Threading`,但 windows-sys // 0.52 未把它们纳入已启用的 feature 面。用 extern "system" 直接声明, // 避免为了三个函数额外开启一个大 feature(会显著增加编译时间)。 unsafe extern "system" { fn InitializeProcThreadAttributeList( lp_attribute_list: *mut std::ffi::c_void, dw_attribute_count: u32, dw_flags: u32, lp_size: *mut usize, ) -> i32; fn UpdateProcThreadAttribute( lp_attribute_list: *mut std::ffi::c_void, dw_flags: u32, attribute: usize, lp_value: *const std::ffi::c_void, cb_size: usize, lp_previous_value: *mut std::ffi::c_void, lp_return_size: *mut usize, ) -> i32; fn DeleteProcThreadAttributeList(lp_attribute_list: *mut std::ffi::c_void); }