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
+16 -7
View File
@@ -657,21 +657,24 @@ async function openSession(name, opts = {}) {
tile.style.height = '100vh';
}
// After animation completes: switch views, then mount terminal
setTimeout(() => {
const overview = $('view-overview');
const expanded = $('view-expanded');
// Start animation concurrently with /connect POST — resolve when view is ready
var animDone = new Promise(function (resolve) {
var timerId = setTimeout(function () {
var overview = $('view-overview');
var expanded = $('view-expanded');
if (overview) overview.style.display = 'none';
if (expanded) {
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
initSidebar();
renderSidebar(_currentSessions, name);
resolve();
}, 260);
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
if (timerId == null) resolve();
});
// Mobile pill
if (isMobile()) {
@@ -685,7 +688,7 @@ async function openSession(name, opts = {}) {
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 {
if (!opts.skipConnect) {
await api('POST', `/api/sessions/${name}/connect`);
@@ -694,6 +697,12 @@ async function openSession(name, opts = {}) {
showToast(err.message || 'Connection failed');
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 { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -2025,4 +2026,28 @@ test('bindSidebarClickAway registers click listener on terminal-container', () =
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');
});