From 8b71f40eaf894b6735df958ef0483134741c2c83 Mon Sep 17 00:00:00 2001 From: Prateek Date: Sat, 14 Feb 2026 21:11:09 +0530 Subject: [PATCH] fix: add health check to wait for ttyd before returning URL Fixes race condition where iframe loads before ttyd is ready, causing 'localhost refused to connect' on direct page loads. Now waits up to 3s for ttyd to be listening before responding. Co-Authored-By: Claude Sonnet 4.5 --- packages/web/src/server/terminal-websocket.ts | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/web/src/server/terminal-websocket.ts b/packages/web/src/server/terminal-websocket.ts index 9de7cdeec..f8ab1659d 100644 --- a/packages/web/src/server/terminal-websocket.ts +++ b/packages/web/src/server/terminal-websocket.ts @@ -10,6 +10,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createServer } from "node:http"; +import { request } from "node:http"; interface TtydInstance { sessionId: string; @@ -20,6 +21,43 @@ interface TtydInstance { const instances = new Map(); let nextPort = 7800; // Start ttyd instances from port 7800 +/** + * Check if ttyd is ready to accept connections by making a test request. + * Returns a promise that resolves when ttyd is ready or rejects after timeout. + */ +function waitForTtyd(port: number, sessionId: string, timeoutMs = 3000): Promise { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const checkReady = () => { + if (Date.now() - startTime > timeoutMs) { + reject(new Error(`ttyd did not become ready within ${timeoutMs}ms`)); + return; + } + + const req = request({ + hostname: "localhost", + port, + path: `/${sessionId}/`, + method: "GET", + timeout: 500, + }, (res) => { + // Any response (even 404) means ttyd is listening + resolve(); + }); + + req.on("error", () => { + // Connection refused or timeout - ttyd not ready yet, retry + setTimeout(checkReady, 100); + }); + + req.end(); + }; + + checkReady(); + }); +} + function getOrSpawnTtyd(sessionId: string): TtydInstance { const existing = instances.get(sessionId); if (existing) return existing; @@ -66,7 +104,7 @@ function getOrSpawnTtyd(sessionId: string): TtydInstance { } // Simple HTTP API for the dashboard to request terminal URLs -const server = createServer((req, res) => { +const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); // CORS for dashboard @@ -90,12 +128,21 @@ const server = createServer((req, res) => { } const instance = getOrSpawnTtyd(sessionId); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - url: `http://localhost:${instance.port}/${sessionId}/`, - port: instance.port, - sessionId, - })); + + // Wait for ttyd to be ready before returning the URL + try { + await waitForTtyd(instance.port, sessionId); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + url: `http://localhost:${instance.port}/${sessionId}/`, + port: instance.port, + sessionId, + })); + } catch (err) { + console.error(`[Terminal] ttyd ${sessionId} failed to become ready:`, err); + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Terminal server not ready" })); + } return; }