244 lines
9.3 KiB
C#
244 lines
9.3 KiB
C#
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);
|
||
}
|
||
}
|