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:
+25
-16
@@ -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');
|
||||
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);
|
||||
}, 260);
|
||||
// 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
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user