fix(scripting): unload compiled-script assemblies via collectible ALC
Core.Scripting-008 resolution: replace the legacy CSharpScript.CreateDelegate
path with hand-rolled CSharpCompilation + Emit + collectible AssemblyLoadContext,
so per-publish compile accretion no longer requires a server restart to reclaim.
Why this was needed:
Roslyn's CSharpScript path emits dynamically-compiled script assemblies into
the default AssemblyLoadContext, which is non-collectible. Across config-
publish generations each Clear() drops dictionary entries but the emitted
assemblies stay loaded for process lifetime, so memory grows steadily on
long-running servers with frequent publishes. The accepted-limitation note
in docs/VirtualTags.md recommended scheduled restarts as the workaround;
operator feedback was that restarts are difficult, so the underlying
limitation was the right thing to fix.
Implementation:
- New ScriptAssemblyLoadContext(name, isCollectible: true) hosts one emitted
script assembly per evaluator.
- ScriptEvaluator.Compile synthesises a wrapper class around the user source
(CompiledScript.Run(globals) — explicit return required per ordinary C#
semantics, which every existing script already uses), builds a
CSharpCompilation against the sandbox references, runs the
ForbiddenTypeAnalyzer over the semantic model unchanged, emits to an
in-memory PE stream, loads via ScriptAssemblyLoadContext.LoadFromStream,
and binds a strongly-typed Func<ScriptGlobals<TContext>, TResult> delegate
via reflection.
- ScriptEvaluator now implements IDisposable — Dispose calls
AssemblyLoadContext.Unload(), which makes the emitted assembly eligible
for GC at the next collection cycle.
- CompiledScriptCache.Clear() disposes every materialised evaluator before
dropping its dictionary entry; CompiledScriptCache itself is now
IDisposable for graceful server shutdown.
- ScriptSandbox.Build returns a new SandboxConfig (References + Imports)
instead of a Roslyn ScriptOptions; references now span BCL via the
TRUSTED_PLATFORM_ASSEMBLIES set filtered to System.* + netstandard +
Microsoft.Win32.Registry, so forbidden BCL types resolve at compile and
ForbiddenTypeAnalyzer is the sole security gate (consistent with the
Core.Scripting-001 / -002 model — references-list-only restriction is
porous against type forwarding, so the analyzer must be the real gate).
Verification:
- All 104 Core.Scripting tests pass (was 101 — three new regression tests
locking the unload contract).
- All 56 VirtualTags tests pass (unchanged).
- All 63 ScriptedAlarms tests pass (unchanged).
- New CompiledScriptCacheTests:
- Dispose_unloads_compiled_script_assembly_load_context — proves single-
evaluator ALC unload via WeakReference + bounded GC.Collect() loop.
- Clear_disposes_every_materialised_evaluator — proves publish-replace
releases every prior generation's ALC.
- GetOrCompile_after_Dispose_throws_ObjectDisposedException — locks the
post-dispose contract.
Docs:
- docs/VirtualTags.md "Compile cache" section rewritten: the accepted-
limitation note replaced with the unload contract + the new authoring
convention (explicit return).
- docs/ScriptedAlarms.md cross-reference updated to drop the obsolete
restart guidance.
- code-reviews/Core.Scripting/findings.md Core.Scripting-008 flipped
Won't Fix → Resolved with the implementation summary.
- code-reviews/README.md regenerated.
Pre-existing breakage note: Driver.Galaxy fails the solution-wide build on
master because its ProjectReference to the sibling mxaccessgw repo's
MxGateway.Client targets a path that the sibling repo no longer has after a
recent restructuring. This is unrelated to Core.Scripting-008 and was
verified to exist on master before this branch was cut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -240,7 +240,7 @@ race ordering.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Location | `CompiledScriptCache.cs:34`, `ScriptEvaluator.cs:34` |
|
| Location | `CompiledScriptCache.cs:34`, `ScriptEvaluator.cs:34` |
|
||||||
| Status | Won't Fix |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `CompiledScriptCache` has no capacity bound (acknowledged in the class
|
**Description:** `CompiledScriptCache` has no capacity bound (acknowledged in the class
|
||||||
remarks) and no eviction. Each cached `ScriptEvaluator` holds a Roslyn `ScriptRunner<T>`
|
remarks) and no eviction. Each cached `ScriptEvaluator` holds a Roslyn `ScriptRunner<T>`
|
||||||
@@ -257,7 +257,33 @@ compile scripts into a collectible `AssemblyLoadContext` so `Clear()` can unload
|
|||||||
generations. At minimum add a note to `docs/ScriptedAlarms.md` so operators with
|
generations. At minimum add a note to `docs/ScriptedAlarms.md` so operators with
|
||||||
high-publish-frequency deployments are aware.
|
high-publish-frequency deployments are aware.
|
||||||
|
|
||||||
**Resolution:** Resolved 2026-05-23 — accepted as a documented known limitation rather than fixing in code (collectible `AssemblyLoadContext` for Roslyn-emitted assemblies is a v3 concern). The "Compile cache" section of `docs/VirtualTags.md` now carries a "Per-publish assembly accretion (accepted limitation, Core.Scripting-008)" note that operators with high-publish-frequency deployments can scan, and `docs/ScriptedAlarms.md` cross-references it. The accretion is benign at the expected "low thousands" of scripts scale; recommended mitigation is a scheduled server restart for deployments that publish very frequently.
|
**Resolution:** Resolved 2026-05-23 — switched the compile pipeline off the legacy
|
||||||
|
`CSharpScript.CreateDelegate` path (which emits into the default, non-collectible
|
||||||
|
`AssemblyLoadContext`) and onto a hand-rolled `CSharpCompilation` →
|
||||||
|
`Compilation.Emit(MemoryStream)` → `ScriptAssemblyLoadContext.LoadFromStream` chain,
|
||||||
|
with the new `ScriptAssemblyLoadContext` constructed `isCollectible: true`. Each
|
||||||
|
compiled script lives in its own ALC; `ScriptEvaluator` now implements `IDisposable`
|
||||||
|
and calls `AssemblyLoadContext.Unload()` on dispose. `CompiledScriptCache.Clear()`
|
||||||
|
disposes every materialised evaluator before dropping its dictionary entry, and
|
||||||
|
`CompiledScriptCache` itself is now `IDisposable` for graceful server shutdown.
|
||||||
|
After a publish-replace cycle the prior generation's emitted assemblies become
|
||||||
|
eligible for GC; the reclaim is GC-timing-sensitive (Unload is
|
||||||
|
*eligible-for-collection*, not synchronous) and the next collection cycle reclaims
|
||||||
|
them. The references list is now BCL-wide (System.* + netstandard + Microsoft.Win32.Registry
|
||||||
|
via the TRUSTED_PLATFORM_ASSEMBLIES set) so forbidden BCL types resolve at compile and
|
||||||
|
`ForbiddenTypeAnalyzer` is the sole security gate (consistent with the
|
||||||
|
Core.Scripting-001 / -002 model). `docs/VirtualTags.md` "Compile cache" section rewritten;
|
||||||
|
`docs/ScriptedAlarms.md` cross-reference updated to drop the obsolete restart guidance.
|
||||||
|
Regression tests added in `CompiledScriptCacheTests`:
|
||||||
|
`Dispose_unloads_compiled_script_assembly_load_context`,
|
||||||
|
`Clear_disposes_every_materialised_evaluator`, and
|
||||||
|
`GetOrCompile_after_Dispose_throws_ObjectDisposedException`; the first two
|
||||||
|
prove ALC unload via `WeakReference` + bounded `GC.Collect()` loops. Suite now 104
|
||||||
|
green (was 101). Authoring convention: the synthesized wrapper is an ordinary
|
||||||
|
C# static method, so scripts must end with explicit `return …;` per ordinary C# rules
|
||||||
|
(the legacy `CSharpScript` "last expression yields result" shorthand no longer applies);
|
||||||
|
every script in the existing corpus already uses explicit `return` so this is a doc-only
|
||||||
|
change for new authors.
|
||||||
|
|
||||||
### Core.Scripting-009
|
### Core.Scripting-009
|
||||||
|
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| Core.ScriptedAlarms-011 | Low | Resolved | Code organization & conventions | `Part9StateMachine.cs:275` |
|
| Core.ScriptedAlarms-011 | Low | Resolved | Code organization & conventions | `Part9StateMachine.cs:275` |
|
||||||
| Core.Scripting-005 | Low | Resolved | Correctness & logic bugs | `DependencyExtractor.cs:97` |
|
| Core.Scripting-005 | Low | Resolved | Correctness & logic bugs | `DependencyExtractor.cs:97` |
|
||||||
| Core.Scripting-006 | Low | Resolved | Concurrency & thread safety | `CompiledScriptCache.cs:55` |
|
| Core.Scripting-006 | Low | Resolved | Concurrency & thread safety | `CompiledScriptCache.cs:55` |
|
||||||
| Core.Scripting-008 | Low | Won't Fix | Performance & resource management | `CompiledScriptCache.cs:34`, `ScriptEvaluator.cs:34` |
|
| Core.Scripting-008 | Low | Resolved | Performance & resource management | `CompiledScriptCache.cs:34`, `ScriptEvaluator.cs:34` |
|
||||||
| Core.Scripting-009 | Low | Resolved | Design-document adherence | `ForbiddenTypeAnalyzer.cs:45` |
|
| Core.Scripting-009 | Low | Resolved | Design-document adherence | `ForbiddenTypeAnalyzer.cs:45` |
|
||||||
| Core.Scripting-011 | Low | Resolved | Testing coverage | `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/` |
|
| Core.Scripting-011 | Low | Resolved | Testing coverage | `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/` |
|
||||||
| Core.VirtualTags-004 | Low | Resolved | Correctness & logic bugs | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:349` |
|
| Core.VirtualTags-004 | Low | Resolved | Correctness & logic bugs | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:349` |
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ new ScriptedAlarmDefinition(
|
|||||||
|
|
||||||
## Predicate evaluation
|
## Predicate evaluation
|
||||||
|
|
||||||
Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvaluator<AlarmPredicateContext, bool>` compiles the source, `TimedScriptEvaluator` wraps it with the configured timeout (default from `TimedScriptEvaluator.DefaultTimeout`), and `DependencyExtractor` statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in [VirtualTags.md](VirtualTags.md); ScriptedAlarms does not redefine them. The known resource limits — unbounded script-side memory, the per-publish accretion of dynamically-emitted script assemblies (Core.Scripting-008), and the orphan-thread CPU-budget caveat — are documented in that file as well.
|
Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvaluator<AlarmPredicateContext, bool>` compiles the source, `TimedScriptEvaluator` wraps it with the configured timeout (default from `TimedScriptEvaluator.DefaultTimeout`), and `DependencyExtractor` statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in [VirtualTags.md](VirtualTags.md); ScriptedAlarms does not redefine them. The known resource limits — unbounded script-side memory and the orphan-thread CPU-budget caveat — are documented in that file as well; per-publish assembly accretion was resolved by the Core.Scripting-008 collectible-`AssemblyLoadContext` rewrite and no longer requires periodic server restarts.
|
||||||
|
|
||||||
`AlarmPredicateContext` (`AlarmPredicateContext.cs`) is the script's `ScriptContext` subclass:
|
`AlarmPredicateContext` (`AlarmPredicateContext.cs`) is the script's `ScriptContext` subclass:
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ Similarly, **`System.Threading.Tasks` is now denied** (Core.Scripting-003), whic
|
|||||||
|
|
||||||
`ConcurrentDictionary<string, Lazy<ScriptEvaluator<...>>>` keyed on `SHA-256(UTF8(source))` rendered to hex. `Lazy<T>` with `ExecutionAndPublication` mode means two threads racing a miss compile exactly once. Failed compiles evict the entry (via the `TryRemove(KeyValuePair<,>)` overload so a concurrently re-added retry entry is not collateral damage — Core.Scripting-006) so a corrected retry can succeed (used during Admin UI authoring). No capacity bound — scripts are operator-authored and bounded by the config DB. Whitespace changes miss the cache on purpose. `Clear()` is called on config-publish.
|
`ConcurrentDictionary<string, Lazy<ScriptEvaluator<...>>>` keyed on `SHA-256(UTF8(source))` rendered to hex. `Lazy<T>` with `ExecutionAndPublication` mode means two threads racing a miss compile exactly once. Failed compiles evict the entry (via the `TryRemove(KeyValuePair<,>)` overload so a concurrently re-added retry entry is not collateral damage — Core.Scripting-006) so a corrected retry can succeed (used during Admin UI authoring). No capacity bound — scripts are operator-authored and bounded by the config DB. Whitespace changes miss the cache on purpose. `Clear()` is called on config-publish.
|
||||||
|
|
||||||
**Per-publish assembly accretion (accepted limitation, Core.Scripting-008).** Each compiled `ScriptEvaluator` holds a Roslyn `ScriptRunner<T>` delegate, which keeps the dynamically-emitted script assembly loaded for the process lifetime. Emitted assemblies in the default `AssemblyLoadContext` cannot be unloaded; `CompiledScriptCache.Clear()` drops the dictionary entries but does **not** unload the underlying assemblies. Across many config-publish generations (each `Clear()` followed by recompiling every script), the process accumulates dead script assemblies. For the expected "low thousands" of scripts this is benign, but a long-running server with very frequent publishes will see steady managed-memory growth that does not return until the process restarts. Out-of-process script evaluation or a collectible `AssemblyLoadContext` is a v3 concern; deployments with high-publish-frequency requirements should schedule a periodic server restart to reclaim the accrued assemblies.
|
**Per-publish assembly unload (Core.Scripting-008 resolved).** Each compiled `ScriptEvaluator` emits its script into a dedicated **collectible** `AssemblyLoadContext` — the BCL escape hatch for assemblies that can be unloaded. The compile path is hand-rolled `CSharpCompilation.Create` + `Emit(MemoryStream)` + `ScriptAssemblyLoadContext.LoadFromStream` rather than the legacy `CSharpScript.CreateDelegate` (which emits into the default ALC and cannot be unloaded). `ScriptEvaluator.Dispose()` calls `AssemblyLoadContext.Unload()` and `CompiledScriptCache.Clear()` disposes every materialised evaluator before dropping its dictionary entry, so the emitted assemblies become eligible for GC immediately after a config-publish. The reclaim is GC-timing-sensitive (Unload is *eligible-for-collection*, not synchronous); the next collection cycle reclaims them. Regression tests `Dispose_unloads_compiled_script_assembly_load_context` and `Clear_disposes_every_materialised_evaluator` in `CompiledScriptCacheTests` lock this contract via `WeakReference` + `GC.Collect()` assertions. Server restarts are no longer required to reclaim compiled-script memory.
|
||||||
|
|
||||||
|
**Scripting authoring convention.** With the collectible-ALC rewrite, the wrapper around a user script is an ordinary C# static method, not a Roslyn `Script` submission. The script body is pasted verbatim as the method body and must therefore end with an explicit `return …;` per ordinary C# rules — the legacy `CSharpScript` "last expression yields result" shorthand is gone. Every script in the existing test corpus already uses explicit `return`; this convention is operator-visible only when authoring a brand-new script from scratch.
|
||||||
|
|
||||||
### Per-evaluation timeout (`TimedScriptEvaluator<TContext, TResult>`)
|
### Per-evaluation timeout (`TimedScriptEvaluator<TContext, TResult>`)
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,20 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|||||||
/// bounded by config DB (typically low thousands). If that changes in v3, add an
|
/// bounded by config DB (typically low thousands). If that changes in v3, add an
|
||||||
/// LRU eviction policy — the API stays the same.
|
/// LRU eviction policy — the API stays the same.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Lifecycle:</b> compiled scripts hold a collectible
|
||||||
|
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/> per evaluator
|
||||||
|
/// (Core.Scripting-008 fix). <see cref="Clear"/> disposes every materialised
|
||||||
|
/// evaluator before dropping its dictionary entry so the emitted assemblies are
|
||||||
|
/// eligible for GC immediately after a publish. <see cref="Dispose"/> drops the
|
||||||
|
/// cache itself for graceful server shutdown.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class CompiledScriptCache<TContext, TResult>
|
public sealed class CompiledScriptCache<TContext, TResult> : IDisposable
|
||||||
where TContext : ScriptContext
|
where TContext : ScriptContext
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
|
private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
|
/// Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
|
||||||
@@ -46,6 +55,7 @@ public sealed class CompiledScriptCache<TContext, TResult>
|
|||||||
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
|
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
|
||||||
{
|
{
|
||||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(CompiledScriptCache<TContext, TResult>));
|
||||||
|
|
||||||
var key = HashSource(scriptSource);
|
var key = HashSource(scriptSource);
|
||||||
var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
|
var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
|
||||||
@@ -72,13 +82,58 @@ public sealed class CompiledScriptCache<TContext, TResult>
|
|||||||
/// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
|
/// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
|
||||||
public int Count => _cache.Count;
|
public int Count => _cache.Count;
|
||||||
|
|
||||||
/// <summary>Drop every cached compile. Used on config generation publish + tests.</summary>
|
/// <summary>
|
||||||
public void Clear() => _cache.Clear();
|
/// Drop every cached compile. Used on config generation publish + tests.
|
||||||
|
/// Disposes each materialised evaluator before removing it so its collectible
|
||||||
|
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/> unloads and the
|
||||||
|
/// emitted script assembly becomes eligible for GC (Core.Scripting-008).
|
||||||
|
/// </summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
// Snapshot the entries, swap them out, then dispose. We use TryRemove rather
|
||||||
|
// than _cache.Clear() so a concurrent GetOrCompile re-add after our snapshot
|
||||||
|
// is not silently lost — a new compile starts a fresh cache entry, the old
|
||||||
|
// evaluator is still disposed.
|
||||||
|
foreach (var key in _cache.Keys.ToArray())
|
||||||
|
{
|
||||||
|
if (_cache.TryRemove(key, out var lazy))
|
||||||
|
DisposeLazyIfMaterialised(lazy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
|
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
|
||||||
public bool Contains(string scriptSource)
|
public bool Contains(string scriptSource)
|
||||||
=> _cache.ContainsKey(HashSource(scriptSource));
|
=> _cache.ContainsKey(HashSource(scriptSource));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drop the cache and dispose every materialised evaluator. After disposal
|
||||||
|
/// <see cref="GetOrCompile"/> throws <see cref="ObjectDisposedException"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeLazyIfMaterialised(Lazy<ScriptEvaluator<TContext, TResult>> lazy)
|
||||||
|
{
|
||||||
|
// IsValueCreated is false for a faulted Lazy too, so the catch in GetOrCompile
|
||||||
|
// has already taken care of failed compiles — there's no evaluator to dispose.
|
||||||
|
if (!lazy.IsValueCreated) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lazy.Value.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Dispose is best-effort here: an evaluator disposal failure would leak its
|
||||||
|
// ALC but mustn't prevent the rest of the cache from clearing. The ALC
|
||||||
|
// unload itself is exception-free in practice; this is defensive.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string HashSource(string source)
|
private static string HashSource(string source)
|
||||||
{
|
{
|
||||||
var bytes = Encoding.UTF8.GetBytes(source);
|
var bytes = Encoding.UTF8.GetBytes(source);
|
||||||
|
|||||||
@@ -1,75 +1,315 @@
|
|||||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
using System.Reflection;
|
||||||
using Microsoft.CodeAnalysis.Scripting;
|
using System.Runtime.Loader;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compiles + runs user scripts against a <see cref="ScriptContext"/> subclass. Core
|
/// Compiles + runs user scripts against a <see cref="ScriptContext"/> subclass. Core
|
||||||
/// evaluator — no caching, no timeout, no logging side-effects yet (those land in
|
/// evaluator — no caching, no timeout, no logging side-effects (those land in
|
||||||
/// Stream A.3, A.4, A.5 respectively). Stream B + C wrap this with the dependency
|
/// <see cref="CompiledScriptCache{TContext, TResult}"/>,
|
||||||
/// scheduler + alarm state machine.
|
/// <see cref="TimedScriptEvaluator{TContext, TResult}"/>, and
|
||||||
|
/// <see cref="ScriptLogCompanionSink"/> respectively).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Scripts are compiled against <see cref="ScriptGlobals{TContext}"/> so the
|
/// Scripts are wrapped in a synthesized <c>CompiledScript.Run(globals)</c> method
|
||||||
/// context member is named <c>ctx</c> in the script, matching the
|
/// and compiled via <see cref="CSharpCompilation"/> into a regular .NET assembly
|
||||||
/// <see cref="DependencyExtractor"/>'s walker and the Admin UI type stub.
|
/// that is loaded into a <b>collectible</b>
|
||||||
|
/// <see cref="AssemblyLoadContext"/>. The collectible ALC is the fix for
|
||||||
|
/// Core.Scripting-008: per-publish recompile accretion was previously unbounded
|
||||||
|
/// because Roslyn's <c>CSharpScript.CreateDelegate</c> emits into the default ALC
|
||||||
|
/// (non-collectible); now <see cref="Dispose"/> unloads the entire ALC and the
|
||||||
|
/// emitted assembly becomes eligible for GC.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Compile pipeline is a three-step gate: (1) Roslyn compile — catches syntax
|
/// Compile pipeline is a three-step gate, unchanged in intent from the legacy
|
||||||
/// errors + type-resolution failures, throws <see cref="CompilationErrorException"/>;
|
/// <c>CSharpScript</c> path: (1) Roslyn parse + compile against the
|
||||||
/// (2) <see cref="ForbiddenTypeAnalyzer"/> runs against the semantic model —
|
/// <see cref="ScriptSandbox"/> allow-list — catches syntax errors, unresolved
|
||||||
/// catches sandbox escapes that slipped past reference restrictions due to .NET's
|
/// types (the sandbox's first line of defense), and most type-resolution
|
||||||
/// type forwarding, throws <see cref="ScriptSandboxViolationException"/>; (3)
|
/// failures, throwing <see cref="CompilationErrorException"/>; (2)
|
||||||
/// delegate creation — throws at this layer only for internal Roslyn bugs, not
|
/// <see cref="ForbiddenTypeAnalyzer"/> runs against the semantic model — catches
|
||||||
/// user error.
|
/// sandbox escapes that slipped past reference restrictions due to .NET's type
|
||||||
|
/// forwarding, throwing <see cref="ScriptSandboxViolationException"/>; (3) emit
|
||||||
|
/// to an in-memory PE stream + load into the collectible ALC — throws at this
|
||||||
|
/// layer only for internal Roslyn bugs, not user error.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag
|
/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag
|
||||||
/// engine (Stream B) catches them per-tag + maps to <c>BadInternalError</c>
|
/// engine catches them per-tag and maps to <c>BadInternalError</c> quality
|
||||||
/// quality per Phase 7 decision #11 — this layer doesn't swallow anything so
|
/// per Phase 7 decision #11; this layer doesn't swallow anything so tests can
|
||||||
/// tests can assert on the original exception type.
|
/// assert on the original exception type.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Scripts are expected to be statement bodies that end with an explicit
|
||||||
|
/// <c>return …;</c> — the wrapper provides only the surrounding method body, so
|
||||||
|
/// the script's final-expression-yields-result behavior of legacy
|
||||||
|
/// <c>CSharpScript</c> is replaced by ordinary C# method semantics. Every script
|
||||||
|
/// in the existing test corpus already uses explicit <c>return</c>; this is a
|
||||||
|
/// documented authoring convention.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ScriptEvaluator<TContext, TResult>
|
public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||||
where TContext : ScriptContext
|
where TContext : ScriptContext
|
||||||
{
|
{
|
||||||
private readonly ScriptRunner<TResult> _runner;
|
private readonly ScriptAssemblyLoadContext _alc;
|
||||||
|
private readonly Func<ScriptGlobals<TContext>, TResult> _func;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
private ScriptEvaluator(ScriptRunner<TResult> runner)
|
private ScriptEvaluator(ScriptAssemblyLoadContext alc, Func<ScriptGlobals<TContext>, TResult> func)
|
||||||
{
|
{
|
||||||
_runner = runner;
|
_alc = alc;
|
||||||
|
_func = func;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
|
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
|
||||||
{
|
{
|
||||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||||
|
|
||||||
var options = ScriptSandbox.Build(typeof(TContext));
|
var sandbox = ScriptSandbox.Build(typeof(TContext));
|
||||||
var script = CSharpScript.Create<TResult>(
|
|
||||||
code: scriptSource,
|
|
||||||
options: options,
|
|
||||||
globalsType: typeof(ScriptGlobals<TContext>));
|
|
||||||
|
|
||||||
// Step 1 — Roslyn compile. Throws CompilationErrorException on syntax / type errors.
|
// Step 1 — synthesize a wrapper class around the script body and parse it. The
|
||||||
var diagnostics = script.Compile();
|
// wrapper's `Run` method is what we invoke at runtime; the user's source is
|
||||||
|
// pasted in as its body so explicit `return` semantics apply.
|
||||||
|
var wrapperSource = BuildWrapperSource(scriptSource, sandbox.Imports);
|
||||||
|
var syntaxTree = CSharpSyntaxTree.ParseText(wrapperSource);
|
||||||
|
|
||||||
// Step 2 — forbidden-type semantic analysis. Defense-in-depth against reference-list
|
// Step 2 — Roslyn compile against the sandbox allow-list. Anything not in the
|
||||||
// leaks due to type forwarding.
|
// references set is unresolved and produces a compiler error.
|
||||||
var rejections = ForbiddenTypeAnalyzer.Analyze(script.GetCompilation());
|
var assemblyName = "ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled." +
|
||||||
|
Guid.NewGuid().ToString("N");
|
||||||
|
var compileOptions = new CSharpCompilationOptions(
|
||||||
|
OutputKind.DynamicallyLinkedLibrary,
|
||||||
|
optimizationLevel: OptimizationLevel.Release,
|
||||||
|
allowUnsafe: false,
|
||||||
|
// Don't generate XML doc warnings for the synthesized wrapper.
|
||||||
|
warningLevel: 4,
|
||||||
|
nullableContextOptions: NullableContextOptions.Enable);
|
||||||
|
var compilation = CSharpCompilation.Create(
|
||||||
|
assemblyName,
|
||||||
|
syntaxTrees: new[] { syntaxTree },
|
||||||
|
references: sandbox.References,
|
||||||
|
options: compileOptions);
|
||||||
|
|
||||||
|
var compileDiagnostics = compilation.GetDiagnostics();
|
||||||
|
var compileErrors = compileDiagnostics
|
||||||
|
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||||
|
.ToArray();
|
||||||
|
if (compileErrors.Length > 0)
|
||||||
|
throw new CompilationErrorException(compileErrors);
|
||||||
|
|
||||||
|
// Step 3 — forbidden-type semantic analysis. Defense-in-depth against
|
||||||
|
// reference-list leaks due to type forwarding.
|
||||||
|
var rejections = ForbiddenTypeAnalyzer.Analyze(compilation);
|
||||||
if (rejections.Count > 0)
|
if (rejections.Count > 0)
|
||||||
throw new ScriptSandboxViolationException(rejections);
|
throw new ScriptSandboxViolationException(rejections);
|
||||||
|
|
||||||
// Step 3 — materialize the callable delegate.
|
// Step 4 — emit to an in-memory PE stream and load into a collectible ALC.
|
||||||
var runner = script.CreateDelegate();
|
using var peStream = new MemoryStream();
|
||||||
return new ScriptEvaluator<TContext, TResult>(runner);
|
var emitResult = compilation.Emit(peStream);
|
||||||
|
if (!emitResult.Success)
|
||||||
|
{
|
||||||
|
var emitErrors = emitResult.Diagnostics
|
||||||
|
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||||
|
.ToArray();
|
||||||
|
throw new CompilationErrorException(emitErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
peStream.Position = 0;
|
||||||
|
var alc = new ScriptAssemblyLoadContext(assemblyName);
|
||||||
|
Assembly assembly;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
assembly = alc.LoadFromStream(peStream);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Failed to load — drop the ALC so we don't leak a half-initialised one.
|
||||||
|
alc.Unload();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5 — resolve the wrapper's Run method and bind a typed delegate. The
|
||||||
|
// wrapper source above puts the type in this exact namespace + class — keep the
|
||||||
|
// names in sync with BuildWrapperSource.
|
||||||
|
Func<ScriptGlobals<TContext>, TResult> func;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var wrapperType = assembly.GetType(
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled.CompiledScript",
|
||||||
|
throwOnError: true)!;
|
||||||
|
var runMethod = wrapperType.GetMethod(
|
||||||
|
"Run",
|
||||||
|
BindingFlags.Public | BindingFlags.Static)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Synthesized wrapper is missing the public static Run method.");
|
||||||
|
func = (Func<ScriptGlobals<TContext>, TResult>)Delegate.CreateDelegate(
|
||||||
|
typeof(Func<ScriptGlobals<TContext>, TResult>), runMethod);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
alc.Unload();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ScriptEvaluator<TContext, TResult>(alc, func);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Run against an already-constructed context.</summary>
|
/// <summary>Run against an already-constructed context.</summary>
|
||||||
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator<TContext, TResult>));
|
||||||
if (context is null) throw new ArgumentNullException(nameof(context));
|
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
var globals = new ScriptGlobals<TContext> { ctx = context };
|
var globals = new ScriptGlobals<TContext> { ctx = context };
|
||||||
return _runner(globals, ct);
|
// The user's script is synchronous (Roslyn emits a static method that returns
|
||||||
|
// TResult directly). We surface a Task<TResult> only to keep the existing
|
||||||
|
// RunAsync contract consumers depend on. TimedScriptEvaluator wraps this in
|
||||||
|
// Task.Run so a long-running script still honours its wall-clock budget.
|
||||||
|
var result = _func(globals);
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unload the collectible <see cref="AssemblyLoadContext"/> that holds the emitted
|
||||||
|
/// script assembly so the runtime can reclaim it. After disposal the evaluator can
|
||||||
|
/// no longer be invoked — call <see cref="ScriptEvaluator{TContext, TResult}.Compile"/>
|
||||||
|
/// again for a fresh one. Dispose is idempotent.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unload is <i>eligible-for-collection</i>, not synchronous: the assembly is
|
||||||
|
/// reclaimed when the GC determines no live references remain. The cache disposes
|
||||||
|
/// evaluators in <see cref="CompiledScriptCache{TContext, TResult}.Clear"/> so a
|
||||||
|
/// config-generation publish releases the prior generation in one sweep; the
|
||||||
|
/// reclaim then races with the next GC cycle. Tests verify the reclaim via
|
||||||
|
/// <see cref="WeakReference"/> + <see cref="GC.Collect()"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_alc.Unload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synthesize the source we hand to Roslyn. The user's script body is pasted
|
||||||
|
/// verbatim inside <c>CompiledScript.Run</c>; the <c>using</c> directives mirror
|
||||||
|
/// <see cref="ScriptSandbox"/>'s imports so scripts can write <c>Math.Abs</c>
|
||||||
|
/// instead of <c>System.Math.Abs</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildWrapperSource(string userSource, IReadOnlyList<string> imports)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var import in imports)
|
||||||
|
sb.Append("using ").Append(import).AppendLine(";");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("public static class CompiledScript");
|
||||||
|
sb.AppendLine("{");
|
||||||
|
sb.Append(" public static ").Append(ToCSharpTypeName(typeof(TResult)))
|
||||||
|
.Append(" Run(").Append(ToCSharpTypeName(typeof(ScriptGlobals<TContext>)))
|
||||||
|
.AppendLine(" globals)");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
sb.AppendLine(" var ctx = globals.ctx;");
|
||||||
|
// User source ends with `return X;` per the authoring convention; we paste it
|
||||||
|
// verbatim. The leading newline keeps Roslyn diagnostics' line numbers usable
|
||||||
|
// by operators (errors point at the user's source line, not the wrapper).
|
||||||
|
sb.AppendLine("#line 1");
|
||||||
|
sb.AppendLine(userSource);
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine("}");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a runtime <see cref="Type"/> to a C# type-name string suitable for
|
||||||
|
/// emitting into Roslyn source. Uses <c>global::</c>-qualified FQNs to avoid
|
||||||
|
/// accidental capture by the wrapper's <c>using</c> directives, handles nested
|
||||||
|
/// types (<c>+</c> → <c>.</c>), and recurses for generic arguments so the
|
||||||
|
/// <c>ScriptGlobals<TContext></c> parameter is emitted correctly.
|
||||||
|
/// </summary>
|
||||||
|
private static string ToCSharpTypeName(Type t)
|
||||||
|
{
|
||||||
|
if (t == typeof(void)) return "void";
|
||||||
|
// Primitive aliases keep the synthesized source readable when diagnostic
|
||||||
|
// logging dumps it; functionally identical to the FQN form.
|
||||||
|
if (t == typeof(bool)) return "bool";
|
||||||
|
if (t == typeof(byte)) return "byte";
|
||||||
|
if (t == typeof(sbyte)) return "sbyte";
|
||||||
|
if (t == typeof(short)) return "short";
|
||||||
|
if (t == typeof(ushort)) return "ushort";
|
||||||
|
if (t == typeof(int)) return "int";
|
||||||
|
if (t == typeof(uint)) return "uint";
|
||||||
|
if (t == typeof(long)) return "long";
|
||||||
|
if (t == typeof(ulong)) return "ulong";
|
||||||
|
if (t == typeof(float)) return "float";
|
||||||
|
if (t == typeof(double)) return "double";
|
||||||
|
if (t == typeof(decimal)) return "decimal";
|
||||||
|
if (t == typeof(string)) return "string";
|
||||||
|
if (t == typeof(object)) return "object";
|
||||||
|
|
||||||
|
if (Nullable.GetUnderlyingType(t) is { } inner)
|
||||||
|
return ToCSharpTypeName(inner) + "?";
|
||||||
|
|
||||||
|
if (t.IsArray)
|
||||||
|
return ToCSharpTypeName(t.GetElementType()!) + "[]";
|
||||||
|
|
||||||
|
if (t.IsGenericType)
|
||||||
|
{
|
||||||
|
var def = t.GetGenericTypeDefinition();
|
||||||
|
var rawName = def.FullName!.Replace('+', '.');
|
||||||
|
var nameNoArity = rawName.Substring(0, rawName.IndexOf('`'));
|
||||||
|
var args = string.Join(", ", t.GetGenericArguments().Select(ToCSharpTypeName));
|
||||||
|
return "global::" + nameNoArity + "<" + args + ">";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "global::" + t.FullName!.Replace('+', '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collectible <see cref="AssemblyLoadContext"/> that hosts a single emitted script
|
||||||
|
/// assembly. Created per <see cref="ScriptEvaluator{TContext, TResult}"/> instance so
|
||||||
|
/// <see cref="AssemblyLoadContext.Unload"/> releases exactly that script. Resolves
|
||||||
|
/// dependencies via the default ALC — script assemblies reference the BCL + the
|
||||||
|
/// application's own types, all of which live in the default context.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ScriptAssemblyLoadContext : AssemblyLoadContext
|
||||||
|
{
|
||||||
|
public ScriptAssemblyLoadContext(string name) : base(name, isCollectible: true)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Assembly? Load(AssemblyName assemblyName) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown by <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> when Roslyn
|
||||||
|
/// reports compile-time errors against the wrapper source. Mirrors the
|
||||||
|
/// <c>Microsoft.CodeAnalysis.Scripting.CompilationErrorException</c> from the legacy
|
||||||
|
/// <c>CSharpScript</c> path so callers (engines + the Admin test-harness) keep the
|
||||||
|
/// same catch site after the Core.Scripting-008 rewrite.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CompilationErrorException : Exception
|
||||||
|
{
|
||||||
|
public IReadOnlyList<Diagnostic> Diagnostics { get; }
|
||||||
|
|
||||||
|
public CompilationErrorException(IReadOnlyList<Diagnostic> diagnostics)
|
||||||
|
: base(BuildMessage(diagnostics))
|
||||||
|
{
|
||||||
|
Diagnostics = diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildMessage(IReadOnlyList<Diagnostic> diagnostics)
|
||||||
|
{
|
||||||
|
if (diagnostics.Count == 0) return "Script compile failed.";
|
||||||
|
// Operators see this — match the legacy Roslyn format ("(line,col): error CSxxxx:
|
||||||
|
// message") so existing operator runbooks still match.
|
||||||
|
var first = diagnostics[0];
|
||||||
|
var rest = diagnostics.Count == 1 ? "" : $" (and {diagnostics.Count - 1} more)";
|
||||||
|
return first.ToString() + rest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
using Microsoft.CodeAnalysis;
|
||||||
using Microsoft.CodeAnalysis.Scripting;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Factory for the <see cref="ScriptOptions"/> every user script is compiled against.
|
/// Factory for the compile-time sandbox every user script is built against.
|
||||||
/// Implements Phase 7 plan decision #6 (read-only sandbox) by whitelisting only the
|
/// Implements Phase 7 plan decision #6 (read-only sandbox) by whitelisting only the
|
||||||
/// assemblies + namespaces the script API needs; no <c>System.IO</c>, no
|
/// assemblies + namespaces the script API needs; no <c>System.IO</c>, no
|
||||||
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
|
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
|
||||||
@@ -15,9 +14,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Roslyn's default <see cref="ScriptOptions"/> references <c>mscorlib</c> /
|
/// Roslyn would otherwise pull in every type in the BCL transitively via
|
||||||
/// <c>System.Runtime</c> transitively which pulls in every type in the BCL — this
|
/// <c>mscorlib</c> / <c>System.Runtime</c> — this class overrides that with an
|
||||||
/// class overrides that with an explicit minimal allow-list.
|
/// explicit minimal allow-list. The list is the same regardless of whether
|
||||||
|
/// <see cref="ScriptEvaluator{TContext, TResult}"/> uses the legacy
|
||||||
|
/// <c>CSharpScript</c> path or the collectible-<c>AssemblyLoadContext</c> path
|
||||||
|
/// (Core.Scripting-008): both go through <see cref="Build"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
|
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
|
||||||
@@ -35,29 +37,21 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|||||||
public static class ScriptSandbox
|
public static class ScriptSandbox
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build the <see cref="ScriptOptions"/> used for every virtual-tag / alarm
|
/// Build the sandbox configuration used for every virtual-tag / alarm script.
|
||||||
/// script. <paramref name="contextType"/> is the concrete
|
/// <paramref name="contextType"/> is the concrete <see cref="ScriptContext"/>
|
||||||
/// <see cref="ScriptContext"/> subclass the globals will be of — the compiler
|
/// subclass the script's <c>ctx</c> will be of — the compiler uses its assembly
|
||||||
/// uses its type to resolve <c>ctx.GetTag(...)</c> calls.
|
/// to resolve <c>ctx.GetTag(...)</c> calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ScriptOptions Build(Type contextType)
|
public static SandboxConfig Build(Type contextType)
|
||||||
{
|
{
|
||||||
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
||||||
if (!typeof(ScriptContext).IsAssignableFrom(contextType))
|
if (!typeof(ScriptContext).IsAssignableFrom(contextType))
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
$"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));
|
$"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));
|
||||||
|
|
||||||
// Allow-listed assemblies — each explicitly chosen. Adding here is a
|
// OtOpcUa-owned assemblies — pinned by typeof(...) so they survive a rename.
|
||||||
// plan-level decision; do not expand casually. HashSet so adding the
|
var pinnedAssemblies = new HashSet<System.Reflection.Assembly>
|
||||||
// contextType's assembly is idempotent when it happens to be Core.Scripting
|
|
||||||
// already.
|
|
||||||
var allowedAssemblies = new HashSet<System.Reflection.Assembly>
|
|
||||||
{
|
{
|
||||||
// System.Private.CoreLib — primitives (int, double, bool, string, DateTime,
|
|
||||||
// TimeSpan, Math, Convert, nullable<T>). Can't practically script without it.
|
|
||||||
typeof(object).Assembly,
|
|
||||||
// System.Linq — IEnumerable extensions (Where / Select / Sum / Average / etc.).
|
|
||||||
typeof(System.Linq.Enumerable).Assembly,
|
|
||||||
// Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name
|
// Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name
|
||||||
// the types they receive from ctx.GetTag.
|
// the types they receive from ctx.GetTag.
|
||||||
typeof(DataValueSnapshot).Assembly,
|
typeof(DataValueSnapshot).Assembly,
|
||||||
@@ -72,7 +66,23 @@ public static class ScriptSandbox
|
|||||||
contextType.Assembly,
|
contextType.Assembly,
|
||||||
};
|
};
|
||||||
|
|
||||||
var allowedImports = new[]
|
// BCL references. We list the trusted-platform-assemblies set restricted to
|
||||||
|
// System.* and netstandard so the synthesized wrapper can reference every BCL
|
||||||
|
// type by FQN — including the ones we forbid (HttpClient, File, Process,
|
||||||
|
// Registry, etc.). Letting those types resolve at compile is intentional: the
|
||||||
|
// hard security gate is ForbiddenTypeAnalyzer in step 3 of the compile pipeline
|
||||||
|
// (Core.Scripting-001 / -002 established the analyzer must be the sole gate
|
||||||
|
// because type forwarding makes any references-list-only restriction porous).
|
||||||
|
// The references list now serves only as scoping hygiene — out-of-band BCL
|
||||||
|
// surface (operator-authored hosting helpers, third-party packages, app code)
|
||||||
|
// is not on the list and stays unreachable.
|
||||||
|
var references = new List<MetadataReference>();
|
||||||
|
foreach (var asm in pinnedAssemblies)
|
||||||
|
references.Add(MetadataReference.CreateFromFile(asm.Location));
|
||||||
|
foreach (var path in EnumerateBclAssemblyPaths())
|
||||||
|
references.Add(MetadataReference.CreateFromFile(path));
|
||||||
|
|
||||||
|
var imports = new[]
|
||||||
{
|
{
|
||||||
"System",
|
"System",
|
||||||
"System.Linq",
|
"System.Linq",
|
||||||
@@ -80,8 +90,56 @@ public static class ScriptSandbox
|
|||||||
"ZB.MOM.WW.OtOpcUa.Core.Scripting",
|
"ZB.MOM.WW.OtOpcUa.Core.Scripting",
|
||||||
};
|
};
|
||||||
|
|
||||||
return ScriptOptions.Default
|
return new SandboxConfig(references, imports);
|
||||||
.WithReferences(allowedAssemblies)
|
}
|
||||||
.WithImports(allowedImports);
|
|
||||||
|
private static IEnumerable<string> EnumerateBclAssemblyPaths()
|
||||||
|
{
|
||||||
|
// The .NET host advertises the resolved runtime-shared-framework + BCL DLL set
|
||||||
|
// via the TRUSTED_PLATFORM_ASSEMBLIES AppContext data slot. This is what the
|
||||||
|
// ALC fallback uses when resolving assemblies, so anything in here is already
|
||||||
|
// loadable by the host process. We restrict to System.* and netstandard to keep
|
||||||
|
// the script's reachable surface to the BCL — anything else (Microsoft.*,
|
||||||
|
// application code, third-party packages happening to be in the runtime store)
|
||||||
|
// would expand the analyzer's deny-list job unnecessarily.
|
||||||
|
var raw = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES");
|
||||||
|
if (string.IsNullOrEmpty(raw))
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
var separator = OperatingSystem.IsWindows() ? ';' : ':';
|
||||||
|
foreach (var path in raw.Split(separator, StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var name = System.IO.Path.GetFileName(path);
|
||||||
|
if (name.StartsWith("System.", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(name, "netstandard.dll", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(name, "mscorlib.dll", StringComparison.Ordinal) ||
|
||||||
|
// Microsoft.Win32.Registry isn't a System.* DLL but the analyzer's
|
||||||
|
// Microsoft.Win32 deny-list relies on the type being resolvable so it
|
||||||
|
// can identify + reject it (Core.Scripting-001 / -002). Add the one
|
||||||
|
// DLL we need rather than broadening to Microsoft.* (which would also
|
||||||
|
// pull in compilers, build tooling, etc.).
|
||||||
|
string.Equals(name, "Microsoft.Win32.Registry.dll", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
yield return path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compile-time sandbox configuration. Returned by <see cref="ScriptSandbox.Build"/>;
|
||||||
|
/// consumed by <see cref="ScriptEvaluator{TContext, TResult}"/>'s manual
|
||||||
|
/// <c>CSharpCompilation</c> path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="References">
|
||||||
|
/// Metadata references (allow-listed assemblies) the script compilation is built
|
||||||
|
/// against. Anything not in this set is unresolved at compile, which is the sandbox's
|
||||||
|
/// first line of defense — <see cref="ForbiddenTypeAnalyzer"/> is the second.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Imports">
|
||||||
|
/// Namespaces pre-imported into the wrapper compilation as <c>using</c> directives
|
||||||
|
/// so scripts can write <c>Math.Abs</c> rather than <c>System.Math.Abs</c>.
|
||||||
|
/// </param>
|
||||||
|
public sealed record SandboxConfig(
|
||||||
|
IReadOnlyList<MetadataReference> References,
|
||||||
|
IReadOnlyList<string> Imports);
|
||||||
|
|||||||
@@ -221,4 +221,107 @@ public sealed class CompiledScriptCacheTests
|
|||||||
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
|
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
|
||||||
cache.Count.ShouldBe(0, "faulted Lazy must still be evicted after compile failure");
|
cache.Count.ShouldBe(0, "faulted Lazy must still be evicted after compile failure");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Core.Scripting-008: collectible AssemblyLoadContext unload ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_unloads_compiled_script_assembly_load_context()
|
||||||
|
{
|
||||||
|
// The whole point of switching the emit path off CSharpScript.CreateDelegate
|
||||||
|
// and onto a collectible ALC: after Dispose, the runtime can reclaim the
|
||||||
|
// emitted assembly. We assert this via a WeakReference to the compiled
|
||||||
|
// assembly itself — if the ALC unloads correctly the reference is dead after
|
||||||
|
// a forced GC; if the assembly stayed rooted (the pre-fix behaviour) the
|
||||||
|
// reference survives. The exact reclaim is GC-timing-sensitive, so we loop a
|
||||||
|
// bounded number of times to absorb GC scheduling noise.
|
||||||
|
var weak = CompileAndCaptureWeakAssembly();
|
||||||
|
// Help the runtime — ALC.Unload is *eligible-for-collection*, not synchronous.
|
||||||
|
for (int i = 0; i < 10 && weak.IsAlive; i++)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
}
|
||||||
|
weak.IsAlive.ShouldBeFalse(
|
||||||
|
"the collectible ALC must release the emitted script assembly after Dispose " +
|
||||||
|
"(Core.Scripting-008). If this fails, either the cache held a reference past " +
|
||||||
|
"Dispose, or a delegate/closure rooted the assembly in the default ALC.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(
|
||||||
|
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
|
||||||
|
private static WeakReference CompileAndCaptureWeakAssembly()
|
||||||
|
{
|
||||||
|
// Isolation method so the JIT cannot keep the local references rooted past
|
||||||
|
// its return — without [NoInlining] the GC may decide the locals are still
|
||||||
|
// live and the WeakReference test becomes flaky.
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 42;""");
|
||||||
|
var weak = new WeakReference(evaluator.GetType().Assembly is var asm &&
|
||||||
|
asm is not null ? GetEmittedAssembly(evaluator) : null);
|
||||||
|
evaluator.Dispose();
|
||||||
|
return weak;
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(
|
||||||
|
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
|
||||||
|
private static object GetEmittedAssembly(ScriptEvaluator<FakeScriptContext, int> evaluator)
|
||||||
|
{
|
||||||
|
// The evaluator's delegate Method lives on the synthesized wrapper class in
|
||||||
|
// the emitted assembly. The delegate field is private; we reach it via a
|
||||||
|
// reflection probe rather than expose internals — keeps the public surface
|
||||||
|
// unchanged.
|
||||||
|
var funcField = typeof(ScriptEvaluator<FakeScriptContext, int>).GetField(
|
||||||
|
"_func", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||||
|
var del = (Delegate)funcField.GetValue(evaluator)!;
|
||||||
|
return del.Method.Module.Assembly;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_disposes_every_materialised_evaluator()
|
||||||
|
{
|
||||||
|
// Locks the contract that Clear() is publish-safe: after a config-generation
|
||||||
|
// publish drops the cache, every prior script's ALC should unload so the
|
||||||
|
// process memory plateaus rather than growing across publishes.
|
||||||
|
var weaks = CompileFiveAndCaptureWeakAssemblies();
|
||||||
|
for (int i = 0; i < 10 && weaks.Any(w => w.IsAlive); i++)
|
||||||
|
{
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
}
|
||||||
|
weaks.ShouldAllBe(w => !w.IsAlive,
|
||||||
|
"after Clear() every compiled-script ALC must be unloadable " +
|
||||||
|
"(Core.Scripting-008). If this fails the publish-replace pattern leaks " +
|
||||||
|
"emitted assemblies, which is exactly the v3 concern this rewrite fixes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(
|
||||||
|
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
|
||||||
|
private static List<WeakReference> CompileFiveAndCaptureWeakAssemblies()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
var weaks = new List<WeakReference>();
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
// Distinct source per iteration so each compiles to its own assembly.
|
||||||
|
var e = cache.GetOrCompile($"""return {i};""");
|
||||||
|
var funcField = typeof(ScriptEvaluator<FakeScriptContext, int>).GetField(
|
||||||
|
"_func", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||||
|
var del = (Delegate)funcField.GetValue(e)!;
|
||||||
|
weaks.Add(new WeakReference(del.Method.Module.Assembly));
|
||||||
|
}
|
||||||
|
cache.Count.ShouldBe(5);
|
||||||
|
cache.Clear();
|
||||||
|
cache.Count.ShouldBe(0);
|
||||||
|
return weaks;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetOrCompile_after_Dispose_throws_ObjectDisposedException()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
cache.GetOrCompile("""return 1;""");
|
||||||
|
cache.Dispose();
|
||||||
|
Should.Throw<ObjectDisposedException>(() => cache.GetOrCompile("""return 2;"""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user