Files
fluid-explorer/Services/Search/Usn/DirectoryChangeWatcher.cs
T

244 lines
9.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
namespace FluidExplorer.Services.Search.Usn;
/// <summary>
/// 增量监听的<b>回退通道</b>:ReadDirectoryChangesW。
///
/// 什么情况下用:USN 变更日志不可用(卷上没日志且没权限创建、被策略禁用、日志刚被删除等)。
/// 这条路径不需要任何特殊权限,普通用户也能跑,从而保证「索引实时」这个卖点不落空。
///
/// 与 USN 的差别与应对:
/// * RDCW 只给相对<b>路径</b>,不给 FRN。这里用 CreateFileW(FILE_READ_ATTRIBUTES) +
/// GetFileInformationByHandle 取句柄上的 FileIndex —— 它与 USN 的 FRN 完全同口径
/// (低 48 位记录号 + 高 16 位序列号),因此能直接命中同一个索引条目。
/// * 新增/改名/内容变化都伴随着文件仍然存在,可以 stat 出来 → 直接 upsert。
/// * 删除只剩一个路径(stat 必然失败),无法反查 FRN。应对:把受影响的<b>目录</b>记下来,
/// 批处理结束后对该目录做一次「磁盘现状 vs 索引」的对账(<see cref="UsnVolumeIndex.ResyncDirectoryFromDisk"/>),
/// 只把索引里存在、磁盘上已消失的孩子打墓碑。对账按目录去重、每轮有上限,代价可控。
/// </summary>
internal sealed class DirectoryChangeWatcher
{
private const int ReadBufferSize = 64 * 1024;
/// <summary>每轮最多对账多少个目录,防止一次批量删除把 CPU 打满。</summary>
private const int MaxResyncDirectoriesPerPass = 256;
private static readonly uint NotifyFilter =
UsnNative.FILE_NOTIFY_CHANGE_FILE_NAME |
UsnNative.FILE_NOTIFY_CHANGE_DIR_NAME |
UsnNative.FILE_NOTIFY_CHANGE_SIZE |
UsnNative.FILE_NOTIFY_CHANGE_LAST_WRITE |
UsnNative.FILE_NOTIFY_CHANGE_ATTRIBUTES;
private readonly UsnVolumeIndex _index;
private readonly string _root;
private readonly ConcurrentDictionary<string, ulong> _directoryFrnCache = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _pendingResync = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _resyncScratch = [];
private SafeFileHandle? _handle;
private volatile bool _stopRequested;
internal DirectoryChangeWatcher(UsnVolumeIndex index)
{
_index = index;
_root = index.VolumeRoot;
}
/// <summary>打断阻塞中的 ReadDirectoryChangesW:置停止标志 + 关目录句柄(双保险)。</summary>
internal void RequestStop()
{
_stopRequested = true;
Interlocked.Exchange(ref _handle, null)?.Dispose();
}
/// <summary>打开卷根目录句柄。返回 false 时 <paramref name="error"/> 给出中文原因。</summary>
internal bool TryOpen(out string? error)
{
error = null;
var handle = UsnNative.CreateFileW(
_root,
UsnNative.FILE_LIST_DIRECTORY,
UsnNative.FILE_SHARE_READ | UsnNative.FILE_SHARE_WRITE | UsnNative.FILE_SHARE_DELETE,
IntPtr.Zero,
UsnNative.OPEN_EXISTING,
UsnNative.FILE_FLAG_BACKUP_SEMANTICS, // 打开目录必须带这个
IntPtr.Zero);
if (handle.IsInvalid)
{
int openError = Marshal.GetLastWin32Error();
handle.Dispose();
error = UsnNative.DescribeError(openError, $"无法以 FILE_LIST_DIRECTORY 打开 {_root}");
return false;
}
Volatile.Write(ref _handle, handle);
return true;
}
/// <summary>阻塞式监听循环,直到 <see cref="RequestStop"/> 或句柄被关闭。</summary>
internal void Run()
{
var handle = Volatile.Read(ref _handle);
if (handle is null) return;
var buffer = new byte[ReadBufferSize];
try
{
while (!_stopRequested)
{
uint returned;
bool ok;
unsafe
{
fixed (byte* p = buffer)
{
ok = UsnNative.ReadDirectoryChangesW(
handle, p, (uint)buffer.Length, true, NotifyFilter, out returned, IntPtr.Zero, IntPtr.Zero);
}
}
if (!ok)
{
int readError = Marshal.GetLastWin32Error();
if (_stopRequested
|| readError is UsnNative.ERROR_OPERATION_ABORTED or UsnNative.ERROR_CANCELLED or UsnNative.ERROR_INVALID_HANDLE)
{
break;
}
// 单次失败不放弃:报给 UI 后继续(例如枚举期间目录被临时独占)
_index.ReportWatchIssue($"ReadDirectoryChangesW 出错:{UsnNative.DescribeError(readError)}");
continue;
}
if (returned == 0)
{
// 变更缓冲区溢出:期间的事件已经丢了,只能提示 UI 做一次重建
_index.ReportWatchIssue("变更缓冲区溢出,部分变更已丢失,建议重建索引以保持一致。");
continue;
}
ProcessEvents(buffer.AsSpan(0, (int)returned));
FlushPendingResync();
}
}
finally
{
Interlocked.Exchange(ref _handle, null)?.Dispose();
}
}
private void ProcessEvents(ReadOnlySpan<byte> buffer)
{
int offset = 0;
while (true)
{
if (offset + UsnNative.FileNotifyInformation.HeaderSize > buffer.Length) break;
uint nextEntry = BinaryPrimitives.ReadUInt32LittleEndian(buffer[offset..]);
uint action = BinaryPrimitives.ReadUInt32LittleEndian(buffer[(offset + 4)..]);
int nameBytes = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer[(offset + 8)..]);
int nameOffset = offset + UsnNative.FileNotifyInformation.HeaderSize;
if (nameBytes <= 0 || nameOffset + nameBytes > buffer.Length) break;
// 文件名是相对被监听目录(这里是卷根)的路径,形如 "Users\Public\x.txt",不以 NUL 结尾
var relative = MemoryMarshal.Cast<byte, char>(buffer.Slice(nameOffset, nameBytes));
Handle(relative, action);
if (nextEntry == 0) break;
offset += (int)nextEntry;
}
}
private void Handle(ReadOnlySpan<char> relative, uint action)
{
if (relative.Length == 0) return;
var fullPath = string.Concat(_root, relative);
switch (action)
{
case UsnNative.FILE_ACTION_ADDED:
case UsnNative.FILE_ACTION_RENAMED_NEW_NAME:
case UsnNative.FILE_ACTION_MODIFIED:
UpsertFromDisk(fullPath);
break;
case UsnNative.FILE_ACTION_REMOVED:
case UsnNative.FILE_ACTION_RENAMED_OLD_NAME:
QueueDirectoryResync(fullPath);
break;
}
}
/// <summary>按路径 stat 出 FRN + 元数据,然后按 FRN 落入索引(存在则更新,不存在则新增)。</summary>
private void UpsertFromDisk(string fullPath)
{
if (!UsnNative.TryStatPath(fullPath, out var info)) return; // 文件已再次消失等,忽略
var name = Path.GetFileName(fullPath.AsSpan());
if (name.Length == 0) return;
ulong parentFrn = 0;
var parentPath = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(parentPath) && !parentPath.Equals(_root, StringComparison.OrdinalIgnoreCase))
{
parentFrn = GetDirectoryFrn(parentPath);
}
bool isDirectory = (info.FileAttributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
_index.UpsertFromFileSystem(
info.FileIndex,
parentFrn,
name,
isDirectory,
isDirectory ? 0 : info.FileSize,
UsnVolumeIndex.FileTimeToTicks(info.LastWriteTime.ToInt64()),
info.FileAttributes);
if (isDirectory) _directoryFrnCache[fullPath] = UsnNative.NormalizeFrn(info.FileIndex);
}
private ulong GetDirectoryFrn(string directoryPath)
{
if (_directoryFrnCache.TryGetValue(directoryPath, out var cached)) return cached;
if (!UsnNative.TryStatPath(directoryPath, out var info)) return 0;
var frn = UsnNative.NormalizeFrn(info.FileIndex);
_directoryFrnCache[directoryPath] = frn;
return frn;
}
private void QueueDirectoryResync(string fullPath)
{
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory)) _pendingResync.Add(directory);
}
private void FlushPendingResync()
{
if (_pendingResync.Count == 0) return;
_resyncScratch.Clear();
foreach (var directory in _pendingResync)
{
_resyncScratch.Add(directory);
if (_resyncScratch.Count >= MaxResyncDirectoriesPerPass) break;
}
int removed = 0;
foreach (var directory in _resyncScratch)
{
_pendingResync.Remove(directory);
removed += _index.ResyncDirectoryFromDisk(directory);
}
if (removed > 0) _index.ReportWatchIssue(null, removed);
}
}