3 Commits

Author SHA1 Message Date
ecenshu eafb28846f feature/bugfix_selflock (#10)
Build & Push Docker image / build-and-push (push) Successful in 5m15s
ci / build_linux (push) Successful in 4m4s
Check lock against base file in multipart scenario
Explicitly close references when using IArchive

Reviewed-on: #10
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-12-13 23:44:46 +00:00
ecenshu b048705024 Implement DisposeAsync (#8)
Build & Push Docker image / build-and-push (push) Successful in 15m1s
ci / build_linux (push) Successful in 5m31s
Log when snapshot is added

Reviewed-on: #8
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-12-13 01:27:05 +00:00
ecenshu de438f8905 When a file is locked during hash calculation, if retries fails then do not throw exception out but rather return null hash (#7)
Build & Push Docker image / build-and-push (push) Successful in 10m7s
ci / build_linux (push) Successful in 3m42s
Reviewed-on: #7
Co-authored-by: Huy Nguyen <ecenshu@gmail.com>
Co-committed-by: Huy Nguyen <ecenshu@gmail.com>
2025-12-13 00:09:50 +00:00
2 changed files with 120 additions and 68 deletions
+27 -8
View File
@@ -17,12 +17,11 @@ namespace TinfoilVibeServer.Services
/// <summary> /// <summary>
/// Reads a ROM archive (zip / 7z / rar) from a stream. /// Reads a ROM archive (zip / 7z / rar) from a stream.
/// </summary> /// </summary>
public sealed class RomArchiveReader : IDisposable public sealed class RomArchiveReader : IDisposable, IAsyncDisposable
{ {
private readonly ZipArchive? _zipArchive; private readonly ZipArchive? _zipArchive;
private readonly IArchive? _archive; private readonly IArchive? _archive;
private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
private readonly ICollection<Stream>? _partStreams;
public RomArchiveReader(string path) public RomArchiveReader(string path)
{ {
@@ -55,8 +54,16 @@ namespace TinfoilVibeServer.Services
stream.CopyTo(ms); stream.CopyTo(ms);
ms.Position = 0; ms.Position = 0;
_archiveStream = ms; _archiveStream = ms;
if (stream is IAsyncDisposable asyncDisposable)
{
var disposeAsync = asyncDisposable.DisposeAsync();
disposeAsync.ConfigureAwait(false);
}
else
{
stream.Dispose(); // original nonseekable stream no longer needed stream.Dispose(); // original nonseekable stream no longer needed
} }
}
else else
{ {
_archiveStream = stream; _archiveStream = stream;
@@ -73,28 +80,29 @@ namespace TinfoilVibeServer.Services
// Detect whether the file is a multipart RAR and wrap it if necessary // Detect whether the file is a multipart RAR and wrap it if necessary
private static IArchive DetectAndWrap(string path) private static IArchive DetectAndWrap(string path)
{ {
string ext = Path.GetExtension(path).ToLowerInvariant(); var ext = Path.GetExtension(path).ToLowerInvariant();
if (ext == ".rar" || ext == ".r00" || ext == ".r01" || ext == ".r02") if (ext is ".rar" or ".r00" or ".r01" or ".r02")
{ {
var dir = Path.GetDirectoryName(path)!; var dir = Path.GetDirectoryName(path)!;
var fileName = Path.GetFileName(path); var fileName = Path.GetFileName(path);
// ----- 1️⃣ Determine the base name (everything before the first ".rar" or ".partNN") ----- // ----- 1️⃣ Determine the base name (everything before the first ".rar" or ".partNN") -----
string baseName = MultiPartRarHelper.GetBaseNameForRarVolume(fileName); var baseName = MultiPartRarHelper.GetBaseNameForRarVolume(fileName);
// Any file that ends with .rar or .rNN could be the start of a multipart set // Any file that ends with .rar or .rNN could be the start of a multipart set
// Let MultiPartRarStream decide which parts belong together. // Let MultiPartRarStream decide which parts belong together.
var volumes = MultiPartRarHelper.DiscoverVolumes(dir, baseName); var volumes = MultiPartRarHelper.DiscoverVolumes(dir, baseName);
if (volumes.Count is 0 or 1) if (volumes.Count is 0 or 1)
{ {
return ArchiveFactory.Open(path); return ArchiveFactory.Open(path, new ReaderOptions { LeaveStreamOpen = false });
} }
var streams = new List<Stream>(volumes.Count); var streams = new List<Stream>(volumes.Count);
foreach (var volume in volumes) foreach (var volume in volumes)
{ {
streams.Add(new FileStream(volume, FileMode.Open, FileAccess.Read, FileShare.Read)); // todo: check all streams for read validity? The rar may be available but the parts are not all downloaded yet
streams.Add(new FileStream(volume, FileMode.Open, FileAccess.Read, FileShare.Read, 10*1024*1024, FileOptions.Asynchronous));
} }
return ArchiveFactory.Open(streams, new ReaderOptions { LeaveStreamOpen = false }); return ArchiveFactory.Open(streams, new ReaderOptions { LeaveStreamOpen = false });
@@ -102,7 +110,7 @@ namespace TinfoilVibeServer.Services
// Normal singlefile archive (zip, 7z, singlerar, etc.) // Normal singlefile archive (zip, 7z, singlerar, etc.)
using var archiveStream = File.OpenRead(path); using var archiveStream = File.OpenRead(path);
return ArchiveFactory.Open(archiveStream); return ArchiveFactory.Open(archiveStream, new ReaderOptions { LeaveStreamOpen = false });
} }
private static Stream? GetPart(int arg) private static Stream? GetPart(int arg)
@@ -164,9 +172,20 @@ namespace TinfoilVibeServer.Services
/// Disposes the underlying archive objects and the stream(s). /// Disposes the underlying archive objects and the stream(s).
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{
DisposeAsync().GetAwaiter().GetResult();
}
public async ValueTask DisposeAsync()
{ {
_zipArchive?.Dispose(); _zipArchive?.Dispose();
_archive?.Dispose(); _archive?.Dispose();
// Dispose of the underlying stream (may support async)
if (_archiveStream is IAsyncDisposable asyncStream)
await asyncStream.DisposeAsync().ConfigureAwait(false);
else
// ReSharper disable once MethodHasAsyncOverload
_archiveStream?.Dispose(); _archiveStream?.Dispose();
} }
+72 -39
View File
@@ -24,7 +24,7 @@ public interface ISnapshotService
Task RebuildSnapshotAsync(CancellationToken cancellationToken = default); Task RebuildSnapshotAsync(CancellationToken cancellationToken = default);
SnapshotService.ROMSnapshot GetSnapshot(); SnapshotService.ROMSnapshot GetSnapshot();
Task AddToSnapshotAsync(FileEntry entry); Task AddToSnapshotAsync(FileEntry entry, CancellationToken cancellationToken = default);
Task BuildSnapshotAsync(CancellationToken cancellationToken = default); Task BuildSnapshotAsync(CancellationToken cancellationToken = default);
void GetArchiveName(string titleId); void GetArchiveName(string titleId);
char GetArchivePathSeparator(); char GetArchivePathSeparator();
@@ -221,8 +221,23 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_watchers.Remove(fileSystemWatchers); _watchers.Remove(fileSystemWatchers);
} }
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e); private void OnChanged(object? _, FileSystemEventArgs e)
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e); {
var fileInfo = new FileInfo(e.FullPath);
if (_options.ArchiveExtensions.Contains(fileInfo.Extension) || _options.RomExtensions.Contains(fileInfo.Extension))
{
ThrottleSnapshotUpdate(e);
}
}
private void OnRenamed(object? _, RenamedEventArgs e)
{
var fileInfo = new FileInfo(e.FullPath);
if (_options.ArchiveExtensions.Contains(fileInfo.Extension) || _options.RomExtensions.Contains(fileInfo.Extension))
{
ThrottleSnapshotUpdate(e);
}
}
/// <summary> /// <summary>
/// Rebuild the snapshot, if rebuild in process, cancel it and restart /// Rebuild the snapshot, if rebuild in process, cancel it and restart
@@ -293,17 +308,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
#region Snapshot logic #region Snapshot logic
public Task AddToSnapshotAsync(FileEntry entry) public async Task AddToSnapshotAsync(FileEntry entry, CancellationToken cancellationToken = default)
{ {
// Update lookup tables // Update lookup tables
if (entry.Hash == null) if (entry.Hash == null)
{ {
_logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path); _logger.LogWarning("Cannot add entry {Path} to snapshot: no hash", entry.Path);
return Task.CompletedTask; return;
} }
var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : entry.Path); var lastModified = File.GetLastWriteTimeUtc(entry.Path.Contains(ArchivePathSeparator) ? entry.Path.Split(ArchivePathSeparator)[0] : entry.Path);
var cacheUpdated = _cache.ContainsKey(entry.Path);
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles); _cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, lastModified, entry.Titles);
_hashCache[entry.Hash] = entry.Path; _hashCache[entry.Hash] = entry.Path;
_sizeLookup[entry.Hash] = entry.Size; _sizeLookup[entry.Hash] = entry.Size;
@@ -324,12 +340,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
_hashCache[ncaMetadataWithHash.Hash] = entry.Path; _hashCache[ncaMetadataWithHash.Hash] = entry.Path;
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size; _sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
//_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
} }
// Persist snapshot to disk // Persist snapshot to disk
PersistSnapshotAsync(); // If entry.Titles is null, treat it as an empty collection
return Task.CompletedTask; var titleIds = string.Join(",", entry.Titles.Select(t => t.TitleId.ToString()));
_logger.LogInformation(cacheUpdated ? "Updated snapshot for {Path}, titleIds=[{TitleIds}]" : "Added {Path} to snapshot, titleIds=[{TitleIds}]", entry.Path, titleIds);
await PersistSnapshotAsync(cancellationToken);
} }
/* ============================================================== /* ==============================================================
@@ -390,7 +408,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
} }
} }
var currentHash = ComputeSnapshotHash(entries); //var currentHash = ComputeSnapshotHash(entries);
if (entries.Count > 0 || fileInfo.Exists && index.Count == 0) if (entries.Count > 0 || fileInfo.Exists && index.Count == 0)
SnapshotRebuilt?.Invoke(this, EventArgs.Empty); SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
} }
@@ -421,7 +439,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
return fileInfo.LastWriteTimeUtc; return fileInfo.LastWriteTimeUtc;
})) }))
{ {
string hash; string? hash;
var ext = Path.GetExtension(file).ToLowerInvariant(); var ext = Path.GetExtension(file).ToLowerInvariant();
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext))) if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
@@ -437,7 +455,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
{ {
//var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result; //var titleInfo = _titleDatabaseService.GetAsync(ncaMetadataWithHash.TitleId).Result;
var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]); var fileEntryFromFileName = new FileEntry(file, fileInfo.Length, ncaMetadataWithHash.Hash, [ncaMetadataWithHash]);
AddToSnapshotAsync(fileEntryFromFileName); var addToSnapshotAsync = AddToSnapshotAsync(fileEntryFromFileName, cancellationToken);
addToSnapshotAsync.Wait(cancellationToken);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
yield return fileEntryFromFileName; yield return fileEntryFromFileName;
continue; continue;
@@ -457,7 +476,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
if (title != null) if (title != null)
{ {
var romEntry = new FileEntry(file, nspStreamLength, hash, [title]); var romEntry = new FileEntry(file, nspStreamLength, hash, [title]);
AddToSnapshotAsync(romEntry); var addToSnapshotAsync = AddToSnapshotAsync(romEntry, cancellationToken);
addToSnapshotAsync.Wait(cancellationToken);
titles.Add((title.TitleId, nspStreamLength, title)); titles.Add((title.TitleId, nspStreamLength, title));
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
yield return romEntry; yield return romEntry;
@@ -471,9 +491,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
if (_archiveLookup.ContainsKey(file)) continue; if (_archiveLookup.ContainsKey(file)) continue;
if (processedFiles.Contains(file)) continue; if (processedFiles.Contains(file)) continue;
_logger.LogDebug("Extracting hash for {File}", file); _logger.LogDebug("Extracting hash for {File}", file);
Stopwatch stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
hash = ComputeFirstStreamHashAsync(file, cancellationToken).Result; hash = ComputeFirstStreamHashAsync(file, cancellationToken).Result;
stopwatch.Stop(); stopwatch.Stop();
if (!string.IsNullOrEmpty(hash))
{
_logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds); _logger.LogDebug("Computed hash for {File} in {Time}ms", file, stopwatch.ElapsedMilliseconds);
if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path) if (_hashCache.TryGetValue(hash, out var value) && file == _cache[value].Path)
{ {
@@ -481,6 +503,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
yield return null; yield return null;
continue; continue;
} }
}
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null; IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
try try
@@ -509,7 +532,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
foreach (var title in titles) foreach (var title in titles)
{ {
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]); var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
AddToSnapshotAsync(archiveEntry); var addToSnapshotAsync = AddToSnapshotAsync(archiveEntry, cancellationToken);
addToSnapshotAsync.Wait(cancellationToken);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
yield return archiveEntry; yield return archiveEntry;
} }
@@ -546,11 +570,15 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
} }
catch (IOException ex) when (attempt < _options.MaxRetryCount - 1) catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
{ {
var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier); var delay = (int)((attempt + 1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
_logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.", _logger.LogWarning(ex, "Failed to load {Path}. Attempt {Attempt}, Retrying after {Delay}.",
attempt + 1, file, delay); file, attempt + 1, delay);
await Task.Delay(delay, cancellationToken); await Task.Delay(delay, cancellationToken);
} }
catch (IOException) when (attempt >= _options.MaxRetryCount - 1)
{
_logger.LogWarning("Load {Path} failed after {retries} attempts", file, attempt + 1);
}
} }
return null; return null;
} }
@@ -570,9 +598,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
private Task PersistSnapshotAsync(CancellationToken cancellationToken = default) private Task PersistSnapshotAsync(CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested();
if (_debouncerCache.TryGetValue(_jsonPath, out _)) if (_debouncerCache.TryGetValue(_jsonPath, out _))
{ {
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence"); _logger.LogDebug("Sliding debounce in progress, skipping snapshot persistence");
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -597,8 +627,8 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired)) if (reason is not (EvictionReason.Expired or EvictionReason.TokenExpired))
return; return;
var filePath = (string)key; var filePath = (string)key;
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) if (!_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout)) return;
{
try try
{ {
if (FileLockHelper.IsFileLocked(filePath)) if (FileLockHelper.IsFileLocked(filePath))
@@ -619,7 +649,6 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
} }
} }
} }
}
}); });
cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs)); cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(DebounceMs));
return Task.CompletedTask; return Task.CompletedTask;
@@ -821,62 +850,66 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class) // File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
private async Task<string> ComputeFirstStreamHashAsync(string filePath, CancellationToken cancellationToken = default) private async Task<string?> ComputeFirstStreamHashAsync(string filePath, CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++) for (var attempt = 0; attempt < _options.MaxRetryCount; attempt++)
{ {
try try
{ {
var ext = Path.GetExtension(filePath).ToLowerInvariant();
var multiPartBasePathWithExtension = $"{MultiPartRarHelper.GetBaseNameForRarVolume(filePath)}{ext}";
if (string.CompareOrdinal(multiPartBasePathWithExtension, filePath) != 0)
{
filePath = multiPartBasePathWithExtension;
}
if (FileLockHelper.IsFileLocked(filePath)) if (FileLockHelper.IsFileLocked(filePath))
{ {
throw new IOException("File is locked"); throw new IOException("File is locked");
} }
// Only treat NSP/XCI/XCZ as “firststream” files // Only treat NSP/XCI/XCZ as “firststream” files
var ext = Path.GetExtension(filePath).ToLowerInvariant();
if (ext is not ".nsp" and not ".xci" and not ".xcz") if (ext is not ".nsp" and not ".xci" and not ".xcz")
{ {
// Open the NSP/XCI with LibHac and read the first stream. // Open the NSP/XCI with LibHac and read the first stream.
// The first stream is the first entry returned by GetContentInfos(). // The first stream is the first entry returned by GetContentInfos().
try try
{ {
using var reader = new RomArchiveReader(filePath); await using var reader = new RomArchiveReader(filePath);
var first = reader.GetEntries().FirstOrDefault(); var first = reader.GetEntries().FirstOrDefault();
if (first == null) return ComputeFullHash(filePath); if (first == null) return ComputeFullHash(filePath);
//using var seekableWrapper = new SeekableBufferedStream(first.Stream, first.Stream.Length, 10*1024*1024, true); //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); await using var rewindableWrapper = new RewindableStream(first.Stream, () => first.Stream, 10 * 1024 * 1024, first.Stream.Length);
var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper); var hash = _nspExtractor.ExtractHashFromStream(rewindableWrapper);
return hash; return hash;
} }
catch catch
{ {
// On error, fall back to the full file hash // ignored
}
}
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs); var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
return ncaMetadataWithHash?.Hash ?? string.Empty; 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 (IOException ex) when (attempt < _options.MaxRetryCount - 1) catch (IOException ex) when (attempt < _options.MaxRetryCount - 1)
{ {
var delay = (int)((attempt+1) * _options.DebounceTimeoutMs * _options.RetryMultiplier); var delay = (int)((attempt + 1) * _options.DebounceTimeoutMs * _options.RetryMultiplier);
_logger.LogWarning(ex, "Attempt {Attempt} failed for {Path}. Retrying after {Delay}.", _logger.LogWarning(ex, "Failed to load {Path}. Attempt {Attempt}, Retrying after {Delay}.",
attempt + 1, filePath, delay); filePath, attempt + 1, delay);
await Task.Delay(delay, cancellationToken); await Task.Delay(delay, cancellationToken);
} }
catch (IOException) when (attempt >= _options.MaxRetryCount - 1)
{
_logger.LogWarning("Load {Path} failed after {retries} attempts", filePath, attempt + 1);
return null;
}
} }
return string.Empty; return string.Empty;
throw new IOException($"Failed to compute hash for {filePath} after {_options.MaxRetryCount} attempts");
} }
private static string ComputeFullHash(string filePath) private static string ComputeFullHash(string filePath)