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. */