using System.Text; namespace ScadaLink.CentralUI.ScriptAnalysis; /// /// Per-call console capture for the Test Run sandbox. /// /// Sandbox scripts use System.Console.WriteLine for ad-hoc output. The /// sandbox needs to capture that output per execution. Console.Out is, /// however, process-global: redirecting it with Console.SetOut for /// the duration of one run corrupts any other run executing concurrently — /// outputs interleave, and whichever run finishes first restores /// Console.Out while the others are still writing (CentralUI-003). /// /// /// This writer is installed into Console.Out/Console.Error /// exactly once (see ) and never removed. Each /// concurrent run pushes its own buffer onto an /// scope via ; 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. /// /// 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 _current = new(); private SandboxConsoleCapture(TextWriter fallback) => _fallback = fallback; public override Encoding Encoding => _fallback.Encoding; /// /// Installs the routing writers into and /// once for the process. Idempotent and /// thread-safe. Subsequent calls return the already-installed instances. /// 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); } /// /// 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 instead of the original writer. /// The scope is restored on dispose, so nesting and concurrent scopes on /// other call-trees are unaffected. /// 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; } }