using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
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;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
///
/// Executes the C# script associated with an inbound API method.
/// Compiles method scripts via Roslyn and caches compiled delegates.
///
public class InboundScriptExecutor
{
private readonly ILogger _logger;
// A cached compiled handler, paired with the exact script text it was
// compiled from. The DB row is authoritative: on every request the cached
// Script is ordinal-compared against the freshly-fetched ApiMethod.Script; a
// mismatch means the cache is stale (a standby node compiled its own copy at
// startup, or the row was edited via direct SQL / bundle import) and the
// handler is recompiled in place. A null Script means "no revision info,
// serve as-is" — the RegisterHandler test seam, exempt from revision checks.
private sealed record CachedHandler(string? Script, Func> Handler);
// This executor is registered as a singleton and its handler cache
// is read and written from concurrent ASP.NET request threads. A plain Dictionary is
// not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
private readonly ConcurrentDictionary _scriptHandlers = new();
// A script that fails to compile (or violates the trust model)
// is recorded here — keyed by method name, valued by the exact script text
// that failed — so it is compiled at most once. Without this, every subsequent
// request for a broken method re-runs the expensive Roslyn compilation — a CPU
// amplification vector since the inbound API has no rate limiting. A record only
// short-circuits when the CURRENT script text is the one that failed: a changed
// script always gets a fresh compile. The entry is cleared whenever the method
// is (re)compiled successfully.
//
// Bound the cache so a spam attack of unique method names cannot
// grow it without bound. Once the cap is reached new bad-method records are
// dropped — the cache is just a fast-fail optimisation; the per-request DB
// lookup remains the correctness path.
private const int KnownBadMethodsCap = 1000;
private readonly ConcurrentDictionary _knownBadMethods = new();
///
/// Diagnostic helper — returns the current size of the
/// known-bad-methods cache so tests can assert the cap is honoured. Internal
/// so the cache itself stays an implementation detail.
///
internal int KnownBadMethodCount => _knownBadMethods.Count;
///
/// Records → in the
/// known-bad-methods cache so the CURRENT failing script text short-circuits the
/// next request. An existing record for the method is overwritten so a newer bad
/// script replaces an older one (the fast-fail always tracks the latest failure).
/// A brand-new record is only added while the cache has not reached
/// ; once full, new method names are dropped
/// (paying the cheap recompile next time rather than leaking memory under a
/// unique-name flood). Records are cleared on a successful (re)compile.
///
private void TryRecordBadMethod(string methodName, string script)
{
// Overwrite an existing record (newer bad script wins) without counting
// against the cap; only a genuinely new method name is gated by the cap.
if (_knownBadMethods.ContainsKey(methodName))
{
_knownBadMethods[methodName] = script;
return;
}
if (_knownBadMethods.Count >= KnownBadMethodsCap)
return;
_knownBadMethods.TryAdd(methodName, script);
}
private readonly IServiceProvider _serviceProvider;
// Count of inbound executions whose handler outlived its request (timed out or
// client-aborted) and is still running with its DI scope disposal deferred. A
// non-cooperative script that ignores the cancellation token keeps running after
// WaitAsync returns; the scope is disposed by the handler's own completion
// continuation, not the request finally, and this gauge makes the pile-up visible.
private long _abandonedExecutions;
///
/// Diagnostic gauge — number of inbound executions currently orphaned (handler
/// still running after a timeout/abort, with DI-scope disposal deferred to the
/// handler's completion). Internal so the counter stays an implementation detail;
/// promote to public if a health surface needs it later.
///
internal long AbandonedExecutionCount => Interlocked.Read(ref _abandonedExecutions);
///
/// Initializes a new instance of the InboundScriptExecutor.
///
/// Logger instance.
/// Service provider for dependency resolution.
public InboundScriptExecutor(ILogger logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
///
/// Registers a compiled script handler for a method name. This is the script-less
/// test seam: the handler is stored with no associated script text, so it is
/// exempt from the per-request revision check and serves as-is regardless of
/// the fetched row's script. Production compiles always flow through
/// , which records the script text and is
/// revision-checked.
///
/// The method name to register.
/// The compiled handler function.
public void RegisterHandler(string methodName, Func> handler)
{
_scriptHandlers[methodName] = new CachedHandler(null, handler);
}
///
/// Drops the compiled handler AND any known-bad record for the method. The
/// consumption seam for the script-artifact invalidation contract (bundle import,
/// out-of-band updates, failover activation). Safe to call for unknown names.
/// The per-request revision check in ExecuteAsync remains the correctness
/// fallback when an invalidation is missed.
///
/// The method name to invalidate.
public void InvalidateMethod(string methodName)
{
_scriptHandlers.TryRemove(methodName, out _);
_knownBadMethods.TryRemove(methodName, out _);
}
///
/// Removes a compiled script handler for a method name. Delegates to
/// (public API preserved).
///
/// The method name to remove.
public void RemoveHandler(string methodName) => InvalidateMethod(methodName);
///
/// Compiles and registers a single API method script. Returns false if the
/// script is empty, fails Roslyn compilation, or violates the script trust model.
///
/// The API method to compile and register.
/// True if successfully compiled and registered, false otherwise.
public bool CompileAndRegister(ApiMethod method) => CompileAndRegister(method, out _);
///
/// Compiles and registers a single API method script, additionally reporting the
/// compilation diagnostics. Returns false together with the human-readable
/// error list if the script is empty, fails Roslyn compilation, or violates the
/// script trust model. On failure this method does not overwrite any previously
/// registered handler, but the persisted DB row is authoritative:
/// revision-checks the cached handler against the freshly-fetched
/// ApiMethod.Script on every request, so once a broken script is saved the
/// method returns a compilation error consistently on every node rather than the
/// stale delegate silently serving. The management Create/Update path can therefore
/// surface a "saved but did not compile" warning that reflects real, consistent
/// behavior (see HandleCreateApiMethod/HandleUpdateApiMethod).
///
/// The API method to compile and register.
/// On failure, the compilation/trust-model error messages; empty on success.
/// True if successfully compiled and registered, false otherwise.
public bool CompileAndRegister(ApiMethod method, out IReadOnlyList errors)
{
var (handler, compileErrors) = Compile(method);
if (handler == null)
{
// Record the failure so the lazy-compile path does not
// keep recompiling a broken script on every request. Routed
// through the capped TryRecordBadMethod helper so the cache
// cannot grow without bound under a flood of unique method names.
errors = compileErrors;
TryRecordBadMethod(method.Name, method.Script ?? string.Empty);
return false;
}
// The method definition was (re)compiled successfully — drop any stale
// failure record so a fixed script is no longer treated as bad.
errors = Array.Empty();
_knownBadMethods.TryRemove(method.Name, out _);
return Register(method, handler);
}
private bool Register(ApiMethod method, Func> handler)
{
_scriptHandlers[method.Name] = new CachedHandler(method.Script, handler);
return true;
}
///
/// Compiles a single API method script into an executable handler. Returns
/// null when the script is missing, fails to compile, or violates the
/// script trust model. Does not mutate the handler cache.
///
private (Func>? Handler, IReadOnlyList Errors) Compile(ApiMethod method)
{
if (string.IsNullOrWhiteSpace(method.Script))
{
_logger.LogWarning("API method {Method} has no script code", method.Name);
return (null, new[] { "Method has no script code." });
}
// Enforce the script trust model before compiling. Roslyn
// scripting performs no API allow/deny-listing, so forbidden namespaces must
// be rejected statically or the script could reach the host process.
var violations = ForbiddenApiChecker.FindViolations(method.Script);
if (violations.Count > 0)
{
_logger.LogWarning(
"API method {Method} script rejected — trust model violation(s): {Violations}",
method.Name, string.Join("; ", violations));
return (null, violations.Select(v => "Trust model violation: " + v).ToList());
}
try
{
var scriptOptions = ScriptOptions.Default
.WithReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Dictionary<,>).Assembly,
typeof(RouteHelper).Assembly,
typeof(ScriptParameters).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
.WithImports(
"System",
"System.Collections.Generic",
"System.Linq",
"System.Threading.Tasks");
var compiled = CSharpScript.Create