fix/terminal-snapshot-restore:把终端「回放」换成「快照恢复」

figuretu/eyrie · 0b46bcd5...d9289e3e · PR #127 · 2026-07-08 · 自包含,读完即弃

5 commits
19 文件
+1429 / −193
~57% 是测试代码
类型:修复 + 重构

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自本分支、经裁剪,青色斜体注释是解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。终端这块依赖 pty 底层知识,第 3 节先把 pty 讲清楚再进旅程。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1TL;DR

一个终端 tab 从后台切回前台、或被拖进分屏时,前端会挂一个全新的空白 xterm,需要把这个终端「之前的内容」重新灌进去。这个 PR 换掉了灌历史的整套机制。

以前:daemon 把录下来的原始字节,连同一串 resize 控制帧,原样回放给前端,让前端的 xterm 一段段重演。这条路有三个病:拖窄窗口时 prompt 会留一条幽灵行;重演过程中前端会中途改宽度,导致错位;而且重挂时前端根本没换新连接,复用了「历史已经放过一次」的旧 socket,于是直接空白

现在:daemon 在 attach 那一刻起一个临时的 headless xterm「影子」,自己把历史重演一遍、重排到前端声明的尺寸,用 SerializeAddon 序列化成一帧 snapshot 下发。前端一次性写进去,不再中途重排、不再有回放竞态。配套还给 xterm 打了一个字符的补丁修幽灵行。最后一个 commit 是对 Codex/Claude review 的响应(尺寸夹上限、构建期缓冲加背压、删一处死守卫)。

2变更地图(称重)

总量约 1,600 变更行,测试占了一多半——这类底层重构靠测试钉住竞态,测试重不代表实现轻。真正承载设计的非测试代码集中在 daemon 的快照构建、data-plane 协议,和前端面板/连接注册表。

apps/desktop
789 行 · 49%
apps/daemon
663 行 · 41%
packages/client
87 行 · 5%
scripts (xterm patch)
75 行 · 5%
设计重心(要细读)可放心略过
apps/daemon/.../terminal-snapshot.ts(新增,影子终端重建核心)
apps/daemon/.../terminal-ws.ts(回放→快照的协议改造 + 背压)
apps/desktop/.../TerminalPanel.tsx(删客户端回放/闩锁、声明尺寸)
apps/desktop/.../connection-registry.ts(viewer 重挂换 socket)
scripts/patch-xterm-prompt-reflow.mjs(幽灵行补丁)
bun.lock / package.json(新增 @xterm/headless@xterm/addon-serialize 两个依赖)
*.test.ts(x) 的样板搭建(fake pty、waitForSnapshot 之类的测试脚手架)
terminal-registry.ts 里只改了几行文档注释(replay→snapshot 措辞)

3pty 扫盲(读旅程前先建立词汇)

这个 PR 全程绕着 pty 的几个特性转。不熟悉 pty 的同事,这一节建立起下面旅程要用的心智模型;已经熟的可以跳到第 4 节。

3.1pty 是什么:一根「假装成硬件终端」的管子

pty(pseudo-terminal,伪终端)是内核提供的一对虚拟设备,一端叫 master、一端叫 slave。它存在的唯一目的,是骗过 shell:让 zsh 以为自己连着一台真的串口终端(像上世纪的 VT100),而实际上另一端是我们的程序。

把它想成一根两头的管子:

关键认知:这根管子里流的是字节流,不是文本。而且中间还隔着一层内核的「行规程」(line discipline)会动手脚——比如把 shell 输出里的 \n 自动转成 \r\n(回车+换行)。这就是为什么前端 xterm 特意convertEol:内核已经转过了,前端再转一次就会错位。

apps/desktop/.../TerminalPanel.tsx为什么不转行尾
const terminal = new Terminal({
  allowProposedApi: false,
  // 不开 convertEol:pty 的内核行规程(ONLCR)已经把 '\n' 变成 '\r\n' 了,
  // 原始流自己带行尾;再转一遍会掩盖对齐 bug。
  cursorBlink: true,
  scrollback: 5000,
})

3.2字节流里有什么:文字、颜色、光标、还有「换屏」

shell 输出的字节里,一部分是可见文字,另一部分是转义序列(escape sequence)——以 ESC [ 开头的控制指令,负责「把光标移到第 3 行第 5 列」「把这段染成绿色」「清屏」。真正把这团字节解释成一格一格的字符网格的,是终端模拟器(terminal emulator),也就是 xterm 做的事。记住三类在本 PR 里会反复出现的序列:

3.3谁是「模拟器」:本 PR 最重要的一句话

这是理解整个 PR 的钥匙:live 直播期间,daemon 端没有终端模拟器。

daemon 在 live 时只干两件事:把 master 读到的字节哑管道转发给前端 socket;同时把这些字节抄一份进一个固定大小的环形录音机(ring)存历史。它不解析这些字节、不知道「屏幕现在长什么样」——它只是字节的搬运工和录音师。唯一在解析、在维护网格的,是前端的 xterm。

那 daemon 怎么给一个刚挂上来、空白的前端「重建历史」?以前的答案是把录音倒带、原样播给前端让它自己重演(回放)。这个 PR 的答案是:daemon 临时起一个自己的模拟器——一个 headless(无界面)的 xterm「影子」,只在 attach 那一瞬间存在,把录音重演进这个影子、拍一张「成品照片」发给前端,然后立刻销毁。live 期间它依然没有模拟器。

一句话记住:daemon 平时是哑管道 + 录音机;只有在你「接上一个新前端」的那 10–120 毫秒,它才临时开一个影子模拟器拍张照,拍完即焚。你日常打字看到的输出,全程只有前端 xterm 一个模拟器在跑。

4架构:模拟器在哪一端

结构面的变化只有一处,但它是整个 PR 的支点:attach 时「重建历史」的活儿,从前端搬到了 daemon 的临时影子里。live 直播那条哑管道两版一模一样。

以前 · 前端自己重演历史

daemon
哑管道+录音
回放:一串 resize 帧 + 原始历史字节
前端 xterm
一段段重演
(无影子)
daemon 端不解析字节
ring 录音机

现在 · daemon 临时影子拍照

影子 headless xterm
attach 时临时起
序列化成一帧 snapshot
前端 xterm
一次写入
ring 录音机
重演进影子
影子

注意 before 图里,前端 xterm 是「热点」——它承担重演、每段 resize、边收边重排的全部复杂度;after 图里热点移到了 daemon 影子,前端退化成「一次写入」的被动接收方。复杂度没有消失,是从时序敏感的前端搬到了没有并发干扰的 daemon 同步构建里。

5数据与协议形状

进旅程前先认三样东西的形状:录音机存什么、影子产出什么、协议帧怎么变。只看形状不讲行为。

① 录音机存的是「分段的原始字节」

ring 不存「渲染好的屏幕」,存的是原始 pty 字节,按「当时的尺寸」分段。为什么存字节不存截图?因为只有原始字节里才保留了颜色、光标、清屏这些序列,重演时才能一比一还原。

apps/daemon/.../terminal-registry.ts录音的段结构
export interface ReplaySegment {
  cols: number   // 这一段字节是在多宽的终端下录的
  rows: number
  data: Buffer   // 这些尺寸在force时录下的原始 pty 字节
}
export type ReplayPayload = ReplaySegment[]   // 一次 attach 拿到的全部段,按录制顺序

分段的意义:终端一生里尺寸会变(拖窗口、分屏),窄的时候录的字节和宽的时候录的字节,重演时必须各自在录制时的尺寸下喂给模拟器,否则折行位置全乱。所以 recordResize(cols, rows) 每次尺寸变化就「封一段、开新段」。

② 影子产出的是「一帧成品」

apps/daemon/.../terminal-snapshot.ts快照的形状
export interface TerminalSnapshot {
  cols: number   // 这张快照是按哪个尺寸序列化的——前端写之前网格必须先对上
  rows: number
  data: string   // 一次性重画滚屏、当前屏、颜色、光标的转义序列流
}

③ 协议帧:从「多帧 resize」变成「单帧 snapshot」

这是 data-plane 协议的实质改动。以前服务器→客户端会发一串 resize 控制帧(每段历史前面一帧,告诉前端「接下来这段是在这个尺寸下录的」);现在只发一帧 snapshot。同时 open 帧(客户端→服务器,attach 的第一帧)新增了 cols/rows,让 daemon 知道该按多大尺寸拍照。

以前 · 服务器控制帧
{type:'resize', cols, rows} ×N
每段历史前一帧
夹在中间的原始历史字节
现在 · 服务器控制帧
{type:'snapshot', cols, rows, data}
每 socket 一次,在任何 live 字节之前
之后才是 live 字节,直转
packages/client/src/terminal-connection.tsopen 帧现在带上测量到的尺寸
readonly dimensions?: {
  readonly cols: number   // 面板开 socket 前量到的这一格的列数
  readonly rows: number   // daemon 拿它决定影子拍照的尺寸;量不到就省略,退回录制尺寸
}
// ... 发 open 帧时把 dimensions 摊进去
socket.send(JSON.stringify({ type: 'open', credential, terminalId, ...options.dimensions }))

6旅程 A:一次击键的往返(理解哑管道)

这条旅程没被这个 PR 改动,但走一遍能坐实第 3 节那句「daemon 是哑管道」——它是理解旅程 B/C 的地基。跟着一个字符从键盘到屏幕走一圈。

全景 · 涉及 3 个文件
前端击键
TerminalPanel.tsx
WS socket daemon 转发
terminal-ws.ts
pty master shell 录音+回流
terminal-ws.ts

你在前端 xterm 里敲一个字符,xterm 的 onData 把它交给连接的 write(),走 WS 到 daemon。daemon 这端把二进制帧原样写进 pty 的 master——注意它不看内容,连子进程可能刚死这种情况都只是 try/catch 吞掉,不解析:

apps/daemon/.../terminal-ws.ts前端来的输入 → pty,纯搬运
const onMessage = (data, isBinary) => {
  if (isBinary) {
    try {
      pty.write(rawDataToBuffer(data))   // 就一句:字节灌进 pty,不解释
    } catch {
      // 子进程刚没:gone 帧已在 onExit 路上,这里无可挽救
    }
    return
  }
  // 文本帧才是控制协议(resize / exit)
}

shell 收到输入、执行、把输出写回它的 stdout,穿过 slave 从 master 出来,daemon 的 pty.onData 收到这团原始字节。这里 daemon 做的第二件事——录音——是理解后面所有恢复逻辑的前提:每个 live 字节既转发给当前前端,又被 ring 抄一份。注意监听顺序是「先订阅 live,再抓历史」,这是为了保证每个字节不重不漏落进「历史」或「live」正好一边(旅程 B 会展开)。

排查路标 · 旅程 A
症状从哪下手
打字没反应 / 输入没到 shellterminal-ws.tsonMessagepty.write 那条二进制分支
输出显示了但历史没录上terminal-registry.ts:ring 的 appendterminal-ws.tspty.onData 监听

7旅程 B:把 tab 拖到右侧分屏(本 PR 的主线)

这是这个 PR 存在的理由。你把一个终端 tab 拖到右边形成分屏,这个终端会挂到一块新的、空白的 xterm 上,需要把「之前的内容」找回来。走通这条旅程,你就掌握了整套快照恢复。

全景 · 涉及 4 个文件
面板重挂+量尺寸
TerminalPanel.tsx
registry 换新 socket
connection-registry.ts
daemon 起影子
terminal-snapshot.ts
下发单快照帧
terminal-ws.ts
面板一次写入
TerminalPanel.tsx

B.1为什么以前会「直接空白」

先说清病根,因为这正是用户当初报的现象。前端有个模块级的「连接注册表」(connection-registry),多个 tab 看同一个终端时共用一个 socket。以前它长这样:一个终端一个缓存连接,谁来 acquireTerminalrefs += 1 把同一个 socket 递出去,只有连接「死了」(收到 gone)才换新的。

connection-registry.ts · base(旧版)重挂只是 refs+1,复用同一 socket
export function acquireTerminal(terminalId, create) {
  const existing = connections.get(terminalId)
  if (existing?.dead) { /* 只有死了才换 socket */ }
  if (existing) {
    existing.refs += 1
    return existing.conn   // ← 重挂走这里:拿回那个「历史已经放过一次」的旧 socket
  }
  // ...
}

问题在于:旧协议的历史回放是在 socket 刚连上(attach)那一刻发一次的。一个 tab 拖进分屏 → React 把面板卸载再在新位置挂载 → 新面板 acquireTerminal 拿回同一个旧 socket。这个 socket 的历史早在它第一次连上时就放完了,不会再放第二次。于是新挂的空白 xterm 什么也收不到——空白

这是 main 上真实存在的 bug:只要没有本 PR 的 dae94c03 这个 commit,重挂就复用已花掉历史的旧 socket,拖分屏必空白。用户当初就是在一个没合这些 commit 的旧栈上撞到的(🟠 提醒:混栈时容易误判为「新代码回归」)。

B.2影子终端:daemon 把历史重演成一张照片

现在看新机制的核心——新文件 terminal-snapshot.ts。daemon 收到新的 attach 后,起一个 headless xterm(无界面、纯算网格的 xterm,和前端用的是同一套解析/重排引擎),把 ring 的每一段在它录制时的尺寸下重演进去,最后只重排一次到前端声明的尺寸,序列化成一帧。

apps/daemon/.../terminal-snapshot.ts影子重演 + 一次重排 + 序列化
export async function buildTerminalSnapshot(segments, cols, rows) {
  const terminal = new Terminal({ cols: first?.cols ?? cols, rows: ..., scrollback: 5000,
    allowProposedApi: true })   // SerializeAddon 要读 proposed-API 后的 buffer 内部
  try {
    const serializer = new SerializeAddon()
    terminal.loadAddon(serializer)
    for (const segment of segments) {
      resizeIfChanged(terminal, segment.cols, segment.rows)   // 先切到这段录制时的尺寸
      if (segment.data.length > 0)
        await new Promise(r => terminal.write(segment.data, r))   // 再喂字节,且必须 await
    }
    resizeIfChanged(terminal, cols, rows)   // 全部重演完,才重排到客户端尺寸——只此一次
    return { cols, rows, data: serializer.serialize() }
  } finally {
    terminal.dispose()   // 拍完即焚,零常驻内存
  }
}

这里有个必须 await 每次 write 的隐藏时序坑,值得单独点出:xterm 的 resize()立即生效的,而 write()排队异步解析的。如果不等一段写完就 resize 到下一段尺寸,那段还没解析的字节就会在错误的尺寸下被解析、折行折错。所以循环里每段 write 都 await 到解析完,才走到下一段的 resize,把「resize 立即、write 排队」这两个节奏强行对齐。

B.3为什么在服务端重排、而不是前端

「只重排一次、在服务端」是这个 PR 的题眼。对比一下就懂了。

以前 · 前端边收边重排
收 resize 帧 → xterm 改宽度
收历史字节 → 在这宽度下重演
…下一段又改宽度、再重演…
重排跟 shell 的实时重绘、跟前端自己的 fit 抢时序 → 幽灵/错位
现在 · 服务端一次重排
影子里重演全部历史
没有任何并发重绘的服务端,重排到终点尺寸一次
序列化成成品
前端拿到的字节已经在自己的尺寸里,永不中途重排

前端收到这帧后的处理简单到几乎无脑:先把网格 resize 到快照的尺寸(正常情况下就是自己声明的尺寸,是个 no-op),一次写进去,完事。这三行以前是一大段带 replaying 闩锁的重演逻辑,现在全删了。

apps/desktop/.../TerminalPanel.tsx收到快照:resize 一次 + 写一次
else if (frame.type === 'snapshot') {
  // 三个调用都不加 cancelled 守卫:cleanup 里同步退订了 control,
  // handler 不可能在 teardown 之后触发;而只要它能触发,cancelled 必为 false。
  terminal.resize(frame.cols, frame.rows)
  if (frame.data.length > 0) terminal.write(frame.data)
  terminal.write('', () => scheduleFit())
}

那前端声明的尺寸从哪来?面板挂载时、在开 socket 之前,用 fit 插件量一下这一格能放多少列行,塞进 open 帧。量不到(隐藏、布局未定)就省略,daemon 退回用录制尺寸拍照,之后前端第一次 fit 再收敛差异。

apps/desktop/.../TerminalPanel.tsx开 socket 前先量尺寸,随 open 帧声明
const proposed = fitAddon.proposeDimensions()
const paneDimensions =
  proposed && Number.isFinite(proposed.cols) && Number.isFinite(proposed.rows)
    ? { cols: proposed.cols, rows: proposed.rows }
    : undefined   // 量不到就不声明,daemon 用录制尺寸兜底
const connection = acquireTerminal(terminalId, createConnection, 'viewer', paneDimensions)

B.4registry:viewer 重挂时换一个新 socket

回到 B.1 的病根。要让重挂能拿到新快照,registry 必须在「重挂」时换一个新 socket(新 socket 一连上,daemon 就会拍一张新快照)。这个 PR 给注册表加了「角色」概念:owner 是 store 驱动的常驻引用(后台 tab 也保活),viewer 是挂载的面板。判据是:一个 viewer 挂到「已经把一次性快照花掉了」的 socket 上时,换新的。

apps/desktop/.../connection-registry.tsviewer 重挂 → 换 socket 拿新快照
export function acquireTerminal(terminalId, create, role, dimensions) {
  const existing = connections.get(terminalId)
  if (existing) {
    if (existing.dead || (role === 'viewer' && existing.viewers === 0 && existing.settled)) {
      swapConnection(terminalId, existing, create, dimensions)   // ← 换新 socket,历史重新要一次
    }
    existing.refs += 1
    if (role === 'viewer') existing.viewers += 1
    return existing.conn
  }
  // ...首次则新建
}

settled 是「这个 socket 的 open 已经走完、一次性快照已经花掉」的标记;viewers === 0 保证没有别的活面板正看着它(否则把 socket 从活面板底下抽掉会冻结它)。两个条件都满足,才 swapConnection 换新。这正是修掉 B.1 空白的那一手。

为什么重挂时 viewers 会先归零、让这个判据成立?因为一个 owner 引用(connection-sync 持有)在整个拖拽过程里保活着这条缓存 entry,而 React 的 effect 清理顺序保证「旧面板先卸载(viewer 释放,归零)→ 新面板再挂载(viewer 获取,触发换 socket)」。这条时序被一个专门的回归测试钉死(含 StrictMode 双挂)。

排查路标 · 旅程 B
症状从哪下手
拖分屏 / 切平面后终端空白connection-registry.tsacquireTerminal 的换 socket 判据(viewers===0 && settled);确认 connection-sync.ts 的 owner 引用在保活
历史回来了但尺寸不对 / 折行乱TerminalPanel.tsx:open 帧的 proposeDimensionsterminal-snapshot.ts:末尾那次 resizeIfChanged(cols,rows)
历史内容缺一段 / 顺序乱terminal-snapshot.ts:重演循环里每段 write 是否 await;terminal-registry.ts:ring 的分段 recordResize
快照下发了但屏幕没画TerminalPanel.tsxframe.type === 'snapshot' 分支的 resize+write

8旅程 C:拖窄窗口 → 幽灵 prompt(一个字符的补丁)

这是一条独立的 bug 线,跟快照恢复无关,但同样源于 pty 的 SIGWINCH 特性。把终端拖窄,多行 prompt(powerlevel10k)会在原地多留一条「幽灵」提示行。这一节讲清它为什么发生,以及那个只改一个字符的 xterm 补丁。

C.1SIGWINCH 重绘 vs xterm 的重排,撞车

拖窄时同一瞬间发生两件事,它们对「行数」的假设不一致:

xterm 本来就知道这个冲突,它对「光标所在的那个折行组」做了保护——重排时不折它,注释里写着「程序会自己处理带光标的折行」。但多行 prompt 的满宽那一行在光标上方,落在保护范围之外:那一行被重排插了新行,shell 的重画就落低了一行,旧 prompt 的第一行没被盖掉,留成幽灵——每拖窄一次留一条。

C.2把保护窗口往上扩两行

修法是一个 postinstall 脚本,把 xterm 编译产物里的保护判断从「光标所在折行组」扩大到「光标上方两行内的折行组」。改动就是一个字符:l<n 变成 l-2<n

const l=this.ybase+this.y;if(l>=n&&l<n+c.length)continue; const l=this.ybase+this.y;if(l>=n&&l-2<n+c.length)continue;
把「不重排」的保护窗口从光标那一组,扩到光标上方两行——shell 反正会重画这块区域,超出边界的内容仍正常重排。

有两个工程细节值得记住。其一:headless xterm 也要打一模一样的补丁。因为 daemon 的影子重演的是「在一个打了补丁的前端上录下来的字节」;如果影子的重排跟前端不一致,录下的重画就会错一行,幽灵会被烤进每一张快照里。所以补丁脚本对 @xterm/xterm@xterm/headless 两个包各打一遍。

scripts/patch-xterm-prompt-reflow.mjs两个包各打一遍
const targets = [
  { storePrefix: '@xterm+xterm@',    libFile: join('lib', 'xterm.js') },
  { storePrefix: '@xterm+headless@', libFile: join('lib-headless', 'xterm-headless.js') },
]
// 找不到待打补丁的原始 pattern 就 exit(1):让 xterm 升级时强制重新评估这个补丁

其二:脚本在找不到原始 pattern 时会让安装失败exit(1)),而不是静默跳过。这样 @xterm/xterm 一旦升级、编译产物变了,这个补丁会立刻爆出来逼人重新评估,不会悄悄失效。

排查路标 · 旅程 C
症状从哪下手
拖窄后 prompt 多一条幽灵行patch-xterm-prompt-reflow.mjs:确认补丁已应用(改 node_modules 后要删 Electron HTTP 缓存)
升级 xterm 后 bun install 报 reflow guard not found同上脚本:pattern 变了,需重新推导补丁或退役
快照里也带幽灵确认 @xterm/headless 也打了补丁(targets 数组第二项)

9底座补强:最后一个 commit 的 review 响应

PR 开出来后 Codex 和 Claude 各提了意见,末尾 commit d9289e3e 是响应。三件事,都在快照 attach 这条路上加固。

① 尺寸夹上限——防止一帧就把 daemon 打爆

open/resize 帧里客户端声明的 cols/rows 以前只校验「正整数」,没有上限。一个认证过的客户端发 cols: 1000000,这数字会一路淌进 pty.resize 和影子 xterm 的 resize,让它按百万格去分配内存。现在两条 parse 路径都过一个夹子,封在 2000(比任何真实超宽屏还宽 4 倍)。

apps/daemon/.../terminal-ws.tsopen 和 resize 两处都夹
const maxTerminalDimension = 2000
function clampDimension(value) { return value > maxTerminalDimension ? maxTerminalDimension : value }
// parseOpenDims / parseResizeFrame 各调一次 clampDimension(cols)/(rows)

② 构建期缓冲加背压——用 pty 流控封住唯一的无界队列

影子拍照是异步的(典型 10ms、最坏约 120ms)。这期间来的 live 字节先塞进一个数组 pendingLiveChunks 缓着(保证快照先于 live)。问题是:这些字节还没进 socket,现有的按 socket 队列深度做的背压看不见它——一个在 attach 瞬间正狂吐的 pty(跑构建、yes)能在这 120ms 里把数组撑到几 MB。现在给缓冲记字节数,超过 4 MiB 就 pty.pause()(第 3 节讲的 OS 级流控,让子进程阻塞),flush 时 resume()

apps/daemon/.../terminal-ws.ts超水位线 pause,flush 前 resume
if (snapshotting) {
  pendingLiveChunks.push(chunk)
  pendingBytes += chunk.length
  if (!snapshotPaused && pendingBytes >= defaultSnapshotBufferHighWaterMark) {
    pty.pause(); snapshotPaused = true   // 让子进程阻塞,别再往数组灌
  }
  return
}

一个不显眼但关键的顺序:resume() 放在 flush 循环之前,不是之后。因为 flush 把缓冲写进 socket 后,背压控制器接管;如果它在 flush 中途(socket 被灌满)决定 pause,而我随后又 resume,就会把它的 pause 覆盖掉、废掉背压。所以先把 pty 交还给控制器(此刻 socket 还空),再 flush。detach 里也补一句 resume 兜底,保证一个中途离开的 viewer 不会把 pty 留在 paused 状态、冻死下一个 attach。

③ 删一处死守卫

快照 handler 里 terminal.resize 带了 if (!cancelled) 而后两行 write 没带,不对称。核实下来这守卫是死的(cleanup 同步退订了 control,handler 不可能在 teardown 后触发),删掉让三行统一。见 B.3 的代码。

10计划 vs 实现的偏差

这个方案(内部代号 3b)不是一步到位的,中间否过好几版。这些偏差是「照计划做的部分你已知,变卦的地方才是认知裂缝」。

议题计划/中途尝试最终做成 / 为什么变
拖窄幽灵的第一版修法(v1) 把「变窄」特殊处理:先把新尺寸发给 pty,把 xterm 的重折押后,等 shell 重绘字节安静 40ms 再 terminal.resize() 废掉。押后期间 xterm 停在旧宽度、pty 已是新宽度,shell 按新宽度算的重绘落在宽度对不上的网格上,p10k 的相对光标清除把历史行也一起抹了——拿一个观感幽灵换来真数据丢失。改成 v2:xterm 和 pty 同一拍一起 resize。这钉出一条硬不变式:终端里 xterm 和 pty 的宽度任何时刻都不该错开(也是为什么 daemon 拍照前先 pty.resize、让 SIGWINCH 重绘作为 live 字节排在快照后)。
常驻 vs 临时影子 3a:给每个终端常驻一个 headless 影子(VS Code 终端持久化那种形态),随时能拍照。 否掉,只作退路。因为 Eyrie 里终端设计成跟着 task 永生,除非显式清理不删;20 个终端常驻 = 200–400MB 常驻内存,还要跟随 resize 持续重排。改用 3b:影子只在 attach 一瞬存在,拍完即焚,常驻内存零增量。
原始回放 + 客户端闩锁 方案 A / B:保留「原始字节回放」,在前端加 replaying 闩锁压住重排、加 replayed 帧标记回放结束,试图堵前端的时序竞态。 被 3b 整体取代。这两版被证明是「过渡态、治标不治本」,反而给前端引入了额外的全局回放复杂度。3b 把重排搬到服务端后,这些闩锁全家删除——竞态连同它的宿主一起消失。(分支历史里这两个 commit 已被 drop。)
xterm 加宽方向补丁 原计划除了拖窄的 _reflowSmaller 补丁,还要给拖方向的 _reflowLarger 打一个伴生补丁。 实测发现不需要。真机取证:活终端一次性拖宽(122→157,光标上方两个折行组合并)画面干净、没错位。所以加宽补丁降级为「以防万一」的 contingency,没进这个 PR。
P1.5:跨尺寸重挂多一条重复 prompt 一度以为是方案 B 的确定性错位,要专门修。 定性为竞态(嫌疑是 p10k 的 git-status 异步二次重绘 vs fit 的时序),不是确定性 bug。3b 把整个「边收边重排」的时序窗口删掉,这条竞态没了立足之地,靠多轮重复验收兜住。
多设备同时在看的尺寸仲裁 设想像 tmux 那样,多个客户端看同一终端时取「最小屏」尺寸。 3b 明确不解这个。快照只服务 attach 那一刻的单个客户端几何;「两个设备同时看、尺寸不同怎么办」是 tmux 命题,另立债(P3)。

动工前的 spike 实测(给数字一个量级感):headless xterm 灌 1 MiB 历史,最坏约 120ms、典型约 10ms;产出的快照 100–400 KiB;构建期临时堆约 23 MiB。成本担忧就是靠这组数字解除的。

11心智模型补丁

daemon 从头到尾是哑管道,永远不解析 pty 字节。 live 时仍是哑管道,但 attach 那一瞬会临时起一个 headless xterm 影子解析历史、拍快照,拍完即焚。
终端重挂(拖分屏、切平面)复用缓存的同一个 socket。 viewer 重挂到一个「已花掉快照」的 socket 时,registry 会换一个新 socket,好重新要一张快照。
恢复历史 = 前端一段段重演原始字节,边收边按 resize 帧改宽度。 恢复历史 = 收一帧已经在自己尺寸里的 snapshot,resize 一次 + write 一次,前端不再重排。
open 帧只带凭证和 terminalId。 open 帧还带面板量到的 cols/rows,daemon 据此决定影子拍照的尺寸;量不到才退回录制尺寸。
xterm 是纯第三方依赖,装上就用。 xterm 和 xterm/headless 都被 postinstall 脚本打了一个字符的重排保护补丁;升级会让安装失败逼人重看。

12新词表

本 PR / 终端域
影子终端 / shadowdaemon 在 attach 时临时起的 headless xterm,用来把历史重演成快照,拍完即焚。
snapshot 帧影子序列化出的一帧,含 cols/rows + 一段能一次重画整个屏幕的转义序列;每 socket 一次。
ReplaySegment / ring录音机存的一段原始 pty 字节 + 它录制时的尺寸;ring 是固定大小的环形缓冲,满了从头 trim。
viewer / owner注册表里连接的两种引用角色:owner 是 store 驱动的保活引用(后台 tab 也在),viewer 是挂载的面板。
settled标记一个 socket 的 open 已走完、一次性快照已花掉——viewer 重挂到 settled 的 socket 才换新。
pty 通用术语
pty (master/slave)一对内核虚拟设备,骗 shell 以为连着硬件终端;daemon 持 master,shell 用 slave。
SIGWINCH尺寸变化时内核发给子进程的信号,shell 据此用相对光标移动重画 prompt。
alternate screenvim/less 等全屏程序切进去的独立屏幕缓冲,退出时切回露出滚动历史。
flow control (pause/resume)node-pty 停/续从 master 读字节;停读会让子进程写阻塞——OS 级背压。
line discipline (ONLCR)内核 TTY 层对字节的加工,如把输出的 \n 转成 \r\n

13测试与风险地图

纯事实陈述:哪些行为有测试钉住,哪些是薄冰。

有兜底的薄冰(🔴严重 🟠中 🟡低)
✔ 影子重演/重排/序列化:terminal-snapshot.test.ts(按录制宽重演、重排到客户端宽、resize 前 flush、色彩/备用屏保真)
✔ 单快照帧协议 + open 帧尺寸 + 构建期缓冲 flush + mid-build exit 延后:terminal-ws.test.ts
✔ 尺寸夹上限(open + resize 两路):本 PR 新增 2 个用例
✔ 构建期缓冲 pause/resume:本 PR 新增用例(fake pty 记 pause/resume)
✔ 拖分屏重挂换 socket(含 StrictMode 双挂):terminal-split-recovery.test.tsx
✔ 真机 E2E:prompt / seq 1 200 scrollback / alt-screen less 分屏全部内容不丢
🟡 真机拖拽本身没被自动化 E2E 覆盖:CDP 原生 mouse 事件触发不了 dnd-kit 的 PointerSensor,真机验证是走 dev-hook 调同一条 store action,不是模拟真实指针拖拽。
🟡 多设备同尺寸仲裁未做:两个不同尺寸客户端同时看一个终端,第二个 viewer 会拿到按第一个尺寸拍的快照(tmux 命题,P3 债)。
🟡 xterm 补丁靠 pattern 匹配:升级 xterm 若改了编译产物且 pattern 仍在但语义变了,补丁可能打上却不生效——有 exit(1) 兜「pattern 消失」但兜不住「pattern 语义漂移」。
合并前值得知道:本 PR 的 CI verify 全绿、五门全绿(2202 tests)、用户已真机验收通过。上面三条薄冰都不是 blocker,是已知的边界与后续债,非本 PR 引入。

14验收提示 + 覆盖声明

别被这些吓到(不是缺陷):

覆盖声明:报告覆盖全部 19 个改动文件。设计承载文件(terminal-snapshot.tsterminal-ws.ts 及其 base 版、TerminalPanel.tsx 及其 base 版、connection-registry.ts 及其 base 版、connection-sync.tsterminal-registry.tspackages/client/terminal-connection.ts 及其 base 版、patch-xterm-prompt-reflow.mjs)均逐份 Read 后亲手裁剪代码;测试文件按覆盖行为归纳、未逐行精读;lockfile / package.json 仅确认新增依赖。第 10 节「偏差」交叉了随附技术方案文档与 commit 历史。未略读任何设计承载路径。