Fix macOS File Provider Synchronization Drops - #10734
Conversation
A folder created on the Mac is, from the framework's point of view, already fully enumerated: it knows the empty listing it just created and will not ask for the contents again. The refresh subscription a browsed folder gets must therefore be granted here too — `visitedDirectory` is what puts a directory into the materialised set the working-set scan reads, and `downloaded` is ignored for directories there. Without it a locally created folder is never PROPFINDed by the scan, so items added to it on the server (web UI, public upload link, another user) never surface, and no later `enumerateItems` exists to repair that. Resolves: nextcloud#9688 Signed-off-by: Julius van der Vaart <julius@vanderva.art> (cherry picked from commit 5664f74)
Covers the path a notify_push or root-ETag signal actually drives: `enumerateChanges(.workingSet)` -> `scanMaterialisedItemsForRemoteChanges()` -> `pendingWorkingSetChanges(since:)` -> the change observer. Each test mutates the mock server and asserts the change reaches `MockChangeObserver`. Three reproduced real silent drops in the enumerator and database change derivation, and now guard them: the scan returning the changes it discovers rather than relying on the lossy syncTime reconstruction, recursion into changed subdirectories, and `size` participating in the change-detection predicate. Signed-off-by: Julius van der Vaart <julius@vanderva.art> (cherry picked from commit f97a2d8)
Trashing rewrites an item's `serverUrl` to the trashbin but leaves `deleted` false, and a folder keeps `visitedDirectory`, so a trashed folder stayed in the materialised set and the scan PROPFINDed it through the ordinary DAV path. That 404s, and the scan reads a 404 as "the item is gone": it reported the item deleted and hard-removed the very row the trash reconciliation derives permanent deletions from. Trash has its own enumeration path via `listingTrashAsync`. The regression test fails on both counts without the fix — it observes the PROPFIND to /remote.php/dav/trashbin/... and the row's destruction. Signed-off-by: Julius van der Vaart <julius@vanderva.art> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-by: Claude Code:claude-opus-5 (cherry picked from commit acf6bb5)
…tree Pinning walks every descendant and asks the framework to download each one, but those calls address the framework's own item store, which only knows what enumeration has handed it. Every descendant the user has never browsed answers `noSuchItem`: 15,229 of 15,849 on one pinned tree, each logged as an error and together about half the log volume. Nothing is lost by skipping them — the database flag is written first, so `contentPolicy` reports `.downloadEagerlyAndKeepDownloaded` and the framework acts on it the moment it first enumerates the item. The signal only brings that forward for descendants already being tracked. Genuine failures still log as errors. Signed-off-by: Julius van der Vaart <julius@vanderva.art> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-by: Claude Code:claude-opus-5 (cherry picked from commit a2216f7)
|
Artifact containing the AppImage: nextcloud-appimage-pr-10734.zip Digest: To test this change/fix you can download the above artifact file, unzip it, and run it. Please make sure to quit your existing Nextcloud app and backup your data. |
| let account = await ext.awaitAccount(timeoutNanoseconds: 5_000_000_000) | ||
|
|
||
| XCTAssertEqual(account, Self.account) | ||
| XCTAssertLessThan(ContinuousClock().now - started, .milliseconds(500)) |
There was a problem hiding this comment.
This is the only thing I consider worth changing. Waiting for a fixed time will result in flaky tests because hosted GitHub action runners sometimes are horribly slow. I fought this problem so often already. Changing the pattern of awaitAccount() to have a return value or throw an error likely mitigates the issue.
Implementing tests with Swift Testing would enable @Test(.timeLimit(.seconds(5)). Otherwise waitForExpectations(timeout:handler:) in XCTest.
There was a problem hiding this comment.
Hi @i2h3,
According to Claude, removing the timing assertion was the right call (sorry, Swift is beyond me, but i understand the logic).
Summary:
Rather than loosening the bound I took your suggestion about the return value.
awaitAccount now throws NSFileProviderError(.notAuthenticated) on timeout
instead of answering nil. Returning an optional conflated "no account arrived"
with "the account was already there", which is why a clock was the only way to
tell the two apart. Now the outcome carries that information, so the test calls
it with timeoutNanoseconds: 0: the fast path is the only way to get an account
back, since parking would throw. No elapsed time involved.
I also moved the file to Swift Testing, as you suggested — #expect(throws:) is
much nicer than the XCTest dance for an async throwing call.
15 consecutive runs pass, and 5 more with 12 CPU-saturating processes running
alongside. More to the point, there's no upper-bound time assertion left in the
file at all, so that failure mode isn't just less likely, it's gone.
Two small notes, neither of them disagreements:
The timeout test still checks that at least 300 ms elapsed. That one is a lower
bound, so a slow runner can only overshoot it — and it's the only thing left
distinguishing "waited and gave up" from "failed instantly". Happy to drop it if
you'd rather have no clock in there whatsoever.
On @Test(.timeLimit(...)) — I tried it, and Swift Testing only accepts minutes:
'seconds' is unavailable: Time limit must be specified in minutes. I think that
restriction is deliberate, since a time limit is really a backstop against hangs
rather than a promptness assertion. It also wouldn't have caught this particular
bug: a broken fast path would have waited the full 5 s and still finished well
inside any limit. Useful to know for the flaky tests you mentioned elsewhere,
though.
The 200 ms sleeps in the park-and-release tests I've left as they are. They only
order the two halves and pass either way round — if the sleep is too short the
account is published first, the fast path runs, and the same assertion holds.
Same pattern as RetrievedCapabilitiesActorTests. Shout if you'd still prefer
them gone and I'll make the parking observable instead.
Sorry about the lost line anchor — I amended rather than adding a fixup commit
to keep the history clean.
…equests The system starts the extension process and begins asking it for work before the main app has handed the account across. `fetchContents` and `item(for:)` answered `notAuthenticated` outright, and the framework treated those downloads as failed: on one relaunch 17 fetches arrived in the first 0.6 seconds, 1.7 seconds before the account landed, and most were never re-requested. Both now wait for the account, which turns the race into a short delay because it almost always arrives moments later. A genuinely account-less domain still fails, just after the timeout. `createItem` / `modifyItem` / `deleteItem` are left alone — they are user-initiated, and nobody is editing files before the app has loaded — and `enumerator(for:)` self-heals through `signalEnumeratorAfterAccountSetup`. `awaitAccount` throws on timeout rather than answering `nil`, so its outcome says which path it took. Answering `nil` conflated "no account arrived" with "the account was already there", and the difference was then only observable by timing the call — which is what made the test asserting it flaky on slow CI runners. With a throwing timeout, a call given no time to wait can only return an account by taking the fast path. Signed-off-by: Julius van der Vaart <julius@vanderva.art> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-by: Claude Code:claude-opus-5
629b5c5 to
7f68861
Compare
Summary
First of eight PRs splitting #10728 into self-contained changes, as requested in review.
This one is correctness only — five fixes for cases where remote changes silently never
reach the Mac, plus the log noise that made those cases hard to diagnose.
No performance claims here, so there is nothing to benchmark. The figures quoted below
are observations from live runs that motivated each fix, not measurements of an improvement.
The benchmark harness the later PRs use is #.
What is fixed
Locally created directories are never scanned — a folder created on the Mac never got
visitedDirectory, which is what puts a directory into the materialised set the working-setscan reads. Items added to it on the server (web UI, public upload link, another user) never
surfaced, and no later
enumerateItemsexisted to repair it.Resolves #9688.
Trashed rows break the working-set scan — trashing rewrites an item's
serverUrlto thetrashbin but leaves
deletedfalse, and a folder keepsvisitedDirectory. The scan thenPROPFINDed the trashed folder through the ordinary DAV path, which 404s, and read that 404 as
"the item is gone" — reporting it deleted and hard-removing the row the trash reconciliation
derives permanent deletions from. Trash has its own path via
listingTrashAsync.Requests arriving before the account does — the system starts the extension and asks it
for work before the main app has handed the account across.
fetchContentsanditem(for:)answered
notAuthenticatedoutright and the framework treated those downloads as failed. Onone relaunch, 17 fetches arrived in the first 0.6 s, 1.7 s before the account landed, and most
were never re-requested. Both now wait for the account, turning the race into a short delay.
createItem/modifyItem/deleteItemare deliberately left alone — they areuser-initiated — and
enumerator(for:)already self-heals.Expected
noSuchItemlogged as an error — pinning a subtree asks the framework to downloadevery descendant, but that addresses the framework's own item store, which only knows what
enumeration has handed it. Every never-browsed descendant answers
noSuchItem: 15,229 of15,849 on one pinned tree, roughly half the log volume. Nothing is lost by skipping them, since
the database flag is written first and
contentPolicyreports.downloadEagerlyAndKeepDownloadedwhen the item is first enumerated. Genuine failures stilllog as errors.
Regression tests for the path a
notify_pushor root-ETag signal actually drives:enumerateChanges(.workingSet)→scanMaterialisedItemsForRemoteChanges()→pendingWorkingSetChanges(since:)→ the change observer. Three of them reproduced real silentdrops before the fixes and now guard them.
Verification
stable-34.0)TODO
Enumerator+WorkingSetScan.swiftis touched by bothChecklist
stable-34.0