fix(scripting): route engines through CompiledScriptCache (Core.Scripting-016)

Both VirtualTagEngine.Load and ScriptedAlarmEngine.LoadAsync were calling
ScriptEvaluator.Compile directly, bypassing CompiledScriptCache. The
Core.Scripting-008 collectible-ALC fix wired Dispose only through the cache's
Clear()/Dispose(), so the per-publish accretion the -008 fix was meant to
eliminate was still in effect on the actual production path — the headline
'no more restarts needed' guarantee wasn't delivered.

Resolution:
  - VirtualTagEngine + ScriptedAlarmEngine each gained a private
    CompiledScriptCache<TContext, TResult> instance.
  - Both Load methods now call _compileCache.GetOrCompile(source).
  - Publish-replace path: _compileCache.Clear() runs alongside the existing
    _tags / _alarms clears so the prior generation's ALCs are disposed
    before recompile.
  - Engine Dispose now calls _compileCache.Dispose() so shutdown actually
    releases the emitted assemblies.

Side-fix in CompiledScriptCache: Dispose() set _disposed=true then called
Clear(), but Clear() had a pre-existing 'if (_disposed) return' guard that
aborted the drain unconditionally — making the Dispose-triggered cleanup a
silent no-op. Removed the disposed-guard on Clear() (clearing an empty/
cleared cache is idempotent).

Side-fix in ScriptedAlarmEngine.Dispose: cleared _alarms AFTER the
Task.WhenAll drain. The drain guarantees no background callback is mid-
flight, so clearing is safe. Previously _alarms was deliberately NOT
cleared on Dispose (per Core.ScriptedAlarms-005), but that left the
AlarmState records holding TimedScriptEvaluator → ScriptEvaluator → delegate
references that rooted the emitted assemblies, defeating the cache's
Dispose work on the engine side.

Regression tests:
  - VirtualTagEngineTests.Dispose_unloads_compiled_script_assembly
  - ScriptedAlarmEngineTests.Dispose_unloads_compiled_predicate_assembly
  Both use WeakReference + bounded GC.Collect() to prove the emitted
  assembly is reclaimable after engine.Dispose(). The alarms test had to
  be synchronous (not 'async Task<WeakReference>') because async state
  machines capture locals as state-struct fields, keeping them alive past
  the method's apparent end and defeating GC.

Verification:
  - Core.Scripting.Tests: 104/104 (unchanged).
  - VirtualTags.Tests: 57/57 (was 56 — +1 unload test).
  - ScriptedAlarms.Tests: 67/67 (was 66 — +1 unload test).
  - All other consumer suites still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 17:33:34 -04:00
parent a6ae4e22d1
commit fb7c6c7046
7 changed files with 242 additions and 13 deletions

View File

@@ -1015,4 +1015,75 @@ public sealed class ScriptedAlarmEngineTests
"LoadAsync must drop the prior generation's scratch — reuse across a publish " +
"would attach a stale Logger / Inputs to the new alarm definition.");
}
// --- Core.Scripting-016: engine routes compiles through CompiledScriptCache ---
[Fact]
public void Dispose_unloads_compiled_predicate_assembly()
{
// Pre-fix the engine called ScriptEvaluator.Compile directly, so the
// emitted predicate assembly's ALC stayed loaded for the process lifetime.
// After the fix the engine routes through CompiledScriptCache; engine
// Dispose triggers cache Dispose which unloads every cached evaluator's ALC.
// Assert via WeakReference + GC that the assembly is actually reclaimed.
// Helper is sync + [NoInlining] so its locals can't be kept alive by an
// async state machine (an earlier async version of this test failed because
// the state-machine struct held the evaluator past the method-end).
var weak = CompileAlarmAndCaptureWeak();
for (int i = 0; i < 10 && weak.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
weak.IsAlive.ShouldBeFalse(
"engine Dispose must release the compiled-predicate assembly via " +
"CompiledScriptCache (Core.Scripting-016). If this fails the engine is " +
"back to calling ScriptEvaluator.Compile directly and -008's headline " +
"fix doesn't run in production.");
}
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static WeakReference CompileAlarmAndCaptureWeak()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var eng = Build(up, out _);
// Block on LoadAsync so this helper stays synchronous — an `async Task`
// wrapper would compile to a state machine whose generated struct keeps the
// local `eng` reference alive past the method's apparent end, defeating GC.
eng.LoadAsync(
[new ScriptedAlarmDefinition(
"HighTemp", "Plant/Line1", "HighTemp",
AlarmKind.AlarmCondition, AlarmSeverity.High,
"x",
"""return (int)ctx.GetTag("Temp").Value > 100;""")],
default).GetAwaiter().GetResult();
// Reach into the engine's compile cache via reflection — the field is
// private; we only need the Assembly reference, scoped to this NoInlining
// helper so the locals die when it returns.
var weak = ExtractEmittedAssemblyWeakRef(eng);
eng.Dispose();
return weak;
}
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static WeakReference ExtractEmittedAssemblyWeakRef(ScriptedAlarmEngine eng)
{
var cacheField = typeof(ScriptedAlarmEngine).GetField(
"_compileCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var cache = cacheField.GetValue(eng)!;
var cacheDictField = cache.GetType().GetField(
"_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var cacheDict = (System.Collections.IDictionary)cacheDictField.GetValue(cache)!;
var lazy = cacheDict.Values.Cast<object>().Single();
var evaluator = lazy.GetType().GetProperty("Value")!.GetValue(lazy)!;
var del = (Delegate)evaluator.GetType().GetField(
"_func", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(evaluator)!;
return new WeakReference(del.Method.Module.Assembly);
}
}

View File

@@ -655,4 +655,60 @@ public sealed class VirtualTagEngineTests
lock (_buf) { _buf.Add((path, value)); }
}
}
// --- Core.Scripting-016: engine routes compiles through CompiledScriptCache ---
[Fact]
public void Dispose_unloads_compiled_script_assembly()
{
// Pre-fix the engine called ScriptEvaluator.Compile directly, so the emitted
// script assembly's ALC stayed loaded for the process lifetime. After the fix
// the engine routes through CompiledScriptCache; engine Dispose triggers cache
// Dispose which unloads every cached evaluator's ALC. Assert via WeakReference
// + GC that the assembly is actually reclaimed.
var weak = CompileTagAndCaptureWeak();
for (int i = 0; i < 10 && weak.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
weak.IsAlive.ShouldBeFalse(
"engine Dispose must release the compiled-tag assembly via " +
"CompiledScriptCache (Core.Scripting-016). If this fails the engine is " +
"back to calling ScriptEvaluator.Compile directly and -008's headline " +
"fix doesn't run in production.");
}
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static WeakReference CompileTagAndCaptureWeak()
{
var up = new FakeUpstream();
up.Set("X", 10);
var engine = Build(up);
engine.Load([new VirtualTagDefinition(
Path: "Doubled",
DataType: DriverDataType.Float64,
ScriptSource: """return (double)ctx.GetTag("X").Value * 2.0;""")]);
// Reach into the engine's compile cache via reflection — internals scoped to
// Tests-via-InternalsVisibleTo and the field is private.
var cacheField = typeof(VirtualTagEngine).GetField(
"_compileCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var cache = cacheField.GetValue(engine)!;
var cacheDictField = cache.GetType().GetField(
"_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var cacheDict = (System.Collections.IDictionary)cacheDictField.GetValue(cache)!;
var lazy = cacheDict.Values.Cast<object>().Single();
var lazyValueProp = lazy.GetType().GetProperty("Value")!;
var evaluator = lazyValueProp.GetValue(lazy)!;
var funcField = evaluator.GetType().GetField(
"_func", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var del = (Delegate)funcField.GetValue(evaluator)!;
var weak = new WeakReference(del.Method.Module.Assembly);
engine.Dispose();
return weak;
}
}