Stop Robot Advice from crashing the app on a deep plan (#430) - #431
Conversation
System.Text.Json defaults MaxDepth to 64 and throws past it. An operator
tree nests once per operator and each level costs two JSON levels (the
object, then the Children array), so a plan roughly 30 operators deep
exhausts the default. The throw came out of an Avalonia click handler,
which Avalonia does not guard, so the process died with no dialog and
nothing the user could act on.
The reported stack named the cause precisely: the path was
$.Statements.OperatorTree.Children.Children...(30 deep)...NodeId - thirty
CONSECUTIVE Children. That distinction decides the fix, because the
exception message offers two causes ("either be due to a cycle or if the
object depth is larger than the maximum allowed depth of 64") and they
want opposite remedies. The serialized type is OperatorResult, which has
Children and no parent link, so it is a tree and depth is the right read.
Worth recording for whoever hits this next: the INTERNAL model it is
mapped from, PlanNode, DOES carry a Parent back-reference that the parser
populates (ShowPlanParser.RelOp.cs). Serializing that type directly would
be a genuine cycle needing [JsonIgnore], not a bigger number.
The ceiling is now one shared constant, because four places serialize this
object and none of them could tell when they got it wrong: both Robot
Advice entry points (QuerySessionControl and MainWindow build the payload
independently), the MCP tool options, and the CLI's two option sets. Only
the reported one crashes the process; the others returned an error where
the caller expected a plan.
1024 is about 500 nested operators against the ~30 that used to fail, and
it is deliberately paired with a caller-side catch at both UI sites: a
serialization limit should never be able to take the app down, so the
headroom is not the only thing standing between a deep plan and a crash.
Tested: 137/137 in PlanViewer.Core.Tests, including a reproduction that
fails on the old inline options and passes on the shared ones. App and CLI
build with zero warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed. The core fix (shared Two more writers of the same
Neither is covered by the new No other issues found — no new warnings, no untrusted-input/T-SQL concerns (this is pure JSON serialization plumbing), and the SSMS extension/versioning conventions aren't touched by this PR. |
Minor rather than patch. 1.19.x would understate it: #439 adds a "source" field to every warning in the JSON and MCP output and a new badge in the app and CLI, and #437 changes what an existing analysis rule concludes about a plan. Both are things a consumer can notice, and one of them is output-shape. What ships: - #437 Rule 12 no longer calls a conversion non-SARGable when it converts the parameter rather than the column. Plans carrying a parameter-side conversion on a scan lose that warning and report the scan's residual predicate instead. Verified against all 38 committed plans: no other plan's verdict moves. - #439 SQL Server's own warnings are now told apart from ours, tagged [SQL Server] in the app and CLI and carried as "source" in JSON/MCP. Additive, but it changes the bytes of analyze --compact. - #431 Robot Advice no longer takes the app down on a deep plan. - #438 querystore gets the same depth ceiling analyze got; it had been failing quietly on deep plans, one ERROR row per plan. - #443 the macOS handle resolver no longer corrupts memory on Apple silicon. - #425 Entra MFA works again (WAM parent window handle). Not user-facing but worth knowing for anyone building from this tag: the suite runs on Microsoft.Testing.Platform now (#442), and `dotnet test` finishes on macOS for the first time (#443) - 307 tests, 305 passing, 13 seconds. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ingdata#431 missed erikdarlingdata#431 made AnalysisJson the one place that knows how deep a serialized AnalysisResult goes (MaxDepth 1024 against the System.Text.Json default of 64, two JSON levels per operator), precisely because inline options keep getting rebuilt without it. Review found three more such sites, all on the web share path, so a deep-but-realistic plan (~30 nested operators) analyzed fine and then failed to Share — or shared and failed to load — with an "object cycle" message pointing at the wrong cause: - PlanShareService.ShareAsync serialized the upload envelope with default options; - PlanShareService.LoadAsync parsed the share with default JsonDocumentOptions and deserialized the result at the default 64 again; - server/PlanShare's /api/share parsed the uploaded body with default JsonDocumentOptions, turning a legitimate deep upload into 400 "Invalid JSON" before ttl_days was ever read. AnalysisJson grows a Wire options set (default formatting, only the ceiling raised — shares already in the database were written unindented with nulls, and the fix is the ceiling, not a wire-format change) and a Document counterpart for JsonDocument.Parse call sites. The web project links AnalysisJson.cs the way it links the rest of Core's sources. The server cannot reference PlanViewer.Core, so it mirrors the constant as a literal with a comment naming AnalysisJson as the source of truth — a shared constant only helps call sites that reference it; this one cannot. The Core depth test now says so too, as the tripwire for anyone changing the number. Tests: the exact share envelope shape ({result, text, ttl_days} → JsonDocument → GetRawText → Deserialize) round-trips a 100-operator chain through the shared options, and the default reader is shown to reject the same payload so the options are provably load-bearing. The Web and server call sites themselves are out of this suite's reach (Blazor WASM project not referenced; server references nothing), so they are verified by inspection and the contract is pinned here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
Fixes #430.
What does this PR do?
System.Text.JsondefaultsMaxDepthto 64 and throws past it. An operator tree nests once per operator and each level costs two JSON levels (the object, then theChildrenarray), so a plan roughly 30 operators deep exhausts the default. The throw came out of an Avalonia click handler, which Avalonia does not guard — so the process died with no dialog and nothing the user could act on.The reporter's stack named the cause precisely:
Thirty consecutive
Children, and that distinction decides the fix. The message offers two causes and they want opposite remedies — raising the ceiling on a genuine cycle just moves the crash. The serialized type isOperatorResult, which hasChildrenand no parent link, so it is a tree and the depth reading is the right one.Worth recording for whoever hits this next: the internal model it is mapped from,
PlanViewer.Core.Models.PlanNode, does carry aParentback-reference that the parser populates (ShowPlanParser.RelOp.cs:140). Serializing that type directly would be a real cycle needing[JsonIgnore], not a bigger number. Nothing does today, and the new file says so.Four places serialize this object and none of them could tell when they got it wrong, so the ceiling is now one shared constant:
QuerySessionControl.RobotAdvice_ClickMainWindow.PlanViewer.csrobot adviceMcpHelpers.JsonOptionsAnalyzeCommandJSON + compact optionsanalyze --jsonfails on a large plan1024 is about 500 nested operators against the ~30 that used to fail. It is deliberately paired with a caller-side
catchat both UI sites: a serialization limit should never be able to take the app down, so the headroom is not the only thing standing between a deep plan and a crash.Which component(s) does this affect?
How was this tested?
AnalysisJsonDepthTests(6 cases), including a reproduction — a 60-operator chain that throws on the options the call sites used to build inline and serializes on the shared ones. Without that case the fix would be unfalsifiable: a passing serialize proves nothing if nothing ever failed. It also asserts every level actually made it into the output rather than the serializer stopping quietly partway, and that a tree past the ceiling still throws rather than emitting truncated JSON that reads like a complete plan.PlanViewer.Core.Tests, macOS (arm64)PlanViewer.AppandPlanViewer.Clibuild with zero warningsSynthetic depth chains rather than a plan file — the reporter's plan was too large to anonymize, and depth is the whole variable, so constructing it directly tests the boundary precisely at 64 and at the new ceiling.
One note on the local run. The full suite reports all 137 passing in 54 ms and then the test host livelocks on shutdown at ~100% CPU;
dotnet testprintsCatastrophic failure: Test process crashed with exit code 143, which is theSIGTERMI sent it. A 3-secondsampleshowsSuspendEE,inject_activation,CheckActivationSafePointandthread_get_state/thread_set_statechurn inside nested_sigtramp— the known CoreCLR GC-suspension livelock on macOS ARM64, unrelated to this change (sampleitself hangs until the host is killed, since it needs the same thread suspension). Mentioning it because it makes a green local run look like a crash, and because #427's watchdog is the thing that catches it.Checklist
dotnet build -c Debug)dotnet test)🤖 Generated with Claude Code