Skip to content

fix(tty): WS overflow 修复(Phase A/B/C)+ 压缩管道 + 实测验收 (issue #114) - #129

Open
linletian wants to merge 18 commits into
developfrom
fix/fix-issue114
Open

linletian wants to merge 18 commits into
developfrom
fix/fix-issue114

Conversation

@linletian

@linletian linletian commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • 修复 tty WebSocket overflow 问题(issue tty websocket: 大缓存终端实例 subscriber overflow 导致 1013 反复断开重连(v0.6.0 develop) #114),含 Phase A/B/C 全部改动
  • overflow-notice 聚合 + 窗口化回放双封顶 + 慢链路心跳(Phase A)
  • 压缩消费管道 + 指数退避(Phase B);膨胀率守卫经 PR 评审实证重裁定后废止(v8:合法流实测 118–214x,无可放置阈值,详见裁定书)
  • app-layer deflate-raw 压缩 + wire/logical 双口径埋点(决策 5a)
  • netem 隔离 netns 实测验收 + 文档定稿(CHANGELOG/API/DATA-COLLECTION)

Test plan

  • CI 全绿(2026-10-21 synchronize 后:Go CI ubuntu+macOS、Reasonix contract 双平台全绿)
  • netem 实测验收通过(§五)(见 ACCEPTANCE-2026-10-20.md:AC1a PARTIAL 诚实分级,其余全 PASS,双评审复跑复现)

…verflow (issue #114)

Data collection only, no behaviour change. Server: metered *ws.Conn wrapper
(internal/app/tty_metrics.go) timing every TTY socket write, logging
handshake replay stats, per-overflow drain-rate fields, per-connection
write-block summary, and >=1s slow-write lines (op=ping proves heartbeat
starvation). Client (index.html): per-connection ready/close lines with
rx bytes, #94-bracketed replay volume, tab visibility and xterm.js parse
backlog, visibilitychange logging, and window.__ttyMetrics() snapshot.

Respects node-test slicing rules (no new free identifiers in sliced
blocks, document behind typeof guard) and keeps the pinned overflow log
line and reconnect source line verbatim. Full test suite passes; verified
end-to-end with a live yes-flood smoke run (overflow fired 56ms after
subscribe; server binBytes matched client rx to the byte).

Runbook: docs/plans/fix-issue114-tty-overflow/DATA-COLLECTION.md
… -tags ttymetrics (issue #114)

Build-channel gating so beta releases keep the instrumentation long-term
while stable ships none of it:

- internal/app/tty_metrics.go splits into shared types plus
  tty_metrics_on.go (//go:build ttymetrics: real timing/logging) and
  tty_metrics_off.go (//go:build !ttymetrics: passthrough stubs that
  inline away); ttyMetricsBuild is the channel switch.
- release-beta.yml builds both binaries with -tags ttymetrics;
  release.yml is untouched (no tag). go-ci.yml tests and builds both
  variants so neither bit-rots.
- withServerRevision hands out the mw_tty_metrics=1 cookie only in beta
  builds; the client half (console lines, visibility logging, xterm.js
  write-callback timing, __ttyMetrics dump) arms only when the cookie is
  present, so stable pages stay fully silent.

Review follow-ups (REVIEW-TEMP-fix-issue114-tty-metrics.md, F1-F5, all
confirmed valid and fixed):

- F1: stable builds now actively EXPIRE a stale mw_tty_metrics cookie
  planted by a beta daemon earlier in the same browser session, instead
  of leaving the client half armed with no server counterpart.
- F2: the two EndOffset probes (handshake entry, overflow branch) and
  the handshake metrics snapshot are const-gated behind ttyMetricsBuild,
  so stable builds compile the manager reads away entirely; the off-file
  comment no longer overclaims zero cost.
- F3: cookie checks use a boundary-bracketed match
  ("; mw_tty_metrics=1;" in "; "+cookie+";") so suffix-named
  lookalikes cannot false-arm the gate (module helper and connectTTY's
  typeof-guarded local, per the node slicing rules).
- F4: the client close line now carries the xterm.js parse backlog in
  both scopes: session-cumulative (writeCalls/writeWait*) and
  per-connection deltas (connWriteCalls/connWriteWaitMs) against a
  baseline snapshotted at connect.
- F5: the instrumentation gains automated coverage:
  tty_metrics_test.go (tagged: handshake/conn-closed/overflow lines with
  field payloads, cookie planted only when absent) and
  tty_metrics_cookie_off_test.go (untagged: stale cookie expired, never
  planted).

Verified: both build variants compile; full untagged suite green (21
packages); tagged app/ws tests green; internal/ui node slice tests pass;
gofmt clean; beta/stable runtime cookie behaviour smoke-checked.
…114)

- FIX-PLAN v6:两引擎诊断(生产>排空为 1013 扳机 / 回放跟随活 head 时长只受预算约束)+ 五决策(drop-oldest+notice、4MB drop 队列、closing sync 后 60s 稳定锚点退避、write deadline、窗口化回放起点+总量双封顶)+ 跨境慢链路适配(app-layer 压缩、W 调参);协议契约(caps/overflow 帧/四组合矩阵/fencing)、Phase A/B/C 分解、AC1-8
- EVIDENCE-2026-10-17:mwbeta docs 实例 52 次 overflow 风暴生产证据(四格计数口径可复现,三次订正史)
- DATA-COLLECTION:埋点手册 + 决策矩阵作废行修订注记(pacing 弯路)
- ORCA-RESEARCH:stablyai/orca 慢链路模式调研(水印背滞、resume 确认、generation+fencing、read-mostly 快照)
…licy keeps 64 verbatim (issue #114 decision 1b)

OverflowDropOldest subscriptions get capacity 4096 chunks (PTY reads
1024B -> ~4MB exact per-subscriber upper bound), overridable per
subscription by MYWORKTREE_TTY_DROP_QUEUE_CHUNKS (>0 wins, anything
else falls back to 4096). OverflowClose and SubscribeOutput keep the
#82 contract's 64 as literals the env can never move.

Arithmetic (FIX-PLAN v6 decision 1b, storm-day measurements): burst
production 150-358KB/s minus median drain 47KB/s = a ~103-311KB/s
gap; 4MB buys a ~13-39s reset cadence where 64KB filled in ~0.2-0.6s
(the 19 sub-second re-overflows cross-check). No fixed queue rescues
a pathological consumer - that stays the client's reset+tail story.

Dual-reviewed APPROVE (muse-spark-1.3-contributor, minimax-m3); the
mixed-policy test pins legacy 64 as a literal and asserts the drop
queue is strictly larger - #82 assertions unweakened. Tests pin the
default capacity against runner-env drift (t.Setenv+Unsetenv hygiene).
…记录 (issue #114)

- A0-GATE: docs 实例 8MiB 真实输出 flate BestSpeed+1KB sync flush 实测 5.719x(阈值 2.0x),5a 编码放行;工具 tools/a0compress(独立嵌套 module,仅 stdlib)
- FIX-PLAN v7:§四.0 状态表回填(Phase A 服务端除 5a 全部落地);采纳评审 B round2 实证订正(部分读非对齐、windowed/budgetExhausted 组合可达)
- TASK-A2 仲裁修订记录:F1(W=0 构造性纯 tail)/F2(心跳冲刷 pending)/F3(读长裁剪精确 <=W)成立返工,F5 裁定接受,允许清单对齐计划
- TASK-A3(服务端压缩)/TASK-B(客户端)/TASK-C(收尾)任务书预立
- REVIEW-A2-RECORD:两轮双评审(A=muse-spark/B=MiniMax)结论与仲裁处置全记录
…slow-link 心跳 (issue #114 Phase A)

决策 1/1b/3/4/5b/5c 服务端落地(5a 压缩除外,TASK-A3 接续):
- overflow-notice caps:drop-oldest 订阅保留连接,wire notice 250ms 区间聚合
  (首增量即发,<=4/s),输出停止时心跳分支冲刷 pending(评审 B F2)
- 回放窗口 W_eff=min(MYWORKTREE_TTY_REPLAY_WINDOW_BYTES 默认 1MiB,
  caps replay-window=<bytes> 只缩不涨):起点 clamp 复用 #94 start-sync,
  总量封顶 streamed>=W_eff 复用预算截断语义,读长裁剪精确 <=W(评审 B F3);
  W=0 构造性纯 tail(pureTail 跳过读循环,评审 B F1)
- write deadline 15s 默认+MYWORKTREE_TTY_WRITE_TIMEOUT_MS(每连接解析),
  ready 帧后武装,c.mu 内逐写装弹
- slow-link caps:服务端 ping 10s->15s(未声明连接逐字不变)
- 埋点:handshake 行增 windowed/droppedFromWindow;1013 legacy 路径逐字不变
双评审两轮闭环(muse-spark+MiniMax),F1~F8 处置见 REVIEW-A2-RECORD;
基线:build/vet/gofmt/app test/-race/全仓全绿
…e/logical 双口径埋点 (issue #114 决策 5a)

- ttyCompressor:每连接持久 raw-deflate 流(BestSpeed、scratch 复用、不中途 Close),
  ttyCompressingConn 挂 metered wrapper 外侧,WriteBinary 为唯一压缩咽喉;
  TEXT/ping/Close 逐字原样;legacy(comp==nil)零 flate 对象逐字透传
- ready 帧回声:compress 连接 {"type":"ready","compress":true},legacy 逐字不变
- flate 错误->1011 关连接(不撞 1013 retry 语义);sink 可注入(newTTYCompressorSink)+失败路径 pin
- cursor/offset 全逻辑口径(streamed/endOffset/start-sync/closing sync 用压缩前字节);
  被 drop chunk 从未入流;回放+live 共流;重连新流独立
- 埋点:conn-closed/slow-write 行 wireBytes/logicalBytes;handshake 行 compress=true +
  replayLogicalBytes(评审 B F1:replayBytes 在 compress 连接为 wire 口径,冻结不改名,
  验收 3.b 读 replayLogicalBytes/replayMs);overflow 行 liveLogicalBytes
- A0 门 PASS(5.719x,raw deflate 1KB 帧);四组合矩阵/近似 token/持久流/drop 后 inflate 单测 pin
双评审两轮闭环(round2 双 APPROVE);判据:build/vet/gofmt/test/-race/ttymetrics/全仓全绿
… + TASK-C 增补 (issue #114)

- A0-GATE:「deflate-raw 2 字节流头(CMF+FLG)」系 zlib(RFC1950)误记,raw deflate(RFC1951)无流头;测量值由 flate.Writer 直产不受影响(编码 agent 质疑,仲裁实证订正)
- FIX-PLAN §五.3.b:compress 连接验收带宽读 replayLogicalBytes/replayMs(字段已落地);replayBytes 在 compress 连接为 wire 口径冻结不改名
- FIX-PLAN §五.5:RSS 上界订正为 4MB×N+~1.3MB×N(评审 B 实测 flate BestSpeed 1.17MB/writer,hashHead 512KB+hashPrev 128KB 结构地板 level 无关);原 64KB×N 系误估;klauspost/compress 备选留未来项
- TASK-C D3 增补:DATA-COLLECTION 五列字段表与 liveBytes wire 口径注记、a0compress StreamHeader=2 幻影订正(仅 mean/fr 显示列,ratio 不受影响)
…ase B)

- caps 声明 overflow-notice 恒带、compress 走 DecompressionStream('deflate-raw')
  特性探测降级;ready.compress 回声门控压缩/明文两态,R16 回滚面自洽
- 压缩管道:每连接持久单流单 reader,pump 内 await term.write(data,cb) 保序背压;
  per-frame FIFO 帧账本(喂入时捕获 live 标志)——回放帧落屏不推进游标,
  closing sync 绝对赋值唯一终权(评审 B F1 修复);回放期推进规则保中段断连
  精确续传(评审 B R2 修复)
- 滑窗膨胀率守卫(pin5 v7 重裁定):trailing 2MB 窗口 + wire 修正分母
  (wireFedTotal−wireConsumedTotal@窗口起点)+ 比率 32:1 触发 + fail-closed;
  触发走可见提示+reset+tail 重连(与 notice 同构);A0 合法上限 13.056x 实测
  余量充足(评审 B 五轮实证收敛)
- backlog 界 2MB(待解压+待写),双触发点均带可见提示
- overflow notice:白名单+console+活动会话状态行+term.reset+无 since tail 重连
  计入退避;generation fencing 三层残余收编
- 指数退避 min(1000*2^n,cap)*(0.75+rand*0.5),前台 8s/后台 30s 前置 cap,
  重置锚点=closing sync 后存活 60s;两处固定 5s 排程点替换,入口唯一
- #73 English-only 保持(状态行英文文案,仲裁裁定);切片纪律+真实
  DecompressionStream 用例组;node 166 全绿
双评审五轮闭环(终轮双 APPROVE,残余 nit 修复后仲裁者独立确认)
…稿 + 计划 v7 修订集 (issue #114)

- CHANGELOG Unreleased 顶部新增 issue #114 fix 条目(数字纪律:14 组数字全部
  一手出处可溯;slow-link 句为如实口径——服务端 ping 10s→15s、45s deadline 与
  客户端 30s stale 不变、60s 客户端半边本期未实现;退避为前置 cap 口径,
  有效上限 10s/37.5s;drop 4096/close 64 逐字不变分清)
- API.md:backlog/degenerate/overflow 三守卫状态行原文并列(与 index.html
  实发逐字一致)
- DATA-COLLECTION:服务端五列字段表(wireBytes/logicalBytes/replayLogicalBytes/
  liveLogicalBytes/compress=true)与 tty_metrics_on.go 行格式逐字一致;
  compress 连接口径订正(速率读 liveLogicalBytes、验收算术读
  replayLogicalBytes/replayMs);客户端埋点归属订正(首见 55f5518,门控
  ca875d1,Phase B 未新增行)
- FIX-PLAN v7 修订记录补全 7 项(含 pin5 滑窗膨胀率守卫方案性重裁定、
  决策 2 前置 cap、§五.5 RSS ~1.3MB×N、§五.3.b 字段口径、notice 英文文案
  裁定、§四.0 回填、A2 部分读订正);§四 B-9/§五.4/§四 B-6 废止口径清扫
- A0-GATE:StreamHeader 幻影订正(2→0)+复跑留档(md5 一致、ratio/分位不变)
- a0compress 工具 StreamHeader=2 幻影修复(仅 mean/fr 显示列)
- REVIEW-B-RECORD:Phase B 五轮双评审闭环记录(含仲裁者设计被推翻的如实留档)
双评审收敛复审通过(A 条件 APPROVE/B APPROVE,D1/D2/B-C1~C5 残余 nit 已收口);
判据 go test ./... 全绿、a0compress build/gofmt/vet 绿
…-1013 matrix test (issue #114 flake)

The test asserted an order it never synchronized: with the handler not yet
parked on <-outputChan, the flood fills the 16-slot buffer and the overflow's
drain wins the race against the handler's first receive — zero binary frames
precede the 1013 close, which is legitimate production behavior (app.go's
close() readies the parked receiver and races the drain for the rest).
Fix: publish one chunk and read back its deflate frame before flooding, so a
binary frame is deterministically on the wire ahead of the 1013. Assertions
strengthened, not weakened (the sync point itself pins live delivery; the old
zero-frame pass shape becomes a hard 10s timeout failure).
Flake was ~7.5-15% under contention (review evidence), existing since d2fc415.
Verified: 200 consecutive runs green + -race -count=20 + full suite green;
double-review final round APPROVED by both reviewers.
- ACCEPTANCE-2026-10-20.md:AC1a PARTIAL(wire notice 到达=pin+常量互补覆盖)
  /AC2/AC3a-d/AC5/AC7 PASS——端到端计时 e2eCatchupMs、逐帧 cursor 连续性
  (continuityOK=true)、压缩判别对照(明文溢出 vs 压缩 41.8-42.2x 零溢出)、
  AC5 真 5 路(handshake 5 distinct+conn-closed×5+RSS 同进程采样)、
  AC7 env=5000→blockMs=5002 有界回收;工件轮次戳目录留存
- tools/acceptance/:netem 验收 harness(internal/ws 客户端复用,嵌套 module
  +replace)+run-netem.sh(netns 隔离,trap 复原,单 daemon 全程)
- FIX-PLAN v7 收尾:§五.3.c 误杀定义加适用域限定(健康慢链路;消费者排空
  持续低于生产时的 deadline 回收=设计保护路径);§五.5 RSS 双口径注记
  (分配上界 1.3MB×N / 常驻 30-280kB 波动,ring 不在判据)
- 过程:五轮双评审闭环;评审 B 档内反例驱动口径收窄,数字三轮同源统一
判据:go test ./... 全绿(flake 修复态 4b0525b);宿主 lo 零改动;prod 未动
@linletian

Copy link
Copy Markdown
Owner Author

评审结论:当前不建议合并(阻塞项需先处理)

本评论只做评审,未修改仓库或 PR 分支。以下按严重程度列出。

P0 — 与 develop 冲突,且没有任何 CI check

gh pr view 129 当前为:

  • mergeable=CONFLICTING
  • mergeStateStatus=DIRTY
  • statusCheckRollup=[](gh pr checks 129 报 no checks reported)

在临时 clone 中执行 git merge --no-commit --no-ff origin/develop 可稳定复现 6 个冲突文件:

  • CHANGELOG.md
  • docs/plans/fix-issue114-tty-overflow/DATA-COLLECTION.md
  • internal/app/app.go
  • internal/app/tty_metrics.go
  • internal/app/tty_metrics_on.go
  • internal/ui/static/index.html

另外,分支前两个提交 55f5518、ca875d1 的内容已经通过 PR #119(781f30f)进入 develop,但它们本身不是 develop 的祖先。直接 merge 会把这两份改动与 #119 的后续修复再次叠加,冲突已实证。建议先 rebase/squash 到当前 develop,解决上述 6 个文件时保留 #119 对 tty_metrics* 的后续修改,然后重新跑测试与 CI;PR body 的“CI 全绿”目前仍未勾选。

P1 — 客户端 32:1 膨胀率阈值与 PR 自己的 AC3a 实测冲突

  • 阈值定义:internal/ui/static/index.html:2705-2706,2,000,000 字节窗口、32:1 上限。
  • 判定逻辑:internal/ui/static/index.html:6202-6221,窗口内 2,000,000 > 32 * wireSpan 即 reset + tail reconnect。
  • PR 自己的验收:docs/plans/fix-issue114-tty-overflow/ACCEPTANCE-2026-10-20.md:21,62 测得压缩流 binaryWireBytes=236100、inflatedBytes=9890880,即 41.8–42.2x;该结果被标记为 PASS(0 overflow)。

按窗口口径,2 MB 输出对应的 wire 约为 2,000,000 / 42 ≈ 47.6 KB,32 * 47.6 KB ≈ 1.52 MB < 2 MB,即会命中守卫。实际分母会受 feed lead 抬高而把计算比值压低,但这使结果依赖时序;无论如何,AC3a 自己使用的合法 payload 已经否定了“合法流 13.056x 上限、32:1 足够安全”的校准假设。

更关键的是,AC3a 用的是 docs/plans/fix-issue114-tty-overflow/tools/acceptance/main.go 的 Go harness,它只做 inflate 和计数,不执行 index.html 里的 ratio guard;客户端测试里的无告警用例又特意把真实 deflate 比率固定在约 13.2:1(internal/ui/testdata/tty_overflow_reconnect.test.mjs:936-943)。因此当前测试无法证明生产浏览器不会在 AC3a 这类重复输出上反复 reset。

建议:用与 AC3a 相同的 payload 跑真实客户端(或至少把 pumpTTYDecompressor 抽出来做 Node 级测试),确认 guard 的窗口比值;不要只调高 32 这个数字。若确实会误触,需要重新设计阈值/窗口,或把“合法流”上界按生产 BestSpeed 和实际帧尺寸重新标定。

P2 — 压缩管道异常路径会静默失败或错误回退明文

  1. startTTYCompressedPipe 的构造失败分支(internal/ui/static/index.html:6097-6104)只设置 ttyCompressed=false 并返回 false;ready 调用点(:7079)忽略返回值。之后服务端仍按 compress:true 发送压缩二进制帧,而客户端走 decodeTTYOutputChunk 明文路径,会把 deflate 字节当终端文本画到屏幕上。无 DecompressionStream 的分支会 reset/reconnect,这个分支却不会,行为不一致。
  2. pumpTTYDecompressor 的 reader.read() 异常(:6153-6155)和 feedTTYCompressedFrame 的 writer.write 异常(:6303-6305)都直接 return,既不记录原因,也不 reset/reconnect。真实 deflate 错误或流损坏时,终端会静默冻结,而 socket 仍保持打开。

建议:构造、feed、read 的真实失败在 generation/socket 仍属于当前连接时调用 resetTTYStreamAndReconnect(或主动关闭并重连);teardown 触发的 abort 用 generation 判断后静默放行即可。构造失败尤其不能退回明文消费。

P3 — overflow 指标的 liveFrames 包含 replay 帧

internal/app/tty_metrics_on.go:99-122 的注释和 DATA-COLLECTION.md 都把 liveFrames 定义为“订阅后、溢出前交付的 live 输出”,但实现直接用了 m.binFrames:

m.binBytes-m.replayBytes, m.binFrames, ...

binFrames 包含握手 replay 帧,且没有对应的 replayFrames 快照。结果 liveFrames 偏大,liveBytes/liveFrames 的帧长估计会被 replay 拉偏。建议在 handshake 结束时同时快照 replayFrames,overflow 行使用 binFrames - replayFrames;或把字段改名为 totalFrames 并同步文档。仅影响诊断,不影响协议。

已验证 / 说明

  • 在带 .git 的临时 clone 中 go test ./...、go test -tags ttymetrics ./internal/app ./internal/instance/pty ./internal/ws ./internal/framework ./internal/ui、go test -race ./internal/app ./internal/instance/pty ./internal/ws ./internal/framework 均通过;go vet ./...、gofmt -l . 无输出。
  • 用 git archive 解出的无 .git 副本曾导致 TestWithAuth_* 报 not a git repository,这是测试环境问题,不是 PR 回归;以上结论以带 .git 的 clone 为准。
  • 本次评审只读,没有修改工作区文件,也没有改动 PR 分支。

— opencode (deepseek-v4.1-flash-free)

@linletian

Copy link
Copy Markdown
Owner Author

评审结论(第二轮 · 补充)

已有评论的 P0–P3 我逐条复核,全部成立。本轮做三件事:①把 P0 从"会冲突"推进到"已跑通的解法";②用出货常量独立复算 P1,并找到 PR 自己的测试文件里已经写下的佐证;③两条本轮新增发现。


P0 补充 — 冲突面实测 + 已验证的解冲突配方

git merge-tree --write-tree origin/develop origin/fix/fix-issue114 实测:6 个冲突文件、8 个 hunk(internal/app/app.go 5、internal/ui/static/index.html 2、CHANGELOG.md 1)。

我在临时 clone 里把整条路径跑通了:

  1. git merge origin/develop -X ours → 6 个冲突全部自动解开,develop 的特性自动回来(merged index.html 里 iosIme 112 处、startWTRename 3 处、fetchMonitorAlerts 5 处,与 develop 逐处一致)。
  2. 只有 CHANGELOG.md 需要手工合并。-X ours 会拿 PR 侧整段替换 develop 的 Unreleased 列表(实测 develop 侧 14 条 → 合并后 10 条,mwbeta 长驻进程被 systemd-oomd OOM 杀掉(内存峰值 9.3G scope),托管会话随之中断 #111 P2 那条连同它引用的 23 cases — that file's total 计数一起消失,随即触发 TestResponsiveLayerChangelogCounts 失败)。正确做法:以 develop 的 CHANGELOG 为底,在 ## Unreleased 下插入 PR 的 tty websocket: 大缓存终端实例 subscriber overflow 导致 1013 反复断开重连(v0.6.0 develop) #114 条目,而不是替换。
  3. 按上面两步合并后的树:go build ./...、go vet ./...、go test ./... 全绿(internal/ui 7.2s,node 用例真跑);go build -tags ttymetrics ./... + go test -tags ttymetrics ./internal/app ./internal/ws 绿;go test -race ./internal/app ./internal/instance/pty ./internal/ws ./internal/framework 绿。

反向用例(请不要这样解):git checkout --ours <冲突文件> 会把 develop 在这两个文件里的非冲突增量一并丢掉。实测碎 5 个 develop 侧测试:

也就是说 iOS IME 桥 / 工作树改名 / Resource Monitor 告警这三个已发布特性,都靠 develop 那批新 .mjs 守着——解冲突时它们就是现成的探针,不必另写测试。

另外,internal/app/tty_metrics.go、internal/app/tty_metrics_on.go、docs/plans/fix-issue114-tty-overflow/DATA-COLLECTION.md 这三个 add/add 是良性的:逐行 diff 确认 PR 侧相对 develop 侧是纯增量(+76 / +61 行,只追加字段、不动既有列),直接取 PR 侧即可,不必手工合。


P1 独立复核 — 32:1 阈值误触发可以用出货常量算出来

出货常量:internal/ui/static/index.html:2705 TTY_INFLATION_WINDOW_BYTES = 2000000、:2706 TTY_INFLATION_RATIO_LIMIT = 32、:2710 TTY_INFLATION_SAMPLE_BYTES = 262144。

触发条件 internal/ui/static/index.html:6213:

if (wireSpan <= 0 || TTY_INFLATION_WINDOW_BYTES > TTY_INFLATION_RATIO_LIMIT * wireSpan)

即 wireSpan < 62500 就命中。

本 PR 自己的验收(docs/plans/fix-issue114-tty-overflow/ACCEPTANCE-2026-10-20.md 结论速览表与 §AC3a 逐条证据)三轮实测 ratio = 41.89 / 41.98 / 42.17x,压缩臂 notices=[] 判 PASS。2,000,000 inflated 字节对应的真实 wire ≈ 2,000,000 / 41.89 ≈ 47,746 < 62,500 → 这条"合法流"必然命中守卫。

最硬的佐证来自 PR 自己的测试文件——internal/ui/testdata/tty_overflow_reconnect.test.mjs:67-70 已经写着:

CAVEAT (rework I2, Phase C tracking): the ~13.2:1 ratio is pinned to the server's BestSpeed basis (level=1, app.go:2696). Under Go DefaultCompression this SAME content measures ~58.3:1 and WOULD trip the 32 limit - any server compression-level change must re-pin both the threshold and this recipe.

即:同一份"合法"内容,在换一种压缩级别后是 58.3:1、会踩 32 的阈值;而 AC3a 在出货的 BestSpeed 上实测 41.9x。"32:1 高于合法流上界、留有安全余量"这个前提,被本 PR 自己的两个产物同时否证——代码注释里那句 "A legit stream at A0's true ceiling 13.056:1 therefore computes <= ~21x, still under the 32 limit" 用的是 A0 的 13.056:1,与 41.9x 已不是同一个口径。

第二个机制,比阈值本身更要紧:出货分母 wireSpan = pipe.wireFedTotal - ref.wireConsumed(index.html:6209)系统性偏小。

  • wireConsumedTotal 在 await pipe.writer.write(u8) resolve 时就累加(index.html:6311)——transform 一接收就算"已消费",远早于实际 inflate 与 xterm paint;
  • ref.wireConsumed 取的是窗口起点那一采样的已消费量(index.html:6195)。

分母被系统性压低 → 比值被系统性抬高 → 比"真实窗口 wire"更容易命中。同一机制下 wireSpan <= 0 这条 fail-closed 的"退化流"判定也会被健康流踩到:feed 领先 pump 足够多时(writer.write() 立即 resolve,pump 要等 inflate + xterm paint),窗口起点的 wireConsumed 可能已经追平 wireFedTotal,分母为 0 → 一条健康流被判成 degenerate deflate stream,走 reset + 重连。

佐证链:验收 harness docs/plans/fix-issue114-tty-overflow/tools/acceptance/main.go 里没有任何 ratio / 膨胀率守卫(只有 inflate + 计数),所以 AC3a 的 PASS 与客户端守卫无关;node 用例把真实 deflate 比率固定在 ~13.2:1(tty_overflow_reconnect.test.mjs:936-943),够不到 41.9x 档。

结论与前一轮一致,但这不是"把 32 调高一点"能解决的:先用 AC3a 同款 payload 驱动出货的 guard(把 pumpTTYDecompressor 抽出来在 node 里跑),拿到真实窗口比值,再谈阈值与窗口口径。


本轮新增发现 A — MYWORKTREE_TTY_DROP_QUEUE_CHUNKS 无上界,超大值在订阅时 panic

  • 位置:internal/instance/pty/driver.go:471-481(subscriberQueueChunksDrop)、internal/instance/pty/driver.go:943(make(chan string, capacity))。
  • 触发:MYWORKTREE_TTY_DROP_QUEUE_CHUNKS=9223372036854775807 → strconv.Atoi 在 64 位下成功(仍在 int64 范围内)→ make(chan string, 9223372036854775807) → runtime panic makechan: size out of range(本机 Go 实测复现)。
  • 影响:panic 发生在 WS handler 内 → 被 net/http 的每连接 recover 兜住 → 守护进程存活,但该连接被杀、日志里留一条 panic 栈。driver.go:467-470 的注释 "an operator who sets 1000000 owns the RSS that buys" 只覆盖了"大但可分配"的值;越界值不是 RSS 问题,是 panic。
  • 建议:加一个上界(例如 ≤ 1<<20),越界回落 defaultSubscriberQueueChunksDrop。这与本 PR 其他 env 的处理一致——ttyWriteTimeout()(internal/app/app.go:2504-2514)、ttyReplayWindowDefaultBytes()(internal/app/app.go:2525-2535)都是越界回落默认值。

本轮新增发现 B — 压缩管道构造失败会静默退回明文消费

  • internal/ui/static/index.html:7078-7080:if (controlMsg.compress === true) { startTTYCompressedPipe(session, ws); } —— 返回值被丢弃。
  • internal/ui/static/index.html:6098-6103:catch 分支只做 session.ttyDecompressor = null; session.ttyCompressed = false; return false;,不 reset、不重连。
  • 对照 internal/ui/static/index.html:6050-6053:DecompressionStream === undefined 分支是 reset + 重连的。两个"能力不可用"的分支行为不一致。
  • 后果:new DecompressionStream("deflate-raw") 构造抛异常时,客户端回退明文消费,而服务端仍按 compress:true 发 deflate 字节 → deflate 字节走 index.html:7281 的明文分支被当终端文本画到屏幕上。
  • 建议:构造失败同样走 resetTTYStreamAndReconnect(或直接 close + 重连),绝不能静默退回明文消费。这是前一轮 P2-1 的确切落点与对照分支。

核验记录

  • 本 PR 分支(未合并):go build ./...、go vet ./...、go test ./... 全绿;go build -tags ttymetrics ./...、go test -tags ttymetrics ./internal/app ./internal/ws 全绿;docs/plans/fix-issue114-tty-overflow/tools/{a0compress,acceptance} 两个独立 module go build + go vet 通过。
  • node 用例是真跑的:TestTTYOverflowReconnectHandling PASS(node --test testdata/tty_overflow_reconnect.test.mjs),不是 skip。
  • 按上面 P0 配方合并后的树:go build / go vet / go test ./... / 带 tag / -race 全绿。
  • 本次评审只读:未修改本仓库工作区、未改动 PR 分支、未向 PR 提交任何内容;所有合并实验都在 /tmp 的临时 clone 里做,已清理。

— Mavis(minimax Code,MiniMax-M3.1-Flash)|GitHub 账号:linletian

…n-ratio guard, harden compressed-pipe failure paths, liveFrames excludes replay, drop-queue env bound (issue #114)

- P1 (plan v8 re-arbitration): remove the sliding-window inflation-ratio
  guard — legit BestSpeed streams measure 118-214x (yes-style 64KB frames),
  past ANY placeable fixed threshold (deflate ceiling ~1032:1), and the
  lag-corrected denominator collapses to <=0 whenever one frame's inflated
  span exceeds the window (>31.25x at 64KB frames, the replay norm), so the
  fail-closed branch false-killed healthy streams. Protections that remain:
  inflate/read/write errors reset+reconnect visibly, the 2MB backlog bound,
  and awaited term.write backpressure.
- P2: compressed-pipe construct/read/write failures now split on
  generation/socket: current connection -> visible status line + reset +
  tail reconnect (NEVER a silent plaintext fallback or frozen terminal);
  teardown-induced aborts stay silent via the fence. The
  no-DecompressionStream branch gains the same visible status line
  (isomorphic with the construction catch, review A N1).
- P3: tty-metrics overflow line's liveFrames is now binFrames-replayFrames
  (snapshot at handshake end), matching its documented live-only definition
  and the liveBytes family; frozen column layout unchanged.
- R2-A: MYWORKTREE_TTY_DROP_QUEUE_CHUNKS gains a 1<<20 ceiling — larger
  values fall back to the default instead of panicking makechan at
  subscribe time (reproduced: maxInt -> 'size out of range').
- Tests: ratio-guard cases replaced by a real-DecompressionStream yes-style
  >32:1 survival anchor; new fault-injection cases pin all four failure
  directions (current-gen reset / fenced silent, read AND write); a flaky
  liveFrames>0 assertion removed (legal 0-live-frame overflow race, review
  B NIT-1, 300-run verified); metrics/env-bound edge cases added.

Double-review record: docs/plans/fix-issue114-tty-overflow/REVIEW-PR129-RECORD.md
@linletian

Copy link
Copy Markdown
Owner Author

评审意见闭环回复(2026-10-21,全部修复已推送 + CI 全绿)

感谢两位评审。所有意见经逐条取证(代码走查 + 实测复现 + 测量实验)全部成立,修复与验证已完成。当前 PR 状态:MERGEABLE / CLEAN,4 个 check(Go CI ubuntu+macOS、Reasonix contract 双平台)全绿。裁定全记录见 docs/plans/fix-issue114-tty-overflow/PR129-ADJUDICATION.md,双评审闭环见 REVIEW-PR129-RECORD.md。

P0 — 冲突与 CI ✅ 已解决

  • 按第二轮给出的配方执行:git merge origin/develop -X ours(6 冲突自动解)+ CHANGELOG.md 手工插入式合并(以 develop 为底插入本 PR 的 tty websocket: 大缓存终端实例 subscriber overflow 导致 1013 反复断开重连(v0.6.0 develop) #114 条目,而非替换)——合并提交 a8d93c8。
  • 合并保真经独立双评审验证:树级复现比对与独立复现的 -X ours 树仅差手工插入的 13 行 CHANGELOG;双边单边文件 sha256 全等;探针 iosIme 112 / startWTRename 3 / fetchMonitorAlerts 5 与 develop 逐处一致;mwbeta 长驻进程被 systemd-oomd OOM 杀掉(内存峰值 9.3G scope),托管会话随之中断 #111 P2 条目(含 "23 cases — that file's total")完整保留,TestResponsiveLayerChangelogCounts 等点名测试全 PASS;archive 副本全量测试独立复跑绿。
  • 对配方的实证补充:纯 -X ours 会把 CHANGELOG 解成 9 条(丢 develop 6 条),手工步骤确为必需——与第二轮的警告一致。
  • CI:opened 事件未触发的原因未能坐实(工作流文件无损坏、非 draft);本次 synchronize 推送后两个工作流正常开跑并全绿。
  • 遗留提示:本地 develop 领先 origin/develop 5 个未推送提交(CHANGELOG 散文 + fix-issue115 文档)。若其推送,本 PR 需再合一次 CHANGELOG(其中不含计数短语,守卫不会红)。

P1 — 32:1 膨胀率守卫 ✅ 接受,且比评审所述更重:守卫已整体移除

两位评审的两条机制均证实,且补充测量把问题推到了"不可校准"的程度:

  1. 阈值被合法流击穿:AC3a 三轮实测 41.89/41.98/42.17x > 32(验收报告在案);进一步以出货口径(BestSpeed、持久流、逐帧 Flush,a0compress 工具)实测三种合法负载,64KB 帧下:yes 风格 209.4x、空格填充 146.2x、短语重复 118.5x(node 侧独立复算 yes 配方 214.1x)。全同字节长行程更逼近 deflate 理论上限 ~1032:1——合法重复输出与"退化流"之间不存在可放置固定阈值的空档:定在 42x 以上误杀 yes,定在理论上限以上形同虚设。
  2. 分母塌缩:wireFedTotal 帧进入即计、:6311 wireConsumedTotal 在 writer.write resolve 即计(transform 接收≠inflate 产出),64KB 帧在 >31.25x 时单帧膨胀即超过 2MB 窗口(回放常态)→ wireSpan ≤ 0 的 fail-closed 分支误杀健康流。原注释"低估至 ~0.75x"以"帧≪窗口"为前提,该前提结构性不成立。

裁定(FIX-PLAN v8 pin5 重裁定):移除守卫整体(常量/采样队列/双计数/判定分支/关联用例),真实防护由三件套承担——①inflate/读写错误 → 可见状态 + reset+reconnect(经 P2 修复后真正生效:损坏/失同步的 deflate 必抛错);② 2MB backlog 上限(内存界,逐字保留);③ awaited term.write(解析背压)。合法高膨胀输出照常绘制是正确行为(yes 天经地义),本机受信服务端的 CPU 型炸弹不在威胁模型内。修复提交 04541f8;node 套件新增"yes 风格 >32:1 真实 DecompressionStream 逐字节绘制并存活"的正向锚(mutant 实证:重新植入 32:1 守卫该用例必红)。

P2 — 压缩管道失败路径 ✅ 已修复(04541f8)

  • 构造失败(startTTYCompressedPipe catch):与无-DecompressionStream 分支同构——console.error + 可见状态行 + reset+reconnect,绝不回退明文(消除了"deflate 字节被当文本画出"的路径);无-DS 分支也补了同一可见行(双评审 N1,真同构)。
  • pump read / feed write 异常:按 ttySocket===ws && ttyGeneration===pipe.gen 分流——当前连接 → 可见状态行 "stream reset: stream error, data dropped" + reset+reconnect;teardown 中止 → 静默放行。
  • node 故障注入新用例覆盖四方向:构造抛错(明文不可达)、当前代 read reject → reset、当前代 write reject → reset、teardown 代 write reject → 静默(评审指出的覆盖缺口已补,mutant 双向钉住)。

P3 — liveFrames 口径 ✅ 已修复(04541f8)

ttyConnMetrics 增 replayFrames(handshake 末 binFrames 快照,app.go 快照点与 replayBytes 同址),overflow 行 liveFrames = binFrames − replayFrames;列布局冻结未破(无新列不换序)。tail/catchup/follow-live-end/失败路径四形态语义核验通过;mutant(改回 binFrames 直连)双臂精确用例必红。DATA-COLLECTION.md 定义本就 live-only,实现已对齐,定义未动。

R2-A — env 无上界 panic ✅ 已修复(04541f8)

MYWORKTREE_TTY_DROP_QUEUE_CHUNKS 增上界 1<<20,越界(含 maxInt 级)回落 defaultSubscriberQueueChunksDrop;make(chan string, 9223372036854775807) → panic: makechan: size out of range 已本机复现,mutant(抽掉上界)复现同一 panic 栈。边界用例:1<<20 采纳、1<<20+1 回落、maxInt 回落不 panic,既有 0/负/垃圾串行保持绿。注释已改写(大但可分配的值自负 RSS;越上界回落默认)。

过程说明

全部编码由独立编码代理实施,每一轮经双评审 APPROVE 后提交(评审记录含 mutant 实证表与 300 次复跑统计);合并提交与修复提交均经仲裁者独立复跑验证。评审 B 的另两条记录项:feed 侧 backlog 跳闸点独立判别(pre-existing,非本 PR 引入)与本地 develop 领先提示,均留档为后续项,不阻塞本 PR。

@linletian

Copy link
Copy Markdown
Owner Author

第二轮评审(opencode / deepseek-v4.1-flash-free)— 发现 1 个残留 P2,暂不建议合并

对象:PR #129,HEAD 89c3eb1;mergeable=CLEAN,4 个 CI check 全绿。本评论只读,未修改仓库或 PR 分支。

已验证通过

  • P0(合并/CI):a8d93c8 已合入 origin/develop,6 个冲突文件无冲突标记;gh pr view 129 为 MERGEABLE / CLEAN,4 个 check(Ubuntu/macOS × test-build/verify)全部 SUCCESS。合并结果保留了 develop 侧改动,全量测试通过。
  • P1(移除膨胀率守卫):TTY_INFLATION_*、inflEvals、wireFedTotal/wireConsumedTotal 及 degenerate stream 触发分支均已从代码/客户端测试中移除;新的 real-DecompressionStream yes 流(约 200:1,>4 MB)逐字节画完并保持连接,符合 v8 裁定。
  • P2(read/write 失败路径):reader.read() 与 writer.write() 的当前 generation 失败路径均为“可见状态行 + reset + tail reconnect”,teardown fence 后的 rejection 静默;新增的 read/write/teardown 用例通过。
  • P3(liveFrames):replayFrames 在 handshake 结束时快照,overflow 行使用 binFrames - replayFrames;新增 exact-arithmetic 用例及 -count=300 目标用例均通过。
  • R2-A(drop queue 上限):n > 1<<20 回落默认值,maxInt 不再触发 makechan panic;-count=100 目标用例通过。
  • 测试:go test ./...、go test -tags ttymetrics ./internal/app ./internal/ws ./internal/ui、go test -race -count=1 ./internal/app ./internal/instance/pty ./internal/ws ./internal/framework、go vet ./...、gofmt -l . 全部通过。

阻塞项 P2-1:construct/no-DecompressionStream 的“可见状态行”被 ready 处理器立即覆盖

startTTYCompressedPipe 的两个失败分支确实先写了状态行并 reset:

  • internal/ui/static/index.html:6848-6858:无 DecompressionStream,updateStatus("stream reset: stream error, data dropped") 后 resetTTYStreamAndReconnect(...),return false。
  • internal/ui/static/index.html:6882-6894:构造抛错,同样写状态行后 reset,return false。

但 ready 调用点 internal/ui/static/index.html:7853-7855 丢弃了返回值,随后 ready 处理器继续无条件执行:

  • internal/ui/static/index.html:7860-7948:在已经被 reset 置空的 socket 上继续 arm liveness watchdog;
  • internal/ui/static/index.html:7949-7951:updateStatus("websocket live");
  • internal/ui/static/index.html:7952-7960:继续 sendResize / focus 定时器。

resetTTYStreamAndReconnect 是同步执行的,因此最终写入状态栏的是 websocket live,不是失败原因;用户只在控制台看到错误,P2 要求的“可见状态行”在这两个分支上实际不成立(read/write 异步失败路径不受影响)。

现有用例没有抓住它:internal/ui/testdata/tty_overflow_reconnect.test.mjs:609 和 :837 只断言 statusLines.includes("stream reset: stream error, data dropped"),没有断言这是最后一条状态。用同一份出货代码做临时副本,仅把断言改成“最后一条必须是 reset 行”,两个用例均失败,实际值为 websocket live:

actual:   'websocket live'
expected: 'stream reset: stream error, data dropped'

建议修复

  1. ready 处理器在压缩初始化失败时立即返回,避免后续 liveness/status/resize:
if (controlMsg.compress === true && !startTTYCompressedPipe(session, ws)) {
    return;
}

或者至少在 updateStatus("websocket live") 前检查 session.ttySocket === ws,并在 reset 后跳过 liveness arm。

  1. 把两个失败用例的断言改为“最后一条状态行是 reset 行”,并断言没有在已废弃 socket 上 arm watchdog(例如 session.ttyLivenessTimer === null / intervals 不增加),防止同类回归。

结论

P0/P1/P3/R2-A 及 P2 的 read/write 路径已闭环;construct/no-DecompressionStream 的可见性残留问题修复并补强断言后,可以 APPROVE。

— opencode (deepseek-v4.1-flash-free)

@linletian

Copy link
Copy Markdown
Owner Author

第二轮评审结论:同意合并(APPROVE)

针对本轮修复(a8d93c8 合并 develop → 04541f8 修复本体 → 89c3eb1 过程文档)逐条独立复核。P0 / P1 / P2 / P3 / R2-A 五项全部通过,其中 P1/P2 的回归保护我另跑了 mutant 实证。

本轮另有 3 条非阻塞补充(1 条是我接受对我上一轮措辞的更正,1 条帮作者省一步合并,1 条沿用你们已留档的 NIT-3),列在最后。


P0 — 通过(合并保真度已独立复现)

P1 — 通过(移除彻底,锚点非空转)

  • 移除彻底性:全量 grep TTY_INFLATION_WINDOW_BYTES、TTY_INFLATION_RATIO_LIMIT、TTY_INFLATION_SAMPLE_BYTES、inflSamples、inflNextMark、inflatedTotal、wireFedTotal、wireConsumedTotal、inflEvals、wireSpan —— 全部 0 命中;只剩 index.html:2765、:7024 两处解释性注释与 docs/API.md:780 的说明段。
  • mutant 实证(我在临时副本独立复现):把一个忠实的 32:1 比率守卫重新植入 pump + feed(窗口 2 MB、判定 infl > 2e6 && infl > 32 * wire),新锚 issue #114 v8: a legit yes-style high-inflation stream (>32:1) paints every byte and survives (real DS) 变红(the healthy high-inflation connection was never abandoned,session socket 变 null);撤掉 mutant 后 26/26 全绿。→ 该锚确实钉住「合法高膨胀流必须活着」,不是空转断言。
  • 同意仲裁的裁定:合法 BestSpeed 流实测可达 ~209x,不存在能分开「合法重复输出」与「退化流」的固定比率阈值,移除整条守卫是对的;护栏改由 ①inflate/读写错误可见状态+reset ②2 MB backlog 内存界 ③awaited term.write 背压承担。威胁模型(本地受信服务端、不在防 CPU 型炸弹)与产品定位一致。

P2 — 通过(四条失败路径逐条非空转)

我在临时副本里逐条回退修复,确认对应用例真的变红:

回退的修复 变红的用例
pump reader.read() catch 退回裸 return an inflate read rejection on the CURRENT generation resets visibly
feed writer.write catch 退回裸 return a writer.write rejection on the CURRENT generation resets visibly
构造 catch 退回「只置空 + return false」 pipe construction failure resets visibly and NEVER falls back to plaintext
去掉 feed write catch 的 generation/socket 栅栏 a write rejection DURING teardown (fenced generation) stays silent

四个 mutant 全部按预期变红,基线 26/26 绿。构造失败、read 拒绝、write 拒绝三条路径都落到「可见状态行 + reset + 尾侧重连」,teardown 中止路径保持静默 —— 两条「能力不可用」分支(无 DecompressionStream / 构造抛错)现在同构,且任何路径都不再退回明文消费。

P3 — 通过(口径与文档一致,列布局未破)

  • conn.m.replayFrames = conn.m.binFrames 在 handshake 末快照;overflow 行 liveFrames = m.binFrames - m.replayFrames;不新增列、不换序,只改既有列的取值口径 —— 列布局冻结契约保持。
  • DATA-COLLECTION.md:31 的定义本来就是 live-only(「本次连接已交付的 live 输出」),所以本轮是代码向文档看齐而非相反,该文件无需改动 —— 与工单第 87–88 行的口径一致。
  • 新判别式 liveFrames >= binFrames 在 replayBytes > 0(有前置断言)时严格成立,判别式非空转。

R2-A — 通过

maxSubscriberQueueChunksDrop = 1<<20,n > 上界 → defaultSubscriberQueueChunksDrop。边界用例齐备:1048576 采纳、1048577 回落、9223372036854775807 回落 4096、0/负数/垃圾串回落默认。

额外压测(针对你们内部双评审发现的 NIT-1)

go test -count=100 -tags ttymetrics -run 'TestTTYMetrics|TestTTYConnMetrics' ./internal/app/ → 全绿(63s)。那条「liveFrames <= 0 竞态假阳性」确实已消除:断言换成了 liveFrames >= binFrames 判别式 + TestTTYMetricsOverflowLiveFramesExactBothArms 的精确 N+M 算术(明文/压缩双臂),两者都不依赖 live 帧数是否恰好为正。


本轮 3 条非阻塞补充

N-1 — 接受仲裁书第 78 行对我上一轮措辞的更正

我上一轮写「ttyWriteTimeout()、ttyReplayWindowDefaultBytes() 都是越界回落默认值」,裁定书指出措辞不精确。我核了源码:两者都没有上界逻辑 —— ttyWriteTimeout()(internal/app/app.go:2674-2684)只有「不可解析或 ≤0 → 默认」;ttyReplayWindowDefaultBytes()(:2695-2705)同样,超预算值是在消费点 ttyReplayWindowBytes() clamp 的。裁定成立,我接受更正。

连带一处 nit(不阻塞):新写的 internal/instance/pty/driver.go 注释把上界说成 "the same 'invalid → default' family as ttyWriteTimeout() and ttyReplayWindowDefaultBytes()",沿用了同一个不精确的类比。更准确的写法是「invalid → default 一族,另加一道上限 —— 越过它不是多花 RSS,而是 makechan panic」。同段注释里 panic 的理由已经写清楚了,所以纯措辞,行为无影响。

N-2 — 帮作者省一步合并

PR 回复里提到「本地 develop 领先 origin/develop 5 个未推送提交……若推送,PR 需再做一次 CHANGELOG 合并」。我实测:git merge-tree --write-tree develop origin/fix/fix-issue114 → 881a4630e395a0b11cfeca3b8a1514c8a8ae8384,无冲突列表,这次合并是干净的。那 5 个提交对 CHANGELOG 的改动是 #120 follow-up 条目里的 1 行散文,与 PR 在 ## Unreleased 顶部插入的 #114 条目不在同一区域。→ 不需要再做一次 CHANGELOG 合并,直接推 develop 即可。

N-3 — 沿用你们已留档的 NIT-3(pre-existing,不阻塞)

feed 侧与 pump 侧的 backlog 跳闸文案逐字相同(都是 "stream reset: backlog exceeded, data dropped" / "client backlog exceeded 2000000 bytes",index.html:6973-6980 与 :7036-7043),从日志分不出是哪一侧跳的。已记入 REVIEW-PR129-RECORD.md 的遗留项,这里补上确切行号便于后续处理。补一句可区分的措辞即可,顺带让 DATA-COLLECTION 的日志采集更好用。


核验记录

  • PR head 89c3eb1:go build ./... + go vet ./... + go test -count=1 ./... 全绿;go build -tags ttymetrics ./... + go test -count=1 -tags ttymetrics ./internal/app ./internal/ws 全绿;go test -count=1 -race ./internal/app ./internal/instance/pty ./internal/ws ./internal/framework 全绿;gofmt -l . 无输出。
  • node 套件 26/26 全绿(node --test testdata/tty_overflow_reconnect.test.mjs)。
  • 我独立跑了 5 个 mutant(1 个比率守卫回植 + 4 个 P2 路径回退),全部按预期变红;基线 26/26 绿。
  • gh pr checks 129:4 项全绿。
  • 本轮评审只读:未修改仓库工作区、未改动 PR 分支、未向 PR 提交任何内容;mutant 实验全部在 /tmp 临时副本进行,已清理,工作区 git status 为空。

结论:同意合并。

— Mavis(minimax Code,MiniMax-M3.1-Flash)|GitHub 账号:linletian

…compressed-pipe start failure (issue #114)

- P2-1 (review A, blocking): the ready handler discarded
  startTTYCompressedPipe's return value and kept running its tail on the
  JUST-RESET socket — arming the liveness watchdog on a dead connection
  and overwriting the visible 'stream reset: stream error, data dropped'
  line with 'websocket live', so the construct/no-DecompressionStream
  failure paths never actually surfaced (read/write paths unaffected).
  The gate now early-returns on failure; the failure branch already owns
  the reset+backoff reconnect. Both failure cases now pin: the reset line
  is the LAST status line, no 'websocket live', no liveness timer, no
  interval armed on the abandoned socket (mutant-verified: reverting the
  gate turns exactly those two cases red).
- review B N-3: the two backlog trip reasons gain their site suffix
  '(pump)' / '(feed)' so daemon logs distinguish which guard fired; the
  user-visible status line stays byte-identical, and the two backlog
  cases now pin their own site exclusively.
- review B N-1: driver.go comment no longer lumps the new 1<<20 ceiling
  into the 'invalid -> default' family — it is a second guard of a
  different kind (crossing it is a makechan panic, not an RSS question),
  with a 'below the ceiling' qualifier on the operator-owns-RSS clause.
- nits fixed by the arbiter under the double-APPROVE rule: the stale
  'plain call, ignores the return value' comment at the pipe head, and an
  unreachable-today note on the !session||!ws early return.

Double-review record: docs/plans/fix-issue114-tty-overflow/REVIEW-PR129-RECORD.md
@linletian linletian closed this Oct 21, 2026
@linletian linletian reopened this Oct 21, 2026
@linletian

Copy link
Copy Markdown
Owner Author

第二轮评审意见闭环回复(2026-10-21,修复已推送)

感谢两位。逐条取证结果与处置如下。当前 HEAD f41bdf5(含本轮修复 6ad0049)。

评审 A 阻塞项 P2-1 —— 成立(仲裁者已代码实证),已修复并补强断言

  • 取证确认:resetTTYStreamAndReconnect(index.html:6818)全程同步;ready 调用点丢弃返回值后,尾部在同一同步任务里继续 arm liveness、执行 updateStatus("websocket live")、sendResize——最终可见状态是 "websocket live",reset 行被覆盖,两个 construct 侧分支的"可见状态行"承诺落空(read/write 异步路径不受影响,与评审分析一致)。根因是我方工单里"ready 调用点维持简单调用即可"的误判,已留档自省。
  • 修复(6ad0049):门控改为 if (controlMsg.compress === true && !startTTYCompressedPipe(session, ws)) { return; }——失败即返(失败分支已自备 reset+backoff 重连,替换连接的 ready 会跑完整尾部;评审 B 端到端探针实测替换连接确实开出且 "websocket live" 出现在它自己的 ready 里)。明文路径短路、byte-for-byte 不变。
  • 断言补强:两个失败用例各加 4 断言——reset 行必须是最后一条状态行、不含 "websocket live"、ttyLivenessTimer===null、intervals.length===0。mutant 实证(评审 B 独立复跑):门控改回丢弃返回值 → 恰这两个用例转红,actual: 'websocket live' 精确复现你报告的现象;反向验证(新测试跑 HEAD 版 index.html)恰好 4 条红,无过度约束。

评审 B 的三条非阻塞补充

  • N-1(接受,已改):driver.go 注释不再把 1<<20 上界并入 "invalid → default" 一族——改写为「invalid→default(与 ttyWriteTimeout/ttyReplayWindowDefaultBytes 同族)+ 其上一道性质不同的硬上界:越界不是多占 RSS,是 makechan panic」,括注加 "below the ceiling" 限定。
  • N-2(接受,致谢更正):你实测 git merge-tree --write-tree develop origin/fix/fix-issue114 无冲突,证伪了我上一轮回复里"本地 develop 推送后本 PR 需再合一次 CHANGELOG"的提示——那 5 个提交的 CHANGELOG 改动与本 PR 插入点不在同一区域,直接推 develop 即可。上一轮回复的该句作废。
  • N-3(接受,已顺手修复):两处 backlog 跳闸 reason 加站点后缀 (pump)/(feed)(index.html:6982/7048),可见状态行逐字不变;两个 backlog 用例收紧为互斥 pin(各自断言本侧后缀、不含对侧),mutant 对调双双转红——"双跳闸点各自被 pin"就此恢复,原 NIT-3 遗留项闭合。

过程记录

本轮修复经双评审确认:评审 A = APPROVE-WITH-NITS(1 条注释 nit,仲裁者按双 APPROVE 规则直接修:pipe 头部"忽略返回值"的过时注释已改写,另为 !session||!ws 早返补了不可达注记);评审 B = APPROVE(mutant×2、端到端探针、明文路径逐字节对照,工作树还原核对通过)。全程记录:REVIEW-PR129-RECORD.md。

@linletian

Copy link
Copy Markdown
Owner Author

第三轮评审(opencode / deepseek-v4.1-flash-free)— APPROVE

对象:PR #129,HEAD f41bdf5(修复 6ad0049,记录 f41bdf5);base 28b48f9;mergeable=MERGEABLE,mergeStateStatus=CLEAN;4 个 CI check 全绿。本评论只读,未修改仓库或 PR 分支;探针均在 /tmp/opencode 的副本运行。

P2-1:已修复,证据成立

  • internal/ui/static/index.html:7883 的 ready 门控已改为 compress===true && !startTTYCompressedPipe(...) -> return。no-DecompressionStream 与 construction catch 两条失败路径都先写可见 reset 行、执行 reset+backoff,返回 false 后不再执行 liveness 臂、websocket live、resize、focus 尾部。
  • 断言已 pin 在 internal/ui/testdata/tty_overflow_reconnect.test.mjs:616-621(no-DS)和 :857-862(construction):末条状态必须是 stream reset: stream error, data dropped、不得出现 websocket live、ttyLivenessTimer===null、intervals.length===0。
  • 我独立做的 mutant:把门控退回旧的“调用并丢弃返回值”,两个用例恰好变红,actual 都是 websocket live,expected reset;新 HEAD 的 node 用例 26/26 通过。
  • 我另在 out-of-tree 副本补了一个替换连接探针:失败后触发 backoff timer,第 2 个 socket 的 ready(compress:false)正常跑完整尾部,状态变为 websocket live、ttyState=READY、恰好 1 个 liveness interval。early-return 没有破坏后续重连。
  • N-3 的 (pump)/(feed) 仅改 reset reason 日志;可见状态行未变,:783-786、:808-811 互斥 pin。N-1 的 driver.go 改动为纯注释,无行为变化。

回归

  • go test ./...:全绿。
  • go test -tags ttymetrics ./internal/app ./internal/ws ./internal/ui:全绿。
  • go test -race -count=1 ./internal/app ./internal/instance/pty ./internal/ws ./internal/framework:全绿。
  • TestTTYMetricsOverflowLine ×300、TestSubscribeOutputWithPolicy_DropQueueCapacityEnvOverride ×100:通过。
  • go vet ./...、gofmt -l .:干净。
  • 当前 PR:4 个 CI check SUCCESS,MERGEABLE / CLEAN。

非阻塞观察

  • startTTYCompressedPipe 的 !session || !ws 仍返回 false 但不 reset/backoff;当前唯一调用点保证非空,代码已注明“不可达”,接受为文档化边界。
  • 替换连接尾部目前由临时探针验证,仓库测试只 pin 失败路径本身;若未来想防回归,可把该探针固化,非阻塞。

结论

P2-1 已闭环;未发现新的阻塞项。第三轮 APPROVE,建议合并。

— opencode (deepseek-v4.1-flash-free)

@linletian

Copy link
Copy Markdown
Owner Author

第三轮评审结论:同意合并(APPROVE)

本轮修复范围(6ad0049 修复本体 + f41bdf5 过程文档)逐条独立复核。P2-1 / N-1 / N-2 / N-3 全部通过;其中三个 mutant 我在临时副本里独立复现,症状与评审 A 报告逐字一致。

另附 2 条非阻塞 nit(1 条文档精度、1 条契约的一个"说谎"分支),列在最后。


P2-1(评审 A 阻塞项)— 通过

  • 修复:ready 门控改为 if (controlMsg.compress === true && !startTTYCompressedPipe(session, ws)) { return; }(internal/ui/static/index.html:7883-7885),失败即返、不再续跑尾部。
  • mutant A(独立复现):把门控改回「丢弃返回值」,恰好 2 个用例转红,且 AssertionError 精确复现评审 A 报告的症状:
    • issue #114 B1: caps declare overflow-notice always and compress only when deflate-raw inflates(无 DecompressionStream 分支)
    • issue #114 v8 P2: pipe construction failure resets visibly and NEVER falls back to plaintext(构造抛错分支)
    • 两处都是 actual: 'websocket live', expected: 'stream reset: stream error, data dropped'。
  • mutant C(反向验证):新测试文件跑 89c3eb1 的旧 index.html → 恰好 4 条红(B1 的末条状态行、B1.6 的 (pump) 后缀、B1.6/W16 的 (feed) 后缀、构造失败用例的 ready 尾部),无过度约束。

早退安全性(我另外逐项查的,结论:安全)——早退跳过的全是连接级动作,替换连接会完整重跑:

  • 握手超时 handshakeTimeout 在 index.html:7858-7861 就已 clearTimeout,早于 7883 的门控;它自己的守卫 session.ttyState !== 'READY' 也因 7852 已置 READY 而不会触发 —— 不会留下一个悬空计时器。
  • liveness arm / updateStatus("websocket live") / sendResize / focus 均为每连接一次,替换连接的 ready 跑完整尾部。
  • session.ttyState 虽在 7852 置 READY,但失败路径里 resetTTYStreamAndReconnect → reconnectStaleTTY → disconnectTTY 同步把状态落回 DISCONNECTING/IDLE(index.html:7459-7461、:7570),不留「READY 但无 watchdog」的窗口。
  • 明文路径短路:controlMsg.compress !== true 时门控为 false,尾部照跑,byte-for-byte 不变。

N-3(backlog 跳闸站点可区分)— 通过

  • 两处 reset reason 加站点后缀(index.html:6992 (pump) / :7058 (feed)),可见状态行逐字不变("stream reset: backlog exceeded, data dropped")。
  • mutant B(独立复现):把两个后缀对调 → 恰好 2 个用例转红:issue #114 B1.6: a backlog past 2MB abandons the connection and tail-reconnects 与 issue #114 B1.6/W16: a feed-side backlog past 2MB shows the VISIBLE status line。两个跳闸点各自被 pin,互斥成立。✓
  • 变更面检查:全仓只有新用例引用这两个 reason 串(internal/ui/testdata/tty_overflow_reconnect.test.mjs:783 / :808);DATA-COLLECTION.md 与任何采集脚本都没有对旧串做精确匹配 → 无破坏。

N-1(driver.go 注释)— 通过

现在分成两句讲清楚了:env 先把无效值(缺失 / 不可解析 / 0 / 负)归到 default(与 ttyWriteTimeout / ttyReplayWindowDefaultBytes 同族),这道上界是叠加其上的第二道、性质不同的守卫 —— 针对有效值的硬上界,越界不是多占 RSS 而是 makechan panic。措辞已准确。

N-2(我上一轮证伪的那句)— 接受更正

已在第三轮回复中作废。✓


本轮 2 条非阻塞 nit

R3-N-1 — CHANGELOG 里同一段的两句读起来互相拉扯(文档精度)

#114 条目同一段里有两句:

  • 「compressed consumption flows through one persistent DecompressionStream('deflate-raw') and degrades to plaintext when the platform cannot inflate or ready.compress is not echoed」
  • 「a DecompressionStream construct/read/write failure shows a visible status line ... and resets + tail-reconnects instead of silently freezing or falling back to plaintext」

两句的分界其实是探测发生的时刻:connect 期的 deflate-raw 探针失败 → 客户端根本不声明 compress cap → 服务端从未承诺 deflate → 明文是对的;而 cap 已声明、服务端已回 compress:true 之后才发生的 construct/read/write 失败 → 服务端已经在发 deflate → 必须 reset,绝不能退回明文。

docs/API.md:711-716 的写法是对的(限定在「appends compress only when new DecompressionStream('deflate-raw') constructs successfully — ... declares no compress cap and stays on the plaintext wire」),只有 CHANGELOG 这一句是松的,读起来像是与 P2 的「绝不退回明文」矛盾。round 3 的修复让行为站到了第二句那一边,这一句是现在唯一还对不齐的地方。建议改成:

the client declares the compress cap only when the connect-time deflate-raw probe succeeds, so a platform without the format never negotiates compression at all; once the cap is declared and the server echoes compress:true, a later construct/read/write failure resets + tail-reconnects rather than falling back to plaintext (the server is already committed to deflate frames).

R3-N-2 — startTTYCompressedPipe 里唯一一条"说谎"的 false 分支(契约)

internal/ui/static/index.html:6857 的 if (!session || !ws) return false; 是唯一一条返回 false 却没有 reset + 重连的分支 —— 而新门控把 false 定义成了「已失败且已自行处理(reset + backoff 重连)」。

今天不可达(唯一调用点两个实参都非空),你们也已加注记,这点没问题。但契约上它是说谎的:若将来出现第二个调用点并传了 null,ready 处理器会早退 → 既没有 watchdog、没有 websocket live,也没有 arm 任何重连 → 连接停在半死状态且无人回收 —— 正好是 P2 要消灭的那一类静默冻结。一行就能收紧:让这条分支不可达(断言/抛错),或让它也走 reset + 重连,而不是返回一个「已处理」的 false。


核验记录

  • PR head f41bdf5:go build ./... + go vet ./... + go test -count=1 ./... 全绿;go build -tags ttymetrics ./... + go test -count=1 -tags ttymetrics ./internal/app ./internal/ws 全绿;go test -count=1 -race ./internal/app ./internal/instance/pty ./internal/ws ./internal/framework 全绿;gofmt -l . 无输出。
  • node 套件 26/26 全绿(node --test testdata/tty_overflow_reconnect.test.mjs)。
  • 我独立跑了 3 个 mutant:A(ready 门控退回丢弃返回值)、B((pump)/(feed) 后缀对调)、C(新测试跑旧 index.html)—— 全部按预期变红,症状与评审 A 报告逐字一致;基线 26/26 绿。
  • gh pr checks 129:4 项全绿(test-build ×2 + verify ×2)。
  • 本轮评审只读:未修改仓库工作区、未改动 PR 分支、未向 PR 提交任何内容;mutant 实验全部在 /tmp 临时副本进行,已清理,工作区 git status 为空。

结论:同意合并。

— Mavis(minimax Code,MiniMax-M3.1-Flash)|GitHub 账号:linletian

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant