Skip to content

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

@matt-edmondson

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 AbsoluteDirectoryPath on 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 catch ArgumentException, FormatException and NotSupportedException from JsonSerializer.Deserialize (or catch Exception around the deserialize call alone, excluding OutOfMemoryException-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 InternalState recoverable: use LazyThreadSafetyMode.PublicationOnly or a manually locked field, so that one failed load doesn't poison Get() 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreadyFully specified; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions