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,214 @@
|
||||
// lmxopcua#780 — host <-> ScriptWorker IPC (design docs/plans/2026-11-09-out-of-process-script-runner-design.md §4).
|
||||
// Framing: 4-byte little-endian length prefix + one serialized Envelope. Zero-length and oversize frames are protocol
|
||||
// faults. One request frame and one reply frame per evaluation; the only other worker->host traffic is logs and
|
||||
// heartbeats. Bump protocol_version on any incompatible change.
|
||||
syntax = "proto3";
|
||||
|
||||
package otopcua.scriptworker.v1;
|
||||
|
||||
option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1";
|
||||
|
||||
message Envelope {
|
||||
uint32 protocol_version = 1;
|
||||
uint64 seq = 2;
|
||||
oneof body {
|
||||
Hello hello = 10; // host -> worker, first frame
|
||||
Ready ready = 11; // worker -> host, reply to Hello
|
||||
LoadScripts load = 12; // host -> worker
|
||||
LoadResult load_result = 13; // worker -> host
|
||||
Unload unload = 14; // host -> worker
|
||||
EvalBatch eval = 15; // host -> worker
|
||||
EvalResults results = 16; // worker -> host, streamed as runs complete
|
||||
LogBatch logs = 17; // worker -> host, fire-and-forget, bounded
|
||||
Heartbeat heartbeat = 18; // worker -> host, every heartbeat_interval_ms
|
||||
Shutdown shutdown = 19; // host -> worker
|
||||
Fault fault = 20; // either way; the sender closes after it
|
||||
DiagnosticsRequest diag_request = 21; // host -> worker (tests / ops)
|
||||
DiagnosticsReply diag_reply = 22; // worker -> host
|
||||
Cancel cancel = 23; // host -> worker, at the soft deadline
|
||||
}
|
||||
}
|
||||
|
||||
message Hello {
|
||||
string nonce = 1; // must be echoed in Ready
|
||||
uint32 max_frame_bytes = 2;
|
||||
uint32 eval_threads = 3; // the worker's scheduler width; 1 for the isolation worker
|
||||
uint32 queue_capacity = 4; // queued evaluations above this answer BUSY
|
||||
uint32 hard_grace_ms = 5; // worker watchdog: fail fast past soft + grace (+ margin)
|
||||
uint32 heartbeat_interval_ms = 6;
|
||||
uint32 max_log_lines = 7; // outbound log buffer cap
|
||||
}
|
||||
|
||||
message Ready {
|
||||
string nonce = 1;
|
||||
int32 pid = 2;
|
||||
string runtime_version = 3;
|
||||
}
|
||||
|
||||
enum ContextKind {
|
||||
CONTEXT_KIND_UNSPECIFIED = 0;
|
||||
VIRTUAL_TAG = 1;
|
||||
CALCULATION = 2;
|
||||
ALARM_PREDICATE = 3;
|
||||
}
|
||||
|
||||
message ScriptSource {
|
||||
string key = 1;
|
||||
bytes source_sha256 = 2;
|
||||
ContextKind kind = 3;
|
||||
string source = 4;
|
||||
repeated string input_paths = 5; // slot order for EvalRequest.inputs
|
||||
}
|
||||
|
||||
message LoadScripts {
|
||||
uint64 load_id = 1;
|
||||
repeated ScriptSource scripts = 2;
|
||||
}
|
||||
|
||||
message LoadOutcome {
|
||||
string key = 1;
|
||||
bool ok = 2;
|
||||
ScriptError error = 3; // COMPILE or SANDBOX when !ok
|
||||
}
|
||||
|
||||
message LoadResult {
|
||||
uint64 load_id = 1;
|
||||
repeated LoadOutcome outcomes = 2;
|
||||
}
|
||||
|
||||
message Unload {
|
||||
repeated string keys = 1;
|
||||
}
|
||||
|
||||
message EvalBatch {
|
||||
repeated EvalRequest items = 1;
|
||||
}
|
||||
|
||||
message EvalRequest {
|
||||
uint64 eval_id = 1;
|
||||
string key = 2;
|
||||
repeated Input inputs = 3;
|
||||
int64 now_utc_ticks = 4; // ctx.Now (host clock)
|
||||
uint32 soft_timeout_ms = 5;
|
||||
uint32 hard_grace_ms = 6; // this run's G; 0 = Hello.hard_grace_ms
|
||||
}
|
||||
|
||||
message Input {
|
||||
uint32 slot = 1; // index into ScriptSource.input_paths; ignored when path is set
|
||||
Variant value = 2;
|
||||
uint32 status_code = 3;
|
||||
int64 source_ts_ticks = 4; // 0 = null
|
||||
int64 server_ts_ticks = 5;
|
||||
string path = 6; // inline path for an input with no registered slot
|
||||
bool unsupported = 7; // the host could not encode the value's CLR type (see unsupported_type)
|
||||
string unsupported_type = 8;
|
||||
}
|
||||
|
||||
message EvalResult {
|
||||
uint64 eval_id = 1;
|
||||
oneof outcome {
|
||||
Variant value = 2;
|
||||
bool predicate = 3;
|
||||
ScriptError error = 4;
|
||||
}
|
||||
uint64 allocated_bytes = 5; // GC.GetAllocatedBytesForCurrentThread around the run
|
||||
uint32 run_micros = 6;
|
||||
}
|
||||
|
||||
message EvalResults {
|
||||
repeated EvalResult items = 1;
|
||||
}
|
||||
|
||||
message ScriptError {
|
||||
enum Kind {
|
||||
THREW = 0;
|
||||
COMPILE = 1;
|
||||
SANDBOX = 2;
|
||||
SOFT_TIMEOUT = 3;
|
||||
UNSUPPORTED_TYPE = 4;
|
||||
BUSY = 5;
|
||||
NOT_LOADED = 6;
|
||||
}
|
||||
Kind kind = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message Variant {
|
||||
enum TypeTag {
|
||||
NULL = 0;
|
||||
BOOLEAN = 1;
|
||||
SBYTE = 2;
|
||||
BYTE = 3;
|
||||
INT16 = 4;
|
||||
UINT16 = 5;
|
||||
INT32 = 6;
|
||||
UINT32 = 7;
|
||||
INT64 = 8;
|
||||
UINT64 = 9;
|
||||
FLOAT = 10;
|
||||
DOUBLE = 11;
|
||||
STRING = 12;
|
||||
DATETIME_UTC = 13;
|
||||
ARRAY = 14;
|
||||
}
|
||||
TypeTag type = 1; // the exact CLR type: (short)ctx.GetTag(x).Value must keep working
|
||||
oneof v {
|
||||
bool b = 2;
|
||||
sint64 i = 3;
|
||||
uint64 u = 4;
|
||||
double d = 5;
|
||||
float f = 6;
|
||||
string s = 7;
|
||||
int64 dt_ticks = 8;
|
||||
ArrayValue array = 9;
|
||||
}
|
||||
}
|
||||
|
||||
message ArrayValue {
|
||||
Variant.TypeTag element_type = 1;
|
||||
repeated Variant items = 2;
|
||||
}
|
||||
|
||||
message LogBatch {
|
||||
repeated LogLine lines = 1;
|
||||
uint32 dropped = 2;
|
||||
}
|
||||
|
||||
message LogLine {
|
||||
string key = 1;
|
||||
uint32 level = 2; // Serilog LogEventLevel
|
||||
string rendered = 3;
|
||||
string exception = 4;
|
||||
int64 ts_ticks = 5;
|
||||
}
|
||||
|
||||
message Heartbeat {
|
||||
uint32 inflight = 1;
|
||||
uint32 queued = 2;
|
||||
int64 heap_bytes = 3;
|
||||
uint32 gen2_count = 4;
|
||||
}
|
||||
|
||||
message Shutdown {
|
||||
}
|
||||
|
||||
// Sent by the host when it has answered the caller with a soft timeout: cancels the run's ctx.CancellationToken, so a
|
||||
// polling script ends itself. The worker never cancels on its own clock, which keeps the in-process ordering — the
|
||||
// caller always has its timeout before the script can observe cancellation.
|
||||
message Cancel {
|
||||
uint64 eval_id = 1;
|
||||
}
|
||||
|
||||
message Fault {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message DiagnosticsRequest {
|
||||
}
|
||||
|
||||
message DiagnosticsReply {
|
||||
int32 pid = 1;
|
||||
repeated string loaded_assemblies = 2;
|
||||
repeated string environment_names = 3;
|
||||
string working_directory = 4;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The environment variables the host sets on a ScriptWorker (design §6). The nonce travels in the environment,
|
||||
/// never on the command line (mxaccessgw <c>WorkerBootstrap.md</c>): a command line is world-readable on most
|
||||
/// systems.
|
||||
/// </summary>
|
||||
public static class ScriptWorkerEnvironment
|
||||
{
|
||||
/// <summary>The pipe name the worker connects to.</summary>
|
||||
public const string PipeName = "OTOPCUA_SCRIPTWORKER_PIPE";
|
||||
|
||||
/// <summary>The one-time launch nonce the worker must echo in <c>Ready</c>.</summary>
|
||||
public const string Nonce = "OTOPCUA_SCRIPTWORKER_NONCE";
|
||||
|
||||
/// <summary>The host's process id; the worker exits when it disappears.</summary>
|
||||
public const string ParentPid = "OTOPCUA_SCRIPTWORKER_PARENT_PID";
|
||||
|
||||
/// <summary>The worker's shard label, for its own diagnostics.</summary>
|
||||
public const string Shard = "OTOPCUA_SCRIPTWORKER_SHARD";
|
||||
|
||||
/// <summary>
|
||||
/// Exit code of a worker whose pipe reached end of stream (the host went away). Distinct from a crash so the
|
||||
/// host's exit reason metric can tell them apart.
|
||||
/// </summary>
|
||||
public const int ExitPipeClosed = 3;
|
||||
|
||||
/// <summary>Exit code of a worker whose handshake or framing failed.</summary>
|
||||
public const int ExitProtocolFault = 4;
|
||||
|
||||
/// <summary>Exit code of a worker whose parent process disappeared.</summary>
|
||||
public const int ExitParentGone = 5;
|
||||
|
||||
/// <summary>Exit code of a worker that found a forbidden assembly loaded in itself.</summary>
|
||||
public const int ExitForbiddenAssembly = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Assembly-name prefixes that must never be loaded in a worker (design §6): the host's cluster, database,
|
||||
/// OPC UA and secrets stacks. The worker checks itself against this list and exits if it finds one.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> ForbiddenAssemblyPrefixes =
|
||||
[
|
||||
"Akka",
|
||||
"Microsoft.Data.SqlClient",
|
||||
"System.Data.SqlClient",
|
||||
"Microsoft.EntityFrameworkCore",
|
||||
"Opc.Ua",
|
||||
"ZB.MOM.WW.Secrets",
|
||||
"ZB.MOM.WW.OtOpcUa.Runtime",
|
||||
"ZB.MOM.WW.OtOpcUa.Host",
|
||||
"ZB.MOM.WW.OtOpcUa.Driver.",
|
||||
"Grpc.",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using Google.Protobuf;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Length-prefixed protobuf framing for the ScriptWorker pipe (lmxopcua#780, design §4.2): a 4-byte little-endian
|
||||
/// length, then one serialized <see cref="Envelope"/>. Copies mxaccessgw's <c>WorkerFrameProtocol</c> discipline:
|
||||
/// zero-length, oversize, truncated and unparseable frames are all a <see cref="ScriptWorkerProtocolException"/>,
|
||||
/// after which the stream is unusable and the peer is dropped.
|
||||
/// </summary>
|
||||
public static class ScriptWorkerFrameCodec
|
||||
{
|
||||
/// <summary>The protocol version both ends put on every envelope.</summary>
|
||||
public const uint ProtocolVersion = 1;
|
||||
|
||||
/// <summary>Default frame cap: 4 MiB.</summary>
|
||||
public const int DefaultMaxFrameBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Writes one frame. Not thread-safe: callers serialise writes.</summary>
|
||||
/// <param name="stream">The pipe.</param>
|
||||
/// <param name="envelope">The envelope.</param>
|
||||
/// <param name="maxFrameBytes">The negotiated cap; a larger envelope is refused before anything is written.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>A task that completes when the frame is written (not flushed).</returns>
|
||||
public static async ValueTask WriteAsync(Stream stream, Envelope envelope, int maxFrameBytes, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
var size = envelope.CalculateSize();
|
||||
if (size == 0) throw new ScriptWorkerProtocolException("refusing to write an empty frame");
|
||||
if (size > maxFrameBytes)
|
||||
throw new ScriptWorkerProtocolException($"frame of {size} bytes exceeds the {maxFrameBytes}-byte cap");
|
||||
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(size + 4);
|
||||
try
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, size);
|
||||
envelope.WriteTo(new Span<byte>(buffer, 4, size));
|
||||
await stream.WriteAsync(buffer.AsMemory(0, size + 4), ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads one frame.</summary>
|
||||
/// <param name="stream">The pipe.</param>
|
||||
/// <param name="maxFrameBytes">The negotiated cap.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The envelope, or null on a clean end of stream at a frame boundary.</returns>
|
||||
/// <exception cref="ScriptWorkerProtocolException">A zero-length, oversize, truncated or unparseable frame.</exception>
|
||||
public static async ValueTask<Envelope?> ReadAsync(Stream stream, int maxFrameBytes, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
var header = new byte[4];
|
||||
var got = await ReadFullyAsync(stream, header, ct).ConfigureAwait(false);
|
||||
if (got == 0) return null;
|
||||
if (got < 4) throw new ScriptWorkerProtocolException("truncated frame header");
|
||||
|
||||
var size = BinaryPrimitives.ReadInt32LittleEndian(header);
|
||||
if (size == 0) throw new ScriptWorkerProtocolException("zero-length frame");
|
||||
if (size < 0 || size > maxFrameBytes)
|
||||
throw new ScriptWorkerProtocolException($"frame length {size} is outside 1..{maxFrameBytes}");
|
||||
|
||||
var body = ArrayPool<byte>.Shared.Rent(size);
|
||||
try
|
||||
{
|
||||
var read = await ReadFullyAsync(stream, body.AsMemory(0, size), ct).ConfigureAwait(false);
|
||||
if (read < size) throw new ScriptWorkerProtocolException($"truncated frame: {read} of {size} bytes");
|
||||
Envelope envelope;
|
||||
try
|
||||
{
|
||||
envelope = Envelope.Parser.ParseFrom(body, 0, size);
|
||||
}
|
||||
catch (InvalidProtocolBufferException ex)
|
||||
{
|
||||
throw new ScriptWorkerProtocolException($"unparseable frame: {ex.Message}", ex);
|
||||
}
|
||||
if (envelope.ProtocolVersion != ProtocolVersion)
|
||||
throw new ScriptWorkerProtocolException(
|
||||
$"protocol version {envelope.ProtocolVersion} is not the supported {ProtocolVersion}");
|
||||
if (envelope.BodyCase == Envelope.BodyOneofCase.None)
|
||||
throw new ScriptWorkerProtocolException("frame carries no body");
|
||||
return envelope;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(body);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<int> ReadFullyAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
|
||||
{
|
||||
var total = 0;
|
||||
while (total < buffer.Length)
|
||||
{
|
||||
var n = await stream.ReadAsync(buffer[total..], ct).ConfigureAwait(false);
|
||||
if (n == 0) break;
|
||||
total += n;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A framing or schema violation on the ScriptWorker pipe; the connection is dropped.</summary>
|
||||
public sealed class ScriptWorkerProtocolException : Exception
|
||||
{
|
||||
/// <summary>Initializes the exception.</summary>
|
||||
/// <param name="message">What was wrong.</param>
|
||||
/// <param name="inner">The underlying parser error, if any.</param>
|
||||
public ScriptWorkerProtocolException(string message, Exception? inner = null) : base(message, inner) { }
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<!-- protoc-generated members carry no XML docs. -->
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- lmxopcua#780: the host <-> ScriptWorker frame protocol (design §4). Shared by the host-side runner and the
|
||||
worker, so it references nothing but Google.Protobuf: the worker's assembly set stays small (design §6). -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf"/>
|
||||
<PackageReference Include="Grpc.Tools">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\script_worker.proto" GrpcServices="None"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user