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:
@@ -211,13 +211,14 @@ scripted-alarm predicates) runs scripts through the shared `ScriptRunGuard`:
|
||||
`Scripting:MaxAllocBytesPerRun` (64 MB) is quarantined. This is sampled after the
|
||||
run returns, so a run that OOMs the process first is not stopped — that needs an
|
||||
out-of-process runner (→ #750).
|
||||
- **Configuration keys and their ranges.** All three knobs are node-wide (the
|
||||
- **Configuration keys and their ranges.** All four knobs are node-wide (the
|
||||
`Scripting` appsettings section, shared by virtual tags, Calculation tags and
|
||||
scripted alarms) and are clamped when bound, with one startup Warning per
|
||||
clamped value:
|
||||
|
||||
| Key | Default | Range after the clamp |
|
||||
|---|---|---|
|
||||
| `Scripting:RunTimeout` | `00:00:02` (2 s) | zero or negative becomes the default; above `00:01:00` becomes 60 s. The per-run budget of virtual tags and scripted-alarm predicates (lmxopcua#779 — alarms used to fall back to 250 ms). Calculation tags keep their per-driver `runTimeoutMs`. |
|
||||
| `Scripting:MaxAllocBytesPerRun` | `67108864` (64 MB) | `0` (or negative) disables the budget; a positive value below `1048576` (1 MB) becomes 1 MB |
|
||||
| `Scripting:QuarantineAfterConsecutiveTimeouts` | `3` | `1`–`1000` |
|
||||
| `Scripting:StuckOrphanQuarantineFactor` | `10` | `0` (or negative) disables the rule; above `1000` becomes `1000`; `NaN` becomes the default |
|
||||
|
||||
@@ -47,7 +47,7 @@ new ScriptedAlarmDefinition(
|
||||
|
||||
## Predicate evaluation
|
||||
|
||||
Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvaluator<AlarmPredicateContext, bool>` compiles the source, `TimedScriptEvaluator` wraps it with the configured timeout (default from `TimedScriptEvaluator.DefaultTimeout`), and `DependencyExtractor` statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in [VirtualTags.md](VirtualTags.md); ScriptedAlarms does not redefine them. The known resource limits — unbounded script-side memory and the orphan-thread CPU-budget caveat — are documented in that file as well; per-publish assembly accretion was resolved by the Core.Scripting-008 collectible-`AssemblyLoadContext` rewrite and no longer requires periodic server restarts.
|
||||
Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvaluator<AlarmPredicateContext, bool>` compiles the source, `ScriptRunGuard` runs it with the node's `Scripting:RunTimeout` budget (default 2 s — the same budget virtual tags get; before lmxopcua#779 the host built the engine without a budget and every predicate silently ran on `TimedScriptEvaluator.DefaultTimeout`, 250 ms), and `DependencyExtractor` statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in [VirtualTags.md](VirtualTags.md); ScriptedAlarms does not redefine them. The known resource limits — unbounded script-side memory and the orphan-thread CPU-budget caveat — are documented in that file as well; per-publish assembly accretion was resolved by the Core.Scripting-008 collectible-`AssemblyLoadContext` rewrite and no longer requires periodic server restarts.
|
||||
|
||||
`AlarmPredicateContext` (`AlarmPredicateContext.cs`) is the script's `ScriptContext` subclass:
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ The sandbox cannot pre-empt a script. **Memory:** each run's own-thread allocati
|
||||
|
||||
### Per-evaluation timeout (`TimedScriptEvaluator<TContext, TResult>`)
|
||||
|
||||
Wraps `ScriptEvaluator` with a wall-clock budget. Default `DefaultTimeout = 250ms`. Implementation pushes the inner `RunAsync` onto `Task.Run` (so a CPU-bound script can't hog the calling thread before `WaitAsync` registers its timeout) then awaits `runTask.WaitAsync(Timeout, ct)`. A `TimeoutException` from `WaitAsync` is wrapped as `ScriptTimeoutException`. Caller-supplied `CancellationToken` cancellation wins over the timeout and propagates as `OperationCanceledException` — so a shutdown cancel is not misclassified. **Known leak:** when a CPU-bound script times out, the underlying compiled-script delegate keeps running on its `Task.Run` thread-pool thread until it returns of its own accord (the CT is checked only at evaluator entry; once the script body is running, only the script returning or throwing will release the thread). The post-rewrite delegate is a regular C# `Func<>` bound to the synthesized `CompiledScript.Run` method, so this is a vanilla "synchronous CPU-bound work on a pool thread" leak rather than anything Roslyn-specific. Documented trade-off; out-of-process evaluation is a v3 concern.
|
||||
Wraps `ScriptEvaluator` with a wall-clock budget. Default `DefaultTimeout = 250ms`. This wrapper is now used only by single-shot callers; the production seams run through `ScriptRunGuard` with `Scripting:RunTimeout` (default 2 s) for virtual tags and scripted-alarm predicates, and the per-driver `runTimeoutMs` (default 2 s) for Calculation tags (lmxopcua#779 — the alarm engine used to inherit this 250 ms default by omission). Implementation pushes the inner `RunAsync` onto `Task.Run` (so a CPU-bound script can't hog the calling thread before `WaitAsync` registers its timeout) then awaits `runTask.WaitAsync(Timeout, ct)`. A `TimeoutException` from `WaitAsync` is wrapped as `ScriptTimeoutException`. Caller-supplied `CancellationToken` cancellation wins over the timeout and propagates as `OperationCanceledException` — so a shutdown cancel is not misclassified. **Known leak:** when a CPU-bound script times out, the underlying compiled-script delegate keeps running on its `Task.Run` thread-pool thread until it returns of its own accord (the CT is checked only at evaluator entry; once the script body is running, only the script returning or throwing will release the thread). The post-rewrite delegate is a regular C# `Func<>` bound to the synthesized `CompiledScript.Run` method, so this is a vanilla "synchronous CPU-bound work on a pool thread" leak rather than anything Roslyn-specific. Documented trade-off; out-of-process evaluation is a v3 concern.
|
||||
|
||||
### Script logger plumbing
|
||||
|
||||
|
||||
@@ -238,7 +238,10 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <param name="loggerFactory">The factory for creating alarm loggers.</param>
|
||||
/// <param name="engineLogger">The logger for engine-level diagnostics.</param>
|
||||
/// <param name="clock">Optional function providing the current UTC time; defaults to DateTime.UtcNow.</param>
|
||||
/// <param name="scriptTimeout">Optional timeout for script execution; defaults to the evaluator's default timeout.</param>
|
||||
/// <param name="scriptTimeout">Optional per-predicate wall-clock budget; null ⇒ <see cref="ScriptingOptions.RunTimeout"/>
|
||||
/// of <paramref name="scripting"/> (2 s by default — the same budget virtual tags get). lmxopcua#779: the null
|
||||
/// default used to be <c>TimedScriptEvaluator.DefaultTimeout</c> (250 ms), which is what every production engine
|
||||
/// silently ran with because the host never passed a value.</param>
|
||||
/// <param name="scripting">Run-guard budget + quarantine threshold; null ⇒ <see cref="ScriptingOptions"/> defaults.</param>
|
||||
public ScriptedAlarmEngine(
|
||||
ITagUpstreamSource upstream,
|
||||
@@ -254,7 +257,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<AlarmPredicateContext, bool>.DefaultTimeout;
|
||||
_scriptTimeout = scriptTimeout ?? (scripting ?? new ScriptingOptions()).Sanitized(out _).RunTimeout;
|
||||
_guard = new ScriptRunGuard(scripting, _engineLogger);
|
||||
_shutdownToken = _shutdownCts.Token;
|
||||
}
|
||||
@@ -262,6 +265,9 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <summary>The run guard every predicate evaluation goes through (diagnostics / tests).</summary>
|
||||
public ScriptRunGuard RunGuard => _guard;
|
||||
|
||||
/// <summary>The wall-clock budget each predicate run is given (diagnostics / tests).</summary>
|
||||
public TimeSpan ScriptTimeout => _scriptTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>BadInternalError</c> — the quality a condition is annotated with while its predicate
|
||||
/// cannot be evaluated (timed out, refused because the previous run is still executing, or
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,7 +57,8 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
|
||||
/// <summary>Initializes a new RoslynVirtualTagEvaluator with the given loggers and optional timeout.</summary>
|
||||
/// <param name="logger">Logger for recording compilation and execution errors (host diagnostics).</param>
|
||||
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
||||
/// <param name="runTimeout">Maximum execution time for each script; defaults to 2 seconds if not specified.</param>
|
||||
/// <param name="runTimeout">Maximum execution time for each script; null ⇒ <see cref="ScriptingOptions.RunTimeout"/>
|
||||
/// of <paramref name="scripting"/> (2 s by default).</param>
|
||||
/// <param name="scripting">Run-guard budget + quarantine threshold (the <c>Scripting</c> section); null ⇒ defaults.</param>
|
||||
public RoslynVirtualTagEvaluator(
|
||||
ILogger<RoslynVirtualTagEvaluator> logger,
|
||||
@@ -67,7 +68,7 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
|
||||
{
|
||||
_logger = logger;
|
||||
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
_runTimeout = runTimeout ?? (scripting ?? new ScriptingOptions()).Sanitized(out _).RunTimeout;
|
||||
_guard = new ScriptRunGuard(scripting, _scriptRoot);
|
||||
}
|
||||
|
||||
|
||||
@@ -367,8 +367,9 @@ if (hasDriver)
|
||||
// F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression).
|
||||
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
|
||||
// scripts at runtime.
|
||||
// Node-wide script run-guard knobs (Scripting:MaxAllocBytesPerRun / QuarantineAfterConsecutiveTimeouts /
|
||||
// StuckOrphanQuarantineFactor). Bound once and clamped into sane ranges here, so an absurd value is a startup
|
||||
// Node-wide script run-guard knobs (Scripting:RunTimeout / MaxAllocBytesPerRun / QuarantineAfterConsecutiveTimeouts /
|
||||
// StuckOrphanQuarantineFactor). RunTimeout is the per-run budget of virtual tags and scripted-alarm predicates
|
||||
// (lmxopcua#779 — the alarm engine used to fall back to a 250 ms default instead). Bound once and clamped into sane ranges here, so an absurd value is a startup
|
||||
// Warning rather than an OverflowException inside a script run. The one instance is shared by every script
|
||||
// seam on the node: the virtual-tag evaluator below, the scripted-alarm engine (DriverHostActor, via
|
||||
// WithOtOpcUaRuntimeActors) and every Calculation driver (DriverFactoryBootstrap).
|
||||
|
||||
@@ -1248,14 +1248,34 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
() =>
|
||||
{
|
||||
var upstream = new DependencyMuxTagUpstreamSource();
|
||||
var engine = new ScriptedAlarmEngine(
|
||||
upstream, store, new ScriptLoggerFactory(scriptLogger), scriptLogger, scripting: scripting);
|
||||
return (upstream, engine);
|
||||
return (upstream, CreateScriptedAlarmEngine(upstream, store, scriptLogger, scripting));
|
||||
},
|
||||
_localNode, telemetryHub: _telemetryHub),
|
||||
"scripted-alarm-host");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the scripted-alarm engine the alarm host owns, with the node's <c>Scripting:RunTimeout</c> as the
|
||||
/// per-predicate budget. lmxopcua#779: the engine used to be built without a budget and fell back to the
|
||||
/// 250 ms <c>TimedScriptEvaluator.DefaultTimeout</c>, an eighth of what a virtual tag reading the same
|
||||
/// inputs gets. Passed explicitly (not left to the engine's own null default) so the host is the one place
|
||||
/// that decides it.
|
||||
/// </summary>
|
||||
/// <param name="upstream">The engine's upstream tag source.</param>
|
||||
/// <param name="store">The condition-state store.</param>
|
||||
/// <param name="scriptLogger">The root script logger.</param>
|
||||
/// <param name="scripting">The node-wide <c>Scripting</c> options; null ⇒ defaults.</param>
|
||||
/// <returns>A new, unloaded engine.</returns>
|
||||
internal static ScriptedAlarmEngine CreateScriptedAlarmEngine(
|
||||
ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.ITagUpstreamSource upstream, IAlarmStateStore store,
|
||||
Serilog.ILogger scriptLogger, ScriptingOptions? scripting)
|
||||
{
|
||||
var budget = (scripting ?? new ScriptingOptions()).Sanitized(out _).RunTimeout;
|
||||
return new ScriptedAlarmEngine(
|
||||
upstream, store, new ScriptLoggerFactory(scriptLogger), scriptLogger,
|
||||
scriptTimeout: budget, scripting: scripting);
|
||||
}
|
||||
|
||||
private void Bootstrap()
|
||||
{
|
||||
// Per-cluster mesh Phase 3: a FetchAndCache node NEVER reads central SQL for config, at boot
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#779 — an engine built without an explicit <c>scriptTimeout</c> (which is how the host built every
|
||||
/// production engine) ran its predicates on the 250 ms Phase-7 default instead of the 2 s budget virtual tags
|
||||
/// and Calculation tags get. The default is now <see cref="ScriptingOptions.RunTimeout"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmEngineBudgetTests
|
||||
{
|
||||
/// <summary>A predicate that busy-waits ~600 ms and then fires: over the old 250 ms budget, well inside 2 s.</summary>
|
||||
private const string SixHundredMsPredicate =
|
||||
"""var t = ctx.GetTag("Temp"); var end = System.DateTime.UtcNow.AddMilliseconds(600); while (System.DateTime.UtcNow < end) { } return true;""";
|
||||
|
||||
private static ScriptedAlarmEngine Build(FakeUpstream up, ScriptingOptions? scripting = null)
|
||||
{
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
return new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger,
|
||||
scripting: scripting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_an_explicit_timeout_the_budget_is_the_Scripting_RunTimeout_default_not_250ms()
|
||||
{
|
||||
using var eng = Build(new FakeUpstream());
|
||||
eng.ScriptTimeout.ShouldBe(ScriptingOptions.DefaultRunTimeout);
|
||||
eng.ScriptTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_configured_RunTimeout_is_the_budget()
|
||||
{
|
||||
using var eng = Build(new FakeUpstream(), new ScriptingOptions { RunTimeout = TimeSpan.FromSeconds(7) });
|
||||
eng.ScriptTimeout.ShouldBe(TimeSpan.FromSeconds(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_predicate_slower_than_250ms_but_inside_the_budget_completes_and_activates()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up);
|
||||
|
||||
await eng.LoadAsync(
|
||||
[new ScriptedAlarmDefinition(AlarmId: "slow", EquipmentPath: "Plant/Line1/Reactor", AlarmName: "slow",
|
||||
Kind: AlarmKind.AlarmCondition, Severity: AlarmSeverity.High, MessageTemplate: "m",
|
||||
PredicateScriptSource: SixHundredMsPredicate)],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// LoadAsync evaluates once (startup recovery). Under the old 250 ms default that run timed out and the
|
||||
// condition stayed Inactive with a Bad annotation.
|
||||
eng.RunGuard.StartedRunCount.ShouldBe(1);
|
||||
eng.GetState("slow")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -24,6 +24,7 @@ public sealed class ScriptingOptionsBindingTests
|
||||
["Scripting:MaxAllocBytesPerRun"] = "8388608",
|
||||
["Scripting:QuarantineAfterConsecutiveTimeouts"] = "7",
|
||||
["Scripting:StuckOrphanQuarantineFactor"] = "4.5",
|
||||
["Scripting:RunTimeout"] = "00:00:05",
|
||||
// Sibling keys owned by the script-log pipeline must not disturb the bind.
|
||||
["Scripting:LogFilePath"] = "logs/x-.log",
|
||||
["Scripting:LogTopicMinLevel"] = "Warning",
|
||||
@@ -33,6 +34,7 @@ public sealed class ScriptingOptionsBindingTests
|
||||
bound.MaxAllocBytesPerRun.ShouldBe(8L * 1024 * 1024);
|
||||
bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
|
||||
bound.StuckOrphanQuarantineFactor.ShouldBe(4.5);
|
||||
bound.RunTimeout.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -43,12 +45,27 @@ public sealed class ScriptingOptionsBindingTests
|
||||
["Scripting:StuckOrphanQuarantineFactor"] = "1e300",
|
||||
["Scripting:MaxAllocBytesPerRun"] = "1024",
|
||||
["Scripting:QuarantineAfterConsecutiveTimeouts"] = "0",
|
||||
["Scripting:RunTimeout"] = "00:10:00",
|
||||
}), out var adjustments);
|
||||
|
||||
bound.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.MaxStuckOrphanQuarantineFactor);
|
||||
bound.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.MinMaxAllocBytesPerRun);
|
||||
bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(1);
|
||||
adjustments.Count.ShouldBe(3);
|
||||
bound.RunTimeout.ShouldBe(ScriptingOptions.MaxRunTimeout);
|
||||
adjustments.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#779: a non-positive budget is not "no timeout"; it falls back to the default.</summary>
|
||||
[Theory]
|
||||
[InlineData("00:00:00")]
|
||||
[InlineData("-00:00:01")]
|
||||
public void A_non_positive_RunTimeout_becomes_the_default_and_is_reported(string value)
|
||||
{
|
||||
var bound = ScriptingOptionsBinding.Bind(Config(new() { ["Scripting:RunTimeout"] = value }), out var adjustments);
|
||||
|
||||
bound.RunTimeout.ShouldBe(ScriptingOptions.DefaultRunTimeout);
|
||||
adjustments.Count.ShouldBe(1);
|
||||
adjustments[0].ShouldContain("RunTimeout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -60,5 +77,6 @@ public sealed class ScriptingOptionsBindingTests
|
||||
bound.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun);
|
||||
bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
|
||||
bound.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor);
|
||||
bound.RunTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#779 — the engine the driver host builds for its scripted-alarm host runs predicates on the node's
|
||||
/// <c>Scripting:RunTimeout</c>, not on the 250 ms Phase-7 default it used to fall back to.
|
||||
/// </summary>
|
||||
public sealed class ScriptedAlarmEngineBudgetWiringTests
|
||||
{
|
||||
private static readonly ILogger Logger = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
[Fact]
|
||||
public void The_host_built_engine_uses_the_configured_RunTimeout()
|
||||
{
|
||||
using var engine = DriverHostActor.CreateScriptedAlarmEngine(
|
||||
new DependencyMuxTagUpstreamSource(), new InMemoryAlarmStateStore(), Logger,
|
||||
new ScriptingOptions { RunTimeout = TimeSpan.FromSeconds(5) });
|
||||
|
||||
engine.ScriptTimeout.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_Scripting_options_the_host_built_engine_gets_the_2s_default_not_250ms()
|
||||
{
|
||||
using var engine = DriverHostActor.CreateScriptedAlarmEngine(
|
||||
new DependencyMuxTagUpstreamSource(), new InMemoryAlarmStateStore(), Logger, scripting: null);
|
||||
|
||||
engine.ScriptTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
engine.ScriptTimeout.ShouldNotBe(TimedScriptEvaluator<AlarmPredicateContext, bool>.DefaultTimeout);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user