Repository navigation
A saved value that a semantic type rejects makes LoadOrCreate throw ArgumentException, and Get() then throws on every call for the rest of the process #315
Description
What's wrong
AppData<T>.LoadOrCreate (AppDataStorage/AppData.cs) only recovers from JsonException:
try
{
newAppData = JsonSerializer.Deserialize<T>(jsonString, AppData.JsonSerializerOptions) ?? throw new JsonException(...);
...
}
catch (JsonException)
{
AppData.FileSystem.File.Delete(newAppData.FilePath);
return LoadOrCreate(subdirectory, fileName);
}A value that is well-formed JSON but fails a semantic type's validation doesn't raise JsonException. RoundTripStringJsonConverterFactory passes through the ArgumentException that the semantic type throws, so it escapes LoadOrCreate completely.
Get() makes it worse. InternalState is a Lazy<T> in the default ExecutionAndPublication mode, which caches the exception. After the first failure, every AppData<T>.Get(), QueueSave() and SaveIfRequired() for the rest of the process rethrows the same exception. Nothing on disk changes, so the next launch fails the same way. The app can't start until the user finds and deletes the settings file.
Repro (verified against current main, Linux, MockFileSystem)
private sealed class ProbeData : AppData<ProbeData>
{
public string Data { get; set; } = "d";
public AbsoluteDirectoryPath? Path { get; set; }
}
// settings file on disk:
// {"Data":"keep","Path":"C:/Users/me/Documents"}
AppData<ProbeData>.LoadOrCreate(); // throws System.ArgumentException:
// Cannot convert "C:/Users/me/Documents" to AbsoluteDirectoryPath
AppData<ProbeData>.Get(); // throws System.ArgumentException
AppData<ProbeData>.Get(); // throws System.ArgumentException again (cached by Lazy)The file is left untouched, so every later launch fails in the same place.
Why it matters
The saved text is valid JSON, so any of these can trigger it without disk corruption:
- A settings file holding a path is synced or copied between Windows and Linux/macOS (dotfiles, roaming profiles). A Windows drive-letter path isn't an
AbsoluteDirectoryPathon Linux. - An app update tightens validation on a semantic string type, and values saved by the old version no longer pass.
- A user hand-edits a value into something the type rejects.
In each case a settings problem becomes a startup crash loop, when the app should get recovered or default settings. JsonException is already handled, so the gap is only in which exception types count as "could not deserialize".
Suggested fix
- Handle deserialization failures from converters the same way as
JsonException. At minimum catchArgumentException,FormatExceptionandNotSupportedExceptionfromJsonSerializer.Deserialize(or catchExceptionaround the deserialize call alone, excludingOutOfMemoryException-class failures), and route them to the existing recovery path. A settings file that fails to deserialize is deleted outright and replaced with defaults, with no backup left to recover from #314 covers making that path archive the file rather than delete it. - Consider making
InternalStaterecoverable: useLazyThreadSafetyMode.PublicationOnlyor a manually locked field, so that one failed load doesn't poisonGet()for the rest of the process.
Acceptance: with the settings file above, LoadOrCreate() returns an instance (defaults, or recovered content) without throwing, Get() succeeds, and a regression test covers a semantic-type validation failure alongside the existing corrupt-JSON tests.
Activity
matt-edmondson commentedon Sep 27, 2026
Triage
Lazy<T>caches the exception for the rest of the process, and the file is left on disk, so every later launch fails the same way until the user finds and deletes it. Ordinary events trigger it, such as Windows↔Linux settings sync or an update that tightens validation, with no corruption involved.AppDataStorage/AppData.cs: theLoadOrCreatecatch clause, which only catchesJsonException, and theInternalStateLazy<T>mode. Maintainer: @matt-edmondson.Next steps: Route
ArgumentException/FormatException/NotSupportedExceptionfromJsonSerializer.Deserializeinto the existing recovery path. SwitchInternalStatetoLazyThreadSafetyMode.PublicationOnlyor a locked field so one failure doesn't poisonGet(). Add a regression test with the{"Path":"C:/Users/me/Documents"}fixture onMockFileSystem.Generated by Claude Code