namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
///
/// Where compiled scripts run (Scripting:Runner, lmxopcua#780). is the default
/// and the documented rollback: it is today's path, unchanged.
///
public enum ScriptRunnerMode
{
/// Compiled scripts run in the host process under (the default).
InProcess = 0,
///
/// Run in process — that result is the one published — AND mirror each evaluation of the selected seams to the
/// worker pool, comparing the two (design P2, ). A time-boxed diagnostic: it
/// doubles script CPU and keeps the in-process risk. Nothing on the shadow side reaches a published value or
/// readiness.
///
Shadow = 1,
///
/// Compiled scripts of the selected seams run in the pre-emptible ScriptWorker process pool. The
/// runner fails closed: a worker that cannot start makes the script Bad
/// (BadResourceUnavailable), never an in-process run.
///
OutOfProcess = 2,
}
/// The three host seams that run operator-authored C# (Scripting:Runner:Seams).
public enum ScriptSeam
{
/// Virtual tags (RoslynVirtualTagEvaluator).
VirtualTag = 0,
/// Calculation tags (CalculationEvaluator).
Calculation = 1,
///
/// Scripted-alarm predicates (ScriptedAlarmEngine). Last in the rollout (design §14 #10): out of process only
/// when Scripting:Runner:Seams lists it — an unset list does not select it.
///
ScriptedAlarm = 2,
}
///
/// The out-of-process worker pool's knobs (the Scripting:Worker section). Every value is clamped by
/// , which reports each out-of-range key as a startup Warning, like the rest of the
/// Scripting section. Only consulted when is not
/// .
///
public sealed class ScriptWorkerOptions
{
/// Default : 2 (design §14 #1).
public const int DefaultShards = 2;
/// Upper bound of .
public const int MaxShards = 8;
/// Default : 512 MiB.
public const long DefaultHeapLimitBytes = 512L * 1024 * 1024;
/// Lower bound of : 64 MiB (a worker below this cannot host Roslyn).
public const long MinHeapLimitBytes = 64L * 1024 * 1024;
/// Default : 2.
public const int DefaultQuarantineAfterKills = 2;
/// Default : 5 s.
public static readonly TimeSpan DefaultUnavailableGrace = TimeSpan.FromSeconds(5);
/// Lower bound of an explicit : 250 ms.
public static readonly TimeSpan MinHardGrace = TimeSpan.FromMilliseconds(250);
/// Upper bound of an explicit : 30 s.
public static readonly TimeSpan MaxHardGrace = TimeSpan.FromSeconds(30);
/// Upper bound of : 60 s.
public static readonly TimeSpan MaxUnavailableGrace = TimeSpan.FromSeconds(60);
/// Default : 20.
public const int DefaultIsolationProbationRuns = 20;
/// Default : 10 min (design §3.3).
public static readonly TimeSpan DefaultIsolationIdleTimeout = TimeSpan.FromMinutes(10);
/// Default : 2 min (design §5.3.6).
public static readonly TimeSpan DefaultCircuitOpenAfter = TimeSpan.FromMinutes(2);
/// Shard workers per node; a script is placed by a stable hash of its key. Clamped to 1–8.
public int Shards { get; set; } = DefaultShards;
///
/// Grace after the soft deadline T before the worker running the script is killed. Null (the default)
/// means max(1 s, T) per run (design §14 #2); an explicit value is clamped to 250 ms – 30 s.
///
public TimeSpan? HardGrace { get; set; }
/// The worker's DOTNET_GCHeapHardLimit. Default 512 MiB, minimum 64 MiB.
public long HeapLimitBytes { get; set; } = DefaultHeapLimitBytes;
/// Hard-deadline kills (within one hour) after which a script is quarantined. Default 2; 1–100.
public int QuarantineAfterKills { get; set; } = DefaultQuarantineAfterKills;
///
/// How long an evaluation waits for its shard to become ready (a restart, a backoff) before it reports
/// script runner unavailable. Default 5 s; 0–60 s.
///
public TimeSpan UnavailableGrace { get; set; } = DefaultUnavailableGrace;
///
/// Successful runs a suspect script must complete in the isolation worker before it returns to its shard.
/// Default 20; 1–10 000.
///
public int IsolationProbationRuns { get; set; } = DefaultIsolationProbationRuns;
///
/// How long the isolation worker may sit with nothing to run before it is stopped (it is started again by the
/// next suspect). Default 10 min; 1 s – 24 h.
///
public TimeSpan IsolationIdleTimeout { get; set; } = DefaultIsolationIdleTimeout;
///
/// The crash-loop circuit (design §5.3.6): when every shard worker has been down for this long, the runner
/// reports Unavailable, logs one Error and answers evaluations at once (no
/// wait) while it keeps retrying at the backoff cap. Default 2 min; 1 s – 1 h.
///
public TimeSpan CircuitOpenAfter { get; set; } = DefaultCircuitOpenAfter;
///
/// Directory of the node-local compiled-PE cache (design §7): HMAC-authenticated PE images, so a worker restart
/// does not re-enter Roslyn. Must be absolute and outside the content root when set; startup fails otherwise
/// (the family's fail-closed path rule). Null (the default) runs without a cache: every worker start recompiles.
///
public string? PeCacheDir { get; set; }
///
/// Path of the worker entry assembly (ZB.MOM.WW.OtOpcUa.ScriptWorker.dll). Null (the default) means
/// script-worker/ under the host's base directory, where the Host build places it.
///
public string? ExecutablePath { get; set; }
///
/// The worker's identity on Windows (design §6, owner decision §14 #5): NT SERVICE\<name> — the virtual
/// account scripts/install/Install-ScriptWorkerIsolation.ps1 registers, by default
/// NT SERVICE\OtOpcUaScriptWorker — or a SID string. When set, each worker starts under a restricted token
/// derived from the host's (see ): every privilege removed, and every access check
/// must ALSO pass for this identity, Everyone, BUILTIN\Users or RESTRICTED. The pipe ACL then
/// admits the host account and this identity only. Null (the default) keeps the host's own token. Windows only:
/// startup refuses it elsewhere.
///
public string? Identity { get; set; }
/// The hard grace that applies to a run with soft budget .
/// The run's soft deadline T.
/// when set, otherwise max(1 s, T).
public TimeSpan EffectiveHardGrace(TimeSpan softTimeout)
=> HardGrace ?? (softTimeout > TimeSpan.FromSeconds(1) ? softTimeout : TimeSpan.FromSeconds(1));
/// Returns a clamped copy; one line per out-of-range key in .
/// The clamps applied, naming each key.
/// A new, clamped instance.
public ScriptWorkerOptions Sanitized(out IReadOnlyList adjustments)
{
const string prefix = ScriptingOptions.SectionName + ":Worker:";
var notes = new List();
var shards = Shards;
if (shards < 1 || shards > MaxShards)
{
var clamped = Math.Clamp(shards, 1, MaxShards);
notes.Add($"{prefix}{nameof(Shards)} {shards} is outside 1–{MaxShards}; clamped to {clamped}.");
shards = clamped;
}
var grace = HardGrace;
if (grace is { } g && (g < MinHardGrace || g > MaxHardGrace))
{
var clamped = g < MinHardGrace ? MinHardGrace : MaxHardGrace;
notes.Add($"{prefix}{nameof(HardGrace)} {g} is outside {MinHardGrace}–{MaxHardGrace}; clamped to {clamped}.");
grace = clamped;
}
var heap = HeapLimitBytes;
if (heap < MinHeapLimitBytes)
{
notes.Add($"{prefix}{nameof(HeapLimitBytes)} {heap} is below the minimum; clamped to {MinHeapLimitBytes}.");
heap = MinHeapLimitBytes;
}
var kills = QuarantineAfterKills;
if (kills < 1 || kills > 100)
{
var clamped = Math.Clamp(kills, 1, 100);
notes.Add($"{prefix}{nameof(QuarantineAfterKills)} {kills} is outside 1–100; clamped to {clamped}.");
kills = clamped;
}
var unavailable = UnavailableGrace;
if (unavailable < TimeSpan.Zero || unavailable > MaxUnavailableGrace)
{
var clamped = unavailable < TimeSpan.Zero ? TimeSpan.Zero : MaxUnavailableGrace;
notes.Add($"{prefix}{nameof(UnavailableGrace)} {unavailable} is outside 0–{MaxUnavailableGrace}; clamped to {clamped}.");
unavailable = clamped;
}
var probation = IsolationProbationRuns;
if (probation < 1 || probation > 10_000)
{
var clamped = Math.Clamp(probation, 1, 10_000);
notes.Add($"{prefix}{nameof(IsolationProbationRuns)} {probation} is outside 1–10000; clamped to {clamped}.");
probation = clamped;
}
var idle = IsolationIdleTimeout;
if (idle < TimeSpan.FromSeconds(1) || idle > TimeSpan.FromHours(24))
{
var clamped = idle < TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : TimeSpan.FromHours(24);
notes.Add($"{prefix}{nameof(IsolationIdleTimeout)} {idle} is outside 00:00:01–1.00:00:00; clamped to {clamped}.");
idle = clamped;
}
var circuit = CircuitOpenAfter;
if (circuit < TimeSpan.FromSeconds(1) || circuit > TimeSpan.FromHours(1))
{
var clamped = circuit < TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : TimeSpan.FromHours(1);
notes.Add($"{prefix}{nameof(CircuitOpenAfter)} {circuit} is outside 00:00:01–01:00:00; clamped to {clamped}.");
circuit = clamped;
}
adjustments = notes;
return new ScriptWorkerOptions
{
IsolationIdleTimeout = idle,
CircuitOpenAfter = circuit,
Shards = shards,
HardGrace = grace,
HeapLimitBytes = heap,
QuarantineAfterKills = kills,
UnavailableGrace = unavailable,
IsolationProbationRuns = probation,
PeCacheDir = PeCacheDir,
ExecutablePath = ExecutablePath,
Identity = Identity,
};
}
///
/// Fail-closed path validation (design §7): , when set, must be absolute and must not
/// sit inside . Same rule as the family's Secrets:SqlitePath.
///
/// The host's content root.
/// The violation, or null when the options are acceptable.
public string? ValidatePaths(string contentRootPath)
{
if (string.IsNullOrWhiteSpace(PeCacheDir)) return null;
const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(PeCacheDir);
if (!Path.IsPathFullyQualified(PeCacheDir))
return $"{key} '{PeCacheDir}' must be an absolute path.";
var dir = Path.TrimEndingDirectorySeparator(Path.GetFullPath(PeCacheDir));
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(contentRootPath));
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (string.Equals(dir, root, comparison)
|| dir.StartsWith(root + Path.DirectorySeparatorChar, comparison)
|| dir.StartsWith(root + Path.AltDirectorySeparatorChar, comparison))
return $"{key} '{PeCacheDir}' is inside the content root '{contentRootPath}'; it must live outside it.";
return null;
}
///
/// Fail-closed identity validation: , when set, must be NT SERVICE\<name> or a SID
/// string, and the host must run on Windows.
///
/// Whether the host runs on Windows (callers pass ).
/// The violation, or null when the identity is unset or acceptable.
public string? ValidateIdentity(bool isWindows)
{
if (string.IsNullOrWhiteSpace(Identity)) return null;
const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(Identity);
if (!isWindows)
return $"{key} '{Identity}' is only supported on Windows (it is a restricted token); unset it on this host.";
return ScriptWorkerIdentity.TryResolveSid(Identity, out _, out var error) ? null : $"{key} '{Identity}': {error}";
}
}
///
/// The shadow comparison's knobs (the Scripting:Shadow section, lmxopcua#780 P2). Only consulted when
/// is .
///
public sealed class ScriptShadowOptions
{
/// Default : 256.
public const int DefaultMaxInFlight = 256;
/// Default : 20.
public const int DefaultMismatchLogsPerMinute = 20;
///
/// Shadow runs outstanding at once (all seams). An evaluation that would exceed it is not mirrored and is counted
/// as dropped (reason = behind): the shadow is best effort and never queues without bound. Default 256;
/// 1–100 000.
///
public int MaxInFlight { get; set; } = DefaultMaxInFlight;
///
/// Mismatch Warning lines per minute (each script logs once per mismatch reason until its seam redeploys; this
/// caps a burst of many scripts). Default 20; 0 (counted, never logged) – 10 000.
///
public int MismatchLogsPerMinute { get; set; } = DefaultMismatchLogsPerMinute;
/// Returns a clamped copy; one line per out-of-range key in .
/// The clamps applied, naming each key.
/// A new, clamped instance.
public ScriptShadowOptions Sanitized(out IReadOnlyList adjustments)
{
const string prefix = ScriptingOptions.SectionName + ":Shadow:";
var notes = new List();
var inFlight = MaxInFlight;
if (inFlight < 1 || inFlight > 100_000)
{
var clamped = Math.Clamp(inFlight, 1, 100_000);
notes.Add($"{prefix}{nameof(MaxInFlight)} {inFlight} is outside 1–100000; clamped to {clamped}.");
inFlight = clamped;
}
var logs = MismatchLogsPerMinute;
if (logs < 0 || logs > 10_000)
{
var clamped = Math.Clamp(logs, 0, 10_000);
notes.Add($"{prefix}{nameof(MismatchLogsPerMinute)} {logs} is outside 0–10000; clamped to {clamped}.");
logs = clamped;
}
adjustments = notes;
return new ScriptShadowOptions { MaxInFlight = inFlight, MismatchLogsPerMinute = logs };
}
}