feat(scripting): out-of-process pre-emptible script runner behind Scripting:Runner (lmxopcua#780 P1)
v2-ci / unit-tests (push) Waiting to run
v2-ci / unit-tests (push) Waiting to run
Opt-in (Scripting:Runner=OutOfProcess; InProcess stays the default and its code path is unchanged). Implements the P1 slice of docs/plans/2026-11-09-out-of-process-script-runner-design.md with the owner's §14 decisions: - Options: Scripting:Runner (InProcess | Shadow | OutOfProcess), Scripting:Runner:Seams, Scripting:Worker:* (Shards 2, HardGrace max(1 s,T), HeapLimitBytes 512 MiB, QuarantineAfterKills 2, UnavailableGrace 5 s, IsolationProbationRuns 20, PeCacheDir validated absolute + outside the content root (fail closed), ExecutablePath). Shadow is not implemented yet: runs InProcess with a startup Warning. - Core.Scripting: IScriptRunner seam + InProcessScriptRunner (today's guard, the parity baseline). - Core.Scripting.Protocol: protobuf envelopes, 4-byte LE framing (zero/oversize/truncated/garbage refused), Variant carrying the exact CLR type; unsupported types are explicit failures. - ScriptWorker app: compiles in the worker, P-thread scheduler, host-driven soft cancel, fail-fast watchdog, heartbeats, ctx.Logger forwarding, exits on pipe EOF / parent gone, forbidden-assembly self-check; Akka and Grpc.Core.Api excluded from its folder. Shipped in script-worker/ (Build + Publish). - Core.Scripting.OutOfProcess: K shards + isolation worker, soft T / hard T+G kill, attribution (deadline kill = one key; crash = suspects -> isolation; isolation crash = strike), kill-strike quarantine, backoff 250 ms -> 30 s, fail closed, cleared allow-listed env, nonce + pid handshake on a current-user pipe, Windows Job Object. - VirtualTag + Calculation seams use the runner when selected; a runner-side failure (worker restarted / runner unavailable) publishes BadResourceUnavailable (0x80040000) — ScadaBridge#112. Script faults keep BadInternalError. The scripted-alarm seam stays in process (alarms last). Tests: new ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests (Category=ScriptWorker, real processes; green on macOS and on windev/Windows), seam tests in Host/Runtime/Calculation suites; mutation-checked. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// The execution seam every host script owner can run a compiled script through (lmxopcua#780, design §11): a
|
||||
/// script key, its source, its input snapshots and its budget go in; one <see cref="ScriptRunOutcome"/> comes
|
||||
/// out. Implementations never throw for script faults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="InProcessScriptRunner"/> is today's <see cref="ScriptRunGuard"/> + <see cref="CompiledScriptCache{TContext, TResult}"/>
|
||||
/// behind this interface (the parity baseline). The out-of-process runner
|
||||
/// (<c>ZB.MOM.WW.OtOpcUa.Core.Scripting.Runner</c>) runs the same compile in a pre-emptible worker process.
|
||||
/// The passthrough fast path (<c>PassthroughScript</c>) never reaches a runner: it runs no user code.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Keys are namespaced by <see cref="ScriptInvocation.Kind"/>, so a virtual-tag id and a calc RawPath that
|
||||
/// happen to be equal never share quarantine state, and <see cref="Reset"/> for one seam leaves the other
|
||||
/// seams' quarantines alone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IScriptRunner
|
||||
{
|
||||
/// <summary>Runs one compiled evaluation.</summary>
|
||||
/// <param name="invocation">What to run.</param>
|
||||
/// <param name="ct">Caller cancellation (shutdown); wins over the budget.</param>
|
||||
/// <returns>The outcome; never faults for a script failure.</returns>
|
||||
Task<ScriptRunOutcome> RunAsync(ScriptInvocation invocation, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// The deploy boundary for one seam: lifts every quarantine and forgets every remembered compile failure of
|
||||
/// <paramref name="kind"/>, and drops its compiled scripts.
|
||||
/// </summary>
|
||||
/// <param name="kind">The seam's context kind.</param>
|
||||
void Reset(ScriptContextKind kind);
|
||||
|
||||
/// <summary>The keys of <paramref name="kind"/> currently quarantined.</summary>
|
||||
/// <param name="kind">The seam's context kind.</param>
|
||||
/// <returns>The quarantined keys (un-namespaced).</returns>
|
||||
IReadOnlyCollection<string> QuarantinedKeys(ScriptContextKind kind);
|
||||
}
|
||||
|
||||
/// <summary>Which script context (and result type) a script compiles against.</summary>
|
||||
public enum ScriptContextKind
|
||||
{
|
||||
/// <summary>A virtual tag: <c>VirtualTagContext</c> → <c>object?</c>.</summary>
|
||||
VirtualTag = 1,
|
||||
|
||||
/// <summary>A Calculation tag: <c>VirtualTagContext</c> → <c>object?</c>.</summary>
|
||||
Calculation = 2,
|
||||
|
||||
/// <summary>A scripted-alarm predicate: <c>AlarmPredicateContext</c> → <c>bool</c> (not run out of process yet).</summary>
|
||||
AlarmPredicate = 3,
|
||||
}
|
||||
|
||||
/// <summary>One evaluation handed to an <see cref="IScriptRunner"/>.</summary>
|
||||
/// <param name="Key">The script instance key (virtual-tag id, calc RawPath, alarm id).</param>
|
||||
/// <param name="Kind">The context kind.</param>
|
||||
/// <param name="Source">The script source (also its identity: a change lifts the key's quarantine).</param>
|
||||
/// <param name="Inputs">The read cache: dependency snapshots by path. Not mutated until the run completes.</param>
|
||||
/// <param name="Timeout">The soft budget <c>T</c>.</param>
|
||||
/// <param name="ScriptLogger">The per-evaluation script logger <c>ctx.Logger</c> writes through; null ⇒ a silent logger.</param>
|
||||
public sealed record ScriptInvocation(
|
||||
string Key,
|
||||
ScriptContextKind Kind,
|
||||
string Source,
|
||||
IReadOnlyDictionary<string, DataValueSnapshot> Inputs,
|
||||
TimeSpan Timeout,
|
||||
Serilog.ILogger? ScriptLogger = null);
|
||||
|
||||
/// <summary>How an evaluation ended.</summary>
|
||||
public enum ScriptRunStatus
|
||||
{
|
||||
/// <summary>The script returned <see cref="ScriptRunOutcome.Value"/>.</summary>
|
||||
Ok = 0,
|
||||
|
||||
/// <summary>A script-attributable failure (threw, timed out, refused, quarantined, did not compile) — Bad as today.</summary>
|
||||
Failed = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The runner could not run the script, and it is not the script's fault: its worker restarted under it, or no
|
||||
/// worker became ready in time. Published as <c>BadResourceUnavailable</c> (design §9 / §14 #3).
|
||||
/// </summary>
|
||||
Unavailable = 2,
|
||||
}
|
||||
|
||||
/// <summary>The result of one <see cref="IScriptRunner.RunAsync"/>.</summary>
|
||||
/// <param name="Status">How it ended.</param>
|
||||
/// <param name="Value">The script's result when <see cref="ScriptRunStatus.Ok"/>.</param>
|
||||
/// <param name="Reason">The failure reason otherwise; the same strings the evaluators have always published.</param>
|
||||
public sealed record ScriptRunOutcome(ScriptRunStatus Status, object? Value, string? Reason)
|
||||
{
|
||||
/// <summary>Reason when the worker running the script died under it (another script's kill, a crash).</summary>
|
||||
public const string WorkerRestartedReason = "script worker restarted";
|
||||
|
||||
/// <summary>Reason when no worker became ready within <see cref="ScriptWorkerOptions.UnavailableGrace"/>.</summary>
|
||||
public const string RunnerUnavailableReason = "script runner unavailable";
|
||||
|
||||
/// <summary>A successful run.</summary>
|
||||
/// <param name="value">The script's result.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
public static ScriptRunOutcome Ok(object? value) => new(ScriptRunStatus.Ok, value, null);
|
||||
|
||||
/// <summary>A script-attributable failure.</summary>
|
||||
/// <param name="reason">The reason.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
public static ScriptRunOutcome Failed(string reason) => new(ScriptRunStatus.Failed, null, reason);
|
||||
|
||||
/// <summary>A runner-attributable failure.</summary>
|
||||
/// <param name="reason">The reason.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
public static ScriptRunOutcome Unavailable(string reason) => new(ScriptRunStatus.Unavailable, null, reason);
|
||||
}
|
||||
|
||||
/// <summary>Maps an <see cref="IScriptRunner"/> outcome onto the value seams' result type.</summary>
|
||||
public static class ScriptRunOutcomeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="ScriptRunStatus.Ok"/> → <c>Ok(value)</c>; <see cref="ScriptRunStatus.Failed"/> → <c>Failure(reason)</c>
|
||||
/// (published <c>BadInternalError</c>, as today); <see cref="ScriptRunStatus.Unavailable"/> →
|
||||
/// <c>Unavailable(reason)</c> (published <c>BadResourceUnavailable</c>).
|
||||
/// </summary>
|
||||
/// <param name="outcome">The runner's outcome.</param>
|
||||
/// <returns>The seam result.</returns>
|
||||
public static Commons.Engines.VirtualTagEvalResult ToVirtualTagEvalResult(this ScriptRunOutcome outcome)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(outcome);
|
||||
return outcome.Status switch
|
||||
{
|
||||
ScriptRunStatus.Ok => Commons.Engines.VirtualTagEvalResult.Ok(outcome.Value),
|
||||
ScriptRunStatus.Unavailable => Commons.Engines.VirtualTagEvalResult.Unavailable(
|
||||
outcome.Reason ?? ScriptRunOutcome.RunnerUnavailableReason),
|
||||
_ => Commons.Engines.VirtualTagEvalResult.Failure(outcome.Reason ?? "evaluator failure"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IScriptRunner"/> over today's in-process machinery: a <see cref="CompiledScriptCache{TContext, TResult}"/>
|
||||
/// per context kind and one <see cref="ScriptRunGuard"/> (lmxopcua#780, design §11). It produces the outcomes and
|
||||
/// reason strings the seams' own in-process paths produce, which makes it the parity baseline the out-of-process
|
||||
/// runner is tested against, and the in-process half of the future shadow mode (P2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The seams do not route their <see cref="ScriptRunnerMode.InProcess"/> path through this class: they keep their
|
||||
/// existing guard code, so the default mode is unchanged byte for byte. Unlike those paths, this runner remembers
|
||||
/// compile failures by source for every kind (Calculation already did, R07:P4).
|
||||
/// </remarks>
|
||||
public sealed class InProcessScriptRunner : IScriptRunner, IDisposable
|
||||
{
|
||||
private readonly CompiledScriptCache<VirtualTagContext, object?> _valueCache = new();
|
||||
private readonly CompiledScriptCache<AlarmPredicateContext, bool> _predicateCache = new();
|
||||
private readonly ConcurrentDictionary<string, string> _compileFailures = new(StringComparer.Ordinal);
|
||||
private readonly ScriptRunGuard _guard;
|
||||
|
||||
/// <summary>Initializes the runner.</summary>
|
||||
/// <param name="scripting">Run-guard knobs; null ⇒ defaults.</param>
|
||||
/// <param name="logger">Where quarantine lines go; null ⇒ Serilog's global logger.</param>
|
||||
public InProcessScriptRunner(ScriptingOptions? scripting = null, ILogger? logger = null)
|
||||
{
|
||||
_guard = new ScriptRunGuard(scripting, logger);
|
||||
}
|
||||
|
||||
/// <summary>The run guard (diagnostics / tests).</summary>
|
||||
public ScriptRunGuard RunGuard => _guard;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScriptRunOutcome> RunAsync(ScriptInvocation invocation, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invocation);
|
||||
var key = ScopedKey(invocation.Kind, invocation.Key);
|
||||
var failureKey = ScopedKey(invocation.Kind, invocation.Source);
|
||||
if (_compileFailures.TryGetValue(failureKey, out var known)) return ScriptRunOutcome.Failed(known);
|
||||
var logger = invocation.ScriptLogger ?? Logger.None;
|
||||
|
||||
try
|
||||
{
|
||||
switch (invocation.Kind)
|
||||
{
|
||||
case ScriptContextKind.VirtualTag:
|
||||
case ScriptContextKind.Calculation:
|
||||
{
|
||||
var evaluator = await Task.Run(() => _valueCache.GetOrCompile(invocation.Source), ct).ConfigureAwait(false);
|
||||
var context = new VirtualTagContext(invocation.Inputs, static (_, _) => { }, logger);
|
||||
var value = await _guard.RunAsync(key, invocation.Source, evaluator, context, invocation.Timeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
return ScriptRunOutcome.Ok(value);
|
||||
}
|
||||
case ScriptContextKind.AlarmPredicate:
|
||||
{
|
||||
var evaluator = await Task.Run(() => _predicateCache.GetOrCompile(invocation.Source), ct).ConfigureAwait(false);
|
||||
var context = new AlarmPredicateContext(invocation.Inputs, logger);
|
||||
var value = await _guard.RunAsync(key, invocation.Source, evaluator, context, invocation.Timeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
return ScriptRunOutcome.Ok(value);
|
||||
}
|
||||
default:
|
||||
return ScriptRunOutcome.Failed($"unsupported script kind {invocation.Kind}");
|
||||
}
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
return RememberCompileFailure(failureKey, $"compile error: {ex.Message}");
|
||||
}
|
||||
catch (ScriptSandboxViolationException ex)
|
||||
{
|
||||
return RememberCompileFailure(failureKey, $"sandbox violation: {ex.Message}");
|
||||
}
|
||||
catch (ScriptTimeoutException)
|
||||
{
|
||||
return ScriptRunOutcome.Failed($"{ScriptEvalFailureReasons.TimedOutPrefix}{invocation.Timeout.TotalSeconds:F1}s");
|
||||
}
|
||||
catch (ScriptRunRefusedException ex)
|
||||
{
|
||||
return ScriptRunOutcome.Failed(ex.Message);
|
||||
}
|
||||
catch (ScriptQuarantinedException ex)
|
||||
{
|
||||
return ScriptRunOutcome.Failed(ex.Message);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return ScriptRunOutcome.Failed("evaluation cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ScriptRunOutcome.Failed($"script threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Reset(ScriptContextKind kind)
|
||||
{
|
||||
var prefix = ScopedKey(kind, string.Empty);
|
||||
foreach (var k in _compileFailures.Keys)
|
||||
if (k.StartsWith(prefix, StringComparison.Ordinal)) _compileFailures.TryRemove(k, out _);
|
||||
foreach (var k in _guard.QuarantinedKeys)
|
||||
if (k.StartsWith(prefix, StringComparison.Ordinal)) _guard.Reset(k);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<string> QuarantinedKeys(ScriptContextKind kind)
|
||||
{
|
||||
var prefix = ScopedKey(kind, string.Empty);
|
||||
return _guard.QuarantinedKeys
|
||||
.Where(k => k.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Select(k => k[prefix.Length..])
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_valueCache.Dispose();
|
||||
_predicateCache.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>The runner-internal key: <c>{kind}:{key}</c>.</summary>
|
||||
/// <param name="kind">The context kind.</param>
|
||||
/// <param name="key">The seam's key.</param>
|
||||
/// <returns>The namespaced key.</returns>
|
||||
public static string ScopedKey(ScriptContextKind kind, string key) => $"{(int)kind}:{key}";
|
||||
|
||||
private ScriptRunOutcome RememberCompileFailure(string failureKey, string reason)
|
||||
{
|
||||
_compileFailures.TryAdd(failureKey, reason);
|
||||
return ScriptRunOutcome.Failed(reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Where compiled scripts run (<c>Scripting:Runner</c>, lmxopcua#780). <see cref="InProcess"/> is the default
|
||||
/// and the documented rollback: it is today's <see cref="ScriptRunGuard"/> path, unchanged.
|
||||
/// </summary>
|
||||
public enum ScriptRunnerMode
|
||||
{
|
||||
/// <summary>Compiled scripts run in the host process under <see cref="ScriptRunGuard"/> (the default).</summary>
|
||||
InProcess = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Run in process AND submit to the worker, comparing the two (design P2). Not implemented yet: a node
|
||||
/// configured for it runs <see cref="InProcess"/> and logs a startup Warning.
|
||||
/// </summary>
|
||||
Shadow = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Compiled scripts of the selected seams run in the pre-emptible <c>ScriptWorker</c> process pool. The
|
||||
/// runner fails closed: a worker that cannot start makes the script Bad
|
||||
/// (<c>BadResourceUnavailable</c>), never an in-process run.
|
||||
/// </summary>
|
||||
OutOfProcess = 2,
|
||||
}
|
||||
|
||||
/// <summary>The three host seams that run operator-authored C# (<c>Scripting:Runner:Seams</c>).</summary>
|
||||
public enum ScriptSeam
|
||||
{
|
||||
/// <summary>Virtual tags (<c>RoslynVirtualTagEvaluator</c>).</summary>
|
||||
VirtualTag = 0,
|
||||
|
||||
/// <summary>Calculation tags (<c>CalculationEvaluator</c>).</summary>
|
||||
Calculation = 1,
|
||||
|
||||
/// <summary>Scripted-alarm predicates (<c>ScriptedAlarmEngine</c>). Last in the rollout (design §14 #10).</summary>
|
||||
ScriptedAlarm = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The out-of-process worker pool's knobs (the <c>Scripting:Worker</c> section). Every value is clamped by
|
||||
/// <see cref="Sanitized"/>, which reports each out-of-range key as a startup Warning, like the rest of the
|
||||
/// <c>Scripting</c> section. Only consulted when <see cref="ScriptingOptions.Runner"/> is not
|
||||
/// <see cref="ScriptRunnerMode.InProcess"/>.
|
||||
/// </summary>
|
||||
public sealed class ScriptWorkerOptions
|
||||
{
|
||||
/// <summary>Default <see cref="Shards"/>: 2 (design §14 #1).</summary>
|
||||
public const int DefaultShards = 2;
|
||||
|
||||
/// <summary>Upper bound of <see cref="Shards"/>.</summary>
|
||||
public const int MaxShards = 8;
|
||||
|
||||
/// <summary>Default <see cref="HeapLimitBytes"/>: 512 MiB.</summary>
|
||||
public const long DefaultHeapLimitBytes = 512L * 1024 * 1024;
|
||||
|
||||
/// <summary>Lower bound of <see cref="HeapLimitBytes"/>: 64 MiB (a worker below this cannot host Roslyn).</summary>
|
||||
public const long MinHeapLimitBytes = 64L * 1024 * 1024;
|
||||
|
||||
/// <summary>Default <see cref="QuarantineAfterKills"/>: 2.</summary>
|
||||
public const int DefaultQuarantineAfterKills = 2;
|
||||
|
||||
/// <summary>Default <see cref="UnavailableGrace"/>: 5 s.</summary>
|
||||
public static readonly TimeSpan DefaultUnavailableGrace = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Lower bound of an explicit <see cref="HardGrace"/>: 250 ms.</summary>
|
||||
public static readonly TimeSpan MinHardGrace = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>Upper bound of an explicit <see cref="HardGrace"/>: 30 s.</summary>
|
||||
public static readonly TimeSpan MaxHardGrace = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Upper bound of <see cref="UnavailableGrace"/>: 60 s.</summary>
|
||||
public static readonly TimeSpan MaxUnavailableGrace = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>Default <see cref="IsolationProbationRuns"/>: 20.</summary>
|
||||
public const int DefaultIsolationProbationRuns = 20;
|
||||
|
||||
/// <summary>Shard workers per node; a script is placed by a stable hash of its key. Clamped to 1–8.</summary>
|
||||
public int Shards { get; set; } = DefaultShards;
|
||||
|
||||
/// <summary>
|
||||
/// Grace after the soft deadline <c>T</c> before the worker running the script is killed. Null (the default)
|
||||
/// means <c>max(1 s, T)</c> per run (design §14 #2); an explicit value is clamped to 250 ms – 30 s.
|
||||
/// </summary>
|
||||
public TimeSpan? HardGrace { get; set; }
|
||||
|
||||
/// <summary>The worker's <c>DOTNET_GCHeapHardLimit</c>. Default 512 MiB, minimum 64 MiB.</summary>
|
||||
public long HeapLimitBytes { get; set; } = DefaultHeapLimitBytes;
|
||||
|
||||
/// <summary>Hard-deadline kills (within one hour) after which a script is quarantined. Default 2; 1–100.</summary>
|
||||
public int QuarantineAfterKills { get; set; } = DefaultQuarantineAfterKills;
|
||||
|
||||
/// <summary>
|
||||
/// How long an evaluation waits for its shard to become ready (a restart, a backoff) before it reports
|
||||
/// <c>script runner unavailable</c>. Default 5 s; 0–60 s.
|
||||
/// </summary>
|
||||
public TimeSpan UnavailableGrace { get; set; } = DefaultUnavailableGrace;
|
||||
|
||||
/// <summary>
|
||||
/// Successful runs a suspect script must complete in the isolation worker before it returns to its shard.
|
||||
/// Default 20; 1–10 000.
|
||||
/// </summary>
|
||||
public int IsolationProbationRuns { get; set; } = DefaultIsolationProbationRuns;
|
||||
|
||||
/// <summary>
|
||||
/// Directory of the node-local compiled-PE cache (design §7). Must be absolute and outside the content root
|
||||
/// when set; startup fails otherwise (the family's fail-closed path rule). Reserved: the cache itself has
|
||||
/// not landed yet (lmxopcua#780 P1 remainder), so a worker restart recompiles.
|
||||
/// </summary>
|
||||
public string? PeCacheDir { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path of the worker entry assembly (<c>ZB.MOM.WW.OtOpcUa.ScriptWorker.dll</c>). Null (the default) means
|
||||
/// <c>script-worker/</c> under the host's base directory, where the Host build places it.
|
||||
/// </summary>
|
||||
public string? ExecutablePath { get; set; }
|
||||
|
||||
/// <summary>The hard grace that applies to a run with soft budget <paramref name="softTimeout"/>.</summary>
|
||||
/// <param name="softTimeout">The run's soft deadline <c>T</c>.</param>
|
||||
/// <returns><see cref="HardGrace"/> when set, otherwise <c>max(1 s, T)</c>.</returns>
|
||||
public TimeSpan EffectiveHardGrace(TimeSpan softTimeout)
|
||||
=> HardGrace ?? (softTimeout > TimeSpan.FromSeconds(1) ? softTimeout : TimeSpan.FromSeconds(1));
|
||||
|
||||
/// <summary>Returns a clamped copy; one line per out-of-range key in <paramref name="adjustments"/>.</summary>
|
||||
/// <param name="adjustments">The clamps applied, naming each key.</param>
|
||||
/// <returns>A new, clamped instance.</returns>
|
||||
public ScriptWorkerOptions Sanitized(out IReadOnlyList<string> adjustments)
|
||||
{
|
||||
const string prefix = ScriptingOptions.SectionName + ":Worker:";
|
||||
var notes = new List<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
adjustments = notes;
|
||||
return new ScriptWorkerOptions
|
||||
{
|
||||
Shards = shards,
|
||||
HardGrace = grace,
|
||||
HeapLimitBytes = heap,
|
||||
QuarantineAfterKills = kills,
|
||||
UnavailableGrace = unavailable,
|
||||
IsolationProbationRuns = probation,
|
||||
PeCacheDir = PeCacheDir,
|
||||
ExecutablePath = ExecutablePath,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-closed path validation (design §7): <see cref="PeCacheDir"/>, when set, must be absolute and must not
|
||||
/// sit inside <paramref name="contentRootPath"/>. Same rule as the family's <c>Secrets:SqlitePath</c>.
|
||||
/// </summary>
|
||||
/// <param name="contentRootPath">The host's content root.</param>
|
||||
/// <returns>The violation, or null when the options are acceptable.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,30 @@ public sealed class ScriptingOptions
|
||||
/// </summary>
|
||||
public double StuckOrphanQuarantineFactor { get; set; } = DefaultStuckOrphanQuarantineFactor;
|
||||
|
||||
/// <summary>
|
||||
/// Where compiled scripts run (<c>Scripting:Runner</c>, lmxopcua#780). Default
|
||||
/// <see cref="ScriptRunnerMode.InProcess"/> — today's path, unchanged, and the rollback for the other modes.
|
||||
/// </summary>
|
||||
public ScriptRunnerMode Runner { get; set; } = ScriptRunnerMode.InProcess;
|
||||
|
||||
/// <summary>
|
||||
/// The seams <see cref="Runner"/> applies to when it is not <see cref="ScriptRunnerMode.InProcess"/>
|
||||
/// (<c>Scripting:Runner:Seams</c>, e.g. <c>["Calculation"]</c>). Null or empty means all three. Bound by
|
||||
/// <c>ScriptingOptionsBinding</c> from the documented key, because <c>Scripting:Runner</c> is itself a value.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ScriptSeam>? RunnerSeams { get; set; }
|
||||
|
||||
/// <summary>The out-of-process worker pool's knobs (<c>Scripting:Worker</c>).</summary>
|
||||
public ScriptWorkerOptions Worker { get; set; } = new();
|
||||
|
||||
/// <summary>Whether <paramref name="seam"/> is selected for out-of-process execution by these options.</summary>
|
||||
/// <param name="seam">The seam.</param>
|
||||
/// <returns><c>true</c> when <see cref="Runner"/> is <see cref="ScriptRunnerMode.OutOfProcess"/> and the seam is in
|
||||
/// <see cref="RunnerSeams"/> (or that list is empty).</returns>
|
||||
public bool IsOutOfProcess(ScriptSeam seam)
|
||||
=> Runner == ScriptRunnerMode.OutOfProcess
|
||||
&& (RunnerSeams is not { Count: > 0 } seams || seams.Contains(seam));
|
||||
|
||||
/// <summary>Upper bound <see cref="Sanitized"/> applies to <see cref="StuckOrphanQuarantineFactor"/>.</summary>
|
||||
public const double MaxStuckOrphanQuarantineFactor = 1000;
|
||||
|
||||
@@ -158,6 +182,9 @@ public sealed class ScriptingOptions
|
||||
runTimeout = MaxRunTimeout;
|
||||
}
|
||||
|
||||
var worker = (Worker ?? new ScriptWorkerOptions()).Sanitized(out var workerNotes);
|
||||
notes.AddRange(workerNotes);
|
||||
|
||||
adjustments = notes;
|
||||
return new ScriptingOptions
|
||||
{
|
||||
@@ -165,6 +192,9 @@ public sealed class ScriptingOptions
|
||||
MaxAllocBytesPerRun = alloc,
|
||||
QuarantineAfterConsecutiveTimeouts = threshold,
|
||||
StuckOrphanQuarantineFactor = factor,
|
||||
Runner = Runner,
|
||||
RunnerSeams = RunnerSeams is null ? null : [.. RunnerSeams],
|
||||
Worker = worker,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user