Skip to content

Fix macOS File Provider Synchronization Drops - #10734

Open
juliusvaart wants to merge 5 commits into
nextcloud:stable-34.0from
juliusvaart:macos/vf/fix-sync-drops
Open

Fix macOS File Provider Synchronization Drops#10734
juliusvaart wants to merge 5 commits into
nextcloud:stable-34.0from
juliusvaart:macos/vf/fix-sync-drops

Conversation

@juliusvaart

Copy link
Copy Markdown

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-set
scan reads. Items added to it on the server (web UI, public upload link, another user) never
surfaced, and no later enumerateItems existed to repair it.
Resolves #9688.

Trashed rows break the working-set scan — trashing rewrites an item's serverUrl to the
trashbin but leaves deleted false, and a folder keeps visitedDirectory. The scan then
PROPFINDed 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. 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 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 / deleteItem are deliberately left alone — they are
user-initiated — and enumerator(for:) already self-heals.

Expected noSuchItem logged as an error — pinning a subtree asks the framework to download
every 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 of
15,849 on one pinned tree, roughly half the log volume. Nothing is lost by skipping them, since
the database flag is written first and contentPolicy reports
.downloadEagerlyAndKeepDownloaded when the item is first enumerated. Genuine failures still
log as errors.

Regression tests for the path a notify_push or root-ETag signal actually drives:
enumerateChanges(.workingSet)scanMaterialisedItemsForRemoteChanges()
pendingWorkingSetChanges(since:) → the change observer. Three of them reproduced real silent
drops before the fixes and now guard them.

Verification

  • 357 tests pass, 7 of them new (350 on stable-34.0)
  • Every commit builds and tests standalone, so the history bisects
  • Each fix has a test that fails without it

TODO

Checklist

  • Sign-off message is added to all commits.
  • The commit messages are following The Conventional Commits specification.
  • The commit history is clean with no merge commits.
  • Uploaded screenshots from before and after for UI changes. — n/a, no UI changes
  • Test(s) added to branch, with before/after results included in PR description, for performance improvements where applicable. — tests added; no performance claims in this PR
  • Documentation has been updated or is not required. — not required
  • Backports requested where applicable (critical bugfixes). — n/a, targets stable-34.0
  • Labels added where applicable (bug/enhancement). — needs maintainer
  • Milestone added for target version. — needs maintainer

juliusvaart and others added 4 commits September 3, 2026 13:12
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)
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Artifact containing the AppImage: nextcloud-appimage-pr-10734.zip

Digest: sha256:63356841deb6323528368223b3088d1b6fff3b81ca93fe00e5b447de37a1b6c5

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.

@Rello Rello added os: 🍎 macOS Apple macOS, formerly also known as OS X feature: 📁 file provider macOS File Provider Extension, more general also known as virtual file system. labels Sep 4, 2026
@i2h3 i2h3 changed the title Macos/vf/fix sync drops File Provider Synchronization Drops Sep 4, 2026
@i2h3 i2h3 changed the title File Provider Synchronization Drops Fix macOS File Provider Synchronization Drops Sep 4, 2026

@i2h3 i2h3 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me overall.

let account = await ext.awaitAccount(timeoutNanoseconds: 5_000_000_000)

XCTAssertEqual(account, Self.account)
XCTAssertLessThan(ContinuousClock().now - started, .milliseconds(500))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will review next week.

@i2h3 i2h3 added this to the 34.0.4 milestone Sep 4, 2026
@i2h3 i2h3 self-assigned this Sep 4, 2026
@github-project-automation github-project-automation Bot moved this to 🧭 Planning evaluation (don't pick) in 💻 Desktop Clients team Sep 4, 2026
@i2h3 i2h3 moved this from 🧭 Planning evaluation (don't pick) to 🏗️ In progress in 💻 Desktop Clients team Sep 4, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: 📁 file provider macOS File Provider Extension, more general also known as virtual file system. os: 🍎 macOS Apple macOS, formerly also known as OS X

Projects

Status: 🏗️ In progress

Development

Successfully merging this pull request may close these issues.

3 participants