终端模块初版

This commit is contained in:
zhongluofeng
2026-09-18 18:28:13 +08:00
parent f6c1cc250e
commit b018abd922
63 changed files with 26695 additions and 185 deletions
+746
View File
@@ -0,0 +1,746 @@
//! 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<HPCON>>,
/// 写入端(我们 → 伪控制台输入)。`Option` 以便 kill 后释放。
writer: Mutex<Option<std::fs::File>>,
/// 子进程句柄,用于退出等待与强制终止。
process: Mutex<Option<OwnedHandle>>,
/// 是否已收到首帧输出(ConPTY resize 竞态的处理依据,见模块注释坑 2)。
first_output_seen: AtomicBool,
/// 首帧之前缓存的尺寸请求。
pending_size: Mutex<Option<(u16, u16)>>,
/// 是否已关闭(幂等保护)。
closed: AtomicBool,
}
/// 本地 ConPTY 会话。
pub struct ConPtySession {
pub state: Arc<LocalSessionState>,
inner: Arc<PtyInner>,
/// 事件发射器。持有 `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<String>,
env: Vec<(String, String)>,
cols: u16,
rows: u16,
) -> Result<Self, String> {
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::<STARTUPINFOEXW>() 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::<HPCON>(),
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<u16> = command_line.encode_utf16().chain(std::iter::once(0)).collect();
let cwd_wide: Option<Vec<u16>> = 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<AtomicU64> {
// 这里刻意返回一个独立计数器:`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<Vec<u16>, String> {
let mut map: std::collections::BTreeMap<String, String> = 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` 返回 0EOF)—— 正常结束
/// 2. `ERROR_BROKEN_PIPE` / `ERROR_OPERATION_ABORTED` —— PTY 被关闭(kill 路径)
/// 3. 其他 IO 错误 —— 异常,上报 error
fn spawn_reader(
app: AppHandle,
state: Arc<LocalSessionState>,
inner: Arc<PtyInner>,
mut reader: std::fs::File,
) {
std::thread::spawn(move || {
let mut buf = vec![0u8; READ_BUF_SIZE];
let mut pending: Vec<u8> = Vec::with_capacity(READ_BUF_SIZE);
let mut last_flush = Instant::now();
// OSC 序列扫描的拼接缓冲:序列可能被切在两批数据之间(见 shell::parse_control_sequences
let mut osc_tail: Vec<u8> = 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 7cwd/ OSC 0,2(标题)/ OSC 133(命令边界)的扫描与落库
// 全部委托给 `session::scan_control_sequences`。
//
// 这里此前有一份与本文件同源的实现,SSH 后端另有一份几乎相同的拷贝。
// P1 加命令历史时把两份合一了 —— 否则「OSC 133 解析 + 落库」要在两处各写一遍,
// 任何一处漏掉都表现为「只有本地会话有历史」这类按后端分支的诡异 bug。
// 具体理由与实现见 `session.rs` 中该函数的长注释。
/// 退出等待线程:等子进程结束 → 拿退出码 → 等读线程收尾。
fn spawn_waiter(
app: AppHandle,
state: Arc<LocalSessionState>,
inner: Arc<PtyInner>,
_seq: Arc<AtomicU64>,
) {
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<u8>) -> Option<u64> {
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<PtyInner>) {
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<String>) {
*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);
}