@@ -1127,11 +1127,12 @@ type mountState struct {
11271127 // are additive/omitempty: legacy state files load with zero values
11281128 // (BootstrapComplete=false), which self-heals by forcing a full
11291129 // reconcile on the next cycle.
1130- BootstrapComplete bool `json:"bootstrapComplete,omitempty"`
1131- BootstrapCursor string `json:"bootstrapCursor,omitempty"`
1132- BootstrapFilesSynced int `json:"bootstrapFilesSynced,omitempty"`
1133- BootstrapFilesTotal int `json:"bootstrapFilesTotal,omitempty"`
1134- BootstrapStartedAt string `json:"bootstrapStartedAt,omitempty"`
1130+ BootstrapComplete bool `json:"bootstrapComplete,omitempty"`
1131+ BootstrapDirectories []string `json:"bootstrapDirectories,omitempty"`
1132+ BootstrapCursor string `json:"bootstrapCursor,omitempty"`
1133+ BootstrapFilesSynced int `json:"bootstrapFilesSynced,omitempty"`
1134+ BootstrapFilesTotal int `json:"bootstrapFilesTotal,omitempty"`
1135+ BootstrapStartedAt string `json:"bootstrapStartedAt,omitempty"`
11351136 // QuarantinedPaths holds remote paths that cannot be materialized locally
11361137 // due to file/directory name collisions. Persisted across cycle restarts
11371138 // so the daemon does not re-fetch these paths from the cloud until the
@@ -4031,43 +4032,79 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
40314032 metrics := fullTreeTraversalMetrics {startedAt : time .Now ()}
40324033 defer func () {
40334034 s .logf (
4034- "mount full-tree traversal summary remote_root=%q list_calls=%d entries_seen=%d files_seen=%d directories_seen=%d bytes_seen=%d runtime_entries_seen=%d traversal_complete=%t duration_ms=%d" ,
4035+ "mount full-tree traversal summary remote_root=%q list_calls=%d entries_seen=%d files_seen=%d directories_seen=%d bytes_seen=%d runtime_entries_seen=%d runtime_subtrees_pruned=%d traversal_complete=%t traversal_failed =%t duration_ms=%d" ,
40354036 s .remoteRoot ,
40364037 metrics .listCalls ,
40374038 metrics .entriesSeen ,
40384039 metrics .filesSeen ,
40394040 metrics .directoriesSeen ,
40404041 metrics .bytesSeen ,
40414042 metrics .runtimeEntriesSeen ,
4043+ metrics .runtimeSubtreesPruned ,
40424044 metrics .traversalComplete ,
4045+ returnErr != nil ,
40434046 time .Since (metrics .startedAt ).Milliseconds (),
40444047 )
40454048 }()
40464049 remotePaths := map [string ]struct {}{}
4047- // Resumable bootstrap: if a prior bootstrap was interrupted mid-tree,
4048- // pick traversal back up from the persisted cursor rather than
4049- // re-reading everything. startedFromEmpty tracks whether this process
4050- // traversed the WHOLE tree (empty start -> NextCursor==nil): only then
4051- // is the snapshot delete pass authoritative. Resuming from a persisted
4052- // cursor means we did not observe the full remote set this cycle, so
4053- // the delete pass is skipped (next full cycle does the authoritative
4054- // delete) — preserving the #164/#165 mount-root-clobber invariants.
4050+ // Traverse one directory level at a time. A deep ListTree request makes the
4051+ // server enumerate every descendant before the client can reject reserved
4052+ // runtime paths. The shallow queue sees a nested .relay directory first and
4053+ // can prune it without ever requesting its state/outbox descendants.
4054+ directories := []string {s .remoteRoot }
40554055 cursor := ""
4056- if ! s .state .BootstrapComplete && strings .TrimSpace (s .state .BootstrapCursor ) != "" {
4057- cursor = s .state .BootstrapCursor
4058- s .logf ("resuming bootstrap full-tree pull from persisted cursor (%d files already synced)" , s .state .BootstrapFilesSynced )
4056+ startedFromEmpty := true
4057+ if ! s .state .BootstrapComplete && len (s .state .BootstrapDirectories ) > 0 {
4058+ resumedDirectories := make ([]string , 0 , len (s .state .BootstrapDirectories ))
4059+ for _ , directory := range s .state .BootstrapDirectories {
4060+ directory = normalizeRemotePath (directory )
4061+ if ! isUnderRemoteRoot (s .remoteRoot , directory ) || isMountRuntimeRemotePath (directory ) {
4062+ continue
4063+ }
4064+ resumedDirectories = append (resumedDirectories , directory )
4065+ }
4066+ if len (resumedDirectories ) > 0 {
4067+ directories = resumedDirectories
4068+ cursor = strings .TrimSpace (s .state .BootstrapCursor )
4069+ startedFromEmpty = false
4070+ s .logf ("resuming bootstrap shallow-tree pull at %s from persisted cursor (%d directories pending, %d files already synced)" , directories [0 ], len (directories ), s .state .BootstrapFilesSynced )
4071+ }
4072+ } else if ! s .state .BootstrapComplete && strings .TrimSpace (s .state .BootstrapCursor ) != "" {
4073+ // v0.10.28 and older persisted a cursor into one depth=200 listing.
4074+ // That cursor is invalid for a depth=1 directory page, so restart from
4075+ // the root. The local-hash fast path avoids re-reading unchanged files.
4076+ s .logf ("discarding legacy deep-tree bootstrap cursor and restarting shallow traversal from %s" , s .remoteRoot )
4077+ s .state .BootstrapCursor = ""
4078+ }
4079+ queuedDirectories := make (map [string ]struct {}, len (directories ))
4080+ for _ , directory := range directories {
4081+ queuedDirectories [directory ] = struct {}{}
40594082 }
4060- startedFromEmpty := cursor == ""
40614083 if s .state .BootstrapStartedAt == "" {
40624084 s .state .BootstrapStartedAt = time .Now ().UTC ().Format (time .RFC3339Nano )
40634085 }
4086+ persistTraversal := func (filesThisPage int ) error {
4087+ if s .state .BootstrapComplete {
4088+ return nil
4089+ }
4090+ s .state .BootstrapDirectories = append ([]string (nil ), directories ... )
4091+ s .state .BootstrapCursor = cursor
4092+ s .state .BootstrapFilesSynced += filesThisPage
4093+ prog .touch ()
4094+ if err := s .saveState (); err != nil {
4095+ return err
4096+ }
4097+ prog .touch ()
4098+ return nil
4099+ }
40644100 maxObservedRevision := ""
40654101 var transientBootstrapAbort bool
4066- for {
4102+ for len (directories ) > 0 {
4103+ currentDirectory := directories [0 ]
40674104 var page TreeResponse
40684105 var err error
40694106 s .runFullPullIO (func () {
4070- page , err = s .client .ListTree (ctx , s .workspace , s . remoteRoot , 200 , cursor )
4107+ page , err = s .client .ListTree (ctx , s .workspace , currentDirectory , 1 , cursor )
40714108 })
40724109 metrics .listCalls ++
40734110 if err != nil {
@@ -4079,6 +4116,7 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
40794116 filesThisPage := 0
40804117 readJobs := make ([]bootstrapReadJob , 0 , len (page .Entries ))
40814118 for _ , entry := range page .Entries {
4119+ remotePath := normalizeRemotePath (entry .Path )
40824120 metrics .entriesSeen ++
40834121 switch entry .Type {
40844122 case "file" :
@@ -4089,26 +4127,35 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
40894127 case "dir" :
40904128 metrics .directoriesSeen ++
40914129 }
4092- if isMountRuntimeRemotePath (entry . Path ) {
4130+ if isMountRuntimeRemotePath (remotePath ) {
40934131 metrics .runtimeEntriesSeen ++
40944132 }
4095- if entry .Type != "file" {
4096- continue
4097- }
4098- if revisionAdvances (maxObservedRevision , entry .Revision ) {
4099- maxObservedRevision = entry .Revision
4100- }
4101- remotePath := normalizeRemotePath (entry .Path )
41024133 if ! isUnderRemoteRoot (s .remoteRoot , remotePath ) {
41034134 continue
41044135 }
41054136 if s .skipMountRuntimeRemotePath (remotePath , conflicted ) {
4137+ if entry .Type == "dir" {
4138+ metrics .runtimeSubtreesPruned ++
4139+ }
41064140 continue
41074141 }
41084142 // Contract: lazy GitHub repos do not eagerly hydrate per-repo content at startup.
41094143 if s .lazyRepos && isUnderLazyGithubRepoSubtree (s .remoteRoot , remotePath ) {
41104144 continue
41114145 }
4146+ if entry .Type == "dir" {
4147+ if _ , exists := queuedDirectories [remotePath ]; ! exists {
4148+ queuedDirectories [remotePath ] = struct {}{}
4149+ directories = append (directories , remotePath )
4150+ }
4151+ continue
4152+ }
4153+ if entry .Type != "file" {
4154+ continue
4155+ }
4156+ if revisionAdvances (maxObservedRevision , entry .Revision ) {
4157+ maxObservedRevision = entry .Revision
4158+ }
41124159 if tracked , ok := s .state .Files [remotePath ]; ok && tracked .Denied {
41134160 continue
41144161 }
@@ -4203,31 +4250,30 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
42034250 // cursor so the same page (including the failing path) is retried next
42044251 // cycle. Save state up to the last successfully-committed cursor.
42054252 if transientBootstrapAbort {
4206- if ! s .state .BootstrapComplete {
4207- s .state .BootstrapFilesSynced += filesThisPage
4208- if err := s .saveState (); err != nil {
4209- return err
4210- }
4253+ if err := persistTraversal (filesThisPage ); err != nil {
4254+ return err
42114255 }
42124256 break
42134257 }
4214- if page .NextCursor == nil || * page .NextCursor == "" {
4215- metrics .traversalComplete = true
4216- break
4258+ if page .NextCursor != nil && * page .NextCursor != "" {
4259+ cursor = * page .NextCursor
4260+ if err := persistTraversal (filesThisPage ); err != nil {
4261+ return err
4262+ }
4263+ continue
42174264 }
4218- cursor = * page .NextCursor
4219- // Persist the resume point + progress so an interrupted bootstrap
4220- // (timeout, crash, watchdog cancel) picks up here next cycle
4221- // instead of restarting the whole tree.
4222- if ! s .state .BootstrapComplete {
4223- s .state .BootstrapCursor = cursor
4224- s .state .BootstrapFilesSynced += filesThisPage
4225- prog .touch ()
4226- if err := s .saveState (); err != nil {
4265+ // This directory is exhausted. Persist the remaining queue before
4266+ // requesting its next member so cancellation resumes at a directory
4267+ // boundary instead of restarting the root.
4268+ directories = directories [1 :]
4269+ cursor = ""
4270+ if len (directories ) > 0 {
4271+ if err := persistTraversal (filesThisPage ); err != nil {
42274272 return err
42284273 }
4229- prog . touch ()
4274+ continue
42304275 }
4276+ metrics .traversalComplete = true
42314277 }
42324278
42334279 // A transient read error stopped the page scan early. Bootstrap is not
@@ -4240,8 +4286,8 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
42404286
42414287 // Resumed/partial traversal safety: only run the authoritative
42424288 // snapshot delete pass when this process traversed the ENTIRE tree
4243- // (started from an empty cursor and reached NextCursor==nil ). If the
4244- // traversal began from a persisted resume cursor, remotePaths only
4289+ // (started from the root with an empty directory queue ). If the
4290+ // traversal began from a persisted directory/ cursor boundary , remotePaths only
42454291 // covers the tail of the tree, so deleting "everything not in
42464292 // remotePaths" would wipe the already-mirrored prefix — exactly the
42474293 // #164/#165 clobber failure mode. Skip the delete pass this cycle;
@@ -4275,14 +4321,15 @@ func (s *Syncer) pullRemoteFullTree(ctx context.Context, conflicted map[string]s
42754321}
42764322
42774323type fullTreeTraversalMetrics struct {
4278- startedAt time.Time
4279- listCalls int
4280- entriesSeen int
4281- filesSeen int
4282- directoriesSeen int
4283- bytesSeen int64
4284- runtimeEntriesSeen int
4285- traversalComplete bool
4324+ startedAt time.Time
4325+ listCalls int
4326+ entriesSeen int
4327+ filesSeen int
4328+ directoriesSeen int
4329+ bytesSeen int64
4330+ runtimeEntriesSeen int
4331+ runtimeSubtreesPruned int
4332+ traversalComplete bool
42864333}
42874334
42884335type bootstrapReadJob struct {
@@ -4409,6 +4456,7 @@ func bootstrapReadWorkers() int {
44094456// gate (Step 5) cannot be satisfied by a partial pull.
44104457func (s * Syncer ) markBootstrapComplete () {
44114458 s .state .BootstrapComplete = true
4459+ s .state .BootstrapDirectories = nil
44124460 s .state .BootstrapCursor = ""
44134461 s .state .BootstrapStartedAt = ""
44144462 s .state .BootstrapFilesSynced = 0
@@ -5496,19 +5544,52 @@ func (s *Syncer) skipMountRuntimeRemotePath(remotePath string, conflicted map[st
54965544 return false
54975545 }
54985546 s .logf ("skipping mount runtime path surfaced as workspace content: %s" , remotePath )
5499- if err := s .applyRemoteDelete (remotePath , conflicted ); err != nil {
5500- s .logf ("failed to clean local mount runtime path %s: %v" , remotePath , err )
5547+ prefix := strings .TrimSuffix (remotePath , "/" ) + "/"
5548+ for trackedPath := range s .state .Files {
5549+ if trackedPath != remotePath && ! strings .HasPrefix (trackedPath , prefix ) {
5550+ continue
5551+ }
5552+ delete (s .state .Files , trackedPath )
5553+ s .clearIncrementalReadNotReady (trackedPath )
5554+ if conflicted != nil {
5555+ delete (conflicted , trackedPath )
5556+ }
5557+ }
5558+ for notReadyPath := range s .state .IncrementalReadNotReadySince {
5559+ if notReadyPath == remotePath || strings .HasPrefix (notReadyPath , prefix ) {
5560+ s .clearIncrementalReadNotReady (notReadyPath )
5561+ }
55015562 }
55025563 if localPath , err := s .remoteToLocalPath (remotePath ); err == nil {
5503- if removeErr := os .Remove (localPath ); removeErr != nil && ! errors .Is (removeErr , os .ErrNotExist ) {
5504- s .logf ("failed to remove local mount runtime file %s (%s): %v" , remotePath , localPath , removeErr )
5564+ if s .isActiveMountRuntimeLocalPath (localPath ) {
5565+ // A leaked remote /.relay entry maps onto this daemon's own public
5566+ // runtime directory. Reject the remote entry, but never delete the
5567+ // live local state/outbox while doing so.
5568+ } else if err := s .assertNotMountRoot (localPath ); err != nil {
5569+ s .logf ("failed to clean local mount runtime path %s: %v" , remotePath , err )
5570+ } else if removeErr := os .RemoveAll (localPath ); removeErr != nil {
5571+ s .logf ("failed to remove local mount runtime subtree %s (%s): %v" , remotePath , localPath , removeErr )
55055572 }
55065573 }
5507- delete (s .state .Files , remotePath )
55085574 s .clearIncrementalReadNotReady (remotePath )
55095575 return true
55105576}
55115577
5578+ func (s * Syncer ) isActiveMountRuntimeLocalPath (localPath string ) bool {
5579+ localPath = filepath .Clean (localPath )
5580+ publicRuntimeRoot := filepath .Join (filepath .Clean (s .localRoot ), ".relay" )
5581+ if relative , err := filepath .Rel (publicRuntimeRoot , localPath ); err == nil &&
5582+ (relative == "." || (relative != ".." && ! strings .HasPrefix (relative , ".." + string (filepath .Separator )))) {
5583+ return true
5584+ }
5585+ stateFile := filepath .Clean (s .stateFile )
5586+ if localPath == stateFile {
5587+ return true
5588+ }
5589+ return filepath .Dir (localPath ) == filepath .Dir (stateFile ) &&
5590+ strings .HasPrefix (filepath .Base (localPath ), "." + filepath .Base (stateFile )+ ".tmp-" )
5591+ }
5592+
55125593func (s * Syncer ) applyLocalPermissions (localPath string , canWrite bool ) error {
55135594 if canWrite {
55145595 return os .Chmod (localPath , 0o644 )
@@ -6202,6 +6283,7 @@ func (s *Syncer) loadState() error {
62026283 if s .state .BootstrapComplete && s .state .SyncMode == "write-only" && ! s .writeOnly {
62036284 s .logf ("syncMode transition write-only->mirror detected; resetting BootstrapComplete to force a full bootstrap pull (backfills records missed while write-only)" )
62046285 s .state .BootstrapComplete = false
6286+ s .state .BootstrapDirectories = nil
62056287 s .state .BootstrapCursor = ""
62066288 s .state .BootstrapStartedAt = ""
62076289 s .state .BootstrapFilesSynced = 0
0 commit comments