diff --git a/Source/FileManager/PersistentDictionary.cs b/Source/FileManager/PersistentDictionary.cs
index 2a40a4fc..0901a040 100644
--- a/Source/FileManager/PersistentDictionary.cs
+++ b/Source/FileManager/PersistentDictionary.cs
@@ -289,9 +289,27 @@ public class PersistentDictionary : IJsonBackedDictionary
/// Replaces the settings file in one step, so a concurrent reader - including one in another
/// Libation process - sees either the whole old file or the whole new one. Mirrors how
/// saves AccountsSettings.json.
+ ///
+ /// Windows refuses to rename over a file while someone else holds a handle to it, and the CLI,
+ /// another GUI instance or a virus scanner can all hold Settings.json for a moment, so retry
+ /// before giving up and letting the caller see the failure.
///
private void writeFileContents(string contents)
- => AtomicFileWriter.WriteAllText(Filepath, contents, validateJsonTempFile);
+ {
+ const int attempts = 5;
+ for (var attempt = 1; ; attempt++)
+ {
+ try
+ {
+ AtomicFileWriter.WriteAllText(Filepath, contents, validateJsonTempFile);
+ return;
+ }
+ catch (Exception ex) when (attempt < attempts && ex is IOException or UnauthorizedAccessException)
+ {
+ Thread.Sleep(20 * attempt);
+ }
+ }
+ }
/// Throws before the temp file replaces the real one, leaving the real one untouched.
private static void validateJsonTempFile(string tempPath)
diff --git a/Source/_Tests/FileManager.Tests/PersistentDictionaryConcurrencyTests.cs b/Source/_Tests/FileManager.Tests/PersistentDictionaryConcurrencyTests.cs
index 8e86aa49..5458befc 100644
--- a/Source/_Tests/FileManager.Tests/PersistentDictionaryConcurrencyTests.cs
+++ b/Source/_Tests/FileManager.Tests/PersistentDictionaryConcurrencyTests.cs
@@ -200,10 +200,17 @@ public class PersistentDictionaryConcurrencyTests
/// The lock cannot reach a second process - the GUI and the CLI share one Settings.json - so a
/// write must never leave the file truncated. Reading it straight off disk, bypassing the
/// dictionary, stands in for that outside reader.
+ ///
+ /// Unix only. These readers hold a handle almost continuously, and Windows denies a rename over
+ /// an open file however generously the reader shares it, so on Windows this would test the
+ /// retry in writeFileContents rather than the atomicity of the write.
///
[TestMethod]
public void ExternalReaderNeverSeesAPartiallyWrittenFile()
{
+ if (Environment.OSVersion.Platform != PlatformID.Unix)
+ Assert.Inconclusive($"Skipped because OS is not {PlatformID.Unix}.");
+
var file = createSettingsFile();
try
{
@@ -261,6 +268,57 @@ public class PersistentDictionaryConcurrencyTests
}
}
+ ///
+ /// Windows denies a rename over a file another handle holds open, and the CLI, a second GUI or a
+ /// virus scanner can all do that for a moment, so a write has to outlast a brief denial. Revoking
+ /// write permission on the containing directory reproduces that denial portably.
+ ///
+ [TestMethod]
+ public void Write_SurvivesATemporarilyUnwritableDirectory()
+ {
+ if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
+ Assert.Inconclusive("Skipped because revoking directory write permission needs unix file modes.");
+
+ var file = createSettingsFile();
+ var directory = Path.GetDirectoryName(file)!;
+ var original = File.GetUnixFileMode(directory);
+ try
+ {
+ var dictionary = new PersistentDictionary(file);
+ File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserExecute);
+
+ if (canCreateFileIn(directory))
+ Assert.Inconclusive("Skipped because this user can write to a read-only directory (running as root?).");
+
+ // shorter than the retry budget in writeFileContents
+ using var restore = new Timer(_ => File.SetUnixFileMode(directory, original), null, dueTime: 50, period: Timeout.Infinite);
+
+ dictionary.SetString("WrittenDespiteTheOutage", "value");
+
+ Assert.AreEqual("value", new PersistentDictionary(file).GetString("WrittenDespiteTheOutage"));
+ }
+ finally
+ {
+ try { File.SetUnixFileMode(directory, original); } catch { /* ignore */ }
+ deleteSettingsFile(file);
+ }
+ }
+
+ private static bool canCreateFileIn(string directory)
+ {
+ var probe = Path.Combine(directory, Guid.NewGuid().ToString("N"));
+ try
+ {
+ File.WriteAllText(probe, "");
+ File.Delete(probe);
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
[TestMethod]
public void ReadWhileWriting_DoesNotThrow()
{