diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 14ad26d1..a1090eab 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -21,6 +21,7 @@ + @@ -81,6 +82,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs new file mode 100644 index 00000000..481ce36e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; +using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; +using SerilogLogger = Serilog.ILogger; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation; + +/// +/// T0-4 — the Calculation driver's script evaluator. A calc tag is a raw tag whose value is +/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of +/// the VirtualTag RoslynVirtualTagEvaluator: it compiles each unique source once via +/// (the Roslyn-backed sandbox), caches the +/// resulting evaluator keyed by source in a , +/// and runs steady-state evaluations as in-process method invocations against the dependency +/// dictionary — fast enough to run inline on the driver's dispatch. It reuses +/// verbatim so the Monaco editor, sandbox, and diagnostics pipeline +/// apply to calc scripts with no divergence. +/// +/// +/// Single-tag mode. A calc tag produces exactly one output value — its own — so cross-tag +/// ctx.SetVirtualTag writes are dropped (and logged at Debug). Fan-out between tags is owned +/// by the host's DependencyMuxActor (calc-of-calc chains re-enter the mux), never by the +/// eval engine, exactly as in the VirtualTag adapter. +/// +/// +/// Error surfacing (for WP7). This evaluator never throws for script faults: compile error, +/// sandbox violation, runtime throw, and timeout all return +/// with a descriptive Reason. A successful run returns +/// carrying the computed value (which may be null). WP7's CalculationDriver maps a +/// Failure to Bad quality + a ScriptLogEntry and an Ok to the published value. +/// +/// +/// Cache management. Implements so WP7 can drop compiled +/// scripts at each deploy generation whose source set changed — mirroring the VirtualTag adapter's +/// apply-boundary clear, which is what stops collectible AssemblyLoadContexts accreting across +/// script edits. +/// +/// +public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable +{ + // Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose() + // that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the + // WP7 driver per deploy generation — is what stops ALCs accreting across script edits. + private readonly CompiledScriptCache _cache = new(); + private readonly ILogger _logger; + private readonly SerilogLogger _scriptRoot; + private readonly TimeSpan _runTimeout; + private bool _disposed; + + /// Initializes a new . + /// Logger for host-side compilation/execution diagnostics. + /// Root script logger; user ctx.Logger.* output flows through this to the Script-log page. + /// Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator). + public CalculationEvaluator( + ILogger logger, + ScriptRootLogger scriptRoot, + TimeSpan? runTimeout = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger; + _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2); + } + + /// + /// Evaluate for the calc tag against + /// the snapshot in (RawPath → last value). Never throws — script + /// faults are reported via ; a successful run returns + /// carrying the single computed value. + /// + /// Identity of the calc tag (bound to the script log; in the live path equals the script id). + /// The C# script source producing the tag's value. + /// Read-only snapshot of dependency values keyed by RawPath. + public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary dependencies) + { + if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed"); + if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression"); + + // Passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no Roslyn. + // Semantics are byte-identical to the compiled mirror: a present dependency returns its value + // (Ok), an absent one makes GetTag yield a Bad snapshot whose .Value is null, so the script + // returns null — Ok(null) here too. Near-misses fall through to the compiled path. + if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef)) + { + return dependencies.TryGetValue(passthroughRef, out var ptValue) + ? VirtualTagEvalResult.Ok(ptValue) + : VirtualTagEvalResult.Ok(null); + } + + var readCache = BuildReadCache(dependencies); + // Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which + // the Script-log page attributes on) from the calc-tag id so each line is attributable. + var scriptLog = _scriptRoot + .ForContext(ScriptLoggerFactory.ScriptIdProperty, calcTagId) + .ForContext(ScriptLoggerFactory.VirtualTagIdProperty, calcTagId); + var context = new VirtualTagContext( + readCache, + // Single-tag mode: a calc tag has exactly one output (its own), so cross-tag writes are dropped. + setVirtualTag: (path, _) => + _logger.LogDebug("Calculation {Id}: cross-tag write to {Path} dropped (single-tag evaluator)", + calcTagId, path), + logger: scriptLog); + + // Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent + // ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's + // disposed guard) re-fetches and retries exactly once. The loop runs at most twice. + for (var attempt = 0; ; attempt++) + { + ScriptEvaluator evaluator; + try + { + evaluator = _cache.GetOrCompile(scriptSource); + } + catch (CompilationErrorException ex) + { + _logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId); + return VirtualTagEvalResult.Failure($"compile error: {ex.Message}"); + } + catch (ScriptSandboxViolationException ex) + { + _logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId); + return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}"); + } + catch (ObjectDisposedException) + { + // Fetch-time disposal is a shutdown signal (the cache is tearing down), not the + // apply-boundary race — never retry. + return VirtualTagEvalResult.Failure("evaluator disposed"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Calculation {Id}: compile threw", calcTagId); + return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}"); + } + + try + { + // Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't + // interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a + // runaway script to the wall-clock budget instead of wedging the driver's dispatch. + var timed = new TimedScriptEvaluator(evaluator, _runTimeout); + var raw = timed.RunAsync(context).GetAwaiter().GetResult(); + return VirtualTagEvalResult.Ok(raw); + } + catch (ScriptTimeoutException) + { + return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s"); + } + catch (ObjectDisposedException) when (!_disposed && attempt == 0) + { + // A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the + // run's disposed-guard. Re-fetch (recompiling the same source into the CURRENT generation) + // and retry once. A second disposal mid-retry falls through to the failure path below. + continue; + } + catch (ObjectDisposedException) + { + return VirtualTagEvalResult.Failure("evaluator disposed during evaluation"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId); + return VirtualTagEvalResult.Failure($"script threw: {ex.Message}"); + } + } + } + + /// + public void ClearCompiledScripts() => _cache.Clear(); + + private static IReadOnlyDictionary BuildReadCache( + IReadOnlyDictionary deps) + { + // VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality + // so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null. + var nowUtc = DateTime.UtcNow; + var cache = new Dictionary(StringComparer.Ordinal); + foreach (var kv in deps) + { + cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc); + } + return cache; + } + + /// Disposes the evaluator, draining + disposing every cached compile's collectible ALC. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _cache.Dispose(); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj new file mode 100644 index 00000000..c3aa6ec4 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Calculation + ZB.MOM.WW.OtOpcUa.Driver.Calculation + + + + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs new file mode 100644 index 00000000..3893e2bc --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs @@ -0,0 +1,247 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Serilog; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests; + +/// +/// T0-4 — parity tests for against the VirtualTag +/// RoslynVirtualTagEvaluator it mirrors: the passthrough fast-path, the wall-clock timeout, +/// sandbox-violation rejection, single-tag SetVirtualTag drop, compile-error surfacing, and +/// the compile-cache reuse / clear contract. All script faults surface as +/// VirtualTagEvalResult.Failure (never a thrown exception) so WP7 can map them to Bad quality. +/// +public sealed class CalculationEvaluatorTests +{ + /// Builds a no-op for tests that don't assert on script logging. + private static ScriptRootLogger NoOpScriptRoot() => + new(new LoggerConfiguration().CreateLogger()); + + // ── passthrough fast-path ─────────────────────────────────────────────────────────────── + + /// The mirror shape return ctx.GetTag("X").Value; returns the dependency value + /// verbatim without invoking Roslyn (parity with the VT evaluator's fast-path). + [Fact] + public void Passthrough_returns_dependency_value_without_compiling() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + var result = sut.Evaluate( + calcTagId: "calc-mirror", + scriptSource: "return ctx.GetTag(\"a\").Value;", + dependencies: new Dictionary { ["a"] = 42 }); + + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBe(42); + } + + /// Decisive proof the fast-path skips Roslyn: the compiled-script cache stays EMPTY after a + /// mirror evaluation, then grows to one entry once a genuine (non-mirror) expression forces compilation. + [Fact] + public void Passthrough_does_not_populate_the_compiled_script_cache() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + sut.Evaluate("calc-mirror", "return ctx.GetTag(\"a\").Value;", + new Dictionary { ["a"] = 1 }); + CompiledCacheCount(sut).ShouldBe(0); + + sut.Evaluate("calc-real", "return (int)ctx.GetTag(\"a\").Value + 1;", + new Dictionary { ["a"] = 1 }); + CompiledCacheCount(sut).ShouldBe(1); + } + + // ── timeout ───────────────────────────────────────────────────────────────────────────── + + /// An infinite-loop script returns Failure ("timed out") within the wall-clock budget instead + /// of wedging the caller. The whole call is WaitAsync-bounded so a regression FAILS rather than hangs. + [Fact] + public async Task Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang() + { + using var sut = new CalculationEvaluator( + NullLogger.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200)); + + var evalTask = Task.Run( + () => sut.Evaluate("calc-loop", "while(true){}return 0;", new Dictionary()), + TestContext.Current.CancellationToken); + + var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("timed out"); + } + + // ── sandbox violation ─────────────────────────────────────────────────────────────────── + + /// A script touching a forbidden API (file I/O) is rejected the way the VT evaluator rejects + /// it: the ScriptSandboxViolationException from compile is caught and returned as a Failure whose + /// reason names the sandbox violation — never propagated as an exception. + [Fact] + public void Evaluate_sandbox_violation_returns_Failure_and_does_not_throw() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + var result = sut.Evaluate( + calcTagId: "calc-fileio", + scriptSource: "return System.IO.File.ReadAllText(\"c:/secrets.txt\");", + dependencies: new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("sandbox violation"); + } + + // ── single-tag mode: ctx.SetVirtualTag drop ───────────────────────────────────────────── + + /// Single-tag mode: a script calling ctx.SetVirtualTag has that cross-tag write dropped + /// (logged at Debug) while the script's own return value — the calc tag's single output — is preserved. + [Fact] + public void SetVirtualTag_call_is_dropped_and_logged_single_output_preserved() + { + var logger = new CapturingLogger(); + using var sut = new CalculationEvaluator(logger, NoOpScriptRoot()); + + var result = sut.Evaluate( + calcTagId: "calc-fanout", + scriptSource: "ctx.SetVirtualTag(\"other\", 99); return (int)ctx.GetTag(\"a\").Value + 1;", + dependencies: new Dictionary { ["a"] = 41 }); + + // The write to "other" is dropped, but the calc tag's own output flows through unaffected. + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBe(42); + + // The drop was logged (single-tag evaluator), never applied. + logger.Entries.ShouldContain(e => + e.Level == LogLevel.Debug && e.Message.Contains("dropped") && e.Message.Contains("other")); + } + + // ── compile error ─────────────────────────────────────────────────────────────────────── + + /// A compile error returns Failure with a descriptive reason (parity with the VT evaluator). + [Fact] + public void Evaluate_compile_error_returns_Failure_with_reason() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + var result = sut.Evaluate("calc-bad", "this is not valid C#;", new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("compile"); + } + + /// A runtime throw returns Failure ("threw") rather than propagating (parity with the VT evaluator). + [Fact] + public void Evaluate_runtime_exception_returns_Failure_with_reason() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + var result = sut.Evaluate( + calcTagId: "calc-div0", + scriptSource: "int a = 0; return 1 / a;", + dependencies: new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("threw"); + } + + // ── cache reuse + clear ───────────────────────────────────────────────────────────────── + + /// A compiled (non-mirror) expression is cached and re-used across calls: two evaluations of the + /// same source produce fresh values but leave the cache at exactly one entry. + [Fact] + public void Evaluate_caches_compiled_expression_across_calls() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;"; + + var first = sut.Evaluate("calc-cache", expr, new Dictionary { ["x"] = 5 }); + var second = sut.Evaluate("calc-cache", expr, new Dictionary { ["x"] = 7 }); + + first.Success.ShouldBeTrue(first.Reason); + first.Value.ShouldBe(10); + second.Success.ShouldBeTrue(second.Reason); + second.Value.ShouldBe(14); + CompiledCacheCount(sut).ShouldBe(1); + } + + /// drops every cached compile — the hook + /// WP7 calls per deploy generation to release stale AssemblyLoadContexts — and the same source recompiles + /// cleanly afterward. + [Fact] + public void ClearCompiledScripts_empties_cache_then_recompiles() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;"; + + sut.Evaluate("calc-clear", expr, new Dictionary { ["a"] = 1 }); + CompiledCacheCount(sut).ShouldBe(1); + + sut.ClearCompiledScripts(); + CompiledCacheCount(sut).ShouldBe(0); + + var again = sut.Evaluate("calc-clear", expr, new Dictionary { ["a"] = 41 }); + again.Success.ShouldBeTrue(again.Reason); + again.Value.ShouldBe(42); + CompiledCacheCount(sut).ShouldBe(1); + } + + // ── guard rails ───────────────────────────────────────────────────────────────────────── + + /// Empty / whitespace scripts return Failure (parity with the VT evaluator). + [Fact] + public void Evaluate_empty_expression_returns_Failure() + { + using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + + sut.Evaluate("calc-empty", "", new Dictionary()).Success.ShouldBeFalse(); + sut.Evaluate("calc-empty", " ", new Dictionary()).Success.ShouldBeFalse(); + } + + /// Evaluation after disposal returns Failure ("disposed") rather than throwing. + [Fact] + public void Evaluate_after_dispose_returns_Failure() + { + var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot()); + sut.Dispose(); + + var result = sut.Evaluate("calc", "return 1;", new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason!.ShouldContain("disposed"); + } + + /// Reads the count of the private CompiledScriptCache (which exposes a Count + /// property) via reflection — mirrors the VT evaluator test's cache-count probe. + private static int CompiledCacheCount(CalculationEvaluator sut) + { + var field = typeof(CalculationEvaluator) + .GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic); + field.ShouldNotBeNull(); + var cache = field.GetValue(sut)!; + var countProp = cache.GetType().GetProperty("Count"); + countProp.ShouldNotBeNull(); + return (int)countProp.GetValue(cache)!; + } + + /// Minimal that records emitted entries so the SetVirtualTag-drop + /// test can assert the Debug "dropped" line was logged. + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => + Entries.Add((logLevel, formatter(state, exception))); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj new file mode 100644 index 00000000..b3cbfe8b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + +