386 lines
27 KiB
C#
386 lines
27 KiB
C#
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<int> 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<FixtureWitness> FixtureScopeAsync(string root, StreamWriter output, WeakWitness positive, WeakWitness negative, CancellationToken ct)
|
|
{
|
|
var sdk = typeof(NodeId).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.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<WeakWitness>
|
|
{
|
|
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<Task> 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<WeakWitness[]> ClosedScopeAsync(BaselineRig rig, ApplicationConfiguration config, StreamWriter output, CancellationToken ct)
|
|
{
|
|
var weak = new List<WeakWitness>();
|
|
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<IServiceResponse> 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<WeakWitness> 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<WeakWitness> 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<WeakWitness> witnesses, StreamWriter output)
|
|
{ foreach (var witness in witnesses) await LogAsync(output, witness.Shape); }
|
|
private static async Task<WeakObservation[]> 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<object>();
|
|
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);
|
|
}
|