fix(scripts): build Roslyn ScriptOptions once per process, not per compile
ScriptOptions.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. Building the options per compile therefore leaked native memory permanently — invisible to the GC, to gcdump and to the managed allocation counters, so the working set grew while the GC heap did not. Diagnosed from a live dump of a wonder-app-vd03 Site node: 2,885 MB working set 78 min after a cold start, only 150 MB live GC heap, ~2,469 MB on the default process heap across ~6,700 undisposed AssemblyMetadata instances against 473 DLLs on disk. Three sites, all hoisted to static readonly: - SiteRuntime ScriptCompilationService (the dumped one) - InboundAPI InboundScriptExecutor — same defect on the central node; method compiles recur on every re-registration and revision change - CentralUI ScriptAnalysisService — CreateFromFile per sandbox run ScriptAnalysis RoslynScriptCompiler also builds options per call but draws from the static ScriptTrustPolicy.DefaultReferences, so it mints no metadata and is left alone. Guarded by reference-equality on the artifact rather than by watching memory: a bytes-watching test would be flaky, and the leak is native so the managed counters cannot see it at all. The test is proven to fail before the fix. This does NOT close the AddTemplateScript OOM — that path was shown twice not to compile scripts. It explains how a long-running node reaches a native memory state where a large allocation fails with gigabytes free, which is a lead worth re-testing, not a closure.
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user