diff --git a/internal/mountsync/syncer.go b/internal/mountsync/syncer.go index 56b928c0..3124dba0 100644 --- a/internal/mountsync/syncer.go +++ b/internal/mountsync/syncer.go @@ -62,7 +62,7 @@ var sensitiveLogQueryValue = regexp.MustCompile(`(?i)([?&](?:token|access_token| // version instead of treating the failure as a fatal sync error. var ErrSchemaValidation = errors.New("schema validation failed") -// ErrMalformedPagination is returned when an events feed reports a non-empty +// ErrMalformedPagination is returned when a paginated feed reports a non-empty // cursor that does not advance pagination. The mount fails closed instead of // retrying the same page until its reconcile context expires. var ErrMalformedPagination = errors.New("malformed pagination") @@ -335,8 +335,8 @@ func (e *HTTPError) Error() string { return fmt.Sprintf("http %d: %s", e.StatusCode, e.Message) } -// MalformedPaginationError reports a server response that cannot make an -// events pagination walk advance. Cursor values are included so operators can +// MalformedPaginationError reports a server response that cannot make a +// pagination walk advance. Cursor values are included so operators can // identify the broken response and repair the server or upgrade the backend. type MalformedPaginationError struct { Feed string @@ -6954,8 +6954,10 @@ type githubTreeFile struct { func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapProgress, strictComplete bool) (map[string]githubTreeFile, string, error) { files := map[string]githubTreeFile{} cursor := "" + seenCursors := make(map[string]struct{}) maxObservedRevision := "" for { + pageStartCursor := cursor page, err := s.client.ListTree(ctx, s.workspace, s.githubWorkingTree.ContentsRoot, 200, cursor) if err != nil { return nil, "", err @@ -6994,10 +6996,27 @@ func (s *Syncer) githubWorkingTreeSnapshot(ctx context.Context, prog bootstrapPr Mode: entry.Mode, } } - if page.NextCursor == nil || strings.TrimSpace(*page.NextCursor) == "" { + nextCursor := "" + if page.NextCursor != nil { + nextCursor = strings.TrimSpace(*page.NextCursor) + } + if nextCursor == "" { break } - cursor = strings.TrimSpace(*page.NextCursor) + reason := "next cursor did not advance" + if nextCursor != pageStartCursor { + reason = "next cursor repeated a previous page" + } + if _, seen := seenCursors[nextCursor]; seen || nextCursor == pageStartCursor { + return nil, "", &MalformedPaginationError{ + Feed: "github working-tree", + Cursor: pageStartCursor, + NextCursor: nextCursor, + Reason: reason, + } + } + seenCursors[nextCursor] = struct{}{} + cursor = nextCursor } return files, maxObservedRevision, nil } diff --git a/internal/mountsync/syncer_test.go b/internal/mountsync/syncer_test.go index 184a53fd..8242967d 100644 --- a/internal/mountsync/syncer_test.go +++ b/internal/mountsync/syncer_test.go @@ -3023,6 +3023,103 @@ func TestGithubWorkingTreeSnapshotRejectsUnsupportedCompleteEntry(t *testing.T) } } +type repeatedGithubTreeCursorClient struct { + *fakeClient + requestedCursors []string +} + +func (c *repeatedGithubTreeCursorClient) ListTree(_ context.Context, _, _ string, _ int, cursor string) (TreeResponse, error) { + c.requestedCursors = append(c.requestedCursors, cursor) + next := "page-2" + return TreeResponse{NextCursor: &next}, nil +} + +func TestGithubWorkingTreeSnapshotRejectsRepeatedCursor(t *testing.T) { + localDir := t.TempDir() + client := &repeatedGithubTreeCursorClient{fakeClient: &fakeClient{}} + syncer, err := NewSyncer(client, SyncerOptions{ + WorkspaceID: "ws_repeated_github_cursor", RemoteRoot: "/github/repos/o/r/contents", + LocalRoot: localDir, StateFile: filepath.Join(localDir, ".state.json"), WebSocket: boolPtr(false), + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + + _, _, err = syncer.githubWorkingTreeSnapshot(context.Background(), bootstrapProgress{}, true) + var paginationErr *MalformedPaginationError + if !errors.As(err, &paginationErr) { + t.Fatalf("error = %v, want MalformedPaginationError", err) + } + if !errors.Is(err, ErrMalformedPagination) { + t.Fatalf("error = %v, want ErrMalformedPagination", err) + } + if paginationErr.Feed != "github working-tree" || paginationErr.Cursor != "page-2" || paginationErr.NextCursor != "page-2" { + t.Fatalf("unexpected pagination error: %#v", paginationErr) + } + if got, want := client.requestedCursors, []string{"", "page-2"}; !slices.Equal(got, want) { + t.Fatalf("ListTree cursors = %#v, want %#v", got, want) + } +} + +type repeatedGithubSnapshotCursorClient struct { + *fakeExportClient + snapshotCursors []string +} + +func (c *repeatedGithubSnapshotCursorClient) ListTree(ctx context.Context, workspaceID, path string, depth int, cursor string) (TreeResponse, error) { + if depth == 200 { + c.snapshotCursors = append(c.snapshotCursors, cursor) + next := "page-2" + return TreeResponse{Path: path, NextCursor: &next}, nil + } + return c.fakeClient.ListTree(ctx, workspaceID, path, depth, cursor) +} + +func TestReconcileFallsBackToBoundedTreeWhenGithubSnapshotCursorRepeats(t *testing.T) { + localDir := t.TempDir() + contentsRoot := "/github/repos/AgentWorkforce/cloud/contents" + headSHA := "head123" + readme := []byte("# Cloud\n") + readmeRemote := contentsRoot + "/README.md@" + headSHA + ".json" + sentinelPath := "/github/repos/AgentWorkforce/cloud/.relayfile/clone.json" + base := &fakeExportClient{fakeClient: &fakeClient{files: map[string]RemoteFile{ + sentinelPath: { + Path: sentinelPath, Revision: "rev_1", ContentType: "application/json", + Content: `{"headSha":"` + headSHA + `","defaultBranch":"main","sourceProfile":"complete-v1","filesExpected":1,"eventsCursor":"evt_1"}`, + }, + readmeRemote: { + Path: readmeRemote, Revision: "rev_2", ContentType: "application/json", + Content: string(readme), ContentHash: hashBytes(readme), + }, + }}} + client := &repeatedGithubSnapshotCursorClient{fakeExportClient: base} + syncer, err := NewSyncer(client, SyncerOptions{ + WorkspaceID: "ws_repeated_github_cursor_fallback", RemoteRoot: contentsRoot, + LocalRoot: localDir, StateFile: filepath.Join(localDir, ".state.json"), + WebSocket: boolPtr(false), FullPullEvery: -1, + }) + if err != nil { + t.Fatalf("NewSyncer failed: %v", err) + } + + if err := syncer.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile should fall back to bounded tree traversal: %v", err) + } + if got, want := client.snapshotCursors, []string{"", "page-2"}; !slices.Equal(got, want) { + t.Fatalf("snapshot ListTree cursors = %#v, want %#v", got, want) + } + if client.tarCalls != 0 { + t.Fatalf("tar export must not run after its verification snapshot fails, got %d calls", client.tarCalls) + } + if base.listTreeCalls == 0 { + t.Fatal("bounded tree fallback did not run") + } + if !syncer.state.BootstrapComplete { + t.Fatal("bounded tree fallback did not complete bootstrap") + } + assertLocalFileContent(t, filepath.Join(localDir, "README.md"), string(readme)) +} + func TestValidateSymlinkTargetAcceptsInRootSymlinkChain(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink creation requires developer mode or elevated privileges on Windows")