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
+ }
+}