fix(scripting): give scripted-alarm predicates the configured run budget, not 250 ms (lmxopcua#779)

DriverHostActor built ScriptedAlarmEngine without a scriptTimeout, so every
production alarm predicate fell back to TimedScriptEvaluator.DefaultTimeout
(250 ms) while virtual tags and Calculation tags ran on 2 s. History shows no
decision behind it: 250 ms is the generic Phase-7 A.4 default, the VT
evaluator moved to 2 s in F8b (219d10a2) and the alarm engine was simply never
given a value.

- New node-wide Scripting:RunTimeout (default 2 s, clamped to (0, 60 s],
  non-positive -> default, each clamp a startup Warning) on ScriptingOptions.
- The VT evaluator and the alarm engine default to it; DriverHostActor now
  builds the engine through CreateScriptedAlarmEngine, which passes it
  explicitly. Calculation keeps its per-driver runTimeoutMs.
- Docs: ScriptEditor (config table), ScriptedAlarms, VirtualTags.

Tests: ScriptedAlarmEngineBudgetTests (a 600 ms predicate now completes and
activates; default and configured budgets), ScriptedAlarmEngineBudgetWiringTests
(the host-built engine), ScriptingOptionsBindingTests (bind + clamps). The
engine and wiring tests fail with the old 250 ms default restored.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:12:39 -05:00
parent 2331f7ed14
commit d683b83156
11 changed files with 192 additions and 13 deletions
@@ -19,6 +19,12 @@ public sealed class ScriptingOptions
/// <summary>The appsettings section this binds from.</summary>
public const string SectionName = "Scripting";
/// <summary>Default <see cref="RunTimeout"/>: 2 s — the budget virtual tags and Calculation tags have always run with.</summary>
public static readonly TimeSpan DefaultRunTimeout = TimeSpan.FromSeconds(2);
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="RunTimeout"/>: 60 s.</summary>
public static readonly TimeSpan MaxRunTimeout = TimeSpan.FromSeconds(60);
/// <summary>Default <see cref="MaxAllocBytesPerRun"/>: 64 MB.</summary>
public const long DefaultMaxAllocBytesPerRun = 64L * 1024 * 1024;
@@ -28,6 +34,18 @@ public sealed class ScriptingOptions
/// <summary>Default <see cref="StuckOrphanQuarantineFactor"/>: 10.</summary>
public const double DefaultStuckOrphanQuarantineFactor = 10;
/// <summary>
/// Wall-clock budget for one script run on the node-wide seams: virtual tags and scripted-alarm
/// predicates. Default 2 s. A Calculation driver keeps its own per-driver <c>runTimeoutMs</c> (default
/// 2 s as well). Before lmxopcua#779 the scripted-alarm engine was built without a budget and silently
/// took the 250 ms Phase-7 default of <see cref="TimedScriptEvaluator{TContext, TResult}"/>, so an alarm
/// predicate got an eighth of the budget a virtual tag reading the same inputs got — not a decision
/// anywhere in the history, just a missing argument. Also scales the stuck-orphan rule
/// (<see cref="StuckOrphanQuarantineFactor"/> × this). Zero or negative becomes the default; above
/// <see cref="MaxRunTimeout"/> becomes that maximum.
/// </summary>
public TimeSpan RunTimeout { get; set; } = DefaultRunTimeout;
/// <summary>
/// Managed bytes one script run may allocate on its own thread (sampled with
/// <see cref="GC.GetAllocatedBytesForCurrentThread"/> around the run) before the script is
@@ -78,6 +96,8 @@ public sealed class ScriptingOptions
/// <item><see cref="QuarantineAfterConsecutiveTimeouts"/>: into [1, <see cref="MaxQuarantineAfterConsecutiveTimeouts"/>].</item>
/// <item><see cref="MaxAllocBytesPerRun"/>: a negative value becomes 0 (disabled), and a positive value
/// below <see cref="MinMaxAllocBytesPerRun"/> becomes that minimum.</item>
/// <item><see cref="RunTimeout"/>: zero or negative becomes <see cref="DefaultRunTimeout"/>, above
/// <see cref="MaxRunTimeout"/> becomes that maximum.</item>
/// </list>
/// </summary>
/// <param name="adjustments">One line per value that was out of range, naming its key. A negative value that
@@ -126,9 +146,22 @@ public sealed class ScriptingOptions
alloc = MinMaxAllocBytesPerRun;
}
var runTimeout = RunTimeout;
if (runTimeout <= TimeSpan.Zero)
{
notes.Add($"{SectionName}:{nameof(RunTimeout)} {runTimeout} is not positive; using the default {DefaultRunTimeout}.");
runTimeout = DefaultRunTimeout;
}
else if (runTimeout > MaxRunTimeout)
{
notes.Add($"{SectionName}:{nameof(RunTimeout)} {runTimeout} is above the maximum; clamped to {MaxRunTimeout}.");
runTimeout = MaxRunTimeout;
}
adjustments = notes;
return new ScriptingOptions
{
RunTimeout = runTimeout,
MaxAllocBytesPerRun = alloc,
QuarantineAfterConsecutiveTimeouts = threshold,
StuckOrphanQuarantineFactor = factor,