From b2d714b119bb5840f95503728debd7bbfbff69ea Mon Sep 17 00:00:00 2001 From: ted Date: Sun, 15 Mar 2026 11:03:38 -0700 Subject: [PATCH] CLI: keep inactive loopback probes fast --- src/commands/gateway-status.test.ts | 21 +++++++++++++++++++++ src/commands/gateway-status.ts | 2 +- src/commands/gateway-status/helpers.test.ts | 16 +++++++++++----- src/commands/gateway-status/helpers.ts | 15 ++++++++++----- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 068d36a4bda..3762afc6d8a 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -587,6 +587,27 @@ describe("gateway-status command", () => { ); }); + it("keeps inactive local loopback probes on the short timeout in remote mode", async () => { + const { runtime } = createRuntimeCapture(); + probeGateway.mockClear(); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "remote", + auth: { mode: "token", token: "ltok" }, + remote: {}, + }, + } as never); + + await runGatewayStatus(runtime, { timeout: "15000", json: true }); + + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + timeoutMs: 800, + }), + ); + }); + it("skips invalid ssh-auto discovery targets", async () => { const { runtime } = createRuntimeCapture(); await withEnvAsync({ USER: "steipete" }, async () => { diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index ecdeeaa9570..c338d7fe55b 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -176,7 +176,7 @@ export async function gatewayStatusCommand( token: authResolution.token, password: authResolution.password, }; - const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); + const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target); const probe = await probeGateway({ url: target.url, auth, diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index 1d43b4c4f4b..525b99db98c 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -276,13 +276,19 @@ describe("probe reachability classification", () => { }); describe("resolveProbeBudgetMs", () => { - it("lets local loopback probes use the full caller budget", () => { - expect(resolveProbeBudgetMs(15_000, "localLoopback")).toBe(15_000); - expect(resolveProbeBudgetMs(3_000, "localLoopback")).toBe(3_000); + it("lets active local loopback probes use the full caller budget", () => { + expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: true })).toBe(15_000); + expect(resolveProbeBudgetMs(3_000, { kind: "localLoopback", active: true })).toBe(3_000); + }); + + it("keeps inactive local loopback probes on the short cap", () => { + expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: false })).toBe(800); + expect(resolveProbeBudgetMs(500, { kind: "localLoopback", active: false })).toBe(500); }); it("keeps non-local probe caps unchanged", () => { - expect(resolveProbeBudgetMs(15_000, "configRemote")).toBe(1_500); - expect(resolveProbeBudgetMs(15_000, "sshTunnel")).toBe(2_000); + expect(resolveProbeBudgetMs(15_000, { kind: "configRemote", active: true })).toBe(1_500); + expect(resolveProbeBudgetMs(15_000, { kind: "explicit", active: true })).toBe(1_500); + expect(resolveProbeBudgetMs(15_000, { kind: "sshTunnel", active: true })).toBe(2_000); }); }); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index eb78e21212d..761c556d4aa 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -13,6 +13,7 @@ const MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i; type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel"; +const INACTIVE_LOOPBACK_PROBE_BUDGET_MS = 800; const REMOTE_PROBE_BUDGET_MS = 1_500; const SSH_TUNNEL_PROBE_BUDGET_MS = 2_000; @@ -119,12 +120,16 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew return targets; } -export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number { - switch (kind) { +export function resolveProbeBudgetMs( + overallMs: number, + target: Pick, +): number { + switch (target.kind) { case "localLoopback": - // Let the local probe use the caller's full budget. Slow local shells/containers can - // exceed the old fixed cap and produce false "unreachable" results. - return overallMs; + // Active loopback probes should honor the caller budget because local shells/containers + // can legitimately take longer to connect. Inactive loopback probes stay bounded so + // remote-mode status checks do not stall on an expected local miss. + return target.active ? overallMs : Math.min(INACTIVE_LOOPBACK_PROBE_BUDGET_MS, overallMs); case "sshTunnel": return Math.min(SSH_TUNNEL_PROBE_BUDGET_MS, overallMs); default: