diff --git a/MSStore.CLI.UnitTests/ConfigurationManagerUnitTests.cs b/MSStore.CLI.UnitTests/ConfigurationManagerUnitTests.cs new file mode 100644 index 0000000..a85f5ea --- /dev/null +++ b/MSStore.CLI.UnitTests/ConfigurationManagerUnitTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using MSStore.CLI.Services; +using MSStore.CLI.Services.Telemetry; + +namespace MSStore.CLI.UnitTests +{ + [TestClass] + public class ConfigurationManagerUnitTests + { + /// + /// Signals as soon as the configuration manager logs its first open retry, so tests can + /// react to an actual retry attempt instead of racing against a fixed delay. + /// + private sealed class RetrySignalingLogger : ILogger> + { + private readonly TaskCompletionSource _retryObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task RetryObserved => _retryObserved.Task; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + // Match the retry message specifically. The configuration manager also logs at + // Information when it creates the settings directory, which would otherwise signal + // before a single retry had happened on a machine that has never run the CLI. + if (logLevel == LogLevel.Information && formatter(state, exception).Contains("Retrying", StringComparison.Ordinal)) + { + _retryObserved.TrySetResult(); + } + } + } + private ConfigurationManager _configurationManager = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void Initialize() + { + _configurationManager = new ConfigurationManager( + TelemetrySourceGenerationContext.Default.TelemetryConfigurations, + $"test_{Guid.NewGuid()}.json", + null); + } + + [TestCleanup] + public void Cleanup() + { + if (File.Exists(_configurationManager.ConfigPath)) + { + File.Delete(_configurationManager.ConfigPath); + } + } + + [TestMethod] + public async Task SaveAsyncWaitsForOtherProcessToReleaseTheFile() + { + var logger = new RetrySignalingLogger(); + var configurationManager = new ConfigurationManager( + TelemetrySourceGenerationContext.Default.TelemetryConfigurations, + $"test_{Guid.NewGuid()}.json", + logger); + + try + { + await configurationManager.ClearAsync(TestContext.CancellationToken); + + var otherProcessFile = File.Open(configurationManager.ConfigPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var saveTask = configurationManager.SaveAsync(new TelemetryConfigurations { TelemetryEnabled = true }, TestContext.CancellationToken); + + // Wait for an actual retry attempt to be logged, instead of a fixed delay, so this + // cannot flake if the runner is slow to schedule this thread: as soon as the first + // retry is observed, the file is released well within the remaining retry budget. + await logger.RetryObserved.WaitAsync(TimeSpan.FromSeconds(30), TestContext.CancellationToken); + + // The save must not have completed yet, otherwise it did not really + // wait for the other process to release the file. + saveTask.IsCompleted.Should().BeFalse(); + + otherProcessFile.Dispose(); + + await saveTask; + + var telemetryConfigurations = await configurationManager.LoadAsync(true, TestContext.CancellationToken); + + telemetryConfigurations.TelemetryEnabled.Should().BeTrue(); + } + finally + { + if (File.Exists(configurationManager.ConfigPath)) + { + File.Delete(configurationManager.ConfigPath); + } + } + } + + [TestMethod] + public async Task LoadAsyncDoesNotThrowIfFileIsLockedByAnotherProcess() + { + await _configurationManager.ClearAsync(TestContext.CancellationToken); + await _configurationManager.SaveAsync(new TelemetryConfigurations { TelemetryEnabled = true }, TestContext.CancellationToken); + + using var otherProcessFile = File.Open(_configurationManager.ConfigPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var telemetryConfigurations = await _configurationManager.LoadAsync(true, TestContext.CancellationToken); + + telemetryConfigurations.Should().NotBeNull(); + + // The file of the other process should not have been cleared. + otherProcessFile.Length.Should().BeGreaterThan(0); + } + + [TestMethod] + public async Task LoadAsyncThrowsIfFileIsLockedAndClearInvalidConfigIsDisabled() + { + await _configurationManager.ClearAsync(TestContext.CancellationToken); + + using var otherProcessFile = File.Open(_configurationManager.ConfigPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + // Callers that need to tell a locked file apart from an invalid one rely on this. + await Assert.ThrowsExactlyAsync( + () => _configurationManager.LoadAsync(false, TestContext.CancellationToken)); + } + + [TestMethod] + public async Task LoadAsyncRecreatesTheFileIfItContainsInvalidJson() + { + await _configurationManager.ClearAsync(TestContext.CancellationToken); + + await File.WriteAllTextAsync(_configurationManager.ConfigPath, "not json", TestContext.CancellationToken); + + var telemetryConfigurations = await _configurationManager.LoadAsync(true, TestContext.CancellationToken); + + telemetryConfigurations.Should().NotBeNull(); + + // The invalid file should have been repaired. + var content = await File.ReadAllTextAsync(_configurationManager.ConfigPath, TestContext.CancellationToken); + content.Should().NotBe("not json"); + } + + [TestMethod] + public async Task ConcurrentLoadsAndSavesDoNotThrow() + { + var tasks = new List(); + + for (var i = 0; i < 5; i++) + { + tasks.Add(Task.Run( + async () => + { + var telemetryConfigurations = await _configurationManager.LoadAsync(true, TestContext.CancellationToken); + telemetryConfigurations.TelemetryGuid = Guid.NewGuid().ToString(); + await _configurationManager.SaveAsync(telemetryConfigurations, TestContext.CancellationToken); + }, + TestContext.CancellationToken)); + } + + // Any exception thrown by a concurrent load/save fails the test. + await Task.WhenAll(tasks); + } + } +} diff --git a/MSStore.CLI.UnitTests/ReconfigureCredentialSafetyUnitTests.cs b/MSStore.CLI.UnitTests/ReconfigureCredentialSafetyUnitTests.cs index 874602e..61e4e0c 100644 --- a/MSStore.CLI.UnitTests/ReconfigureCredentialSafetyUnitTests.cs +++ b/MSStore.CLI.UnitTests/ReconfigureCredentialSafetyUnitTests.cs @@ -205,8 +205,8 @@ public async Task ResetShouldNotDiscardSettingsWhenTheCredentialSurvivesTheClear // settings.json while an unremovable credential lingers leaves the machine worse off than before. ArrangeExistingClientSecretConfiguration(validationSucceeds: true); FakeConfigurationManager - .Setup(x => x.LoadAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new Configurations { ClientId = new Guid(ExistingClientId) }); + .Setup(x => x.TryLoadAsync(It.IsAny())) + .ReturnsAsync((new Configurations { ClientId = new Guid(ExistingClientId) }, true)); CredentialManager .Setup(x => x.ClearCredentials(It.IsAny())) .Callback(() => { }); @@ -214,11 +214,15 @@ public async Task ResetShouldNotDiscardSettingsWhenTheCredentialSurvivesTheClear .Setup(x => x.YesNoConfirmationAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); - await ParseAndInvokeAsync(["reconfigure", "--reset"], expectedResult: -1); + var result = await ParseAndInvokeAsync(["reconfigure", "--reset"], expectedResult: -1); _credentialStore[ExistingClientId].Should().Be(ExistingSecret); FakeConfigurationManager.Verify(x => x.ClearAsync(It.IsAny()), Times.Never); TokenManager.Verify(x => x.ClearAllCacheAsync(), Times.Never); + + // The default log level is Critical, so a LogError alone would leave the user staring at + // a bare -1. The reason has to reach the console. + result.Error.Should().Contain("Could not remove the credential for"); } [TestMethod] @@ -226,8 +230,8 @@ public async Task ResetShouldClearEverythingWhenTheCredentialIsRemoved() { ArrangeExistingClientSecretConfiguration(validationSucceeds: true); FakeConfigurationManager - .Setup(x => x.LoadAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new Configurations { ClientId = new Guid(ExistingClientId) }); + .Setup(x => x.TryLoadAsync(It.IsAny())) + .ReturnsAsync((new Configurations { ClientId = new Guid(ExistingClientId) }, true)); FakeConsole .Setup(x => x.YesNoConfirmationAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); @@ -239,6 +243,32 @@ public async Task ResetShouldClearEverythingWhenTheCredentialIsRemoved() TokenManager.Verify(x => x.ClearAllCacheAsync(), Times.Once); } + [TestMethod] + public async Task ResetShouldNotWipeSettingsWhenTheConfigurationCouldNotBeRead() + { + // If settings.json could not be read (e.g. a concurrent `msstore` process is holding it open), + // we cannot tell a config that never had a ClientId apart from one we simply couldn't read. Wiping + // settings.json in that case could orphan a credential in the OS store with no ClientId left to find it. + ArrangeExistingClientSecretConfiguration(validationSucceeds: true); + FakeConfigurationManager + .Setup(x => x.TryLoadAsync(It.IsAny())) + .ReturnsAsync((new Configurations(), false)); + FakeConsole + .Setup(x => x.YesNoConfirmationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await ParseAndInvokeAsync(["reconfigure", "--reset"], expectedResult: -1); + + _credentialStore[ExistingClientId].Should().Be(ExistingSecret); + CredentialManager.Verify(x => x.ClearCredentials(It.IsAny()), Times.Never); + FakeConfigurationManager.Verify(x => x.ClearAsync(It.IsAny()), Times.Never); + TokenManager.Verify(x => x.ClearAllCacheAsync(), Times.Never); + + // The default log level is Critical, so a LogError alone would leave the user staring at + // a bare -1. The reason has to reach the console. + result.Error.Should().Contain("Could not read the configuration file."); + } + [TestMethod] public async Task ReconfigureShouldValidateBeforeMutatingTheCredentialStore() { diff --git a/MSStore.CLI/Commands/ReconfigureCommand.cs b/MSStore.CLI/Commands/ReconfigureCommand.cs index 28a0221..cfc352d 100644 --- a/MSStore.CLI/Commands/ReconfigureCommand.cs +++ b/MSStore.CLI/Commands/ReconfigureCommand.cs @@ -118,7 +118,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return await _telemetryClient.TrackCommandEventAsync( (reset == true - ? await _cliConfigurator.ResetAsync(ct: ct) + ? await _cliConfigurator.ResetAsync(_ansiConsole, ct: ct) : await _cliConfigurator.ConfigureAsync( _ansiConsole, askConfirmation, diff --git a/MSStore.CLI/Program.cs b/MSStore.CLI/Program.cs index d969440..909508b 100644 --- a/MSStore.CLI/Program.cs +++ b/MSStore.CLI/Program.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.CommandLine.Invocation; +using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; @@ -50,8 +51,8 @@ public static async Task Main(params string[] args) TelemetrySourceGenerationContext.Default.TelemetryConfigurations, "telemetrySettings.json", null); - TelemetryConfigurations telemetryConfigurations = await telemetryConfigurationManager.LoadAsync(true, CancellationToken.None); - TelemetryClient telemetryClient = await CreateTelemetryClientAsync(telemetryConfigurationManager, telemetryConfigurations); + (TelemetryConfigurations telemetryConfigurations, bool telemetryConfigurationsReadable) = await telemetryConfigurationManager.TryLoadAsync(); + TelemetryClient telemetryClient = await CreateTelemetryClientAsync(telemetryConfigurationManager, telemetryConfigurations, telemetryConfigurationsReadable); var ansiConsole = AnsiConsole.Create(new() { Interactive = Console.IsErrorRedirected ? InteractionSupport.No : InteractionSupport.Yes, @@ -254,7 +255,7 @@ void AddMSCorrelationId(HttpRequestHeaders defaultRequestHeaders) internal static string SessionId { get; } = Guid.NewGuid().ToString(); - private static async Task CreateTelemetryClientAsync(ConfigurationManager telemetryConfigurationManager, TelemetryConfigurations telemetryConfigurations) + private static async Task CreateTelemetryClientAsync(ConfigurationManager telemetryConfigurationManager, TelemetryConfigurations telemetryConfigurations, bool telemetryConfigurationsReadable) { var changed = false; if (!telemetryConfigurations.TelemetryEnabled.HasValue) @@ -272,9 +273,25 @@ private static async Task CreateTelemetryClientAsync(Configurat changed = true; } + if (!telemetryConfigurationsReadable) + { + // We could not read the stored preferences, so we cannot tell whether the user opted + // out. Fail closed for this run, and do not persist defaults over their settings. + telemetryConfigurations.TelemetryEnabled = false; + changed = false; + } + if (changed) { - await telemetryConfigurationManager.SaveAsync(telemetryConfigurations, CancellationToken.None); + try + { + await telemetryConfigurationManager.SaveAsync(telemetryConfigurations, CancellationToken.None); + } + catch (IOException) + { + // Telemetry settings are incidental bookkeeping. If another instance of the CLI + // is using the file, just move on instead of failing the command. + } } TelemetryConfiguration telemetryConfiguration = TelemetryConfiguration.CreateDefault(); diff --git a/MSStore.CLI/Services/CLIConfigurator.cs b/MSStore.CLI/Services/CLIConfigurator.cs index 8bad0ff..34355b0 100644 --- a/MSStore.CLI/Services/CLIConfigurator.cs +++ b/MSStore.CLI/Services/CLIConfigurator.cs @@ -666,7 +666,7 @@ private async Task UpdateClientAppAsync(IAnsiConsole ansiConsole, string i }); } - public async Task ResetAsync(CancellationToken ct = default) + public async Task ResetAsync(IAnsiConsole ansiConsole, CancellationToken ct = default) { if (!await _consoleReader.YesNoConfirmationAsync( "Are you sure you want to reset the MSStore CLI credentials?", ct)) @@ -676,7 +676,17 @@ public async Task ResetAsync(CancellationToken ct = default) try { - var config = await _configurationManager.LoadAsync(true, ct: ct); + var (config, readable) = await _configurationManager.TryLoadAsync(ct); + + // If the settings file could not be read (e.g. another process is holding it open), + // we cannot tell whether a credential was ever stored, so we must not wipe settings.json: + // that could leave an orphaned credential in the OS store with no ClientId left to find it. + if (!readable) + { + _logger.LogError("Could not read the configuration file. Please try again."); + ansiConsole.MarkupLine(":collision: [bold red]Could not read the configuration file. It may be in use by another process. Nothing was changed.[/]"); + return false; + } // Remove the credential before discarding the settings, and only continue if it is really gone. // Wiping settings.json while an unremovable credential lingers would leave the machine in a worse @@ -684,6 +694,7 @@ public async Task ResetAsync(CancellationToken ct = default) if (config.ClientId.HasValue && !TryClearCredentials(config.ClientId.Value.ToString())) { _logger.LogError("Could not remove the credential for '{ClientId}' from the credential store. Remove it manually.", config.ClientId.Value); + ansiConsole.MarkupLine($":collision: [bold red]Could not remove the credential for '{config.ClientId.Value}'. Nothing was changed, remove it manually.[/]"); return false; } @@ -696,6 +707,7 @@ public async Task ResetAsync(CancellationToken ct = default) catch (Exception ex) { _logger.LogError(ex, "Error while resetting configuration"); + ansiConsole.MarkupLine($":collision: [bold red]Error while resetting the configuration: {ex.Message.EscapeMarkup()}[/]"); return false; } } diff --git a/MSStore.CLI/Services/ConfigurationManager.cs b/MSStore.CLI/Services/ConfigurationManager.cs index 0b19aef..ea530b8 100644 --- a/MSStore.CLI/Services/ConfigurationManager.cs +++ b/MSStore.CLI/Services/ConfigurationManager.cs @@ -19,6 +19,17 @@ namespace MSStore.CLI.Services internal class ConfigurationManager(JsonTypeInfo jsonTypeInfo, string fileName, ILogger>? logger) : IConfigurationManager where T : new() { + private const int MaxOpenAttempts = 5; + + // HRESULTs Windows reports when another process holds the file open. + private const int ErrorSharingViolation = unchecked((int)0x80070020); // ERROR_SHARING_VIOLATION (32) + private const int ErrorLockViolation = unchecked((int)0x80070021); // ERROR_LOCK_VIOLATION (33) + + // On Unix, FileShare is implemented with flock(), and .NET surfaces the raw errno + // (EWOULDBLOCK) as the HResult when the lock cannot be taken. The value differs per platform. + private const int ErrorWouldBlockLinux = 11; // EAGAIN/EWOULDBLOCK on Linux + private const int ErrorWouldBlockBsd = 35; // EAGAIN/EWOULDBLOCK on macOS and other BSDs + private static readonly string SettingsDirectory = Path.Combine(GetSystemLocalApplicationDataPath(), "Microsoft", "MSStore.CLI"); private static string GetSystemLocalApplicationDataPath() @@ -43,6 +54,23 @@ private static string GetSystemLocalApplicationDataPath() return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); } + private static readonly TimeSpan OpenRetryDelay = TimeSpan.FromMilliseconds(50); + + /// + /// Checks whether an was caused by another process holding the file open, + /// as opposed to an unrelated I/O failure that should not be retried or silently ignored. + /// + private static bool IsFileInUse(IOException ex) + { + // A missing file/directory is never a sharing violation, even though both derive from IOException. + if (ex is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + + return ex.HResult is ErrorSharingViolation or ErrorLockViolation or ErrorWouldBlockLinux or ErrorWouldBlockBsd; + } + private readonly string _settingsPath = Path.Combine(SettingsDirectory, fileName); private readonly JsonTypeInfo _jsonTypeInfo = jsonTypeInfo ?? throw new ArgumentNullException(nameof(jsonTypeInfo)); private readonly ILogger? _logger = logger; @@ -59,10 +87,25 @@ public async Task LoadAsync(bool clearInvalidConfig, CancellationToken ct) return await ClearAsync(ct); } - using var file = File.Open(_settingsPath, FileMode.Open); + // Reading does not need to exclude other readers: only a concurrent writer can + // produce a half-written file, and that already holds an exclusive lock. + using var file = await OpenAsync(FileMode.Open, FileAccess.Read, FileShare.Read, ct); return await JsonSerializer.DeserializeAsync(file, _jsonTypeInfo, ct) ?? new T(); } + catch (IOException ex) when (IsFileInUse(ex)) + { + // Another process is using the file. Do not overwrite its contents, + // just fallback to the default configuration. + _logger?.LogWarning(ex, "Could not read the configuration file: {SettingsPath}", _settingsPath); + + if (!clearInvalidConfig) + { + throw; + } + + return new T(); + } catch { if (!clearInvalidConfig) @@ -74,10 +117,63 @@ public async Task LoadAsync(bool clearInvalidConfig, CancellationToken ct) } } + public async Task<(T Configurations, bool Readable)> TryLoadAsync(CancellationToken ct = default) + { + try + { + // Do not repair yet, so that a locked file is distinguishable from an invalid one. + return (await LoadAsync(false, ct), true); + } + catch (IOException ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // The file vanished between the existence check and the open. That leaves us in the + // same state as never having had one, so recreate the defaults instead of reporting + // the configuration as unknown and making callers abort on a benign race. + _logger?.LogWarning(ex, "Configuration file vanished while reading it: {SettingsPath}", _settingsPath); + + return await TryRecreateAsync(ct); + } + catch (IOException ex) + { + // Whether this is contention or a genuine I/O failure, the file is there and we + // could not read it, so the stored state is unknown and must not be reported as + // an empty configuration that the caller is free to act on. + // LoadAsync already logs contention before rethrowing, so only report what it does not. + if (!IsFileInUse(ex)) + { + _logger?.LogWarning(ex, "Could not read the configuration file: {SettingsPath}", _settingsPath); + } + + return (new T(), false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The content is invalid. Recreate it rather than going through LoadAsync(true), + // which swallows a lock taken since the first attempt and would report a read that + // never happened. + _logger?.LogWarning(ex, "Invalid configuration file, recreating it: {SettingsPath}", _settingsPath); + + return await TryRecreateAsync(ct); + } + } + + private async Task<(T Configurations, bool Readable)> TryRecreateAsync(CancellationToken ct) + { + try + { + return (await ClearAsync(ct), true); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogWarning(ex, "Could not recreate the configuration file: {SettingsPath}", _settingsPath); + return (new T(), false); + } + } + public async Task ClearAsync(CancellationToken ct) { EnsureDirectoryExists(); - using var file = File.Open(_settingsPath, FileMode.OpenOrCreate); + using var file = await OpenAsync(FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, ct); file.SetLength(0); await file.FlushAsync(ct); file.Position = 0; @@ -88,12 +184,30 @@ public async Task ClearAsync(CancellationToken ct) public async Task SaveAsync(T config, CancellationToken ct) { - using var file = File.Open(_settingsPath, FileMode.OpenOrCreate); + using var file = await OpenAsync(FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, ct); file.SetLength(0); file.Position = 0; await JsonSerializer.SerializeAsync(file, config, _jsonTypeInfo, ct); } + private async Task OpenAsync(FileMode fileMode, FileAccess fileAccess, FileShare fileShare, CancellationToken ct) + { + for (var attempt = 1; ; attempt++) + { + try + { + return File.Open(_settingsPath, fileMode, fileAccess, fileShare); + } + catch (IOException ex) when (attempt < MaxOpenAttempts && IsFileInUse(ex)) + { + // The file is being used by another process. Wait a bit and try again. + _logger?.LogInformation("Configuration file '{SettingsPath}' is in use. Retrying ({Attempt}/{MaxOpenAttempts})...", _settingsPath, attempt, MaxOpenAttempts); + + await Task.Delay(OpenRetryDelay * attempt, ct); + } + } + } + private void EnsureDirectoryExists() { if (Directory.Exists(SettingsDirectory)) diff --git a/MSStore.CLI/Services/ICLIConfigurator.cs b/MSStore.CLI/Services/ICLIConfigurator.cs index fe51958..bd0a5c3 100644 --- a/MSStore.CLI/Services/ICLIConfigurator.cs +++ b/MSStore.CLI/Services/ICLIConfigurator.cs @@ -11,6 +11,6 @@ namespace MSStore.CLI.Services internal interface ICLIConfigurator { Task ConfigureAsync(IAnsiConsole ansiConsole, bool askConfirmation, Guid? tenantId = null, string? sellerId = null, Guid? clientId = null, string? clientSecret = null, string? certificateThumbprint = null, string? certificateFilePath = null, string? certificatePassword = null, bool clientAssertion = false, CancellationToken ct = default); - Task ResetAsync(CancellationToken ct = default); + Task ResetAsync(IAnsiConsole ansiConsole, CancellationToken ct = default); } } diff --git a/MSStore.CLI/Services/IConfigurationManager.cs b/MSStore.CLI/Services/IConfigurationManager.cs index 612cf16..50e7657 100644 --- a/MSStore.CLI/Services/IConfigurationManager.cs +++ b/MSStore.CLI/Services/IConfigurationManager.cs @@ -11,6 +11,20 @@ internal interface IConfigurationManager { string ConfigPath { get; } Task LoadAsync(bool clearInvalidConfig = false, CancellationToken ct = default); + + /// + /// Loads the configuration, repairing invalid content, but reporting separately when the + /// stored state could not be determined at all, so callers can avoid making destructive + /// decisions based on a configuration they never actually read. + /// + /// + /// The loaded (or default) configuration, and whether the stored state is known. + /// The flag is when the file exists but could not be read or + /// recreated, whether because another process holds it or because of an I/O failure. + /// A missing file (created with defaults) and repaired invalid content both count as + /// known, since in neither case are we discarding a real setting. + /// + Task<(T Configurations, bool Readable)> TryLoadAsync(CancellationToken ct = default); Task ClearAsync(CancellationToken ct); Task SaveAsync(T config, CancellationToken ct); }