feat(scripting): out-of-process pre-emptible script runner behind Scripting:Runner (lmxopcua#780 P1)
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:
Joseph Doherty
2026-11-09 23:31:51 -05:00
parent f5c30ed0ef
commit 69a6e0b4c8
49 changed files with 5107 additions and 25 deletions
@@ -0,0 +1,159 @@
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
/// <summary>
/// CLR value ↔ <see cref="Variant"/> (design §4.4). The exact boxed CLR type survives the round trip — a script
/// doing <c>(short)ctx.GetTag(x).Value</c> throws <see cref="InvalidCastException"/> if a <c>short</c> arrives as an
/// <c>int</c> — so the type tag names the CLR type, not just an OPC UA one. A type with no tag is never converted:
/// <see cref="TryEncode"/> refuses it, and the caller reports <c>UNSUPPORTED_TYPE</c> (design §14 #11).
/// </summary>
public static class VariantCodec
{
/// <summary>Encodes <paramref name="value"/>.</summary>
/// <param name="value">The boxed CLR value (null allowed).</param>
/// <param name="variant">The encoded variant when supported.</param>
/// <param name="unsupportedType">The CLR type name when not supported.</param>
/// <returns><c>true</c> when the type has a tag.</returns>
public static bool TryEncode(object? value, out Variant variant, out string? unsupportedType)
{
unsupportedType = null;
variant = new Variant();
switch (value)
{
case null: variant.Type = Variant.Types.TypeTag.Null; return true;
case bool b: variant.Type = Variant.Types.TypeTag.Boolean; variant.B = b; return true;
case sbyte v: variant.Type = Variant.Types.TypeTag.Sbyte; variant.I = v; return true;
case byte v: variant.Type = Variant.Types.TypeTag.Byte; variant.U = v; return true;
case short v: variant.Type = Variant.Types.TypeTag.Int16; variant.I = v; return true;
case ushort v: variant.Type = Variant.Types.TypeTag.Uint16; variant.U = v; return true;
case int v: variant.Type = Variant.Types.TypeTag.Int32; variant.I = v; return true;
case uint v: variant.Type = Variant.Types.TypeTag.Uint32; variant.U = v; return true;
case long v: variant.Type = Variant.Types.TypeTag.Int64; variant.I = v; return true;
case ulong v: variant.Type = Variant.Types.TypeTag.Uint64; variant.U = v; return true;
case float v: variant.Type = Variant.Types.TypeTag.Float; variant.F = v; return true;
case double v: variant.Type = Variant.Types.TypeTag.Double; variant.D = v; return true;
case string v: variant.Type = Variant.Types.TypeTag.String; variant.S = v; return true;
case DateTime v:
variant.Type = Variant.Types.TypeTag.DatetimeUtc;
variant.DtTicks = ToUtc(v).Ticks;
return true;
case Array array when array.Rank == 1 && TryElementTag(array.GetType().GetElementType()!, out var elementTag):
{
var av = new ArrayValue { ElementType = elementTag };
foreach (var item in array)
{
if (!TryEncode(item, out var element, out unsupportedType)) return false;
av.Items.Add(element);
}
variant.Type = Variant.Types.TypeTag.Array;
variant.Array = av;
return true;
}
default:
unsupportedType = value.GetType().FullName ?? value.GetType().Name;
return false;
}
}
/// <summary>Decodes <paramref name="variant"/> back to the boxed CLR value it was encoded from.</summary>
/// <param name="variant">The variant.</param>
/// <returns>The boxed value.</returns>
/// <exception cref="ScriptWorkerProtocolException">An unknown tag, or a payload that does not match the tag.</exception>
public static object? Decode(Variant? variant)
{
if (variant is null) return null;
return variant.Type switch
{
Variant.Types.TypeTag.Null => null,
Variant.Types.TypeTag.Boolean => Expect(variant, Variant.VOneofCase.B).B,
Variant.Types.TypeTag.Sbyte => checked((sbyte)Expect(variant, Variant.VOneofCase.I).I),
Variant.Types.TypeTag.Byte => checked((byte)Expect(variant, Variant.VOneofCase.U).U),
Variant.Types.TypeTag.Int16 => checked((short)Expect(variant, Variant.VOneofCase.I).I),
Variant.Types.TypeTag.Uint16 => checked((ushort)Expect(variant, Variant.VOneofCase.U).U),
Variant.Types.TypeTag.Int32 => checked((int)Expect(variant, Variant.VOneofCase.I).I),
Variant.Types.TypeTag.Uint32 => checked((uint)Expect(variant, Variant.VOneofCase.U).U),
Variant.Types.TypeTag.Int64 => Expect(variant, Variant.VOneofCase.I).I,
Variant.Types.TypeTag.Uint64 => Expect(variant, Variant.VOneofCase.U).U,
Variant.Types.TypeTag.Float => Expect(variant, Variant.VOneofCase.F).F,
Variant.Types.TypeTag.Double => Expect(variant, Variant.VOneofCase.D).D,
Variant.Types.TypeTag.String => Expect(variant, Variant.VOneofCase.S).S,
Variant.Types.TypeTag.DatetimeUtc => new DateTime(Expect(variant, Variant.VOneofCase.DtTicks).DtTicks, DateTimeKind.Utc),
Variant.Types.TypeTag.Array => DecodeArray(Expect(variant, Variant.VOneofCase.Array).Array),
_ => throw new ScriptWorkerProtocolException($"unknown variant type tag {(int)variant.Type}"),
};
}
/// <summary>A <see cref="DateTime"/> as UTC: a <c>Local</c> value is converted; an <c>Unspecified</c> one is taken as UTC.</summary>
/// <param name="value">The value.</param>
/// <returns>The UTC value.</returns>
public static DateTime ToUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
private static Variant Expect(Variant variant, Variant.VOneofCase expected)
{
// proto3 drops a default scalar (0, false, "") from the oneof, so an absent payload is the default value.
if (variant.VCase != expected && variant.VCase != Variant.VOneofCase.None)
throw new ScriptWorkerProtocolException($"variant tagged {variant.Type} carries a {variant.VCase} payload");
return variant;
}
private static Array DecodeArray(ArrayValue? array)
{
if (array is null) throw new ScriptWorkerProtocolException("array variant without an array payload");
var elementType = ClrType(array.ElementType);
var result = Array.CreateInstance(elementType, array.Items.Count);
for (var i = 0; i < array.Items.Count; i++)
{
var item = array.Items[i];
if (item.Type != array.ElementType && item.Type != Variant.Types.TypeTag.Null)
throw new ScriptWorkerProtocolException($"array of {array.ElementType} holds a {item.Type} element");
result.SetValue(Decode(item), i);
}
return result;
}
private static bool TryElementTag(Type elementType, out Variant.Types.TypeTag tag)
{
tag = elementType switch
{
_ when elementType == typeof(bool) => Variant.Types.TypeTag.Boolean,
_ when elementType == typeof(sbyte) => Variant.Types.TypeTag.Sbyte,
_ when elementType == typeof(byte) => Variant.Types.TypeTag.Byte,
_ when elementType == typeof(short) => Variant.Types.TypeTag.Int16,
_ when elementType == typeof(ushort) => Variant.Types.TypeTag.Uint16,
_ when elementType == typeof(int) => Variant.Types.TypeTag.Int32,
_ when elementType == typeof(uint) => Variant.Types.TypeTag.Uint32,
_ when elementType == typeof(long) => Variant.Types.TypeTag.Int64,
_ when elementType == typeof(ulong) => Variant.Types.TypeTag.Uint64,
_ when elementType == typeof(float) => Variant.Types.TypeTag.Float,
_ when elementType == typeof(double) => Variant.Types.TypeTag.Double,
_ when elementType == typeof(string) => Variant.Types.TypeTag.String,
_ when elementType == typeof(DateTime) => Variant.Types.TypeTag.DatetimeUtc,
_ => Variant.Types.TypeTag.Null,
};
return tag != Variant.Types.TypeTag.Null;
}
private static Type ClrType(Variant.Types.TypeTag tag) => tag switch
{
Variant.Types.TypeTag.Boolean => typeof(bool),
Variant.Types.TypeTag.Sbyte => typeof(sbyte),
Variant.Types.TypeTag.Byte => typeof(byte),
Variant.Types.TypeTag.Int16 => typeof(short),
Variant.Types.TypeTag.Uint16 => typeof(ushort),
Variant.Types.TypeTag.Int32 => typeof(int),
Variant.Types.TypeTag.Uint32 => typeof(uint),
Variant.Types.TypeTag.Int64 => typeof(long),
Variant.Types.TypeTag.Uint64 => typeof(ulong),
Variant.Types.TypeTag.Float => typeof(float),
Variant.Types.TypeTag.Double => typeof(double),
Variant.Types.TypeTag.String => typeof(string),
Variant.Types.TypeTag.DatetimeUtc => typeof(DateTime),
_ => throw new ScriptWorkerProtocolException($"array element type {tag} is not supported"),
};
}