From c8a3a79ce3356c8b460510cd110967ebec8a4acf Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Sat, 13 Dec 2025 09:43:09 +1030 Subject: [PATCH] Allow SnapshotService operations to be cancellable Restart BuildSnapshot on debounced file change --- TinfoilVibeServer.sln.DotSettings.user | 4 + TinfoilVibeServer/Models/SnapshotOptions.cs | 25 ++ TinfoilVibeServer/Services/ArchiveHandler.cs | 8 +- TinfoilVibeServer/Services/SnapshotService.cs | 333 +++++++++++------- TinfoilVibeServer/Utilities/FileLockHelper.cs | 46 +++ 5 files changed, 282 insertions(+), 134 deletions(-) create mode 100644 TinfoilVibeServer/Utilities/FileLockHelper.cs diff --git a/TinfoilVibeServer.sln.DotSettings.user b/TinfoilVibeServer.sln.DotSettings.user index 31bbd05..8b9ba5c 100644 --- a/TinfoilVibeServer.sln.DotSettings.user +++ b/TinfoilVibeServer.sln.DotSettings.user @@ -11,6 +11,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -30,11 +31,13 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -100,6 +103,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/TinfoilVibeServer/Models/SnapshotOptions.cs b/TinfoilVibeServer/Models/SnapshotOptions.cs index d93da6a..6188a1d 100644 --- a/TinfoilVibeServer/Models/SnapshotOptions.cs +++ b/TinfoilVibeServer/Models/SnapshotOptions.cs @@ -93,6 +93,31 @@ public sealed class SnapshotOptions : INotifyPropertyChanged } } + #region FileSystemWatcher options + + /// + /// How many times to retry when the snapshot file is locked. + /// + public int MaxRetryCount { get; set; } = 3; + + /// + /// Base debounce timeout (ms) used by the file‑watcher. + /// The retry interval will be this value multiplied by . + /// + public int DebounceTimeoutMs { get; set; } = 500; // existing value – keep it + + /// + /// Multiplier used to compute the retry wait time: `wait = DebounceTimeoutMs * RetryMultiplier`. + /// + public double RetryMultiplier { get; set; } = 1.5; + + /// + /// Optional custom path to the snapshot file; the service will try to write there. + /// + public string SnapshotFilePath { get; set; } = "snapshots/latest.snapshot"; + + #endregion + public event PropertyChangedEventHandler? PropertyChanged; private void OnPropertyChanged(string propertyName) => diff --git a/TinfoilVibeServer/Services/ArchiveHandler.cs b/TinfoilVibeServer/Services/ArchiveHandler.cs index 56940f5..c3fbe96 100644 --- a/TinfoilVibeServer/Services/ArchiveHandler.cs +++ b/TinfoilVibeServer/Services/ArchiveHandler.cs @@ -126,16 +126,20 @@ public sealed class ArchiveHandler : IArchiveHandler var archiveEntryName = archiveEntry.Name; try { - using var rewindableWrapper = new RewindableStream(archiveEntry.Stream, () => + Stream? wrapper = null; + + wrapper = new RewindableStream(archiveEntry.Stream, () => { _logger.LogDebug("Rewinding archive entry {ArchiveEntry}", archiveEntryName); return romArchive.GetEntries().First(romArchiveEntry => romArchiveEntry.Name == archiveEntryName).Stream; },10*1024*1024, archiveEntry.Stream.Length); - var title = _nspExtractor.ExtractFromStream(rewindableWrapper); + //wrapper = new SeekableBufferedStream(archiveEntry.Stream, archiveEntry.Stream.Length, 10*1024*1024, true); + var title = _nspExtractor.ExtractFromStream(wrapper); if (title != null) { titles.Add((archiveEntry.Name, archiveEntry.Stream.Length, title)); } + wrapper?.Dispose(); } catch (IncompleteArchiveException incompleteArchiveException) { diff --git a/TinfoilVibeServer/Services/SnapshotService.cs b/TinfoilVibeServer/Services/SnapshotService.cs index d81b4cb..266499b 100644 --- a/TinfoilVibeServer/Services/SnapshotService.cs +++ b/TinfoilVibeServer/Services/SnapshotService.cs @@ -21,16 +21,21 @@ public interface ISnapshotService { event EventHandler SnapshotRebuilt; // event raised after a rebuild void RebuildSnapshot(); + Task RebuildSnapshotAsync(CancellationToken cancellationToken = default); SnapshotService.ROMSnapshot GetSnapshot(); Task AddToSnapshotAsync(FileEntry entry); - Task BuildSnapshotAsync(); + Task BuildSnapshotAsync(CancellationToken cancellationToken = default); void GetArchiveName(string titleId); char GetArchivePathSeparator(); + void Start(); + void Stop(); } /// -/// Keeps an in‑memory snapshot, watches the filesystem for changes, and +/// Watches a folder for changes and rebuilds a snapshot when the first change after a debounce window occurs. +/// While a rebuild is in progress, subsequent file changes are ignored (they will be processed once the current +/// rebuild finishes and a new debounce window starts). /// only re‑processes a file if its hash changed. /// public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService @@ -40,7 +45,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ /* ============================================================== * 1️⃣ FileSystemWatcher * ============================================================== */ - private readonly List _watchers = new(); + private readonly List _watchers = []; #endregion @@ -69,8 +74,15 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ public event EventHandler? SnapshotRebuilding; private readonly SemaphoreSlim _snapshotFileSemaphore = new(1, 1); + private const char ArchivePathSeparator = '|'; + // Cache key used to keep the debounce flag + private const string DebounceKey = "SnapshotService.IsDebouncing"; + private const string BuildKey = "SnapshotService.IsBuilding"; + private CancellationTokenSource _cancellation = new(); + private Task? _currentBuildTask; + public char GetArchivePathSeparator() => ArchivePathSeparator; #endregion @@ -128,7 +140,26 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ AddWatchDirectory(path); } } + #region Public API + public void Start() => _watchers.ForEach(watcher => watcher.EnableRaisingEvents = true); + + public void Stop() + { + foreach (var fileSystemWatcher in _watchers) + { + fileSystemWatcher.EnableRaisingEvents = false; + } + _cancellation.Cancel(); + try { _currentBuildTask?.Wait(); } + catch + { + // ignored + } + } + + #endregion + // --------- Private helpers --------- private void OnOptionsChanged(string? propertyName) { @@ -151,12 +182,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ AddWatchDirectory(newWatchedDirectory); } - BuildSnapshotAsync(); // rebuild everything - PersistSnapshotAsync(); + _ = BuildSnapshotAsync(_cancellation.Token); // rebuild everything + PersistSnapshotAsync(_cancellation.Token); } - #region FileSystemWatcher + #region FileSystemWatcher helpers /* ============================================================== * 5️⃣ FileSystemWatcher helpers @@ -193,18 +224,38 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e); private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e); + /// + /// Rebuild the snapshot, if rebuild in process, cancel it and restart + /// + /// private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs) { + // If a rebuild is already underway, ignore the event + if (_currentBuildTask is { IsCompleted: false }) + { + _logger.LogInformation( + "File system event {ChangeType} on {Path} triggered, but build is in progress, skipping snapshot update", + fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath); + return; + } + + // Schedule a rebuild only if we’re not already debouncing + if (_debouncerCache.TryGetValue(DebounceKey, out bool isDebouncing) && isDebouncing) + return; + // If a rebuild is in progress, ignore the event immediately if (_buildLock.CurrentCount == 0) // lock held by a rebuild { _logger.LogInformation( - "File system event {ChangeType} on {Path} ignored because a rebuild is already in progress", + "File system event {ChangeType} on {Path} triggered, restart Build Task on next completed entry", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath); - return; + _cancellation.Cancel(); + _buildLock.Wait(); + _buildLock.Release(); + _cancellation.Dispose(); + _cancellation = new CancellationTokenSource(); } - SnapshotRebuilding?.Invoke(this, fileSystemEventArgs); CancellationTokenSource cts = new(); using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath) @@ -217,62 +268,27 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ new PostEvictionCallbackRegistration { EvictionCallback = - (_, value, reason, _) => + (_, _, reason, _) => { if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired)) return; - if (value is FileSystemEventArgs args) - { - if (IsFileLocked(args.FullPath)) - { - _logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath); - using var rebounce = _debouncerCache.CreateEntry(args.FullPath) - .AddExpirationToken(new CancellationChangeToken(cts.Token)) - .SetValue(args); - cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs)); - } - } - - RebuildSnapshot(); + SnapshotRebuilding?.Invoke(this, fileSystemEventArgs); + // Kick off the rebuild asynchronously + _currentBuildTask = RebuildSnapshotAsync(_cancellation.Token); } } } }); cts.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs)); + _logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss.fff")); } - private static bool IsFileLocked(string filePath) - { - FileStream? stream = null; - var file = new FileInfo(filePath); - - try - { - stream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None); - } - catch (IOException) - { - return true; - } - finally - { - stream?.Close(); - } - - return false; - } - private const int DebounceMs = 400; private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true }; private int SnapshotFileLockTimeout { get; } = 1000; - private void DebounceElapsed() - { - UpdateSnapshot(); - } - #endregion #region Snapshot logic @@ -280,20 +296,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ public Task AddToSnapshotAsync(FileEntry entry) { // Update lookup tables - if (entry.Hash != null) - { - var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : entry.Path); - - _cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles); - _hashCache[entry.Hash] = entry.Path; - _sizeLookup[entry.Hash] = entry.Size; - } - else + if (entry.Hash == null) { _logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path); return Task.CompletedTask; } + var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : entry.Path); + + _cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles); + _hashCache[entry.Hash] = entry.Path; + _sizeLookup[entry.Hash] = entry.Size; + if (entry.Path.Contains(ArchivePathSeparator)) { var filename = entry.Path.Split(ArchivePathSeparator)[0]; @@ -322,13 +336,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ * 6️⃣ Snapshot build / persistence helpers * ============================================================== */ /// Builds _cache and _hashCache based on directory configuration - public Task BuildSnapshotAsync() + /// + public async Task BuildSnapshotAsync(CancellationToken cancellationToken = default) { // Acquire the rebuild lock – if we cannot, skip this build. - if (!_buildLock.Wait(0)) + if (!await _buildLock.WaitAsync(0, cancellationToken)) { _logger.LogInformation("BuildSnapshotAsync called while rebuild in progress, ignoring."); - return Task.CompletedTask; + return; } try @@ -337,7 +352,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ var index = LoadSnapshotIndex(); var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories); var fileInfo = new FileInfo(_snapshotPath); - bool snapshotVerified = fileInfo.Exists; + var snapshotVerified = fileInfo.Exists; if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc) { if (index.Count != 0) @@ -345,11 +360,19 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ foreach (var dir in _options.RootDirectories) { // Snapshot is older than the latest modified file in the directory - var lastOrDefault = BuildSnapshot(dir).LastOrDefault(); - if (lastOrDefault != null && !index.TryGetValue(lastOrDefault.Path, out _)) + try { - snapshotVerified = false; - _logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir); + var lastOrDefault = BuildSnapshot(dir, cancellationToken).LastOrDefault(); + if (lastOrDefault != null && !index.TryGetValue(lastOrDefault.Path, out _)) + { + snapshotVerified = false; + _logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir); + } + } + catch (OperationCanceledException operationCanceledException) + { + _logger.LogInformation("Build Cancelled while building snapshot from directory {Directory}: {Message}", dir, operationCanceledException.Message); + break; } } } @@ -361,7 +384,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ var entries = new List(); foreach (var dir in _options.RootDirectories) { - foreach (var entry in BuildSnapshot(dir)) + foreach (var entry in BuildSnapshot(dir, cancellationToken)) { if (entry != null) entries.Add(entry); } @@ -372,13 +395,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ SnapshotRebuilt?.Invoke(this, EventArgs.Empty); } - PersistSnapshotAsync(); + await PersistSnapshotAsync(cancellationToken); } finally { _buildLock.Release(); } - return Task.CompletedTask; } public void GetArchiveName(string titleId) @@ -388,7 +410,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ // Returns List of FileEntry that do not have a hash in the cache // Each entry that has not been added to the lookup table is added to the cache - private IEnumerable BuildSnapshot(string dir) + private IEnumerable BuildSnapshot(string dir, CancellationToken cancellationToken = default) { var processedFiles = new HashSet(); @@ -399,7 +421,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ return fileInfo.LastWriteTimeUtc; })) { - var hash = string.Empty; + string hash; var ext = Path.GetExtension(file).ToLowerInvariant(); if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext))) @@ -416,6 +438,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ //var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result; var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]); AddToSnapshotAsync(fileEntryFromFileName); + cancellationToken.ThrowIfCancellationRequested(); yield return fileEntryFromFileName; continue; } @@ -433,10 +456,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ var title = _nspExtractor.ExtractFromStream(nspStream); if (title != null) { - var archiveEntry = new FileEntry(file, nspStreamLength, hash, [title]); - AddToSnapshotAsync(archiveEntry); + var romEntry = new FileEntry(file, nspStreamLength, hash, [title]); + AddToSnapshotAsync(romEntry); titles.Add((title.TitleId, nspStreamLength, title)); - yield return archiveEntry; + cancellationToken.ThrowIfCancellationRequested(); + yield return romEntry; continue; } } @@ -448,11 +472,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ if (processedFiles.Contains(file)) continue; _logger.LogDebug("Extracting hash for {File}", file); Stopwatch stopwatch = Stopwatch.StartNew(); - hash = ComputeFirstStreamHash(file); + hash = ComputeFirstStreamHashAsync(file, cancellationToken).Result; stopwatch.Stop(); _logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds); if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path) { + cancellationToken.ThrowIfCancellationRequested(); yield return null; continue; } @@ -460,10 +485,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null; try { - Stopwatch stopwatch2 = Stopwatch.StartNew(); - titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file); - stopwatch2.Stop(); - _logger.LogDebug("Extracted title infos for {File} in {Time}ms", file, stopwatch2.ElapsedMilliseconds); + titlesEnumerable = TryExtractTitleInfosWithRetryAsync(file, cancellationToken).Result; // if it was multipart, add multiparts to processedFiles var directoryName = Path.GetDirectoryName(file); if (directoryName != null) @@ -488,17 +510,12 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ { var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]); AddToSnapshotAsync(archiveEntry); + cancellationToken.ThrowIfCancellationRequested(); yield return archiveEntry; } - continue; - /*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList()); - AddToSnapshotAsync(fileEntry); - yield return fileEntry;*/ - } - else - { - continue; } + + continue; } if (titles.Count == 0) @@ -508,11 +525,36 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ else { _logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash); + cancellationToken.ThrowIfCancellationRequested(); yield return new FileEntry(file, titles.Select((tuple, _) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, _) => tuple.Item3).ToList()); } } } + private async Task?> TryExtractTitleInfosWithRetryAsync(string file, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++) + { + try + { + var stopwatch2 = Stopwatch.StartNew(); + var titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file); + stopwatch2.Stop(); + _logger.LogDebug("Extracted title infos for {File} in {Time}ms", file, stopwatch2.ElapsedMilliseconds); + return titlesEnumerable; + } + catch (IOException ex) when (attempt < _options.MaxRetryCount - 1) + { + var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier); + _logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.", + attempt + 1, file, delay); + await Task.Delay(delay, cancellationToken); + } + } + return null; + } + private async Task ValidateSnapshotAsync(CancellationToken cancellationToken = default) { await Task.CompletedTask; @@ -520,15 +562,13 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ private string ComputeFirstStreamHash(Stream nspStream) => _nspExtractor.ExtractHashFromStream(nspStream); - private void UpdateSnapshot() => BuildSnapshotAsync(); - private IEnumerable GetEntries() { foreach (var kv in _cache.OrderByDescending(pair => pair.Value.LastModified)) yield return new FileEntry(kv.Key, kv.Value.Size, kv.Value.Hash, kv.Value.NcaMetadataWithHash); } - private Task PersistSnapshotAsync() + private Task PersistSnapshotAsync(CancellationToken cancellationToken = default) { if (_debouncerCache.TryGetValue(_jsonPath, out _)) { @@ -561,7 +601,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ { try { - if (IsFileLocked(filePath)) + if (FileLockHelper.IsFileLocked(filePath)) { _logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath); } @@ -589,16 +629,15 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ { using var sha = SHA256.Create(); using var stream = File.OpenRead(filePath); - var hash = sha.ComputeHash(stream); - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + var hash = SHA256.HashData(stream); + return Convert.ToHexStringLower(hash); } private static string ComputeSnapshotHash(IEnumerable entries) { var json = JsonSerializer.Serialize(entries); - using var sha = SHA256.Create(); - var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json)); - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)); + return Convert.ToHexStringLower(hash); } /// @@ -693,13 +732,19 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ } public void RebuildSnapshot() + { + RebuildSnapshotAsync().Wait(); + } + + public async Task RebuildSnapshotAsync(CancellationToken cancellationToken = default) { // Fast path: if we already have the lock, just log and exit. - if (!_buildLock.Wait(0)) + if (!await _buildLock.WaitAsync(0, cancellationToken)) { _logger.LogInformation("RebuildSnapshot called while a rebuild is already in progress, ignoring."); return; } + try { // 1️⃣ Flush the old in‑memory snapshot @@ -710,8 +755,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ //_failedAttempts.Clear(); // if you keep per‑user counters // 2️⃣ Re‑build from disk again - BuildSnapshotAsync().Wait(); // synchronous – we already own the lock - PersistSnapshotAsync().Wait(); // same + _buildLock.Release(); + await BuildSnapshotAsync(cancellationToken); + await PersistSnapshotAsync(cancellationToken); SnapshotRebuilt?.Invoke(this, EventArgs.Empty); } finally @@ -756,14 +802,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ return new ROMSnapshot(); } + #region IDisposable public void Dispose() { - foreach (var watcher in _watchers) + Stop(); + foreach (var fileSystemWatcher in _watchers) { - watcher.Dispose(); + fileSystemWatcher.Dispose(); } + _cancellation.Dispose(); } - + #endregion + /// /// Represents a single ROM/archive entry in the snapshot cache. /// @@ -771,44 +821,62 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ // File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class) - private string ComputeFirstStreamHash(string filePath) + private async Task ComputeFirstStreamHashAsync(string filePath, CancellationToken cancellationToken = default) { - // Only treat NSP/XCI/XCZ as “first‑stream” files - var ext = Path.GetExtension(filePath).ToLowerInvariant(); - if (ext is not ".nsp" and not ".xci" and not ".xcz") + cancellationToken.ThrowIfCancellationRequested(); + for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++) { - // Open the NSP/XCI with LibHac and read the first stream. - // The first stream is the first entry returned by GetContentInfos(). try { - using var reader = new RomArchiveReader(filePath); - - var first = reader.GetEntries().FirstOrDefault(); - if (first == null) return ComputeFullHash(filePath); - - //using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true); - using var rewindableWrapper = new RewindableStream(first.Stream, () => + if (FileLockHelper.IsFileLocked(filePath)) { - return reader.GetEntries().FirstOrDefault().Stream; - }, 10*1024*1024, first.Stream.Length); - var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper); - return hash; + throw new IOException("File is locked"); + } + + // Only treat NSP/XCI/XCZ as “first‑stream” files + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + if (ext is not ".nsp" and not ".xci" and not ".xcz") + { + // Open the NSP/XCI with LibHac and read the first stream. + // The first stream is the first entry returned by GetContentInfos(). + try + { + using var reader = new RomArchiveReader(filePath); + + var first = reader.GetEntries().FirstOrDefault(); + if (first == null) return ComputeFullHash(filePath); + + //using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true); + await using var rewindableWrapper = new RewindableStream(first.Stream, () => { return reader.GetEntries().FirstOrDefault().Stream; }, 10 * 1024 * 1024, first.Stream.Length); + var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper); + return hash; + } + catch + { + // On error, fall back to the full file hash + await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs); + return ncaMetadataWithHash?.Hash ?? string.Empty; + } + } + else + { + await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs); + return ncaMetadataWithHash?.Hash ?? string.Empty; + } } - catch + catch (IOException ex) when (attempt < _options.MaxRetryCount - 1) { - // On error, fall back to the full file hash - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs); - return ncaMetadataWithHash?.Hash ?? string.Empty; + var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier); + _logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.", + attempt + 1, filePath, delay); + await Task.Delay(delay, cancellationToken); } } - else - { - using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs); - return ncaMetadataWithHash?.Hash ?? string.Empty; - } + return string.Empty; + throw new IOException($"Failed to compute hash for {filePath} after {_options.MaxRetryCount} attempts"); } private static string ComputeFullHash(string filePath) @@ -831,8 +899,9 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ _ = Task.Run(async () => { await ValidateSnapshotAsync(cancellationToken); - await BuildSnapshotAsync(); - await PersistSnapshotAsync(); + _currentBuildTask = BuildSnapshotAsync(_cancellation.Token); + await _currentBuildTask.WaitAsync(_cancellation.Token); + await PersistSnapshotAsync(_cancellation.Token); }, cancellationToken); // initial scan /*var timer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);*/ await Task.CompletedTask; diff --git a/TinfoilVibeServer/Utilities/FileLockHelper.cs b/TinfoilVibeServer/Utilities/FileLockHelper.cs new file mode 100644 index 0000000..9bac39b --- /dev/null +++ b/TinfoilVibeServer/Utilities/FileLockHelper.cs @@ -0,0 +1,46 @@ +namespace TinfoilVibeServer.Utilities; + +public static class FileLockHelper +{ + /// + /// Checks if a file is locked (open by another process). + /// Works on Windows and Unix (Linux / macOS). + /// + public static bool IsFileLocked(string filePath, ILogger? logger = null) + { + // Quick sanity check: the file must exist + if (!File.Exists(filePath)) + return false; + + try + { + using var stream = File.Open(filePath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.None); + // If we get here, the file is not locked. + stream.Close(); + + return false; + } + catch (IOException ioEx) + { + // On Windows, error code 32 (sharing violation) or 33 (lock violation) + // On Unix, EINVAL or EACCES + // The .NET exception already hides the native error, so we just log it. + logger?.LogDebug(ioEx, "File '{Path}' is locked.", filePath); + return true; + } + catch (UnauthorizedAccessException uaEx) + { + logger?.LogDebug(uaEx, "File '{Path}' access denied (likely locked).", filePath); + return true; + } + catch (Exception ex) + { + // Unexpected: we conservatively say it’s locked + logger?.LogError(ex, "Unexpected error while checking lock on '{Path}'. Assuming locked.", filePath); + return true; + } + } +} \ No newline at end of file