fix(central-ui): resolve CentralUI-002/003/004 — site-scope enforcement, per-circuit console capture, cached auth state

This commit is contained in:
Joseph Doherty
2026-05-16 19:33:09 -04:00
parent 5a08b04535
commit 87f14c190a
17 changed files with 693 additions and 40 deletions
@@ -0,0 +1,106 @@
using System.Text;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Per-call console capture for the Test Run sandbox.
/// <para>
/// Sandbox scripts use <c>System.Console.WriteLine</c> for ad-hoc output. The
/// sandbox needs to capture that output per execution. <c>Console.Out</c> is,
/// however, <b>process-global</b>: redirecting it with <c>Console.SetOut</c> for
/// the duration of one run corrupts any other run executing concurrently —
/// outputs interleave, and whichever run finishes first restores
/// <c>Console.Out</c> while the others are still writing (CentralUI-003).
/// </para>
/// <para>
/// This writer is installed into <c>Console.Out</c>/<c>Console.Error</c>
/// <b>exactly once</b> (see <see cref="Install"/>) and never removed. Each
/// concurrent run pushes its own buffer onto an <see cref="AsyncLocal{T}"/>
/// scope via <see cref="BeginCapture"/>; writes on that run's logical call-tree
/// land in that run's buffer only. Writes made on threads with no active
/// capture scope (i.e. genuine host-process console output) fall through to the
/// original writer. No process-global mutation happens per run.
/// </para>
/// </summary>
internal sealed class SandboxConsoleCapture : TextWriter
{
private static readonly object InstallLock = new();
private static SandboxConsoleCapture? _outInstance;
private static SandboxConsoleCapture? _errorInstance;
private readonly TextWriter _fallback;
private readonly AsyncLocal<StringWriter?> _current = new();
private SandboxConsoleCapture(TextWriter fallback) => _fallback = fallback;
public override Encoding Encoding => _fallback.Encoding;
/// <summary>
/// Installs the routing writers into <see cref="Console.Out"/> and
/// <see cref="Console.Error"/> once for the process. Idempotent and
/// thread-safe. Subsequent calls return the already-installed instances.
/// </summary>
public static (SandboxConsoleCapture Out, SandboxConsoleCapture Error) Install()
{
if (_outInstance != null && _errorInstance != null)
return (_outInstance, _errorInstance);
lock (InstallLock)
{
if (_outInstance == null)
{
_outInstance = new SandboxConsoleCapture(Console.Out);
Console.SetOut(_outInstance);
}
if (_errorInstance == null)
{
_errorInstance = new SandboxConsoleCapture(Console.Error);
Console.SetError(_errorInstance);
}
}
return (_outInstance, _errorInstance);
}
/// <summary>
/// Begins a capture scope on the current logical (async) call-tree. All
/// console writes from this point until the returned scope is disposed are
/// routed into <paramref name="buffer"/> instead of the original writer.
/// The scope is restored on dispose, so nesting and concurrent scopes on
/// other call-trees are unaffected.
/// </summary>
public CaptureScope BeginCapture(StringWriter buffer)
{
var previous = _current.Value;
_current.Value = buffer;
return new CaptureScope(this, previous);
}
public override void Write(char value) => Target.Write(value);
public override void Write(string? value) => Target.Write(value);
public override void Write(char[] buffer, int index, int count) =>
Target.Write(buffer, index, count);
public override void WriteLine() => Target.WriteLine();
public override void WriteLine(string? value) => Target.WriteLine(value);
private TextWriter Target => _current.Value ?? _fallback;
internal readonly struct CaptureScope : IDisposable
{
private readonly SandboxConsoleCapture _owner;
private readonly StringWriter? _previous;
internal CaptureScope(SandboxConsoleCapture owner, StringWriter? previous)
{
_owner = owner;
_previous = previous;
}
public void Dispose() => _owner._current.Value = _previous;
}
}
@@ -165,8 +165,10 @@ public class ScriptAnalysisService
/// because a shared script has no template siblings in this context.
/// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
/// because cross-site routing needs a deployed site.
/// Console.Out / Console.Error are redirected per-call so writes from
/// the script land in the result.
/// Console.Out / Console.Error are captured per-call via an AsyncLocal
/// scope (see <see cref="SandboxConsoleCapture"/>) so writes from the script
/// land in the result without mutating process-global Console state — two
/// concurrent Test Runs do not interfere with each other.
/// </summary>
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{
@@ -377,16 +379,19 @@ public class ScriptAnalysisService
Instance = instanceContext,
};
var originalOut = Console.Out;
var originalError = Console.Error;
// Console capture is routed per-call via an AsyncLocal scope (CentralUI-003).
// Console.Out is process-global, so it must NOT be redirected per run — two
// concurrent Test Runs would interleave output and the first to finish would
// restore Console.Out while the other is still writing. SandboxConsoleCapture
// installs routing writers once and scopes capture to this call-tree only.
var (captureOut, captureError) = SandboxConsoleCapture.Install();
var captured = new StringWriter();
using var outScope = captureOut.BeginCapture(captured);
using var errorScope = captureError.BeginCapture(captured);
var stopwatch = Stopwatch.StartNew();
try
{
Console.SetOut(captured);
Console.SetError(captured);
// Run on a thread-pool thread with no SynchronizationContext: a
// bound script's Instance.SetAttribute / Attributes[...] block
// synchronously on cross-site I/O (the API surface is sync by
@@ -437,11 +442,9 @@ public class ScriptAnalysisService
$"{inner.GetType().Name}: {inner.Message}",
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
}
finally
{
Console.SetOut(originalOut);
Console.SetError(originalError);
}
// outScope / errorScope are disposed by their `using` declarations when the
// method returns, restoring the previous capture scope on this call-tree
// without touching process-global Console state.
}
private static Dictionary<string, object?> ConvertJsonParameters(