fix(centralui): P6-12 — dispose sandbox-run CTS in ApiMethodForm + SharedScriptForm

Neither Test Run form implemented IDisposable, so navigating away mid-run left
_runCts uncancelled: the sandbox kept the run alive until its own internal
script timeout, and the eventual continuation touched a torn-down component's
_running/_runResult fields. TemplateScriptDialog already got this right — both
forms now mirror it exactly: @implements IDisposable plus a Dispose() that
cancels + disposes _runCts.

ScriptAnalysisService.RunInSandboxAsync is marked virtual (test-only seam,
mirroring the existing virtual on DebugStreamService.StartStreamAsync/
StopStream) so a TaskCompletionSource-gated stub can hold a run open
deterministically. New bUnit tests (ScriptFormSandboxDisposalTests) prove, for
both forms: disposing mid-run cancels the token the sandbox observed, and the
cancelled continuation resolves with no unhandled exception and no _runResult/
_running mutation. Proof of failure: reverting only the two .razor changes
(keeping the virtual seam) reproduces all 4 failures — the token is never
cancelled and the no-op assertions time out.
This commit is contained in:
Joseph Doherty
2026-08-19 16:25:48 -04:00
parent 21d50d4406
commit f1ef95f4da
4 changed files with 305 additions and 1 deletions
@@ -11,6 +11,7 @@
@inject IInboundApiKeyAdmin ApiKeyAdmin
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject NavigationManager NavigationManager
@implements IDisposable
<div class="container-fluid mt-3">
<button class="btn btn-outline-secondary btn-sm mb-2" @onclick="GoBack">&larr; Back</button>
@@ -377,6 +378,14 @@
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
/// <summary>Cancels an in-flight Test Run when the component is torn down.</summary>
public void Dispose()
{
_runCts?.Cancel();
_runCts?.Dispose();
_runCts = null;
}
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
private async Task RunInSandboxAsync()
@@ -11,6 +11,7 @@
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@implements IDisposable
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
@@ -241,6 +242,14 @@
NavigationManager.NavigateTo("/design/shared-scripts");
}
/// <summary>Cancels an in-flight Test Run when the component is torn down.</summary>
public void Dispose()
{
_runCts?.Cancel();
_runCts?.Dispose();
_runCts = null;
}
private void CheckCompilation()
{
var syntaxError = ValidateSyntaxLocally(_formCode);
@@ -242,7 +242,7 @@ public class ScriptAnalysisService
/// <param name="request">The sandbox run request including code, kind, parameters, and optional instance binding.</param>
/// <param name="ct">Cancellation token for the caller; combined with an internal timeout CTS.</param>
/// <returns>A <see cref="SandboxRunResult"/> describing the outcome, return value, console output, and any error.</returns>
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
public virtual async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
{