test(opcua): distinguish baseline close semantics and terminal ownership

This commit is contained in:
Joseph Doherty
2026-11-10 16:50:00 -05:00
parent 2cf7c1d248
commit 3e136d7367
8 changed files with 2668 additions and 17 deletions
@@ -18,6 +18,7 @@ internal sealed class BaselineRig : IAsyncDisposable
?? throw new InvalidOperationException("An ephemeral fixture credential is required");
private readonly OpcUaApplicationHostOptions _options;
private OpcUaApplicationHost _host = null!;
private bool _disposed;
internal OtOpcUaSdkServer Server { get; private set; } = null!;
internal OtOpcUaNodeManager Nm => Server.NodeManager!;
internal SdkAddressSpaceSink Sink { get; private set; } = null!;
@@ -88,9 +89,13 @@ internal sealed class BaselineRig : IAsyncDisposable
}
public async ValueTask DisposeAsync()
{
await _host.DisposeAsync();
Canonical.Ledger.Record("host-stop");
if (Directory.Exists(PkiRoot)) Directory.Delete(PkiRoot, true);
if (_disposed) return;
_disposed = true;
try
{
if (_host is not null) { await _host.DisposeAsync(); Canonical.Ledger.Record("host-stop"); }
}
finally { if (Directory.Exists(PkiRoot)) Directory.Delete(PkiRoot, true); }
}
private sealed class Auth : IOpcUaUserAuthenticator
{
@@ -0,0 +1,167 @@
# Original close semantics and bounded retention controls
These are separately reviewed short controls against the unchanged 83ed880e
publication, SDK 1.5.378.182. They do not replace the historical `(1 browse,
6 history)` held-list port comparison, change an oracle, approve a memory
baseline, or claim an unbounded leak. The operation deadline is 120 seconds;
normal shutdown/private-directory cleanup has independent 20/20/5/5-second
bounds, and the semantic runner has a final 180-second process bound.
## What was tested
A fresh fixture creates the original production host, encrypted opc.tcp client,
one abandoned browse point and six event-history points. After normal client
CloseAsync, the control verifies removal from the same running SessionManager.
The old closed object still exposes `(1,6)` while explicitly held, as previously
recorded; that observation is not treated as live protocol ownership.
A fresh valid session supplies the same open raw transport for all following
requests. `ITransportChannel.SendRequestAsync` receives explicit request headers;
no closed Session client guard or ClientBase header substitution can satisfy the
negative checks. Each response must carry the exact sent RequestHandle.
| Request | Verified outcome |
|---|---|
| Fresh raw read before old-token request | Good service/item status |
| Read with old authentication token | Correlated ServiceFault, BadSessionIdInvalid (2149908480) |
| Fresh raw read after the fault, same transport | Good service/item status |
| Fresh browse point creation | Good, nonempty point |
| Old browse point from the fresh session | BadContinuationPointInvalid (2152333312) |
| Fresh own browse point resume and release | Good; resume returns references and another point |
| Fresh history page | Good; nonempty point; backend dispatched |
| Old history point from the fresh session | BadContinuationPointInvalid; backend counter remains 7 to 7 |
| Fresh own history point resume and release | Good; resume dispatches backend and returns another point |
All 11 raw responses were correlated. Generic transport failures/timeouts are not
accepted as token denial. The exact .182 ClientBase independently preserves a
nonnull AuthenticationToken in UpdateRequestHeader; the raw path bypasses that
method entirely. The protected token getter is read through an exact-type-checked
diagnostic accessor before close; tokens and payloads are never logged.
## Two distinct weak-reference boundaries
A NoInlining synchronous helper completes the async closed-session scope from a
thread-pool context, returning only weak references and primitive/type metadata.
No Session, point, response, token array or backend is returned as a strong
reference. The first GC phase runs while the SAME original host/configuration
remain alive. Their weak targets are explicitly expected alive. A second
NoInlining fixture boundary completes normal host shutdown, private-directory
cleanup and the complete async fixture scope before the outer GC phase.
Each phase records round 0 followed by exactly five forced blocking/compacting
collections with fixed 50/100/250/500/1000 ms delays. Each GC/finalizer worker is
bounded at five seconds. Observers use WeakReference.IsAlive rather than keeping
TryGetTarget values across collections. A deliberately retained positive object
must stay alive and an isolated collectible negative must disappear. No delay is
extended to obtain collection.
In the ordinary run all **16 old client/server Session, browse point/Data,
history-slot wrapper and history-value targets** disappeared during the first
live-host forced collection. This separates the held-list implementation detail
from those objects' actual reclamation in this fixture. After normal stop and
all fixture boundaries, five server-graph targets remained across all five
observations: server, node manager, backend, server validator and server cached
store. Rig/client configuration/client application/client validator/client cached
store targets disappeared. Positive and negative controls behaved correctly.
PKI descriptors were zero in live-host and stopped-but-harness-held observations.
The ordinary run preserves **exit 1**, with unsupported SDK2 manager-disposal
observables still explicit.
For this single fixture, live-host final managed bytes were 94,550,728 and timers
10; after-stop final managed bytes were 7,774,200 and timers 7. This drop does not
establish that the earlier multi-restart managed/timer growth disappears, nor is
it a usable performance denominator. Full process/framework parity remains open.
## Exact point-resource ownership
Runtime inspection identifies browse `ContinuationPoint.Data` as **Opc.Ua.NodeBrowser**
from the original Opc.Ua.Types binary. Its exact Dispose(bool) implementation is
empty. Its declared storage is managed IReference/List/HashSet/index state plus
managed context/view properties, with no owned IDisposable field. The containing
ContinuationPoint.Dispose delegates to Data, so omission of that call on this
exact browser does not skip a direct cleanup action. This is not a statement
about every INodeBrowser implementation or arbitrary driver browser resources.
The six history values are the original event HistoryContinuationState records;
the original store saves each record directly in the SDK slot. They have scalar,
string/date and managed paging/set state, no IDisposable contract or backend/store
handle ownership. This event fixture has no pending raw end-bound resource. Both
the SDK slot wrapper and its Value are weak-tracked; all collected while the
original host remained running.
## Stop is distinct from terminal Dispose
Read-only inspection of the pinned original product/SDK establishes:
1. Archived OtOpcUa OpcUaApplicationHost.DisposeAsync awaits only
ApplicationInstance.StopAsync.
2. Original ApplicationInstance.StopAsync delegates to Server.StopAsync.
3. ServerBase.StopAsync stops listeners/service hosts and invokes the standard
stopping hook. StandardServer's stopping hook DOES shut down subscriptions,
sessions and node managers and dispose internal server data.
4. Terminal ServerBase.Dispose separately disposes its RequestQueue.
RequestQueue owns a ServerBase reference and starts minimum worker tasks;
those workers await SemaphoreSlim with a 15,000 ms timeout and cancellation
token. At the minimum worker count they continue waiting. Queue.Dispose
cancels that token, releases/disposes its signal and terminates its ownership.
5. StandardServer.Dispose also unregisters its CertificateUpdate callback and
disposes remaining terminal resources. The SDK2 ApplicationInstance.DisposeAsync
explicitly performs StopAsync THEN Server.Dispose; the archived original host
never calls that terminal API.
This is a source-backed original application lifecycle gap and a plausible
retention path consistent with the observed server graph. It is not a measured
heap dominator/root chain or attribution of any particular timer. No original
production code was patched, and no extra disposal was added to ordinary mode.
A separate, gated `semantic-terminal-dispose` attribution mode first records the
ordinary result. Only afterward does a NoInlining helper briefly acquire the
known weak original server target, invoke its PUBLIC terminal Dispose once,
drop that strong reference, then repeat the same fixed GC schedule. No private
timer disposal, cache clearing, oracle adjustment or delay extension is used.
The original ordinary exit is returned even if terminal disposal clears all
observed targets. Its results are recorded separately in `semantic-evidence.json`.
It cannot turn the archived original behavior or failed RSS gates into a pass.
That separately approved run reproduced the same normal-stop five survivors and
returned **exit 1**. After the single public Dispose, all five disappeared at the
first next forced collection and remained absent through round 5. At the final
observations, timers changed from 7 to 2 and managed bytes from 7,692,144 to
4,513,760. Positive/negative GC controls remained valid. This controlled intervention
supports missing terminal server disposal as the ownership distinction; it does
not identify which queue task, timer or event subscription was the actual GC root.
It does not establish an unbounded leak from one fixture, eliminate unrelated
native retention, or make the original process a performance denominator.
Both immutable adapter builds completed with zero warnings/errors, using the
same 208 reference hashes. The ordinary run uses build 3 and the separate
counterfactual uses build 4; their exact source, binary, build-log and raw-result
hashes are retained in `semantic-evidence.json`. Raw evidence remains in private
external directories. No additional control was run to adjust these findings.
The exact original test seams are public virtual
`ServerBase.StopAsync(CancellationToken)`, protected virtual `Dispose(bool)`,
`OnServerStarting(ApplicationConfiguration)`,
`StartApplicationAsync(ApplicationConfiguration, CancellationToken)`, and
`OnServerStoppingAsync(CancellationToken)`. Public `Dispose()` and `StartAsync`
are nonvirtual. StandardServer overrides those protected lifecycle hooks and
also exposes protected virtual `OnServerStarted(IServerInternal)`. A separate
reviewed application repair can test stop failure/partial startup using these
supported seams; this diagnostic does not repair or rebuild the archived product.
## Smallest future matched-host path (proposal only)
A standalone SDK2 driver mirroring this original standalone driver is the smallest
path: same .NET runtime, assertions, telemetry/logging configuration, PKI/security,
operation recipe/counts, raw request/correlation probes, NoInlining boundaries,
weak controls, sampling schedule and module provenance. Keep SDK-specific value
representations and read-only ownership observers explicit. Both xUnit hosts
would require creating/porting a larger original test-host graph and introduce
more loader/framework differences. Neither path has been implemented or approved
as an acceptance protocol. The current candidate xUnit control is semantic
corroboration, not process-memory comparability.
Any correction of archived app ownership must be a separate reviewed change and
new provenance; these controls do not substitute a repaired baseline or use its
post-counterfactual memory as a denominator. Long original runs, reset baselines,
RSS caps and acceptance remain outside this work.
@@ -11,7 +11,8 @@ using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using CoreHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
return await Canonical.RunAsync(args);
return args.Length == 3 && (args[2] == "--semantic-retention-control" || args[2] == "--semantic-terminal-dispose-control")
? await Canonical.SemanticRetentionAsync(args) : await Canonical.RunAsync(args);
internal static partial class Canonical
{
private const string Scalar = BaselineRig.RawDeviceFolder + "/SoakScalar";
@@ -103,12 +104,15 @@ internal static partial class Canonical
{public Task<NodeWriteOutcome> WriteAsync(string id,object? value,AddressSpaceRealm realm,CancellationToken ct)=>Task.FromResult(new NodeWriteOutcome(true,null));}
internal sealed class SqlLikeEventSource(List<HistoricalEvent> events) : IHistorianDataSource
{
private int _eventReads;
internal int EventReads => Volatile.Read(ref _eventReads);
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 _eventReads);
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;
@@ -82,13 +82,14 @@ as the candidate; those observations cannot invent the missing manager ownership
proof. No manual Session.Dispose, store replacement or cache clearing was added
to make an assertion pass.
Before any common lifecycle/acceptance protocol can be proposed, a separately
reviewed short control must establish original SessionManager removal, rejection
of the old authentication token, rejection of old CP tokens from a fresh session,
then drop every strong session/point/test reference at an isolated helper boundary
and perform bounded GC/weak-reference observation. Any retained IDisposable point
resource needs direct source/ownership inspection. Certificate-store/native
ownership gaps must remain explicit. This proposal has **not** been executed.
The historical partial proof above did not test token/continuation rejection or
collection after dropping its diagnostic references. Later, separately reviewed
short controls established those semantics and observed the remaining server
graph retention after normal shutdown. Their direct point-resource inspection,
two weak-reference phases and separate terminal-disposal attribution are recorded
in [OWNERSHIP.md](OWNERSHIP.md) and `semantic-evidence.json`. Both ordinary failure
results remain intact. Certificate-store/native ownership gaps remain explicit;
these findings do not approve a common lifecycle/acceptance protocol.
The standalone adapter also lacks the candidate's xUnit/VSTest host; equal process
memory overhead is not proven. Earlier original-subset managed/timer growth is an
@@ -120,5 +121,10 @@ Add `--inventory` to build the read-only metadata inventory instead. The runner
uses a fresh private directory and a temporary environment credential with the
same prefix/byte length as the canonical fixture; it preserves the original child
exit. Exit 1 is an observed control failure; exit 2 is operations completed with
unsupported ownership parity. Neither is a passing baseline. The smoke driver
has a hard 120-second bound and no long-run option.
unsupported ownership parity. Neither is a passing baseline. Normal/restart modes
have a hard 120-second bound and no long-run option.
The later semantic modes are `--mode semantic-retention` and the separately gated
`--mode semantic-terminal-dispose` attribution control. They use the 120-second
operation deadline plus bounded independent cleanup and a 180-second outer runner
limit. Neither changes normal/active-restart results or authorizes a long run.
@@ -0,0 +1,385 @@
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);
}
@@ -32,7 +32,7 @@ items = ET.SubElement(project,'ItemGroup')
ET.SubElement(items,'FrameworkReference',Include='Microsoft.AspNetCore.App')
for name,path in references.items():
reference=ET.SubElement(items,'Reference',Include=path.stem); ET.SubElement(reference,'HintPath').text=str(path)
files = ['Inventory.cs'] if args.inventory else ['Program.cs','BaselineRig.cs','BaselineMetrics.cs','CanonicalOperations.cs']
files = ['Inventory.cs'] if args.inventory else ['Program.cs','BaselineRig.cs','BaselineMetrics.cs','CanonicalOperations.cs','SemanticRetention.cs']
for name in files: ET.SubElement(items,'Compile',Include=str(source/name))
path=root/'adapter.csproj'; ET.indent(project); ET.ElementTree(project).write(path,encoding='unicode')
manifest={'diagnosticOnly':True,'productRebuilt':False,'references':{name:hashlib.sha256(p.read_bytes()).hexdigest() for name,p in references.items()},
@@ -10,7 +10,7 @@ import subprocess
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--adapter',type=Path,required=True)
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--mode',choices=['normal','active-restart'],required=True)
parser.add_argument('--mode',choices=['normal','active-restart','semantic-retention','semantic-terminal-dispose'],required=True)
args=parser.parse_args()
output=args.output.resolve()
if output.exists():raise RuntimeError('Use a fresh private output directory')
@@ -19,11 +19,14 @@ environment=os.environ.copy()
environment['OTOPCUA_BASELINE_CANONICAL_KEY']='otua_k-server-events_'+secrets.token_hex(3)
command=['dotnet',str(args.adapter.resolve(strict=True)),str(output),'1']
if args.mode=='active-restart':command+=['--active-restart-control']
if args.mode=='semantic-retention':command+=['--semantic-retention-control']
if args.mode=='semantic-terminal-dispose':command+=['--semantic-terminal-dispose-control']
outer_timeout=180 if args.mode.startswith('semantic-') else 120
with os.fdopen(os.open(log,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600),'w') as stream:
try:result=subprocess.run(command,env=environment,stdout=stream,stderr=subprocess.STDOUT,timeout=120);exit_code=result.returncode
try:result=subprocess.run(command,env=environment,stdout=stream,stderr=subprocess.STDOUT,timeout=outer_timeout);exit_code=result.returncode
except subprocess.TimeoutExpired:exit_code=124
if output.exists():
(output/'runner-receipt.json').write_text(json.dumps({'mode':args.mode,'exitCode':exit_code,'acceptance':False,
'productRebuilt':False,'timeoutSeconds':120,'credentialBytes':len(environment['OTOPCUA_BASELINE_CANONICAL_KEY'].encode())},indent=2)+'\n')
'productRebuilt':False,'timeoutSeconds':outer_timeout,'credentialBytes':len(environment['OTOPCUA_BASELINE_CANONICAL_KEY'].encode())},indent=2)+'\n')
print(json.dumps({'mode':args.mode,'exitCode':exit_code,'acceptance':False}))
raise SystemExit(exit_code)