4615495f01
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
138 lines
7.2 KiB
C#
138 lines
7.2 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
/// <summary>
|
|
/// Node-wide script runtime-safety knobs (the <c>Scripting</c> appsettings section), consumed by
|
|
/// <see cref="ScriptRunGuard"/>. One instance is shared by every script seam on the node — the
|
|
/// virtual-tag evaluator, the Calculation evaluator and the scripted-alarm engine — so a single setting
|
|
/// bounds every authored script the same way.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The in-process sandbox cannot pre-empt a script: a <c>while(true)</c> is abandoned on its pool
|
|
/// thread at the timeout, and an allocation is observed only after the run returns. These knobs bound
|
|
/// the <em>damage</em> — one orphan per script, no further runs until the source changes — not the
|
|
/// first occurrence. Pre-emptive CPU/memory budgeting needs an out-of-process script runner.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ScriptingOptions
|
|
{
|
|
/// <summary>The appsettings section this binds from.</summary>
|
|
public const string SectionName = "Scripting";
|
|
|
|
/// <summary>Default <see cref="MaxAllocBytesPerRun"/>: 64 MB.</summary>
|
|
public const long DefaultMaxAllocBytesPerRun = 64L * 1024 * 1024;
|
|
|
|
/// <summary>Default <see cref="QuarantineAfterConsecutiveTimeouts"/>: 3.</summary>
|
|
public const int DefaultQuarantineAfterConsecutiveTimeouts = 3;
|
|
|
|
/// <summary>Default <see cref="StuckOrphanQuarantineFactor"/>: 10.</summary>
|
|
public const double DefaultStuckOrphanQuarantineFactor = 10;
|
|
|
|
/// <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
|
|
/// quarantined. Zero or negative disables the budget. Default 64 MB.
|
|
/// </summary>
|
|
public long MaxAllocBytesPerRun { get; set; } = DefaultMaxAllocBytesPerRun;
|
|
|
|
/// <summary>
|
|
/// Consecutive timeouts after which the script is quarantined: no further runs are dispatched, every
|
|
/// evaluation reports Bad, and the quarantine lifts only on a source change, a redeploy or an explicit
|
|
/// reset. Only a timeout is a strike — a run refused because the previous one is still executing is
|
|
/// reported Bad and counted, but neither adds to nor resets the streak; a completed run (value or
|
|
/// throw) resets it. Default 3; values below 1 are treated as 1.
|
|
/// </summary>
|
|
public int QuarantineAfterConsecutiveTimeouts { get; set; } = DefaultQuarantineAfterConsecutiveTimeouts;
|
|
|
|
/// <summary>
|
|
/// A refusal quarantines the script immediately when the abandoned run it was refused behind has been
|
|
/// executing for longer than this many times the script's timeout (20 s at the 2 s default). This is
|
|
/// what quarantines a permanently stuck script (<c>while(true)</c>): it times out once and is then
|
|
/// only ever refused, so the timeout streak alone would never reach
|
|
/// <see cref="QuarantineAfterConsecutiveTimeouts"/>. A young orphan (a short overrun) is still just a
|
|
/// refusal. Zero or negative disables the rule. Default 10.
|
|
/// </summary>
|
|
public double StuckOrphanQuarantineFactor { get; set; } = DefaultStuckOrphanQuarantineFactor;
|
|
|
|
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="StuckOrphanQuarantineFactor"/>.</summary>
|
|
public const double MaxStuckOrphanQuarantineFactor = 1000;
|
|
|
|
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="QuarantineAfterConsecutiveTimeouts"/>.</summary>
|
|
public const int MaxQuarantineAfterConsecutiveTimeouts = 1000;
|
|
|
|
/// <summary>
|
|
/// Lower bound <see cref="Sanitized"/> applies to a positive <see cref="MaxAllocBytesPerRun"/>: 1 MB. A
|
|
/// smaller budget would quarantine practically every script on its first run.
|
|
/// </summary>
|
|
public const long MinMaxAllocBytesPerRun = 1024L * 1024;
|
|
|
|
/// <summary>
|
|
/// Returns a copy with every numeric knob pulled into a sane range. The bind site calls it and logs
|
|
/// <paramref name="adjustments"/>. <see cref="ScriptRunGuard"/> calls it again, so options that reach the
|
|
/// guard by another route (a Calculation driver's own config, a test) are clamped too.
|
|
/// <list type="bullet">
|
|
/// <item><see cref="StuckOrphanQuarantineFactor"/>: NaN becomes the default, a negative value becomes 0
|
|
/// (disabled), and a value above <see cref="MaxStuckOrphanQuarantineFactor"/> becomes that maximum.
|
|
/// Unclamped, an absurd factor made <c>timeout * factor</c> throw <see cref="OverflowException"/> on
|
|
/// the first refused run.</item>
|
|
/// <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>
|
|
/// </list>
|
|
/// </summary>
|
|
/// <param name="adjustments">One line per value that was out of range, naming its key. A negative value that
|
|
/// already meant "disabled" is normalised without a line.</param>
|
|
/// <returns>A new, clamped instance. This instance is not modified.</returns>
|
|
public ScriptingOptions Sanitized(out IReadOnlyList<string> adjustments)
|
|
{
|
|
var notes = new List<string>();
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|