Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions AppDataStorage.Test/AppDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,57 @@ public void TestLoadOrCreateArchivesAnUnreadableFileInsteadOfDeletingIt()
Assert.AreEqual(unreadable, AppData.FileSystem.File.ReadAllText(archived[0]), "The archive must hold the original content.");
}

[TestMethod]
public void TestLoadOrCreateRecoversFromAValueASemanticTypeRejects()
{
// Well-formed JSON whose path the semantic type rejects: the converter throws
// ArgumentException rather than JsonException.
const string rejected = "{\"Data\":\"keep\",\"Path\":\"not/absolute\"}";
using SemanticPathAppData probe = new();
AbsoluteFilePath filePath = probe.FilePath;
AppData.EnsureDirectoryExists(filePath);
AppData.FileSystem.File.WriteAllText(filePath, rejected);

SemanticPathAppData appData = SemanticPathAppData.LoadOrCreate();

Assert.AreEqual("d", appData.Data, "Data should be default if a value could not be read.");
Assert.IsNull(appData.Path, "Path should be default if a value could not be read.");
string[] archived = AppData.FileSystem.Directory.GetFiles(filePath.AbsoluteDirectoryPath.ToString(), $"{Path.GetFileName(filePath.ToString())}.corrupt.*");
Assert.HasCount(1, archived, "The rejected file must be archived, not left to fail again.");
Assert.AreEqual(rejected, AppData.FileSystem.File.ReadAllText(archived[0]), "The archive must hold the original content.");
}

[TestMethod]
public void TestGetRetriesAfterAFailedLoadInsteadOfRethrowingIt()
{
FlakyAppData.FailConstruction = true;
// new() wraps the constructor's exception in a TargetInvocationException.
Assert.Throws<Exception>(FlakyAppData.Get);

FlakyAppData.FailConstruction = false;
Assert.IsNotNull(FlakyAppData.Get(), "A failed load must not poison Get() for the rest of the process.");
}

internal sealed class SemanticPathAppData : AppData<SemanticPathAppData>
{
public string Data { get; set; } = "d";
public AbsoluteDirectoryPath? Path { get; set; }
}

[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by AppData<T>.LoadOrCreate through the new() constraint.")]
internal sealed class FlakyAppData : AppData<FlakyAppData>
{
internal static bool FailConstruction { get; set; }

public FlakyAppData()
{
if (FailConstruction)
{
throw new InvalidOperationException("Simulated load failure.");
}
}
}

[TestMethod]
public void TestLoadOrCreateHandlesNullJsonFile()
{
Expand Down
13 changes: 10 additions & 3 deletions AppDataStorage/AppData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,12 @@
/// <summary>
/// Gets the internal state of the app data.
/// </summary>
internal static Lazy<T> InternalState { get; } = new(LoadOrCreate);
/// <remarks>
/// Publication-only, so a load that throws is retried by the next <see cref="Get"/> rather than
/// cached and rethrown for the rest of the process. <see cref="LoadOrCreate()"/> takes the lock,
/// so racing first calls still load one at a time, and only one result is ever published.
/// </remarks>
internal static Lazy<T> InternalState { get; } = new(LoadOrCreate, LazyThreadSafetyMode.PublicationOnly);

/// <summary>
/// Gets or sets the last save time of the app data.
Expand Down Expand Up @@ -466,10 +471,10 @@
/// </summary>
#if NET9_0_OR_GREATER
[JsonIgnore]
public static Lock Lock { get; } = new();

Check warning on line 474 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 474 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 474 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 474 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.
#else
[JsonIgnore]
public static object Lock { get; } = new();

Check warning on line 477 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 477 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 477 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 477 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 477 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.

Check warning on line 477 in AppDataStorage/AppData.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

A static field in a generic type is not shared among instances of different close constructed types.
#endif

internal bool IsSaveQueued()
Expand Down Expand Up @@ -592,10 +597,12 @@
newAppData.FileNameOverride = fileName;
return newAppData;
}
catch (JsonException)
catch (Exception ex) when (ex is JsonException or ArgumentException or FormatException or NotSupportedException)
{
// The file could not be read as T, whether from corruption, a hand edit or a model
// change in an app update. It is usually the only copy of the user's data, since a
// change in an app update. Well-formed JSON can fail too: a converter passes on the
// exception a semantic type throws for a value it rejects, such as a Windows path
// read on Linux. It is usually the only copy of the user's data, since a
// successful save removes the backup, so it is archived rather than deleted. The
// retry then finds it missing and falls back to a temp or backup file, or defaults.
_ = AppData.Archive(newAppData.FilePath, ".corrupt");
Expand Down
Loading