Skip to content

Trash Exclusion from Working Set Updates - #10752

Open
juliusvaart wants to merge 5 commits into
nextcloud:masterfrom
juliusvaart:macos/vf/trash-scan-exclusion
Open

Trash Exclusion from Working Set Updates#10752
juliusvaart wants to merge 5 commits into
nextcloud:masterfrom
juliusvaart:macos/vf/trash-scan-exclusion

Conversation

@juliusvaart

Copy link
Copy Markdown
Contributor

Summary

A folder in the trash was still being scanned as if it were a live folder, and
the 404 that produced destroyed the database row that trash restore and
permanent-deletion reconciliation depend on.

Trashing rewrites an item's serverUrl to the trashbin path but leaves
deleted false, and a folder keeps its visitedDirectory flag. Both are what
put an item in the materialised set, so a trashed folder stayed in it and the
working-set scan PROPFINDed it through the ordinary DAV path. That path 404s for
something that now lives in the trashbin — and the scan reads a 404 as "the item
is gone". It reported the item deleted and hard-removed the row, which is the
same row the trash reconciliation derives permanent deletions from. Trash has
its own enumeration path via listingTrashAsync and does not need the scan to
visit it.

The fix excludes trashed rows from the scan's seed set.

Changes

  1. Add remote-change propagation regression tests — 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 of
    them reproduce silent drops that were real: the scan relying on the lossy
    syncTime reconstruction instead of returning what it discovered, recursion
    into changed subdirectories, and size participating in the change-detection
    predicate.
  2. Keep trashed rows out of the working-set scan — filter them from the
    materialised set the scan walks.

Testing

Full suite green at 359 XCTest + 59 Swift Testing; the first commit is green
standalone at 358 + 59.

testTrashedFolderIsNotScannedThroughTheRegularDavPath fails on both counts
without the fix: it observes the PROPFIND going to
/remote.php/dav/trashbin/… and it observes the row being destroyed.

Depends on #

This branch is stacked on the directory-visits PR and its diff here includes
those three commits until that one merges.

The dependency is real, not just packaging.
testItemCreatedOnServerInLocallyCreatedFolderIsReported fails on stable-34.0
without "mark locally created directories as visited", and the trashed-rows
change does not apply to the test file without the tests commit that precedes
it. Happy to rebase and repush once the other one lands.

Checklist

AI (if applicable)

@i2h3

i2h3 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Please ensure this pull request is based on master (unless it really is just meant to be integrated into the specific major release branch and not any future releases as well) and opened to be integrated into master.

@juliusvaart
juliusvaart force-pushed the macos/vf/trash-scan-exclusion branch from 35d1c9b to 50b6d78 Compare September 7, 2026 13:39
@juliusvaart juliusvaart closed this Sep 7, 2026
@juliusvaart juliusvaart reopened this Sep 7, 2026
@juliusvaart
juliusvaart changed the base branch from stable-34.0 to master September 7, 2026 13:51
@juliusvaart

Copy link
Copy Markdown
Contributor Author

Now based on master

@juliusvaart
juliusvaart force-pushed the macos/vf/trash-scan-exclusion branch from 50b6d78 to e19820f Compare September 8, 2026 06:29
juliusvaart and others added 5 commits September 8, 2026 08:45
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>
Assisted-by: Claude Code:claude-opus-5
Browsing into a folder that had never been enumerated showed an empty view and a
spinner that never resolved. Finder's status bar read "0 items" for a folder
holding three subfolders on the server, and the framework never scheduled a
fetch for its children -- it logged a new enumerator and then asked it for
nothing at all, not even a sync anchor, while the extension sat idle.

`childItemCount` derived the number from the local database, which holds a
directory's children only once something has read that directory. For one nobody
had read it therefore returned 0. But `NSFileProviderItem.childItemCount` is
optional precisely so a provider can say it does not know, and 0 is not "I do
not know", it is "this folder is empty". Finder printed that verbatim, and the
framework, told the container had no children, had no reason to ask for any.

Report nil whenever the local set is empty. There is no flag available to
distinguish a genuinely empty directory from an unread one -- `visitedDirectory`
is still false after a paginated enumeration, which is the path every server from
Nextcloud 31 takes -- so an empty local set cannot support the stronger claim.
The cost is one enumeration of a truly empty folder, which the framework performs
on first browse anyway. The cost of the previous answer was the folder never
being enumerated at all.

`delete` had been reading nil as "empty or a file", which was safe only while
nil meant a file. It now asks whether the item is a directory, so a directory
whose contents are unknown refuses a non-recursive delete rather than assuming
it has nothing to lose.

Two existing tests asserted the old 0. Their expectations move to nil, with the
reason recorded at the assertion.

Signed-off-by: Julius van der Vaart <julius@vanderva.art>
Assisted-by: Claude Code:claude-opus-5
`visitedDirectory` marks a directory as actually read. Two things depend on it:
membership of the working set the remote-change scan walks, and whether an empty
child count means "this folder is empty" or "nobody has looked".

The flag is local-only, so it takes no part in
`isInSameDatabaseStoreableRemoteState`. The depth-1 ingestion decided whether to
persist its read target purely on that comparison, so reading a directory whose
remote state had not changed persisted nothing and dropped the visit. That is not
an edge case: a directory's etag does not change because somebody opened it, so
the ordinary browse recorded nothing. The paginated ingestion path already
handled this -- `addItemMetadataPreservingLocalState` compares the flag
explicitly -- so only the non-paginated path was affected.

Record the visit separately rather than by adding the row to `metadatasToUpdate`.
That set is also the change set handed to the framework, and announcing a
directory as changed every time it is first browsed would re-queue its
update-item job for a flag the framework cannot see.

Signed-off-by: Julius van der Vaart <julius@vanderva.art>
Assisted-by: Claude Code:claude-opus-5
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>
Assisted-by: Claude Code:claude-opus-5
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
@juliusvaart
juliusvaart force-pushed the macos/vf/trash-scan-exclusion branch from e19820f to a908860 Compare September 8, 2026 06:49
@i2h3

i2h3 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

If I read correctly, this depends on #10751. So let's wait until that is integrated first.

@i2h3 i2h3 changed the title Macos/vf/trash scan exclusion Trash Exclusion from Working Set Updates Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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

Digest: sha256:eae441b8d01319322a13e3342c1d3e5476f42e2848388feca1519d316323754b

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants