Skip to content

Darling's viewer silently replaces a corrupt settings file with defaults, on a click #2434

Description

@erikdarlingdata

#2425 fixed this in Lite. The Darling viewer has the same defect in both of its JSON settings stores, and it is worse there: it loses the file on an ordinary click, with no diagnostic at all in a shipped build.

Both stores swallow the difference between "no file" and "corrupt file"

Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs and .../ViewerPreferences.cs have the identical shape:

public ViewerAppSettings Load()
{
    try
    {
        if (!File.Exists(_filePath)) return new ViewerAppSettings();
        var json = File.ReadAllText(_filePath);
        var settings = JsonSerializer.Deserialize<ViewerAppSettings>(json, s_jsonOptions);
        return (settings ?? new ViewerAppSettings()).Normalize();
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"ViewerAppSettingsStore: failed to load '{_filePath}', using defaults: {ex.Message}");
        return new ViewerAppSettings();
    }
}

Debug.WriteLine compiles out of a Release build, so in the viewer anyone actually runs there is no record whatsoever — not a log line, not a dialog, nothing. ViewerPreferences.Load's comment is explicit that this is by design ("the viewer writes no application log of its own"), which is exactly the reasoning #2425 found to be wrong in Lite: the absence of a place to report to is a reason to add one, not a reason to stay silent.

And Save destroys the file, on a click nobody would call a save

Neither Save merges. Both serialize the whole in-memory object over the file:

File.WriteAllText(_filePath, JsonSerializer.Serialize(settings, s_jsonOptions));

Lite's pre-#2425 writers at least parsed the existing document first, so a corrupt settings.json made the save fail rather than overwrite — the user had to delete the file themselves to lose it. The viewer has no such accident protecting it. Two handlers in MainWindow.xaml.cs do load-then-save directly:

private void OnDisplayModeChanged(TimeDisplayMode mode)      // ~L1040
{
    var settings = _appSettingsStore.Load();                 // corrupt file -> all defaults
    settings.TimeDisplayMode = mode.ToString();
    _appSettingsStore.Save(settings);                        // defaults written over the file

and the Overview sort selector at ~L1086 does the same. So on a viewer whose viewer-settings.json has a trailing comma in it, changing the time-display dropdown once silently replaces every setting in the file with a default. SettingsWindow.xaml.cs L1509 (_appSettingsStore.Save(_appSettings)) is the same story from the Settings window.

ViewerControlPlaneMigration is fed from _appSettingsStore.Load() too (MainWindow.ServerManagement.cs L564), so a corrupt file also silently feeds the migration defaults rather than the operator's configured control plane.

What to do

The Lite shape ports over, and most of the thinking is already done in Lite/Services/SettingsFileGuard.cs:

  1. Split the three states — absent (silent, defaults are right), readable, unreadable (never silent). Absent must stay completely silent or a first run becomes a warning.
  2. Copy an unreadable file aside as viewer-settings.json.unreadable-<timestamp> before the first Save replaces it, and refuse the write if even the copy cannot be made.
  3. Give the failure somewhere to go. ViewerLogger exists — the Debug.WriteLine comment predates it, and is worth re-checking rather than trusted.

Whether SettingsFileGuard should move to a shared project or be mirrored is a real decision, not an obvious one: Lite's file is a JsonNode document merged key by key, while the viewer's two are whole-object JsonSerializer round-trips, so only the read/classify/quarantine half is common. The quarantine half is the half that matters.

Found by the review bot on #2428. Related: #2425, #2433.

Activity

  1. added a commit that references this issue on Aug 21, 2026
  2. erikdarlingdata commented on Aug 21, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed on dev by #2439. The premise held in full — Debug.WriteLine really does carry [Conditional("DEBUG")], so in a shipped build the failure was not merely unlogged but unreportable; neither Save merged; and the load-then-save pairs are where the issue said they were.

    There were three stores, not two, and the third is the worst. ViewerServerStore — the monitored-server registry — had the identical defect with an extra edge:

    catch (Exception ex)
    {
        /* A corrupt or unreadable registry must never block startup — begin empty and log. */
        return new List<ViewerServerEntry>();
    }

    Beginning empty is defensible; the next Save() writing that empty list over the registry is not. So one favourite toggle against an unreadable file replaced the server list. That store was found because the guard was written as a category rather than to the two names in this issue — a guard covering two of three would have been exactly the scenario-shaped fix it exists to prevent.

    The guard is shared, not duplicated: SettingsFileGuard moved to PerformanceMonitor.Common/Services/, referenced by Lite and the viewer, still net10.0 with no WPF and no logger. Two members added for the whole-object shape; Read/RootForWrite keep Lite's merge semantics unchanged, and all nine of #2425's pins still pass against the moved guard.

    Two things the issue could not have known, both caught in the doing:

    ViewerLogger.Log drops anything enqueued before Initialize, unlike Lite's AppLogger — which #2428 had relied on. App.OnStartup reads the settings file for the theme before ViewerLogger.Initialize(), so reporting from the store alone would have dropped the earliest and most useful diagnostic. Initialize moved ahead of the theme read.

    And the registry's root is a JSON array, so the object-shaped read had to stop applying the "root must be an object" rule — that rule belongs to the merge path. Borrowing it would have quarantined a copy of every healthy registry on every toggle, which is a new bug wearing the fix's clothes. There is a control test for that exact one-line mistake.

    Proved in Release specifically, so Debug.WriteLine is genuinely absent: dev 3 passed / 10 failed on identical bodies, and the full committed file does not compile against dev at all — 13 errors, because the contract does not exist there.

    Two residues split out: #2444 (per-key reads, so a message can name the offending key — the viewer's whole-object round trip has the same limitation one level up) and the note in #2444 about the sample-key extractor constraint.

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions