Skip to content

DeepCloneFrom from a ConcurrentDictionary under concurrent writes throws ~40% of the time and sometimes adds a phantom (default, default) entry #100

Open

Description

@matt-edmondson

What's wrong

The snapshot added for #64 buffers the source with a collection expression:

  • DeepClone/DeepCloneContainerExtensions.cs:48: T[] items = [.. source];
  • DeepClone/DeepCloneContainerExtensions.cs:84: KeyValuePair<TKey, TValue>[] items = [.. source];

For an ICollection<T> source this reads Count, allocates the array, then calls CopyTo. A ConcurrentDictionary can change size between those two calls:

  • It grew: CopyTo throws ArgumentException ("…number of elements in the dictionary is greater than the available space…").
  • It shrank: the array keeps trailing default(KeyValuePair) slots, which are then added to dest.

Before #64 the code enumerated the source directly, which ConcurrentDictionary supports during concurrent writes.

Repro (main @ 0287d0c, net10.0), 5 s stress run

var cd = new ConcurrentDictionary<int,int>();
for (int i = 1; i <= 1000; i++) cd[i] = i;     // key 0 never exists
// writer thread: loop { k = 1 + rnd.Next(2000); if (!cd.TryRemove(k, out _)) cd[k] = k; }

var dest = new Dictionary<int,int>();
dest.DeepCloneFrom(cd);                          // IDictionary overload
// and: new List<KeyValuePair<int,int>>().DeepCloneFrom(cd);   // ICollection overload

Observed:

  • IDictionary overload, 63,217 runs: 23,197 threw the CopyTo ArgumentException; 17,817 threw An item with the same key has already been added. Key: 0 (two padding slots); 4,685 succeeded with a key 0 that was never in the source.
  • ICollection overload, 59,850 runs: 23,912 threw; 23,276 contained a phantom (0,0) pair.

cd.DeepClone() and ((IDictionary<,>)cd).DeepClone() had no errors in the same run.

Expected: no exception and no entries that were never in the source.

Why it matters

Concurrent writers are the reason to use ConcurrentDictionary. With value-type keys the clone silently contains wrong data; with reference-type keys the padding surfaces as ArgumentNullException.

Suggested fix

Buffer with an explicit loop (List<T> items = []; foreach (T x in source) items.Add(x);). Avoid ToArray(), new List<T>(source) and spreads, which all take the same Count/CopyTo shortcut. Optionally special-case source is ConcurrentDictionary<K,V> c and use c.ToArray() for an atomic snapshot. With the explicit loop applied locally, the same probe ran 49,026 times with 0 phantom entries and 0 throws.

Acceptance: a stress test cloning from a concurrently mutated ConcurrentDictionary never throws and never yields a key absent from the source; the #64 same-instance guarantee still holds.

Activity

  1. matt-edmondson commented on Oct 6, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Recommended next step: Replace both [.. source] snapshots with an explicit foreach into a List<T>, and optionally special-case ConcurrentDictionary with ToArray(). Add the stress test from the issue, with a bounded run time, as a regression guard.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions