Skip to content

fix(terminal): a stripped X-Log-Offset replayed the tail twice (issue #86) - #93

Merged
linletian merged 5 commits into
developfrom
fix/issue86-follow-from-live-end
Oct 6, 2026
Merged

linletian merged 5 commits into
developfrom
fix/issue86-follow-from-live-end

Conversation

@linletian

@linletian linletian commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Fixes #86.

Stacked PR. Base is fix/issue87-ws-handshake-offset (PR #90), not develop. Review/merge #90 first, then this one. The diff here contains only the #86 change.

Problem

GET /api/instances/log carries the end offset in the X-Log-Offset header and the browser keeps it as session.logCursor. If a proxy strips that header:

  1. loadLog paints the tail, but only advances the cursor inside if (Number.isFinite(next) && next >= 0) — so with the header gone neither logCursor nor ttyOffset moves.
  2. startSSE / connectTTY then omit since entirely (the guard is > 0).
  3. The server falls into its tail default and serves the tail again.

→ the tail renders twice, on both SSE and the WebSocket handshake.

Root cause

Two different "no valid offset" states were collapsed into one -1:

state what the client wants
nothing painted the tail — it genuinely needs it
painted, offset unknown (header stripped) no replay at all — it already has the tail

Fix

A "follow from live end" sentinel, sinceFollowLiveEnd = -2: send nothing, publish the current end offset, then stream only new data. It is branched on explicitly, ahead of the existing since < 0 tail branch, in both handlers — parseInt64Default passes any negative through untouched, so an unbranched -2 would behave exactly like an omitted since and reproduce the duplication it exists to prevent.

Manager.EndOffset(id) returns the head without copying the buffer. It is Tail(id, 0) in every respect that matters — same lookup, same kind delegation, same clamps (a kind without log capture echoes -1 → 0; a non-running instance reports 0, exactly as Tail returns ("", 0, nil)) — except that RingBuffer.Tail short-circuits n <= 0 to ("", head). Tail(id, 0) can't be reused directly because Manager.Tail clamps n <= 0 to 4096. internal/framework/kind.go now states the contract this relies on: a tail read must carry its cursor even at maxBytes == 0.

loadLog sets the sentinel only after bytes actually reach the screen. The gate is the byte count writeSanitizedTerminalOutput returns, not the response length — a tail consisting only of stripped escape sequences sanitizes to "" and paints nothing, so text.length would still have claimed the live end for exactly the state this is guarding. A failed or aborted fetch leaves CURSOR_UNKNOWN, so an empty screen still asks for the tail.

Trade-off, stated rather than glossed

This trades duplication for a bounded loss. Bytes produced between the head loadLog's tail read (H1) and the head the sentinel publishes (H2) are in no painted body, no replay and no live frame — and the published cursor already sits past them, so no reconnect re-requests them. That is milliseconds normally; on the WS-handshake-timeout → SSE fallback it is 5 s + 500 ms of real lost lines. It applies only behind a header-stripping proxy. Documented in app.go, docs/API.md, docs/ARCHITECTURE.md and the CHANGELOG.

A related non-goal is recorded explicitly: this covers a stripped header, not a rewritten one. A proxy that fabricates X-Log-Offset: 0 over a non-empty ring still gets the duplicate tail — telling a truthful 0 from a fabricated one needs a second, independently sourced length, which is precisely the datum the proxy is already willing to lie about.

Also fixed here: a guard against a total SPA outage

kinds/pty.js and index.html's inline block both declared top-level const CURSOR_UNKNOWN. Classic scripts share one global lexical environment, so index.html's entire application script died at parse time:

Uncaught SyntaxError: Identifier 'CURSOR_UNKNOWN' has already been declared

— no state, no sessions, no loadLog/connectTTY/startSSE, no terminal. The existing node harness slices each function with new Function(...) and is structurally blind to that constraint, which is how it got through the first review round.

pty.js's literal is now prefixed (PTY_CURSOR_UNKNOWN), and a new guard test fails when any two classic scripts declare the same top-level name. It was verified red before green, and independently confirmed by concatenating every classic script in load order and running V8's own parser (node --check → exit 0). A 15-case injection matrix confirmed the guard agrees with V8 on every collision shape and stays silent on legitimate non-collisions.

Tests

  • internal/app/tty_ws_test.go — 8 sentinel cases: SSE no-replay-then-only-new-bytes, omitted/real since regression guard, stopped instance (next:0, no replay), non-capturing kind (the off < 0 clamp on the wire), WS replays nothing, empty ring, stopped instance, read failure, plus 400 on the plain endpoint.
  • internal/framework/manager_endoffset_test.go (new) — head correctness, non-running → 0, echoed sentinel normalised.
  • internal/ui/testdata/terminal_status.test.mjs — 9 new cases across the PR (67 total: 58 baseline + 8 initial + 1 review follow-up): the two cursor states across both transports, SSE and WS each painting the tail exactly once behind a stripped header, the unpainted and empty-tail cases keeping the unknown cursor, cross-file constant agreement, and the script-collision guard.

Verification: gofmt -l . clean, go build ./..., go vet ./... clean, go test -count=1 ./... all packages ok, node --test 67/67, go test -race -count=5 on the WS + stream suites clean. TestTerminalStatusChangelogCount re-derives 67 from the file rather than trusting the prose — note its regex anchors on the first Pinned by N \node --test` cases` match, which lives in the #80 entry (CHANGELOG.md:11); that entry's number is the one kept in step with the file (33 → … → 67). CI: all 4 checks green after the merge of develop and the review follow-ups.

Follow-up, not in this PR: index.html declares function formatBytes twice (pre-existing at HEAD, verified untouched here). Hoisting means the second wins for all 9 callers and the first is dead code with different semantics. Worth a separate cleanup issue.

🤖 Generated with DeepSeek Code

… (issue #87)

A network blip, a proxy idle timeout or a laptop sleep/wake duplicated or
garbled the terminal. ws.onclose reconnects after 5s without clearing the
screen, while completeHandshake unconditionally replayed Tail(id, 64*1024)
to EVERY connection, so the newest 64 KB landed under whatever the terminal
already showed.

Root cause: the WS handshake had no offset contract. The connect URL carried
only id/token, the handshake threw away Tail's end offset via `_`, and the
client's logCursor served only the SSE path -- so the client could not even
express a position, let alone have one honoured.

Fix: the handshake gains a cursor round-trip.

  GET /api/instances/tty/ws?id=<id>[&since=<offset>]

  since absent/unparseable -> -1 sentinel -> Tail, unchanged (first connect).
  since >= 0              -> catch-up loop, see below.

completeHandshake(since) loops ReadSince(id, cursor, 64*1024) until the body
comes back empty, retaining only the NEWEST chunk, so peak memory stays
~64KB regardless of the ring cap and the replay is the newest <=64KB
contiguous with head. A single capped ReadSince would instead publish a
cursor 64KB BEHIND head while the live subscription starts AT head: those
bytes land in neither path and, since a client cursor only moves forward,
they are never re-requested. Retaining the newest chunk (rather than a
rolling 64KB window) keeps the stronger property that every replayed byte is
new to the client.

After the replay the server emits a TEXT control frame:

  {"type":"sync","offset":N}

even when the replay was empty (0 is a legitimate cursor on a fresh
instance). The client stores it as `ttyOffset`, advances it by the wire
byte count of every binary frame received after that sync, preserves it
across ws.onclose, and sends it back as `since` on reconnect.
RingBuffer.ReadSince supplies the silent stale-cursor clamp for free.

Cursor invalidation is screen-content driven: callers that wipe the screen
(activate(), refreshCurrentInstance, resetTerminalForSwitch) reset BOTH
cursors to the -1 sentinel at the clear, and a successful loadLog re-pins
ttyOffset to logCursor immediately afterwards (one ring end offset, two
consumers). The SSE handler advances both cursors together so a later WS
reconnect cannot replay bytes SSE already painted.

Because the cursor counts wire bytes, the server must never emit a
non-ring binary frame: both handshake error paths now CLOSE the connection
with 1013 instead of writing the diagnostic as binary output, matching the
existing newTTYClientHandle precedent. The replay write error also aborts
the handshake rather than advertising a cursor over undelivered bytes.

Defensive: a first read that makes no progress consults Tail for the real
head, because ReadSince echoes the caller's own number once the cursor
reaches head. A cursor AHEAD of head (not reachable today -- Manager.Restart
mints a new instance id, so a given id's ring head is monotonic) degrades to
the first-connect tail instead of publishing a cursor the ring never held;
output written between that read and the consult is picked up by continuing
the loop rather than skipped.

Tests: internal/app/tty_ws_test.go gives the endpoint its first coverage --
8 TestHandleInstanceTTYWS_* cases driving a real ws.Dial against the real
handler (no-redelivery / tail / silent clamp / sync offset / over-64KB
contiguity / read-failure close / cursor-ahead-of-head / consult-sees-new-
bytes), plus the at-head no-op and the non-running instance. The pty driver
gains a since>=0 contract test so the app-test mirror cannot drift.
Frontend gains 6 cases in testdata/terminal_status.test.mjs (39 total).

Docs: docs/API.md (TTY WS section) and docs/ARCHITECTURE.md.
…86)

GET /api/instances/log returns the tail and carries the real end offset
in the X-Log-Offset header; the browser keeps that byte offset as
session.logCursor. If a proxy in front of the daemon strips the header
the browser has no cursor, so startSSE omits `since` entirely, the
server falls into its tail default, and the tail is painted a second
time on top of what loadLog just drew. The WS handshake had the same
shape and duplicated the 64KB tail as binary frames.

Root cause: TWO different "no valid offset" states were collapsed into
one -1 sentinel.

  nothing painted   -> the client genuinely wants the tail
  painted, offset unknown (header stripped) -> the client wants NO
                        replay at all; it already has the tail

Fix: a "follow from live end" sentinel, sinceFollowLiveEnd = -2,
meaning send nothing, publish the current end offset, then stream only
newly produced data. It is branched on EXPLICITLY in both
handleInstanceLogStream and completeHandshake, ahead of their existing
`since < 0` tail branch -- parseInt64Default passes any negative through
untouched, so an unbranched -2 would behave exactly like an omitted
`since` and reproduce the duplication this value exists to prevent. On
SSE that is one empty frame `{"chunk":"","next":<head>}` (writeSSELogEvent
already always writes both fields, so no new frame format is needed);
on WS it is zero binary frames plus the existing `sync` control frame.

New Manager.EndOffset(id) returns the head without copying the buffer:
it is Tail(id, 0) in every respect that matters -- same lookup, same
kind delegation with a negative since, same clamps (a kind without log
capture echoes -1 and gets 0; a non-running instance reports 0, exactly
as Tail returns ("", 0, nil)) -- except that RingBuffer.Tail
short-circuits n <= 0 to ("", head). Tail(id, 0) itself cannot be
reused because Manager.Tail clamps n <= 0 to 4096. internal/framework/
kind.go now states the contract this relies on: a tail read must carry
its cursor even at maxBytes == 0.

Client: CURSOR_UNKNOWN (-1) and CURSOR_FOLLOW_LIVE_END (-2) are named
constants in index.html, mirrored (prefixed) in kinds/pty.js. loadLog
sets the sentinel only after bytes have actually reached the screen --
the gate is the byte count writeSanitizedTerminalOutput returns, not
the response length, because a tail of nothing but stripped escape
sequences sanitizes to "" and paints nothing. A failed or aborted fetch
leaves CURSOR_UNKNOWN, so an empty screen still asks for the tail.
Both transports send the sentinel explicitly; neither ever sends
since=0, which the server would read as the oldest live byte (issue
#81). GET /api/instances/log now 400s the sentinel rather than
silently serving the opposite of what it means.

Trade-off, stated honestly in the code, docs and changelog rather than
glossed: this trades duplication for a bounded loss. Bytes produced
between the head loadLog's tail read (H1) and the head the sentinel
publishes (H2) are in no painted body, no replay and no live frame,
and the published cursor already sits past them, so no reconnect
re-requests them. That is milliseconds normally; on the
WS-handshake-timeout -> SSE fallback it is 5s + 500ms of real lost
lines. It only applies behind a header-stripping proxy.

Also fixed here: a guard test that fails when two classic scripts
declare the same top-level name. kinds/pty.js and index.html's inline
block both declared `const CURSOR_UNKNOWN`, and classic scripts share
one global lexical environment, so index.html's entire application
script died at parse time with "Identifier 'CURSOR_UNKNOWN' has already
been declared" -- a total SPA outage. The existing node harness slices
each function with new Function(...) and so is structurally blind to
that constraint.
@linletian

Copy link
Copy Markdown
Owner Author

待评估项(来自 #90 第二轮评审,非本 PR 的缺陷)

#90 的第二轮评审记录了一条前瞻性、非阻塞的观察,与本 PR 直接相关,先记在这里免得丢:

streaming 之后,WS 握手的 replay→sync 死亡窗口变宽了。 #90 的 catch-up loop 现在把每个 chunk 边读边写,于是「replay 已上屏、sync 尚未到达时连接断开」这个窗口从 1 帧(≤64KB)变成最多 128 帧 / 8MB——窗口更宽,且重复量放大约 128 倍。客户端在这个窗口里不会推进游标(handshakeSynced latch 未打开),所以持续网络劣化时它会从原处重拉整段,不收敛。

latch 本身是对的,理由是 stale cursor 会被 RingBuffer.ReadSince 静默 clamp 到最老存活字节,逐帧累加可能冲过从未送达的字节;sync 是唯一携带绝对偏移的帧。所以这不是 bug,是一个已记录的有意取舍。

给本 PR 评审者的具体问题:本 PR(issue #86,follow-from-live-end)正好建立在这个游标契约上,是否值得把 replay 的实际起始 offset 也纳入 sync 契约一并下发?客户端若知道起始 offset,就能逐帧推进游标,中途断开后续传而非重传,从而把上面那个 8MB 重复窗口收敛掉。

我没有在 #90 里实现它——超出该 PR 范围。请在本 PR 评审时决定:纳入、单独开 issue,还是明确不做。取舍要点:多一个字段 vs 客户端一段更简单的游标推进逻辑。

@linletian
linletian changed the base branch from fix/issue87-ws-handshake-offset to develop October 6, 2026 06:45
Resolve conflicts between issue #86 (follow-from-live-end) and the
squash-merged #87/#82/#83 on develop. Resolution principle: union --
both feature sets are preserved intact.

develop's #87 squash replaced the branch's "loop ReadSince retaining
the newest chunk" handshake with a streaming replay bounded by
ttyHandshakeReplayBudget (8MB), so the resolution uses develop's
streaming skeleton and drops the branch's
TestHandleInstanceTTYWS_LargeDeltaReplaysNewestContiguousChunk in
favour of develop's ..._LargeDeltaStreamsEveryByteSinceCursor.

Kept from develop (#82/#83): the liveness const block and
handleInstanceTTYWSLiveness seam, reader read-deadline, RFC 6455 +
TEXT ping heartbeat ticker, isPing->pong before SendInput, overflow
subscriber disconnect with exactly-once close and bounded 1013, and
all client-side liveness (isTTYHeartbeatStale, watchdog,
reconnectStaleTTY, connectTTY FIX-V guard, !stale gates, lastDataAt,
ping/pong whitelist).

Kept from this branch (#86): sinceFollowLiveEnd = -2 branched before
`since < 0` in completeHandshake and handleInstanceLogStream,
Manager.EndOffset, the 400 on the plain log endpoint, client cursor
states CURSOR_UNKNOWN/CURSOR_FOLLOW_LIVE_END (PTY_CURSOR_UNKNOWN in
kinds/pty.js), the painted-byte-count gate in loadLog, three-state
since URLs in both transports, and the classic-script top-level-name
collision guard.

One shared node test was updated to the merged semantics (a stripped
header now yields CURSOR_FOLLOW_LIVE_END on both cursors rather than
retaining the previous cursor). Node suite: 66 cases, CHANGELOG count
re-derived.
@linletian

Copy link
Copy Markdown
Owner Author

独立评审:#93

结论:实现正确、覆盖到位,可以合。since=-2 的显式分支、Manager.EndOffset 的零长度读、painted > 0 的门控、classic-script 重名守卫我都逐条核对过并独立复现了;下面的都是 MINOR/NIT,不阻塞合并。

MINOR — PR 描述的验证数字自相矛盾,且与仓库不符

描述里写 "8 new cases (47 total)"、"node --test 47/47"、"TestTerminalStatusChangelogCount re-derives 47"。实际:

建议描述改成 66,并说明该守卫锚定的是 #80 那条。

MINOR — startSSE 的哨兵分支只读一个兄弟游标,与它自己注释的规则不一致

const sseCursor = Math.max(session.logCursor ?? -1, session.ttyOffset ?? -1);
if (sseCursor > 0) url += `&since=${sseCursor}`;
else if (session.logCursor === CURSOR_FOLLOW_LIVE_END) url += `&since=${CURSOR_FOLLOW_LIVE_END}`;

上面那段注释写的是"a sibling that IS a real cursor outranks -2 on either side",但兜底分支只查 logCursor;connectTTY 则只查 ttyOffset。

我逐个枚举过写入点,今天不可达:ttyOffset 只有 loadLog 会写成 -2,而它同时写 logCursor;sync 帧(index.html:4625)只写非负的 ttyOffset;其余写入点都是成对赋值。所以这只是一处潜在脆弱性,不是当前 bug。但"只读一个兄弟游标"恰好是 #87 那轮 MAJOR 的同一个形状——只要将来出现任何单边的 ttyOffset 赋值,就会静默退回重复 tail。建议改成:

else if (session.logCursor === CURSOR_FOLLOW_LIVE_END || session.ttyOffset === CURSOR_FOLLOW_LIVE_END)

NIT — writeSanitizedTerminalOutput 的返回语义用词不准

注释说返回"到达屏幕的字节数",实际是 sanitize 后字符串的 .length(UTF-16 code unit,不是字节)。目前只用作 > 0 布尔门,无实际影响;把注释收窄成"字符数 / 非零即已落屏"更贴合,也免得以后有人拿它做字节记账。

我独立复现的验证(未改动任何文件)

检查 结果
node --test testdata/terminal_status.test.mjs 66/66 pass
test -z "$(gofmt -l .)" clean
go vet ./... clean
go test -count=1 ./... 全包 ok
按加载顺序拼接全部 classic script → node --check exit 0(SPA 全瘫的修复是真的,守卫也确实成立)

另外确认了几条容易看漏的:completeHandshake 的两个调用点(app.go:2738 / 2788)都由 !handshakeComplete 守卫,每条连接只跑一次,所以 -2 不会二次发布 head;四个已发布的 kind 都满足 kind.go 新写的"maxBytes == 0 也必须带游标"契约(pty 走 RingBuffer.Tail 的 n <= 0 短路,其余三个 echo since,由 EndOffset 的 off < 0 归一);GET /api/instances/log 的 400 早于 since >= 0 分支,/api/instances/log/stream 与 /api/instances/tty/ws 是仅有的两个 since 消费点,没有第三个会被 -2 误伤;ensureTerminalLiveTransport 的 promote 路径不调 loadLog,游标仍是 -1,所以仍然要 tail——没有被这个 PR 误伤。

formatBytes 重复声明被新守卫降级成 note:(var/function 跨脚本重复声明合法,只是互相遮蔽)是对的,PR 描述也已声明为 follow-up,本 PR 不必处理。

@linletian

Copy link
Copy Markdown
Owner Author

评审补充:回应 #90 轮次显式留给本 PR 的那个问题

关于「是否把 replay 的实际起始 offset 一并纳入 sync 契约下发,让客户端逐帧推进游标」——我的结论是单独开 issue,不在 #93 里做。

前提我复核成立。 handshakeSynced 是 per-socket 局部量(index.html:4448),只在 sync 到达时置位(:4626),而 ttyOffset 的逐帧累加被它挡住(:4656);catch-up loop 最多写 ttyHandshakeReplayBudget = 8 << 20(app.go:179),所以 replay 中途断线,客户端会从旧游标重拉整段。另外 completeHandshake 由 !handshakeComplete 在两个调用点(app.go:2735 / 2786)各守一次,每连接只跑一次,所以 -2 的 head 不会二次发布。

但 #93 既不制造也不加剧这个窗口。 -2 模式写零个 binary frame,sync 立刻带绝对 head,根本不进 replay 分支。反过来说,#93 的修复本身就建立在「客户端只采纳绝对偏移、绝不采纳累加偏移」这条纪律上——发布绝对起始 offset 是同一原则的延伸,不是要引入的新纪律。

若要做,最小正确形态不是把请求里的 since 原样回传。 客户端不能从 since 起步:stale cursor 会被 RingBuffer.ReadSince 静默 clamp 到最老存活字节(ringbuffer.go:155-157),从 since 逐帧累加会发布一个从未存在过的游标。必须下发 clamp 之后的真实起点 S = max(since, oldestLive),而 S 只有第一次 ReadSince 返回后才知道。代价很低的取法是不额外查 ring——在写出第一个 chunk 之前先把那一次 ReadSince 的结果发出去,S 与首个 chunk 同一次调用即可拿到,无需新增 Manager 接口;代价是客户端要处理「控制帧夹在两个 binary frame 之间、且只对第一个生效」。

为什么不放进 #93: 这是 wire 契约变更,要同时改 app.go / index.html / docs/API.md 三处;而触发条件相当窄——需要重连恰好落在一次 >8MB 的 replay 窗口内,而要凑出 >8MB 的 delta 本身链路已经很慢。建议单开 issue,把上面那个「首个 ReadSince 结果先行下发」的形态写进去即可。


楼上已记录的两点,我独立复核后结论相同,不重复:#93 描述里的 "47" 与仓库不符(testdata/terminal_status.test.mjs 实际 66 个 ^test(,CHANGELOG.md:11 也是 66,TestTerminalStatusChangelogCount 的正则首个匹配锚在 #80 那一条上);startSSE 的哨兵分支只读 logCursor——我枚举了 index.html 与 kinds/*.js 里对两个游标的全部写入点,确认 ttyOffset 只有 loadLog 会写成 -2 且同时写 logCursor,sync 帧只写非负值,其余都是成对赋值,因此今天不可达;但它与紧邻注释里 "outranks -2 on either side" 的规则确实不一致,值得按楼上建议改成查两个兄弟游标。

#93 review)

Two findings from the PR93 reviews, both verified accurate against the
code before fixing.

startSSE's sentinel fallback checked only session.logCursor while its
own comment says a real cursor outranks -2 "on either side". The
fallback now checks both siblings:

  else if (logCursor === CURSOR_FOLLOW_LIVE_END || ttyOffset === CURSOR_FOLLOW_LIVE_END)

Unreachable today -- the only -2 assignment site (loadLog) sets both
cursors together, confirmed by grep -- but a future single-sided
ttyOffset assignment would otherwise silently omit `since` and replay
the whole tail over the painted screen, the same shape as the #87
duplication. Behaviour is bit-identical in every state that exists
today. connectTTY is deliberately unchanged: it reads ttyOffset in
both branches, which is complete there because logCursor is only ever
written at paired sites while ttyOffset additionally advances alone
(WS sync frame, latched binary frames), making ttyOffset the superset
cursor. That reasoning is written into both comment blocks, and the
ui_test.go source anchor was updated to the new two-sided line.

Also narrows writeSanitizedTerminalOutput's return-semantics comment:
it returns the sanitized text's `.length` (UTF-16 code units, NOT
bytes), used only as a `> 0` boolean gate, and must not be used for
byte accounting. Aligned wording in loadLog's painted-gate comment,
the harness stubs, docs/API.md and docs/ARCHITECTURE.md.

Node suite gains a case driving the split state
{logCursor:-1, ttyOffset:-2} asserting `since=-2` still goes on the
wire, plus the mirror split, the both-unknown omit, and a real cursor
outranking -2 (67 total, CHANGELOG count re-derived).
@linletian

Copy link
Copy Markdown
Owner Author

对两份独立评审的仲裁与处理

我逐条对照代码核实了两份评审(09:33 独立评审、09:37 补充评审),结论:三条具体意见全部属实,前瞻问题的处置建议(单独开 issue)予以采纳。处理如下。

1. MINOR — PR 描述数字自相矛盾(47 vs 66)→ 属实,已修 PR 描述

核实:grep -c '^test(' = 66(develop 基线 58 + 本 PR 8),CHANGELOG.md:11 为 #80 条目,FindSubmatch 确取首个匹配。PR 描述是在合并 develop 之前写的,已更新为 66 并注明该守卫锚定 #80 条目。
附带接受一条前瞻性观察(不改代码):该"首个匹配"锚点在按时间倒序的文件里确实脆——将来任何含同样措辞的条目插到第 11 行之前都会静默改锚。记录在案,如需加固建议单独处理。

2. MINOR — startSSE 哨兵分支只读 logCursor,与自身注释 "either side" 矛盾 → 属实,已修(94385ae)

核实:index.html:4775 注释确实写 "outranks -2 on either side",而兜底分支只查 logCursor。已改为:

else if (session.logCursor === CURSOR_FOLLOW_LIVE_END || session.ttyOffset === CURSOR_FOLLOW_LIVE_END)

同时核对了两位评审"今天不可达"的论断:grep 全仓库确认 -2 的唯一赋值点(loadLog)总是成对写两个游标,其余写入点要么成对、要么只写非负值——所以这是纯防御性加固,现存全部状态下行为逐位等价。新增 node 测试钉住分裂态(66→67,CHANGELOG 计数重新推导)。
connectTTY 有意不改:它两个分支都只读 ttyOffset,而这是完备的——logCursor 只在成对赋值点被写,ttyOffset 还会被 WS sync 帧和 latch 后的 binary 帧单独推进,所以 ttyOffset 是超集游标,镜像分裂态无赋值点可达。该论证已写进两处注释。

3. NIT — writeSanitizedTerminalOutput 返回"字节数"措辞不准 → 属实,已修(94385ae)

实际返回 sanitize 后字符串的 .length(UTF-16 code unit)。注释已收窄为"字符数(非字节),仅作 > 0 布尔门,不得用于字节记账",并对齐了 loadLog painted 门、测试桩、docs/API.md、docs/ARCHITECTURE.md 的同一措辞。

4. 前瞻问题 — replay 实际起始 offset 纳入 sync 契约 → 采纳"单独开 issue"的建议

复核了补充评审的前提,全部成立:handshakeSynced latch 只在 sync 到达时置位且正确(stale cursor 会被 RingBuffer.ReadSince 静默 clamp,累加偏移可能发布从未存在的游标);-2 模式写零个 binary frame,#93 既不制造也不加宽这个窗口;正确形态必须下发 clamp 后的真实起点 S = max(since, oldestLive) 而非请求的 since。
已开 #94,把"首个 ReadSince 结果先行下发、S 与首个 chunk 同一次调用拿到、无需新增 Manager 接口"的最小形态写了进去。

验证

gofmt -l clean · go build ./... · go vet ./... · go test -count=1 ./... 全包 ok · node --test 67/67 · 计数守卫与 TestLogCursorBootstrapNeverRequestsOldestBytes 锚点同步更新并通过。

修复提交:94385ae。本 PR 仍待你评审合并。

@linletian

Copy link
Copy Markdown
Owner Author

第二轮评审:#93(94385ae 之后)

三条意见的修复我都独立复核过,全部正确:改动落在正确的位置、论证成立、并且新增的用例是真正的 red-before-green。CI 4 项全绿。可以合。

逐条复核

MINOR 2 — startSSE 改查两个兄弟游标:正确。

else if (session.logCursor === CURSOR_FOLLOW_LIVE_END || session.ttyOffset === CURSOR_FOLLOW_LIVE_END)

我穷举了 4 个游标状态(-2/-2、-1/-2、-2/-1、-2/undefined)× 正游标优先,语义都正确:正游标分支在前且未动,-2 不会变成无条件;两个未知仍然 omit since。在今天存在的任何状态下逐位等价,扩容为纯防御性。

新用例是真红的。 我把 static/ + testdata/ 复制到 /tmp(未改动仓库),只在副本里把这一行还原成 logCursor 单查,跑 node --test:

✖ issue #86 follow: startSSE asks for the sentinel when EITHER sibling holds the live end

还原前 67/67 绿。所以这条钉住的确实是那条规则,不是恒真断言。用例同时覆盖了镜像分裂态、两侧都未知、以及"真实游标压过 -2",三个反向断言都在,OR 不会被写成无条件的。

connectTTY 保持单查:论证成立。 我验证了它依赖的不变式:全仓库 -2 的唯一赋值点是 index.html:4229-4230,且总是成对写;logCursor 的全部写入点(两个 factory、每处清屏、loadLog 的 pin、SSE handler)都同时写等值 ttyOffset,只有 ttyOffset 会单独前进(sync 帧、latch 后的 binary 帧)。所以 ttyOffset 确实是超集游标,分裂态在 connectTTY 侧无赋值点可达——不改是对的,注释也补在 index.html 和 ui_test.go 两处了。

顺带确认新加的两个 ui_test.go 锚点不会互相顶替:connectTTY 锚点在 index.html 里恰好出现 1 次,且不是 startSSE 锚点的子串,所以两条 check 各自锚在各自的站点上。

NIT 3 — writeSanitizedTerminalOutput 措辞:已收窄为"字符数(.length,非字节),仅作 > 0 门控,不得用于字节记账",并同步了 loadLog 的 painted 门、三处测试桩、docs/API.md、docs/ARCHITECTURE.md。四处措辞现在一致。

仍然存在的一条

MINOR — PR 描述的 Tests / Verification 段这次又落后了一格(66 vs 67)。

修复提交新增了第 9 个 node 用例,CHANGELOG.md:11 也已改成 Pinned by 67,但描述里仍然是:

  • internal/ui/testdata/terminal_status.test.mjs — 8 new cases (**66 total** after merging develop: 58 baseline + 8 from this PR)
  • node --test 66/66
  • TestTerminalStatusChangelogCount re-derives 66

我 grep 了整份描述,"67" 出现 0 次。这和第一轮是同一类问题,只是这次差 1 而不是差 19。值得注意的是没有任何守卫会替你抓它:TestTerminalStatusChangelogCount 读的是 CHANGELOG.md 而不是 PR 描述,CHANGELOG 已经是 67,所以这个守卫现在是绿的。把描述改成 9 / 67 即可。

(Pinned by N 那个"首个匹配"锚点的加固,你按"记录在案、单独处理"处置我认可——它现在仍是唯一守着总数的东西,值得在 #94 或另开一条里顺手加个按 issue 定位的锚。)

NIT — 新注释有一行没换行

index.html startSSE 注释里 …but if any future site ever assigned ttyOffset = CURSOR_FOLLOW_LIVE_END 这一行明显超出周围注释宽度,断行点没对齐。纯排版,顺手断一下就行。

第二轮我独立复现的验证(全程未改动仓库,git status 保持干净)

检查 结果
grep -c '^test(' 67(CHANGELOG.md:11 = 67,一致)
node --test testdata/terminal_status.test.mjs 67/67 pass
新用例 red-before-green(/tmp 副本内还原那一行) ✖ 失败 → 确认真红
test -z "$(gofmt -l .)" clean
go vet ./... clean
go test -count=1 ./... 21 个包全 ok
go test -race -count=2 -run 'TTYWS|LogStream' ./internal/app/ ok
按加载顺序拼接全部 classic script → node --check exit 0
gh pr checks 93 4/4 pass,mergeable=CLEAN

结论:可以合。 剩下的是描述数字和一个断行,不涉及代码。

@linletian

Copy link
Copy Markdown
Owner Author

第二轮评审:#93

先把结论放前面:代码可以合。 94385aec 的修复我独立复核后是对的,没有引入新缺陷;下面三条都是文档层的残留,不阻塞合并。

修复本身:我验证过的部分

  • startSSE 的 || session.ttyOffset === CURSOR_FOLLOW_LIVE_END 是正确的。配合既有不变量 ttyOffset >= logCursor(logCursor 的每一处写入都与同值 ttyOffset 成对,ttyOffset 额外单独前进的地方只有 sync 帧和 latch 后的 binary 帧),新增的 OR 在今天存在的一切状态下与旧表达式逐位等价,只在分裂态才产生差异。
  • connectTTY 只读 ttyOffset 的理由我按同一不变量验过,成立:logCursor 从不被单独写入,所以不存在"logCursor 持有屏幕真相而 ttyOffset 没有"的状态,这里读单一兄弟是完备的,不是又一处单边。把这段推理写进注释是对的。
  • 新增的 node 用例不是空断言:sseCursorFactory 是用 sliceBlockQuoted 从 indexSource 切出真实 startSSE 再 new Function 执行的,不是重写的桩。按旧写法推演,{logCursor:-1, ttyOffset:-2} 下 -1 === -2 为假 → URL 不含 &since=-2 → 断言必然失败,即回退实现会让这条转红。(未执行回退验证,按只读评审约束走的推理。)

MINOR — CHANGELOG.md:5 漏改了,这正是本次要统一的那句措辞

94385aec 把 writeSanitizedTerminalOutput 的返回语义在 index.html 的函数注释、loadLog 的 painted 门注释、三处 harness stub、docs/API.md、docs/ARCHITECTURE.md 全都改成了"characters / .length, NOT bytes",但 CHANGELOG.md:5(#86 那条)没跟上,现在仍写着:

The painted-then-stripped claim is now gated on BYTES REACHING THE SCREEN … writeSanitizedTerminalOutput returns the count of bytes it actually wrote

CHANGELOG 是这条改动的长期记录,读者从这里拿到的仍然是错的单位;commit message 里列的"对齐清单"也确实没包含它。

同一句还有个数:#86 那条写 "client-side by seven new node --test cases",而 #93 实际加了 8 条(58 → 66),加上本次跟进共 9 条(现在 67)。建议一起改掉。

MINOR — PR 描述的计数又被这次跟进推回漂移

描述里现在是 "66 total"、"node --test 66/66"、"re-derives 66"。本次跟进加到 67,三处都要跟着改成 67。描述自己已经把这个数漂移当成要防的事("a hand-maintained count in prose is a claim nobody checks"),那就顺手跟上。

上一轮提出、这轮未处理(非阻塞,建议单开 issue)

TestTerminalStatusChangelogCount 的正则 Pinned by (\d+) \node --test` cases取**首个**匹配,全仓只有一处、在CHANGELOG.md:11` 的 #80 条目里。本次为了让守卫保持绿,做法是把 #80 条目的数字从 66 改成 67——等于继续维护一个锚在别人条目上的计数:#86 自己那条依然零约束。真正的修法是让守卫定位到 #86 条目(例如按条目 id 抓取该条目的计数句),而不是每次有新用例时回头改 #80。这属于测试基础设施改进,不该塞进 #93。

独立复现的验证(未改动任何文件)

检查 结果
node --test(internal/ui) 67/67 pass
gofmt -l . / go build ./... / go vet ./... clean
go test -count=1 ./... 全包 ok
^test( 实际条数 vs CHANGELOG.md 首个计数锚 67 vs 67,一致
枚举 index.html + kinds/*.js 全部游标写入点 无任何单边 logCursor 写入,ttyOffset >= logCursor 成立

#90 那轮交给本 PR 评审者的"replay 起始 offset 是否纳入 sync 契约"问题,结论仍是单开 issue,理由与我上一条评论相同,本次跟进未改变该判断。

…g; wrap a comment (PR #93 review round 2)

Second review round on PR #93, both MINORs verified against the files
before fixing.

CHANGELOG.md's #86 entry was the one place the follow-up's wording
change missed: it still claimed the painted-then-stripped gate was on
"BYTES REACHING THE SCREEN" and that writeSanitizedTerminalOutput
"returns the count of bytes". Aligned with the rest of the tree: it
returns the number of CHARACTERS (`.length`, UTF-16 code units, NOT
bytes), consumed only as a `> 0` boolean gate. The same entry also
undercounted the new node tests -- "seven" became "eight ... (the
eighth added by the review follow-up commit)": the PR added seven
driver cases plus the scope guard (58 -> 66), and the first-round
follow-up added one more driver case (66 -> 67), nine across the PR.

Also rewrapped one 103-column comment line in startSSE to the
surrounding ~72-column width. No logic changed anywhere.
@linletian

Copy link
Copy Markdown
Owner Author

第二轮评审的仲裁与处理

三条意见逐条对文件核实,全部属实,已修复;另按两轮共识把守卫锚点加固单开了 issue。

1. MINOR — CHANGELOG.md:5 漏改措辞 + 计数错 → 已修(06a6eb2)

核实:#86 条目确实仍写 "gated on BYTES REACHING THE SCREEN" 和 "count of bytes",是第一轮措辞对齐时漏掉的一处。已改为与全树一致的 "characters(.length,UTF-16 code units,NOT bytes),仅作 > 0 布尔门"。
计数句核实过程:PR 最初加 7 个驱动用例 + 1 个脚本冲突守卫(58→66),第一轮跟进再加 1 个驱动用例(66→67),故改为 "eight ... (the eighth added by the review follow-up commit)"——驱动用例 8 个,连同守卫共 9 个,与 "67" 的总数一致。CHANGELOG.md:11(Pinned by 67)未动。

2. MINOR — PR 描述又落后一格(66 vs 67)→ 已修 PR 描述

改为 "9 new cases across the PR (67 total: 58 baseline + 8 initial + 1 review follow-up)",三处 66→67。评审指出"没有任何守卫会抓它"是对的——守卫读 CHANGELOG 不读 PR 描述,这正是它两轮连续漏网的原因。

3. NIT — startSSE 注释一行 103 列 → 已修(06a6eb2)

断到 68/58/67/71 列,与周围一致;同行的长布尔代码行按本文件惯例(参照 parseTTYControlMessage 白名单行)保持不动。

4. 守卫锚点加固 → 已单开 issue #95

两轮评审的一致结论(属测试基础设施、不进 #93)我同意,且不宜塞进 #94(主题不同)。#95 里写了两种可选形态:按条目定位计数句(锚到对应 issue 条目),或要求全文件每个 Pinned by N 都等于实际数(更严,强制每条目诚实)。

验证

gofmt -l clean · go build ./... · go vet ./... · go test -count=1 ./... 全包 ok · node --test 67/67 · 计数 67 与 CHANGELOG.md:11 一致 · 未新增第二个计数句(grep -c 'Pinned by' 与 HEAD 相同)。

#90 留下的 replay 起始 offset 问题维持原结论:单开 issue,即 #94。

@linletian
linletian merged commit 630d89a into develop Oct 6, 2026
4 checks passed
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