From 04c4cb449efccd253150ac00f18f8c8b94216171 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:40:03 -0400 Subject: [PATCH] fix: make per-user config writes crash-safe via atomic rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added Services/AtomicFile.WriteAllText which writes to a sibling .tmp and then renames into place. File.Move(src, dst, overwrite: true) maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING) on Windows and rename(2) on Unix — both atomic when source and destination share a filesystem, which is always the case here. Swapped File.WriteAllText for AtomicFile.WriteAllText at every config-save site: - ConnectionStore.Save (saved server list — the worst-case loss) - AppSettingsService.Save (recent/open plans, slicer days-back, etc.) - SqlFormatSettingsService.Save (format options) - AboutWindow.SaveMcpSettings (MCP enable + port) A crash between the .tmp write and the rename leaves the original file intact and a stray .tmp sibling; the next save overwrites .tmp before renaming, so no manual cleanup is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/PlanViewer.App/AboutWindow.axaml.cs | 2 +- .../Services/AppSettingsService.cs | 2 +- src/PlanViewer.App/Services/AtomicFile.cs | 27 +++++++++++++++++++ .../Services/ConnectionStore.cs | 2 +- .../Services/SqlFormatSettingsService.cs | 2 +- 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/PlanViewer.App/Services/AtomicFile.cs diff --git a/src/PlanViewer.App/AboutWindow.axaml.cs b/src/PlanViewer.App/AboutWindow.axaml.cs index d5ce313..89ac6d7 100644 --- a/src/PlanViewer.App/AboutWindow.axaml.cs +++ b/src/PlanViewer.App/AboutWindow.axaml.cs @@ -56,7 +56,7 @@ private void SaveMcpSettings() }, new JsonSerializerOptions { WriteIndented = true }); Directory.CreateDirectory(settingsDir); - File.WriteAllText(settingsFile, json); + Services.AtomicFile.WriteAllText(settingsFile, json); } private void GitHubLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(GitHubUrl); diff --git a/src/PlanViewer.App/Services/AppSettingsService.cs b/src/PlanViewer.App/Services/AppSettingsService.cs index 46b374b..afff7fa 100644 --- a/src/PlanViewer.App/Services/AppSettingsService.cs +++ b/src/PlanViewer.App/Services/AppSettingsService.cs @@ -59,7 +59,7 @@ public static void Save(AppSettings settings) { Directory.CreateDirectory(SettingsDir); var json = JsonSerializer.Serialize(settings, JsonOptions); - File.WriteAllText(SettingsPath, json); + AtomicFile.WriteAllText(SettingsPath, json); } catch { diff --git a/src/PlanViewer.App/Services/AtomicFile.cs b/src/PlanViewer.App/Services/AtomicFile.cs new file mode 100644 index 0000000..c2c2447 --- /dev/null +++ b/src/PlanViewer.App/Services/AtomicFile.cs @@ -0,0 +1,27 @@ +using System.IO; + +namespace PlanViewer.App.Services; + +/// +/// Helper for atomic text-file writes: write to a sibling .tmp and rename +/// into place so a crash mid-write can't truncate the target file. Callers +/// are responsible for creating the parent directory first. +/// +internal static class AtomicFile +{ + /// + /// Writes to atomically + /// with respect to process crashes. If the process dies before the rename, + /// keeps its previous contents and a stray + /// .tmp sibling is left behind (cleaned up on the next call). + /// + public static void WriteAllText(string path, string contents) + { + var tmp = path + ".tmp"; + File.WriteAllText(tmp, contents); + // File.Move with overwrite:true maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING) + // on Windows and rename(2) on Unix — both atomic when source and destination + // live on the same filesystem, which is always the case here. + File.Move(tmp, path, overwrite: true); + } +} diff --git a/src/PlanViewer.App/Services/ConnectionStore.cs b/src/PlanViewer.App/Services/ConnectionStore.cs index ae56194..9d0e8b9 100644 --- a/src/PlanViewer.App/Services/ConnectionStore.cs +++ b/src/PlanViewer.App/Services/ConnectionStore.cs @@ -39,7 +39,7 @@ public void Save(List connections) { Directory.CreateDirectory(ConfigDir); var json = JsonSerializer.Serialize(connections, JsonOptions); - File.WriteAllText(ConfigFile, json); + AtomicFile.WriteAllText(ConfigFile, json); } public void AddOrUpdate(ServerConnection connection) diff --git a/src/PlanViewer.App/Services/SqlFormatSettingsService.cs b/src/PlanViewer.App/Services/SqlFormatSettingsService.cs index 23e8cc5..179e908 100644 --- a/src/PlanViewer.App/Services/SqlFormatSettingsService.cs +++ b/src/PlanViewer.App/Services/SqlFormatSettingsService.cs @@ -123,7 +123,7 @@ public static bool Save(SqlFormatSettings settings, out string? error) { Directory.CreateDirectory(SettingsDir); var json = JsonSerializer.Serialize(settings, JsonOptions); - File.WriteAllText(SettingsPath, json); + AtomicFile.WriteAllText(SettingsPath, json); return true; } catch (Exception ex)