diff --git a/extensions/qa-lab/src/gateway-child-readiness.ts b/extensions/qa-lab/src/gateway-child-readiness.ts index 054b98935275..9c4bf381387c 100644 --- a/extensions/qa-lab/src/gateway-child-readiness.ts +++ b/extensions/qa-lab/src/gateway-child-readiness.ts @@ -62,6 +62,8 @@ export function resolveQaGatewayStartupRetry(params: { } function isRetryableGatewayCallError(details: string): boolean { + // The persistent QA client exposes preformatted close errors plus child logs, + // not the typed transport errors produced by one-shot gateway calls. return ( details.includes("handshake timeout") || details.includes("gateway closed (1000") || @@ -256,6 +258,8 @@ export async function waitForGatewayListening(params: { } export function isRetryableRpcStartupError(error: unknown) { + // Startup errors cross the same low-level client/log boundary; timeout and + // token-mismatch retry facts exist only in the formatted diagnostic. const details = formatErrorMessage(error); return ( details.includes("gateway timeout after") || diff --git a/extensions/qa-lab/src/suite-runtime-gateway.ts b/extensions/qa-lab/src/suite-runtime-gateway.ts index dbe8f7937e1c..d2a8fd9a7b70 100644 --- a/extensions/qa-lab/src/suite-runtime-gateway.ts +++ b/extensions/qa-lab/src/suite-runtime-gateway.ts @@ -119,6 +119,8 @@ async function waitForConfigRestartSettle( } function formatGatewayPrimaryErrorText(error: unknown) { + // The persistent QA client flattens low-level closes and appends child logs, + // so the public one-shot gateway guards cannot recover a typed close here. const text = formatErrorMessage(error); const gatewayLogsIndex = text.indexOf("\nGateway logs:"); return (gatewayLogsIndex >= 0 ? text.slice(0, gatewayLogsIndex) : text).trim(); diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index a303e244c8cb..9b36fb40d783 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -22,6 +22,7 @@ const service = vi.hoisted(() => ({ const note = vi.hoisted(() => vi.fn()); const sleep = vi.hoisted(() => vi.fn(async () => {})); const healthCommand = vi.hoisted(() => vi.fn(async () => {})); +const formatGatewayClosedDiagnostic = vi.hoisted(() => vi.fn((): string | undefined => undefined)); const inspectPortConnections = vi.hoisted(() => vi.fn()); const inspectPortUsage = vi.hoisted(() => vi.fn()); const formatPortDiagnostics = vi.hoisted(() => vi.fn(() => ["Port 18789 is already in use."])); @@ -154,7 +155,7 @@ vi.mock("./gateway-install-token.js", () => ({ })); vi.mock("./health-format.js", () => ({ - formatGatewayClosedDiagnostic: vi.fn(() => undefined), + formatGatewayClosedDiagnostic, formatHealthCheckFailure: vi.fn(() => "health failed"), })); @@ -173,6 +174,8 @@ describe("maybeRepairGatewayDaemon", () => { beforeEach(() => { vi.clearAllMocks(); + formatGatewayClosedDiagnostic.mockReset(); + formatGatewayClosedDiagnostic.mockReturnValue(undefined); service.isLoaded.mockResolvedValue(true); service.readRuntime.mockResolvedValue({ status: "running" }); service.readCommand.mockResolvedValue(null); @@ -624,6 +627,25 @@ describe("maybeRepairGatewayDaemon", () => { expect(service.restart).toHaveBeenCalledTimes(1); }); + it("reports a typed close after restart without depending on error wording", async () => { + setPlatform("linux"); + const error = new Error("transport closed after restart"); + healthCommand.mockRejectedValueOnce(error); + formatGatewayClosedDiagnostic.mockReturnValueOnce( + "Gateway connect failed: transport closed after restart", + ); + + const runtime = await runAutoRepair(); + + expect(formatGatewayClosedDiagnostic).toHaveBeenCalledWith(error); + expect(note).toHaveBeenCalledWith( + "Gateway connect failed: transport closed after restart", + "Gateway", + ); + expect(note).toHaveBeenCalledWith("details", "Gateway connection"); + expect(runtime.error).not.toHaveBeenCalled(); + }); + it("restarts running service when --yes explicitly approves repairs", async () => { setPlatform("linux"); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 0a4d9fdab3f2..9c9c986b1c84 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -527,16 +527,10 @@ export async function maybeRepairGatewayDaemon(params: { try { await healthCommand({ json: false, timeoutMs: 10_000 }, params.runtime); } catch (err) { - const message = String(err); - if (message.includes("gateway closed")) { - const closedDiagnostic = formatGatewayClosedDiagnostic(err); - if (closedDiagnostic) { - note(closedDiagnostic, "Gateway"); - note(params.gatewayDetailsMessage, "Gateway connection"); - } else { - note("Gateway not running.", "Gateway"); - note(params.gatewayDetailsMessage, "Gateway connection"); - } + const closedDiagnostic = formatGatewayClosedDiagnostic(err); + if (closedDiagnostic) { + note(closedDiagnostic, "Gateway"); + note(params.gatewayDetailsMessage, "Gateway connection"); } else { params.runtime.error(formatHealthCheckFailure(err)); } diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts index 82d71d8d567e..920faf5de968 100644 --- a/src/commands/doctor-gateway-health.test.ts +++ b/src/commands/doctor-gateway-health.test.ts @@ -311,11 +311,12 @@ describe("checkGatewayHealth", () => { ); }); - it("reports the typed close reason instead of claiming the gateway is not running", async () => { + it("reports a typed close without depending on gateway error wording", async () => { const error = Object.assign( - new Error("gateway closed (1008): \u001B]52;c;YXR0YWNr\u0007protocol version mismatch"), + new Error("transport closed: \u001B]52;c;YXR0YWNr\u0007protocol version mismatch"), { kind: "closed", + code: 1008, }, ); callGateway.mockRejectedValueOnce(error); @@ -325,7 +326,7 @@ describe("checkGatewayHealth", () => { await checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }); expect(note).toHaveBeenCalledWith( - "Gateway connect failed: gateway closed (1008): protocol version mismatch", + "Gateway connect failed: transport closed: protocol version mismatch", "Gateway", ); expect(note).not.toHaveBeenCalledWith("Gateway not running.", "Gateway"); diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts index 013e880bda8a..9302c0f759b9 100644 --- a/src/commands/doctor-gateway-health.ts +++ b/src/commands/doctor-gateway-health.ts @@ -198,15 +198,10 @@ export async function checkGatewayHealth(params: { return { healthOk, authenticated: false }; } } - const message = String(err); - if (message.includes("gateway closed")) { + const closedDiagnostic = formatGatewayClosedDiagnostic(err); + if (closedDiagnostic) { const gatewayDetails = buildGatewayConnectionDetails({ config: params.cfg }); - const closedDiagnostic = formatGatewayClosedDiagnostic(err); - if (closedDiagnostic) { - note(closedDiagnostic, "Gateway"); - } else { - note("Gateway not running.", "Gateway"); - } + note(closedDiagnostic, "Gateway"); note(gatewayDetails.message, "Gateway connection"); } else { params.runtime.error(formatHealthCheckFailure(err)); diff --git a/src/commands/health-format.test.ts b/src/commands/health-format.test.ts index fdf810754f8b..914de4c11085 100644 --- a/src/commands/health-format.test.ts +++ b/src/commands/health-format.test.ts @@ -1,6 +1,31 @@ import { describe, expect, it } from "vitest"; import type { HealthSummary } from "../gateway/health/types.js"; -import { formatHealthChannelLines } from "./health-format.js"; +import { formatGatewayClosedDiagnostic, formatHealthChannelLines } from "./health-format.js"; + +describe("formatGatewayClosedDiagnostic", () => { + it("formats a coded gateway transport close", () => { + const error = Object.assign(new Error("gateway closed (1006): no close reason"), { + name: "GatewayTransportError", + kind: "closed", + code: 1006, + connectionDetails: {}, + }); + + expect(formatGatewayClosedDiagnostic(error)).toBe( + "Gateway connect failed: gateway closed (1006): no close reason", + ); + }); + + it("does not equate an uncoded connect-time close with a websocket close", () => { + const error = Object.assign(new Error("Gateway not reachable at ws://127.0.0.1:18789"), { + name: "GatewayTransportError", + kind: "closed", + connectionDetails: {}, + }); + + expect(formatGatewayClosedDiagnostic(error)).toBeUndefined(); + }); +}); const createHealthSummary = ( params: Pick, diff --git a/src/commands/health-format.ts b/src/commands/health-format.ts index ba577c2bd40b..0105dd2defbe 100644 --- a/src/commands/health-format.ts +++ b/src/commands/health-format.ts @@ -8,7 +8,7 @@ import { isGatewayTransportError } from "../gateway/call.js"; import type { ChannelAccountHealthSummary, HealthSummary } from "../gateway/health/types.js"; export function formatGatewayClosedDiagnostic(err: unknown): string | undefined { - if (!isGatewayTransportError(err) || err.kind !== "closed") { + if (!isGatewayTransportError(err) || err.kind !== "closed" || err.code === undefined) { return undefined; } return `Gateway connect failed: ${sanitizeTerminalText(err.message.split("\n", 1)[0] ?? "")}`;