Skip to content

Invalidate the discovery state when hidden files are enabled - #1029

Open
junkerderprovinz wants to merge 9 commits into
opencloud-eu:mainfrom
junkerderprovinz:fix/hidden-files-not-synced-on-enable
Open

Invalidate the discovery state when hidden files are enabled#1029
junkerderprovinz wants to merge 9 commits into
opencloud-eu:mainfrom
junkerderprovinz:fix/hidden-files-not-synced-on-enable

Conversation

@junkerderprovinz

@junkerderprovinz junkerderprovinz commented Aug 17, 2026

Copy link
Copy Markdown

Summary

Fixes #714. With "sync hidden files" disabled, creating .hello/Text File.txt is correctly skipped. Enabling the setting afterwards does nothing: the folder is not synced for 10+ minutes, and editing the hidden file does not trigger a sync either. Only a forced "Sync now" or a restart of the client helps.

Root cause

FolderMan::setIgnoreHiddenFiles() writes the new value into every FolderDefinition and saves the config. Nothing else happens. The state that was built up while the hidden items were ignored is never invalidated: hidden items were never written to the local database, so an incremental local discovery (LocalDiscoveryStyle::DatabaseAndFilesystem, the normal case when the watcher is reliable) cannot find them. The next full local discovery only happens once fullLocalDiscoveryInterval expires, which defaults to one hour, hence "wait 10 minutes, nothing happens". On the remote side the hidden entries were skipped in ProcessDirectoryJob without changing any parent etag, so remote discovery skips those directories as unchanged.

There is an existing precedent for exactly this in IgnoreListEditor::slotUpdateLocalIgnoreList(), which after an ignore list change calls forceRemoteDiscoveryNextSync(), reloadExcludes(), slotNextSyncFullLocalDiscovery() and enqueues the folder ("We need to force a remote discovery after a change of the ignore list"). The hidden files toggle changes the effective exclude set in exactly the same way but never got the same treatment.

Fix

Folder::setIgnoreHiddenFiles() becomes a no-op when the value did not change, and otherwise, when hidden files start to be synced, calls SyncJournalDb::forceRemoteDiscoveryNextSync() and slotNextSyncFullLocalDiscovery(), mirroring the ignore list editor.

FolderMan::setIgnoreHiddenFiles() enqueues the folders whose value actually changed, so the items that were hidden so far are synced right away instead of on the next scheduled run.

Disabling the setting deliberately does not force a re-discovery: nothing new has to be found, and invalidating all folder etags would cost a full PROPFIND of the tree for no benefit.

Two earlier versions of this fix did more and were dropped after review (thanks @TheOneRing). One called FolderWatcher::init() to re-register watches on previously skipped directories: FolderWatcherPrivate has no destructor on Linux, so re-initialising an existing watcher leaks the old inotify fd and every watch on it. The other pushed the new value into the sync engine from this setter: the engine reads it at sync start (SyncEngine::_ignore_hidden_files in startSync()), and the watcher only triggers syncs rather than affecting a running one, so the push bought nothing that the forced full local discovery does not already cover.

Testing

Two tests:

  • TestFolderMan::testSetIgnoreHiddenFiles in test/testfolderman.cpp builds a real Folder through FolderMan::addFolder() (same pattern as the other tests in that file), flips the setting through FolderMan, and asserts that a stored directory record's etag was invalidated to _invalid_.
  • TestLocalDiscovery::testHiddenFilesNeedFullLocalDiscovery in test/testlocaldiscovery.cpp reproduces the discovery half at FakeFolder level: a hidden folder created while hidden files are ignored stays unsynced across a following incremental sync even after the flag is flipped, and only shows up once a full local discovery runs. It complements the existing testDiscoveryHiddenFile, which covers the remote side with an explicit forceRemoteDiscoveryNextSync().

The end-to-end path (settings toggle -> watcher -> scheduled sync against a server) is not reachable from the current harness: FakeFolder wraps a SyncEngine without a gui Folder, and the gui tests have no fake server. The two tests above cover each half.

I wasn't able to build/run this locally (no Qt/CMake toolchain available in my environment), so I am relying on CI to validate compilation and the new tests.


Toggling "sync hidden files" only wrote the new value into the folder
definition. The sync engine keeps its own copy of the flag that is
refreshed when a sync starts, and the state that was built up while the
hidden items were ignored was never invalidated:

* The folder watcher asks the sync engine whether a changed path is
  excluded, so changes to hidden files kept being discarded until a sync
  run started. On Linux the watcher also registers one inotify watch per
  directory and skipped the hidden ones, so changes below them were not
  reported at all until the client was restarted.
* Hidden items are not in the local database, so an incremental local
  discovery cannot see them. The next full local discovery only happens
  after fullLocalDiscoveryInterval, one hour by default.
* Hidden items on the server were skipped without changing the parent
  etags, so remote discovery would not look at them again either.

Folder::setIgnoreHiddenFiles() now updates the sync engine right away
and, when hidden files start to be synced, forces a full local and
remote re-discovery and re-initialises the folder watcher, the same way
a change of the ignore list already does. FolderMan enqueues the
affected folders so the items that were hidden so far are picked up
without waiting for the next scheduled run.

Fixes opencloud-eu#714
@TheOneRing

Copy link
Copy Markdown
Contributor

I mean it's obvious from the number of comments and the length of issue description, but please disclose extensive use of ai in the future.

Comment thread src/gui/folder.cpp Outdated
Comment on lines +337 to +346
if (!_engine) {
// the folder failed to set up, there is no state to invalidate
return;
}

// The engine caches the flag and is otherwise only updated when a sync starts. The folder
// watcher asks the engine whether a path is excluded, so without updating it right here
// changes to hidden files would keep being discarded.
_engine->setIgnoreHiddenFiles(ignore);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not needed, the value is updated before a sync starts see:

_engine->setIgnoreHiddenFiles(_definition.ignoreHiddenFiles);

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.

Right for the sync path itself, since startSync() already refreshes this from _definition every run. Kept it though: the folder watcher reads the engine's cached flag live, any time, not just during a sync, so a file change between the toggle and the next sync needs this pushed immediately or the watcher still filters it out with the stale value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But changing the value here has no effect, as it's not propagated. If you insist that it is so essential to change the value immediately, I'd recommend that you restart the sync instead of just scheduling a new sync.

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.

It is propagated, just by live read instead of by push, and the readers sit outside the sync path.

SyncEngine::ignoreHiddenFiles() is a plain member read. SyncEngine::isExcluded() calls it on every invocation and passes the result straight into ExcludedFiles::isExcluded(), which takes excludeHidden as a per-call parameter and does not memoise it. So Folder::isFileExcludedAbsolute(), and with it FolderWatcher::pathIsIgnored(), sees the new value on the very next notification, with no sync involved.

Three readers use it that way:

1. FolderWatcher::addChanges() filters when the OS reports the change, not when a sync pops it:

if (pathIsIgnored(*it)) {
    it = paths.erase(it);
}
...
if (!paths.isEmpty()) {
    _changeSet.unite(paths);
    if (!_timer.isActive()) {
        _timer.start();
        Q_EMIT changesDetected();
    }
}

Erased paths never enter _changeSet, so the popChangeSet() in startSync() has nothing left to recover. That is precisely why the line you linked does not cover this case: it runs after that popChangeSet() call, and the event was already discarded before either of them. The drop also skips Q_EMIT changesDetected(), so no sync is scheduled for that change in the first place, which means there is no next sync to be rescued by.

2. On Linux, FolderWatcherPrivate::slotReceivedNotification():

if ((event->mask & (IN_MOVED_TO | IN_CREATE))
    && QFileInfo(p).isDir()
    && !_parent->pathIsIgnored(p)) {
    slotAddFolderRecursive(p);
}

slotAddFolderRecursive() has exactly two callers: the FolderWatcherPrivate constructor, which is FolderWatcher::init(), and this one. If the flag is stale when a hidden directory is created or moved in, that directory never gets a watch, and nothing re-registers it afterwards. Not startSync(), not a restarted sync, not a full local discovery. Only a new FolderWatcher. This assignment is what keeps newly created hidden directories out of the same permanent gap the pre-existing ones are already in.

3. SyncFileStatusTracker::fileStatus() calls _syncEngine->isExcluded() for the overlay icons, so without the immediate update they keep marking hidden files as excluded until a sync starts.

On Windows and macOS point 2 does not apply, because the watch is recursive from the sync root (ReadDirectoryChangesW with bWatchSubtree set to true, FSEvents on the root). Hidden directories do produce events there, so point 1 is the entire story on those platforms: without this assignment every one of those events is dropped.

The same invariant is already relied on in the Folder constructor at folder.cpp#L93, which performs the identical assignment. If the engine's copy only mattered from startSync() onwards, that line would be dead code too, since L913 sets it from the same _definition on every run. It is not dead: the watcher and the status tracker query the engine before the first sync ever runs. L348 just keeps that invariant across a change.

TestFolderMan::testSetIgnoreHiddenFiles pins the behaviour: immediately after the toggle it asserts !folder->isFileExcludedRelative(QStringLiteral(".hello/Text File.txt")), which is the exact call FolderWatcher::pathIsIgnored() makes. Dropping the assignment fails that test.

On restarting the sync instead: that would do strictly less here. SyncScheduler::terminateCurrentSync() aborts the engine and clears _currentSync, and the following startSync() then performs the identical _engine->setIgnoreHiddenFiles(_definition.ignoreHiddenFiles), only later and after throwing away in-flight transfers. It never touches inotify registrations, so it does nothing for point 2. And "later" is not just a scheduling tick: SyncScheduler::enqueueFolder() starts nothing while another folder is syncing, startNext() returns early on _currentSync, so the stale window is the running sync plus whatever is queued ahead of this folder.

There is one real thing your suggestion points at, though, and I would rather name it than skip past it. If the toggle lands while a sync is running, that sync can commit fresh directory etags after forceRemoteDiscoveryNextSync() has already written _invalid_, which silently undoes the invalidation. That is pre-existing and identical in IgnoreListEditor::slotUpdateLocalIgnoreList(), which this change mirrors. I am happy to handle it either here, by terminating a running sync for the affected folder before invalidating, or in a separate PR that fixes both call sites. Tell me which you prefer and I will do it.

What did change: the comment above the assignment was misleading. It described the flag as a sync-time thing, which is exactly the reading that makes the line look inert. It now names the actual live readers. The outdated block below it is rewritten as well, in 43652aa.

Comment thread src/gui/folder.cpp Outdated
@TheOneRing

Copy link
Copy Markdown
Contributor

I mean it's obvious from the number of comments and the length of issue description, but please disclose extensive use of ai in the future.

https://github.com/opencloud-eu/.github/blob/main/profile/AI_GUIDELINE.md

@junkerderprovinz

junkerderprovinz commented Aug 18, 2026

Copy link
Copy Markdown
Author

You're right, and I should have flagged this upfront. I used Claude Code throughout this work: investigating the stale discovery-state bug, drafting the fix, and writing the tests.

I reviewed and understand every part of the change. The design call was mine: the ignore-hidden-files flag goes stale in three separate places (SyncEngine's cached copy, the folder watcher not watching hidden directories, and local discovery's cached exclude state). Each of those needs to be invalidated and refreshed, not just papered over with a full resync.

Assisted-by: Claude Code:claude-opus-5

I've added that line to the PR description too, and I'll disclose this on opencloud-eu contributions going forward without waiting to be asked.

junkerderprovinz and others added 2 commits August 18, 2026 20:57
FolderWatcherPrivate has no destructor on Linux, so calling init() again
leaks the old inotify fd and every watch registered on it. Reported by
TheOneRing in review. Full local discovery still catches up on existing
hidden content; only live changes inside directories that stayed unwatched
need a restart in the meantime, matching prior behavior.
Comment thread src/gui/folder.cpp Outdated
Comment on lines +337 to +346
if (!_engine) {
// the folder failed to set up, there is no state to invalidate
return;
}

// The engine caches the flag and is otherwise only updated when a sync starts. The folder
// watcher asks the engine whether a path is excluded, so without updating it right here
// changes to hidden files would keep being discarded.
_engine->setIgnoreHiddenFiles(ignore);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But changing the value here has no effect, as it's not propagated. If you insist that it is so essential to change the value immediately, I'd recommend that you restart the sync instead of just scheduling a new sync.

Comment thread src/gui/folder.cpp Outdated
Comment on lines +354 to +362
// On Linux the watcher registers one watch per directory and skipped the hidden ones,
// so directories that already existed while hidden files were ignored stay unwatched
// until the app restarts or something else re-creates the watcher. That gap is real
// but is not fixed here: FolderWatcherPrivate has no destructor on Linux, so calling
// init() again on an existing instance leaks the old inotify fd and every watch on it.
// The full local discovery above still catches up on existing hidden content; only
// live changes inside previously-unwatched hidden directories need the app restart in
// the meantime.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

outdated comment

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.

Good catch, that block still described the FolderWatcher::init() call that was dropped in 66c56f7. Rewritten in 43652aa so it describes the state as it actually is: on Linux, directories that were already hidden when the watcher was set up have no inotify watch, because slotAddFolderRecursive() skipped them and it runs again only from FolderWatcher::init(). The full local discovery above picks up their content, and live changes below them stay invisible until the client is restarted, since re-creating the watcher here would leak the old inotify fd and every watch on it while FolderWatcherPrivate has no destructor on Linux.

The comment above _engine->setIgnoreHiddenFiles() was misleading in the same way and is reworded in the same commit. Details in the other thread.

junkerderprovinz and others added 3 commits August 19, 2026 19:10
…es toggle

The old comment implied the engine's copy of the flag only matters for the
sync path, which makes the assignment read as dead code. Name the live
consumers instead: FolderWatcher::addChanges() filters paths at the moment
the OS reports them, so anything dropped there never reaches the change set
that startSync() pops, and on Linux slotReceivedNotification() decides right
then whether a newly created directory is given a watch at all.

Also replace the stale note about the removed FolderWatcher::init() call
with a description of the remaining Linux-only watch gap as it stands.
Comment thread src/gui/folder.cpp Outdated
_journal.forceRemoteDiscoveryNextSync();
slotNextSyncFullLocalDiscovery();

// On Linux only: directories that were already hidden when the watcher was set up have no

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

still outdated. please check what you ai is doing...

Comment thread src/gui/folder.cpp Outdated
Comment on lines +342 to +347
// The engine holds the flag the folder watcher and the status tracker read, and they read it
// outside of a sync: FolderWatcher::addChanges() drops ignored paths the moment the OS reports
// them, before they ever reach the change set startSync() pops, and on Linux
// FolderWatcherPrivate::slotReceivedNotification() decides right there whether a newly created
// directory is given a watch at all. Refreshing the engine only in startSync() would discard
// every hidden-file change until then, without even scheduling the sync that would fix it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bullshit

Comment thread src/gui/folder.cpp Outdated
// FolderWatcherPrivate::slotReceivedNotification() decides right there whether a newly created
// directory is given a watch at all. Refreshing the engine only in startSync() would discard
// every hidden-file change until then, without even scheduling the sync that would fix it.
_engine->setIgnoreHiddenFiles(ignore);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the value is only evaluated when the sync run is started as it is passed to steps deeper down in the sync.

and please stop responding with ai generated answers

@TheOneRing

Copy link
Copy Markdown
Contributor

PS: Your quite focused on the folder watcher, it has no effect on running syncs, only on triggering syncs...

The engine reads the flag when a sync run starts, and the folder watcher only
triggers syncs rather than affecting a running one, so pushing the value here
bought nothing the forced full local discovery does not already cover. The
tests that asserted the engine flag go with it; they were checking the
constructor rather than the toggle.
@junkerderprovinz

Copy link
Copy Markdown
Author

You're right, dropped it along with both comments. setIgnoreHiddenFiles() now only invalidates the remote etags and requests a full local discovery. The test assertions that checked for the engine flag are gone too. Sorry about the AI walls of text, that stops.

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.

enable 'sync hidden files' fails to start syncing

2 participants