Skip to content

Commit ea7b103

Browse files
Ark0Nclaude
andcommitted
fix: prevent stale terminal data on tab switch — add chunkedTerminalWrite cancellation
chunkedTerminalWrite used requestAnimationFrame to write buffer chunks across frames but had no cancellation. When switching tabs, old session's remaining chunks continued writing stale data into the new session's terminal, causing visual artifacts and garbled content. - Add _chunkedWriteGen generation counter to abort in-flight chunked writes - Bump gen early in selectSession() and SSE reconnect to immediately cancel - Guard finish() so aborted writes don't flush SSE queue for wrong session - Add fitAddon.fit() before buffer writes to sync terminal dimensions - Add fitAddon.fit() in sendResize() to ensure local/server dim parity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bec8e2f commit ea7b103

2 files changed

Lines changed: 31 additions & 1 deletion

File tree

src/web/public/app.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,8 @@ class CodemanApp {
13621362
this.writeFrameScheduled = false;
13631363
this._isLoadingBuffer = false;
13641364
this._loadBufferQueue = null;
1365+
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
1366+
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
13651367
// Preserve local echo overlay text across SSE reconnect — just hide until
13661368
// terminal buffer reloads and prompt is visible again. _render() re-scans
13671369
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
@@ -2020,6 +2022,10 @@ class CodemanApp {
20202022
this.writeFrameScheduled = false;
20212023
this._isLoadingBuffer = false;
20222024
this._loadBufferQueue = null;
2025+
// Abort any in-flight chunkedTerminalWrite from the previous session.
2026+
// Without this, old rAF-scheduled chunks continue writing stale data
2027+
// into the terminal, interleaving with the new session's buffer.
2028+
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
20232029
// End any in-flight IME composition.
20242030
// iOS Safari keeps autocorrect composing; switching tabs without ending it
20252031
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
@@ -2136,6 +2142,11 @@ class CodemanApp {
21362142
this._isLoadingBuffer = true;
21372143
this._loadBufferQueue = [];
21382144
try {
2145+
// Fit terminal to container BEFORE writing any buffer data.
2146+
// If the browser was resized while viewing another session, the terminal
2147+
// canvas may be at stale dimensions — content would render at wrong width.
2148+
if (this.fitAddon) this.fitAddon.fit();
2149+
21392150
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
21402151
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
21412152
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.

src/web/public/terminal-ui.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ Object.assign(CodemanApp.prototype, {
219219
// Welcome message
220220
this.showWelcome();
221221

222+
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
223+
this._chunkedWriteGen = 0;
224+
222225
// Handle resize with throttling for performance
223226
this._resizeTimeout = null;
224227
this._lastResizeDims = null;
@@ -1033,6 +1036,10 @@ Object.assign(CodemanApp.prototype, {
10331036
* @returns {Promise<void>} - Resolves when all chunks written
10341037
*/
10351038
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
1039+
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
1040+
// older writes abort instead of continuing to push stale data into the terminal.
1041+
const writeGen = ++this._chunkedWriteGen;
1042+
10361043
return new Promise((resolve) => {
10371044
if (!buffer || buffer.length === 0) {
10381045
this._finishBufferLoad();
@@ -1049,7 +1056,10 @@ Object.assign(CodemanApp.prototype, {
10491056
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
10501057

10511058
const finish = () => {
1052-
this._finishBufferLoad();
1059+
// Only finish if we're still the active write — a newer write owns buffer load state
1060+
if (this._chunkedWriteGen === writeGen) {
1061+
this._finishBufferLoad();
1062+
}
10531063
resolve();
10541064
};
10551065

@@ -1067,6 +1077,12 @@ Object.assign(CodemanApp.prototype, {
10671077
const _chunkStart = performance.now();
10681078
let _chunkCount = 0;
10691079
const writeChunk = () => {
1080+
// Abort if a newer chunked write started (user switched tabs)
1081+
if (this._chunkedWriteGen !== writeGen) {
1082+
resolve();
1083+
return;
1084+
}
1085+
10701086
if (offset >= cleanBuffer.length) {
10711087
const _totalMs = performance.now() - _chunkStart;
10721088
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
@@ -1240,6 +1256,9 @@ Object.assign(CodemanApp.prototype, {
12401256
* @returns {Promise<void>}
12411257
*/
12421258
async sendResize(sessionId) {
1259+
// Fit terminal to container before reading dimensions — ensures local
1260+
// terminal size matches what we report to the server PTY.
1261+
if (this.fitAddon) this.fitAddon.fit();
12431262
const dims = this.getTerminalDimensions();
12441263
if (!dims) return;
12451264
// Fast path: WebSocket resize

0 commit comments

Comments
 (0)