fix(status): honor explicit local RPC fallback timeouts

This commit is contained in:
Peter Steinberger
2026-08-01 10:23:29 -07:00
parent a14ada134f
commit a64c4e51b4
2 changed files with 120 additions and 26 deletions

View File

@@ -1,8 +1,19 @@
// Status scan shared tests cover gateway probe snapshots, Tailscale URLs, and shared scan helpers.
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js";
import { parseStatusRouteArgs } from "../cli/program/route-args.js";
import {
buildMinimalGatewayHelloOkPayload,
closeMinimalGatewayServer,
parseMinimalGatewayRequestFrame,
sendMinimalGatewayConnectChallenge,
sendMinimalGatewayResponse,
} from "../gateway/minimal-gateway.test-helpers.js";
import {
buildTailscaleHttpsUrl,
resolveGatewayProbeSnapshot,
@@ -325,39 +336,118 @@ describe("resolveGatewayProbeSnapshot", () => {
expect(gatewayCall.timeoutMs).toBe(2000);
});
it("does not raise an explicit local status RPC fallback timeout", async () => {
it.each([1, 50, 999, 1000, 2000, 8000])(
"does not raise an explicit local status RPC fallback timeout (%i ms)",
async (timeoutMs) => {
mocks.resolveGatewayProbeTarget.mockReturnValue({
mode: "local",
gatewayMode: "local",
remoteUrlMissing: false,
});
mocks.probeGateway.mockResolvedValue({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "timeout",
close: null,
auth: {
role: null,
scopes: [],
capability: "unknown",
},
health: null,
status: null,
presence: null,
configSnapshot: null,
});
mocks.callGateway.mockResolvedValue({ sessions: 1 });
await resolveGatewayProbeSnapshot({
cfg: {},
opts: { timeoutMs },
});
const probeCall = readProbeCall();
expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs");
expect(probeCall.timeoutMs).toBe(timeoutMs);
expect(readGatewayCall().timeoutMs).toBe(Math.min(2000, timeoutMs));
},
);
it("enforces an explicit CLI timeout against a real local fallback status RPC", async () => {
const gateway = new WebSocketServer({ host: "127.0.0.1", port: 0 });
await once(gateway, "listening");
const address = gateway.address() as AddressInfo;
const url = `ws://127.0.0.1:${address.port}`;
const observedMethods: string[] = [];
gateway.on("connection", (socket) => {
sendMinimalGatewayConnectChallenge(socket);
socket.on("message", (data) => {
const frame = parseMinimalGatewayRequestFrame(data);
if (frame.type !== "req" || !frame.id || !frame.method) {
return;
}
const requestId = frame.id;
if (frame.method === "connect") {
sendMinimalGatewayResponse(
socket,
requestId,
buildMinimalGatewayHelloOkPayload({
methods: ["system-presence", "status"],
auth: { role: "operator", scopes: ["operator.read"] },
}),
);
return;
}
observedMethods.push(frame.method);
if (frame.method === "status") {
const responseTimer = setTimeout(() => {
if (socket.readyState === socket.OPEN) {
sendMinimalGatewayResponse(socket, requestId, { sessions: 1 });
}
}, 400);
responseTimer.unref();
}
});
});
mocks.buildGatewayConnectionDetailsWithResolvers.mockReturnValue({
url,
urlSource: "local loopback",
message: `Gateway target: ${url}`,
});
mocks.resolveGatewayProbeTarget.mockReturnValue({
mode: "local",
gatewayMode: "local",
remoteUrlMissing: false,
});
mocks.probeGateway.mockResolvedValue({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "timeout",
close: null,
auth: {
role: null,
scopes: [],
capability: "unknown",
},
health: null,
status: null,
presence: null,
configSnapshot: null,
mocks.probeGateway.mockImplementation(async (...args: unknown[]) => {
const { probeGateway } =
await vi.importActual<typeof import("../gateway/probe.js")>("../gateway/probe.js");
return await probeGateway(...(args as Parameters<typeof probeGateway>));
});
mocks.callGateway.mockResolvedValue({ sessions: 1 });
await resolveGatewayProbeSnapshot({
cfg: {},
opts: { timeoutMs: 1000 },
mocks.callGateway.mockImplementation(async (...args: unknown[]) => {
const { callGateway } =
await vi.importActual<typeof import("../gateway/call.js")>("../gateway/call.js");
return await callGateway(...(args as Parameters<typeof callGateway>));
});
const parsed = parseStatusRouteArgs(["node", "openclaw", "status", "--timeout", "250"]);
expect(parsed?.timeoutMs).toBe(250);
const probeCall = readProbeCall();
expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs");
expect(probeCall.timeoutMs).toBe(1000);
expect(readGatewayCall().timeoutMs).toBe(1000);
try {
const result = await resolveGatewayProbeSnapshot({
cfg: { gateway: { auth: { mode: "none" } } },
opts: { timeoutMs: parsed?.timeoutMs },
});
expect(readProbeCall().timeoutMs).toBe(250);
expect(readGatewayCall().timeoutMs).toBe(250);
expect(observedMethods).toEqual(["system-presence", "status"]);
expect(result.gatewayProbe?.ok).toBe(false);
expect(result.gatewayProbe?.error).toContain("timeout");
} finally {
await closeMinimalGatewayServer(gateway);
}
});
it("lets callGateway reuse paired-device auth for local status RPC fallback", async () => {

View File

@@ -208,7 +208,11 @@ async function applyLocalStatusRpcFallback(params: {
if (!shouldTryLocalStatusRpcFallback(params)) {
return params.gatewayProbe;
}
const boundedFallbackTimeoutMs = Math.min(2000, Math.max(1000, params.timeoutMs));
// Explicit probe budgets are operator-owned; only implicit fallback defaults get a floor.
const boundedFallbackTimeoutMs = Math.min(
2000,
params.timeoutMsExplicit ? params.timeoutMs : Math.max(1000, params.timeoutMs),
);
// The fallback uses the gateway status RPC because it can succeed after probe handshake ambiguity.
const status = await loadGatewayCallModule()
.then(({ callGateway }) =>