fix(adminui): Monaco dispose on a gone circuit is expected, not an error (#902)

Closing a tab with the script editor open ended the circuit; MonacoEditor.DisposeAsync then
called MonacoBlazor.dispose through SafeInvokeAsync, which caught only JSDisconnectedException,
so the TaskCanceledException from the torn-down circuit escaped and was logged at [ERR].

DisposeAsync now treats JSDisconnectedException, OperationCanceledException (incl.
TaskCanceledException) and ObjectDisposedException as the expected gone-circuit outcome and logs
them at Debug; a real JSException from a live page is still logged (Warning); the
DotNetObjectReference is released in a finally. MonacoEditor is the only component in the repo
that uses JS interop, so there is nothing else to align.

bUnit tests: dispose throwing each of the three gone-circuit exceptions neither escapes nor logs
above Debug (with a positive control that the failing call ran), and a JSException still warns.
They fail against the previous DisposeAsync.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 01:50:37 -05:00
parent d528838864
commit b85e004bdb
3 changed files with 127 additions and 198 deletions
@@ -237,12 +237,40 @@
public async ValueTask DisposeAsync()
{
if (_initialized)
try
{
// Disposal commonly races a circuit disconnect — JSDisconnectedException
// here is expected and silent; a real JSException is still logged.
await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id);
if (_initialized)
{
try
{
await JS.InvokeVoidAsync("MonacoBlazor.dispose", _id);
}
catch (Exception ex) when (IsGoneCircuit(ex))
{
// lmxopcua#902: disposal normally runs because the circuit is ending (tab closed, navigation,
// reconnect timeout), so the JS side — and the editor with it — is already gone. The interop
// call then fails with JSDisconnectedException, or with TaskCanceledException /
// ObjectDisposedException when the circuit tore down mid-call. Nothing is left to clean up.
Logger.LogDebug("Monaco editor {EditorId}: JS dispose skipped, the circuit is gone ({Reason}).",
_id, ex.GetType().Name);
}
catch (JSException ex)
{
// The page is alive but Monaco's own dispose threw — a real fault, surfaced.
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to dispose editor.", _id);
}
}
}
finally
{
_dotNetRef?.Dispose();
}
_dotNetRef?.Dispose();
}
/// <summary>
/// True for the failures a JS-interop call raises when the circuit (or the page) has already gone away — the
/// expected outcome of interop issued from <see cref="DisposeAsync"/>.
/// </summary>
private static bool IsGoneCircuit(Exception ex) =>
ex is JSDisconnectedException or OperationCanceledException or ObjectDisposedException;
}