From d683b831563f8e14b033ab8e346401f70e9d8f52 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 17:12:39 -0500 Subject: [PATCH] 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 --- docs/ScriptEditor.md | 3 +- docs/ScriptedAlarms.md | 2 +- docs/VirtualTags.md | 2 +- .../ScriptedAlarmEngine.cs | 10 ++- .../ScriptingOptions.cs | 33 ++++++++++ .../Engines/RoslynVirtualTagEvaluator.cs | 5 +- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 5 +- .../Drivers/DriverHostActor.cs | 26 +++++++- .../ScriptedAlarmEngineBudgetTests.cs | 61 +++++++++++++++++++ .../ScriptingOptionsBindingTests.cs | 20 +++++- .../ScriptedAlarmEngineBudgetWiringTests.cs | 38 ++++++++++++ 11 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineBudgetTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmEngineBudgetWiringTests.cs diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index c72e83f5..5959e204 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -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 | diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md index 9b20a7b9..171a6f45 100644 --- a/docs/ScriptedAlarms.md +++ b/docs/ScriptedAlarms.md @@ -47,7 +47,7 @@ new ScriptedAlarmDefinition( ## Predicate evaluation -Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvaluator` 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` 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: diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md index 69297dfe..03a0fd10 100644 --- a/docs/VirtualTags.md +++ b/docs/VirtualTags.md @@ -51,7 +51,7 @@ The sandbox cannot pre-empt a script. **Memory:** each run's own-thread allocati ### Per-evaluation timeout (`TimedScriptEvaluator`) -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 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index 17b513ac..d372c283 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -238,7 +238,10 @@ public sealed class ScriptedAlarmEngine : IDisposable /// The factory for creating alarm loggers. /// The logger for engine-level diagnostics. /// Optional function providing the current UTC time; defaults to DateTime.UtcNow. - /// Optional timeout for script execution; defaults to the evaluator's default timeout. + /// Optional per-predicate wall-clock budget; null ⇒ + /// of (2 s by default — the same budget virtual tags get). lmxopcua#779: the null + /// default used to be TimedScriptEvaluator.DefaultTimeout (250 ms), which is what every production engine + /// silently ran with because the host never passed a value. /// Run-guard budget + quarantine threshold; null ⇒ defaults. 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.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 /// The run guard every predicate evaluation goes through (diagnostics / tests). public ScriptRunGuard RunGuard => _guard; + /// The wall-clock budget each predicate run is given (diagnostics / tests). + public TimeSpan ScriptTimeout => _scriptTimeout; + /// /// OPC UA BadInternalError — the quality a condition is annotated with while its predicate /// cannot be evaluated (timed out, refused because the previous run is still executing, or diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs index 167e124f..b6a4e48f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs @@ -19,6 +19,12 @@ public sealed class ScriptingOptions /// The appsettings section this binds from. public const string SectionName = "Scripting"; + /// Default : 2 s — the budget virtual tags and Calculation tags have always run with. + public static readonly TimeSpan DefaultRunTimeout = TimeSpan.FromSeconds(2); + + /// Upper bound applies to : 60 s. + public static readonly TimeSpan MaxRunTimeout = TimeSpan.FromSeconds(60); + /// Default : 64 MB. public const long DefaultMaxAllocBytesPerRun = 64L * 1024 * 1024; @@ -28,6 +34,18 @@ public sealed class ScriptingOptions /// Default : 10. public const double DefaultStuckOrphanQuarantineFactor = 10; + /// + /// 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 runTimeoutMs (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 , 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 + /// ( × this). Zero or negative becomes the default; above + /// becomes that maximum. + /// + public TimeSpan RunTimeout { get; set; } = DefaultRunTimeout; + /// /// Managed bytes one script run may allocate on its own thread (sampled with /// around the run) before the script is @@ -78,6 +96,8 @@ public sealed class ScriptingOptions /// : into [1, ]. /// : a negative value becomes 0 (disabled), and a positive value /// below becomes that minimum. + /// : zero or negative becomes , above + /// becomes that maximum. /// /// /// 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, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs index 8a9347d9..9fec0f2f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs @@ -57,7 +57,8 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac /// Initializes a new RoslynVirtualTagEvaluator with the given loggers and optional timeout. /// Logger for recording compilation and execution errors (host diagnostics). /// Root script logger; user ctx.Logger.* output flows through this to the Script-log page. - /// Maximum execution time for each script; defaults to 2 seconds if not specified. + /// Maximum execution time for each script; null ⇒ + /// of (2 s by default). /// Run-guard budget + quarantine threshold (the Scripting section); null ⇒ defaults. public RoslynVirtualTagEvaluator( ILogger 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); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 01c98d84..e277a94b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -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). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index c9947120..781b0348 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -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"); } + /// + /// Builds the scripted-alarm engine the alarm host owns, with the node's Scripting:RunTimeout as the + /// per-predicate budget. lmxopcua#779: the engine used to be built without a budget and fell back to the + /// 250 ms TimedScriptEvaluator.DefaultTimeout, 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. + /// + /// The engine's upstream tag source. + /// The condition-state store. + /// The root script logger. + /// The node-wide Scripting options; null ⇒ defaults. + /// A new, unloaded engine. + 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 diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineBudgetTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineBudgetTests.cs new file mode 100644 index 00000000..a07e1947 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineBudgetTests.cs @@ -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; + +/// +/// lmxopcua#779 — an engine built without an explicit scriptTimeout (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 . +/// +[Trait("Category", "Unit")] +public sealed class ScriptedAlarmEngineBudgetTests +{ + /// A predicate that busy-waits ~600 ms and then fires: over the old 250 ms budget, well inside 2 s. + 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); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs index f717d6c5..1bcd1010 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs @@ -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); + } + + /// lmxopcua#779: a non-positive budget is not "no timeout"; it falls back to the default. + [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)); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmEngineBudgetWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmEngineBudgetWiringTests.cs new file mode 100644 index 00000000..f6d934d9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmEngineBudgetWiringTests.cs @@ -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; + +/// +/// lmxopcua#779 — the engine the driver host builds for its scripted-alarm host runs predicates on the node's +/// Scripting:RunTimeout, not on the 250 ms Phase-7 default it used to fall back to. +/// +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.DefaultTimeout); + } +}