fix: WS proxy checks ttyd liveness before accepting + reset counter on message

Root cause (4th iteration): The WS proxy called websocket.accept() before
verifying ttyd was alive. The browser 'open' event fired immediately, resetting
_reconnectAttempts to 0. Counter bounced 0→1→0→1 forever — the client-side
/connect POST at >= 2 attempts never fired.

Server fix: _ttyd_is_listening() TCP probe (socket.create_connection, <1ms)
before websocket.accept(). If ttyd is dead, auto-spawns it from active_session
in state (kill_ttyd → spawn_ttyd → sleep 0.8s) THEN accepts. Browser 'open'
only fires when ttyd is confirmed reachable.

Client fix: _reconnectAttempts reset moved from 'open' handler to 'message'
handler. First data message proves ttyd is alive and relaying — not just that
the proxy accepted. Belt-and-suspenders: client-side /connect fetch still fires
at attempt >= 2 as a fallback.

Tests added:
- test_ttyd_is_listening_function_exists: function importable from main
- test_ws_proxy_checks_ttyd_before_accepting: source inspection ensures
  _ttyd_is_listening() call appears before await websocket.accept()
- test_ws_proxy_auto_spawns_ttyd_when_dead: verifies spawn_ttyd called with
  active_session when _ttyd_is_listening returns False
- terminal.mjs: _reconnectAttempts = 0 NOT in open handler, IS in message
This commit is contained in:
Brian Krabach
2026-04-01 06:56:21 -07:00
parent a862327e27
commit 38e2fc45d5
4 changed files with 184 additions and 3 deletions
+11 -1
View File
@@ -71,7 +71,11 @@ function connectWebSocket(name, sourceUrl) {
ws.addEventListener('open', function() {
if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore
_reconnectAttempts = 0; // reset backoff counter on successful connection
// NOTE: do NOT reset _reconnectAttempts here. The server-side proxy accepts
// the WS before confirming ttyd is alive (auto-spawning if needed), but the
// browser 'open' event fires as soon as the proxy accepts — not when ttyd
// is actually ready. Resetting here caused the 0→1→0→1 bounce. Instead,
// reset on first data message (proves ttyd is alive and relaying).
if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
// Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY
ws.send(JSON.stringify({ AuthToken: '' }));
@@ -86,6 +90,12 @@ function connectWebSocket(name, sourceUrl) {
ws.addEventListener('message', function(e) {
if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore
if (!_term) return;
// First data message proves ttyd is alive and relaying — safe to reset counter.
// We deliberately do NOT reset in the 'open' handler: the server-side proxy
// accepts the browser WS before ttyd is fully confirmed alive, so 'open'
// firing alone doesn't mean data will flow. Resetting here prevents the
// 0→1→0→1 bounce that kept the reconnect loop from escalating to /connect.
if (_reconnectAttempts > 0) _reconnectAttempts = 0;
if (e.data instanceof ArrayBuffer) {
var msg = new Uint8Array(e.data);
if (msg.length < 1) return;
+51
View File
@@ -831,6 +831,57 @@ test('terminal.js WebSocket reconnect awaits /connect before creating WS', () =>
assert.ok(connectFn.includes('return;'), 'connect() must return after fetch to prevent falling through to immediate WS creation');
});
// --- Reconnect counter: must reset on message, not on open ---
test('terminal.js resets _reconnectAttempts on first message, not on open', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
// Find the open handler body (between "addEventListener('open'" and its closing "})")
const openStart = source.indexOf("addEventListener('open'");
assert.ok(openStart !== -1, "must have an open handler");
// Find the matching closing "})" for the open handler — walk from openStart
let depth = 0;
let openBodyEnd = -1;
for (let i = openStart; i < source.length - 1; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}') {
depth--;
if (depth === 0) { openBodyEnd = i; break; }
}
}
assert.ok(openBodyEnd !== -1, "must find the end of the open handler");
const openBody = source.substring(openStart, openBodyEnd + 1);
// _reconnectAttempts = 0 must NOT appear in the open handler
// (the proxy accepts before ttyd is alive, so open doesn't prove ttyd is up)
assert.ok(
!openBody.includes('_reconnectAttempts = 0'),
'_reconnectAttempts must NOT be reset in the open handler — ' +
'the proxy accepts the WS before confirming ttyd is alive; ' +
'reset must happen on first message (proves ttyd is sending data)',
);
// _reconnectAttempts reset must appear in the message handler instead
const msgStart = source.indexOf("addEventListener('message'");
assert.ok(msgStart !== -1, "must have a message handler");
let msgDepth = 0;
let msgBodyEnd = -1;
for (let i = msgStart; i < source.length - 1; i++) {
if (source[i] === '{') msgDepth++;
else if (source[i] === '}') {
msgDepth--;
if (msgDepth === 0) { msgBodyEnd = i; break; }
}
}
assert.ok(msgBodyEnd !== -1, "must find the end of the message handler");
const msgBody = source.substring(msgStart, msgBodyEnd + 1);
assert.ok(
msgBody.includes('_reconnectAttempts'),
'_reconnectAttempts must be reset inside the message handler ' +
'(first data message proves ttyd is alive and relaying)',
);
});
// --- Issue 4: setTerminalFontSize ---
test('terminal.js exposes window._setTerminalFontSize function', () => {