diff --git a/scripts/validation/opcua-lifecycle-memory/SATURATION.md b/scripts/validation/opcua-lifecycle-memory/SATURATION.md index d9b12a443..9c2af2bd7 100644 --- a/scripts/validation/opcua-lifecycle-memory/SATURATION.md +++ b/scripts/validation/opcua-lifecycle-memory/SATURATION.md @@ -29,7 +29,10 @@ A fresh task-owned control directory and UUID bind every request/receipt to this run and target PID. Files are published atomically; existing, malformed, foreign, or incomplete receipts fail closed. The observer is a separate diagnostic process using public `DiagnosticsClient` / EventPipe APIs. Product dependencies are -unchanged. +unchanged. A separate short positive control first invokes a named non-SDK method +with NoInlining/NoOptimization after attaching and requires its JIT-start count in +the parsed receipt. This proves CLR provider delivery/classification independently +of custom marker delivery. That probe is never invoked by the canonical experiment. One continuous trace covers both fixed windows, including their boundary gap: diff --git a/scripts/validation/opcua-lifecycle-memory/SaturationObserver.cs b/scripts/validation/opcua-lifecycle-memory/SaturationObserver.cs index 52ee22a0d..1f61d438a 100644 --- a/scripts/validation/opcua-lifecycle-memory/SaturationObserver.cs +++ b/scripts/validation/opcua-lifecycle-memory/SaturationObserver.cs @@ -50,7 +50,7 @@ try { protocol, runId, targetPid, observerPid = Environment.ProcessId, active = false, drained = true, summary.markersValid, summary.eventsLost, summary.sdkFactoryJit, summary.sdkFactoryLoads, - summary.sdkAssemblyLoads, summary.windows, summary.otherJit, summary.loadedNativeBytes, + summary.sdkAssemblyLoads, summary.windows, summary.otherJit, summary.loadedNativeBytes, summary.runtimeControlJit, configuration = new { runtimeProvider = "Microsoft-Windows-DotNETRuntime", level = "Verbose", keywords = "0x18", markerProvider, circularBufferMiB = 16, requestRundown = false }, acceptance = false @@ -82,11 +82,17 @@ async Task Publish(string name, object value) } static TraceResult Parse(string path, string expectedRun) { + var runtimeControlJit = 0; var markers = new List(); var events = new List(); using var source = new EventPipeEventSource(path); - source.Clr.MethodJittingStarted += data => events.Add(new(data.TimeStampRelativeMSec, - data.MethodNamespace == "Opc.Ua.OpcUaExtensions" ? "factory-jit" : "other-jit", 0)); + source.Clr.MethodJittingStarted += data => + { + if (data.MethodNamespace == "ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.Sdk2LifecycleSoakTests" + && data.MethodName == "SaturationProtocolRuntimeProbe") runtimeControlJit++; + events.Add(new(data.TimeStampRelativeMSec, + data.MethodNamespace == "Opc.Ua.OpcUaExtensions" ? "factory-jit" : "other-jit", 0)); + }; source.Clr.MethodLoadVerbose += data => events.Add(new(data.TimeStampRelativeMSec, data.MethodNamespace == "Opc.Ua.OpcUaExtensions" ? "factory-load" : "other-load", data.MethodSize)); source.Clr.LoaderAssemblyLoad += data => @@ -117,11 +123,11 @@ static TraceResult Parse(string path, string expectedRun) // Totals include the whole attached interval, including marker gaps, startup and stopping the collector. return new TraceResult(valid, source.EventsLost, events.Count(x => x.Kind == "factory-jit"), events.Count(x => x.Kind == "factory-load"), events.Count(x => x.Kind == "sdk-assembly"), windows, - events.Count(x => x.Kind == "other-jit"), events.Sum(x => x.Bytes)); + events.Count(x => x.Kind == "other-jit"), events.Sum(x => x.Bytes), runtimeControlJit); } record Marker(double Ms, string Run, string Name, int Cycle, int Restarts); record Observed(double Ms, string Kind, long Bytes); record Window(string name, int firstCycle, int lastCycle, int startRestarts, int endRestarts, int sdkFactoryJit, int sdkFactoryLoads, int sdkAssemblyLoads); record TraceResult(bool markersValid, int eventsLost, int sdkFactoryJit, int sdkFactoryLoads, - int sdkAssemblyLoads, Window[] windows, int otherJit, long loadedNativeBytes); + int sdkAssemblyLoads, Window[] windows, int otherJit, long loadedNativeBytes, int runtimeControlJit); diff --git a/scripts/validation/opcua-lifecycle-memory/prepare_saturation.py b/scripts/validation/opcua-lifecycle-memory/prepare_saturation.py index b9187ea7e..bb8a907af 100644 --- a/scripts/validation/opcua-lifecycle-memory/prepare_saturation.py +++ b/scripts/validation/opcua-lifecycle-memory/prepare_saturation.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Prepare a fresh immutable candidate bundle. This command never launches the experiment.""" import argparse +from datetime import datetime, timezone import hashlib import json from pathlib import Path @@ -42,15 +43,18 @@ def main(): shutil.copy2(source / name, tools / name) manifest = {str(path.relative_to(root)): sha(path) for directory in ["app", "observer", "tools"] for path in sorted((root / directory).rglob("*")) if path.is_file()} - runtime = json.loads((root / "app/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.runtimeconfig.json").read_text()) - included = runtime["runtimeOptions"]["includedFrameworks"] - if not included or any(value["version"] != "10.0.14" for value in included): - raise RuntimeError("Published app is not the prospectively fixed self-contained runtime") + for config in ["app/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.runtimeconfig.json", "observer/observer.runtimeconfig.json"]: + runtime = json.loads((root / config).read_text()) + included = runtime["runtimeOptions"]["includedFrameworks"] + if not included or any(value["version"] != "10.0.14" for value in included): + raise RuntimeError("Published participant is not the prospectively fixed self-contained runtime") + dependencies = json.loads((root / "app/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.deps.json").read_text())["libraries"] receipt = {"protocol": "sdk2-saturation-v1", "sourceRevision": revision, "image": options.image, "rid": "linux-arm64", "runtimeVersion": "10.0.14", "selfContained": True, "canonicalCycles": 384, "completedRestarts": 96, "settlingCycles": 1, "maximumPhaseSeconds": 1800, "windows": [[321, 352], [353, 384]], "observerBufferMiB": 16, "runtimeKeywords": "0x18", "rundown": False, - "acceptance": False, "publishCommand": command, + "acceptance": False, "preparedUtc": datetime.now(timezone.utc).isoformat(), "publishCommand": command, + "sdkLibraries": [name for name in dependencies if name.startswith("OPCFoundation.")], "testSourceSha256": sha(project.parent / "Sdk2LifecycleSoakTests.cs"), "observerSourceSha256": sha(source / "SaturationObserver.cs"), "files": manifest} receipt_path = root / "receipt.json" diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs index ee6e531f5..2e62fe75f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs @@ -859,6 +859,7 @@ public sealed class Sdk2LifecycleSoakTests var stdout = observer.StandardOutput.ReadToEndAsync(deadline.Token); var stderr = observer.StandardError.ReadToEndAsync(deadline.Token); await control.AttachAsync(deadline.Token); + if (!foreignMarker) SaturationProtocolRuntimeProbe(1).ShouldBe(2); if (foreignMarker) SaturationMarkers.Log.Boundary(Guid.NewGuid().ToString("D"), "window1-start", 321, 80); control.Boundary("window1-start", 321, 80); await Task.Delay(20, deadline.Token); @@ -868,6 +869,12 @@ public sealed class Sdk2LifecycleSoakTests control.Boundary("window2-end", 384, 96); await control.DetachAndDrainAsync(deadline.Token); control.Readiness.ShouldBe(!foreignMarker); + if (!foreignMarker) + { + using var parsed = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "drained.json"), deadline.Token)); + parsed.RootElement.GetProperty("runtimeControlJit").GetInt32().ShouldBeGreaterThan(0, + "the known non-SDK probe must prove runtime-provider delivery and method classification"); + } SaturationMarkers.Log.IsEnabled().ShouldBeFalse("Stop and stream drain precede return to the target"); await observer.WaitForExitAsync(deadline.Token); observer.ExitCode.ShouldBe(0, await stderr); @@ -880,6 +887,10 @@ public sealed class Sdk2LifecycleSoakTests } } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining + | System.Runtime.CompilerServices.MethodImplOptions.NoOptimization)] + private static int SaturationProtocolRuntimeProbe(int value) => value + 1; + [EventSource(Name = "OtOpcUa-Sdk2-Saturation")] private sealed class SaturationMarkers : EventSource {