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,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"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user