Agent / Server / Client 数据面流控与阻塞隔离加固 Implementation Plan
Agent / Server / Client 数据面流控与阻塞隔离加固 Implementation Plan
Goal: 修复三方数据面审计出的 3 个 P0、3 个 P1、3 个 P2 缺陷:发送侧每流队列永不回收、接收循环内同步写目标导致跨流头阻塞、dial 期间早到 DATA 不归还信用可致流永久死锁、bufferedStream 丢失半关闭、升级路径丢失 Hijack 缓冲字节、截断响应无日志、对端通告 window 无夹紧、窗口类配置项为空转、UDP association 无上限。
Architecture: 不改帧格式与协议版本,不新增能力协商。所有”阻塞”从三个共享接收循环(StreamDispatcher.Handle、serveClientSession 帧循环、Session.Run)移到每流泵 goroutine + 字节有界队列;队列容量始终 ≥ 对端通告的 credit,沿用 internal/protocol/window.go:26-40 已文档化的不变量,使队列满只对单流 RESET。信用回收规则统一为”消费即累计、达阈值或剩余窗口低于单帧上限即刷新”,由 internal/protocol 提供单一实现供三端复用。分层保持 Handler → Service → Repository/Adapter,不动存储与 API 层。
Tech Stack: Go 标准库(新增 log/slog 于 internal/client、internal/agent)、golang.org/x/net/websocket、github.com/prometheus/client_golang(本次不新增标签维度)、stdlib testing。
规格引用: 无新增设计规格;约束来源为 AGENTS.md(协议演进约束、高可用与容错、可观测性)与 docs/protocol/websocket-frame.md。关联记录:docs/superpowers/specs/2026-09-10-socks5-web-page-latency-design.md、docs/superpowers/plans/2026-09-11-socks5-web-page-latency-implementation.md(本计划继承其”阻塞不出接收循环”“队列 ≥ credit”两条不变量并补齐执行点)。
全局约束
- 不做 worktree,直接在
/Users/z-yuchangjun/.codex/worktrees/6215/TunnelMesh上实现。 - TDD 强制:每个任务先写失败测试、运行并记录红→再最小实现→再跑聚焦与全包测试。
- 不改变任何帧类型、能力名、
schema_meta.version、OpenAPI 语义;因此本计划不触发migrations/与docs/api/openapi.yaml变更(docs/operations/configuration.md除外,见 T8)。 - 新增日志字段不得包含 secret、密码、私钥、完整 Authorization、DSN、原始字节、目标主机名以外的敏感值;允许
agent_id、route、protocol、stream_id、字节数与状态码(与现有proxy_tunnel_closed一致)。 - Prometheus 标签不得新增
stream_id/connection_id/目标地址维度;本计划只做日志与既有指标,不加新指标。 - 不引入新第三方依赖;不使用
goleak,goroutine 泄漏用显式退出信道与WaitGroup断言。 - 队列/窗口常量若可配置,必须满足并校验不变量:
advertisedWindow - windowUpdateThreshold >= maxStreamFrame且queueBytes >= advertisedWindow。 - 每个任务必须独立编译、独立通过其聚焦测试后才进入下一个。
- 未经用户明确授权,不执行 commit / push / merge / 创建远端 PR。
- 顺序:T1、T2、T6、T7、T9 互相独立;T3 → T4 → T5 有依赖(T4 复用 T3 的下行投递路径,T5 复用 T3/T4 确立的
NegotiateReceiveWindow与泵形态);T8 依赖 T3、T5 的队列尺寸入口。
T1 FairFrameWriter 回收空闲每流队列(P0-1)
症状与根因:internal/session/fair_writer.go:145-172 为每个 streamID 懒建队列并追加 w.order,removeStream 只在 TryPush 失败且 created 时调用;流正常结束/RESET/半关闭均无回收,nextDataLocked(:275-290)在持有 w.mu 时全量扫描 w.order,而 wire ID 单调不复用(internal/server/agent_relay_transport.go:106-113),于是每帧成本随连接生命周期线性增长,最终把有界队列拖满 → 拒帧 → 单流 RESET → 大响应截断。
文件清单
- 修改
internal/session/fair_writer.go - 修改
internal/session/fair_writer_test.go
接口约定
func (w *FairFrameWriter) nextDataLocked() (protocol.Frame, bool)语义变更:一次公平轮内,弹出后仍为空的流队列被就地摘除(streams与order同步收缩),且摘除必须在持有w.mu的同一临界区内完成。func (w *FairFrameWriter) removeStreamLocked(streamID uint32, stream *fairStreamQueue):新增(不取锁的内部实现);removeStream保留为加锁包装,供EnqueueData失败分支使用。func (w *FairFrameWriter) EnqueueData(streamID uint32, frame protocol.Frame) error:查找队列 +TryPush必须整体在w.mu下完成,消除”已取到 queue 指针但已被摘除并 Close”导致的一次伪ErrStreamQueueFull。- 新增
func (w *FairFrameWriter) StreamQueueCount() int:只读观测,供测试与后续运维使用(无锁内调用)。
TDD 步骤
- 先在
fair_writer_test.go写 4 个测试:TestFairWriterRetiresEmptyStreamQueues:64 个流各推 1 帧,跑Run发完后断言StreamQueueCount()==0且len(w.order)==0。TestFairWriterPreservesOrderAcrossQueueRetirement:流 7 推 1 帧发出、被回收,再推 3 帧;断言 sender 观测到的流 7 帧顺序与 payload 序列一致。TestFairWriterScanStaysBoundedAcrossManyStreams:顺序开闭 20000 个流,断言StreamQueueCount() <= 4,并用带计数的 sender 断言总发送调用次数与帧数同阶(线性而非二次)。TestFairWriterConcurrentEnqueueDuringRetirement:8 goroutine 对同一批流 ID 持续EnqueueData,在窗口内不得出现ErrStreamQueueFull;用-race跑。
- 运行
go test ./internal/session -run 'FairWriter(Retires|Preserves|ScanStays|Concurrent)' -count=1,确认前 3 个红:预期StreamQueueCount()恒等于历史流数、len(w.order)线性增长,第 3 个测试因二次扫描显著超阈值失败;第 4 个在实现前也应通过(记录为回归护栏)。 - 最小实现:抽出
removeStreamLocked;在nextDataLocked中弹出后若stream.queue.Len() == 0则收集 id,轮结束后统一摘除(摘除时按现有逻辑修正w.cursor);EnqueueData把TryPush移入w.mu临界区。不改变控制帧优先级与 quantum 语义。 - 再跑同一条聚焦命令至全绿,随后
go test ./internal/session -count=1、go test -race ./internal/session -count=1。
回滚注意事项:纯进程内调度改动,无协议/存储影响,git revert 即可;回滚后表现为长连接吞吐衰减但不产生数据错误。
T2 serveClientSession 的 seen 有界化 + 并发上限改判 live(P0-1 同类)
症状与根因:internal/server/ws_client.go:263-270 的 seen 只增不删,且第 265 行以 len(seen) >= openLimit 触发整会话 GOAWAY(maxClientOpenAttempts = 1<<16,internal/server/ws_client.go:22)。因此该上限实际是”累计开流数”,一个长期存活的 client 会话会被自己的历史流量判死并踢下线。
文件清单
- 修改
internal/server/ws_client.go - 修改
internal/server/ws_client_test.go
接口约定
seen变为有界 FIFO:保留seen map[uint32]struct{}+ 新增seenOrder []uint32,插入新 ID 时若len(seenOrder) > openLimit则淘汰最旧 ID(delete(seen, oldest)),保持”最近openLimit个 ID 拒绝重用”。GOAWAY判据改为实时并发:len(streams)+len(openings) >= openLimit。- 会话结束路径(现有
defer清理,internal/server/ws_client.go:108-131)不需额外改动。
TDD 步骤
- 写
TestServeClientSessionAllowsManySequentialStreamIDs:serveClientSessionWithOpenLimit(ctx, ..., openLimit=4, ...),顺序打开并正常关闭 12 条流,断言会话不被GOAWAY终止、12 次 open 全部成功(当前实现第 5 次即 GOAWAY → 红)。 - 写
TestServeClientSessionGoAwayOnConcurrentOpenLimit:同时保持 4 条活流(不关闭),第 5 条 open 触发GOAWAY(断言Serve返回 nil 且收到 GOAWAY 帧)。 - 写
TestServeClientSessionRejectsReusedStreamIDWithinWindow:在openLimit窗口内重用 ID → 收到RESET,行为不变(护栏)。 - 运行
go test ./internal/server -run 'TestServeClientSession(AllowsManySequential|GoAwayOnConcurrent|RejectsReused)' -count=1观察第 1 项红。 - 最小实现上述 FIFO + live 判据,复跑至绿;再
go test ./internal/server -run TestServeClientSession -count=1。
回滚注意事项:只影响单 client 会话的准入判定;回滚即恢复”累计开流 GOAWAY”。若现网依赖旧行为做硬限流,需改用 T3/T5 的每流隔离 + 观测计数,不额外记录为债务(并发上限仍由 len(streams) 提供)。
T3 Agent 下行(Server→Agent→目标)写隔离(P0-2)
症状与根因:internal/agent/session.go:220 在 StreamDispatcher.Handle 内同步 entry.conn.Write(f.Payload),而 Handle 由 Session.Run 的唯一接收循环内联调用(internal/agent/session.go:930-935);全仓 internal/agent/ 无任何 SetWriteDeadline。一个停读的目标会冻结该连接上所有流,并让 internal/agent/session.go:907-912 的 PONG 也发不出去,而服务端没有心跳超时清扫(internal/server/session_manager.go:96 仅记录 lastHeartbeat),最终表现为”列表在线、转发为 0”的静默死锁。这与 internal/client/session.go:776-780 自述的不变量直接冲突。
文件清单
- 修改
internal/agent/session.go - 修改
internal/agent/session_test.go - 只读复用
internal/session/bounded_queue.go(TryPush/Pop/Close已具备所需语义,无需改动)
接口约定
streamEntry新增字段:inbound *streamsession.BoundedFrameQueue、inboundClosed atomic.Bool(避免重复Close)、pumpDone chan struct{}(泵退出信号,供测试与Close断言)。Handle的protocol.FrameData分支:不再写目标、不再做receiveState.Handle、不再调用releaseReceiveWindow;只做localHalf校验 +entry.inbound.TryPush(frame)。TryPush失败(仅可能由不守 credit 的对端造成)→rejectAndClose。- 新增
func (d *StreamDispatcher) pumpTarget(id uint32, entry *streamEntry):Pop→receiveState.Handle(dataFrame)(错误 →rejectAndClose)→conn.Write→ 短写/错误 →rejectAndClose→releaseReceiveWindow(id, entry, written);Pop返回false(队列被 Close)即退出并close(entry.pumpDone)。 completeDial创建 entry 时按defaultAgentReceiveWindow建inbound,d.readers计入两个 goroutine(readBack+pumpTarget);removeAndClose、handleHalfClose完成分支、Close()、FrameReset分支统一entry.inbound.Close()(幂等由inboundClosed保证)。releaseReceiveWindow、waitForSendWindow签名不变(实现改动见 T4)。
TDD 步骤
- 写
TestAgentTargetWriteDoesNotBlockOtherStreams:流 A 的io.ReadWriteCloser的Write阻塞在信道直到测试放行;对同一 dispatcher 的流 B 完整双向传输并断言完成(当前实现:Handle卡死 → B 永不完成,测试超时红)。 - 写
TestAgentInboundQueueOverflowResetsOnlyOffendingStream:对端在defaultAgentReceiveWindow之外继续灌 DATA(不发 WINDOW_UPDATE 也不等信用),断言仅该流被 RESET、dispatcher 仍可服务新流、ActiveStreams()收敛。 - 写
TestAgentStreamResetUnblocksTargetWritePump:流被FrameReset后关闭目标 conn,断言pumpDone被关闭且Close()不在readers.Wait()上挂死。 - 运行
go test ./internal/agent -run 'TestAgent(TargetWriteDoesNot|InboundQueueOverflow|StreamResetUnblocks)' -count=1 -timeout 60s,记录第 1、3 项红(第 1 项预期test timed out或断言 B 未完成;第 3 项预期pumpDone永不关闭)。 - 最小实现上述改动,复跑至绿;再
go test ./internal/agent -count=1、go test -race ./internal/agent -count=1。
回滚注意事项:单进程行为,无协议变化;回滚 git revert 立即生效。内存上界变化需在回滚说明中写明:每流新增 inbound 队列上限 256 KiB(与既有 streamQueueBytes 同量级),1024 条并发流最坏 256 MiB,由 Agent 侧既有 max_concurrent_dials 与 Server openLimit 间接限制;T8 会把该尺寸接到 agent.streams.inbound_buffer_bytes。
T4 早到 DATA 归还信用 + 剩余窗口不足一帧时强制刷新(P0-3)
症状与根因:dial 未完成时 DATA 走 internal/agent/session.go:202-207 存入 pending.data,拨号完成后在 internal/agent/session.go:396-401 直接写目标,全程不触发 releaseReceiveWindow(唯一调用点在 Handle,见 internal/agent/session.go:228)。Server 侧 sendState 初值 DefaultAgentReceiveWindow=256 KiB(internal/server/agent_relay_transport.go:129-132),刷新阈值 128 KiB(internal/protocol/window.go:22-24),而 waitForSendWindow(internal/agent/session.go:566-596)与 consumeSendWindow(internal/server/agent_relay_transport.go:492-570)都无超时 → 早到数据 >128 KiB 时两侧永久互等。
文件清单
- 修改
internal/protocol/window.go(新增统一刷新判据与通告窗口夹紧) - 新建
internal/protocol/window_test.go - 修改
internal/agent/session.go - 修改
internal/agent/session_test.go - 修改
internal/client/session.go(frameStream.releaseReceiveWindow复用同一判据) - 修改
internal/client/session_test.go - 修改
internal/server/agent_relay_transport.go(releaseReceiveWindow复用同一判据) - 修改
internal/server/agent_relay_transport_test.go
接口约定
internal/protocol/window.go新增:func ShouldFlushWindowUpdate(remaining uint32, unacked uint32, threshold uint32) bool:unacked > 0 && (unacked >= threshold || remaining < MaxStreamFrame)。这是三端唯一实现。func NegotiateReceiveWindow(advertised uint32) uint32:0或< 2*DefaultWindowUpdateThreshold或> DefaultServerReceiveWindow一律回退DefaultServerReceiveWindow,保证advertised - threshold >= MaxStreamFrame且receiveBudget有界(本条同时服务 T5、T8)。
releaseReceiveWindow三端实现改为:读state.ReceiveWindow()得到 remaining,调用ShouldFlushWindowUpdate,其余(累计、清零、AddReceiveWindow、发帧)不变。- Agent 早到数据:
completeDial不再自己conn.Write(earlyData),改为把pending.data依次entry.inbound.TryPush(由pumpTarget完成 receive 记账 + 写 + 归还信用);pending.data增加字节上限defaultAgentReceiveWindow,超限立即cancelPending+sendReset(防御不守 credit 的对端)。
TDD 步骤
internal/protocol/window_test.go:TestShouldFlushWindowUpdate(表驱动:达阈值 / 剩余 <32 KiB / 剩余充足且未达阈值不刷 /unacked==0不刷)、TestNegotiateReceiveWindow(0、1024、4 GiB、合法 256 KiB、512 KiB)。先跑go test ./internal/protocol -run 'TestShouldFlushWindowUpdate|TestNegotiateReceiveWindow' -count=1→ 编译失败即红(函数不存在)。internal/agent/session_test.go新增TestAgentCreditsEarlyDataAfterSlowDial:拨号阻塞期间喂入 200 KiB,放行拨号后继续要求 Agent 归还 ≥200 KiB 信用,并断言后续 200 KiB 能在超时内送达目标(当前实现红:无 WINDOW_UPDATE 且流停摆)。internal/agent/session_test.go新增TestAgentPendingBufferOverflowResetsStream:dial 挂起时灌入 >defaultAgentReceiveWindow的 DATA,断言该流被 RESET 且pending清空。internal/server/agent_relay_transport_test.go新增TestAgentRelayStreamFlushesCreditNearWindowFloor:把sendState压到剩余 <MaxStreamFrame(小通告窗口 + 对端不刷)时,断言一旦消费即发出 WINDOW_UPDATE。internal/client/session_test.go新增TestClientFrameStreamFlushesCreditNearWindowFloor:同判据的 client 侧断言。- 依次
go test ./internal/protocol ./internal/agent ./internal/server ./internal/client -run '...(上列用例名)' -count=1记录红,再最小实现,复跑至绿;随后go test ./internal/protocol ./internal/agent ./internal/client -count=1与go test -race ./internal/agent ./internal/session ./internal/protocol -count=1。
回滚注意事项:ShouldFlushWindowUpdate 只改变 WINDOW_UPDATE 发送时机(帧类型与语义不变),新旧版本互通;回滚后重新暴露早到数据死锁。若只回滚 Agent 早到数据改动而保留刷新判据,行为仍安全(更保守的刷新不是协议依赖)。
T5 Server client 会话上行(Client→Server→Agent)写隔离(P0-2 同类)
症状与根因:internal/server/ws_client.go:368 在共享帧循环里 stream.conn.Write(frame.Payload),其内部 consumeSendWindow 会无超时阻塞(internal/server/agent_relay_transport.go:492-570),因此单个 Agent 停读会冻结该 client 会话的全部流、OPEN_STREAM 准入与 PONG 回复。
文件清单
- 修改
internal/server/ws_client.go - 修改
internal/server/ws_client_test.go
接口约定
clientRelayStream新增inbound *streamsession.BoundedFrameQueue、pumpDone chan struct{}、inboundClosed atomic.Bool;newRelayStream按NegotiateReceiveWindow(initialWindow)的结果设置队列字节(T8 接入配置前先用该夹紧值),并go起pumpClientStream(id, stream, mu, streams, reset)。protocol.FrameData分支:只查流、查clientHalfClosed、TryPush;失败 →closeStream+reset。记账与写入(含ObserveBytes、短写判定、错误 →closeStream+reset)全部移入泵。protocol.FrameHalfClose分支保持同步语义:置clientHalfClosed后仍走CloseWrite;泵在写侧错误时只closeStream,不升级成会话错误。closeStream追加inbound.Close()(幂等),确保泵随流退出。FrameReset/FrameWindowUpdate/default分支不变。
TDD 步骤
- 写
TestServeClientSessionUploadToStalledAgentDoesNotBlockOtherStreams:桩relay.NodeTransport的首条流Write阻塞(不返回信用),第二条流必须完成 open + 双向传输(当前实现红:帧循环卡死)。 - 写
TestServeClientSessionAnswersPingDuringStalledUpload:在流 A 停读时向会话发FramePing,断言在 2 s 内收到 PONG(当前实现红)。 - 写
TestServeClientSessionUploadQueueOverflowResetsSingleStream:client 无视自身通告窗口连续灌 DATA,断言仅该流 RESET 且会话仍可用。 - 运行
go test ./internal/server -run 'TestServeClientSession(UploadToStalled|AnswersPing|UploadQueueOverflow)' -count=1 -timeout 120s记录红;实现后转绿;再go test ./internal/server -run 'TestServeClientSession' -count=1。
回滚注意事项:仅 Server 单进程;回滚恢复上行头阻塞。每流新增队列上界 = 夹紧后的通告窗口(≤512 KiB),与 T3 同量级;openLimit 提供总量界。
T6 bufferedStream 转发 CloseWrite + 升级前排空 Hijack 读缓冲(P1-4 / P1-5)
症状与根因
internal/client/forward.go:448-454只实现Close();bridge(internal/client/forward.go:399-441)探测CloseWrite失败即halfClosed=false(:404-408),随后同时关闭两端(:422-430),丢弃仍存活方向,与:395-398自述相反;被包的frameStream本身实现了CloseWrite(internal/client/session.go:829)。internal/client/forward.go:361-390的 101 分支只处理远端侧br.Buffered(),从不看rw.Reader.Buffered();net/http 可能已在 101 之前把浏览器紧跟的第一个 WS 帧读进缓冲 → 静默丢字节。服务端两处已正确实现并注明原因(internal/server/http_proxy.go:136-140、internal/server/proxy_entry.go:630-637)。
文件清单
- 修改
internal/client/forward.go - 修改
internal/client/forward_test.go
接口约定
func (s *bufferedStream) CloseWrite() error:对s.Writer做interface{ CloseWrite() error }断言并委托;不支持时返回errors.New("client: stream does not support half-close")(使bridge明确降级为整体关闭,而非静默)。- 101 分支在
_ = rw.Flush()之后、br.Buffered()搬运之前,插入if buffered := rw.Reader.Buffered(); buffered > 0 { if _, err := io.CopyN(stream, rw.Reader, int64(buffered)); err != nil { _ = conn.Close(); return } }。
TDD 步骤
TestBufferedStreamCloseWriteDelegates:底层为记录调用的 stub writer,断言CloseWrite命中且类型断言路径正确(当前红:方法不存在 → 编译失败)。TestForwardHTTPUpgradeForwardsBufferedClientBytes:真实httptestserver + 真实 hijack,客户端先发 101 请求再立刻写入first-frame,桩远端 stream 读到的必须是first-frame起始字节(当前红:读到 0 字节/EOF)。TestForwardHTTPUpgradeKeepsUploadOpenAfterRemoteEOF:远端在 101 后写完即 EOF;断言本地后续写入仍能到达远端(用可写 spy stream 断言收到),即方向未被整体关闭(当前红)。- 运行
go test ./internal/client -run 'TestBufferedStreamCloseWriteDelegates|TestForwardHTTPUpgrade' -count=1记录红;实现后转绿;再go test ./internal/client -count=1。
回滚注意事项:纯 client 行为,无兼容性影响;回滚重新引入升级流截断与首帧丢失。
T7 截断响应与静默丢帧补日志(P1-6)
症状与根因:internal/client/forward.go:392 与 internal/server/http_proxy.go:175 都是 _, _ = io.Copy(...);已发 Content-Length 后短写只让浏览器报 ERR_CONTENT_LENGTH_MISMATCH,服务端/客户端不留痕迹。丢帧点同样无日志:internal/server/ws_client.go:495-510(队列满 → RESET)、internal/agent/session.go:523-530(信用等待失败 / 发送失败 → RESET)。
文件清单
- 修改
internal/client/forward.go(新增log/slog导入) - 修改
internal/agent/session.go(新增log/slog导入) - 修改
internal/server/http_proxy.go(copyResponse返回 error 并记日志) - 修改
internal/server/ws_client.go(补一条slog.Warn) - 修改
internal/client/forward_test.go、internal/server/http_proxy_test.go
接口约定
func copyResponse(w http.ResponseWriter, resp *http.Response) error:返回io.Copy的错误;调用点(internal/server/http_proxy.go内 3 处)改为if err := copyResponse(...); err != nil { ... },不改 HTTP 状态与响应体。- 事件名固定小写下划线:
client_response_truncated、proxy_response_truncated、client_stream_queue_full_reset、agent_stream_send_reset。字段:protocol、status_code、content_length、bytes_copied、error_class(用observability.NormalizeErrorClass)。internal/client不 importinternal/observability之外的新包,仅用 stdlib。 - 不改任何返回码、帧序、重试语义;只增加
slog.Warn。
TDD 步骤
internal/client/forward_test.go新增TestForwardHTTPLogsTruncatedResponseBody:用slog.New(slog.NewTextHandler(buf, nil))与可切换的包级clientLogger(新增var clientLogger = slog.Default(),测试内替换)驱动一次”响应体短于 Content-Length”的forwardHTTP,断言出现client_response_truncated且bytes_copied < content_length(当前红:无日志)。internal/server/http_proxy_test.go新增TestCopyResponseReportsShortBody:桩resp.Body在读到一半时返回错误,断言copyResponse返回该错误(当前红:函数无返回值 → 编译失败)。- 运行
go test ./internal/client -run TestForwardHTTPLogsTruncatedResponseBody -count=1与go test ./internal/server -run TestCopyResponseReportsShortBody -count=1记录红。 - 最小实现四处日志/返回值改造,复跑至绿。
- 追加一次性人工验证(不入库):本地以
tunnelmesh-client固定端口映射复现大 JS 资源加载,确认截断时控制台出现client_response_truncated。
回滚注意事项:仅日志与函数签名(copyResponse 为包内私有,无外部调用方)。回滚 git revert 无兼容风险。新增日志级别为 Warn,需同步确认 docs/operations/logging.md 是否需要登记事件名(本计划包含该更新)。
T8 通告窗口夹紧 + 窗口类配置落地/纠错(P2-7 / P2-8)
症状与根因
internal/server/ws_client.go:332/:340与internal/server/agent_relay_transport.go:124-131直接使用对端通告的frame.Window:过小(本仓测试用 4096,见internal/server/agent_relay_transport_test.go:49)与固定 128 KiB 刷新阈值组合成永久停摆;过大(uint32可到 4 GiB)直接把receiveBudget变成无界缓冲许可。internal/config/config.go:181-183、:246、:291、默认值:493-495、校验:684-688、:794、:805声明并校验server.stream.initial_window/window_update_threshold/max_frame_payload、*.stream.inbound_buffer_bytes,但internal/server/runtime.go:273-275只消费max_concurrent_opens/max_pending_opens/open_timeout,其余键在运行时完全不生效;docs/operations/configuration.md、docs/operations/config-examples.md仍在承诺它们。
文件清单
- 修改
internal/config/config.go(validateServerStream增加initial_window - window_update_threshold >= max_frame_payload;max_frame_payload != 32768直接报错”不支持”;inbound_buffer_bytes >= 2*32768) - 修改
internal/config/config_test.go - 修改
internal/server/runtime.go(把cfg.Server.Stream传入NewAgentRelayTransport) - 修改
internal/server/agent_relay_transport.go(构造参数agentRelayWindowConfig{advertised, threshold};OpenStream通告窗口与releaseReceiveWindow阈值改为该配置;对端request.InitialWindow走protocol.NegotiateReceiveWindow) - 修改
internal/server/agent_relay_transport_test.go(构造点与新增用例) - 修改
internal/server/ws_client.go(newRelayStream与relayToClient使用夹紧窗口;刷新阈值走protocol.ShouldFlushWindowUpdate) - 修改
internal/agent/session.go、internal/cli/root.go(inbound_buffer_bytes作为 T3inbound队列字节;常量继续作为默认值) - 修改
internal/client/session.go、internal/cli/root.go(clientinbound_buffer_bytes→frameStream.readQueue与 writerStreamQueueBytes的下界校验) - 修改
docs/operations/configuration.md、docs/operations/config-examples.md、docs/protocol/websocket-frame.md(写明夹紧规则与生效范围)、docs/operations/logging.md(T7 事件名登记) - 新建
docs/architecture/adr/0007-dataplane-window-credit-invariants.md(记录”队列 ≥ 通告窗口、窗口 − 阈值 ≥ 单帧上限、对端通告一律夹紧”三条不变量与被否决的替代方案:新增协商帧 / 每流协商)
接口约定
func NewAgentRelayTransport(manager *AgentSessionManager, windows AgentRelayWindowConfig) *AgentRelayTransport(唯一构造点:internal/server/runtime.go;AgentRelayWindowConfig{AdvertisedWindow, UpdateThreshold uint32},零值回落protocol默认,保证既有测试与 embedder 兼容)。internal/protocol/window.go的NegotiateReceiveWindow为唯一夹紧入口,Server 与 Agent 都调用它,禁止本地另写 min/max。
TDD 步骤
internal/config/config_test.go扩表驱动:initial_window=65536 + threshold=131072必须报不变量错误;max_frame_payload=65536必须报”not supported”;inbound_buffer_bytes=32768必须报下界错误(当前红:全部通过校验)。internal/server/agent_relay_transport_test.go新增TestAgentRelayClampsAdvertisedWindowFromPeer:request.InitialWindow=1024时stream.receiveBudget等于夹紧值,且 >1 MiB 数据可在超时内流过(当前红:停在 1 KiB)。- 新增
TestAgentRelayHonoursConfiguredWindow:AgentRelayWindowConfig{AdvertisedWindow: 1<<20, UpdateThreshold: 128<<10}时 OPEN_STREAM 帧Window==1<<20(当前红:恒为常量)。 internal/agent/internal/client各加一条inbound_buffer_bytes生效断言(构造 dispatcher/session 后读取entry.inbound/frameStream.readQueue容量)。- 逐条
go test ./internal/config ./internal/server ./internal/agent ./internal/client -run '<上列用例>' -count=1记录红→实现→绿;ADR 与文档手工核对无孤儿链接后运行python3 scripts/gen_doc_index.py并检查git diff。
回滚注意事项:这是本计划唯一改动配置语义的任务,必须最后合入。默认值不变(262144/131072/32768 与现常量等价),因此现网配置文件无需修改即可回滚;若运维已按文档设置过这些键,回滚后配置继续被接受但重新变为空转——需在回滚说明中显式写出。DB/Schema 无关。
T9 UDP association 数量上限(P2-9)
症状与根因:internal/client/udp_assoc.go:86-127 按 source.String() 无界建立 association,每条对应一条隧道流;只有空闲过期(:155-171)与包大小上限(:83-85),本地任意进程可无限放大 Agent 拨号池占用。
文件清单
- 修改
internal/client/udp_assoc.go - 修改
internal/client/forward.go(UDPForwardConfig增MaxAssociations,零值取默认) - 修改
internal/client/udp_assoc_test.go - 修改
docs/user-guide/client.md(如已有 UDP 转发段落则补该上限)
接口约定
const defaultUDPAssociationLimit = 1024(不新增配置键,避免与 T8 的配置面变更混在一起)。UDPAssociationConfig.MaxAssociations int;HandleDatagram在达到上限时先淘汰”最久未使用且非当前包来源”的 association,全部仍在使用则返回errors.New("client: UDP association limit reached")并丢弃该包(UDP 语义下丢包优于无界增长)。
TDD 步骤
TestUDPAssociationLimitEvictsLeastRecentlyUsed:塞满MaxAssociations=4后从新来源发包,断言Len()==4、被淘汰者stream已Close、新 association 建立成功。TestUDPAssociationLimitDropsWhenNothingIdle:4 条同一时刻 touch,第 5 个来源返回错误且不新建。- 运行
go test ./internal/client -run TestUDPAssociationLimit -count=1记录红(当前无上限 → 第 1 项断言失败),实现后转绿;再go test ./internal/client -count=1。
回滚注意事项:仅 client 本地策略,回滚无数据影响;如现网依赖大量并发 UDP 源(如 DNS 递归),1024 上限需通过 MaxAssociations 显式调大(默认值保守)。
验证(全部任务完成后依次执行)
go build ./...
go test ./internal/session ./internal/protocol -count=1
go test ./internal/agent ./internal/client -count=1
go test ./internal/server -count=1
go test ./internal/relay ./internal/e2e -count=1
go test ./... -count=1
go test -race ./internal/session ./internal/protocol ./internal/agent ./internal/client -count=1
go test -race ./internal/server -count=1 -timeout 20m
go test -race ./... -count=1 -timeout 30m
go vet ./...
git diff --check
python3 scripts/gen_doc_index.py && git diff --stat docs/
Web 未被触碰,因此不需要 npm test -- --run / npm run build;如 T7 之后决定在管理后台暴露截断事件,则另开计划并补 embed 验证。
提交与 PR
- 完成后再向用户请求授权;授权后按任务边界拆分提交:
fix(session):、fix(server):、fix(agent):、fix(client):、docs(config):等,subject 祈使句 ≤50 字,body 写”为什么”。 - PR 描述文档:
docs/pull-requests/2026-09-24-dataplane-flow-control-hardening.md(英文正文,面向 Reviewer),必须含目标分支、用户影响、无 Schema/OpenAPI 影响的显式声明、每任务测试证据(含红→绿输出摘要)、灰度与回滚步骤、Reviewer 关注点(T3/T5 的每流队列内存上界、T8 的配置语义变化)。 - 已知遗留(不在本计划范围,需在 PR 中记为技术债务):
internal/agent/session.go:515-517取消时丢弃已读缓冲;internal/agent/session.go:586-590、internal/server/ws_client.go:575-581流被移除时返回nil继续发帧;internal/relay/transport.go:781-782的Close()不唤醒RecvMsg;internal/server/http_proxy.go:139-141的go io.Copy(stream, clientConn)无半关闭。