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 |
|
||||
| Category | Performance & resource management |
|
||||
| Location | `CompiledScriptCache.cs:34`, `ScriptEvaluator.cs:34` |
|
||||
| Status | Won't Fix |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `CompiledScriptCache` has no capacity bound (acknowledged in the class
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user