@@ -3,6 +3,7 @@ package mountsync
33import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "os"
89 "path/filepath"
@@ -365,6 +366,173 @@ func TestBootstrapResumesFromPersistedCursor(t *testing.T) {
365366 }
366367}
367368
369+ // TestBootstrapStallCycleGuardPersistsAndFailsHard reproduces the
370+ // non-converging unversioned partial-bootstrap shape: the first page is
371+ // persisted, then every resumed cycle fails at the same next-page cursor.
372+ // The empty revision is non-destructive and not the reason completion fails;
373+ // the unchanged checkpoint is. The guard turns the otherwise infinite
374+ // "non-empty state without completed bootstrap" loop into a hard failure,
375+ // even when every attempt is a fresh Syncer process.
376+ func TestBootstrapStallCycleGuardPersistsAndFailsHard (t * testing.T ) {
377+ client := newBootstrapClient (30 , 10 )
378+ for path , file := range client .files {
379+ file .Revision = ""
380+ client .files [path ] = file
381+ }
382+ client .listTreeFailAt = 2
383+ client .listTreeFailErr = & HTTPError {StatusCode : 502 , Code : "bad_gateway" , Message : "stuck" }
384+ localDir := t .TempDir ()
385+ opts := SyncerOptions {RootCtx : context .Background (), BootstrapStallCycles : 2 }
386+
387+ s := newBootstrapSyncer (t , client , localDir , opts )
388+ err := s .Reconcile (context .Background ())
389+ if err == nil {
390+ t .Fatalf ("expected partial bootstrap cycle to return its cloud error" )
391+ }
392+ var stalled * BootstrapStalledError
393+ if errors .As (err , & stalled ) {
394+ t .Fatalf ("checkpoint-advancing cycle must stay below the hard limit: %v" , err )
395+ }
396+ st := loadPersistedState (t , localDir )
397+ if st .BootstrapStallCycles != 0 || st .BootstrapCursor == "" || st .BootstrapFilesSynced == 0 {
398+ t .Fatalf ("expected persisted partial checkpoint with reset stall count, state=%#v" , st )
399+ }
400+ if got := countLocalFiles (t , localDir ); got != 10 {
401+ t .Fatalf ("expected first page to stay materialized, got %d files" , got )
402+ }
403+
404+ // A fresh Syncer resumes at the same failing cursor. This is the first
405+ // checkpoint-stable stalled cycle, so it increments but stays retryable.
406+ s = newBootstrapSyncer (t , client , localDir , opts )
407+ err = s .Reconcile (context .Background ())
408+ if err == nil || errors .As (err , & stalled ) {
409+ t .Fatalf ("first checkpoint-stable cycle must return the cloud error, got %v" , err )
410+ }
411+ st = loadPersistedState (t , localDir )
412+ if st .BootstrapStallCycles != 1 {
413+ t .Fatalf ("BootstrapStallCycles after first unchanged cycle = %d, want 1" , st .BootstrapStallCycles )
414+ }
415+
416+ // The next fresh Syncer sees the same persisted partial checkpoint and
417+ // reaches the configured limit, returning a typed, status-visible failure.
418+ s = newBootstrapSyncer (t , client , localDir , opts )
419+ err = s .Reconcile (context .Background ())
420+ if ! errors .As (err , & stalled ) {
421+ t .Fatalf ("expected BootstrapStalledError at the hard limit, got %v" , err )
422+ }
423+ if stalled .Cycles != 2 || stalled .Limit != 2 {
424+ t .Fatalf ("BootstrapStalledError = %#v, want cycles=2 limit=2" , stalled )
425+ }
426+ st = loadPersistedState (t , localDir )
427+ if st .BootstrapComplete {
428+ t .Fatalf ("hard stall must not force BootstrapComplete" )
429+ }
430+ if st .BootstrapStallCycles != 2 {
431+ t .Fatalf ("persisted BootstrapStallCycles = %d, want 2" , st .BootstrapStallCycles )
432+ }
433+ if st .LastError == nil || st .LastError .Kind != "bootstrap_stalled" || st .LastError .Code != "bootstrap_stall_cycle_limit" {
434+ t .Fatalf ("expected structured persisted bootstrap stall error, got %#v" , st .LastError )
435+ }
436+ }
437+
438+ func TestBootstrapStallCycleGuardIgnoresCanceledContext (t * testing.T ) {
439+ s := newBootstrapSyncer (t , newBootstrapClient (1 , 1 ), t .TempDir (), SyncerOptions {
440+ RootCtx : context .Background (),
441+ BootstrapStallCycles : 2 ,
442+ })
443+ s .state .BootstrapCursor = "resume-cursor"
444+ s .state .BootstrapStallCycles = 1
445+
446+ wrappedCanceled := fmt .Errorf ("mount shutting down: %w" , context .Canceled )
447+ if err := s .recordBootstrapCycle ("resume-cursor" , nil , wrappedCanceled ); err != nil {
448+ t .Fatalf ("canceled context must not trip the stall guard: %v" , err )
449+ }
450+ if got := s .state .BootstrapStallCycles ; got != 1 {
451+ t .Fatalf ("canceled context changed stall count to %d, want 1" , got )
452+ }
453+
454+ // DeadlineExceeded is a failed retry at the same checkpoint, so it still
455+ // consumes the remaining attempt and produces the typed hard stop.
456+ err := s .recordBootstrapCycle ("resume-cursor" , nil , context .DeadlineExceeded )
457+ var stalled * BootstrapStalledError
458+ if ! errors .As (err , & stalled ) {
459+ t .Fatalf ("deadline exceeded must count toward the stall limit, got %v" , err )
460+ }
461+ if stalled .Cycles != 2 || stalled .Limit != 2 {
462+ t .Fatalf ("BootstrapStalledError = %#v, want cycles=2 limit=2" , stalled )
463+ }
464+ }
465+
466+ // TestBootstrapStallCycleGuardResetsOnCheckpointAdvanceAndCompletion proves
467+ // that the counter reflects lack of traversal progress, not merely errors.
468+ func TestBootstrapStallCycleGuardResetsOnCheckpointAdvanceAndCompletion (t * testing.T ) {
469+ client := newBootstrapClient (30 , 10 )
470+ client .listTreeFailAt = 2
471+ client .listTreeFailErr = & HTTPError {StatusCode : 502 , Code : "bad_gateway" , Message : "page failure" }
472+ localDir := t .TempDir ()
473+ s := newBootstrapSyncer (t , client , localDir , SyncerOptions {
474+ RootCtx : context .Background (),
475+ BootstrapStallCycles : 3 ,
476+ })
477+
478+ // Page one advances the cursor before page two fails, so the count resets.
479+ if err := s .Reconcile (context .Background ()); err == nil {
480+ t .Fatalf ("expected first bootstrap attempt to fail on page 2" )
481+ }
482+ if st := loadPersistedState (t , localDir ); st .BootstrapStallCycles != 0 || st .BootstrapCursor == "" {
483+ t .Fatalf ("cursor advance must reset stall count; state=%#v" , st )
484+ }
485+
486+ // Fail at the same persisted cursor once: count increments.
487+ if err := s .Reconcile (context .Background ()); err == nil {
488+ t .Fatalf ("expected unchanged checkpoint attempt to fail" )
489+ }
490+ if st := loadPersistedState (t , localDir ); st .BootstrapStallCycles != 1 {
491+ t .Fatalf ("unchanged checkpoint count = %d, want 1" , st .BootstrapStallCycles )
492+ }
493+
494+ // Let page two advance, then fail on page three. The new cursor resets the
495+ // persisted count despite the returned cloud error.
496+ client .mu .Lock ()
497+ client .listTreeFailAt = 3
498+ client .mu .Unlock ()
499+ if err := s .Reconcile (context .Background ()); err == nil {
500+ t .Fatalf ("expected bootstrap attempt to fail on page 3" )
501+ }
502+ if st := loadPersistedState (t , localDir ); st .BootstrapStallCycles != 0 {
503+ t .Fatalf ("advanced checkpoint must reset stall count, got %d" , st .BootstrapStallCycles )
504+ }
505+
506+ client .mu .Lock ()
507+ client .listTreeFailAt = 0
508+ client .mu .Unlock ()
509+ if err := s .Reconcile (context .Background ()); err != nil {
510+ t .Fatalf ("completion after checkpoint advances: %v" , err )
511+ }
512+ st := loadPersistedState (t , localDir )
513+ if ! st .BootstrapComplete || st .BootstrapStallCycles != 0 {
514+ t .Fatalf ("completion must clear checkpoint and stall count, state=%#v" , st )
515+ }
516+ }
517+
518+ func TestBootstrapStallCycleLimitUsesOptionThenEnvThenDefault (t * testing.T ) {
519+ newSyncer := func (t * testing.T , opts SyncerOptions ) * Syncer {
520+ t .Helper ()
521+ return newBootstrapSyncer (t , newBootstrapClient (1 , 1 ), t .TempDir (), opts )
522+ }
523+ t .Setenv ("RELAYFILE_BOOTSTRAP_STALL_CYCLES" , "7" )
524+ if got := newSyncer (t , SyncerOptions {}).bootstrapStallCycles ; got != 7 {
525+ t .Fatalf ("env bootstrap stall limit = %d, want 7" , got )
526+ }
527+ if got := newSyncer (t , SyncerOptions {BootstrapStallCycles : 3 }).bootstrapStallCycles ; got != 3 {
528+ t .Fatalf ("explicit bootstrap stall limit = %d, want 3" , got )
529+ }
530+ t .Setenv ("RELAYFILE_BOOTSTRAP_STALL_CYCLES" , "not-a-number" )
531+ if got := newSyncer (t , SyncerOptions {}).bootstrapStallCycles ; got != defaultBootstrapStallCycles {
532+ t .Fatalf ("invalid env bootstrap stall limit = %d, want default %d" , got , defaultBootstrapStallCycles )
533+ }
534+ }
535+
368536// TestBootstrapCompleteGatesFastPath: a hand-seeded state with Files +
369537// LastEventAt but BootstrapComplete=false MUST NOT short-circuit; the
370538// full pull runs and the mirror converges (the rw_517d60b6 repro).
0 commit comments