using System.Collections; using System.Diagnostics; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Text.Json; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using Shouldly; using ServerSession = Opc.Ua.Server.ISession; using ClientSession = Opc.Ua.Client.ISession; internal static partial class Canonical { internal static async Task SemanticRetentionAsync(string[] args) { if (args.Length != 3 || args[1] != "1") throw new InvalidOperationException("One bounded semantic control only"); var root = Path.GetFullPath(args[0]); var counterfactual = args[2] == "--semantic-terminal-dispose-control"; if (Directory.Exists(root)) throw new InvalidOperationException("Use a fresh private 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 retainedPositive = new object(); var positive = Witness("positive-retained", retainedPositive); var negative = EphemeralNegative(); try { await LogAsync(output, new { kind = "semantic-process", phase = "before-fixture", process = ProcessCounters() }); // Task.Run has no captured synchronization context. Its closure contains only path/writer/token. var result = await Task.Run(() => FixtureBoundary(root, output, positive, negative, deadline.Token), deadline.Token); var phase2 = await ObserveAsync("after-stop", result.All, positive, negative, output, deadline.Token); var liveOld = result.LiveHostSurvivors; var stopped = phase2.Where(x => x.alive && x.label != "positive-retained").Select(x => x.label).ToArray(); await LogAsync(output, new { kind = "semantic-retention-result", semanticsComplete = true, liveHostOldSurvivors = liveOld, afterStopSurvivors = stopped, acceptance = false, ownershipParity = false, unsupportedObservables = new[] { "SDK2 CertificateManager disposed flag", "SDK2 manager-owned cache-clear proof", "ApplicationInstance.DisposeAsync" } }); var ordinaryExitCode = liveOld.Length == 0 && stopped.Length == 0 ? 2 : 1; if (counterfactual) { // Separate attribution only, AFTER preserving the ordinary stop result above. var server = result.All.Single(x => x.Label == "fixture-server"); var invoked = await Task.Run(() => TerminalDisposeBoundary(server), deadline.Token).WaitAsync(TimeSpan.FromSeconds(20), deadline.Token); await LogAsync(output, new { kind = "counterfactual-terminal-dispose", invoked, ordinaryExitCode, publicMethod = "ServerBase.Dispose", acceptance = false }); var terminal = await ObserveAsync("after-terminal-dispose", result.All, positive, negative, output, deadline.Token); await LogAsync(output, new { kind = "counterfactual-attribution-result", ordinaryExitCode, survivors = terminal.Where(x => x.alive && x.label != "positive-retained").Select(x => x.label).ToArray(), acceptance = false }); } GC.KeepAlive(retainedPositive); // Even collection of every observed target does not invent the unsupported ownership proof. return ordinaryExitCode; } catch (Exception error) { await LogAsync(output, new { kind = "semantic-control-failed", exceptionType = error.GetType().FullName, acceptance = false }); Console.Error.WriteLine(error); GC.KeepAlive(retainedPositive); return 1; } } [MethodImpl(MethodImplOptions.NoInlining)] private static FixtureWitness FixtureBoundary(string root, StreamWriter output, WeakWitness positive, WeakWitness negative, CancellationToken ct) => FixtureScopeAsync(root, output, positive, negative, ct).GetAwaiter().GetResult(); private static async Task FixtureScopeAsync(string root, StreamWriter output, WeakWitness positive, WeakWitness negative, CancellationToken ct) { var sdk = typeof(NodeId).Assembly.GetCustomAttribute()?.InformationalVersion; if (sdk is null || !sdk.StartsWith("1.5.378.182+", StringComparison.Ordinal)) throw new InvalidOperationException("Immutable .182 SDK required"); var clientRoot = Path.Combine(root, "private-fixture", "client-pki"); BaselineRig? rig = null; ApplicationConfiguration? config = null; ApplicationInstance? application = null; ExceptionDispatchInfo? failure = null; FixtureWitness? result = null; var booted = false; try { var telemetry = DefaultTelemetry.Create(_ => { }); 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 } }; application = new ApplicationInstance(telemetry) { ApplicationConfiguration = config }; await config.ValidateAsync(ApplicationType.Client, ct); await application.CheckApplicationInstanceCertificatesAsync(true, null, ct); rig = new BaselineRig(Path.Combine(root, "private-fixture")); await rig.BootAsync(ct); Configure(rig); booted = true; await LogAsync(output, new { kind = "semantic-start", sdk, acceptance = false, productRebuilt = false, maximumSeconds = 120, gcDelaysMilliseconds = new[] { 50, 100, 250, 500, 1000 }, gcRoundTimeoutSeconds = 5 }); var fixture = new List { Witness("fixture-rig", rig), Witness("fixture-server", rig.Server), Witness("fixture-node-manager", rig.Nm), Witness("fixture-backend", rig.Nm.HistorianDataSource), Witness("fixture-client-config", config), Witness("fixture-client-application", application), Witness("fixture-server-validator", rig.Validator), Witness("fixture-client-validator", config.CertificateValidator) }; // This synchronous NoInlining boundary drops the completed async state machine before phase1 GC. var closed = ClosedBoundary(rig, config, output, ct); PublicCounts(rig).ShouldBe((0, 0, 0)); // Validation may populate stores on first connection; inspect after those operations. CaptureStores(rig.Validator, "fixture-server-store", fixture); CaptureStores(config.CertificateValidator, "fixture-client-store", fixture); var all = closed.Concat(fixture).ToArray(); await WriteShapes(all, output); var phase1 = await ObserveAsync("live-host", all, positive, negative, output, ct); var survivors = phase1.Where(x => x.alive && x.label.StartsWith("old-", StringComparison.Ordinal)).Select(x => x.label).ToArray(); foreach (var live in phase1.Where(x => x.label.StartsWith("fixture-", StringComparison.Ordinal))) live.alive.ShouldBeTrue("the host/configuration are intentionally still owned in phase1"); var descriptors = await BaselineMetrics.DescriptorsAsync(ct, clientRoot, rig.PkiRoot); descriptors.Pki.ShouldBe(0); await LogAsync(output, new { kind = "semantic-owned-resources", phase = "live-host", pkiDescriptors = descriptors.Pki, serverCachedStoreReferences = BaselineMetrics.CachedStoreReferences(rig.Validator), clientCachedStoreReferences = BaselineMetrics.CachedStoreReferences(config.CertificateValidator), cachedReferenceIsOpenHandle = false }); result = new FixtureWitness(all, survivors); } catch (Exception error) { failure = ExceptionDispatchInfo.Capture(error); } finally { // These bounds do not reuse the canceled 120-second operation token. async Task Cleanup(string step, Func operation, int seconds) { try { await operation().WaitAsync(TimeSpan.FromSeconds(seconds), CancellationToken.None); } catch (Exception error) { failure ??= ExceptionDispatchInfo.Capture(error); await LogAsync(output, new { kind = "semantic-cleanup-failed", step, exceptionType = error.GetType().FullName }); } } if (rig is not null) await Cleanup("normal-host-stop", () => rig.DisposeAsync().AsTask(), 20); if (booted && rig is not null && config is not null) await Cleanup("post-stop-observations", async () => { using var probe = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var after = await BaselineMetrics.DescriptorsAsync(probe.Token, clientRoot, rig.PkiRoot); after.Pki.ShouldBe(0); await LogAsync(output, new { kind = "semantic-owned-resources", phase = "stopped-but-harness-held", pkiDescriptors = after.Pki, serverCachedStoreReferences = BaselineMetrics.CachedStoreReferences(rig.Validator), clientCachedStoreReferences = BaselineMetrics.CachedStoreReferences(config.CertificateValidator), disposalFlagObservable = false }); }, 20); await Cleanup("client-private-directory", () => Task.Run(() => { if (Directory.Exists(clientRoot)) Directory.Delete(clientRoot, true); }), 5); if (rig is not null) await Cleanup("server-private-directory", () => Task.Run(() => { if (Directory.Exists(rig.PkiRoot)) Directory.Delete(rig.PkiRoot, true); }), 5); GC.KeepAlive(application); GC.KeepAlive(config); GC.KeepAlive(rig); } failure?.Throw(); return result ?? throw new InvalidOperationException("The fixture did not produce diagnostic witnesses"); } [MethodImpl(MethodImplOptions.NoInlining)] private static WeakWitness[] ClosedBoundary(BaselineRig rig, ApplicationConfiguration config, StreamWriter output, CancellationToken ct) => ClosedScopeAsync(rig, config, output, ct).GetAwaiter().GetResult(); private static async Task ClosedScopeAsync(BaselineRig rig, ApplicationConfiguration config, StreamWriter output, CancellationToken ct) { var weak = new List(); using var owner = await ConnectAsync(rig, config, null, ct); weak.Add(Witness("old-client-session", owner)); var oldServerSession = rig.Server.CurrentInstance.SessionManager.GetSessions().Single(x => x.Id == owner.SessionId); weak.Add(Witness("old-server-session", oldServerSession)); var oldAuthenticationToken = Token(owner); var oldBrowse = (await BrowseAsync(owner, ct))!; RequirePoint(oldBrowse); byte[]? oldHistory = null; for (var i = 0; i < 6; i++) { var page = await PageAsync(owner, null, false, ct); RequirePoint(page.ContinuationPoint); oldHistory ??= page.ContinuationPoint; } CapturePoints(oldServerSession, weak); ContinuationCounts(oldServerSession).ShouldBe((1, 6)); await owner.CloseAsync(ct); rig.Server.CurrentInstance.SessionManager.GetSessions().Any(x => ReferenceEquals(x, oldServerSession)).ShouldBeFalse(); rig.Server.CurrentInstance.SessionManager.GetSessions().Count.ShouldBe(0); await LogAsync(output, new { kind = "semantic-closed-state", activeSessions = 0, rawClosedBrowse = ContinuationCounts(oldServerSession).Browse, rawClosedHistory = ContinuationCounts(oldServerSession).History, diagnosticReferenceHeld = true }); using var fresh = await ConnectAsync(rig, config, null, ct); var channel = fresh.TransportChannel; var freshToken = Token(fresh); freshToken.ShouldNotBe(oldAuthenticationToken); uint sequence = 10000; RequestHeader Header(NodeId token) => new() { AuthenticationToken = token, RequestHandle = ++sequence, Timestamp = DateTime.UtcNow, TimeoutHint = 5000 }; ReadRequest Read(NodeId token) => new() { RequestHeader = Header(token), TimestampsToReturn = TimestampsToReturn.Neither, NodesToRead = [new ReadValueId { NodeId = Raw(fresh, Scalar), AttributeId = Attributes.Value }] }; await RequireReadGood(channel, Read(freshToken), "fresh-read-before", output, ct); var oldRequest = Read(oldAuthenticationToken); var denied = await Send(channel, oldRequest, "old-authentication-token", output, ct); var deniedCode = denied.ResponseHeader.ServiceResult; (deniedCode == StatusCodes.BadSessionIdInvalid || deniedCode == StatusCodes.BadSessionClosed).ShouldBeTrue(); await RequireReadGood(channel, Read(freshToken), "fresh-read-after", output, ct); var ownBrowseRequest = new BrowseRequest { RequestHeader = Header(freshToken), RequestedMaxReferencesPerNode = 1, NodesToBrowse = [new BrowseDescription { NodeId = Raw(fresh, BaselineRig.RawDeviceFolder), BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All }] }; var ownBrowse = (BrowseResponse)await Send(channel, ownBrowseRequest, "fresh-own-browse", output, ct); ownBrowse.Results[0].StatusCode.ShouldBe(StatusCodes.Good); RequirePoint(ownBrowse.Results[0].ContinuationPoint); var oldBrowseResponse = (BrowseNextResponse)await Send(channel, new BrowseNextRequest { RequestHeader = Header(freshToken), ContinuationPoints = [oldBrowse], ReleaseContinuationPoints = false }, "old-browse-point", output, ct); oldBrowseResponse.Results[0].StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid); await LogAsync(output, new { kind = "semantic-item-status", name = "old-browse-point", statusCode = (uint)oldBrowseResponse.Results[0].StatusCode }); var ownBrowseNext = (BrowseNextResponse)await Send(channel, new BrowseNextRequest { RequestHeader = Header(freshToken), ContinuationPoints = [ownBrowse.Results[0].ContinuationPoint], ReleaseContinuationPoints = false }, "fresh-own-browse-resume", output, ct); ownBrowseNext.Results[0].StatusCode.ShouldBe(StatusCodes.Good); ownBrowseNext.Results[0].References.Count.ShouldBeGreaterThan(0); RequirePoint(ownBrowseNext.Results[0].ContinuationPoint); var ownBrowseRelease = (BrowseNextResponse)await Send(channel, new BrowseNextRequest { RequestHeader = Header(freshToken), ContinuationPoints = [ownBrowseNext.Results[0].ContinuationPoint], ReleaseContinuationPoints = true }, "fresh-own-browse-release", output, ct); ownBrowseRelease.Results[0].StatusCode.ShouldBe(StatusCodes.Good); HistoryReadRequest History(byte[]? point, bool release) => new() { RequestHeader = Header(freshToken), TimestampsToReturn = TimestampsToReturn.Both, ReleaseContinuationPoints = release, HistoryReadDetails = new ExtensionObject(new ReadEventDetails { StartTime = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), NumValuesPerNode = 4, Filter = new EventFilter { SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(BrowseNames.EventId))] } }), NodesToRead = [new HistoryReadValueId { NodeId = Raw(fresh, BaselineRig.RawDeviceFolder), ContinuationPoint = point }] }; var backend = (SqlLikeEventSource)rig.Nm.HistorianDataSource; var beforeOwn = backend.EventReads; var ownHistory = (HistoryReadResponse)await Send(channel, History(null, false), "fresh-own-history", output, ct); ownHistory.Results[0].StatusCode.ShouldBe(StatusCodes.Good); RequirePoint(ownHistory.Results[0].ContinuationPoint); backend.EventReads.ShouldBeGreaterThan(beforeOwn); var beforeOld = backend.EventReads; var oldHistoryResponse = (HistoryReadResponse)await Send(channel, History(oldHistory, false), "old-history-point", output, ct); oldHistoryResponse.Results[0].StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid); backend.EventReads.ShouldBe(beforeOld); await LogAsync(output, new { kind = "semantic-backend", operation = "old-history-point", before = beforeOld, after = backend.EventReads, statusCode = (uint)oldHistoryResponse.Results[0].StatusCode }); var ownHistoryNext = (HistoryReadResponse)await Send(channel, History(ownHistory.Results[0].ContinuationPoint, false), "fresh-own-history-resume", output, ct); ownHistoryNext.Results[0].StatusCode.ShouldBe(StatusCodes.Good); RequirePoint(ownHistoryNext.Results[0].ContinuationPoint); backend.EventReads.ShouldBeGreaterThan(beforeOld); var releaseHistory = (HistoryReadResponse)await Send(channel, History(ownHistoryNext.Results[0].ContinuationPoint, true), "fresh-own-history-release", output, ct); releaseHistory.Results[0].StatusCode.ShouldBe(StatusCodes.Good); await fresh.CloseAsync(ct); rig.Server.CurrentInstance.SessionManager.GetSessions().Count.ShouldBe(0); return weak.ToArray(); } private static NodeId Token(ClientSession session) { var property = typeof(ClientBase).GetProperty("AuthenticationToken", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Exact .182 token getter is missing"); return property.PropertyType == typeof(NodeId) && property.GetValue(session) is NodeId value && !NodeId.IsNull(value) ? NodeId.Parse(value.ToString()) : throw new InvalidOperationException("Exact .182 token getter changed"); } private static async Task Send(ITransportChannel channel, IServiceRequest request, string name, StreamWriter output, CancellationToken ct) { // No Session closed guard or ClientBase header update runs on this public raw transport path. var response = await channel.SendRequestAsync(request, ct); response.ResponseHeader.RequestHandle.ShouldBe(request.RequestHeader.RequestHandle); await LogAsync(output, new { kind = "semantic-wire", name, requestHandle = request.RequestHeader.RequestHandle, responseHandle = response.ResponseHeader.RequestHandle, responseType = response.GetType().Name, serviceStatusCode = (uint)response.ResponseHeader.ServiceResult }); return response; } private static async Task RequireReadGood(ITransportChannel channel, ReadRequest request, string name, StreamWriter output, CancellationToken ct) { var response = (ReadResponse)await Send(channel, request, name, output, ct); response.ResponseHeader.ServiceResult.ShouldBe(StatusCodes.Good); response.Results[0].StatusCode.ShouldBe(StatusCodes.Good); } private static FieldInfo ExactField(object owner, string name) { for (var type = owner.GetType(); type is not null; type = type.BaseType) if (type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly) is { } field) return field; throw new InvalidOperationException("Missing exact .182 diagnostic field: " + name); } private static void CapturePoints(ServerSession session, List target) { var gate = ExactField(session, "m_lock").GetValue(session) as System.Threading.Lock ?? throw new InvalidOperationException("Unexpected session lock"); using (gate.EnterScope()) { var browse = ExactField(session, "m_browseContinuationPoints").GetValue(session) as IList ?? throw new InvalidOperationException("Missing populated browse list"); foreach (var value in browse) { var point = value as Opc.Ua.Server.ContinuationPoint ?? throw new InvalidOperationException("Unexpected browse point type"); target.Add(Witness("old-browse-point", point)); if (point.Data is { } data) target.Add(Witness("old-browse-data", data)); } var history = ExactField(session, "m_historyContinuationPoints").GetValue(session) as IList ?? throw new InvalidOperationException("Missing populated history list"); var index = 0; foreach (var value in history) { var slot = value ?? throw new InvalidOperationException("Null history slot"); target.Add(Witness("old-history-slot-" + index, slot)); var state = ExactField(slot, "Value").GetValue(slot) ?? throw new InvalidOperationException("Null history state"); target.Add(Witness("old-history-value-" + index++, state)); } } } private static void CaptureStores(CertificateValidator validator, string label, List target) { var gate = ExactField(validator, "m_semaphore").GetValue(validator) as SemaphoreSlim ?? throw new InvalidOperationException("Unexpected validator lock"); if (!gate.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("Validator witness lock timed out"); try { var index = 0; foreach (var name in new[] { "m_trustedCertificateStore", "m_issuerCertificateStore", "m_rejectedCertificateStore" }) if (ExactField(validator, name).GetValue(validator) is CertificateStoreIdentifier identifier && ExactField(identifier, "m_store").GetValue(identifier) is ICertificateStore store) target.Add(Witness(label + "-" + index++, store)); } finally { gate.Release(); } } private static WeakWitness Witness(string label, object target) => new(label, target); [MethodImpl(MethodImplOptions.NoInlining)] private static WeakWitness EphemeralNegative() => Witness("negative-collectible", new object()); [MethodImpl(MethodImplOptions.NoInlining)] private static bool TerminalDisposeBoundary(WeakWitness server) => server.DisposeOriginalServerForAttribution(); private static async Task WriteShapes(IEnumerable witnesses, StreamWriter output) { foreach (var witness in witnesses) await LogAsync(output, witness.Shape); } private static async Task ObserveAsync(string phase, WeakWitness[] witnesses, WeakWitness positive, WeakWitness negative, StreamWriter output, CancellationToken ct) { var delays = new[] { 0, 50, 100, 250, 500, 1000 }; WeakObservation[] last = []; for (var round = 0; round < delays.Length; round++) { if (round > 0) { await Task.Delay(delays[round], ct); await Task.Run(ForcedCollection, ct).WaitAsync(TimeSpan.FromSeconds(5), ct); } last = witnesses.Concat([positive, negative]).Select(x => x.Observe(phase == "live-host" && x.Label.StartsWith("fixture-", StringComparison.Ordinal) || x.Label == "positive-retained")).ToArray(); await LogAsync(output, new { kind = "weak-observation", phase, round, groups = last, process = ProcessCounters() }); positive.IsAlive().ShouldBeTrue("the retained positive must remain observable"); if (round > 0) negative.IsAlive().ShouldBeFalse("the isolated collectible negative proves GC observation is effective"); } return last; } private static void ForcedCollection() { GC.Collect(2, GCCollectionMode.Forced, true, true); GC.WaitForPendingFinalizers(); GC.Collect(2, GCCollectionMode.Forced, true, true); } private static object ProcessCounters() { using var process = Process.GetCurrentProcess(); process.Refresh(); return new { managedBytes = GC.GetTotalMemory(false), rssBytes = process.WorkingSet64, activeTimers = Timer.ActiveCount, gcCommittedBytes = GC.GetGCMemoryInfo().TotalCommittedBytes, jitMethods = System.Runtime.JitInfo.GetCompiledMethodCount() }; } private sealed class WeakWitness { private readonly WeakReference _reference; internal string Label { get; } internal object Shape { get; } private readonly string _type; internal WeakWitness(string label, object target) { Label = label; _type = target.GetType().FullName ?? target.GetType().Name; _reference = new WeakReference(target); var fields = new List(); for (var type = target.GetType(); type is not null; type = type.BaseType) foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) if (typeof(IDisposable).IsAssignableFrom(field.FieldType)) fields.Add(new { name = field.Name, type = field.FieldType.FullName }); Shape = new { kind = "weak-resource-shape", label, type = _type, isDisposable = target is IDisposable, resourceFields = fields }; } [MethodImpl(MethodImplOptions.NoInlining)] internal bool IsAlive() => _reference.IsAlive; [MethodImpl(MethodImplOptions.NoInlining)] internal bool DisposeOriginalServerForAttribution() { var value = _reference.Target; if (value is null) return false; if (Label != "fixture-server" || value is not ZB.MOM.WW.OtOpcUa.OpcUaServer.OtOpcUaSdkServer server) throw new InvalidOperationException("Only the known original server may receive terminal attribution disposal"); server.Dispose(); // Public terminal lifecycle API, once; no private timer/cache manipulation. return true; } internal WeakObservation Observe(bool expectedAlive) => new(Label, _type, IsAlive(), expectedAlive); } private sealed record WeakObservation(string label, string type, bool alive, bool expectedAlive); private sealed record FixtureWitness(WeakWitness[] All, string[] LiveHostSurvivors); }