fix: blank terminal when switching cached sessions

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.)
This commit is contained in:
Ken
2026-05-28 01:42:55 +00:00
parent e032d540b0
commit f7ba0a29e5
+29 -12
View File
@@ -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)) { if (_terminalCache.has(key)) {
var entry = _terminalCache.get(key); var entry = _terminalCache.get(key);
if (entry.container) { var wsIsLive = entry.ws &&
entry.container.style.display = ''; (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); // WebSocket died while this session was in the background.
_activeSessionKey = key; // The close handler's stale-guard (ws !== _ws) suppresses reconnects for
_touchCacheOrder(key); // non-active sessions, so the entry is left with a dead socket.
if (_fitAddon) { // Destroy the stale entry and fall through to create a fresh terminal.
try { _fitAddon.fit(); } catch (_) {} // openSession() already POST'd /connect so the backend ttyd is ready.
} destroyCachedEntry(key);
if (_term) _term.focus();
return;
} }
// 4. Not in cache — create new entry // 4. Not in cache — create new entry
@@ -698,7 +710,12 @@ function switchTerminal(sessionName, remoteId, fontSize) {
subContainer.style.height = '100%'; subContainer.style.height = '100%';
parentContainer.appendChild(subContainer); 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; _currentSession = null;
_reconnectAttempts = 0; _reconnectAttempts = 0;
_reconnectTimer = null; _reconnectTimer = null;