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, }; } }