fix(json,json-cppagent): cache JsonSerializerOptions singletons — plug DynamicMethod LCG leak (+3.2-3.8 MB/h RSS) - #249
Draft
ottobolyos wants to merge 16 commits into
Conversation
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 20, 2026
…o-pr-249 (PR TrakHound#243 head on top of PR TrakHound#249)
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 20, 2026
…#249) into integration/up-to-pr-249
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…#249) into integration/up-to-pr-249 Cascade rebuild after integration/up-to-pr-241 refresh — PR TrakHound#241 cycle-3 closed 5 LOW dime findings and shifted its tip; the queue-head merge is re-applied unchanged onto the new base so int-249 stays semantically identical to the pre-cascade shape. # Conflicts: # libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
This was referenced Aug 21, 2026
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
… ordering (net8+) — coverage-FLOOR §1.0d-trigies-novodecies Cycle-3 test-coverage-audit gap on PR TrakHound#249: the XML-doc invariant "Order matters: the warm-up must run BEFORE MakeReadOnly" (JsonFunctions.cs static ctor, both flavours) had no named regression pin. The existing freeze-Add-throws tests prove the freeze happened, but they do not prove the Serialize<object>(null, _defaultOptions) warm-up completed BEFORE MakeReadOnly(populateMissingResolver: false). If a future refactor reordered the two calls — or removed the warm-up entirely — the frozen, resolver-less singleton would throw NotSupportedException on the first real-payload Serialize. Convert's silent catch-all would then swallow the exception into a null return, degrading any existing "Assert.That(s, Is.Not.Null)" failure into an opaque "Expected: not null" with no diagnostic pointing at the warm-up-order regression. The new test calls JsonSerializer.Serialize(payload, JsonFunctions.DefaultOptions) DIRECTLY (bypassing Convert's catch) so the failure surfaces as a diagnostic NotSupportedException at the exact site, with a message naming the warm-up-before-freeze invariant. Guarded under #if NET8_0_OR_GREATER since MakeReadOnly + the freeze pattern only exist on net8+. Mirror pins in both MTConnect.NET-JSON-Tests and MTConnect.NET-JSON-cppagent-Tests fixtures — both assemblies ship independent copies of the JsonFunctions surface and must keep the invariant in lockstep.
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
… ordering (net8+) — coverage-FLOOR §1.0d-trigies-novodecies Cycle-3 test-coverage-audit gap on PR TrakHound#249: the XML-doc invariant "Order matters: the warm-up must run BEFORE MakeReadOnly" (JsonFunctions.cs static ctor, both flavours) had no named regression pin. The existing freeze-Add-throws tests prove the freeze happened, but they do not prove the Serialize<object>(null, _defaultOptions) warm-up completed BEFORE MakeReadOnly(populateMissingResolver: false). If a future refactor reordered the two calls — or removed the warm-up entirely — the frozen, resolver-less singleton would throw NotSupportedException on the first real-payload Serialize. Convert's silent catch-all would then swallow the exception into a null return, degrading any existing "Assert.That(s, Is.Not.Null)" failure into an opaque "Expected: not null" with no diagnostic pointing at the warm-up-order regression. The new test calls JsonSerializer.Serialize(payload, JsonFunctions.DefaultOptions) DIRECTLY (bypassing Convert's catch) so the failure surfaces as a diagnostic NotSupportedException at the exact site, with a message naming the warm-up-before-freeze invariant. Guarded under #if NET8_0_OR_GREATER since MakeReadOnly + the freeze pattern only exist on net8+. Mirror pins in both MTConnect.NET-JSON-Tests and MTConnect.NET-JSON-cppagent-Tests fixtures — both assemblies ship independent copies of the JsonFunctions surface and must keep the invariant in lockstep.
ottobolyos
force-pushed
the
fix/json-serializer-options-per-call-leak
branch
from
August 21, 2026 06:07
639c986 to
4026ac6
Compare
PatrickRitchie
approved these changes
Aug 21, 2026
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
… ordering (net8+) — coverage-FLOOR §1.0d-trigies-novodecies Cycle-3 test-coverage-audit gap on PR TrakHound#249: the XML-doc invariant "Order matters: the warm-up must run BEFORE MakeReadOnly" (JsonFunctions.cs static ctor, both flavours) had no named regression pin. The existing freeze-Add-throws tests prove the freeze happened, but they do not prove the Serialize<object>(null, _defaultOptions) warm-up completed BEFORE MakeReadOnly(populateMissingResolver: false). If a future refactor reordered the two calls — or removed the warm-up entirely — the frozen, resolver-less singleton would throw NotSupportedException on the first real-payload Serialize. Convert's silent catch-all would then swallow the exception into a null return, degrading any existing "Assert.That(s, Is.Not.Null)" failure into an opaque "Expected: not null" with no diagnostic pointing at the warm-up-order regression. The new test calls JsonSerializer.Serialize(payload, JsonFunctions.DefaultOptions) DIRECTLY (bypassing Convert's catch) so the failure surfaces as a diagnostic NotSupportedException at the exact site, with a message naming the warm-up-before-freeze invariant. Guarded under #if NET8_0_OR_GREATER since MakeReadOnly + the freeze pattern only exist on net8+. Mirror pins in both MTConnect.NET-JSON-Tests and MTConnect.NET-JSON-cppagent-Tests fixtures — both assemblies ship independent copies of the JsonFunctions surface and must keep the invariant in lockstep.
ottobolyos
force-pushed
the
fix/json-serializer-options-per-call-leak
branch
from
August 21, 2026 06:39
52d98a1 to
e55badc
Compare
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…on singletons — dime M+L-C4 Cycle-4 leftovers on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (M-C4 — F-IMP-001): Adds MTConnect.Errors.ErrorResponseDocument to the WarmReachableGraph pass on BOTH JsonFunctions static ctors. ErrorResponseDocument is the fourth top-level response envelope written directly by Format(IErrorResponseDocument, ...) in both formatter families — no Json* surrogate wrapper. Without the addition, the first /probe error, /current parse failure, or unsupported device request would pay a cold LCG DynamicMethod emit against the shared, frozen options, which is the exact hot-path cost the singleton pattern exists to amortize. Fix (L-C4): Extends the <remarks> block on DefaultOptions + IndentOptions in both files with a WHY sentence explaining, in operator-actionable terms, that mutating a shared JsonSerializerOptions triggers System.Text.Json to rebuild its metadata cache, which re-emits property accessors as DynamicMethods into the runtime's LCG loader heaps. Those heaps are never reclaimed by the GC — a peer measured this at +3.2-3.8 MB/h RSS in production, which is what the frozen singleton eliminates. Pin tests: Adds Frozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ran to both JsonSerializerOptionsSingletonTests fixtures. Mirrors the existing SamplePayload warm-up-before-freeze pin — a regression that dropped ErrorResponseDocument from WarmReachableGraph would surface here as NotSupportedException on the frozen, resolver-less options.
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…ResponseDocument pin with IL-inspection; add sibling structural pins — coverage-FLOOR §1.0d-trigies-novodecies The cycle-4 Frozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ran pin was tautological — verified by mutation on bluefin 2026-08-21: removing the ErrorResponseDocument warm-up line from WarmReachableGraph left the pin green. STJ's DefaultJsonTypeInfoResolver lazily populates JsonTypeInfo on frozen options for arbitrary types once TypeInfoResolver is set (which any earlier warm-up call does); MakeReadOnly(populateMissingResolver: false) only locks the configuration surface, not the internal metadata cache. Rewrite the pin as WarmReachableGraph_IL_contains_newobj_for_ErrorResponseDocument_ctor: walk the method body's IL for a newobj instruction whose ResolveMethod resolves to a ctor of the expected type. Mutation-verified: removing the warm-up line now cleanly fails the pin. Add companion pin covering the three Json* top-level response surrogates in each fixture. Also add sibling-site structural pins for the four MTConnect.NET-Common per-call `new JsonSerializerOptions(...)` sites flipped to shared static readonly fields in PR TrakHound#249 (MTConnectAgentInformation, MTConnectClientInformation, MTConnectAssetFileBuffer, AdapterApplicationConfiguration, AgentConfiguration), plus the MTConnect.NET-MQTT sibling (MTConnectMqttMessage) in the MqttRelay test project (which transitively references MTConnect.NET-MQTT). Each pin asserts at least one private static readonly JsonSerializerOptions field exists on the type — sibling-mutation-verified on MTConnectAgentInformation. Net +8 pins across four projects; all fixtures green on bluefin (net8.0, 4,530 passing).
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…Document null-tolerance — dime M+L-C5 Cycle-5 leftovers on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (M-C5 — F-IMP-C5-001): Populates the WarmReachableGraph Error envelope with a concrete MTConnectErrorHeader + a single Error entry + a Version(2, 5), so STJ walks the RUNTIME types the production /probe-error and /parse-failure paths actually serialize. STJ resolves accessors for interface-typed properties (IMTConnectErrorHeader Header, IEnumerable<IError> Errors) only when the value is non-null; the prior naked `new ErrorResponseDocument()` warmed the envelope's own accessors but left the concrete MTConnectErrorHeader (9 properties), Error (2), and System.Version (6) cold — exactly the LCG-emit class the singleton pattern exists to eliminate. Populating a representative graph completes the fix. Fix (L-C5 — F-IMP-C5-002): Documents the null-tolerance contract on `JsonAssetsDocument(IAssetsResponseDocument)` in a <remarks> block. The plain-JSON WarmReachableGraph calls `new JsonAssetsDocument(null)` because the surrogate has no public parameterless ctor. If a future refactor added `ArgumentNullException.ThrowIfNull(assetsDocument)` there, first assembly load would fail with TypeInitializationException on any JSON serialization. The <remarks> names the coupling and directs future contributors to update the warm-up site atomically per §1.0d-trigies-bis before changing the ctor contract. Cycle-5 dispositions: - code-review: NO FINDINGS - security-audit: NO FINDINGS - documentation-audit: NO FINDINGS - simplification: F-SIMP-001 LOW (dup first-para on IndentOptions remarks) — Closed-with-rationale: IntelliSense hover shows only the current member's remarks and does not chase <see cref/>; a consumer inspecting IndentOptions in isolation must see the invariant text self-contained, not as a pointer. - improvement: F-IMP-C5-001 MEDIUM (this commit); F-IMP-C5-002 LOW (this commit). - test-coverage-audit: 2 findings resolved atomically in 5a19658 (tautological Error-warm-up pin replaced with IL-inspection walker; sibling structural pins added for the five per-call → static-readonly refactors).
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…ion25 — dime L-C6 Cycle-6 leftover on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (L-C6 — F-CR-C6-001): Replaces the magic `new Version(2, 5)` literal in both `WarmReachableGraph` Error envelopes with the repo's canonical `MTConnectVersions.Version25` constant. The value is functionally identical (same `new Version(2, 5)` allocation under the hood) but removes a hand-typed tuple in favour of the named constant every other error-doc construction site in the codebase uses (`MTConnectAgentBroker.GetErrorHeader` sources Version from `MTConnectVersion` rather than a literal). Reads scan-and-recognise instead of raising "why 2.5 in a 2.7-max library" questions. Removes now-unused `using System;` from the cppagent JsonFunctions.cs (the only unqualified `Version` reference was the magic literal, now gone; the property type is resolved through the receiver's declared type). Cycle-6 dispositions: - code-review: F-CR-C6-001 LOW (this commit). - security-audit: NO FINDINGS. - simplification: NO FINDINGS (cycle-5 F-SIMP-001 duplication class intentionally preserved per prior Closed-with-rationale). - improvement: NO FINDINGS. - documentation-audit: NO FINDINGS. - test-coverage-audit: F-COV-C6-001 MEDIUM (concrete Error-envelope field pins) — Fixed atomically in e19674e (IL walker now asserts newobj for MTConnectErrorHeader + Error + Version, catching a revert-to-unpopulated regression that cycle-5's ErrorResponseDocument-only pin missed).
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
… ordering (net8+) — coverage-FLOOR Cycle-3 test-coverage-audit gap on PR TrakHound#249: the XML-doc invariant "Order matters: the warm-up must run BEFORE MakeReadOnly" (JsonFunctions.cs static ctor, both flavors) had no named regression pin. The existing freeze-Add-throws tests prove the freeze happened, but they do not prove the Serialize<object>(null, _defaultOptions) warm-up completed BEFORE MakeReadOnly(populateMissingResolver: false). If a future refactor reordered the two calls — or removed the warm-up entirely — the frozen, resolver-less singleton would throw NotSupportedException on the first real-payload Serialize. Convert's silent catch-all would then swallow the exception into a null return, degrading any existing "Assert.That(s, Is.Not.Null)" failure into an opaque "Expected: not null" with no diagnostic pointing at the warm-up-order regression. The new test calls JsonSerializer.Serialize(payload, JsonFunctions.DefaultOptions) DIRECTLY (bypassing Convert's catch) so the failure surfaces as a diagnostic NotSupportedException at the exact site, with a message naming the warm-up-before-freeze invariant. Guarded under #if NET8_0_OR_GREATER since MakeReadOnly + the freeze pattern only exist on net8+. Mirror pins in both MTConnect.NET-JSON-Tests and MTConnect.NET-JSON-cppagent-Tests fixtures — both assemblies ship independent copies of the JsonFunctions surface and must keep the invariant in lockstep.
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…on singletons — dime M+L-C4 Cycle-4 leftovers on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (M-C4 — F-IMP-001): Adds MTConnect.Errors.ErrorResponseDocument to the WarmReachableGraph pass on BOTH JsonFunctions static ctors. ErrorResponseDocument is the fourth top-level response envelope written directly by Format(IErrorResponseDocument, ...) in both formatter families — no Json* surrogate wrapper. Without the addition, the first /probe error, /current parse failure, or unsupported device request would pay a cold LCG DynamicMethod emit against the shared, frozen options, which is the exact hot-path cost the singleton pattern exists to amortize. Fix (L-C4): Extends the <remarks> block on DefaultOptions + IndentOptions in both files with a WHY sentence explaining, in operator-actionable terms, that mutating a shared JsonSerializerOptions triggers System.Text.Json to rebuild its metadata cache, which re-emits property accessors as DynamicMethods into the runtime's LCG loader heaps. Those heaps are never reclaimed by the GC — a peer measured this at +3.2–3.8 MB/h RSS in production, which is what the frozen singleton eliminates. Pin tests: Adds Frozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ran to both JsonSerializerOptionsSingletonTests fixtures. Mirrors the existing SamplePayload warm-up-before-freeze pin — a regression that dropped ErrorResponseDocument from WarmReachableGraph would surface here as NotSupportedException on the frozen, resolver-less options.
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…ResponseDocument pin with IL-inspection; add sibling structural pins — coverage-FLOOR The cycle-4 Frozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ran pin was tautological — verified by mutation on bluefin 2026-08-21: removing the ErrorResponseDocument warm-up line from WarmReachableGraph left the pin green. STJ's DefaultJsonTypeInfoResolver lazily populates JsonTypeInfo on frozen options for arbitrary types once TypeInfoResolver is set (which any earlier warm-up call does); MakeReadOnly(populateMissingResolver: false) only locks the configuration surface, not the internal metadata cache. Rewrite the pin as WarmReachableGraph_IL_contains_newobj_for_ErrorResponseDocument_ctor: walk the method body's IL for a newobj instruction whose ResolveMethod resolves to a ctor of the expected type. Mutation-verified: removing the warm-up line now cleanly fails the pin. Add companion pin covering the three Json* top-level response surrogates in each fixture. Also add sibling-site structural pins for the four MTConnect.NET-Common per-call `new JsonSerializerOptions(...)` sites flipped to shared static readonly fields in PR TrakHound#249 (MTConnectAgentInformation, MTConnectClientInformation, MTConnectAssetFileBuffer, AdapterApplicationConfiguration, AgentConfiguration), plus the MTConnect.NET-MQTT sibling (MTConnectMqttMessage) in the MqttRelay test project (which transitively references MTConnect.NET-MQTT). Each pin asserts at least one private static readonly JsonSerializerOptions field exists on the type — sibling-mutation-verified on MTConnectAgentInformation. Net +8 pins across four projects; all fixtures green on bluefin (net8.0, 4,530 passing).
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…Document null-tolerance — dime M+L-C5 Cycle-5 leftovers on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (M-C5 — F-IMP-C5-001): Populates the WarmReachableGraph Error envelope with a concrete MTConnectErrorHeader + a single Error entry + a Version(2, 5), so STJ walks the RUNTIME types the production /probe-error and /parse-failure paths actually serialize. STJ resolves accessors for interface-typed properties (IMTConnectErrorHeader Header, IEnumerable<IError> Errors) only when the value is non-null; the prior naked `new ErrorResponseDocument()` warmed the envelope's own accessors but left the concrete MTConnectErrorHeader (9 properties), Error (2), and System.Version (6) cold — exactly the LCG-emit class the singleton pattern exists to eliminate. Populating a representative graph completes the fix. Fix (L-C5 — F-IMP-C5-002): Documents the null-tolerance contract on `JsonAssetsDocument(IAssetsResponseDocument)` in a <remarks> block. The plain-JSON WarmReachableGraph calls `new JsonAssetsDocument(null)` because the surrogate has no public parameterless ctor. If a future refactor added `ArgumentNullException.ThrowIfNull(assetsDocument)` there, first assembly load would fail with TypeInitializationException on any JSON serialization. The <remarks> names the coupling and directs future contributors to update the warm-up site atomically, in the same commit, before changing the ctor contract. Cycle-5 dispositions: - code-review: NO FINDINGS - security-audit: NO FINDINGS - documentation-audit: NO FINDINGS - simplification: F-SIMP-001 LOW (dup first-para on IndentOptions remarks) — Closed-with-rationale: IntelliSense hover shows only the current member's remarks and does not chase <see cref/>; a consumer inspecting IndentOptions in isolation must see the invariant text self-contained, not as a pointer. - improvement: F-IMP-C5-001 MEDIUM (this commit); F-IMP-C5-002 LOW (this commit). - test-coverage-audit: 2 findings resolved atomically in 5a19658 (tautological Error-warm-up pin replaced with IL-inspection walker; sibling structural pins added for the five per-call → static-readonly refactors).
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…ion25 — dime L-C6 Cycle-6 leftover on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (L-C6 — F-CR-C6-001): Replaces the magic `new Version(2, 5)` literal in both `WarmReachableGraph` Error envelopes with the repo's canonical `MTConnectVersions.Version25` constant. The value is functionally identical (same `new Version(2, 5)` allocation under the hood) but removes a hand-typed tuple in favor of the named constant every other error-doc construction site in the codebase uses (`MTConnectAgentBroker.GetErrorHeader` sources Version from `MTConnectVersion` rather than a literal). Reads scan-and-recognize instead of raising "why 2.5 in a 2.7-max library" questions. Removes now-unused `using System;` from the cppagent JsonFunctions.cs (the only unqualified `Version` reference was the magic literal, now gone; the property type is resolved through the receiver's declared type). Cycle-6 dispositions: - code-review: F-CR-C6-001 LOW (this commit). - security-audit: NO FINDINGS. - simplification: NO FINDINGS (cycle-5 F-SIMP-001 duplication class intentionally preserved per prior Closed-with-rationale). - improvement: NO FINDINGS. - documentation-audit: NO FINDINGS. - test-coverage-audit: F-COV-C6-001 MEDIUM (concrete Error-envelope field pins) — Fixed atomically in e19674e (IL walker now asserts newobj for MTConnectErrorHeader + Error + Version, catching a revert-to-unpopulated regression that cycle-5's ErrorResponseDocument-only pin missed).
ottobolyos
force-pushed
the
fix/json-serializer-options-per-call-leak
branch
from
August 21, 2026 14:13
3166858 to
af2f0ca
Compare
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…call-leak) at af2f0ca # Conflicts: # libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
… ordering (net8+) — coverage-FLOOR Cycle-3 test-coverage-audit gap on PR TrakHound#249: the XML-doc invariant "Order matters: the warm-up must run BEFORE MakeReadOnly" (JsonFunctions.cs static ctor, both flavors) had no named regression pin. The existing freeze-Add-throws tests prove the freeze happened, but they do not prove the Serialize<object>(null, _defaultOptions) warm-up completed BEFORE MakeReadOnly(populateMissingResolver: false). If a future refactor reordered the two calls — or removed the warm-up entirely — the frozen, resolver-less singleton would throw NotSupportedException on the first real-payload Serialize. Convert's silent catch-all would then swallow the exception into a null return, degrading any existing "Assert.That(s, Is.Not.Null)" failure into an opaque "Expected: not null" with no diagnostic pointing at the warm-up-order regression. The new test calls JsonSerializer.Serialize(payload, JsonFunctions.DefaultOptions) DIRECTLY (bypassing Convert's catch) so the failure surfaces as a diagnostic NotSupportedException at the exact site, with a message naming the warm-up-before-freeze invariant. Guarded under #if NET8_0_OR_GREATER since MakeReadOnly + the freeze pattern only exist on net8+. Mirror pins in both MTConnect.NET-JSON-Tests and MTConnect.NET-JSON-cppagent-Tests fixtures — both assemblies ship independent copies of the JsonFunctions surface and must keep the invariant in lockstep.
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…on singletons — dime M+L-C4 Cycle-4 leftovers on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (M-C4 — F-IMP-001): Adds MTConnect.Errors.ErrorResponseDocument to the WarmReachableGraph pass on BOTH JsonFunctions static ctors. ErrorResponseDocument is the fourth top-level response envelope written directly by Format(IErrorResponseDocument, ...) in both formatter families — no Json* surrogate wrapper. Without the addition, the first /probe error, /current parse failure, or unsupported device request would pay a cold LCG DynamicMethod emit against the shared, frozen options, which is the exact hot-path cost the singleton pattern exists to amortize. Fix (L-C4): Extends the <remarks> block on DefaultOptions + IndentOptions in both files with a WHY sentence explaining, in operator-actionable terms, that mutating a shared JsonSerializerOptions triggers System.Text.Json to rebuild its metadata cache, which re-emits property accessors as DynamicMethods into the runtime's LCG loader heaps. Those heaps are never reclaimed by the GC — a peer measured this at +3.2–3.8 MB/h RSS in production, which is what the frozen singleton eliminates. Pin tests: Adds Frozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ran to both JsonSerializerOptionsSingletonTests fixtures. Mirrors the existing SamplePayload warm-up-before-freeze pin — a regression that dropped ErrorResponseDocument from WarmReachableGraph would surface here as NotSupportedException on the frozen, resolver-less options.
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…ResponseDocument pin with IL-inspection; add sibling structural pins — coverage-FLOOR The cycle-4 Frozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ran pin was tautological — verified by mutation on bluefin 2026-08-21: removing the ErrorResponseDocument warm-up line from WarmReachableGraph left the pin green. STJ's DefaultJsonTypeInfoResolver lazily populates JsonTypeInfo on frozen options for arbitrary types once TypeInfoResolver is set (which any earlier warm-up call does); MakeReadOnly(populateMissingResolver: false) only locks the configuration surface, not the internal metadata cache. Rewrite the pin as WarmReachableGraph_IL_contains_newobj_for_ErrorResponseDocument_ctor: walk the method body's IL for a newobj instruction whose ResolveMethod resolves to a ctor of the expected type. Mutation-verified: removing the warm-up line now cleanly fails the pin. Add companion pin covering the three Json* top-level response surrogates in each fixture. Also add sibling-site structural pins for the four MTConnect.NET-Common per-call `new JsonSerializerOptions(...)` sites flipped to shared static readonly fields in PR TrakHound#249 (MTConnectAgentInformation, MTConnectClientInformation, MTConnectAssetFileBuffer, AdapterApplicationConfiguration, AgentConfiguration), plus the MTConnect.NET-MQTT sibling (MTConnectMqttMessage) in the MqttRelay test project (which transitively references MTConnect.NET-MQTT). Each pin asserts at least one private static readonly JsonSerializerOptions field exists on the type — sibling-mutation-verified on MTConnectAgentInformation. Net +8 pins across four projects; all fixtures green on bluefin (net8.0, 4,530 passing).
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…Document null-tolerance — dime M+L-C5 Cycle-5 leftovers on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (M-C5 — F-IMP-C5-001): Populates the WarmReachableGraph Error envelope with a concrete MTConnectErrorHeader + a single Error entry + a Version(2, 5), so STJ walks the RUNTIME types the production /probe-error and /parse-failure paths actually serialize. STJ resolves accessors for interface-typed properties (IMTConnectErrorHeader Header, IEnumerable<IError> Errors) only when the value is non-null; the prior naked `new ErrorResponseDocument()` warmed the envelope's own accessors but left the concrete MTConnectErrorHeader (9 properties), Error (2), and System.Version (6) cold — exactly the LCG-emit class the singleton pattern exists to eliminate. Populating a representative graph completes the fix. Fix (L-C5 — F-IMP-C5-002): Documents the null-tolerance contract on `JsonAssetsDocument(IAssetsResponseDocument)` in a <remarks> block. The plain-JSON WarmReachableGraph calls `new JsonAssetsDocument(null)` because the surrogate has no public parameterless ctor. If a future refactor added `ArgumentNullException.ThrowIfNull(assetsDocument)` there, first assembly load would fail with TypeInitializationException on any JSON serialization. The <remarks> names the coupling and directs future contributors to update the warm-up site atomically, in the same commit, before changing the ctor contract. Cycle-5 dispositions: - code-review: NO FINDINGS - security-audit: NO FINDINGS - documentation-audit: NO FINDINGS - simplification: F-SIMP-001 LOW (dup first-para on IndentOptions remarks) — Closed-with-rationale: IntelliSense hover shows only the current member's remarks and does not chase <see cref/>; a consumer inspecting IndentOptions in isolation must see the invariant text self-contained, not as a pointer. - improvement: F-IMP-C5-001 MEDIUM (this commit); F-IMP-C5-002 LOW (this commit). - test-coverage-audit: 2 findings resolved atomically in 5a19658 (tautological Error-warm-up pin replaced with IL-inspection walker; sibling structural pins added for the five per-call → static-readonly refactors).
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
…ion25 — dime L-C6 Cycle-6 leftover on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (L-C6 — F-CR-C6-001): Replaces the magic `new Version(2, 5)` literal in both `WarmReachableGraph` Error envelopes with the repo's canonical `MTConnectVersions.Version25` constant. The value is functionally identical (same `new Version(2, 5)` allocation under the hood) but removes a hand-typed tuple in favor of the named constant every other error-doc construction site in the codebase uses (`MTConnectAgentBroker.GetErrorHeader` sources Version from `MTConnectVersion` rather than a literal). Reads scan-and-recognize instead of raising "why 2.5 in a 2.7-max library" questions. Removes now-unused `using System;` from the cppagent JsonFunctions.cs (the only unqualified `Version` reference was the magic literal, now gone; the property type is resolved through the receiver's declared type). Cycle-6 dispositions: - code-review: F-CR-C6-001 LOW (this commit). - security-audit: NO FINDINGS. - simplification: NO FINDINGS (cycle-5 F-SIMP-001 duplication class intentionally preserved per prior Closed-with-rationale). - improvement: NO FINDINGS. - documentation-audit: NO FINDINGS. - test-coverage-audit: F-COV-C6-001 MEDIUM (concrete Error-envelope field pins) — Fixed atomically in e19674e (IL walker now asserts newobj for MTConnectErrorHeader + Error + Version, catching a revert-to-unpopulated regression that cycle-5's ErrorResponseDocument-only pin missed).
ottobolyos
force-pushed
the
fix/json-serializer-options-per-call-leak
branch
from
August 21, 2026 16:07
af2f0ca to
bec0dde
Compare
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
… integration/up-to-pr-249 # Conflicts: # libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
… integration/up-to-pr-249 # Conflicts: # libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 21, 2026
… integration/up-to-pr-249 # Conflicts: # libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs
…g DynamicMethod LCG leak A fresh JsonSerializerOptions owns its own serialization-metadata cache, and building that cache emits LCG DynamicMethod property accessors for every property in the reachable type graph. Allocating one 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. Peer diagnosis on two DIME-connector production hosts (tempco-001 amd64 server-GC, tim-001 arm64 workstation-GC) observed +3.2–3.8 MB/h RSS climb with a flat managed heap, ~370 MB outside the managed heap, 9.4–17.6 M methods jitted lifetime, the ReflectionEmitCachingMemberAccessor cache present in gcdump, and `dynamicClass` accessor names in the JIT trace — the mechanism is confirmed at IL level in both shipped JSON assemblies. Fix: hoist DefaultOptions and IndentOptions to static readonly fields on both JsonFunctions classes (`MTConnect.NET-JSON` and `MTConnect.NET-JSON-cppagent`) so a single instance backs every serialization call. Convert/ConvertBytes/ConvertStream now route through a shared GetOptions(converter, indented) helper: hot path (no per-call converter, which is every in-tree caller) returns the singleton; cold path builds a private instance only when a caller-supplied converter forces per-call mutation. No in-tree caller passes a converter or mutates the returned options — assumptions verified via grep across libraries/, agent/, and tests/. Sibling sweep: the same static-readonly pattern applied to AgentConfiguration.ReadJson (2 sites), AdapterApplicationConfiguration.ReadJson (2 sites), MTConnectAgentInformation.Save, MTConnectClientInformation.Save, MTConnectAssetFileBuffer.WriteAssetFile, and MTConnectMqttMessage.CreateAgentInformationMessage — lower-frequency call sites but same anti-pattern, folded in atomically into this same commit rather than deferred to a follow-up PR. MTConnectObservationFileBuffer.cs was in the peer's sweep list but already uses default options (no `new JsonSerializerOptions` in-method). RED-first: `tests/MTConnect.NET-JSON-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs` and its cppagent mirror pin ReferenceEquals across repeat access, distinct-instance guard on Default vs Indent, WriteIndented shape guards, and a reflection guard that the static readonly options fields exist. Verified RED on upstream/master (587a126): 3+3 failing per project. Verified GREEN after fix: 6+6 passing per project. Full-suite GREEN on bluefin/net8.0: MTConnect.NET-JSON-Tests 69/69, MTConnect.NET-JSON-cppagent-Tests 369/369, MTConnect.NET-Common-Tests 3987/3987. Total 4425 tests, zero failures. Root-cause mechanism is peer-validated at IL level; empirical fix-efficacy measurement (RSS-floor slope before/after against untouched control) pending peer's fresh before/after run.
…onvert overload parity — coverage-FLOOR Extends the JsonSerializerOptionsSingletonTests regression fixture in both MTConnect.NET-JSON-Tests and MTConnect.NET-JSON-cppagent-Tests with seven additional pins beyond the ReferenceEquals guards that already ship on the PR head: - Convert_is_thread_safe_across_100_concurrent_callers — spins up 100 gated threads that all call JsonFunctions.Convert against the shared DefaultOptions; every output must equal a single-threaded canonical and no exception may escape. Pins the STJ thread-safe-for-read contract we now rely on. - Convert_is_thread_safe_under_ParallelFor — same guarantee via the ThreadPool scheduling path DIME's MQTT sink actually uses. - Convert_with_custom_converter_does_not_mutate_DefaultOptions_converters — cold-path pin. Freezes the singleton, then calls Convert with a caller-supplied JsonConverter and asserts DefaultOptions.Converters and IndentOptions.Converters counts are unchanged. Guards the observable side of the GetOptions cold-path branch — a regression that appended to the singleton would both pollute it and throw once the converter list froze. - Convert_with_custom_converter_uses_converter_without_affecting_singleton_output — pins that the fresh cold-path options object actually applies the caller's converter (via a NoopConverter that drops the Child field), and that a following converter-less call is byte-identical to the original singleton output. - Convert_ConvertBytes_ConvertStream_produce_identical_output_for_compact and _for_indented — smoke tests each Convert / ConvertBytes / ConvertStream overload for the compact and indented presets and asserts UTF-8 decode / stream read match Convert exactly. Guards against a future refactor routing one overload through a divergent options instance. - Convert_overloads_return_null_for_null_input — pins the documented null-swallow behavior on the singleton refactor. Verified GREEN on bluefin (dotnet 10.0.302, net8.0): - MTConnect.NET-JSON-Tests: Passed 76 / Failed 0 (was 69 / 0). - MTConnect.NET-JSON-cppagent-Tests: Passed 376 / Failed 0 (was 369 / 0). Exit 0 both. Total +14 new tests across the two mirrored fixtures. Local sandbox lacks the net9.0 SDK, so the dotnet-test run was dispatched to the bluefin runner (ts_p15g2-bluefin) per the repo's policy that resource-intensive test runs happen there rather than in the local sandbox.
…ime H1+M1 The shared JsonSerializerOptions singletons in MTConnect.NET-JSON and MTConnect.NET-JSON-cppagent were documented as reusable but not enforced as immutable — a careless caller could still mutate the Converters collection (or any other writable property) and silently corrupt every other in-process serializer that shares the instance. Ultrareview cycle 1 (2026-08-21) flagged this as a 3-agent convergent HIGH finding across docs + code-review + improvement. Fix — freeze the singletons (H1): - Add a static constructor on both JsonFunctions classes that calls MakeReadOnly(populateMissingResolver: false) on _defaultOptions and _indentOptions under #if NET8_0_OR_GREATER. Attempted mutation on net8+ now throws InvalidOperationException at the point of the offending Add, rather than silently polluting the shared instance. - On older TFMs (netstandard2.0, net4.6.1–net4.8, net6.0, net7.0) MakeReadOnly is not available on the STJ surface those runtimes ship, so the guard is compile-time gated. The <remarks> block on DefaultOptions / IndentOptions calls out that the immutability contract still holds by convention on those TFMs. - Add matching <remarks> XML docs on both DefaultOptions and IndentOptions properties in both files, stating the singleton + do-not-mutate + net8+ throws contract explicitly. Consumers reading IntelliSense now see the constraint at the call site. - Extend the JsonSerializerOptionsSingletonTests regression fixture in both MTConnect.NET-JSON-Tests and MTConnect.NET-JSON-cppagent-Tests with two net8+-guarded freeze pins per side: * DefaultOptions_Converters_Add_throws_InvalidOperationException_when_frozen * IndentOptions_Converters_Add_throws_InvalidOperationException_when_frozen Each asserts Assert.Throws<InvalidOperationException> on Converters.Add(new NoopConverter()) so a future regression that drops MakeReadOnly (or accidentally rebuilds one of the singletons as a fresh writable instance) fails loudly. Warm-up at load (M1): - The static ctor also runs JsonSerializer.Serialize<object>(null, _defaultOptions) and the same for _indentOptions BEFORE the MakeReadOnly calls. The warm-up pays the reflection-resolver bootstrap cost at assembly-load time rather than on the first production /current or /sample request under load. - Order matters and is called out in the comment: MakeReadOnly(false) freezes the options WITHOUT choosing a TypeInfoResolver, so 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. Verified locally (dotnet 8.0.104, net8.0): - MTConnect.NET-JSON: build 0/0, JsonSerializerOptionsSingletonTests filter 15 passed / 0 failed (was 13/0 before the two freeze pins). - MTConnect.NET-JSON-cppagent: build 0/0, filter 15 passed / 0 failed (was 13/0). Full-suite validation runs on bluefin next.
… dime L1 The private CreateOptions(bool indented) and GetOptions(JsonConverter, bool) helpers on JsonFunctions carried inline comments in the method body but no /// <summary> XML doc blocks. Ultrareview cycle 1 flagged this as a LOW documentation gap — the two helpers embody the singleton-vs-fresh contract at the heart of the leak fix, so their role deserves first-class IntelliSense-visible documentation rather than implicit knowledge in the method body. Fix: add <summary> + <remarks> XML docs to both helpers in both files (MTConnect.NET-JSON and MTConnect.NET-JSON-cppagent), naming the contract explicitly: - CreateOptions: returns a fresh instance; intended only for static-init and the cold-path branch of GetOptions; every call allocates + re-emits the STJ reflection metadata cache and must therefore never sit on a hot-path serialization site. - GetOptions: hot path returns the shared frozen singleton (every in-tree caller hits this branch); cold path allocates a fresh instance per call and appends the caller's converter. The cold-path branch is not shared between callers — per-call concurrent use is safe (each call owns its options); sharing a converter across cold callers is safe iff the converter itself is thread-safe. Build verified locally on net8.0: both projects 0 warnings, 0 errors.
Cycle-1 landings on JsonFunctions.cs (cppagent flavor) and the two JsonSerializerOptionsSingletonTests fixtures used the BrE spellings `serialisation` / `behaviour` in comments and Assert.That failure messages. MTConnect.NET's canonical spelling rule is AmE inside committed source — including code comments and XML docs — with BrE reserved for user-authored prose only. Cycle-1 Ultrareview flagged the drift as a LOW code-review finding. Fix: normalize the BrE tokens to AmE across all three affected files: - libraries/MTConnect.NET-JSON-cppagent/JsonFunctions.cs — one occurrence in the LCG-heap comment (`serialisation` → `serialization`). - tests/MTConnect.NET-JSON-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs — 3× `serialisation` → `serialization`, 8× `behaviour` → `behavior`. - tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs — 2× `serialisation` → `serialization`, 7× `behaviour` → `behavior`. Verified no assertion value depends on the BrE spelling before the rewrite: every match landed in a code comment, a `/// <summary>` block, or the optional failure-message argument of `Assert.That` — none in the value under test. Regression fixture reruns green (15/15 on both sides) after normalization.
… ordering (net8+) — coverage-FLOOR Cycle-3 test-coverage-audit gap on PR TrakHound#249: the XML-doc invariant "Order matters: the warm-up must run BEFORE MakeReadOnly" (JsonFunctions.cs static ctor, both flavors) had no named regression pin. The existing freeze-Add-throws tests prove the freeze happened, but they do not prove the Serialize<object>(null, _defaultOptions) warm-up completed BEFORE MakeReadOnly(populateMissingResolver: false). If a future refactor reordered the two calls — or removed the warm-up entirely — the frozen, resolver-less singleton would throw NotSupportedException on the first real-payload Serialize. Convert's silent catch-all would then swallow the exception into a null return, degrading any existing "Assert.That(s, Is.Not.Null)" failure into an opaque "Expected: not null" with no diagnostic pointing at the warm-up-order regression. The new test calls JsonSerializer.Serialize(payload, JsonFunctions.DefaultOptions) DIRECTLY (bypassing Convert's catch) so the failure surfaces as a diagnostic NotSupportedException at the exact site, with a message naming the warm-up-before-freeze invariant. Guarded under #if NET8_0_OR_GREATER since MakeReadOnly + the freeze pattern only exist on net8+. Mirror pins in both MTConnect.NET-JSON-Tests and MTConnect.NET-JSON-cppagent-Tests fixtures — both assemblies ship independent copies of the JsonFunctions surface and must keep the invariant in lockstep.
…— dime M1-C3
Cycle-2's static-ctor warm-up used JsonSerializer.Serialize<object>(null,
options), which bootstrapped the shared STJ reflection resolver but
never named a concrete MTConnect type. The reachable-graph LCG
DynamicMethod emit for JsonStreamsDocument, JsonAssetsDocument, and
JsonDevicesDocument (and their cppagent counterparts) therefore still
fell on the first user-facing /current | /sample | /assets request —
the exact cold-first-request cost the warm-up was supposed to amortize.
Replace the null-typed Serialize<object>(null, …) calls with typed
Serialize calls against instances of each MTConnect top-level response
surrogate, so STJ configures JsonTypeInfo (and emits the LCG accessors)
for the whole reachable graph rooted at each type. Extracted to a
private WarmReachableGraph(options) helper called once per singleton
(compact + indented) from each assembly's static constructor.
- MTConnect.NET-JSON: warms JsonStreamsDocument (parameterless ctor),
JsonAssetsDocument (single (IAssetsResponseDocument) ctor tolerates
null), JsonDevicesDocument (parameterless ctor).
- MTConnect.NET-JSON-cppagent: warms JsonStreamsResponseDocument,
JsonAssetsResponseDocument, JsonDevicesResponseDocument (all
expose public parameterless ctors for JSON deserialization).
The warm-up ordering pin from cycle-3's test-coverage audit
(Frozen_singleton_can_serialize_a_real_payload_proving_warm_up_ran_before_MakeReadOnly)
still holds — the typed Serialize calls run BEFORE MakeReadOnly, so
the resolver is populated before the freeze; test comments updated
to name the new WarmReachableGraph mechanism.
…or — dime M2-C3
Sweep-through of BrE tokens missed by cycle-2's L2 sweep, converting
each to its AmE spelling per the repo's canonical rule: AmE inside
committed source, including code and code comments.
- libraries/MTConnect.NET-JSON/JsonFunctions.cs:
* amortise → amortize (CreateOptions <remarks>)
* synchronised → synchronized (GetOptions <remarks>)
- libraries/MTConnect.NET-JSON-cppagent/JsonFunctions.cs:
same two tokens in the mirror surface.
- tests/MTConnect.NET-JSON-cppagent-Tests/Regressions/JsonSerializerOptionsSingletonTests.cs:
* cppagent-flavoured → cppagent-flavored (fixture summary)
* cppagent flavour → cppagent flavor (thread-safety pin summary)
Doc-comment-only change; no test assertion string depends on any of
these tokens (verified via grep against the tests directory before
rewriting).
…on singletons — dime M+L-C4 Cycle-4 leftovers on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (M-C4 — F-IMP-001): Adds MTConnect.Errors.ErrorResponseDocument to the WarmReachableGraph pass on BOTH JsonFunctions static ctors. ErrorResponseDocument is the fourth top-level response envelope written directly by Format(IErrorResponseDocument, ...) in both formatter families — no Json* surrogate wrapper. Without the addition, the first /probe error, /current parse failure, or unsupported device request would pay a cold LCG DynamicMethod emit against the shared, frozen options, which is the exact hot-path cost the singleton pattern exists to amortize. Fix (L-C4): Extends the <remarks> block on DefaultOptions + IndentOptions in both files with a WHY sentence explaining, in operator-actionable terms, that mutating a shared JsonSerializerOptions triggers System.Text.Json to rebuild its metadata cache, which re-emits property accessors as DynamicMethods into the runtime's LCG loader heaps. Those heaps are never reclaimed by the GC — a peer measured this at +3.2–3.8 MB/h RSS in production, which is what the frozen singleton eliminates. Pin tests: Adds Frozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ran to both JsonSerializerOptionsSingletonTests fixtures. Mirrors the existing SamplePayload warm-up-before-freeze pin — a regression that dropped ErrorResponseDocument from WarmReachableGraph would surface here as NotSupportedException on the frozen, resolver-less options.
…ResponseDocument pin with IL-inspection; add sibling structural pins — coverage-FLOOR The cycle-4 Frozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ran pin was tautological — verified by mutation on bluefin 2026-08-21: removing the ErrorResponseDocument warm-up line from WarmReachableGraph left the pin green. STJ's DefaultJsonTypeInfoResolver lazily populates JsonTypeInfo on frozen options for arbitrary types once TypeInfoResolver is set (which any earlier warm-up call does); MakeReadOnly(populateMissingResolver: false) only locks the configuration surface, not the internal metadata cache. Rewrite the pin as WarmReachableGraph_IL_contains_newobj_for_ErrorResponseDocument_ctor: walk the method body's IL for a newobj instruction whose ResolveMethod resolves to a ctor of the expected type. Mutation-verified: removing the warm-up line now cleanly fails the pin. Add companion pin covering the three Json* top-level response surrogates in each fixture. Also add sibling-site structural pins for the four MTConnect.NET-Common per-call `new JsonSerializerOptions(...)` sites flipped to shared static readonly fields in PR TrakHound#249 (MTConnectAgentInformation, MTConnectClientInformation, MTConnectAssetFileBuffer, AdapterApplicationConfiguration, AgentConfiguration), plus the MTConnect.NET-MQTT sibling (MTConnectMqttMessage) in the MqttRelay test project (which transitively references MTConnect.NET-MQTT). Each pin asserts at least one private static readonly JsonSerializerOptions field exists on the type — sibling-mutation-verified on MTConnectAgentInformation. Net +8 pins across four projects; all fixtures green on bluefin (net8.0, 4,530 passing).
…Document null-tolerance — dime M+L-C5 Cycle-5 leftovers on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (M-C5 — F-IMP-C5-001): Populates the WarmReachableGraph Error envelope with a concrete MTConnectErrorHeader + a single Error entry + a Version(2, 5), so STJ walks the RUNTIME types the production /probe-error and /parse-failure paths actually serialize. STJ resolves accessors for interface-typed properties (IMTConnectErrorHeader Header, IEnumerable<IError> Errors) only when the value is non-null; the prior naked `new ErrorResponseDocument()` warmed the envelope's own accessors but left the concrete MTConnectErrorHeader (9 properties), Error (2), and System.Version (6) cold — exactly the LCG-emit class the singleton pattern exists to eliminate. Populating a representative graph completes the fix. Fix (L-C5 — F-IMP-C5-002): Documents the null-tolerance contract on `JsonAssetsDocument(IAssetsResponseDocument)` in a <remarks> block. The plain-JSON WarmReachableGraph calls `new JsonAssetsDocument(null)` because the surrogate has no public parameterless ctor. If a future refactor added `ArgumentNullException.ThrowIfNull(assetsDocument)` there, first assembly load would fail with TypeInitializationException on any JSON serialization. The <remarks> names the coupling and directs future contributors to update the warm-up site atomically, in the same commit, before changing the ctor contract. Cycle-5 dispositions: - code-review: NO FINDINGS - security-audit: NO FINDINGS - documentation-audit: NO FINDINGS - simplification: F-SIMP-001 LOW (dup first-para on IndentOptions remarks) — Closed-with-rationale: IntelliSense hover shows only the current member's remarks and does not chase <see cref/>; a consumer inspecting IndentOptions in isolation must see the invariant text self-contained, not as a pointer. - improvement: F-IMP-C5-001 MEDIUM (this commit); F-IMP-C5-002 LOW (this commit). - test-coverage-audit: 2 findings resolved atomically in 5a19658 (tautological Error-warm-up pin replaced with IL-inspection walker; sibling structural pins added for the five per-call → static-readonly refactors).
…rage-FLOOR Cycle-6 gap: the F-IMP-C5-001 fix (6b081ea) populated the Error envelope in WarmReachableGraph with a concrete MTConnectErrorHeader + Errors.Error + Version(2, 5) so STJ walks the runtime types the production /probe error and parse-failure paths actually serialize. The cycle-5 IL walker only pinned newobj ErrorResponseDocument, so a revert to `new ErrorResponseDocument()` (unpopulated) would silently pass — re-opening the exact LCG-emit-on-first-error class F-IMP-C5-001 closed. Adds a per-fixture pin WarmReachableGraph_IL_contains_newobj_for_concrete_Error_envelope_fields that asserts newobj instructions for the three concrete types. Mutation-verified on bluefin 2026-08-21: reverting the Error init to `new ErrorResponseDocument()` fires the new pin (MTConnectErrorHeader newobj gone) while the ErrorResponseDocument-only pin still passes. System.Version is not news-up-ed by the three top-level surrogates, so a walker match on System.Version uniquely fingerprints the Error-envelope initializer surviving. Cycle-6 also verified the existing IL walker still catches deletion of the whole Error Serialize call: mutation on bluefin confirmed WarmReachableGraph_IL_contains_newobj_for_ErrorResponseDocument_ctor fails cleanly after the object-initializer form replaces `new ErrorResponseDocument()` — the newobj for the envelope ctor is the first opcode the initializer emits (before dup + property setters). Test totals bluefin 2026-08-21 (net +2 pins over cycle-5): - MTConnect.NET-JSON-Tests: 82/82 - MTConnect.NET-JSON-cppagent-Tests: 382/382 - MTConnect.NET-Common-Tests: 4085/4085 - MTConnect.NET-AgentModule-MqttRelay-Tests: 63/63
…ion25 — dime L-C6 Cycle-6 leftover on the JsonSerializerOptions singleton campaign (TrakHound#249). Fix (L-C6 — F-CR-C6-001): Replaces the magic `new Version(2, 5)` literal in both `WarmReachableGraph` Error envelopes with the repo's canonical `MTConnectVersions.Version25` constant. The value is functionally identical (same `new Version(2, 5)` allocation under the hood) but removes a hand-typed tuple in favor of the named constant every other error-doc construction site in the codebase uses (`MTConnectAgentBroker.GetErrorHeader` sources Version from `MTConnectVersion` rather than a literal). Reads scan-and-recognize instead of raising "why 2.5 in a 2.7-max library" questions. Removes now-unused `using System;` from the cppagent JsonFunctions.cs (the only unqualified `Version` reference was the magic literal, now gone; the property type is resolved through the receiver's declared type). Cycle-6 dispositions: - code-review: F-CR-C6-001 LOW (this commit). - security-audit: NO FINDINGS. - simplification: NO FINDINGS (cycle-5 F-SIMP-001 duplication class intentionally preserved per prior Closed-with-rationale). - improvement: NO FINDINGS. - documentation-audit: NO FINDINGS. - test-coverage-audit: F-COV-C6-001 MEDIUM (concrete Error-envelope field pins) — Fixed atomically in e19674e (IL walker now asserts newobj for MTConnectErrorHeader + Error + Version, catching a revert-to-unpopulated regression that cycle-5's ErrorResponseDocument-only pin missed).
…F-CR-C6-001 The F-CR-C6-001 refactor (ec86e33) switched the Error-envelope Version producer from `new Version(2, 5)` to `MTConnectVersions.Version25`. That eliminates the `newobj System.Version` IL instruction the cycle-6 walker (`WarmReachableGraph_IL_contains_newobj_for_concrete_Error_envelope_fields`) required, so the pin would false-positive on the current source. Widen the walker to accept EITHER `newobj T::.ctor` OR `ldsfld <static-field-of-type-T>`. Both producers are semantically equivalent for warm-up: STJ walks the runtime type of the value fed into Serialize and cannot tell whether the instance came from a fresh allocation or a static-field load. Companion `<summary>` blocks updated in both fixtures to name the widened contract and reference F-CR-C6-001. Mutation-verified (bluefin 2026-08-21): - Delete the Error `Serialize(...)` call → pin fails on `MTConnectErrorHeader must produce a concrete instance…`. - Revert to naked `new Errors.ErrorResponseDocument()` → pin fails on `MTConnectErrorHeader must produce a concrete instance…`. - Revert `Version = MTConnectVersions.Version25` to `Version = null` → pin fails on `System.Version must produce a concrete instance…`. - Restore all three → green. The widened walker still rejects the pathological regression class (any change that removes both the newobj AND the ldsfld producer of a target type).
ottobolyos
force-pushed
the
fix/json-serializer-options-per-call-leak
branch
from
August 22, 2026 00:52
cca448c to
c0dea3d
Compare
ottobolyos
force-pushed
the
fix/json-serializer-options-per-call-leak
branch
from
August 22, 2026 08:58
c0dea3d to
c8406d5
Compare
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 22, 2026
… loader-heap Replace "DIME-connector native-heap leak" with "loader-heap accumulation" in the four regression/sibling-site pin docstrings. DynamicMethod/LCG emission for JsonSerializerOptions lands in the CLR's runtime loader heap (managed, GC-untouched) rather than native anonymous memory, so "native-heap" mischaracterised the mechanism. Drops the peer-attribution label since the fix is verifiable from CLR emission semantics directly. Comment-only; no logic changes.
ottobolyos
added a commit
to ottobolyos/mtconnect.net
that referenced
this pull request
Aug 22, 2026
…to integration/up-to-pr-249
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cache
JsonSerializerOptionsasstatic readonlysingletons in bothMTConnect.NET-JSONandMTConnect.NET-JSON-cppagentJsonFunctions.cs, plus every sibling site that re-allocated the option object per call. Prevents unbounded loader-heap growth from LCG-emitted property accessors, observed on two DIME-connector production hosts as a +3.2–3.8 MB/h RSS climb with a flat managed heap.Root cause
A fresh
JsonSerializerOptionsinstance owns its own serialization-metadata cache. Building that cache emits reflection-based property accessors (DynamicMethodin the runtime's LCG heap) for every property in the reachable type graph. Allocating one on every serialization call therefore re-emits those accessors on every call, and the emitted code accumulates in the runtime's loader heaps where the GC cannot reclaim it.Peer diagnosis (tempco-investigation-server, 2026-08-21) on two hosts:
dotnet-counters: GC heap flat (76–87 MB sawtooth); working set climbed (445 → 460 MB); ~370 MB outside the managed heap.Number of Methods Jitted: 17,651,641 in 71 h on tempco-001 (+67/s forever) — healthy service is typically 10 k–50 k lifetime.IL Bytes Jitted: +67,083 B / 90 s = 2.7 MB/h, matching the 3.3 MB/h RSS climb.dotnet-gcdump:System.Text.Json.Serialization.Metadata.ReflectionEmitCachingMemberAccessor+Cache+CacheEntrypresent.dotnet-traceLCG DynamicMethods: 8946 hits ondynamicClass, 2465 onIObservationOutputaccessor, 1371 onJsonEvents, 1161 onJsonSamples.Why both
JsonFunctions.csfiles ship in one PR. DIME's MQTT sink atDIME/Connectors/MtConnectMqtt/Sink.cs:49setsdocumentFormat: "JSON-cppagent-mqtt", so per-observation serialization on peer's production hosts flowed throughMTConnect.NET-JSON-cppagent.JsonFunctions, not the plain one. An earlier peer hot-swap that patched only the plain-JSON assembly did not reduce JIT churn, because DIME's real workload never touched it.MTConnect.NET-JSON-cppagent.dllalso defines 25Streams.Jsonclasses vs 10 in the plain assembly, so the LCG cost per serialization is proportionally larger. Both assemblies carry independent copies of the anti-pattern (fivenew JsonSerializerOptionssites each inDefaultOptions,IndentOptions,Convert,ConvertBytes,ConvertStream); both must ship together in this same PR, rather than split into a follow-up, to close the leak for cppagent-format consumers.Fix
Both
JsonFunctions.csfiles gain:Convert/ConvertBytes/ConvertStreamnow delegate throughGetOptions(converter, indented). Every in-tree caller (verified via grep acrosslibraries/,agent/,tests/) passesconverter == nulland does not mutate the returned instance, so every in-tree call reuses the singleton. The cold-path branch preserves the public API contract for external consumers that supply a per-call converter.Sibling sweep (same static-readonly pattern, folded in atomically here rather than deferred to a follow-up PR):
libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs—_readOptions(ReadCommentHandling = Skip), 2 call sites.libraries/MTConnect.NET-Common/Configurations/AdapterApplicationConfiguration.cs—_readOptions, 2 call sites.libraries/MTConnect.NET-Common/Agents/MTConnectAgentInformation.cs—_saveOptions(WriteIndented = true), 1 call site.libraries/MTConnect.NET-Common/Clients/MTConnectClientInformation.cs—_saveOptions, 1 call site.libraries/MTConnect.NET-Common/Buffers/MTConnectAssetFileBuffer.cs—_writeOptions, 1 call site.libraries/MTConnect.NET-MQTT/MTConnectMqttMessage.cs—_agentInformationOptions, 1 call site.libraries/MTConnect.NET-Common/Buffers/MTConnectObservationFileBuffer.cswas in the peer's sweep list but already callsJsonSerializer.Serializewith no options argument — no anti-pattern present.Regression tests
tests/MTConnect.NET-JSON-Tests/Regressions/JsonSerializerOptionsSingletonTests.csand its cppagent mirror pin the singleton contract:DefaultOptions_returns_the_same_instance_on_repeat_access—ReferenceEquals(JsonFunctions.DefaultOptions, JsonFunctions.DefaultOptions) == true.IndentOptions_returns_the_same_instance_on_repeat_access— same for the indented preset.DefaultOptions_and_IndentOptions_are_distinct_instances.DefaultOptions_WriteIndented_is_falseandIndentOptions_WriteIndented_is_true— behavior guards.JsonFunctions_holds_a_static_readonly_options_field— reflection guard that the shared field exists (blocks the anti-pattern regressing under any future refactor).Verified RED on
upstream/master(587a126): 3 fails + 3 passes per project (6 fails total across both suites). Verified GREEN after fix: 6 pass + 6 pass. Full-suite GREEN on bluefin/net8.0:MTConnect.NET-JSON-TestsMTConnect.NET-JSON-cppagent-TestsMTConnect.NET-Common-TestsTotal 4425 tests, zero failures.
Depends on
None. Standalone fix branched off
upstream/master.Efficacy validation
JIT / emit churn eliminated in production; end-to-end RSS-floor confirmation received 2026-08-21 from the same peer that filed the original diagnosis (tempco-investigation-server session, ≥ 22 h post-deploy on tim-001, matched-window controlled comparison against unpatched tempco-001). IL bytes / method transitioned from 22 (emitted accessor stubs) to 108 (ordinary real methods); working set 292–300 MB stable.
Same-host matched-process-age comparison on tim-001 (both DLLs patched, deployed 2026-08-20T23:05:21Z):
~225× reduction; curve is FLAT, not merely lower.
Supporting signals:
dotnet-traceLCG DynamicMethod count over a 20 s window: 5 862 → 1 276 (post-fix sample still warming when captured; has since flatlined).Assembly hygiene
Rebuilt cppagent DLL reports
.ver 7:0:0:0identical to the shipped assembly; informational version unchanged; no strong-name change.common the.assembly externsets between pre-fix and post-fix returns empty — no new external references introduced on either assembly. Drop-in binary-compatible.End-to-end RSS-floor confirmation (2026-08-21)
Controlled comparison, both hosts, identical wall-clock window (08:00–17:00 CDT, 13:00–22:00Z Chicago manufacturing shift), trough-based least squares on 15-min post-GC minima:
Working-day confidence intervals do not overlap. The control host resumed climbing when the shift started; the patched host did not.
Same-host before/after on tim-001 (cleanest measure — same machine, same config, same workload conditions):
67 % reduction in RSS-floor growth. Container-limit cap projection moves from ~22 days to ~61 days (against the 2 GB container ceiling).
JIT side-by-side, workload-independent, taken minutes apart:
649× difference in total methods compiled. The patched host's total is essentially just its own startup JIT and has been flat for a day; the control added ~2.8 M methods in the 24 h between measurements.
Residual grower caveat. tim-001's last-6h window reads +2.44 ± 0.29 MB/h — above its own 22-hour average, and JIT cannot account for it (0.3 methods/s = 0.06 MB/h). The residual is entirely anonymous memory (
+1.10 MB/h anonout of+1.11 MB/h rss, decomposed against/proc/self/status+ smaps; file/shmem/threads/fds/sockets all flat over the 22.5 h window). With the managed GC heap at 39 MB and methods-jitted at 0.3/s, loader/code heap is not growing either — so the residual mechanism is distinct from the reflection-emit class this PR closes. Every siblingJsonSerializerOptionsconstruction site onMTConnect.NET-Commonwas independently verified as already using thestatic readonlysingleton pattern (either landed by this PR or pre-existing), consistent with the peer's finding. The residual is most likely native-side (glibc arena fragmentation, native allocator growth, or a native-side buffer) and is being characterised in a separate investigation. This PR eliminates the dominant reflection-emit leak (67 % reduction in RSS-floor growth, 649× reduction in cumulative JIT); the residual is out of scope + tracked separately.Measurement discipline:
RestartCount=0throughout, nomemory.eventsmax/oom on either host, no container hit its limit, and tim-001 published normally the whole time (30 mtconnectMqttSink writes/60 s).Dime review cycles
Cycle 1 — baseline pass on
b2703730code-review:[BLOCKER]H1 — the per-callnew JsonSerializerOptionson everyConvert/ConvertBytes/ConvertStreaminvocation re-emits LCG DynamicMethod property accessors on every serialization, matching the peer's tempco-001 / tim-001 heap-outside-GC diagnosis (+3.2–3.8 MB/h RSS climb). Fix: cache the options asstatic readonlysingletons. → Fixed @f1565222(the initial cache-singletons commit).code-review:[FINDING]M1 — the shared singleton must be frozen (MakeReadOnlyon net8+) so a caller's strayConverters.Addfails fast rather than silently polluting the process-wide instance. → Fixed @adfed47d.security-audit: NO FINDINGS.simplification: NO FINDINGS.improvement: NO FINDINGS.documentation-audit: NO FINDINGS.test-coverage-audit:[TEST]FLOOR gap — thread-safety, cold-path converter path, and Convert overload parity lacked regression pins. → Fixed @b2703730(coverage-FLOOR pin batch, TDD RED-first before the H1 fix).Cycle 2 — verify freeze + warm
code-review/security-audit/simplification/improvement: NO FINDINGS.documentation-audit:[DOCS]L1 — the newCreateOptions/GetOptionshelpers lacked XML///blocks explaining the hot-path / cold-path invariant. → Fixed @c212f6fc.test-coverage-audit: NO FINDINGS.Cycle 3 — verify L1 docs
code-review/security-audit/simplification/improvement/documentation-audit: NO FINDINGS.test-coverage-audit: NO FINDINGS.documentation-audit:[DOCS]L2 — AmE spelling drift in the freshly-added doc comments (serialise,initialise,synchronise). → Fixed @38cbeb07.Cycle 4 — deeper warm-up + AmE re-sweep
improvement:[IMPROVE]M1-C3 — the initial warm-up usedSerialize<object>(null, options), which only bootstrapped STJ's shared reflection resolver but never named a concrete MTConnect type; the cold LCG emit for the four top-level response envelopes still fell on the first user-facing request. → Fixed @432be963(warm-up traverses MTConnect response graph via typedSerializecalls onJsonStreamsDocument/JsonAssetsDocument(null)/JsonDevicesDocument).documentation-audit:[DOCS]M2-C3 — repo-wide grep surfaced additional AmE-token drift (amortise,synchronise,flavour). → Fixed @e55badca.test-coverage-audit:[TEST]FLOOR gap — no regression pin for the warm-up-before-MakeReadOnlystatic-ctor ordering invariant (a reorder would surface asNotSupportedExceptionon the first real serialize). → Fixed @86b60a66.[IMPROVE]F-IMP-001 MEDIUM (WarmReachableGraphomitsErrorResponseDocument) + LOW (DefaultOptions/IndentOptions<remarks>state the invariant but not the WHY / LCG loader-heap link) — deferred to cycle 5.Cycle 5 — cycle-4 leftovers + Error warm-up correctness
code-review/security-audit/documentation-audit: NO FINDINGS.improvement:[IMPROVE]F-IMP-001 M-C4 (Error warm-up missing) → Fixed @014347b7;[IMPROVE]L-C4 (WHY-LCG remarks) → Fixed @014347b7.improvement:[IMPROVE]F-IMP-C5-001 MEDIUM — cycle-5 Error warm-up populated onlynew ErrorResponseDocument()(null Header / Errors / Version), leaving the concreteMTConnectErrorHeader(9 accessors),Error(2), andSystem.Version(6) cold on the first real error response. → Fixed @6b081eac(populatesHeader = new MTConnectErrorHeader(),Errors = new[] { new Error() },Version = new Version(2, 5)so STJ walks the runtime types the production /probe-error and parse-failure paths actually serialize).improvement:[IMPROVE]F-IMP-C5-002 LOW —WarmReachableGraphdepends on the null-tolerance of theJsonAssetsDocument(IAssetsResponseDocument)ctor; a future null-guard would turn first assembly load intoTypeInitializationException. → Fixed @6b081eac(adds an<remarks>block on the ctor documenting the null-tolerance contract, the dependent warm-up site, and the requirement to update both atomically in the same commit before changing the ctor contract).simplification:[SIMPLIFY]F-SIMP-001 LOW — the first<remarks>paragraph onIndentOptionsis byte-identical to the paragraph onDefaultOptionsin both files. → Closed with rationale: IntelliSense hover shows only the current member's remarks and does not chase<see cref/>; a consumer inspectingIndentOptionsin isolation must see the invariant text self-contained, not as a pointer. Self-contained per-member docs are the maintainer preference.test-coverage-audit:[TEST]MEDIUM — cycle-5's initialFrozen_singleton_can_serialize_ErrorResponseDocument_proving_Error_warm_up_ranpin was tautological (mutation-verified: removing the warm-up line left the pin green; STJ'sDefaultJsonTypeInfoResolverlazily populatesJsonTypeInfoon frozen options for arbitrary types onceTypeInfoResolveris set). → Fixed @5a196588(replaced with an IL-inspection walkerWarmReachableGraph_IL_contains_newobj_for_ErrorResponseDocument_ctor+ companion IL pins for the threeJson*top-level surrogates per fixture).[TEST]LOW — the five sibling per-call → static-readonly refactors (AgentConfiguration,AdapterApplicationConfiguration,MTConnectAgentInformation,MTConnectClientInformation,MTConnectAssetFileBuffer,MTConnectMqttMessage) had no test-time guard. → Fixed @5a196588(JsonSerializerOptionsSiblingSingletonTests+MTConnectMqttMessageSingletonTestsstructural pins, sibling-mutation-verified).Cycle 6 — verify cycle-5 fixes + walker match against populated shape
security-audit/simplification/improvement/documentation-audit: NO FINDINGS.code-review:[FINDING]F-CR-C6-001 LOW — the populated Error warm-up hard-codesnew Version(2, 5)where the repo already exposesMTConnectVersions.Version25; reads scan-and-recognize vs. wonder why "2.5" appears amid a 2.7-max library. → Fixed @ec86e338(swap to the canonical constant; unusedusing System;removed from cppagentJsonFunctions.cs).test-coverage-audit:[TEST]F-COV-C6-001 MEDIUM — cycle-5's IL walker only pinned thenewobjforErrorResponseDocument; a revert tonew ErrorResponseDocument()(naked) would silently pass the pin while re-opening the exact LCG-emit-on-first-error class F-IMP-C5-001 closed. → Fixed @e19674ec(addsWarmReachableGraph_IL_contains_newobj_for_concrete_Error_envelope_fieldspin per fixture, assertingnewobjproducers forMTConnectErrorHeader+Errors.Error+System.Version; mutation-verified on bluefin).Cycle 6 — chase-fix for F-CR-C6-001 side-effect
[TEST]chase-fix @31668585— the F-CR-C6-001 swap toMTConnectVersions.Version25eliminated thenewobj System.VersioninWarmReachableGraph(anldsfldnow loads the canonical constant), which would have false-positived the cycle-6 walker onSystem.Version. Widened the IL walker helperAssertWarmReachableGraphNewsUpin both fixtures to accept EITHERnewobj T::.ctorORldsfld <static-field-of-type-T>— both producers are semantically equivalent for warm-up because STJ walks the runtime type of the value fed intoSerializeregardless of source.<summary>blocks updated in both fixtures to name the widened contract and cross-reference F-CR-C6-001. Mutation-verified: deletingVersion = MTConnectVersions.Version25fires the pin with the exactSystem.Versionfailure message; restoring returns to green.Cycle 7 — full-diff re-sweep at HEAD
31668585code-review: NO FINDINGS. Verified: no circular init risk fromJsonFunctions.cctorreferencingMTConnectVersions.Version25(leaf static class, no MTConnect deps); widened walker'sldsfldbranch endianness sound on every .NET-supported target; sibling singleton adopters follow the pattern with cross-referencing comments back toJsonFunctions.cs.security-audit: NO FINDINGS. Verified:Module.ResolveField(token)operates on the test project's own compiled assembly (no untrusted metadata); no secret exposure in walker error strings; no dependency-manifest change.simplification: NO FINDINGS. Verified: comment blocks on bothWarmReachableGraphmethods (~28 lines each) trace to distinct closed-finding rationales; the dual-opcode walker branch is clearer as-is than split into two helpers.improvement: NO FINDINGS. Verified:MTConnectVersions.Version25field-initializer chain has no cycle; widened walker adds zero production-side overhead;using System;correctly retained in plain-JSON (needed forDateTime/TimeSpaninGetTimestamp), correctly removed from cppagent (no unqualifiedSystem.*reference remains).documentation-audit: NO FINDINGS. Verified: no docs page references the removed magicnew Version(2, 5)literal; both updated<summary>blocks accurately name the widened walker contract and cross-reference F-CR-C6-001; no AmE-token drift in the cycle-6/7 prose.test-coverage-audit: NO FINDINGS. Verified: mutation-tests confirm the widened walker catches every intended regression class (RED on delete of the Version producer, GREEN on restore); nested-field-of-same-type analysis returns no false-positive class.(Zero unfixed findings — Ready-eligible.)
Test results at HEAD
31668585(bluefin 2026-08-21,dotnet testper project):MTConnect.NET-JSON-TestsMTConnect.NET-JSON-cppagent-TestsMTConnect.NET-Common-TestsMTConnect.NET-AgentModule-MqttRelay-TestsTotal 4612 tests, zero failures. Net +11 pins over cycle 1 (Error warm-up IL walker + concrete-field pin + sibling singleton pins + MQTT singleton pin + widened walker
<summary>updates). All 14 commitsupstream/master..HEADsigned (%G? = G).