Merge branch 'fix/archreview-crit2-vt-timeout'

This commit is contained in:
Joseph Doherty
2026-07-09 06:07:33 -04:00
5 changed files with 149 additions and 17 deletions
@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -20,10 +19,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
/// Cycle detection + cascade ordering live in <see cref="VirtualTagEngine"/>; this adapter
/// stays single-tag scoped to keep <see cref="VirtualTagActor"/>'s message loop simple.
/// </summary>
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCacheOwner, IDisposable
{
private readonly ConcurrentDictionary<string, ScriptEvaluator<VirtualTagContext, object?>> _cache
= new(StringComparer.Ordinal);
// Purpose-built source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and
// Clear()/Dispose() that dispose each evaluator's collectible AssemblyLoadContext. The apply-boundary
// Clear() (driven by VirtualTagHostActor via IScriptCacheOwner) is what stops ALCs accreting across
// script edits — the raw ConcurrentDictionary this replaced never released them.
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
private readonly ILogger<RoslynVirtualTagEvaluator> _logger;
private readonly SerilogLogger _scriptRoot;
private readonly TimeSpan _runTimeout;
@@ -64,7 +66,7 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
ScriptEvaluator<VirtualTagContext, object?> evaluator;
try
{
evaluator = _cache.GetOrAdd(expression, ScriptEvaluator<VirtualTagContext, object?>.Compile);
evaluator = _cache.GetOrCompile(expression);
}
catch (CompilationErrorException ex)
{
@@ -98,11 +100,16 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
try
{
using var cts = new CancellationTokenSource(_runTimeout);
var raw = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
// Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't
// interrupt a CPU-bound/infinite-loop script (Roslyn scripts don't poll it), so the previous
// CTS-only path let one runaway script hang this actor forever. WaitAsync returns control when
// the wall-clock budget fires regardless of whether the inner task completes (the orphaned
// thread is the documented, accepted trade-off — TimedScriptEvaluator remarks).
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _runTimeout);
var raw = timed.RunAsync(context).GetAwaiter().GetResult();
return VirtualTagEvalResult.Ok(raw);
}
catch (OperationCanceledException)
catch (ScriptTimeoutException)
{
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
}
@@ -113,6 +120,9 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
}
}
/// <inheritdoc />
public void ClearCompiledScripts() => _cache.Clear();
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
IReadOnlyDictionary<string, object?> deps)
{
@@ -133,10 +143,7 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
{
if (_disposed) return;
_disposed = true;
foreach (var ev in _cache.Values)
{
try { ev.Dispose(); } catch { /* best-effort */ }
}
_cache.Clear();
// CompiledScriptCache.Dispose drains + disposes every materialised evaluator's ALC.
_cache.Dispose();
}
}
@@ -85,6 +85,12 @@ public sealed class VirtualTagHostActor : ReceiveActor
private void OnApply(ApplyVirtualTags msg)
{
// Apply-boundary compile-cache drop: each deploy generation may carry edited script sources, and
// the production evaluator roots one collectible AssemblyLoadContext per compiled expression. Drop
// them here (mirroring ScriptedAlarmEngine's per-generation clear) so stale ALCs are reclaimed
// instead of accreting across edits. No-op for evaluators without a cache (NullVirtualTagEvaluator).
(_evaluator as IScriptCacheOwner)?.ClearCompiledScripts();
var desired = new HashSet<string>(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);
// Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its