fix: add gateway close context
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
- macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438.
|
- 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.
|
- 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.
|
- 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: show colored table output for `clawdbot cron list` (JSON behind `--json`).
|
||||||
- CLI: add cron `create`/`remove`/`delete` aliases for job management.
|
- CLI: add cron `create`/`remove`/`delete` aliases for job management.
|
||||||
|
|||||||
@@ -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 loadConfig = vi.fn();
|
||||||
const resolveGatewayPort = vi.fn();
|
const resolveGatewayPort = vi.fn();
|
||||||
@@ -7,7 +7,12 @@ const pickPrimaryTailnetIPv4 = vi.fn();
|
|||||||
let lastClientOptions: {
|
let lastClientOptions: {
|
||||||
url?: string;
|
url?: string;
|
||||||
onHelloOk?: () => void | Promise<void>;
|
onHelloOk?: () => void | Promise<void>;
|
||||||
|
onClose?: (code: number, reason: string) => void;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
type StartMode = "hello" | "close" | "silent";
|
||||||
|
let startMode: StartMode = "hello";
|
||||||
|
let closeCode = 1006;
|
||||||
|
let closeReason = "";
|
||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
@@ -27,6 +32,7 @@ vi.mock("./client.js", () => ({
|
|||||||
constructor(opts: {
|
constructor(opts: {
|
||||||
url?: string;
|
url?: string;
|
||||||
onHelloOk?: () => void | Promise<void>;
|
onHelloOk?: () => void | Promise<void>;
|
||||||
|
onClose?: (code: number, reason: string) => void;
|
||||||
}) {
|
}) {
|
||||||
lastClientOptions = opts;
|
lastClientOptions = opts;
|
||||||
}
|
}
|
||||||
@@ -34,7 +40,11 @@ vi.mock("./client.js", () => ({
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
start() {
|
start() {
|
||||||
void lastClientOptions?.onHelloOk?.();
|
if (startMode === "hello") {
|
||||||
|
void lastClientOptions?.onHelloOk?.();
|
||||||
|
} else if (startMode === "close") {
|
||||||
|
lastClientOptions?.onClose?.(closeCode, closeReason);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stop() {}
|
stop() {}
|
||||||
},
|
},
|
||||||
@@ -48,6 +58,9 @@ describe("callGateway url resolution", () => {
|
|||||||
resolveGatewayPort.mockReset();
|
resolveGatewayPort.mockReset();
|
||||||
pickPrimaryTailnetIPv4.mockReset();
|
pickPrimaryTailnetIPv4.mockReset();
|
||||||
lastClientOptions = null;
|
lastClientOptions = null;
|
||||||
|
startMode = "hello";
|
||||||
|
closeCode = 1006;
|
||||||
|
closeReason = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses tailnet IP when local bind is tailnet", async () => {
|
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");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,14 +38,15 @@ export async function callGateway<T = unknown>(
|
|||||||
preferTailnet && tailnetIPv4
|
preferTailnet && tailnetIPv4
|
||||||
? `ws://${tailnetIPv4}:${localPort}`
|
? `ws://${tailnetIPv4}:${localPort}`
|
||||||
: `ws://127.0.0.1:${localPort}`;
|
: `ws://127.0.0.1:${localPort}`;
|
||||||
const url =
|
const urlOverride =
|
||||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
typeof opts.url === "string" && opts.url.trim().length > 0
|
||||||
? opts.url.trim()
|
? opts.url.trim()
|
||||||
: undefined) ||
|
: undefined;
|
||||||
(typeof remote?.url === "string" && remote.url.trim().length > 0
|
const remoteUrl =
|
||||||
|
typeof remote?.url === "string" && remote.url.trim().length > 0
|
||||||
? remote.url.trim()
|
? remote.url.trim()
|
||||||
: undefined) ||
|
: undefined;
|
||||||
localUrl;
|
const url = urlOverride || remoteUrl || localUrl;
|
||||||
const token =
|
const token =
|
||||||
(typeof opts.token === "string" && opts.token.trim().length > 0
|
(typeof opts.token === "string" && opts.token.trim().length > 0
|
||||||
? opts.token.trim()
|
? opts.token.trim()
|
||||||
@@ -66,6 +67,43 @@ export async function callGateway<T = unknown>(
|
|||||||
(typeof remote?.password === "string" && remote.password.trim().length > 0
|
(typeof remote?.password === "string" && remote.password.trim().length > 0
|
||||||
? remote.password.trim()
|
? remote.password.trim()
|
||||||
: undefined);
|
: 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<T>((resolve, reject) => {
|
return await new Promise<T>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let ignoreClose = false;
|
let ignoreClose = false;
|
||||||
@@ -106,14 +144,14 @@ export async function callGateway<T = unknown>(
|
|||||||
if (settled || ignoreClose) return;
|
if (settled || ignoreClose) return;
|
||||||
ignoreClose = true;
|
ignoreClose = true;
|
||||||
client.stop();
|
client.stop();
|
||||||
stop(new Error(`gateway closed (${code}): ${reason}`));
|
stop(new Error(formatCloseError(code, reason)));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
ignoreClose = true;
|
ignoreClose = true;
|
||||||
client.stop();
|
client.stop();
|
||||||
stop(new Error("gateway timeout"));
|
stop(new Error(formatTimeoutError()));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
client.start();
|
client.start();
|
||||||
|
|||||||
Reference in New Issue
Block a user