fix(cli): report web readiness probe failures (#169)

Co-authored-by: Yaowei Zheng <hiyouga@buaa.edu.cn>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Laodouuu
2026-08-04 12:34:27 +08:00
committed by GitHub
parent 4d5d55d15a
commit e05dea536b
3 changed files with 244 additions and 15 deletions
+118 -10
View File
@@ -18,7 +18,16 @@ import { spawn } from "node:child_process";
import path from "node:path";
import { DEFAULT_SERVER_PORT } from "@prismshadow/penguin-core";
import type { Command } from "commander";
import type { Messages } from "../i18n.js";
import type { Messages, WebProbeFailureKind } from "../i18n.js";
/** Why the readiness poll gave up: failure class plus a one-line diagnostic from the last probe. */
export interface ReadinessFailure {
kind: WebProbeFailureKind;
detail: string;
}
/** Result of `waitForReady`: ready, or timed out with the retained last probe failure. */
export type ReadinessResult = { ready: true } | { ready: false; failure: ReadinessFailure };
/** Default service port — core's DEFAULT_SERVER_PORT (7364), the single source of truth. */
export const DEFAULT_PORT = DEFAULT_SERVER_PORT;
@@ -96,9 +105,103 @@ async function startServer(opts: {
return { host, port };
}
/** Polls the service root path until it responds (any HTTP response counts as ready); keeps waiting on connection failure, returns false on timeout. */
async function waitForReady(url: string, timeoutMs = 15_000, intervalMs = 300): Promise<boolean> {
function errorProperty(
value: unknown,
property: "cause" | "code" | "errors" | "message" | "name",
): unknown {
if ((typeof value !== "object" && typeof value !== "function") || value === null) {
return undefined;
}
return (value as Record<string, unknown>)[property];
}
/**
* Turns Node/undici's nested `fetch failed` errors into a stable one-line diagnostic.
* The useful code usually lives on `error.cause` rather than the top-level TypeError.
* Exported for unit tests.
*/
export function describeReadinessFailure(error: unknown): ReadinessFailure {
const chain: unknown[] = [];
const seen = new Set<unknown>();
let current: unknown = error;
while (current !== undefined && current !== null && !seen.has(current)) {
chain.push(current);
seen.add(current);
// Follow `cause` first; an AggregateError (e.g. localhost resolving to several
// addresses, all refused) keeps the real connect errors in `errors` instead.
const cause = errorProperty(current, "cause");
if (cause !== undefined && cause !== null) {
current = cause;
continue;
}
const errors = errorProperty(current, "errors");
current = Array.isArray(errors) ? errors[0] : undefined;
}
const code = chain
.map((item) => errorProperty(item, "code"))
.find((value): value is string => typeof value === "string" && value.length > 0);
const selected = [...chain].reverse().find((item) => {
const value = errorProperty(item, "message");
// Skip empty messages: an AggregateError's own message is "" and would mask real text.
return typeof value === "string" && value.length > 0;
});
const messageValue = selected === undefined ? undefined : errorProperty(selected, "message");
const nameValue = selected === undefined ? undefined : errorProperty(selected, "name");
const message =
typeof messageValue === "string" && messageValue.length > 0
? messageValue
: typeof error === "string"
? error
: "Unknown readiness probe error";
const name = typeof nameValue === "string" && nameValue !== "Error" ? nameValue : undefined;
const detail =
code !== undefined
? `${code}: ${message}`
: name !== undefined
? `${name}: ${message}`
: message;
const signature = chain
.flatMap((item) => [
errorProperty(item, "code"),
errorProperty(item, "name"),
errorProperty(item, "message"),
])
.filter((value): value is string => typeof value === "string")
.join(" ")
.toUpperCase();
let kind: WebProbeFailureKind = "unknown";
if (signature.includes("ECONNREFUSED")) kind = "refused";
else if (
signature.includes("ETIMEDOUT") ||
signature.includes("TIMEOUT") ||
signature.includes("ABORTERROR")
) {
kind = "timeout";
} else if (
signature.includes("ECONNRESET") ||
signature.includes("UND_ERR_SOCKET") ||
signature.includes("EPIPE")
) {
kind = "reset";
} else if (signature.includes("EACCES") || signature.includes("EPERM")) {
kind = "permission";
} else if (signature.includes("ENOTFOUND") || signature.includes("EAI_AGAIN")) {
kind = "dns";
}
return { kind, detail };
}
/** Polls the service root path until it responds (any HTTP response counts as ready); retains the last connection failure for diagnostics on timeout. Exported for unit tests. */
export async function waitForReady(
url: string,
timeoutMs = 15_000,
intervalMs = 300,
): Promise<ReadinessResult> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
for (;;) {
try {
// Each probe is capped at 1s: if the port is held by a non-HTTP program, the
@@ -109,11 +212,14 @@ async function waitForReady(url: string, timeoutMs = 15_000, intervalMs = 300):
// so treat that 302 as ready rather than chasing it to a name that may resolve to ::1.
const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(1000) });
void res.body?.cancel();
return true;
} catch {
// The service isn't listening yet (or this probe timed out): keep polling.
return { ready: true };
} catch (error) {
// The service isn't listening yet (or this probe timed out): retain the reason and keep polling.
lastError = error;
}
if (Date.now() >= deadline) {
return { ready: false, failure: describeReadinessFailure(lastError) };
}
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
@@ -149,9 +255,11 @@ export function registerServeCommands(program: Command, t: Messages): void {
.action(async (opts: { port?: string; host?: string; open: boolean }) => {
const { host, port } = await startServer(opts);
const url = browserUrl(host, port);
const ready = await waitForReady(url);
if (!ready) {
process.stdout.write(`${t.webTimeout(url)}\n`);
const readiness = await waitForReady(url);
if (!readiness.ready) {
process.stderr.write(
`${t.webProbeFailed(url, readiness.failure.detail, readiness.failure.kind, port)}\n`,
);
return;
}
process.stdout.write(`${t.webReady(url)}\n`);
+33 -4
View File
@@ -9,6 +9,10 @@
/** UI language. */
export type Language = "en" | "zh";
/** Readiness probe failure classes; selects which hint `webProbeFailed` appends. */
export type WebProbeFailureKind =
"timeout" | "refused" | "reset" | "permission" | "dns" | "unknown";
/** Resolve the language from the env var; `zh` matches exactly, everything else falls back to English (see comment #2). */
export function resolveLanguage(): Language {
const v = (process.env.PENGUIN_LANG ?? "").trim().toLowerCase();
@@ -219,8 +223,8 @@ export interface Messages {
vaultListEmpty(): string;
/** URL prompt once the `penguin web` service is ready. */
webReady(url: string): string;
/** Manual-open prompt after the `penguin web` ready-poll times out (15s). */
webTimeout(url: string): string;
/** Diagnostic shown after the `penguin web` ready-poll times out (15s). */
webProbeFailed(url: string, detail: string, kind: WebProbeFailureKind, port: number): string;
}
function headerEn(
@@ -451,7 +455,22 @@ const en: Messages = {
vaultListTitle: () => "Vault environment variables (values masked):",
vaultListEmpty: () => "The vault is empty. Add one with `penguin config vault set`.",
webReady: (url) => `Web UI ready: ${url}`,
webTimeout: (url) => `Server is not responding yet; open ${url} manually once it is ready.`,
webProbeFailed: (url, detail, kind, port) => {
const hint = {
timeout:
`The connection timed out. Check whether a firewall or security application is blocking it. ` +
`Allow PenguinHarness to communicate on local port ${port}.`,
refused:
"Nothing accepted the connection. Check whether the server exited or HOST/PORT points somewhere else.",
reset:
"The connection closed before an HTTP response. Check local security software and retry.",
permission:
"The operating system denied the connection. Check firewall or security policy permissions.",
dns: "The host name could not be resolved. Check --host or HOST.",
unknown: `Open ${url} manually after the server is ready.`,
}[kind];
return `Server readiness check failed for ${url}.\nLast probe error: ${detail}\n${hint}`;
},
};
const zh: Messages = {
@@ -645,7 +664,17 @@ const zh: Messages = {
vaultListTitle: () => "vault 环境变量(值已掩码):",
vaultListEmpty: () => "vault 为空。用 `penguin config vault set` 添加。",
webReady: (url) => `Web 界面已就绪:${url}`,
webTimeout: (url) => `服务尚未就绪,请稍后手动打开 ${url}。`,
webProbeFailed: (url, detail, kind, port) => {
const hint = {
timeout: `连接超时。请检查防火墙或安全软件是否拦截。请允许 PenguinHarness 在本机端口 ${port} 上通信。`,
refused: "没有进程接受连接。请检查服务是否已经退出,或 HOST/PORT 是否指向了其他地址。",
reset: "连接在收到 HTTP 响应前已关闭。请检查本机安全软件后重试。",
permission: "操作系统拒绝了连接。请检查防火墙或安全策略权限。",
dns: "无法解析主机名。请检查 --host 或 HOST。",
unknown: `请在服务就绪后手动打开 ${url}。`,
}[kind];
return `服务探活失败:${url}\n最后一次探测错误:${detail}\n${hint}`;
},
};
/** Get the message set for a language. */
+93 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import path from "node:path";
import { Command } from "commander";
import { DEFAULT_SERVER_PORT } from "@prismshadow/penguin-core";
@@ -8,8 +8,10 @@ import {
browserCommand,
browserUrl,
cliEntryFor,
describeReadinessFailure,
registerServeCommands,
resolvePort,
waitForReady,
} from "../src/commands/serve.js";
import { getMessages } from "../src/i18n.js";
@@ -93,3 +95,93 @@ describe("cliEntryFor (the entry advertised for the web self-update)", () => {
expect(cliEntryFor("")).toBeNull();
});
});
describe("readiness probe diagnostics", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("reports a successful HTTP response as ready regardless of its status", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 503 }));
await expect(waitForReady("http://127.0.0.1:7364/", 0, 0)).resolves.toEqual({
ready: true,
});
});
it("keeps polling after failed probes and reports ready once a response arrives", async () => {
vi.spyOn(globalThis, "fetch")
.mockRejectedValueOnce(new TypeError("fetch failed"))
.mockResolvedValue(new Response(null, { status: 200 }));
await expect(waitForReady("http://127.0.0.1:7364/", 5_000, 0)).resolves.toEqual({
ready: true,
});
});
it("retains the nested undici error from the last failed probe", async () => {
const cause = Object.assign(new Error("Connect Timeout Error"), {
code: "UND_ERR_CONNECT_TIMEOUT",
});
vi.spyOn(globalThis, "fetch").mockRejectedValue(
Object.assign(new TypeError("fetch failed"), { cause }),
);
await expect(waitForReady("http://127.0.0.1:7364/", 0, 0)).resolves.toEqual({
ready: false,
failure: {
kind: "timeout",
detail: "UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error",
},
});
});
it.each([
["ECONNREFUSED", "refused"],
["ECONNRESET", "reset"],
["EACCES", "permission"],
["ENOTFOUND", "dns"],
] as const)("classifies %s failures as %s", (code, kind) => {
expect(describeReadinessFailure(Object.assign(new Error("probe failed"), { code }))).toEqual({
kind,
detail: `${code}: probe failed`,
});
});
it("classifies the probe's own 1s abort (DOMException TimeoutError) as a timeout", () => {
expect(
describeReadinessFailure(
new DOMException("The operation was aborted due to timeout", "TimeoutError"),
),
).toEqual({
kind: "timeout",
detail: "TimeoutError: The operation was aborted due to timeout",
});
});
it("digs the connect error out of an empty-message AggregateError (multi-address host)", () => {
const aggregate = Object.assign(
new AggregateError([
Object.assign(new Error("connect ECONNREFUSED ::1:7364"), { code: "ECONNREFUSED" }),
Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:7364"), { code: "ECONNREFUSED" }),
]),
{ code: "ECONNREFUSED" },
);
const error = Object.assign(new TypeError("fetch failed"), { cause: aggregate });
expect(describeReadinessFailure(error)).toEqual({
kind: "refused",
detail: "ECONNREFUSED: connect ECONNREFUSED ::1:7364",
});
});
it("includes actionable localized firewall guidance for connection timeouts", () => {
const detail = "UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error";
expect(
getMessages("en").webProbeFailed("http://127.0.0.1:7364/", detail, "timeout", 7364),
).toContain("Allow PenguinHarness to communicate on local port 7364");
expect(
getMessages("zh").webProbeFailed("http://127.0.0.1:7364/", detail, "timeout", 7364),
).toContain("请允许 PenguinHarness 在本机端口 7364 上通信");
});
});