docs(terminal): the merge-time notes promised on #436

The four edits the review said would be folded in at merge, none of them
code: the changeset becomes one user-facing paragraph, since it is what
CHANGELOG.md and the release notes print; the `_bufferLoadFinishOpts` comment
now names the second contributor to the duplicate window (`captureActivePaneBuffer`
is `execSync`, so anything painted into the pane before the server read it is
in the capture and is broadcast after the reply) and says why a `history`
payload keeps the pre-existing discard when its exposure is the same; the
`_finishBufferLoad` doc block moves from above `_beginBufferLoad` onto the
function it documents; and the test file's header describes both rules the
file now pins instead of only COD-144.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-18 21:38:40 +02:00
parent 492f8d8ddf
commit 3cdb4bf42e
4 changed files with 75 additions and 88 deletions
+25 -12
View File
@@ -1917,23 +1917,36 @@ class CodemanApp {
* browser after the response headers can already be in it. Such a load
* replays exactly that tail; discarding it drops the CLI's output for the
* rest of the load window, and its next partial redraw then lands on a frame
* the terminal never received. A payload built from the server's accumulated
* byte history needs the opposite: that history is current up to the
* response, so replaying the queue on top of it would duplicate output.
* the terminal never received.
*
* A `history` payload is the server's byte buffer alone: the direct-PTY
* fallback, or a mux pane whose capture came back empty. The route reads
* that buffer in the same synchronous tick it takes the capture, so it is
* current up to the route's own read and no further, which is the same
* exposure. It deliberately keeps the pre-existing discard all the same:
* both cases are rare, neither has been measured, and a duplicated Ink
* redraw is more visible than a few milliseconds of missing output.
* `capturedFromMux` below is the one line to widen if either turns out to
* matter.
*
* `headersReceivedAt` is the caller's own `performance.now()` reading from
* the moment the response arrived, compared only against other client-side
* readings, so there is no clock skew to worry about.
*
* What this cutoff does NOT cover: the server appends output to the byte
* buffer and emits it in the same tick, but it BROADCASTS on a batch timer —
* 8ms over WebSocket, 16 to 50ms over SSE. The terminal route runs
* synchronously from `capture-pane` to its return, so a batch that was
* already pending when the capture ran leaves the server after the reply,
* arrives after `headersReceivedAt`, and is replayed although the capture
* holds it. The duplicate is one batch interval wide, against a recovery
* window that spans the whole chunked write. Closing it belongs on the
* server: flush that session's pending batch before taking the capture.
* What this cutoff does NOT cover, and there are two contributors. The
* server appends output to the byte buffer and emits it in the same tick,
* but BROADCASTS on a batch timer (8ms over WebSocket, 16 to 50ms over SSE),
* and the terminal route runs synchronously from `capture-pane` to its
* return, so a batch already pending when the capture ran leaves the server
* after the reply, arrives after `headersReceivedAt`, and is replayed
* although the capture holds it. Separately, `captureActivePaneBuffer` is
* `execSync`, which blocks the event loop for the whole capture: anything
* tmux had already painted into the pane that the server had not yet read
* from the attach PTY is in the capture too, is broadcast only after the
* reply, and replays the same way. The duplicate is one batch interval plus
* one capture wide, against a recovery window that spans the whole chunked
* write. Closing it belongs on the server: flush that session's pending
* batch before taking the capture.
*
* @param {{source?: string}} payload - The parsed `data` of a terminal response.
* @param {number} headersReceivedAt - When that response reached this client.
+24 -15
View File
@@ -3924,6 +3924,30 @@ Object.assign(CodemanApp.prototype, {
});
},
/**
* Open a buffer load: live terminal events are queued from here until
* `_finishBufferLoad` decides what to do with them. Returns the load token the
* finish call must present; a stale token makes that call a no-op.
*
* @param {string} [owner] Reuse an existing token to re-enter the same load
* (see below); omit it to start a new one.
* @returns {string} The load token.
*/
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
// opens it again under the SAME owner when it starts writing. Resetting the
// queue on that second call would throw away everything that arrived during
// the fetch, which on the capture path is output no buffer holds. Re-entering
// one load keeps its queue; a genuinely new load still starts empty.
const reentering = this._bufferLoadOwner === loadOwner && Array.isArray(this._loadBufferQueue);
this._bufferLoadOwner = loadOwner;
this._isLoadingBuffer = true;
if (!reentering) this._loadBufferQueue = [];
return loadOwner;
},
/**
* Complete a buffer load: unblock live SSE writes.
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
@@ -3960,21 +3984,6 @@ Object.assign(CodemanApp.prototype, {
* is true, replay queued events whose arrival timestamp is at or after
* `since` (default 0, meaning the whole queue).
*/
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
// opens it again under the SAME owner when it starts writing. Resetting the
// queue on that second call would throw away everything that arrived during
// the fetch, which on the capture path is output no buffer holds. Re-entering
// one load keeps its queue; a genuinely new load still starts empty.
const reentering = this._bufferLoadOwner === loadOwner && Array.isArray(this._loadBufferQueue);
this._bufferLoadOwner = loadOwner;
this._isLoadingBuffer = true;
if (!reentering) this._loadBufferQueue = [];
return loadOwner;
},
_finishBufferLoad(owner, opts) {
if (owner !== undefined && this._bufferLoadOwner !== owner) {
return false;