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
@@ -21,6 +21,14 @@
<div @ref="_hostRef" class="monaco-editor-host"
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
@if (_tooLargeBytes > 0)
{
@* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@
<div class="text-warning small mt-1" role="status">
This script is too large to send to the server (@_tooLargeBytes bytes; the limit is
@ScriptEditorLimits.MaxEditorSyncBytes bytes). It cannot be saved until it is shortened.
</div>
}
@code {
/// <summary>
@@ -46,15 +54,18 @@
/// <summary>
/// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response
/// to the CURRENT text — superseded responses are dropped in the JS). Feeds <c>ScriptProblemsList</c>; the
/// markers are sent from JS only when this callback has a subscriber.
/// markers are sent from JS only when this callback has a subscriber, and at most
/// <see cref="ScriptEditorLimits.MaxReportedMarkers"/> of them (plus the total) so the report always fits in one
/// circuit message.
/// </summary>
[Parameter] public EventCallback<IReadOnlyList<DiagnosticMarker>> MarkersChanged { get; set; }
[Parameter] public EventCallback<ScriptProblemReport> MarkersChanged { get; set; }
private ElementReference _hostRef;
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
private readonly string _id = Guid.NewGuid().ToString("N");
private string _lastSentValue = "";
private bool _initialized;
private int _tooLargeBytes;
private bool _wrap;
private bool _minimap;
private bool _dark;
@@ -78,7 +89,9 @@
readOnly = ReadOnly,
equipmentId = EquipmentId,
maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes,
reportMarkers = MarkersChanged.HasDelegate
reportMarkers = MarkersChanged.HasDelegate,
maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers,
maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars
},
_dotNetRef);
_initialized = true;
@@ -160,6 +173,7 @@
return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim());
}
if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed
_tooLargeBytes = 0; // it was read, so it fits
await OnValueChanged(current);
return new EditorReadResult(current, null);
}
@@ -174,8 +188,17 @@
}
[JSInvokable]
public Task OnMarkersChanged(DiagnosticMarker[]? markers) =>
MarkersChanged.InvokeAsync(markers ?? Array.Empty<DiagnosticMarker>());
public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) =>
MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total));
/// <summary>The JS reports, at blur, that the text has grown past (bytes &gt; 0) or back under (0) the sync limit.</summary>
[JSInvokable]
public void OnSyncRefused(int bytes)
{
if (_tooLargeBytes == bytes) return;
_tooLargeBytes = bytes;
StateHasChanged();
}
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
public async Task RevealLineAsync(int line, int column = 1)
@@ -5,12 +5,13 @@
ScriptProblems (unit-tested). *@
@{
var ordered = ScriptProblems.Order(Markers);
var report = Report ?? ScriptProblemReport.Empty;
var ordered = ScriptProblems.Order(report.Markers);
}
<div class="script-problems mt-2 small" role="region" aria-label="Problems">
<div class="d-flex align-items-center gap-2 text-muted mb-1">
<span class="fw-semibold">Problems</span>
<span>@ScriptProblems.Summary(ordered)</span>
<span>@ScriptProblems.Summary(report with { Markers = ordered })</span>
</div>
@if (ordered.Count > 0)
{
@@ -29,18 +30,20 @@
</li>
}
</ul>
@if (ordered.Count > MaxRows)
@if (report.Total > Math.Min(ordered.Count, MaxRows))
{
<div class="text-muted mt-1">… and @(ordered.Count - MaxRows) more</div>
@* The editor reports at most ScriptEditorLimits.MaxReportedMarkers problems (one circuit message); the
rest are still squiggled in the editor. *@
<div class="text-muted mt-1">+@(report.Total - Math.Min(ordered.Count, MaxRows)) more — fix the problems above, or scroll the editor to see them all.</div>
}
}
</div>
@code {
private const int MaxRows = 200;
private const int MaxRows = ScriptEditorLimits.MaxReportedMarkers;
/// <summary>The markers the editor last reported (null before the first diagnostics pass).</summary>
[Parameter] public IReadOnlyList<DiagnosticMarker>? Markers { get; set; }
/// <summary>The report the editor last sent (null before the first diagnostics pass).</summary>
[Parameter] public ScriptProblemReport? Report { get; set; }
/// <summary>Raised when the author picks a problem; wire it to <c>MonacoEditor.RevealLineAsync</c>.</summary>
[Parameter] public EventCallback<DiagnosticMarker> OnSelect { get; set; }
@@ -102,7 +102,7 @@
</div>
<MonacoEditor @ref="_scriptEditor" @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId"
MarkersChanged="OnScriptMarkersChanged" />
<ScriptProblemsList Markers="_scriptMarkers" OnSelect="RevealScriptProblemAsync" />
<ScriptProblemsList Report="_scriptMarkers" OnSelect="RevealScriptProblemAsync" />
@if (!string.IsNullOrWhiteSpace(_scriptError))
{
<div class="text-danger small mt-2">@_scriptError</div>
@@ -221,9 +221,9 @@
private bool _scriptSaved;
private string? _scriptError;
private MonacoEditor? _scriptEditor;
private IReadOnlyList<DiagnosticMarker> _scriptMarkers = Array.Empty<DiagnosticMarker>();
private ScriptProblemReport _scriptMarkers = ScriptProblemReport.Empty;
private void OnScriptMarkersChanged(IReadOnlyList<DiagnosticMarker> markers) => _scriptMarkers = markers;
private void OnScriptMarkersChanged(ScriptProblemReport report) => _scriptMarkers = report;
private Task RevealScriptProblemAsync(DiagnosticMarker m)
=> _scriptEditor is null ? Task.CompletedTask : _scriptEditor.RevealLineAsync(m.StartLineNumber, m.StartColumn);
@@ -248,6 +248,9 @@
if (!Visible)
{
_loadedKey = null; // closed → next open reloads fresh
// Closing removes the editor component: drop its reference and its problems.
_scriptEditor = null;
_scriptMarkers = ScriptProblemReport.Empty;
return;
}
@@ -299,6 +302,9 @@
{
_scriptSaved = false;
_scriptError = null;
// A different script (or a reload) — drop the previous script's problems. The editor instance itself stays:
// while the panel is open the same MonacoEditor component receives the new source via its Value.
_scriptMarkers = ScriptProblemReport.Empty;
if (string.IsNullOrEmpty(_form.ScriptId))
{
@@ -387,7 +393,7 @@
// A failed read keeps the panel open (with the error) rather than discarding the edits.
if (!await PullScriptSourceAsync()) return;
_scriptEditor = null;
_scriptMarkers = Array.Empty<DiagnosticMarker>();
_scriptMarkers = ScriptProblemReport.Empty;
}
_scriptExpanded = !_scriptExpanded;
if (_scriptExpanded)