diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgentInformation.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgentInformation.cs index 1670879c1..a5310a23c 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgentInformation.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgentInformation.cs @@ -19,6 +19,15 @@ public class MTConnectAgentInformation /// public const string Filename = "agent.information.json"; + // Shared across every Save call. See JsonFunctions.cs for the + // rationale — a fresh JsonSerializerOptions per call re-emits + // LCG DynamicMethods into the loader heap, and the GC cannot + // reclaim them. + private static readonly JsonSerializerOptions _saveOptions = new JsonSerializerOptions + { + WriteIndented = true + }; + /// /// A token regenerated on every save, used to detect when the persisted information has changed. @@ -127,12 +136,7 @@ public void Save(string path = null) try { - var options = new JsonSerializerOptions - { - WriteIndented = true - }; - - var json = JsonSerializer.Serialize(this, options); + var json = JsonSerializer.Serialize(this, _saveOptions); File.WriteAllText(configurationPath, json); } catch { } diff --git a/libraries/MTConnect.NET-Common/Buffers/MTConnectAssetFileBuffer.cs b/libraries/MTConnect.NET-Common/Buffers/MTConnectAssetFileBuffer.cs index b89d1c84e..73f131bfe 100644 --- a/libraries/MTConnect.NET-Common/Buffers/MTConnectAssetFileBuffer.cs +++ b/libraries/MTConnect.NET-Common/Buffers/MTConnectAssetFileBuffer.cs @@ -36,6 +36,15 @@ public class MTConnectAssetFileBuffer : MTConnectAssetBuffer, IDisposable /// public const string DirectoryAssets = "assets"; + // Shared across every asset persistence call. See + // JsonFunctions.cs for the rationale — a fresh + // JsonSerializerOptions per call re-emits LCG DynamicMethods + // into the loader heap, and the GC cannot reclaim them. + private static readonly JsonSerializerOptions _writeOptions = new JsonSerializerOptions + { + WriteIndented = true + }; + private readonly string _basePath; private readonly MTConnectAssetQueue _items; private readonly Regex _regex = new Regex("([0-9]*)_(.*)"); @@ -432,14 +441,9 @@ private async Task WriteToFile(uint index, IAsset asset, uint originalInde } } - var options = new JsonSerializerOptions - { - WriteIndented = true - }; - var assetType = Asset.GetAssetType(asset.Type); - var json = JsonSerializer.Serialize(asset, assetType, options); + var json = JsonSerializer.Serialize(asset, assetType, _writeOptions); if (!string.IsNullOrEmpty(json)) { if (UseCompression) diff --git a/libraries/MTConnect.NET-Common/Clients/MTConnectClientInformation.cs b/libraries/MTConnect.NET-Common/Clients/MTConnectClientInformation.cs index 81bfe9e50..4e8bbad87 100644 --- a/libraries/MTConnect.NET-Common/Clients/MTConnectClientInformation.cs +++ b/libraries/MTConnect.NET-Common/Clients/MTConnectClientInformation.cs @@ -23,6 +23,15 @@ public class MTConnectClientInformation /// public const string FilenameExtension = ".json"; + // Shared across every Save call. See JsonFunctions.cs for the + // rationale — a fresh JsonSerializerOptions per call re-emits + // LCG DynamicMethods into the loader heap, and the GC cannot + // reclaim them. + private static readonly JsonSerializerOptions _saveOptions = new JsonSerializerOptions + { + WriteIndented = true + }; + /// /// A token regenerated on every save, used to detect that the persisted state has changed since it was last loaded. @@ -126,12 +135,7 @@ public void Save(string path = null) var configurationPath = Path.Combine(dir, GenerateFilename(DeviceKey)); if (path != null) configurationPath = path; - var options = new JsonSerializerOptions - { - WriteIndented = true - }; - - var json = JsonSerializer.Serialize(this, options); + var json = JsonSerializer.Serialize(this, _saveOptions); File.WriteAllText(configurationPath, json); } catch { } diff --git a/libraries/MTConnect.NET-Common/Configurations/AdapterApplicationConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/AdapterApplicationConfiguration.cs index 647873396..5f97a6972 100644 --- a/libraries/MTConnect.NET-Common/Configurations/AdapterApplicationConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/AdapterApplicationConfiguration.cs @@ -40,6 +40,15 @@ public class AdapterApplicationConfiguration : IAdapterApplicationConfiguration /// public const string DefaultYamlFilename = "adapter.config.default.yaml"; + // Shared across every ReadJson call. See JsonFunctions.cs for + // the rationale — a fresh JsonSerializerOptions per call + // re-emits LCG DynamicMethods into the loader heap, and the GC + // cannot reclaim them. + private static readonly JsonSerializerOptions _readOptions = new JsonSerializerOptions() + { + ReadCommentHandling = JsonCommentHandling.Skip + }; + /// /// An opaque token regenerated each time the configuration is saved, allowing consumers to detect that the configuration has changed. @@ -371,12 +380,7 @@ public static T ReadJson(string path = null) where T : AdapterApplicationConf var text = File.ReadAllText(configurationPath); if (!string.IsNullOrEmpty(text)) { - var options = new JsonSerializerOptions() - { - ReadCommentHandling = JsonCommentHandling.Skip - }; - - var configuration = JsonSerializer.Deserialize(text, options); + var configuration = JsonSerializer.Deserialize(text, _readOptions); configuration.Path = configurationPath; return configuration; } @@ -411,12 +415,7 @@ public static AdapterApplicationConfiguration ReadJson(Type type, string path = var text = File.ReadAllText(configurationPath); if (!string.IsNullOrEmpty(text)) { - var options = new JsonSerializerOptions() - { - ReadCommentHandling = JsonCommentHandling.Skip - }; - - var configuration = (AdapterApplicationConfiguration)JsonSerializer.Deserialize(text, type, options); + var configuration = (AdapterApplicationConfiguration)JsonSerializer.Deserialize(text, type, _readOptions); configuration.Path = configurationPath; return configuration; } diff --git a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs index fd873c6c7..ce979aeac 100644 --- a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs @@ -40,6 +40,15 @@ public class AgentConfiguration : IAgentConfiguration /// public const string DefaultYamlFilename = "agent.config.default.yaml"; + // Shared across every ReadJson call. See JsonFunctions.cs for + // the rationale — a fresh JsonSerializerOptions per call + // re-emits LCG DynamicMethods into the loader heap, and the GC + // cannot reclaim them. + private static readonly JsonSerializerOptions _readOptions = new JsonSerializerOptions() + { + ReadCommentHandling = JsonCommentHandling.Skip + }; + /// /// An opaque token regenerated each time the configuration is saved, allowing consumers to detect that the configuration has changed. @@ -277,12 +286,7 @@ public static T ReadJson(string path = null) where T : AgentConfiguration var text = File.ReadAllText(configurationPath); if (!string.IsNullOrEmpty(text)) { - var options = new JsonSerializerOptions() - { - ReadCommentHandling = JsonCommentHandling.Skip - }; - - var configuration = JsonSerializer.Deserialize(text, options); + var configuration = JsonSerializer.Deserialize(text, _readOptions); configuration.Path = configurationPath; return configuration; } @@ -317,12 +321,7 @@ public static AgentConfiguration ReadJson(Type type, string path = null) var text = File.ReadAllText(configurationPath); if (!string.IsNullOrEmpty(text)) { - var options = new JsonSerializerOptions() - { - ReadCommentHandling = JsonCommentHandling.Skip - }; - - var configuration = (AgentConfiguration)JsonSerializer.Deserialize(text, type, options); + var configuration = (AgentConfiguration)JsonSerializer.Deserialize(text, type, _readOptions); configuration.Path = configurationPath; return configuration; } diff --git a/libraries/MTConnect.NET-JSON-cppagent/JsonFunctions.cs b/libraries/MTConnect.NET-JSON-cppagent/JsonFunctions.cs index 046fd4f47..8229d3678 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/JsonFunctions.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/JsonFunctions.cs @@ -18,50 +18,264 @@ namespace MTConnect /// public static class JsonFunctions { + // A JsonSerializerOptions instance owns its own serialization + // metadata cache, and building that cache emits reflection-based + // property accessors (LCG DynamicMethods) for every property in + // the reachable type graph. Allocating a fresh instance per call + // therefore re-emits those accessors on every serialization, and + // the emitted code accumulates in the runtime's loader heaps + // where the GC cannot reclaim it — this is the mechanism behind + // the ~3.3 MB/h RSS climb observed on DIME production hosts in + // 2026-08. The cppagent-format assembly ships 25 Streams.Json + // classes (2.5× the plain-JSON count), so the LCG cost per + // serialization is proportionally larger. The instances below + // are created once and reused; JsonSerializerOptions is + // thread-safe for read after its first (de)serialization, and + // nothing in this file mutates them after construction. + private static readonly JsonSerializerOptions _defaultOptions = CreateOptions(false); + private static readonly JsonSerializerOptions _indentOptions = CreateOptions(true); + + static JsonFunctions() + { +#if NET8_0_OR_GREATER + // Warm the default reflection resolver + serialization + // metadata cache at assembly load rather than on the + // first production /current | /sample | /assets request. + // Serializing a real instance of each cppagent top-level + // response surrogate forces STJ to walk that type's + // reachable property graph, which is where the + // LCG DynamicMethod property accessors are emitted; paying + // that cost once at assembly load is the whole point of + // the warm-up. This matters more for the cppagent assembly + // than for the plain-JSON one — it ships 25 Streams.Json + // classes vs 10, so the reflection cost per fresh options + // is proportionally larger. + // + // A prior warm-up form (Serialize(null, options)) + // only bootstrapped the shared reflection resolver — it + // never named a concrete MTConnect type, so the cold LCG + // emit for JsonStreamsResponseDocument / + // JsonAssetsResponseDocument / JsonDevicesResponseDocument / + // ErrorResponseDocument still fell on the first user-facing + // request. The typed calls below fix that. + // ErrorResponseDocument (from MTConnect.Errors) is the + // surrogate-less envelope written directly by + // JsonHttpResponseDocumentFormatter.Format(IErrorResponseDocument); + // its cold LCG emit would otherwise hit on the first /probe + // error, /current parse failure, or unsupported device + // response — request paths that are already the symptom, + // not the happy path we want to pay reflection cost on top of. + // + // Order matters: the warm-up must run BEFORE MakeReadOnly, + // because MakeReadOnly(populateMissingResolver: false) + // freezes the options WITHOUT choosing a TypeInfoResolver; + // a subsequent Serialize on a resolver-less, frozen + // options would throw NotSupportedException. Running + // Serialize first lets STJ auto-populate the resolver via + // its normal lazy path, after which MakeReadOnly(false) + // is a pure lock with no side effect on serialization. + WarmReachableGraph(_defaultOptions); + WarmReachableGraph(_indentOptions); + + // Freeze both singletons so callers cannot mutate the + // shared instance (adding a Converter, flipping + // WriteIndented, etc.). Attempted mutation throws + // InvalidOperationException — the fail-fast is preferable + // to silent cross-caller pollution, and the cold-path + // Convert branch stays open because it builds a fresh + // (non-read-only) options object per call. + _defaultOptions.MakeReadOnly(populateMissingResolver: false); + _indentOptions.MakeReadOnly(populateMissingResolver: false); +#endif + } + +#if NET8_0_OR_GREATER + // Serialize an instance of each cppagent top-level response + // surrogate against , so STJ + // configures JsonTypeInfo (and emits the LCG DynamicMethod + // property accessors) for the reachable graph rooted at each + // type. Called from the static constructor for both the + // compact and indented option singletons. + // + // All three cppagent response envelopes expose public + // parameterless constructors for JSON deserialization, so the + // warm-up just news each one up and hands it to Serialize. + // ErrorResponseDocument (from MTConnect.Errors) is the fourth + // top-level envelope — no cppagent-specific surrogate, written + // by the formatter's IErrorResponseDocument overload directly. + // + // The Error envelope populates a concrete + // MTConnectErrorHeader + Error entry + Version, so STJ walks + // the runtime types the production path actually serializes + // (interface-typed properties resolve to their concrete + // implementations only when the value is non-null); a naked + // `new ErrorResponseDocument()` with null Header / Errors / + // Version would only warm ErrorResponseDocument's own + // accessors, leaving the first real /probe error or parse + // failure to pay a cold LCG emit on MTConnectErrorHeader (9 + // properties), Error (2), and System.Version (6) — the exact + // symptom class the singleton pattern exists to eliminate for + // the error path (F-IMP-C5-001). + private static void WarmReachableGraph(JsonSerializerOptions options) + { + JsonSerializer.Serialize(new Streams.Json.JsonStreamsResponseDocument(), options); + JsonSerializer.Serialize(new Assets.Json.JsonAssetsResponseDocument(), options); + JsonSerializer.Serialize(new Devices.Json.JsonDevicesResponseDocument(), options); + JsonSerializer.Serialize( + new Errors.ErrorResponseDocument + { + Header = new Headers.MTConnectErrorHeader(), + Errors = new[] { new Errors.Error() }, + // Any non-null concrete Version warms the + // System.Version accessors identically; using the + // repo's canonical constant instead of a magic + // `new Version(2, 5)` tuple keeps the warm-up + // aligned with the rest of the codebase's + // MTConnectVersion sourcing (F-CR-C6-001). + Version = MTConnectVersions.Version25 + }, + options); + } +#endif + /// - /// Default serializer options used when no indentOutput - /// option is requested. Produces compact JSON, omits properties - /// at their default value, allows numbers to be read from - /// strings, and ignores property-name casing. + /// Builds a fresh instance + /// with the cppagent option preset (compact / indented per + /// , default-value omission on net5+, + /// number-from-string reading on net5+, case-insensitive property + /// lookup, 1000-deep recursion limit). /// - public static JsonSerializerOptions DefaultOptions + /// + /// This helper is intended for two callers only: (1) the + /// static-init assignments that construct the shared + /// _defaultOptions and _indentOptions singletons, + /// and (2) the cold-path branch of when + /// a per-call converter forces a private options instance. Every + /// call allocates a new object and re-emits the STJ reflection + /// metadata cache for the reachable type graph — the very cost + /// the singletons exist to amortize — so it MUST NOT be invoked + /// on any hot-path serialization site. + /// + /// When true, sets WriteIndented = true; otherwise compact JSON. + private static JsonSerializerOptions CreateOptions(bool indented) { - get + return new JsonSerializerOptions { - return new JsonSerializerOptions - { - WriteIndented = false, + WriteIndented = indented, #if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + NumberHandling = JsonNumberHandling.AllowReadingFromString, #endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - } + PropertyNameCaseInsensitive = true, + MaxDepth = 1000 + }; + } + + /// + /// Resolves the instance + /// used by , + /// , and + /// for a given per-call converter + indentation combination. + /// + /// + /// Hot path ( is null, which is + /// every in-tree caller): returns the shared frozen singleton + /// (_defaultOptions or _indentOptions) so STJ + /// reuses its metadata cache instead of re-emitting property + /// accessors on every call. + /// + /// Cold path ( is non-null): + /// allocates a fresh via + /// , appends the caller's converter, + /// and returns it. This branch pays the full reflection-emit + /// cost per call and is NOT thread-safe under simultaneous cold + /// callers because the fresh options is neither shared nor + /// synchronized — each call allocates and mutates its own + /// object, so per-call concurrent use is safe; multiple callers + /// sharing one converter instance is safe iff the converter + /// itself is thread-safe. The branch exists solely to preserve + /// the public API contract for external consumers that pass a + /// per-call converter. + /// + /// Optional caller-supplied converter. When non-null forces the cold path. + /// Selects the compact or indented preset. + private static JsonSerializerOptions GetOptions(JsonConverter converter, bool indented) + { + // Hot path: no per-call converter, hand back the shared + // instance and let System.Text.Json reuse its metadata cache + // instead of re-emitting property accessors for the whole + // type graph on every call. + if (converter == null) return indented ? _indentOptions : _defaultOptions; + + // Cold path: a caller-supplied converter cannot be added to + // a shared instance once it has been used, so build a + // private one. Callers that mint many one-off converters + // will still pay the LCG cost — this fallback preserves the + // public API contract for external consumers. + var options = CreateOptions(indented); + options.Converters.Add(converter); + return options; } + /// + /// Default serializer options used when no indentOutput + /// option is requested. Produces compact JSON, omits properties + /// at their default value, allows numbers to be read from + /// strings, and ignores property-name casing. + /// + /// + /// This is a process-wide shared singleton; do NOT mutate the + /// returned instance's + /// collection or any writable property. The returned object is + /// marked MakeReadOnly() under net8.0 and later; + /// attempted mutation throws . + /// On older TFMs (netstandard2.0, net4.6.1–net4.8, net6.0, net7.0) + /// the instance is not statically frozen but callers must still + /// treat it as immutable — mutating it silently corrupts every + /// other in-process serializer that shares the singleton. + /// + /// Why the invariant matters: every mutation of a + /// instance that has already + /// been used forces System.Text.Json to rebuild its serialization + /// metadata cache, which re-emits reflection-based property + /// accessors as s + /// into the runtime's LCG (lightweight code generation) loader + /// heaps. Those heaps are never reclaimed by the GC — every + /// mutated-then-reused options object leaks the emit permanently. + /// A peer measured this class of misuse at +3.2–3.8 MB/h RSS in + /// production; the cppagent assembly ships 25 Streams.Json + /// classes (2.5× the plain-JSON count), so the per-mutation LCG + /// cost here is proportionally larger and the frozen singleton + /// pays back correspondingly more. + /// + public static JsonSerializerOptions DefaultOptions => _defaultOptions; + /// /// Pretty-printed serializer options used when the /// indentOutput formatter option is enabled; otherwise /// identical to . /// - public static JsonSerializerOptions IndentOptions - { - get - { - return new JsonSerializerOptions - { - WriteIndented = true, -#if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, -#endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - } - } + /// + /// This is a process-wide shared singleton; do NOT mutate the + /// returned instance's + /// collection or any writable property. The returned object is + /// marked MakeReadOnly() under net8.0 and later; + /// attempted mutation throws . + /// On older TFMs (netstandard2.0, net4.6.1–net4.8, net6.0, net7.0) + /// the instance is not statically frozen but callers must still + /// treat it as immutable — mutating it silently corrupts every + /// other in-process serializer that shares the singleton. + /// + /// Why the invariant matters: same reflection-emit / LCG + /// loader-heap accumulation as documented on + /// . Every per-call mutation of this + /// shared instance re-emits property-accessor + /// s into a + /// heap the GC cannot free — the mechanism behind the peer's + /// production +3.2–3.8 MB/h RSS climb before the singleton was + /// frozen. + /// + public static JsonSerializerOptions IndentOptions => _indentOptions; /// @@ -76,18 +290,7 @@ public static string Convert(object obj, JsonConverter converter = null, bool in { try { - var options = new JsonSerializerOptions - { - WriteIndented = indented, -#if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, -#endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - - if (converter != null) options.Converters.Add(converter); + var options = GetOptions(converter, indented); return JsonSerializer.Serialize(obj, options); } @@ -109,18 +312,7 @@ public static byte[] ConvertBytes(object obj, JsonConverter converter = null, bo { try { - var options = new JsonSerializerOptions - { - WriteIndented = indented, -#if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, -#endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - - if (converter != null) options.Converters.Add(converter); + var options = GetOptions(converter, indented); return JsonSerializer.SerializeToUtf8Bytes(obj, options); } @@ -143,18 +335,7 @@ public static Stream ConvertStream(object obj, JsonConverter converter = null, b { try { - var options = new JsonSerializerOptions - { - WriteIndented = indented, -#if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, -#endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - - if (converter != null) options.Converters.Add(converter); + var options = GetOptions(converter, indented); var outputStream = new MemoryStream(); JsonSerializer.Serialize(outputStream, obj, options); @@ -166,4 +347,4 @@ public static Stream ConvertStream(object obj, JsonConverter converter = null, b return null; } } -} \ No newline at end of file +} diff --git a/libraries/MTConnect.NET-JSON/Assets/JsonAssetsDocument.cs b/libraries/MTConnect.NET-JSON/Assets/JsonAssetsDocument.cs index e147db4b3..d5bb34909 100644 --- a/libraries/MTConnect.NET-JSON/Assets/JsonAssetsDocument.cs +++ b/libraries/MTConnect.NET-JSON/Assets/JsonAssetsDocument.cs @@ -43,6 +43,22 @@ public class JsonAssetsDocument /// , dispatching each asset to the /// surrogate that matches its type. /// + /// + /// This constructor MUST remain null-tolerant on + /// . The static-ctor + /// warm-up in MTConnect.JsonFunctions.WarmReachableGraph + /// calls new JsonAssetsDocument(null) to force + /// System.Text.Json to emit the LCG DynamicMethod property + /// accessors for the reachable Assets graph at assembly load, + /// avoiding a cold reflection-emit on the first user-facing + /// /assets request. Adding an + /// ArgumentNullException.ThrowIfNull(assetsDocument) + /// guard here would turn the first JSON serialization on + /// process start into a . + /// If the null-tolerance contract must change, update the + /// warm-up site atomically per §1.0d-trigies-bis + /// (F-IMP-C5-002). + /// public JsonAssetsDocument(IAssetsResponseDocument assetsDocument) { if (assetsDocument != null) diff --git a/libraries/MTConnect.NET-JSON/JsonFunctions.cs b/libraries/MTConnect.NET-JSON/JsonFunctions.cs index 908940911..d7f9ecee6 100644 --- a/libraries/MTConnect.NET-JSON/JsonFunctions.cs +++ b/libraries/MTConnect.NET-JSON/JsonFunctions.cs @@ -16,50 +16,259 @@ namespace MTConnect /// public static class JsonFunctions { + // A JsonSerializerOptions instance owns its own serialization + // metadata cache, and building that cache emits reflection-based + // property accessors (LCG DynamicMethods) for every property in + // the reachable type graph. Allocating a fresh instance per call + // therefore re-emits those accessors on every serialization, and + // the emitted code accumulates in the runtime's loader heaps + // where the GC cannot reclaim it — this is the mechanism behind + // the ~3.3 MB/h RSS climb observed on DIME production hosts in + // 2026-08. The instances below are created once and reused; + // JsonSerializerOptions is thread-safe for read after its first + // (de)serialization, and nothing in this file mutates them after + // construction. + private static readonly JsonSerializerOptions _defaultOptions = CreateOptions(false); + private static readonly JsonSerializerOptions _indentOptions = CreateOptions(true); + + static JsonFunctions() + { +#if NET8_0_OR_GREATER + // Warm the default reflection resolver + serialization + // metadata cache at assembly load rather than on the + // first production /current | /sample | /assets request. + // Serializing a real instance of each MTConnect top-level + // response surrogate forces STJ to walk that type's + // reachable property graph, which is where the + // LCG DynamicMethod property accessors are emitted; paying + // that cost once at assembly load is the whole point of + // the warm-up. + // + // A prior warm-up form (Serialize(null, options)) + // only bootstrapped the shared reflection resolver — it + // never named a concrete MTConnect type, so the cold LCG + // emit for JsonStreamsDocument / JsonAssetsDocument / + // JsonDevicesDocument / ErrorResponseDocument still fell + // on the first user-facing request. The typed calls below + // fix that. ErrorResponseDocument is the surrogate-less + // response envelope written directly by + // JsonResponseDocumentFormatter.Format(IErrorResponseDocument), + // so its cold LCG emit would otherwise hit on the first + // /probe error, /current parse failure, or unsupported + // device response — request paths that are already the + // symptom, not the happy path we want to pay reflection + // cost on top of. + // + // Order matters: the warm-up must run BEFORE MakeReadOnly, + // because MakeReadOnly(populateMissingResolver: false) + // freezes the options WITHOUT choosing a TypeInfoResolver; + // a subsequent Serialize on a resolver-less, frozen + // options would throw NotSupportedException. Running + // Serialize first lets STJ auto-populate the resolver via + // its normal lazy path, after which MakeReadOnly(false) + // is a pure lock with no side effect on serialization. + WarmReachableGraph(_defaultOptions); + WarmReachableGraph(_indentOptions); + + // Freeze both singletons so callers cannot mutate the + // shared instance (adding a Converter, flipping + // WriteIndented, etc.). Attempted mutation throws + // InvalidOperationException — the fail-fast is preferable + // to silent cross-caller pollution, and the cold-path + // Convert branch stays open because it builds a fresh + // (non-read-only) options object per call. + _defaultOptions.MakeReadOnly(populateMissingResolver: false); + _indentOptions.MakeReadOnly(populateMissingResolver: false); +#endif + } + +#if NET8_0_OR_GREATER + // Serialize an instance of each MTConnect top-level response + // surrogate against , so STJ + // configures JsonTypeInfo (and emits the LCG DynamicMethod + // property accessors) for the reachable graph rooted at each + // type. Called from the static constructor for both the + // compact and indented option singletons. + // + // JsonAssetsDocument has no public parameterless constructor; + // its single (IAssetsResponseDocument) overload tolerates a + // null argument and yields an instance with null Header + + // null Assets, which is enough to walk its declared property + // types. JsonStreamsDocument and JsonDevicesDocument both + // expose parameterless constructors for JSON deserialization. + // ErrorResponseDocument (MTConnect.Errors) is the fourth + // top-level response envelope — no surrogate wrapper, written + // by the formatter's IErrorResponseDocument overload directly. + // + // The Error envelope populates a concrete + // MTConnectErrorHeader + Error entry + Version, so STJ walks + // the runtime types the production path actually serializes + // (interface-typed properties resolve to their concrete + // implementations only when the value is non-null); a naked + // `new ErrorResponseDocument()` with null Header / Errors / + // Version would only warm ErrorResponseDocument's own + // accessors, leaving the first real /probe error or parse + // failure to pay a cold LCG emit on MTConnectErrorHeader (9 + // properties), Error (2), and System.Version (6) — the exact + // symptom class the singleton pattern exists to eliminate for + // the error path (F-IMP-C5-001). + private static void WarmReachableGraph(JsonSerializerOptions options) + { + JsonSerializer.Serialize(new Streams.Json.JsonStreamsDocument(), options); + JsonSerializer.Serialize(new Assets.Json.JsonAssetsDocument(null), options); + JsonSerializer.Serialize(new Devices.Json.JsonDevicesDocument(), options); + JsonSerializer.Serialize( + new Errors.ErrorResponseDocument + { + Header = new Headers.MTConnectErrorHeader(), + Errors = new[] { new Errors.Error() }, + // Any non-null concrete Version warms the + // System.Version accessors identically; using the + // repo's canonical constant instead of a magic + // `new Version(2, 5)` tuple keeps the warm-up + // aligned with the rest of the codebase's + // MTConnectVersion sourcing (F-CR-C6-001). + Version = MTConnectVersions.Version25 + }, + options); + } +#endif + /// - /// The default used by MTConnect - /// JSON serialization: compact output, default-valued properties - /// omitted on write (on net5+), numbers read from strings (on net5+), - /// case-insensitive property names, and a depth limit of 1000. + /// Builds a fresh instance + /// with the MTConnect option preset (compact / indented per + /// , default-value omission on net5+, + /// number-from-string reading on net5+, case-insensitive property + /// lookup, 1000-deep recursion limit). /// - public static JsonSerializerOptions DefaultOptions + /// + /// This helper is intended for two callers only: (1) the + /// static-init assignments that construct the shared + /// _defaultOptions and _indentOptions singletons, + /// and (2) the cold-path branch of when + /// a per-call converter forces a private options instance. Every + /// call allocates a new object and re-emits the STJ reflection + /// metadata cache for the reachable type graph — the very cost + /// the singletons exist to amortize — so it MUST NOT be invoked + /// on any hot-path serialization site. + /// + /// When true, sets WriteIndented = true; otherwise compact JSON. + private static JsonSerializerOptions CreateOptions(bool indented) { - get + return new JsonSerializerOptions { - return new JsonSerializerOptions - { - WriteIndented = false, + WriteIndented = indented, #if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + NumberHandling = JsonNumberHandling.AllowReadingFromString, #endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - } + PropertyNameCaseInsensitive = true, + MaxDepth = 1000 + }; } /// - /// The indented variant of , used when the - /// formatter's indentOutput option is set. + /// Resolves the instance + /// used by , + /// , and + /// for a given per-call converter + indentation combination. /// - public static JsonSerializerOptions IndentOptions + /// + /// Hot path ( is null, which is + /// every in-tree caller): returns the shared frozen singleton + /// (_defaultOptions or _indentOptions) so STJ + /// reuses its metadata cache instead of re-emitting property + /// accessors on every call. + /// + /// Cold path ( is non-null): + /// allocates a fresh via + /// , appends the caller's converter, + /// and returns it. This branch pays the full reflection-emit + /// cost per call and is NOT thread-safe under simultaneous cold + /// callers because the fresh options is neither shared nor + /// synchronized — each call allocates and mutates its own + /// object, so per-call concurrent use is safe; multiple callers + /// sharing one converter instance is safe iff the converter + /// itself is thread-safe. The branch exists solely to preserve + /// the public API contract for external consumers that pass a + /// per-call converter. + /// + /// Optional caller-supplied converter. When non-null forces the cold path. + /// Selects the compact or indented preset. + private static JsonSerializerOptions GetOptions(JsonConverter converter, bool indented) { - get - { - return new JsonSerializerOptions - { - WriteIndented = true, -#if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, -#endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - } + // Hot path: no per-call converter, hand back the shared + // instance and let System.Text.Json reuse its metadata cache + // instead of re-emitting property accessors for the whole + // type graph on every call. + if (converter == null) return indented ? _indentOptions : _defaultOptions; + + // Cold path: a caller-supplied converter cannot be added to + // a shared instance once it has been used, so build a + // private one. Callers that mint many one-off converters + // will still pay the LCG cost — this fallback preserves the + // public API contract for external consumers. + var options = CreateOptions(indented); + options.Converters.Add(converter); + return options; } + /// + /// The default used by MTConnect + /// JSON serialization: compact output, default-valued properties + /// omitted on write (on net5+), numbers read from strings (on net5+), + /// case-insensitive property names, and a depth limit of 1000. + /// + /// + /// This is a process-wide shared singleton; do NOT mutate the + /// returned instance's + /// collection or any writable property. The returned object is + /// marked MakeReadOnly() under net8.0 and later; + /// attempted mutation throws . + /// On older TFMs (netstandard2.0, net4.6.1–net4.8, net6.0, net7.0) + /// the instance is not statically frozen but callers must still + /// treat it as immutable — mutating it silently corrupts every + /// other in-process serializer that shares the singleton. + /// + /// Why the invariant matters: every mutation of a + /// instance that has already + /// been used forces System.Text.Json to rebuild its serialization + /// metadata cache, which re-emits reflection-based property + /// accessors as s + /// into the runtime's LCG (lightweight code generation) loader + /// heaps. Those heaps are never reclaimed by the GC — every + /// mutated-then-reused options object leaks the emit permanently. + /// A peer measured this class of misuse at +3.2–3.8 MB/h RSS in + /// production, which is what the frozen singleton eliminates. + /// + public static JsonSerializerOptions DefaultOptions => _defaultOptions; + + /// + /// The indented variant of , used when the + /// formatter's indentOutput option is set. + /// + /// + /// This is a process-wide shared singleton; do NOT mutate the + /// returned instance's + /// collection or any writable property. The returned object is + /// marked MakeReadOnly() under net8.0 and later; + /// attempted mutation throws . + /// On older TFMs (netstandard2.0, net4.6.1–net4.8, net6.0, net7.0) + /// the instance is not statically frozen but callers must still + /// treat it as immutable — mutating it silently corrupts every + /// other in-process serializer that shares the singleton. + /// + /// Why the invariant matters: same reflection-emit / LCG + /// loader-heap accumulation as documented on + /// . Every per-call mutation of this + /// shared instance re-emits property-accessor + /// s into a + /// heap the GC cannot free — the mechanism behind the peer's + /// production +3.2–3.8 MB/h RSS climb before the singleton was + /// frozen. + /// + public static JsonSerializerOptions IndentOptions => _indentOptions; + /// /// Formats as a round-trip ISO 8601 @@ -97,18 +306,7 @@ public static string Convert(object obj, JsonConverter converter = null, bool in { try { - var options = new JsonSerializerOptions - { - WriteIndented = indented, -#if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, -#endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - - if (converter != null) options.Converters.Add(converter); + var options = GetOptions(converter, indented); return JsonSerializer.Serialize(obj, options); } @@ -130,18 +328,7 @@ public static byte[] ConvertBytes(object obj, JsonConverter converter = null, bo { try { - var options = new JsonSerializerOptions - { - WriteIndented = indented, -#if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, -#endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - - if (converter != null) options.Converters.Add(converter); + var options = GetOptions(converter, indented); return JsonSerializer.SerializeToUtf8Bytes(obj, options); } @@ -163,18 +350,7 @@ public static Stream ConvertStream(object obj, JsonConverter converter = null, b { try { - var options = new JsonSerializerOptions - { - WriteIndented = indented, -#if NET5_0_OR_GREATER - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, - NumberHandling = JsonNumberHandling.AllowReadingFromString, -#endif - PropertyNameCaseInsensitive = true, - MaxDepth = 1000 - }; - - if (converter != null) options.Converters.Add(converter); + var options = GetOptions(converter, indented); var outputStream = new MemoryStream(); JsonSerializer.Serialize(outputStream, obj, options); @@ -186,4 +362,4 @@ public static Stream ConvertStream(object obj, JsonConverter converter = null, b return null; } } -} \ No newline at end of file +} diff --git a/libraries/MTConnect.NET-MQTT/MTConnectMqttMessage.cs b/libraries/MTConnect.NET-MQTT/MTConnectMqttMessage.cs index feb833e98..9007412ba 100644 --- a/libraries/MTConnect.NET-MQTT/MTConnectMqttMessage.cs +++ b/libraries/MTConnect.NET-MQTT/MTConnectMqttMessage.cs @@ -23,6 +23,12 @@ namespace MTConnect.Mqtt /// public static class MTConnectMqttMessage { + // Shared across every agent-information publish. See + // JsonFunctions.cs for the rationale — a fresh + // JsonSerializerOptions per call re-emits LCG DynamicMethods + // into the loader heap, and the GC cannot reclaim them. + private static readonly JsonSerializerOptions _agentInformationOptions = new JsonSerializerOptions { WriteIndented = true }; + private static MqttApplicationMessage CreateMessage(string topic, string payload, bool retain = false) { try @@ -91,7 +97,7 @@ public static IEnumerable Create(IMTConnectAgent agent, } var topic = $"MTConnect/Agents/{agent.Uuid}/Information"; - var json = JsonSerializer.Serialize(information, new JsonSerializerOptions { WriteIndented = true }); + var json = JsonSerializer.Serialize(information, _agentInformationOptions); messages.Add(CreateMessage(topic, json, retain)); } diff --git a/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/Regressions/MTConnectMqttMessageSingletonTests.cs b/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/Regressions/MTConnectMqttMessageSingletonTests.cs new file mode 100644 index 000000000..ab1543195 --- /dev/null +++ b/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/Regressions/MTConnectMqttMessageSingletonTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using MTConnect.Mqtt; +using NUnit.Framework; + +namespace MTConnect.AgentModule.MqttRelay.Tests.Regressions +{ + /// + /// Sibling-site structural pin for the DIME-connector native-heap leak + /// (peer diagnosis dated 2026-08-21). + /// (in MTConnect.NET-MQTT) is the fifth per-call-new JsonSerializerOptions + /// site flipped to a shared static readonly field in PR #249; it lives in + /// a library MTConnect.NET-Common-Tests does not reference, so its + /// structural pin lives here in the MqttRelay test project which + /// transitively references MTConnect.NET-MQTT. + /// + /// Hosted under an MQTT-flavored publisher path — every /agent + /// information republish flows through + /// MTConnectMqttMessage.CreateAgentInformation, and its + /// _agentInformationOptions must be a shared singleton, not a + /// per-call allocation. See the sibling fixture + /// JsonSerializerOptionsSiblingSingletonTests in + /// MTConnect.NET-Common-Tests for the rationale and the twin + /// pins on the four Common sibling sites. + /// + [TestFixture] + public class MTConnectMqttMessageSingletonTests + { + /// + /// Pin: serializes agent + /// information into every /Agents/{uuid}/Information republish. + /// Its _agentInformationOptions must be a shared singleton, + /// not a per-call allocation, so the LCG DynamicMethod + /// property-accessor emit is paid once at assembly load rather + /// than once per publish. + /// + [Test] + public void MTConnectMqttMessage_holds_a_static_readonly_JsonSerializerOptions_field() + { + var fields = typeof(MTConnectMqttMessage).GetFields( + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + var optionsFields = System.Array.FindAll( + fields, + f => f.FieldType == typeof(JsonSerializerOptions) && f.IsInitOnly); + Assert.That(optionsFields.Length, Is.GreaterThanOrEqualTo(1), + "MTConnectMqttMessage must declare at least one static readonly JsonSerializerOptions field so " + + "the instance outlives each publish. A per-call `new JsonSerializerOptions(...)` re-emits LCG " + + "DynamicMethod property accessors on every call, and those emits accumulate in the runtime's " + + "loader heap where the GC cannot reclaim them (+3.2-3.8 MB/h RSS in production)."); + } + } +} diff --git a/tests/MTConnect.NET-Common-Tests/Regressions/JsonSerializerOptionsSiblingSingletonTests.cs b/tests/MTConnect.NET-Common-Tests/Regressions/JsonSerializerOptionsSiblingSingletonTests.cs new file mode 100644 index 000000000..25deeeaac --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Regressions/JsonSerializerOptionsSiblingSingletonTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using MTConnect.Agents; +using MTConnect.Buffers; +using MTConnect.Clients; +using MTConnect.Configurations; +using NUnit.Framework; + +namespace MTConnect.NET_Common_Tests.Regressions +{ + /// + /// Sibling-site structural pin for the DIME-connector native-heap leak + /// (peer diagnosis dated 2026-08-21). The primary singleton refactor + /// landed on MTConnect.NET-JSON.JsonFunctions and its cppagent + /// twin, but the same per-call new JsonSerializerOptions(...) + /// misuse existed at four other sites inside MTConnect.NET-Common that + /// also allocate an options object once per Save / Read / Write call: + /// , + /// , + /// , + /// , and + /// . Each was flipped to a + /// private static readonly JsonSerializerOptions field in + /// PR #249 so the LCG DynamicMethod property-accessor emit is paid + /// once per assembly-load instead of once per call. + /// + /// This fixture pins the STRUCTURAL shape (a private static + /// readonly JsonSerializerOptions field exists) rather than an + /// instance-identity via ReferenceEquals, because the fields + /// are private and un-exposed. A regression that either + /// (a) removes the field and re-inlines new JsonSerializerOptions(...) + /// per call, or (b) flips the field to instance / non-readonly, would + /// silently reintroduce the LCG leak; each pin below fails cleanly + /// on such a refactor. The MTConnect.NET-MQTT sibling + /// (MTConnectMqttMessage._agentInformationOptions) is pinned + /// in the MqttRelay test project because MTConnect.NET-Common-Tests + /// does not reference MTConnect.NET-MQTT. + /// + [TestFixture] + public class JsonSerializerOptionsSiblingSingletonTests + { + private static void AssertHoldsStaticReadonlyJsonSerializerOptionsField(System.Type type) + { + var fields = type.GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + var optionsFields = System.Array.FindAll( + fields, + f => f.FieldType == typeof(JsonSerializerOptions) && f.IsInitOnly); + Assert.That(optionsFields.Length, Is.GreaterThanOrEqualTo(1), + $"{type.FullName} must declare at least one static readonly JsonSerializerOptions field so " + + "the instance outlives each serialization call. A per-call `new JsonSerializerOptions(...)` " + + "re-emits LCG DynamicMethod property accessors on every call, and those emits accumulate in " + + "the runtime's loader heap where the GC cannot reclaim them (+3.2-3.8 MB/h RSS in production)."); + } + + /// + /// Pin: serializes itself to + /// disk on every Save call. Its _saveOptions must be a shared + /// singleton, not a per-call allocation. + /// + [Test] + public void MTConnectAgentInformation_holds_a_static_readonly_JsonSerializerOptions_field() + { + AssertHoldsStaticReadonlyJsonSerializerOptionsField(typeof(MTConnectAgentInformation)); + } + + /// + /// Pin: mirrors the Agent + /// information persistence pattern client-side. Its _saveOptions + /// must be a shared singleton, not a per-call allocation. + /// + [Test] + public void MTConnectClientInformation_holds_a_static_readonly_JsonSerializerOptions_field() + { + AssertHoldsStaticReadonlyJsonSerializerOptionsField(typeof(MTConnectClientInformation)); + } + + /// + /// Pin: writes one JSON file + /// per asset — potentially many per second under load. Its + /// _writeOptions must be a shared singleton, not a per-call + /// allocation. + /// + [Test] + public void MTConnectAssetFileBuffer_holds_a_static_readonly_JsonSerializerOptions_field() + { + AssertHoldsStaticReadonlyJsonSerializerOptionsField(typeof(MTConnectAssetFileBuffer)); + } + + /// + /// Pin: deserializes + /// its config file on startup and on every reload. Its + /// _readOptions must be a shared singleton, not a per-call + /// allocation — hot-reload watchers can trigger many parses per + /// minute. + /// + [Test] + public void AdapterApplicationConfiguration_holds_a_static_readonly_JsonSerializerOptions_field() + { + AssertHoldsStaticReadonlyJsonSerializerOptionsField(typeof(AdapterApplicationConfiguration)); + } + + /// + /// Pin: deserializes its config file + /// on startup and on every reload. Same singleton discipline as + /// . + /// + [Test] + public void AgentConfiguration_holds_a_static_readonly_JsonSerializerOptions_field() + { + AssertHoldsStaticReadonlyJsonSerializerOptionsField(typeof(AgentConfiguration)); + } + } +} diff --git a/tests/MTConnect.NET-JSON-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs b/tests/MTConnect.NET-JSON-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs new file mode 100644 index 000000000..aa9728b52 --- /dev/null +++ b/tests/MTConnect.NET-JSON-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs @@ -0,0 +1,580 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace MTConnect.NET_JSON_Tests.Regressions +{ + /// + /// Regression pin for the DIME-connector native-heap leak (peer + /// diagnosis dated 2026-08-21). A fresh + /// instance owns its own serialization-metadata cache; building that + /// cache emits reflection-based property accessors for the entire type + /// graph, and the emitted code accumulates in the runtime's loader + /// heaps where the GC cannot reclaim it. Allocating one on every + /// serialization call therefore leaked ~3.3 MB/h RSS in production + /// (tempco-001, tim-001). + /// + /// The and + /// properties must therefore + /// return the same instance on every access, and the + /// Convert/ConvertBytes/ConvertStream hot paths + /// must reuse those instances when no per-call converter is supplied. + /// + [TestFixture] + public class JsonSerializerOptionsSingletonTests + { + // Small POCO used by the Convert/ConvertBytes/ConvertStream overload + // smoke tests and the thread-safety pin. Kept tiny on purpose so + // the metadata cache warms in a single JIT pass and each thread's + // work is bounded, but with a nested type so the reachable-graph + // JIT emit still runs on cold DefaultOptions instances. + internal class SamplePayload + { + public string Name { get; set; } = "sample"; + public int Count { get; set; } = 42; + public SampleChild Child { get; set; } = new SampleChild(); + } + + internal class SampleChild + { + public string Label { get; set; } = "child"; + public double Value { get; set; } = 3.14; + } + + private sealed class NoopConverter : JsonConverter + { + public override SamplePayload Read(ref Utf8JsonReader reader, System.Type typeToConvert, JsonSerializerOptions options) + => new SamplePayload(); + + public override void Write(Utf8JsonWriter writer, SamplePayload value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("Name", value.Name); + writer.WriteNumber("Count", value.Count); + writer.WriteEndObject(); + } + } + + /// Pins the behavior expressed by the test name: default options returns the same instance on repeat access. + [Test] + public void DefaultOptions_returns_the_same_instance_on_repeat_access() + { + var a = JsonFunctions.DefaultOptions; + var b = JsonFunctions.DefaultOptions; + Assert.That(ReferenceEquals(a, b), Is.True, + "JsonFunctions.DefaultOptions must be a shared singleton — a fresh JsonSerializerOptions per call leaks LCG-emitted metadata into the loader heap."); + } + + /// Pins the behavior expressed by the test name: indent options returns the same instance on repeat access. + [Test] + public void IndentOptions_returns_the_same_instance_on_repeat_access() + { + var a = JsonFunctions.IndentOptions; + var b = JsonFunctions.IndentOptions; + Assert.That(ReferenceEquals(a, b), Is.True, + "JsonFunctions.IndentOptions must be a shared singleton — a fresh JsonSerializerOptions per call leaks LCG-emitted metadata into the loader heap."); + } + + /// Pins the behavior expressed by the test name: default options and indent options are distinct instances. + [Test] + public void DefaultOptions_and_IndentOptions_are_distinct_instances() + { + Assert.That(ReferenceEquals(JsonFunctions.DefaultOptions, JsonFunctions.IndentOptions), Is.False, + "DefaultOptions (compact) and IndentOptions (pretty-printed) must be separate instances so WriteIndented differs."); + } + + /// Pins the behavior expressed by the test name: default options write indented is false. + [Test] + public void DefaultOptions_WriteIndented_is_false() + { + Assert.That(JsonFunctions.DefaultOptions.WriteIndented, Is.False); + } + + /// Pins the behavior expressed by the test name: indent options write indented is true. + [Test] + public void IndentOptions_WriteIndented_is_true() + { + Assert.That(JsonFunctions.IndentOptions.WriteIndented, Is.True); + } + + /// Pins the behavior expressed by the test name: json functions holds a static readonly options field. + [Test] + public void JsonFunctions_holds_a_static_readonly_options_field() + { + // Structural guard: at least one static readonly field of type + // JsonSerializerOptions must exist on JsonFunctions so the + // shared instance survives across serialization calls. + var fields = typeof(JsonFunctions).GetFields(BindingFlags.NonPublic | BindingFlags.Static); + var optionsFields = System.Array.FindAll(fields, f => f.FieldType == typeof(JsonSerializerOptions) && f.IsInitOnly); + Assert.That(optionsFields.Length, Is.GreaterThanOrEqualTo(2), + "JsonFunctions must declare shared static readonly JsonSerializerOptions fields (compact + indented) so the instances outlive each serialization call."); + } + + /// + /// Thread-safety pin: 100 concurrent Convert calls against the shared + /// DefaultOptions must all succeed, must not throw, and must produce + /// byte-identical output. JsonSerializerOptions is documented as + /// thread-safe for read after its first (de)serialization call, so + /// concurrent Serialize calls sharing one instance is legal by + /// contract — this test pins that contract against any future + /// refactor that would put a mutation on the hot path. + /// + [Test] + public void Convert_is_thread_safe_across_100_concurrent_callers() + { + const int threadCount = 100; + var payload = new SamplePayload(); + var outputs = new ConcurrentBag(); + var exceptions = new ConcurrentBag(); + var startGate = new ManualResetEventSlim(false); + + var threads = new Thread[threadCount]; + for (int i = 0; i < threadCount; i++) + { + threads[i] = new Thread(() => + { + try + { + startGate.Wait(); + var s = JsonFunctions.Convert(payload); + outputs.Add(s); + } + catch (System.Exception ex) + { + exceptions.Add(ex); + } + }); + threads[i].Start(); + } + + // Warm the singleton once on the driver thread so the first-use + // metadata build is not itself concurrent with the race window. + // The point of the pin is steady-state hot-path concurrency, not + // first-touch racing (STJ handles that internally). + _ = JsonFunctions.Convert(payload); + + startGate.Set(); + foreach (var t in threads) t.Join(); + + Assert.That(exceptions, Is.Empty, + "Concurrent Convert calls against the shared DefaultOptions must not throw."); + Assert.That(outputs.Count, Is.EqualTo(threadCount)); + + var canonical = JsonFunctions.Convert(payload); + foreach (var s in outputs) + { + Assert.That(s, Is.EqualTo(canonical), + "Every concurrent Convert output must be byte-identical to the single-threaded canonical output."); + } + } + + /// + /// Cold-path pin: when a caller supplies a per-call converter, the + /// implementation must NOT append that converter to the shared + /// DefaultOptions.Converters collection. Doing so would (a) permanently + /// pollute the singleton and (b) throw InvalidOperationException + /// because JsonSerializerOptions freezes its converter list after + /// first use. The pin observes the singleton before and after a + /// converter-supplied Convert call and asserts the count is unchanged. + /// + [Test] + public void Convert_with_custom_converter_does_not_mutate_DefaultOptions_converters() + { + // Warm the singleton so its converters list is frozen — if the + // implementation ever tried to append to it, the test would + // observe an exception path rather than silent pollution. + _ = JsonFunctions.Convert(new SamplePayload()); + + var beforeCount = JsonFunctions.DefaultOptions.Converters.Count; + var beforeIndentCount = JsonFunctions.IndentOptions.Converters.Count; + + var converter = new NoopConverter(); + var result = JsonFunctions.Convert(new SamplePayload(), converter); + var resultIndented = JsonFunctions.Convert(new SamplePayload(), converter, indented: true); + + Assert.That(result, Is.Not.Null, + "Convert with a custom converter must succeed via the cold-path fresh-options branch."); + Assert.That(resultIndented, Is.Not.Null, + "Convert(indented:true) with a custom converter must succeed via the cold-path fresh-options branch."); + + Assert.That(JsonFunctions.DefaultOptions.Converters.Count, Is.EqualTo(beforeCount), + "The cold-path branch must build a fresh JsonSerializerOptions — a caller-supplied converter must never leak into DefaultOptions.Converters."); + Assert.That(JsonFunctions.IndentOptions.Converters.Count, Is.EqualTo(beforeIndentCount), + "The cold-path branch must build a fresh JsonSerializerOptions — a caller-supplied converter must never leak into IndentOptions.Converters."); + } + + /// + /// Cold-path pin: the custom-converter Convert output must reflect + /// the caller's converter (proving the fresh options object used + /// it), while a following converter-less Convert on the same input + /// must NOT reflect the converter (proving the singleton was untouched). + /// + [Test] + public void Convert_with_custom_converter_uses_converter_without_affecting_singleton_output() + { + var payload = new SamplePayload(); + var canonicalWithoutConverter = JsonFunctions.Convert(payload); + + var converter = new NoopConverter(); + var withConverter = JsonFunctions.Convert(payload, converter); + + // The custom converter drops the Child property, so its output + // must not equal the canonical singleton-emitted output. + Assert.That(withConverter, Is.Not.EqualTo(canonicalWithoutConverter), + "The cold-path fresh options must apply the caller's converter — otherwise the singleton was reused, defeating the branch."); + Assert.That(withConverter, Does.Not.Contain("Child"), + "NoopConverter drops the Child field; the cold-path output should reflect that."); + + // Following singleton-path call must be untouched. + var afterCanonical = JsonFunctions.Convert(payload); + Assert.That(afterCanonical, Is.EqualTo(canonicalWithoutConverter), + "After a converter-supplied call, the singleton-path output must be byte-identical to before."); + Assert.That(afterCanonical, Does.Contain("Child"), + "The singleton must still emit the Child field — the cold-path converter must not have polluted it."); + } + + /// + /// Overload smoke pin: Convert/ConvertBytes/ConvertStream must all + /// produce the same JSON for the same input under the compact + /// preset, and the indented variants must be internally consistent. + /// This guards against a future refactor that accidentally routes + /// one overload through a divergent options instance. + /// + [Test] + public void Convert_ConvertBytes_ConvertStream_produce_identical_output_for_compact() + { + var payload = new SamplePayload(); + + var s = JsonFunctions.Convert(payload); + var bytes = JsonFunctions.ConvertBytes(payload); + var stream = JsonFunctions.ConvertStream(payload); + + Assert.That(s, Is.Not.Null); + Assert.That(bytes, Is.Not.Null); + Assert.That(stream, Is.Not.Null); + + var bytesString = Encoding.UTF8.GetString(bytes!); + Assert.That(bytesString, Is.EqualTo(s), + "ConvertBytes(UTF-8) must decode to the same string ConvertBytes emits."); + + stream!.Position = 0; + using var reader = new System.IO.StreamReader(stream); + var streamString = reader.ReadToEnd(); + Assert.That(streamString, Is.EqualTo(s), + "ConvertStream must contain the same JSON Convert returns."); + } + + /// Overload smoke pin — indented variant of the same guard. + [Test] + public void Convert_ConvertBytes_ConvertStream_produce_identical_output_for_indented() + { + var payload = new SamplePayload(); + + var s = JsonFunctions.Convert(payload, indented: true); + var bytes = JsonFunctions.ConvertBytes(payload, indented: true); + var stream = JsonFunctions.ConvertStream(payload, indented: true); + + Assert.That(s, Is.Not.Null); + Assert.That(bytes, Is.Not.Null); + Assert.That(stream, Is.Not.Null); + + var bytesString = Encoding.UTF8.GetString(bytes!); + Assert.That(bytesString, Is.EqualTo(s)); + + stream!.Position = 0; + using var reader = new System.IO.StreamReader(stream); + var streamString = reader.ReadToEnd(); + Assert.That(streamString, Is.EqualTo(s)); + + // Cross-check indentation is present (crude, but sufficient + // to pin that IndentOptions actually took effect on all + // three overloads). + Assert.That(s, Does.Contain("\n"), + "Indented Convert output must contain newlines."); + Assert.That(bytesString, Does.Contain("\n"), + "Indented ConvertBytes output must contain newlines."); + Assert.That(streamString, Does.Contain("\n"), + "Indented ConvertStream output must contain newlines."); + } + + /// + /// Null-input pin: every overload must swallow a null input and + /// return null / null / null — this is documented behavior and + /// the singleton refactor must preserve it. + /// + [Test] + public void Convert_overloads_return_null_for_null_input() + { + Assert.That(JsonFunctions.Convert(null), Is.Null); + Assert.That(JsonFunctions.ConvertBytes(null), Is.Null); + Assert.That(JsonFunctions.ConvertStream(null), Is.Null); + } + + /// + /// Async-throughput pin: 100 parallel Convert calls via + /// Parallel.For to complement the Thread-based pin above. The + /// ThreadPool scheduling differs from raw Thread scheduling, and + /// System.Text.Json's internal metadata-lock behavior has + /// historically been sensitive to the difference; both paths are + /// pinned so future STJ upgrades are covered. + /// + [Test] + public void Convert_is_thread_safe_under_ParallelFor() + { + _ = JsonFunctions.Convert(new SamplePayload()); // warm + + var canonical = JsonFunctions.Convert(new SamplePayload()); + var results = new ConcurrentBag(); + + Parallel.For(0, 100, _ => + { + var s = JsonFunctions.Convert(new SamplePayload()); + results.Add(s); + }); + + Assert.That(results.Count, Is.EqualTo(100)); + foreach (var r in results) + { + Assert.That(r, Is.EqualTo(canonical)); + } + } + +#if NET8_0_OR_GREATER + /// + /// Freeze pin (net8+): the shared DefaultOptions and IndentOptions + /// singletons must be marked read-only at static-ctor time, so any + /// attempt to mutate their + /// collection throws . + /// The freeze is the enforcement half of the singleton pattern — + /// documentation alone would let a careless caller silently pollute + /// every other in-process serializer; the read-only lock makes the + /// misuse fail loudly at the point of the offending Add. + /// + [Test] + public void DefaultOptions_Converters_Add_throws_InvalidOperationException_when_frozen() + { + Assert.Throws( + () => JsonFunctions.DefaultOptions.Converters.Add(new NoopConverter()), + "DefaultOptions is a shared singleton and must be frozen on net8+ so a stray Converters.Add fails fast rather than silently mutating the process-wide instance."); + } + + /// + /// Freeze pin (net8+) — IndentOptions mirror of the DefaultOptions guard. + /// + [Test] + public void IndentOptions_Converters_Add_throws_InvalidOperationException_when_frozen() + { + Assert.Throws( + () => JsonFunctions.IndentOptions.Converters.Add(new NoopConverter()), + "IndentOptions is a shared singleton and must be frozen on net8+ so a stray Converters.Add fails fast rather than silently mutating the process-wide instance."); + } + + /// + /// Warm-up-before-freeze ordering pin (net8+): the frozen singleton + /// must still be able to serialize a real typed payload, proving that + /// the static-ctor warm-up (typed JsonSerializer.Serialize + /// calls against each MTConnect top-level response surrogate — see + /// JsonFunctions.WarmReachableGraph) ran BEFORE + /// MakeReadOnly(populateMissingResolver: false). If a future + /// refactor reordered the two calls — or removed the warm-up + /// entirely — the frozen, resolver-less options would throw + /// on the first real-payload + /// Serialize. The invariant is documented in the JsonFunctions static + /// ctor comment ("Order matters: the warm-up must run BEFORE + /// MakeReadOnly"); this test names it as a regression pin. + /// + /// Calls + /// directly rather than via because + /// Convert's catch-all silently swallows serialization exceptions into + /// a null return — routing through it would degrade a diagnostic + /// "NotSupportedException at Serialize" into an opaque "Expected: not null". + /// + [Test] + public void Frozen_singleton_can_serialize_a_real_payload_proving_warm_up_ran_before_MakeReadOnly() + { + var payload = new SamplePayload(); + + string compact = string.Empty; + string indented = string.Empty; + Assert.DoesNotThrow( + () => compact = JsonSerializer.Serialize(payload, JsonFunctions.DefaultOptions), + "Frozen DefaultOptions must have a populated TypeInfoResolver — a static-ctor reorder that runs MakeReadOnly BEFORE the WarmReachableGraph typed-Serialize calls would surface here as NotSupportedException."); + Assert.DoesNotThrow( + () => indented = JsonSerializer.Serialize(payload, JsonFunctions.IndentOptions), + "Frozen IndentOptions must have a populated TypeInfoResolver — same warm-up-before-freeze invariant as DefaultOptions."); + + Assert.That(compact, Does.Contain("\"Name\""), + "The warmed-and-frozen singleton must still emit real property data — a resolver-less frozen options would either throw or emit empty output."); + Assert.That(indented, Does.Contain("\"Name\"")); + } + + /// + /// Structural warm-up-coverage pin (net8+) — WarmReachableGraph's + /// IL must contain a newobj instruction constructing + /// , the fourth + /// top-level response envelope written directly by + /// JsonResponseDocumentFormatter.Format(IErrorResponseDocument, ...) + /// without a Json* surrogate wrapper. + /// + /// A runtime Assert.DoesNotThrow(() => Serialize(new ErrorResponseDocument(), frozen)) + /// check is tautological here: once the TypeInfoResolver is set (which + /// any preceding warm-up call does), STJ's + /// lazily populates + /// on frozen options for arbitrary types — the frozen state only locks + /// the options' configuration surface (Converters, DefaultIgnoreCondition, + /// etc.), not the internal metadata cache. Verified empirically: + /// mutating out the ErrorResponseDocument warm-up line and running + /// the "can it serialize" pin still passes (see cycle-5 test-coverage-audit + /// mutation test 2026-08-21). The performance invariant the warm-up + /// enforces — pay the LCG DynamicMethod emit for ErrorResponseDocument + /// at assembly load, not on the first /probe error / parse-failure + /// request — is therefore only observable at the source-of-truth level: + /// the IL of WarmReachableGraph. + /// + [Test] + public void WarmReachableGraph_IL_contains_newobj_for_ErrorResponseDocument_ctor() + { + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Errors.ErrorResponseDocument)); + } + + /// + /// Structural warm-up-coverage pin (net8+) — companion assertion that + /// each of the three Json* top-level response surrogates is also + /// news-up-ed by WarmReachableGraph. Mirrors the ErrorResponseDocument + /// pin above; the same tautology argument applies to per-type + /// "can it serialize" runtime checks. + /// + [Test] + public void WarmReachableGraph_IL_contains_newobj_for_every_top_level_response_surrogate() + { + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Streams.Json.JsonStreamsDocument)); + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Assets.Json.JsonAssetsDocument)); + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Devices.Json.JsonDevicesDocument)); + } + + /// + /// Structural warm-up-coverage pin (net8+) — companion assertion for + /// the F-IMP-C5-001 fix that populated the Error envelope with a + /// concrete + + /// + . + /// STJ resolves accessors for interface-typed properties + /// (IMTConnectErrorHeader Header, IEnumerable<IError> Errors) + /// only when the value is non-null; a revert to a naked + /// new ErrorResponseDocument() would silently pass the + /// ErrorResponseDocument-only IL pin above while re-opening the + /// exact LCG-emit-on-first-error class F-IMP-C5-001 closed — + /// MTConnectErrorHeader (9 properties), Error (2), and + /// System.Version (6) would each pay their first cold accessor + /// emit on the first real /probe error or parse failure. + /// The three assertions below are the source-of-truth evidence + /// that the populated warm-up shape survives. + /// + /// The walker () + /// accepts either a newobj producing an instance of the + /// expected type OR a ldsfld loading a static field of + /// that type — semantically equivalent for warm-up, because STJ + /// walks the runtime type of whatever value the caller passes + /// into + /// regardless of how the instance was produced. The current + /// warm-up uses MTConnectVersions.Version25 (a static + /// field of type ) rather than a + /// magic new System.Version(2, 5) literal (F-CR-C6-001); + /// dropping either producer would fail the pin. + /// + /// System.Version is not news-up-ed OR ldsfld-loaded by the + /// three surrogate envelopes' Header defaulting (they use their + /// own concrete MTConnectStreamsHeader / + /// MTConnectDevicesHeader / MTConnectAssetsHeader + /// and initialize the Version property only after construction), + /// so the walker match on System.Version uniquely + /// fingerprints the Error-envelope initializer. + /// + [Test] + public void WarmReachableGraph_IL_contains_newobj_for_concrete_Error_envelope_fields() + { + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Headers.MTConnectErrorHeader)); + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Errors.Error)); + AssertWarmReachableGraphNewsUp(typeof(System.Version)); + } + + // IL walker: proves JsonFunctions.WarmReachableGraph feeds a concrete + // instance of to STJ by locating EITHER a + // `newobj ` (fresh allocation) OR a + // `ldsfld ` (canonical-constant load) + // in the method body. Runs on the compiled test assembly's view of + // MTConnect.NET-JSON, so a source change that removes both producers + // is caught deterministically at test time. + // + // Scans for the 5-byte patterns <4-byte-token> and + // <4-byte-token>. Full opcode-length decoding is + // unnecessary: a spurious match would need a non-target opcode at + // byte i whose following 4 bytes coincidentally decode to a valid + // MemberRef token whose ResolveMethod/ResolveField returns a target + // of the expected type — inside a ~40-byte method body, practically + // impossible. + // + // Both producers are semantically equivalent for warm-up: STJ walks + // the runtime type of the value passed into Serialize, and cannot + // tell whether the instance came from a fresh newobj or from a + // static field load (e.g. MTConnectVersions.Version25 for + // System.Version). + private static void AssertWarmReachableGraphNewsUp(System.Type expected) + { + var method = typeof(JsonFunctions).GetMethod( + "WarmReachableGraph", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(method, Is.Not.Null, + "JsonFunctions.WarmReachableGraph private static method must exist."); + var body = method!.GetMethodBody(); + Assert.That(body, Is.Not.Null, + "WarmReachableGraph must have a method body (not abstract / p-invoke)."); + var il = body!.GetILAsByteArray(); + Assert.That(il, Is.Not.Null); + var module = method.Module; + + for (int i = 0; i + 4 < il!.Length; i++) + { + if (il[i] == 0x73) + { + // newobj + int token = System.BitConverter.ToInt32(il, i + 1); + System.Reflection.MethodBase? resolved; + try { resolved = module.ResolveMethod(token); } + catch { continue; } + if (resolved is System.Reflection.ConstructorInfo ctor + && ctor.DeclaringType == expected) + { + return; + } + } + else if (il[i] == 0x7E) + { + // ldsfld + int token = System.BitConverter.ToInt32(il, i + 1); + System.Reflection.FieldInfo? field; + try { field = module.ResolveField(token); } + catch { continue; } + if (field != null && field.FieldType == expected) + { + return; + } + } + } + + Assert.Fail( + $"WarmReachableGraph must produce a concrete instance of {expected.FullName} " + + "(via `new` or via a static-field load) so STJ walks its accessor graph at assembly load. " + + "Without it, the first request that hits this envelope pays a cold LCG DynamicMethod emit " + + "against the frozen singleton — which is the +3.2-3.8 MB/h RSS leak-in-miniature the warm-up exists to prevent."); + } +#endif + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs new file mode 100644 index 000000000..248d63dad --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs @@ -0,0 +1,542 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace MTConnect.NET_JSON_cppagent_Tests.Regressions +{ + /// + /// Regression pin for the DIME-connector native-heap leak (peer + /// diagnosis dated 2026-08-21). This is the cppagent-flavored + /// mirror of the same guard applied to + /// MTConnect.NET-JSON/JsonFunctions.cs. The two files ship + /// independent copies of the same option-preset surface, so both + /// must singleton their to keep + /// the runtime's loader heap from accumulating LCG-emitted property + /// accessors on every serialization call. + /// + [TestFixture] + public class JsonSerializerOptionsSingletonTests + { + // Small POCO used by the Convert/ConvertBytes/ConvertStream overload + // smoke tests and the thread-safety pin. Nested type so the + // reachable-graph JIT emit still runs on cold options instances. + internal class SamplePayload + { + public string Name { get; set; } = "sample"; + public int Count { get; set; } = 42; + public SampleChild Child { get; set; } = new SampleChild(); + } + + internal class SampleChild + { + public string Label { get; set; } = "child"; + public double Value { get; set; } = 3.14; + } + + private sealed class NoopConverter : JsonConverter + { + public override SamplePayload Read(ref Utf8JsonReader reader, System.Type typeToConvert, JsonSerializerOptions options) + => new SamplePayload(); + + public override void Write(Utf8JsonWriter writer, SamplePayload value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("Name", value.Name); + writer.WriteNumber("Count", value.Count); + writer.WriteEndObject(); + } + } + + /// Pins the behavior expressed by the test name: default options returns the same instance on repeat access. + [Test] + public void DefaultOptions_returns_the_same_instance_on_repeat_access() + { + var a = JsonFunctions.DefaultOptions; + var b = JsonFunctions.DefaultOptions; + Assert.That(ReferenceEquals(a, b), Is.True, + "JsonFunctions.DefaultOptions must be a shared singleton — a fresh JsonSerializerOptions per call leaks LCG-emitted metadata into the loader heap."); + } + + /// Pins the behavior expressed by the test name: indent options returns the same instance on repeat access. + [Test] + public void IndentOptions_returns_the_same_instance_on_repeat_access() + { + var a = JsonFunctions.IndentOptions; + var b = JsonFunctions.IndentOptions; + Assert.That(ReferenceEquals(a, b), Is.True, + "JsonFunctions.IndentOptions must be a shared singleton — a fresh JsonSerializerOptions per call leaks LCG-emitted metadata into the loader heap."); + } + + /// Pins the behavior expressed by the test name: default options and indent options are distinct instances. + [Test] + public void DefaultOptions_and_IndentOptions_are_distinct_instances() + { + Assert.That(ReferenceEquals(JsonFunctions.DefaultOptions, JsonFunctions.IndentOptions), Is.False, + "DefaultOptions (compact) and IndentOptions (pretty-printed) must be separate instances so WriteIndented differs."); + } + + /// Pins the behavior expressed by the test name: default options write indented is false. + [Test] + public void DefaultOptions_WriteIndented_is_false() + { + Assert.That(JsonFunctions.DefaultOptions.WriteIndented, Is.False); + } + + /// Pins the behavior expressed by the test name: indent options write indented is true. + [Test] + public void IndentOptions_WriteIndented_is_true() + { + Assert.That(JsonFunctions.IndentOptions.WriteIndented, Is.True); + } + + /// Pins the behavior expressed by the test name: json functions holds a static readonly options field. + [Test] + public void JsonFunctions_holds_a_static_readonly_options_field() + { + var fields = typeof(JsonFunctions).GetFields(BindingFlags.NonPublic | BindingFlags.Static); + var optionsFields = System.Array.FindAll(fields, f => f.FieldType == typeof(JsonSerializerOptions) && f.IsInitOnly); + Assert.That(optionsFields.Length, Is.GreaterThanOrEqualTo(2), + "JsonFunctions must declare shared static readonly JsonSerializerOptions fields (compact + indented) so the instances outlive each serialization call."); + } + + /// + /// Thread-safety pin — cppagent flavor. The cppagent assembly + /// ships 25 Streams.Json classes vs 10 in the plain-JSON assembly, + /// so the LCG cost per serialization is proportionally larger; + /// the concurrency pin runs on the exact JsonFunctions surface + /// DIME's MQTT sink hits in production. + /// + [Test] + public void Convert_is_thread_safe_across_100_concurrent_callers() + { + const int threadCount = 100; + var payload = new SamplePayload(); + var outputs = new ConcurrentBag(); + var exceptions = new ConcurrentBag(); + var startGate = new ManualResetEventSlim(false); + + var threads = new Thread[threadCount]; + for (int i = 0; i < threadCount; i++) + { + threads[i] = new Thread(() => + { + try + { + startGate.Wait(); + var s = JsonFunctions.Convert(payload); + outputs.Add(s); + } + catch (System.Exception ex) + { + exceptions.Add(ex); + } + }); + threads[i].Start(); + } + + // Warm the singleton once before releasing the gate. + _ = JsonFunctions.Convert(payload); + + startGate.Set(); + foreach (var t in threads) t.Join(); + + Assert.That(exceptions, Is.Empty, + "Concurrent Convert calls against the shared DefaultOptions must not throw."); + Assert.That(outputs.Count, Is.EqualTo(threadCount)); + + var canonical = JsonFunctions.Convert(payload); + foreach (var s in outputs) + { + Assert.That(s, Is.EqualTo(canonical), + "Every concurrent Convert output must be byte-identical to the single-threaded canonical output."); + } + } + + /// + /// Cold-path pin: when a caller supplies a per-call converter, the + /// implementation must NOT append that converter to the shared + /// DefaultOptions.Converters collection. Doing so would (a) permanently + /// pollute the singleton and (b) throw InvalidOperationException + /// because JsonSerializerOptions freezes its converter list after + /// first use. + /// + [Test] + public void Convert_with_custom_converter_does_not_mutate_DefaultOptions_converters() + { + _ = JsonFunctions.Convert(new SamplePayload()); // warm/freeze + + var beforeCount = JsonFunctions.DefaultOptions.Converters.Count; + var beforeIndentCount = JsonFunctions.IndentOptions.Converters.Count; + + var converter = new NoopConverter(); + var result = JsonFunctions.Convert(new SamplePayload(), converter); + var resultIndented = JsonFunctions.Convert(new SamplePayload(), converter, indented: true); + + Assert.That(result, Is.Not.Null); + Assert.That(resultIndented, Is.Not.Null); + + Assert.That(JsonFunctions.DefaultOptions.Converters.Count, Is.EqualTo(beforeCount), + "The cold-path branch must build a fresh JsonSerializerOptions — a caller-supplied converter must never leak into DefaultOptions.Converters."); + Assert.That(JsonFunctions.IndentOptions.Converters.Count, Is.EqualTo(beforeIndentCount), + "The cold-path branch must build a fresh JsonSerializerOptions — a caller-supplied converter must never leak into IndentOptions.Converters."); + } + + /// + /// Cold-path pin: the custom-converter Convert output must reflect + /// the caller's converter (proving the fresh options object used + /// it), while a following converter-less Convert on the same input + /// must NOT reflect the converter (proving the singleton was untouched). + /// + [Test] + public void Convert_with_custom_converter_uses_converter_without_affecting_singleton_output() + { + var payload = new SamplePayload(); + var canonicalWithoutConverter = JsonFunctions.Convert(payload); + + var converter = new NoopConverter(); + var withConverter = JsonFunctions.Convert(payload, converter); + + Assert.That(withConverter, Is.Not.EqualTo(canonicalWithoutConverter), + "The cold-path fresh options must apply the caller's converter — otherwise the singleton was reused, defeating the branch."); + Assert.That(withConverter, Does.Not.Contain("Child"), + "NoopConverter drops the Child field; the cold-path output should reflect that."); + + var afterCanonical = JsonFunctions.Convert(payload); + Assert.That(afterCanonical, Is.EqualTo(canonicalWithoutConverter), + "After a converter-supplied call, the singleton-path output must be byte-identical to before."); + Assert.That(afterCanonical, Does.Contain("Child"), + "The singleton must still emit the Child field — the cold-path converter must not have polluted it."); + } + + /// + /// Overload smoke pin: Convert/ConvertBytes/ConvertStream must all + /// produce the same JSON for the same input under the compact + /// preset. + /// + [Test] + public void Convert_ConvertBytes_ConvertStream_produce_identical_output_for_compact() + { + var payload = new SamplePayload(); + + var s = JsonFunctions.Convert(payload); + var bytes = JsonFunctions.ConvertBytes(payload); + var stream = JsonFunctions.ConvertStream(payload); + + Assert.That(s, Is.Not.Null); + Assert.That(bytes, Is.Not.Null); + Assert.That(stream, Is.Not.Null); + + var bytesString = Encoding.UTF8.GetString(bytes!); + Assert.That(bytesString, Is.EqualTo(s)); + + stream!.Position = 0; + using var reader = new System.IO.StreamReader(stream); + var streamString = reader.ReadToEnd(); + Assert.That(streamString, Is.EqualTo(s)); + } + + /// Overload smoke pin — indented variant. + [Test] + public void Convert_ConvertBytes_ConvertStream_produce_identical_output_for_indented() + { + var payload = new SamplePayload(); + + var s = JsonFunctions.Convert(payload, indented: true); + var bytes = JsonFunctions.ConvertBytes(payload, indented: true); + var stream = JsonFunctions.ConvertStream(payload, indented: true); + + Assert.That(s, Is.Not.Null); + Assert.That(bytes, Is.Not.Null); + Assert.That(stream, Is.Not.Null); + + var bytesString = Encoding.UTF8.GetString(bytes!); + Assert.That(bytesString, Is.EqualTo(s)); + + stream!.Position = 0; + using var reader = new System.IO.StreamReader(stream); + var streamString = reader.ReadToEnd(); + Assert.That(streamString, Is.EqualTo(s)); + + Assert.That(s, Does.Contain("\n"), + "Indented Convert output must contain newlines."); + Assert.That(bytesString, Does.Contain("\n")); + Assert.That(streamString, Does.Contain("\n")); + } + + /// + /// Null-input pin: every overload must swallow a null input and + /// return null / null / null. + /// + [Test] + public void Convert_overloads_return_null_for_null_input() + { + Assert.That(JsonFunctions.Convert(null), Is.Null); + Assert.That(JsonFunctions.ConvertBytes(null), Is.Null); + Assert.That(JsonFunctions.ConvertStream(null), Is.Null); + } + + /// + /// Async-throughput pin: 100 parallel Convert calls via + /// Parallel.For to complement the Thread-based pin. Covers the + /// ThreadPool-scheduled path DIME's MQTT sink actually uses in + /// production. + /// + [Test] + public void Convert_is_thread_safe_under_ParallelFor() + { + _ = JsonFunctions.Convert(new SamplePayload()); + + var canonical = JsonFunctions.Convert(new SamplePayload()); + var results = new ConcurrentBag(); + + Parallel.For(0, 100, _ => + { + var s = JsonFunctions.Convert(new SamplePayload()); + results.Add(s); + }); + + Assert.That(results.Count, Is.EqualTo(100)); + foreach (var r in results) + { + Assert.That(r, Is.EqualTo(canonical)); + } + } + +#if NET8_0_OR_GREATER + /// + /// Freeze pin (net8+): the shared DefaultOptions and IndentOptions + /// singletons must be marked read-only at static-ctor time, so any + /// attempt to mutate their + /// collection throws . + /// The freeze is the enforcement half of the singleton pattern — + /// documentation alone would let a careless caller silently pollute + /// every other in-process serializer; the read-only lock makes the + /// misuse fail loudly at the point of the offending Add. + /// + [Test] + public void DefaultOptions_Converters_Add_throws_InvalidOperationException_when_frozen() + { + Assert.Throws( + () => JsonFunctions.DefaultOptions.Converters.Add(new NoopConverter()), + "DefaultOptions is a shared singleton and must be frozen on net8+ so a stray Converters.Add fails fast rather than silently mutating the process-wide instance."); + } + + /// + /// Freeze pin (net8+) — IndentOptions mirror of the DefaultOptions guard. + /// + [Test] + public void IndentOptions_Converters_Add_throws_InvalidOperationException_when_frozen() + { + Assert.Throws( + () => JsonFunctions.IndentOptions.Converters.Add(new NoopConverter()), + "IndentOptions is a shared singleton and must be frozen on net8+ so a stray Converters.Add fails fast rather than silently mutating the process-wide instance."); + } + + /// + /// Warm-up-before-freeze ordering pin (net8+): the frozen singleton + /// must still be able to serialize a real typed payload, proving that + /// the static-ctor warm-up (typed JsonSerializer.Serialize + /// calls against each cppagent top-level response surrogate — see + /// JsonFunctions.WarmReachableGraph) ran BEFORE + /// MakeReadOnly(populateMissingResolver: false). If a future + /// refactor reordered the two calls — or removed the warm-up + /// entirely — the frozen, resolver-less options would throw + /// on the first real-payload + /// Serialize. The invariant is documented in the JsonFunctions static + /// ctor comment ("Order matters: the warm-up must run BEFORE + /// MakeReadOnly"); this test names it as a regression pin. + /// + /// Calls + /// directly rather than via because + /// Convert's catch-all silently swallows serialization exceptions into + /// a null return — routing through it would degrade a diagnostic + /// "NotSupportedException at Serialize" into an opaque "Expected: not null". + /// + [Test] + public void Frozen_singleton_can_serialize_a_real_payload_proving_warm_up_ran_before_MakeReadOnly() + { + var payload = new SamplePayload(); + + string compact = string.Empty; + string indented = string.Empty; + Assert.DoesNotThrow( + () => compact = JsonSerializer.Serialize(payload, JsonFunctions.DefaultOptions), + "Frozen DefaultOptions must have a populated TypeInfoResolver — a static-ctor reorder that runs MakeReadOnly BEFORE the WarmReachableGraph typed-Serialize calls would surface here as NotSupportedException."); + Assert.DoesNotThrow( + () => indented = JsonSerializer.Serialize(payload, JsonFunctions.IndentOptions), + "Frozen IndentOptions must have a populated TypeInfoResolver — same warm-up-before-freeze invariant as DefaultOptions."); + + Assert.That(compact, Does.Contain("\"Name\""), + "The warmed-and-frozen singleton must still emit real property data — a resolver-less frozen options would either throw or emit empty output."); + Assert.That(indented, Does.Contain("\"Name\"")); + } + + /// + /// Structural warm-up-coverage pin (net8+) — WarmReachableGraph's + /// IL must contain a newobj instruction constructing + /// , the fourth + /// top-level response envelope written directly by + /// JsonHttpResponseDocumentFormatter.Format(IErrorResponseDocument, ...) + /// without a cppagent-specific surrogate wrapper. + /// + /// A runtime Assert.DoesNotThrow(() => Serialize(new ErrorResponseDocument(), frozen)) + /// check is tautological here: once the TypeInfoResolver is set (which + /// any preceding warm-up call does), STJ's + /// lazily populates + /// on frozen options for arbitrary types — the frozen state only locks + /// the options' configuration surface (Converters, DefaultIgnoreCondition, + /// etc.), not the internal metadata cache. Verified empirically: + /// mutating out the ErrorResponseDocument warm-up line and running + /// the "can it serialize" pin still passes (see cycle-5 test-coverage-audit + /// mutation test 2026-08-21). The performance invariant the warm-up + /// enforces — pay the LCG DynamicMethod emit for ErrorResponseDocument + /// at assembly load, not on the first /probe error / parse-failure + /// request — is therefore only observable at the source-of-truth level: + /// the IL of WarmReachableGraph. + /// + [Test] + public void WarmReachableGraph_IL_contains_newobj_for_ErrorResponseDocument_ctor() + { + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Errors.ErrorResponseDocument)); + } + + /// + /// Structural warm-up-coverage pin (net8+) — companion assertion that + /// each of the three cppagent top-level response surrogates is also + /// news-up-ed by WarmReachableGraph. Mirrors the ErrorResponseDocument + /// pin above; the same tautology argument applies to per-type + /// "can it serialize" runtime checks. + /// + [Test] + public void WarmReachableGraph_IL_contains_newobj_for_every_top_level_response_surrogate() + { + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Streams.Json.JsonStreamsResponseDocument)); + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Assets.Json.JsonAssetsResponseDocument)); + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Devices.Json.JsonDevicesResponseDocument)); + } + + /// + /// Structural warm-up-coverage pin (net8+) — companion assertion for + /// the F-IMP-C5-001 fix that populated the Error envelope with a + /// concrete + + /// + . + /// STJ resolves accessors for interface-typed properties + /// (IMTConnectErrorHeader Header, IEnumerable<IError> Errors) + /// only when the value is non-null; a revert to a naked + /// new ErrorResponseDocument() would silently pass the + /// ErrorResponseDocument-only IL pin above while re-opening the + /// exact LCG-emit-on-first-error class F-IMP-C5-001 closed — + /// MTConnectErrorHeader (9 properties), Error (2), and + /// System.Version (6) would each pay their first cold accessor + /// emit on the first real cppagent-formatted error response. The + /// three assertions below are the source-of-truth evidence that + /// the populated warm-up shape survives. + /// + /// The walker () + /// accepts either a newobj producing an instance of the + /// expected type OR a ldsfld loading a static field of + /// that type — semantically equivalent for warm-up, because STJ + /// walks the runtime type of whatever value the caller passes + /// into + /// regardless of how the instance was produced. The current + /// warm-up uses MTConnectVersions.Version25 (a static + /// field of type ) rather than a + /// magic new System.Version(2, 5) literal (F-CR-C6-001); + /// dropping either producer would fail the pin. + /// + /// System.Version is not news-up-ed OR ldsfld-loaded by the + /// three cppagent surrogate envelopes' Header defaulting, so + /// the walker match on System.Version uniquely + /// fingerprints the Error-envelope initializer. + /// + [Test] + public void WarmReachableGraph_IL_contains_newobj_for_concrete_Error_envelope_fields() + { + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Headers.MTConnectErrorHeader)); + AssertWarmReachableGraphNewsUp(typeof(MTConnect.Errors.Error)); + AssertWarmReachableGraphNewsUp(typeof(System.Version)); + } + + // IL walker: proves JsonFunctions.WarmReachableGraph feeds a concrete + // instance of to STJ by locating EITHER a + // `newobj ` (fresh allocation) OR a + // `ldsfld ` (canonical-constant load) + // in the method body. Runs on the compiled test assembly's view of + // MTConnect.NET-JSON-cppagent, so a source change that removes both + // producers is caught deterministically at test time. Same walker + // shape as the plain-JSON sibling fixture; kept per-fixture to avoid + // a shared helper project just for this pin. + // + // Scans for the 5-byte patterns <4-byte-token> and + // <4-byte-token>. Full opcode-length decoding is + // unnecessary: a spurious match would need a non-target opcode at + // byte i whose following 4 bytes coincidentally decode to a valid + // MemberRef token whose ResolveMethod/ResolveField returns a target + // of the expected type — inside a ~40-byte method body, practically + // impossible. + // + // Both producers are semantically equivalent for warm-up: STJ walks + // the runtime type of the value passed into Serialize, and cannot + // tell whether the instance came from a fresh newobj or from a + // static field load (e.g. MTConnectVersions.Version25 for + // System.Version). + private static void AssertWarmReachableGraphNewsUp(System.Type expected) + { + var method = typeof(JsonFunctions).GetMethod( + "WarmReachableGraph", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(method, Is.Not.Null, + "JsonFunctions.WarmReachableGraph private static method must exist."); + var body = method!.GetMethodBody(); + Assert.That(body, Is.Not.Null, + "WarmReachableGraph must have a method body (not abstract / p-invoke)."); + var il = body!.GetILAsByteArray(); + Assert.That(il, Is.Not.Null); + var module = method.Module; + + for (int i = 0; i + 4 < il!.Length; i++) + { + if (il[i] == 0x73) + { + // newobj + int token = System.BitConverter.ToInt32(il, i + 1); + System.Reflection.MethodBase? resolved; + try { resolved = module.ResolveMethod(token); } + catch { continue; } + if (resolved is System.Reflection.ConstructorInfo ctor + && ctor.DeclaringType == expected) + { + return; + } + } + else if (il[i] == 0x7E) + { + // ldsfld + int token = System.BitConverter.ToInt32(il, i + 1); + System.Reflection.FieldInfo? field; + try { field = module.ResolveField(token); } + catch { continue; } + if (field != null && field.FieldType == expected) + { + return; + } + } + } + + Assert.Fail( + $"WarmReachableGraph must produce a concrete instance of {expected.FullName} " + + "(via `new` or via a static-field load) so STJ walks its accessor graph at assembly load. " + + "Without it, the first request that hits this envelope pays a cold LCG DynamicMethod emit " + + "against the frozen singleton — which is the +3.2-3.8 MB/h RSS leak-in-miniature the warm-up exists to prevent."); + } +#endif + } +}