diff --git a/AppDataStorage.Test/AppDataTests.cs b/AppDataStorage.Test/AppDataTests.cs index 8a22695..81e927d 100644 --- a/AppDataStorage.Test/AppDataTests.cs +++ b/AppDataStorage.Test/AppDataTests.cs @@ -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() { diff --git a/AppDataStorage/AppData.cs b/AppDataStorage/AppData.cs index e1de456..14c1df4 100644 --- a/AppDataStorage/AppData.cs +++ b/AppDataStorage/AppData.cs @@ -248,6 +248,7 @@ private static bool TryRestoreFrom(AbsoluteFilePath candidate, AbsoluteFilePath lock (AppData.Lock) { appData.SaveQueuedTime = DateTime.UtcNow; + appData.HasQueuedSave = true; appData.EnsureDisposeOnExit(); } } @@ -423,6 +424,20 @@ public static void ResetFileSystem() /// internal DateTime SaveQueuedTime { get; set; } = DateTime.MinValue; + /// + /// Gets or sets a value indicating whether a save has been queued and not yet written. + /// + /// + /// This is a flag rather than a comparison of against , + /// because two reads of are not guaranteed to differ. On macOS the clock + /// advances in whole microseconds, so a QueueSave issued straight after a + /// can read the same instant, compare as "not after the last save", and be dropped: + /// never written by SaveIfRequired 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. + /// + internal bool HasQueuedSave { get; set; } + /// /// Gets the debounce time for saving the app data. /// @@ -448,7 +463,7 @@ internal bool IsSaveQueued() { lock (Lock) { - return SaveQueuedTime > LastSaveTime; + return HasQueuedSave; } } @@ -470,6 +485,7 @@ public void Save() string jsonString = JsonSerializer.Serialize(this, typeof(T), AppData.JsonSerializerOptions); AppData.WriteText((T)this, jsonString); LastSaveTime = DateTime.UtcNow; + HasQueuedSave = false; } } diff --git a/CLAUDE.md b/CLAUDE.md index 89111e1..ff9d504 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`