fix(accounts): accounts.json is safe across gclo processes; an unreadable file is not a corrupt one #156

Merged
james.maes merged 2 commits from fix/155-accounts-store into main 2026-11-10 22:06:08 +00:00
12 changed files with 1166 additions and 46 deletions

No files matched your search

+16 -5
View File
@@ -237,10 +237,21 @@ throttle is reported as such (exit code 4) rather than shrinking the list.
An *account* is a saved sync profile: a name, an organization, a target root
folder, a parallelism setting, an org-subfolder preference, an optional
description, and a token stored in **Windows Credential Manager** (under
`gclo:account:<id>`; the metadata file, `%LOCALAPPDATA%\gclo\accounts.json`,
never contains the token). Accounts are shared between the CLI and the desktop
app: create them with `gclo accounts add` on a headless mirror server, or in
the app's account wizard, and use them from either.
`gclo:account:<id>`; the metadata file,
`%LOCALAPPDATA%\KofTwentyTwo\gclo\accounts.json`, never contains the token).
Accounts are shared between the CLI and the desktop app: create them with
`gclo accounts add` on a headless mirror server, or in the app's account
wizard, and use them from either.
The CLI and the app can run at the same time, and so can several CLI runs:
each change (adding, editing, or removing an account, recording a sync
result) is applied to what the file holds at that moment. An account one of
them added or removed stays added or removed, and a recorded sync result is
kept; when two of them edit the same account, the later edit wins, and an
edit of an account that was removed in the meantime is refused. The app lists
an account that was added from the CLI the next time it starts. If another
gclo process is changing the accounts at that very moment, a command waits a
few seconds and then exits with code 4; run it again.
> **Windows-only.** Because the token lives in Windows Credential Manager,
> `gclo accounts` and `gclo sync --account` exit with code 2 on Linux/macOS.
@@ -404,7 +415,7 @@ its offset from UTC (`2026-11-10 14:03:22.123 -06:00 [INFO] ...`). Text that cam
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). `--json`'s `wasCanceled` tells the two apart. | Inspect the failures; retry them. |
| 2 | Fatal: bad arguments, missing/empty token, organization not found, unknown account, missing account token, unusable target folder, or an account command on a non-Windows OS. | Fix the invocation. |
| 3 | GitHub rejected the token (401), or refused it access (403: the token may not see the organization, or the organization requires SAML single sign-on and the token is not authorized for it). The message carries GitHub's reason when it gave one. | Fix the token or its access; do not retry as is. |
| 4 | Not your doing: GitHub's primary or secondary rate limit (403 or 429), a temporary throttle, a network failure (DNS, connection, TLS, proxy), a request that timed out, or an error on GitHub's side (HTTP 5xx). | Retry later (for a rate limit, the message says when). |
| 4 | Not your doing: GitHub's primary or secondary rate limit (403 or 429), a temporary throttle, a network failure (DNS, connection, TLS, proxy), a request that timed out, an error on GitHub's side (HTTP 5xx), or the accounts file being changed by another gclo process at that moment. | Retry later (for a rate limit, the message says when). |
| 70 | An unexpected error inside gclo. The exception type is printed and the full details are in the activity log. | Please report it. |
Codes 3 and 4 were part of code 2 before version 1.1; a script that tested
+1 -1
View File
@@ -61,7 +61,7 @@ flowchart LR
| Git HTTPS transport (libgit2) | out/in | Repository objects and trees | PAT as HTTPS basic (`x-access-token`) | `WindowsPathValidator` checks every incoming path against Windows rules before checkout; non-fast-forward is reported, never merged |
| PAT entry | in | The token | n/a | App: in-memory or Credential Manager; CLI: env var, file, or stdin only (no `--token <value>`) |
| Credential Manager | out/in | Tokens by `gclo[:<scope>]:account:<id>` target | Windows user session | `CredentialManagerVault`; scope derived from the data root |
| `settings.json`, `accounts.json` | out/in | Non-secret settings and account metadata | File ACL (user profile) | Atomic writes, sanitized on load, corrupt files preserved as `.bak` |
| `settings.json`, `accounts.json` (with `accounts.json.lock`) | out/in | Non-secret settings and account metadata | File ACL (user profile) | Written beside the file and put in place in one step; sanitized on load; an accounts file whose content is not valid is moved aside (`.corrupt-*`), one that cannot be read is left in place; the previous accounts file is kept as `.bak`. Changes to the accounts are serialized across gclo processes by the lock file: a process of the same user that holds it open can delay account changes (they fail after a few seconds, nothing is written), not read or alter them |
| Activity logs | out | Actions and errors; token *lengths* only | File ACL | `IActivityLog` callers never pass tokens |
| `.git/gclo-recovery.json`, `gclo.checkoutpending` config key | out/in | Path-recovery plan; checkout-pending marker | File ACL | Written atomically; read only for the repository it belongs to |
| Command line (CLI) | in | Options, org, target, filters | n/a | `OptionReader` / `RepoFilterSpec`; parallelism bounded 1..64; token options refused on argv |
+6
View File
@@ -103,6 +103,12 @@ internal static class Program
Terminal.Error("Canceled.");
return ExitCodes.Partial;
}
catch(gclo.ViewModels.AccountsFileBusyException ex)
{
// Another gclo process is changing the accounts: nothing is wrong, try again.
Terminal.Error(ex.Message);
return ExitCodes.Transient;
}
catch(Exception ex)
{
return ReportUnexpected(ex);
@@ -0,0 +1,609 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// The desktop app and the CLI share accounts.json, and each may run more than once.
/// Two stores over one folder stand for two processes here: a change made through
/// one must survive the next change made through the other, and a file that is in
/// use is waited for, never taken for a corrupt one (#155).
/// </summary>
public sealed class AccountsStoreSharedFileTests : IDisposable
{
private static readonly TimeSpan s_long = TimeSpan.FromSeconds(60);
private static readonly TimeSpan s_short = TimeSpan.FromMilliseconds(300);
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
/// <summary>One vault for both stores, as Credential Manager is one for both processes.</summary>
private readonly InMemoryVault _vault = new();
private readonly RecordingActivityLog _log = new();
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private string FilePath => Path.Combine(_root, "accounts.json");
private string LockPath => Path.Combine(_root, "accounts.json.lock");
private AccountsStore NewStore(TimeSpan? patience = null) => new(_vault, _root, _log, patience ?? s_long);
private static Account MakeAccount(string name) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
private IReadOnlyList<string> NamesOnDisk()
=> new AccountsStore(new InMemoryVault(), _root).GetAll().Select(a => a.Name).ToList();
private IReadOnlyList<string> ErrorsLogged()
=> _log.Entries.Where(e => string.Equals(e.Level, "ERROR", StringComparison.Ordinal)).Select(e => e.Message).ToList();
// ---------------------------------------------------------------- one change at a time, on what the file holds now
[Fact]
public void AnAccountAddedByTheOtherProcess_SurvivesThisProcessesNextChange()
{
AccountsStore app = NewStore();
AccountsStore cli = NewStore(); // both started before anything was added
cli.Save(MakeAccount("From the CLI"), "ghp_cli_token");
app.Save(MakeAccount("From the app"), null);
Assert.Equal(["From the app", "From the CLI"], NamesOnDisk(), StringComparer.Ordinal);
// The store that made the later change has seen the earlier one.
Assert.Equal(["From the app", "From the CLI"], app.GetAll().Select(a => a.Name), StringComparer.Ordinal);
}
[Fact]
public void RecordingASyncResult_KeepsWhatTheOtherProcessAddedAndChanged_AndItsTokens()
{
Account work = MakeAccount("Work");
NewStore().Save(work, "ghp_work_token");
AccountsStore app = NewStore();
AccountsStore cli = NewStore();
Account added = MakeAccount("Added meanwhile");
cli.Save(added, "ghp_added_token");
cli.Save(work with { Description = "edited by the CLI" }, null);
app.RecordSyncResult(work.Id, new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero), "42 done");
IReadOnlyList<Account> onDisk = new AccountsStore(_vault, _root).GetAll();
Assert.Equal(["Added meanwhile", "Work"], onDisk.Select(a => a.Name), StringComparer.Ordinal);
Account recorded = onDisk.Single(a => a.Id == work.Id);
Assert.Equal("edited by the CLI", recorded.Description); // the other process's change
Assert.Equal("42 done", recorded.LastSyncSummary); // and this one's
Assert.Equal("ghp_added_token", _vault.TryRetrieve(added.Id)); // no token without its account
Assert.Equal("ghp_work_token", _vault.TryRetrieve(work.Id));
}
[Fact]
public void RecordingASyncResult_ForAnAccountTheOtherProcessDeleted_DoesNotBringItBack()
{
Account work = MakeAccount("Work");
Account other = MakeAccount("Other");
AccountsStore setup = NewStore();
setup.Save(work, "ghp_work_token");
setup.Save(other, null);
AccountsStore app = NewStore();
AccountsStore cli = NewStore();
cli.Delete(work.Id);
app.RecordSyncResult(work.Id, DateTimeOffset.UtcNow, "done");
Assert.Equal(["Other"], NamesOnDisk(), StringComparer.Ordinal);
Assert.Null(_vault.TryRetrieve(work.Id));
Assert.Contains(ErrorsLogged(), e => e.Contains("no account with id", StringComparison.Ordinal));
Assert.Equal(["Other"], app.GetAll().Select(a => a.Name), StringComparer.Ordinal);
}
[Fact]
public void Save_UnderANameTheOtherProcessTookMeanwhile_IsRefused_AndWritesNothing()
{
AccountsStore app = NewStore();
AccountsStore cli = NewStore();
cli.Save(MakeAccount("Work"), null);
Account clash = MakeAccount("work");
ArgumentException ex = Assert.Throws<ArgumentException>(() => app.Save(clash, "ghp_clash_token"));
Assert.Contains("already exists", ex.Message);
Assert.Equal(["Work"], NamesOnDisk(), StringComparer.Ordinal);
Assert.Null(_vault.TryRetrieve(clash.Id));
}
[Fact]
public void Delete_OfAnAccountTheOtherProcessAlreadyDeleted_ChangesNothing_AndKeepsTheRest()
{
Account work = MakeAccount("Work");
AccountsStore setup = NewStore();
setup.Save(work, null);
AccountsStore app = NewStore();
AccountsStore cli = NewStore();
cli.Delete(work.Id);
cli.Save(MakeAccount("Added meanwhile"), null);
app.Delete(work.Id);
Assert.Equal(["Added meanwhile"], NamesOnDisk(), StringComparer.Ordinal);
}
[Fact]
public void ManyChangesThroughTwoStoresAtOnce_AreAllKept()
{
AccountsStore app = NewStore();
AccountsStore cli = NewStore();
const int PerStore = 25;
Parallel.Invoke(
() =>
{
for(int i = 0; i < PerStore; i++)
{
app.Save(MakeAccount($"app-{i:D2}"), null);
}
},
() =>
{
for(int i = 0; i < PerStore; i++)
{
cli.Save(MakeAccount($"cli-{i:D2}"), null);
}
});
Assert.Equal(2 * PerStore, NamesOnDisk().Count);
}
// ---------------------------------------------------------------- the lock
[Fact]
public async Task AChange_WhileTheOtherProcessHoldsTheLock_WaitsForIt()
{
AccountsStore store = NewStore();
Directory.CreateDirectory(_root);
Task save;
using(new FileStream(LockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
save = Task.Run(() => store.Save(MakeAccount("Waited"), null));
Task finished = await Task.WhenAny(save, Task.Delay(TimeSpan.FromMilliseconds(200)));
Assert.NotSame(save, finished); // still waiting for the other process
Assert.False(File.Exists(FilePath));
}
await save.WaitAsync(s_long);
Assert.Equal(["Waited"], NamesOnDisk(), StringComparer.Ordinal);
}
[Fact]
public void AChange_WhenTheLockStaysHeld_Fails_AndChangesNothing()
{
Account existing = MakeAccount("Existing");
NewStore().Save(existing, "ghp_existing_token");
AccountsStore store = NewStore(s_short);
string before = File.ReadAllText(FilePath);
Account added = MakeAccount("Never saved");
using(new FileStream(LockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
AccountsFileBusyException save = Assert.Throws<AccountsFileBusyException>(() => store.Save(added, "ghp_never_stored"));
Assert.Contains("in use by another gclo process", save.Message);
Assert.Throws<AccountsFileBusyException>(() => store.Delete(existing.Id));
Assert.Throws<AccountsFileBusyException>(() => store.RecordSyncResult(existing.Id, DateTimeOffset.UtcNow, "done"));
}
Assert.Equal(before, File.ReadAllText(FilePath));
Assert.Null(_vault.TryRetrieve(added.Id));
Assert.Equal("ghp_existing_token", _vault.TryRetrieve(existing.Id));
Assert.Equal(["Existing"], store.GetAll().Select(a => a.Name), StringComparer.Ordinal);
}
[Fact]
public void Construction_DoesNotWaitForTheLock_SoAStalledProcessCannotKeepAnotherFromStarting()
{
NewStore().Save(MakeAccount("Existing"), null);
AccountsStore store;
var clock = System.Diagnostics.Stopwatch.StartNew();
using(new FileStream(LockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
store = NewStore(); // a patience of a minute, which it does not use
}
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(30), $"construction waited {clock.Elapsed} for the lock");
Assert.Equal(["Existing"], store.GetAll().Select(a => a.Name), StringComparer.Ordinal);
Assert.Empty(ErrorsLogged());
}
[Fact]
public void ALockThatCannotBeOpenedAtAll_IsNotTakenForABusyOne_AndDoesNotHideTheAccounts()
{
NewStore().Save(MakeAccount("Existing"), null);
File.Delete(LockPath);
Directory.CreateDirectory(LockPath); // a folder where the lock file belongs
AccountsStore store = NewStore();
Assert.Equal(["Existing"], store.GetAll().Select(a => a.Name), StringComparer.Ordinal);
var clock = System.Diagnostics.Stopwatch.StartNew();
Exception ex = Assert.ThrowsAny<Exception>(() => store.Save(MakeAccount("Never saved"), null));
Assert.IsNotType<AccountsFileBusyException>(ex);
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(30), $"waited {clock.Elapsed} for something that does not pass");
Assert.Equal(["Existing"], NamesOnDisk(), StringComparer.Ordinal);
}
[Fact]
public void TheLockFile_StaysBesideTheAccounts_Empty_AndIsNotCreatedByLookingAtNothing()
{
_ = NewStore().GetAll();
Assert.False(Directory.Exists(_root), "looking at a folder with no accounts creates nothing");
NewStore().Save(MakeAccount("First"), null);
Assert.True(File.Exists(LockPath));
Assert.Equal(0, new FileInfo(LockPath).Length);
}
// ---------------------------------------------------------------- a file that is in use is not a corrupt file
[Fact]
public async Task Construction_WhenTheFileIsInUseForAMoment_TriesAgain()
{
if(!OperatingSystem.IsWindows())
{
return; // file sharing is what keeps a reader out
}
NewStore().Save(MakeAccount("Existing"), null);
Task<AccountsStore> construction;
using(new FileStream(FilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
construction = Task.Run(() => NewStore());
Task finished = await Task.WhenAny(construction, Task.Delay(TimeSpan.FromMilliseconds(200)));
Assert.NotSame(construction, finished); // still waiting for the file
}
AccountsStore store = await construction.WaitAsync(s_long);
Assert.Equal(["Existing"], store.GetAll().Select(a => a.Name), StringComparer.Ordinal);
Assert.Empty(ErrorsLogged());
}
[Fact]
public void WhenTheFileStaysUnreadable_ItIsNotMovedAside_AndAChangeFailsWithoutWriting()
{
if(!OperatingSystem.IsWindows())
{
return; // file sharing is what keeps a reader out
}
NewStore().Save(MakeAccount("Existing"), null);
string before = File.ReadAllText(FilePath);
Account added = MakeAccount("Never saved");
AccountsStore store;
using(new FileStream(FilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
store = NewStore(s_short);
Assert.Empty(store.GetAll());
Assert.Throws<IOException>(() => store.Save(added, "ghp_never_stored"));
}
Assert.Equal(before, File.ReadAllText(FilePath));
Assert.Empty(Directory.GetFiles(_root, "accounts.json.corrupt-*"));
Assert.Null(_vault.TryRetrieve(added.Id));
Assert.Contains(ErrorsLogged(), e => e.Contains("Could not read accounts", StringComparison.Ordinal));
Assert.Equal(["Existing"], NewStore().GetAll().Select(a => a.Name), StringComparer.Ordinal);
}
[Fact]
public void ContentThatIsNotAListOfAccounts_IsStillMovedAside_AlsoWhenAChangeFindsIt()
{
AccountsStore store = NewStore();
store.Save(MakeAccount("Existing"), null);
File.WriteAllText(FilePath, "{ not json"); // damaged after the store read it
store.Save(MakeAccount("Fresh"), null);
string aside = Assert.Single(Directory.GetFiles(_root, "accounts.json.corrupt-*"));
Assert.Equal("{ not json", File.ReadAllText(aside));
// The change did not start over from nothing: what the store last read is written back with it.
Assert.Equal(["Existing", "Fresh"], NamesOnDisk(), StringComparer.Ordinal);
Assert.Contains(ErrorsLogged(), e => e.Contains("not a valid list of accounts", StringComparison.Ordinal));
Assert.Contains(ErrorsLogged(), e => e.Contains("missing or damaged", StringComparison.Ordinal));
}
// ---------------------------------------------------------------- two processes and one account
[Fact]
public void AnEditOfAnAccountTheOtherProcessRemoved_IsRefused_NotBroughtBackWithoutItsToken()
{
Account work = MakeAccount("Work");
NewStore().Save(work, "ghp_work_token");
AccountsStore app = NewStore(); // has Work loaded
AccountsStore cli = NewStore();
cli.Delete(work.Id);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => app.Save(work with { Description = "edited in the app" }, null));
Assert.Contains("was removed by another gclo process", ex.Message);
Assert.Empty(NamesOnDisk());
Assert.Null(_vault.TryRetrieve(work.Id));
Assert.Empty(app.GetAll()); // and the store now knows it is gone
// Adding it again is a new decision, and works.
app.Save(work, "ghp_new_token");
Assert.Equal(["Work"], NamesOnDisk(), StringComparer.Ordinal);
}
[Fact]
public void AnEditFromAnOlderCopy_KeepsTheSyncResultTheOtherProcessRecordedSince()
{
Account work = MakeAccount("Work");
NewStore().Save(work, null);
AccountsStore app = NewStore();
AccountsStore cli = NewStore();
var recorded = new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero);
cli.RecordSyncResult(work.Id, recorded, "42 done");
app.Save(work with { Description = "edited in the app" }, null); // 'work' was read before the sync
Account onDisk = Assert.Single(new AccountsStore(_vault, _root).GetAll());
Assert.Equal("edited in the app", onDisk.Description);
Assert.Equal(recorded, onDisk.LastSyncUtc);
Assert.Equal("42 done", onDisk.LastSyncSummary);
}
[Fact]
public void AnEditThatCarriesANewerSyncResult_KeepsItsOwn()
{
Account work = MakeAccount("Work") with
{
LastSyncUtc = new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.Zero),
LastSyncSummary = "older",
};
AccountsStore store = NewStore();
store.Save(work, null);
var newer = new DateTimeOffset(2026, 11, 10, 0, 0, 0, TimeSpan.Zero);
store.Save(work with { LastSyncUtc = newer, LastSyncSummary = "newer" }, null);
Account onDisk = Assert.Single(new AccountsStore(_vault, _root).GetAll());
Assert.Equal(newer, onDisk.LastSyncUtc);
Assert.Equal("newer", onDisk.LastSyncSummary);
}
// ---------------------------------------------------------------- a file that is gone is not "no accounts"
[Fact]
public void AChange_ThatFindsTheFileGone_IsAppliedToWhatTheStoreLastRead_WhichPutsTheAccountsBack()
{
Account kept = MakeAccount("Kept");
Account removed = MakeAccount("Removed");
AccountsStore store = NewStore();
store.Save(kept, "ghp_kept_token");
store.Save(removed, "ghp_removed_token");
File.Delete(FilePath);
store.Delete(removed.Id);
Assert.Equal(["Kept"], NamesOnDisk(), StringComparer.Ordinal);
Assert.Null(_vault.TryRetrieve(removed.Id)); // the delete did what it said, also for the token
Assert.Equal("ghp_kept_token", _vault.TryRetrieve(kept.Id));
Assert.Contains(ErrorsLogged(), e => e.Contains("missing or damaged", StringComparison.Ordinal));
}
[Fact]
public void AFirstChange_WhereThereNeverWasAFile_SaysNothingAboutDamage()
{
NewStore().Save(MakeAccount("First"), null);
Assert.Empty(ErrorsLogged());
}
[Fact]
public async Task WhileChangesAreBeingSaved_TheFileIsNeverAbsent()
{
// A process that starts while another saves must find the accounts, not "no file yet".
AccountsStore store = NewStore();
store.Save(MakeAccount("First"), null);
using var stop = new CancellationTokenSource();
int absences = 0;
int looks = 0;
var watcher = Task.Run(() =>
{
while(!stop.IsCancellationRequested)
{
looks++;
if(!File.Exists(FilePath))
{
absences++;
}
}
});
for(int i = 0; i < 150; i++)
{
store.RecordSyncResult(store.GetAll()[0].Id, DateTimeOffset.UtcNow, $"run {i}");
}
stop.Cancel();
await watcher;
Assert.True(looks > 0);
Assert.Equal(0, absences);
}
// ---------------------------------------------------------------- files that will not change by waiting
[Fact]
public void AReadOnlyAccountsFile_FailsAtOnce_AndSaysSo()
{
AccountsStore store = NewStore(); // a patience of a minute, which it does not use
Account existing = MakeAccount("Existing");
store.Save(existing, null);
File.SetAttributes(FilePath, FileAttributes.ReadOnly);
Account added = MakeAccount("Never saved");
try
{
var clock = System.Diagnostics.Stopwatch.StartNew();
UnauthorizedAccessException ex = Assert.Throws<UnauthorizedAccessException>(() => store.Save(added, "ghp_never_kept"));
Assert.Contains("is read-only", ex.Message);
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(10), $"waited {clock.Elapsed} for a file that stays read-only");
Assert.Null(_vault.TryRetrieve(added.Id));
}
finally
{
File.SetAttributes(FilePath, FileAttributes.Normal);
}
Assert.Equal(["Existing"], NamesOnDisk(), StringComparer.Ordinal);
}
[Fact]
public void ABackupThatIsReadOnly_IsReplaced_AndOneThatCannotBeWritten_DoesNotStopTheChange()
{
AccountsStore store = NewStore();
store.Save(MakeAccount("First"), null);
store.Save(MakeAccount("Second"), null);
string backup = FilePath + ".bak";
File.SetAttributes(backup, FileAttributes.ReadOnly);
store.Save(MakeAccount("Third"), null);
Assert.Contains("Second", File.ReadAllText(backup)); // the file as it was before this change
Assert.Empty(ErrorsLogged());
File.SetAttributes(backup, FileAttributes.Normal);
File.Delete(backup);
Directory.CreateDirectory(backup); // a folder where the backup belongs
store.Save(MakeAccount("Fourth"), null);
Assert.Equal(["First", "Fourth", "Second", "Third"], NamesOnDisk(), StringComparer.Ordinal);
Assert.Contains(ErrorsLogged(), e => e.Contains("Could not write the backup", StringComparison.Ordinal));
}
// ---------------------------------------------------------------- the swap, when something else holds the live file
/// <summary>Holds the file the way a scanner or a viewer does: it can be read, not replaced.</summary>
private FileStream HoldLiveFileAgainstReplacement()
=> new(FilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
[Fact]
public async Task AChange_WhenTheLiveFileIsHeldForAMoment_TriesTheSwapAgain()
{
if(!OperatingSystem.IsWindows())
{
return; // an open file can be replaced elsewhere
}
AccountsStore store = NewStore();
store.Save(MakeAccount("Existing"), null);
Task save;
using(HoldLiveFileAgainstReplacement())
{
save = Task.Run(() => store.Save(MakeAccount("Added"), null));
Task finished = await Task.WhenAny(save, Task.Delay(TimeSpan.FromMilliseconds(200)));
Assert.NotSame(save, finished); // still trying the swap
}
await save.WaitAsync(s_long);
Assert.Equal(["Added", "Existing"], NamesOnDisk(), StringComparer.Ordinal);
}
[Fact]
public void AChange_WhenTheLiveFileStaysHeld_Fails_AndTheStoreStillSaysWhatIsOnDisk()
{
if(!OperatingSystem.IsWindows())
{
return; // an open file can be replaced elsewhere
}
AccountsStore store = NewStore(s_short);
store.Save(MakeAccount("Existing"), null);
Account added = MakeAccount("Never saved");
using(HoldLiveFileAgainstReplacement())
{
Exception ex = Assert.ThrowsAny<Exception>(() => store.Save(added, "ghp_never_kept"));
Assert.True(ex is IOException or UnauthorizedAccessException, ex.GetType().Name);
}
Assert.Equal(["Existing"], NamesOnDisk(), StringComparer.Ordinal);
Assert.Equal(["Existing"], store.GetAll().Select(a => a.Name), StringComparer.Ordinal);
Assert.Null(_vault.TryRetrieve(added.Id)); // the vault write was undone
}
}
+138
View File
@@ -10,6 +10,7 @@ namespace gclo.Engine.Tests;
/// <summary>Pins defaults and load-time sanitizing of <see cref="AppSettings"/>.</summary>
[Collection("GCLO_DATA_DIR")] // sets a process-wide variable: never beside another class that does
public sealed class AppSettingsTests
{
[Fact]
@@ -90,4 +91,141 @@ public sealed class AppSettingsTests
}
}
}
// ---------------------------------------------------------------- written whole; failures are said (#155)
private static void WithDataDirectory(Action<string> test)
{
string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir);
try
{
test(dir);
}
finally
{
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
GitTestHelpers.TryDeleteDirectory(dir);
}
}
[Fact]
public void Save_SaysItSucceeded_AndLeavesOnlyTheSettingsFile()
=> WithDataDirectory(dir =>
{
var log = new RecordingActivityLog();
bool saved = new AppSettings { Theme = "Dark" }.Save(log);
Assert.True(saved);
Assert.Equal(["settings.json"], Directory.GetFiles(dir).Select(Path.GetFileName), StringComparer.Ordinal);
Assert.Equal("Dark", AppSettings.Load(log).Theme);
Assert.Empty(log.Entries);
});
[Fact]
public void Save_WhenItCannotPutTheFileInPlace_SaysSo_LeavesThePreviousSettings_AndNoTemporaryFile()
=> WithDataDirectory(dir =>
{
if(!OperatingSystem.IsWindows())
{
return; // elsewhere a file that is open can still be replaced
}
var log = new RecordingActivityLog();
Assert.True(new AppSettings { Theme = "Dark" }.Save());
string path = Path.Combine(dir, "settings.json");
bool saved;
using(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
saved = new AppSettings { Theme = "Light" }.Save(log, TimeSpan.FromMilliseconds(100));
}
Assert.False(saved);
(string level, string message) = Assert.Single(log.Entries);
Assert.Equal("ERROR", level);
Assert.StartsWith("Settings could not be saved (", message);
Assert.Equal("Dark", AppSettings.Load().Theme); // not half a file, not the new one: the old one
Assert.Equal(["settings.json"], Directory.GetFiles(dir).Select(Path.GetFileName), StringComparer.Ordinal);
});
[Fact]
public async Task Save_WhileTheFileIsOpenForAMoment_TriesAgain()
{
if(!OperatingSystem.IsWindows())
{
return; // elsewhere a file that is open can still be replaced
}
string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir);
try
{
Assert.True(new AppSettings { Theme = "Dark" }.Save());
Task<bool> save;
using(new FileStream(Path.Combine(dir, "settings.json"), FileMode.Open, FileAccess.Read, FileShare.Read))
{
save = Task.Run(() => new AppSettings { Theme = "Light" }.Save(log: null, TimeSpan.FromSeconds(60)));
Task finished = await Task.WhenAny(save, Task.Delay(TimeSpan.FromMilliseconds(200)));
Assert.NotSame(save, finished); // still trying
}
Assert.True(await save.WaitAsync(TimeSpan.FromSeconds(60)));
Assert.Equal("Light", AppSettings.Load().Theme);
}
finally
{
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
GitTestHelpers.TryDeleteDirectory(dir);
}
}
[Fact]
public void Save_ByTwoAtOnce_NeverLeavesHalfAFile_OrALeftover()
=> WithDataDirectory(dir =>
{
Parallel.For(0, 40, i => new AppSettings { Theme = i % 2 == 0 ? "Dark" : "Light" }.Save());
Assert.Contains(AppSettings.Load().Theme, new[] { "Dark", "Light" }, StringComparer.Ordinal);
Assert.Equal(["settings.json"], Directory.GetFiles(dir).Select(Path.GetFileName), StringComparer.Ordinal);
});
[Fact]
public void Load_AFileThatCannotBeUsed_GivesTheDefaults_AndSaysSo()
=> WithDataDirectory(dir =>
{
var log = new RecordingActivityLog();
File.WriteAllText(Path.Combine(dir, "settings.json"), "{ not json");
var loaded = AppSettings.Load(log);
Assert.Equal("System", loaded.Theme);
(string level, string message) = Assert.Single(log.Entries);
Assert.Equal("ERROR", level);
Assert.StartsWith("Settings could not be loaded (", message);
});
[Fact]
public void Load_ASplashDurationOfZero_IsBroughtIntoRange_LikeAnyOtherValue()
=> WithDataDirectory(dir =>
{
File.WriteAllText(Path.Combine(dir, "settings.json"), "{ \"SplashMilliseconds\": 0 }");
Assert.Equal(AppSettings.MinSplashMilliseconds, AppSettings.Load().SplashMilliseconds);
});
}
+2 -1
View File
@@ -14,6 +14,7 @@ namespace gclo.Engine.Tests;
/// branches, settings failure fallbacks, store corruption handling, and the
/// remaining status-text arms. Part of the 100%-coverage contract.
/// </summary>
[Collection("GCLO_DATA_DIR")] // sets a process-wide variable: never beside another class that does
public sealed class CoverageResidueTests
{
// ------------------------------------------------- WindowsPathValidator
@@ -220,7 +221,7 @@ public sealed class CoverageResidueTests
Assert.Empty(store.GetAll());
}
Assert.Contains(log.Errors, e => e.Contains("Failed to load accounts"));
Assert.Contains(log.Errors, e => e.Contains("not a valid list of accounts"));
Assert.Contains(log.Errors, e => e.Contains("Could not preserve"));
}
finally
+1
View File
@@ -15,6 +15,7 @@ namespace gclo.Engine.Tests;
/// first and deletes that folder, which destroyed accounts.json and settings.json
/// in 1.0.2-beta.1 (#91).
/// </summary>
[Collection("GCLO_DATA_DIR")] // sets a process-wide variable: never beside another class that does
public sealed class GcloPathsTests
{
/// <summary>The default root is %LOCALAPPDATA%\KofTwentyTwo\gclo.</summary>
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.ViewModels;
/// <summary>
/// A change to the accounts could not be made because another gclo process kept the
/// accounts file's lock for the whole wait. Nothing was changed, and nothing is
/// wrong: the same change succeeds once the other process has finished. An
/// <see cref="IOException"/>, so everything that already handles one keeps working.
/// </summary>
public sealed class AccountsFileBusyException : IOException
{
/// <summary>Creates the exception with its message and the sharing violation that caused it.</summary>
public AccountsFileBusyException(string message, Exception innerException)
: base(message, innerException)
{
}
}
+302 -21
View File
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Text.Json;
using gclo.Engine;
@@ -17,7 +18,7 @@ namespace gclo.ViewModels;
/// token. Unlike <see cref="AppSettings"/>, accounts are primary user data, so write
/// failures in <see cref="Save"/>, <see cref="Delete"/>, and
/// <see cref="RecordSyncResult"/> propagate instead of being swallowed; only loading
/// is tolerant (a missing or corrupt file yields an empty list).
/// is tolerant (a missing, corrupt, or unreadable file yields an empty list).
/// </summary>
/// <remarks>
/// <para>
@@ -43,7 +44,27 @@ namespace gclo.ViewModels;
/// the file; Delete tolerates an absent vault entry.
/// </description></item>
/// </list>
/// <para>In-memory state changes only after both stores succeeded. Nothing here logs a token.</para>
/// <para>A change shows in <see cref="GetAll"/> only after both stores succeeded. Nothing here logs a token.</para>
/// <para>
/// The desktop app and the CLI share the file, and each may run more than once. Every
/// change therefore takes a lock that every gclo process takes (accounts.json.lock,
/// opened for this process alone), reads the file again, applies its one change by
/// <see cref="Account.Id"/> to what the file holds now, and writes that: an account
/// another process added or removed in the meantime stays added or removed, and a
/// sync result it recorded is kept. When two processes edit the same account, the
/// later edit wins; an edit of an account that another process removed is refused
/// instead of bringing it back. Reads (<see cref="GetAll"/>, <see cref="FindByName"/>)
/// answer from what this store last read, at construction or at its latest change;
/// that is also what the store holds after a change that failed.
/// </para>
/// <para>
/// A file that cannot be read is not a corrupt file. Only content that is not valid
/// JSON is moved aside. A file that is in use is tried again for a short while; one
/// that stays unreadable is left exactly where it is: at construction the store then
/// starts empty (and logs why), and a change fails without writing anything. A
/// change that finds the file gone or damaged does not start over from nothing: it
/// is applied to what this store last read, which puts the accounts back.
/// </para>
/// </remarks>
public sealed class AccountsStore
{
@@ -53,8 +74,23 @@ public sealed class AccountsStore
private readonly string _filePath;
/// <summary>How long the lock another gclo process holds is waited for.</summary>
private static readonly TimeSpan s_defaultPatience = TimeSpan.FromSeconds(5);
/// <summary>
/// The share of the patience spent on a file that something outside gclo holds for
/// a moment (reading it, or swapping the new one into place). Kept well below the
/// wait for the lock, so a process that holds the lock while it waits here does
/// not outlast the patience of the ones waiting for it.
/// </summary>
private const int FilePatienceDivisor = 5;
private static readonly TimeSpan s_pause = TimeSpan.FromMilliseconds(15);
private readonly Lock _gate = new();
private readonly TimeSpan _patience;
private List<Account> _accounts;
@@ -66,13 +102,22 @@ public sealed class AccountsStore
/// surface that) and treated as empty rather than blocking startup.
/// </summary>
public AccountsStore(ITokenVault vault, string? directory = null, IActivityLog? log = null)
: this(vault, directory, log, s_defaultPatience)
{
}
/// <summary>Creates a store with its own patience for a busy file, for tests.</summary>
internal AccountsStore(ITokenVault vault, string? directory, IActivityLog? log, TimeSpan patience)
{
ArgumentNullException.ThrowIfNull(vault);
_vault = vault;
_log = log ?? new NullActivityLog();
directory ??= GcloPaths.DataRoot;
_filePath = Path.Combine(directory, "accounts.json");
_accounts = Load();
_patience = patience;
_accounts = LoadAtStart();
}
@@ -102,7 +147,7 @@ public sealed class AccountsStore
/// <summary>
/// Inserts or updates (matching by <see cref="Account.Id"/>) and persists
/// immediately. Throws <see cref="ArgumentException"/> when another account
/// immediately, on top of what the file holds at that moment. Throws <see cref="ArgumentException"/> when another account
/// already uses the name (case-insensitive); nothing is persisted in that case.
/// A non-null <paramref name="token"/> is written to the vault BEFORE the
/// metadata; if the metadata write then fails, the vault is put back the way it
@@ -115,6 +160,18 @@ public sealed class AccountsStore
ArgumentNullException.ThrowIfNull(account);
lock(_gate)
{
bool knownBefore = _accounts.Any(a => a.Id == account.Id);
///////////////////////////////////////////////////////////////////////////
// Another gclo process may have changed the file since it was last read. //
// The change is applied to what the file holds now, under the lock every //
// gclo process takes, so nothing another process wrote is written over. //
///////////////////////////////////////////////////////////////////////////
using FileStream _ = AcquireFileLock();
RefreshForAChange();
account = ReconcileWithTheFile(account, knownBefore);
bool nameTaken = _accounts.Any(a =>
a.Id != account.Id
&& string.Equals(a.Name, account.Name, StringComparison.OrdinalIgnoreCase));
@@ -269,6 +326,14 @@ public sealed class AccountsStore
{
lock(_gate)
{
///////////////////////////////////////////////////////////////////////////
// Another gclo process may have changed the file since it was last read. //
// The change is applied to what the file holds now, under the lock every //
// gclo process takes, so nothing another process wrote is written over. //
///////////////////////////////////////////////////////////////////////////
using FileStream _ = AcquireFileLock();
RefreshForAChange();
var remaining = _accounts.Where(a => a.Id != id).ToList();
if(remaining.Count == _accounts.Count)
{
@@ -319,13 +384,22 @@ public sealed class AccountsStore
/// <summary>
/// Stamps the outcome of a finished sync onto the account and persists it; every
/// other field is left untouched. An unknown id (account deleted while its sync
/// ran) is logged and ignored.
/// other field is left untouched, including what another process changed on the
/// account while its sync ran. An unknown id (account deleted while its sync ran,
/// here or by another process) is logged and ignored: it is not brought back.
/// </summary>
public void RecordSyncResult(Guid id, DateTimeOffset lastSyncUtc, string summary)
{
lock(_gate)
{
///////////////////////////////////////////////////////////////////////////
// Another gclo process may have changed the file since it was last read. //
// The change is applied to what the file holds now, under the lock every //
// gclo process takes, so nothing another process wrote is written over. //
///////////////////////////////////////////////////////////////////////////
using FileStream _ = AcquireFileLock();
RefreshForAChange();
int index = _accounts.FindIndex(a => a.Id == id);
if(index < 0)
{
@@ -345,31 +419,194 @@ public sealed class AccountsStore
private List<Account> Load()
/// <summary>
/// What the file holds when the store is created. Never throws: a file that
/// cannot be read leaves the store empty, says why in the log, and is left where
/// it is (the next change reads it again and fails if it still cannot). Read
/// without the lock: a new file is put in place in one step, so what is there is
/// always a whole file, and a stalled process that holds the lock cannot keep
/// another from starting.
/// </summary>
private List<Account> LoadAtStart()
{
try
{
if(!File.Exists(_filePath))
{
return [];
}
return ReadFile() ?? [];
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
_log.Error(
$"Could not read accounts from '{_filePath}' ({ex.Message}); no accounts are shown. "
+ "The file was left as it is.",
ex);
return [];
}
}
List<Account>? loaded = JsonSerializer.Deserialize(
File.ReadAllText(_filePath), AccountsJsonContext.Default.ListAccount);
/// <summary>
/// Brings what this store holds up to what the file holds now, before a change is
/// applied. Called with the file lock held. A file that is gone or damaged is not
/// taken for "no accounts": the change is then applied to what this store last
/// read, and writing it puts the accounts back.
/// </summary>
private void RefreshForAChange()
{
List<Account>? current = ReadFile();
if(current is not null)
{
_accounts = current;
}
else if(_accounts.Count > 0)
{
_log.Error(
$"Accounts file '{_filePath}' is missing or damaged; the change is applied to the {_accounts.Count} "
+ "account(s) this session last read, which writes them back.");
}
}
/// <summary>
/// What a save writes for <paramref name="account"/>, given what the file holds
/// now. An edit of an account that another process removed since this store last
/// read the file is refused: saving it would bring the account back, without the
/// token that was removed with it. A sync result another process recorded after
/// the caller's copy was read is kept.
/// </summary>
private Account ReconcileWithTheFile(Account account, bool knownBefore)
{
Account? onDisk = _accounts.FirstOrDefault(a => a.Id == account.Id);
if(onDisk is null)
{
return knownBefore
? throw new InvalidOperationException(
$"Account '{account.Name}' was removed by another gclo process while it was open here; "
+ "nothing was saved. Add it again to keep it.")
: account;
}
return onDisk.LastSyncUtc > (account.LastSyncUtc ?? DateTimeOffset.MinValue)
? account with { LastSyncUtc = onDisk.LastSyncUtc, LastSyncSummary = onDisk.LastSyncSummary }
: account;
}
/// <summary>
/// The accounts the file holds now, or null when it holds none that can be used:
/// the file is missing, or its content is not a list of accounts (it is then moved
/// aside). A file that cannot be read at all throws, and is not touched.
/// </summary>
private List<Account>? ReadFile()
{
string json;
try
{
json = WithPatience(() => File.ReadAllText(_filePath), FilePatience, accessDeniedIsBusy: false);
}
catch(Exception ex) when(ex is FileNotFoundException or DirectoryNotFoundException)
{
return null;
}
try
{
List<Account>? loaded = JsonSerializer.Deserialize(json, AccountsJsonContext.Default.ListAccount);
if(loaded is not null)
{
return loaded;
}
_log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts.");
PreserveCorruptFile();
}
catch(Exception ex)
{
_log.Error($"Failed to load accounts from '{_filePath}'; starting with no accounts.", ex);
PreserveCorruptFile();
///////////////////////////////////////////////////////////////////////////
// The text was read; it is what it says that cannot be used. That, and //
// only that, is a corrupt file. //
///////////////////////////////////////////////////////////////////////////
_log.Error($"Accounts file '{_filePath}' is not a valid list of accounts; starting with no accounts.", ex);
}
return [];
PreserveCorruptFile();
return null;
}
private TimeSpan FilePatience => _patience / FilePatienceDivisor;
/// <summary>
/// Takes the lock that every gclo process takes before it changes the accounts
/// file, waiting a short while for another process to finish. The lock is a file
/// beside accounts.json, opened for this process alone and held until it is
/// disposed; the file itself stays, empty.
/// </summary>
/// <exception cref="AccountsFileBusyException">Another gclo process kept the lock for the whole wait.</exception>
private FileStream AcquireFileLock()
{
string? directory = Path.GetDirectoryName(_filePath);
if(!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
try
{
return WithPatience(
() => new FileStream(_filePath + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None),
_patience,
accessDeniedIsBusy: false);
}
catch(IOException ex) when(IsBusy(ex, accessDeniedIsBusy: false))
{
throw new AccountsFileBusyException(
$"The accounts file '{_filePath}' is in use by another gclo process; nothing was changed. Try again in a moment.",
ex);
}
}
/// <summary>Runs <paramref name="action"/>, trying again for <paramref name="patience"/> as long as the file is busy.</summary>
private static T WithPatience<T>(Func<T> action, TimeSpan patience, bool accessDeniedIsBusy)
{
long started = Stopwatch.GetTimestamp();
while(true)
{
try
{
return action();
}
catch(Exception ex) when(IsBusy(ex, accessDeniedIsBusy) && Stopwatch.GetElapsedTime(started) < patience)
{
Thread.Sleep(s_pause);
}
}
}
/// <summary>
/// True for a failure that means "in use right now", which passes: on Windows a
/// sharing or lock violation, and, where the operation reports a busy target that
/// way (replacing a file somebody has open), a refused access. A missing file, a
/// full disk, or a path that is too long is never that, and is not waited for.
/// </summary>
private static bool IsBusy(Exception exception, bool accessDeniedIsBusy)
{
const int SharingViolation = 32;
const int LockViolation = 33;
if(exception is UnauthorizedAccessException)
{
return accessDeniedIsBusy;
}
return exception is IOException and not (FileNotFoundException or DirectoryNotFoundException or PathTooLongException)
&& (!OperatingSystem.IsWindows() || (exception.HResult & 0xFFFF) is SharingViolation or LockViolation);
}
@@ -419,13 +656,57 @@ public sealed class AccountsStore
stream.Flush(flushToDisk: true);
}
if(File.Exists(_filePath))
KeepABackup();
///////////////////////////////////////////////////////////////////////////////
// A file nobody may write to stays that way however long it is tried: say //
// so at once instead of taking the refusal for a file that is busy. //
///////////////////////////////////////////////////////////////////////////////
if(File.Exists(_filePath) && File.GetAttributes(_filePath).HasFlag(FileAttributes.ReadOnly))
{
File.Replace(tempPath, _filePath, _filePath + ".bak");
throw new UnauthorizedAccessException($"The accounts file '{_filePath}' is read-only; nothing was changed.");
}
else
///////////////////////////////////////////////////////////////////////////////
// One step puts the new file in place of the old: the name is never absent, //
// so a process that reads without the lock always finds a whole file. //
// Something outside gclo (a scanner, a backup, a viewer) can hold the live //
// file for a moment; the step is then refused, and tried again for a while. //
///////////////////////////////////////////////////////////////////////////////
WithPatience(
() =>
{
File.Move(tempPath, _filePath, overwrite: true);
return true;
},
FilePatience,
accessDeniedIsBusy: true);
}
/// <summary>
/// Copies the file that is about to be replaced to accounts.json.bak. Best effort:
/// the backup is a convenience for a person, and failing to write it must not
/// stop the change it is a backup for.
/// </summary>
private void KeepABackup()
{
try
{
File.Move(tempPath, _filePath);
if(File.Exists(_filePath))
{
string backup = _filePath + ".bak";
if(File.Exists(backup))
{
File.SetAttributes(backup, FileAttributes.Normal);
}
File.Copy(_filePath, backup, overwrite: true);
}
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException)
{
_log.Error($"Could not write the backup '{_filePath}.bak' ({ex.Message}); the change goes ahead without it.", ex);
}
}
}
+64 -14
View File
@@ -4,6 +4,7 @@
*/
using System.Text.Json;
using gclo.Engine;
namespace gclo.ViewModels;
@@ -60,8 +61,9 @@ public sealed class AppSettings
/// <summary>
/// Loads settings from disk. Never throws: a missing, corrupt, or unreadable file
/// yields defaults, and loaded values are sanitized (concurrency clamped, theme validated).
/// A file that is there but cannot be used is reported to <paramref name="log"/>.
/// </summary>
public static AppSettings Load()
public static AppSettings Load(IActivityLog? log = null)
{
try
{
@@ -77,9 +79,10 @@ public sealed class AppSettings
}
}
}
catch
catch(Exception ex)
{
// Settings must never prevent startup; fall through to defaults.
log?.Error($"Settings could not be loaded ({ex.Message}); the defaults are used.", ex);
}
return new AppSettings();
@@ -87,12 +90,27 @@ public sealed class AppSettings
/// <summary>How long a save tries to put the new file in place while something has the old one open.</summary>
private static readonly TimeSpan s_savePatience = TimeSpan.FromSeconds(1);
/// <summary>
/// Persists settings to disk, creating the directory if needed. Failures (locked file,
/// read-only profile, full disk, ...) are swallowed: losing a preference write must
/// never crash the app.
/// Persists settings to disk, creating the directory if needed. The new content is
/// written beside the file and then put in its place in one step, so a crash or a
/// full disk in the middle leaves the previous settings, never half a file. The
/// step is refused while anything has the file open, so it is tried again for a
/// moment. Failures (locked file, read-only profile, full disk, ...) do not throw,
/// because losing a preference write must never crash the app; they are reported
/// to <paramref name="log"/> and in the result.
/// </summary>
public void Save()
/// <returns>True when the settings are on disk.</returns>
public bool Save(IActivityLog? log = null) => Save(log, s_savePatience);
/// <summary>Saves with its own patience for a file that is in use, for tests.</summary>
internal bool Save(IActivityLog? log, TimeSpan patience)
{
try
{
@@ -103,11 +121,45 @@ public sealed class AppSettings
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(path, JsonSerializer.Serialize(this, AppSettingsJsonContext.Default.AppSettings));
// A name of its own: two processes saving at once do not write one file.
string temporary = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
File.WriteAllText(temporary, JsonSerializer.Serialize(this, AppSettingsJsonContext.Default.AppSettings));
MoveIntoPlace(temporary, path, patience);
}
finally
{
// Gone already when the move succeeded; otherwise nothing is left behind.
File.Delete(temporary);
}
return true;
}
catch
catch(Exception ex)
{
// Best effort only.
log?.Error($"Settings could not be saved ({ex.Message}); the change is lost when the app closes.", ex);
return false;
}
}
private static void MoveIntoPlace(string temporary, string path, TimeSpan patience)
{
long started = System.Diagnostics.Stopwatch.GetTimestamp();
while(true)
{
try
{
File.Move(temporary, path, overwrite: true);
return;
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException
&& System.Diagnostics.Stopwatch.GetElapsedTime(started) < patience)
{
Thread.Sleep(15);
}
}
}
@@ -118,11 +170,9 @@ public sealed class AppSettings
// The JSON deserializer can assign null despite the non-nullable annotation.
DefaultTargetFolder ??= "";
DefaultMaxConcurrency = Math.Clamp(DefaultMaxConcurrency, MinConcurrency, MaxConcurrency);
// 0 means the field was absent (settings.json written by an older version):
// migrate to the default rather than clamping an unset value to the minimum.
SplashMilliseconds = SplashMilliseconds == 0
? DefaultSplashMilliseconds
: Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds);
// A file written before this setting existed has no such field, and the
// property then keeps its default; whatever a file does say is brought into range.
SplashMilliseconds = Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds);
if(Theme is not ("System" or "Light" or "Dark"))
{
Theme = "System";
+1 -1
View File
@@ -142,7 +142,7 @@ public sealed partial class MainWindow : Window
}
UpdateSyncAllEnabled();
_settings = AppSettings.Load();
_settings = AppSettings.Load(_log);
// Create and show Quick Sync before selecting it, so NavigationView.Content
// is never empty regardless of when SelectionChanged fires.
+4 -3
View File
@@ -154,7 +154,7 @@ public sealed partial class SettingsDialog : ContentDialog
}
}
_settings.Save();
bool written = _settings.Save(_log);
string typed = DefaultTokenBox.Password;
switch(_tokenState.Plan(typed))
@@ -173,9 +173,10 @@ public sealed partial class SettingsDialog : ContentDialog
break;
}
string outcome = written ? "Settings saved" : "Settings apply until the app closes (they could not be written, see above)";
_log.Info(changes.Count == 0
? "Settings saved (no changes)."
: "Settings saved: " + string.Join("; ", changes) + ".");
? outcome + " (no changes)."
: outcome + ": " + string.Join("; ", changes) + ".");
}