Files
Libation/Source/FileManager/PersistentDictionary.cs
T
Cursor Agentandrmcrackan d0cf459450 fix(config): serialize PersistentDictionary cache and file access
Configuration.Instance is a process-wide singleton read and written from the UI
thread, BackgroundWorker callbacks and download workers at the same time, but
PersistentDictionary guarded only its file writes. Its two Dictionary caches were
touched without synchronization, so concurrent inserts eventually corrupted them
and threw "Operations that change non-concurrent collections must have exclusive
access" (issue #1959, reported from MainVM.UpdateCountsBw_Completed reading
AutoDownloadEpisodes while a second GetCounts pass ran).

Reads on the file were unguarded too: Exists/GetJObject could observe a partially
written Settings.json, and readFile responds to empty contents by rewriting the
file, so a reader racing a writer could scramble the settings on disk.

Take one lock across each operation's cache and file access, and keep logging
outside it by having writeFile report whether it rewrote the file.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 01:58:54 +00:00

327 lines
7.9 KiB
C#

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
namespace FileManager;
public class PersistentDictionary : IJsonBackedDictionary
{
public string Filepath { get; }
public bool IsReadOnly { get; }
// optimize for strings. expectation is most settings will be strings and a rare exception will be something else
private Dictionary<string, string?> stringCache { get; } = new();
private Dictionary<string, object?> objectCache { get; } = new();
// Configuration.Instance is a process-wide singleton whose properties are read and written from
// the UI thread, BackgroundWorker callbacks and download workers simultaneously. Every cache and
// file access below must be serialized: unsynchronized Dictionary writes corrupt the cache, and
// unsynchronized file access lets a reader observe a half-written file.
private Lock locker { get; } = new();
public PersistentDictionary(string filepath, bool isReadOnly = false)
{
Filepath = filepath;
IsReadOnly = isReadOnly;
if (File.Exists(Filepath) || Path.GetDirectoryName(Filepath) is not string dirName)
return;
// will create any missing directories, incl subdirectories. if all already exist: no action
Directory.CreateDirectory(dirName);
if (IsReadOnly)
return;
createNewFile();
}
[return: NotNullIfNotNull(nameof(defaultValue))]
public string? GetString(string propertyName, string? defaultValue = null)
{
lock (locker)
{
if (!stringCache.ContainsKey(propertyName))
{
var jObject = readFile();
if (jObject.ContainsKey(propertyName))
stringCache[propertyName] = jObject[propertyName]?.Value<string>();
else
stringCache[propertyName] = defaultValue;
}
return stringCache[propertyName];
}
}
[return: NotNullIfNotNull(nameof(defaultValue))]
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
{
object? obj;
lock (locker)
{
obj = getObject(propertyName);
if (obj is null)
{
objectCache[propertyName] = defaultValue;
return defaultValue;
}
}
// UpCast can throw InvalidConfigurationValueException. Do it outside the lock: it neither
// reads nor writes the cache, and callers turn the exception into a user-facing error.
return IJsonBackedDictionary.UpCast<T>(obj, propertyName);
}
public object? GetObject(string propertyName)
{
lock (locker)
return getObject(propertyName);
}
private object? getObject(string propertyName)
{
if (!objectCache.ContainsKey(propertyName))
{
var jObject = readFile();
if (!jObject.ContainsKey(propertyName))
return null;
objectCache[propertyName] = jObject[propertyName]?.Value<object>();
}
return objectCache[propertyName];
}
public string? GetStringFromJsonPath(string jsonPath)
{
lock (locker)
{
if (!stringCache.ContainsKey(jsonPath))
{
try
{
var jObject = readFile();
var token = jObject.SelectToken(jsonPath);
if (token is null)
return null;
stringCache[jsonPath] = token.Value<string>();
}
catch
{
return null;
}
}
return stringCache[jsonPath];
}
}
public bool Exists(string propertyName)
{
lock (locker)
return readFile().ContainsKey(propertyName);
}
public void SetString(string propertyName, string? newValue)
{
bool written;
lock (locker)
{
// only do this check in string cache, NOT object cache
if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue)
return;
// set cache
stringCache[propertyName] = newValue;
written = writeFile(propertyName, newValue);
}
if (written)
logConfigChanged(propertyName, newValue);
}
public void SetNonString(string propertyName, object? newValue)
{
bool written;
JToken parsedNewValue;
lock (locker)
{
// set cache
objectCache[propertyName] = newValue;
parsedNewValue = JToken.Parse(JsonConvert.SerializeObject(newValue));
written = writeFile(propertyName, parsedNewValue);
}
if (written)
logConfigChanged(propertyName, parsedNewValue.ToString());
}
public bool RemoveProperty(string propertyName)
{
if (IsReadOnly)
return false;
var success = false;
try
{
lock (locker)
{
var jObject = readFile();
if (!jObject.ContainsKey(propertyName))
return false;
jObject.Remove(propertyName);
var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
File.WriteAllText(Filepath, endContents);
success = true;
}
Serilog.Log.Logger.Information("Removed property. {propertyName}", propertyName);
}
catch { }
return success;
}
/// <summary>Caller must hold <see cref="locker"/>.</summary>
/// <returns>The file was rewritten</returns>
private bool writeFile(string propertyName, JToken? newValue)
{
if (IsReadOnly)
return false;
// write new setting to file
var jObject = readFile();
var startContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
jObject[propertyName] = newValue;
var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
if (startContents == endContents)
return false;
File.WriteAllText(Filepath, endContents);
return true;
}
private static void logConfigChanged(string propertyName, string? newValue)
{
try
{
Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { propertyName, newValue = formatValueForLog(newValue) });
}
catch { }
}
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
/// <returns>Value was changed</returns>
public bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false)
{
if (IsReadOnly)
return false;
var path = $"{jsonPath}.{propertyName}";
try
{
lock (locker)
{
// only do this check in string cache, NOT object cache
if (stringCache.ContainsKey(path) && stringCache[path] == newValue)
return false;
// set cache
stringCache[path] = newValue;
var jObject = readFile();
var token = jObject.SelectToken(jsonPath);
if (token is null || token[propertyName] is null)
return false;
var oldValue = token.Value<string>(propertyName);
if (oldValue == newValue)
return false;
token[propertyName] = newValue;
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
}
}
catch (Exception exDebug)
{
Serilog.Log.Logger.Debug(exDebug, "Silent failure");
return false;
}
if (!suppressLogging)
{
try
{
var str = formatValueForLog(newValue?.ToString());
Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { jsonPath, propertyName, newValue = str });
}
catch { }
}
return true;
}
private static string formatValueForLog(string? value)
=> value is null ? "[null]"
: string.IsNullOrEmpty(value) ? "[empty]"
: string.IsNullOrWhiteSpace(value) ? $"[whitespace. Length={value.Length}]"
: value.Length > 100 ? $"[Length={value.Length}] {value[0..50]}...{value[^50..^0]}"
: value;
/// <summary>Caller must hold <see cref="locker"/>.</summary>
private JObject readFile()
{
if (!File.Exists(Filepath))
{
var msg = "Unrecoverable error. Settings file cannot be found";
var ex = new FileNotFoundException(msg, Filepath);
Serilog.Log.Logger.Error(ex, msg);
throw ex;
}
var settingsJsonContents = File.ReadAllText(Filepath);
if (string.IsNullOrWhiteSpace(settingsJsonContents))
{
createNewFile();
settingsJsonContents = File.ReadAllText(Filepath);
}
var jObject = JsonConvert.DeserializeObject<JObject>(settingsJsonContents);
if (jObject is null)
{
var msg = "Unrecoverable error. Unable to read settings from Settings file";
var ex = new NullReferenceException(msg);
Serilog.Log.Logger.Error(ex, msg);
throw ex;
}
return jObject;
}
private void createNewFile()
{
File.WriteAllText(Filepath, "{}");
}
public JObject GetJObject()
{
lock (locker)
return readFile();
}
}