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
26 changes: 26 additions & 0 deletions AppDataStorage.Test/AppDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,32 @@ public void TestIsSaveQueuedReturnsCorrectValue()
Assert.IsTrue(appData.IsSaveQueued(), "Save should be queued after QueueSave is called.");
}

[TestMethod]
public void TestQueueSaveIsNotLostWhenTheClockHasNotAdvancedSinceTheLastSave()
{
using TestAppData appData = CreateTestAppData();
appData.Save();

// Stand in for a clock that reads the same instant for the save and the queue that follows it,
// which is what DateTime.UtcNow does on macOS when both land inside one microsecond. A later
// LastSaveTime is the same situation with the wall clock set back.
appData.LastSaveTime = DateTime.MaxValue;
appData.QueueSave();

Assert.IsTrue(appData.IsSaveQueued(), "A save queued after the last save must be outstanding whatever the clock reads.");
}

[TestMethod]
public void TestSaveClearsQueuedSave()
{
using TestAppData appData = CreateTestAppData();
appData.QueueSave();

appData.Save();

Assert.IsFalse(appData.IsSaveQueued(), "Saving should satisfy the queued save.");
}

[TestMethod]
public async Task TestIsDebounceTimeElapsedReturnsCorrectValue()
{
Expand Down
18 changes: 17 additions & 1 deletion AppDataStorage/AppData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@
lock (AppData<T>.Lock)
{
appData.SaveQueuedTime = DateTime.UtcNow;
appData.HasQueuedSave = true;
appData.EnsureDisposeOnExit();
}
}
Expand Down Expand Up @@ -423,6 +424,20 @@
/// </summary>
internal DateTime SaveQueuedTime { get; set; } = DateTime.MinValue;

/// <summary>
/// Gets or sets a value indicating whether a save has been queued and not yet written.
/// </summary>
/// <remarks>
/// This is a flag rather than a comparison of <see cref="SaveQueuedTime"/> against <see cref="LastSaveTime"/>,
/// because two reads of <see cref="DateTime.UtcNow"/> are not guaranteed to differ. On macOS the clock
/// advances in whole microseconds, so a <c>QueueSave</c> issued straight after a
/// <see cref="Save"/> can read the same instant, compare as "not after the last save", and be dropped:
/// never written by <c>SaveIfRequired</c> and not flushed on dispose either. The wall
/// clock can also be set back, which would drop a queued save the same way. Whether a save is outstanding
/// is a fact about the order of two calls, not about the time either one happened at.
/// </remarks>
internal bool HasQueuedSave { get; set; }

/// <summary>
/// Gets the debounce time for saving the app data.
/// </summary>
Expand All @@ -438,17 +453,17 @@
/// </summary>
#if NET9_0_OR_GREATER
[JsonIgnore]
public static Lock Lock { get; } = new();

Check warning on line 456 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 456 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 456 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 456 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 459 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 459 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 459 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 459 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 459 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 459 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()
{
lock (Lock)
{
return SaveQueuedTime > LastSaveTime;
return HasQueuedSave;
}
}

Expand All @@ -470,6 +485,7 @@
string jsonString = JsonSerializer.Serialize(this, typeof(T), AppData.JsonSerializerOptions);
AppData.WriteText((T)this, jsonString);
LastSaveTime = DateTime.UtcNow;
HasQueuedSave = false;
}
}

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ data.Save();
- Files stored in `%APPDATA%/{AppDomain.FriendlyName}/` as `{class_name_snake_case}.json`
- Thread-safe with lock objects (uses `Lock` type on .NET 9+, `object` on earlier versions)
- Debounced saves via `QueueSave()` / `SaveIfRequired()` (3-second debounce)
- Whether a save is outstanding is the `HasQueuedSave` flag, set by `QueueSave()` and cleared by a successful `Save()`. It is not `SaveQueuedTime > LastSaveTime`: `DateTime.UtcNow` advances in whole microseconds on macOS, so a queue straight after a save can read the same instant and would be dropped
- Automatic backup files (`.bk` suffix) with timestamped collision handling
- Safe write pattern: write to `.tmp`, backup existing, move `.tmp` to final
- Lazy singleton access via `Get()` backed by `Lazy<T>`
Expand Down
Loading