fix(scriptanalysis): compile gate reads diagnostics only and never caches a faulted verdict (review 2026-10-21 04/M1, 04/C1)

RoslynScriptCompiler.Compile now uses Script.GetCompilation().GetDiagnostics()
with one shared, never-loading InteractiveAssemblyLoader instead of
Script.Compile(), which emitted and loaded a non-collectible assembly (and a
fresh AssemblyLoadContext) per distinct script. The catch-all that turned any
exception into a 'Compilation exception' diagnostic is gone; TryCompile and
CheckExpressionSyntax report a fault for that call only, uncached.
This commit is contained in:
Joseph Doherty
2026-10-22 06:15:35 -04:00
parent 75149e83b0
commit ae1282ed9d
8 changed files with 274 additions and 68 deletions
@@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.Scripting.Hosting;
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
@@ -16,6 +17,16 @@ namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
/// </summary>
public static class RoslynScriptCompiler
{
/// <summary>
/// One loader for every design-time compile. <c>CSharpScript.Create</c> otherwise
/// constructs a fresh <see cref="InteractiveAssemblyLoader"/> per call, and on .NET
/// each loader registers its own non-collectible <c>AssemblyLoadContext</c> — even
/// when nothing is ever emitted or loaded through it. The gate never executes a
/// script, so this loader never loads anything; sharing it keeps the per-compile
/// ALC count at zero (review 2026-10-21 04/M1).
/// </summary>
private static readonly InteractiveAssemblyLoader SharedNonLoadingLoader = new();
/// <summary>
/// Parses the script as C# script source and returns syntax-error diagnostic
/// messages (severity Error only). Empty list means the script parses.
@@ -47,38 +58,50 @@ public static class RoslynScriptCompiler
/// <param name="extraReferences">Optional additional metadata references.</param>
/// <param name="extraImports">Optional additional namespace imports.</param>
/// <returns>Error-severity compile diagnostic messages; empty if the script compiles.</returns>
/// <exception cref="Exception">
/// Any non-diagnostic failure (metadata I/O, out-of-memory, an invalid import) is
/// thrown, never folded into the returned list — a returned message is always a
/// genuine compile error and therefore safe to memoise.
/// </exception>
public static IReadOnlyList<string> Compile(
string code,
Type? globalsType = null,
IEnumerable<MetadataReference>? extraReferences = null,
IEnumerable<string>? extraImports = null)
{
try
{
var references = ScriptTrustPolicy.DefaultReferences.ToList();
if (extraReferences != null)
references.AddRange(extraReferences);
var references = ScriptTrustPolicy.DefaultReferences.ToList();
if (extraReferences != null)
references.AddRange(extraReferences);
var imports = ScriptTrustPolicy.DefaultImports.AsEnumerable();
if (extraImports != null)
imports = imports.Concat(extraImports);
var imports = ScriptTrustPolicy.DefaultImports.AsEnumerable();
if (extraImports != null)
imports = imports.Concat(extraImports);
var options = ScriptOptions.Default
.WithReferences(references)
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
.WithImports(imports);
var options = ScriptOptions.Default
.WithReferences(references)
.WithMetadataResolver(CachingScriptMetadataResolver.Instance)
.WithImports(imports);
var script = CSharpScript.Create<object?>(code, options, globalsType);
var diagnostics = script.Compile();
var script = CSharpScript.Create<object?>(code, options, globalsType, SharedNonLoadingLoader);
return diagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error)
.Select(d => d.GetMessage())
.ToList();
}
catch (Exception ex)
{
return [$"Compilation exception: {ex.Message}"];
}
// Diagnostics only — NO emit, NO load. `Script.Compile()` builds the
// executor, which emits the PE image and loads it through
// InteractiveAssemblyLoader into a fresh, non-collectible
// AssemblyLoadContext: one permanent assembly per DISTINCT script for a gate
// that never executes anything (review 2026-10-21 04/M1). The compilation's
// own diagnostics are the declaration + method-body errors that decide
// whether the script compiles.
//
// No catch-all here (review 2026-10-21 04/C1): an unexpected exception
// (I/O reading metadata, OOM, ...) used to come back as a
// "Compilation exception: ..." diagnostic, which the design-time verdict
// cache then memoised as a sticky, process-wide "failed to compile". It now
// propagates, so the caller can report it without caching it.
var diagnostics = script.GetCompilation().GetDiagnostics();
return diagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error)
.Select(d => d.GetMessage())
.ToList();
}
}
@@ -11,7 +11,14 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// compile-time-static <c>ScriptTrustPolicy</c> AND the globals surface it was
/// compiled against, so it is sound to memoise it across the whole process: an
/// unchanged script runs the forbidden-API analysis and the Roslyn compile exactly
/// once, removing both the repeat CPU cost and the repeat compiled-assembly load.
/// once, removing the repeat CPU cost. (The compile itself no longer emits or loads an
/// assembly — <c>RoslynScriptCompiler.Compile</c> reads compilation diagnostics only.)
///
/// <para>
/// <b>Only completed verdicts are stored.</b> A factory that throws propagates out of
/// <see cref="GetOrAdd"/> and leaves the slot empty, so the next call re-runs it — a
/// transient fault must never become a sticky, process-wide failure verdict.
/// </para>
///
/// <para>
/// The globals surface is part of the key because a verdict is NOT interchangeable
@@ -32,11 +39,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// <para>
/// <b>Eviction is segmented, never wholesale.</b> Overflow used to
/// <c>Clear()</c> the whole cache, which reads as harmless ("the verdict is cheap
/// to recompute") but is not: a recompute is a fresh Roslyn compile, and every
/// script compile loads an assembly through a NON-COLLECTIBLE
/// <c>InteractiveAssemblyLoader</c>. That leak is the entire reason this cache
/// exists, so dropping every hot entry at the 4096th distinct script re-opens it
/// for the whole working set at once. Instead the cache runs two generations: new
/// to recompute") but is not: a recompute is a fresh trust analysis plus Roslyn
/// compile against the full reference set, so dropping every hot entry at the
/// 4096th distinct script re-pays that for the whole working set at once. Instead
/// the cache runs two generations: new
/// verdicts land in <c>hot</c>, a hit in <c>cold</c> is promoted back into
/// <c>hot</c>, and on overflow <c>hot</c> becomes the new <c>cold</c> and the old
/// <c>cold</c> — the half nothing has touched for a full generation — is the only
@@ -22,6 +22,27 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// </summary>
public class ScriptCompiler
{
private readonly Func<string, IReadOnlyList<string>> _compile;
/// <summary>
/// Creates the gate over the real Roslyn compile against
/// <see cref="ScriptCompileSurface"/>.
/// </summary>
public ScriptCompiler()
: this(code => RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)))
{
}
/// <summary>
/// Test seam: substitutes the compile step (the forbidden-API verdict still runs
/// for real). Used to prove a faulting compile is never memoised.
/// </summary>
/// <param name="compile">Returns error-severity diagnostics for the code, or throws.</param>
internal ScriptCompiler(Func<string, IReadOnlyList<string>> compile)
{
_compile = compile;
}
/// <summary>
/// Attempts to compile a script and returns success or a compilation error.
/// The security check runs first: a script that uses a forbidden API is
@@ -39,23 +60,40 @@ public class ScriptCompiler
// globals surface are compile-time static), so memoise it process-wide: an
// unchanged script compiles exactly once per process. The cached error is
// name-free — the script name is formatted in below at read time.
var (ok, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(ScriptCompileSurface), code, () =>
//
// Only a DIAGNOSTIC verdict is cached. If the analysis or compile throws
// (metadata I/O, out-of-memory, ...) the factory propagates, the cache slot
// stays empty, and this call alone reports the fault — the next attempt
// re-runs the gate instead of serving a sticky, process-wide "failed to
// compile" that only a restart clears (review 2026-10-21 04/C1).
bool ok;
string? error;
try
{
// Authoritative forbidden-API verdict first — a security violation must
// gate the script regardless of whether it otherwise compiles. Report
// ALL violations, not just the first, so an operator fixing
// one forbidden API isn't surprised by a second on the next attempt.
var violations = ScriptTrustValidator.FindViolations(code);
if (violations.Count > 0)
return (false, $"uses forbidden API: {string.Join("; ", violations)}");
(ok, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(ScriptCompileSurface), code, () =>
{
// Authoritative forbidden-API verdict first — a security violation must
// gate the script regardless of whether it otherwise compiles. Report
// ALL violations, not just the first, so an operator fixing
// one forbidden API isn't surprised by a second on the next attempt.
var violations = ScriptTrustValidator.FindViolations(code);
if (violations.Count > 0)
return (false, $"uses forbidden API: {string.Join("; ", violations)}");
// Real CSharpScript compile against the runtime-mirroring globals surface.
var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
if (errors.Count > 0)
return (false, $"failed to compile: {string.Join("; ", errors)}");
// Real CSharpScript compile against the runtime-mirroring globals surface.
var errors = _compile(code);
if (errors.Count > 0)
return (false, $"failed to compile: {string.Join("; ", errors)}");
return (true, (string?)null);
});
return (true, (string?)null);
});
}
catch (Exception ex)
{
return Result<bool>.Failure(
$"Script '{scriptName}' could not be checked — the compile gate faulted " +
$"({ex.GetType().Name}: {ex.Message}). This is not a script error; retry.");
}
return ok
? Result<bool>.Success(true)
@@ -97,7 +97,7 @@ public class ValidationService
/// </param>
/// <param name="validateScriptCompilation">
/// <c>true</c> (default) runs the authoritative Roslyn <see cref="ValidateScriptCompilation"/>
/// stage — the single most expensive check (a non-collectible assembly load per script) —
/// stage — the single most expensive check (a full trust analysis + Roslyn compile per script) —
/// AND the Expression-trigger syntax/compile check
/// (<see cref="CheckExpressionSyntax"/>, a forbidden-API verdict plus a real compile of every
/// Expression-triggered script/alarm body against <see cref="TriggerCompileSurface"/>).
@@ -125,7 +125,7 @@ public class ValidationService
ValidateFlatteningSuccess(configuration),
ValidateNamingCollisions(configuration),
// The Roslyn compile is the single most expensive validation stage (a
// non-collectible InteractiveAssemblyLoader assembly load per script).
// full trust analysis + compile per uncached script).
// Read-only staleness/comparison callers pass validateScriptCompilation:
// false to skip ONLY this stage; every other structural/semantic check
// still runs. The deploy gate keeps the default (true) — it is the
@@ -556,22 +556,34 @@ public class ValidationService
// the verdict is a pure function of expression + policy + TriggerCompileSurface, and
// it is the exact hot-path cost N1 flagged — every staleness sweep / import probe of
// an Expression-triggered config re-ran a full trust compilation + script compile.
var (_, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(TriggerCompileSurface), expression, () =>
//
// A faulting gate (exception, not diagnostic) is reported for this call only and
// never memoised — see ScriptCompiler.TryCompile (review 2026-10-21 04/C1).
string? error;
try
{
// Authoritative forbidden-API verdict first. Report ALL violations, not
// just the first, so an operator fixing one forbidden API isn't surprised
// by a second on the next deploy attempt (mirrors ScriptCompiler.TryCompile).
var violations = ScriptTrustValidator.FindViolations(expression);
if (violations.Count > 0)
return (false, $"uses forbidden API: {string.Join("; ", violations)}");
(_, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(TriggerCompileSurface), expression, () =>
{
// Authoritative forbidden-API verdict first. Report ALL violations, not
// just the first, so an operator fixing one forbidden API isn't surprised
// by a second on the next deploy attempt (mirrors ScriptCompiler.TryCompile).
var violations = ScriptTrustValidator.FindViolations(expression);
if (violations.Count > 0)
return (false, $"uses forbidden API: {string.Join("; ", violations)}");
// Real compile of the bare boolean expression against the trigger globals.
var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
if (errors.Count > 0)
return (false, $"is not a valid expression: {string.Join("; ", errors)}");
// Real compile of the bare boolean expression against the trigger globals.
var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
if (errors.Count > 0)
return (false, $"is not a valid expression: {string.Join("; ", errors)}");
return (true, (string?)null);
});
return (true, (string?)null);
});
}
catch (Exception ex)
{
return $"could not be checked — the compile gate faulted ({ex.GetType().Name}: {ex.Message}). " +
"This is not an expression error; retry.";
}
return error;
}