/** * Readiness detection: port probing, pattern matching, wait-for-ready. */ import { createConnection } from "node:net"; import { addEvent, pushAlert } from "./process-manager.js"; import { DEFAULT_READY_TIMEOUT, PORT_PROBE_TIMEOUT, READY_POLL_INTERVAL, } from "./types.js"; // ── Readiness Transition ─────────────────────────────────────────────────── export function transitionToReady(bg, detail) { bg.status = "ready"; bg.wasReady = true; addEvent(bg, { type: "ready", detail }); } // ── Port Probing ─────────────────────────────────────────────────────────── export function probePort(port, host = "127.0.0.1") { return new Promise((resolve) => { const socket = createConnection({ port, host, timeout: PORT_PROBE_TIMEOUT }, () => { socket.destroy(); resolve(true); }); socket.on("error", () => { socket.destroy(); resolve(false); }); socket.on("timeout", () => { socket.destroy(); resolve(false); }); }); } // ── Port Probing Loop ────────────────────────────────────────────────────── export function startPortProbing(bg, port, customTimeout) { const timeout = customTimeout || DEFAULT_READY_TIMEOUT; const interval = setInterval(async () => { if (!bg.alive) { clearInterval(interval); const stderrLines = bg.output .filter((l) => l.stream === "stderr") .slice(-10) .map((l) => l.line); const detail = `Process exited (code ${bg.exitCode}) before port ${port} opened${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; addEvent(bg, { type: "port_timeout", detail, data: { port, exitCode: bg.exitCode }, }); return; } if (bg.status !== "starting") { clearInterval(interval); return; } const open = await probePort(port); if (open) { clearInterval(interval); if (!bg.ports.includes(port)) bg.ports.push(port); transitionToReady(bg, `Port ${port} is open`); addEvent(bg, { type: "port_open", detail: `Port ${port} is open`, data: { port }, }); } }, READY_POLL_INTERVAL); // Stop probing after timeout — transition to error state so the process // doesn't stay in "starting" forever (fixes #428) setTimeout(() => { clearInterval(interval); if (bg.alive && bg.status === "starting") { const stderrLines = bg.output .filter((l) => l.stream === "stderr") .slice(-10) .map((l) => l.line); const detail = `Port ${port} not open after ${timeout}ms${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`; bg.status = "error"; addEvent(bg, { type: "port_timeout", detail, data: { port, timeout } }); pushAlert(bg, `Port ${port} readiness timeout after ${timeout / 1000}s`); } }, timeout); } // ── Wait for Ready ───────────────────────────────────────────────────────── export async function waitForReady(bg, timeout, signal) { const start = Date.now(); while (Date.now() - start < timeout) { if (signal?.aborted) { return { ready: false, detail: "Cancelled" }; } if (!bg.alive) { const stderrLines = bg.output .filter((l) => l.stream === "stderr") .slice(-5) .map((l) => l.line); const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; return { ready: false, detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}${stderrContext}`, }; } if (bg.status === "error") { const stderrLines = bg.output .filter((l) => l.stream === "stderr") .slice(-5) .map((l) => l.line); const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; return { ready: false, detail: `Process entered error state${bg.readyPort ? ` (port ${bg.readyPort} never opened)` : ""}${stderrContext}`, }; } if (bg.status === "ready") { return { ready: true, detail: bg.events.find((e) => e.type === "ready")?.detail || "Process is ready", }; } await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL)); } // Timeout — try port probe as last resort if (bg.readyPort) { const open = await probePort(bg.readyPort); if (open) { transitionToReady(bg, `Port ${bg.readyPort} is open (detected at timeout)`); return { ready: true, detail: `Port ${bg.readyPort} is open` }; } } const stderrLines = bg.output .filter((l) => l.stream === "stderr") .slice(-5) .map((l) => l.line); const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : ""; return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal${stderrContext}`, }; }