What's wrong
Whether a save is pending is decided by comparing two wall-clock timestamps:
QueueSave sets SaveQueuedTime = DateTime.UtcNow (AppDataStorage/AppData.cs:250)
Save sets LastSaveTime = DateTime.UtcNow (AppDataStorage/AppData.cs:472)
IsSaveQueued() returns SaveQueuedTime > LastSaveTime (AppDataStorage/AppData.cs:447-453)
SaveIfRequired (AppData.cs:268) and Dispose (AppData.cs:503, which also runs from the ProcessExit handler) both skip the save when IsSaveQueued() is false. So a QueueSave is silently dropped whenever its timestamp is not strictly later than the last save's. That happens in two cases:
- The clock steps backwards. An NTP correction, a manual clock change or a VM resume can move UTC backwards. Until the clock passes the old
LastSaveTime again, every QueueSave counts as "already saved", and anything queued before the process exits is lost.
- The clock does not tick. The library targets
netstandard2.0, so it runs on .NET Framework, where DateTime.UtcNow has a resolution of about 15.6 ms. If an edit and QueueSave() follow a Save() within the same tick (for example, in the same UI frame), the two timestamps are equal and the change is never written.
IsDoubounceTimeElapsed() (AppData.cs:455-461) uses the same clock. After a backwards step, DateTime.UtcNow - SaveQueuedTime stays negative, so the debounce can hold back a save for as long as the clock went back.
A scratch console app referencing AppDataStorage.csproj. It uses reflection to set the internal timestamps the way a clock step or a coarse tick leaves them:
var s = Settings.LoadOrCreate();
s.Value = "A"; s.Save();
// clock moved back 5 minutes after the save:
lastSaveTime.SetValue(s, (DateTime)lastSaveTime.GetValue(s)! + TimeSpan.FromMinutes(5));
s.Value = "B"; s.QueueSave();
s.Dispose(); // should flush the queued save
Settings.LoadOrCreate().Value; // "A"
var s2 = Settings.LoadOrCreate();
s2.Value = "C"; s2.Save();
s2.Value = "D"; s2.QueueSave();
saveQueuedTime.SetValue(s2, lastSaveTime.GetValue(s2)); // same clock tick
s2.Dispose();
Settings.LoadOrCreate().Value; // "C"
Output:
clock-step: IsSaveQueued=False
clock-step: reloaded Value=A
same-tick: IsSaveQueued=False
same-tick: reloaded Value=C
Why it matters
QueueSave, backed by the ProcessExit flush, is the library's main way of persisting settings. When a save is dropped, the user's change disappears with no error, and the API gives the caller no way to detect it.
Suggested fix
- Track the pending state with something that doesn't depend on the clock. Either a
bool dirty flag that QueueSave sets and Save clears, or a save-request counter that only increases, compared against the counter value at the last save. The counter has the advantage that a QueueSave racing a Save is not lost.
- Time the debounce with a monotonic source (
Stopwatch.GetTimestamp() / Environment.TickCount64, polyfilled for netstandard) instead of DateTime.UtcNow.
Acceptance criteria
- A
QueueSave() after a Save() always leaves the instance with a save pending, whatever the wall clock does, and Dispose() writes it.
- Tests cover two cases: a queue in the same tick as the last save, and a queue after
LastSaveTime has been set in the future. In both, the new value is on disk after Dispose().
What's wrong
Whether a save is pending is decided by comparing two wall-clock timestamps:
QueueSavesetsSaveQueuedTime = DateTime.UtcNow(AppDataStorage/AppData.cs:250)SavesetsLastSaveTime = DateTime.UtcNow(AppDataStorage/AppData.cs:472)IsSaveQueued()returnsSaveQueuedTime > LastSaveTime(AppDataStorage/AppData.cs:447-453)SaveIfRequired(AppData.cs:268) andDispose(AppData.cs:503, which also runs from the ProcessExit handler) both skip the save whenIsSaveQueued()is false. So aQueueSaveis silently dropped whenever its timestamp is not strictly later than the last save's. That happens in two cases:LastSaveTimeagain, everyQueueSavecounts as "already saved", and anything queued before the process exits is lost.netstandard2.0, so it runs on .NET Framework, whereDateTime.UtcNowhas a resolution of about 15.6 ms. If an edit andQueueSave()follow aSave()within the same tick (for example, in the same UI frame), the two timestamps are equal and the change is never written.IsDoubounceTimeElapsed()(AppData.cs:455-461) uses the same clock. After a backwards step,DateTime.UtcNow - SaveQueuedTimestays negative, so the debounce can hold back a save for as long as the clock went back.Repro (HEAD 976c542)
A scratch console app referencing
AppDataStorage.csproj. It uses reflection to set the internal timestamps the way a clock step or a coarse tick leaves them:Output:
Why it matters
QueueSave, backed by the ProcessExit flush, is the library's main way of persisting settings. When a save is dropped, the user's change disappears with no error, and the API gives the caller no way to detect it.Suggested fix
booldirty flag thatQueueSavesets andSaveclears, or a save-request counter that only increases, compared against the counter value at the last save. The counter has the advantage that aQueueSaveracing aSaveis not lost.Stopwatch.GetTimestamp()/Environment.TickCount64, polyfilled for netstandard) instead ofDateTime.UtcNow.Acceptance criteria
QueueSave()after aSave()always leaves the instance with a save pending, whatever the wall clock does, andDispose()writes it.LastSaveTimehas been set in the future. In both, the new value is on disk afterDispose().