fix(settings): replace the settings file in one rename
The stored file is now replaced with a single rename that works while other handles are open on it, so there is never a moment without a settings file and a process killed mid-save leaves the old file or the new one. Volumes without that rename fall back to a move. Content the JSON reader cannot decode is treated as invalid content rather than an error, the read handle is released on every path, and a kept-aside copy that cannot be made no longer blocks the save. Clearing recent files also deletes kept-aside copies. ADR-0006, the user guide and the threat model describe the protocol as implemented. Refs #46 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
1 parent
600825030a
commit
80e00b3ed1
11 files changed
+820
-112
No files matched your search
@@ -32,42 +32,64 @@ update. It is never deleted: deleting a lock file leaves a window in which a sec
|
||||
opener is refused with access denied. The operating system releases the handle if
|
||||
the process dies. A lock that stays held for every attempt refuses the update.
|
||||
|
||||
**Read.** The file is opened for reading while denying writers. A program that is
|
||||
rewriting the file in place therefore reads as busy, not as invalid content, and
|
||||
during an update no such program can start until the swap has happened. Readers
|
||||
allow the file to be replaced, so a read never blocks a save.
|
||||
**Read for an update.** The file is opened for reading while denying writers, and
|
||||
that handle is held until the rename. A program rewriting the file in place therefore
|
||||
reads as busy, not as invalid content, and none can start between the read and the
|
||||
rename. An empty file is waited on for the same attempts, because a program that
|
||||
clears a file before rewriting it leaves it empty for a moment.
|
||||
|
||||
**Read for display.** The same open is tried first. If another handle has write
|
||||
access, the file is read beside it instead of waiting, since such a handle is usually
|
||||
idle. Content read that way which does not parse is retried and, if it never parses,
|
||||
reported as unreadable rather than corrupt: it cannot be judged while it can still be
|
||||
written. An empty file reads as defaults at once.
|
||||
|
||||
**What a read can find.**
|
||||
|
||||
| Found | Used for display | On update |
|
||||
| --- | --- | --- |
|
||||
| No file | Defaults | The file is created |
|
||||
| Empty file | Defaults | Replaced after the wait; nothing kept aside |
|
||||
| Valid settings | The stored values | Replaced |
|
||||
| Some invalid values | Valid values kept, invalid ones as defaults | Replaced; the original is kept aside |
|
||||
| Not settings at all | Defaults | Replaced; the original is kept aside |
|
||||
| Some invalid properties | Valid properties kept, invalid ones as defaults | Replaced; a copy of the original is kept aside |
|
||||
| Not settings at all | Defaults | Replaced; a copy of the original is kept aside |
|
||||
| Unreadable or being written | Defaults | Refused; nothing is written |
|
||||
|
||||
A trailing comma, a comment and a quoted number are valid: they are what a hand edit
|
||||
commonly leaves behind. A file over 16 MiB is not loaded.
|
||||
commonly leaves behind. Text the JSON reader cannot decode, such as an unpaired
|
||||
surrogate escape, makes its property invalid. Repair works per top-level property:
|
||||
one bad entry in a list costs that list. A file over 16 MiB is not loaded.
|
||||
|
||||
**Write.** The new content is written to `settings.json.<32 hex digits>.tmp`,
|
||||
flushed to disk, and swapped in with a replace operation, which also moves an invalid
|
||||
original to its kept-aside name in the same step. If the file did not exist when it
|
||||
was read, the temporary file is renamed into place without overwriting. A failed
|
||||
**Write.** The new content is written to `settings.json.<32 hex digits>.tmp` and
|
||||
flushed to disk. That file is then renamed over `settings.json` in one step with the
|
||||
Windows rename that replaces an open file (`FileRenameInfoEx` with POSIX semantics).
|
||||
At no point is there no `settings.json`: a reader sees the old file or the new one,
|
||||
and a process killed at any moment leaves one of them. Handles that were open on the
|
||||
old file and allow deletion go on reading it. `File.Replace` is not used because it
|
||||
is two renames with the file missing between them, and `File.Move` with overwrite is
|
||||
not used because it refuses while any handle is open on the destination.
|
||||
|
||||
On a volume that does not implement that rename, the store closes its own read
|
||||
handle and falls back to `File.Move` with overwrite. If the file did not exist when
|
||||
it was read, the temporary file is renamed into place without overwriting. A failed
|
||||
update leaves the stored file as it was and removes its temporary file.
|
||||
|
||||
**Kept-aside files.** `settings.json.corrupt`, then `settings.json.corrupt-2` to
|
||||
`settings.json.corrupt-9`; the first free name is used, and the last is reused once
|
||||
all exist. Nothing reads them back; they exist so a hand edit gone wrong can be
|
||||
recovered.
|
||||
**Kept-aside copies.** Before invalid content is replaced it is copied to
|
||||
`settings.json.corrupt`, or to the first free of `settings.json.corrupt-2` to
|
||||
`settings.json.corrupt-9`; the last name is overwritten once all exist. The copy is a
|
||||
courtesy for recovering a hand edit: if it cannot be made the fault is reported and
|
||||
the save proceeds, because refusing would block every later change too. A save that
|
||||
then fails removes the copy it made. Nothing reads the copies back. They contain
|
||||
whatever the file contained, including recent-document paths, so clearing recent
|
||||
files deletes them.
|
||||
|
||||
**Abandoned temporary files.** A process killed between its write and its swap
|
||||
**Abandoned temporary files.** A process killed between its write and its rename
|
||||
leaves a temporary file. The next update, holding the lock, removes files that match
|
||||
this store's own name pattern and no others.
|
||||
|
||||
**Waiting.** Only a sharing violation is waited out: at most 20 attempts, 50 ms
|
||||
apart, for the lock, the read and the swap. Access denied is treated as permanent and
|
||||
reported at once, so a read-only profile does not stall a window.
|
||||
apart, for the lock, the read and the rename. Access denied is treated as permanent
|
||||
and reported at once, so a read-only profile does not stall a window.
|
||||
|
||||
**Reporting.** Faults are reported by kind with the exception type and HRESULT. No
|
||||
path and no operating-system message text reaches the log.
|
||||
@@ -77,11 +99,14 @@ path and no operating-system message text reaches the log.
|
||||
- An update is a synchronous call on the window thread. It normally takes a few
|
||||
milliseconds and at most about one second per contended step.
|
||||
- The JSON written is byte-compatible with earlier releases, so a downgrade reads it.
|
||||
- An earlier release running against the same folder neither takes the lock nor
|
||||
denies writers. While it is mid-save, this store reads the file as busy and refuses
|
||||
the update; while this store holds the file, the earlier release's save fails and
|
||||
it reports that the setting could not be saved. Neither loses stored data, but the
|
||||
two can refuse each other until both are the same version.
|
||||
- The rename needs Windows 10 version 1607 or later and a file system that supports
|
||||
it; NTFS does. Other volumes use the fallback, which is not safe against a kill
|
||||
between closing the file and the move completing.
|
||||
- An earlier release running against the same data folder still has its own defect:
|
||||
it takes no lock, and when a read fails it falls back to defaults and may save
|
||||
them. This store cannot prevent that. It also makes that release's saves fail more
|
||||
often while both run, because the earlier release cannot replace a file this store
|
||||
has open. Run one version per data folder.
|
||||
- The store keeps no in-memory copy. Windows hold their own view of the settings and
|
||||
merge only the fields they changed (ADR-0004).
|
||||
|
||||
@@ -90,6 +115,9 @@ path and no operating-system message text reaches the log.
|
||||
- **A named mutex instead of a lock file.** Rejected: a session-local name does not
|
||||
cover two sessions of one user on the same profile, and a global name can be
|
||||
created first by another account on the machine.
|
||||
- **Renaming the invalid file away instead of copying it.** Rejected: between that
|
||||
rename and the new file arriving there is no settings file, and a kill there loses
|
||||
the profile.
|
||||
- **Fixing AppKit's store instead.** Still worthwhile for other applications, but the
|
||||
read-modify-write API is a different shape, and this application needed the fix
|
||||
without waiting for a package release.
|
||||
@@ -34,7 +34,7 @@ physical directory. It does not authorize native commands or remote resource acc
|
||||
| Remote resources | Denied by CSP and resource interception |
|
||||
| External links | HTTP(S) only, actual browser user gesture, external Windows handler; no document navigation outside current page |
|
||||
| Heading navigation | Labels extracted after sanitation; authored identity attributes stripped; generated numeric targets only. Fixed host operations reveal/jump/collapse on the current intercepted page; authored scripts, messages and host objects remain disabled |
|
||||
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths. Settings changes are read-modify-write under a lock file beside `settings.json` and are swapped in atomically; a file that is unreadable or being written by another program is never overwritten, invalid content is kept aside under a bounded set of names, a file over 16 MiB is not loaded, and faults are logged by kind, exception type and HRESULT only (ADR-0006) |
|
||||
| Settings / logs | AppKit sanitation; per-user data root; log operational events without document content or paths. Settings changes are read-modify-write under a lock file beside `settings.json` and are renamed into place in one step, so the file is never absent; a file that is unreadable or being written by another program is never overwritten, a copy of invalid content is kept aside under a bounded set of names and removed when recent files are cleared, a file over 16 MiB is not loaded, and faults are logged by kind, exception type and HRESULT only (ADR-0006) |
|
||||
| Updates | Repository HTTPS feed, Velopack integrity checks and production signing preflight |
|
||||
| Repository / CI | Protected main and release tags; producer-bound checks; no untrusted PR checkout with privileged review token |
|
||||
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ Documents are rendered locally. Authored scripts and remote images are blocked;
|
||||
- **The viewer is not a default option:** use Setup rather than a portable copy, then reopen Windows Default Apps.
|
||||
- **The app does not start:** confirm Microsoft Edge WebView2 Runtime is installed and inspect the activity logs if available.
|
||||
- **A setting could not be saved:** another program is using `settings.json`, or the file or folder is read-only. Nothing is overwritten while the file cannot be read; try the change again.
|
||||
- **Settings were edited by hand:** a trailing comma, a comment or a quoted number is accepted. A value that is not valid is ignored and the rest are kept. The next save keeps the file as it was beside the new one, as `settings.json.corrupt` (then `.corrupt-2` up to `.corrupt-9`).
|
||||
- **Settings were edited by hand:** a trailing comma, a comment or a quoted number is accepted; the app rewrites the file in its own format on the next save, so comments are not kept. A setting whose value is not valid is ignored and the other settings are kept. When that happens, the next save first copies the file as it was to `settings.json.corrupt` (then `.corrupt-2` up to `.corrupt-9`). Those copies contain everything the file contained; **File → Recent files → Clear recent files** deletes them.
|
||||
|
||||
For help, use [Discussions](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/discussions). Report reproducible problems through [Issues](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/issues/new/choose), including **Help → About** version, Windows version, and relevant sanitized logs. Report vulnerabilities privately using [SECURITY.md](../SECURITY.md).
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -12,41 +13,50 @@ namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The settings file shared by every window and every process of one edition. A change is
|
||||
/// applied to the latest stored settings under a cross-process lock and swapped in atomically,
|
||||
/// so two instances cannot erase each other's change. A file that cannot be read, or that
|
||||
/// another program is writing, is never treated as empty; content that is invalid while
|
||||
/// nobody is writing it is repaired where possible and the original is kept aside.
|
||||
/// See ADR-0006 for the on-disk protocol.
|
||||
/// applied to the latest stored settings under a cross-process lock and renamed into place in
|
||||
/// one step, so two instances cannot erase each other's change and the file is never absent.
|
||||
/// A file that cannot be read, or that another program is writing, is never treated as empty;
|
||||
/// content that is invalid while nobody is writing it is repaired where possible and a copy of
|
||||
/// the original is kept aside. ADR-0006 describes the files involved.
|
||||
/// </summary>
|
||||
public sealed class AppSettingsStore
|
||||
{
|
||||
/// <summary>Attempts for the lock, a read, and the final swap before the operation gives up.</summary>
|
||||
/// <summary>Attempts for the lock, a read, and the final rename before the operation gives up.</summary>
|
||||
public const int MaximumAttempts = 20;
|
||||
|
||||
/// <summary>A larger file is not this application's settings and is never loaded into memory.</summary>
|
||||
public const int MaximumBytes = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>Invalid files kept aside before the newest slot is reused.</summary>
|
||||
/// <summary>Copies of invalid files kept aside before the last name is reused.</summary>
|
||||
public const int MaximumKeptAside = 9;
|
||||
|
||||
/// <summary>The pause between attempts while another handle is in the way.</summary>
|
||||
public static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private const int SharingViolation = unchecked((int)0x80070020);
|
||||
private const int LockViolation = unchecked((int)0x80070021);
|
||||
private static readonly JsonDocumentOptions s_lenient = new() { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip };
|
||||
private readonly Action<SettingsFault, Exception?> _report;
|
||||
private readonly Action<TimeSpan> _wait;
|
||||
private readonly Func<string, string, bool> _renameOver;
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the store; faults go to <paramref name="report"/>, and tests supply <paramref name="wait"/> to count attempts instead of sleeping.</summary>
|
||||
public AppSettingsStore(string path, Action<SettingsFault, Exception?>? report = null, Action<TimeSpan>? wait = null)
|
||||
: this(path, report, wait, FileSwap.TryRenameOver)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Creates the store over a given rename, so tests can stand in for a volume that cannot rename over an open file.</summary>
|
||||
internal AppSettingsStore(string path, Action<SettingsFault, Exception?>? report, Action<TimeSpan>? wait, Func<string, string, bool> renameOver)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
Path = System.IO.Path.GetFullPath(path);
|
||||
_report = report ?? ((_, _) => { });
|
||||
_wait = wait ?? Thread.Sleep;
|
||||
_renameOver = renameOver;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,11 +66,14 @@ public sealed class AppSettingsStore
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the stored settings for display. A missing, invalid, or unreadable file yields defaults and is left untouched.</summary>
|
||||
/// <summary>
|
||||
/// Reads the stored settings for display. A missing, empty, invalid or unreadable file yields
|
||||
/// defaults and is left untouched. A handle that another program merely holds with write
|
||||
/// access does not delay the read.
|
||||
/// </summary>
|
||||
public AppSettings Load()
|
||||
{
|
||||
_ = TryRead(out AppSettings settings, out FileStream? stored);
|
||||
stored?.Dispose();
|
||||
_ = TryRead(forUpdate: false, out AppSettings settings, out _);
|
||||
return settings;
|
||||
}
|
||||
|
||||
@@ -75,6 +88,7 @@ public sealed class AppSettingsStore
|
||||
public AppSettings? Update(Func<AppSettings, AppSettings> change)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
FileStream? stored = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
|
||||
@@ -83,26 +97,42 @@ public sealed class AppSettingsStore
|
||||
{
|
||||
return null;
|
||||
}
|
||||
SettingsRead read = TryRead(out AppSettings current, out FileStream? stored);
|
||||
// The read handle denies writers until the swap, so the content judged here is the content replaced.
|
||||
using(stored)
|
||||
// The read handle denies writers until the rename, so the content judged here is the content replaced.
|
||||
SettingsRead read = TryRead(forUpdate: true, out AppSettings current, out stored);
|
||||
if(read == SettingsRead.Unreadable)
|
||||
{
|
||||
if(read == SettingsRead.Unreadable)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
AppSettings edited = change(current);
|
||||
edited.Sanitize();
|
||||
RemoveAbandonedTemporaryFiles();
|
||||
Replace(JsonSerializer.SerializeToUtf8Bytes(edited, AppSettingsContext.Default.AppSettings), stored is not null, read is SettingsRead.Corrupt or SettingsRead.Repaired);
|
||||
return edited;
|
||||
return null;
|
||||
}
|
||||
AppSettings edited = change(current);
|
||||
edited.Sanitize();
|
||||
RemoveAbandonedTemporaryFiles();
|
||||
string? aside = read is SettingsRead.Corrupt or SettingsRead.Repaired ? KeepAside(stored!) : null;
|
||||
Replace(JsonSerializer.SerializeToUtf8Bytes(edited, AppSettingsContext.Default.AppSettings), stored, aside);
|
||||
return edited;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_report(SettingsFault.WriteFailed, exception);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stored?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes the copies of invalid files that saves have kept aside. They hold whatever the
|
||||
/// file held, including the recent-document history, so clearing that history removes them.
|
||||
/// </summary>
|
||||
public void DeleteKeptAside()
|
||||
{
|
||||
for(int slot = 1; slot <= MaximumKeptAside; slot++)
|
||||
{
|
||||
TryDelete(KeptAsideName(slot));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,43 +159,122 @@ public sealed class AppSettingsStore
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the file denying writers, so a program caught rewriting it in place reads as busy
|
||||
/// rather than as invalid content, then distinguishes missing, valid, repaired and invalid.
|
||||
/// The open handle is returned so the caller can keep writers out until it has finished.
|
||||
/// Reads the file, waiting out other handles a bounded number of times, and reports what it
|
||||
/// found. For an update the open handle is returned so writers stay out until the rename.
|
||||
/// </summary>
|
||||
private SettingsRead TryRead(out AppSettings settings, out FileStream? stored)
|
||||
private SettingsRead TryRead(bool forUpdate, out AppSettings settings, out FileStream? stored)
|
||||
{
|
||||
settings = new AppSettings();
|
||||
stored = null;
|
||||
SettingsRead result;
|
||||
try
|
||||
SettingsRead? read = null;
|
||||
for(int attempt = 1; read is null; attempt++)
|
||||
{
|
||||
FileStream? stream = null;
|
||||
Retry(() => stream = new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete));
|
||||
stored = stream;
|
||||
result = Parse(stream!, ref settings);
|
||||
if(attempt > 1)
|
||||
{
|
||||
_wait(RetryDelay);
|
||||
}
|
||||
read = Attempt(forUpdate, attempt == MaximumAttempts, ref settings, ref stored);
|
||||
}
|
||||
catch(Exception exception) when(exception is FileNotFoundException or DirectoryNotFoundException)
|
||||
if(read is SettingsRead.Corrupt or SettingsRead.Repaired)
|
||||
{
|
||||
result = SettingsRead.Missing;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_report(SettingsFault.Unreadable, exception);
|
||||
result = SettingsRead.Unreadable;
|
||||
_report(read == SettingsRead.Corrupt ? SettingsFault.Corrupt : SettingsFault.Repaired, null);
|
||||
}
|
||||
settings.Sanitize();
|
||||
return result;
|
||||
return read.Value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads at most <see cref="MaximumBytes"/>; one invalid value costs only that value, never the whole profile.</summary>
|
||||
private SettingsRead Parse(FileStream stream, ref AppSettings settings)
|
||||
/// <summary>
|
||||
/// One read. Returns null to try again: another handle is in the way, the file is empty while
|
||||
/// a save is waiting for whoever cleared it, or the content read beside a writer did not parse.
|
||||
/// On the last attempt it always answers.
|
||||
/// </summary>
|
||||
private SettingsRead? Attempt(bool forUpdate, bool last, ref AppSettings settings, ref FileStream? stored)
|
||||
{
|
||||
FileStream? stream = null;
|
||||
bool keep = false;
|
||||
try
|
||||
{
|
||||
stream = Open(forUpdate, out bool writersExcluded);
|
||||
SettingsRead read = Parse(stream, ref settings);
|
||||
if(read == SettingsRead.Empty && forUpdate && !last)
|
||||
{
|
||||
// A program that clears the file before rewriting it is about to write.
|
||||
return null;
|
||||
}
|
||||
if(read == SettingsRead.Corrupt && !writersExcluded)
|
||||
{
|
||||
// Content read while another program can write may simply be half written.
|
||||
return last ? Unreadable(null) : null;
|
||||
}
|
||||
keep = forUpdate;
|
||||
stored = keep ? stream : null;
|
||||
return read;
|
||||
}
|
||||
catch(Exception exception) when(exception is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
return SettingsRead.Missing;
|
||||
}
|
||||
catch(IOException exception) when(IsTransient(exception) && !last)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return Unreadable(exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!keep)
|
||||
{
|
||||
stream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reports a file whose content could not be established and answers that it must not be replaced.</summary>
|
||||
private SettingsRead Unreadable(Exception? exception)
|
||||
{
|
||||
_report(SettingsFault.Unreadable, exception);
|
||||
return SettingsRead.Unreadable;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the file denying writers, so a program rewriting it in place reads as busy rather
|
||||
/// than as invalid content. A read for display falls back to reading beside a handle that has
|
||||
/// write access, because such a handle is usually idle; the caller then knows the content may
|
||||
/// be half written.
|
||||
/// </summary>
|
||||
private FileStream Open(bool forUpdate, out bool writersExcluded)
|
||||
{
|
||||
writersExcluded = true;
|
||||
try
|
||||
{
|
||||
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
|
||||
}
|
||||
catch(IOException exception) when(IsTransient(exception) && !forUpdate)
|
||||
{
|
||||
writersExcluded = false;
|
||||
return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads at most <see cref="MaximumBytes"/> and classifies the content; an invalid property costs that property, never the whole profile.</summary>
|
||||
private static SettingsRead Parse(FileStream stream, ref AppSettings settings)
|
||||
{
|
||||
if(stream.Length == 0)
|
||||
{
|
||||
return SettingsRead.Empty;
|
||||
}
|
||||
if(stream.Length > MaximumBytes)
|
||||
{
|
||||
_report(SettingsFault.Corrupt, null);
|
||||
return SettingsRead.Corrupt;
|
||||
}
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
|
||||
@@ -178,22 +287,20 @@ public sealed class AppSettingsStore
|
||||
return SettingsRead.Loaded;
|
||||
}
|
||||
}
|
||||
catch(JsonException)
|
||||
catch(Exception exception) when(IsInvalidContent(exception))
|
||||
{
|
||||
if(Salvage(json) is { } repaired)
|
||||
{
|
||||
settings = repaired;
|
||||
_report(SettingsFault.Repaired, null);
|
||||
return SettingsRead.Repaired;
|
||||
}
|
||||
}
|
||||
_report(SettingsFault.Corrupt, null);
|
||||
return SettingsRead.Corrupt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Keeps every stored value that is valid on its own and drops the ones that are not; null when the file is not a JSON object at all.</summary>
|
||||
/// <summary>Keeps every stored property that is valid on its own and drops the ones that are not; null when the file is not a JSON object at all.</summary>
|
||||
private static AppSettings? Salvage(string json)
|
||||
{
|
||||
try
|
||||
@@ -208,14 +315,14 @@ public sealed class AppSettingsStore
|
||||
{
|
||||
_ = new JsonObject { [name] = stored[name]?.DeepClone() }.Deserialize(AppSettingsContext.Default.AppSettings);
|
||||
}
|
||||
catch(JsonException)
|
||||
catch(Exception exception) when(IsInvalidContent(exception))
|
||||
{
|
||||
stored.Remove(name);
|
||||
}
|
||||
}
|
||||
return stored.Deserialize(AppSettingsContext.Default.AppSettings);
|
||||
}
|
||||
catch(Exception exception) when(exception is JsonException or ArgumentException)
|
||||
catch(Exception exception) when(IsInvalidContent(exception) || exception is ArgumentException)
|
||||
{
|
||||
// Not JSON, or an object that repeats a property name.
|
||||
return null;
|
||||
@@ -225,10 +332,54 @@ public sealed class AppSettingsStore
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes beside the file and flushes to disk, then swaps it in. A failure leaves the stored
|
||||
/// file and no temporary file. Invalid content being replaced is kept under a free name.
|
||||
/// What the JSON reader throws for content that is not valid settings: a syntax or type error,
|
||||
/// or text it cannot decode, such as an unpaired surrogate escape.
|
||||
/// </summary>
|
||||
private void Replace(byte[] content, bool exists, bool keepAside)
|
||||
private static bool IsInvalidContent(Exception exception) => exception is JsonException or InvalidOperationException;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Copies an invalid file to the first free kept-aside name before it is replaced, or over the
|
||||
/// last name once all are taken, and returns the copy's path. The copy is a courtesy: failing
|
||||
/// to make it is reported and does not stop the save, since refusing would block every later
|
||||
/// change as well; null is returned then.
|
||||
/// </summary>
|
||||
private string? KeepAside(FileStream stored)
|
||||
{
|
||||
int slot = 1;
|
||||
while(slot < MaximumKeptAside && (File.Exists(KeptAsideName(slot)) || Directory.Exists(KeptAsideName(slot))))
|
||||
{
|
||||
slot++;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var copy = new FileStream(KeptAsideName(slot), FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
stored.Position = 0;
|
||||
stored.CopyTo(copy);
|
||||
copy.Flush(flushToDisk: true);
|
||||
return KeptAsideName(slot);
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_report(SettingsFault.NotKeptAside, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The kept-aside names: <c>.corrupt</c>, then <c>.corrupt-2</c> to <c>.corrupt-9</c>.</summary>
|
||||
private string KeptAsideName(int slot) => slot == 1 ? Path + ".corrupt" : Path + ".corrupt-" + slot.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Writes beside the file and flushes to disk, then renames over it in one step. A failure
|
||||
/// leaves the stored file as it was, and neither the temporary file nor the copy just kept
|
||||
/// aside behind.
|
||||
/// </summary>
|
||||
private void Replace(byte[] content, FileStream? stored, string? aside)
|
||||
{
|
||||
string temporary = Path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
bool replaced = false;
|
||||
@@ -239,16 +390,22 @@ public sealed class AppSettingsStore
|
||||
stream.Write(content);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if(exists)
|
||||
{
|
||||
string? aside = keepAside ? KeptAsideName() : null;
|
||||
Retry(() => File.Replace(temporary, Path, aside, ignoreMetadataErrors: true));
|
||||
}
|
||||
else
|
||||
if(stored is null)
|
||||
{
|
||||
// No overwrite: a file that appeared since the read is not this instance's to replace.
|
||||
File.Move(temporary, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
bool renamed = false;
|
||||
Retry(() => renamed = _renameOver(temporary, Path));
|
||||
if(!renamed)
|
||||
{
|
||||
// This volume cannot rename over an open file, so the file is closed first.
|
||||
stored.Dispose();
|
||||
Retry(() => File.Move(temporary, Path, overwrite: true));
|
||||
}
|
||||
}
|
||||
replaced = true;
|
||||
}
|
||||
finally
|
||||
@@ -256,26 +413,17 @@ public sealed class AppSettingsStore
|
||||
if(!replaced)
|
||||
{
|
||||
TryDelete(temporary);
|
||||
if(aside is not null)
|
||||
{
|
||||
TryDelete(aside);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The first free name for an invalid file; once every slot is used the last slot is reused.</summary>
|
||||
private string KeptAsideName()
|
||||
{
|
||||
string candidate = Path + ".corrupt";
|
||||
for(int slot = 2; slot <= MaximumKeptAside && File.Exists(candidate); slot++)
|
||||
{
|
||||
candidate = Path + ".corrupt-" + slot.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A writer that died before its swap left a temporary file with this store's own name pattern; under the lock every such file is abandoned.</summary>
|
||||
/// <summary>A writer that died before its rename left a temporary file with this store's own name pattern; under the lock every such file is abandoned.</summary>
|
||||
private void RemoveAbandonedTemporaryFiles()
|
||||
{
|
||||
string prefix = System.IO.Path.GetFileName(Path) + ".";
|
||||
@@ -310,12 +458,12 @@ public sealed class AppSettingsStore
|
||||
|
||||
|
||||
|
||||
/// <summary>A sharing or byte-range lock violation: another handle on the file that is expected to go away. Access denied is not transient.</summary>
|
||||
private static bool IsTransient(IOException exception) => exception.HResult is SharingViolation or LockViolation;
|
||||
/// <summary>A sharing violation: another handle on the file that is expected to go away. Access denied is not transient.</summary>
|
||||
private static bool IsTransient(IOException exception) => exception.HResult == SharingViolation;
|
||||
|
||||
|
||||
|
||||
/// <summary>Cleanup only: a temporary file that cannot be removed now is removed by the next successful save.</summary>
|
||||
/// <summary>Removes a file if it is there and can be removed; a folder with that name, or a file in use, is left.</summary>
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
@@ -324,7 +472,7 @@ public sealed class AppSettingsStore
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// The outcome of the save is already decided.
|
||||
// Cleanup only: the operation this was part of has already succeeded or failed.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a file by renaming another over it in a single step. The destination is never
|
||||
/// absent, not even for an instant, and the rename works while the destination is open through
|
||||
/// handles that allow deletion; those handles go on reading the content they opened.
|
||||
/// <see cref="File.Move(string, string, bool)"/> refuses when any handle is open on the
|
||||
/// destination, and <see cref="File.Replace(string, string, string?)"/> is two renames with the
|
||||
/// destination missing between them, so neither can do this.
|
||||
/// </summary>
|
||||
internal static partial class FileSwap
|
||||
{
|
||||
private const uint DeleteAccess = 0x00010000;
|
||||
private const uint ShareAll = 0x00000007;
|
||||
private const uint OpenExisting = 3;
|
||||
private const int FileRenameInfoEx = 22;
|
||||
private const uint ReplaceIfExists = 0x1;
|
||||
private const uint PosixSemantics = 0x2;
|
||||
private const int AccessDenied = 5;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Renames <paramref name="source"/> over <paramref name="destination"/>. Returns false when the
|
||||
/// volume cannot rename over an open file, as file systems other than NTFS may not. Throws
|
||||
/// <see cref="IOException"/> with the Windows error as its HRESULT when the rename is refused,
|
||||
/// for example by a handle on the destination that does not allow deletion, and
|
||||
/// <see cref="UnauthorizedAccessException"/> when access is denied.
|
||||
/// </summary>
|
||||
public static bool TryRenameOver(string source, string destination)
|
||||
{
|
||||
using SafeFileHandle handle = CreateFile(source, DeleteAccess, ShareAll, 0, OpenExisting, 0, 0);
|
||||
if(handle.IsInvalid)
|
||||
{
|
||||
throw Failure(Marshal.GetLastPInvokeError());
|
||||
}
|
||||
// FILE_RENAME_INFO: flags, root directory handle, name length in bytes, then the name itself.
|
||||
byte[] name = Encoding.Unicode.GetBytes(destination);
|
||||
int lengthOffset = 2 * IntPtr.Size;
|
||||
int nameOffset = lengthOffset + sizeof(int);
|
||||
byte[] rename = new byte[nameOffset + name.Length + sizeof(char)];
|
||||
BitConverter.TryWriteBytes(rename, ReplaceIfExists | PosixSemantics);
|
||||
BitConverter.TryWriteBytes(rename.AsSpan(lengthOffset), name.Length);
|
||||
name.CopyTo(rename, nameOffset);
|
||||
if(SetFileInformationByHandle(handle, FileRenameInfoEx, rename, rename.Length))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
int error = Marshal.GetLastPInvokeError();
|
||||
return IsUnsupported(error) ? false : throw Failure(error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The errors a volume answers with when it does not implement this kind of rename: invalid function, not supported, invalid parameter.</summary>
|
||||
internal static bool IsUnsupported(int error) => error is 1 or 50 or 87;
|
||||
|
||||
|
||||
|
||||
/// <summary>The exception a managed file operation would throw for the same Windows error.</summary>
|
||||
private static Exception Failure(int error)
|
||||
{
|
||||
string message = new Win32Exception(error).Message;
|
||||
return error == AccessDenied ? new UnauthorizedAccessException(message) : new IOException(message, unchecked((int)0x80070000) | error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Opens the file to be renamed with delete access, which a managed file stream cannot request.</summary>
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
private static partial SafeFileHandle CreateFile(string path, uint access, uint share, nint security, uint disposition, uint flags, nint template);
|
||||
|
||||
|
||||
|
||||
/// <summary>Applies the rename to the open file.</summary>
|
||||
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool SetFileInformationByHandle(SafeFileHandle file, int informationClass, byte[] information, int size);
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<!-- Include the SDK tool dependency in the lock used by single-file CLI publishing. -->
|
||||
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
|
||||
<!-- Source-generated P/Invoke marshalling (FileSwap) compiles to unsafe code. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,9 +9,9 @@ namespace MarkdownViewr.Windows.Core;
|
||||
/// <summary>Why the settings file could not be used as stored; reported without paths or operating-system message text.</summary>
|
||||
public enum SettingsFault
|
||||
{
|
||||
/// <summary>The file does not hold settings at all; defaults are used and the next save keeps the invalid file aside.</summary>
|
||||
/// <summary>The file does not hold settings at all; defaults are used and the next save keeps a copy of the file aside.</summary>
|
||||
Corrupt,
|
||||
/// <summary>Some stored values were invalid and read as their defaults; the next save keeps the original file aside.</summary>
|
||||
/// <summary>Some stored properties were invalid and read as their defaults; the next save keeps a copy of the original aside.</summary>
|
||||
Repaired,
|
||||
/// <summary>The file could not be read, or another program was writing it; it is left untouched and nothing is written over it.</summary>
|
||||
Unreadable,
|
||||
@@ -19,4 +19,6 @@ public enum SettingsFault
|
||||
Busy,
|
||||
/// <summary>The change could not be written; the stored file is unchanged.</summary>
|
||||
WriteFailed,
|
||||
/// <summary>The invalid original could not be copied aside; the change was saved without the copy.</summary>
|
||||
NotKeptAside,
|
||||
}
|
||||
@@ -13,9 +13,11 @@ internal enum SettingsRead
|
||||
Loaded,
|
||||
/// <summary>No file yet; defaults apply and the first save creates it.</summary>
|
||||
Missing,
|
||||
/// <summary>Valid values were kept and invalid ones dropped; a save keeps the original file aside.</summary>
|
||||
/// <summary>A file with no content, as a program that clears a file before rewriting it leaves it; defaults apply and a save replaces it.</summary>
|
||||
Empty,
|
||||
/// <summary>Valid properties were kept and invalid ones dropped; a save keeps a copy of the original aside.</summary>
|
||||
Repaired,
|
||||
/// <summary>Not settings at all; defaults apply and a save keeps the invalid file aside.</summary>
|
||||
/// <summary>Not settings at all; defaults apply and a save keeps a copy of the file aside.</summary>
|
||||
Corrupt,
|
||||
/// <summary>The content is unknown, so it must not be replaced.</summary>
|
||||
Unreadable,
|
||||
|
||||
@@ -129,6 +129,11 @@ public partial class App
|
||||
settings.RecentFiles = [];
|
||||
return settings;
|
||||
}) is not null;
|
||||
if(saved)
|
||||
{
|
||||
// Copies of an invalid settings file kept beside it would still list the history.
|
||||
AppIdentity.SettingsStore.DeleteKeptAside();
|
||||
}
|
||||
RefreshRecentMenus();
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,24 @@ public class AppSettingsStoreTests
|
||||
|
||||
|
||||
|
||||
/// <summary>A change that throws leaves the stored file as it was and open to every other program at once.</summary>
|
||||
[Fact]
|
||||
public void Update_ChangeThrows_ReleasesTheStoredFile()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
Seed(temp);
|
||||
byte[] before = File.ReadAllBytes(temp.File("settings.json"));
|
||||
var store = new AppSettingsStore(temp.File("settings.json"));
|
||||
Assert.Throws<InvalidOperationException>(() => store.Update(_ => throw new InvalidOperationException("change failed")));
|
||||
using(new FileStream(temp.File("settings.json"), FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
}
|
||||
Assert.Equal(before, File.ReadAllBytes(temp.File("settings.json")));
|
||||
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Another handle holding the file for a moment (a scanner, a backup) is waited out, not read as an empty profile.</summary>
|
||||
[Fact]
|
||||
public void Load_FileLockedBriefly_RetriesAndReadsTheStoredValues()
|
||||
@@ -358,7 +376,7 @@ public class AppSettingsStoreTests
|
||||
Assert.Null(store.Update(settings => settings));
|
||||
Assert.Equal("Catppuccin Mocha", store.Load().DocumentTheme);
|
||||
}
|
||||
Assert.Equal([SettingsFault.Unreadable, SettingsFault.Unreadable], faults);
|
||||
Assert.Equal([SettingsFault.Unreadable], faults);
|
||||
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
|
||||
Assert.Equal(0, new FileInfo(temp.File("settings.json")).Length);
|
||||
}
|
||||
@@ -367,7 +385,6 @@ public class AppSettingsStoreTests
|
||||
|
||||
/// <summary>Content that is not settings at all yields defaults, is reported, and stays where it is until something is saved.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("{")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[1]")]
|
||||
@@ -403,24 +420,181 @@ public class AppSettingsStoreTests
|
||||
|
||||
|
||||
|
||||
/// <summary>One invalid value costs only that value: every other stored preference is kept.</summary>
|
||||
/// <summary>An invalid property costs only that property: every other stored preference, before and after it, is kept.</summary>
|
||||
[Fact]
|
||||
public void Load_OneInvalidValue_KeepsEveryOtherValue()
|
||||
public void Load_InvalidProperties_KeepsEveryOtherProperty()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("settings.json"), """{"Theme":"Dark","OutlineDepth":5.5,"DocumentTheme":"Nord","RecentFiles":["C:\\docs\\a.md",5],"GlobalCss":"body { color: red }","FutureSetting":{"a":1}}""");
|
||||
File.WriteAllText(temp.File("settings.json"), """{"Theme":"Dark","OutlineDepth":5.5,"DocumentTheme":"Nord","RecentFiles":["C:\\docs\\a.md",5],"GlobalCss":"body { color: red }","FutureSetting":{"a":1},"WrapOutlineEntries":true,"ContentWidthPx":1234,"Frontmatter":"Metadata","RenderHighlight":false,"ShowSplashScreen":false,"OutlineWidth":300,"Print":{"Theme":"Nord","Borders":true}}""");
|
||||
var faults = new List<SettingsFault>();
|
||||
AppSettings loaded = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault)).Load();
|
||||
Assert.Equal([SettingsFault.Repaired], faults);
|
||||
Assert.Equal("Dark", loaded.Theme);
|
||||
Assert.Equal("Nord", loaded.DocumentTheme);
|
||||
Assert.Equal("body { color: red }", loaded.GlobalCss);
|
||||
Assert.True(loaded.WrapOutlineEntries);
|
||||
Assert.Equal(1234, loaded.ContentWidthPx);
|
||||
Assert.Equal("Metadata", loaded.Frontmatter);
|
||||
Assert.False(loaded.RenderHighlight);
|
||||
Assert.False(loaded.ShowSplashScreen);
|
||||
Assert.Equal(300, loaded.OutlineWidth);
|
||||
Assert.Equal("Nord", loaded.Print.Theme);
|
||||
Assert.True(loaded.Print.Borders);
|
||||
Assert.Equal(3, loaded.OutlineDepth);
|
||||
Assert.Empty(loaded.RecentFiles);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Text the JSON reader cannot decode, an unpaired surrogate escape, is an invalid property
|
||||
/// like any other: it is dropped, the rest load, and nothing is thrown into startup.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LoadAndUpdate_UndecodableText_DropsThatPropertyAndSavesTheRest()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("settings.json"), "{\"DocumentTheme\":\"Nord\",\"GlobalCss\":\"a\\uD800b\",\"Theme\":\"Dark\"}");
|
||||
var faults = new List<SettingsFault>();
|
||||
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault));
|
||||
AppSettings loaded = store.Load();
|
||||
Assert.Equal("Nord", loaded.DocumentTheme);
|
||||
Assert.Equal("Dark", loaded.Theme);
|
||||
Assert.Equal("", loaded.GlobalCss);
|
||||
Assert.NotNull(store.Update(settings => settings));
|
||||
Assert.Equal([SettingsFault.Repaired, SettingsFault.Repaired], faults);
|
||||
Assert.Equal("Dark", store.Load().Theme);
|
||||
Assert.Equal(["settings.json", "settings.json.corrupt", "settings.json.lock"], FileNames(temp.Path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A file with no content is not invalid settings: it reads as defaults, without a fault and without a wait.</summary>
|
||||
[Fact]
|
||||
public void Load_EmptyFile_ReturnsDefaultsWithoutAFaultOrAWait()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllBytes(temp.File("settings.json"), []);
|
||||
int waits = 0;
|
||||
var faults = new List<SettingsFault>();
|
||||
Assert.Equal("Catppuccin Mocha", new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ => waits++).Load().DocumentTheme);
|
||||
Assert.Empty(faults);
|
||||
Assert.Equal(0, waits);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A program that clears the file, closes it, and then writes it again leaves it empty for a
|
||||
/// moment. A save waits for the content instead of filling the gap with defaults.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Update_FileClearedThenRewritten_WaitsForTheContent()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
Seed(temp);
|
||||
File.WriteAllBytes(temp.File("settings.json"), []);
|
||||
int waits = 0;
|
||||
var faults = new List<SettingsFault>();
|
||||
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ =>
|
||||
{
|
||||
if(++waits == 2)
|
||||
{
|
||||
File.WriteAllText(temp.File("settings.json"), """{"Theme":"Light","DocumentTheme":"Dracula"}""");
|
||||
}
|
||||
});
|
||||
AppSettings? saved = store.Update(settings =>
|
||||
{
|
||||
settings.OutlineDepth = 5;
|
||||
return settings;
|
||||
});
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(2, waits);
|
||||
Assert.Empty(faults);
|
||||
Assert.Equal("Dracula", store.Load().DocumentTheme);
|
||||
Assert.Equal("Light", store.Load().Theme);
|
||||
Assert.Equal(5, store.Load().OutlineDepth);
|
||||
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A file that stays empty is replaced after the wait, and nothing is kept aside: there is nothing to keep.</summary>
|
||||
[Fact]
|
||||
public void Update_FileStaysEmpty_SavesAfterTheWaitWithoutKeepingAnythingAside()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllBytes(temp.File("settings.json"), []);
|
||||
int waits = 0;
|
||||
var faults = new List<SettingsFault>();
|
||||
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ => waits++);
|
||||
Assert.NotNull(store.Update(settings =>
|
||||
{
|
||||
settings.Theme = "Dark";
|
||||
return settings;
|
||||
}));
|
||||
Assert.Equal(AppSettingsStore.MaximumAttempts - 1, waits);
|
||||
Assert.Empty(faults);
|
||||
Assert.Equal("Dark", store.Load().Theme);
|
||||
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A handle another program merely holds with write access does not delay a read for display:
|
||||
/// the stored values are read beside it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Load_FileHeldWithWriteAccess_ReadsTheStoredValuesWithoutWaiting()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
Seed(temp);
|
||||
int waits = 0;
|
||||
var faults = new List<SettingsFault>();
|
||||
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ => waits++);
|
||||
using(new FileStream(temp.File("settings.json"), FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete))
|
||||
{
|
||||
Assert.Equal("Nord", store.Load().DocumentTheme);
|
||||
}
|
||||
Assert.Equal(0, waits);
|
||||
Assert.Empty(faults);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Content read beside a writer that does not parse may simply be half written. The read waits
|
||||
/// for the writer; if the content never becomes valid it is reported as unreadable, not as
|
||||
/// corrupt, because it cannot be judged while someone is writing it.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void Load_HalfWrittenContentBesideAWriter_WaitsAndNeverCallsItCorrupt(bool writerFinishes)
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
using var writer = new FileStream(temp.File("settings.json"), FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
|
||||
writer.Write("""{"DocumentTheme":"Dra"""u8);
|
||||
writer.Flush();
|
||||
int waits = 0;
|
||||
var faults = new List<SettingsFault>();
|
||||
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ =>
|
||||
{
|
||||
if(++waits == 2 && writerFinishes)
|
||||
{
|
||||
writer.Write("""cula"}"""u8);
|
||||
writer.Flush();
|
||||
}
|
||||
});
|
||||
AppSettings loaded = store.Load();
|
||||
Assert.Equal(writerFinishes ? "Dracula" : "Catppuccin Mocha", loaded.DocumentTheme);
|
||||
Assert.Equal(writerFinishes ? 2 : AppSettingsStore.MaximumAttempts - 1, waits);
|
||||
Assert.Equal(writerFinishes ? [] : [SettingsFault.Unreadable], faults);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A repeated property beside an invalid value is still read without throwing into startup.</summary>
|
||||
[Fact]
|
||||
public void Load_RepeatedPropertyAndInvalidValue_DoesNotThrow()
|
||||
@@ -520,7 +694,144 @@ public class AppSettingsStoreTests
|
||||
|
||||
|
||||
|
||||
/// <summary>When the save over invalid content fails, the invalid file is still in place: it is never moved away first.</summary>
|
||||
/// <summary>A folder where a kept-aside copy would go is skipped; the next name is used.</summary>
|
||||
[Fact]
|
||||
public void Update_KeptAsideNameIsAFolder_UsesTheNextName()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
Directory.CreateDirectory(temp.File("settings.json.corrupt"));
|
||||
File.WriteAllText(temp.File("settings.json"), "not json");
|
||||
var faults = new List<SettingsFault>();
|
||||
Assert.NotNull(new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault)).Update(settings => settings));
|
||||
Assert.Equal("not json", File.ReadAllText(temp.File("settings.json.corrupt-2")));
|
||||
Assert.Equal([SettingsFault.Corrupt], faults);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>When no copy can be made the save still goes through, and the missing copy is reported.</summary>
|
||||
[Fact]
|
||||
public void Update_CopyCannotBeKeptAside_SavesAndReportsIt()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
for(int slot = 2; slot <= AppSettingsStore.MaximumKeptAside; slot++)
|
||||
{
|
||||
Directory.CreateDirectory(temp.File("settings.json.corrupt-" + slot.ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
||||
}
|
||||
Directory.CreateDirectory(temp.File("settings.json.corrupt"));
|
||||
File.WriteAllText(temp.File("settings.json"), "not json");
|
||||
var faults = new List<(SettingsFault Fault, Exception? Exception)>();
|
||||
var store = new AppSettingsStore(temp.File("settings.json"), (fault, exception) => faults.Add((fault, exception)));
|
||||
Assert.NotNull(store.Update(settings =>
|
||||
{
|
||||
settings.Theme = "Dark";
|
||||
return settings;
|
||||
}));
|
||||
Assert.Equal([SettingsFault.Corrupt, SettingsFault.NotKeptAside], faults.Select(entry => entry.Fault));
|
||||
Assert.IsType<UnauthorizedAccessException>(faults[1].Exception);
|
||||
Assert.Equal("Dark", store.Load().Theme);
|
||||
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Kept-aside copies hold whatever the file held, so they can be removed on request; folders with those names are left.</summary>
|
||||
[Fact]
|
||||
public void DeleteKeptAside_CopiesAndAFolder_RemovesOnlyTheCopies()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
Seed(temp);
|
||||
File.WriteAllText(temp.File("settings.json.corrupt"), "copy 1");
|
||||
File.WriteAllText(temp.File("settings.json.corrupt-2"), "copy 2");
|
||||
File.WriteAllText(temp.File("settings.json.corrupt-9"), "copy 9");
|
||||
File.WriteAllText(temp.File("settings.json.corrupt-10"), "not a kept-aside name");
|
||||
Directory.CreateDirectory(temp.File("settings.json.corrupt-3"));
|
||||
new AppSettingsStore(temp.File("settings.json")).DeleteKeptAside();
|
||||
Assert.Equal(["settings.json", "settings.json.corrupt-10", "settings.json.lock"], FileNames(temp.Path));
|
||||
Assert.True(Directory.Exists(temp.File("settings.json.corrupt-3")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The stored file is in place at the moment the new one is renamed over it and immediately
|
||||
/// after: there is no step at which the folder has no settings file.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Update_AtTheRename_TheStoredFileIsStillInPlace()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
Seed(temp);
|
||||
string path = temp.File("settings.json");
|
||||
var observed = new List<string>();
|
||||
var store = new AppSettingsStore(path, null, null, (source, destination) =>
|
||||
{
|
||||
observed.Add(File.Exists(destination) ? "stored file present before" : "stored file missing before");
|
||||
observed.Add(File.Exists(source) ? "new file written" : "new file missing");
|
||||
bool renamed = FileSwap.TryRenameOver(source, destination);
|
||||
observed.Add(File.Exists(destination) ? "stored file present after" : "stored file missing after");
|
||||
return renamed;
|
||||
});
|
||||
Assert.NotNull(store.Update(settings =>
|
||||
{
|
||||
settings.Theme = "Light";
|
||||
return settings;
|
||||
}));
|
||||
Assert.Equal(["stored file present before", "new file written", "stored file present after"], observed);
|
||||
Assert.Equal("Light", store.Load().Theme);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>On a volume that cannot rename over an open file the store closes its own handle and replaces the file the older way.</summary>
|
||||
[Fact]
|
||||
public void Update_VolumeCannotRenameOverAnOpenFile_ReplacesAfterClosingItsHandle()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
Seed(temp);
|
||||
int attempts = 0;
|
||||
var store = new AppSettingsStore(temp.File("settings.json"), null, _ => { }, (_, _) =>
|
||||
{
|
||||
attempts++;
|
||||
return false;
|
||||
});
|
||||
Assert.NotNull(store.Update(settings =>
|
||||
{
|
||||
settings.Theme = "Light";
|
||||
return settings;
|
||||
}));
|
||||
Assert.Equal(1, attempts);
|
||||
Assert.Equal("Light", store.Load().Theme);
|
||||
Assert.Equal("Nord", store.Load().DocumentTheme);
|
||||
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only a sharing violation is waited out at the rename; any other I/O failure fails the save at once.</summary>
|
||||
[Theory]
|
||||
[InlineData(unchecked((int)0x80070020), 3, true)]
|
||||
[InlineData(unchecked((int)0x80070021), 0, false)]
|
||||
[InlineData(unchecked((int)0x8007001F), 0, false)]
|
||||
[InlineData(0x00000020, 0, false)]
|
||||
public void Update_RenameFails_WaitsOnlyForASharingViolation(int result, int expectedWaits, bool saved)
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
Seed(temp);
|
||||
int waits = 0;
|
||||
int attempts = 0;
|
||||
var faults = new List<SettingsFault>();
|
||||
var store = new AppSettingsStore(temp.File("settings.json"), (fault, _) => faults.Add(fault), _ => waits++, (source, destination) =>
|
||||
++attempts <= 3 ? throw new IOException("refused", result) : FileSwap.TryRenameOver(source, destination));
|
||||
Assert.Equal(saved, store.Update(settings => settings) is not null);
|
||||
Assert.Equal(expectedWaits, waits);
|
||||
Assert.Equal(saved ? [] : [SettingsFault.WriteFailed], faults);
|
||||
Assert.Equal(["settings.json", "settings.json.lock"], FileNames(temp.Path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>When the save over invalid content fails, the invalid file is still in place and the copy made for this save is removed again.</summary>
|
||||
[Fact]
|
||||
public void Update_InvalidContentAndFailedSave_LeavesTheInvalidFileInPlace()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
|
||||
namespace MarkdownViewr.Windows.Core.Tests;
|
||||
|
||||
/// <summary>Renaming one real file over another, with other real handles open on the destination.</summary>
|
||||
public sealed class FileSwapTests
|
||||
{
|
||||
/// <summary>The rename replaces the destination and removes the source name.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_ExistingDestination_ReplacesIt()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.False(File.Exists(temp.File("new.tmp")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A reader that allows deletion does not stop the rename, and goes on reading the content it opened.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_DestinationOpenByAReaderThatAllowsDeletion_ReplacesItUnderTheReader()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old content");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
using(var reader = new StreamReader(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete)))
|
||||
{
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.Equal("old content", reader.ReadToEnd());
|
||||
}
|
||||
Assert.Equal(["stored.json"], Directory.GetFiles(temp.Path).Select(file => Path.GetFileName(file)!), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A handle that does not allow deletion refuses the rename with a sharing violation, and both files stay as they were.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_DestinationOpenWithoutDeleteSharing_IsASharingViolation()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
using(new FileStream(temp.File("stored.json"), FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
IOException refused = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal(unchecked((int)0x80070020), refused.HResult);
|
||||
}
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("new.tmp")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A read-only destination is access denied, as it is for a managed move.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_ReadOnlyDestination_IsAccessDenied()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
File.SetAttributes(temp.File("stored.json"), FileAttributes.ReadOnly);
|
||||
try
|
||||
{
|
||||
Assert.Throws<UnauthorizedAccessException>(() => FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.SetAttributes(temp.File("stored.json"), FileAttributes.Normal);
|
||||
}
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A source that does not exist is reported with the Windows error for a missing file.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_MissingSource_IsAnIoFailure()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("stored.json"), "old");
|
||||
IOException missing = Assert.Throws<IOException>(() => FileSwap.TryRenameOver(temp.File("absent.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal(unchecked((int)0x80070002), missing.HResult);
|
||||
Assert.Equal("old", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A destination that does not exist yet is simply created by the rename.</summary>
|
||||
[Fact]
|
||||
public void TryRenameOver_NoDestinationYet_RenamesTheSource()
|
||||
{
|
||||
using var temp = new TempDirectory();
|
||||
File.WriteAllText(temp.File("new.tmp"), "new");
|
||||
Assert.True(FileSwap.TryRenameOver(temp.File("new.tmp"), temp.File("stored.json")));
|
||||
Assert.Equal("new", File.ReadAllText(temp.File("stored.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Only the errors a volume gives for an unimplemented rename mean "use another way"; every other error is a failure.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(50, true)]
|
||||
[InlineData(87, true)]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(2, false)]
|
||||
[InlineData(5, false)]
|
||||
[InlineData(32, false)]
|
||||
[InlineData(17, false)]
|
||||
public void IsUnsupported_WindowsError_SeparatesAnUnimplementedRenameFromAFailure(int error, bool unsupported)
|
||||
=> Assert.Equal(unsupported, FileSwap.IsUnsupported(error));
|
||||
}
|
||||
Reference in new issue
Block a user