fix/terminal-snapshot-restore:把终端「回放」换成「快照恢复」
figuretu/eyrie · 0b46bcd5...d9289e3e · PR #127 · 2026-07-08 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自本分支、经裁剪,青色斜体注释是解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。终端这块依赖 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/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),而实际上另一端是我们的程序。
把它想成一根两头的管子:
- slave 端交给 shell 当它的标准输入/输出。shell 往 stdout 写的每个字节,进 slave → 穿过内核 → 从 master 端出来。
- master 端由 daemon 持有(通过
node-pty这个库)。daemon 从 master 读到的,就是 shell「画到屏幕上」的原始字节;daemon 往 master 写的,shell 会当成「用户敲的键盘」读到。
关键认知:这根管子里流的是字节流,不是文本。而且中间还隔着一层内核的「行规程」(line discipline)会动手脚——比如把 shell 输出里的 \n 自动转成 \r\n(回车+换行)。这就是为什么前端 xterm 特意不开 convertEol:内核已经转过了,前端再转一次就会错位。
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 里会反复出现的序列:
- SIGWINCH 重绘:终端尺寸一变,内核给 shell 发
SIGWINCH信号,像 powerlevel10k 这种花哨 prompt 会用「光标回车、上移 N 行、擦到底」这套相对光标的移动来重画自己的提示符。这套移动假设行数没变——旅程 C 的幽灵行就出在这。 - 备用屏幕(alternate screen):
vim、less、htop这类全屏程序,进入时会切到一块独立的「备用屏」(DECSET 1049),退出时切回来、露出原来的滚动历史。模拟器必须正确跟踪当前在主屏还是备用屏。 - 流控(flow control):
node-pty可以pause()停止从 master 读字节。一旦停读,内核的管道缓冲很快填满,shell 再写就会阻塞。这是操作系统级的背压——第 9 节会用到它。
3.3谁是「模拟器」:本 PR 最重要的一句话
这是理解整个 PR 的钥匙:live 直播期间,daemon 端没有终端模拟器。
daemon 在 live 时只干两件事:把 master 读到的字节哑管道转发给前端 socket;同时把这些字节抄一份进一个固定大小的环形录音机(ring)存历史。它不解析这些字节、不知道「屏幕现在长什么样」——它只是字节的搬运工和录音师。唯一在解析、在维护网格的,是前端的 xterm。
那 daemon 怎么给一个刚挂上来、空白的前端「重建历史」?以前的答案是把录音倒带、原样播给前端让它自己重演(回放)。这个 PR 的答案是:daemon 临时起一个自己的模拟器——一个 headless(无界面)的 xterm「影子」,只在 attach 那一瞬间存在,把录音重演进这个影子、拍一张「成品照片」发给前端,然后立刻销毁。live 期间它依然没有模拟器。
4架构:模拟器在哪一端
结构面的变化只有一处,但它是整个 PR 的支点:attach 时「重建历史」的活儿,从前端搬到了 daemon 的临时影子里。live 直播那条哑管道两版一模一样。
以前 · 前端自己重演历史
哑管道+录音
一段段重演
现在 · daemon 临时影子拍照
attach 时临时起
一次写入
注意 before 图里,前端 xterm 是「热点」——它承担重演、每段 resize、边收边重排的全部复杂度;after 图里热点移到了 daemon 影子,前端退化成「一次写入」的被动接收方。复杂度没有消失,是从时序敏感的前端搬到了没有并发干扰的 daemon 同步构建里。
5数据与协议形状
进旅程前先认三样东西的形状:录音机存什么、影子产出什么、协议帧怎么变。只看形状不讲行为。
① 录音机存的是「分段的原始字节」
ring 不存「渲染好的屏幕」,存的是原始 pty 字节,按「当时的尺寸」分段。为什么存字节不存截图?因为只有原始字节里才保留了颜色、光标、清屏这些序列,重演时才能一比一还原。
export interface ReplaySegment {
cols: number // 这一段字节是在多宽的终端下录的
rows: number
data: Buffer // 这些尺寸在force时录下的原始 pty 字节
}
export type ReplayPayload = ReplaySegment[] // 一次 attach 拿到的全部段,按录制顺序
分段的意义:终端一生里尺寸会变(拖窗口、分屏),窄的时候录的字节和宽的时候录的字节,重演时必须各自在录制时的尺寸下喂给模拟器,否则折行位置全乱。所以 recordResize(cols, rows) 每次尺寸变化就「封一段、开新段」。
② 影子产出的是「一帧成品」
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 字节之前
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 的地基。跟着一个字符从键盘到屏幕走一圈。
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 吞掉,不解析:
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
| 症状 | 从哪下手 |
|---|---|
| 打字没反应 / 输入没到 shell | terminal-ws.ts:onMessage 里 pty.write 那条二进制分支 |
| 输出显示了但历史没录上 | terminal-registry.ts:ring 的 append;terminal-ws.ts:pty.onData 监听 |
7旅程 B:把 tab 拖到右侧分屏(本 PR 的主线)
这是这个 PR 存在的理由。你把一个终端 tab 拖到右边形成分屏,这个终端会挂到一块新的、空白的 xterm 上,需要把「之前的内容」找回来。走通这条旅程,你就掌握了整套快照恢复。
TerminalPanel.tsx→ registry 换新 socket
connection-registry.ts→ daemon 起影子
terminal-snapshot.ts→ 下发单快照帧
terminal-ws.ts→ 面板一次写入
TerminalPanel.tsx
B.1为什么以前会「直接空白」
先说清病根,因为这正是用户当初报的现象。前端有个模块级的「连接注册表」(connection-registry),多个 tab 看同一个终端时共用一个 socket。以前它长这样:一个终端一个缓存连接,谁来 acquireTerminal 就 refs += 1 把同一个 socket 递出去,只有连接「死了」(收到 gone)才换新的。
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 什么也收不到——空白。
dae94c03 这个 commit,重挂就复用已花掉历史的旧 socket,拖分屏必空白。用户当初就是在一个没合这些 commit 的旧栈上撞到的(🟠 提醒:混栈时容易误判为「新代码回归」)。B.2影子终端:daemon 把历史重演成一张照片
现在看新机制的核心——新文件 terminal-snapshot.ts。daemon 收到新的 attach 后,起一个 headless xterm(无界面、纯算网格的 xterm,和前端用的是同一套解析/重排引擎),把 ring 的每一段在它录制时的尺寸下重演进去,最后只重排一次到前端声明的尺寸,序列化成一帧。
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 到快照的尺寸(正常情况下就是自己声明的尺寸,是个 no-op),一次写进去,完事。这三行以前是一大段带 replaying 闩锁的重演逻辑,现在全删了。
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 再收敛差异。
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 上时,换新的。
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.ts:acquireTerminal 的换 socket 判据(viewers===0 && settled);确认 connection-sync.ts 的 owner 引用在保活 |
| 历史回来了但尺寸不对 / 折行乱 | TerminalPanel.tsx:open 帧的 proposeDimensions;terminal-snapshot.ts:末尾那次 resizeIfChanged(cols,rows) |
| 历史内容缺一段 / 顺序乱 | terminal-snapshot.ts:重演循环里每段 write 是否 await;terminal-registry.ts:ring 的分段 recordResize |
| 快照下发了但屏幕没画 | TerminalPanel.tsx:frame.type === 'snapshot' 分支的 resize+write |
8旅程 C:拖窄窗口 → 幽灵 prompt(一个字符的补丁)
这是一条独立的 bug 线,跟快照恢复无关,但同样源于 pty 的 SIGWINCH 特性。把终端拖窄,多行 prompt(powerlevel10k)会在原地多留一条「幽灵」提示行。这一节讲清它为什么发生,以及那个只改一个字符的 xterm 补丁。
C.1SIGWINCH 重绘 vs xterm 的重排,撞车
拖窄时同一瞬间发生两件事,它们对「行数」的假设不一致:
- xterm 这边:宽度变窄,它把每一行满宽的内容重新折行(rewrap),一行可能变两行,插入了新行。
- shell 这边:收到 SIGWINCH,powerlevel10k 用「回车、上移 N 行、擦到底」重画 prompt,这套相对光标移动假设 prompt 占的行数没变。
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;
有两个工程细节值得记住。其一:headless xterm 也要打一模一样的补丁。因为 daemon 的影子重演的是「在一个打了补丁的前端上录下来的字节」;如果影子的重排跟前端不一致,录下的重画就会错一行,幽灵会被烤进每一张快照里。所以补丁脚本对 @xterm/xterm 和 @xterm/headless 两个包各打一遍。
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 倍)。
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()。
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心智模型补丁
12新词表
| 本 PR / 终端域 | |
|---|---|
影子终端 / shadow | daemon 在 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 screen | vim/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 语义漂移」。 |
14验收提示 + 覆盖声明
别被这些吓到(不是缺陷):
- 看板还是老样式:本分支基于
0b46bcd5,不含 kanban 重构;那是另一条分支。 - 新增两个依赖
@xterm/headless、@xterm/addon-serialize:影子终端和序列化各需一个,是设计必需,不是误加。 - 在没这些 commit 的栈上仍会空白:修复是分支级的,main / kanban 要合了 #127 才吃得到——你日常那套默认端口栈的「拖分屏空白」在合并前不会消失。别把它当成本 PR 没修好。
覆盖声明:报告覆盖全部 19 个改动文件。设计承载文件(terminal-snapshot.ts、terminal-ws.ts 及其 base 版、TerminalPanel.tsx 及其 base 版、connection-registry.ts 及其 base 版、connection-sync.ts、terminal-registry.ts、packages/client/terminal-connection.ts 及其 base 版、patch-xterm-prompt-reflow.mjs)均逐份 Read 后亲手裁剪代码;测试文件按覆盖行为归纳、未逐行精读;lockfile / package.json 仅确认新增依赖。第 10 节「偏差」交叉了随附技术方案文档与 commit 历史。未略读任何设计承载路径。