Files
lmxopcua/scripts/validation/opcua-lifecycle-memory/baseline-canonical/Program.cs
T

215 lines
14 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text.Json;
using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
return args.Length == 3 && (args[2] == "--semantic-retention-control" || args[2] == "--semantic-terminal-dispose-control")
? await Canonical.SemanticRetentionAsync(args) : await Canonical.RunAsync(args);
internal static partial class Canonical
{
private const string Scalar = BaselineRig.RawDeviceFolder + "/SoakScalar";
private const string ArrayTag = BaselineRig.RawDeviceFolder + "/SoakArray";
private const string UnsScalar = "SoakEquipment/Scalar";
private static readonly DateTime Epoch = new(2026,10,1,0,0,0,DateTimeKind.Utc);
internal static readonly OperationLedger Ledger = new();
private static StreamWriter CurrentEvidence = null!;
internal static async Task<int> RunAsync(string[] args)
{
var root=Path.GetFullPath(args[0]); var cycles=int.Parse(args[1]);
var isolatedRestart = args.Length == 3 && args[2] == "--active-restart-control";
if (args.Length > 3 || (args.Length == 3 && !isolatedRestart)) throw new InvalidOperationException("Unknown short-control mode");
if(cycles<1 || cycles>4) throw new InvalidOperationException("Only a 1..4-cycle short diagnostic smoke is authorized");
if(Directory.Exists(root)) throw new InvalidOperationException("Use a fresh private artifact directory");
Directory.CreateDirectory(root);
if(!OperatingSystem.IsWindows()) File.SetUnixFileMode(root,UnixFileMode.UserRead|UnixFileMode.UserWrite|UnixFileMode.UserExecute);
await using var output=new StreamWriter(new FileStream(Path.Combine(root,"resources.jsonl"),FileMode.CreateNew,FileAccess.Write,FileShare.Read));
CurrentEvidence=output;
using var deadline=new CancellationTokenSource(TimeSpan.FromSeconds(120)); var ct=deadline.Token;
var sdk=typeof(NodeId).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if(sdk is null || !sdk.StartsWith("1.5.378.182+",StringComparison.Ordinal)) throw new InvalidOperationException("The immutable .182 SDK is required");
var clientRoot=Path.Combine(root,"private-fixture","client-pki");
var telemetry=DefaultTelemetry.Create(_=>{});
var config=new ApplicationConfiguration(telemetry)
{
ApplicationName="SDK2LifecycleSoak",ApplicationUri=$"urn:SDK2LifecycleSoak:{Guid.NewGuid():N}",ApplicationType=ApplicationType.Client,
SecurityConfiguration=new SecurityConfiguration
{
ApplicationCertificate=new CertificateIdentifier{StoreType=CertificateStoreType.Directory,StorePath=Path.Combine(clientRoot,"own"),SubjectName="CN=SDK2LifecycleSoak"},
TrustedIssuerCertificates=new CertificateTrustList{StoreType=CertificateStoreType.Directory,StorePath=Path.Combine(clientRoot,"issuer")},
TrustedPeerCertificates=new CertificateTrustList{StoreType=CertificateStoreType.Directory,StorePath=Path.Combine(clientRoot,"trusted")},
RejectedCertificateStore=new CertificateTrustList{StoreType=CertificateStoreType.Directory,StorePath=Path.Combine(clientRoot,"rejected")},AutoAcceptUntrustedCertificates=true
},ClientConfiguration=new ClientConfiguration{DefaultSessionTimeout=15000},TransportQuotas=new TransportQuotas{OperationTimeout=5000}
};
var application=new ApplicationInstance(telemetry){ApplicationConfiguration=config};
await config.ValidateAsync(ApplicationType.Client,ct);await application.CheckApplicationInstanceCertificatesAsync(true,null,ct);
await using var rig=new BaselineRig(Path.Combine(root,"private-fixture"));
await rig.BootAsync(ct);Configure(rig);
var watch=Stopwatch.StartNew();var completed=0;var restarts=0;
await LogAsync(output,new {kind="start",sdk,productRebuilt=false,sourcePublication="83ed880e",requestedCycles=cycles,
processId=Environment.ProcessId,acceptance=false,scope="canonical operations short smoke; ownership differences remain explicit",
unsupportedObservables=new[]{"SDK2 CertificateManager disposed flag","SDK2 manager-owned cache-clear proof","ApplicationInstance.DisposeAsync"}});
try
{
if (isolatedRestart)
{
if (cycles != 1) throw new InvalidOperationException("The isolated restart control performs exactly one restart");
await RestartWithActiveResourcesAsync(rig,config,ct);Configure(rig);restarts++;
await LogAsync(output,new {kind="isolated-active-restart-completed",normalCycles=0,restarts,
acceptance=false,ownershipParity=false,ledger=Ledger.Snapshot()});
return 2;
}
while(completed<cycles)
{
await ExerciseAsync(rig,config,completed+1,output,ct);completed++;
await EventuallyAsync(()=>PublicCounts(rig)==(0,0,0),ct);
var counts=PublicCounts(rig);var fd=await BaselineMetrics.DescriptorsAsync(ct,clientRoot,rig.PkiRoot);
fd.Pki.ShouldBe(0);
foreach(var name in new[]{"LiveItemCount","LiveEventItemCount","MonitoredNodeCount"}) BaselineMetrics.Counter(rig.Nm,name).ShouldBe(0);
using var process=Process.GetCurrentProcess();process.Refresh();
await LogAsync(output,new {kind="quiescent",cycle=completed,seconds=watch.Elapsed.TotalSeconds,
sessions=counts.Sessions,subscriptions=counts.Subscriptions,items=counts.Items,
pkiDescriptors=fd.Pki,descriptors=fd.Total,managedBytes=GC.GetTotalMemory(true),rssBytes=process.WorkingSet64,
serverCachedStoreReferences=BaselineMetrics.CachedStoreReferences(rig.Validator),
clientCachedStoreReferences=BaselineMetrics.CachedStoreReferences(config.CertificateValidator),jit=System.Runtime.JitInfo.GetCompiledMethodCount(),timers=Timer.ActiveCount});
if(completed%4==0){await RestartWithActiveResourcesAsync(rig,config,ct);Configure(rig);restarts++;}
}
await LogAsync(output,new {kind="operations-completed",cycles=completed,restarts,seconds=watch.Elapsed.TotalSeconds,
acceptance=false,ownershipParity=false,reason="Original SDK has no equivalent manager-disposal observable; review required",ledger=Ledger.Snapshot()});
return 2;
}
catch(Exception error)
{
await LogAsync(output,new {kind="failed",cycles=completed,restarts,exceptionType=error.GetType().FullName,
uaStatus=error is ServiceResultException ua ? ua.StatusCode.ToString() : null,ledger=Ledger.Snapshot(),acceptance=false});
Console.Error.WriteLine(error);return 1;
}
}
private static int ScalarValue(Variant value)=>value.Value is int result?result:throw new InvalidOperationException("Expected scalar Int32 CLR shape");
private static void RequirePoint(byte[]? point)=>(point is {Length:>0}).ShouldBeTrue();
private static (int Browse,int History) ContinuationCounts(ISession session)=>BaselineMetrics.Points(session);
private static (int Sessions,int Subscriptions,int Items) PublicCounts(BaselineRig rig)
{var subscriptions=rig.Server.CurrentInstance.SubscriptionManager.GetSubscriptions();return(rig.Server.CurrentInstance.SessionManager.GetSessions().Count,subscriptions.Count,subscriptions.Sum(s=>s.MonitoredItemCount));}
private static async Task EventuallyAsync(Func<bool> condition,CancellationToken ct)
{var elapsed=Stopwatch.StartNew();while(!condition()&&elapsed.Elapsed<TimeSpan.FromSeconds(10))await Task.Delay(25,ct);condition().ShouldBeTrue();}
private static async Task LogAsync(StreamWriter output,object value){await output.WriteLineAsync(JsonSerializer.Serialize(value));await output.FlushAsync();}
private sealed class AcceptWrites:IOpcUaNodeWriteGateway
{public Task<NodeWriteOutcome> WriteAsync(string id,object? value,AddressSpaceRealm realm,CancellationToken ct)=>Task.FromResult(new NodeWriteOutcome(true,null));}
internal sealed class SqlLikeEventSource(List<HistoricalEvent> events) : IHistorianDataSource
{
private int _eventReads;
internal int EventReads => Volatile.Read(ref _eventReads);
private readonly List<HistoricalEvent> _all =
events.OrderBy(e => e.EventTimeUtc).ThenBy(e => e.EventId, StringComparer.Ordinal).ToList();
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct)
{
Interlocked.Increment(ref _eventReads);
await Task.Delay(1, ct).ConfigureAwait(false); // complete on a pool thread, like a real backend
var window = _all.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList();
var cap = maxEvents <= 0 ? 1000 : maxEvents;
return new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null);
}
public bool SupportsReverseEvents => true;
/// <summary>The #675 overload: reverse is <c>(lo, hi]</c> by (time, id) descending.</summary>
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, CancellationToken ct)
{
if (!reverse) return await ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct).ConfigureAwait(false);
await Task.Delay(1, ct).ConfigureAwait(false);
var (lo, hi) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc);
var window = _all.Where(e => e.EventTimeUtc > lo && e.EventTimeUtc <= hi).Reverse().ToList();
var cap = maxEvents <= 0 ? 1000 : maxEvents;
return new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null);
}
public Task<CoreHistoryReadResult> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
public Task<CoreHistoryReadResult> ReadProcessedAsync(
string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
public Task<CoreHistoryReadResult> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
/// <summary>Task-owned TCP relay. Pause blocks forwarding in both directions without closing either socket.</summary>
private sealed class PauseProxy : IAsyncDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _stop = new();
private readonly ConcurrentBag<TcpClient> _sockets = [];
private readonly ConcurrentBag<Task> _pumps = [];
private readonly Task _accept;
private TaskCompletionSource? _pause;
internal int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
internal PauseProxy(int targetPort)
{
_listener.Start();
_accept = AcceptAsync(targetPort);
}
internal void Pause() => Volatile.Write(ref _pause, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
internal void Resume() => Interlocked.Exchange(ref _pause, null)?.TrySetResult();
private async Task AcceptAsync(int port)
{
try
{
while (!_stop.IsCancellationRequested)
{
var incoming = await _listener.AcceptTcpClientAsync(_stop.Token);
_sockets.Add(incoming);
var outgoing = new TcpClient(); _sockets.Add(outgoing);
await outgoing.ConnectAsync(IPAddress.Loopback, port, _stop.Token);
_pumps.Add(PumpAsync(incoming.GetStream(), outgoing.GetStream()));
_pumps.Add(PumpAsync(outgoing.GetStream(), incoming.GetStream()));
}
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
}
private async Task PumpAsync(NetworkStream source, NetworkStream destination)
{
var buffer = new byte[16 * 1024];
try
{
while (true)
{
var length = await source.ReadAsync(buffer, _stop.Token);
if (length == 0) return;
if (Volatile.Read(ref _pause) is { } paused) await paused.Task.WaitAsync(_stop.Token);
await destination.WriteAsync(buffer.AsMemory(0, length), _stop.Token);
}
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
catch (IOException) when (_stop.IsCancellationRequested) { }
}
public async ValueTask DisposeAsync()
{
Resume(); _stop.Cancel(); _listener.Stop();
foreach (var socket in _sockets) socket.Dispose();
try { await _accept; await Task.WhenAll(_pumps); }
finally { _stop.Dispose(); }
}
}
}
internal sealed class OperationLedger
{
private readonly ConcurrentDictionary<string,long> _counts=new();
internal void Record(string name)=>_counts.AddOrUpdate(name,1,(_,count)=>count+1);
internal async Task<T> Call<T>(string name,Func<Task<T>> operation)
{Record(name+".attempted");try{var value=await operation();Record(name+".returned");return value;}catch{Record(name+".faulted");throw;}}
internal async Task Call(string name,Func<Task> operation)
{Record(name+".attempted");try{await operation();Record(name+".returned");}catch{Record(name+".faulted");throw;}}
internal IReadOnlyDictionary<string,long> Snapshot()=>new SortedDictionary<string,long>(_counts);
}