Skip to content

Commit 340f128

Browse files
committed
fix(macOS): handle file system events off the GUI thread
The FSEvents stream was scheduled on the main dispatch queue, so every event batch was handled on the GUI thread. Dropping a large tree into the sync folder made that work unbounded: addCoalescedPaths() walked each reported directory's whole subtree with QDirIterator and deduplicated it with a linear QStringList::contains(), and on kFSEventStreamEventFlagMustScanSubDirs or a kernel drop -- exactly what a mass copy triggers -- notifyAll() enumerated the entire sync folder and reported every path one by one. Each reported path then costs a journal lookup and several stats in Folder::slotWatchedPathChanged(), so the window stayed frozen until the whole batch was processed. Deliver events on a private serial queue instead, as the Windows backend already does with its own WatcherThread, and hand each batch over to the FolderWatcher's thread with a queued invocation. Deduplicate the expansion with a QSet rather than a linear scan, and stop expanding past maxCoalescedPaths: nothing is lost by stopping, because the directory being expanded was itself reported and SyncEngine::shouldDiscoverLocally() descends into a touched directory's entire subtree. notifyAll() no longer walks the tree either: a full local discovery re-reads it anyway, so it just emits lostChanges(). It reports no path deliberately -- the watched root would reach the local discovery tracker as an empty relative path, which matches only the root itself and not the tree below it. Signed-off-by: Felipe Tumonis <ftumonis@gmail.com>
1 parent 1e20e71 commit 340f128

4 files changed

Lines changed: 105 additions & 27 deletions

File tree

src/gui/folderwatcher.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,17 @@ int FolderWatcher::lockChangeDebouncingTimout() const
213213
return _lockChangeDebouncingTimer.interval();
214214
}
215215

216+
void FolderWatcher::changesLost(const QString &root)
217+
{
218+
qCWarning(lcFolderWatcher) << "Lost track of individual changes in" << root
219+
<< "- the next sync will run a full local discovery";
220+
221+
// Makes the next sync rediscover the tree from the filesystem instead of the database. No path
222+
// is reported: the root would reach the local discovery tracker as an empty relative path,
223+
// which matches only the root itself and not the tree below it.
224+
emit lostChanges();
225+
}
226+
216227
void FolderWatcher::changeDetected(const QString &path)
217228
{
218229
QStringList paths(path);

src/gui/folderwatcher.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ private slots:
129129
void startNotificationTestWhenReady();
130130
void lockChangeDebouncingTimerTimedOut();
131131

132+
private:
133+
/** Report that the backend could not keep track of individual changes under @a root.
134+
*
135+
* Emits lostChanges() so that the next sync runs a full local discovery. Reports no path, so
136+
* whoever wants a sync scheduled has to react to lostChanges() as well.
137+
*/
138+
void changesLost(const QString &root);
139+
132140
protected:
133141
QHash<QString, int> _pendingPathes;
134142

src/gui/folderwatcher_mac.cpp

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,22 @@
1313

1414
#include <cerrno>
1515
#include <QDirIterator>
16+
#include <QSet>
1617
#include <QStringList>
1718

1819

1920
namespace OCC {
2021

22+
namespace {
23+
/* Upper bound on the paths one event batch may be expanded into by addCoalescedPaths().
24+
*
25+
* Every reported path costs a journal lookup and a few stats on the GUI thread later on, so for a
26+
* large tree it is both faster and gentler on the UI to stop enumerating and let the next sync
27+
* rediscover the tree instead.
28+
*/
29+
constexpr auto maxCoalescedPaths = 1000;
30+
}
31+
2132
FolderWatcherPrivate::FolderWatcherPrivate(FolderWatcher *p, const QString &path)
2233
: _parent(p)
2334
, _folder(path)
@@ -27,9 +38,20 @@ FolderWatcherPrivate::FolderWatcherPrivate(FolderWatcher *p, const QString &path
2738

2839
FolderWatcherPrivate::~FolderWatcherPrivate()
2940
{
30-
FSEventStreamStop(_stream);
31-
FSEventStreamInvalidate(_stream);
32-
FSEventStreamRelease(_stream);
41+
if (_stream) {
42+
FSEventStreamStop(_stream);
43+
FSEventStreamInvalidate(_stream);
44+
FSEventStreamRelease(_stream);
45+
_stream = nullptr;
46+
}
47+
48+
if (_queue) {
49+
// A callback block may already be running on the queue and still touching this object,
50+
// so let it drain before the members it reads are destroyed.
51+
dispatch_sync(_queue, ^{});
52+
dispatch_release(_queue);
53+
_queue = nullptr;
54+
}
3355
}
3456

3557
static void callback(
@@ -108,44 +130,71 @@ void FolderWatcherPrivate::startWatching()
108130

109131
CFRelease(pathsToWatch);
110132
CFRelease(folderCF);
111-
FSEventStreamSetDispatchQueue(_stream, dispatch_get_main_queue());
133+
134+
// Deliver events on a private queue rather than the main one: dropping a large tree into the
135+
// sync folder produces event batches big enough that handling them on the main queue blocks
136+
// the GUI. This mirrors what the Windows watcher does with its own WatcherThread.
137+
_queue = dispatch_queue_create("com.nextcloud.desktopclient.folderwatcher", DISPATCH_QUEUE_SERIAL);
138+
FSEventStreamSetDispatchQueue(_stream, _queue);
112139
FSEventStreamStart(_stream);
113140
}
114141

115-
QStringList FolderWatcherPrivate::addCoalescedPaths(const QStringList &paths) const
142+
void FolderWatcherPrivate::addCoalescedPaths(const QStringList &paths, QStringList &coalesced) const
116143
{
117-
QStringList coalescedPaths;
144+
// Only the expansion below is bounded, not `paths` itself: those are events FSEvents actually
145+
// reported, they are capped by the kernel's own buffer, and reporting them is what drives
146+
// things a rediscovery cannot redo, such as releasing office file locks.
147+
//
148+
// FSEvents reports a renamed or moved directory, but not the items below it, so the tree is
149+
// walked to report those too.
150+
coalesced = paths;
151+
QSet<QString> seen{paths.begin(), paths.end()};
152+
118153
for (const auto &eventPath : paths) {
119-
if (QDir(eventPath).exists()) {
120-
QDirIterator it(eventPath, QDir::AllDirs | QDir::NoDotAndDotDot | QDir::Files, QDirIterator::Subdirectories);
121-
while (it.hasNext()) {
122-
const auto path = it.next();
123-
if (!paths.contains(path)) {
124-
coalescedPaths.append(path);
125-
}
154+
if (!QDir(eventPath).exists()) {
155+
continue;
156+
}
157+
QDirIterator it(eventPath, QDir::AllDirs | QDir::NoDotAndDotDot | QDir::Files, QDirIterator::Subdirectories);
158+
while (it.hasNext()) {
159+
const auto path = it.next();
160+
if (coalesced.size() >= maxCoalescedPaths) {
161+
// Give up on expanding this batch rather than report a whole tree path by path.
162+
// Nothing is lost by stopping: the directory that is being expanded was itself
163+
// reported, and SyncEngine::shouldDiscoverLocally() descends into a touched
164+
// directory's entire subtree, so the entries below it are discovered anyway.
165+
return;
166+
}
167+
if (!seen.contains(path)) {
168+
seen.insert(path);
169+
coalesced.append(path);
126170
}
127171
}
128172
}
129-
return (paths + coalescedPaths);
130173
}
131174

132175
void FolderWatcherPrivate::doNotifyParent(const QStringList &paths)
133176
{
134-
const QStringList totalPaths = addCoalescedPaths(paths);
135-
_parent->changeDetected(totalPaths);
177+
if (paths.isEmpty()) {
178+
return;
179+
}
180+
181+
QStringList totalPaths;
182+
addCoalescedPaths(paths, totalPaths);
183+
184+
// We are on the private dispatch queue here, but everything FolderWatcher touches from
185+
// changeDetected() (its timers, the lock-file sets, the pathChanged() receivers) may only be
186+
// used on the thread it lives on, so hand the batch over instead of processing it here.
187+
QMetaObject::invokeMethod(
188+
_parent, [parent = _parent, totalPaths] { parent->changeDetected(totalPaths); }, Qt::QueuedConnection);
136189
}
137190

138191
void FolderWatcherPrivate::notifyAll()
139192
{
140-
QDirIterator dirIterator(_folder, QDirIterator::Subdirectories);
141-
QStringList allPaths;
142-
143-
while(dirIterator.hasNext()) {
144-
const auto dirEntry = dirIterator.next();
145-
allPaths.append(dirEntry);
146-
}
147-
148-
_parent->changeDetected(allPaths);
193+
// FSEvents either dropped events or asked for a full rescan. Enumerating the whole tree to
194+
// report every path would take minutes on a large folder, and a full local discovery re-reads
195+
// it anyway, so ask for one of those instead of reporting anything.
196+
QMetaObject::invokeMethod(
197+
_parent, [parent = _parent, folder = _folder] { parent->changesLost(folder); }, Qt::QueuedConnection);
149198
}
150199

151200

src/gui/folderwatcher_mac.h

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ class FolderWatcherPrivate
2626
~FolderWatcherPrivate();
2727

2828
void startWatching();
29-
QStringList addCoalescedPaths(const QStringList &) const;
29+
30+
/** Expand @a paths with the contents of any directory among them, into @a coalesced.
31+
*
32+
* The expansion stops at maxCoalescedPaths; the sync engine descends into a reported directory
33+
* on its own, so the entries left out are still discovered. @a paths itself is always passed
34+
* through, however large it is.
35+
*/
36+
void addCoalescedPaths(const QStringList &paths, QStringList &coalesced) const;
3037
void doNotifyParent(const QStringList &);
3138
void notifyAll();
3239

@@ -38,7 +45,10 @@ class FolderWatcherPrivate
3845

3946
QString _folder;
4047

41-
FSEventStreamRef _stream;
48+
FSEventStreamRef _stream = nullptr;
49+
50+
/// Events are delivered here instead of on the main queue, to keep them off the GUI thread.
51+
dispatch_queue_t _queue = nullptr;
4252
};
4353
}
4454

0 commit comments

Comments
 (0)