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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user