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:
+16
-7
@@ -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');
|
||||||
|
var expanded = $('view-expanded');
|
||||||
if (overview) overview.style.display = 'none';
|
if (overview) overview.style.display = 'none';
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
expanded.classList.remove('hidden'); // must remove class — !important wins over style.display
|
expanded.classList.remove('hidden'); // must remove class — !important wins over style.display
|
||||||
expanded.classList.add('view--active'); // makes it display:flex
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user