Skip to content

Commit 23fe355

Browse files
fix(cli): auto-reconnect logs --follow on transient gateway disconnect #74782 (#75059)
* fix(cli): auto-reconnect logs --follow on transient gateway disconnect * fix(cli): honor errorLine return value in follow retry warning
1 parent f9a1f86 commit 23fe355

3 files changed

Lines changed: 189 additions & 0 deletions

File tree

docs/cli/logs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ openclaw logs --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
5757

5858
- Use `--local-time` to render timestamps in your local timezone.
5959
- If the implicit local loopback Gateway asks for pairing, closes during connect, or times out before `logs.tail` answers, `openclaw logs` falls back to the configured Gateway file log automatically. Explicit `--url` targets do not use this fallback.
60+
- When using `--follow`, transient gateway disconnects (WebSocket close, timeout, connection drop) trigger automatic reconnection with exponential backoff (up to 8 retries, capped at 30 s between attempts). A warning is printed to stderr on each retry. Non-recoverable errors (auth failure, bad configuration) still exit immediately.
6061

6162
## Related
6263

src/cli/logs-cli.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ vi.mock("../logging/log-tail.js", () => ({
6565
) => readConfiguredLogTail(...args),
6666
}));
6767

68+
vi.mock("../infra/backoff.js", () => ({
69+
computeBackoff: vi.fn().mockReturnValue(0),
70+
}));
71+
6872
vi.mock("./gateway-rpc.js", async () => {
6973
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
7074
return {
@@ -271,6 +275,147 @@ describe("logs cli", () => {
271275
expect(stderrWrites.join("")).toContain("Local Gateway RPC unavailable");
272276
});
273277

278+
describe("--follow retry behavior", () => {
279+
it("uses local fallback (not retry warning) for loopback close errors in --follow mode", async () => {
280+
// Loopback close errors are absorbed by shouldUseLocalLogsFallback inside fetchLogs —
281+
// they never reach the retry path, so no "gateway disconnected" warning is emitted.
282+
callGatewayFromCli.mockRejectedValueOnce(
283+
new GatewayTransportError({
284+
kind: "closed",
285+
code: 1006,
286+
reason: "abnormal closure",
287+
connectionDetails: {
288+
url: "ws://127.0.0.1:18789",
289+
urlSource: "local loopback",
290+
message: "",
291+
},
292+
message: "gateway closed (1006 abnormal closure): abnormal closure",
293+
}),
294+
);
295+
readConfiguredLogTail.mockResolvedValueOnce({
296+
file: "/tmp/openclaw.log",
297+
cursor: 5,
298+
lines: ["local fallback line"],
299+
truncated: false,
300+
reset: false,
301+
});
302+
303+
const stderrWrites = captureStderrWrites();
304+
const stdoutWrites = captureStdoutWrites();
305+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
306+
307+
await runLogsCli(["logs", "--follow"]);
308+
309+
expect(stderrWrites.join("")).toContain("Local Gateway RPC unavailable");
310+
expect(stderrWrites.join("")).not.toContain("gateway disconnected");
311+
expect(stdoutWrites.join("")).toContain("local fallback line");
312+
expect(exitSpy).toHaveBeenCalledWith(1);
313+
});
314+
315+
it("exits after exhausting max retries in --follow mode with explicit URL", async () => {
316+
// Explicit --url bypasses shouldUseLocalLogsFallback so close errors reach the retry path.
317+
// initial attempt + 8 retries = 9 total calls before fatal exit.
318+
const closeError = new GatewayTransportError({
319+
kind: "closed",
320+
code: 1006,
321+
reason: "abnormal closure",
322+
connectionDetails: {
323+
url: "ws://127.0.0.1:18789",
324+
urlSource: "cli",
325+
message: "",
326+
},
327+
message: "gateway closed (1006 abnormal closure): abnormal closure",
328+
});
329+
for (let i = 0; i <= 8; i += 1) {
330+
callGatewayFromCli.mockRejectedValueOnce(closeError);
331+
}
332+
333+
const stderrWrites = captureStderrWrites();
334+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
335+
336+
await runLogsCli(["logs", "--follow", "--url", "ws://127.0.0.1:18789"]);
337+
338+
expect((stderrWrites.join("").match(/gateway disconnected/g) ?? []).length).toBe(8);
339+
expect(stderrWrites.join("")).toContain("Gateway not reachable");
340+
expect(exitSpy).toHaveBeenCalledWith(1);
341+
});
342+
343+
it("retries on transient close errors in --follow mode with explicit URL (no local fallback)", async () => {
344+
callGatewayFromCli
345+
.mockRejectedValueOnce(
346+
new GatewayTransportError({
347+
kind: "closed",
348+
code: 1006,
349+
reason: "abnormal closure",
350+
connectionDetails: {
351+
url: "ws://remote.example.com:18789",
352+
urlSource: "cli",
353+
message: "",
354+
},
355+
message: "gateway closed (1006 abnormal closure): abnormal closure",
356+
}),
357+
)
358+
.mockResolvedValueOnce({
359+
file: "/tmp/openclaw.log",
360+
cursor: 10,
361+
lines: ["line from remote"],
362+
});
363+
364+
const stderrWrites = captureStderrWrites();
365+
const stdoutWrites = captureStdoutWrites();
366+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
367+
368+
await runLogsCli(["logs", "--follow", "--url", "ws://remote.example.com:18789"]);
369+
370+
expect(readConfiguredLogTail).not.toHaveBeenCalled();
371+
expect(stderrWrites.join("")).toContain("gateway disconnected");
372+
expect(stdoutWrites.join("")).toContain("line from remote");
373+
expect(exitSpy).toHaveBeenCalledWith(1);
374+
});
375+
376+
it("exits immediately on pairing-required close errors in --follow mode with explicit URL", async () => {
377+
callGatewayFromCli.mockRejectedValueOnce(
378+
new GatewayTransportError({
379+
kind: "closed",
380+
code: 1008,
381+
reason: "pairing required",
382+
connectionDetails: { url: "ws://127.0.0.1:18789", urlSource: "cli", message: "" },
383+
message: "gateway closed (1008 policy violation): pairing required",
384+
}),
385+
);
386+
387+
const stderrWrites = captureStderrWrites();
388+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
389+
390+
await runLogsCli(["logs", "--follow", "--url", "ws://127.0.0.1:18789"]);
391+
392+
expect(stderrWrites.join("")).not.toContain("gateway disconnected");
393+
expect(stderrWrites.join("")).toContain("Gateway not reachable");
394+
expect(exitSpy).toHaveBeenCalledWith(1);
395+
});
396+
397+
it("exits immediately on app-defined auth errors (4xxx) in --follow mode with explicit URL", async () => {
398+
callGatewayFromCli.mockRejectedValueOnce(
399+
new GatewayTransportError({
400+
kind: "closed",
401+
code: 4001,
402+
reason: "unauthorized",
403+
connectionDetails: { url: "ws://127.0.0.1:18789", urlSource: "cli", message: "" },
404+
message: "gateway closed (4001 unauthorized): unauthorized",
405+
}),
406+
);
407+
408+
const stderrWrites = captureStderrWrites();
409+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
410+
411+
await runLogsCli(["logs", "--follow", "--url", "ws://127.0.0.1:18789"]);
412+
413+
expect(stderrWrites.join("")).not.toContain("gateway disconnected");
414+
expect(stderrWrites.join("")).toContain("Gateway not reachable");
415+
expect(exitSpy).toHaveBeenCalledWith(1);
416+
});
417+
});
418+
274419
it("does not use local fallback for explicit Gateway URLs", async () => {
275420
callGatewayFromCli.mockRejectedValueOnce(
276421
new GatewayTransportError({

src/cli/logs-cli.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../gateway/call.js";
88
import { isLoopbackHost } from "../gateway/net.js";
99
import { readConnectPairingRequiredMessage } from "../gateway/protocol/connect-error-details.js";
10+
import { computeBackoff } from "../infra/backoff.js";
1011
import { formatErrorMessage } from "../infra/errors.js";
1112
import { readConfiguredLogTail } from "../logging/log-tail.js";
1213
import { parseLogLine } from "../logging/parse-log-line.js";
@@ -146,6 +147,29 @@ function isPlainGatewayRequestTimeoutError(message: string): boolean {
146147
return /^gateway timeout after \d+ms\b/u.test(message);
147148
}
148149

150+
const MAX_FOLLOW_RETRIES = 8;
151+
152+
const FOLLOW_BACKOFF_POLICY = { initialMs: 1_000, maxMs: 30_000, factor: 2, jitter: 0.2 };
153+
154+
// Returns true only for transport-level disconnects that are worth retrying.
155+
// Auth errors (4xxx), policy violations (1008), and pairing-required messages are
156+
// non-recoverable without user action and must not loop.
157+
function isTransientFollowError(error: unknown): boolean {
158+
if (isGatewayTransportError(error)) {
159+
if (error.kind === "timeout") {
160+
return true;
161+
}
162+
const code = error.code ?? 0;
163+
// 1008 = policy violation (pairing required); 4xxx = app-defined (auth, rate-limit)
164+
return code !== 1008 && !(code >= 4000 && code <= 4999);
165+
}
166+
const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error));
167+
if (readConnectPairingRequiredMessage(message)) {
168+
return false;
169+
}
170+
return isPlainGatewayRequestCloseError(message) || isPlainGatewayRequestTimeoutError(message);
171+
}
172+
149173
export function formatLogTimestamp(
150174
value?: string,
151175
mode: "pretty" | "plain" = "plain",
@@ -306,13 +330,31 @@ export function registerLogsCli(program: Command) {
306330
const localTime =
307331
Boolean(opts.localTime) || (!!process.env.TZ && isValidTimeZone(process.env.TZ));
308332

333+
let followRetryAttempt = 0;
309334
while (true) {
310335
let payload: LogsTailPayload;
311336
// Show progress spinner only on first fetch, not during follow polling
312337
const showProgress = first && !opts.follow;
313338
try {
314339
payload = await fetchLogs(opts, cursor, showProgress);
315340
} catch (err) {
341+
if (opts.follow && followRetryAttempt < MAX_FOLLOW_RETRIES && isTransientFollowError(err)) {
342+
followRetryAttempt += 1;
343+
const backoffMs = computeBackoff(FOLLOW_BACKOFF_POLICY, followRetryAttempt);
344+
if (
345+
!errorLine(
346+
colorize(
347+
rich,
348+
theme.warn,
349+
`[logs] gateway disconnected, reconnecting in ${Math.round(backoffMs / 1_000)}s...`,
350+
),
351+
)
352+
) {
353+
return;
354+
}
355+
await delay(backoffMs);
356+
continue;
357+
}
316358
await emitGatewayError(
317359
err,
318360
opts,
@@ -324,6 +366,7 @@ export function registerLogsCli(program: Command) {
324366
process.exit(1);
325367
return;
326368
}
369+
followRetryAttempt = 0;
327370
const lines = Array.isArray(payload.lines) ? payload.lines : [];
328371
if (jsonMode) {
329372
if (first) {

0 commit comments

Comments
 (0)