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