跳到内容
Yinjie 的博客
返回

ConPTY 踩坑实录:在桌面应用里内嵌一个真终端

编辑此页

上一篇《给 Claude Code 做了个原生会话管理器:ReSession》里,ConPTY 踩坑只是一小节。写的时候就知道这块值得单独展开——中文互联网上系统讲 ConPTY 实战坑的文章几乎空白,而这几个坑每一个都足够让人调试到怀疑人生。

先交代背景:ReSession 要在桌面应用里内嵌一个真终端,跑原生的 claude --resume,TUI 完整保留。技术栈是 Tauri 2 + xterm.js + portable-pty(WezTerm 同款 PTY 抽象,Windows 下走 ConPTY)。

ConPTY 是 Windows 的伪终端基础设施:应用侧拿到一对读写端,中间由 Conhost 代理,子进程以为自己连着一个真终端。Windows Terminal、VS Code 内置终端用的都是这套。理论很美好,实际接起来,坑全在细节里。

坑一:TUI 黑白无色

症状:claude 的 TUI 能跑,但灰蒙蒙一片,代码高亮、主题色全部失效。

根因:Node/chalk 这类库靠探测环境判断终端能力。在 ConPTY 下,这套自动探测不可靠——它可能认为自己连着一个不具备真彩能力的哑终端,于是主动降级。

解法:不猜了,直接把答案喂给它。spawn 时注入三个环境变量:

// 颜色三件套:TERM/COLORTERM 是常规声明;FORCE_COLOR=3 强制 Node/chalk
// 走真彩,绕过其在 ConPTY 下不可靠的终端能力自动探测
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("FORCE_COLOR", "3");

前两个是标准声明,第三个 FORCE_COLOR 是 chalk 系的约定,数字表示级别(3 = 真彩)。三件套下去,颜色立刻正常。

坑二:恢复的会话静默不保存

症状:一切功能正常——终端有颜色、会话能恢复、交互无异常。直到某天发现:从 ReSession 里恢复的会话,没有写进 ~/.claude/projects/ 的 jsonl。关掉就没了。

根因:环境变量是隐形协议。如果你是从 Claude Code 的会话里启动 ReSession(很常见的用法),宿主进程环境里带着 CLAUDE* 系列变量;PTY spawn 的子进程会全盘继承,claude 一看这些标记,认定自己是 child session,静默关闭转录保存。全程无报错、无提示,数据无声丢失。

解法:spawn 前 env_clear + 白名单重灌:

cmd.env_clear();
for (k, v) in std::env::vars_os() {
    let upper = k.to_string_lossy().to_uppercase();
    // CLAUDE*:child-session 标记会关闭转录保存;
    // CI / NO_COLOR:两者都会压掉 TUI 颜色
    if upper.starts_with("CLAUDE") || upper == "CI" || upper == "NO_COLOR" {
        continue;
    }
    cmd.env(&name, v);
}

CINO_COLOR 是顺带清理的——前者会让部分工具切换到非交互模式,后者直接压掉颜色,都不是终端环境该有的。

这个坑的可怕之处在于没有任何失败信号。教训:宿主传给子进程的每一个环境变量都是一种”表态”,你对继承了什么必须有数。

坑三:中文跨块乱码

症状:终端输出里,中文偶尔变成乱码,概率性出现。

根因:PTY 输出是按块到达的字节流,UTF-8 的一个汉字占 3 个字节,完全可能被切在两块的边界上。如果在应用侧逐块做 String::from_utf8_lossy 之类的解码,跨界字符必被腰斩。

解法字节流的边界不属于你,应用侧别碰字符编码。输出全程按原始字节传输——Rust 侧 Vec<u8>,前端 Uint8Array,直接 xterm.write()

// 读线程:pty 原始字节 → 前端事件 + 回放缓冲。不解析、不修改内容。
let mut buf = [0u8; 8192];
loop {
    match reader.read(&mut buf) {
        Ok(0) | Err(_) => break,
        Ok(n) => {
            // ... 追加进回放缓冲 ...
            app.emit("pty-out", PtyEvent { id, data: buf[..n].to_vec() })?;
        }
    }
}

xterm.js 自带 UTF-8 状态机,让它自己拼。解法本身很朴素,难的是意识到”在这里做字符串转换”本身就是错误设计。

坑四:slave 句柄不放,读端行为异常

症状:偶发的、说不清的读取异常。

根因:portable-pty 的 openpty 返回 master/slave 一对句柄。Rust 的所有权模型在这里帮了倒忙——只要父进程还持有 slave,读端的行为就可能不对。

解法:一行:

// 父进程若持有 slave 会导致读端行为异常,必须立刻释放
drop(pair.slave);

spawn 完立刻 drop。这类坑没什么技术含量,但没人告诉你的话,能查一下午。

坑五:并行会话的生命周期

严格说这是设计题而不是 bug:多会话并行时,PTY 放哪?

ReSession 的选择:PTY 以会话 id 为键常驻 Rust 后端,和前端 UI 生命周期彻底解耦。切换会话只是”断开观看”——切回时用 512KB 滚动缓冲做快照回放,输出一个不丢;后端还在忙闲状态里挂个标记,侧栏绿点提示。附带收益是天然支持几十个会话同时挂着跑。

另外一个已知问题:ConPTY 的 resize 会触发整屏重绘,肉眼可见闪烁。这是 ConPTY 机制使然,VS Code 内置终端同款问题,选择接受,不做对抗。

心法三条

五个坑复盘下来,其实就三句话:

  1. 环境变量是隐形协议。 宿主传给子进程的每个变量都在替你做决定,env_clear + 白名单是内嵌终端的卫生底线。
  2. 字节流的边界不属于你。 不在流上做任何字符级假设,编码交给终端渲染层。
  3. 别替子进程做决定。 把它本来就认识的信号(TERMFORCE_COLOR)原样喂给它,比替它探测、替它降级可靠得多。

参考


编辑此页
分享这篇文章:

上一篇
给 Claude Code 做了个原生会话管理器:ReSession