Make one badly-shaped setting cost one setting (#2444) - #2453
Conversation
|
Reviewed the diff (Lite/App.xaml.cs, the new PerformanceMonitor.Common/Services/SettingsValueReader.cs, and the two Lite.Tests files). No T-SQL in this PR, so the CONTRIBUTING.md T-SQL section doesn't apply; checked against the C# style section instead. Correctness — verified, no bugs found. I independently re-derived every one of the ~50 clamped/unclamped call sites in Regex self-test claims verified. Confirmed by hand that none of the new/rewritten XML-doc comments in Lite/Darling parity. Minor, non-blocking nit: Test coverage ( |
| /// <summary> | ||
| /// The value as an int, clamped into <paramref name="min"/>..<paramref name="max"/>. Read as Int64 first | ||
| /// on purpose: a number too large for an <c>int</c> is a value out of range, which the clamp is there to | ||
| /// handle, not a value of the wrong shape. | ||
| /// </summary> | ||
| public int Int(int fallback, int min, int max) => | ||
| Element.ValueKind == JsonValueKind.Number && Element.TryGetInt64(out var number) | ||
| ? (int)Math.Clamp(number, min, max) | ||
| : Reject(fallback, "a whole number"); | ||
|
|
||
| /// <summary>The value as a long, clamped into <paramref name="min"/>..<paramref name="max"/>.</summary> | ||
| public long Long(long fallback, long min, long max) => | ||
| Element.ValueKind == JsonValueKind.Number && Element.TryGetInt64(out var number) | ||
| ? Math.Clamp(number, min, max) | ||
| : Reject(fallback, "a whole number"); |
There was a problem hiding this comment.
Minor edge case: Int(fallback, min, max) and Long(fallback, min, max) read via TryGetInt64 specifically so an out-of-range number clamps to the bound instead of being reported as a shape problem (per the doc comment above Int and the AnOutOfRangeNumberClampsToTheBound_RatherThanWrappingNegative test). That holds only up to long range. A value beyond long.MaxValue/below long.MinValue (e.g. "alert_low_disk_threshold_gb": 99999999999999999999) makes TryGetInt64 return false, so it falls into the Reject(fallback, "a whole number") branch instead of clamping to max — the startup dialog would tell the user this "holds a JSON number where a whole number belongs," which is a confusing message for a value that plainly is a whole number, just an extreme one. Not a practical concern for most fields, but worth either clamping via decimal/string-magnitude sniffing or at least acknowledging the boundary in the comment so it doesn't read as an absolute guarantee.
|
Reviewed the diff (App.xaml.cs, the new SettingsValueReader/SettingsReader in PerformanceMonitor.Common, and the Lite.Tests additions). Correctness. The per-key rejection model is sound: Test coverage. Lite/Darling parity. This is Lite-only, which is correct: Darling's config loading ( Security. No new file/network/process surface; webhook/credential handling paths are unchanged other than routing through the new shape-checked reads, and secrets still load unconditionally before any settings.json parsing as before. No blocking issues found. |
1487079 to
4d4ad04
Compare
|
Addressed the inline finding on Fixed by clamping by SIGN rather than by value. A number beyond Int64 in either direction cannot be inside any range a caller can express, so the only question left is which end — read off the raw token rather than through a The unclamped Two tests pin both sides ( Both branches have been rebased onto |
| public int Int(int fallback, int min, int max) => | ||
| Element.ValueKind != JsonValueKind.Number ? Reject(fallback, "a whole number") | ||
| : Element.TryGetInt64(out var number) ? (int)Math.Clamp(number, min, max) | ||
| : IsNegative() ? min : max; | ||
|
|
||
| /// <summary>The value as a long, clamped into <paramref name="min"/>..<paramref name="max"/>.</summary> | ||
| public long Long(long fallback, long min, long max) => | ||
| Element.ValueKind != JsonValueKind.Number ? Reject(fallback, "a whole number") | ||
| : Element.TryGetInt64(out var number) ? Math.Clamp(number, min, max) | ||
| : IsNegative() ? min : max; |
There was a problem hiding this comment.
Fractional numbers silently clamp to the bound with no Problems entry — for every clamped int/long setting.
Element.TryGetInt64 returns false for any JSON number that isn't a pure integer token — not just ones that overflow Int64. A hand-typed 30.0 or 5.5 is a syntactically valid JSON number (ValueKind == Number), but TryGetInt64 fails on it the same way it fails on 99999999999999999999, and the fallthrough treats every TryGetInt64 failure as "magnitude out of range":
: Element.TryGetInt64(out var number) ? (int)Math.Clamp(number, min, max)
: IsNegative() ? min : max;So e.g. { "analysis_timeout_seconds": 30.0 } (meant to be 30) silently becomes 600 (the max bound), { "alert_cooldown_minutes": 5.5 } silently becomes 120, etc. — without adding anything to Problems, so it never reaches ReportBadSettingValues/the startup dialog/the log. This affects every setting that goes through the clamped Int(fallback, min, max) or Long(fallback, min, max) overloads (connection_refire_minutes, analysis_timeout_seconds, alert_cooldown_minutes, ag_redo_queue_alert_kb, …).
This directly undercuts the point of #2444: a bad value is supposed to be named, not silently substituted with a value the user never asked for and never sees flagged. The unclamped Int(fallback) overload above doesn't have this problem — it goes through RejectAsOutOfRange, which at least records a Problems entry (even if "out of range" is a slightly odd description for "not a whole number").
Suggest checking TryGetInt64's failure reason (e.g. try TryGetInt32-style shape validation first — does the raw text look like an integer? — or just fall back through Reject(fallback, "a whole number") when the token isn't purely digits/sign) before assuming the only way TryGetInt64 fails is overflow. Worth a test case alongside ANumberBeyondInt64_StillClampsToABound for something like { "n": 5.5 } asserting Problems is non-empty.
ReviewScope: C#/WPF only ( Lite/Darling parity: No drift. The reader intentionally lives in Bug found: left an inline comment on Everything else — the |
|
Fixed, and thank you — this was a defect I introduced one commit earlier while fixing the previous finding in the same place, which is the worse kind. You were exactly right about the mechanism: Reading the remainder as a double tells all three cases apart in one step: vast values fall outside any bound and land on one, fractional values truncate into range. The On the suggestion to Two tests, and both were run against the previous commit to confirm they are red there: |
| /// <summary> | ||
| /// Records this key against its reader and hands back the caller's fallback, so every reader above is one | ||
| /// expression. The message names the kind that WAS there, in the same phrasing the MCP settings loader | ||
| /// already uses, because "it holds a JSON string" is what lets someone find the line in their file. | ||
| /// </summary> | ||
| /// <summary> | ||
| /// Records a value that IS a number and still cannot be used, naming which of the two problems it has — | ||
| /// "holds a JSON number where a whole number belongs" is a nonsense sentence about a number, and this one | ||
| /// reaches a dialog. The token is truncated because a hand-edited file is where a thousand-digit number | ||
| /// comes from. | ||
| /// </summary> |
There was a problem hiding this comment.
Doc-comment leftover from the last refactor commit: this <summary> block ("Records this key against its reader and hands back the caller's fallback...") originally described Reject<T> (now at line 237), but a second <summary> for the new RejectAsUnusableNumber<T> was inserted directly after it instead of replacing it. The two summaries now both attach to RejectAsUnusableNumber, and Reject<T> below is left with no doc comment at all. Not a functional bug, just orphaned documentation — worth folding the first block into (or moving it above) Reject<T> at line 237.
|
Reviewed. This is a C#/WPF change (no T-SQL surface), scoped to Correctness — The core fix (per-key shape checking instead of one giant Parity — No Darling counterpart needed here: Darling's Test coverage — Left one inline nit: a leftover/duplicated XML doc comment in No security, injection, or performance concerns — this is local file/JSON parsing with no network or process boundary involved. |
App.LoadAlertSettings wrapped all eighty-seven reads in a single try. #2425 took the parse out of it, so a malformed document is now reported instead of silently reverting everything, but the value half was untouched: a key holding a string where an int belongs threw on its own Get* call and abandoned every read after it. Which settings survived therefore depended on where the bad key happened to sit in the file -- one wrong value near the top cost almost everything, the same value near the bottom cost almost nothing -- and the ordering it turned on is an implementation detail of this method's line order. Nothing about any of that was visible. Eighty-seven try blocks would work and would be unreadable, so the reads go through a SettingsReader that checks ValueKind before it calls a getter and RECORDS the key it could not read. That is the call McpSettings.Load already makes, for the reason it gives on the line: a catch around GetBoolean is what turned a quoted "true" into a silently disabled endpoint, and a hand-edited file is exactly where a quoted boolean comes from. It reports the whole set rather than the first. Someone who edited one line by hand probably has one mistake; someone who pasted a block out of settings.sample.json has several, and stopping at the first means they fix it, restart, and discover the next one, several times over. The old code could not have done this even in principle -- it threw on the first bad value and never reached the rest -- so "which key" and "all of them" are the same fix, not two. The reads keep the call shape `<reader>.TryGetProperty("key", out v)` and that is load-bearing rather than stylistic. #2418's SettingsSampleTests extracts the documented key list by regexing that method NAME out of App.xaml.cs and requires two-way symmetry with settings.sample.json; a helper spelled any other way makes all eighty-seven keys vanish from the extraction, leaves both symmetry tests passing on what is left, and lets the sample drift exactly the way #2418 was filed about. PR #2428 hit precisely that and had to read back through JsonDocument. So SettingsReader's method carries the name deliberately, the extractor needed no change at all, and the next commit adds the self-test that proves the extraction really does still see this shape. Two smaller things fall out of it. The clamps move off the eighty-seven call sites that repeated them inline, and two of those forms -- (int)Math.Max(0, v.GetInt64()) -- floored and then NARROWED, so a hand-typed value beyond int range wrapped negative and a bigger threshold became a smaller one; Int(fallback, min, max) clamps before it narrows. And alert_excluded_databases now filters its elements by kind the way the fleet-group list beside it already did, because elem.GetString() throws on a number in the array and inside the old single try that one element cost every setting after it too. The reader lives in PerformanceMonitor.Common beside SettingsFileGuard rather than in Lite. It is the same seam the Viewer's startup dialog needs for the same gap one level up -- it round-trips a whole object, so its deserialize fails before any property exists and it cannot say which settings were lost either -- and that is the next issue rather than this one. The value fault also becomes visible. It logged and stopped there, so a user whose thresholds had silently reverted had no signal unless they went looking; #2425 had already decided for the document half that someone looking at an app which has forgotten its configuration should not have to find a log to learn why, and the same argument applies to a setting that reverted. What is new is that the message can name the keys and say that everything else loaded, which is what makes it worth a dialog rather than noise. It reuses the one startup modal #2425 added rather than opening a second: the two cannot both happen on one run, because a document that will not parse never reaches a value read. The catch stays, with no known cause left in it, so an unforeseen throw cannot take startup down -- and the comment now says what it still costs when it fires, because the reads are ordered and anything after a throw is still lost. That is the residue this removes for every case it can name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…controls SettingsSampleTests is the guard #2418 exists for, and until now its only exercise was whatever the tree happened to contain. That is enough to catch a key missing from the sample and not enough to catch the failure the previous commit was one refactor away from: an extraction that stops MATCHING makes both symmetry tests pass on an empty set, and the only thing standing between that and a green build was a floor of "at least fifty keys". So the extraction is split out to run over TEXT, and the comparison Sample_DocumentsEveryKeyTheLoadersRead makes is split out with it, so the self-tests exercise the real check rather than a re-implementation that could drift away from it. Three things are now pinned that were previously assumed: Both call shapes are seen -- the one the loaders used before #2444 and the one they use now -- and the check still BITES, because a synthetic sample missing one key names that key. A guard that extracts keys but no longer compares them is the same vacuous green as one that extracts nothing. The trap itself is measured rather than described. The shape PR #2428 tried, a read helper taking the key as an ordinary argument, is run through the same extraction and yields nothing. That is what makes SettingsReader's method name load-bearing instead of incidental, and it is why the paragraph about it is a measurement. And the name is asserted directly, so renaming it fails HERE with the reason attached rather than as an unexplained collapse of the key count somewhere else. The scoping contract gets its own case for the same reason. Each read belongs to the most recent *.json literal above it, which is how App.xaml.cs's servers.json and collection_schedule.json loaders stay out of this set without an exemption -- and how a loader moved above the settings.json literal would silently leave it. SettingsValueReadTests covers the loader. The position cases are position cases deliberately: a fixture with one bad key and nothing after it passes against the old code too, so only a bad key with good keys on BOTH sides can tell "this key fell back" from "this key and the rest of the file fell back". The rest pin the reader's policy -- every bad key reported rather than the first, an absent key never reported, an out-of-range number clamping to the bound rather than wrapping negative, and the line between a value of the wrong SHAPE, which the dialog is allowed to complain about, and a well-shaped string the caller does not recognise, which has always been ignored quietly and stays that way. Verification: Lite.Tests targets net10.0-windows and cannot run on macOS, so everything runnable was compiled into a net10.0 xUnit-shim harness against the real PerformanceMonitor.Common build, with the real App.xaml.cs and settings.sample.json as fixtures. 13 passed / 0 failed, including all four pre-existing SettingsSampleTests -- which is the statement that matters most here, because they are the guard the refactor was most likely to break, and they are green with two-way symmetry over all eighty-nine documented keys and empty exemption sets. Against dev the same file runs 6 passed / 0 failed with the one test that needs the new type removed; the extraction self-tests are guards rather than regression tests and pass on both sides by design. The two loader-level cases need WPF and can only run on CI, so their premise was measured instead of assumed. On dev, alert_cpu_threshold is read with v.GetInt32() 12,389 characters before analysis_timeout_seconds inside a single 13,902-character try with no inner try, and GetInt32 on a JSON string throws -- both measured from the shipped dev source and the shipped BCL, in the harness. That is exactly the two facts those cases assert the absence of. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review caught this and it is right. ReportBadSettingValues is called by both loaders, and LoadDefaultTimeRange runs first -- so a malformed default_time_range_hours wrote "every other setting in the file loaded normally" into the log before a single alert setting had been read. Momentarily false, and false in the direction that matters: the whole point of #2444 is that the reader can now say exactly what did and did not survive, so a line overstating its own scope undercuts the thing it is reporting. The line now claims only what its caller can see -- every other key THIS loader read was applied -- which is true for both callers and stays true if a third appears. The whole-file statement moves nowhere, because the dialog was already the only place entitled to make it: it is shown once, after both loaders have run, and it is what the user actually reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found the boundary the clamped readers stopped at, and it is a real one. They go through TryGetInt64 specifically so that a number too large for the target is a value out of RANGE -- which the clamp exists to handle -- rather than a value of the wrong shape. That reasoning holds up to Int64 and then falls over: 99999999999999999999 makes TryGetInt64 return false, so it fell into the wrong-shape arm and the startup dialog told the user their whole number "holds a JSON number where a whole number belongs". A nonsense sentence, and one this change put in a dialog rather than a log. A value beyond Int64 in either direction cannot be inside any range a caller can express, so the only question left is which end, and the sign answers it. Read off the raw token rather than through a double, because long.MaxValue does not survive that round trip and a bound near it would come back wrong in the one case this exists for. The unclamped read is the other side of the boundary and keeps reporting, because with no range there is nothing to put the number into -- but it reports it as out of range and names the value, which is a sentence about a number. The token is truncated at forty characters: a hand-edited file is where a thousand-digit number comes from, and this string reaches a dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…a bound Review caught this and it is a defect I introduced one commit ago, which is the worse kind. TryGetInt64 returns false for a number that is not a pure integer token as well as for one too big to hold, so 30.0 and 5.5 fail it exactly as 99999999999999999999 does. Choosing the bound by SIGN was right for the case I was looking at and silently turned analysis_timeout_seconds: 30.0 into 600, and alert_cooldown_minutes: 5.5 into 120 -- values the user never asked for, never saw flagged, and could not have found, because nothing was added to Problems and nothing reached the dialog. That is the failure #2444 exists to end, so relocating it into the fix would have been worse than leaving the original alone. Reading the remainder as a double tells the three cases apart in one step: the vast ones fall outside any bound and land on one, and the fractional ones truncate into range. Compared rather than clamped for the long overload, because a bound near long.MaxValue does not survive a round trip through a double. Truncating is the same species of silent adjustment the clamp already is, and it stays confined to the readers whose caller declared a range for exactly that purpose. The unclamped read has nowhere to put a number that will not fit, so it must not invent a range: it takes a value that is exact however it was written -- 30.0 is 30 -- and reports the rest, naming which of the two problems it has, because "holds a JSON number where a whole number belongs" is a nonsense sentence about a number and this one reaches a dialog. Both new cases were run against the previous commit and both are red there: the fractional one reports 600 where it should report 30, which is the reviewer's example reproduced exactly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DocCommentHygieneTests caught this, which is what it is for, and it is the defect I was writing a widened version of that rule about on another branch at the time. Anchoring the insert on `private T Reject<T>(...)` -- the signature line -- put the new helper between Reject's doc block and Reject, so the file carried two stacked summaries: XML docs take the LAST one, so tooling rendered RejectAsUnusableNumber correctly and Reject silently lost its documentation to a paragraph about a different method. The helper moves below rather than the stray text being deleted. Reject's block is the record of why the message is phrased the way the MCP settings loader phrases it, and #1745 found that seven of eight instances of this shape were a displaced block whose real member had been left undocumented -- a blind "remove the extra summary" would have destroyed it here too. Anchor C# inserts on the previous member's closing brace, not on the next member's signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b22a83b to
74dbd57
Compare
|
Reviewed. This PR is C#-only (Lite + PerformanceMonitor.Common) — no T-SQL touched, so the T-SQL style section doesn't apply here. Correctness: Traced every Compile sanity: Security: No secret values (webhook URLs, SMTP password) ever reach Lite/Darling parity: Darling's Extraction guard: Confirmed the new No blocking issues found. This is a solid, thoroughly self-tested fix for the "one bad value costs the rest of the file" defect. |
|
The orphaned-doc inline on Cause and lesson worth writing down: I anchored the insert on The helper moved below rather than the stray text being deleted — Both branches are rebased onto |
Closes #2444.
The defect
App.LoadAlertSettingswrapped all eighty-seven reads in onetry. #2425 took the parse out of it; the value half was untouched. A key holding a string where an int belongs threw on its ownGet*call and abandoned every read after it, so which settings survived depended on where the bad key sat in the file — and that ordering is an implementation detail of the method's line order.Measured against dev's own source rather than described:
alert_cpu_thresholdis read withv.GetInt32()12,389 characters beforeanalysis_timeout_seconds, inside a single 13,902-charactertrywith no innertry.GetInt32()on a JSON string throws. So{"alert_cpu_threshold": "ninety"}costsanalysis_timeout_secondsand every other key below it.The two judgement calls the issue named
A read helper that carries the key name — yes. Eighty-seven
tryblocks would work and would be unreadable.SettingsReaderchecksValueKindbefore it calls a getter and records the key it could not read. That is the callMcpSettings.Loadalready makes, for the reason it gives on the line: acatcharoundGetBooleanis what turned a quoted"true"into a silently disabled endpoint, and a hand-edited file is exactly where a quoted boolean comes from.Report the whole set — yes, and it is the same fix. Someone who edited one line has one mistake; someone who pasted a block out of
settings.sample.jsonhas several, and stopping at the first means they fix, restart, and discover the next one, several times over. The old code could not have done this even in principle — it threw on the first bad value and never reached the rest — so "which key" and "all of them" are not two decisions.The extractor trap
The key literals stay in the shape the extractor already expects, and the extractor is unchanged.
SettingsReader's method is calledTryGetProperty, so a call site still readsread.TryGetProperty("alert_cpu_threshold", out v)andSettingsSampleTests's regex matches it exactly as before. The name is load-bearing, not a stylistic echo, and it is documented as such at the definition, at the call site, and in the guard.Keeping the shape was the lower-risk half of the choice the issue offered — but it creates a new dependency held together by nothing the compiler checks, so the guard gets the self-test the issue asked for anyway:
root.TryGetProperty(…)and the newread.TryGetProperty(…)— over a source the test writes.UndocumentedKeyscomparison names it. A guard that extracts keys but no longer compares them is the same vacuous green as one that extracts nothing, and the only thing standing between that and a green build was a floor of>= 50.*.jsonliteral above it, which is howservers.jsonandcollection_schedule.json's loaders stay out of this set without an exemption — and how a loader moved above thesettings.jsonliteral would silently leave it.To do any of that, the extraction and the comparison are split out to run over text. Both are the shipped ones the real tests call, not copies.
The literal is also kept out of the new comments.
LoadDefaultTimeRangealready warns that the extractor cannot tell a comment from code and would read an example as a real key; the first draft of this change putTryGetProperty("in two prose lines and would have injected two junk keys into the extracted set.Two things that fall out
A latent overflow, now unreachable. Two of the inline clamp forms were
(int)Math.Max(0, v.GetInt64())— floor, then narrow. A hand-typed value beyond int range wrapped, so a bigger threshold became a negative one.Int(fallback, min, max)clamps before it narrows.A second throw site the single
tryhid.alert_excluded_databasescalledelem.GetString()on every element; a number in that array threw, and cost every setting after it. It now filters by element kind, which the fleet-group list beside it already did.The message becomes visible, and specific
The value fault logged and stopped there, so a user whose thresholds had silently reverted had no signal unless they went looking. #2425 had already decided, for the document half, that someone looking at an app which has forgotten its configuration should not have to find a log to learn why; the same argument applies to a setting that reverted. What is new is that the message can name the keys and say everything else loaded — which is what makes it worth a dialog rather than noise, and is the difference from the message that used to send someone to proofread an eighty-seven-key file.
It reuses the one startup modal #2425 added rather than opening a second. The two cannot both happen on one run: a document that will not parse never reaches a value read.
The
catchstays with no known cause left in it, so an unforeseen throw cannot take startup down — and its comment now says what it still costs when it fires, because the reads are ordered and anything after a throw is still lost.Where the reader lives, and the load/save mental model
PerformanceMonitor.Common, besideSettingsFileGuard, not in Lite. #2441 rewroteSaveAlertSettingsso all ten writers mutate one document opened once throughApp.SettingsRootForWrite()and written once; the load side now has the matching shape — one document opened once throughSettingsFileGuard.Read, one reader over it, one report at the end. Both sides treat a per-key failure as a value to carry rather than an exception to throw, and neither can now lose the rest of the file to one key.It is in Common rather than Lite because that is the seam the Viewer's dialog needs for the gap #2444 names one level up: it round-trips a whole object, so its deserialize fails before any property exists and it cannot say which settings were lost either. Deferred, not folded in — the Viewer's loader is a different mechanism (whole-object deserialize, not per-property reads) and converting it is its own change with its own tests.
Verification
Lite.Teststargetsnet10.0-windowsand cannot run on macOS, so everything runnable was compiled into anet10.0xUnit-shim harness against the realPerformanceMonitor.Commonbuild, with the realApp.xaml.csandsettings.sample.jsonas fixtures.SettingsSampleTests, which is the result that matters most here — they are the guard this refactor was most likely to break, and they are green with two-way symmetry over all 89 documented keys and both exemption sets still empty.SettingsValueReadTeststhat exercisesSettingsReadercannot compile against dev, which is the stronger statement.The two loader-level cases need WPF and can only run on CI, so their premise was measured rather than assumed — the character offsets and the single
tryabove, plusGetInt32()'s throw, both taken from the shipped dev source and the shipped BCL in the harness. Those are exactly the two facts those cases assert the absence of. Stating the scope precisely: the reader and the guard ran here; the two end-to-end loader cases did not, and CI is the arbiter for them.No CHANGELOG entry, matching #2425, #2428, #2432 and #2442:
[3.5.0]is a shipped, dated section and there is no[Unreleased]on dev.🤖 Generated with Claude Code