From b07eaafcc4bc86f783462a4dcc04320fded4858a Mon Sep 17 00:00:00 2001 From: vermasomesh835 Date: Sun, 30 Aug 2026 17:39:16 +0530 Subject: [PATCH] fix(cli): probe both IPv4 and IPv6 loopback for server readiness (#11766) (#11794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix correto — CLI agora sonda IPv4 e IPv6 no probe de prontidão do servidor, com teste de regressão próprio (`tests/unit/cli-waitForServer.test.mjs`). Validado no worktree combinado (typecheck limpo, teste focado verde). Obrigado! --- bin/cli/utils/pid.mjs | 79 +++++++++++++++++++-------- tests/unit/cli-waitForServer.test.mjs | 30 ++++++++++ 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/bin/cli/utils/pid.mjs b/bin/cli/utils/pid.mjs index 077a38e410..9d0b10bd9e 100644 --- a/bin/cli/utils/pid.mjs +++ b/bin/cli/utils/pid.mjs @@ -100,31 +100,66 @@ export async function waitForServer(port, timeout = 60000) { // - "hanging": the request timed out waiting for any response — the // process accepted the TCP connection but never answered (#6800). // - "not-listening": nothing is accepting connections on the port at all. +// #11766: probe both IPv4 and IPv6 loopback to handle servers listening on +// either family (or both). async function pollHealthOnce(port) { - try { - const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, { - signal: AbortSignal.timeout(2000), - }); - return res.ok ? "ready" : "fast-reject"; - } catch (err) { - if (err?.name === "TimeoutError") return "hanging"; - const listening = await isPortListening(port).catch(() => false); - return listening ? "fast-reject" : "not-listening"; - } + const hosts = ["127.0.0.1", "::1"]; + const outcomes = []; + + // Probe both loopback families concurrently + const results = await Promise.all( + hosts.map(async (host) => { + try { + const res = await fetch(`http://${host}:${port}/api/monitoring/health`, { + signal: AbortSignal.timeout(2000), + }); + return { host, outcome: res.ok ? "ready" : "fast-reject" }; + } catch (err) { + const outcome = err?.name === "TimeoutError" ? "hanging" : "error"; + return { host, outcome }; + } + }) + ); + + outcomes.push(...results.map((r) => r.outcome)); + + // If either family is ready, the server is ready + if (outcomes.includes("ready")) return "ready"; + + // If either family is fast-reject, treat as fast-reject + // (TCP is listening and rejecting, just route not ready yet) + if (outcomes.includes("fast-reject")) return "fast-reject"; + + // If either family is hanging, server accepted TCP but not answering + // (still booting, must not report as ready per #6800) + if (outcomes.includes("hanging")) return "hanging"; + + // Both families failed — check if either port is actually listening + // If listening, then errors above are route-level (fast-reject case) + const listening = await isPortListening(port).catch(() => false); + return listening ? "fast-reject" : "not-listening"; } async function isPortListening(port) { const net = await import("node:net"); - return new Promise((resolve) => { - const socket = net.connect({ host: "127.0.0.1", port, timeout: 1000 }); - const finish = (ok) => { - try { - socket.destroy(); - } catch {} - resolve(ok); - }; - socket.once("connect", () => finish(true)); - socket.once("error", () => finish(false)); - socket.once("timeout", () => finish(false)); - }); + // #11766: check both IPv4 and IPv6 loopback. Return true if either is listening. + const hosts = ["127.0.0.1", "::1"]; + const results = await Promise.all( + hosts.map( + (host) => + new Promise((resolve) => { + const socket = net.connect({ host, port, timeout: 1000 }); + const finish = (ok) => { + try { + socket.destroy(); + } catch {} + resolve(ok); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.once("timeout", () => finish(false)); + }) + ) + ); + return results.some((ok) => ok); } diff --git a/tests/unit/cli-waitForServer.test.mjs b/tests/unit/cli-waitForServer.test.mjs index b58a0ce0e9..e163f6ba92 100644 --- a/tests/unit/cli-waitForServer.test.mjs +++ b/tests/unit/cli-waitForServer.test.mjs @@ -11,6 +11,7 @@ import { waitForServer } from "../../bin/cli/utils/pid.mjs"; // listening, and (d) return false when the port merely accepts TCP and then // hangs without ever answering a request (#6800 — a still-booting/CPU-bound // process must NOT be reported as ready just because the socket is open). +// #11766: also test that IPv6 loopback is checked when IPv4 is unavailable. async function freePort() { return new Promise((resolve) => { @@ -77,3 +78,32 @@ test("waitForServer returns false when the port accepts TCP but never answers a await new Promise((resolve) => server.close(() => resolve())); } }); + +test("waitForServer detects IPv6 loopback health endpoint when IPv4 is unavailable (#11766)", async () => { + const port = await freePort(); + const server = net.createServer((socket) => { + socket.on("data", (data) => { + const request = data.toString(); + if (request.includes("GET /api/monitoring/health")) { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); + } + }); + }); + + // Listen only on IPv6 loopback + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "::1", () => resolve()); + }); + + try { + const result = await waitForServer(port, 8000); + assert.equal( + result, + true, + "expected waitForServer to detect health endpoint on IPv6 loopback even when IPv4 is unavailable" + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +});