fix(adminui): bound the diagnostics marker payload on both the endpoint and the circuit (D-a review r1)

I-1 - a non-C# paste produced thousands of Roslyn markers. The Problems-list report sent
them all as one JS->.NET message, which could exceed the 1 MB hub limit and kill the
circuit. The JS now sends at most MaxReportedMarkers (200) markers, errors first, each
message cut to 300 chars, plus the total. ScriptProblemReport.FromEditor clamps again
on arrival, and the Problems list shows "showing N of Total" / "+N more". The diagnostics
endpoint also bounds its response: Distinct, errors first, MaxDiagnosticMarkers (1000),
messages cut to 1000 chars. An error is always kept, so editor-accepts == publish-accepts.

M-1 - drop the per-instance compilation lineage; analyses derive from BaseCompilation.
M-2 - the VirtualTag modal clears its problems (and editor ref) on close and on a script
reload.
M-5 - an over-limit text is announced at blur (OnSyncRefused), not only at Save.

Refs #745

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:51:35 -04:00
parent 17c7d05696
commit a9e8f5abfe
10 changed files with 305 additions and 34 deletions
@@ -77,19 +77,14 @@ public sealed class ScriptAnalysisService
return compilation;
}
// This scope's current compilation (one per scoped service). Each analysis swaps its tree in with
// ReplaceSyntaxTree; a lost race between two concurrent calls on one scope is harmless because every
// compilation in the lineage is a complete, valid single-tree compilation.
private CSharpCompilation _compilation = BaseCompilation;
// userSource is appended after Preamble, then the method/class are closed.
private (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(
// userSource is appended after Preamble, then the method/class are closed. Every analysis derives straight
// from the shared BaseCompilation: the analysis endpoints are minimal-API handlers, so a scoped service lives
// for one HTTP request and a per-instance lineage would never be more than one step long.
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(
string userSource, CancellationToken ct)
{
var tree = WrapAndParse(userSource, ct);
var current = _compilation;
var compilation = current.ReplaceSyntaxTree(current.SyntaxTrees.Single(), tree);
_compilation = compilation;
var compilation = BaseCompilation.ReplaceSyntaxTree(BaseCompilation.SyntaxTrees[0], tree);
return (tree, compilation, compilation.GetSemanticModel(tree), Preamble.Length);
}
@@ -159,7 +154,7 @@ public sealed class ScriptAnalysisService
foreach (var inv in SetVirtualTagCalls(tree.GetRoot(ct), preambleLength))
markers.Add(ToMarker(tree.GetMappedLineSpan(inv.Span), 4, SetVirtualTagInertMessage, "OTSCRIPT_SETVTAG_INERT"));
return new DiagnoseResponse(markers.Distinct().ToList());
return new DiagnoseResponse(Bound(markers));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -216,7 +211,7 @@ public sealed class ScriptAnalysisService
$"reference named '{refName}'. Add a matching reference or correct the path.",
"OTSCRIPT_EQUIPREF"));
}
return new DiagnoseResponse(markers.Distinct().ToList());
return new DiagnoseResponse(Bound(markers));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -229,6 +224,18 @@ public sealed class ScriptAnalysisService
}
}
// Bounds what the diagnostics endpoint returns (and so what Monaco renders and the Problems list is fed from):
// de-duplicated, errors first then by position, at most MaxDiagnosticMarkers, each message cut to
// MaxDiagnosticMessageChars. Ordering before the cut keeps an error whenever one exists, so the editor still
// rejects exactly what publish rejects even for a pathological (e.g. non-C#) paste.
private static IReadOnlyList<DiagnosticMarker> Bound(IEnumerable<DiagnosticMarker> markers)
=> ScriptProblems.Order(markers.Distinct())
.Take(ScriptEditorLimits.MaxDiagnosticMarkers)
.Select(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars
? m
: m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxDiagnosticMessageChars) })
.ToList();
private const string SetVirtualTagInertMessage =
"ctx.SetVirtualTag(...) has no effect at runtime: cross-tag writes are dropped in single-tag mode (the " +
"cascade engine is not wired into the host). The script compiles and publishes, but this write will not " +