TitleDb loading is handled for multithreading

Fix some memory leaks
This commit is contained in:
2025-11-07 14:31:59 +10:30
parent 209b766a1f
commit c2ed73e03f
6 changed files with 97 additions and 40 deletions
+1
View File
@@ -52,6 +52,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemMetaCore_00603_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F84_003F3a6a0a14_003FPartitionFileSystemMetaCore_00603_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemMetaCore_00603_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F84_003F3a6a0a14_003FPartitionFileSystemMetaCore_00603_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F94_003F00e38e6a_003FPartitionFileSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F94_003F00e38e6a_003FPartitionFileSystem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcf5011822fd54235e86fdc54ee3baa876b4876e5549223ffeadca5607e59f6af_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcf5011822fd54235e86fdc54ee3baa876b4876e5549223ffeadca5607e59f6af_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8bfc57955751d07e3fad13bb97d5f66af412d7a34486ec29ad916547ec8ce6b_003FPath_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARandomAccess_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cb442cc26c0b982afc257bb62f18d1554630732b2b8245e97107fb51974d_003FRandomAccess_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARandomAccess_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cb442cc26c0b982afc257bb62f18d1554630732b2b8245e97107fb51974d_003FRandomAccess_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc9e95c2dd9e75e585f636790bc74e1484a536fc029954c307fe585a3822129_003FRarArchiveEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc9e95c2dd9e75e585f636790bc74e1484a536fc029954c307fe585a3822129_003FRarArchiveEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveVolumeFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F416daa6b333a5ecff282ed559ac81071113ee993ebcdc662c5cf488d7072575b_003FRarArchiveVolumeFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveVolumeFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F416daa6b333a5ecff282ed559ac81071113ee993ebcdc662c5cf488d7072575b_003FRarArchiveVolumeFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
+6 -1
View File
@@ -114,6 +114,7 @@ public sealed class ArchiveHandler : IArchiveHandler
var entryCount = 0; var entryCount = 0;
try try
{ {
// todo: handle and skip multipart archives
using var archive = RarArchive.Open(path); using var archive = RarArchive.Open(path);
entryCount = archive.Entries.Count; entryCount = archive.Entries.Count;
foreach (var entry in archive.Entries) foreach (var entry in archive.Entries)
@@ -122,10 +123,14 @@ public sealed class ArchiveHandler : IArchiveHandler
{ {
try try
{ {
using var streamWrapper = new SeekableBufferedStream(entry.OpenEntryStream(), entry.Size, 64 * 1024 * 1024, false); using var streamWrapper = new SeekableBufferedStream(entry.OpenEntryStream(), entry.Size, 64 * 1024 * 1024, true);
var title = _nspExtractor.ExtractFromStream(streamWrapper); var title = _nspExtractor.ExtractFromStream(streamWrapper);
if (title != null) titles.Add((entry.Key, entry.Size, title)); if (title != null) titles.Add((entry.Key, entry.Size, title));
} }
catch (IncompleteArchiveException incompleteArchiveException)
{
_logger.LogWarning("Incomplete archive {Archive}: {Exception}", path, incompleteArchiveException.Message);
}
catch (Exception e) catch (Exception e)
{ {
if (e.Message.StartsWith("Failed to extract NSP")) if (e.Message.StartsWith("Failed to extract NSP"))
@@ -1,13 +1,7 @@
using System; using System.Security.Cryptography;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using LibHac.Ncm; using LibHac.Ncm;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TinfoilVibeServer.Models; using TinfoilVibeServer.Models;
namespace TinfoilVibeServer.Services; namespace TinfoilVibeServer.Services;
@@ -86,7 +80,7 @@ public sealed class IndexBuilderService: IHostedService
{ {
var titleId = title.TitleId; var titleId = title.TitleId;
var name = _titleDb.TryGetTitle(titleId, out var t) var name = _titleDb.TryGetTitle(titleId, out var t)
? t.Name ? t?.Name
: "Unknown"; : "Unknown";
var versionNumberParsed = (title.ContentMetaType == ContentMetaType.Application) ? title.Version : title.Version * 0x10000; var versionNumberParsed = (title.ContentMetaType == ContentMetaType.Application) ? title.Version : title.Version * 0x10000;
@@ -96,7 +90,7 @@ public sealed class IndexBuilderService: IHostedService
{ {
// Patch should use the application title name // Patch should use the application title name
name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle) name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle)
? appTitle.Name ? appTitle?.Name
: "Unknown"; : "Unknown";
//_logger.LogInformation("Patch title {TitleId} uses application title {ApplicationTitle}", titleId, title.ApplicationTitle); //_logger.LogInformation("Patch title {TitleId} uses application title {ApplicationTitle}", titleId, title.ApplicationTitle);
} }
@@ -129,7 +123,9 @@ public sealed class IndexBuilderService: IHostedService
private IndexCache? LoadCache() private IndexCache? LoadCache()
{ {
if (!File.Exists(_cachePath)) return null; if (!File.Exists(_cachePath)) return null;
_lock.Wait();
var json = File.ReadAllText(_cachePath); var json = File.ReadAllText(_cachePath);
_lock.Release();
_logger.LogInformation("Loaded index cache from {Path}", _cachePath); _logger.LogInformation("Loaded index cache from {Path}", _cachePath);
return JsonSerializer.Deserialize<IndexCache>(json); return JsonSerializer.Deserialize<IndexCache>(json);
} }
+5 -3
View File
@@ -186,13 +186,15 @@ namespace TinfoilVibeServer.Services
var storage = new StreamStorage(stream, true); var storage = new StreamStorage(stream, true);
var partition = new PartitionFileSystem(); var partition = new PartitionFileSystem();
partition.Initialize(storage).ThrowIfFailure(); partition.Initialize(storage).ThrowIfFailure();
_logger.LogInformation("PFS0 found");
return true; return true;
} }
catch (Exception e) catch
{ {
_logger.LogError("Failed to extract NSP: {Exception}", e.Message); // ignored
return false;
} }
return false;
} }
private bool IsXciFileSystem(Stream stream) private bool IsXciFileSystem(Stream stream)
{ {
+16 -6
View File
@@ -543,9 +543,16 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
public void RebuildSnapshot() public void RebuildSnapshot()
{ {
// Build a fresh snapshot and persist it. // 1️⃣ Flush the old inmemory snapshot
BuildSnapshotAsync(); // private method inside the same class _cache.Clear();
PersistSnapshotAsync(); // private method inside the same class _hashCache.Clear();
_archiveLookup.Clear();
_sizeLookup.Clear();
//_failedAttempts.Clear(); // if you keep peruser counters
// 2️⃣ Rebuild from disk again
BuildSnapshotAsync().Wait(); // synchronous we already own the lock
PersistSnapshotAsync().Wait(); // same
SnapshotRebuilt?.Invoke(this, EventArgs.Empty); SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
} }
#endregion #endregion
@@ -602,7 +609,6 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
var ext = Path.GetExtension(filePath).ToLowerInvariant(); 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
@@ -612,12 +618,16 @@ public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedServ
var first = reader.GetEntries().FirstOrDefault(); var first = reader.GetEntries().FirstOrDefault();
if (first == null) return ComputeFullHash(filePath); if (first == null) return ComputeFullHash(filePath);
return _nspExtractor.ExtractHashFromStream(first.Stream); using var firstStream = first.Stream;
var hash = _nspExtractor.ExtractHashFromStream(firstStream);
return hash;
} }
catch catch
{ {
// On error, fall back to the full file hash // On error, fall back to the full file hash
return ComputeFullHash(filePath); using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
return ncaMetadataWithHash?.Hash ?? string.Empty;
} }
} }
else else
@@ -36,7 +36,9 @@ public sealed class TitleDatabaseService : IHostedService
// Regex to find a 16digit hex TitleId in a filename // Regex to find a 16digit hex TitleId in a filename
private static readonly Regex TitleIdRegex = new( private static readonly Regex TitleIdRegex = new(
@"([0-9a-fA-F]{8})([0-9a-fA-F]{8})", RegexOptions.Compiled); @"([0-9a-fA-F]{8})([0-9a-fA-F]{8})", RegexOptions.Compiled);
private readonly SemaphoreSlim _reloadLock = new(1, 1); // protects reload logic
private Task? _reloadTask; // the currently running or last finished reload
#endregion #endregion
#region ctor #region ctor
@@ -107,19 +109,12 @@ public sealed class TitleDatabaseService : IHostedService
{ {
var ttlSec = _options.CurrentValue.TtlSeconds; var ttlSec = _options.CurrentValue.TtlSeconds;
_logger.LogInformation("Reloading title database cache (TTL={TTL}s).", ttlSec); _logger.LogInformation("Reloading title database cache (TTL={TTL}s).", ttlSec);
var entryOptions = new MemoryCacheEntryOptions() var entryOptions = new MemoryCacheEntryOptions()
.SetSlidingExpiration(TimeSpan.FromSeconds(ttlSec)).RegisterPostEvictionCallback((key, value, reason, .SetSlidingExpiration(TimeSpan.FromSeconds(ttlSec))
state) => .RegisterPostEvictionCallback((key, value, reason, state) => { _logger.LogInformation("Cache eviction: {Key} ({Reason})", key, reason); });
{
_logger.LogInformation("Cache eviction: {Key} ({Reason})", key, reason);
}); // <‑‑ sliding!
var dict = await LoadFromDiskAsync() ?? new Dictionary<string, TitleInfoDto>(); var dict = await LoadFromDiskAsync() ?? new Dictionary<string, TitleInfoDto>();
// Set the new entry the sliding expiration will now
// automatically move 30 s (or whatever you configured) forward
// every time the entry is accessed via Get/Set.
_cache.Set(CacheKey, dict, entryOptions); _cache.Set(CacheKey, dict, entryOptions);
_logger.LogInformation("Title DB reloaded {Count} items cached (TTL={TTL}s).", _logger.LogInformation("Title DB reloaded {Count} items cached (TTL={TTL}s).",
dict.Count, ttlSec); dict.Count, ttlSec);
@@ -142,21 +137,20 @@ public sealed class TitleDatabaseService : IHostedService
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
public async Task<Dictionary<string, TitleInfoDto>> GetAllAsync() public async Task<Dictionary<string, TitleInfoDto>> GetAllAsync()
{ {
if (!_cache.TryGetValue(CacheKey, out Dictionary<string, TitleInfoDto>? dict)) await EnsureCacheLoadedAsync().ConfigureAwait(false);
{
await ReloadCacheAsync(); // cache miss → load from disk // The cache entry is guaranteed to exist now
_cache.TryGetValue(CacheKey, out dict); _cache.TryGetValue(CacheKey, out Dictionary<string, TitleInfoDto>? dict);
} return dict!;
return dict!; // Get() has already slid the entry
} }
public async Task<TitleInfoDto?> GetAsync(string titleId) public async Task<TitleInfoDto?> GetAsync(string titleId)
{ {
var all = await GetAllAsync(); // slides the entry var all = await GetAllAsync(); // slides the entry
all.TryGetValue(titleId, out var dto); all.TryGetValue(titleId, out var dto);
return dto; return dto;
} }
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* 3️⃣ Persist to disk & notify snapshot service /* 3️⃣ Persist to disk & notify snapshot service
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
@@ -165,18 +159,23 @@ public sealed class TitleDatabaseService : IHostedService
// Trigger a rebuild so SnapshotService (and any other listeners) // Trigger a rebuild so SnapshotService (and any other listeners)
// can pick up the new snapshot. // can pick up the new snapshot.
//_snapshotService.RebuildSnapshot(); //_snapshotService.RebuildSnapshot();
await Task.CompletedTask;
} }
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* 4️⃣ Dispose /* 4️⃣ Dispose
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
public void Dispose() => _snapshotService.SnapshotRebuilt -= SnapshotServiceOnSnapshotRebuilt; public void Dispose()
{
_snapshotService.SnapshotRebuilt -= SnapshotServiceOnSnapshotRebuilt;
_reloadLock.Dispose();
}
private async void SnapshotServiceOnSnapshotRebuilt(object? o, EventArgs eventArgs) private async void SnapshotServiceOnSnapshotRebuilt(object? o, EventArgs eventArgs)
{ {
try try
{ {
await ReloadCacheAsync(); await EnsureCacheLoadedAsync();
} }
catch (Exception e) catch (Exception e)
{ {
@@ -197,7 +196,51 @@ public sealed class TitleDatabaseService : IHostedService
#endregion #endregion
#region Private helpers #region Private helpers
/// <summary>
/// Makes sure the cache is loaded. If a reload is already in progress,
/// the caller simply awaits the same task. Otherwise it starts a new reload.
/// </summary>
private async Task EnsureCacheLoadedAsync()
{
// Fast path a cache entry exists, no work needed
if (_cache.TryGetValue(CacheKey, out _))
return;
// Fast path a reload is already underway
var existingTask = _reloadTask;
if (existingTask != null)
{
await existingTask.ConfigureAwait(false);
return;
}
// Slow path we need to start the reload
await _reloadLock.WaitAsync().ConfigureAwait(false);
try
{
// Doublecheck after acquiring the lock
if (_cache.TryGetValue(CacheKey, out _))
return;
// If another thread started the reload while we were waiting, use it
existingTask = _reloadTask;
if (existingTask != null)
{
await existingTask.ConfigureAwait(false);
return;
}
// Create the shared task
_reloadTask = ReloadCacheAsync();
await _reloadTask.ConfigureAwait(false);
}
finally
{
_reloadTask = null; // reset for the next miss
_reloadLock.Release();
}
}
/// <summary> /// <summary>
/// Downloads the JSON file from GitHub (raw) if it does not exist /// Downloads the JSON file from GitHub (raw) if it does not exist
/// or the cached copy is older than the remote one. /// or the cached copy is older than the remote one.