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>
This commit is contained in:
arkon
2026-03-22 03:00:58 +01:00
co-authored by Claude Opus 4.6
parent bec8e2f9ee
commit ea7b103b47
2 changed files with 31 additions and 1 deletions
+11
View File
@@ -1362,6 +1362,8 @@ class CodemanApp {
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// Preserve local echo overlay text across SSE reconnect — just hide until
// terminal buffer reloads and prompt is visible again. _render() re-scans
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
@@ -2020,6 +2022,10 @@ class CodemanApp {
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
// Abort any in-flight chunkedTerminalWrite from the previous session.
// Without this, old rAF-scheduled chunks continue writing stale data
// into the terminal, interleaving with the new session's buffer.
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// End any in-flight IME composition.
// iOS Safari keeps autocorrect composing; switching tabs without ending it
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
@@ -2136,6 +2142,11 @@ class CodemanApp {
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
try {
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
// canvas may be at stale dimensions — content would render at wrong width.
if (this.fitAddon) this.fitAddon.fit();
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
+20 -1
View File
@@ -219,6 +219,9 @@ Object.assign(CodemanApp.prototype, {
// Welcome message
this.showWelcome();
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
this._chunkedWriteGen = 0;
// Handle resize with throttling for performance
this._resizeTimeout = null;
this._lastResizeDims = null;
@@ -1033,6 +1036,10 @@ Object.assign(CodemanApp.prototype, {
* @returns {Promise<void>} - Resolves when all chunks written
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
// older writes abort instead of continuing to push stale data into the terminal.
const writeGen = ++this._chunkedWriteGen;
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
this._finishBufferLoad();
@@ -1049,7 +1056,10 @@ Object.assign(CodemanApp.prototype, {
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
const finish = () => {
this._finishBufferLoad();
// Only finish if we're still the active write — a newer write owns buffer load state
if (this._chunkedWriteGen === writeGen) {
this._finishBufferLoad();
}
resolve();
};
@@ -1067,6 +1077,12 @@ Object.assign(CodemanApp.prototype, {
const _chunkStart = performance.now();
let _chunkCount = 0;
const writeChunk = () => {
// Abort if a newer chunked write started (user switched tabs)
if (this._chunkedWriteGen !== writeGen) {
resolve();
return;
}
if (offset >= cleanBuffer.length) {
const _totalMs = performance.now() - _chunkStart;
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, {
* @returns {Promise<void>}
*/
async sendResize(sessionId) {
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
const dims = this.getTerminalDimensions();
if (!dims) return;
// Fast path: WebSocket resize