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:
Joseph Doherty
2026-05-23 15:55:04 -04:00
parent 5a9c4591b9
commit 7b6ab2ec6f
8 changed files with 553 additions and 69 deletions
@@ -1,11 +1,10 @@
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <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
/// assemblies + namespaces the script API needs; no <c>System.IO</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>
/// <remarks>
/// <para>
/// Roslyn's default <see cref="ScriptOptions"/> references <c>mscorlib</c> /
/// <c>System.Runtime</c> transitively which pulls in every type in the BCL — this
/// class overrides that with an explicit minimal allow-list.
/// Roslyn would otherwise pull in every type in the BCL transitively via
/// <c>mscorlib</c> / <c>System.Runtime</c> — this class overrides that with an
/// 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>
/// 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
{
/// <summary>
/// Build the <see cref="ScriptOptions"/> used for every virtual-tag / alarm
/// script. <paramref name="contextType"/> is the concrete
/// <see cref="ScriptContext"/> subclass the globals will be of — the compiler
/// uses its type to resolve <c>ctx.GetTag(...)</c> calls.
/// Build the sandbox configuration used for every virtual-tag / alarm script.
/// <paramref name="contextType"/> is the concrete <see cref="ScriptContext"/>
/// subclass the script's <c>ctx</c> will be of — the compiler uses its assembly
/// to resolve <c>ctx.GetTag(...)</c> calls.
/// </summary>
public static ScriptOptions Build(Type contextType)
public static SandboxConfig Build(Type contextType)
{
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
if (!typeof(ScriptContext).IsAssignableFrom(contextType))
throw new ArgumentException(
$"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));
// Allow-listed assemblies — each explicitly chosen. Adding here is a
// plan-level decision; do not expand casually. HashSet so adding the
// contextType's assembly is idempotent when it happens to be Core.Scripting
// already.
var allowedAssemblies = new HashSet<System.Reflection.Assembly>
// OtOpcUa-owned assemblies — pinned by typeof(...) so they survive a rename.
var pinnedAssemblies = 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
// the types they receive from ctx.GetTag.
typeof(DataValueSnapshot).Assembly,
@@ -72,7 +66,23 @@ public static class ScriptSandbox
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.Linq",
@@ -80,8 +90,56 @@ public static class ScriptSandbox
"ZB.MOM.WW.OtOpcUa.Core.Scripting",
};
return ScriptOptions.Default
.WithReferences(allowedAssemblies)
.WithImports(allowedImports);
return new SandboxConfig(references, imports);
}
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);