Files

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);
}