From 81f63285d9d355b538d0c4934007e19d230dc05b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:26:39 +0000 Subject: [PATCH] Archive a settings file that fails to deserialize instead of deleting it [patch] LoadOrCreate deleted the main file on a JsonException and retried, but a successful save removes the backup, so that file was usually the only copy of the user's data and a hand edit or a model change in an app update silently wiped it. Move it aside to .corrupt. with the same counter loop TryRestoreFrom uses, now shared as AppData.Archive. Fixes ktsu-dev/AppDataStorage#314 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DCzUAHg8icJNNp7WtR51Um --- AppDataStorage.Test/AppDataTests.cs | 18 +++++++++++++++++ AppDataStorage/AppData.cs | 31 +++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/AppDataStorage.Test/AppDataTests.cs b/AppDataStorage.Test/AppDataTests.cs index 81e927d..8e5b12a 100644 --- a/AppDataStorage.Test/AppDataTests.cs +++ b/AppDataStorage.Test/AppDataTests.cs @@ -441,6 +441,24 @@ public void TestLoadOrCreateHandlesCorruptFile() Assert.AreEqual(string.Empty, appData.Data, "Data should be default if loaded from corrupt file."); } + [TestMethod] + public void TestLoadOrCreateArchivesAnUnreadableFileInsteadOfDeletingIt() + { + using TestAppData original = CreateTestAppDataWithContent("years of user settings"); + original.Save(); + AbsoluteFilePath filePath = original.FilePath; + string unreadable = AppData.FileSystem.File.ReadAllText(filePath).Replace("{", "{,", StringComparison.Ordinal); + AppData.FileSystem.File.WriteAllText(filePath, unreadable); + + TestAppData appData = TestAppData.LoadOrCreate(); + + Assert.AreEqual(string.Empty, appData.Data, "Data should be default if the file could not be read."); + + string[] archived = AppData.FileSystem.Directory.GetFiles(filePath.AbsoluteDirectoryPath.ToString(), $"{Path.GetFileName(filePath.ToString())}.corrupt.*"); + Assert.AreEqual(1, archived.Length, "The unreadable file must be kept, not deleted."); + Assert.AreEqual(unreadable, AppData.FileSystem.File.ReadAllText(archived[0]), "The archive must hold the original content."); + } + [TestMethod] public void TestLoadOrCreateHandlesNullJsonFile() { diff --git a/AppDataStorage/AppData.cs b/AppDataStorage/AppData.cs index 14c1df4..e440925 100644 --- a/AppDataStorage/AppData.cs +++ b/AppDataStorage/AppData.cs @@ -220,19 +220,32 @@ private static bool TryRestoreFrom(AbsoluteFilePath candidate, AbsoluteFilePath // Archiving rather than deleting keeps the recovered content for inspection, and moving it // out of the way is what stops a candidate that turns out to be unreadable from being - // promoted again on the next attempt: LoadOrCreate deletes a file it cannot deserialize and + // promoted again on the next attempt: LoadOrCreate archives a file it cannot deserialize and // reads again, which would otherwise restore the same bad content forever. + _ = Archive(candidate, string.Empty); + return true; + } + + /// + /// Moves aside under a unique timestamped name, so its content + /// survives without being read again in its place. + /// + /// The file to archive. + /// A suffix naming why the file was archived, such as ".corrupt", or empty. + /// The path the file was archived to. + internal static AbsoluteFilePath Archive(AbsoluteFilePath filePath, string label) + { string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - AbsoluteFilePath archived = candidate.WithSuffix($".{timestamp}"); + AbsoluteFilePath archived = filePath.WithSuffix($"{label}.{timestamp}"); int counter = 0; while (FileSystem.File.Exists(archived)) { counter++; - archived = candidate.WithSuffix($".{timestamp}_{counter}"); + archived = filePath.WithSuffix($"{label}.{timestamp}_{counter}"); } - FileSystem.File.Move(candidate, archived); - return true; + FileSystem.File.Move(filePath, archived); + return archived; } /// @@ -581,9 +594,11 @@ public static T LoadOrCreate(RelativeDirectoryPath? subdirectory, FileName? file } catch (JsonException) { - // file was corrupt or could not be deserialized - // delete and try load a backup - AppData.FileSystem.File.Delete(newAppData.FilePath); + // The file could not be read as T, whether from corruption, a hand edit or a model + // change in an app update. It is usually the only copy of the user's data, since a + // successful save removes the backup, so it is archived rather than deleted. The + // retry then finds it missing and falls back to a temp or backup file, or defaults. + _ = AppData.Archive(newAppData.FilePath, ".corrupt"); return LoadOrCreate(subdirectory, fileName); } }