From 2b301587643cc39749554cfc3abf807ec781e2d0 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Mon, 6 Oct 2025 19:25:24 -0500 Subject: [PATCH] Implement debounce to avoid thrashing disk on settings update --- .../AuthenticationService.cs | 4 +- .../CommandLineService.cs | 4 +- .../FilterService.cs | 2 +- .../UpdateService.cs | 5 +- LANCommander.Launcher/Program.cs | 7 +- .../Services/LocalizationService.cs | 2 +- .../Authenticate/Components/LoginForm.razor | 10 -- .../UI/Components/AuthenticatedView.razor | 4 +- .../UI/Components/ImportHandler.razor | 12 +- LANCommander.Launcher/UI/Settings/Index.razor | 2 +- .../Abstractions/ISettingsProvider.cs | 3 +- .../Providers/SettingsProvider.cs | 114 +++++++++++------- LANCommander.SDK/Services/ConnectionClient.cs | 12 +- 13 files changed, 94 insertions(+), 87 deletions(-) diff --git a/LANCommander.Launcher.Services/AuthenticationService.cs b/LANCommander.Launcher.Services/AuthenticationService.cs index 7ae40644..5ec8eccf 100644 --- a/LANCommander.Launcher.Services/AuthenticationService.cs +++ b/LANCommander.Launcher.Services/AuthenticationService.cs @@ -70,7 +70,7 @@ public class AuthenticationService( //SetOfflineMode(false); TemporarilyOffline = false; - await settingsProvider.UpdateAsync(s => + settingsProvider.Update(s => { s.Authentication.ServerAddress = serverAddress; s.Authentication.AccessToken = token.AccessToken; @@ -100,7 +100,7 @@ public class AuthenticationService( await client.Authentication.RegisterAsync(username, password, passwordConfirmation); - await settingsProvider.UpdateAsync(s => + settingsProvider.Update(s => { s.Authentication.ServerAddress = client.Connection.GetServerAddress(); s.Authentication.AccessToken = tokenProvider.GetToken(); diff --git a/LANCommander.Launcher.Services/CommandLineService.cs b/LANCommander.Launcher.Services/CommandLineService.cs index d90d8c47..8d8ca85e 100644 --- a/LANCommander.Launcher.Services/CommandLineService.cs +++ b/LANCommander.Launcher.Services/CommandLineService.cs @@ -257,7 +257,7 @@ namespace LANCommander.Launcher.Services var token = await client.Authentication.AuthenticateAsync(options.Username, options.Password, client.Connection.GetServerAddress()); - await client.Settings.UpdateAsync(s => + client.Settings.Update(s => { s.Authentication.AccessToken = token.AccessToken; s.Authentication.RefreshToken = token.RefreshToken; @@ -276,7 +276,7 @@ namespace LANCommander.Launcher.Services { await client.Authentication.LogoutAsync(); - await client.Settings.UpdateAsync(s => + client.Settings.Update(s => { s.Authentication.AccessToken = String.Empty; s.Authentication.RefreshToken = String.Empty; diff --git a/LANCommander.Launcher.Services/FilterService.cs b/LANCommander.Launcher.Services/FilterService.cs index 3bf68f62..150cdac1 100644 --- a/LANCommander.Launcher.Services/FilterService.cs +++ b/LANCommander.Launcher.Services/FilterService.cs @@ -154,7 +154,7 @@ namespace LANCommander.Launcher.Services async Task SaveSettingsAsync() { - await settingsProvider.UpdateAsync(s => + settingsProvider.Update(s => { s.Filter.Title = Filter.Title; s.Filter.GroupBy = Filter.GroupBy; diff --git a/LANCommander.Launcher.Services/UpdateService.cs b/LANCommander.Launcher.Services/UpdateService.cs index 20dc3ff9..3a00b4b6 100644 --- a/LANCommander.Launcher.Services/UpdateService.cs +++ b/LANCommander.Launcher.Services/UpdateService.cs @@ -62,10 +62,7 @@ namespace LANCommander.Launcher.Services Process.Start(process); - await client.Settings.UpdateAsync(s => - { - s.Launcher.LaunchCount = 0; - }); + client.Settings.Update(s => s.Launcher.LaunchCount = 0); Logger?.LogInformation("Shutting down to get out of the way"); diff --git a/LANCommander.Launcher/Program.cs b/LANCommander.Launcher/Program.cs index 799a0c7f..35a4eaf7 100644 --- a/LANCommander.Launcher/Program.cs +++ b/LANCommander.Launcher/Program.cs @@ -90,11 +90,8 @@ namespace LANCommander.Launcher var tokenProvider = app.Services.GetService()!; tokenProvider.SetToken(settingsProvider.CurrentValue.Authentication.AccessToken); - - settingsProvider.UpdateAsync(s => - { - s.LaunchCount++; - }); + + settingsProvider.Update(s => s.LaunchCount++); Logger?.Debug("Starting application!"); diff --git a/LANCommander.Launcher/Services/LocalizationService.cs b/LANCommander.Launcher/Services/LocalizationService.cs index 15c39020..9fca7926 100644 --- a/LANCommander.Launcher/Services/LocalizationService.cs +++ b/LANCommander.Launcher/Services/LocalizationService.cs @@ -10,7 +10,7 @@ namespace LANCommander.Launcher.Services private readonly ResourceManager _resourceManager; public LocalizationService(IOptions settings) - { ; + { _resourceManager = new ResourceManager("LANCommander.Launcher.Resources.SharedResources", typeof(LocalizationService).Assembly); var cultureInfo = new CultureInfo(settings.Value.Culture); diff --git a/LANCommander.Launcher/UI/Authenticate/Components/LoginForm.razor b/LANCommander.Launcher/UI/Authenticate/Components/LoginForm.razor index d087ce09..7a0a2866 100644 --- a/LANCommander.Launcher/UI/Authenticate/Components/LoginForm.razor +++ b/LANCommander.Launcher/UI/Authenticate/Components/LoginForm.razor @@ -173,14 +173,4 @@ Loading = false; } } - - async Task OfflineMode() - { - await Client.Settings.UpdateAsync(s => - { - s.Authentication.OfflineModeEnabled = true; - }); - - NavigationManager.NavigateTo("/"); - } } \ No newline at end of file diff --git a/LANCommander.Launcher/UI/Components/AuthenticatedView.razor b/LANCommander.Launcher/UI/Components/AuthenticatedView.razor index cb9de8e1..176042b3 100644 --- a/LANCommander.Launcher/UI/Components/AuthenticatedView.razor +++ b/LANCommander.Launcher/UI/Components/AuthenticatedView.razor @@ -17,7 +17,7 @@ else if (!ConnectionClient.IsConfigured()) protected override async Task OnInitializedAsync() { - ConnectionClient.OnConnect += (sender, args) => StateHasChanged(); - ConnectionClient.OnOfflineModeEnabled += (sender, args) => StateHasChanged(); + ConnectionClient.OnConnect += (sender, args) => InvokeAsync(StateHasChanged); + ConnectionClient.OnOfflineModeEnabled += (sender, args) => InvokeAsync(StateHasChanged); } } \ No newline at end of file diff --git a/LANCommander.Launcher/UI/Components/ImportHandler.razor b/LANCommander.Launcher/UI/Components/ImportHandler.razor index d2f72d52..005ea78d 100644 --- a/LANCommander.Launcher/UI/Components/ImportHandler.razor +++ b/LANCommander.Launcher/UI/Components/ImportHandler.razor @@ -55,7 +55,7 @@ Importing = true; ImportStatusIndex = 0; ImportStatusTotal = 0; - await NotifyProgress(ImportProgressState.Importing); + NotifyProgress(ImportProgressState.Importing); await InvokeAsync(StateHasChanged); MessageService.Info(LocalizationService.GetString("ImportStarted"), 2.5); @@ -66,12 +66,12 @@ } } - private async Task NotifyProgress(ImportProgressState state) + private void NotifyProgress(ImportProgressState state) { if (!Progress.HasDelegate) return; - await Progress.InvokeAsync(new ImportProgressEventArgs + Progress.InvokeAsync(new ImportProgressEventArgs { State = state, Index = ImportStatusIndex, @@ -86,7 +86,7 @@ ImportStatusIndex = 0; ImportStatusTotal = 0; - await NotifyProgress(ImportProgressState.Imported); + NotifyProgress(ImportProgressState.Imported); MessageService.Success(LocalizationService.GetString("ImportComplete"), 3); } @@ -96,7 +96,7 @@ ImportStatusIndex = 0; ImportStatusTotal = 0; - await NotifyProgress(ImportProgressState.Failed); + NotifyProgress(ImportProgressState.Failed); MessageService.Error(LocalizationService.GetString("ImportFailed"), 3); } @@ -106,7 +106,7 @@ ImportStatusIndex = status.Index; ImportStatusTotal = status.Total; - await NotifyProgress(ImportProgressState.Updated); + NotifyProgress(ImportProgressState.Updated); } public enum ImportProgressState diff --git a/LANCommander.Launcher/UI/Settings/Index.razor b/LANCommander.Launcher/UI/Settings/Index.razor index 7baf7ae5..fc5e9345 100644 --- a/LANCommander.Launcher/UI/Settings/Index.razor +++ b/LANCommander.Launcher/UI/Settings/Index.razor @@ -120,7 +120,7 @@ // Client.DefaultInstallDirectory = _settings.Games.InstallDirectories.First(); Client.Scripts.Debug = SettingsProvider.CurrentValue.Debug.EnableScriptDebugging; - await SettingsProvider.UpdateAsync(s => + SettingsProvider.Update(s => { s.Games.InstallDirectories = _installDirectories.Select(d => d.Path).ToArray(); s.Media.StoragePath = _settings.Media.StoragePath; diff --git a/LANCommander.SDK/Abstractions/ISettingsProvider.cs b/LANCommander.SDK/Abstractions/ISettingsProvider.cs index f458ba4b..ea9f3e92 100644 --- a/LANCommander.SDK/Abstractions/ISettingsProvider.cs +++ b/LANCommander.SDK/Abstractions/ISettingsProvider.cs @@ -7,6 +7,5 @@ namespace LANCommander.SDK.Abstractions; public interface ISettingsProvider { Settings CurrentValue { get; } - Task UpdateAsync(Settings settings); - Task UpdateAsync(Action patch); + void Update(Action patch); } \ No newline at end of file diff --git a/LANCommander.SDK/Providers/SettingsProvider.cs b/LANCommander.SDK/Providers/SettingsProvider.cs index c68f70a3..6b3c0184 100644 --- a/LANCommander.SDK/Providers/SettingsProvider.cs +++ b/LANCommander.SDK/Providers/SettingsProvider.cs @@ -1,9 +1,13 @@ using System; using System.IO; +using System.Text; +using System.Threading; using System.Threading.Tasks; using LANCommander.SDK; using LANCommander.SDK.Abstractions; +using LANCommander.SDK.Factories; using Microsoft.Extensions.Options; +using YamlDotNet.Core; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using Settings = LANCommander.SDK.Models.Settings; @@ -13,6 +17,13 @@ public class SettingsProvider : ISettingsProvider { private readonly string _filePath; private readonly IOptionsMonitor _optionsMonitor; + + private readonly TimeSpan _debounceDelay = TimeSpan.FromMilliseconds(250); + + private readonly SemaphoreSlim _ioGate = new(1, 1); + private readonly object _debounceLock = new(); + + private CancellationTokenSource? _saveCts; public TSettings CurrentValue => _optionsMonitor.CurrentValue; @@ -22,62 +33,81 @@ public class SettingsProvider : ISettingsProvider { _filePath = Path.Join(AppPaths.GetConfigDirectory(), Settings.SETTINGS_FILE_NAME); - if (!File.Exists(_filePath)) - { - var template = new TSettings(); - Save(template); - } - _optionsMonitor = optionsMonitor; } - public async Task UpdateAsync(TSettings settings) => await SaveAsync(settings); - - public async Task UpdateAsync(Action patch) + public void Update(Action mutator) { - patch.Invoke(_optionsMonitor.CurrentValue); - await SaveAsync(_optionsMonitor.CurrentValue); + mutator.Invoke(_optionsMonitor.CurrentValue); + + ScheduleSave(); } - // ISettingsProvider (non-generic) explicit impls - async Task ISettingsProvider.UpdateAsync(Settings settings) + void ISettingsProvider.Update(Action mutator) { - // Allow callers who only know about base Settings to update - if (settings is TSettings typed) + mutator.Invoke(_optionsMonitor.CurrentValue); + + ScheduleSave(); + } + + private void ScheduleSave() + { + CancellationTokenSource? ctsToStart; + + lock (_debounceLock) { - await UpdateAsync(typed); + _saveCts?.Cancel(); + _saveCts?.Dispose(); + _saveCts = new CancellationTokenSource(); + ctsToStart = _saveCts; } - else + + _ = DebouncedSaveAsync(ctsToStart!.Token); + } + + private async Task DebouncedSaveAsync(CancellationToken token) + { + try + { + await Task.Delay(_debounceDelay, token).ConfigureAwait(false); + } + catch (OperationCanceledException) { - var current = _optionsMonitor.CurrentValue; - await UpdateAsync(current); + } + + await _ioGate.WaitAsync().ConfigureAwait(false); + + try + { + await SaveAsync(CurrentValue, token).ConfigureAwait(false); + } + finally + { + _ioGate.Release(); } } - async Task ISettingsProvider.UpdateAsync(Action patch) + private async Task SaveAsync(TSettings settings, CancellationToken ct) { - // Patch the current derived instance through the base type view - var current = _optionsMonitor.CurrentValue; - patch(current); - await UpdateAsync(current); - } - - private void Save(TSettings settings) - { - var serializer = new SerializerBuilder() - .WithNamingConvention(PascalCaseNamingConvention.Instance) - .Build(); - - File.WriteAllText(_filePath, serializer.Serialize(settings)); - } - - private async Task SaveAsync(TSettings settings) - { - var serializer = new SerializerBuilder() - .WithNamingConvention(PascalCaseNamingConvention.Instance) - .Build(); - - await File.WriteAllTextAsync(_filePath, serializer.Serialize(settings)); + var fso = new FileStreamOptions + { + Mode = FileMode.OpenOrCreate, + Access = FileAccess.Write, + Share = FileShare.None, + Options = FileOptions.Asynchronous | FileOptions.WriteThrough, + }; + + await using (var fs = new FileStream(_filePath, fso)) + await using (var writer = new StreamWriter(fs, Encoding.UTF8)) + { + var serializer = YamlSerializerFactory.Create(); + var serialization = serializer.Serialize(settings); + + await writer.WriteAsync(serialization).WaitAsync(ct); + + await writer.FlushAsync(ct).ConfigureAwait(false); + await fs.FlushAsync(ct).ConfigureAwait(false); + } } } diff --git a/LANCommander.SDK/Services/ConnectionClient.cs b/LANCommander.SDK/Services/ConnectionClient.cs index f0c61f24..da05ea8b 100644 --- a/LANCommander.SDK/Services/ConnectionClient.cs +++ b/LANCommander.SDK/Services/ConnectionClient.cs @@ -69,10 +69,7 @@ public class ConnectionClient( { if (await PingAsync(uri)) { - await settingsProvider.UpdateAsync(s => - { - s.Authentication.ServerAddress = uri; - }); + settingsProvider.Update(s => s.Authentication.ServerAddress = uri); logger?.LogInformation("Successfully discovered server at {ServerAddress}", uri.ToString()); @@ -99,7 +96,7 @@ public class ConnectionClient( if (!rpc.IsConnected()) await rpc.ConnectAsync(GetServerAddress()); - await settingsProvider.UpdateAsync(s => s.Authentication.OfflineModeEnabled = false); + settingsProvider.Update(s => s.Authentication.OfflineModeEnabled = false); OnConnect?.Invoke(this, EventArgs.Empty); @@ -120,10 +117,7 @@ public class ConnectionClient( { await DisconnectAsync(); - await settingsProvider.UpdateAsync(s => - { - s.Authentication.OfflineModeEnabled = true; - }); + settingsProvider.Update(s => s.Authentication.OfflineModeEnabled = true); OnOfflineModeEnabled?.Invoke(this, EventArgs.Empty); }