feat(scripting): bind the Scripting section node-wide and clamp its knobs
Program.cs binds ScriptingOptions from Scripting:* once, clamps every numeric knob through ScriptingOptions.Sanitized (logging a Warning per adjustment) and registers the one instance. It now reaches all three script seams: RoslynVirtualTagEvaluator, the scripted-alarm engine built in DriverHostActor (new trailing Props parameter, wired in WithOtOpcUaRuntimeActors) and every Calculation driver (node options as the base, per-driver config keys override one knob on a copy). ScriptRunGuard sanitizes again in its ctor and computes the stuck threshold with a saturating multiply, so an absurd StuckOrphanQuarantineFactor can no longer throw OverflowException on a refused run. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ScriptingOptions.Sanitized"/> — the bind-time clamp of the <c>Scripting</c> section's numeric
|
||||
/// knobs. An absurd value must be pulled into a sane range at bind time rather than surfacing later as an
|
||||
/// <see cref="OverflowException"/> inside a script run (<c>timeout * StuckOrphanQuarantineFactor</c>).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="OverflowException"/>.
|
||||
/// </summary>
|
||||
[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<FakeScriptContext, int>.Compile(spin);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var timeout = TimeSpan.FromMilliseconds(60);
|
||||
|
||||
await Should.ThrowAsync<ScriptTimeoutException>(
|
||||
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
|
||||
// The refusal path evaluates timeout * factor. Unclamped, TimeSpan * double.MaxValue throws OverflowException.
|
||||
await Should.ThrowAsync<ScriptRunRefusedException>(
|
||||
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct));
|
||||
// A huge timeout times the clamped maximum factor must not overflow either.
|
||||
await Should.ThrowAsync<ScriptRunRefusedException>(
|
||||
() => guard.RunAsync("k", spin, eval, new FakeScriptContext(), TimeSpan.FromDays(365 * 1000), ct));
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Calculation driver's run-guard options: the node-wide <c>Scripting</c> 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user