From a40b95efe606e0c92350800e063381ebb76427fa Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 05:50:47 -0700 Subject: [PATCH] fix: WebSocket reconnect respawns ttyd after 2 failed attempts + exponential backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a service restart ttyd dies. The reconnect loop was retrying the WebSocket every 2s but never calling POST /api/sessions/{name}/connect to respawn ttyd. The proxy had nothing to connect to so it looped forever showing 'Reconnecting...'. Fix: - Add _reconnectAttempts counter at module level - Close handler now uses exponential backoff: 1s, 2s, 4s, 8s, cap 15s + jitter - After 2 failed WS attempts, fire-and-forget POST to /api/sessions/{name}/connect to respawn ttyd before the next WebSocket retry - Reset _reconnectAttempts to 0 on successful open, openTerminal, closeTerminal Root cause: open event fires and reconnect loop retried the WS address but ttyd was dead so the proxy rejected it — POST /connect was only called from openSession() on page load/restore, never during reconnect. --- muxplex/frontend/terminal.js | 22 +++++++++++++++++++++- muxplex/frontend/tests/test_terminal.mjs | 9 +++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 4d55bf7..3b4fbe0 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -8,6 +8,7 @@ let _ws = null; let _reconnectTimer = null; let _currentSession = null; let _vpHandler = null; +let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn // ─── Forward declarations ───────────────────────────────────────────────────── @@ -61,12 +62,25 @@ function connectWebSocket(name, sourceUrl) { // `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'; ws.addEventListener('open', function() { if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore + _reconnectAttempts = 0; // reset backoff counter on successful connection if (reconnectOverlay) reconnectOverlay.classList.add('hidden'); // Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY ws.send(JSON.stringify({ AuthToken: '' })); @@ -99,7 +113,11 @@ function connectWebSocket(name, sourceUrl) { if (ws !== _ws) return; // stale connection — don't reconnect for old sockets if (!_currentSession) return; // intentional close — don't reconnect if (reconnectOverlay) reconnectOverlay.classList.remove('hidden'); - _reconnectTimer = setTimeout(connect, 2000); + _reconnectAttempts++; + // Exponential backoff: 1s, 2s, 4s, 8s, cap at 15s. Add jitter to avoid thundering herd. + var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000); + delay += Math.random() * 500; // jitter + _reconnectTimer = setTimeout(connect, delay); }); ws.addEventListener('error', function() { @@ -190,6 +208,7 @@ function openTerminal(sessionName, sourceUrl) { // Null _currentSession first so any in-flight close handler on the old WS won't // schedule a reconnect (it checks `if (!_currentSession) return;`). _currentSession = null; + _reconnectAttempts = 0; // reset backoff on new session open // Cancel any pending reconnect timer from the previous session. if (_reconnectTimer) { @@ -262,6 +281,7 @@ function closeTerminal() { } _currentSession = null; + _reconnectAttempts = 0; // reset backoff on intentional close } // ─── Expose to app.js ───────────────────────────────────────────────────────── diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 8dd6af9..af21702 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -807,6 +807,15 @@ test('terminal.js Android touch scroll is UA-gated', () => { assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)'); }); +// --- WebSocket reconnect + ttyd respawn --- + +test('terminal.js WebSocket reconnect calls /connect after 2 failed attempts', () => { + const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); + assert.ok(source.includes('_reconnectAttempts'), 'must track reconnect attempts'); + assert.ok(source.includes('/api/sessions/'), 'must call connect API to respawn ttyd'); + assert.ok(source.includes('Math.pow'), 'must use exponential backoff'); +}); + // --- Issue 4: setTerminalFontSize --- test('terminal.js exposes window._setTerminalFontSize function', () => {