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