SSH 重连:窗格重试为何获得字节尾部,以及什么才会真正改变它¶
状态:调查结果。PR #14844 显然的后续方案已被追踪并否决,追踪过程还发现了真正的根本原因:带检查点的源恢复从未在 SSH 重连中运行。这里同时记录两者——被否决的形态,以免有人再次提出;以及已验证的原因及其隐含的修复。
问题的形态¶
重连会重新挂载窗格(tab.generation 是它的 React key),因此 xterm 会连同其 buffer 一起被销毁,必须有某种机制重新绘制它。目前这是一种字节尾部:reattachSshPtySession 发送 requireReplay: true,relay 返回 RecentPtyOutputBuffer.read()——最后 100KB,非破坏性读取,且不考虑该客户端已经消费了哪些内容。
随之产生两个代价。Main 的 @xterm/headless 模型从未看到这些字节(尾部绕过了 onPtyData),因此它恰好陈旧了中断期间——这正是迫使 sshReconnectPaintsFromModel 将网格重绘限制在备用屏幕的原因。并且 shell 会永久丢失超过 100KB 的中断输出。
不起作用的提案¶
“让窗格重试路径像 reattachKnownPtys 那样请求源恢复。”机制上这很简单——sourceRecovery 已经是 relay 会解析的可选 pty.attach 参数,Path C 已经调用同一个 requestSshPtyAttach 辅助函数,并且已经解析响应字段。所需的检查点状态也能在传输断开后存活,位于模块级 recoveryByTarget 映射中(ssh-pty-consumer-recovery.ts:17),因为 connectionId === targetId,所以可以从 connectionId 到达。
它仍然会失败,有三种方式:
- relay 在看到恢复参数之前就回答
'existing'。relay-pty-source-publication.ts:99-109会对相同clientId的 attach 短路,而重连客户端会呈现相同的 id——见下文根本原因部分,那里会发现这其实是整个故事,而不是这个提案特有的障碍。 - 失败的
reattachKnownPtys会故意删除检查点(ssh-relay-session.ts:3006-3007) 并解除租约(:3008)。窗格重试在那之后运行,因此它会呈现checkpointUnavailable,relay 将其转换为restoreRequired(relay-pty-source-publication.ts:124-130),provider 再将其转换为SSH_SESSION_EXPIRED_ERROR(ssh-pty-provider.ts:103-107)。我们会把带尾部的空白窗格 换成一个被杀死的会话。 - 载荷形态错误。 恢复只重放检查点之后的增量
(acceptedSourceEndSu → receivedEndSu]。字节尾部是一个面向_全新、空_ xterm 的屏幕快照。即使在常见情况下恢复成功,也几乎返回不了什么,窗格仍会保持 空白。
这两种机制回答的是不同问题。恢复保持 Main 的模型完整;尾部重绘 一个新终端。用一个替代另一个是范畴错误。
值得记录的更正¶
动机论点是“requireReplay 是可选的,所以旧版 relay 会忽略它,仍然显示空白窗格。”这对 SSH relay 来说是错误的。 客户端会部署并启动自己的 relay 构建到版本限定目录中(ssh-relay-deploy.ts:231、:594),并且 validateGrant 会拒绝任何 serverBuildId 与预期值不同的授权(ssh-pty-consumer-session.ts:58-65,代码内理由:"client and relay ship in one build")。客户端和 SSH relay 是版本锁定的;该通道上不会出现混合版本。remote-wire-compatibility.md 中的独立更新规则仍然适用于远程 运行时 主机——只是不适用于这一种。
因此没有需要拯救的旧主机群体,该论点制造的紧迫性是虚假的。
已回答:带检查点的恢复从未在 SSH 重连中运行¶
上面的问题是“重连客户端是否呈现新的 clientId?”它没有,后果是整条检查点机制在该路径上失效。每个环节都已验证:
- 客户端保留其 id。 重连会调用
Dispatcher.setWrite(src/relay/dispatcher.ts:149-157),它会复用this.primaryClient——包括其id——并且 只替换写入器。dispatcher 拒绝分离主客户端。这一点已在仓库中说明,见src/relay/pty-handler.ts:1736-1742。 - 因此
activate()短路。relay-pty-source-publication.ts:99测试current?.clientId === context.clientId,并在:108返回'existing'。:118-142处的rotateDelivery恢复路径只有在 id 不同时才可达——也就是说,在这里永远不会。 - 因此 relay 不返回
sourceRecovery。 - 因此客户端放弃。
finishSourceRecovery(ssh-relay-session.ts:2766-2785)未通过其!pendingRecovery守卫,调用abandonPtySourceRecovery,并返回 false——这会取消 投递并删除检查点(:3006-3008)。 - 因此窗格重试以全新方式打开,并通过
requireReplay修复获得字节尾部。
因此字节尾部并不是回退方案。它是 SSH 重连中唯一曾经运行的路径,而流控/检查点机制在该路径上是不活动的。
这也精确解释了最初的空白窗格 bug:relay 得出“该客户端已经持有流”的结论,因为按照它自己的身份规则,它确实持有。
这隐含的修复¶
给重连后的主客户端一个可区分的身份——在客户端记录上增加一个传输代际,并在 setWrite 中递增——然后让 activate() 将其与 clientId 一起比较,使重连走 rotateDelivery 而不是 'existing'。
为什么这是可行的形态:
- 无协议线变更。
RequestContext、setWrite和发布都是 relay 内部的。 - 无兼容性暴露。 客户端和 relay 在一个构建中发布,并且版本锁定。
- 它不会扰动破坏前三次尝试的那个不变量。 投递仍然比客户端存活更久;没有任何东西在
onClientDetached时退役。投递是在_重新 attach 时轮换_的,这正是恢复设计已经意图的,也是其测试已经覆盖的。
未经验证,实施前需检查: 在 rotateDelivery 的该时刻,其前置条件是否成立(检查点的 deliveryToken、clientGeneration、ownerGeneration 和 ptyIncarnation 必须与实时身份匹配,:124-128);outputFlowControl 是否已在重连会话上被授予;以及轮换对 renderer 意味着什么,它仍以空 xterm 重新挂载,需要的是屏幕,而不是检查点之后的增量。恢复保持 main 的模型完整——它本身不会重绘一个新终端,因此即使模型不再过期,尾部可能仍被窗格需要。
不要从 onClientDetached 开始¶
在那里有三次尝试失败,每次在运行前看似合理:
- 在
dispatcher.onClientDetached上退役 delivery 会破坏检查点恢复(10 个测试)。delivery 比其 client 存活更久是有意为之——正是它让 client 能从检查点恢复。 - 在没有
session.cancelDelivery()的情况下退役,会使信用账本中唯一的上游所有者槽位成为孤儿;下一次 open 会抛出PTY source delivery already has an upstream owner。在线表现为一个 toast 和一个空白窗格。 - 将
record.identity.clientGeneration与请求进行比较是不可能的:该值由客户端通过pty.openClient提供,而RequestContext自身不携带 generation。
幸存的线索¶
reattachRejectedPty(ssh-relay-session.ts:1957-2004)是进入 reattachKnownPtys 机制的现有 单 PTY 入口点,接受 (relayPtyId, mux, providerGeneration, mode),并使用 targetedDeliveryRecovery 驱动恢复。如果需要按窗格恢复,那就是钩子——它完全不涉及窗格重试路径。尚未验证它在 renderer 重试的那一刻是否可达。
前置条件,未变¶
在尝试其中任何内容之前,SSH e2e 测试通道必须为绿色,并且会在 source 变更时触发。它曾跳过 15 个 spec;在那段时间内,四个回归问题到达了用户。
未决:保留标签页背后的窗格并不总能重新绑定¶
该合并现在会保留一个 host 从未被告知过的本地标签页,因此标签页及其标题能在重连后存活。其背后的重新附加并不能可靠地存活——在 Docker-SSH 通道上,四次运行中有三次出现。当它未命中时,store 持有该标签页,标签栏渲染它,而窗格从未重新绑定:这正是原始报告所描述的“冻结标签页”形态,比过去导致它的那个删除低一层。
故意未在 ssh-reconnect-tab-destruction.spec.ts 中断言。在专门用于捕获这一类问题的通道中,四分之一的 flake 代价大于其证明价值——该通道将不再被信任,而这正是之前静默跳过失败发生的方式。标签页存活在那里被断言,并且是确定性的;存活差距则记录在此处。
值得首先检查,因为它与本文件中的其他所有内容形态相同:该标签页不存在于 host 快照中,因此在 apply 之后驱动按标签页重新附加的东西,可能根本不知道要重新附加一个快照从未提及的标签页。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 orca48/orca