diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 3b4fbe0..11be612 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -53,27 +53,18 @@ function connectWebSocket(name, sourceUrl) { }); } - function connect() { + // _connectWebSocket — creates the WebSocket instance and registers all event handlers. + // Called directly for normal reconnects (ttyd still alive), or after a brief delay + // following the /connect POST (ttyd was dead and needed respawning). + // + // Local const `ws` captures this specific instance so each handler can check + // `if (ws !== _ws) return;` (stale guard). Without it, rapid reconnects or + // session switches cause old handlers to fire on the new _ws while it is still + // CONNECTING → send error → close → reconnect → infinite loop (Bug 2). + function _connectWebSocket() { // 'tty' subprotocol is REQUIRED — without it ttyd never starts the PTY. // Confirmed via raw Python WebSocket tests: ttyd accepts the TCP upgrade but // sits completely silent (no child process spawned) when subprotocol is omitted. - // - // Local const `ws` captures this specific instance so each handler can check - // `if (ws !== _ws) return;` (stale guard). Without it, rapid reconnects or - // session switches cause old handlers to fire on the new _ws while it is still - // CONNECTING → send error → close → reconnect → infinite loop (Bug 2). - - // After 2 failed WS attempts, ttyd is likely dead (e.g. after service restart). - // Fire-and-forget POST to /api/sessions/{name}/connect to respawn ttyd before - // attempting the next WebSocket connection. fetch() includes cookies automatically - // for same-origin requests so auth is handled transparently. - if (_reconnectAttempts >= 2 && _currentSession) { - fetch('/api/sessions/' + encodeURIComponent(_currentSession) + '/connect', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }).catch(function() {}); // ignore errors — the WS retry will catch the result - } - const ws = new WebSocket(url, ['tty']); _ws = ws; ws.binaryType = 'arraybuffer'; @@ -126,6 +117,30 @@ function connectWebSocket(name, sourceUrl) { }); } + function connect() { + // After 2 failed WS attempts, ttyd is likely dead (e.g. after service restart). + // AWAIT the /connect POST before opening the WebSocket — ttyd must be alive first. + // fetch() includes cookies automatically for same-origin requests so auth is transparent. + // + // Critical: this path uses .then() so _connectWebSocket() runs only AFTER the POST + // response (plus an 800ms settle delay for ttyd to bind its port). The early return + // prevents falling through to the direct _connectWebSocket() call below. + if (_reconnectAttempts >= 2 && _currentSession) { + fetch('/api/sessions/' + encodeURIComponent(_currentSession) + '/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + .catch(function() { return null; }) + .then(function() { + // Brief delay for ttyd to bind its port after /connect spawns it + setTimeout(_connectWebSocket, 800); + }); + return; // Don't fall through — .then() handles the WebSocket creation + } + + _connectWebSocket(); + } + connect(); } function initVisualViewport() { diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index af21702..4216a96 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -816,6 +816,21 @@ test('terminal.js WebSocket reconnect calls /connect after 2 failed attempts', ( assert.ok(source.includes('Math.pow'), 'must use exponential backoff'); }); +test('terminal.js WebSocket reconnect awaits /connect before creating WS', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('_reconnectAttempts'), 'must track reconnect attempts'); + // WS creation must be extracted into a separate helper — not inlined in connect() + assert.ok(source.includes('_connectWebSocket'), 'must extract WS creation into _connectWebSocket helper'); + // The /connect fetch must use .then() to chain WS creation — not fire-and-forget + assert.ok(source.includes('.then('), '/connect fetch must chain via .then() before WS creation'); + // connect() must return after scheduling the fetch chain, to prevent falling through to immediate WS creation + const connectFn = source.substring( + source.indexOf('function connect()'), + source.indexOf('function _connectWebSocket'), + ); + assert.ok(connectFn.includes('return;'), 'connect() must return after fetch to prevent falling through to immediate WS creation'); +}); + // --- Issue 4: setTerminalFontSize --- test('terminal.js exposes window._setTerminalFontSize function', () => {