diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunGuard.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunGuard.cs
index de1afee2..7efa4026 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunGuard.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunGuard.cs
@@ -96,9 +96,11 @@ public sealed class ScriptRunGuard
/// Where the quarantine Error line goes; null ⇒ Serilog's global logger.
public ScriptRunGuard(ScriptingOptions? options = null, ILogger? logger = null)
{
- options ??= new ScriptingOptions();
+ // Clamped here as well as at the bind site, because options also arrive from a Calculation driver's own
+ // config and from tests. An unclamped factor made the refusal path's timeout * factor overflow.
+ options = (options ?? new ScriptingOptions()).Sanitized(out _);
_maxAllocBytesPerRun = options.MaxAllocBytesPerRun;
- _quarantineThreshold = Math.Max(1, options.QuarantineAfterConsecutiveTimeouts);
+ _quarantineThreshold = options.QuarantineAfterConsecutiveTimeouts;
_stuckFactor = options.StuckOrphanQuarantineFactor;
_logger = logger ?? Log.Logger;
}
@@ -197,7 +199,11 @@ public sealed class ScriptRunGuard
if (_stuckFactor > 0 && Volatile.Read(ref state.RunStartedTimestamp) is var startedAt and not 0)
{
var running = Stopwatch.GetElapsedTime(startedAt);
- var threshold = timeout * _stuckFactor;
+ // Saturating: even the clamped maximum factor times a huge timeout must not overflow TimeSpan.
+ var thresholdTicks = timeout.Ticks * _stuckFactor;
+ var threshold = thresholdTicks >= TimeSpan.MaxValue.Ticks
+ ? TimeSpan.MaxValue
+ : TimeSpan.FromTicks((long)thresholdTicks);
if (running > threshold)
{
Quarantine(key, state,
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 296d862c..167e124f 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs
@@ -53,4 +53,85 @@ public sealed class ScriptingOptions
/// refusal. Zero or negative disables the rule. Default 10.
///
public double StuckOrphanQuarantineFactor { get; set; } = DefaultStuckOrphanQuarantineFactor;
+
+ /// Upper bound applies to .
+ public const double MaxStuckOrphanQuarantineFactor = 1000;
+
+ /// Upper bound applies to .
+ public const int MaxQuarantineAfterConsecutiveTimeouts = 1000;
+
+ ///
+ /// Lower bound applies to a positive : 1 MB. A
+ /// smaller budget would quarantine practically every script on its first run.
+ ///
+ public const long MinMaxAllocBytesPerRun = 1024L * 1024;
+
+ ///
+ /// Returns a copy with every numeric knob pulled into a sane range. The bind site calls it and logs
+ /// . calls it again, so options that reach the
+ /// guard by another route (a Calculation driver's own config, a test) are clamped too.
+ ///
+ /// - : NaN becomes the default, a negative value becomes 0
+ /// (disabled), and a value above becomes that maximum.
+ /// Unclamped, an absurd factor made timeout * factor throw on
+ /// the first refused run.
+ /// - : into [1, ].
+ /// - : a negative value becomes 0 (disabled), and a positive value
+ /// below becomes that minimum.
+ ///
+ ///
+ /// One line per value that was out of range, naming its key. A negative value that
+ /// already meant "disabled" is normalised without a line.
+ /// A new, clamped instance. This instance is not modified.
+ public ScriptingOptions Sanitized(out IReadOnlyList adjustments)
+ {
+ var notes = new List();
+
+ var factor = StuckOrphanQuarantineFactor;
+ if (double.IsNaN(factor))
+ {
+ notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} is NaN; using the default {DefaultStuckOrphanQuarantineFactor}.");
+ factor = DefaultStuckOrphanQuarantineFactor;
+ }
+ else if (factor < 0)
+ {
+ factor = 0;
+ }
+ else if (factor > MaxStuckOrphanQuarantineFactor)
+ {
+ notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} {factor} is above the maximum; clamped to {MaxStuckOrphanQuarantineFactor}.");
+ factor = MaxStuckOrphanQuarantineFactor;
+ }
+
+ var threshold = QuarantineAfterConsecutiveTimeouts;
+ if (threshold < 1)
+ {
+ notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is below 1; clamped to 1.");
+ threshold = 1;
+ }
+ else if (threshold > MaxQuarantineAfterConsecutiveTimeouts)
+ {
+ notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is above the maximum; clamped to {MaxQuarantineAfterConsecutiveTimeouts}.");
+ threshold = MaxQuarantineAfterConsecutiveTimeouts;
+ }
+
+ var alloc = MaxAllocBytesPerRun;
+ if (alloc < 0)
+ {
+ alloc = 0;
+ }
+ else if (alloc > 0 && alloc < MinMaxAllocBytesPerRun)
+ {
+ notes.Add($"{SectionName}:{nameof(MaxAllocBytesPerRun)} {alloc} is below the minimum; clamped to {MinMaxAllocBytesPerRun}.");
+ alloc = MinMaxAllocBytesPerRun;
+ }
+
+ adjustments = notes;
+ return new ScriptingOptions
+ {
+ MaxAllocBytesPerRun = alloc,
+ QuarantineAfterConsecutiveTimeouts = threshold,
+ StuckOrphanQuarantineFactor = factor,
+ };
+ }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs
index 1a589d65..ce7817cf 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs
@@ -38,13 +38,16 @@ public static class CalculationDriverFactoryExtensions
/// The driver factory registry to register with.
/// Optional logger factory for per-instance diagnostics loggers.
/// Optional root script logger (fans failure entries onto the script-logs topic).
+ /// The node-wide Scripting options every Calculation driver inherits; a key
+ /// authored in a driver's own config overrides that one knob. Null ⇒ the compiled defaults.
public static void Register(
DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory = null,
- ScriptRootLogger? scriptRoot = null)
+ ScriptRootLogger? scriptRoot = null,
+ ScriptingOptions? scripting = null)
{
ArgumentNullException.ThrowIfNull(registry);
- registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot, scripting));
}
/// Public overload for the Server-side bootstrapper + test consumers.
@@ -63,6 +66,18 @@ public static class CalculationDriverFactoryExtensions
public static CalculationDriver CreateInstance(
string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
+ => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory, scriptRoot, nodeScripting: null);
+
+ /// The full overload: also takes the node-wide Scripting options the driver inherits.
+ /// The unique identifier for the driver instance.
+ /// The merged driver config JSON (carries the RawTags array).
+ /// Optional logger factory for per-instance diagnostics loggers.
+ /// Optional root script logger for failure fan-out.
+ /// The node-wide run-guard options; null ⇒ the compiled defaults.
+ /// The constructed .
+ public static CalculationDriver CreateInstance(
+ string driverInstanceId, string driverConfigJson,
+ ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot, ScriptingOptions? nodeScripting)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -71,15 +86,7 @@ public static class CalculationDriverFactoryExtensions
?? throw new InvalidOperationException(
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
- // Run-guard knobs are optional and default to the node-wide ScriptingOptions defaults; only an
- // explicitly authored key overrides one (so a config carrying neither binds Scripting = null).
- ScriptingOptions? scripting = null;
- if (dto.MaxAllocBytesPerRun is not null || dto.QuarantineAfterConsecutiveTimeouts is not null)
- {
- scripting = new ScriptingOptions();
- if (dto.MaxAllocBytesPerRun is { } alloc) scripting.MaxAllocBytesPerRun = alloc;
- if (dto.QuarantineAfterConsecutiveTimeouts is { } n) scripting.QuarantineAfterConsecutiveTimeouts = n;
- }
+ var scripting = ResolveScripting(dto.MaxAllocBytesPerRun, dto.QuarantineAfterConsecutiveTimeouts, nodeScripting);
var options = new CalculationDriverOptions
{
@@ -94,6 +101,32 @@ public static class CalculationDriverFactoryExtensions
logger: loggerFactory?.CreateLogger());
}
+ ///
+ /// The run-guard options for one Calculation driver. The node-wide options are the base. A key authored
+ /// in the driver's config overrides only its own knob, on a copy, so the shared node object is never
+ /// written. With neither, the result is null and the guard uses its compiled defaults.
+ ///
+ /// The driver config's maxAllocBytesPerRun, or null.
+ /// The driver config's quarantineAfterConsecutiveTimeouts, or null.
+ /// The node-wide options, or null.
+ /// The options to hand the driver, or null.
+ internal static ScriptingOptions? ResolveScripting(
+ long? maxAllocBytesPerRun, int? quarantineAfterConsecutiveTimeouts, ScriptingOptions? nodeScripting)
+ {
+ if (maxAllocBytesPerRun is null && quarantineAfterConsecutiveTimeouts is null) return nodeScripting;
+
+ var resolved = new ScriptingOptions();
+ if (nodeScripting is not null)
+ {
+ resolved.MaxAllocBytesPerRun = nodeScripting.MaxAllocBytesPerRun;
+ resolved.QuarantineAfterConsecutiveTimeouts = nodeScripting.QuarantineAfterConsecutiveTimeouts;
+ resolved.StuckOrphanQuarantineFactor = nodeScripting.StuckOrphanQuarantineFactor;
+ }
+ if (maxAllocBytesPerRun is { } alloc) resolved.MaxAllocBytesPerRun = alloc;
+ if (quarantineAfterConsecutiveTimeouts is { } n) resolved.QuarantineAfterConsecutiveTimeouts = n;
+ return resolved;
+ }
+
/// The merged-config DTO the calc factory binds from. The Calculation driver has no
/// backend/endpoint config — only the injected raw tags (+ an optional evaluation-timeout override).
internal sealed class CalculationDriverConfigDto
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
index 45ee0286..7cb8d869 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
@@ -68,6 +68,8 @@ public static class DriverFactoryBootstrap
// The calc driver needs the root script logger so a script failure fans out onto the
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
var scriptRoot = sp.GetService();
+ // The node-wide Scripting section (bound + clamped in Program.cs); null in test graphs ⇒ defaults.
+ var scripting = sp.GetService();
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
var secretResolver = sp.GetRequiredService();
@@ -84,7 +86,7 @@ public static class DriverFactoryBootstrap
// failures; without this it writes to NullLogger in production.
ConfigureAbTransportLogging(sp);
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
- scriptRoot);
+ scriptRoot, scripting);
return registry;
});
services.AddSingleton(sp =>
@@ -209,11 +211,12 @@ public static class DriverFactoryBootstrap
ISecretResolver secretResolver,
IDriverPrimaryRoleSource primaryRoleSource,
Driver.AbCip.AbCipTransport abCipDefaultTransport,
- ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
+ ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null,
+ ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions? scripting = null)
{
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
- Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
+ Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot, scripting);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index b2300ab6..70ecc606 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -365,10 +365,22 @@ 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
+ // 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).
+ var scriptingOptions = (builder.Configuration.GetSection(ScriptingOptions.SectionName).Get()
+ ?? new ScriptingOptions()).Sanitized(out var scriptingAdjustments);
+ foreach (var adjustment in scriptingAdjustments)
+ Log.Warning("Scripting misconfiguration detected at startup: {Adjustment}", adjustment);
+ builder.Services.AddSingleton(scriptingOptions);
+
builder.Services.AddSingleton(sp =>
new RoslynVirtualTagEvaluator(
sp.GetRequiredService().CreateLogger(),
- sp.GetRequiredService()));
+ sp.GetRequiredService(),
+ scripting: sp.GetRequiredService()));
builder.Services.AddSingleton(sp => sp.GetRequiredService());
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
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 76c82ebf..eee3a095 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -202,6 +202,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private readonly IHistoryWriter _historyWriter;
private readonly IActorRef? _virtualTagHostOverride;
private readonly ScriptRootLogger? _scriptRootLogger;
+
+ /// Node-wide script run-guard options for the scripted-alarm engine; null ⇒ compiled defaults.
+ private readonly ScriptingOptions? _scripting;
private readonly IActorRef? _scriptedAlarmHostOverride;
private readonly ILoggingAdapter _log = Context.GetLogger();
@@ -694,6 +697,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// capture to a mesh-partner Primary the cluster failure detector reports unreachable.
/// Where the OPC UA HistoryUpdate verdict is published for the node manager's
/// HistoryUpdate Primary gate. Null on nodes that wire no redundancy manager.
+ /// The node-wide Scripting run-guard options handed to the scripted-alarm
+ /// engine. Null ⇒ the compiled defaults.
/// The Akka.NET used to spawn this actor.
public static Props Props(
IDbContextFactory? dbFactory,
@@ -728,7 +733,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Func? sharesAlarmQueueWithLocal = null,
TimeSpan? applyOutcomeTimeout = null,
bool captureWhilePrimaryUnreachable = true,
- IHistoryUpdateRoleView? historyUpdateRoleView = null) =>
+ IHistoryUpdateRoleView? historyUpdateRoleView = null,
+ ScriptingOptions? scripting = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -741,7 +747,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
- sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView));
+ sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView,
+ scripting));
/// Initializes a new DriverHostActor with the specified dependencies.
/// Database context factory for configuration database access.
@@ -830,6 +837,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// ( — the data-plane verdict, but closed until the first
/// redundancy snapshot) is published for the node manager's HistoryUpdate Primary gate. Null on nodes that wire no
/// redundancy manager.
+ /// The node-wide Scripting run-guard options handed to the scripted-alarm
+ /// engine. Null ⇒ the compiled defaults.
public DriverHostActor(
IDbContextFactory? dbFactory,
CommonsNodeId localNode,
@@ -863,8 +872,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Func? sharesAlarmQueueWithLocal = null,
TimeSpan? applyOutcomeTimeout = null,
bool captureWhilePrimaryUnreachable = true,
- IHistoryUpdateRoleView? historyUpdateRoleView = null)
+ IHistoryUpdateRoleView? historyUpdateRoleView = null,
+ ScriptingOptions? scripting = null)
{
+ _scripting = scripting;
_captureWhilePrimaryUnreachable = captureWhilePrimaryUnreachable;
_historyUpdateRoleView = historyUpdateRoleView;
_applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout;
@@ -1181,7 +1192,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
var store = _alarmStateStore;
var upstream = new DependencyMuxTagUpstreamSource();
var engine = new ScriptedAlarmEngine(
- upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger);
+ upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger,
+ scripting: _scripting);
_scriptedAlarmHost = Context.ActorOf(
ScriptedAlarmHostActor.Props(
_opcUaPublishActor, _dependencyMux, upstream, engine, _localNode, telemetryHub: _telemetryHub),
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index e6e58419..17237391 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -638,7 +638,10 @@ public static class ServiceCollectionExtensions
sharesAlarmQueueWithLocal: BuildAlarmQueuePeerPredicate(
resolver.GetService(), roleInfo, replicationPeerHost),
// The HistoryUpdate verdict (closed until the first redundancy snapshot).
- historyUpdateRoleView: resolver.GetService()),
+ historyUpdateRoleView: resolver.GetService(),
+ // The node-wide Scripting section (bound + clamped in Program.cs) for the scripted-alarm
+ // engine. Unbound in test graphs ⇒ null ⇒ the compiled defaults.
+ scripting: resolver.GetService()),
DriverHostActorName);
registry.Register(driverHost);
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptingOptionsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptingOptionsTests.cs
new file mode 100644
index 00000000..c702854a
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptingOptionsTests.cs
@@ -0,0 +1,109 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
+
+///
+/// — the bind-time clamp of the Scripting section's numeric
+/// knobs. An absurd value must be pulled into a sane range at bind time rather than surfacing later as an
+/// inside a script run (timeout * StuckOrphanQuarantineFactor).
+///
+[Trait("Category", "Unit")]
+public sealed class ScriptingOptionsTests
+{
+ [Fact]
+ public void Defaults_pass_through_unchanged_with_no_adjustments()
+ {
+ var sanitized = new ScriptingOptions().Sanitized(out var adjustments);
+
+ adjustments.ShouldBeEmpty();
+ sanitized.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun);
+ sanitized.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
+ sanitized.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor);
+ }
+
+ [Theory]
+ [InlineData(double.MaxValue, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
+ [InlineData(double.PositiveInfinity, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
+ [InlineData(1e300, ScriptingOptions.MaxStuckOrphanQuarantineFactor)]
+ [InlineData(double.NaN, ScriptingOptions.DefaultStuckOrphanQuarantineFactor)]
+ [InlineData(-5, 0)]
+ [InlineData(double.NegativeInfinity, 0)]
+ [InlineData(0, 0)]
+ [InlineData(2.5, 2.5)]
+ public void StuckOrphanQuarantineFactor_is_clamped(double configured, double expected)
+ {
+ var sanitized = new ScriptingOptions { StuckOrphanQuarantineFactor = configured }.Sanitized(out var adjustments);
+
+ sanitized.StuckOrphanQuarantineFactor.ShouldBe(expected);
+ // Negative already means "disabled" and is documented as such; normalising it to 0 is not worth a warning.
+ if (!double.IsNaN(configured) && configured >= 0 && configured <= ScriptingOptions.MaxStuckOrphanQuarantineFactor)
+ adjustments.ShouldBeEmpty();
+ }
+
+ [Theory]
+ [InlineData(0, 1)]
+ [InlineData(-3, 1)]
+ [InlineData(int.MaxValue, ScriptingOptions.MaxQuarantineAfterConsecutiveTimeouts)]
+ [InlineData(5, 5)]
+ public void QuarantineAfterConsecutiveTimeouts_is_clamped(int configured, int expected) =>
+ new ScriptingOptions { QuarantineAfterConsecutiveTimeouts = configured }.Sanitized(out _)
+ .QuarantineAfterConsecutiveTimeouts.ShouldBe(expected);
+
+ [Theory]
+ [InlineData(-1L, 0L)]
+ [InlineData(0L, 0L)]
+ [InlineData(1024L, ScriptingOptions.MinMaxAllocBytesPerRun)]
+ [InlineData(long.MaxValue, long.MaxValue)]
+ public void MaxAllocBytesPerRun_is_clamped(long configured, long expected) =>
+ new ScriptingOptions { MaxAllocBytesPerRun = configured }.Sanitized(out _)
+ .MaxAllocBytesPerRun.ShouldBe(expected);
+
+ [Fact]
+ public void An_out_of_range_value_is_reported_as_an_adjustment_naming_the_key()
+ {
+ _ = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 }.Sanitized(out var adjustments);
+
+ adjustments.ShouldHaveSingleItem().ShouldContain("Scripting:StuckOrphanQuarantineFactor");
+ }
+
+ [Fact]
+ public void Sanitized_returns_a_copy_and_leaves_the_source_untouched()
+ {
+ var source = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 };
+
+ var sanitized = source.Sanitized(out _);
+
+ sanitized.ShouldNotBeSameAs(source);
+ source.StuckOrphanQuarantineFactor.ShouldBe(1e300);
+ }
+
+ ///
+ /// The guard takes options from more places than the bind (the Calculation driver's per-driver DTO, tests),
+ /// so it must not overflow either: an absurd factor reaching the guard directly is clamped, and a refusal
+ /// behind a young orphan is still a plain refusal rather than an .
+ ///
+ [Fact]
+ public async Task An_absurd_factor_reaching_the_guard_directly_does_not_overflow_on_a_refusal()
+ {
+ var guard = new ScriptRunGuard(new ScriptingOptions { StuckOrphanQuarantineFactor = double.MaxValue });
+ const string spin =
+ """
+ var end = System.DateTime.UtcNow.AddSeconds(3);
+ while (System.DateTime.UtcNow < end) { }
+ return 1;
+ """;
+ using var eval = ScriptEvaluator.Compile(spin);
+ var ct = TestContext.Current.CancellationToken;
+ var timeout = TimeSpan.FromMilliseconds(60);
+
+ await Should.ThrowAsync(
+ () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
+ // The refusal path evaluates timeout * factor. Unclamped, TimeSpan * double.MaxValue throws OverflowException.
+ await Should.ThrowAsync(
+ () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
+ // A huge timeout times the clamped maximum factor must not overflow either.
+ await Should.ThrowAsync(
+ () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), TimeSpan.FromDays(365 * 1000), ct));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs
new file mode 100644
index 00000000..3b187df6
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs
@@ -0,0 +1,56 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
+
+///
+/// The Calculation driver's run-guard options: the node-wide Scripting section is the base, and a key
+/// authored in the driver's own config overrides only that knob. Before the section was bound, a Calculation
+/// driver always ran on the compiled defaults whatever the node configured.
+///
+[Trait("Category", "Unit")]
+public sealed class CalculationScriptingOptionsResolutionTests
+{
+ private static readonly ScriptingOptions Node = new()
+ {
+ MaxAllocBytesPerRun = 8L * 1024 * 1024,
+ QuarantineAfterConsecutiveTimeouts = 7,
+ StuckOrphanQuarantineFactor = 4,
+ };
+
+ [Fact]
+ public void No_driver_keys_and_no_node_options_resolves_null_so_the_guard_uses_its_defaults() =>
+ CalculationDriverFactoryExtensions.ResolveScripting(null, null, null).ShouldBeNull();
+
+ [Fact]
+ public void No_driver_keys_inherits_every_node_knob()
+ {
+ var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node).ShouldNotBeNull();
+
+ resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun);
+ resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
+ resolved.StuckOrphanQuarantineFactor.ShouldBe(4);
+ }
+
+ [Fact]
+ public void A_driver_key_overrides_only_its_own_knob_and_the_node_object_is_not_mutated()
+ {
+ var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, 2, Node).ShouldNotBeNull();
+
+ resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(2);
+ resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun, "the unauthored key keeps the node value");
+ resolved.StuckOrphanQuarantineFactor.ShouldBe(4, "the stuck factor has no driver key; it is node-wide");
+ resolved.ShouldNotBeSameAs(Node);
+ Node.QuarantineAfterConsecutiveTimeouts.ShouldBe(7);
+ }
+
+ [Fact]
+ public void A_driver_key_with_no_node_options_layers_on_the_compiled_defaults()
+ {
+ var resolved = CalculationDriverFactoryExtensions.ResolveScripting(0L, null, null).ShouldNotBeNull();
+
+ resolved.MaxAllocBytesPerRun.ShouldBe(0);
+ resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
+ }
+}