fix(terminal): recover a dropped output frame, do not merely schedule it

`_onSessionTerminal` drops an incoming frame once the app-owned render queues
already hold 128KB. That is the right call — the alternative is an unbounded
backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced
cursor is muffled text (#464). The drop was only half of it.

The recovery was a fire-and-forget timer: it nulled its own handle and then
called `_onSessionNeedsRefresh()`, which opens with four early returns. Two of
them — a buffer load already in flight, a refresh already owning this session —
are MOST likely to be true during exactly the output burst that caused the
drop. So the recovery was skipped precisely when it was needed, with nothing
left to retry it, and the dropped bytes were never replayed.

`_onSessionNeedsRefresh` reports whether it actually repainted now, and
`_scheduleDroppedOutputRecovery` re-arms while it has not. Bounded by
`DROP_RECOVERY_MAX_ATTEMPTS`, because every reason the refresh can be skipped is
transient contention that clears in seconds and a permanently failing refresh
must not become a loop against the API; giving up at the cap leaves exactly what
the old code left, so the floor is no worse than before. The same 2s debounce
still collapses a burst of drops into one attempt.

This is the principle Ark0N established reviewing #431 for the WebSocket
output-gap marker — only a repaint that actually happened settles the recovery —
applied to the one recovery path that still trusted a timer having fired.

The retry decision is a pure function in constants.js so the gate can reach it,
and the scheduler itself is driven from app.js under a fake clock. The retry
case and the no-retry case only pin the fix AS A PAIR: either alone passes
against something wrong, one against the old fire-and-forget timer and the other
against retrying forever. Checked by reverting app.js to the old shape, where
three of the twelve fail.

Two harness details that would otherwise have made the tests lie. The vm context
baked in the real `setTimeout`, so `vi.useFakeTimers()` could not reach the
scheduler and every case reported zero calls; it delegates lazily now. And
app.js reached `CodemanDroppedOutput` as a bare global, which resolves in a
browser but not in the vm — worth fixing beyond the test, because that call sits
inside a timer where a ReferenceError is swallowed and would take the recovery
with it. It reads through `window.` like terminal-ui.js does with its own
constants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rounak Datta
2026-09-22 21:34:03 +05:30
co-authored by Claude Opus 5
parent e1e7dc5bd8
commit 00f022ccf8
5 changed files with 360 additions and 14 deletions
+69 -13
View File
@@ -2018,14 +2018,10 @@ class CodemanApp {
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
+ (this._terminalWriteInFlightBytes || 0);
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
// Schedule a self-recovery once the
// queue drains (debounced to avoid hammering the API during sustained bursts).
if (!this._clientDropRecoveryTimer) {
this._clientDropRecoveryTimer = setTimeout(() => {
this._clientDropRecoveryTimer = null;
this._onSessionNeedsRefresh();
}, 2000);
}
// The bytes are gone from the stream now, so the recovery is the only
// thing that puts this terminal back in step with the PTY.
_crashDiag.log(`TERMINAL DROP: ${(queued / 1024).toFixed(0)}KB queued`);
this._scheduleDroppedOutputRecovery(data.id);
return;
}
@@ -2033,6 +2029,52 @@ class CodemanApp {
}
}
/**
* Put the terminal back in step after a dropped frame, and keep trying until
* something actually repaints.
*
* ⚠️ A fire-and-forget timer is not a recovery, which is what this used to be:
* it nulled its own handle and then called `_onSessionNeedsRefresh()`, whose
* early returns are most likely to fire during the very burst that caused the
* drop. A skipped refresh lost the recovery with nothing left to retry it, so
* the hole stayed in the stream — and a hole in a TUI byte stream is a
* desynced cursor, which is muffled text (issue #464).
*
* Debounced by the same 2s as before, so a sustained burst still collapses
* into one attempt rather than hammering the API; bounded by
* `DROP_RECOVERY_MAX_ATTEMPTS`, because every reason the refresh can be
* skipped is transient contention. Giving up after the cap leaves exactly
* what the old code left, so the floor is no worse.
*
* @param {string} sessionId - the session whose output was dropped
* @param {number} [attempt] - zero-based, for the bound
*/
_scheduleDroppedOutputRecovery(sessionId, attempt = 0) {
if (!sessionId || this._clientDropRecoveryTimer) return;
this._clientDropRecoveryTimer = setTimeout(async () => {
this._clientDropRecoveryTimer = null;
let repainted = false;
try {
repainted = (await this._onSessionNeedsRefresh({ id: sessionId })) === true;
} catch {
// Treated as "did not repaint" — retrying is the entire point of this.
}
const retry = window.CodemanDroppedOutput.shouldRetryDroppedOutputRecovery({
repainted,
attempt,
stillActive: this.activeSessionId === sessionId,
});
if (retry) {
_crashDiag.log(`DROP RECOVERY: attempt ${attempt + 1} did not repaint, retrying`);
this._scheduleDroppedOutputRecovery(sessionId, attempt + 1);
}
// Read through `window.` like terminal-ui.js does with its own constants:
// a bare global resolves in a browser but not in the vm harnesses the gate
// runs app.js under, and this body executes inside a timer where a
// ReferenceError would be swallowed — taking the recovery with it.
}, window.CodemanDroppedOutput.DROP_RECOVERY_DELAY_MS);
}
// ═══════════════════════════════════════════════════════════════
// Response Viewer — native-scroll panel for reading full Claude responses
// ═══════════════════════════════════════════════════════════════
@@ -2656,15 +2698,27 @@ class CodemanApp {
}
}
/**
* Reload this session's buffer from the server.
*
* ⚠️ Returns whether it ACTUALLY reloaded. Four of the paths out of here are
* early returns, and two of them — a buffer load in flight, a refresh already
* owning this session — are most likely to be true during exactly the output
* burst that makes a caller need this. A caller that treats "called" as
* "recovered" silently loses the recovery; `_scheduleDroppedOutputRecovery`
* is the one that cannot afford to.
*
* @returns {Promise<boolean>} true only once a response has been applied.
*/
async _onSessionNeedsRefresh(event = {}) {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
const sessionId = this.activeSessionId;
if (event?.id && event.id !== sessionId) return;
if (!sessionId || !this.terminal) return;
if (event?.id && event.id !== sessionId) return false;
if (!sessionId || !this.terminal) return false;
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
if (this._isLoadingBuffer) return;
if (this._terminalRefreshOwner?.sessionId === sessionId) return;
if (this._isLoadingBuffer) return false;
if (this._terminalRefreshOwner?.sessionId === sessionId) return false;
const refreshOwner = { sessionId };
this._terminalRefreshOwner = refreshOwner;
try {
@@ -2690,7 +2744,7 @@ class CodemanApp {
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return;
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false;
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
@@ -2740,8 +2794,10 @@ class CodemanApp {
// replay. Leaving the marker set there refetched on every reconnect for
// the life of the page.
this._markTerminalBufferReconciled(sessionId);
return true;
} catch (err) {
console.error('needsRefresh reload failed:', err);
return false;
} finally {
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
}
+44
View File
@@ -1673,6 +1673,45 @@ function sanitizeDiagEntry(msg) {
.slice(0, DIAG_ENTRY_MAX_CHARS);
}
// ── Recovering a dropped output frame ──────────────────────────────────────
//
// `_onSessionTerminal` drops an incoming frame when the app-owned render queues
// already hold 128KB, which is the right call — the alternative is an unbounded
// backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced
// cursor is muffled text (issue #464). So the drop is only half of it: the
// recovery has to actually happen.
//
// ⚠️ It used to be a fire-and-forget timer. `_onSessionNeedsRefresh` opens with
// four early returns, and two of them — a buffer load in flight, a refresh
// already owning this session — are MOST likely to be true during exactly the
// output burst that caused the drop. The timer nulled itself before the call,
// so a skipped refresh lost the recovery silently and the dropped bytes were
// never replayed.
//
// Bounded, because every reason the refresh can be skipped is transient
// contention that clears in seconds, and a permanently failing refresh must not
// become a forever-loop against the API. Giving up after the cap leaves exactly
// the garbled frames the old code left, so the floor is no worse than before.
const DROP_RECOVERY_DELAY_MS = 2000;
const DROP_RECOVERY_MAX_ATTEMPTS = 5;
/**
* Should a dropped-output recovery run again?
*
* @param {{repainted: boolean, attempt: number, stillActive: boolean}} state
* `repainted` — whether `_onSessionNeedsRefresh` actually rewrote the buffer.
* `attempt` — how many have already run, zero-based.
* `stillActive` — whether the dropped session is still the one on screen.
* @returns {boolean}
*/
function shouldRetryDroppedOutputRecovery({ repainted, attempt, stillActive }) {
// Switched away: `selectSession` repaints from the server on its own, so a
// retry here would be a second replay of a buffer that is about to be written.
if (!stillActive) return false;
if (repainted) return false;
return attempt + 1 < DROP_RECOVERY_MAX_ATTEMPTS;
}
// ── Terminal geometry: xterm and the PTY must never disagree ───────────────
//
// Issue #464 ("text gets muffled"). Claude Code's TUI repaints by wrapping its
@@ -1758,6 +1797,11 @@ if (typeof window !== 'undefined') {
FETCH_DEADLINE_MAX_MS,
};
window.CodemanDiag = { sanitizeDiagEntry, DIAG_ENTRY_MAX_CHARS };
window.CodemanDroppedOutput = {
shouldRetryDroppedOutputRecovery,
DROP_RECOVERY_DELAY_MS,
DROP_RECOVERY_MAX_ATTEMPTS,
};
window.CodemanTerminalGeometry = {
clampTerminalDimensions,
terminalGeometryAgrees,