namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
///
/// Node-wide script runtime-safety knobs (the Scripting appsettings section), consumed by
/// . 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.
///
///
///
/// The in-process sandbox cannot pre-empt a script: a while(true) is abandoned on its pool
/// thread at the timeout, and an allocation is observed only after the run returns. These knobs bound
/// the damage — 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.
///
///
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;
/// Default : 3.
public const int DefaultQuarantineAfterConsecutiveTimeouts = 3;
/// 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
/// quarantined. Zero or negative disables the budget. Default 64 MB.
///
public long MaxAllocBytesPerRun { get; set; } = DefaultMaxAllocBytesPerRun;
///
/// 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.
///
public int QuarantineAfterConsecutiveTimeouts { get; set; } = DefaultQuarantineAfterConsecutiveTimeouts;
///
/// 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 (while(true)): it times out once and is then
/// only ever refused, so the timeout streak alone would never reach
/// . A young orphan (a short overrun) is still just a
/// refusal. Zero or negative disables the rule. Default 10.
///
public double StuckOrphanQuarantineFactor { get; set; } = DefaultStuckOrphanQuarantineFactor;
///
/// Where compiled scripts run (Scripting:Runner, lmxopcua#780). Default
/// — today's path, unchanged, and the rollback for the other modes.
///
public ScriptRunnerMode Runner { get; set; } = ScriptRunnerMode.InProcess;
///
/// The seams applies to when it is not
/// (Scripting:Runner:Seams, e.g. ["Calculation"]). Null or empty means VirtualTag and Calculation:
/// scripted alarms run out of process only when listed explicitly (design §14 #10, alarms last). Bound by
/// ScriptingOptionsBinding from the documented key, because Scripting:Runner is itself a value.
///
public IReadOnlyList? RunnerSeams { get; set; }
/// The out-of-process worker pool's knobs (Scripting:Worker).
public ScriptWorkerOptions Worker { get; set; } = new();
/// The shadow comparison's knobs (Scripting:Shadow), for .
public ScriptShadowOptions Shadow { get; set; } = new();
/// Whether is selected for out-of-process execution by these options.
/// The seam.
/// true when is and the seam is in
/// (or that list is empty and the seam is not ).
public bool IsOutOfProcess(ScriptSeam seam)
=> Runner == ScriptRunnerMode.OutOfProcess
&& (RunnerSeams is { Count: > 0 } seams ? seams.Contains(seam) : seam != ScriptSeam.ScriptedAlarm);
/// 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.
/// - : zero or negative becomes , above
/// becomes that maximum.
///
///
/// 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;
}
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;
}
var worker = (Worker ?? new ScriptWorkerOptions()).Sanitized(out var workerNotes);
notes.AddRange(workerNotes);
var shadow = (Shadow ?? new ScriptShadowOptions()).Sanitized(out var shadowNotes);
notes.AddRange(shadowNotes);
adjustments = notes;
return new ScriptingOptions
{
RunTimeout = runTimeout,
MaxAllocBytesPerRun = alloc,
QuarantineAfterConsecutiveTimeouts = threshold,
StuckOrphanQuarantineFactor = factor,
Runner = Runner,
RunnerSeams = RunnerSeams is null ? null : [.. RunnerSeams],
Worker = worker,
Shadow = shadow,
};
}
}