Skip to content

A change queued with QueueSave is never written when the wall clock steps backwards (or does not tick) after the previous save, including at process exit #324

Description

@matt-edmondson

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:

  1. 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.
  2. 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.

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:

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().

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions