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(); } }