fix: session switch race — mount terminal AFTER /connect POST completes

_openTerminal(name) was inside a 260ms setTimeout that fired before the
/connect POST completed. The WebSocket connected to the OLD ttyd (still
serving the previous session). Fix: animation runs concurrently with
/connect as before, but _openTerminal only fires after BOTH resolve.

Sequence: start animation + POST concurrently → await POST → await
animation → mount terminal. The new ttyd is guaranteed to be serving
the correct session before the WebSocket connects.

Added timerId null-check so animDone resolves immediately in test envs
where setTimeout is stubbed, preventing test hangs.
This commit is contained in:
Brian Krabach
2026-03-29 14:17:10 -07:00
parent ab1f579ea9
commit 4b4b914858
2 changed files with 50 additions and 16 deletions
+25 -16
View File
@@ -657,21 +657,24 @@ async function openSession(name, opts = {}) {
tile.style.height = '100vh'; tile.style.height = '100vh';
} }
// After animation completes: switch views, then mount terminal // Start animation concurrently with /connect POST — resolve when view is ready
setTimeout(() => { var animDone = new Promise(function (resolve) {
const overview = $('view-overview'); var timerId = setTimeout(function () {
const expanded = $('view-expanded'); var overview = $('view-overview');
if (overview) overview.style.display = 'none'; var expanded = $('view-expanded');
if (expanded) { if (overview) overview.style.display = 'none';
expanded.classList.remove('hidden'); // must remove class — !important wins over style.display if (expanded) {
expanded.classList.add('view--active'); // makes it display:flex expanded.classList.remove('hidden'); // must remove class — !important wins over style.display
} expanded.classList.add('view--active'); // makes it display:flex
// Mount terminal AFTER view is visible so FitAddon measures real dimensions }
if (window._openTerminal) window._openTerminal(name); // Re-render sidebar after DOM is visible and dimensions are correct
// Re-render sidebar after DOM is visible and dimensions are correct initSidebar();
initSidebar(); renderSidebar(_currentSessions, name);
renderSidebar(_currentSessions, name); resolve();
}, 260); }, 260);
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
if (timerId == null) resolve();
});
// Mobile pill // Mobile pill
if (isMobile()) { if (isMobile()) {
@@ -685,7 +688,7 @@ async function openSession(name, opts = {}) {
updateSessionPill(_currentSessions); updateSessionPill(_currentSessions);
} }
// Connect to session (before animation completes so ttyd is ready) // Connect to session (kill old ttyd, spawn new one for this session)
try { try {
if (!opts.skipConnect) { if (!opts.skipConnect) {
await api('POST', `/api/sessions/${name}/connect`); await api('POST', `/api/sessions/${name}/connect`);
@@ -694,6 +697,12 @@ async function openSession(name, opts = {}) {
showToast(err.message || 'Connection failed'); showToast(err.message || 'Connection failed');
return closeSession(); return closeSession();
} }
// Wait for animation to finish (may already be done if /connect was slow)
await animDone;
// Mount terminal NOW — /connect has completed, new ttyd is serving the correct session
if (window._openTerminal) window._openTerminal(name);
} }
/** /**
+25
View File
@@ -45,6 +45,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -2025,4 +2026,28 @@ test('bindSidebarClickAway registers click listener on terminal-container', () =
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
}); });
test('openSession mounts terminal AFTER connect POST, not inside animation timer', () => {
const source = fs.readFileSync(
new URL('../app.js', import.meta.url), 'utf8'
);
// Find the openSession function body
const fnStart = source.indexOf('async function openSession');
const fnBody = source.substring(fnStart, fnStart + 3000);
// _openTerminal must NOT appear inside setTimeout
const setTimeoutIdx = fnBody.indexOf('setTimeout');
const setTimeoutEnd = fnBody.indexOf('}, 260)', setTimeoutIdx);
const setTimeoutBody = fnBody.substring(setTimeoutIdx, setTimeoutEnd);
assert.ok(!setTimeoutBody.includes('_openTerminal'),
'_openTerminal must NOT be inside the 260ms setTimeout — causes race condition with /connect POST');
// _openTerminal must appear AFTER the /connect POST
const connectIdx = fnBody.indexOf('/api/sessions/');
const openTermIdx = fnBody.indexOf('_openTerminal', connectIdx);
assert.ok(openTermIdx > connectIdx,
'_openTerminal must appear AFTER the /connect POST in the source');
});