Repository navigation
The viewer stops replacing a settings file it could not read (#2434) - #2439
Conversation
…ave gone #2425 put SettingsFileGuard in Lite/Services because Lite was the only place the defect had been found. #2434 is the same defect in the Darling viewer's two stores, so the question is now whether the rule lives in one place or in two, and duplicating a quarantine rule is how the two copies eventually stop agreeing about what gets preserved. PerformanceMonitor.Common is the project both front ends already reference, it targets net10.0 with no WPF and no logger, and the guard was written that way on purpose -- a pure function of a path and a clock -- so the move is a namespace change and nothing else. SettingsWindow.xaml.cs already had the Common using; App.xaml.cs gains one. The two new members are the half the viewer needs and Lite does not. Lite merges a JsonObject key by key, so RootForWrite hands back the document to merge into. The viewer round-trips a whole object through JsonSerializer and REPLACES the file, so it has no document to be handed and only one question left: whether writing is allowed to destroy what is there now. PermitReplace answers that, and ReadObject is Read plus the typed deserialize, which is inside the guard rather than in front of it because it fails on files Read is perfectly happy with -- one key holding a quoted number is a JSON object and still unusable, and the store that hits it is about to replace the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both of the viewer's per-user stores ended their load in a bare catch whose
only output was Debug.WriteLine. That carries [Conditional("DEBUG")] and the
compiler removes it from a Release build, so in the viewer anyone actually
runs the failure was not merely unlogged, it was unreportable: no log line,
no dialog, no trace. Neither Save merged either -- each serialized its whole
in-memory object over the file -- so load-then-save from the time-display
dropdown or the Overview sort selector wrote defaults over every setting in
an unreadable viewer-settings.json, on one click nobody would call a save.
Lite's pre-#2425 writers at least parsed first and aborted, which left the
file intact by accident. The viewer had no such accident.
Both stores now read and write through one helper on the shared guard. An
absent file stays completely silent, because a first run has nothing to
preserve and nothing to explain. A present one that cannot be read is
reported to ViewerLogger, left exactly where it is, and copied aside as
.unreadable-<timestamp> before any save replaces it -- and when even the copy
cannot be made the save is refused and says so, because leaving the file
alone beats replacing it when the alternative is permanent.
Save answers with a bool rather than throwing. Three of its four call sites
never caught anything, so on a full disk the old contract was a crash, and a
save nobody could ask about is how a UI ends up claiming it saved something
it did not. The two handlers that persist on an ordinary click now say when
the click did not stick; PersistCollapseState logs instead, because a modal
every time a sidebar group collapses would be worse than what it reports.
MainWindow raises one dialog at Loaded for a file that was unreadable at
startup: someone looking at an app that has forgotten its configuration
should not have to go and find a log to learn that it did.
ViewerLogger.Initialize moves ahead of the theme read in OnStartup. That read
is the viewer's first touch of viewer-settings.json and therefore the first
thing that can discover it is broken, and ViewerLogger.Log drops anything
enqueued before Initialize -- which would have made the earliest and most
useful diagnostic the one guaranteed to vanish. Nothing between the two
positions depends on the order.
The comment saying the viewer writes no application log of its own is gone.
It predates ViewerLogger, and #2425's finding was that having nowhere to
report to is a reason to add somewhere, not a reason to stay silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| /// already replaced whatever it could not read. Save answers with a bool instead of throwing now, and | ||
| /// this is what does something with the answer. Which file and what was wrong with it is in the log; | ||
| /// what the user needs from a dialog is that the click did not stick.</para> | ||
| /// </summary> |
There was a problem hiding this comment.
Doc-comment mixup: this new <summary> for WarnSettingNotSaved was inserted between the existing <summary> for OnDisplayModeChanged (the "A server tab's Server/Local/UTC picker changed..." block just above it) and the OnDisplayModeChanged method itself, instead of being placed as its own block before/after that pair.
Net effect: the pre-existing OnDisplayModeChanged summary now dangles with no method directly under it (doc tooling will attach it to WarnSettingNotSaved instead), and OnDisplayModeChanged itself ends up with no doc comment at all.
Suggest moving this WarnSettingNotSaved summary block above the OnDisplayModeChanged summary block (swap their order) so each method keeps its own doc comment.
Review summaryWent through the diff (Darling viewer settings-file guard + Overall: solid. The core fix (three-way Absent/Readable/Unreadable classification, copy-aside-before-replace via
One finding posted inline: a doc-comment placement bug in |
DocCommentHygieneTests caught this and it is exactly what that rule exists for. WarnSettingNotSaved was inserted anchored on the OnDisplayModeChanged signature, which already carried a summary, so the new method landed BETWEEN that summary and the member it describes -- leaving OnDisplayModeChanged undocumented and giving WarnSettingNotSaved two stacked summaries, of which XML docs would silently have taken the second. Nothing warns, nothing else notices, and only someone reading the file top-down is misled. The fix is the order rather than a deletion: the helper and its own summary move above, and the displaced block goes back onto the member it was written for. Nothing is lost, which is the outcome that rule's message insists on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| diagnostic the one guaranteed to vanish. Nothing between here and its old position depends on | ||
| the ordering: Initialize creates a directory and starts a timer, and it swallows its own | ||
| failure. */ | ||
| ViewerLogger.Initialize(); |
There was a problem hiding this comment.
Minor diagnostic-quality nit, not a correctness bug: this move is correct and well-justified by the comment above it — but it has a side effect worth flagging. A few lines down, ThemeManager.Apply(new ViewerAppSettingsStore().Load().ColorTheme) creates a second, independent ViewerAppSettingsStore that reads the same viewer-settings.json MainWindow's own _appSettingsStore will load moments later (MainWindow.xaml.cs constructor, ~L172-183).
ViewerSettingsFile.Load<T> logs a ViewerLogger.Error unconditionally whenever State == Unreadable, with no dedup. So a single corrupt viewer-settings.json now produces two near-identical "could not be read" ERROR log lines at startup — one from this throwaway theme-load instance, one from MainWindow's real store — even though only one dialog is ever shown (ReportUnreadableSettingsFiles only reads MainWindow's own store state). That's a bit self-defeating for a PR whose whole point is making the log trustworthy for someone diagnosing a lost-settings report: two log lines with no indication they refer to the same read.
Not blocking, but worth considering having MainWindow accept the already-loaded settings from App (or the store instance) instead of loading viewer-settings.json a second time, so there's one authoritative read (and one log line) per startup.
|
Reviewed. This PR is entirely C#/WPF (no T-SQL), so the T-SQL style conventions in CONTRIBUTING.md don't apply here. Design/correctness: The Ordering fix verified: Parity: No Lite/Darling drift introduced — Lite's merge-based One minor finding posted inline on Doc-comment ordering fix (3rd commit) and the new test coverage ( |
Writing the category guard is what found it. ViewerServerStore persists viewer-servers.json -- the operator's registry of monitored servers, their display names, favourites, view filters and credential-profile links -- and it is the same whole-object round trip as the other two, with the same bare catch and the same Debug.WriteLine. A guard that listed two of the three stores would have been exactly the scenario-shaped fix it was written to prevent. Its shape made the loss arrive sooner rather than later. LoadFromDisk began EMPTY on an unreadable registry, so the first favourite toggle serialized an empty list over the file. There was no intermediate state where anyone could have noticed: the servers were simply not there, and then the file said so. Two things had to move in the guard to take it. ReadObject no longer applies Read's "the root must be an object" rule -- that rule exists because a MERGE has nothing to merge into otherwise, and the registry is a legitimate JSON array. Borrowing it here would have called every healthy registry unreadable and dropped a quarantine copy beside it on every toggle, which trades data loss for litter, and litter is how a real quarantine copy gets ignored. There is a control test for exactly that mistake, because it was one line away. PermitReplace became generic for the same reason: it now asks whether the file can still be read AS the thing about to replace it, which is the same question the store's own Load asks, rather than by some other standard. Read and ReadObject share ReadText now instead of the second calling the first, so absent-vs-empty-vs-unparseable is decided in one place for both. The registry's mutators still return void, so a write failure there is logged rather than surfaced. That is a smaller residue than it looks -- Add/Update/Delete have no live UI caller since server management moved to the control plane, and what remains is favourites, view filters and tags -- but it is real and it is named rather than quietly fixed halfway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A viewer that opens with no servers because it could not read viewer-servers.json looks exactly like a viewer nobody has configured yet, which is the worst of the three files to be quiet about. The registry loads in its own field initializer and that has already run by the time the constructor body does, so its state is available at the same point the other two are recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review bot noticed that moving ViewerLogger.Initialize above the theme read now puts TWO identical "could not be read" errors in the log, because the theme read builds its own throwaway store and MainWindow builds another moments later. It is right, and the count is worse than two: the Settings window loads on open, MainWindow re-loads on close, and the control-plane migration loads as well, so one trailing comma could account for five identical ERROR lines. That is a poor reward for the one person who went and opened the log, on a change whose entire point is that the log is worth opening. Reported once per (file, problem) instead. The first line carries the fact and the rest carry nothing, so nothing is lost by dropping them. Keyed on the problem as well as the path on purpose: a file that changes mid-session and breaks in a NEW way is a new fact, and it gets said again. Sharing one store instance between App and MainWindow was the other way to get here, and it is the wrong lever -- it would have made the number of log lines a property of object ownership across startup, so the next caller to need its own read would quietly reintroduce this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#2435 landed on dev while this was open and made McpSettings.Load take SettingsFileGuard -- from Lite/Services, where it lived at the time. This branch moved it to Common, so the two merge cleanly and do not compile: git has no way to see that a using directive one file over is what the other lane's new call site needs. The PR build caught it, which is what building the merge result is for. One using, and it is the whole fix. Nothing about #2435's reasoning changes: the guard is the same type with the same behavior, one assembly further down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| /// Delete, favourite, tag, import — so this is the whole-file replacement that stands behind an | ||
| /// ordinary click, exactly as the display-mode dropdown does for viewer-settings.json. | ||
| /// </summary> | ||
| private bool Save() => ViewerSettingsFile.Save(_filePath, _servers, LogSource, s_jsonOptions); |
There was a problem hiding this comment.
Save() now returns whether the write actually reached disk, but every one of its callers in this file discards that bool: AddServer (L155), both UpdateServer overloads (L167, L175), DeleteServer (L185), ImportServersFromFile (L219), ToggleFavorite (L249) and SetFavorite (L292)/SetViewFilterDatabases (L332) all call Save(); and move on regardless of the result.
That means the quarantine-and-refuse path this PR adds (PermitReplace returning Allowed: false — file unreadable and the quarantine copy itself failed) is unreachable from the UI for this store. Concretely:
AddServerDialog.xaml.cscalls_serverStore.SetFavorite(...)and immediately setsDialogResult = true; Close();— the dialog reports success even if nothing was written.MainWindow.ServerManagement.cs(~L657) shows"Imported {imported} server definition(s)."afterImportServersFromFileunconditionally, with no check that the resultingSave()succeeded.- The sidebar favorite-star (
ToggleFavorite,MainWindow.ServerManagement.cs:261) flips the UI state but a failed underlying save is never surfaced — unlikeviewer-settings.json/viewer-preferences.json, which now show aWarnSettingNotSaveddialog on every failed save (MainWindow.xaml.csL1140/L1188/L1706,MainWindow.Tags.csL122).
The PR's own description calls this store "the one where losing the file costs the operator the most," so of the three stores it's the one where this gap matters most. Worth threading the bool (or at least logging + a warning like the other two stores get) through the mutators that are reachable from user actions, especially ToggleFavorite/SetFavorite/ImportServersFromFile.
|
Reviewed the settings-persistence fix (#2434). No T-SQL in this PR, so the T-SQL style/parity rules don't apply — this is a Lite/Darling C# parity fix, and it's well-targeted: Left one inline finding: No security, SQL injection, or missing-index concerns — this PR doesn't touch T-SQL or the DMV surface at all. |
I filed this as a residue and review pushed back on it, correctly. The two settings files now say when a save did not land and the registry did not, which is the inconsistency rather than the compromise -- and by this PR's own argument the registry is the file where losing it costs the operator most. The answer lives on the store as LastSaveSucceeded rather than in the return types, because two of the mutators already answer other questions: ToggleFavorite says whether it is favourite now, ImportServersFromFile answers with counts. A caller about to tell the user something happened can ask; one doing incidental cleanup need not. Two callers ask. The sidebar's pin toggle writes its own status line and that line is the whole report for that click, so it now says when the pin did not persist instead of announcing a star that will be gone next launch. And "Imported N server definition(s)." is a claim about a file, made at exactly the moment a refused write matters most. The two SetFavorite(..., false) cleanups on server removal deliberately do not ask, and say so in place: they are removing a pin for a server that no longer exists, so a refused write strands an entry nothing reads rather than losing anything. The store logs it either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed this PR (C#/WPF, no T-SQL touched, so the CONTRIBUTING.md T-SQL style section doesn't apply here — no missing-index folklore concerns either since there's no SQL in the diff). Went through the full diff carefully: Things I specifically checked and didn't find issues with:
No correctness, security, parity, or performance findings to raise — this is a well-scoped, well-tested fix (including a source-parsing wiring test to guard against a future store reintroducing |
One click on the time-display dropdown was enough to lose every setting in the Darling viewer's
viewer-settings.json, and in a shipped build nothing anywhere recorded that it had happened.Both of the viewer's per-user stores ended their load in a bare
catchwhose only output wasDebug.WriteLine. That carries[Conditional("DEBUG")]and the compiler removes it from a Release build, so this was not "unlogged" — it was unreportable: no log line, no dialog, not even a trace. And neitherSavemerged; each serialized its whole in-memory object over the file.OnDisplayModeChangedand the Overview sort selector both do load-then-save, so an unreadable file was loaded as defaults and those defaults were written straight back over it. Lite's pre-#2425 writers at least parsed first and aborted, which left the file intact by accident. The viewer had no such accident.The premise held up in source, with one correction to make
Everything in the issue checked out: the
Debug.WriteLinecatch in both stores, the whole-objectFile.WriteAllTextin bothSaves, the load-then-save atMainWindow.xaml.csL1042 and L1086,SettingsWindow.xaml.csL1509, andViewerControlPlaneMigrationbeing fed from_appSettingsStore.Load().The correction is that the issue's third bullet — "
ViewerLoggerexists, and theDebug.WriteLinecomment predates it, worth re-checking rather than trusted" — is exactly right, and the re-check found one thing the issue could not have known.ViewerLogger.Logreturns early when the logger has not been initialized, unlike Lite'sAppLogger, which enqueues unconditionally.App.OnStartupreadsviewer-settings.jsonfor the color theme beforeViewerLogger.Initialize(). So simply logging from the store would have dropped the earliest and most useful diagnostic — the very first read of the file — on the floor.Initializemoves ahead of the theme read; it creates a directory, starts a timer and swallows its own failure, and nothing between the two positions depends on the ordering.There is a third store, and writing the category guard is what found it
The issue names two stores. There are three.
ViewerServerStorepersistsviewer-servers.json— the operator's registry of monitored servers, their display names, favourites, view filters and credential-profile links — through the identical whole-object round trip, with the same bare catch and the sameDebug.WriteLine.Its shape made the loss arrive sooner rather than later.
LoadFromDiskbegan empty on an unreadable registry, so the first favourite toggle serialized an empty list over the file. There is no intermediate state in which anyone could have noticed: the servers were simply not there, and then the file said so.It is fixed here rather than filed, because a guard listing two of the three stores would be exactly the scenario-shaped fix it was written to prevent. Two things had to move in the guard to take it, and one of them is a trap worth naming:
ReadObjectno longer appliesRead's "the root must be an object" rule. That rule exists because a MERGE has nothing to merge into otherwise; the registry is a legitimate JSON array. Borrowing the rule here would have called every healthy registry unreadable and dropped a quarantine copy beside it on every favourite toggle — data loss traded for litter, and litter is how a real quarantine copy gets ignored.Save_LeavesNoCopyBehind_ForAHealthyRegistry_ThoughItsRootIsAnArrayis the control for exactly that mistake, because it was one line away.PermitReplacebecame generic, so it asks whether the file can still be read AS the thing about to replace it — the same question the store's ownLoadasks — rather than by some other standard.ReadandReadObjectnow share aReadTexthelper instead of the second calling the first, so absent / empty / unparseable is decided in one place for both.Shared, not mirrored
SettingsFileGuardmoved toPerformanceMonitor.Common/Services/. Common is referenced by Lite and the viewer, targetsnet10.0, and carries no WPF and no logger — which is what the guard was already written to be, a pure function of a path and a clock. The move is a namespace change and nothing else;SettingsWindow.xaml.csalready had theusing.The issue is right that only half of it is common, and that is the half that matters. Lite merges a
JsonObjectkey by key, soRootForWritehands back a document to merge into. The viewer round-trips a whole object and REPLACES the file, so it has no document to be handed and one question left: whether the write is allowed to destroy what is there now. Two members answer that shape:PermitReplace— absent or readable is a plain yes; unreadable is copied aside first and the answer is yes only if the copy exists.ReadObject<T>—Readplus the typed deserialize. That deserialize is inside the guard rather than in front of it because it fails on filesReadis perfectly happy with:{"DefaultTimeRangeIndex":"not a number"}is a valid JSON object and still unusable, and a classify step that stops at "is this a JSON object" hands the exception straight back to the bare catch it was meant to replace.Mirroring the class into the viewer would have meant two copies of the quarantine rule, and this issue exists because the first copy already had a sibling nobody had looked at.
What the viewer does now
Both stores read and write through one helper,
ViewerSettingsFile. Absent stays completely silent — a first run has nothing to preserve and nothing to explain, and a warning there is how a diagnostic earns being ignored. A present file that cannot be read is reported toViewerLogger, left exactly where it is, and copied aside asviewer-settings.json.unreadable-<timestamp>before any save replaces it. When even the copy cannot be made, the save is refused and says so: leaving the file alone beats replacing it when the alternative is permanent.One broken file gets one log line, not one per read. Nothing reads these files once — the theme is applied from
viewer-settings.jsonbefore the window exists,MainWindowloads it again to seed itself, the Settings window loads it on open andMainWindowre-loads it on close, and the control-plane migration loads it too — so one trailing comma would otherwise account for five identical ERROR lines, which is a poor reward for the one person who went and opened the log. Reported once per (file, problem); keyed on the problem as well as the path, so a file that changes mid-session and breaks in a NEW way is said again. (Sharing one store instance betweenAppandMainWindowwas the other way to get here and is the wrong lever — it makes the number of log lines a property of object ownership across startup, so the next caller needing its own read quietly brings it back.)Saveanswers with aboolinstead of throwing. Three of its four call sites never caught anything, so on a full disk the old contract was a crash — and a save nobody can ask about is precisely how a UI ends up claiming it saved something it did not (#2433, next door). The two handlers that persist on an ordinary click now say when the click did not stick.PersistCollapseStatelogs instead: a modal every time a sidebar group collapses would be worse than what it reports, and that is the write that most needed the guard behind it, because collapsing a group is a whole-file rewrite ofviewer-preferences.jsonthat nobody thinks of as a save.MainWindowraises one dialog atLoadedfor a file that was unreadable at startup — somebody looking at an app that has forgotten its configuration should not have to go and find a log to learn that it did.Verification
Darling.Teststargetsnet10.0-windowsand cannot run on macOS, so the real test file was compiled into a throwawaynet10.0harness with a minimal xUnit shim, against the REALViewerAppSettings.cs,ViewerPreferences.cs,ViewerLogger.csandViewerSettingsFile.csfrom each branch plus a project reference to the realPerformanceMonitor.Common— not a transcription of them. Compiled-c Releaseon purpose, soDebug.WriteLineis genuinely gone the way it is in a shipped build.Against dev the test file does not compile at all — 13
CSerrors, becauseLastLoadState,LastLoadProblem,SettingsFileStateand aSavethat returns anything do not exist there. That absence is the finding, not a measurement, so the pass/fail split below runs the subset that can be expressed against dev's contract, identical bodies on both sides:The three that pass on dev are the controls — a readable file, a first run, and a healthy array-rooted registry all leave no quarantine copy — which were never broken and are the first thing a wrong fix breaks. The ten failures are the ones that matter: no copy made before ANY of the three stores replaced an unreadable file, all six source-wiring guards red, and
Load_LeavesARecordAShippedBuildCanSeefinding nothing whatsoever in either channel a shipped viewer has (the Trace listenersDebug.WriteLineroutes to, and the viewer's own log file) — the only line in the log isViewerLogger's own "Logging initialized".That last one stays in the harness rather than the committed suite, deliberately. It needs process-wide
ViewerLoggerandTrace.Listeners, and more to the point CI runsdotnet testin Debug, whereDebug.WriteLinestill works — so as a standing guard it would pass on unfixed code, which is worse than not having it. The standing guard for the same property isNoViewerSettingsStore_ReportsThroughDebugWriteLine, which is a source scan and is configuration-independent.ViewerSettingsStoreWiringTestsis the category rather than the instance, and it earned its keep before it ever ran in CI — writing it is what turned up the third store. The defect was never that one store'sSavewas wrong; it was that each store rolled its own read in front of its own whole-file replace, so whether a save could destroy your configuration depended on which store you happened to be in. A fourth written the same way tomorrow fails that test instead of shipping. It keys on_filePath— the store's own file — rather than onFile.*in general, becauseImportServersFromFilelegitimately reads a file the user picked, and a rule that could not tell those apart would either miss the defect or forbid a feature.One note on the merge
#2435 landed on dev while this was open and made
McpSettings.LoadtakeSettingsFileGuard— fromLite/Services, where it lived at the time. The two merge cleanly and do not compile: git has no way to see that ausingdirective one file over is what the other lane's new call site needs. Oneusingis the whole fix, and nothing about #2435's reasoning changes — it is the same type with the same behavior, one assembly further down. Worth knowing that the PR build catches this, because a local build of the branch alone does not.Deliberately not in scope
— filed as a residue, then argued out of it by review, correctly: the two settings files say when a save did not land and leaving the registry silent is the inconsistency rather than the compromise, on the file this PR itself calls the costliest to lose.ViewerServerStore's mutators still returnvoidThe answer lives on the store as
LastSaveSucceededrather than in the return types, because two mutators already answer other questions —ToggleFavoritesays whether it is favourite now,ImportServersFromFileanswers with counts. A caller about to tell the user something happened can ask; one doing incidental cleanup need not. Two ask: the sidebar's pin toggle, whose status line is that click's whole report, and"Imported N server definition(s).", which is a claim about a file made at exactly the moment a refused write matters most. The twoSetFavorite(..., false)cleanups on server removal deliberately do not, and say so in place — they remove a pin for a server that no longer exists, so a refused write strands an entry nothing reads rather than losing anything.ViewerSettings(the viewer's read of the SERVICE'sdarling.json) is a different file with a different owner and its own diagnostics path (#1954), and it is read-only from the viewer — there is no save that could replace it, which is the whole hazard here. Left alone.The startup dialog reports the file and the parse position but not which SETTINGS were lost, because the whole-object round trip cannot know: the deserialize failed. Naming them would mean per-property reads, which is the viewer's version of the eighty-eight-try-blocks item #2428 filed for Lite.
Fixes #2434