From f7ba0a29e553359ca802fc2a60a8b8d68d1f3f92 Mon Sep 17 00:00:00 2001 From: Ken Date: Thu, 28 May 2026 01:42:55 +0000 Subject: [PATCH] fix: blank terminal when switching cached sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs introduced by the terminal instance cache that caused blank terminals: Bug 1 (primary — every second+ visit to a cached session): switchTerminal()'s section 4 calls createTerminal(), which disposes the existing _term before creating a new one. At the time section 4 runs, _term still points to the PREVIOUSLY-ACTIVE session's terminal — the one sitting live in its own cache entry. After disposal the cache entry holds a dead xterm instance; the next visit to that session via the fast-path calls fit()/focus() on the disposed terminal, both silently no-op, and the terminal renders blank. Fix: null out _term and _fitAddon in section 4's 'Reset module-level state' block BEFORE createTerminal(), so the previously-active session's terminal is left untouched in its cache entry. Bug 2 (secondary — WS dies while session is in background): When a cached session's WebSocket closes while hidden, the close handler bails early via the stale-guard (ws !== _ws), because _ws now points to the active session's socket. No reconnect is scheduled. On revisit: isTerminalCached() returns false → openSession() POSTs /connect → but switchTerminal() takes the cache-HIT branch (_terminalCache.has() is still true), shows the container, and returns — without creating a new WebSocket. Terminal container appears but has no live connection → blank. Fix: in the cache-HIT branch, check WS readyState before taking the fast path. If the socket is CLOSED or CLOSING, call destroyCachedEntry() and fall through to section 4 so a fresh terminal + connection is created. (openSession() has already POST'd /connect, so ttyd is ready.) --- muxplex/frontend/terminal.js | 41 +++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 3fc4109..ba50478 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -661,20 +661,32 @@ function switchTerminal(sessionName, remoteId, fontSize) { } } - // 3. If target is in cache — instant switch, no network + // 3. If target is in cache — check WebSocket liveness before the fast path if (_terminalCache.has(key)) { var entry = _terminalCache.get(key); - if (entry.container) { - entry.container.style.display = ''; + var wsIsLive = entry.ws && + (entry.ws.readyState === WebSocket.OPEN || + entry.ws.readyState === WebSocket.CONNECTING); + if (wsIsLive) { + // Fast path: live connection — swap containers, no network round-trip + if (entry.container) { + entry.container.style.display = ''; + } + _syncModuleState(entry); + _activeSessionKey = key; + _touchCacheOrder(key); + if (_fitAddon) { + try { _fitAddon.fit(); } catch (_) {} + } + if (_term) _term.focus(); + return; } - _syncModuleState(entry); - _activeSessionKey = key; - _touchCacheOrder(key); - if (_fitAddon) { - try { _fitAddon.fit(); } catch (_) {} - } - if (_term) _term.focus(); - return; + // WebSocket died while this session was in the background. + // The close handler's stale-guard (ws !== _ws) suppresses reconnects for + // non-active sessions, so the entry is left with a dead socket. + // Destroy the stale entry and fall through to create a fresh terminal. + // openSession() already POST'd /connect so the backend ttyd is ready. + destroyCachedEntry(key); } // 4. Not in cache — create new entry @@ -698,7 +710,12 @@ function switchTerminal(sessionName, remoteId, fontSize) { subContainer.style.height = '100%'; parentContainer.appendChild(subContainer); - // Reset module-level state for fresh creation + // Reset module-level state for fresh creation. + // Null _term/_fitAddon BEFORE createTerminal() so it does NOT dispose the + // previously-active session's terminal — that terminal lives on in its own + // cache entry and must stay alive for instant switching later. + _term = null; + _fitAddon = null; _currentSession = null; _reconnectAttempts = 0; _reconnectTimer = null;