From a64c4e51b4d7f77a7cc6bb5852d7ebd2db01ca09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 10:23:29 -0700 Subject: [PATCH] fix(status): honor explicit local RPC fallback timeouts --- src/commands/status.scan.shared.test.ts | 140 +++++++++++++++++++----- src/commands/status.scan.shared.ts | 6 +- 2 files changed, 120 insertions(+), 26 deletions(-) diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts index 58ca7f554cf2..a2c8a50d17ba 100644 --- a/src/commands/status.scan.shared.test.ts +++ b/src/commands/status.scan.shared.test.ts @@ -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("../gateway/probe.js"); + return await probeGateway(...(args as Parameters)); }); - mocks.callGateway.mockResolvedValue({ sessions: 1 }); - - await resolveGatewayProbeSnapshot({ - cfg: {}, - opts: { timeoutMs: 1000 }, + mocks.callGateway.mockImplementation(async (...args: unknown[]) => { + const { callGateway } = + await vi.importActual("../gateway/call.js"); + return await callGateway(...(args as Parameters)); }); + 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 () => { diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index fa70f4abd7b4..eb61f71bd4d0 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -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 }) =>