test(opcua): package cross-platform lifecycle memory controls
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
bin/
|
||||
obj/
|
||||
private-fixture/
|
||||
*.pfx
|
||||
*.der
|
||||
*.nettrace
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
var mode = args[0];
|
||||
var directory = args[1];
|
||||
Directory.CreateDirectory(directory);
|
||||
using var key = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=NativeMemoryControl", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var original = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
|
||||
var pfx = original.Export(X509ContentType.Pfx);
|
||||
// Certificate.From takes ownership of original; only wrapped disposes that native certificate.
|
||||
using var wrapped = Certificate.From(original);
|
||||
void Sample(int i, string kind)
|
||||
{
|
||||
var live = GC.GetTotalMemory(true);
|
||||
GC.WaitForPendingFinalizers();
|
||||
using var p = Process.GetCurrentProcess();
|
||||
p.Refresh();
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { kind, i, pid = Environment.ProcessId, mode, live, rss = p.WorkingSet64, methods = JitInfo.GetCompiledMethodCount(), gc = GC.GetGCMemoryInfo().TotalCommittedBytes }));
|
||||
Console.Out.Flush();
|
||||
}
|
||||
Sample(0, "ready");
|
||||
while (!File.Exists(Path.Combine(directory, "go"))) Thread.Sleep(50);
|
||||
for (int i = 1; i <= 1000; i++)
|
||||
{
|
||||
using X509Certificate2 copy = mode switch
|
||||
{
|
||||
"pfx" => System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadPkcs12(pfx, null, X509KeyStorageFlags.Exportable),
|
||||
"sdk-copy" => wrapped.AsX509Certificate2(),
|
||||
"shared-copy" => (X509Certificate2)typeof(X509Certificate2).GetConstructor(new[] { typeof(X509Certificate) })!.Invoke(new object[] { original }),
|
||||
_ => System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadCertificate(original.RawData)
|
||||
};
|
||||
if (i % 100 == 0) Sample(i, "sample");
|
||||
}
|
||||
Sample(1000, "done");
|
||||
while (!File.Exists(Path.Combine(directory, "stop"))) Thread.Sleep(50);
|
||||
@@ -0,0 +1,15 @@
|
||||
// Read-only temporary diagnostic attachment. Requires Microsoft.Diagnostics.NETCore.Client from dotnet-trace.
|
||||
using System.Diagnostics.Tracing;
|
||||
using Microsoft.Diagnostics.NETCore.Client;
|
||||
|
||||
var seconds = int.Parse(args[1]);
|
||||
if (seconds < 1 || seconds > 300) throw new ArgumentOutOfRangeException(nameof(seconds), "Use a bounded 1..300 second diagnostic attachment");
|
||||
var client = new DiagnosticsClient(int.Parse(args[0]));
|
||||
var providers = new[] { new EventPipeProvider("Microsoft-Windows-DotNETRuntime", EventLevel.Verbose, 0x18) };
|
||||
// Match the recorded experiment's API default (256 MiB capacity, not measured resident allocation).
|
||||
using var session = client.StartEventPipeSession(providers, requestRundown: false, circularBufferMB: 256);
|
||||
await using var file = File.Create(args[2]);
|
||||
var copy = session.EventStream.CopyToAsync(file);
|
||||
await Task.Delay(TimeSpan.FromSeconds(seconds));
|
||||
session.Stop();
|
||||
await copy;
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Diagnostics;
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
var root = Path.GetFullPath(args[0]);
|
||||
Directory.CreateDirectory(root);
|
||||
var seconds = int.Parse(args[1]);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(seconds + 120));
|
||||
var ct = timeout.Token;
|
||||
var clientPki = Path.Combine(root, "client-pki");
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var config = new ApplicationConfiguration(telemetry);
|
||||
config.ApplicationName = "NativeLifecycleControl";
|
||||
config.ApplicationUri = "urn:NativeLifecycleControl:" + Guid.NewGuid();
|
||||
config.ApplicationType = ApplicationType.Client;
|
||||
config.SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(clientPki, "own"), SubjectName = "CN=NativeLifecycleControl" },
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(clientPki, "issuer") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(clientPki, "trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(clientPki, "rejected") },
|
||||
AutoAcceptUntrustedCertificates = true
|
||||
};
|
||||
config.ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 15000 };
|
||||
var app = new ApplicationInstance(telemetry) { ApplicationConfiguration = config };
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
await app.CheckApplicationInstanceCertificatesAsync(true, null, ct);
|
||||
int port;
|
||||
using (var listener = new TcpListener(IPAddress.Loopback, 0))
|
||||
{
|
||||
listener.Start();
|
||||
port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
var options = new OpcUaApplicationHostOptions { ApplicationName = "NativeLifecycleServer", ApplicationUri = "urn:NativeLifecycleServer:" + Guid.NewGuid(), OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = Path.Combine(root, "server-pki"), AllowAnonymous = false, AutoAcceptUntrustedClientCertificates = true, EnabledSecurityProfiles = [OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt] };
|
||||
OpcUaApplicationHost host = null!;
|
||||
OtOpcUaSdkServer server = null!;
|
||||
async Task Boot()
|
||||
{
|
||||
server = new OtOpcUaSdkServer();
|
||||
host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance, new Auth());
|
||||
await host.StartAsync(server, ct);
|
||||
var n = server.NodeManager!;
|
||||
n.NodeWriteGateway = new Gateway();
|
||||
n.EnsureFolder("Control", null, "Control", AddressSpaceRealm.Raw);
|
||||
n.EnsureFolder("Equipment", null, "Equipment", AddressSpaceRealm.Uns);
|
||||
n.EnsureVariable("Control/Scalar", "Control", "Scalar", "Int32", true, AddressSpaceRealm.Raw);
|
||||
n.EnsureVariable("Control/Array", "Control", "Array", "Int16", true, AddressSpaceRealm.Raw, isArray: true, arrayLength: 4);
|
||||
n.EnsureVariable("Equipment/Scalar", "Equipment", "Scalar", "Int32", true, AddressSpaceRealm.Uns);
|
||||
for (var i = 0; i < 16; i++) n.EnsureVariable("Control/Browse" + i, "Control", "Browse" + i, "Int32", false, AddressSpaceRealm.Raw);
|
||||
n.MaterialiseAlarmCondition("Control/Alarm", "Control", "Alarm", "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true);
|
||||
n.WriteValue("Control/Scalar", 0, 0, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
n.WriteValue("Control/Array", new short[] { 0, 1, 2, 3 }, 0, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
n.WriteValue("Equipment/Scalar", 0, 0, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
}
|
||||
async Task<ISession> Connect()
|
||||
{
|
||||
var t = DefaultTelemetry.Create(_ => { });
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, $"opc.tcp://127.0.0.1:{port}/OtOpcUa", true, t, ct);
|
||||
var factory = new DefaultSessionFactory(t);
|
||||
var identity = new UserIdentity("control", Encoding.UTF8.GetBytes(FixtureCredential.Value));
|
||||
return await factory.CreateAsync(config, new ConfiguredEndpoint(null, endpoint ?? throw new Exception("Missing endpoint"), EndpointConfiguration.Create(config)), false, false, "NativeLifecycleControl", 15000, identity, default, ct);
|
||||
}
|
||||
static DataValue Value(object v)
|
||||
{
|
||||
#if SDK2
|
||||
if (!VariantHelper.TryCastFrom(v, out var variant)) throw new Exception("Unsupported control value");
|
||||
return new DataValue(variant);
|
||||
#else
|
||||
return new DataValue(new Variant(v));
|
||||
#endif
|
||||
}
|
||||
static object? Clr(DataValue v)
|
||||
{
|
||||
#if SDK2
|
||||
return v.WrappedValue.AsBoxedObject(Variant.BoxingBehavior.Legacy);
|
||||
#else
|
||||
return v.Value;
|
||||
#endif
|
||||
}
|
||||
object? CertificateStats()
|
||||
{
|
||||
#if SDK2
|
||||
return new { created = Opc.Ua.Security.Certificates.Certificate.InstancesCreated, disposed = Opc.Ua.Security.Certificates.Certificate.InstancesDisposed, outstanding = Opc.Ua.Security.Certificates.Certificate.InstancesLeaked };
|
||||
#else
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
void Sample(int cycle, string kind)
|
||||
{
|
||||
var live = GC.GetTotalMemory(true);
|
||||
using var p = Process.GetCurrentProcess();
|
||||
p.Refresh();
|
||||
var m = GC.GetGCMemoryInfo();
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { kind, cycle, pid = Environment.ProcessId, live, rss = p.WorkingSet64, gcCommitted = m.TotalCommittedBytes, gcHeap = m.HeapSizeBytes, fragmented = m.FragmentedBytes, jit = JitInfo.GetCompiledMethodCount(), jitIl = JitInfo.GetCompiledILBytes(), sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), scope = "matched encrypted public lifecycle subset; no history/revocation/proxy", assemblies = AppDomain.CurrentDomain.GetAssemblies().Length, dynamicAssemblies = AppDomain.CurrentDomain.GetAssemblies().Count(a => a.IsDynamic), certificate = CertificateStats(), timers = Timer.ActiveCount, threads = p.Threads.Count, sessions = server.CurrentInstance.SessionManager.GetSessions().Count, subscriptions = server.CurrentInstance.SubscriptionManager.GetSubscriptions().Count }));
|
||||
Console.Out.Flush();
|
||||
}
|
||||
int cycles = 0, restarts = 0;
|
||||
await Boot();
|
||||
var watch = Stopwatch.StartNew();
|
||||
Sample(0, "start");
|
||||
try
|
||||
{
|
||||
while (watch.Elapsed.TotalSeconds < seconds)
|
||||
{
|
||||
using (var owner = await Connect()) using (var other = await Connect())
|
||||
{
|
||||
var ns = (ushort)owner.NamespaceUris.GetIndex("https://zb.com/otopcua/raw");
|
||||
var scalar = new NodeId("Control/Scalar", ns);
|
||||
var array = new NodeId("Control/Array", ns);
|
||||
using var subscription = new Subscription(owner.DefaultSubscription) { PublishingInterval = 100 };
|
||||
owner.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(ct);
|
||||
subscription.AddItem(new MonitoredItem(subscription.DefaultItem) { StartNodeId = scalar, AttributeId = Attributes.Value, SamplingInterval = 50 });
|
||||
subscription.AddItem(new MonitoredItem(subscription.DefaultItem) { StartNodeId = array, AttributeId = Attributes.Value, SamplingInterval = 50 });
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
var response = await owner.WriteAsync(null, [new WriteValue { NodeId = scalar, AttributeId = Attributes.Value, Value = Value(++cycles) }, new WriteValue { NodeId = array, AttributeId = Attributes.Value, Value = Value(new short[] { 1, 2, 3, 4 }) }], ct);
|
||||
if (response.Results.ToList().Any(s => StatusCode.IsBad(s))) throw new Exception("Control write denied");
|
||||
if (!Equals(Clr(await owner.ReadValueAsync(scalar, ct)), cycles)) throw new Exception("Scalar changed");
|
||||
if (!((short[])Clr(await owner.ReadValueAsync(array, ct))!).SequenceEqual(new short[] { 1, 2, 3, 4 })) throw new Exception("Array changed");
|
||||
var browse = await owner.BrowseAsync(null, null, 1, [new BrowseDescription { NodeId = new NodeId("Control", ns), BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All }], ct);
|
||||
await Task.Delay(1000, ct);
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
await owner.CloseAsync(ct);
|
||||
await other.CloseAsync(ct);
|
||||
}
|
||||
if (server.CurrentInstance.SessionManager.GetSessions().Count != 0 || server.CurrentInstance.SubscriptionManager.GetSubscriptions().Count != 0) throw new Exception("Control retained sessions/subscriptions");
|
||||
Sample(cycles, "sample");
|
||||
if (cycles % 4 == 0)
|
||||
{
|
||||
using var parked = await Connect();
|
||||
using var sub = new Subscription(parked.DefaultSubscription) { PublishingInterval = 100 };
|
||||
parked.AddSubscription(sub);
|
||||
await sub.CreateAsync(ct);
|
||||
sub.AddItem(new MonitoredItem(sub.DefaultItem) { StartNodeId = new NodeId("Control/Scalar", (ushort)parked.NamespaceUris.GetIndex("https://zb.com/otopcua/raw")), AttributeId = Attributes.Value, SamplingInterval = 50 });
|
||||
await sub.ApplyChangesAsync(ct);
|
||||
await host.DisposeAsync();
|
||||
await Boot();
|
||||
restarts++;
|
||||
}
|
||||
}
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { kind = "complete", cycles, restarts, seconds = watch.Elapsed.TotalSeconds, fullGate = false }));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
#if SDK2
|
||||
await app.DisposeAsync();
|
||||
#endif
|
||||
}
|
||||
#if SDK2
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
var certificateType = typeof(Opc.Ua.Security.Certificates.Certificate);
|
||||
var report = new Dictionary<string, object>();
|
||||
var trackingEnabled = Environment.GetEnvironmentVariable("OPCUA_CERTIFICATE_LEAK_TRACKING") == "1";
|
||||
if (trackingEnabled)
|
||||
{
|
||||
foreach (var name in new[] { "EnumerateLiveCertificates", "EnumerateUnreachableUndisposedCertificates" })
|
||||
{
|
||||
var method = certificateType.GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("SDK2 diagnostic method missing: " + name);
|
||||
var values = method.Invoke(null, null) as IEnumerable
|
||||
?? throw new InvalidOperationException("SDK2 diagnostic method return type changed: " + name);
|
||||
var stacks = new List<string>();
|
||||
foreach (var value in values)
|
||||
{
|
||||
var field = value!.GetType().GetField(name.Contains("Unreachable") ? "Item2" : "Item4")
|
||||
?? throw new InvalidOperationException("SDK2 diagnostic tuple field missing: " + name);
|
||||
if (field.FieldType != typeof(string))
|
||||
throw new InvalidOperationException("SDK2 diagnostic stack field type changed");
|
||||
// Deliberately omit tuple Item1 (certificate identity).
|
||||
stacks.Add((string)field.GetValue(value)!);
|
||||
}
|
||||
report[name] = new
|
||||
{
|
||||
count = stacks.Count,
|
||||
allocations = stacks.GroupBy(x => x).OrderByDescending(g => g.Count()).Take(15)
|
||||
.Select(g => new { count = g.Count(), stack = g.Key }).ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { kind = "certificate-final-diagnostics", trackingEnabled, stats = CertificateStats(), report }));
|
||||
#endif
|
||||
class Auth : IOpcUaUserAuthenticator
|
||||
{
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string user, string password, CancellationToken ct) => Task.FromResult(user == "control" && password == FixtureCredential.Value ? OpcUaUserAuthResult.Allow("control", [OpcUaDataPlaneRoles.WriteOperate]) : OpcUaUserAuthResult.Deny("Control credential refused"));
|
||||
}
|
||||
class Gateway : IOpcUaNodeWriteGateway
|
||||
{
|
||||
public Task<NodeWriteOutcome> WriteAsync(string n, object? v, AddressSpaceRealm r, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(true, null));
|
||||
}
|
||||
|
||||
static class FixtureCredential
|
||||
{
|
||||
internal static string Value { get; } = Environment.GetEnvironmentVariable("OPCUA_MEMORY_PASSWORD") ?? throw new InvalidOperationException("Set OPCUA_MEMORY_PASSWORD for the task-owned fixture.");
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
# SDK 2 lifecycle memory investigation
|
||||
|
||||
The required two-hour server lifecycle gate failed on macOS and Linux. The RSS
|
||||
limit remains **cycle-3 baseline + 128 MiB**; retained managed memory remains
|
||||
+64 MiB and descriptors +32. No threshold, production code, certificate provider,
|
||||
or session factory was changed by this investigation. These controls do not
|
||||
replace the full gate. Issue #799 remains open.
|
||||
|
||||
## Preserved full-workload failures
|
||||
|
||||
| Run | Cycles / restarts | Observation time | RSS / limit (bytes) | Outcome |
|
||||
|---|---:|---:|---:|---|
|
||||
| macOS `fa823378` | 250 / 62 | 1045.53 s | 365,215,744 / 363,331,584 | Failed |
|
||||
| macOS `e135b7c9` profile reproduction | 245 / 61 | 1024.14 s | 368,656,384 / 363,053,056 | Failed |
|
||||
| Linux full harness, recorded build `38098ce142ab8c382825aa754e0758071dd3a46b` | 248 / 61 | 903.508 s | 353,931,264 / 351,850,496 | Failed |
|
||||
| macOS `6b2d4b81` bounded profile | 432 / 108 | 1807.479 s | First rejection: 364,199,936 / 363,266,048 | Failed; first assertion rethrown |
|
||||
|
||||
The last macOS row requested 1,800 seconds. It first rejected RSS at cycle 253 /
|
||||
1055.482 seconds, continued recording explicitly rejected observations, then
|
||||
threw the original assertion before any completion record. Its final observation
|
||||
was RSS 380,059,648, live managed 41,364,096, GC committed 37,060,608, JIT
|
||||
compilations 81,535, descriptors 200, and zero sessions, subscriptions, monitored
|
||||
items, browse/history continuation points, and PKI descriptors. SDK cached stores
|
||||
remained two during operation. The runner and skip guard returned exit 1 with one
|
||||
executed test and zero skipped tests. There is no passing two-hour result here.
|
||||
|
||||
The first macOS run's reported PKI descriptor zero is invalid: `/var` and
|
||||
`/private/var` aliases were not canonicalized. Corrected positive-handle and timeout
|
||||
controls passed before subsequent profiles. Global descriptor counts and the RSS
|
||||
failure itself were independent of that defect. A Shouldly source-expression
|
||||
message incorrectly names `Descriptors` in the RSS failure; the actual numeric
|
||||
comparison is RSS, as retained before the assertion.
|
||||
|
||||
## What the controls demonstrate
|
||||
|
||||
`LifecycleControl.cs` uses unchanged published product DLLs and public APIs for
|
||||
both SDK generations. It opens two real encrypted **opc.tcp** sessions with
|
||||
Basic256Sha256 / SignAndEncrypt, resolves namespace URIs, reads/writes Int32 and
|
||||
Int16[] values, creates/deletes two data monitored items, abandons a browse
|
||||
continuation, closes sessions, and restarts a server with an active subscription
|
||||
every four cycles against the same task-owned PKI. Public session/subscription
|
||||
counts must return to zero. It has no historian, revocation, surgical-rebuild,
|
||||
paused-network, or private continuation-store coverage. It is a diagnostic subset,
|
||||
not TLS and not the full soak workload.
|
||||
|
||||
The original DLLs are the immutable 1.5.378.182 baseline publication associated
|
||||
with `83ed880e`; its assembly version alone is 1.5.378.0 and does not identify the
|
||||
patch. The candidate uses SDK 2.0.1 and the server implementation from `fa823378`.
|
||||
Neither baseline nor candidate product code was rebuilt for these controls.
|
||||
The controls were compiled once for each reference set, then those exact control
|
||||
artifacts were used on both operating systems. Selected DLL and input-artifact
|
||||
hashes are in `evidence.json`. Neither reference set's principal SDK/server DLLs
|
||||
has a managed native/ReadyToRun header.
|
||||
|
||||
| Ten-minute matched subset | Cycles / restarts | Final RSS | Change from cycle 3 | Final managed / change | Final JIT compilations |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| macOS original | 212 / 53 | 330,301,440 | +121,683,968 | 271,168,424 / +169,990,456 | 28,066 |
|
||||
| macOS SDK2 | 200 / 50 | 302,170,112 | +95,862,784 | 44,569,584 / +295,120 | 62,608 |
|
||||
| Linux original | 249 / 62 | 384,950,272 | +176,836,608 | 147,139,568 / +99,356,752 | 28,321 |
|
||||
| Linux SDK2 | 259 / 64 | 390,844,416 | +194,183,168 | 19,561,120 / +736 | 71,529 |
|
||||
|
||||
Both ten-minute controls exited zero because their lifecycle checks passed;
|
||||
`fullGate=false` is explicit. They do **not** enforce the full harness memory
|
||||
assertions. In particular, both Linux subset RSS deltas exceed 128 MiB and the
|
||||
original retains managed memory/timers. These observations do not waive either
|
||||
failed full gate. They are not proof of an SDK1 defect or an SDK2 improvement.
|
||||
Both control branches explicitly delete subscriptions, close normal sessions,
|
||||
and dispose sessions/subscriptions. Parked resources intentionally encounter
|
||||
server shutdown and are then disposed. Each version calls its unchanged product
|
||||
host's DisposeAsync. The baseline host only calls ApplicationInstance.StopAsync;
|
||||
that original ApplicationInstance exposes no disposal interface. The SDK2 branch
|
||||
also uses its new ApplicationInstance.DisposeAsync ownership API. Per-connection
|
||||
DefaultTelemetry factories are left to the same ownership pattern on both
|
||||
branches, separately probed above. Thus measured original growth belongs to this
|
||||
published-product/fixture lifecycle combination; its allocation owner has not
|
||||
been isolated. These are one matched run per platform, concurrent processes,
|
||||
with different runtime versions; they are not performance distributions.
|
||||
|
||||
The thirty-minute Linux comparison uses the same immutable artifacts and image,
|
||||
fresh independent fixtures, and SDK2 certificate allocation tracking enabled
|
||||
before the first certificate (the same environment is passed to both versions).
|
||||
Both finished with exit zero and `fullGate=false`:
|
||||
|
||||
| Thirty-minute Linux subset | Cycles / restarts | Final RSS | Final live managed | GC committed | JIT compilations | Active timers |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Original | 736 / 184 | 540,057,600 | 342,626,200 | 408,399,872 | 29,177 | 926 |
|
||||
| SDK2 | 768 / 192 | 334,180,352 | 19,248,984 | 42,983,424 | 72,087 | 19 |
|
||||
|
||||
Observed runtime was 1802.542 seconds original / 1804.523 seconds SDK2. The SDK2
|
||||
RSS fell from 390,782,976 at the 632.7-second checkpoint to 334,180,352 at the last
|
||||
observation; live managed stayed near 20 MB. After disposal and final collection,
|
||||
SDK2 wrapper counters were 19,272 created = 19,272 disposed, with zero live or
|
||||
unreachable-undisposed wrappers under enabled tracking. These finite results
|
||||
support post-transition saturation of this subset, not a two-hour full-workload
|
||||
pass or a guarantee of indefinite stability.
|
||||
|
||||
### Native and runtime observations
|
||||
|
||||
- During the macOS bounded profile, `Security::AppleDatabase` counts increased
|
||||
from 278 at cycle 52 / 211 seconds to 1,953 at cycle 370 / 1546 seconds. These
|
||||
native objects continued accumulating while managed memory stayed near 40 MiB.
|
||||
- The single-owner certificate probe makes a self-signed RSA fixture in memory,
|
||||
then disposes each of 1,000 copies/imports. On macOS, public-only and
|
||||
shared-handle copies leave AppleDatabase at 2; PFX imports and SDK
|
||||
`Certificate.AsX509Certificate2` private-key copies grow it from 2 to 1,002.
|
||||
A separate PFX process retained that count through a 180-second idle hold.
|
||||
`Certificate.From(original)` owns the original; the checked-in control has no
|
||||
second owner. All four corrected single-owner modes were rerun successfully.
|
||||
- The same isolated Linux probe ended at approximately 49 MB RSS for public or
|
||||
shared copies, 61 MB for PFX imports, and 69 MB for the SDK copy. Those are
|
||||
endpoint RSS observations including different JIT/GC costs, not evidence of a
|
||||
Linux native-object leak or a complete attribution.
|
||||
- SDK2 public certificate counters after the Linux ten-minute lifecycle run were
|
||||
6,495 created = 6,495 disposed. A separate stack-tracked macOS lifecycle run
|
||||
ended 591 = 591, zero live and zero unreachable-undisposed wrappers. Allocation
|
||||
tracking is opt-in; empty internal reports without that opt-in are not proof.
|
||||
- The Linux ten-minute native snapshots show SDK2 JIT `doublemapper` resident
|
||||
mappings increasing from about 65.4 to 106.7 MiB, anonymous writable mappings
|
||||
from 84.6 to 121.3 MiB, and `[heap]` around 19.2 MiB. Original JIT mappings were
|
||||
about 33.3 to 37.7 MiB. Mapping totals are descriptive categories, not exclusive
|
||||
object allocations: anonymous memory also contains GC regions, stacks, and
|
||||
native allocator arenas. RSS includes file/shared pages and can fall with
|
||||
reclamation. JIT counters are cumulative compilations, not unique live methods.
|
||||
- 10,000 disposed versus undisposed `DefaultTelemetry` factories ended at similar
|
||||
RSS (~63.4 MB) and live managed memory (~0.43 MB), with no timer growth. This
|
||||
does not justify changing host telemetry ownership to fix this failure.
|
||||
|
||||
Exact SDK tag source shows private-key PFX copying in nonvirtual
|
||||
`Certificate.AsX509Certificate2`. A 60-second macOS sampled-thread-time trace
|
||||
instead found the dominant sampled import path through
|
||||
`CertificateIdentifierResolver.LoadPrivateKeyCoreAsync` →
|
||||
`DirectoryCertificateStore.LoadPrivateKeyAsync` → `X509CertificateLoader.LoadPkcs12`.
|
||||
Inclusive sampled thread-time was about 4.79 seconds for store load and 2.99 seconds
|
||||
for PKCS12, versus 8.5 milliseconds for AsX509. These are neither exclusive CPU
|
||||
costs nor call counts. ApplicationInstance already normalizes its validation
|
||||
input to public DER. The validation copy delegate is internal, the manager sealed,
|
||||
and no safe narrow application copy-policy seam was identified. Changing session
|
||||
factory, certificate exportability, or key storage would alter behavior and was
|
||||
not used as a workaround.
|
||||
|
||||
A major JIT/RSS step appeared near 60–64 restarts in several workloads. This is a
|
||||
hypothesis about runtime/code-cache warmup, not a finding that all RSS growth is
|
||||
benign. A separate read-only EventPipe attachment observed the thirty-minute
|
||||
Linux candidate around that transition using `Microsoft-Windows-DotNETRuntime`,
|
||||
Verbose, keywords `0x18` (Loader + JIT), no rundown, 180 seconds. The transition was UTC 18:51:02.049–18:54:02.062. Factory JIT events
|
||||
occurred UTC 18:52:48.249–18:53:21.434: 6,690 distinct method tokens from one
|
||||
module, each once. The module is Opc.Ua.Core.Types.dll; metadata declares 8,442
|
||||
static methods on OpcUaExtensions. The observed set is a subset, so this does
|
||||
not prove every possible factory or the complete canonical workload is warmed.
|
||||
Method-load events totalled 51,327,536 native code bytes, including 51,161,724
|
||||
OptimizedTier1 bytes and 49,141,484 bytes in generated factories. Only 50
|
||||
reflection InvokeStub JIT starts occurred; the transition trace reports zero lost events. MethodSize sums count loaded code
|
||||
versions, not resident/PSS allocation bytes. An additional 180-second attachment
|
||||
after the transition checks continuing factory compilation. The second attachment (UTC 19:01:44.116–19:04:44.127) recorded 85 JIT starts,
|
||||
zero lost events, zero Opc.Ua starts, zero generated-factory starts, and zero generated-factory
|
||||
loaded bytes. Only 18 ordinary Tier1 method loads (5,720 bytes) and 20
|
||||
instrumented Tier1 loads (5,800 bytes) occurred. This supports a finite generated
|
||||
factory burst in the observed subset; it does not prove complete canonical
|
||||
workload saturation. The capture API's default circular-buffer capacity was 256 MiB; that is a
|
||||
capacity setting, not measured resident overhead. Attachments can allocate event
|
||||
buffers and perturb execution. Both prior full failures and the ten-minute
|
||||
control were uninstrumented by these JIT attachments, and the thirty-minute final
|
||||
interval is unattached. No captured native-code byte total is claimed to equal an
|
||||
RSS delta. Raw traces/heaps are not committed.
|
||||
|
||||
## Reproduce the diagnostic subset
|
||||
|
||||
Requires Python 3 and .NET 10. `run.py` references existing DLL directories,
|
||||
compiles only the control, records SHA-256 provenance, and creates a fresh local
|
||||
artifact directory. First reference directory wins duplicate filenames. Use the
|
||||
actual baseline publications, rather than restoring a different package version.
|
||||
No production project or package file is changed.
|
||||
|
||||
```bash
|
||||
python3 scripts/validation/opcua-lifecycle-memory/run.py lifecycle \
|
||||
--references "$baselineHostDlls" --references "$baselineClientDlls" \
|
||||
--output /tmp/opcua-memory-original --seconds 600
|
||||
python3 scripts/validation/opcua-lifecycle-memory/run.py lifecycle --sdk2 \
|
||||
--references "$candidateDlls" --output /tmp/opcua-memory-sdk2 --seconds 600 \
|
||||
--certificate-tracking
|
||||
```
|
||||
|
||||
Fixture credentials are generated in the runner environment and not logged.
|
||||
PKI material stays under each local `private-fixture` directory. Do not publish
|
||||
those directories or unredacted process/native logs. These controls grant a
|
||||
fixture user WriteOperate and auto-accept task-owned certificates; they are
|
||||
local test fixtures, not deployment configurations.
|
||||
|
||||
For a matched Linux pair, first build both with `--build-only` into fresh
|
||||
`/tmp/opcua-memory-original` and `/tmp/opcua-memory-sdk2` output directories, then:
|
||||
|
||||
```bash
|
||||
python3 scripts/validation/opcua-lifecycle-memory/linux_pair.py \
|
||||
--baseline /tmp/opcua-memory-original/bin/Release/net10.0 \
|
||||
--candidate /tmp/opcua-memory-sdk2/bin/Release/net10.0 \
|
||||
--output /tmp/opcua-memory-linux-pair --seconds 1800 \
|
||||
--image sha256:b04611ee9e1b7d36797012ed921962c57189cb4dbb3dff12afb8e158110f293b
|
||||
```
|
||||
|
||||
The runner uses separate task-owned `--network none` containers, fresh PKI,
|
||||
read-only executable mounts, bounded shutdown, and periodic read-only `/proc`
|
||||
snapshots. It records child and container exit statuses independently. The image
|
||||
is Ubuntu 24.04 arm64, .NET SDK 10.0.103 / runtime 10.0.3. The Mac runs used
|
||||
macOS 26.5.2 arm64, SDK 10.0.403 / runtime 10.0.14. Runtime and OS differences are
|
||||
explicit; do not attribute all cross-platform differences to SDK code.
|
||||
|
||||
On macOS, run each certificate mode with a fresh output directory:
|
||||
|
||||
```bash
|
||||
python3 scripts/validation/opcua-lifecycle-memory/run.py certificate --sdk2 \
|
||||
--references "$candidateDlls" --output /tmp/opcua-memory-pfx --mode pfx
|
||||
```
|
||||
|
||||
Other modes are `sdk-copy`, `public`, and `shared-copy`. The last intentionally
|
||||
uses the old copy constructor by reflection as a diagnostic control. It is not
|
||||
proposed production code. The control exports no fixture key/certificate data.
|
||||
`TelemetryControl.cs` is available through `run.py telemetry --sdk2` with modes
|
||||
`dispose` or `undisposed`. `summarize.py` emits Linux mapping categories without
|
||||
file paths from local `*-smaps.txt` files. All controls are opt-in and outside the
|
||||
ordinary solution/test graph.
|
||||
|
||||
Optional trace reproduction uses the installed dotnet-trace 9.0.661903 managed
|
||||
libraries, without adding a product dependency:
|
||||
|
||||
```bash
|
||||
python3 scripts/validation/opcua-lifecycle-memory/build_trace_tools.py \
|
||||
--libraries "$diagnosticToolLibraries" --output /tmp/opcua-memory-trace-tools
|
||||
# Run capture in the same process namespace / user as the task-owned target.
|
||||
dotnet /tmp/opcua-memory-trace-tools/capture/bin/Release/net10.0/capture.dll \
|
||||
"$targetPid" 180 /tmp/opcua-memory-local.nettrace
|
||||
dotnet /tmp/opcua-memory-trace-tools/summary/bin/Release/net10.0/summary.dll \
|
||||
/tmp/opcua-memory-local.nettrace > /tmp/opcua-memory-method-summary.json
|
||||
```
|
||||
|
||||
Capture is read-only and opt-in; raw traces remain local. The summary exports
|
||||
static method names and aggregate counts rather than certificate identities,
|
||||
arguments, or process mappings. The original transition parser also retained
|
||||
method tokens/modules locally to cross-check name-key uniqueness.
|
||||
|
||||
## Prospective saturation experiment (not executed)
|
||||
|
||||
The observed finite factory burst supports a separately labeled readiness
|
||||
experiment; it does not authorize moving the acceptance baseline. A reviewed
|
||||
candidate proposal is exactly 384 canonical lifecycle cycles / 96 every-four-cycle
|
||||
restarts, then one fixed settling cycle, all within 1,800 seconds. No early finish,
|
||||
extension, or RSS-selected endpoint is permitted. Both fixed readiness windows
|
||||
321–352 and 353–384 must contain zero generated-factory JIT/method-load events, zero SDK assembly
|
||||
loads, and zero lost events. Other JIT/native-code totals remain review evidence,
|
||||
not a claim that every runtime path is warmed. Trace capture is bounded and identical for both versions, then
|
||||
detached/drained before the settling cycle. Ownership, FD, PKI, store, and logical
|
||||
resource checks remain active throughout; original cold budget rejections remain
|
||||
recorded. Any later full gate requires at least 7,200 additional seconds after a
|
||||
prospectively approved baseline. No such warmed full gate has been run here.
|
||||
|
||||
The count is beyond the observed 60–64-restart transition and projects to about
|
||||
1,606 seconds from the Mac canonical run, but completion within the fixed cutoff
|
||||
is not guaranteed under load. Failure to complete or satisfy readiness is a failed
|
||||
experiment, not permission to extend. The full canonical test currently depends
|
||||
on SDK2 value APIs and read-only private SDK2 continuation/store instrumentation.
|
||||
An original-SDK adapter proving identical history, revocation, surgical-edit,
|
||||
and stalled-network operation counts is a prerequisite for a matched canonical
|
||||
experiment; the public subset does not provide that proof. Continued Mac native
|
||||
inventory growth remains an independent unresolved gate, even if JIT saturates.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
`evidence.json` contains sanitized numeric endpoints, native inventory counts,
|
||||
and hashes of retained diagnostic inputs. Full artifacts remain in the originating
|
||||
worktrees or `/tmp/opcua2-rss-investigation`; no PKI, certificate identifiers,
|
||||
credentials, raw traces, unredacted heap/process dumps, or complete test logs are
|
||||
checked in. Existing failures were preserved untouched.
|
||||
|
||||
Supported progress is a reproduced RSS failure, bounded logical/managed
|
||||
inventories in SDK2, demonstrated macOS PFX native retention, and measured
|
||||
cross-platform JIT/anonymous-memory growth. A complete allocation attribution and
|
||||
safe production correction remain unresolved. The server two-hour gate remains
|
||||
failed; a separate long-running client-only process is a different scope.
|
||||
|
||||
Package verification: baseline and candidate control builds, both optional trace
|
||||
tools, and certificate/telemetry controls built with zero warnings/errors. Local
|
||||
control smoke runs and the fresh Linux pair smoke exited zero. Python compilation
|
||||
and whitespace checks passed. One reused-output smoke failed because a manual
|
||||
incremental build used a different `/tmp` path alias and its runtimeconfig was
|
||||
missing; fresh canonical outputs passed, and the Linux runner now rejects an
|
||||
incomplete output directory before launch. These smoke checks validate tooling
|
||||
only and do not substitute for either long run.
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
var dispose = args[0] == "dispose";
|
||||
for (int i = 0; i <= 10000; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
var t = DefaultTelemetry.Create(_ => { });
|
||||
t.LoggerFactory.CreateLogger("TelemetryControl").LogDebug("control");
|
||||
if (dispose) ((IDisposable)t).Dispose();
|
||||
}
|
||||
if (i % 250 == 0)
|
||||
{
|
||||
var live = GC.GetTotalMemory(true);
|
||||
using var p = Process.GetCurrentProcess();
|
||||
p.Refresh();
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { i, dispose, live, rss = p.WorkingSet64, methods = JitInfo.GetCompiledMethodCount(), il = JitInfo.GetCompiledILBytes(), gc = GC.GetGCMemoryInfo().TotalCommittedBytes, threads = p.Threads.Count, timers = Timer.ActiveCount }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Redacted aggregate summary. Keep raw traces local.
|
||||
using Microsoft.Diagnostics.Tracing;
|
||||
using System.Text.Json;
|
||||
|
||||
var starts = new Dictionary<string, int>();
|
||||
var tiers = new Dictionary<string, int>();
|
||||
var nativeBytes = new Dictionary<string, long>();
|
||||
var factoryNames = new Dictionary<string, int>();
|
||||
var factoryTokens = new HashSet<int>();
|
||||
var factoryModules = new HashSet<long>();
|
||||
var firstFactoryMs = double.PositiveInfinity;
|
||||
var lastFactoryMs = 0d;
|
||||
var factoryNativeBytes = 0L;
|
||||
var sdkAssemblyLoads = 0;
|
||||
using var source = new EventPipeEventSource(args[0]);
|
||||
source.Clr.MethodJittingStarted += data =>
|
||||
{
|
||||
var name = data.MethodNamespace + "." + data.MethodName;
|
||||
starts[name] = starts.GetValueOrDefault(name) + 1;
|
||||
if (!name.StartsWith("Opc.Ua.OpcUaExtensions.", StringComparison.Ordinal)) return;
|
||||
factoryNames[name] = factoryNames.GetValueOrDefault(name) + 1;
|
||||
factoryTokens.Add(data.MethodToken);
|
||||
factoryModules.Add(data.ModuleID);
|
||||
firstFactoryMs = Math.Min(firstFactoryMs, data.TimeStampRelativeMSec);
|
||||
lastFactoryMs = Math.Max(lastFactoryMs, data.TimeStampRelativeMSec);
|
||||
};
|
||||
source.Clr.MethodLoadVerbose += data =>
|
||||
{
|
||||
var tier = data.OptimizationTier.ToString();
|
||||
tiers[tier] = tiers.GetValueOrDefault(tier) + 1;
|
||||
nativeBytes[tier] = nativeBytes.GetValueOrDefault(tier) + data.MethodSize;
|
||||
if (data.MethodNamespace == "Opc.Ua.OpcUaExtensions") factoryNativeBytes += data.MethodSize;
|
||||
};
|
||||
source.Clr.LoaderAssemblyLoad += data =>
|
||||
{
|
||||
if (data.FullyQualifiedAssemblyName.StartsWith("Opc.Ua.", StringComparison.Ordinal)) sdkAssemblyLoads++;
|
||||
};
|
||||
source.Process();
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
traceStartUtc = source.SessionStartTime.ToUniversalTime(),
|
||||
traceEndUtc = source.SessionEndTime.ToUniversalTime(),
|
||||
eventsLost = source.EventsLost,
|
||||
sdkAssemblyLoads,
|
||||
jittingStarts = starts.Values.Sum(),
|
||||
// Name keys omit generic signatures; token/module counts independently identify the factory set.
|
||||
distinctNameKeys = starts.Count,
|
||||
opcUaJittingStarts = starts.Where(x => x.Key.StartsWith("Opc.Ua.", StringComparison.Ordinal)).Sum(x => x.Value),
|
||||
invokeStubStarts = starts.Where(x => x.Key.StartsWith("dynamicClass.InvokeStub", StringComparison.Ordinal)).Sum(x => x.Value),
|
||||
tiers,
|
||||
summedLoadedNativeMethodBytes = nativeBytes,
|
||||
factory = new
|
||||
{
|
||||
starts = factoryNames.Values.Sum(), distinctNames = factoryNames.Count,
|
||||
distinctTokens = factoryTokens.Count, moduleCount = factoryModules.Count,
|
||||
everyNameOnce = factoryNames.Values.All(x => x == 1),
|
||||
firstMs = double.IsFinite(firstFactoryMs) ? (double?)firstFactoryMs : null,
|
||||
lastMs = factoryNames.Count == 0 ? (double?)null : lastFactoryMs,
|
||||
summedLoadedNativeMethodBytes = factoryNativeBytes,
|
||||
examples = factoryNames.Keys.Take(10).ToArray()
|
||||
}
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build optional local capture/summary tools using the user's installed dotnet-trace libraries."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--libraries", required=True, type=Path, help="dotnet-trace tools/net8.0/any directory")
|
||||
parser.add_argument("--output", required=True, type=Path, help="Fresh local output directory")
|
||||
args = parser.parse_args()
|
||||
args.output = args.output.resolve()
|
||||
args.output.mkdir(parents=True, exist_ok=False)
|
||||
for name, source, references in [
|
||||
("capture", "EventPipeCapture.cs", ["Microsoft.Diagnostics.NETCore.Client"]),
|
||||
("summary", "TraceSummary.cs", ["Microsoft.Diagnostics.Tracing.TraceEvent", "Microsoft.Diagnostics.FastSerialization", "Microsoft.Diagnostics.NETCore.Client"])
|
||||
]:
|
||||
output = args.output / name
|
||||
output.mkdir()
|
||||
project = ET.Element("Project", Sdk="Microsoft.NET.Sdk")
|
||||
properties = ET.SubElement(project, "PropertyGroup")
|
||||
for key, value in {"OutputType": "Exe", "TargetFramework": "net10.0", "ImplicitUsings": "enable",
|
||||
"EnableDefaultCompileItems": "false"}.items():
|
||||
ET.SubElement(properties, key).text = value
|
||||
items = ET.SubElement(project, "ItemGroup")
|
||||
ET.SubElement(items, "FrameworkReference", Include="Microsoft.AspNetCore.App")
|
||||
ET.SubElement(items, "Compile", Include=str(Path(__file__).resolve().parent / source))
|
||||
for reference in references:
|
||||
dll = (args.libraries / (reference + ".dll")).resolve(strict=True)
|
||||
item = ET.SubElement(items, "Reference", Include=reference)
|
||||
ET.SubElement(item, "HintPath").text = str(dll)
|
||||
path = output / (name + ".csproj")
|
||||
ET.indent(project)
|
||||
ET.ElementTree(project).write(path, encoding="unicode")
|
||||
with (output / "build.log").open("w") as log:
|
||||
result = subprocess.run(["dotnet", "build", str(path), "-c", "Release", "-m:2"], stdout=log, stderr=subprocess.STDOUT)
|
||||
if result.returncode:
|
||||
raise SystemExit(result.returncode)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run two already-built public lifecycle controls in isolated, offline Linux containers."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
def latest_sample(path):
|
||||
# A concurrently-written final line may be incomplete. All complete rows remain in the source log.
|
||||
for row in reversed(path.read_text().splitlines()):
|
||||
try:
|
||||
value = json.loads(row)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if value.get("kind") == "sample":
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def run(options):
|
||||
options.output = options.output.resolve()
|
||||
options.output.mkdir(parents=True, exist_ok=False)
|
||||
processes = []
|
||||
environment = os.environ.copy()
|
||||
environment["OPCUA_MEMORY_PASSWORD"] = secrets.token_urlsafe(32)
|
||||
# Both receive the same environment. SDK1 ignores the SDK2 diagnostic opt-in.
|
||||
environment["OPCUA_CERTIFICATE_LEAK_TRACKING"] = "1"
|
||||
started = time.monotonic()
|
||||
try:
|
||||
for label, directory in [("baseline", options.baseline), ("candidate", options.candidate)]:
|
||||
directory = directory.resolve(strict=True)
|
||||
for required in ["control.dll", "control.deps.json", "control.runtimeconfig.json"]:
|
||||
if not (directory / required).is_file():
|
||||
raise FileNotFoundError("Use a complete, canonically-built control output: missing " + required)
|
||||
output = options.output / label
|
||||
output.mkdir()
|
||||
container = "opcua-memory-" + label + "-" + uuid.uuid4().hex[:10]
|
||||
shell = ('dotnet /app/control.dll /task/private-fixture ' + str(options.seconds) +
|
||||
' > /task/samples.jsonl 2>&1 & child=$!; echo "$child" > /task/dotnet.pid; '
|
||||
'wait "$child"; status=$?; echo "$status" > /task/exit-status.txt; exit "$status"')
|
||||
command = ["docker", "run", "--rm", "--name", container, "--network", "none",
|
||||
"--env", "OPCUA_MEMORY_PASSWORD", "--env", "OPCUA_CERTIFICATE_LEAK_TRACKING",
|
||||
"-v", str(directory) + ":/app:ro", "-v", str(output) + ":/task",
|
||||
options.image, "bash", "-c", shell]
|
||||
manifest = {"argv": command, "fullGate": False, "seconds": options.seconds,
|
||||
"environmentNamesOnly": ["OPCUA_MEMORY_PASSWORD", "OPCUA_CERTIFICATE_LEAK_TRACKING"],
|
||||
"startedUtc": datetime.now(timezone.utc).isoformat(),
|
||||
"runtimeFiles": {p.name: hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in sorted(directory.iterdir()) if p.is_file()}}
|
||||
(output / "command.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
stream = (output / "container.log").open("w")
|
||||
process = subprocess.Popen(command, env=environment, stdout=stream, stderr=subprocess.STDOUT)
|
||||
processes.append((label, container, output, stream, process))
|
||||
next_sample = started + 60
|
||||
while any(process.poll() is None for _, _, _, _, process in processes):
|
||||
if time.monotonic() - started > options.seconds + 240:
|
||||
raise TimeoutError("Diagnostic controls exceeded bounded shutdown allowance")
|
||||
if time.monotonic() >= next_sample:
|
||||
for label, container, output, _, process in processes:
|
||||
pid_file = output / "dotnet.pid"
|
||||
if process.poll() is not None or not pid_file.exists():
|
||||
continue
|
||||
pid = pid_file.read_text().strip()
|
||||
if not pid.isdecimal():
|
||||
raise RuntimeError("Unexpected task-owned process id")
|
||||
stamp = str(int(time.time()))
|
||||
latest = latest_sample(output / "samples.jsonl")
|
||||
with (output / "observations.jsonl").open("a") as observed:
|
||||
observed.write(json.dumps({"utc": datetime.now(timezone.utc).isoformat(),
|
||||
"seconds": time.monotonic() - started,
|
||||
"sample": latest}) + "\n")
|
||||
for name in ["smaps", "smaps_rollup", "status"]:
|
||||
with (output / (stamp + "-" + name + ".txt")).open("w") as destination:
|
||||
result = subprocess.run(["docker", "exec", container, "cat", "/proc/" + pid + "/" + name],
|
||||
stdout=destination, stderr=subprocess.STDOUT, timeout=15)
|
||||
if result.returncode and process.poll() is None:
|
||||
raise RuntimeError(label + " live process diagnostic read failed")
|
||||
next_sample += 60
|
||||
time.sleep(.5)
|
||||
statuses = []
|
||||
for label, _, output, _, process in processes:
|
||||
status = process.wait()
|
||||
(output / "docker-exit-status.txt").write_text(str(status) + "\n")
|
||||
print(label + " exit " + str(status), flush=True)
|
||||
statuses.append(status)
|
||||
return 0 if all(status == 0 for status in statuses) else 1
|
||||
finally:
|
||||
for _, container, _, stream, process in processes:
|
||||
if process.poll() is None:
|
||||
subprocess.run(["docker", "stop", "--time", "10", container], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=20)
|
||||
process.wait(timeout=20)
|
||||
stream.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--baseline", type=Path, required=True, help="Already-built control output directory")
|
||||
parser.add_argument("--candidate", type=Path, required=True, help="Already-built control output directory")
|
||||
parser.add_argument("--output", type=Path, required=True, help="New local diagnostic directory")
|
||||
parser.add_argument("--image", required=True, help="Immutable Linux .NET10 image digest")
|
||||
parser.add_argument("--seconds", type=int, default=1800)
|
||||
options = parser.parse_args()
|
||||
if not 1 <= options.seconds <= 1800 or not options.image.startswith("sha256:"):
|
||||
parser.error("Use an immutable image digest and duration 1..1800; never a full-soak gate")
|
||||
raise SystemExit(run(options))
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build opt-in controls against existing published DLLs; never rebuild product code."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("kind", choices=["lifecycle", "certificate", "telemetry"])
|
||||
parser.add_argument("--references", type=Path, action="append", required=True,
|
||||
help="Existing published DLL directory; first directory wins duplicate names")
|
||||
parser.add_argument("--output", type=Path, required=True, help="New local artifact directory")
|
||||
parser.add_argument("--sdk2", action="store_true")
|
||||
parser.add_argument("--seconds", type=int, default=600)
|
||||
parser.add_argument("--mode", help="Certificate: public/pfx/shared-copy/sdk-copy; telemetry: dispose/undisposed")
|
||||
parser.add_argument("--build-only", action="store_true")
|
||||
parser.add_argument("--certificate-tracking", action="store_true", help="SDK2 opt-in allocation-stack diagnostics")
|
||||
options = parser.parse_args()
|
||||
if options.kind != "lifecycle" and not options.sdk2:
|
||||
parser.error("certificate and telemetry controls require --sdk2")
|
||||
if not 1 <= options.seconds <= 1800:
|
||||
parser.error("Diagnostic duration must be 1..1800 seconds; this is never a full soak")
|
||||
options.output = options.output.resolve()
|
||||
options.output.mkdir(parents=True, exist_ok=False)
|
||||
names = {"lifecycle": "LifecycleControl.cs", "certificate": "CertificateControl.cs", "telemetry": "TelemetryControl.cs"}
|
||||
source = Path(__file__).resolve().parent / names[options.kind]
|
||||
references = {}
|
||||
for directory in options.references:
|
||||
directory = directory.resolve(strict=True)
|
||||
for dll in sorted(directory.glob("*.dll")):
|
||||
references.setdefault(dll.name, dll)
|
||||
if "Opc.Ua.Core.dll" not in references:
|
||||
parser.error("References must contain actual OPC UA managed assemblies")
|
||||
project = ET.Element("Project", Sdk="Microsoft.NET.Sdk")
|
||||
properties = ET.SubElement(project, "PropertyGroup")
|
||||
for key, value in {"OutputType": "Exe", "TargetFramework": "net10.0", "ImplicitUsings": "enable",
|
||||
"Nullable": "enable", "EnableDefaultCompileItems": "false",
|
||||
"DefineConstants": "SDK2" if options.sdk2 else ""}.items():
|
||||
ET.SubElement(properties, key).text = value
|
||||
items = ET.SubElement(project, "ItemGroup")
|
||||
ET.SubElement(items, "FrameworkReference", Include="Microsoft.AspNetCore.App")
|
||||
ET.SubElement(items, "Compile", Include=str(source))
|
||||
for name, path in references.items():
|
||||
reference = ET.SubElement(items, "Reference", Include=path.stem)
|
||||
ET.SubElement(reference, "HintPath").text = str(path)
|
||||
project_path = options.output / "control.csproj"
|
||||
ET.indent(project)
|
||||
ET.ElementTree(project).write(project_path, encoding="unicode")
|
||||
manifest = {"kind": options.kind, "sdk2": options.sdk2, "seconds": options.seconds,
|
||||
"fullSoakGate": False, "source": {source.name: digest(source)},
|
||||
"referencePrecedence": "first directory wins duplicate names",
|
||||
"references": {name: digest(path) for name, path in references.items()}}
|
||||
(options.output / "provenance.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
with (options.output / "build.log").open("w") as log:
|
||||
result = subprocess.run(["dotnet", "build", str(project_path), "-c", "Release", "-m:2"],
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
if result.returncode:
|
||||
return result.returncode
|
||||
if options.build_only:
|
||||
return 0
|
||||
executable = options.output / "bin/Release/net10.0/control.dll"
|
||||
environment = os.environ.copy()
|
||||
environment["OPCUA_MEMORY_PASSWORD"] = secrets.token_urlsafe(32)
|
||||
if options.certificate_tracking:
|
||||
environment["OPCUA_CERTIFICATE_LEAK_TRACKING"] = "1"
|
||||
if options.kind == "lifecycle":
|
||||
command = ["dotnet", str(executable), str(options.output / "private-fixture"), str(options.seconds)]
|
||||
with (options.output / "samples.jsonl").open("w") as log:
|
||||
result = subprocess.run(command, env=environment, stdout=log, stderr=subprocess.STDOUT,
|
||||
timeout=options.seconds + 180)
|
||||
status = result.returncode
|
||||
elif options.kind == "telemetry":
|
||||
if options.mode not in ["dispose", "undisposed"]:
|
||||
parser.error("Telemetry mode must be dispose or undisposed")
|
||||
with (options.output / "samples.jsonl").open("w") as log:
|
||||
result = subprocess.run(["dotnet", str(executable), options.mode], env=environment,
|
||||
stdout=log, stderr=subprocess.STDOUT, timeout=180)
|
||||
status = result.returncode
|
||||
else:
|
||||
if sys.platform != "darwin":
|
||||
parser.error("Native certificate control currently requires macOS heap/vmmap")
|
||||
if options.mode not in ["public", "pfx", "shared-copy", "sdk-copy"]:
|
||||
parser.error("Certificate mode must be public, pfx, shared-copy or sdk-copy")
|
||||
status = certificate_control(executable, options.output, options.mode, environment)
|
||||
(options.output / "exit-status.txt").write_text(str(status) + "\n")
|
||||
return status
|
||||
|
||||
|
||||
def certificate_control(executable, output, mode, environment):
|
||||
# Native snapshots contain process metadata; retain locally, do not publish them unredacted.
|
||||
with (output / "samples.jsonl").open("w") as log:
|
||||
process = subprocess.Popen(["dotnet", str(executable), mode, str(output)], env=environment,
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
deadline = time.monotonic() + 180
|
||||
while not (output / "samples.jsonl").stat().st_size:
|
||||
if process.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError("Certificate control failed before ready")
|
||||
time.sleep(.05)
|
||||
for label in ["before", "after"]:
|
||||
if label == "after":
|
||||
(output / "go").touch()
|
||||
while True:
|
||||
rows = (output / "samples.jsonl").read_text().splitlines()
|
||||
if rows and json.loads(rows[-1]).get("kind") == "done":
|
||||
break
|
||||
if process.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError("Certificate control did not finish")
|
||||
time.sleep(.1)
|
||||
with (output / ("heap-" + label + ".txt")).open("w") as log_heap:
|
||||
subprocess.run(["/usr/bin/heap", "--noContent", "-s", str(process.pid)],
|
||||
stdout=log_heap, stderr=subprocess.STDOUT, timeout=20, check=True)
|
||||
(output / "stop").touch()
|
||||
return process.wait(timeout=10)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit mapping-category totals without path names from local Linux /proc/PID/smaps."""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
HEADER = re.compile(r"^[0-9a-f]+-[0-9a-f]+\s+(\S+)\s+\S+\s+\S+\s+\S+\s*(.*)$")
|
||||
METRIC = re.compile(r"^(Size|Rss|Pss|Private_Dirty|Private_Clean|Anonymous):\s+(\d+) kB$")
|
||||
|
||||
|
||||
def summarize(path):
|
||||
groups = defaultdict(lambda: defaultdict(int))
|
||||
group = None
|
||||
for line in path.read_text().splitlines():
|
||||
header = HEADER.match(line)
|
||||
if header:
|
||||
permissions, name = header.groups()
|
||||
if "doublemapper" in name:
|
||||
group = "jit-doublemapper"
|
||||
elif name == "[heap]":
|
||||
group = "native-heap"
|
||||
elif name.startswith("[stack"):
|
||||
group = "stack"
|
||||
elif not name:
|
||||
group = "anonymous-exec" if "x" in permissions else "anonymous-write" if "w" in permissions else "anonymous-read"
|
||||
elif name.endswith(".dll"):
|
||||
group = "managed-assembly-files"
|
||||
elif ".so" in name:
|
||||
group = "native-library-files"
|
||||
elif name.startswith("["):
|
||||
group = "other-kernel-mappings"
|
||||
else:
|
||||
group = "other-files"
|
||||
elif group and (metric := METRIC.match(line)):
|
||||
groups[group][metric[1]] += int(metric[2]) * 1024
|
||||
return dict(groups)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("files", nargs="+", type=Path)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps([{"sample": index, "groups": summarize(path)} for index, path in enumerate(args.files)], indent=2))
|
||||
Reference in New Issue
Block a user