namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; /// /// Wraps a with a per-evaluation /// wall-clock timeout. Default is 250ms per Phase 7 plan Stream A.4; configurable /// per tag so deployments with slower backends can widen it. /// /// /// /// Implemented with /// rather than a cancellation-token-only approach because Roslyn-compiled /// scripts don't internally poll the cancellation token unless the user code /// does async work. A CPU-bound infinite loop in a script won't honor a /// cooperative cancel — WaitAsync returns control when the timeout fires /// regardless of whether the inner task completes. /// /// /// Known limitation: when a script times out, the underlying ScriptRunner /// task continues running on a thread-pool thread until the Roslyn runtime /// returns. In the CPU-bound-infinite-loop case that's effectively "leaked" — /// the thread is tied up until the runtime decides to return, which it may /// never do. Phase 7 plan Stream A.4 accepts this as a known trade-off; tighter /// CPU budgeting would require an out-of-process script runner, which is a v3 /// concern. In practice, the timeout + structured warning log surfaces the /// offending script so the operator can fix it; the orphan thread is rare. /// /// /// Caller-supplied is honored — if the caller /// cancels before the timeout fires, the caller's cancel wins and the /// propagates (not wrapped as /// ). That distinction matters: the /// virtual-tag engine's shutdown path cancels scripts on dispose; it shouldn't /// see those as timeouts. /// /// public sealed class TimedScriptEvaluator where TContext : ScriptContext { /// Default timeout per Phase 7 plan Stream A.4 — 250ms. public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(250); private readonly ScriptEvaluator _inner; /// Wall-clock budget per evaluation. Script exceeding this throws . public TimeSpan Timeout { get; } public TimedScriptEvaluator(ScriptEvaluator inner) : this(inner, DefaultTimeout) { } public TimedScriptEvaluator(ScriptEvaluator inner, TimeSpan timeout) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); if (timeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive."); Timeout = timeout; } public async Task RunAsync(TContext context, CancellationToken ct = default) { if (context is null) throw new ArgumentNullException(nameof(context)); // Push evaluation to a thread-pool thread so a CPU-bound script (e.g. a tight // loop with no async work) doesn't hog the caller's thread before WaitAsync // gets to register its timeout. Without this, Roslyn's ScriptRunner executes // synchronously on the calling thread and returns an already-completed Task, // so WaitAsync sees a completed task and never fires the timeout. var runTask = Task.Run(() => _inner.RunAsync(context, ct), ct); try { return await runTask.WaitAsync(Timeout, ct).ConfigureAwait(false); } catch (TimeoutException) { // WaitAsync's synthesized timeout — the inner task may still be running // on its thread-pool thread (known leak documented in the class summary). // Wrap so callers can distinguish from user-written timeout logic. throw new ScriptTimeoutException(Timeout); } } } /// /// Thrown when a script evaluation exceeds its configured timeout. The virtual-tag /// engine (Stream B) catches this + maps the owning tag's quality to /// BadInternalError per Phase 7 plan decision #11, logging a structured /// warning with the offending script name so operators can locate + fix it. /// public sealed class ScriptTimeoutException : Exception { public TimeSpan Timeout { get; } public ScriptTimeoutException(TimeSpan timeout) : base($"Script evaluation exceeded the configured timeout of {timeout.TotalMilliseconds:F1} ms. " + "The script was either CPU-bound or blocked on a slow operation; check ctx.Logger output " + "around the timeout and consider widening the timeout per tag, simplifying the script, or " + "moving heavy work out of the evaluation path.") { Timeout = timeout; } }