扫描器与文件夹监视(Watcher)
features::gallery::watcher 与 features::gallery::scanner 共同构成 Gallery 的增量同步引擎:scanner 负责按扩展名规则对根目录执行全量扫描并产出 ScanResult,watcher 负责为每个根目录维护一条目录监听线程,并通过一个全局唯一的同步编排线程把文件系统变化对账进媒体索引。
Purpose and Scope
本页覆盖以下内容(以 src/features/gallery/watcher/ 与 src/features/gallery/scanner/ 的源码为准):
- watcher 门面 API:注册、恢复、启动、移除、关闭,以及每个函数的语义约定;
- 线程模型:每个 root 一条
notify::run_watch_loop监听线程 + 全局唯一sync::run_sync_coordinator编排线程; - 手动文件系统忽略机制(
begin/complete_manual_file_system_ignore)与其 3000ms 宽限期设计; - 启动恢复检查点(
WatchRootRecoveryState/ USN checkpoint)的持久化路径; - scanner 公共辅助函数(扩展名、资产类型、内容指纹)的契约;
- watcher 与
gallery.cpp的集成方式(bootstrap 扫描、关闭序列)。
以下主题刻意留给兄弟页面,本页只做交叉指引:
- 媒体资产/文件夹的持久化细节(
features/gallery/asset/repository.hpp、folder/repository.hpp); - 启动恢复(USN 重放)的实现细节(
features/gallery/recovery/service.hpp); - 忽略规则的匹配器实现(
src/features/gallery/ignore/matcher.cpp); - 根目录可用性探测(
root_availability.hpp)与缩略图生成(asset/thumbnail.hpp)。
Overview
Gallery 需要在用户浏览的同时保持磁盘与索引一致。做法是:
- 注册阶段:
register_watcher_for_directory把一个根目录登记进app_state.gallery,可选地携带ScanOptions(扫描参数、支持的扩展名等)。重复注册只是更新扫描参数,不会启动线程。 - 恢复阶段:
restore_watchers_from_db在应用启动时从数据库读回所有根目录的注册信息,同样不启动任何线程——这保证 UI 恢复与后台线程创建解耦。 - 启动阶段:
start_watcher_for_directory/start_registered_watchers才真正创建线程;首次启动通过bootstrap_full_scan = true触发一次全量对账。 - 运行阶段:每条监听线程只负责"产生目录事件事实",真正的扫描/对账由全局唯一的
sync::run_sync_coordinator编排,避免并发扫描同一 root。 - 应用主动操作:当应用自己移动/删除文件时(而非用户在资源管理器中操作),通过手动忽略集合压制由此产生的文件系统通知,操作完成后仍保留 3 秒缓冲吸收"晚到"通知。
- 关闭阶段:
shutdown_watchers停掉所有监听线程和全局编排线程。
关键类型(定义于 features/gallery/types.hpp,本页按其在 watcher API 中的用法引用):
| 类型 | 作用 |
|---|---|
ScanOptions | 扫描参数:目标目录字符串、支持的扩展名列表等 |
ScanResult | 一次扫描/对账的结果,交付给 post_scan_callback |
ScanChange | 单条文件系统变化(手动文件操作产出,经 dispatch_manual_scan_changes 分发) |
ScanProgress | 扫描进度回调参数 |
FolderWatcherState | 单个 root 的运行时状态:监听线程、目录句柄、生命周期锁等 |
Architecture
结构解读(每条边都对应源码中的真实调用):
- 门面即边界:watcher.hpp 暴露的自由函数集合是 Gallery 其余部分接触文件监视的唯一入口;
gallery.cpp只调用这些函数而不直接触碰线程。 - 每 root 一条监听线程:
start_watch_thread_if_needed在持有生命周期锁时创建std::jthread运行notify::run_watch_loop,并显式保证"同一 root 只能拥有一条目录监听线程"(watcher.joinable()时直接返回)。 - 全局唯一编排线程:
start_sync_coordinator_if_needed在watcher_sync_mutex保护下检查watcher_sync_thread.joinable(),保证整个 Gallery 只有一个同步编排者;注释明确说明该线程"不占用 WorkerPool,扫描内部的叶子任务仍由 WorkerPool 处理"——编排与执行分离,避免编排线程把工作线程池占满。 - 恢复层是旁路:checkpoint 持久化失败只记录
Logger().warn,不影响扫描主流程;下次启动仍可退化为全量扫描。
核心线程模型与生命周期
监听线程的创建与"单线程不变量"
start_watch_thread_if_needed 是所有监听线程的出生点(调用方已持有 watch_lifecycle_mutex):
1auto start_watch_thread_if_needed(core::AppState& app_state, const std::string& watcher_key,
2 FolderWatcherState& watcher, bool bootstrap_full_scan)
3 -> std::expected<bool, std::string> {
4 if (watcher.removal_in_progress.load(std::memory_order_acquire)) {
5 return std::unexpected("Watcher is stopping for root directory: " + watcher_key);
6 }
7 if (watcher.watch_thread.joinable()) {
8 // 同一 root 只能拥有一条目录监听线程。
9 return false;
10 }
11
12 watcher.stop_requested.store(false, std::memory_order_release);
13
14 try {
15 // 全局编排器已由上层先行启动,此处只产生目录事件。
16 watcher.watch_thread = std::jthread([&app_state, watcher_key](std::stop_token stop_token) {
17 notify::run_watch_loop(app_state, watcher_key, stop_token);
18 });
19 } catch (const std::exception& e) {
20 stop_watch_thread(watcher);
21 return std::unexpected("Failed to start watcher thread: " + std::string(e.what()));
22 }
23
24 if (bootstrap_full_scan) {
25 // 首次启动通过同一调度入口请求全量对账,不额外创建扫描任务。
26 sync::request_full_rescan(app_state, watcher);
27 }
28
29 return true;
30}Source: watcher.cpp
三个设计要点:
removal_in_progress守卫:一个 root 正在被移除时拒绝再次启动,避免"移除中又被拉起"的竞态。- 幂等启动:线程已存在时返回
false而不是错误——上层可以把"重复启动"当作正常情况处理。 - bootstrap 扫描走同一调度入口:首启的全量对账不是另起一个扫描任务,而是
sync::request_full_rescan,让全局编排线程统一裁决"现在该扫什么",避免与事件驱动的增量对账并发打架。
监听线程的停止:先关入口,再取消阻塞 I/O,最后 join
1auto stop_watch_thread(FolderWatcherState& watcher) -> void {
2 // 先关闭该 root 的事件接收与同步入口。
3 watcher.stop_requested.store(true, std::memory_order_release);
4
5 // 取消阻塞中的目录读取,让监听线程无需等待下一条文件事件即可退出。
6 if (watcher.watch_thread.joinable()) {
7 watcher.watch_thread.request_stop();
8
9 auto raw_handle = watcher.directory_handle.load(std::memory_order_acquire);
10 auto* directory_handle = static_cast<HANDLE>(raw_handle);
11 if (directory_handle && directory_handle != INVALID_HANDLE_VALUE) {
12 CancelIoEx(directory_handle, nullptr);
13 }
14
15 // join 后该 root 不会再产生新的待处理事实。
16 watcher.watch_thread.join();
17 }
18}Source: watcher.cpp
目录监听在 Windows 上依赖阻塞式目录读取(HANDLE 由 watcher.directory_handle 原子变量持有)。仅设置 stop_requested / request_stop() 不够——监听线程可能正卡在等待下一条文件事件上,因此必须 CancelIoEx 强行取消挂起的 I/O 才能让线程退出。这是典型的 "cancel-blocking-I/O-then-join" 关闭模式。
两把锁的生命周期分层
1// 停止指定 watcher:关闭监听后等待该 root 当前同步退出。
2auto stop_watcher(FolderWatcherState& watcher) -> void {
3 {
4 std::unique_lock<std::mutex> lifecycle_lock(watcher.watch_lifecycle_mutex);
5 stop_watch_thread(watcher);
6 }
7 // 启动恢复和全局编排线程都共享此锁,离开后状态才可销毁。
8 std::lock_guard<std::mutex> execution_lock(watcher.sync_execution_mutex);
9}Source: watcher.cpp
watch_lifecycle_mutex保护"这个 root 有没有监听线程"这一事实;sync_execution_mutex保护"该 root 当前是否正在执行同步"。stop_watcher在锁外等待后者,意味着移除一个 root 时必须先让正在进行的对账自然结束,之后FolderWatcherState才允许销毁——注释点明"启动恢复和全局编排线程都共享此锁"。
全局编排线程的启停
1auto start_sync_coordinator_if_needed(core::AppState& app_state)
2 -> std::expected<bool, std::string> {
3 if (is_shutdown_requested(app_state)) {
4 return std::unexpected("Gallery is shutting down");
5 }
6
7 std::lock_guard<std::mutex> lock(app_state.gallery->watcher_sync_mutex);
8 // 等锁期间可能已进入 shutdown,锁内重查阻止线程被重新创建。
9 if (is_shutdown_requested(app_state)) {
10 return std::unexpected("Gallery is shutting down");
11 }
12 if (app_state.gallery->watcher_sync_thread.joinable()) {
13 return false;
14 }
15
16 try {
17 // 编排线程不占用 WorkerPool,扫描内部的叶子任务仍由 WorkerPool 处理。
18 app_state.gallery->watcher_sync_thread = std::jthread([&app_state](std::stop_token stop_token) {
19 sync::run_sync_coordinator(app_state, stop_token);
20 });
21 } catch (const std::exception& e) {
22 return std::unexpected("Failed to start gallery sync coordinator: " + std::string(e.what()));
23 }
24 return true;
25}Source: watcher.cpp
注意 shutdown 的双重检查:第一次检查在锁外(快速失败),拿到锁后再查一次——因为等锁期间应用可能已进入关闭流程,锁内重查阻止"关闭过程中线程被重新创建"。对应的 stop_sync_coordinator 则在锁内把 jthread 的所有权 std::move 出来再 join,注释解释了原因:"锁内移出线程所有权,后续 join 不再与启动入口并发读写同一 jthread"(见 watcher.cpp)。随后 watcher_sync_condition.notify_all() 打断编排线程在无任务时的条件等待,使其能响应 stop token 及时退出。
生命周期时序
手动文件系统忽略机制(Manual Ignore)
应用自己执行移动/重命名/删除时,Windows 会像对待用户在资源管理器中的操作一样产生目录变更通知。如果让这些通知再走一遍增量对账,就会与应用刚完成的索引更新重复对账。该机制用一组带生命周期的路径集合来压制这类"自产"通知。
1// 应用主动文件系统操作结束后额外缓冲一段时间,吸收“晚到”的通知。
2constexpr std::chrono::milliseconds kManualFileSystemIgnoreGracePeriod{3000};
3
4struct ManualFileSystemIgnorePath {
5 std::filesystem::path normalized_path;
6 std::wstring comparison_key;
7};
8
9// begin/complete 必须使用同一组规范路径;源和目标相同时只登记一次。
10auto normalize_manual_file_system_ignore_paths(const std::filesystem::path& source_path,
11 const std::filesystem::path& destination_path)
12 -> std::expected<std::vector<ManualFileSystemIgnorePath>, std::string> {
13 std::vector<ManualFileSystemIgnorePath> paths;
14 paths.reserve(2);
15 std::unordered_set<std::wstring> seen_keys;
16 seen_keys.reserve(2);
17
18 for (const auto& path : {source_path, destination_path}) {
19 auto normalized_result = utils::path::NormalizePath(path);
20 if (!normalized_result) {
21 return std::unexpected("Failed to normalize ignore path: " + normalized_result.error());
22 }
23
24 auto comparison_key = utils::path::NormalizeForComparison(normalized_result.value());
25 if (!seen_keys.insert(comparison_key).second) {
26 continue;
27 }
28 paths.push_back(ManualFileSystemIgnorePath{
29 .normalized_path = std::move(normalized_result.value()),
30 .comparison_key = std::move(comparison_key),
31 });
32 }
33 return paths;
34}Source: watcher.cpp
设计要点:
- 规范路径 + 比较键:登记用
NormalizePath得到规范路径,同时用NormalizeForComparison生成宽字符串比较键,后续is_path_in_manual_file_system_ignore用比较键做精确匹配。begin与complete必须传入同一组规范路径才能正确对上。 - 源目标去重:同一次操作里源路径与目标路径可能相同(如原地重命名失败、或目标覆盖源),用
seen_keys保证只登记一条,避免in_flight_count计数不对称。 - 精确匹配而非递归匹配:头文件注释明确
is_path_in_manual_file_system_ignore"不递归匹配目录后代"——目录本身被忽略不代表其子路径被忽略,宁可多对账也不漏。
计数与过期清理
1// 清理已经离开 in-flight 且超过缓冲期的手动操作路径。
2auto cleanup_expired_manual_file_system_ignores(core::AppState& app_state) -> void {
3 auto now = std::chrono::steady_clock::now();
4 std::erase_if(app_state.gallery->manual_file_system_ignore_paths, [now](const auto& pair) {
5 const auto& entry = pair.second;
6 return entry.in_flight_count <= 0 && entry.ignore_until <= now;
7 });
8}Source: watcher.cpp
每条登记项由两个维度共同决定存亡:
in_flight_count:当前有多少个应用主动操作仍持有该路径(支持同一路径上的嵌套/并发操作);ignore_until:complete时写入的"宽限期截止时间"(now + 3000ms)。
只有两者同时满足(不再 in-flight 且已过宽限期)才会被清除。这意味着路径在磁盘与索引操作完成后并不会立刻放行,而是再吸收 3 秒内晚到的 OS 通知——这是对"目录变更通知可能延迟到达"这一现实约束的显式补偿。
与门面 API 的对应关系
begin_manual_file_system_ignore(app_state, source, destination):在应用开始修改磁盘之前调用(注释:"begin 成功后调用方才应修改磁盘"),登记路径并把待消费的同路径变化清空。complete_manual_file_system_ignore(...):磁盘与索引操作完成后立即结束 in-flight,同时保留短缓冲吸收延迟通知。is_path_in_manual_file_system_ignore(app_state, path):watcher 侧过滤事件时的查询入口。dispatch_manual_scan_changes(app_state, changes):应用主动操作产出的ScanChange列表不走文件系统事件路径,而是直接分发给对应 root 的post_scan_callback。
启动恢复检查点(Recovery Checkpoint)
watcher 在每次成功应用启动恢复计划后,把恢复边界持久化到数据库,作为下次启动重放 USN 日志的保守起点:
1// 保存已经成功应用的启动恢复边界,作为下次启动重放 USN 的保守起点。
2auto persist_startup_recovery_plan(core::AppState& app_state,
3 const features::gallery::recovery::StartupRecoveryPlan& plan)
4 -> void {
5 // 非 NTFS/无 Journal 的计划没有可恢复边界,继续依赖下次全量扫描。
6 if (plan.root_path.empty() || plan.volume_identity.empty() || plan.rule_fingerprint.empty() ||
7 !plan.journal_id.has_value() || !plan.checkpoint_usn.has_value()) {
8 return;
9 }
10
11 features::gallery::recovery::WatchRootRecoveryState recovery_state{
12 .root_path = plan.root_path,
13 .volume_identity = plan.volume_identity,
14 .journal_id = plan.journal_id,
15 .checkpoint_usn = plan.checkpoint_usn,
16 .rule_fingerprint = plan.rule_fingerprint,
17 };
18 auto persist_result =
19 features::gallery::recovery::service::persist_recovery_state(app_state, recovery_state);
20 if (!persist_result) {
21 Logger().warn("Failed to persist startup recovery checkpoint for '{}': {}", plan.root_path,
22 persist_result.error());
23 }
24}Source: watcher.cpp
设计意图:
- 五字段完整性守卫:
root_path、volume_identity、rule_fingerprint、journal_id、checkpoint_usn任一缺失就静默跳过——这些字段共同构成"这个 checkpoint 是否仍然有效"的前提:卷身份变了(换了盘/格式化)、USN Journal 重建过(journal_id 变化)、忽略规则指纹变了,都意味着旧 checkpoint 不可复用。 - 降级路径:非 NTFS 或无 Journal 的卷根本无法增量恢复,注释明确此时"继续依赖下次全量扫描"——增量恢复是优化,全量扫描是兜底正确性来源。
- 失败不阻断:持久化失败仅
warn日志,不返回错误,不回滚已应用的恢复结果。
Scanner 公共辅助层
features::gallery::scanner::common 提供扫描器与 watcher 共用的判定与指纹逻辑,其契约在头文件中一次性声明:
1namespace features::gallery::scanner::common {
2
3auto default_supported_extensions() -> const std::vector<std::string>&;
4
5auto is_supported_file(const std::filesystem::path& file_path,
6 const std::vector<std::string>& supported_extensions) -> bool;
7
8auto is_photo_file(const std::filesystem::path& file_path) -> bool;
9
10auto detect_asset_type(const std::filesystem::path& file_path) -> std::string;
11
12// 计算素材内容指纹:Debug 使用路径哈希,Release 对小媒体完整哈希、对大媒体五点采样
13auto calculate_content_fingerprint(const std::filesystem::path& file_path, std::int64_t file_size,
14 std::stop_token stop_token)
15 -> std::expected<std::string, std::string>;
16
17} // namespace features::gallery::scanner::commonSource: common.hpp
要点:
default_supported_extensions()返回常量引用,避免每次构造向量;gallery.cpp的make_bootstrap_scan_options正是先填options.directory,再调用它补齐默认媒体扩展名(见 gallery.cpp)。calculate_content_fingerprint的注释披露了按构建配置分级的一致性/性能权衡:Debug 构建用路径哈希(快、便于调试时反复扫描),Release 构建对小媒体做完整哈希、对大媒体做五点采样(在大文件上避免全量读取的 I/O 成本)。传入std::stop_token说明指纹计算是可以被取消的长任务,与scan_stop_source打通。
与 Gallery 的集成
gallery.cpp 是 watcher 门面的主要消费方,负责 bootstrap 扫描与整体关闭序列:
- bootstrap 扫描:输出目录就绪后调用
scan_directory(app_state, make_bootstrap_scan_options(output_dir_result.value())),失败仅记录 warn 不阻断("Failed to scan output directory for gallery sync '{}': {}"),完成回调统一挂core::async::log_completion("Gallery bootstrap scan")。 - 扫描取消源:每次初始化重建
app_state.gallery->scan_stop_source = std::stop_source{},保证"本轮 Gallery 扫描拿到未停止的 token";关闭时request_stop()让所有扫描尽快感知退出(见 gallery.cpp)。 - 扫描生命周期锁:关闭时先置
shutdown_requested,再取std::unique_lock<std::shared_mutex> scan_lifetime_lock(app_state.gallery->scan_lifetime_mutex),注释点明目的——"等所有扫描离开共享区后再释放 Media Foundation 和缩略图路径等运行资源"(见 gallery.cpp)。这是共享锁(扫描持共享)+ 独占锁(收尾持独占)的经典读者-写者模式。
API Reference
以下签名全部取自 watcher.hpp。
restore_watchers_from_db(core::AppState& app_state) -> std::expected<void, std::string>
从数据库恢复根目录 watcher 注册信息,不立即启动监听线程。用于应用启动时先恢复 UI/配置状态,再由调用方决定何时启动线程。
register_watcher_for_directory(core::AppState& app_state, const std::filesystem::path& root_directory, const std::optional<ScanOptions>& scan_options = std::nullopt) -> std::expected<void, std::string>
注册一个根目录 watcher。重复调用会更新扫描参数,但不会启动线程。scan_options 为空时保留/使用既有参数。
set_post_scan_callback_for_directory(core::AppState& app_state, const std::filesystem::path& root_directory, std::function<void(const ScanResult&)> post_scan_callback) -> std::expected<void, std::string>
为已注册 watcher 设置扫描完成回调;ScanResult 即增量对账或全量扫描的结果。目录未注册时返回错误。
start_watcher_for_directory(core::AppState& app_state, const std::filesystem::path& root_directory, bool bootstrap_full_scan = true) -> std::expected<void, std::string>
启动已注册 root 的监听线程;bootstrap_full_scan = true 时同时通过 sync::request_full_rescan 请求一次全量对账。
start_registered_watchers(core::AppState& app_state) -> std::expected<void, std::string>
启动所有已注册 watcher,并在启动后补做一次全量扫描。
remove_watcher_for_directory(core::AppState& app_state, const std::filesystem::path& root_directory) -> std::expected<bool, std::string>
停止并移除某目录 watcher。返回 true 表示实际移除了 watcher(false = 本来就不存在),调用方据此判断是否需要清理派生状态。
shutdown_watchers(core::AppState& app_state) -> void
退出时停掉所有 root 监听线程和 Gallery 全局同步编排线程。无返回值,关闭路径不应失败。
begin_manual_file_system_ignore(core::AppState& app_state, const std::filesystem::path& source_path, const std::filesystem::path& destination_path) -> std::expected<void, std::string>
标记应用主动操作的精确源/目标路径进入忽略集合,并清除尚未消费的同路径变化。begin 成功后调用方才应修改磁盘;已进入媒体分析的任务不在此处强制取消。
complete_manual_file_system_ignore(core::AppState& app_state, const std::filesystem::path& source_path, const std::filesystem::path& destination_path) -> std::expected<void, std::string>
磁盘与索引操作完成后立即结束 in-flight,并保留 3000ms 缓冲吸收延迟通知。
is_path_in_manual_file_system_ignore(core::AppState& app_state, const std::filesystem::path& path) -> bool
判断精确路径是否仍由应用主动操作负责;不递归匹配目录后代。
dispatch_manual_scan_changes(core::AppState& app_state, const std::vector<ScanChange>& changes) -> std::expected<void, std::string>
将手动文件操作产出的 ScanChange 分发到对应 root watcher 的 post_scan_callback。
配置常量
| 常量 | 类型 | 默认值 | 说明 |
|---|---|---|---|
kManualFileSystemIgnoreGracePeriod | std::chrono::milliseconds | 3000 | 手动忽略宽限期:应用主动操作结束后继续压制同路径通知的时长,用于吸收晚到的 OS 通知 |
Failure Modes, Edge Cases & Concurrency
| 场景 | 机制 | 源码依据 |
|---|---|---|
| 关闭过程中线程被重新创建 | start_sync_coordinator_if_needed 锁外+锁内双重检查 is_shutdown_requested | watcher.cpp L99-L107 |
| 监听线程卡在阻塞目录读取 | stop_watch_thread 对目录句柄调用 CancelIoEx 后再 join | watcher.cpp L141-L158 |
| 同一 root 并发启动两条监听线程 | joinable() 检查使启动幂等;removal_in_progress 拒绝移除中重启 | watcher.cpp L171-L180 |
| 移除 root 时同步仍在执行 | stop_watcher 先持生命周期锁停线程,再等待 sync_execution_mutex 释放 | watcher.cpp L160-L168 |
| 手动忽略计数泄漏 | cleanup_expired_manual_file_system_ignores 只清除 in_flight_count <= 0 && ignore_until <= now 的项 | watcher.cpp L62-L69 |
| 路径形式不一致导致 begin/complete 对不上 | 登记时同时保存 NormalizePath 规范路径与 NormalizeForComparison 比较键 | watcher.cpp L36-L60 |
| 旧 checkpoint 在换盘/规则变更后失效 | 持久化前校验 volume_identity / journal_id / rule_fingerprint 完整性 | watcher.cpp L71-L94 |
| 非 NTFS / 无 Journal 卷无法增量恢复 | 校验失败即跳过持久化,退化为下次全量扫描 | watcher.cpp L75-L79 |
| 扫描收尾与 Media Foundation 释放竞态 | 关闭时独占 scan_lifetime_mutex,等所有扫描离开共享区 | gallery.cpp L211-L212 |
Tests
仓库提供 tests/scenarios/gallery/watcher_consistency.ts 场景测试,聚焦 watcher 一致性(文件变化最终反映到索引)——这与本页强调的"增量对账 + 手动忽略 + 宽限期"机制直接对应。场景脚本内容未在本次阅读范围内展开。
Related Links
- watcher.hpp — 门面 API 声明
- watcher.cpp — 线程生命周期、手动忽略、恢复检查点实现
- common.hpp — scanner 公共辅助契约
- gallery.cpp — bootstrap 扫描与关闭序列集成
- 002_watch_root_recovery_state.sql — 恢复检查点表迁移
- watcher_consistency.ts — watcher 一致性场景测试