diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md
index 436d07b7..125a24a9 100644
--- a/docs/requirements/Component-SiteRuntime.md
+++ b/docs/requirements/Component-SiteRuntime.md
@@ -98,6 +98,8 @@ flowchart TD
> **Per-instance compilation during staggered startup (P6, follow-up)**: the Roslyn compile of each instance's scripts still runs inside Instance Actor start during staggered startup; moving it off-thread is a deferred optimization (it affects failover time-to-recover only, not correctness). As of the round-2 hardening, a process-wide compile cache dedupes identical script bodies within a node's process lifetime (the deploy gate's compile is reused by Instance Actor start), shrinking the recompile cost; the *first* compile after process start still runs inside Instance Actor start, so the deferral stands.
+> **The Roslyn `ScriptOptions` are process-static, and must stay that way**: the reference set backing every site-side compile is built once per process, never per compile. `ScriptOptions.WithReferences(Assembly[])` resolves each assembly through `MetadataReference.CreateFromFile`, which does **not** cache — each call mints a fresh `MetadataReference` owning an `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock`, an unmanaged copy of the assembly metadata that nothing disposes. Building the options per compile leaks native memory permanently: no GC reclaims it, and it is invisible to gcdump and to the managed allocation counters, so the node's working set grows without the GC heap growing. This was a live defect (a Site node at 2,885 MB working set with 150 MB of live GC heap, ~2,469 MB of it on the default process heap across ~6,700 undisposed `AssemblyMetadata` instances). Note this is orthogonal to the compile cache above — the cache dedupes *identical* script bodies, so it bounds nothing when the bodies differ, and it clears wholesale on overflow, after which every script recompiles. Pinned by a reference-equality regression test rather than by watching memory.
+
### Deployment Handling
- Receives flattened instance configurations from central via the Communication Layer.
- Stores the new configuration in local SQLite.
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
index b5298b3a..dd1d3698 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
@@ -57,6 +57,26 @@ public class ScriptAnalysisService
"System.Text",
"System.Threading.Tasks");
+ ///
+ /// Options for a sandbox run — plus the sandbox host assembly
+ /// resolved by file path.
+ ///
+ ///
+ /// Built ONCE, deliberately. This was previously rebuilt on every sandbox run, and
+ /// MetadataReference.CreateFromFile does not cache: each call mints an
+ /// AssemblyMetadata → PEReader → NativeHeapMemoryBlock holding an
+ /// unmanaged copy of the assembly metadata that nothing disposes, so every run leaked it
+ /// for the life of the process. Same defect as the Site-side one confirmed from a live dump
+ /// on 2026-08-12; smaller blast radius here only because sandbox runs are operator-driven
+ /// rather than continuous.
+ ///
+ ///
+ private static readonly ScriptOptions SandboxOptions =
+ DefaultOptions.WithReferences(DefaultOptions.MetadataReferences.Concat(new[]
+ {
+ Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(SandboxScriptHost).Assembly.Location)
+ }));
+
private readonly ISharedScriptCatalog _sharedScripts;
private readonly IMemoryCache _cache;
private readonly IServiceProvider _services;
@@ -191,10 +211,7 @@ public class ScriptAnalysisService
request.TimeoutSeconds ?? SandboxDefaultTimeoutSeconds,
1, SandboxMaxTimeoutSeconds);
- var options = DefaultOptions.WithReferences(DefaultOptions.MetadataReferences.Concat(new[]
- {
- Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(SandboxScriptHost).Assembly.Location)
- }));
+ var options = SandboxOptions;
var globalsType = request.Kind == ScriptKind.InboundApi
? typeof(SandboxInboundScriptHost)
diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs
index e3fe8806..49106409 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs
@@ -202,6 +202,39 @@ public class InboundScriptExecutor
/// null when the script is missing, fails to compile, or violates the
/// script trust model. Does not mutate the handler cache.
///
+ ///
+ /// Roslyn scripting options for every inbound-API method compile.
+ ///
+ ///
+ /// Built ONCE, deliberately. Do not inline this back into .
+ /// WithReferences(Assembly[]) resolves each assembly through
+ /// MetadataReference.CreateFromFile, which does not cache — every call mints a
+ /// fresh AssemblyMetadata → PEReader → NativeHeapMemoryBlock holding
+ /// an unmanaged copy of the assembly metadata that nothing disposes. Per-compile options
+ /// therefore leak native memory for the life of the process, invisibly to the GC and to
+ /// gcdump. Method compiles are not one-shot: every method re-registration and every
+ /// revision change recompiles, so the growth is unbounded on a long-lived central node.
+ ///
+ ///
+ ///
+ /// Same defect, same shape as the Site-side one confirmed from a live dump on 2026-08-12
+ /// (SiteRuntime.Scripts.ScriptCompilationService.SharedScriptOptions).
+ ///
+ ///
+ private static readonly ScriptOptions SharedScriptOptions = ScriptOptions.Default
+ .WithReferences(
+ typeof(object).Assembly,
+ typeof(Enumerable).Assembly,
+ typeof(Dictionary<,>).Assembly,
+ typeof(RouteHelper).Assembly,
+ typeof(ScriptParameters).Assembly,
+ typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
+ .WithImports(
+ "System",
+ "System.Collections.Generic",
+ "System.Linq",
+ "System.Threading.Tasks");
+
private (Func>? Handler, IReadOnlyList Errors) Compile(ApiMethod method)
{
if (string.IsNullOrWhiteSpace(method.Script))
@@ -224,23 +257,9 @@ public class InboundScriptExecutor
try
{
- var scriptOptions = ScriptOptions.Default
- .WithReferences(
- typeof(object).Assembly,
- typeof(Enumerable).Assembly,
- typeof(Dictionary<,>).Assembly,
- typeof(RouteHelper).Assembly,
- typeof(ScriptParameters).Assembly,
- typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
- .WithImports(
- "System",
- "System.Collections.Generic",
- "System.Linq",
- "System.Threading.Tasks");
-
var compiled = CSharpScript.Create