FileWatcher.cpp 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /*
  2. * Copyright (c) Contributors to the Open 3D Engine Project.
  3. * For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. *
  5. * SPDX-License-Identifier: Apache-2.0 OR MIT
  6. *
  7. */
  8. #include "FileWatcher.h"
  9. #include "AzCore/std/containers/vector.h"
  10. #include <native/assetprocessor.h>
  11. #include <native/FileWatcher/FileWatcher_platform.h>
  12. #include <QFileInfo>
  13. #include <QDir>
  14. #include <AssetBuilderSDK/AssetBuilderSDK.h>
  15. //! IsSubfolder(folderA, folderB)
  16. //! returns whether folderA is a subfolder of folderB
  17. //! assumptions: absolute paths
  18. static bool IsSubfolder(const QString& folderA, const QString& folderB)
  19. {
  20. // lets avoid allocating or messing with memory - this is a MAJOR hotspot as it is called for any file change even in the cache!
  21. if (folderA.length() <= folderB.length())
  22. {
  23. return false;
  24. }
  25. using AZStd::begin;
  26. using AZStd::end;
  27. auto isSlash = [](const QChar c) constexpr
  28. {
  29. return c == AZ::IO::WindowsPathSeparator || c == AZ::IO::PosixPathSeparator;
  30. };
  31. // if folderB doesn't end in a slash, make sure folderA has one at the appropriate location to
  32. // avoid matching a partial path that isn't a folder e.g.
  33. // folderA = c:/folderWithLongerName
  34. // folderB = c:/folder
  35. if (!isSlash(folderB[folderB.length() - 1]) && !isSlash(folderA[folderB.length()]))
  36. {
  37. return false;
  38. }
  39. const auto firstPathSeparator = AZStd::find_if(begin(folderB), end(folderB), [&isSlash](const QChar c)
  40. {
  41. return isSlash(c);
  42. });
  43. // Follow the convention used by AZ::IO::Path, and use a case-sensitive comparison on Posix paths
  44. const bool useCaseSensitiveCompare = (firstPathSeparator == end(folderB)) ? true : (*firstPathSeparator == AZ::IO::PosixPathSeparator);
  45. return AZStd::equal(begin(folderB), end(folderB), begin(folderA), [isSlash, useCaseSensitiveCompare](const QChar charAtB, const QChar charAtA)
  46. {
  47. if (isSlash(charAtA))
  48. {
  49. return isSlash(charAtB);
  50. }
  51. if (useCaseSensitiveCompare)
  52. {
  53. return charAtA == charAtB;
  54. }
  55. return charAtA.toLower() == charAtB.toLower();
  56. });
  57. }
  58. //////////////////////////////////////////////////////////////////////////
  59. /// FileWatcher
  60. FileWatcher::FileWatcher()
  61. : m_platformImpl(AZStd::make_unique<PlatformImplementation>())
  62. {
  63. auto makeFilter = [this](auto signal)
  64. {
  65. return [this, signal](QString path)
  66. {
  67. const auto foundWatchRoot = AZStd::find_if(begin(m_folderWatchRoots), end(m_folderWatchRoots), [path](const WatchRoot& watchRoot)
  68. {
  69. return Filter(path, watchRoot);
  70. });
  71. if (foundWatchRoot == end(m_folderWatchRoots))
  72. {
  73. return;
  74. }
  75. if (IsExcluded(path))
  76. {
  77. return;
  78. }
  79. AZStd::invoke(signal, this, path);
  80. };
  81. };
  82. // The rawFileAdded signals are emitted by the watcher thread. Use a queued
  83. // connection so that the consumers of the notification process the
  84. // notification on the main thread.
  85. connect(this, &FileWatcherBase::rawFileAdded, this, makeFilter(&FileWatcherBase::fileAdded), Qt::QueuedConnection);
  86. connect(this, &FileWatcherBase::rawFileRemoved, this, makeFilter(&FileWatcherBase::fileRemoved), Qt::QueuedConnection);
  87. connect(this, &FileWatcherBase::rawFileModified, this, makeFilter(&FileWatcherBase::fileModified), Qt::QueuedConnection);
  88. }
  89. FileWatcher::~FileWatcher()
  90. {
  91. disconnect();
  92. StopWatching();
  93. }
  94. void FileWatcher::AddFolderWatch(QString directory, bool recursive)
  95. {
  96. // Search for an already monitored root that is a parent of `directory`,
  97. // that is already watching subdirectories recursively
  98. const auto found = AZStd::find_if(begin(m_folderWatchRoots), end(m_folderWatchRoots), [directory](const WatchRoot& root)
  99. {
  100. return root.m_recursive && IsSubfolder(directory, root.m_directory);
  101. });
  102. if (found != end(m_folderWatchRoots))
  103. {
  104. // This directory is already watched
  105. return;
  106. }
  107. //create a new root and start listening for changes
  108. m_folderWatchRoots.push_back({directory, recursive});
  109. //since we created a new root, see if the new root is a super folder
  110. //of other roots, if it is then then fold those roots into the new super root
  111. if (recursive)
  112. {
  113. AZStd::erase_if(m_folderWatchRoots, [directory](const WatchRoot& root)
  114. {
  115. return IsSubfolder(root.m_directory, directory);
  116. });
  117. }
  118. }
  119. bool FileWatcher::HasWatchFolder(QString directory) const
  120. {
  121. const auto found = AZStd::find_if(begin(m_folderWatchRoots), end(m_folderWatchRoots), [directory](const WatchRoot& root)
  122. {
  123. return root.m_directory == directory;
  124. });
  125. return found != end(m_folderWatchRoots);
  126. }
  127. void FileWatcher::ClearFolderWatches()
  128. {
  129. m_folderWatchRoots.clear();
  130. }
  131. void FileWatcher::StartWatching()
  132. {
  133. if (m_startedWatching)
  134. {
  135. AZ_Warning("FileWatcher", false, "StartWatching() called when already watching for file changes.");
  136. return;
  137. }
  138. m_shutdownThreadSignal = false;
  139. if (PlatformStart())
  140. {
  141. m_thread = AZStd::thread({/*.name=*/ "AssetProcessor FileWatcher thread"}, [this]{
  142. WatchFolderLoop();
  143. });
  144. AZ_TracePrintf(AssetProcessor::ConsoleChannel, "File Change Monitoring started.\n");
  145. }
  146. else
  147. {
  148. AZ_TracePrintf(AssetProcessor::ConsoleChannel, "File Change Monitoring failed to start.\n");
  149. }
  150. while(!m_startedSignal)
  151. {
  152. // wait for the thread to signal that it is completely ready. This should
  153. // take a very short amount of time, so yield for it (not sleep).
  154. AZStd::this_thread::yield();
  155. }
  156. m_startedWatching = true;
  157. }
  158. void FileWatcher::StopWatching()
  159. {
  160. if (!m_startedWatching)
  161. {
  162. return;
  163. }
  164. m_shutdownThreadSignal = true;
  165. // The platform is expected to join the thread in PlatformStop. It cannot be joined here,
  166. // since the platform may have to signal the thread to stop in a platform specific
  167. // way before it is safe to join.
  168. PlatformStop();
  169. m_startedWatching = false;
  170. }
  171. bool FileWatcher::Filter(QString path, const WatchRoot& watchRoot)
  172. {
  173. if (!IsSubfolder(path, watchRoot.m_directory))
  174. {
  175. return false;
  176. }
  177. if (!watchRoot.m_recursive)
  178. {
  179. // filter out subtrees too.
  180. QStringRef subRef = path.rightRef(path.length() - watchRoot.m_directory.length());
  181. if ((subRef.indexOf('/') != -1) || (subRef.indexOf('\\') != -1))
  182. {
  183. return false; // filter this out.
  184. }
  185. }
  186. return true;
  187. }
  188. bool FileWatcher::IsExcluded(QString filepath) const
  189. {
  190. for (const AssetBuilderSDK::FilePatternMatcher& matcher : m_excludes)
  191. {
  192. if (matcher.MatchesPath(filepath.toUtf8().constData()))
  193. {
  194. return true;
  195. }
  196. }
  197. return false;
  198. }
  199. void FileWatcher::AddExclusion(const AssetBuilderSDK::FilePatternMatcher& excludeMatch)
  200. {
  201. m_excludes.push_back(excludeMatch);
  202. }
  203. void FileWatcher::InstallDefaultExclusionRules(QString cacheRootPath, QString projectRootPath)
  204. {
  205. constexpr const char* intermediates = AssetProcessor::IntermediateAssetsFolderName;
  206. using AssetBuilderSDK::FilePatternMatcher;
  207. using AssetBuilderSDK::AssetBuilderPattern;
  208. // Note to maintainers:
  209. // If you add more here, consider updating DefaultExcludes_ExcludeExpectedLocations. It turns out each platform
  210. // can approach these exclusions slightly differently, due to slash direction, naming, and how it installs its
  211. // file monitors.
  212. //
  213. // File exclusions from the config are already checked on all files coming from the file watcher, but are done so
  214. // in the main thread quite late in the process, so as not to block the file monitor unnecessarily.
  215. // The file monitor is sensitive to being blocked due to it being a raw listener to some operating system level file event
  216. // stream, and as little work in its threads should be done as possible, so do not add a large number of exclusions here.
  217. // The best situation is a really small number of exclusions that match a very broad number of actual files (like the entire
  218. // user folder full of log files).
  219. //
  220. // For most situations its probably better to just filter on the main thread instead of in the watcher - and most implementations
  221. // of this class do just that.
  222. // However, on some operating systems, each monitored folder in a tree of monitored folders costs actual system resources
  223. // (a handle) and there are limited handles available, so excluding entire folder trees that we know we don't care about
  224. // is valuable to save resources even if it costs more in the listener.
  225. //
  226. // It is up to each implementation to make use of the list of excludes to best optimize itself for performance. Even if the
  227. // implementation does absolutely nothing with this exclude list, this class itself will still filter out excludes before
  228. // forwarding the file events to actual handlers.
  229. //
  230. // To strike a balance here, add just a few hand-picked exclusions that tend to contain a lot of unnecessary folders:
  231. // * Everything in the cache EXCEPT for the "Intermediate Assets" and "fence" folders (filtering out fence will deadlock!)
  232. // * Project/build/* (case insensitive)
  233. // * Project/user/* (case insensitive)
  234. // * Project/gem/code/* (case insensitive)
  235. // These (except for the cache) also mimic the built in exclusions for scanning, and are also likely to be deep folder trees.
  236. if (!cacheRootPath.isEmpty())
  237. {
  238. // Use the actual cache root as part of the regex for filtering out the Intermediate Assets and fence folder
  239. // this prevents accidental filtering of folders that have the word 'Cache' in them.
  240. QString nativeCacheRoot = QDir::toNativeSeparators(cacheRootPath);
  241. // Sanitize for regex by prepending any special characters with the escape character:
  242. QString sanitizedCacheFolderString;
  243. QString regexEscapeChars(R"(\.^$-+()[]{}|?*)");
  244. for (int pos = 0; pos < nativeCacheRoot.size(); ++pos)
  245. {
  246. if (regexEscapeChars.contains(nativeCacheRoot[pos]))
  247. {
  248. sanitizedCacheFolderString.append('\\');
  249. }
  250. sanitizedCacheFolderString.append(nativeCacheRoot[pos]);
  251. }
  252. const char* filterString = R"(^%s[\\\/](?!%s|fence).*$)"; // [\\\/] will match \\ and /
  253. // final form is something like ^C:\\o3de\\projects\\Project1\\Cache[\\\/](?!Intermediate Assets|fence).*$
  254. // on unix-like, ^/home/myuser/o3de-projects/Project1/Cache[\\\/](?!Intermediate Assets|fence).*$
  255. AZStd::string exclusion = AZStd::string::format(filterString, sanitizedCacheFolderString.toUtf8().constData(), intermediates);
  256. AddExclusion(AssetBuilderSDK::FilePatternMatcher(exclusion, AssetBuilderSDK::AssetBuilderPattern::Regex));
  257. }
  258. if (!projectRootPath.isEmpty())
  259. {
  260. // These are not regexes, so do not need sanitation. Files can't use special characters like * or ? from globs anyway.
  261. AZStd::string userPath = QDir::toNativeSeparators(QDir(projectRootPath).absoluteFilePath("user/*")).toUtf8().constData();
  262. AZStd::string buildPath = QDir::toNativeSeparators(QDir(projectRootPath).absoluteFilePath("build/*")).toUtf8().constData();
  263. AZStd::string gemCodePath = QDir::toNativeSeparators(QDir(projectRootPath).absoluteFilePath("gem/code/*")).toUtf8().constData();
  264. AddExclusion(FilePatternMatcher(userPath.c_str(), AssetBuilderPattern::Wildcard));
  265. AddExclusion(FilePatternMatcher(buildPath.c_str(), AssetBuilderPattern::Wildcard));
  266. AddExclusion(FilePatternMatcher(gemCodePath.c_str(), AssetBuilderPattern::Wildcard));
  267. }
  268. }