Name the settings the viewer lost, and stop one of them costing the rest (#2456) - #2462
Conversation
The viewer's startup dialog could name the file and the parse position and nothing else. That is not a wording problem: it round-trips a whole object, so when JsonSerializer.Deserialize threw there was no property in existence to report -- and every setting in the file reverted, not just the bad one. Per-property reads, the shape #2444 gave Lite, are the wrong answer here, and the reason is not the eighty-odd lines of typing the issue weighed. Lite's settings.json is built key by key on BOTH sides -- #2441 made every writer mutate one JsonObject -- so a per-key read is symmetric with a per-key write. These files are a whole-object round trip on both sides. Converting only the read half would break that symmetry: a property added to ViewerAppSettings afterwards would still be serialized on save and would silently never be read back. That is a worse defect than the one being fixed, and an invisible one. So the fix keeps the round trip and works on the exception instead. Measured rather than assumed, against the shipped BCL: JsonException.Path is "$" for a document fault and "$.AlertCpuThreshold" for a member's value, every time and for every fault class -- string-where-int, string-where-bool, number-where-string, number-where-list, a bad element inside a list, an overflow, a null. So the split between "the document is broken" and "one setting is the wrong shape" is a fact the reader already had in hand. SettingsFileGuard.Describe was throwing it away, deliberately: WithoutPathSuffix cuts " Path: ..." off the message because it duplicates the line and position, and Path is the one part that does not. ReadObject now names the member the deserializer stopped on, drops it, and runs the SAME deserialize again. Two consequences, and the second is the issue's title: a badly-shaped setting costs exactly its own setting, and the read can report the whole set rather than the first one -- which the path alone cannot, because a file with three bad members reports only whichever the reader met first. It is one judge, not two. #2213 spent a review round on a two-pass classify whose second pass judged by a different standard from the first; here every attempt is JsonSerializer.Deserialize<T> with the caller's own options over the caller's own type, and the only thing that changes between attempts is that one member is gone. Only a top-level member of a root JSON OBJECT is ever dropped, and that restriction is the load-bearing half. The viewer's server registry is a root ARRAY: "drop the element that would not deserialize" there means silently deleting a server the operator added -- the data loss #2434 exists to prevent, wearing a repair's clothes. The registry keeps its all-or-nothing behaviour, ViewerServerStore says so where the next reader will find it, and there is a control test for exactly that mistake because it was one line away. The state stays Unreadable for a partially recovered file rather than becoming some third thing, so PermitReplace still copies the original aside before the next save replaces it. Relaxing that would have destroyed the one setting the user actually got wrong, with no copy of it anywhere -- strictly worse than dev, where nothing was recovered and everything was preserved. The dialog gets two paragraphs, because there are two facts. A file that could not be read at all costs every setting in it; a file read after dropping named members costs only those. One sentence covering both would have to overstate one of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caught by probing the shipped reader rather than by reading it. Each retry runs over the document with the previous member removed, and removing it re-serializes -- so from the SECOND member onward the exception's line and position describe the re-serialized text, not the file the user is about to open. Measured on a file whose three bad members sit on lines 2, 3 and 4: the first reported "line 2, position 22", correctly, and the other two reported "line 1, position 30" and "line 1, position 14". A position that confidently names the wrong line is worse than no position at all, and this string goes in a dialog whose entire job is to send someone to the right place in their file. There is a better locator here anyway, and producing it is the point of the whole change: the member's NAME, which is what a reader will search for. So the member problem carries the reason and not the position, uniformly -- keeping it for the first member only would be an inconsistency nobody could reason about. The DOCUMENT fault keeps its line and position, and the same test asserts both halves, because there the parse position is the only locator there is and a rule applied to both would have quietly taken it away. Also fixes the summary line's plural: three settings were "at their default". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| catch (Exception ex) when (ex is NotSupportedException or ArgumentException) | ||
| { | ||
| /* Not a member fault and carries no path to attribute one with. */ | ||
| return new SettingsObjectRead<T>(SettingsFileState.Unreadable, null, Describe(ex), dropped); |
There was a problem hiding this comment.
Value can come back null even though dropped/UnreadableMembers is non-empty — and that combination is misreported to the user as "everything else loaded normally."
Walk through what happens if the recovery loop drops one member successfully (dropped now has 1 entry) and then hits an unrecoverable fault on the next attempt:
- Here (
NotSupportedException/ArgumentException) or at line 380 (member is null || without is null, e.g. aJsonExceptionwhosePathdoesn't match a top-level member) — both returnnew SettingsObjectRead<T>(SettingsFileState.Unreadable, null, ..., dropped). Valueisnull, butUnreadableMembers(dropped) is non-empty.
That's the one combination the type's own doc comment (lines 66-71 above) says shouldn't happen: "Value is non-null for Readable and for a file that was read after DROPPING members... UnreadableMembers is what separates the two." Here UnreadableMembers is non-empty but Value is still null.
Downstream, ViewerSettingsFile.Load does read.Value is null ? read with { Value = new T() } : read; — so every setting reverts to defaults, not just the ones named in dropped. But MainWindow.NoteUnreadableSettingsFile routes any file with members.Count > 0 into _unreadableSettingsValues, producing the dialog text "These individual settings could not be read and are at their defaults for this session. Everything else in their file loaded normally." — which would be false in this case; nothing loaded normally.
Given ViewerAppSettings/ViewerPreferences are currently all bool/int/string/List<string>, only JsonException is realistic today, so this looks unreachable in practice right now. But it's a latent trap: the first property that needs a converter capable of throwing ArgumentException/NotSupportedException on bad data (an enum, DateTime, etc.) — or any document-shape fault that resurfaces after a member has already been dropped — would silently reset the whole file while telling the user only a few named settings were lost.
Worth either (a) not entering the _unreadableSettingsValues branch when Value is null despite dropped being non-empty, or (b) having these fallback branches keep whatever partially-recovered value is available instead of discarding it, so the contract documented above actually holds.
There was a problem hiding this comment.
Correct, and fixed in 9d42f20 — thank you, this was the sharpest possible place to catch it.
You are right that it is unreachable today (ViewerAppSettings and ViewerPreferences are all bool/int/string/List<string>), and that is the argument for pinning it rather than leaving it: the first property needing a converter that throws ArgumentException — an enum, a DateTime — makes it reachable with no warning, and the failure is a confident lie rather than a crash.
I took (a) rather than (b), at the source rather than at the dialog. (b) is not actually available: at the point of an unattributable bail there is no partially-recovered value to keep, because the attempt that would have produced one is the attempt that just failed. So the honest answer is that the file is not partially recoverable, and the named subset is worth nothing there anyway — it is a subset of "all of them".
One Unreadable<T> helper is now the only way to build a whole-file failure and it carries no member list, so the invariant holds for every caller instead of being a rule each caller has to remember. Fixing it in MainWindow would have left the next reader of SettingsObjectRead believing a doc comment that was false.
It cost nothing to report, because the message a whole-file failure carries is now the FIRST failure’s description, kept for the purpose — the only one whose line and position are against the file the user actually has, since every later attempt runs over a re-serialized document. (That is the same trap a5c4e6f took the position off a member for: three bad members on lines 2, 3 and 4 reported "line 2, position 22", then "line 1, position 30" and "line 1, position 14".)
APartialRecoveryThatCannotFinish_ReportsNoMembersAtAll reproduces your scenario with a local type — [JsonPropertyName("weird.name")], which System.Text.Json reports as $['weird.name'] — since no viewer setting has such a name today. Verified it goes red against this branch without the fix and green with it, with the other eleven cases unmoved either way.
Review summaryReviewed this against the T-SQL/C# style guide, Lite/Darling parity, correctness, security, and performance. This PR is entirely C# ( Parity: No drift. Lite's settings loader ( Correctness: The retry-and-drop loop is careful about the cases that matter most:
Left one inline comment on a genuine (if currently unreachable) contract gap: the recovery loop can return Security: No injection surface — this only touches the user's own local Performance: Negligible. Settings files are small (~80 keys); worst case is a bounded number of re-parses (capped at Note: I wasn't able to run |
… again Review finding, and a correct one. The recovery could drop one member successfully and THEN meet a fault it could not attribute -- a path like $['weird.name'], which System.Text.Json bracket-quotes for a property name that is not a bare identifier. Every such bail returned a null value while still carrying the member list it had collected on the way. That is the one combination SettingsObjectRead's own doc comment said could not happen, and the consequence lands exactly where this issue is trying to stop one. ViewerSettingsFile.Load substitutes defaults for a null value, so EVERY setting in the file reverts -- and MainWindow routes on the member list, so the dialog would have said "these settings could not be read... everything else in their file loaded normally" about a file where nothing loaded at all. A confident lie rather than a crash, in the sentence the whole PR exists to make true. Unreachable today with ViewerAppSettings and ViewerPreferences, which are all bool / int / string / List<string> -- which is what made it latent rather than live, and is the argument for pinning it rather than leaving it. The first property that needs a converter capable of throwing ArgumentException, an enum or a DateTime, makes it reachable with no warning at all. Fixed at the source rather than at the dialog: one Unreadable<T> helper is now the only way to build a whole-file failure and it carries no member list, so the invariant -- UnreadableMembers is non-empty if and only if Value is non-null -- holds for every caller instead of being a rule each caller has to remember. The named subset was worth nothing there anyway: it is a subset of "all of them". Discarding it costs nothing else, because the message a whole-file failure reports is now the FIRST failure's description, kept for the purpose. That is the only one whose line and position are against the file the user actually has; every later attempt runs over a re-serialized document, which is the same trap the previous commit took the position off a member for. The regression test reproduces the reviewer's scenario with a local type, since no viewer setting has such a name today. It fails against this branch without the fix and passes with it, and the other eleven cases are unmoved either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| /// answer.</para> | ||
| /// | ||
| /// <para><b>The invariant, because a caller reads one field and believes the other:</b> | ||
| /// <c>UnreadableMembers</c> is non-empty if and only if <c>Value</c> is non-null. Review found the |
There was a problem hiding this comment.
Minor doc-precision nit: the stated invariant is not actually "if and only if."
UnreadableMembers non-empty ⇒ Value non-null does hold (enforced by Unreadable<T> always passing null for members). But the reverse doesn't: a fully Readable file (no members dropped) has Value non-null and UnreadableMembers null — so "Value non-null" does not imply "UnreadableMembers non-empty."
Given this file's own culture of comments that are measured rather than assumed, worth tightening to the one direction that's actually guaranteed, e.g.: "UnreadableMembers non-empty implies Value non-null (the reverse does not hold — a fully Readable file has a non-null Value and no members)." Otherwise a future Debug.Assert written against the literal "iff" wording would fire on the ordinary success path.
There was a problem hiding this comment.
Right, and worth the edit rather than worth waving through. The forward direction holds and is enforced by Unreadable<T>; the reverse does not, because an ordinary Readable file has a non-null Value and no members at all.
The reason it matters is the one you gave: a Debug.Assert written against the literal "iff" would fire on the happy path — every successful load in the viewer — and whoever wrote it would be right to trust the comment. A comment that overstates is the same defect as a message that overstates, one level down, which is the whole subject of this PR.
Fixed: it now states the one guaranteed direction, with the reason the reverse is false written in place so nobody tightens it back, and the test's summary says the same thing and names ReadObject_SaysNothingAboutAHealthyFile as the case covering the other direction.
Review summaryThis is a pure C# change ( Correctness. Traced
Lite/Darling parity. No drift. Security. No new file/network/process surface — same fixed Performance. Bounded retry loop ( No functional bugs found. One minor doc-wording nit posted inline. |
Review nit, and correct. I wrote "UnreadableMembers is non-empty if and only if Value is non-null". The forward direction holds and is enforced. The reverse does not: an ordinary Readable file has a non-null Value and no members at all, so "Value non-null" implies nothing about the member list. Worth the edit rather than worth waving through, because of what the wrong wording invites. A Debug.Assert written against the literal "iff" would fire on the happy path -- every successful load in the viewer -- and the reader who wrote it would be right to trust the comment. This file's comments are load-bearing on purpose; one that overstates is the same defect as a message that overstates, one level down. Now: a non-empty UnreadableMembers always comes with a non-null Value, one direction, with the reason the reverse is false stated in place so nobody tightens it back. The test's summary says the same thing and names the case that covers the other direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| ? new SettingsObjectRead<T>(SettingsFileState.Unreadable, null, | ||
| "the file holds the JSON literal null rather than the settings it should") | ||
| : new SettingsObjectRead<T>(SettingsFileState.Readable, value, null); | ||
| if (JsonNode.Parse(text) is not JsonObject root || !root.Remove(member)) |
There was a problem hiding this comment.
WithoutMember re-parses with JsonNode.Parse(text) using default JsonDocumentOptions, but the member-drop retry in DeserializeWithMemberRecovery deserializes the same text with the caller's own JsonSerializerOptions options (JsonSerializer.Deserialize<T>(current, options)).
Those two option sets aren't always equivalent — JsonSerializerOptions.AllowTrailingCommas / ReadCommentHandling have no effect on this JsonNode.Parse call, which always runs with trailing-commas-disallowed / comments-disallowed. If a future caller passes ReadObject<T> a comment- or trailing-comma-tolerant options (this codebase already does that elsewhere, e.g. ViewerSettings.cs's s_jsonOptions and the Darling service's config readers all set AllowTrailingCommas = true + ReadCommentHandling = Skip), then for any file that relies on that leniency, the first JsonSerializer.Deserialize<T>(current, options) call would succeed right up to the bad member, but the retry's JsonNode.Parse(current) here would throw on the trailing comma/comment, return null, and DeserializeWithMemberRecovery would bail out to a full-file Unreadable instead of doing the intended per-member recovery.
It's a silent regression only for that combination — since it falls back to the old (pre-#2456) all-or-nothing behavior rather than crashing, and none of today's three viewer stores use permissive JsonSerializerOptions, so it's dormant right now. But ReadObject/DeserializeWithMemberRecovery are written as general-purpose, reusable Common APIs (per the class doc comment), so this is a trap for the next caller. Worth threading options through, e.g.:
var docOptions = new JsonDocumentOptions
{
AllowTrailingCommas = options?.AllowTrailingCommas ?? false,
CommentHandling = options?.ReadCommentHandling ?? JsonCommentHandling.Disallow,
MaxDepth = options?.MaxDepth ?? 0,
};
...
if (JsonNode.Parse(text, documentOptions: docOptions) is not JsonObject root || !root.Remove(member))There was a problem hiding this comment.
Correct, and it lands on the load-bearing claim rather than beside it — fixed in 6ac8c8b.
I argued this recovery is "one judge, not two": every attempt being JsonSerializer.Deserialize<T> with the caller's own options, so the two reads cannot disagree about what unreadable means. That was true of the deserializes and not of the read between them, which is the one place the property could quietly become false. Thank you for going and looking there.
Premise checked before acting on it: ViewerSettings.cs:118-123 really does set ReadCommentHandling = Skip + AllowTrailingCommas = true (darling.json is JSONC), and seven call sites in the repo set those options. So the next caller of a general-purpose Common API is as likely to be lenient as strict, and the failure would be silent because it falls back to pre-#2456 behaviour rather than crashing.
Took your suggested mapping including MaxDepth, with the reason written at WithoutMember where the next person is tempted to simplify it back to a bare Parse.
Two tests rather than one. TheRecoveryBorrowsTheCallersLeniency_SoBothOfItsReadsAgree is your scenario — a file with a comment AND a trailing comma AND one bad member, read with lenient options, which must recover just that member. TheSameFileUnderStrictOptionsIsStillADocumentFault is the control: the same file under default options is still all-or-nothing. Without it the first would pass on a reader made permanently lenient, which is a worse defect in the other direction — every strict caller silently accepting JSONC.
Verified the new case goes red against this branch without the fix (Value cannot be null) and green with it, with the other thirteen unmoved either way.
ReviewThis is a C# change to the Darling viewer's settings-file recovery (no T-SQL touched here, so the collector-style conventions in CONTRIBUTING.md don't apply). Walked Correctness: traced the retry loop (member-name extraction via the anchored Lite/Darling parity: Security: no new file/process/network surface; JSON handling stays within Performance: the retry loop is bounded ( |
Review finding, and it lands on the load-bearing claim rather than beside it. I argued this recovery is "one judge, not two" -- every attempt being JsonSerializer.Deserialize<T> with the caller's own options, so the two reads cannot disagree about what unreadable means. That was true of the deserializes and not of the read between them: dropping a member re-parses the text, and a bare JsonNode.Parse runs with trailing commas and comments DISALLOWED however lenient the caller is. So for a caller that permits them, the deserialize reaches the bad member and the re-parse throws on the very same text -- and the recovery bails to whole-file Unreadable, giving up the per-member repair on exactly the files it was written for. Silent, because it falls back to the pre-#2456 behaviour instead of crashing. Not hypothetical in this repo. darling.json is JSONC, ViewerSettings reads it with AllowTrailingCommas and ReadCommentHandling.Skip, and seven call sites here set those options. None of the three viewer stores does today, which is what made it dormant -- and ReadObject is a general-purpose Common API whose whole point is the next caller, who is as likely to be lenient as strict. The mapping is explicit now (AllowTrailingCommas, CommentHandling, MaxDepth), with the reason written where the next person is tempted to simplify it back to a bare Parse. Two tests: the lenient file recovers its one bad member, and the SAME file under strict options is still a document fault -- the second being the control that stops the first passing on a reader made permanently lenient, which would be a worse defect in the other direction. Verified the new case goes red against this branch without the fix and green with it, with the other thirteen unmoved either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| /// <see cref="DeserializeWithMemberRecovery{T}"/>. | ||
| /// </summary> | ||
| private static readonly Regex s_topLevelMember = | ||
| new(@"^\$\.([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.CultureInvariant); |
There was a problem hiding this comment.
Minor/low-severity edge case: s_topLevelMember only matches ASCII identifier characters ([A-Za-z_][A-Za-z0-9_]*). If a top-level member name contains a non-ASCII character (e.g. a diacritic), the regex will still match the ^\$\. prefix but capture only the ASCII-matching prefix of the name (e.g. ServerNaïve → ServerNa), then WithoutMember will call root.Remove("ServerNa"), which returns false since no such key exists, and the read correctly falls back to whole-document Unreadable rather than doing anything wrong. So this fails safe today — no data-loss risk — but it's worth a comment (or widening the char class to \p{L}) since none of the three viewer settings types currently has a non-ASCII property name, and a future one added via [JsonPropertyName(...)] would silently lose per-member recovery for that one setting without any test catching it.
There was a problem hiding this comment.
Right that it fails safe, and right that it was worth raising — fixed in f6c180d. Measuring System.Text.Json rather than reasoning about it made the case both wider and much more ordinary than the diacritic:
$.ServerNaïve dot form
$.サーバー dot form
$.with-dash dot form
$.dollar$sign dot form
$['with space'] bracket form
STJ writes the DOT form for every name that needs no escaping and bracket-quotes only where it must. So the identifier class was capturing with out of with-dash — and a hyphenated key is an ordinary thing to find in a settings file, not an exotic one. Your own example turned out to be the least likely of the four to show up.
Took the segment to the first . or [ rather than widening to \p{L}, since that is what the path grammar actually delimits on and it covers $ and - too, which \p{L} would not.
The boundary that must not move with it has its own test — ABracketQuotedPathIsStillRefused — because the reader has no business editing a document it cannot name a member in unambiguously, and widening the capture is exactly the change that would erode that.
Pinned with the hyphen and the dollar sign rather than the diacritic, those being the two a real settings file is likely to contain. Verified the new case goes red against this branch without the fix and green with it, with the other fifteen unmoved.
Review summaryScope: this PR touches only the Darling viewer and Lite/Darling parity: No drift. Confirmed by grepping call sites — Lite only calls Correctness: Traced the retry loop in
Security: No new attacker-controlled surface — these are local per-user JSON config files the app itself writes; no secrets are stored in the recovered object types (SMTP password / webhook URLs live in Credential Manager, confirmed by grep). No format-string or injection concerns in the new Style: New public members ( Caveat: I could not run an actual Nothing blocking found. |
…racter Review raised the ASCII identifier class as a diacritic edge case. Measuring System.Text.Json rather than reasoning about it made the case both wider and much more ordinary: STJ writes the DOT form for every property name that needs no escaping, and bracket-quotes only where it must. $.ServerNaive (with a diacritic) dot form $. (Japanese) dot form $.with-dash dot form $.dollar$sign dot form $['with space'] bracket form So the narrow class captured "with" out of "with-dash" -- and a hyphenated key is an ordinary thing to find in a settings file, not an exotic one. Review's own example was the least likely of the four to appear. It failed safe, exactly as review said: WithoutMember looks the captured name up, finds no such member, and the read falls back to whole-file Unreadable. Nothing was ever at risk. What it lost was the per-member recovery, silently, for precisely the settings someone had to name by hand with JsonPropertyName -- and no test would have noticed, because every viewer setting today is a plain C# identifier. The segment now runs to the first '.' or '[', which is what the path grammar actually delimits on, and the boundary that must NOT move with it has its own test: a bracket-quoted path is still refused, because the reader has no business editing a document it cannot name a member in unambiguously. Pinned with the hyphen and the dollar sign rather than the diacritic, those being the two a settings file is likely to contain. Verified the new case goes red against this branch without the fix and green with it, with the other fifteen unmoved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed. This is a C# change to Darling's viewer settings loading ( Correctness — traced the retry loop in
Lite/Darling parity — no drift here. Security/perf — no concerns. Purely local JSON file parsing (no network/DB/process boundaries), the retry loop is capped ( One low-severity note, not a blocker: Nothing else stood out — the test suite already covers the edge cases I went looking for (multi-member drops, document-vs-member fault boundary, hand-named/bracket-quoted members, the partial-recovery-then-unattributable-fault combination, the lenient-options seam). |
Fixes #2456.
The gap, and why Lite's answer does not port
The viewer's startup dialog could name the file and the parse position and nothing else. That is not a wording problem: it round-trips a whole object, so when
JsonSerializer.Deserialize<T>threw there was no property in existence to report — and every setting in the file reverted, not just the bad one.Option 1 — per-property reads, the shape #2444 gave Lite — is wrong here, and not for the reason the issue weighed. The issue framed it as "is the settings object stable enough to be worth eighty-odd hand-written reads". The decisive problem is smaller and worse than the typing:
Lite's
settings.jsonis built key by key on both sides — #2441 made all ten writers mutate oneJsonObjectopened once — so a per-key read is symmetric with a per-key write. These files are a whole-object round trip on both sides. Converting only the READ half to hand-written properties breaks that symmetry, and a property added toViewerAppSettingsafterwards would still be serialized on save and silently never read back. That is a worse defect than the one being fixed, and an invisible one; nothing in the compiler or the tests would notice. So the answer had to keep the round trip.What
JsonExceptionactually carries, measuredThe issue said
JsonException.Path"names one member at best". Measured against the shipped BCL rather than assumed, across every fault class these files can produce:PathLineNumber"AlertCpuThreshold": "ninety"$.AlertCpuThreshold"McpEnabled": "true"$.McpEnabled"SmtpServer": 5$.SmtpServer"AlertExcludedDatabases": 5$.AlertExcludedDatabases"AlertExcludedDatabases": ["a", 2]$.AlertExcludedDatabases[1]"AlertCpuThreshold": 99999999999999$.AlertCpuThreshold$$$$.McpEnabledonlySo it is stronger than "often carries":
Path == "$"is exactly the discriminant between the document is broken and one setting is the wrong shape, every time. And the last row is the limit that matters — one member, never the set.There is a small finding in that.
SettingsFileGuard.Describehad the member name in its hand and was deliberately throwing it away:WithoutPathSuffixcutsPath: … | LineNumber: …off the message because it duplicates the line and position it has already rendered — andPathis the one part of that suffix that does not.The fix: name it, drop it, run the same deserialize again
ReadObject<T>now names the member the deserializer stopped on, removes it from the document, and retries. Two consequences, and the second is the issue's title:trycould never have delivered because it threw and stopped reading;Pathalone cannot, because the retry is what surfaces the second and third.It is one judge, not two. #2213 spent a review round on a two-pass classify whose second pass judged the file by a different standard from the first. Every attempt here is
JsonSerializer.Deserialize<T>with the caller's own options over the caller's own type; the only thing that changes between attempts is that one member is gone.Review found the one place that property could quietly become false, and it is worth naming rather than quietly fixing: dropping a member re-parses the text, and a bare
JsonNode.Parseruns with trailing commas and comments disallowed however lenient the caller is. For a permissive caller the deserialize would reach the bad member and the re-parse would throw on the same text, so the recovery would bail to whole-fileUnreadable— silently, because that is the pre-#2456 behaviour rather than a crash. Not hypothetical here:darling.jsonis JSONC,ViewerSettingsreads it withAllowTrailingCommas+ReadCommentHandling.Skip, and seven call sites in the repo set those options. The re-parse now borrows the caller's leniency explicitly, and two tests pin both directions — the lenient file recovers its one member, and the same file under strict options is still a document fault, which is the control that stops the first passing on a reader made permanently lenient.The member name is taken from the path's first segment, up to the first
.or[— which is what the grammar delimits on. Review raised a narrower identifier match as a diacritic edge case; measuring STJ made it wider and more ordinary: the dot form is used for every name needing no escaping, so$.with-dashand$.dollar$signarrive that way alongside$.ServerNaïveand$.サーバー, and only$['with space']is bracket-quoted. A hyphenated key is an ordinary thing to find in a settings file. It failed safe either way — the member lookup finds nothing and the read falls back — but it silently lost per-member recovery for exactly the settings someone had to name by hand.ABracketQuotedPathIsStillRefusedis the boundary that must not move with it.Only a top-level member of a root JSON OBJECT is ever dropped, and that restriction is the load-bearing half rather than a simplification. The viewer's server registry is a root array, and a bad entry's path is
$[1]— "drop the element that would not deserialize" there means silently deleting a server the operator added, which is the data loss #2434 exists to prevent wearing a repair's clothes.ReadObject_LeavesAnArrayRootedRegistryAllOrNothingis the control for exactly that mistake, andViewerServerStoresays why in place, because a future widening of the recovery is the obvious next edit.The state stays
Unreadablefor a partially recovered file rather than becoming some third thing, soPermitReplacestill copies the original aside before the next save replaces it. The dropped member's original text exists nowhere else, and the very next save writes the recovered object over it; relaxing the permit would have destroyed the one setting the user actually got wrong, with no copy — strictly worse than dev, where nothing was recovered and everything was preserved. It also costs no new enum member, so nothing in Lite or the service has to learn a fourth state.The dialog gets two paragraphs, because there are two facts
A member's line is missing from that second paragraph on purpose, and finding out why is the one thing probing the shipped reader caught that reading it did not. Each retry runs over the document with the previous member removed, and removing it re-serializes — so from the second member onward the exception's position describes the re-serialized text. Measured on a file whose three bad members sit on lines 2, 3 and 4: the first reported
line 2, position 22, correctly, and the other two reportedline 1, position 30andline 1, position 14. A position that confidently names the wrong line is worse than none, in a dialog whose whole job is to send someone to the right place in their file — and the member's NAME is the better locator anyway. The document fault keeps its position, where the parse position is the only locator there is, and one test asserts both halves so a rule applied to both cannot quietly take it away.One list covering both would have to overstate whichever case it was not describing, and the issue is explicit that an overstated capability is worse than an admitted gap. The second paragraph says the settings are named, not that the list is exhaustive of everything wrong with the file — because a document fault after a recovered member is still a document fault, and lands in the first paragraph.
The contract is enforced at the source, and review found the case that broke it. a non-empty
UnreadableMembersalways comes with a non-nullValue— one direction, because the reverse is false: an ordinaryReadablefile has a value and no members at all. The recovery could drop one member successfully and then meet a fault it could not attribute — a path like$['weird.name'], which System.Text.Json bracket-quotes for a property name that is not a bare identifier — and every such bail returned a null value while still carrying the member list collected on the way.ViewerSettingsFile.Loadsubstitutes defaults for a null value, so every setting reverts, whileMainWindowroutes on the list and would have said "everything else in their file loaded normally" about a file where nothing loaded at all. Unreachable today (these types are allbool/int/string/List<string>), which is the argument for pinning it: the first property needing a converter that throwsArgumentExceptionmakes it reachable with no warning, and the failure is a confident lie rather than a crash. OneUnreadable<T>helper is now the only way to build a whole-file failure and it carries no member list, so the invariant holds for every caller rather than being a rule each caller has to remember.All three stores gained
LastLoadUnreadableMembers, including the registry where it is always empty — that is the #2439 lesson taken literally: a guard listing two of the three stores is the scenario-shaped fix that issue was written to prevent.Verification
Darling.Teststargetsnet10.0-windowsand cannot run on macOS, so the committed test file was compiled into anet10.0xUnit-shim harness against the realViewerSettingsFile.cs,ViewerAppSettings.cs,ViewerPreferences.cs,ViewerServerStore.cs,ViewerServerEntry.csandViewerLogger.cs— compiled, not transcribed — plus real project references toPerformanceMonitor.CommonandPerformanceMonitor.Notifications.UnreadableMembersdoes not exist on dev, so the subset asks the same questions throughValue,Problemand reflection, which dev has. The six that fail on dev are: one bad setting costing the others, the problem naming the settings, a bad list element costing its own setting only, the store keeping the rest of the file, the three stores answering the member question, and the dialog.The four that pass on both sides are the controls, and they are what a wrong fix breaks first: a document fault stays all-or-nothing and still says where the parse stopped; the array-rooted registry is never partially recovered; a healthy file is silent and untouched; and the unreadable original is still copied aside before a save replaces it.
Both review-driven cases were additionally checked the way the house asks — revert the fix, watch it go red, put the fix back, with every other case unmoved either way, so they are tests rather than assertions that happen to hold.
APartialRecoveryThatCannotFinish_ReportsNoMembersAtAll,TheRecoveryBorrowsTheCallersLeniency_SoBothOfItsReadsAgreeandAHandNamedMemberRecoversLikeAnyOthereach go red alone against this branch without their fix.The two landed suites this change sits under were run against the branch as well, since
ReadObjectis shared: all 13 of #2439'sViewerSettingsFileGuardTestspass (includingLoad_ReportsUnreadable_WhenAValueHasTheWrongShape, the one most exposed to the recovery), and all 9 of Lite'sSettingsFileGuardTestspass — Lite reaches the guard throughRead/RootForWrite, which this PR does not touch.Whole solution builds, 0 errors. CHANGELOG deliberately untouched — #2395 is an open release-prep PR that owns that file for 3.5.1.
#2453 merged while this was open, and the two types coexist
#2453 landed on
devand broughtPerformanceMonitor.Common.SettingsValueProblem—(Key, Problem)— for Lite's per-key reader. This PR addsSettingsMemberProblem—(Member, Problem)— for the viewer's whole-object reader. Different names, different files, no collision; rebased onto88fe948fand re-verified after it merged: whole solution builds 0 errors, and all 31 cases green — this PR's 12, plus all of #2439'sViewerSettingsFileGuardTestsrun against the rebased branch becauseReadObjectis shared.Whether they should become one type is a real question and deliberately not answered here. Unifying them would make the two readers look interchangeable when the whole finding above is that they are not — one is symmetric with a per-key write, the other with a whole-object one — and the decision is better made now that both can be read side by side on
dev.Deliberately not in scope
The
.unreadable-<timestamp>copy is still the only route back to a dropped setting's original text. A recovery that also told the user the value it could not read — rather than only the type it could not read it as — would be a better message, and it is oneElement.GetRawText()away inSettingsValue's Lite twin; it is not here because it means wideningSettingsMemberProblemand deciding how much of a hand-pasted thousand-character value belongs in aMessageBox. Worth its own issue if the dialog gets read in anger.🤖 Generated with Claude Code