249 lines
14 KiB
C#
249 lines
14 KiB
C#
using System.Collections;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Reflection;
|
|
using System.Text.Json;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
public sealed partial class Sdk2LifecycleSoakTests
|
|
{
|
|
public static bool CloseControlEnabled =>
|
|
Environment.GetEnvironmentVariable("OTOPCUA_SDK2_CLOSE_CONTROL") == "1";
|
|
|
|
[Fact(SkipUnless = nameof(CloseControlEnabled), Skip = "Opt in to the bounded SDK2 close/weak-root control.")]
|
|
public async Task Closed_tokens_are_rejected_and_ownership_is_observed_without_harness_roots()
|
|
{
|
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
|
deadline.CancelAfter(TimeSpan.FromSeconds(120));
|
|
var ct = deadline.Token;
|
|
// Positive and negative controls for the observer itself, independent of SDK objects.
|
|
var alive = new object();
|
|
var aliveReference = new WeakReference(alive);
|
|
var collectible = CollectibleReference();
|
|
var observer = await ObserveWeakAsync([new("retained-control", aliveReference), new("collectible-control", collectible)], ct);
|
|
observer.Last().Alive.ShouldBe([true, false]);
|
|
GC.KeepAlive(alive);
|
|
WriteCloseControl("observer-controls", observer);
|
|
|
|
// The helper returns no rig, application, config, Session, point or store objects.
|
|
var stoppedTargets = await Task.Run(() => RunCloseFixture(ct), ct).WaitAsync(TimeSpan.FromSeconds(120), ct);
|
|
var stopped = await ObserveWeakAsync(stoppedTargets, ct);
|
|
WriteCloseControl("after-normal-host-stop", stopped);
|
|
stopped.Last().Alive.ShouldAllBe(value => !value);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static WeakReference CollectibleReference() => new(new object());
|
|
|
|
private sealed record WeakTarget(string Kind, WeakReference Reference);
|
|
private sealed record WeakObservation(int Collection, string[] Kinds, bool[] Alive);
|
|
|
|
private static async Task<WeakObservation[]> ObserveWeakAsync(WeakTarget[] targets, CancellationToken ct)
|
|
{
|
|
var observations = new List<WeakObservation>();
|
|
// Fixed count/delay; no extension or retrieving/resurrecting targets during collection.
|
|
var delays = new[] { 50, 100, 250, 500, 1000 };
|
|
for (var i = 0; i < delays.Length; i++)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
await Task.Run(() =>
|
|
{
|
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
|
|
GC.WaitForPendingFinalizers();
|
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
|
|
}, ct).WaitAsync(TimeSpan.FromSeconds(5), ct);
|
|
await Task.Delay(delays[i], ct);
|
|
observations.Add(new(i + 1, targets.Select(t => t.Kind).ToArray(), targets.Select(t => t.Reference.IsAlive).ToArray()));
|
|
}
|
|
return observations.ToArray();
|
|
}
|
|
|
|
private static void WriteCloseControl(string phase, object observation) =>
|
|
TestContext.Current.TestOutputHelper!.WriteLine(JsonSerializer.Serialize(new { phase, observation, acceptance = false }));
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static WeakTarget[] RunCloseFixture(CancellationToken ct) => ExerciseCloseOwnershipAsync(ct).GetAwaiter().GetResult();
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static WeakTarget[] RunClosedSession(ServerEventRig rig, ApplicationConfiguration config, CancellationToken ct) =>
|
|
CloseAndDenyOldTokensAsync(rig, config, ct).GetAwaiter().GetResult();
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static async Task<WeakTarget[]> ExerciseCloseOwnershipAsync(CancellationToken ct)
|
|
{
|
|
var clientRoot = Path.Combine(Path.GetTempPath(), $"otua-close-client-{Guid.NewGuid():N}");
|
|
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
var security = TestClientSecurity.Build(clientRoot);
|
|
security.ApplicationCertificate!.SubjectName = "CN=SDK2LifecycleSoak";
|
|
var config = new ApplicationConfiguration(telemetry)
|
|
{
|
|
ApplicationName = "SDK2LifecycleSoak", ApplicationUri = $"urn:SDK2LifecycleSoak:{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 15_000 },
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 5000 },
|
|
};
|
|
ApplicationInstance? application = new(telemetry) { ApplicationConfiguration = config };
|
|
ServerEventRig? rig = null;
|
|
Exception? originalFailure = null;
|
|
try
|
|
{
|
|
await config.ValidateAsync(ApplicationType.Client, ct);
|
|
await application.CheckApplicationInstanceCertificatesAsync(true, null, ct);
|
|
rig = await ServerEventRig.StartAsync("SDK2CloseOwnership", encrypted: true, ct);
|
|
Configure(rig);
|
|
var closedTargets = RunClosedSession(rig, config, ct);
|
|
var closed = await ObserveWeakAsync(closedTargets, ct);
|
|
WriteCloseControl("closed-objects-same-host-live", closed);
|
|
closed.Last().Alive.ShouldAllBe(value => !value);
|
|
GC.KeepAlive(rig);
|
|
|
|
var clientManager = config.CertificateManager;
|
|
var serverManager = rig.Server.CertificateManager!;
|
|
var serverApplication = RequiredField<OpcUaApplicationHost>(rig, "_host").ApplicationInstance!;
|
|
var cores = CertificateCores(clientManager).Concat(CertificateCores(serverManager)).ToArray();
|
|
var stoppedTargets = new List<WeakTarget>
|
|
{
|
|
new("client-application", new(application)), new("client-config", new(config)),
|
|
new("client-manager", new(clientManager)), new("server-manager", new(serverManager)),
|
|
new("server-application", new(serverApplication)),
|
|
new("server-rig", new(rig)), new("server", new(rig.Server)),
|
|
new("node-manager", new(rig.Nm)), new("event-source", new(rig.Nm.HistorianDataSource!)),
|
|
};
|
|
foreach (var core in cores)
|
|
{
|
|
stoppedTargets.Add(new("certificate-validation-core", new(core)));
|
|
foreach (var store in RequiredField<IDictionary>(core, "m_stores").Values)
|
|
if (store is not null) stoppedTargets.Add(new("cached-store", new(store)));
|
|
}
|
|
await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct);
|
|
await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct);
|
|
application = null;
|
|
RequiredField<bool>(clientManager, "m_disposed").ShouldBeTrue();
|
|
RequiredField<bool>(serverManager, "m_disposed").ShouldBeTrue();
|
|
cores.Sum(CachedStores).ShouldBe(0);
|
|
(await DescriptorCountsAsync(ct, clientRoot, RequiredField<string>(rig, "_pkiRoot"))).Pki.ShouldBe(0);
|
|
rig = null;
|
|
WriteCloseControl("sdk2-disposal-proofs", new { managersDisposed = 2, cachedStores = 0, pkiDescriptors = 0 });
|
|
return stoppedTargets.ToArray();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
originalFailure = error;
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
var cleanupFailures = new List<Exception>();
|
|
try
|
|
{
|
|
try { if (rig is not null) await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20)); }
|
|
catch (Exception error) { cleanupFailures.Add(error); }
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
try { if (application is not null) await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20)); }
|
|
catch (Exception error) { cleanupFailures.Add(error); }
|
|
}
|
|
finally
|
|
{
|
|
try { if (Directory.Exists(clientRoot)) Directory.Delete(clientRoot, true); }
|
|
catch (Exception error) { cleanupFailures.Add(error); }
|
|
}
|
|
}
|
|
if (cleanupFailures.Count > 0)
|
|
{
|
|
if (originalFailure is not null) cleanupFailures.Insert(0, originalFailure);
|
|
throw new AggregateException("Close-control cleanup failed; original failure retained when present.", cleanupFailures);
|
|
}
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static async Task<WeakTarget[]> CloseAndDenyOldTokensAsync(ServerEventRig rig,
|
|
ApplicationConfiguration config, CancellationToken ct)
|
|
{
|
|
using var old = await ConnectAsync(rig, config, null, ct);
|
|
var authenticationProperty = typeof(ClientBase).GetProperty("AuthenticationToken", BindingFlags.Instance | BindingFlags.NonPublic)
|
|
?? throw new InvalidOperationException("Exact SDK2 authentication diagnostic is unavailable.");
|
|
authenticationProperty.PropertyType.ShouldBe(typeof(NodeId));
|
|
var oldAuthentication = authenticationProperty.GetValue(old).ShouldBeOfType<NodeId>();
|
|
oldAuthentication.IsNull.ShouldBeFalse();
|
|
var browse = await BrowseAsync(old, ct);
|
|
ByteString history = default;
|
|
for (var i = 0; i < 6; i++)
|
|
{
|
|
var point = (await PageAsync(old, default, false, ct)).ContinuationPoint;
|
|
point.IsEmpty.ShouldBeFalse();
|
|
if (i == 0) history = point;
|
|
}
|
|
browse.IsEmpty.ShouldBeFalse(); history.IsEmpty.ShouldBeFalse();
|
|
var serverSession = rig.Server.CurrentInstance.SessionManager.GetSessions().Single();
|
|
var points = serverSession.ContinuationPoints;
|
|
ContinuationCounts(serverSession).ShouldBe((1, 6));
|
|
var weak = new List<WeakTarget>
|
|
{
|
|
new("closed-client-session", new(old)), new("closed-server-session", new(serverSession)),
|
|
new("closed-point-owner", new(points)),
|
|
};
|
|
using (RequiredField<System.Threading.Lock>(points, "m_lock").EnterScope())
|
|
foreach (var field in new[] { "m_browse", "m_history" })
|
|
foreach (var point in RequiredField<IEnumerable>(points, field))
|
|
{
|
|
weak.Add(new(field, new(point)));
|
|
if (point is Opc.Ua.Server.ContinuationPoint browsePoint && browsePoint.Data is { } data)
|
|
weak.Add(new("browse-data", new(data)));
|
|
if (field == "m_history")
|
|
{
|
|
var value = point.GetType().GetField("Value")!.GetValue(point)!;
|
|
weak.Add(new("history-value", new(value)));
|
|
weak.Add(new("history-state", new(value.GetType().GetProperty("State")!.GetValue(value)!)));
|
|
}
|
|
}
|
|
await old.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct);
|
|
rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldBeEmpty();
|
|
ContinuationCounts(serverSession).ShouldBe((0, 0));
|
|
|
|
using var fresh = await ConnectAsync(rig, config, null, ct);
|
|
await OwnPointsRoundTripAsync(fresh, ct);
|
|
(await fresh.ReadValueAsync(Raw(fresh, Scalar), ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
var requestHeader = new RequestHeader { AuthenticationToken = oldAuthentication };
|
|
var failure = await Should.ThrowAsync<ServiceResultException>(async () =>
|
|
await fresh.ReadAsync(requestHeader, 0, TimestampsToReturn.Neither,
|
|
[new ReadValueId { NodeId = Raw(fresh, Scalar), AttributeId = Attributes.Value }], ct));
|
|
failure.StatusCode.ShouldBe(StatusCodes.BadSessionIdInvalid);
|
|
requestHeader.AuthenticationToken.ShouldBe(oldAuthentication);
|
|
(await fresh.BrowseNextAsync(null, false, [browse], ct)).Results[0].StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
var source = rig.Nm.HistorianDataSource.ShouldBeOfType<EventHistoryPagingOverTheWireTests.SqlLikeEventSource>();
|
|
var readsBefore = source.ReadCount;
|
|
(await PageAsync(fresh, history, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
source.ReadCount.ShouldBe(readsBefore, "invalid old tokens must not reach the backend");
|
|
(await fresh.ReadValueAsync(Raw(fresh, Scalar), ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
await OwnPointsRoundTripAsync(fresh, ct);
|
|
await fresh.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct);
|
|
rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldBeEmpty();
|
|
WriteCloseControl("wire-contract", new { oldAuthStatus = failure.StatusCode.Code, oldBrowseRejected = true, oldHistoryRejected = true,
|
|
abandonedBrowse = 1, abandonedHistory = 6, freshPositiveRounds = 2 });
|
|
return weak.ToArray();
|
|
}
|
|
|
|
private static async Task OwnPointsRoundTripAsync(Opc.Ua.Client.ISession session, CancellationToken ct)
|
|
{
|
|
var browse = await BrowseAsync(session, ct);
|
|
browse.IsEmpty.ShouldBeFalse();
|
|
(await session.BrowseNextAsync(null, false, [browse], ct)).Results[0].StatusCode.ShouldBe(StatusCodes.Good);
|
|
var history = (await PageAsync(session, default, false, ct)).ContinuationPoint;
|
|
history.IsEmpty.ShouldBeFalse();
|
|
var resumed = await PageAsync(session, history, false, ct);
|
|
resumed.StatusCode.ShouldBe(StatusCodes.Good);
|
|
resumed.ContinuationPoint.IsEmpty.ShouldBeFalse();
|
|
(await PageAsync(session, resumed.ContinuationPoint, true, ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
}
|
|
}
|