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);
}