64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
123 lines
6.4 KiB
C#
123 lines
6.4 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
/// <summary>
|
|
/// Wraps a <see cref="ScriptEvaluator{TContext, TResult}"/> 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Implemented with <see cref="Task.WaitAsync(TimeSpan, CancellationToken)"/>
|
|
/// 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 — <c>WaitAsync</c> returns control when the timeout fires
|
|
/// regardless of whether the inner task completes.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Known limitation:</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Caller-supplied <see cref="CancellationToken"/> is honored — if the caller
|
|
/// cancels before the timeout fires, the caller's cancel wins and the
|
|
/// <see cref="OperationCanceledException"/> propagates (not wrapped as
|
|
/// <see cref="ScriptTimeoutException"/>). That distinction matters: the
|
|
/// virtual-tag engine's shutdown path cancels scripts on dispose; it shouldn't
|
|
/// see those as timeouts.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class TimedScriptEvaluator<TContext, TResult>
|
|
where TContext : ScriptContext
|
|
{
|
|
/// <summary>Default timeout per Phase 7 plan Stream A.4 — 250ms.</summary>
|
|
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(250);
|
|
|
|
private readonly ScriptEvaluator<TContext, TResult> _inner;
|
|
|
|
/// <summary>Wall-clock budget per evaluation. Script exceeding this throws <see cref="ScriptTimeoutException"/>.</summary>
|
|
public TimeSpan Timeout { get; }
|
|
|
|
/// <summary>Initializes a new instance of the TimedScriptEvaluator class with the default timeout.</summary>
|
|
/// <param name="inner">The inner script evaluator.</param>
|
|
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner)
|
|
: this(inner, DefaultTimeout)
|
|
{
|
|
}
|
|
|
|
/// <summary>Initializes a new instance of the TimedScriptEvaluator class with a custom timeout.</summary>
|
|
/// <param name="inner">The inner script evaluator.</param>
|
|
/// <param name="timeout">The evaluation timeout duration.</param>
|
|
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> 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;
|
|
}
|
|
|
|
/// <summary>Runs the script evaluation with the configured timeout.</summary>
|
|
/// <param name="context">The script context.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The evaluation result.</returns>
|
|
public async Task<TResult> 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Thrown when a script evaluation exceeds its configured timeout. The virtual-tag
|
|
/// engine (Stream B) catches this + maps the owning tag's quality to
|
|
/// <c>BadInternalError</c> per Phase 7 plan decision #11, logging a structured
|
|
/// warning with the offending script name so operators can locate + fix it.
|
|
/// </summary>
|
|
public sealed class ScriptTimeoutException : Exception
|
|
{
|
|
/// <summary>Gets the timeout duration that was exceeded.</summary>
|
|
public TimeSpan Timeout { get; }
|
|
|
|
/// <summary>Initializes a new instance of the ScriptTimeoutException class.</summary>
|
|
/// <param name="timeout">The timeout duration that was exceeded.</param>
|
|
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;
|
|
}
|
|
}
|