test(opcua): distinguish baseline close semantics and terminal ownership
This commit is contained in:
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user