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; } /// Initializes a new instance of the TimedScriptEvaluator class with the default timeout. /// The inner script evaluator. public TimedScriptEvaluator(ScriptEvaluator inner) : this(inner, DefaultTimeout) { } /// Initializes a new instance of the TimedScriptEvaluator class with a custom timeout. /// The inner script evaluator. /// The evaluation timeout duration. 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; } /// Runs the script evaluation with the configured timeout. /// The script context. /// The cancellation token. /// The evaluation result. 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. // // The class docs guarantee "caller-supplied cancel wins over timeout". // When both fire at nearly the same time, WaitAsync observes them in // non-deterministic order, so a cancel that arrives a few µs after the // timeout still reaches here as TimeoutException. Re-check the token so // the guarantee holds regardless of race ordering. (Core.Scripting-007.) if (ct.IsCancellationRequested) throw new OperationCanceledException(ct); 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 { /// Gets the timeout duration that was exceeded. public TimeSpan Timeout { get; } /// Initializes a new instance of the ScriptTimeoutException class. /// The timeout duration that was exceeded. 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; } }