From 2fe3b483b1bce0c4281738a5f0e7792c22f1ee3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 02:42:32 +0100 Subject: [PATCH] fix: add gateway close context --- CHANGELOG.md | 1 + src/gateway/call.test.ts | 77 ++++++++++++++++++++++++++++++++++++++-- src/gateway/call.ts | 54 +++++++++++++++++++++++----- 3 files changed, 122 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2e95bfd..6d0d9b828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixes - macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438. - Doctor/Daemon: surface gateway runtime state + port collision diagnostics; warn on legacy workspace dirs. +- Gateway/CLI: include gateway target/source details in close/timeout errors. - Discord: format slow listener logs in seconds to match shared duration style. - CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`). - CLI: add cron `create`/`remove`/`delete` aliases for job management. diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index e01260a9a..822ad2689 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const loadConfig = vi.fn(); const resolveGatewayPort = vi.fn(); @@ -7,7 +7,12 @@ const pickPrimaryTailnetIPv4 = vi.fn(); let lastClientOptions: { url?: string; onHelloOk?: () => void | Promise; + onClose?: (code: number, reason: string) => void; } | null = null; +type StartMode = "hello" | "close" | "silent"; +let startMode: StartMode = "hello"; +let closeCode = 1006; +let closeReason = ""; vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -27,6 +32,7 @@ vi.mock("./client.js", () => ({ constructor(opts: { url?: string; onHelloOk?: () => void | Promise; + onClose?: (code: number, reason: string) => void; }) { lastClientOptions = opts; } @@ -34,7 +40,11 @@ vi.mock("./client.js", () => ({ return { ok: true }; } start() { - void lastClientOptions?.onHelloOk?.(); + if (startMode === "hello") { + void lastClientOptions?.onHelloOk?.(); + } else if (startMode === "close") { + lastClientOptions?.onClose?.(closeCode, closeReason); + } } stop() {} }, @@ -48,6 +58,9 @@ describe("callGateway url resolution", () => { resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); lastClientOptions = null; + startMode = "hello"; + closeCode = 1006; + closeReason = ""; }); it("uses tailnet IP when local bind is tailnet", async () => { @@ -80,3 +93,63 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); }); }); + +describe("callGateway error details", () => { + beforeEach(() => { + loadConfig.mockReset(); + resolveGatewayPort.mockReset(); + pickPrimaryTailnetIPv4.mockReset(); + lastClientOptions = null; + startMode = "hello"; + closeCode = 1006; + closeReason = ""; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("includes connection details when the gateway closes", async () => { + startMode = "close"; + closeCode = 1006; + closeReason = ""; + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + + let err: Error | null = null; + try { + await callGateway({ method: "health" }); + } catch (caught) { + err = caught as Error; + } + + expect(err?.message).toContain("gateway closed (1006"); + expect(err?.message).toContain("Gateway target: ws://127.0.0.1:18789"); + expect(err?.message).toContain("Source: local loopback"); + expect(err?.message).toContain("Bind: loopback"); + }); + + it("includes connection details on timeout", async () => { + startMode = "silent"; + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + + vi.useFakeTimers(); + let err: Error | null = null; + const promise = callGateway({ method: "health", timeoutMs: 5 }).catch( + (caught) => { + err = caught as Error; + }, + ); + + await vi.advanceTimersByTimeAsync(5); + await promise; + + expect(err?.message).toContain("gateway timeout after 5ms"); + expect(err?.message).toContain("Gateway target: ws://127.0.0.1:18789"); + expect(err?.message).toContain("Source: local loopback"); + expect(err?.message).toContain("Bind: loopback"); + }); +}); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 5987cbad5..5e97d9912 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -38,14 +38,15 @@ export async function callGateway( preferTailnet && tailnetIPv4 ? `ws://${tailnetIPv4}:${localPort}` : `ws://127.0.0.1:${localPort}`; - const url = - (typeof opts.url === "string" && opts.url.trim().length > 0 + const urlOverride = + typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() - : undefined) || - (typeof remote?.url === "string" && remote.url.trim().length > 0 + : undefined; + const remoteUrl = + typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() - : undefined) || - localUrl; + : undefined; + const url = urlOverride || remoteUrl || localUrl; const token = (typeof opts.token === "string" && opts.token.trim().length > 0 ? opts.token.trim() @@ -66,6 +67,43 @@ export async function callGateway( (typeof remote?.password === "string" && remote.password.trim().length > 0 ? remote.password.trim() : undefined); + const urlSource = urlOverride + ? "cli --url" + : remoteUrl + ? "config gateway.remote.url" + : preferTailnet && tailnetIPv4 + ? `local tailnet ${tailnetIPv4}` + : "local loopback"; + const remoteFallbackNote = + isRemoteMode && !urlOverride && !remoteUrl + ? "Note: gateway.mode=remote but gateway.remote.url is missing; using local URL." + : undefined; + const bindDetail = + !urlOverride && !remoteUrl + ? `Bind: ${bindMode}` + : undefined; + const connectionDetails = [ + `Gateway target: ${url}`, + `Source: ${urlSource}`, + bindDetail, + remoteFallbackNote, + ] + .filter(Boolean) + .join("\n"); + + const formatCloseError = (code: number, reason: string) => { + const reasonText = reason?.trim() || "no close reason"; + const hint = + code === 1006 + ? "abnormal closure (no close frame)" + : code === 1000 + ? "normal closure" + : ""; + const suffix = hint ? ` ${hint}` : ""; + return `gateway closed (${code}${suffix}): ${reasonText}\n${connectionDetails}`; + }; + const formatTimeoutError = () => + `gateway timeout after ${timeoutMs}ms\n${connectionDetails}`; return await new Promise((resolve, reject) => { let settled = false; let ignoreClose = false; @@ -106,14 +144,14 @@ export async function callGateway( if (settled || ignoreClose) return; ignoreClose = true; client.stop(); - stop(new Error(`gateway closed (${code}): ${reason}`)); + stop(new Error(formatCloseError(code, reason))); }, }); const timer = setTimeout(() => { ignoreClose = true; client.stop(); - stop(new Error("gateway timeout")); + stop(new Error(formatTimeoutError())); }, timeoutMs); client.start();