fix: WebSocket reconnect respawns ttyd after 2 failed attempts + exponential backoff

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.
This commit is contained in:
Brian Krabach
2026-04-01 05:50:47 -07:00
parent 5d76ba491f
commit a40b95efe6
2 changed files with 30 additions and 1 deletions
+21 -1
View File
@@ -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 ─────────────────────────────────────────────────────────
+9
View File
@@ -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', () => {