xterm 6 升级与 prompt reflow patch
先说结论
xterm 6 解决生命周期竞态:终端刚执行 fit() 就被 React 卸载时,旧版残留的 viewport 定时器会访问已经销毁的 dimensions。prompt reflow patch 解决视觉正确性:powerlevel10k 的多行 prompt 在窗口变窄时会留下幽灵行。
二者并不互相替代。最终方案是升级到 6.0.0,同时保留并收紧 patch;E2E 的未打补丁对照组证明,升级本身没有消除 prompt 叠行。
| 问题 | 触发 | 负责修复的层 | 结果 |
|---|---|---|---|
| disposed dimensions crash | fit() 后快速离开 Workbench | xterm 6 上游生命周期修复 | 5 轮 remount,0 page error |
| prompt ghost rows | powerlevel10k prompt 随窗口变窄 | 安装期 reflow patch | 1500 → 650 px 始终 1 行 |
旅程 A:崩溃是怎样消失的
1. 旧版的竞态窗口
TerminalPanel mount,创建 Terminal 与 FitAddonfitAddon.fit(),xterm 安排 viewport 后续工作terminal.dispose()应用层已有标准清理顺序:取消 observer / animation frame、释放 addon、最后释放 terminal。本次没有绕开或延迟 dispose;真正变化发生在依赖层。
// TerminalPanel.tsx — 生命周期逻辑没有改写
return () => {
disposed = true;
observer.disconnect();
cancelAnimationFrame(fitFrame);
for (const addon of addons.reverse()) addon.dispose();
terminal.dispose();
};
2. 升级承担的职责
xterm 6 在 dispose 时清理 viewport 定时器,从根上关闭“对象已销毁、延迟回调仍在跑”的窗口。因此 PR 只把 TerminalPanel 里的历史注释改成当前事实,没有额外制造应用层 workaround。
| 位置 | 旧版本 | 新版本 |
|---|---|---|
| desktop · xterm | 5.5.0 | 6.0.0 |
| desktop · addon-fit | 0.10.0 | 0.11.0 |
| desktop · addon-webgl | 0.19.0 | 0.19.0(不变) |
| daemon · xterm-headless | 5.5.0 | 6.0.0 |
| daemon · addon-serialize | 0.13.0 | 0.14.0 |
用户看到的是 browser xterm,后台生成 terminal snapshot 的是 headless xterm。两端共享屏幕语义;只升一边会让实时终端和恢复后的快照落在不同版本行为上。
旅程 B:prompt reflow patch 在做什么
1. 它不是运行时 monkey patch
根目录 postinstall 会运行 scripts/patch-xterm-prompt-reflow.mjs。脚本直接改写安装后的 xterm bundle,所以应用启动时拿到的已经是修正过的实现。
"postinstall": "bun run install:electron && node scripts/fix-node-pty-spawn-helper.mjs && node scripts/patch-xterm-prompt-reflow.mjs"
bun install --frozen-lockfile 安装锁定依赖2. 真正改变行为的只有一个边界
// upstream
const e = this.ybase + this.y;
if (e >= l && e < l + c.length) continue;
// patched
const e = this.ybase + this.y;
if (e >= l && e - 2 < l + c.length) continue;
| 符号 | 在 reflow 里的意义 |
|---|---|
e | 当前 cursor 的绝对 buffer 行:ybase + y |
l | 正在判断的 wrapped line group 起点 |
c.length | 该 wrapped group 占据的行数 |
e - 2 | 把 cursor 上方两行也纳入“正在被 shell 重画”的保护区 |
原逻辑只跳过“真正包含 cursor”的 wrapped group。powerlevel10k 的 prompt 往往由 cursor 上方几行和当前输入行共同组成;SIGWINCH 后 shell 会重绘这块区域。如果 xterm 先把上方 prompt 当普通历史重新换行,shell 又画一遍,就出现残留幽灵行。
不要 reflow cursor 附近、即将由 shell 自己重画的 prompt 区域;更早的 scrollback 仍按正常规则 reflow。数字 2 是当前 powerlevel10k 布局对应的窄保护边界,不是对所有历史行禁用换行。
3. 为什么必须改四个 bundle
| 包 / bundle | 消费者 | 承担的职责 |
|---|---|---|
@xterm/xterm/lib/xterm.mjs | Vite / Electron renderer | 用户正在看的 live terminal |
@xterm/xterm/lib/xterm.js | CommonJS browser consumer | 保持包的双模块入口一致 |
@xterm/headless/lib-headless/xterm-headless.mjs | ESM headless consumer | 保持 headless 双模块入口一致 |
@xterm/headless/lib-headless/xterm-headless.js | daemon / Node | 生成与恢复 terminal snapshot |
Live 路径
PTY 输出 → renderer xterm → FitAddon resize → 用户可见屏幕
Snapshot 路径
scrollback 数据 → headless xterm → SerializeAddon → 恢复快照
4. patch 如何避免悄悄腐烂
for (const target of targets) {
if (manifest.version !== target.version) {
process.exit(1);
}
if (source.includes(target.guarded)) continue;
if (!source.includes(target.unguarded)) process.exit(1);
writeFileSync(bundlePath, source.replace(target.unguarded, target.guarded));
}
- 版本锁定:只允许目标包为 6.0.0。
- 签名锁定:必须找到精确的 minified 原表达式,避免误改相似逻辑。
- 幂等:已经存在 guarded 表达式时直接跳过,重复安装不会二次修改。
- fail closed:版本、文件或签名漂移时退出 1,让依赖升级显式失败。
实验如何改变最终方案
一开始最自然的假设是:“上游升级后,也许旧 patch 可以删掉。”所以 E2E 先构造了不打 patch 的候选版本,再用真实 powerlevel10k prompt 做对照。
| 窗口宽度 | xterm 6 未打 patch | xterm 6 + patch |
|---|---|---|
| 1500 px | 1 行 | 1 行 |
| 1100 px | 2 行 | 1 行 |
| 850 px | 3 行 | 1 行 |
| 650 px | 4 行 | 1 行 |
未打 patch 的 xterm 6 仍会随着窗口变窄累积 prompt 路径行。由此可知:上游修的是 dispose timer,并没有覆盖 Buffin 遇到的 prompt reflow 行为。
所以最终提交不是机械沿用旧脚本:它把目标版本和四份 6.0 bundle 的新签名重新锁定,并让脚本在依赖内容漂移时主动阻断安装。
整体心智模型
资源生命周期、定时器清理、dispose 后不再触碰 dimensions。
Buffin + powerlevel10k resize 场景下,cursor 上方 prompt 区不参与历史 reflow。
术语与角色
| 概念 | 职责 | 本 PR 的关系 |
|---|---|---|
| xterm | 浏览器终端模型与渲染 | 5.5 → 6.0,修生命周期崩溃 |
| FitAddon | 把容器像素尺寸换算成 rows / cols | 触发 resize 与 reflow 的入口 |
| reflow | 列数变化时重新组织 wrapped lines | patch 改变 cursor 附近的跳过条件 |
| powerlevel10k | 会自行重画复杂多行 prompt 的 shell theme | 与 xterm 的历史 reflow 重叠时产生幽灵行 |
| headless xterm | 无 DOM 的 terminal buffer 实现 | daemon snapshot 必须与 live 行为一致 |
| postinstall patch | 安装完成后精确改写依赖 bundle | 版本锁定、幂等、签名漂移即失败 |
验证结果与薄冰区
bun install --frozen-lockfile通过,说明 lockfile 与 postinstall patch 可复现。bun run check与完整 verify 通过,包含生产构建。- snapshot、scrollback、alternate screen 路径均通过。
- 真实 prompt resize 对照证明 patch 仍然必要。
- 本 PR 没有新增测试文件;patch 的直接证明来自安装验证和 E2E 对照实验。
- minified signature 天生跟版本绑定。未来升级 xterm 时,安装失败是预期的升级提醒,不应直接放宽匹配。
- Electron CDP 环境无法创建 browser context,因此本轮保留了截图证据,没有录制视频;不影响页面错误与 prompt 行数断言。
另外,WebGL addon dispose 外层已有的 try/catch 保持不变;本次没有把相邻历史防御代码顺手重构进来。
验收清单
- 升级完整:desktop 与 daemon 的 xterm 生态版本成套推进。
- 崩溃闭环:快速 remount 不再产生 disposed dimensions page error。
- 视觉闭环:powerlevel10k prompt 在 1500 / 1100 / 850 / 650 px 均保持 1 行。
- 数据一致:live terminal、scrollback、alt-screen、snapshot 均通过。
- 安装可控:patch 可重复执行,依赖签名漂移时 fail closed。
- 回归验证:320 个测试文件、3231 个测试和生产构建通过。
覆盖范围
| 文件 | 本次角色 | 走读覆盖 |
|---|---|---|
apps/desktop/package.json | browser xterm / fit 版本 | 完整 |
apps/daemon/package.json | headless / serialize 版本 | 完整 |
TerminalPanel.tsx | 更新生命周期注释,运行逻辑不变 | diff + 相关 lifecycle 全文 |
scripts/patch-xterm-prompt-reflow.mjs | 6.0 bundle patch 与防漂移 | 完整 |
bun.lock | 锁定新依赖解析结果 | 完整 diff |
这是变更理解与数据旅程走读,不是 Code Review。所有 5 个 changed files、141 行 diff 都已阅读,并结合 terminal snapshot 实现、相关测试和两轮 E2E 报告还原运行逻辑。