using System.Text.Json; using LumaTunnel.Shared.Protocol; namespace LumaTunnel.Server.Core.Persistence; public sealed class AtomicJsonStore(string path) : IDisposable where T : class { private readonly SemaphoreSlim _gate = new(1, 1); private readonly string _path = path; public async Task LoadOrCreateAsync(Func factory, CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (!File.Exists(_path)) { var value = factory(); await SaveCoreAsync(value, cancellationToken).ConfigureAwait(false); return value; } await using var stream = File.OpenRead(_path); return await JsonSerializer.DeserializeAsync(stream, TunnelMessageSerializer.Options, cancellationToken).ConfigureAwait(false) ?? throw new InvalidDataException($"The JSON document at '{_path}' is empty."); } finally { _gate.Release(); } } public async Task SaveAsync(T value, CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { await SaveCoreAsync(value, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } private async Task SaveCoreAsync(T value, CancellationToken cancellationToken) { var directory = Path.GetDirectoryName(_path) ?? throw new InvalidOperationException("Store path has no directory."); Directory.CreateDirectory(directory); var tempPath = Path.Combine(directory, $".{Path.GetFileName(_path)}.{Guid.NewGuid():N}.tmp"); try { await using (var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 16 * 1024, FileOptions.WriteThrough | FileOptions.Asynchronous)) { await JsonSerializer.SerializeAsync(stream, value, TunnelMessageSerializer.Options, cancellationToken).ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } File.Move(tempPath, _path, true); } finally { if (File.Exists(tempPath)) File.Delete(tempPath); } } public void Dispose() { _gate.Dispose(); GC.SuppressFinalize(this); } }