Skip to content

Commit 044f351

Browse files
authored
Merge pull request #90 from claucambra/bugfix/pagination-pt6
Implement mitigation for server returning empty pages
2 parents a869466 + 0622638 commit 044f351

5 files changed

Lines changed: 114 additions & 10 deletions

File tree

Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,9 @@ extension Enumerator {
461461
not much we can do...
462462
"""
463463
)
464-
return (nil, nil, nil, nil, nextPage, error)
464+
// This is technically possible when doing a paginated request with the index too high.
465+
// It's technically not an error reply.
466+
return ([], nil, nil, nil, nextPage, nil)
465467
}
466468

467469
// Generally speaking a PROPFIND will provide the target of the PROPFIND as the first result

Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,9 @@ public class Enumerator: NSObject, NSFileProviderEnumerator {
221221
var providedPage: NSFileProviderPage? = nil // Used for pagination token sent to server
222222
// Do not pass in the NSFileProviderPage default pages, these are not valid Nextcloud
223223
// pagination tokens
224-
var scanningChildFolder = false
225224
var nextServerUrls: [String]? = nil
226225
var pageIndex = 0
226+
var pageTotal: Int? = nil
227227
if page != NSFileProviderPage.initialPageSortedByName as NSFileProviderPage &&
228228
page != NSFileProviderPage.initialPageSortedByDate as NSFileProviderPage
229229
{
@@ -235,11 +235,13 @@ public class Enumerator: NSObject, NSFileProviderEnumerator {
235235
"Setting enumerator server URL to \(actualServerUrl, privacy: .public)"
236236
)
237237
serverUrl = actualServerUrl
238-
scanningChildFolder = true
239238
}
240239
if let token = enumPageResponse.token?.data(using: .utf8) {
241240
providedPage = NSFileProviderPage(token)
242241
}
242+
if let total = enumPageResponse.total {
243+
pageTotal = total
244+
}
243245
pageIndex = enumPageResponse.index
244246
nextServerUrls = enumPageResponse.serverUrlQueue
245247
} else {
@@ -290,6 +292,8 @@ public class Enumerator: NSObject, NSFileProviderEnumerator {
290292
return
291293
}
292294

295+
pageTotal = nextPage?.total ?? pageTotal
296+
293297
if enumeratedItemIdentifier == .workingSet {
294298
var serverUrlQueue = nextServerUrls ?? []
295299
for metadata in metadatas where metadata.directory {
@@ -304,16 +308,23 @@ public class Enumerator: NSObject, NSFileProviderEnumerator {
304308
\(serverUrlQueue, privacy: .public)
305309
next page is valid: \(nextPageValid, privacy: .public)
306310
next page token: \(nextPageToken, privacy: .public)
311+
page total: \(pageTotal ?? -1, privacy: .public)
307312
"""
308313
)
309314

310-
// If we are scanning a child folder now, add this information for the next call
311-
// to an enumerator
312-
if nextPage != nil, scanningChildFolder {
315+
if let rPage = nextPage, let pageTotal, rPage.index * pageItemCount >= pageTotal {
316+
// Server will sometimes provide a valid next page data even though there are no
317+
// items to enumerate anymore
318+
Self.logger.debug("No more items to enumerate, stopping paged enumeration")
319+
nextPage = nil
320+
}
321+
322+
if nextPage != nil {
313323
Self.logger.debug("Applying actual server url onto server page response")
314-
nextPage?.nextServerUrl = serverUrl
315-
nextPage?.serverUrlQueue = serverUrlQueue
316-
} else if nextPage == nil && !serverUrlQueue.isEmpty {
324+
nextPage!.total = pageTotal
325+
nextPage!.serverUrlQueue = serverUrlQueue
326+
nextPage!.nextServerUrl = serverUrl
327+
} else if !serverUrlQueue.isEmpty {
317328
// If we have finished paged enumeration of the current serverUrl, move to next
318329
// child to scan
319330
let nextServerUrl = serverUrlQueue.removeFirst()

Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ fileprivate let logger = Logger(subsystem: Logger.subsystem, category: "enumerat
1414
struct EnumeratorPageResponse: Sendable, Codable {
1515
let token: String? // Required by server to serve the next page of items
1616
let index: Int // Needed to calculate the offset for the next paginated request
17-
let total: Int? // Total item count, provided in the first non-offset paginated response
17+
var total: Int? // Total item count, provided in the first non-offset paginated response
1818
var serverUrlQueue: [String]?
1919
var nextServerUrl: String? = nil
2020

Tests/Interface/MockRemoteInterface.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,7 @@ public class MockRemoteInterface: RemoteInterface {
567567
public var completedChunkTransferSize: [String: Int64] = [:]
568568
public var pagination: Bool
569569
public var expectedEnumerationPaginationTokens: [String: String] = [:]
570+
public var forceNextPageOnLastContentPage: Bool = false
570571

571572
public init(
572573
rootItem: MockRemoteItem? = nil,
@@ -1081,6 +1082,10 @@ public class MockRemoteInterface: RemoteInterface {
10811082
{
10821083
return (account.ncKitAccount, [], nil, .invalidData)
10831084
}
1085+
guard !forceNextPageOnLastContentPage || firstItem < files.count else {
1086+
let responseData = generateResponse(itemCount: files.count, finalPage: true)
1087+
return (account.ncKitAccount, [], responseData, .success)
1088+
}
10841089
let reachedEnd = firstItem + itemCount >= files.count
10851090
let lastItem = min(firstItem + itemCount, files.count) - 1
10861091
assert(firstItem <= lastItem)

Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,92 @@ final class EnumeratorTests: XCTestCase {
547547
XCTAssertNil(Self.dbManager.itemMetadata(ocId: rootItem.identifier))
548548
}
549549

550+
func testWorkingSetEnumerationStopsOnExhaustedTotal() async throws {
551+
// This test verifies that enumeration correctly terminates when a faulty server
552+
// provides a `nextPage` token but all items (according to `pageTotal`) have been received.
553+
554+
// 1. Setup: A folder with 9 items which, when including itself in the propfind, is an exact
555+
// multiple of the page size.
556+
rootItem.children = []
557+
let folder = MockRemoteItem(
558+
identifier: "folder10",
559+
name: "folder10",
560+
remotePath: Self.account.davFilesUrl + "/folder10",
561+
directory: true,
562+
account: Self.account.ncKitAccount,
563+
username: Self.account.username,
564+
userId: Self.account.id,
565+
serverUrl: Self.account.serverUrl
566+
)
567+
folder.parent = rootItem
568+
rootItem.children.append(folder)
569+
570+
for i in 0..<9 {
571+
let childItem = MockRemoteItem(
572+
identifier: "item\(i)",
573+
name: "item\(i).txt",
574+
remotePath: folder.remotePath + "/item\(i).txt",
575+
account: Self.account.ncKitAccount,
576+
username: Self.account.username,
577+
userId: Self.account.id,
578+
serverUrl: Self.account.serverUrl
579+
)
580+
childItem.parent = folder
581+
folder.children.append(childItem)
582+
}
583+
584+
let db = Self.dbManager.ncDatabase()
585+
debugPrint(db)
586+
587+
let remoteInterface = MockRemoteInterface(rootItem: rootItem, pagination: true)
588+
let pageSize = 5
589+
590+
// This test requires a modification to `MockRemoteInterface` to simulate a faulty server
591+
remoteInterface.forceNextPageOnLastContentPage = true
592+
593+
// 2. Manually drive the pagination loop.
594+
var allEnumeratedItems: [NSFileProviderItem] = []
595+
var currentPage: NSFileProviderPage? =
596+
NSFileProviderPage.initialPageSortedByName as NSFileProviderPage
597+
var loopCount = 0
598+
599+
while let pageToEnumerate = currentPage {
600+
loopCount += 1
601+
currentPage = nil // Reset for the next iteration
602+
603+
// Create a NEW enumerator and observer for this single page request
604+
let enumerator = Enumerator(
605+
enumeratedItemIdentifier: .workingSet,
606+
account: Self.account,
607+
remoteInterface: remoteInterface,
608+
dbManager: Self.dbManager,
609+
pageSize: pageSize
610+
)
611+
let observer = MockEnumerationObserver(enumerator: enumerator)
612+
try await observer.enumerateItemsPage(page: pageToEnumerate)
613+
XCTAssertNil(observer.error)
614+
allEnumeratedItems += observer.items
615+
currentPage = observer.page
616+
}
617+
618+
// 3. Assert the results.
619+
// The loop should have run 3 times:
620+
// 1. For root
621+
// 2. For `folder10` page 1 (items 0-4).
622+
// 3. For `folder10` page 2 (items 5-9). The enumerator logic should see that all items
623+
// have been delivered and should not return the phantom page token, terminating the loop.
624+
XCTAssertNil(currentPage, "Loop should have terminated with a nil next page.")
625+
XCTAssertEqual(loopCount, 3, "The enumeration loop should have terminated correctly.")
626+
627+
// Total items expected: 1 folder + 9 children = 10.
628+
let expectedTotalItems = 1 + 9
629+
XCTAssertEqual(
630+
allEnumeratedItems.count,
631+
expectedTotalItems,
632+
"The correct number of total items should be enumerated."
633+
)
634+
}
635+
550636
func testReadServerUrlFollowUpPagination() async throws {
551637
// 1. Arrange: Setup a folder with enough children to require multiple pages.
552638
remoteFolder.children = []

0 commit comments

Comments
 (0)