@@ -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+
6872vi . 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 ( / g a t e w a y d i s c o n n e c t e d / 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 ( {
0 commit comments