Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs
T

358 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Script Trust Model — compiles C# scripts using Roslyn with restricted API access.
/// The forbidden-API verdict is delegated to the shared authoritative
/// <see cref="ScriptTrustValidator"/>; this service keeps the real
/// execution-path compile of the script against <see cref="ScriptGlobals"/> /
/// <see cref="TriggerExpressionGlobals"/>.
///
/// Compile results — success AND failure — are memoised in this service's
/// <see cref="SiteScriptCompileCache"/> (keyed on code hash + globals type), so an
/// unchanged script body compiles once per process lifetime and the deploy gate,
/// the Instance Actor's PreStart recompile, and SharedScriptLibrary all reuse one
/// compiled <c>Script&lt;object?&gt;</c>. Error text is name-free by construction, so a
/// cached failure renders correctly for every caller.
///
/// <para><b>The cache is owned per service instance, not static (register row 100).</b>
/// "Once per process" is a property of this service being registered as a DI
/// <b>singleton</b> (<see cref="ServiceCollectionExtensions.AddSiteRuntime"/>, pinned by
/// <c>SiteRuntimeServiceRegistrationTests</c>) — the whole site reaches the cache through
/// that one instance, which the Deployment Manager threads into its
/// <see cref="Deployment.DeployCompileValidator"/> and into every Instance Actor. Making the
/// cache an instance field rather than static state means a test (or a second host in one
/// process) that constructs its own service gets its own cache instead of mutating everyone
/// else's; that shared-static cross-talk is what row 100 closed.</para>
/// </summary>
public class ScriptCompilationService
{
private readonly ILogger<ScriptCompilationService> _logger;
private readonly SiteScriptCompileCache _compileCache;
/// <summary>Initializes a new instance of the ScriptCompilationService class.</summary>
/// <param name="logger">Logger instance.</param>
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
: this(logger, new SiteScriptCompileCache())
{
}
/// <summary>
/// Test seam: constructs the service over a caller-supplied compile cache, so a test can
/// observe <see cref="SiteScriptCompileCache.Hits"/> / <see cref="SiteScriptCompileCache.Count"/>
/// for exactly the compiles it caused. Production always uses the public constructor.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="compileCache">The compile cache this service memoises into.</param>
internal ScriptCompilationService(
ILogger<ScriptCompilationService> logger,
SiteScriptCompileCache compileCache)
{
_logger = logger;
_compileCache = compileCache;
}
/// <summary>
/// Validates that the script does not reference forbidden APIs.
///
/// As of the script-analysis consolidation this delegates to the shared
/// authoritative <see cref="ScriptTrustValidator.FindViolations(string, System.Collections.Generic.IEnumerable{MetadataReference})"/>,
/// which is the same Roslyn semantic-symbol analysis this service previously hosted
/// plus reflection-gateway / <c>dynamic</c> / <c>Activator</c> hardening ported from
/// the InboundAPI checker. The shared validator is the single source of truth for the
/// forbidden-API deny-list; SiteRuntime retains only the real execution-path compile
/// in <see cref="CompileCore"/>.
///
/// Returns a list of violation messages, empty if clean.
/// </summary>
/// <param name="code">The script code to validate.</param>
/// <returns>A list of trust-model violation messages; empty if the script is clean.</returns>
public IReadOnlyList<string> ValidateTrustModel(string code)
=> ScriptTrustValidator.FindViolations(code);
/// <summary>
/// Assemblies referenced by compiled scripts, used to build the Roslyn scripting
/// options for the real execution-path compile.
/// </summary>
private static readonly System.Reflection.Assembly[] ScriptAssemblies =
[
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Math).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.DynamicJsonElement).Assembly
];
/// <summary>
/// Shared Roslyn scripting options (references + imports) used by both full
/// script compilation and trigger-expression compilation.
///
/// <para>
/// <b>Built ONCE, deliberately. Do not turn this back into a method.</b>
/// <c>WithReferences(Assembly[])</c> resolves every assembly through
/// <c>MetadataReference.CreateFromFile</c>, which does not cache: each call
/// mints a fresh <c>MetadataReference</c> owning an <c>AssemblyMetadata</c> →
/// <c>PEReader</c> → <c>NativeHeapMemoryBlock</c>, an unmanaged copy of the
/// assembly metadata that nothing disposes. Building the options per compile
/// therefore leaks native memory permanently — no GC reclaims it, and neither
/// gcdump nor <c>GC.GetTotalAllocatedBytes</c> can see it, because it is not
/// on the managed heap.
/// </para>
///
/// <para>
/// This was a live defect: a Site node reached a 2,885 MB working set with only
/// 150 MB of live GC heap, ~2,469 MB of it on the default process heap across
/// ~6,700 undisposed <c>AssemblyMetadata</c> instances (against 473 DLLs on
/// disk). The method form reads as harmless, which is exactly why it survived —
/// see <c>ScriptCompilationServiceTests.Compile_DistinctScripts_ShareOneMetadataReferenceSet_SoNativeMemoryDoesNotGrow</c>,
/// which pins reference-equality of the reference set across compiles.
/// <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",
"System.Linq",
"System.Threading.Tasks");
/// <summary>
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
/// and parameters dictionary, and returns an object? result.
/// </summary>
/// <param name="scriptName">The name of the script.</param>
/// <param name="code">The script code to compile.</param>
/// <returns>A <see cref="ScriptCompilationResult"/> containing the compiled script on success, or error messages on failure.</returns>
public ScriptCompilationResult Compile(string scriptName, string code)
=> CompileCore(scriptName, code, typeof(ScriptGlobals));
/// <summary>
/// Compiles a bare C# boolean trigger expression against the restricted
/// read-only <see cref="TriggerExpressionGlobals"/>. The expression is a
/// trailing expression (no <c>return</c>); Roslyn scripting yields its
/// value, which the caller coerces to <c>bool</c>. Reuses the same script
/// options and forbidden-API trust validation as <see cref="Compile"/>.
/// </summary>
/// <param name="name">The name of the trigger expression.</param>
/// <param name="expression">The trigger expression to compile.</param>
/// <returns>A <see cref="ScriptCompilationResult"/> containing the compiled expression on success, or error messages on failure.</returns>
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
/// <summary>
/// Shared compilation funnel for every site-side compile (the deploy gate, the
/// Instance Actor's PreStart recompile, and SharedScriptLibrary). Results — success
/// AND failure — are memoised in this service's <see cref="SiteScriptCompileCache"/>,
/// keyed on code hash + globals type: an unchanged script body compiles once per
/// process lifetime (this service is a DI singleton) and the compiled
/// <c>Script&lt;object?&gt;</c> is shared across all call sites (N4). Cached error text is
/// name-free by construction (Roslyn diagnostics / the compile-exception message never
/// embed the caller's script name), so a cached failure renders correctly for every caller.
/// </summary>
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
{
var result = _compileCache.GetOrAdd(code, globalsType,
() => CompileUncached(name, code, globalsType));
if (!result.IsSuccess)
_logger.LogDebug(
"Script {Script}: returning cached compile failure ({ErrorCount} error(s))",
name, result.Errors.Count); // miss-path logs name the FIRST caller; keep this caller visible
return result;
}
/// <summary>
/// The uncached compile: validates the trust model, builds the script against the
/// given globals type, and returns the compiled result. The verdict is deterministic
/// on the code (plus the compile-time-static trust policy and globals surface), so it
/// is safe to cache alongside the compiled script.
/// </summary>
private ScriptCompilationResult CompileUncached(string name, string code, Type globalsType)
{
// Validate trust model
var violations = ValidateTrustModel(code);
if (violations.Count > 0)
{
_logger.LogWarning(
"Script {Script} failed trust validation: {Violations}",
name, string.Join("; ", violations));
return ScriptCompilationResult.Failed(violations);
}
try
{
var script = CSharpScript.Create<object?>(
code,
SharedScriptOptions,
globalsType: globalsType);
var diagnostics = script.Compile();
var errors = diagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error)
.Select(d => d.GetMessage())
.ToList();
if (errors.Count > 0)
{
_logger.LogWarning(
"Script {Script} compilation failed: {Errors}",
name, string.Join("; ", errors));
return ScriptCompilationResult.Failed(errors);
}
_logger.LogDebug("Script {Script} compiled successfully", name);
return ScriptCompilationResult.Succeeded(script);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error compiling script {Script}", name);
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
}
}
}
/// <summary>
/// Result of script compilation, containing either the compiled script or error messages.
/// </summary>
public class ScriptCompilationResult
{
/// <summary>Indicates whether compilation succeeded.</summary>
public bool IsSuccess { get; }
/// <summary>The compiled script, or null if compilation failed.</summary>
public Script<object?>? CompiledScript { get; }
/// <summary>List of error messages, empty if compilation succeeded.</summary>
public IReadOnlyList<string> Errors { get; }
private ScriptCompilationResult(bool success, Script<object?>? script, IReadOnlyList<string> errors)
{
IsSuccess = success;
CompiledScript = script;
Errors = errors;
}
/// <summary>Creates a successful compilation result.</summary>
/// <param name="script">The compiled script.</param>
/// <returns>A <see cref="ScriptCompilationResult"/> with <see cref="IsSuccess"/> set to <c>true</c> and the compiled script attached.</returns>
public static ScriptCompilationResult Succeeded(Script<object?> script) =>
new(true, script, []);
/// <summary>Creates a failed compilation result.</summary>
/// <param name="errors">List of error messages.</param>
/// <returns>A <see cref="ScriptCompilationResult"/> with <see cref="IsSuccess"/> set to <c>false</c> and the provided error messages.</returns>
public static ScriptCompilationResult Failed(IReadOnlyList<string> errors) =>
new(false, null, errors);
}
/// <summary>
/// Global variables available to compiled scripts. The ScriptRuntimeContext is injected
/// as the "Instance" global, and parameters are available via "Parameters".
/// </summary>
public class ScriptGlobals
{
/// <summary>The script runtime context providing access to instance state.</summary>
public ScriptRuntimeContext Instance { get; set; } = null!;
/// <summary>Script parameters passed by the caller.</summary>
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
/// <summary>Cancellation token for script execution.</summary>
public CancellationToken CancellationToken { get; set; }
/// <summary>
/// Alarm context when this script is invoked as an on-trigger handler.
/// Null for instance scripts, shared scripts, and inbound-API-routed
/// scripts. Lets on-trigger scripts read the firing alarm's Name, Level
/// (HiLo only), Priority, and per-band Message to branch routing logic.
/// </summary>
public Commons.Types.Scripts.AlarmContext? Alarm { get; set; }
/// <summary>
/// Where this script sits in the composition tree. Defaults to root for
/// scripts on top-level templates; a flattened composed script gets
/// SelfPath = "TempSensor" (etc.) and a ParentPath set to one level up.
/// </summary>
public Commons.Types.Scripts.ScriptScope Scope { get; set; } =
Commons.Types.Scripts.ScriptScope.Root;
/// <summary>
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
/// </summary>
public ScriptRuntimeContext.ExternalSystemHelper ExternalSystem => Instance.ExternalSystem;
/// <summary>
/// Top-level Database access for scripts (delegates to Instance.Database).
/// Usage: Database.Connection("name") or Database.CachedWrite("name", "sql", params)
/// </summary>
public ScriptRuntimeContext.DatabaseHelper Database => Instance.Database;
/// <summary>
/// Top-level Notify access for scripts (delegates to Instance.Notify).
/// Usage: Notify.To("listName").Send("subject", "message")
/// </summary>
public ScriptRuntimeContext.NotifyHelper Notify => Instance.Notify;
/// <summary>
/// Top-level Scripts access for shared script calls (delegates to Instance.Scripts).
/// Usage: Scripts.CallShared("scriptName", params)
/// </summary>
public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts;
/// <summary>
/// Read/write the current template's attributes by name. Resolves to the
/// canonical name for the script's scope, so a script on a composed
/// TempSensor reads its own Temperature via <c>Attributes["Temperature"]</c>.
/// </summary>
public AttributeAccessor Attributes => new(Instance, Scope.SelfPath);
/// <summary>
/// Indexed access to child compositions.
/// <c>Children["TempSensor"].Attributes["Temperature"]</c> reads the
/// composed child's attribute. <c>Children["TempSensor"].CallScript("Sample")</c>
/// invokes a script on the child.
/// </summary>
public ChildrenAccessor Children => new(Instance, Scope.SelfPath);
/// <summary>
/// Read-only view of the instance's CURRENT alarm conditions — computed alarms
/// and mirrored native (OPC UA A&amp;C / MxAccess Gateway) conditions alike.
/// Usage: <c>var alarms = await Alarms.CurrentAsync();</c>
///
/// <para>
/// Unlike the <see cref="Alarm"/> global — which exists only inside an on-trigger
/// handler and describes the ONE alarm that fired — this is available to every script
/// and describes the whole instance. Not scope-prefixed: alarm identity is not a
/// scope-relative attribute name, so every scope sees the same list.
/// </para>
/// </summary>
public AlarmsAccessor Alarms => Instance.Alarms;
/// <summary>Leveled per-run logging (design 2026-09-09). Usage: <c>Log.Info("…")</c>, <c>Log.Warn("…", ex)</c>. Always reaches the node log; becomes Flow Debugger trace rows only for a traced run.</summary>
public ScriptLogger Log => Instance.Log;
/// <summary>
/// Parent composition (null when this script is on a root-level template).
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
/// <c>Parent.CallScript("Trip")</c> invokes a parent script.
/// </summary>
public CompositionAccessor? Parent =>
Scope.ParentPath == null ? null : new CompositionAccessor(Instance, Scope.ParentPath);
}