ADR 0002: 数据面窗口与 credit 不变量

背景

Agent、Server、Client 三端的转发路径都遵守同一条形状:一个共享接收循环解析帧,把 DATA 交给每流队列,再由写泵把字节送到目标。审计(P0-1/P0-2/P0-3)暴露的问题是这条 路径上三个可复现的停摆:每流队列永不回收导致轮询成本线性增长并把有界队列拖满;在接 收循环内同步写目标使一个停读的对端冻结整个会话的所有流、OPEN_STREAM 准入和 PONG; 拨号期间早到的 DATA 不归还 credit,使两侧在刷新阈值上永久互等。

这些故障共享同一个根因:等待 credit 的 pump 全部没有超时。系统里不存在“稍后再试”的 补救路径,任何一处窗口算术上的死角都会变成永久停摆,而不是变慢。

决策

在 internal/protocol/window.go 固定三条不变量,三端只允许这一份实现:

  1. 每流队列不小于对端通告的窗口。 队列只在字节越过窗口时拒绝,因此拒绝等价于对端 不守 credit,可以只 RESET 单流;若队列小于窗口,普通背压也会变成截断。
  2. 通告窗口 - 刷新阈值 >= 单个 DATA 帧上限。 剩余窗口不足一帧时立即刷新 (ShouldFlushWindowUpdate),否则发送方既无法前进也无法请求 credit。
  3. 用来建缓冲的窗口数值一律夹紧(NegotiateReceiveWindow):过小或过大都回落到 默认窗口。过小的数值永远无法被一个整帧填充;过大的数值则会把执行窗口的缓冲撑到不可 验证的尺寸。夹紧只作用于”本进程愿意为一条流缓冲多少”,不改变对端通告的 credit: Server→Client 一跳严格按 Client 通告的窗口发送,从不放大,也不把小窗口当作无限, 因为放大就会冲破对端的接收缓冲。小窗口靠”每次读取限制在当前可用 credit 内”前进, 代价是变慢而不是停摆。

阻塞点全部移出共享接收循环:每流一条写泵加一条有界队列(Server→Agent 256 KiB、 Client→Server 取夹紧后的通告窗口),队列满只影响本流。CloseWrite 作为哨兵帧排在同 一队列里,保持半关闭与数据的先后顺序。

被否决的替代方案

  • 新增窗口协商帧或提升协议版本。 现有 OPEN_STREAM.Window 加能力协商已经表达了同 一事实;新增帧类型会让旧版本链路进入未定义状态,也解决不了“队列小于窗口”这个本地 错误,只是把兼容成本摊给所有部署。
  • 每流独立协商窗口。 每流多一次往返,而真正决定停摆的是不变量 2 和 3,它们在连接 级常量上就能成立。
  • 给 credit 等待加超时。 超时后没有安全的重试路径:重试会重复投递字节,放弃会截断 响应。超时只会把可诊断的截断变成随机的部分写入。
  • 继续在接收循环里同步写目标,只把队列调大。 头阻塞来自共享循环本身,调大队列只是 让一次冻结消耗更多内存。
  • 直接信任对端通告的窗口数值。 以 uint32 建缓冲意味着一个 4 GiB 的通告就能撑爆 进程,而一个 4 KiB 的通告会让流再也发不出一个整帧。

迁移影响

  • 默认值不变(Server 通告 262144、阈值 131072、单帧 32768、Agent/Client 每流缓冲 262144),现网配置无需修改。
  • server.stream.* 与 *.inbound_buffer_bytes 从“可写入但无效”变为真实生效,校验同步 收紧:max_frame_payload 只接受 32768,窗口与阈值必须留下整帧余量,缓冲不得低于两个 整帧。此前按文档设置过非法组合的部署会在 check-config 阶段失败,属于预期行为。
  • Client 通告小于一个整帧的窗口从”退化为无窗口”改为”按原样兑现”:这类窗口此前会拿到 不受额度约束的突发,现在只会变慢。默认与现网窗口(256 KiB)不受影响。
  • 每流新增队列上限为夹紧后的缓冲窗口(不超过 512 KiB),总量仍由 Agent max_concurrent_dials 与 Server openLimit 约束;容量规划按“并发流 × 窗口”估算。
  • 回滚恢复上行/下行的头阻塞与早到数据死锁,相关配置键重新变为无效但继续被接受。