xterm 6 升级与 prompt reflow patch

PR #161 · 从 resize / dispose 崩溃,到 powerlevel10k prompt 幽灵行的完整数据旅程
Merged 5 files · +83 / −58 fde985d

先说结论

这不是“升级后删掉旧 patch”的故事,而是两个问题各自归位

xterm 6 解决生命周期竞态:终端刚执行 fit() 就被 React 卸载时,旧版残留的 viewport 定时器会访问已经销毁的 dimensions。prompt reflow patch 解决视觉正确性:powerlevel10k 的多行 prompt 在窗口变窄时会留下幽灵行。

二者并不互相替代。最终方案是升级到 6.0.0,同时保留并收紧 patch;E2E 的未打补丁对照组证明,升级本身没有消除 prompt 叠行。

问题触发负责修复的层结果
disposed dimensions crashfit() 后快速离开 Workbenchxterm 6 上游生命周期修复5 轮 remount,0 page error
prompt ghost rowspowerlevel10k prompt 随窗口变窄安装期 reflow patch1500 → 650 px 始终 1 行

旅程 A:崩溃是怎样消失的

1. 旧版的竞态窗口

1TerminalPanel mount,创建 Terminal 与 FitAddon
2布局 effect 调用 fitAddon.fit(),xterm 安排 viewport 后续工作
3用户快速切换 Board / Workbench,React cleanup 调用 terminal.dispose()
4xterm 5.5 残留定时器继续执行,读取已销毁的 dimensions → page error

应用层已有标准清理顺序:取消 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 · xterm5.5.06.0.0
desktop · addon-fit0.10.00.11.0
desktop · addon-webgl0.19.00.19.0(不变)
daemon · xterm-headless5.5.06.0.0
daemon · addon-serialize0.13.00.14.0
为什么 desktop 与 daemon 一起升

用户看到的是 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"
1bun install --frozen-lockfile 安装锁定依赖
2脚本确认 package 版本恰好是 6.0.0
3在四个目标 bundle 中寻找已知表达式
4替换为 guarded 表达式;未知内容直接让安装失败

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.mjsVite / Electron renderer用户正在看的 live terminal
@xterm/xterm/lib/xterm.jsCommonJS browser consumer保持包的双模块入口一致
@xterm/headless/lib-headless/xterm-headless.mjsESM headless consumer保持 headless 双模块入口一致
@xterm/headless/lib-headless/xterm-headless.jsdaemon / 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 未打 patchxterm 6 + patch
1500 px1 行1 行
1100 px2 行1 行
850 px3 行1 行
650 px4 行1 行
关键证伪

未打 patch 的 xterm 6 仍会随着窗口变窄累积 prompt 路径行。由此可知:上游修的是 dispose timer,并没有覆盖 Buffin 遇到的 prompt reflow 行为。

所以最终提交不是机械沿用旧脚本:它把目标版本和四份 6.0 bundle 的新签名重新锁定,并让脚本在依赖内容漂移时主动阻断安装。

整体心智模型

1依赖安装:lockfile 固定 xterm 6 生态版本
2安装后校正:patch 修改 browser 与 headless 的 reflow guard
3运行态 resize:FitAddon 改列数,xterm 只 reflow 安全的历史区
4shell redraw:powerlevel10k 重画 cursor 附近 prompt,不再与 xterm 重排重叠
5快速卸载:xterm 6 dispose 清掉 viewport timer,避免延迟访问销毁对象
6恢复路径:daemon 用同版本 headless xterm 生成一致的 snapshot
上游升级负责

资源生命周期、定时器清理、dispose 后不再触碰 dimensions。

本地 patch 负责

Buffin + powerlevel10k resize 场景下,cursor 上方 prompt 区不参与历史 reflow。

术语与角色

概念职责本 PR 的关系
xterm浏览器终端模型与渲染5.5 → 6.0,修生命周期崩溃
FitAddon把容器像素尺寸换算成 rows / cols触发 resize 与 reflow 的入口
reflow列数变化时重新组织 wrapped linespatch 改变 cursor 附近的跳过条件
powerlevel10k会自行重画复杂多行 prompt 的 shell theme与 xterm 的历史 reflow 重叠时产生幽灵行
headless xterm无 DOM 的 terminal buffer 实现daemon snapshot 必须与 live 行为一致
postinstall patch安装完成后精确改写依赖 bundle版本锁定、幂等、签名漂移即失败

验证结果与薄冰区

320
test files passed
3231
tests passed
Board ↔ Workbench remount
0
page errors
  • 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.jsonbrowser xterm / fit 版本完整
apps/daemon/package.jsonheadless / serialize 版本完整
TerminalPanel.tsx更新生命周期注释,运行逻辑不变diff + 相关 lifecycle 全文
scripts/patch-xterm-prompt-reflow.mjs6.0 bundle patch 与防漂移完整
bun.lock锁定新依赖解析结果完整 diff
报告边界

这是变更理解与数据旅程走读,不是 Code Review。所有 5 个 changed files、141 行 diff 都已阅读,并结合 terminal snapshot 实现、相关测试和两轮 E2E 报告还原运行逻辑。