Files

10 KiB

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.