test: add bounded SDK2 close ownership controls

This commit is contained in:
Joseph Doherty
2026-11-10 16:26:26 -05:00
parent ed60970fc2
commit 235196beb2
4 changed files with 314 additions and 1 deletions
@@ -122,12 +122,15 @@ public sealed class EventHistoryPagingOverTheWireTests
/// <summary>(time, id) order, [start, end), cap + truncation flag — the SQL backends' contract.</summary>
internal sealed class SqlLikeEventSource(List<HistoricalEvent> events) : IHistorianDataSource
{
private int _reads;
internal int ReadCount => Volatile.Read(ref _reads);
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 _reads);
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;
@@ -0,0 +1,214 @@
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;
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();
}
finally
{
if (rig is not null) await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct);
if (application is not null) await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct);
if (Directory.Exists(clientRoot)) Directory.Delete(clientRoot, true);
}
}
[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);
var history = (await PageAsync(old, default, false, ct)).ContinuationPoint;
browse.IsEmpty.ShouldBeFalse(); history.IsEmpty.ShouldBeFalse();
var serverSession = rig.Server.CurrentInstance.SessionManager.GetSessions().Single();
var points = serverSession.ContinuationPoints;
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));
// Exact outcome is recorded and narrowed at the reviewed execution gate.
(failure.StatusCode == StatusCodes.BadSessionIdInvalid || failure.StatusCode == StatusCodes.BadSessionClosed).ShouldBeTrue();
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, 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);
}
}
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// No key, certificate, store path, token, or OPC UA payload is written to the JSONL evidence.
/// </summary>
[Trait("Category", "E2E")]
public sealed class Sdk2LifecycleSoakTests
public sealed partial class Sdk2LifecycleSoakTests
{
private const string Scalar = ServerEventRig.RawDeviceFolder + "/SoakScalar";
private const string ArrayTag = ServerEventRig.RawDeviceFolder + "/SoakArray";