using System.Text; namespace ZB.MOM.WW.ScadaBridge.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. /// /// /// 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. /// /// The installed instances for stdout and stderr. 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. /// /// The writer that receives console output for this scope. /// A that, when disposed, restores the previous capture state. public CaptureScope BeginCapture(StringWriter buffer) { var previous = _current.Value; _current.Value = buffer; return new CaptureScope(this, previous); } // Intra-script concurrency hardening. A sandboxed script // can fan out work with `Task.WhenAll` / `Task.Run`; `AsyncLocal` flows // the capture `StringWriter` into every child task, so two tasks can // race the *same* buffer. `StringWriter` is not thread-safe — concurrent // `Write`/`WriteLine` calls can corrupt the underlying `StringBuilder` // and either throw or interleave at the character level. We lock on the // captured writer itself so writes from one capture scope serialise; // fall-through to the original `_fallback` (host-process console) is // unlocked because the BCL's process-wide `Console.Out` is already // synchronised by its TextWriter wrapper. /// public override void Write(char value) => WriteSynchronized(t => t.Write(value)); /// public override void Write(string? value) => WriteSynchronized(t => t.Write(value)); /// public override void Write(char[] buffer, int index, int count) => WriteSynchronized(t => t.Write(buffer, index, count)); /// public override void WriteLine() => WriteSynchronized(t => t.WriteLine()); /// public override void WriteLine(string? value) => WriteSynchronized(t => t.WriteLine(value)); /// /// Routes a single write through the currently-active capture buffer /// under a lock on that buffer, or to the unwrapped fallback writer when /// no capture scope is active. The lock target is the `StringWriter` /// instance itself — different capture scopes have different writers, /// so two unrelated scopes never block each other. /// private void WriteSynchronized(Action write) { var captured = _current.Value; if (captured is null) { write(_fallback); return; } lock (captured) { write(captured); } } internal readonly struct CaptureScope : IDisposable { private readonly SandboxConsoleCapture _owner; private readonly StringWriter? _previous; /// /// Initializes a capture scope that restores the previous writer on dispose. /// /// The owning instance. /// The writer that was active before this scope was opened. internal CaptureScope(SandboxConsoleCapture owner, StringWriter? previous) { _owner = owner; _previous = previous; } /// Restores the previous capture scope. public void Dispose() => _owner._current.Value = _previous; } }