Compare commits
11 Commits
5a781c706c
...
df17b53f08
| Author | SHA1 | Date | |
|---|---|---|---|
| df17b53f08 | |||
| d412fc3696 | |||
| 702de910ad | |||
| b1c3783578 | |||
| 34e1a78e68 | |||
| 8ecbc3fccb | |||
| 71284adcc4 | |||
| 089b16980f | |||
| 431a5f6433 | |||
| 2c8690a34f | |||
| 901cec9026 |
@@ -207,7 +207,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
### Akka.NET Conventions
|
||||
- Tell for hot-path internal communication; Ask reserved for system boundaries.
|
||||
- Cross-cluster communication is gRPC (per-site PSK-authenticated): site→central `CentralControlService`, central→site `SiteCommandService`, plus the `SiteStreamService` data stream. ClusterClient/ClusterClientReceptionist were removed in the migration's Phase 4 — service discovery is by dialling configured endpoints, not the receptionist. (Akka.Cluster.Tools remains for ClusterSingleton.)
|
||||
- Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory.
|
||||
- Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory. Every Roslyn script-compile surface must also attach the shared `CachingScriptMetadataResolver.Instance` (ScriptAnalysis) via `ScriptOptions.WithMetadataResolver` — the four today are Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and ScriptAnalysis `RoslynScriptCompiler`; a new surface that omits it silently reintroduces a per-compile native metadata leak (see Component-ScriptAnalysis.md).
|
||||
- Application-level correlation IDs on all request/response messages.
|
||||
|
||||
## Tool Usage
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
# Script-Compile Metadata Resolver Cache Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Stop Roslyn from minting a fresh `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock` set (~74–150 assemblies' worth) on **every** script compile by installing one process-wide memoizing `MetadataReferenceResolver` on all four script-compile surfaces.
|
||||
|
||||
**Architecture:** A new `CachingScriptMetadataResolver` in the ScriptAnalysis component (the script-compilation single-source-of-truth home) decorates Roslyn's default `ScriptMetadataResolver` and memoizes `ResolveMissingAssembly`/`ResolveReference` results in process-wide concurrent dictionaries. Each of the four compile surfaces attaches the shared instance via `ScriptOptions.WithMetadataResolver(...)`. Resolution *results* are unchanged — only their identity is: one `PortableExecutableReference` per distinct assembly per process instead of one per assembly **per compile**.
|
||||
|
||||
**Tech Stack:** Microsoft.CodeAnalysis.CSharp.Scripting 5.0.0 (Roslyn), net10.0, xUnit.
|
||||
|
||||
---
|
||||
|
||||
## Root cause (confirmed 2026-08-12, post-`5a781c70`)
|
||||
|
||||
`5a781c70` made all four `ScriptOptions`/`MetadataReference.CreateFromFile` construction sites static, and that fix is correct and live — but it was not the dominant allocator. The dominant allocator is **inside Roslyn**:
|
||||
|
||||
- The static options carry only the *direct* API-surface references (e.g. SiteRuntime's `ScriptAssemblies` is 5 assemblies). Every `script.Compile()` must bind their **transitive closure**.
|
||||
- Each transitively-referenced assembly identity not in the explicit reference list is resolved through `ScriptOptions.MetadataResolver` (`ScriptMetadataResolver` → `RuntimeMetadataReferenceResolver`), whose `ResolveMissingAssembly` calls `MetadataReference.CreateFromFile` — eagerly copying the whole file into native memory (`PEStreamOptions.PrefetchEntireImage` → `NativeHeapMemoryBlock`) — **with no cross-compilation cache**.
|
||||
- Those fresh references are retained by the compiled `Script<object?>`'s `Compilation`, which `SiteScriptCompileCache` (site) / `_scriptHandlers` (inbound API) hold for the life of the process.
|
||||
|
||||
**Empirical proof** (scratch repro, Roslyn 5.0.0, options mirroring the site's): a 5-assembly explicit set has a 74-assembly transitive closure; compiling the *same code* three times drove `ResolveMissingAssembly` 74 times **per compile** (222 total) and returned 222 **distinct** `PortableExecutableReference` instances — i.e. 74 fresh `AssemblyMetadata`+`PEReader`+native-block sets per compile. With a memoizing decorator: compile #1 = 74 inner resolutions, compiles #2/#3 = **0**, and the script still evaluates correctly.
|
||||
|
||||
This matches the production gcdump signature exactly:
|
||||
- Per-script scaling, untouched by the `5a781c70` fix: Site (21 scripts) 6,640 triple-objects ≈ 21 × ~105 refs × 3 counted types; Central (6 scripts) 2,699 ≈ 6 × ~150 × 3.
|
||||
- Bit-identical counts pre/post fix on an identical workload (`NativeHeapMemoryBlock` 2,681 both times): the minting is deterministic per compiled script, and the explicit sets the fix de-duplicated were a small fraction of the closure.
|
||||
- Invisible to source greps: no ScadaBridge code calls anything per compile — the `CreateFromFile` calls happen inside `RuntimeMetadataReferenceResolver`.
|
||||
|
||||
**Impact today:** ~21 duplicate native copies of a ~105-assembly closure on a site node (plausibly the bulk of its 2.8 GB working set), growing further on every *changed-code* recompile (script edit + redeploy, inbound method revision change) with no reclamation path, since the compile caches pin the references.
|
||||
|
||||
**Explicitly NOT the cause (verified):** `ScriptTrustValidator.FindViolations` — its `CSharpCompilation.CreateScriptCompilation` gets no resolver in its `CSharpCompilationOptions`, and a plain compilation performs no missing-assembly resolution without one; its `AnalysisReferences` are static TPA-wide objects. `ScriptTrustPolicy.BuildMinimalFallbackReferences()` mints per call but has **no production callers** (test-only, by design). Leave both alone.
|
||||
|
||||
## Design
|
||||
|
||||
### Why a caching resolver decorator (and not the alternatives)
|
||||
|
||||
- **Widening the explicit reference sets to the transitive closure** would also stop the resolver calls, but it changes what the compile gate *sees* (`DefaultReferences` is deliberately minimal so forbidden types die as undefined symbols — see the `ScriptTrustPolicy` remark that anchors are added "ONLY here, never to DefaultReferences"). Even though resolver-resolved assemblies end up bound anyway, changing the explicit set risks semantic drift in a security gate for no extra benefit. Rejected.
|
||||
- **Disabling missing-assembly resolution** would change compile results (unresolved dependency symbols). Rejected.
|
||||
- **The decorator** preserves resolution semantics *exactly* — same inner resolver, same answers — and only de-duplicates the result objects. Trust model unaffected: the resolver can only resolve what the inner resolver would have resolved anyway.
|
||||
|
||||
### Known accepted residual
|
||||
|
||||
Each `CSharpScript.Create` also mints ~2–3 references internally via `MetadataReference.CreateFromAssemblyInternal` (corlib + globals-type assembly); that path is not reachable from `ScriptOptions` and is bounded by compile count at ~3 objects per compile — noise (~200× smaller than the fixed leak). Post-fix measurements should expect the metadata triple to grow by ≤ ~10 objects per *new* compile, not ~300.
|
||||
|
||||
### Cache-correctness assumptions (document in code)
|
||||
|
||||
- Keying `ResolveMissingAssembly` on `AssemblyIdentity.GetDisplayName()` ignores the `definition` parameter (the requesting assembly's directory is a search path in the inner resolver). Safe here: all ScadaBridge nodes run from a single publish directory + shared framework, so identity→path is stable process-wide.
|
||||
- `ConcurrentDictionary.GetOrAdd` may race two factory invocations for the same key; one extra mint, bounded, benign.
|
||||
- The cache never disposes: entries live for the process lifetime, bounded by distinct assemblies on disk (~473 worst case ≈ one native copy each) — the same order as the already-static `AnalysisReferences`, and strictly better than one copy per assembly per compiled script.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `CachingScriptMetadataResolver` (TDD)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (everything else builds on it)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the per-compile native-metadata fix (2026-08-12 follow-up to 5a781c70):
|
||||
/// Roslyn's default ScriptMetadataResolver re-resolves the transitive assembly
|
||||
/// closure through MetadataReference.CreateFromFile on EVERY script compile —
|
||||
/// each call minting a fresh AssemblyMetadata → PEReader → NativeHeapMemoryBlock.
|
||||
/// The decorator memoizes so each distinct assembly is resolved once per process.
|
||||
/// </summary>
|
||||
public class CachingScriptMetadataResolverTests
|
||||
{
|
||||
private sealed class CountingResolver : MetadataReferenceResolver
|
||||
{
|
||||
public int MissingCalls;
|
||||
public int ReferenceCalls;
|
||||
public override bool ResolveMissingAssemblies => true;
|
||||
|
||||
public override PortableExecutableReference? ResolveMissingAssembly(
|
||||
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
||||
{
|
||||
Interlocked.Increment(ref MissingCalls);
|
||||
return MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
|
||||
}
|
||||
|
||||
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
||||
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
||||
{
|
||||
Interlocked.Increment(ref ReferenceCalls);
|
||||
return [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)];
|
||||
}
|
||||
|
||||
public override bool Equals(object? other) => ReferenceEquals(this, other);
|
||||
public override int GetHashCode() => 0;
|
||||
}
|
||||
|
||||
private static readonly AssemblyIdentity SomeIdentity = new("System.Fake", new Version(1, 0, 0, 0));
|
||||
private static readonly MetadataReference SomeDefinition =
|
||||
MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
|
||||
|
||||
[Fact]
|
||||
public void ResolveMissingAssembly_SameIdentityTwice_ResolvesOnceAndSharesInstance()
|
||||
{
|
||||
var inner = new CountingResolver();
|
||||
var sut = new CachingScriptMetadataResolver(inner);
|
||||
|
||||
var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
|
||||
Assert.Equal(1, inner.MissingCalls);
|
||||
Assert.Same(first, second); // one AssemblyMetadata/PEReader/native block, not two
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveMissingAssembly_DistinctIdentities_ResolveIndependently()
|
||||
{
|
||||
var inner = new CountingResolver();
|
||||
var sut = new CachingScriptMetadataResolver(inner);
|
||||
|
||||
sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
sut.ResolveMissingAssembly(SomeDefinition, new AssemblyIdentity("System.Other", new Version(1, 0, 0, 0)));
|
||||
|
||||
Assert.Equal(2, inner.MissingCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveReference_SameArgsTwice_ResolvesOnceAndSharesInstances()
|
||||
{
|
||||
var inner = new CountingResolver();
|
||||
var sut = new CachingScriptMetadataResolver(inner);
|
||||
|
||||
var first = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
|
||||
var second = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
|
||||
|
||||
Assert.Equal(1, inner.ReferenceCalls);
|
||||
Assert.Same(first[0], second[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instance_IsProcessWideSingleton()
|
||||
=> Assert.Same(CachingScriptMetadataResolver.Instance, CachingScriptMetadataResolver.Instance);
|
||||
|
||||
[Fact]
|
||||
public void ResolveMissingAssemblies_DelegatesToInner()
|
||||
{
|
||||
var sut = new CachingScriptMetadataResolver(new CountingResolver());
|
||||
Assert.True(sut.ResolveMissingAssemblies);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter CachingScriptMetadataResolverTests 2>&1 | tail -5`
|
||||
Expected: build FAILS — `CachingScriptMetadataResolver` does not exist.
|
||||
|
||||
**Step 3: Write the implementation**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide memoizing decorator over Roslyn's default script metadata
|
||||
/// resolver, closing the second (dominant) half of the script-compile native
|
||||
/// memory leak fixed in part by 5a781c70.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this exists.</b> The shared <c>ScriptOptions</c> on every compile
|
||||
/// surface carry only the direct API-surface references; each
|
||||
/// <c>script.Compile()</c> binds their transitive closure, and every
|
||||
/// transitively-referenced assembly is resolved through the options'
|
||||
/// <see cref="MetadataReferenceResolver"/>. Roslyn's
|
||||
/// <c>RuntimeMetadataReferenceResolver</c> has NO cross-compilation cache: each
|
||||
/// resolution calls <c>MetadataReference.CreateFromFile</c>, which eagerly
|
||||
/// copies the whole assembly into native memory (<c>AssemblyMetadata</c> →
|
||||
/// <c>PEReader</c> → <c>NativeHeapMemoryBlock</c>). Measured on Roslyn 5.0.0:
|
||||
/// a 5-assembly explicit set resolves a 74-assembly closure afresh on EVERY
|
||||
/// compile — ~74 fresh native metadata copies per compiled script, pinned for
|
||||
/// the process lifetime by the compile caches. This decorator memoizes, so each
|
||||
/// distinct assembly is materialized once per process regardless of compile count.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Trust model unaffected.</b> Resolution RESULTS are identical to the inner
|
||||
/// resolver's — only object identity is de-duplicated. The resolver can never
|
||||
/// resolve anything the undecorated options would not have resolved.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Cache-correctness assumptions.</b> The missing-assembly cache keys on the
|
||||
/// assembly identity display name and deliberately ignores the requesting
|
||||
/// <c>definition</c> (whose directory is a search path in the inner resolver):
|
||||
/// every ScadaBridge node runs from a single publish directory plus the shared
|
||||
/// framework, so identity → path is stable process-wide. A
|
||||
/// <c>GetOrAdd</c> factory race can mint one duplicate — bounded, benign.
|
||||
/// Entries are never disposed; the cache is bounded by the distinct assemblies
|
||||
/// on disk, the same order as the static <see cref="ScriptTrustPolicy.AnalysisReferences"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CachingScriptMetadataResolver : MetadataReferenceResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// The shared process-wide instance every script-compile surface attaches via
|
||||
/// <c>ScriptOptions.WithMetadataResolver</c>. Decorates
|
||||
/// <see cref="ScriptOptions.Default"/>'s resolver — the exact resolver those
|
||||
/// surfaces used implicitly before this fix.
|
||||
/// </summary>
|
||||
public static readonly CachingScriptMetadataResolver Instance =
|
||||
new(ScriptOptions.Default.MetadataResolver);
|
||||
|
||||
private readonly MetadataReferenceResolver _inner;
|
||||
|
||||
private readonly ConcurrentDictionary<string, PortableExecutableReference?> _missingByIdentity =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ConcurrentDictionary<(string Reference, string? BaseFilePath, MetadataReferenceProperties Properties),
|
||||
ImmutableArray<PortableExecutableReference>> _referencesByPath = new();
|
||||
|
||||
/// <summary>Creates a decorator over the given inner resolver. Exposed for tests; production uses <see cref="Instance"/>.</summary>
|
||||
/// <param name="inner">The resolver whose results are memoized.</param>
|
||||
public CachingScriptMetadataResolver(MetadataReferenceResolver inner) => _inner = inner;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PortableExecutableReference? ResolveMissingAssembly(
|
||||
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
||||
=> _missingByIdentity.GetOrAdd(
|
||||
referenceIdentity.GetDisplayName(),
|
||||
_ => _inner.ResolveMissingAssembly(definition, referenceIdentity));
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
||||
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
||||
=> _referencesByPath.GetOrAdd(
|
||||
(reference, baseFilePath, properties),
|
||||
_ => _inner.ResolveReference(reference, baseFilePath, properties));
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? other) => ReferenceEquals(this, other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(this);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter CachingScriptMetadataResolverTests 2>&1 | tail -5`
|
||||
Expected: 5 PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/CachingScriptMetadataResolver.cs tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs
|
||||
git commit -m "feat(scripts): add process-wide caching metadata resolver for script compiles"
|
||||
```
|
||||
|
||||
### Task 2: End-to-end proof — repeat compiles resolve zero
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (needs Task 1)
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs` (append)
|
||||
|
||||
**Step 1: Write the test** (this is the compile-count-keyed regression the gcdump measurements could not express)
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// The decisive regression: with options shaped like the real compile
|
||||
/// surfaces (small explicit set, large transitive closure), the FIRST
|
||||
/// compile resolves the closure through the resolver and every subsequent
|
||||
/// compile — same or different code — resolves NOTHING. Before the fix,
|
||||
/// every compile re-resolved the full closure (measured: 74 fresh
|
||||
/// PortableExecutableReferences per compile on Roslyn 5.0.0).
|
||||
/// A probe wraps the DEFAULT resolver so the test also proves resolution
|
||||
/// actually flows through this path at all (first > 0) — guarding against
|
||||
/// the whole mechanism silently changing in a Roslyn upgrade.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Compile_RepeatAndDistinctScripts_ResolveClosureOnlyOnce()
|
||||
{
|
||||
var probe = new ProbeResolver(Microsoft.CodeAnalysis.Scripting.ScriptOptions.Default.MetadataResolver);
|
||||
var caching = new CachingScriptMetadataResolver(probe);
|
||||
|
||||
var options = Microsoft.CodeAnalysis.Scripting.ScriptOptions.Default
|
||||
.WithReferences(
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
|
||||
.WithImports("System", "System.Linq")
|
||||
.WithMetadataResolver(caching);
|
||||
|
||||
long CompileAndCount(string code)
|
||||
{
|
||||
long before = probe.MissingCalls;
|
||||
var script = Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.Create<object?>(code, options);
|
||||
var errors = script.Compile().Count(d => d.Severity == DiagnosticSeverity.Error);
|
||||
Assert.Equal(0, errors);
|
||||
return probe.MissingCalls - before;
|
||||
}
|
||||
|
||||
var first = CompileAndCount("return Enumerable.Range(1, 3).Sum();");
|
||||
var repeat = CompileAndCount("return Enumerable.Range(1, 3).Sum();");
|
||||
var distinct = CompileAndCount("return string.Join(\",\", Enumerable.Range(1, 2)).Length;");
|
||||
|
||||
Assert.True(first > 0, "expected the first compile to resolve the transitive closure through the resolver");
|
||||
Assert.Equal(0, repeat); // pre-fix: == first (~74) — one fresh native metadata copy per assembly per compile
|
||||
Assert.Equal(0, distinct);
|
||||
}
|
||||
|
||||
private sealed class ProbeResolver : MetadataReferenceResolver
|
||||
{
|
||||
private readonly MetadataReferenceResolver _inner;
|
||||
public long MissingCalls;
|
||||
public ProbeResolver(MetadataReferenceResolver inner) => _inner = inner;
|
||||
public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies;
|
||||
public override PortableExecutableReference? ResolveMissingAssembly(
|
||||
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
||||
{
|
||||
Interlocked.Increment(ref MissingCalls);
|
||||
return _inner.ResolveMissingAssembly(definition, referenceIdentity);
|
||||
}
|
||||
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
||||
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
||||
=> _inner.ResolveReference(reference, baseFilePath, properties);
|
||||
public override bool Equals(object? other) => ReferenceEquals(this, other);
|
||||
public override int GetHashCode() => 0;
|
||||
}
|
||||
```
|
||||
|
||||
(Add `using Microsoft.CodeAnalysis;` / `System.Collections.Immutable` if not already present.)
|
||||
|
||||
**Step 2: Run and verify pass**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter CachingScriptMetadataResolverTests 2>&1 | tail -5`
|
||||
Expected: all PASS (first > 0, repeat/distinct == 0).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/CachingScriptMetadataResolverTests.cs
|
||||
git commit -m "test(scripts): pin that repeat compiles resolve the assembly closure zero times"
|
||||
```
|
||||
|
||||
### Task 3: Attach resolver — SiteRuntime `ScriptCompilationService`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 4, Task 5, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs:93-99`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs`
|
||||
|
||||
**Step 1: Write the failing pin test** (append to `ScriptCompilationServiceTests`; the compiled `Script.Options` is public, so no internals needed)
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Compile_UsesProcessWideCachingMetadataResolver()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
var result = _service.Compile("resolver-pin", "return 41 + 1;");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Without the shared caching resolver, EVERY compile re-resolves the
|
||||
// transitive assembly closure via MetadataReference.CreateFromFile —
|
||||
// ~74+ fresh native metadata copies per compiled script (2026-08-12 dump).
|
||||
Assert.Same(
|
||||
ZB.MOM.WW.ScadaBridge.ScriptAnalysis.CachingScriptMetadataResolver.Instance,
|
||||
result.CompiledScript!.Options.MetadataResolver);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter Compile_UsesProcessWideCachingMetadataResolver 2>&1 | tail -5`
|
||||
Expected: FAIL — resolver is `ScriptMetadataResolver`, not the shared instance.
|
||||
|
||||
**Step 3: Attach the resolver** — in `SharedScriptOptions`, and extend the doc comment:
|
||||
|
||||
```csharp
|
||||
private static readonly ScriptOptions SharedScriptOptions = ScriptOptions.Default
|
||||
.WithReferences(ScriptAssemblies)
|
||||
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
```
|
||||
|
||||
Append to the existing `SharedScriptOptions` XML doc (after the 473-DLLs paragraph):
|
||||
|
||||
```csharp
|
||||
/// <para>
|
||||
/// Static options alone are NOT enough: every <c>script.Compile()</c> binds the
|
||||
/// transitive closure of these references, and Roslyn's default resolver
|
||||
/// re-resolves that closure through <c>MetadataReference.CreateFromFile</c> on
|
||||
/// EVERY compile (~74–105 fresh native metadata copies per compiled script,
|
||||
/// confirmed live post-5a781c70 on 2026-08-12: 21 scripts held 6,640
|
||||
/// AssemblyMetadata/PEReader/MetadataImageReference objects). The shared
|
||||
/// <see cref="CachingScriptMetadataResolver"/> memoizes those resolutions
|
||||
/// process-wide; see its doc for the full mechanism.
|
||||
/// </para>
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter ScriptCompilationServiceTests 2>&1 | tail -5`
|
||||
Expected: all PASS (including the pre-existing reference-set reference-equality test).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptCompilationServiceTests.cs
|
||||
git commit -m "fix(scripts): memoize missing-assembly resolution on the site compile path"
|
||||
```
|
||||
|
||||
### Task 4: Attach resolver — InboundAPI `InboundScriptExecutor`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 3, Task 5, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:224-236`
|
||||
|
||||
**Step 1: Attach the resolver** to its `SharedScriptOptions` (same one-line `.WithMetadataResolver(CachingScriptMetadataResolver.Instance)` after `.WithReferences(...)`), add `using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;` if absent (the project already references ScriptAnalysis for `ForbiddenApiChecker`; if not, add the project reference), and append the same style of doc-comment paragraph as Task 3 (central variant: "6 inbound methods held 2,699 objects; method revisions recompile, so growth was unbounded").
|
||||
|
||||
**Step 2: Build + run the existing suite**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests 2>&1 | tail -5`
|
||||
Expected: PASS. (No public seam exposes the compiled script here; Task 2's resolver test plus this wiring is the coverage — do not add reflection-based tests.)
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs
|
||||
git commit -m "fix(scripts): memoize missing-assembly resolution on inbound API method compiles"
|
||||
```
|
||||
|
||||
### Task 5: Attach resolver — CentralUI `ScriptAnalysisService`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 3, Task 4, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:44-58`
|
||||
|
||||
**Step 1: Attach the resolver** to `DefaultOptions` (`.WithMetadataResolver(ZB.MOM.WW.ScadaBridge.ScriptAnalysis.CachingScriptMetadataResolver.Instance)` after `.AddReferences(...)`). `SandboxOptions` and every per-request `options` derived via `With*`/`Add*` inherit it automatically — `ScriptOptions` is immutable-with-copy. Note in the doc comment that editor diagnostics compile on every analysis request, so this path churned a full closure per keystroke-debounce before the fix.
|
||||
|
||||
**Step 2: Build + run**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests 2>&1 | tail -5`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
|
||||
git commit -m "fix(scripts): memoize missing-assembly resolution in the UI script editor/sandbox"
|
||||
```
|
||||
|
||||
### Task 6: Attach resolver — `RoslynScriptCompiler` (design-time deploy gate)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 3, Task 4, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/RoslynScriptCompiler.cs:66-68`
|
||||
- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs`
|
||||
|
||||
**Step 1: Attach the resolver** in `Compile`:
|
||||
|
||||
```csharp
|
||||
var options = ScriptOptions.Default
|
||||
.WithReferences(references)
|
||||
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
|
||||
.WithImports(imports);
|
||||
```
|
||||
|
||||
The per-call `ScriptOptions` object itself is cheap managed garbage; the references list already reuses the static `DefaultReferences` objects — only the resolver was missing. Every template/deploy validation compile previously minted the closure afresh.
|
||||
|
||||
**Step 2: Add a behavior test** (append to `RoslynScriptCompilerTests`): compile the same trivial script twice via `RoslynScriptCompiler.Compile("return 1;")` and assert both return empty errors — then assert the shared cache took effect indirectly by pinning `CachingScriptMetadataResolver.Instance`'s process-wide identity is what `ScriptCompilationService` also uses (already covered by Task 3's `Assert.Same`); here just guard the happy path still compiles:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Compile_TwiceWithSharedResolver_StillCompilesCleanly()
|
||||
{
|
||||
Assert.Empty(RoslynScriptCompiler.Compile("return 1 + 1;"));
|
||||
Assert.Empty(RoslynScriptCompiler.Compile("return 2 + 2;"));
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Run**
|
||||
|
||||
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests 2>&1 | tail -5`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/RoslynScriptCompiler.cs tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs
|
||||
git commit -m "fix(scripts): memoize missing-assembly resolution on the design-time compile gate"
|
||||
```
|
||||
|
||||
### Task 7: Documentation sync
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (write after code lands so line references are right)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/requirements/Component-ScriptAnalysis.md` — add the resolver to the component's design: process-wide memoizing `MetadataReferenceResolver`, owned here, attached by all four compile surfaces; trust-model-neutral by construction.
|
||||
- Modify: `docs/requirements/Component-SiteRuntime.md` — extend the note `5a781c70` added: static options fixed the per-compile *options* leak; the resolver cache fixes the per-compile *closure re-resolution* (the dominant term).
|
||||
- Modify: `CLAUDE.md` — in *Akka.NET Conventions → Script trust model* bullet (or a nearby scripts note), one sentence: all four compile surfaces must attach `CachingScriptMetadataResolver.Instance`; new compile surfaces that skip it reintroduce a per-compile native metadata leak.
|
||||
|
||||
**Step: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/requirements/Component-ScriptAnalysis.md docs/requirements/Component-SiteRuntime.md CLAUDE.md
|
||||
git commit -m "docs(scripts): record the shared caching metadata resolver invariant"
|
||||
```
|
||||
|
||||
### Task 8: Milestone verification — build + targeted suites
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~5 min (mostly wall-clock)
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | tail -3` — expect 0 errors.
|
||||
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests 2>&1 | tail -8` — expect all green (TemplateEngine included because its validation path consumes `RoslynScriptCompiler`).
|
||||
3. `git diff main --stat` review per Editing Rules, then done — **do not push or deploy**; live-gate is a separate operator step below.
|
||||
|
||||
### Task 9 (operator-gated): Live verification, keyed to compile count — not elapsed time
|
||||
|
||||
**Classification:** high-risk (production measurement; no code)
|
||||
**Estimated implement time:** operator session
|
||||
|
||||
Run on the docker rig first, then wonder-app-vd03 after deploy (tooling at `E:\ApiInstall\_tools\diag\`, baselines at `E:\ApiInstall\_leakcheck-post5a781c70\`):
|
||||
|
||||
1. Baseline: `dotnet-gcdump collect` on a Site node; record the `AssemblyMetadata`/`PEReader`/`MetadataImageReference` triple (the reliable proxy — `NativeHeapMemoryBlock` gets truncated out of large reports).
|
||||
2. Force **N real compiles**: edit a script's *body* and redeploy, N times. **A no-op redeploy compiles nothing** — `SiteScriptCompileCache` keys on code hash, so unchanged bodies are cache hits; the body must actually change each round.
|
||||
3. Re-dump. Pass gate: triple grows ≤ ~10 objects per forced compile (the accepted `CreateFromAssemblyInternal` residual). Pre-fix behavior: ~300+ per compile.
|
||||
4. Also expect the *steady-state* count to collapse on a restarted post-fix node: the 21-script site should hold ~1 closure set (+ statics) instead of 21 — thousands → hundreds, with a corresponding working-set drop.
|
||||
|
||||
## Out of scope / follow-ups
|
||||
|
||||
- `SiteScriptCompileCache` is unbounded across code revisions (every edited script body adds a permanent entry). After this fix each entry is small, but an LRU cap is a reasonable hygiene follow-up.
|
||||
- The ~2–3 refs/compile `CreateFromAssemblyInternal` residual is inside Roslyn and not reachable from `ScriptOptions`; revisit only if a future measurement shows it matters.
|
||||
- TLS/upstream Roslyn issue: consider filing upstream — `RuntimeMetadataReferenceResolver`'s lack of caching is a general scripting-host footgun.
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-12-script-compile-metadata-resolver-cache.md",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: CachingScriptMetadataResolver (TDD)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: End-to-end compile-count regression test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Attach resolver \u2014 SiteRuntime ScriptCompilationService",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: Attach resolver \u2014 InboundAPI InboundScriptExecutor",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: Attach resolver \u2014 CentralUI ScriptAnalysisService",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Attach resolver \u2014 RoslynScriptCompiler deploy gate",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: Documentation sync",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: Milestone verification \u2014 build + targeted suites",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9 (operator-gated): Live compile-count-keyed gcdump gate",
|
||||
"status": "pending",
|
||||
"blockedBy": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-08-12"
|
||||
}
|
||||
@@ -16,6 +16,7 @@ Referenced by: Template Engine, Site Runtime, Inbound API, Central UI, Transport
|
||||
- Provide an authoritative forbidden-API verdict (`ScriptTrustValidator.FindViolations`) that fuses semantic symbol resolution with syntactic reflection-gateway hardening.
|
||||
- Wrap Roslyn `CSharpScript` compilation (`RoslynScriptCompiler`) so callers share one implementation of compile + diagnostics extraction.
|
||||
- Provide compile-only globals stubs (`ScriptCompileSurface`, `TriggerCompileSurface`) that mirror the real execution-time globals member-for-member, allowing the design-time deploy gate to do a real type-checking compile without depending on the execution-time projects.
|
||||
- Own the shared process-wide metadata reference resolver (`CachingScriptMetadataResolver`) that every Roslyn script-compile surface in the system attaches to its `ScriptOptions`.
|
||||
|
||||
---
|
||||
|
||||
@@ -182,6 +183,32 @@ The static enforcement is **defence-in-depth**, not a true runtime sandbox. Scri
|
||||
|
||||
---
|
||||
|
||||
### REQ-SA-6: Shared Metadata Reference Resolver (`CachingScriptMetadataResolver`)
|
||||
|
||||
`CachingScriptMetadataResolver` is a process-wide memoizing decorator over Roslyn's default script metadata resolver (`ScriptOptions.Default.MetadataResolver`), owned by this component because it is a property of *script compilation*, not of any one consumer. `CachingScriptMetadataResolver.Instance` is the single shared instance — the cache lives on the instance, so a second instance is a second (empty) cache.
|
||||
|
||||
#### The invariant
|
||||
|
||||
**Every Roslyn script-compile surface in the system MUST attach the shared instance** via `ScriptOptions.WithMetadataResolver(CachingScriptMetadataResolver.Instance)`. There are four such surfaces today: Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and this component's own `RoslynScriptCompiler`. A surface that omits it — including a new fifth surface — reintroduces a per-compile native-memory leak, and does so silently.
|
||||
|
||||
#### Why
|
||||
|
||||
The `ScriptOptions` on each surface carry only the direct API-surface references; every `script.Compile()` binds their **transitive closure**, and each transitively-referenced assembly is resolved through the options' `MetadataReferenceResolver`. Roslyn's default resolver has **no cross-compilation cache**: each resolution calls `MetadataReference.CreateFromFile`, which eagerly copies the whole assembly into native memory (`AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock`). So a 5-assembly explicit set re-resolves its ~74-assembly closure afresh on *every* compile, and those references are pinned for the process lifetime by the compile caches that hold the compiled scripts. Confirmed live (2026-08-12 gcdumps): 21 site scripts held 6,640 metadata objects, 6 central inbound methods 2,699. With the decorator, each distinct assembly is materialized **once per process** regardless of compile count.
|
||||
|
||||
This is the second and dominant half of the leak whose first half — per-compile `ScriptOptions` construction — was fixed by making the options static (see Site Runtime).
|
||||
|
||||
#### Trust-model neutrality
|
||||
|
||||
The decorator changes **no resolution result**, only object identity: it returns exactly what the inner (undecorated) resolver returned for the same arguments, so it can never resolve anything the undecorated options would not have resolved. `DefaultReferences` stays minimal and the compile gate keeps seeing exactly what it saw before — this fix deliberately does not widen any explicit reference set, precisely to avoid semantic drift in a security gate.
|
||||
|
||||
#### Cache-correctness assumptions
|
||||
|
||||
- `ResolveMissingAssembly` keys on `AssemblyIdentity.GetDisplayName()` (case-insensitive, matching .NET simple-name binding) and deliberately ignores the requesting `definition`, whose directory is a search path in the inner resolver. Safe here: every node runs from a single publish directory plus the shared framework, so identity → path is stable process-wide. A `null` result is cached too — "not found" is a legitimate, sticky answer.
|
||||
- `ResolveReference` keys on the full `(reference, baseFilePath, properties)` tuple, case-**sensitively**, because `baseFilePath` is a filesystem path on the Linux containers these nodes run in.
|
||||
- A `GetOrAdd` factory race can mint one duplicate — bounded and benign. Entries are never disposed; the cache is bounded by the distinct assemblies on disk, the same order as the static `AnalysisReferences`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Commons**: Shared types referenced by `ScriptCompileSurface` and `TriggerCompileSurface` (e.g., `DataType`, attribute access types).
|
||||
@@ -196,4 +223,5 @@ No dependency on Akka.NET, ASP.NET Core, Entity Framework, or any other ScadaBri
|
||||
- **Site Runtime (#3)**: `ScriptCompilationService.ValidateTrustModel` delegates the trust verdict to `ScriptTrustValidator.FindViolations`; retains its own `CSharpScript.Compile` against the real `ScriptGlobals` for execution-time compilation.
|
||||
- **Inbound API (#14)**: `ForbiddenApiChecker.FindViolations` is a thin shim over `ScriptTrustValidator.FindViolations`.
|
||||
- **Central UI (#9)**: `ScriptAnalysisService` delegates the run-gate forbidden-API verdict and sources the editor-marker deny-list from `ScriptTrustPolicy`; retains the Test-Run execution host (`SandboxScriptHost`).
|
||||
- **All script-compile surfaces**: Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and this component's own `RoslynScriptCompiler` each attach `CachingScriptMetadataResolver.Instance` to their `ScriptOptions` (REQ-SA-6).
|
||||
- **Transport (#24)**: `BundleImporter` runs `ScriptTrustValidator.FindViolations` over every non-Skip template, shared, and ApiMethod script body, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** (`InstanceAlarmOverrideDto.TriggerConfigurationOverride`) during import validation (`RunSemanticValidationAsync` Pass 0) and preview (`DetectBlockersAsync`), rejecting forbidden-API scripts at import review rather than at runtime. Trust violations are hard errors for all script kinds (unlike the false-positive-prone name-resolution heuristic, whose template-script findings are advisory).
|
||||
|
||||
@@ -100,6 +100,8 @@ flowchart TD
|
||||
|
||||
> **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.
|
||||
|
||||
> **Static options were only half the fix — the compile must also use the shared caching metadata resolver**: the static `ScriptOptions` carry only the *direct* API-surface references (5 assemblies here), and each `script.Compile()` still binds their **transitive closure** (~105 assemblies on a site node). Every transitively-referenced assembly is resolved through the options' `MetadataReferenceResolver`, and Roslyn's default one has no cross-compilation cache — it calls `MetadataReference.CreateFromFile` afresh on **every compile**, minting the same undisposed `AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock` triple per assembly per compiled script, pinned for the process lifetime by the compile cache. That per-compile *re-resolution* — not the per-compile options construction — was the dominant term (measured live: 21 site scripts holding 6,640 metadata objects, counts bit-identical before and after the static-options fix). `ScriptCompilationService` therefore also attaches the process-wide `CachingScriptMetadataResolver.Instance` (owned by the Script Analysis component, see `Component-ScriptAnalysis.md`) via `ScriptOptions.WithMetadataResolver`, so each distinct assembly is materialized once per process. The decorator returns exactly what the undecorated resolver returned — only object identity is de-duplicated — so compile results and the trust gate are unchanged.
|
||||
|
||||
### Deployment Handling
|
||||
- Receives flattened instance configurations from central via the Communication Layer.
|
||||
- Stores the new configuration in local SQLite.
|
||||
|
||||
@@ -41,6 +41,21 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
/// </summary>
|
||||
public class ScriptAnalysisService
|
||||
{
|
||||
/// <summary>
|
||||
/// Base Roslyn scripting options for every editor analysis, shared by
|
||||
/// <see cref="SandboxOptions"/> and by the per-request options derived from it
|
||||
/// (<c>ScriptOptions</c> is immutable-with-copy, so derivations inherit this).
|
||||
///
|
||||
/// <para>
|
||||
/// Editor diagnostics compile on EVERY analysis request — one per debounced
|
||||
/// keystroke burst — and Roslyn's default resolver re-resolves the transitive
|
||||
/// closure of these references through <c>MetadataReference.CreateFromFile</c> on
|
||||
/// every one of them, minting fresh <c>AssemblyMetadata</c> → <c>PEReader</c> →
|
||||
/// <c>NativeHeapMemoryBlock</c> copies that nothing disposes. The shared
|
||||
/// <see cref="SharedTrust.CachingScriptMetadataResolver"/> memoizes those
|
||||
/// resolutions process-wide; see its doc for the full mechanism.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static readonly ScriptOptions DefaultOptions = ScriptOptions.Default
|
||||
.AddReferences(
|
||||
typeof(object).Assembly,
|
||||
@@ -50,6 +65,7 @@ public class ScriptAnalysisService
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
||||
typeof(Commons.Types.ScriptParameters).Assembly,
|
||||
typeof(SandboxScriptHost).Assembly)
|
||||
.WithMetadataResolver(SharedTrust.CachingScriptMetadataResolver.Instance)
|
||||
.AddImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
|
||||
@@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
@@ -220,6 +221,17 @@ public class InboundScriptExecutor
|
||||
/// Same defect, same shape as the Site-side one confirmed from a live dump on 2026-08-12
|
||||
/// (<c>SiteRuntime.Scripts.ScriptCompilationService.SharedScriptOptions</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Static options alone are NOT enough: every method compile binds the
|
||||
/// transitive closure of these references, and Roslyn's default resolver
|
||||
/// re-resolves that closure through <c>MetadataReference.CreateFromFile</c> on
|
||||
/// EVERY compile (confirmed live post-5a781c70 on 2026-08-12: 6 inbound
|
||||
/// methods held 2,699 AssemblyMetadata/PEReader/MetadataImageReference
|
||||
/// objects, ~150 per compile — and method revisions recompile, so growth was
|
||||
/// unbounded). The shared <see cref="CachingScriptMetadataResolver"/>
|
||||
/// memoizes those resolutions process-wide.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static readonly ScriptOptions SharedScriptOptions = ScriptOptions.Default
|
||||
.WithReferences(
|
||||
@@ -229,6 +241,7 @@ public class InboundScriptExecutor
|
||||
typeof(RouteHelper).Assembly,
|
||||
typeof(ScriptParameters).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
|
||||
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide memoizing decorator over Roslyn's default script metadata
|
||||
/// resolver, closing the second (dominant) half of the script-compile native
|
||||
/// memory leak fixed in part by 5a781c70.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this exists.</b> The shared <c>ScriptOptions</c> on every compile
|
||||
/// surface carry only the direct API-surface references; each
|
||||
/// <c>script.Compile()</c> binds their transitive closure, and every
|
||||
/// transitively-referenced assembly is resolved through the options'
|
||||
/// <see cref="MetadataReferenceResolver"/>. Roslyn's
|
||||
/// <c>RuntimeMetadataReferenceResolver</c> has NO cross-compilation cache: each
|
||||
/// resolution calls <c>MetadataReference.CreateFromFile</c>, which eagerly
|
||||
/// copies the whole assembly into native memory (<c>AssemblyMetadata</c> →
|
||||
/// <c>PEReader</c> → <c>NativeHeapMemoryBlock</c>). Measured on Roslyn 5.0.0:
|
||||
/// a 5-assembly explicit set resolves a 74-assembly closure afresh on EVERY
|
||||
/// compile — ~74 fresh native metadata copies per compiled script, pinned for
|
||||
/// the process lifetime by the compile caches. This decorator memoizes, so each
|
||||
/// distinct assembly is materialized once per process regardless of compile count.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Trust model unaffected.</b> Resolution RESULTS are identical to the inner
|
||||
/// resolver's — only object identity is de-duplicated. The resolver can never
|
||||
/// resolve anything the undecorated options would not have resolved.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Cache-correctness assumptions.</b> The missing-assembly cache keys on the
|
||||
/// assembly identity display name and deliberately ignores the requesting
|
||||
/// <c>definition</c> (whose directory is a search path in the inner resolver):
|
||||
/// every ScadaBridge node runs from a single publish directory plus the shared
|
||||
/// framework, so identity → path is stable process-wide. A
|
||||
/// <c>GetOrAdd</c> factory race can mint one duplicate — bounded, benign.
|
||||
/// Entries are never disposed; the cache is bounded by the distinct assemblies
|
||||
/// on disk, the same order as the static <see cref="ScriptTrustPolicy.AnalysisReferences"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CachingScriptMetadataResolver : MetadataReferenceResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// The shared process-wide instance every script-compile surface attaches via
|
||||
/// <c>ScriptOptions.WithMetadataResolver</c>. Decorates
|
||||
/// <see cref="ScriptOptions.Default"/>'s resolver — the exact resolver those
|
||||
/// surfaces used implicitly before this fix.
|
||||
/// </summary>
|
||||
public static readonly CachingScriptMetadataResolver Instance =
|
||||
new(ScriptOptions.Default.MetadataResolver);
|
||||
|
||||
private readonly MetadataReferenceResolver _inner;
|
||||
|
||||
// Keyed on the assembly identity display name, compared case-insensitively to
|
||||
// match .NET assembly-name binding, which treats simple names case-insensitively.
|
||||
// A null VALUE is cached too: "not found" is a legitimate, sticky result.
|
||||
private readonly ConcurrentDictionary<string, PortableExecutableReference?> _missingByIdentity =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Deliberately case-SENSITIVE (the tuple's default comparer): baseFilePath is a
|
||||
// filesystem path, and on the Linux containers these nodes run in, two paths
|
||||
// differing only in case are two different files.
|
||||
private readonly ConcurrentDictionary<(string Reference, string? BaseFilePath, MetadataReferenceProperties Properties),
|
||||
ImmutableArray<PortableExecutableReference>> _referencesByArgs = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a decorator over the given inner resolver. Exposed for tests;
|
||||
/// production code must NEVER call this constructor directly — always attach
|
||||
/// <see cref="Instance"/>. The cache lives on the instance, so a second
|
||||
/// instance has a second (empty) cache and silently reintroduces the
|
||||
/// per-compile native-metadata leak on whichever surface uses it.
|
||||
/// </summary>
|
||||
/// <param name="inner">The resolver whose results are memoized.</param>
|
||||
public CachingScriptMetadataResolver(MetadataReferenceResolver inner) => _inner = inner;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override PortableExecutableReference? ResolveMissingAssembly(
|
||||
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
||||
=> _missingByIdentity.GetOrAdd(
|
||||
referenceIdentity.GetDisplayName(),
|
||||
_ => _inner.ResolveMissingAssembly(definition, referenceIdentity));
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
||||
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
||||
=> _referencesByArgs.GetOrAdd(
|
||||
(reference, baseFilePath, properties),
|
||||
_ => _inner.ResolveReference(reference, baseFilePath, properties));
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? other) => ReferenceEquals(this, other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(this);
|
||||
}
|
||||
@@ -65,6 +65,7 @@ public static class RoslynScriptCompiler
|
||||
|
||||
var options = ScriptOptions.Default
|
||||
.WithReferences(references)
|
||||
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
|
||||
.WithImports(imports);
|
||||
|
||||
var script = CSharpScript.Create<object?>(code, options, globalsType);
|
||||
|
||||
@@ -89,9 +89,21 @@ public class ScriptCompilationService
|
||||
/// <c>ScriptAnalysisService.DefaultOptions</c> and
|
||||
/// <c>ScriptTrustPolicy.DefaultReferences</c> already follow this pattern.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Static options alone are NOT enough: every <c>script.Compile()</c> binds the
|
||||
/// transitive closure of these references, and Roslyn's default resolver
|
||||
/// re-resolves that closure through <c>MetadataReference.CreateFromFile</c> on
|
||||
/// EVERY compile (~74–105 fresh native metadata copies per compiled script,
|
||||
/// confirmed live post-5a781c70 on 2026-08-12: 21 scripts held 6,640
|
||||
/// AssemblyMetadata/PEReader/MetadataImageReference objects). The shared
|
||||
/// <see cref="CachingScriptMetadataResolver"/> memoizes those resolutions
|
||||
/// process-wide; see its doc for the full mechanism.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static readonly ScriptOptions SharedScriptOptions = ScriptOptions.Default
|
||||
.WithReferences(ScriptAssemblies)
|
||||
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the per-compile native-metadata fix (2026-08-12 follow-up to 5a781c70):
|
||||
/// Roslyn's default ScriptMetadataResolver re-resolves the transitive assembly
|
||||
/// closure through MetadataReference.CreateFromFile on EVERY script compile —
|
||||
/// each call minting a fresh AssemblyMetadata → PEReader → NativeHeapMemoryBlock.
|
||||
/// The decorator memoizes so each distinct assembly is resolved once per process.
|
||||
/// </summary>
|
||||
public class CachingScriptMetadataResolverTests
|
||||
{
|
||||
private sealed class CountingResolver : MetadataReferenceResolver
|
||||
{
|
||||
public int MissingCalls;
|
||||
public int ReferenceCalls;
|
||||
public override bool ResolveMissingAssemblies => true;
|
||||
|
||||
public override PortableExecutableReference? ResolveMissingAssembly(
|
||||
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
||||
{
|
||||
Interlocked.Increment(ref MissingCalls);
|
||||
return MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
|
||||
}
|
||||
|
||||
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
||||
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
||||
{
|
||||
Interlocked.Increment(ref ReferenceCalls);
|
||||
return [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)];
|
||||
}
|
||||
|
||||
public override bool Equals(object? other) => ReferenceEquals(this, other);
|
||||
public override int GetHashCode() => 0;
|
||||
}
|
||||
|
||||
/// <summary>Inner resolver that finds nothing — models an assembly absent from every search path.</summary>
|
||||
private sealed class NullReturningResolver : MetadataReferenceResolver
|
||||
{
|
||||
public int MissingCalls;
|
||||
public override bool ResolveMissingAssemblies => true;
|
||||
|
||||
public override PortableExecutableReference? ResolveMissingAssembly(
|
||||
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
||||
{
|
||||
Interlocked.Increment(ref MissingCalls);
|
||||
return null;
|
||||
}
|
||||
|
||||
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
||||
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
||||
=> [];
|
||||
|
||||
public override bool Equals(object? other) => ReferenceEquals(this, other);
|
||||
public override int GetHashCode() => 0;
|
||||
}
|
||||
|
||||
private static readonly AssemblyIdentity SomeIdentity = new("System.Fake", new Version(1, 0, 0, 0));
|
||||
private static readonly MetadataReference SomeDefinition =
|
||||
MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
|
||||
|
||||
[Fact]
|
||||
public void ResolveMissingAssembly_SameIdentityTwice_ResolvesOnceAndSharesInstance()
|
||||
{
|
||||
var inner = new CountingResolver();
|
||||
var sut = new CachingScriptMetadataResolver(inner);
|
||||
|
||||
var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
|
||||
Assert.Equal(1, inner.MissingCalls);
|
||||
Assert.Same(first, second); // one AssemblyMetadata/PEReader/native block, not two
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveMissingAssembly_DistinctIdentities_ResolveIndependently()
|
||||
{
|
||||
var inner = new CountingResolver();
|
||||
var sut = new CachingScriptMetadataResolver(inner);
|
||||
|
||||
sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
sut.ResolveMissingAssembly(SomeDefinition, new AssemblyIdentity("System.Other", new Version(1, 0, 0, 0)));
|
||||
|
||||
Assert.Equal(2, inner.MissingCalls);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A not-found result is cached and sticky BY DESIGN: the inner resolver
|
||||
/// searches a fixed set of directories that does not change over a node's
|
||||
/// lifetime, so re-asking for an assembly that was not there only pays the
|
||||
/// probe cost again. Pinned because caching nulls is the kind of thing a
|
||||
/// future reader "fixes" without realising it is deliberate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveMissingAssembly_NotFound_CachesTheNullResult()
|
||||
{
|
||||
var inner = new NullReturningResolver();
|
||||
var sut = new CachingScriptMetadataResolver(inner);
|
||||
|
||||
var first = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
var second = sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
|
||||
Assert.Equal(1, inner.MissingCalls);
|
||||
Assert.Null(first);
|
||||
Assert.Null(second);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks in the documented cache-key contract: the key is the assembly
|
||||
/// identity ALONE, and the requesting <c>definition</c> (whose directory is a
|
||||
/// search path in the inner resolver) is deliberately ignored, because every
|
||||
/// ScadaBridge node resolves from one publish directory plus the shared
|
||||
/// framework. If that assumption ever stops holding, this test fails and the
|
||||
/// widening becomes an intentional diff rather than a silent one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveMissingAssembly_SameIdentityDifferentDefinitions_ResolvesOnce()
|
||||
{
|
||||
var otherDefinition = MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location);
|
||||
Assert.NotEqual(
|
||||
typeof(object).Assembly.Location,
|
||||
typeof(Enumerable).Assembly.Location); // guard: the two definitions really are distinct files
|
||||
|
||||
var inner = new CountingResolver();
|
||||
var sut = new CachingScriptMetadataResolver(inner);
|
||||
|
||||
sut.ResolveMissingAssembly(SomeDefinition, SomeIdentity);
|
||||
sut.ResolveMissingAssembly(otherDefinition, SomeIdentity);
|
||||
|
||||
Assert.Equal(1, inner.MissingCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveReference_SameArgsTwice_ResolvesOnceAndSharesInstances()
|
||||
{
|
||||
var inner = new CountingResolver();
|
||||
var sut = new CachingScriptMetadataResolver(inner);
|
||||
|
||||
var first = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
|
||||
var second = sut.ResolveReference("System.Xml", baseFilePath: null, MetadataReferenceProperties.Assembly);
|
||||
|
||||
Assert.Equal(1, inner.ReferenceCalls);
|
||||
Assert.Same(first[0], second[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instance_IsProcessWideSingleton()
|
||||
=> Assert.Same(CachingScriptMetadataResolver.Instance, CachingScriptMetadataResolver.Instance);
|
||||
|
||||
[Fact]
|
||||
public void ResolveMissingAssemblies_DelegatesToInner()
|
||||
{
|
||||
var sut = new CachingScriptMetadataResolver(new CountingResolver());
|
||||
Assert.True(sut.ResolveMissingAssemblies);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The decisive regression: with options shaped like the real compile
|
||||
/// surfaces (small explicit set, large transitive closure), the FIRST
|
||||
/// compile resolves the closure through the resolver and every subsequent
|
||||
/// compile — same or different code — resolves NOTHING. Before the fix,
|
||||
/// every compile re-resolved the full closure (measured: 74 fresh
|
||||
/// PortableExecutableReferences per compile on Roslyn 5.0.0).
|
||||
/// A probe wraps the DEFAULT resolver so the test also proves resolution
|
||||
/// actually flows through this path at all (first > 0) — guarding against
|
||||
/// the whole mechanism silently changing in a Roslyn upgrade.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Compile_RepeatAndDistinctScripts_ResolveClosureOnlyOnce()
|
||||
{
|
||||
var probe = new ProbeResolver(ScriptOptions.Default.MetadataResolver);
|
||||
var caching = new CachingScriptMetadataResolver(probe);
|
||||
|
||||
var options = ScriptOptions.Default
|
||||
.WithReferences(
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
|
||||
.WithImports("System", "System.Linq")
|
||||
.WithMetadataResolver(caching);
|
||||
|
||||
long CompileAndCount(string code)
|
||||
{
|
||||
long before = probe.MissingCalls;
|
||||
var script = Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.Create<object?>(code, options);
|
||||
var errors = script.Compile().Count(d => d.Severity == DiagnosticSeverity.Error);
|
||||
Assert.Equal(0, errors);
|
||||
return probe.MissingCalls - before;
|
||||
}
|
||||
|
||||
var first = CompileAndCount("return Enumerable.Range(1, 3).Sum();");
|
||||
var repeat = CompileAndCount("return Enumerable.Range(1, 3).Sum();");
|
||||
var distinct = CompileAndCount("return string.Join(\",\", Enumerable.Range(1, 2)).Length;");
|
||||
|
||||
Assert.True(first > 0, "expected the first compile to resolve the transitive closure through the resolver");
|
||||
Assert.Equal(0, repeat); // pre-fix: == first (measured 20 for this test's reference set; ~74 on the real compile surfaces)
|
||||
Assert.Equal(0, distinct);
|
||||
}
|
||||
|
||||
private sealed class ProbeResolver : MetadataReferenceResolver
|
||||
{
|
||||
private readonly MetadataReferenceResolver _inner;
|
||||
public long MissingCalls;
|
||||
public ProbeResolver(MetadataReferenceResolver inner) => _inner = inner;
|
||||
public override bool ResolveMissingAssemblies => _inner.ResolveMissingAssemblies;
|
||||
public override PortableExecutableReference? ResolveMissingAssembly(
|
||||
MetadataReference definition, AssemblyIdentity referenceIdentity)
|
||||
{
|
||||
Interlocked.Increment(ref MissingCalls);
|
||||
return _inner.ResolveMissingAssembly(definition, referenceIdentity);
|
||||
}
|
||||
public override ImmutableArray<PortableExecutableReference> ResolveReference(
|
||||
string reference, string? baseFilePath, MetadataReferenceProperties properties)
|
||||
=> _inner.ResolveReference(reference, baseFilePath, properties);
|
||||
public override bool Equals(object? other) => ReferenceEquals(this, other);
|
||||
public override int GetHashCode() => 0;
|
||||
}
|
||||
}
|
||||
@@ -138,4 +138,11 @@ public class RoslynScriptCompilerTests
|
||||
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
Assert.Empty(diagnostics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_TwiceWithSharedResolver_StillCompilesCleanly()
|
||||
{
|
||||
Assert.Empty(RoslynScriptCompiler.Compile("return 1 + 1;"));
|
||||
Assert.Empty(RoslynScriptCompiler.Compile("return 2 + 2;"));
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -251,4 +251,19 @@ public class ScriptCompilationServiceTests
|
||||
// The options object itself is cached, so it must not be rebuilt either.
|
||||
Assert.Same(first.CompiledScript.Options, second.CompiledScript.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_UsesProcessWideCachingMetadataResolver()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
var result = _service.Compile("resolver-pin", "return 41 + 1;");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Without the shared caching resolver, EVERY compile re-resolves the
|
||||
// transitive assembly closure via MetadataReference.CreateFromFile —
|
||||
// ~74+ fresh native metadata copies per compiled script (2026-08-12 dump).
|
||||
Assert.Same(
|
||||
ZB.MOM.WW.ScadaBridge.ScriptAnalysis.CachingScriptMetadataResolver.Instance,
|
||||
result.CompiledScript!.Options.MetadataResolver);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user