Skip to content

fix(transit/delta-message): clone JsonObject/JsonArray on receive (fixes #241)#321

Merged
Kiryuumaru merged 4 commits into
masterfrom
fix/241-delta-jsonobject-clone
May 22, 2026
Merged

fix(transit/delta-message): clone JsonObject/JsonArray on receive (fixes #241)#321
Kiryuumaru merged 4 commits into
masterfrom
fix/241-delta-jsonobject-clone

Conversation

@Kiryuumaru
Copy link
Copy Markdown
Owner

Fixes #241

Problem

DeltaMessageTransit<T>.FromJsonNode had inconsistent aliasing semantics across T choices:

T Branch Result
JsonNode node.DeepClone() Independent instance ✅
JsonObject node.AsObject() Live reference to _lastReceivedState
JsonArray node.AsArray() Live reference to _lastReceivedState
JsonDocument JsonDocument.Parse(...) Fresh instance ✅
JsonElement JsonDocument.Parse(...).RootElement Fresh instance ✅
typed POCO node.Deserialize(_typeInfo) Fresh instance ✅

AsObject() / AsArray() are downcasts, not copies. When ReceiveAsync returned via the delta path (case 0x01), FromJsonNode(_lastReceivedState) handed the caller the same JsonObject / JsonArray instance the receive pump mutates on every subsequent delta.

Two consequences:

  1. Silent state corruption. Caller mutates the returned object; the next delta is applied on top of the contaminated state; the receiver permanently desyncs from the sender.
  2. Concurrent-modification crash. Caller enumerates the returned object; the receive pump applies a delta on the same instance; JsonObject enumerator throws InvalidOperationException("Collection was modified ...").

The full-state branch (case 0x00) was already safe because it stores _lastReceivedState = fullState?.DeepClone() and returns FromJsonNode(fullState) where fullState is the freshly-parsed wire node. The bug is delta-path-specific.

Fix

Make JsonObject / JsonArray consistent with the JsonNode branch — DeepClone first, then downcast:

if (type == typeof(JsonObject))
    return (T)(object)node.DeepClone().AsObject();

if (type == typeof(JsonArray))
    return (T)(object)node.DeepClone().AsArray();

The clone is unconditional but cheap relative to the existing JSON parse on the receive path; full-state already clones once into _lastReceivedState, so this just adds a second clone for the returned value (which is the standard "snapshot" contract for a thread-safe receive API).

Validation

  • New file tests/NetConduit.Transit.DeltaMessage.UnitTests/DeltaMessageTransitJsonAliasingTests.cs — 3 tests:
    • JsonObject_DeltaResult_MutationDoesNotContaminateInternalState — full state → delta receive → mutate result → next delta. With bug: third receive carries the injected extra key. With fix: clean.
    • JsonObject_DeltaResult_IsNotAliasedAcrossSubsequentReceives — full state → delta → delta. With bug: second and third results are the same reference. With fix: distinct.
    • JsonArray_DeltaResult_MutationDoesNotContaminateInternalState — same shape for JsonArray. With bug: ghost element 999 leaks into next delta result.
  • Without the fix: 3/3 fail with the exact documented symptoms.
  • With the fix: 3/3 pass in 1.5 s.
  • Full DeltaMessage transit suite: 81 passed (78 existing + 3 new), 0 failed.
  • Full NetConduit unit suite: 441 passed, 0 failed, 1 skipped (unrelated pre-existing memory-leak skip).
  • Full solution build (net8.0 / net9.0 / net10.0, all transports + transits + samples): 0 warnings, 0 errors.

Files changed

File Change
src/NetConduit.Transit.DeltaMessage/DeltaMessageTransit.cs DeepClone() before AsObject() / AsArray() in FromJsonNode
tests/NetConduit.Transit.DeltaMessage.UnitTests/DeltaMessageTransitJsonAliasingTests.cs New regression tests (JsonObject + JsonArray, delta path, mutation isolation, reference distinctness)

Out of scope

The issue's bonus note about JsonElement / JsonDocument lifetime (the JsonDocument returned from JsonDocument.Parse(...).RootElement is anchored only via the JsonElement, leaving pooled buffers reclaimed via finalization) is a separate concern and is not addressed in this PR — it's a different shape of bug and a different fix surface. Tracked separately if it warrants a follow-up.

Kiryuumaru and others added 4 commits May 21, 2026 22:11
 #241)

DeltaMessageTransit<T>.FromJsonNode used node.AsObject() / node.AsArray() for the JsonObject and JsonArray branches. AsObject and AsArray are downcasts that return the same instance — so when ReceiveAsync was followed by a delta message, FromJsonNode handed the caller a live reference to _lastReceivedState. Caller mutation silently contaminated the receiver's internal state, and any subsequent delta produced a desynced result. Concurrent enumeration during a receive-pump-applied delta also threw 'Collection was modified'.

The JsonNode branch already DeepClones; this fix makes JsonObject and JsonArray consistent: DeepClone first, then AsObject/AsArray. JsonDocument and JsonElement branches already produce fresh instances via JsonDocument.Parse, and the typed-deserializer branch returns a fresh deserialization.

Adds tests/NetConduit.Transit.DeltaMessage.UnitTests/DeltaMessageTransitJsonAliasingTests.cs with 3 tests: JsonObject mutation isolation across deltas, JsonObject distinct references across consecutive delta receives, and JsonArray mutation isolation. All three fail on master with the exact symptoms from issue #241 ('extra' key bleeds through, ReferenceEquals true, ghost array element); all three pass with the fix.
@Kiryuumaru Kiryuumaru merged commit 03415ed into master May 22, 2026
14 checks passed
@Kiryuumaru Kiryuumaru deleted the fix/241-delta-jsonobject-clone branch May 22, 2026 05:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant