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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user