using System.Buffers.Binary;
using System.Data.Common;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// Bounded private IPC for disposable, killable Test Run processes.
public static class SandboxWorkerProtocol
{
private const int FrameLimit = 1024 * 1024;
private static readonly SemaphoreSlim Admission = new(4, 4);
private static readonly JsonSerializerOptions Json = new() { IgnoreReadOnlyProperties = true };
private sealed record Frame(string Kind, string? Method, JsonElement Data, string? Error = null);
private sealed record Start(SandboxRunRequest Request, bool External, bool Database, bool Bound);
private sealed record ConnectionInfo(string Provider, string ConnectionString, bool Open);
/// Runs one child under a deadline; a slot remains charged until confirmed process exit.
public static async Task RunAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller)
=> await RunCoreAsync(request, services, catalog, caller);
internal static async Task RunCoreAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller, Action? started = null)
{
var seconds = Math.Clamp(request.TimeoutSeconds ?? 5, 1, 10);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(caller);
deadline.CancelAfter(TimeSpan.FromSeconds(seconds));
var clock = Stopwatch.StartNew();
var acquired = false;
Process? process = null;
Task? stderr = null;
CancellationTokenRegistration terminateOnDeadline = default;
try
{
await Admission.WaitAsync(deadline.Token); acquired = true;
ISandboxInstanceGateway? gateway = null;
if (request.Kind != ScriptKind.InboundApi && !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
{
var locator = services.GetService();
var comms = services.GetService();
var site = locator is null ? null : await locator.GetSiteIdForInstanceAsync(request.BindInstanceUniqueName, deadline.Token);
if (site is null || comms is null) throw new ScriptSandboxException("Cannot bind instance: cross-site communication or deployment is unavailable.");
gateway = new SandboxInstanceGateway(comms, site, request.BindInstanceUniqueName, deadline.Token);
}
var worker = Path.Combine(AppContext.BaseDirectory, "ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker.dll");
if (!File.Exists(worker)) throw new InvalidOperationException("Sandbox worker is not installed.");
var start = new ProcessStartInfo("dotnet") { UseShellExecute = false, RedirectStandardInput = true,
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true };
start.ArgumentList.Add(worker);
// A finite heap bounds accidental allocation in return serialization and script code.
start.Environment["DOTNET_GCHeapHardLimit"] = "20000000"; // 512 MiB, hexadecimal runtime setting.
process = Process.Start(start) ?? throw new InvalidOperationException("Could not start sandbox worker.");
terminateOnDeadline = deadline.Token.Register(static state =>
{
var child = (Process)state!;
try { if (!child.HasExited) child.Kill(entireProcessTree: true); }
catch (InvalidOperationException) { /* Concurrent normal process exit. */ }
}, process);
started?.Invoke(process.Id);
stderr = DrainAsync(process.StandardError.BaseStream);
await WriteAsync(process.StandardInput.BaseStream, new Frame("start", null,
Encode(new Start(request, services.GetService() is not null,
services.GetService() is not null, gateway is not null))), deadline.Token);
while (true)
{
var frame = await ReadAsync(process.StandardOutput.BaseStream, deadline.Token);
if (frame.Kind == "result")
return frame.Data.Deserialize(Json) ?? throw new InvalidDataException("Missing sandbox result.");
if (frame.Kind != "call") throw new InvalidDataException("Unexpected sandbox message.");
deadline.Token.ThrowIfCancellationRequested();
Frame reply;
try
{
var value = await DispatchAsync(frame.Method!, frame.Data, services, catalog, gateway, deadline.Token);
deadline.Token.ThrowIfCancellationRequested();
reply = new Frame("reply", null, Encode(value));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{ reply = new Frame("reply", null, Encode(null), ex is ScriptSandboxException ? ex.Message : "Sandbox gateway request failed."); }
await WriteAsync(process.StandardInput.BaseStream, reply, deadline.Token);
}
}
catch (OperationCanceledException)
{ return new(false, null, null, "", $"Script execution exceeded the {seconds}-second sandbox timeout or was canceled.", SandboxErrorKind.Timeout, clock.ElapsedMilliseconds, null); }
catch (ScriptSandboxException ex)
{ return new(false, null, null, "", ex.Message, SandboxErrorKind.SandboxLimitation, clock.ElapsedMilliseconds, null); }
catch (Exception)
{ return new(false, null, null, "", "Sandbox worker exited or exceeded its resource budget.", SandboxErrorKind.RuntimeError, clock.ElapsedMilliseconds, null); }
finally
{
deadline.Cancel(); // Stop accepting/brokering new operations before termination.
terminateOnDeadline.Dispose();
if (process is not null)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
catch (InvalidOperationException) when (process.HasExited) { }
await process.WaitForExitAsync(CancellationToken.None);
if (stderr is not null) await stderr;
process.Dispose();
}
if (acquired) Admission.Release();
}
}
private static async Task DrainAsync(Stream stream)
{ var buffer = new byte[4096]; while (await stream.ReadAsync(buffer) != 0) { } }
private static async Task