Skip to content

A power loss or OS crash just after a save can leave a zero-length settings file, which LoadOrCreate then silently overwrites with defaults #325

Description

@matt-edmondson

What's wrong

AppData.WriteText (AppDataStorage/AppData.cs:142-167) aims to be a safe write. It writes .tmp, copies the main file to .bk, deletes the main file, moves .tmp into place, then deletes .bk. That protects against the process crashing, but not against a power loss or OS crash:

  • Nothing is flushed to disk. FileSystem.File.WriteAllText(tempFilePath, text) only hands the data to the OS page cache. The Delete/Move/Delete calls that follow change metadata only. NTFS and ext4 journal metadata but not file data, so the rename and deletes can reach disk before the .tmp contents do. A crash in that window leaves <name>.json zero-length (or NUL-filled), and .bk is already gone.
  • The destination is deleted before the move, which bypasses ext4's auto_da_alloc safety net. ext4 flushes data when a file is renamed over an existing file. Here the move lands on a path that no longer exists, so that safety net never triggers.
  • An empty file is treated as "no settings". ReadText (AppData.cs:176-203) only runs the .tmp/.bk recovery path on FileNotFoundException. For a file that exists but is empty, it returns "". AppData<T>.LoadOrCreate then calls newAppData.Save() when string.IsNullOrEmpty(jsonString), which overwrites the file with defaults. Nothing is logged and nothing is thrown.

Failure scenario

  1. The app saves: Save(), or QueueSave() followed by the debounced or process-exit flush. WriteText completes and deletes .bk.
  2. Within the OS writeback interval (typically a few seconds), the machine loses power, blue-screens, or the laptop battery dies. The exit-time flush right before shutdown is a common point to be in this window.
  3. After reboot, <type>.json exists with length 0 or NUL bytes, and there is no .bk or .tmp.
  4. LoadOrCreate gets "", saves defaults over the file, and returns defaults. All user settings are lost without trace. The NUL-filled case goes to the JsonException delete path tracked in A settings file that fails to deserialize is deleted outright and replaced with defaults, with no backup left to recover from #314 and ends the same way.

Suggested fix

  • Write the temp file through a stream and call Flush(flushToDisk: true) before any metadata change.
  • Replace atomically instead of delete-then-move: use File.Replace(temp, filePath, bk) when the destination exists, or File.Move(temp, filePath, overwrite: true). The destination is then never missing, and ext4's rename-over heuristic applies.
  • In ReadText/LoadOrCreate, treat an existing file that is empty or whitespace-only as unreadable, not as "no file". Try the .tmp/.bk candidates, and archive rather than overwrite, consistent with A settings file that fails to deserialize is deleted outright and replaced with defaults, with no backup left to recover from #314.

Acceptance criteria

  • A MockFileSystem test where the main file exists but is zero-length and a valid .bk exists loads the backup's values, not defaults.
  • A test where the main file is zero-length and no candidates exist does not silently overwrite it. The file is archived or the condition is surfaced.
  • WriteText flushes the temp file to disk before replacing the destination.

Related but distinct: #314 (deleting the file on JsonException) and #310 (closed; process-crash recovery ordering between .tmp and .bk).

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